diff --git a/.agentic/HANDOFF.md b/.agentic/HANDOFF.md new file mode 100644 index 000000000..be2d385d6 --- /dev/null +++ b/.agentic/HANDOFF.md @@ -0,0 +1,399 @@ +# Handoff: ccbot_ju1ian — agentic pipeline + refactoring + +**Дата:** 2026-07-15 +**Назначение документа:** передаточный контекст для LLM-агента (GPT / Claude / Grok / др.). Прочитай этот файл целиком — дальше работай без уточнений, если путь/репо доступны. +**Репозиторий (форк):** https://github.com/ju1ian0701/ccbot_ju1ian +**Локальный клон:** `D:\CCbot_tmux\ccbot\ccbot_ju1ian\` +**Upstream (оригинал):** https://github.com/six-ddc/ccbot (fork tracking) +**Рабочая копия workspace (отчёты/план):** `D:\GROK\` + +--- + +## 1. Цель проекта + +### 1.1. Продукт + +**ccbot** — Telegram-бот, который связывает **forum topics** с **tmux-окнами**, в каждом из которых крутится Claude Code / Codex-сессия. Архитектура **topic-only**: 1 topic = 1 window (`@N`) = 1 session. Стек: Python ≥3.12, `python-telegram-bot`, libtmux, `uv`. + +### 1.2. Задача этой работы (два слоя) + +| Слой | Зачем | +|------|--------| +| **A. Agentic pipeline** | Полный цикл: knowledge graph + refactoring backlog → ранжирование задач → implement-prompt → (CI/MCP) → draft PR, с guardrails и quality gates | +| **B. Рефакторинг кода** | Выполнить backlog `REF-001`…`REF-009` в локальном клоне: DRY auth, split bot.py, error handling, state managers, migration isolation, callback_data types, renames, send fallback, tests | + +**Исходные входы анализа:** +- Knowledge graph: `D:\CCbot_tmux\ccbot\ccbot_ju1ian\.understand-anything\knowledge-graph.json` (~530 nodes / 555 edges, commit `9d7ab97…`) +- Human refactoring report: `D:\GROK\reports\ccbot-refactoring-backlog.md` (5 HIGH + 4 MEDIUM) + +--- + +## 2. Что уже сделано + +### 2.1. Agentic infrastructure (pipeline) + +| Компонент | Результат | +|-----------|-----------| +| Structured backlog | `.agentic/backlog/tasks.json` — `REF-001`…`REF-009` с AC, files, depends_on, atomic_steps | +| Config / policies | `.agentic/config.json`, `.agentic/policies.json` (allow/deny paths, must/must_not) | +| Stage prompts | `.agentic/prompts/{analyze,plan,implement,review,pr-body}.md` | +| Python toolkit | `scripts/agentic/` — analyze graph, prioritize, render prompt, validate, sync issues, CLI | +| Context packer | `build_context_pack.py` — 1-hop subgraph slice → `.agentic/out/context-pack.json` | +| GitHub Actions | `agentic-analyze.yml`, `agentic-plan.yml`, `agentic-implement.yml`, `agentic-orchestrator.yml` (+ existing `check.yml`, `claude.yml`, `claude-code-review.yml`) | +| Custom MCP | `ccbot-agentic` — `scripts/agentic/mcp_server.py` + `.grok/config.toml` (10 tools) | +| Plan docs | `D:\GROK\reports\ccbot-agentic-pipeline-plan.md`, `.agentic/IMPLEMENTATION_PLAN.md` | +| Implement prompt REF-001 | `.agentic/out/implement-prompt-REF-001.md` | + +**CLI (из корня клона):** +```bash +python scripts/agentic/cli.py analyze|plan|select|context|status|list|validate|run|sync-issues +python scripts/agentic/mcp_smoke_test.py +python scripts/agentic/update_backlog_status.py REF-00N done --note "..." +``` + +**MCP tools (qualified names):** +`ccbot-agentic__pipeline_status`, `__list_tasks`, `__get_task`, `__select_next_task`, `__get_analysis`, `__get_context_pack`, `__get_implement_prompt`, `__mark_task_status`, `__run_analyze`, `__run_validate`. + +### 2.2. Рефакторинг REF-001 … REF-009 (все `status: done`) + +| ID | Что сделано | Ключевые артефакты | +|----|-------------|-------------------| +| **REF-001** | Auth + session resolve helper | `src/ccbot/session_guard.py` — `SessionContext`, `require_user`, `require_bound_window_id`, `require_session`; handlers мигрированы; `tests/ccbot/test_session_guard.py` | +| **REF-002** | Split god-module `bot.py` (~1868 → ~190 lines) | `handlers/command_handlers.py`, `message_handlers.py`, `callback_router.py`, `notifications.py`, `window_bind.py`, `screenshot_controls.py`; `bot.py` = lifecycle + registration + re-exports | +| **REF-003** | Typed error handling на hot paths | `src/ccbot/errors.py`; `TelegramError`/`RetryAfter` в sender/queue/tmux/interactive/guard; `tests/ccbot/test_message_sender_errors.py` | +| **REF-004** | Isolate session migration | `src/ccbot/session_migration.py` (pure + documented authoritative model); `SessionManager.resolve_stale_ids` thin orchestrator; `tests/ccbot/test_session_migration.py` (table-driven 6+ cases) | +| **REF-005** | Encapsulate mutable globals | `MessageQueueManager` + `StatusTracker` + singleton `queue_manager`; `InteractiveState`; `BashCaptureTracker`; public wrappers preserved | +| **REF-006** | Typed callback_data | `encode_*` / `parse_*` in `callback_data.py`; producers + `callback_router` use them; 64-byte clip; `tests/ccbot/handlers/test_callback_data.py` | +| **REF-007** | Rename cryptic ids | `wid`→`window_id`, `tid`→`thread_id`, `stored_wid`/`selected_wid`/`created_wid` → descriptive; no public API break | +| **REF-008** | Unify markdown fallback | `_run_markdown_fallback` + `edit_with_fallback`; queue/bash use shared path; `safe_send` → `send_with_fallback` | +| **REF-009** | Coverage on hotspots | `test_tmux_manager.py`, `test_message_queue_enqueue.py`, `test_command_handlers.py`; coverage lift on tmux/queue/commands | + +### 2.3. Побочные fixes по ходу + +- `tests/conftest.py`: fcntl stub on Windows (import `session.py` without POSIX). +- `enqueue_status_update`: regression after REF-007 fixed — use `thread_key = thread_id or 0` for status map; keep `MessageTask.thread_id` optional (not forced to `0`). +- Guardrails allowlist: `.grok/**`, agentic workflows, etc. + +### 2.4. Тесты (последний известный прогон) + +```text +uv run pytest tests/ --ignore=tests/ccbot/test_session_monitor.py -q +→ 360 passed (approx; may grow) +``` + +**Известный flaky/pre-existing на Windows:** +`tests/ccbot/test_session_monitor.py` — offset off-by-one (CRLF vs LF). На Linux CI обычно OK. Не связано с REF-*. + +--- + +## 3. Текущее состояние + +### 3.1. Где остановились + +- **Весь refactoring backlog REF-001…009 — status `done` в** `.agentic/backlog/tasks.json`. +- **Код изменён только в локальном клоне** — **подтверждено: не закоммичен / не запушен**. +- `git status`: `main...origin/main` с множеством modified + untracked (`.agentic/`, `scripts/agentic/`, agentic workflows, `session_guard.py`, split handlers, tests, …). +- Agentic CI workflows и MCP зарегистрированы локально; **remote secrets** и push — не делались. +- Grok enrich client (`grok_client.py`) и workflow `agentic-grok-review.yml` описаны в плане, **не реализованы** (optional). + +### 3.2. Git (снимок handoff) + +```text +Remote: origin = https://github.com/ju1ian0701/ccbot_ju1ian.git +Branch: main...origin/main (DIRTY — local only) +HEAD: 9d7ab97 fix: harden Telegram delivery and window↔session mapping + +Modified (examples): bot.py, session.py, message_queue.py, message_sender.py, + callback_data.py, interactive_ui.py, history.py, directory_browser.py, + status_polling.py, tmux_manager.py, .gitignore, tests/conftest.py, ... + +Untracked (examples): .agentic/, .grok/, scripts/agentic/, agentic-*.yml, + session_guard.py, session_migration.py, errors.py, + handlers/{command_handlers,message_handlers,callback_router,notifications, + window_bind,screenshot_controls}.py, tests/agentic/, many new test files, + .understand-anything/, coverage.json (do NOT commit coverage.json) +``` + +**Действие для нового агента:** `git status` + `git diff --stat`; не коммитить `coverage.json`, `.env`, `.understand-anything/` (по желанию — graph можно ignore). + +### 3.3. Архитектура кода сейчас (post-refactor) + +```text +src/ccbot/ + bot.py # create_bot, post_init/shutdown, re-exports only + session_guard.py # require_user / require_session + session_migration.py # pure startup migrate + session.py # SessionManager (state + tmux orchestration) + errors.py # TelegramError helpers + log_exception + handlers/ + command_handlers.py # /start /history /esc /unbind /usage /forward /topic* + message_handlers.py # text/photo/voice + bash capture + callback_router.py # all inline keyboard routing + message_queue.py # MessageQueueManager + StatusTracker + message_sender.py # _run_markdown_fallback, send/edit/safe_* + callback_data.py # encode_*/parse_* + notifications.py # handle_new_message + window_bind.py # create_and_bind_window + screenshot_controls.py + interactive_ui.py # InteractiveState + ... (history, directory_browser, status_polling, cleanup, response_builder) +``` + +**Инварианты (не нарушать):** +1. Topic-only; route by **window @id**, never window name for internal keys. +2. `is_user_allowed` / auth strength. +3. `set_group_chat_id` + `resolve_chat_id` for supergroup forum topics. +4. No message truncation at parse layer; split only at send. +5. MarkdownV2 via `safe_*` / `send_with_fallback` / `edit_with_fallback`. +6. `RetryAfter` always re-raised from send paths. + +--- + +## 4. Что осталось сделать + +### 4.1. Обязательный next step (рекомендуемый) + +1. **Review + commit** локальных изменений логичными commits (или 1 monorepo-style commit), например: + - `feat(agentic): pipeline, MCP, workflows, backlog` + - `refactor: REF-001…009 (session_guard, split bot, errors, …)` +2. **Push** на `origin` (ветка `main` или `feat/agentic-refactor` + PR). +3. **CI:** убедиться, что `check.yml` зелёный на Linux (ruff, pyright, pytest). +4. **Optional secrets:** `CLAUDE_CODE_OAUTH_TOKEN` (уже implied by existing claude workflows), `XAI_API_KEY` если нужен Grok review/enrich. + +### 4.2. Не сделано (optional / future) + +| Item | Примечание | +|------|------------| +| `scripts/agentic/grok_client.py` | xAI API enrich/review — описан в plan, не в коде | +| `.github/workflows/agentic-grok-review.yml` | PR review via Grok | +| Auto-implement on schedule | `AGENTIC_AUTO_IMPLEMENT` var — default off; keep off for high-risk | +| Custom MCP on other machines | update `CCBOT_REPO_ROOT` in `.grok/config.toml` | +| Full coverage | hotspots improved but not “full”; screenshot.py, session_monitor still thin | +| Upstream sync | not attempted; fork may diverge after local refactor | +| Property-based tests for migrations | table-driven done; hypothesis optional | + +### 4.3. Открытые вопросы (не блокируют commit) + +- Preferred implementer in CI: Claude Code Action only vs + Grok. +- Commit strategy: one PR vs stack. +- Whether to open GitHub Issues via `sync-issues --apply` (needs `gh` + labels). + +### 4.4. Suggested verification after checkout + +```powershell +cd D:\CCbot_tmux\ccbot\ccbot_ju1ian +uv sync --all-extras +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run pyright src/ccbot/ # Windows may warn on fcntl types in session/hook — Linux CI is source of truth +uv run pytest tests/ --ignore=tests/ccbot/test_session_monitor.py -q +python scripts/agentic/cli.py status +python scripts/agentic/cli.py list --status ready +python scripts/agentic/mcp_smoke_test.py +``` + +--- + +## 5. Ключевые решения и договорённости + +### 5.1. Product / architecture + +| Решение | Почему | +|---------|--------| +| **1 topic = 1 window @id = 1 session** | Core AGENTS.md / CLAUDE.md invariant | +| Topic-only, no General-topic routing | Explicit product constraint | +| Window names only for display / re-resolve after tmux restart | Names unstable; @id is authority after migrate | +| Dual state: `state.json` (bindings) + `session_map.json` (hook) | Hook writes session map; bot owns topic bindings | + +### 5.2. Refactor design + +| Решение | Почему | +|---------|--------| +| `session_guard.py` not `session.py` for Telegram helpers | Avoid circular imports (session ↔ bot/telegram) | +| `require_user` + `require_session` (not one-size-fits-all) | `text_handler` unbound must open directory browser; `start` only needs auth; callbacks use `query.answer` | +| Split bot into handlers/* not packages | Matches existing handlers package style | +| Pure `session_migration.py` | Unit-testable without tmux/fcntl | +| Managers as module singletons (`queue_manager`, `interactive_state`) | Keep public function API stable for imports | +| Callback wire format unchanged | Telegram clients already have old payloads; only builders/parsers centralize | +| Markdown fallback centralized once | DRY + consistent RetryAfter / logging | + +### 5.3. Agentic / safety + +| Решение | Почему | +|---------|--------| +| Draft PR only; never push main from agent by default | Human merge gate | +| Path allow/deny in config | Blast radius control | +| 1 REF task = 1 PR / one implement run | Reviewability | +| High-risk tasks (split bot, session migrate, concurrency) already done carefully | Still prefer human review before merge | +| MCP stdlib JSON-RPC, no mcp SDK dep | Portable on Windows + CI | + +### 5.4. Platform notes + +| Note | Impact | +|------|--------| +| Windows has no `fcntl` | `tests/conftest.py` stubs it; production target is Linux/tmux | +| pyright on Windows may flag fcntl attributes | Trust Linux CI | +| session_monitor offset tests flaky on Windows CRLF | Ignore or run on Linux | + +--- + +## 6. Артефакты и файлы + +### 6.1. Отчёты (D:\GROK\reports\) + +| Файл | Описание | +|------|----------| +| `ccbot-refactoring-backlog.md` | Исходный human/LLM analysis (findings) | +| `ccbot-agentic-pipeline-plan.md` | Полный plan: stages, Grok, workflows, MCP, phases | +| `ccbot-agentic-mcp.md` | (если скопирован) MCP README | +| `implement-prompt-REF-001.md` | (если есть копия) prompt for first implement | +| **`ccbot-handoff.md`** | **Этот документ** | + +### 6.2. Agentic config (клон) + +| Путь | Описание | +|------|----------| +| `.agentic/README.md` | Pipeline overview | +| `.agentic/IMPLEMENTATION_PLAN.md` | Copy of full plan | +| `.agentic/config.json` | Weights, outputs, guardrails | +| `.agentic/policies.json` | Agent must/must_not | +| `.agentic/backlog/tasks.json` | **Source of truth for REF status** | +| `.agentic/backlog/source-report.md` | Copy of refactoring report | +| `.agentic/prompts/*` | Stage prompts | +| `.agentic/out/*` | Generated (gitignored): analysis, plan, implement-prompt, context-pack | +| `.agentic/mcp/README.md` | MCP usage | +| `.grok/config.toml` | Project MCP registration (`CCBOT_REPO_ROOT` hard-coded to local path) | +| `.github/workflows/agentic-*.yml` | CI orchestration | +| `scripts/agentic/*` | Toolkit + MCP server | +| `tests/agentic/test_pipeline.py` | Pipeline unit tests | + +### 6.3. Product code (new / major) + +| Путь | Описание | +|------|----------| +| `src/ccbot/session_guard.py` | Auth + session resolve | +| `src/ccbot/session_migration.py` | Pure migrate / stale resolve | +| `src/ccbot/errors.py` | Typed Telegram logging helpers | +| `src/ccbot/bot.py` | Thin app factory | +| `src/ccbot/handlers/command_handlers.py` | Commands + topics | +| `src/ccbot/handlers/message_handlers.py` | text/photo/voice | +| `src/ccbot/handlers/callback_router.py` | Callbacks | +| `src/ccbot/handlers/message_queue.py` | Queue manager | +| `src/ccbot/handlers/message_sender.py` | Unified markdown fallback | +| `src/ccbot/handlers/callback_data.py` | encode/parse | +| `src/ccbot/handlers/notifications.py` | Outbound assistant messages | +| `src/ccbot/handlers/window_bind.py` | Create+bind window | +| `src/ccbot/handlers/screenshot_controls.py` | Screenshot keyboard | + +### 6.4. New / important tests + +| Путь | Покрытие | +|------|----------| +| `tests/ccbot/test_session_guard.py` | require_session scenarios | +| `tests/ccbot/test_session_migration.py` | migrate table cases | +| `tests/ccbot/test_message_sender_errors.py` | fallback + RetryAfter | +| `tests/ccbot/test_tmux_manager.py` | mocked tmux | +| `tests/ccbot/handlers/test_callback_data.py` | round-trip callbacks | +| `tests/ccbot/handlers/test_message_queue_state.py` | managers | +| `tests/ccbot/handlers/test_message_queue_enqueue.py` | enqueue/worker | +| `tests/ccbot/handlers/test_command_handlers.py` | start/esc/unbind | +| `tests/agentic/test_pipeline.py` | pipeline toolkit | + +--- + +## 7. Контекст для нового агента + +### 7.1. Стек и команды + +```text +OS used in development: Windows (PowerShell) +Target runtime: Linux + tmux +Python: ≥3.12 (dev env may be 3.14) +Package manager: uv +Quality gates (must pass before claim done): + uv sync --all-extras + uv run ruff check src/ tests/ + uv run ruff format src/ tests/ # or --check in CI + uv run pyright src/ccbot/ + uv run pytest --tb=short -q +``` + +### 7.2. Стиль кода (из AGENTS.md / CLAUDE.md) + +- Module-level docstring on every new `.py` (purpose first line + responsibilities). +- Prefer existing patterns: `atomic_write_json`, env scrubbing, `safe_*` send helpers. +- Do not reintroduce non-topic mode or auth bypass. +- Prefer small reviewable diffs; one concern per PR when continuing. + +### 7.3. Как продолжать работу (playbook) + +1. `cd D:\CCbot_tmux\ccbot\ccbot_ju1ian` (or clone fresh and re-apply — but work is already there). +2. Read `AGENTS.md`, this handoff, `.agentic/backlog/tasks.json`. +3. `git status` / review diff. +4. If implementing new feature: update graph optional (`/understand`); else skip. +5. Use pipeline: + `python scripts/agentic/cli.py select --task ` → follow implement prompt → validate. +6. Or use MCP `ccbot-agentic__*` if Grok/Claude has folder trust + MCP enabled. +7. Never force-push; never weaken `check.yml`. +8. After changes: full quality gates; update backlog status via `update_backlog_status.py`. + +### 7.4. Что НЕ делать + +- Не коммитить `.env`, secrets, `session_map.json` / live `~/.ccbot` state. +- Не менять quality gates ради green CI. +- Не возвращать General-topic / non-topic routing. +- Не ключить routing по window **name** (только @id после migrate). +- Не silent `except Exception: pass` на user-visible send paths (use typed + log). +- Не начинать «ещё один god-module» в bot.py — handlers уже разнесены. + +### 7.5. Зависимости задач (исторические) + +```text +REF-001 → REF-002, REF-007 +REF-001 done → all others were independent or already completed +All REF-001…009: done +``` + +### 7.6. Контакты путей (absolute) + +| Role | Path | +|------|------| +| Clone | `D:\CCbot_tmux\ccbot\ccbot_ju1ian\` | +| Reports | `D:\GROK\reports\` | +| Knowledge graph | `D:\CCbot_tmux\ccbot\ccbot_ju1ian\.understand-anything\knowledge-graph.json` | +| Backlog SoT | `D:\CCbot_tmux\ccbot\ccbot_ju1ian\.agentic\backlog\tasks.json` | +| This handoff | `D:\GROK\reports\ccbot-handoff.md` (+ copy under `.agentic/` if present) | + +--- + +## 8. Минимальный «bootstrap prompt» для принимающего агента + +Скопируй и дополни: + +```text +You are continuing work on the ccbot_ju1ian fork at D:\CCbot_tmux\ccbot\ccbot_ju1ian. +Read D:\GROK\reports\ccbot-handoff.md as full context. +All REF-001..009 are implemented in the local tree but likely not committed/pushed. +Next priority: review git status, run quality gates, commit/push or open PR. +Respect AGENTS.md topic-only + window @id routing + auth invariants. +Do not weaken CI. Prefer small commits. +``` + +--- + +## 9. Definition of “project complete” vs “handoff complete” + +| Criterion | Status | +|-----------|--------| +| Backlog REF-001…009 implemented in local code | **Yes** | +| Pipeline + MCP usable locally | **Yes** | +| All tests green (modulo Windows session_monitor) | **Yes** | +| Committed / pushed to GitHub | **No (verify)** | +| Production deploy / live bot restarted | **No / unknown** | +| Grok auto-review in CI | **No (optional)** | + +**Handoff complete:** this document is sufficient for another agent to continue from git review → commit/PR → optional Grok CI → deploy. + +--- + +*Generated as handoff artifact for multi-agent continuity. Prefer updating this file when major milestones (push, new REF-*, deploy) land.* diff --git a/.agentic/HANDOFF_REF-006.md b/.agentic/HANDOFF_REF-006.md new file mode 100644 index 000000000..9e03a925b --- /dev/null +++ b/.agentic/HANDOFF_REF-006.md @@ -0,0 +1,135 @@ +# HANDOFF: CCBot Agentic Refactoring Pipeline + +**Дата:** 2026-07-18 +**Ветка:** `agentic/REF-006-data-to-dataclasses` +**Коммит:** `8c3051b` +**Репозиторий:** https://github.com/ju1ian0701/ccbot_ju1ian + +## 1. Цель проекта +Автоматизация рефакторинга Telegram-бота для управления Claude Code (`ccbot`) через agentic pipeline. +**Глобальная задача:** Устранить 9 архитектурных проблем (5 HIGH, 4 MEDIUM), выявленных в `ccbot-refactoring-backlog.md`, используя AI-агентов (Grok/Claude) под контролем человека. +**Текущая цель:** Последовательное выполнение задач REF-001…REF-009 с соблюдением quality gates и сохранением обратной совместимости. + +## 2. Что уже сделано + +### Инфраструктура пайплайна +- ✅ Создан и замержен в `main` каркас agentic pipeline (скрипты, конфиги, workflows). +- ✅ Настроен локальный цикл: `cli.py select/context` → Grok Build → Quality Gates → PR. +- ✅ Решены проблемы аутентификации Git/GitHub (Classic token + `gh auth setup-git`). +- ✅ Исправлены кодировки JSON (UTF-8 без BOM) для кроссплатформенной работы. + +### Выполненные задачи рефакторинга +| ID | Задача | Статус | Результат | +|---|---|---|---| +| REF-001 | Extract auth helper | ✅ Done | `require_session()` + `SessionContext`, устранено дублирование в 15+ хендлерах | +| REF-006 | Callback data → dataclasses | 🔄 In Progress | Ветка `agentic/REF-006-data-to-dataclasses` запушена, ожидает review/merge | + +### Ключевые достижения REF-006 +- Заменён строковый парсинг `data.split(":")` на typed frozen dataclasses. +- Добавлена валидация (`__post_init__`), round-trip сериализация, проверка лимита 64 байта. +- Сохранена модульная архитектура роутера (не создавался единый диспетчер). +- Написаны тесты миграции, иммутабельности и safe serialization. + +## 3. Текущее состояние +- **Активная ветка:** `agentic/REF-006-data-to-dataclasses` (запушена, draft PR создан). +- **Следующая задача:** После merge REF-006 → переход к REF-007 (переименование переменных). +- **Блокировки:** Нет. Все quality gates проходят локально. +- **Knowledge Graph:** Актуален после REF-001, требует обновления после merge REF-006. + +## 4. Что осталось сделать + +### Ближайшие шаги (приоритет) +1. **Замержить REF-006** после human review. +2. **Обновить knowledge graph:** `/understand full` в чате с AI-агентом. +3. **Выполнить REF-007** (Rename wid/tid/skey): + ```powershell + git checkout main && git pull + git checkout -b agentic/REF-007-renaming + python scripts/agentic/cli.py select --task REF-007 + ``` +4. **Продолжить по плану:** REF-002 → REF-003 → REF-008 → REF-009 → REF-004 → REF-005. + +### Незавершённые элементы пайплайна +- ⬜ Grok-интеграция в CI (`agentic-grok-review.yml`). +- ⬜ Atomic task packs (разбиение эпиков на подзадачи в `tasks.json`). +- ⬜ Context packer из knowledge graph (`build_context_pack.py`). +- ⬜ Секреты `XAI_API_KEY` в GitHub Secrets. + +## 5. Ключевые решения и договорённости + +### Архитектурные +- **Topic-only режим:** Никогда не переключать бота в non-topic mode. +- **Авторизация:** Не ослаблять `is_user_allowed()`. Все проверки через `require_session()`. +- **Маршрутизация окон:** Только по `@id`, никогда по `window_name`. +- **Callback Data:** Использовать frozen dataclasses + `safe_callback_data()` с проверкой 64 байта. +- **Модульность роутера:** Не объединять парсеры в единую функцию; каждый `parse_*` возвращает свой dataclass. + +### Процессные +- **1 PR = 1 REF-***: Не смешивать задачи в одном PR. +- **Human Merge:** AI никогда не мерджит в `main`. Только draft PR. +- **Quality Gates:** Ruff + Pyright + Pytest обязательны перед каждым коммитом. +- **Файл `tasks.json`:** Всегда под версионным контролем. Обновлять статус через `update_backlog_status.py`. + +### Технические +- **Кодировка JSON:** Всегда UTF-8 без BOM (использовать Python для записи, не PowerShell). +- **Git Auth:** Использовать `gh auth setup-git` + Classic token с `workflow` scope. +- **`.gitignore`:** `.agentic/out/` игнорируется; `.agentic/backlog/`, `scripts/agentic/`, `.grok/` — в репо. + +## 6. Артефакты и файлы + +### Конфигурация пайплайна +| Файл | Назначение | +|---|---| +| `.agentic/backlog/tasks.json` | База данных задач REF-001…009 со статусами | +| `.agentic/config.json` | Weights, guardrails, paths | +| `.agentic/policies.json` | Must/Must Not правила для агентов | +| `scripts/agentic/cli.py` | CLI toolkit (analyze/select/context/validate) | +| `.github/workflows/agentic-*.yml` | CI workflows (analyze/plan/implement/orchestrator) | + +### Продуктовый код (изменения REF-006) +| Файл | Изменения | +|---|---| +| `src/ccbot/handlers/callback_data.py` | Frozen dataclasses, parsers, safe serialization | +| `src/ccbot/handlers/callback_router.py` | Updated unpacking to use typed callbacks | +| `tests/ccbot/handlers/test_callback_data.py` | Migration, round-trip, immutability, validation tests | + +### Документация +| Файл | Назначение | +|---|---| +| `ccbot-refactoring-backlog.md` | Исходный анализ проблем | +| `ccbot-agentic-pipeline-plan.md` | Полный план автоматизации | +| `.agentic/HANDOFF.md` | Этот документ | +| `.agentic/README.md` | Инструкция по использованию пайплайна | + +## 7. Контекст для нового агента + +### Стек +- **Python 3.12+**, `uv` для управления зависимостями +- **Telegram Bot API** (python-telegram-bot v22.x) +- **tmux** как backend для управления сессиями +- **Grok / Claude** как AI-агенты для кодинга и ревью +- **GitHub Actions** для CI/CD + +### Ограничения +- ❌ Не менять `.github/workflows/check.yml` +- ❌ Не модифицировать `.env` и секретные файлы +- ❌ Не использовать `window_name` для маршрутизации +- ❌ Не удалять проверки авторизации +- ❌ Не делать `git add .` (рисковать закоммитить `.agentic/out/`) + +### Стиль кода +- Type hints везде (Python 3.10+ синтаксис: `X | Y`, не `Union[X, Y]`) +- Frozen dataclasses для immutable структур +- Specific exceptions вместо `except Exception` +- Structured logging вместо `print` +- Тесты: pytest + parametrize + property-based где применимо + +### Как продолжить работу +1. Прочитать `ccbot-agentic-pipeline-plan.md` (§8 Пошаговый план). +2. Проверить статусы в `.agentic/backlog/tasks.json`. +3. Выбрать следующую задачу с `status: ready` и `depends_on: []` (или выполненными зависимостями). +4. Запустить локальный цикл: + ```powershell + python scripts/agentic/cli.py select --task REF-XXX + python scripts/agentic/cli.py context --task REF-XXX + # Передать implement-prompt.md + context-pack.json агенту \ No newline at end of file diff --git a/.agentic/IMPLEMENTATION_PLAN.md b/.agentic/IMPLEMENTATION_PLAN.md new file mode 100644 index 000000000..d7ae4be49 --- /dev/null +++ b/.agentic/IMPLEMENTATION_PLAN.md @@ -0,0 +1,1027 @@ +# Полный agentic pipeline для ccbot_ju1ian + +**Репозиторий (форк):** https://github.com/ju1ian0701/ccbot_ju1ian +**Локальный клон:** `D:\CCbot_tmux\ccbot\ccbot_ju1ian\` +**Knowledge graph:** `D:\CCbot_tmux\ccbot\ccbot_ju1ian\.understand-anything\knowledge-graph.json` +**Результат рефакторинга:** `D:\GROK\reports\ccbot-refactoring-backlog.md` +**Дата плана:** 2026-07-15 +**Статус:** каркас pipeline уже создан в клоне; ниже — полный исполняемый план + интеграция Grok + sub-agents/MCP + оставшиеся шаги. + +--- + +## 0. Цель и границы + +### Цель + +Автоматизировать цикл: + +```text +knowledge-graph.json + refactoring backlog + → структурированные атомарные задачи + → Grok (plan / context / review) + implement-agent (code) + → изменения в Python (локальный клон / PR) + → ruff + pyright + pytest + → auto-review + → human merge (обязательная точка) +``` + +### Уровень автоматизации + +| Этап | Авто | Человек | +|------|:----:|:-------:| +| Обновление knowledge graph (`/understand`) | частично | да (первый full-run, review ignore) | +| Analyze hotspots + rank backlog | **да** | нет | +| Выбор следующей задачи | **да** (алгоритм) | override через `task_id` | +| Генерация implement-prompt | **да** | нет | +| Применение правок в Python | **да** (agent) | при `blocked` | +| Quality gates (ruff/pyright/pytest) | **да** | нет | +| Draft PR | **да** | нет | +| Code review (Grok/Claude) | **да** (комментарии) | **да** (approve/merge) | +| Merge в `main` | нет | **обязательно** | +| Секреты / токены / policy change | нет | **обязательно** | + +### Стек + +- **Python 3.12+** — продукт + toolkit pipeline +- **Shell / PowerShell** — glue, CI steps +- **uv** — env, ruff, pyright, pytest +- **GitHub Actions** — оркестрация +- **Grok (xAI API)** — analyze enrich / plan narrative / PR review +- **Implement-agent** — применение diff (Claude Code Action *или* локальный Grok/CLI agent) +- **MCP tasks** — трекинг long-running job’ов (опционально) +- **understand-anything** — knowledge graph + +--- + +## 1. Текущее состояние (что уже есть) + +### Источники + +| Артефакт | Путь | Назначение | +|----------|------|------------| +| Knowledge graph | `.understand-anything/knowledge-graph.json` | 530 nodes, 555 edges, 10 layers (commit `9d7ab97…`) | +| Refactoring backlog (human report) | `D:\GROK\reports\ccbot-refactoring-backlog.md` | 5 HIGH + 4 MEDIUM findings | +| Structured tasks | `.agentic/backlog/tasks.json` | `REF-001`…`REF-009` | +| Pipeline config | `.agentic/config.json`, `policies.json` | weights, guardrails | +| Toolkit | `scripts/agentic/*.py` | analyze/plan/select/validate/sync | +| Workflows (каркас) | `.github/workflows/agentic-*.yml` | analyze/plan/implement/orchestrator | +| Existing CI | `check.yml` | ruff + pyright + pytest | +| Existing agents | `claude.yml`, `claude-code-review.yml` | @claude + PR review | + +### Локальная проверка toolkit (выполнено) + +```text +python scripts/agentic/cli.py run --skip-quality +→ selected=REF-001 +→ .agentic/out/{analysis-report,plan,selected-task,implement-prompt}.md|json +uv run pytest tests/agentic -q → 8 passed +``` + +### Чего ещё нет (нужно доделать по этому плану) + +1. **Grok-интеграция** (xAI API) — analyze enrich, plan narrative, PR review +2. Workflow **`agentic-grok-review.yml`** +3. **Atomic task packs** (разбиение REF-* на subtasks `REF-001.a`, …) +4. Context packer: **subgraph slice** из knowledge-graph → prompt +5. Опционально: **MCP server** для backlog/graph tools +6. Push каркаса в remote + secrets `XAI_API_KEY` +7. Первый end-to-end implement `REF-001` в локальном клоне + +--- + +## 2. Структуризация задач (agentic-ready) + +### 2.1. Принцип: от finding → epic → atomic task + +```text +ccbot-refactoring-backlog.md (finding, prose) + │ + knowledge-graph hotspots + ▼ +REF-00N (epic / deliverable, 1 draft PR) + │ depends_on, files, acceptance_criteria + ▼ +REF-00N.a / .b / .c (atomic steps, 1 agent turn each) + │ + ▼ +selected-task.json + context-pack.json → agent prompt +``` + +### 2.2. Формат хранения (канон) + +**Файл:** `.agentic/backlog/tasks.json` (уже есть). + +Минимальная schema одной задачи: + +```json +{ + "id": "REF-001", + "title": "Extract shared auth + session resolution helper", + "priority": "high", + "status": "ready", + "categories": ["dry", "solid"], + "files": ["src/ccbot/bot.py", "src/ccbot/session.py"], + "related_nodes": ["file:src/ccbot/bot.py", "file:src/ccbot/session.py"], + "depends_on": [], + "problem": "...", + "solution": "...", + "acceptance_criteria": [ + "At least 8 handlers use the shared helper", + "No behavior change for unauthorized users", + "Unit tests cover authorized/unauthorized/unbound/missing-window" + ], + "estimated_risk": "medium", + "labels": ["priority:high", "category:dry", "agentic"], + "atomic_steps": [ + { + "id": "REF-001.a", + "title": "Add SessionContext dataclass + require_session helper", + "files": ["src/ccbot/session.py", "tests/ccbot/test_session.py"], + "done_when": ["helper exists", "unit tests green"] + }, + { + "id": "REF-001.b", + "title": "Migrate command handlers to helper", + "files": ["src/ccbot/bot.py"], + "done_when": ["≥4 command handlers use helper"] + }, + { + "id": "REF-001.c", + "title": "Migrate message/callback handlers + cleanup duplicates", + "files": ["src/ccbot/bot.py"], + "done_when": ["≥8 handlers total", "no auth boilerplate left in migrated paths"] + } + ] +} +``` + +**Правила атомарности:** + +| Правило | Значение | +|---------|----------| +| 1 PR = 1 `REF-00N` | epic, reviewable | +| 1 agent turn ≤ 1–3 atomic_steps | если epic большой — несколько commits в одной ветке | +| `depends_on` | жёсткий DAG (REF-002 ждёт REF-001) | +| `files` | allow-list scope для guardrails | +| `related_nodes` | ключи в knowledge-graph для context packer | +| `status` | `planned` → `ready` → `in_progress` → `done` / `blocked` | + +### 2.3. Передача агенту (контракт) + +Агент **не** читает prose backlog целиком. Он получает bundle: + +```text +.agentic/out/ + selected-task.json # задача + ranking + context-pack.json # slice графа + excerpts + implement-prompt.md # полный prompt + pr-body.md # шаблон PR + policies snippet # must / must_not +``` + +**Генерация (уже):** + +```bash +python scripts/agentic/cli.py analyze +python scripts/agentic/cli.py select --task REF-001 +# → .agentic/out/implement-prompt.md +``` + +**Доделать:** `scripts/agentic/build_context_pack.py` — вырезка subgraph: + +```python +# Псевдокод (рабочий каркас для реализации) +def build_context_pack(graph: dict, task: dict) -> dict: + node_ids = set(task.get("related_nodes") or []) + # + соседи imports/calls на 1 hop + # + file summaries, complexity, tested_by + # + layer names + return { + "task_id": task["id"], + "nodes": [...], + "edges": [...], + "hotspot_scores": {...}, + "constraints_from_agents_md": [...], + } +``` + +### 2.4. Маппинг backlog → tasks (источник истины) + +| Finding (report) | Task ID | Atomic split (рекомендация) | +|------------------|---------|-----------------------------| +| DRY auth/resolve | REF-001 | a helper · b commands · c messages | +| bot.py god module | REF-002 | a extract commands · b messages · c callbacks (после 001) | +| except Exception | REF-003 | a sender · b queue · c tmux (отдельные PR при желании) | +| session migration | REF-004 | a doc model · b isolate migrate · c tests | +| global mutable state | REF-005 | a MessageQueueManager · b wire shutdown · c tests | +| callback_data splits | REF-006 | a builders · b migrate producers · c tests | +| wid/tid names | REF-007 | после 001; mechanical rename PR | +| markdown fallback DRY | REF-008 | a unified send · b migrate queue | +| test gaps | REF-009 | a bot hotspots · b tmux · c queue | + +--- + +## 3. Цикл автоматизации (triggers + human gates) + +```text +┌──────────────────────────────────────────────────────────────────────┐ +│ A. GRAPH REFRESH │ +│ trigger: manual / after large merge │ +│ tool: /understand → .understand-anything/knowledge-graph.json │ +│ human: confirm .understandignore (first run) │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ B. ANALYZE (auto) │ +│ trigger: schedule Mon 06:00 · push graph/backlog · workflow_dispatch│ +│ job: agentic-analyze.yml │ +│ steps: cli.py analyze → hotspots; cli.py plan → ranking │ +│ Grok (optional enrich): narrative summary of hotspots │ +│ artifacts: analysis-report.{json,md}, plan.{json,md} │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ C. PLAN / SELECT (auto) │ +│ trigger: workflow_dispatch · orchestrator │ +│ job: agentic-plan.yml │ +│ steps: select task (deps OK, max open agent PRs) │ +│ Grok: optional “why this task / risk notes” │ +│ optional: sync-issues → GitHub Issues │ +│ human gate (soft): review ranking if score ties / high risk │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ D. IMPLEMENT (auto, draft only) │ +│ trigger: workflow_dispatch(task_id) · orchestrator if enabled │ +│ job: agentic-implement.yml │ +│ branch: agentic/REF-00N-slug │ +│ agent: Claude Code Action OR local Grok implement loop │ +│ inputs: implement-prompt.md + context-pack + policies │ +│ writes: Python under src/ccbot, tests/ │ +│ never: push to main │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ E. VALIDATE (auto, hard gate) │ +│ trigger: end of implement · every PR (check.yml) │ +│ ruff check + ruff format --check │ +│ pyright src/ccbot/ │ +│ pytest │ +│ agentic validate: path allow/deny lists │ +│ fail → agent retry once OR mark blocked │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ F. REVIEW (auto comments) │ +│ trigger: pull_request opened/synchronize │ +│ jobs: agentic-grok-review.yml (+ optional claude-code-review) │ +│ Grok: summary, must-fix, verdict (comment only) │ +└───────────────────────────────┬──────────────────────────────────────┘ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ G. HUMAN MERGE (обязательно) │ +│ human: read draft PR + Grok review + CI green │ +│ human: merge / request changes │ +│ after merge: update backlog status → done; optional graph refresh│ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### Точки обязательного human intervention + +1. **Merge PR** — всегда. +2. **Первый full knowledge-graph build** — confirm ignore list. +3. **High-risk tasks** (`estimated_risk: high`: REF-002, REF-004, REF-005) — approve перед implement (label `agentic:approved` или manual dispatch). +4. **Secrets** (`XAI_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`). +5. **Policy change** (ослабление guardrails / topic-only / auth). +6. **Blocked agent** — `.agentic/out/blocked.md`. + +--- + +## 4. Интеграция Grok (xAI) — где, что, как + +### 4.1. Роль Grok в pipeline (разделение обязанностей) + +| Роль | Кто | Почему | +|------|-----|--------| +| **Planner / narrative / risk** | **Grok** | сильный reasoning по графу + backlog | +| **Context compression** | **Grok** | сжать 530 nodes → task-relevant pack | +| **PR review** | **Grok** | structured review comment | +| **Code edit apply** | Implement-agent (Claude Code Action *или* local coding agent) | tool-use loop + git/PR | +| **Deterministic rank** | Python toolkit | без LLM, воспроизводимо | + +> Grok **не** должен молча merge’ить в `main`. +> Grok **должен** получать **уже отфильтрованный** context-pack, а не весь `knowledge-graph.json` (экономия токенов + меньше галлюцинаций). + +### 4.2. Точки вызова Grok + +```text +[ANALYZE] scripts/agentic/grok_client.py enrich_analysis() + in: analysis-report.json (top hotspots + layers) + out: analysis-grok.md (recommendations narrative) + +[PLAN] grok_client.py enrich_plan() + in: plan.json + selected-task + context-pack + out: plan-rationale.md + +[REVIEW] .github/workflows/agentic-grok-review.yml + in: PR diff + task id from PR body + policies + out: PR comment (must-fix / should-fix / verdict) + +[OPTIONAL IMPLEMENT via Grok] + local loop: grok_client.py chat + apply_patch tool + (если нет Claude token — fallback implement) +``` + +### 4.3. Клиент Grok (рабочий пример) + +**Файл:** `scripts/agentic/grok_client.py` + +```python +"""Minimal xAI Grok client for agentic pipeline stages.""" + +from __future__ import annotations + +import json +import os +import urllib.request +from typing import Any + +XAI_API_URL = os.environ.get("XAI_API_URL", "https://api.x.ai/v1/chat/completions") +DEFAULT_MODEL = os.environ.get("XAI_MODEL", "grok-3") + + +def grok_chat( + messages: list[dict[str, str]], + *, + model: str = DEFAULT_MODEL, + temperature: float = 0.2, + max_tokens: int = 4096, +) -> str: + api_key = os.environ.get("XAI_API_KEY") + if not api_key: + raise RuntimeError("XAI_API_KEY is not set") + + body = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + req = urllib.request.Request( + XAI_API_URL, + data=json.dumps(body).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=120) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return payload["choices"][0]["message"]["content"] + + +def enrich_analysis(analysis: dict[str, Any]) -> str: + system = ( + "You are a senior Python architect. Given a knowledge-graph analysis " + "of the ccbot Telegram/tmux bridge, produce a concise Russian report: " + "top risks, recommended REF task order, and what NOT to refactor yet. " + "Respect topic-only architecture and auth invariants." + ) + user = json.dumps( + { + "stats": analysis.get("stats"), + "hotspots": (analysis.get("hotspots") or [])[:12], + "recommendations": analysis.get("recommendations"), + }, + ensure_ascii=False, + indent=2, + ) + return grok_chat( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ] + ) + + +def review_pr(diff: str, task: dict[str, Any], policies: dict[str, Any]) -> str: + system = ( + "Review this PR for ccbot. Output Markdown with sections: " + "Summary, Must-fix, Should-fix, Nits, Verdict " + "(approve|request_changes|comment). Fail if topic-only or auth is weakened." + ) + user = json.dumps( + {"task": task, "policies": policies, "diff": diff[:120000]}, + ensure_ascii=False, + ) + return grok_chat( + [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + max_tokens=6000, + ) +``` + +### 4.4. Как Grok получает контекст из knowledge graph + +```text +knowledge-graph.json + │ + ▼ +analyze_graph.py → path_scores, hotspots, layers + │ + ▼ +build_context_pack.py + │ filter by task.related_nodes + 1-hop imports/calls + │ attach file summaries, complexity, tested_by + ▼ +context-pack.json (≤ ~30–80 KB, не мегабайты) + │ + ▼ +Grok messages[] (system policies + user pack + task JSON) +``` + +**Не делать:** пихать весь graph (530 nodes) в один prompt. +**Делать:** hotspot table + subgraph вокруг `files[]` + AGENTS.md constraints. + +### 4.5. Встраивание в GitHub Actions + +**Secret:** `XAI_API_KEY` (repo secret). +**Optional env:** `XAI_MODEL=grok-3`, `XAI_API_URL=https://api.x.ai/v1/chat/completions`. + +Фрагмент для `agentic-analyze.yml` (добавить step): + +```yaml + - name: Grok enrich analysis + if: ${{ secrets.XAI_API_KEY != '' }} + env: + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + XAI_MODEL: ${{ vars.XAI_MODEL || 'grok-3' }} + run: | + python scripts/agentic/cli.py analyze + python - <<'PY' + import json + from pathlib import Path + import sys + sys.path.insert(0, "scripts/agentic") + from grok_client import enrich_analysis + analysis = json.loads(Path(".agentic/out/analysis-report.json").read_text(encoding="utf-8")) + text = enrich_analysis(analysis) + Path(".agentic/out/analysis-grok.md").write_text(text, encoding="utf-8") + print(text[:2000]) + PY +``` + +**Workflow PR review:** `.github/workflows/agentic-grok-review.yml` — полный пример в §6.3. + +### 4.6. Grok vs Claude в этом репо + +| Сценарий | Рекомендация | +|----------|--------------| +| Есть только `XAI_API_KEY` | Grok plan+review; local implement через Grok Build / CLI agent | +| Есть `CLAUDE_CODE_OAUTH_TOKEN` | Claude implement (уже в `agentic-implement.yml`); Grok review | +| Есть оба | **Grok plan/review + Claude implement** (лучшее разделение) | + +--- + +## 5. GitHub Workflows — полный набор + +### 5.1. Карта workflows + +| Файл | Trigger | Назначение | Agent | +|------|---------|------------|-------| +| `check.yml` | push/PR | ruff + pyright + pytest | — | +| `agentic-analyze.yml` | cron / push graph / manual | hotspots + rank | Python + **Grok enrich** | +| `agentic-plan.yml` | manual | select task, optional issues | Python + **Grok rationale** | +| `agentic-implement.yml` | manual / callable | apply code, draft PR | **Claude/Grok implement** | +| `agentic-orchestrator.yml` | manual / weekly | chain A→C→(D) | orchestration | +| `agentic-grok-review.yml` | **создать** PR | Grok review comment | **Grok** | +| `claude.yml` | @claude | ad-hoc | Claude | +| `claude-code-review.yml` | PR | optional second reviewer | Claude | + +### 5.2. Permissions matrix + +| Workflow | contents | pull-requests | issues | id-token | actions | +|----------|----------|---------------|--------|----------|---------| +| analyze | read | — | — | — | — | +| plan | read | — | write | — | — | +| implement | **write** | **write** | write | write | read | +| grok-review | read | **write** | — | — | — | +| check | read | — | — | — | — | + +### 5.3. Quality gates (единый контракт) + +```bash +uv sync --all-extras +uv run ruff check src/ tests/ +uv run ruff format --check src/ tests/ +uv run pyright src/ccbot/ +uv run pytest --tb=short -q +python scripts/agentic/cli.py validate --base-ref origin/main +``` + +--- + +## 6. Полный список файлов + примеры + +### 6.1. Inventory + +```text +# УЖЕ СОЗДАНО в локальном клоне +.agentic/ + README.md + config.json + policies.json + backlog/tasks.json + backlog/source-report.md + prompts/{analyze,plan,implement,review,pr-body}.md + out/ # gitignored, regenerated + +scripts/agentic/ + cli.py + analyze_graph.py + prioritize.py + render_prompt.py + validate_changes.py + sync_issues.py + update_backlog_status.py + paths.py + __init__.py + README.md + +.github/workflows/ + agentic-analyze.yml + agentic-plan.yml + agentic-implement.yml + agentic-orchestrator.yml + check.yml + claude.yml + claude-code-review.yml + +tests/agentic/test_pipeline.py + +# НУЖНО СОЗДАТЬ / ДОПИЛИТЬ +scripts/agentic/grok_client.py # §4.3 +scripts/agentic/build_context_pack.py # subgraph packer +scripts/agentic/apply_local_task.sh # local implement glue (bash) +scripts/agentic/apply_local_task.ps1 # Windows glue +.github/workflows/agentic-grok-review.yml # §6.3 +.agentic/prompts/grok-review.md +.agentic/prompts/grok-plan.md +.agentic/mcp/server.py # optional MCP +.agentic/backlog/tasks.json # + atomic_steps fields +``` + +### 6.2. Пример: `apply_local_task.ps1` (Windows, локальный клон) + +```powershell +# scripts/agentic/apply_local_task.ps1 +param( + [string]$TaskId = "", + [switch]$SkipQuality +) +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot\..\.. + +python scripts/agentic/cli.py analyze +if ($TaskId) { + python scripts/agentic/cli.py select --task $TaskId +} else { + python scripts/agentic/cli.py select +} + +Write-Host "=== Feed this prompt to Grok Build / coding agent ===" +Write-Host (Resolve-Path .agentic\out\implement-prompt.md) + +# After agent edits: +if (-not $SkipQuality) { + uv sync --all-extras + uv run ruff check src/ tests/ + uv run ruff format src/ tests/ + uv run pyright src/ccbot/ + uv run pytest --tb=short -q + python scripts/agentic/cli.py validate +} +``` + +### 6.3. Пример: `agentic-grok-review.yml` + +```yaml +name: Agentic Grok Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + grok-review: + if: startsWith(github.head_ref, 'agentic/') || contains(github.event.pull_request.labels.*.name, 'agentic') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Collect PR context + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git fetch origin "${{ github.base_ref }}" + git diff --unified=3 "origin/${{ github.base_ref }}"...HEAD > /tmp/pr.diff + mkdir -p .agentic/out + # try extract task id from PR body marker + python - <<'PY' + import os, re, json, pathlib + body = """${{ github.event.pull_request.body }}""" + m = re.search(r"REF-\d+", body or "") + task_id = m.group(0) if m else "" + pathlib.Path(".agentic/out/review-meta.json").write_text( + json.dumps({"task_id": task_id}, indent=2), encoding="utf-8" + ) + print("task_id=", task_id) + PY + + - name: Grok review + env: + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + run: | + python - <<'PY' + import json, pathlib, sys + sys.path.insert(0, "scripts/agentic") + from grok_client import review_pr + from paths import load_json, find_repo_root + root = find_repo_root() + policies = load_json(root / ".agentic" / "policies.json") + meta = json.loads(pathlib.Path(".agentic/out/review-meta.json").read_text()) + tasks = load_json(root / ".agentic" / "backlog" / "tasks.json")["tasks"] + task = next((t for t in tasks if t["id"] == meta.get("task_id")), {"id": meta.get("task_id")}) + diff = pathlib.Path("/tmp/pr.diff").read_text(encoding="utf-8", errors="replace") + text = review_pr(diff, task, policies) + pathlib.Path(".agentic/out/grok-review.md").write_text(text, encoding="utf-8") + print(text[:3000]) + PY + + - name: Comment on PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr comment ${{ github.event.pull_request.number }} \ + --body-file .agentic/out/grok-review.md +``` + +### 6.4. Пример prompt: `.agentic/prompts/grok-review.md` + +```markdown +# Grok PR Review — ccbot agentic + +## Inputs +- PR diff +- selected task JSON (REF-*) +- policies.json +- AGENTS.md constraints (topic-only, auth, MarkdownV2 safe_*) + +## Output (Markdown only) +1. **Summary** (2–4 sentences) +2. **Must-fix** (blocking) +3. **Should-fix** +4. **Nits** +5. **Verdict**: `approve` | `request_changes` | `comment` + +## Fail conditions (always Must-fix) +- reintroduces non-topic mode +- weakens is_user_allowed / auth +- keys routing by window name instead of @id +- silences exceptions on user-visible send paths without logging +``` + +### 6.5. Пример `config.json` (ключевые поля — уже в репо) + +```json +{ + "knowledge_graph": { + "path": ".understand-anything/knowledge-graph.json" + }, + "backlog": { "path": ".agentic/backlog/tasks.json" }, + "implementation": { + "branch_prefix": "agentic/", + "draft_pr": true, + "require_tests": true, + "require_ruff": true, + "require_pyright": true + }, + "guardrails": { + "allowed_path_globs": ["src/ccbot/**/*.py", "tests/**", ".agentic/**", "scripts/agentic/**"], + "blocked_path_globs": [".env", "**/*secret*", ".github/workflows/check.yml"] + } +} +``` + +--- + +## 7. Sub-agents и MCP — полный список + +### 7.1. Sub-agents (логические роли) + +| ID | Роль | Реализация | Input | Output | Tools | +|----|------|------------|-------|--------|-------| +| `graph-analyzer` | hotspots из KG | `analyze_graph.py` (+ optional Grok enrich) | knowledge-graph.json | analysis-report.* | read files | +| `task-planner` | rank/select | `prioritize.py` + Grok rationale | backlog + analysis | plan.*, selected-task | read | +| `context-packer` | subgraph slice | `build_context_pack.py` (todo) | graph + task | context-pack.json | read | +| `implementer` | code edits | Claude Code Action / Grok coding agent | implement-prompt | git diff, branch | Edit, Bash(uv/git/gh) | +| `validator` | gates | `validate_changes.py` + check.yml | diff | validation.json | ruff/pyright/pytest | +| `reviewer-grok` | PR review | agentic-grok-review.yml | diff + task | PR comment | gh, XAI API | +| `reviewer-claude` | optional 2nd | claude-code-review.yml | PR | review comments | Claude Action | +| `issue-syncer` | backlog→issues | `sync_issues.py` | tasks.json | GitHub Issues | gh | +| `understand-*` | graph build | skill `/understand` | codebase | knowledge-graph.json | subagents scanner/analyzer/… | + +**Grok Build TUI subagents (локально, при работе в Grok):** + +| subagent_type | Когда | +|---------------|-------| +| `explore` | быстро найти handlers / auth patterns перед implement | +| `plan` | спроектировать split bot.py (REF-002) | +| `general-purpose` | multi-step implement + tests | +| `review` skill | локальный review diff перед push | + +### 7.2. MCP servers + +| MCP server | Tools | Назначение в pipeline | +|------------|-------|------------------------| +| **tasks** (уже в `D:\GROK\mcps\tasks`) | `create`, `list`, `update`, `get_results`, `pause`, `delete` | трекинг long-running analyze/implement jobs в Grok session | +| **github** (опционально, GitHub MCP) | issues/PRs/contents | вместо `gh` CLI, если настроен | +| **filesystem** (built-in agent tools) | Read/Write/Glob | apply patches в локальном клоне | +| **custom `ccbot-agentic` MCP** (рекомендуется добавить) | см. ниже | единый API к backlog/graph для агента | + +#### Custom MCP tool surface (проектный) + +```text +ccbot-agentic MCP (scripts or .agentic/mcp/server.py) + list_tasks(status?) + get_task(task_id) + select_next_task(force_id?) + get_context_pack(task_id) + get_analysis() + mark_task_status(task_id, status) + run_validate(base_ref?) +``` + +Минимальный контракт `list_tasks`: + +```json +{ + "name": "list_tasks", + "arguments": { "status": "ready" }, + "result": { + "tasks": [ + { "id": "REF-001", "title": "...", "score": 120, "priority": "high" } + ] + } +} +``` + +#### Existing tasks MCP (Grok) + +```text +D:\GROK\mcps\tasks\tools\ + create.json — создать job + list.json — список + update.json — обновить статус + get_results.json + pause.json + delete.json +``` + +Использование: при `agentic-implement` длительностью >15 мин — `tasks.create` с prompt «implement REF-001», poll `get_results`. + +### 7.3. Внешние actions / APIs + +| Компонент | Secret / auth | +|-----------|----------------| +| xAI Grok API | `XAI_API_KEY` | +| Claude Code Action | `CLAUDE_CODE_OAUTH_TOKEN` | +| GitHub | `GITHUB_TOKEN` (Actions) / `gh auth` locally | + +--- + +## 8. Пошаговый план реализации (исполняемый) + +### Фаза 0 — Подготовка (1 раз) + +| # | Действие | Команда / результат | Авто/Human | +|---|----------|---------------------|------------| +| 0.1 | Убедиться, что клон на `main` и graph на месте | `Test-Path ...\knowledge-graph.json` | H | +| 0.2 | Прочитать backlog report | `D:\GROK\reports\ccbot-refactoring-backlog.md` | H | +| 0.3 | Проверить toolkit | `python scripts/agentic/cli.py run --skip-quality` | A | +| 0.4 | Прогнать unit tests toolkit | `uv run pytest tests/agentic -q` | A | +| 0.5 | Добавить secrets в GitHub | `XAI_API_KEY`, при необходимости Claude token | **H** | + +### Фаза 1 — Довести data layer + +| # | Действие | Детали | +|---|----------|--------| +| 1.1 | Добавить `atomic_steps` в каждый REF-* | см. §2.2 | +| 1.2 | Реализовать `build_context_pack.py` | 1-hop subgraph + summaries | +| 1.3 | Реализовать `grok_client.py` | §4.3 | +| 1.4 | Промпты `grok-plan.md`, `grok-review.md` | §6.4 | +| 1.5 | CLI: `python scripts/agentic/cli.py context --task REF-001` | пишет context-pack.json | +| 1.6 | CLI: `python scripts/agentic/cli.py grok-enrich` | analysis-grok.md | + +### Фаза 2 — CI workflows + +| # | Действие | Детали | +|---|----------|--------| +| 2.1 | В `agentic-analyze.yml` добавить Grok enrich step | if secret present | +| 2.2 | Создать `agentic-grok-review.yml` | §6.3 | +| 2.3 | В implement: pre-step `build_context_pack` + attach to prompt | | +| 2.4 | Для high-risk: require label `agentic:approved` | `if: contains(labels…)` | +| 2.5 | Push branch `feat/agentic-pipeline` → PR (не сразу в main, по желанию) | **H merge** | + +### Фаза 3 — Первый end-to-end на Python-коде (локально) + +| # | Действие | Детали | +|---|----------|--------| +| 3.1 | `python scripts/agentic/cli.py select --task REF-001` | prompt ready | +| 3.2 | Implement **REF-001** в локальном клоне | helper `require_session` / `SessionContext` | +| 3.3 | Тесты: unauthorized / unbound / missing window | `tests/ccbot/` | +| 3.4 | `uv run ruff … && pyright && pytest` | hard gate | +| 3.5 | `python scripts/agentic/update_backlog_status.py REF-001 done` | after merge | +| 3.6 | Commit на `agentic/REF-001-…`, draft PR | A/H | + +### Фаза 4 — Автоматизация PR review + +| # | Действие | +|---|----------| +| 4.1 | Открыть PR с label `agentic` | +| 4.2 | `check.yml` + `agentic-grok-review.yml` | +| 4.3 | Human: merge only if Grok verdict ≠ request_changes **or** human overrides with comment | +| 4.4 | Optional: second pass Claude review | + +### Фаза 5 — Оркестратор «полный автомат» (осторожно) + +| # | Действие | +|---|----------| +| 5.1 | `AGENTIC_AUTO_IMPLEMENT=false` по умолчанию | +| 5.2 | Weekly: analyze only | +| 5.3 | Включить auto-implement **только** для `priority=medium` + `risk=low` | +| 5.4 | High-risk всегда manual dispatch | + +--- + +## 9. Пошаговый журнал уже выполненных действий + +> Этот раздел — audit trail того, что сделано до/в рамках подготовки pipeline. + +### 2026-07-11 — Refactoring analysis + +1. Проанализирован upstream/local ccbot с опорой на knowledge graph. +2. Сформирован `D:\GROK\reports\ccbot-refactoring-backlog.md` (5 HIGH, 4 MEDIUM). + +### 2026-07-14 — Knowledge graph + +1. Построен/обновлён `.understand-anything/knowledge-graph.json` (530 nodes / 555 edges / 10 layers). +2. `meta.json` → commit `9d7ab9721f09d748db242c22d04aa494cbebc72d`. + +### 2026-07-15 — Agentic scaffold (локальный клон форка) + +1. Создан каталог `.agentic/` (config, policies, prompts, backlog). +2. Структурированы задачи `REF-001`…`REF-009` в `tasks.json`. +3. Написан Python toolkit `scripts/agentic/` (analyze/plan/select/validate/sync). +4. Добавлены workflows: + - `agentic-analyze.yml` + - `agentic-plan.yml` + - `agentic-implement.yml` + - `agentic-orchestrator.yml` +5. Добавлены unit tests `tests/agentic/test_pipeline.py` → **8 passed**. +6. Локальный dry-run: **selected = REF-001**. +7. Guardrails + ignored paths (`.understand-anything/`) настроены. +8. Исходный markdown backlog скопирован в `.agentic/backlog/source-report.md`. +9. **Не сделано:** push в GitHub, Grok client, context packer, grok-review workflow, implement REF-001 в product code. + +### 2026-07-15 — Этот документ + +1. Составлен полный план: `D:\GROK\reports\ccbot-agentic-pipeline-plan.md`. +2. Зафиксированы: task format, Grok integration points, workflows, sub-agents, MCP, human gates. + +### 2026-07-15 — Custom MCP `ccbot-agentic` + +1. Реализован stdio MCP server: `scripts/agentic/mcp_server.py` (stdlib JSON-RPC). +2. Service layer: `scripts/agentic/service.py` + context packer `build_context_pack.py`. +3. Project config: `.grok/config.toml` → `[mcp_servers.ccbot-agentic]`. +4. Tools: `list_tasks`, `get_task`, `select_next_task`, `get_analysis`, `get_context_pack`, `get_implement_prompt`, `mark_task_status`, `run_analyze`, `run_validate`, `pipeline_status`. +5. Smoke test: `scripts/agentic/mcp_smoke_test.py`. +6. Docs: `.agentic/mcp/README.md`. + +--- + +## 10. Рекомендуемый порядок «завтра утром» + +```powershell +cd D:\CCbot_tmux\ccbot\ccbot_ju1ian + +# 1) sanity +python scripts/agentic/cli.py analyze +python scripts/agentic/cli.py plan + +# 2) (после добавления grok_client.py + XAI_API_KEY) +# $env:XAI_API_KEY = "..." +# python scripts/agentic/cli.py grok-enrich + +# 3) локальный implement REF-001 через Grok Build / coding agent +python scripts/agentic/cli.py select --task REF-001 +# открыть .agentic\out\implement-prompt.md → agent edits src/ + +# 4) gates +uv sync --all-extras +uv run ruff check src/ tests/ +uv run ruff format src/ tests/ +uv run pyright src/ccbot/ +uv run pytest --tb=short -q + +# 5) commit / PR (когда готовы) +# git checkout -b agentic/REF-001-auth-session-helper +# git add ... +# git commit -m "refactor(agentic): REF-001 extract require_session helper" +# git push -u origin HEAD +# gh pr create --draft ... +``` + +--- + +## 11. Риски и mitigations + +| Риск | Mitigation | +|------|------------| +| Agent ломает auth/topic routing | policies.json + Grok must-fix + human merge | +| Слишком большой diff | max_files_changed=40, atomic_steps, 1 REF per PR | +| Graph stale | re-run `/understand` after big merges | +| Grok hallucinates file paths | context-pack only from real graph node ids | +| CI cost / token cost | weekly analyze; implement manual; compress context | +| Secret leak to Claude subprocess | existing env scrubbing; never commit `.env` | + +--- + +## 12. Definition of Done (pipeline) + +Pipeline считается **готовым**, когда: + +- [x] Structured backlog `REF-*` существует +- [x] Analyze/plan/select/validate работают локально +- [x] Agentic workflows-каркас на месте +- [ ] Grok client + review workflow +- [ ] Context packer из knowledge graph +- [ ] Atomic steps в tasks.json +- [ ] Secrets в GitHub +- [ ] Первый successful draft PR `REF-001` с green `check.yml` +- [ ] Human merge process задокументирован (этот файл) + +Pipeline считается **production-ready**, когда дополнительно: + +- [ ] ≥3 agentic PR прошли human merge без откатов +- [ ] High-risk gate (`agentic:approved`) работает +- [ ] Backlog statuses синхронизируются после merge + +--- + +## 13. Нужна ли доп. информация? + +Для бесшовного запуска implement в CI уточни (если есть): + +1. **`XAI_API_KEY`** уже есть в GitHub secrets форка? +2. Предпочтительный implementer: **Claude Code Action**, **Grok only**, или **оба**? +3. Можно ли пушить ветку `feat/agentic-pipeline` в `origin`? +4. Нужен ли **custom MCP server** сразу, или хватит CLI + workflows? + +Без ответов можно продолжать **локальный** цикл (фаза 3) полностью оффлайн от secrets, кроме Grok enrich. + +--- + +## Приложение A — Быстрый reference CLI + +```bash +python scripts/agentic/cli.py analyze +python scripts/agentic/cli.py plan [--task REF-001] +python scripts/agentic/cli.py select [--task REF-001] +python scripts/agentic/cli.py validate [--base-ref origin/main] [--skip-quality] +python scripts/agentic/cli.py sync-issues [--apply] +python scripts/agentic/cli.py run [--task REF-001] [--skip-quality] +python scripts/agentic/update_backlog_status.py REF-001 done +``` + +## Приложение B — Связанные документы + +| Документ | Путь | +|----------|------| +| Этот план | `D:\GROK\reports\ccbot-agentic-pipeline-plan.md` | +| Refactoring backlog | `D:\GROK\reports\ccbot-refactoring-backlog.md` | +| Pipeline README | `D:\CCbot_tmux\ccbot\ccbot_ju1ian\.agentic\README.md` | +| Knowledge graph | `D:\CCbot_tmux\ccbot\ccbot_ju1ian\.understand-anything\knowledge-graph.json` | +| AGENTS.md / CLAUDE.md | корень клона | diff --git a/.agentic/README.md b/.agentic/README.md new file mode 100644 index 000000000..468a6a60c --- /dev/null +++ b/.agentic/README.md @@ -0,0 +1,91 @@ +# Agentic pipeline for ccbot_ju1ian + +End-to-end automation from **knowledge graph + refactoring backlog** to **validated draft PRs**. + +## Stages + +```text +┌─────────────┐ ┌──────────┐ ┌─────────────┐ ┌────────────┐ +│ ANALYZE │ → │ PLAN │ → │ IMPLEMENT │ → │ VALIDATE │ +│ KG+hotspot │ │ rank/pick│ │ Claude/local│ │ ruff/py/… │ +└─────────────┘ └──────────┘ └─────────────┘ └─────┬──────┘ + ↓ + draft PR + review +``` + +| Stage | Local command | GitHub workflow | +|-------|---------------|-----------------| +| Analyze | `uv run python scripts/agentic/cli.py analyze` | `agentic-analyze.yml` | +| Plan | `uv run python scripts/agentic/cli.py plan` | `agentic-plan.yml` | +| Select + prompt | `uv run python scripts/agentic/cli.py select` | (part of orchestrator) | +| Implement | agent + `implement-prompt.md` | `agentic-implement.yml` | +| Validate | `uv run python scripts/agentic/cli.py validate` | called after implement | +| Sync issues | `uv run python scripts/agentic/cli.py sync-issues --dry-run` | optional in plan | +| Full run | `uv run python scripts/agentic/cli.py run` | `agentic-orchestrator.yml` | + +## Layout + +```text +.agentic/ + config.json # weights, guardrails, output paths + policies.json # hard must / must_not for agents + backlog/tasks.json # structured refactoring backlog + prompts/ # stage prompts + PR body template + mcp/README.md # custom MCP server docs + out/ # generated reports (gitignored artifacts OK) + README.md + +scripts/agentic/ # pure-stdlib Python toolkit (3.12+) + mcp_server.py # MCP stdio server (ccbot-agentic) + service.py # shared CLI + MCP logic + build_context_pack.py + +.grok/config.toml # project-scoped MCP registration for Grok +.github/workflows/ + agentic-analyze.yml + agentic-plan.yml + agentic-implement.yml + agentic-orchestrator.yml +``` + +## MCP (Grok) + +Server name: **`ccbot-agentic`** (tools: `ccbot-agentic__list_tasks`, …). + +```powershell +# trust project folder once, then: +# /mcps → enable ccbot-agentic +python scripts/agentic/mcp_smoke_test.py +``` + +See [mcp/README.md](mcp/README.md). + +## Secrets / permissions + +| Secret | Used by | +|--------|---------| +| `CLAUDE_CODE_OAUTH_TOKEN` | implement + review (existing Claude Code Action) | +| `GITHUB_TOKEN` | issues/PRs (default Actions token; grant `contents`/`pull-requests`/`issues`) | + +Optional: `AGENTIC_AUTO_IMPLEMENT=true` repository variable to allow orchestrator to chain into implement without manual `workflow_dispatch` confirmation. + +## Safety model + +- Implement stage opens **draft PRs** on `agentic/-…` branches — never commits to `main`. +- Path allow/deny lists in `config.json` → `guardrails`. +- Quality gates must pass (`ruff`, `pyright`, `pytest`) before the run is considered successful. +- Max concurrent agentic PRs controlled by `prioritization.max_open_agent_prs`. + +## Typical human loop + +1. Weekly analyze workflow uploads hotspot report. +2. Plan workflow refreshes ranking / optional GitHub issues. +3. You (or orchestrator) dispatch implement for `REF-00N`. +4. Claude Code Action applies the change, pushes branch, opens draft PR. +5. `check.yml` + `claude-code-review.yml` run on the PR. +6. Human merges after review. + +## Updating the backlog + +Edit `.agentic/backlog/tasks.json` (status: `ready` | `planned` | `in_progress` | `done` | `blocked`). +Re-run `analyze` + `plan` after knowledge-graph refresh (`.understand-anything/`). diff --git a/.agentic/backlog/source-report.md b/.agentic/backlog/source-report.md new file mode 100644 index 000000000..634ee6f5b --- /dev/null +++ b/.agentic/backlog/source-report.md @@ -0,0 +1,222 @@ +# CCBot Refactoring Backlog + +**Репозиторий:** https://github.com/six-ddc/ccbot +**Путь к коду:** `D:\CCbot_tmux\ccbot` +**Дата анализа:** 2026-07-11 +**Основа:** `.understand-anything/knowledge-graph.json` + прямой анализ исходного кода + +## Цель +Сформировать бэклог задач рефакторинга в формате GitHub Issues для разработчика, работающего с кодом напрямую. + +--- + +### [HIGH] Массовое дублирование логики авторизации и разрешения окна (DRY + SRP) + +**Файл(ы):** `src/ccbot/bot.py:182,200,219,253,283,308,410,450,491,552,569,643,813,1159` (и ~15+ других мест) + +**Критерий:** 2. Принцип DRY; 1. Ясность и читабельность; 3. SOLID (SRP) + +**Проблема:** +```python +user = update.effective_user +if not user or not is_user_allowed(user.id): + if update.message: + await safe_reply(update.message, "You are not authorized...") + return + +thread_id = _get_thread_id(update) +wid = session_manager.resolve_window_for_thread(user.id, thread_id) +if not wid: + await safe_reply(...) + return + +w = await tmux_manager.find_window_by_id(wid) +if not w: + ... + return +``` +Один и тот же паттерн (проверка пользователя + разрешение thread → wid + проверка существования окна) повторяется в `start_command`, `history_command`, `screenshot_command`, `esc_command`, `forward_command_handler`, `photo_handler`, `voice_handler`, `text_handler`, `callback_handler` и обработчиках закрытия. + +**Предлагаемое решение:** Вынести в декоратор/dependency (например, `require_session(update) -> tuple[user, wid]` или middleware) + отдельный `SessionResolver`. Это сократит код bot.py на сотни строк и устранит расхождения в обработке ошибок. + +**Метки:** priority:high, category:dr y, category:solid + +--- + +### [HIGH] bot.py — монолит (god module), нарушает SRP + +**Файл(ы):** `src/ccbot/bot.py:1-1730` (весь файл) + +**Критерий:** 3. SOLID (Single Responsibility Principle); 7. Масштабируемость; 1. Ясность + +**Проблема:** +Один файл содержит: все command handler'ы, callback routing (с кучей `if data.startswith(...)`), state machine для directory/window/session picker'ов (context.user_data), `_bash_capture_tasks`, обработку фото/voice/text, создание окон, скриншоты, forward команд, topic closed/edited. + +Функции `text_handler` и `callback_handler` — огромные. Знание о Telegram, tmux, session и UI смешано. + +Знание-граф показывает отдельные узлы для каждого handler'а (`handlers/message_queue.py`, `directory_browser.py` и т.д.), но orchestrating-логика сконцентрирована здесь. + +**Предлагаемое решение:** Разбить на `command_handlers.py`, `callback_router.py`, `message_handlers.py` + `BotApplicationBuilder`. Оставить в bot.py только регистрацию и lifecycle (`create_bot`, `post_init`). + +**Метки:** priority:high, category:solid, category:maintainability + +--- + +### [HIGH] Широкое использование `except Exception` (проглатывание ошибок) + +**Файл(ы):** +- `src/ccbot/bot.py:793-802,1729` +- `src/ccbot/handlers/message_queue.py:370,385,452,468,524,537,574,582` +- `src/ccbot/handlers/message_sender.py:75,140,161,190` +- `src/ccbot/tmux_manager.py:67,98,139,210,269...` +- `src/ccbot/handlers/interactive_ui.py:247,272` +- `src/ccbot/utils.py:44` (BaseException) + +**Критерий:** 4. Обработка ошибок; 6. Управление ресурсами + +**Проблема:** +```python +except Exception: + try: + await bot.edit_message_text(..., text=plain...) + except Exception: + pass # bot.py:801 +``` +Многие критические пути (редактирование сообщений, отправка, захват pane, статусы) просто логируют на debug или молча игнорируют. RetryAfter правильно перебрасывается, но остальные ошибки теряются. Это приводит к "тихим" потерям сообщений и сложной отладке. + +**Предлагаемое решение:** Ввести конкретные except'ы + `TelegramError`, `TmuxError`. Добавить structured logging + метрики падений. Для edit/send использовать более умный retry с exponential backoff. + +**Метки:** priority:high, category:error-handling + +--- + +### [HIGH] Сложная, хрупкая логика миграции и re-resolution stale ID (техдолг) + +**Файл(ы):** `src/ccbot/session.py:199-390` (`resolve_stale_ids`, `_migrate_old_format_map`, `_cleanup_stale_session_map_entries`, `override_session_map_entry`, `_mutate_session_map_locked` и др.) + +**Критерий:** 3. SOLID; 7. Масштабируемость; 14. Архитектурные решения (расхождение с knowledge-graph) + +**Проблема:** +Несмотря на "topic-only" архитектуру в `.claude/rules/topic-architecture.md` и knowledge-graph, код содержит сотни строк для миграции старого формата (window_name вместо `@id`), re-resolve после рестарта tmux, flock'ов на `session_map.json`, override'ов при `--resume`. + +Много источников истины (`window_states`, `session_map.json`, `thread_bindings`, `monitor_state.json`). Любая ошибка в миграции приводит к потерянным сообщениям или неправильным привязкам. + +**Предлагаемое решение:** +- Постепенно удалить поддержку старых форматов (breaking change в major версии). +- Упростить модель: одна authoritative структура + чёткий процесс восстановления. +- Добавить property-based тесты на миграции. + +**Метки:** priority:high, category:architecture, category:maintainability + +--- + +### [HIGH] Глобальное мутабельное состояние + риски race condition + +**Файл(ы):** +- `src/ccbot/handlers/message_queue.py:70-85` (`_message_queues`, `_queue_workers`, `_tool_msg_ids`, `_status_msg_info`, `_flood_until`) +- `src/ccbot/bot.py:720` (`_bash_capture_tasks`) +- `src/ccbot/session.py` (много dict'ов + `_save_state`) + +**Критерий:** 8. Конкурентность; 6. Управление ресурсами + +**Проблема:** +Модульные глобальные словари без строгой инкапсуляции. Worker'ы, polling (status_polling.py), capture tasks и handlers работают конкурентно. Есть `_queue_locks`, но не везде. `context.user_data` модифицируется напрямую в разных местах. При краше worker'а или отмене задачи состояние может остаться inconsistent (зависшие статусы, tool_msg_ids). + +**Предлагаемое решение:** +- Выделить классы `MessageQueueManager`, `StatusTracker`, `InteractiveState`. +- Использовать `asyncio.Lock` шире или `anyio` / контексты. +- Добавить graceful shutdown и тесты на гонки. + +**Метки:** priority:high, category:concurrency + +--- + +### [MEDIUM] Хрупкий парсинг callback_data через строки + +**Файл(ы):** `src/ccbot/bot.py:1178-1190` (и дальше в `callback_handler`) + +**Критерий:** 9. Валидация входных данных; 1. Ясность; 4. Обработка ошибок + +**Проблема:** +```python +rest = data[prefix_len:] +parts = rest.split(":") +... +offset_str = parts[0] +window_id = ":".join(parts[1:-2]) # window_id может содержать ":" +``` +Обработка нескольких форматов (старый/новый), ручной split, обрезка до 64 байт. Легко сломать при добавлении новых полей. + +**Предлагаемое решение:** Перейти на `pydantic` / `dataclasses` + сериализацию (или короткие base64/JSON с checksum). Использовать `callback_data.py` константы + typed builder'ы. + +**Метки:** priority:medium, category:validation, category:readability + +--- + +### [MEDIUM] Неоднозначные и сокращённые имена переменных + +**Файл(ы):** `src/ccbot/bot.py` (wid, tid, data, w, msg повсеместно), `src/ccbot/handlers/message_queue.py:340` (wid, tid, skey, _tkey), `src/ccbot/session.py` (ws, info, data) + +**Критерий:** 1. Ясность и читабельность + +**Проблема:** +`wid = session_manager.resolve...`, `data = query.data`, `tid`, `skey` и т.п. затрудняют чтение без контекста. В одном файле `data` означает callback data, JSON-данные, dict и т.д. + +**Предлагаемое решение:** Переименовать в `window_id`, `thread_id`, `callback_data`, `window_state`. Ввести type aliases / `NewType`. + +**Метки:** priority:medium, category:readability + +--- + +### [MEDIUM] Повторяющийся boilerplate разрешения окна + обработки "окно исчезло" + +**Файл(ы):** `src/ccbot/bot.py` (19+ вызовов `resolve_window_for_thread` + `find_window_by_id`, grep показал ~19 упоминаний в одном файле) + +**Критерий:** 2. DRY; 3. SOLID + +**Проблема:** +Одинаковый блок "получить wid → найти окно → если нет — unbind + ошибка" в десятках мест. При изменении логики (например, поведение при stale) нужно править везде. + +**Предлагаемое решение:** Метод `ensure_active_window(user_id, thread_id) -> Window | Error` в SessionManager/TmuxManager. Централизованная обработка. + +**Метки:** priority:medium, category:dr y + +--- + +### [MEDIUM] Дублирование fallback-логики Markdown/plain и обработки RetryAfter + +**Файл(ы):** `src/ccbot/handlers/message_sender.py:60-80,130-200` (send_with_fallback, safe_reply, safe_edit, safe_send) + аналогичная в `message_queue.py` + +**Критерий:** 2. DRY; 4. Обработка ошибок + +**Проблема:** +Одинаковый паттерн `try: formatted except: strip_sentinels + plain` повторяется в 4+ функциях + в очереди. + +**Предлагаемое решение:** Единый `MessageFormatter` или контекст-менеджер/декоратор для отправки. + +**Метки:** priority:medium, category:dr y, category:error-handling + +--- + +## Сводная таблица + +| Приоритет | Кол-во | Основные категории | +|-----------|--------|---------------------------------------------| +| **Critical** | 0 | — | +| **High** | 5 | DRY, SOLID (SRP), Error handling, Concurrency, Architecture | +| **Medium** | 4 | Readability, Validation, DRY, Maintainability | +| **Low** | 0 | — | + +--- + +## Дополнительные наблюдения + +- Хорошие практики: атомарные записи (`atomic_write_json`), scrubbing sensitive env vars, валидация UUID для resume. +- Соответствие структуре из knowledge-graph: handlers разделены хорошо. +- Рекомендации для дальнейшего улучшения: + - Централизованная обработка ошибок Telegram/tmux + - Property-based тесты для миграций состояний + - Уменьшить использование `context.user_data` как state machine + - Добавить типизацию callback data + +**Файл сгенерирован автоматически на основе глубокого анализа кода и архитектурного графа.** diff --git a/.agentic/backlog/tasks.json b/.agentic/backlog/tasks.json new file mode 100644 index 000000000..fef7db267 --- /dev/null +++ b/.agentic/backlog/tasks.json @@ -0,0 +1,1315 @@ +{ + "tasks": [ + { + "id": "REF-001", + "title": "Extract shared auth + session resolution helper", + "priority": "high", + "status": "done", + "categories": [ + "dry", + "solid" + ], + "files": [ + "src/ccbot/bot.py", + "src/ccbot/session.py" + ], + "related_nodes": [ + "file:src/ccbot/bot.py", + "file:src/ccbot/session.py" + ], + "depends_on": [], + "estimated_risk": "medium", + "acceptance_criteria": [ + "At least 8 handlers use the shared helper" + ] + }, + { + "id": "REF-002", + "title": "Split bot.py god module into handlers", + "priority": "high", + "status": "done", + "categories": [ + "solid", + "maintainability" + ], + "files": [ + "src/ccbot/bot.py" + ], + "related_nodes": [ + "file:src/ccbot/bot.py" + ], + "depends_on": [ + "REF-001" + ], + "estimated_risk": "high", + "acceptance_criteria": [ + "bot.py contains only registration and lifecycle" + ], + "status_updated_at": "2026-07-17T11:22:16.745757+00:00" + }, + { + "id": "REF-003", + "title": "Replace broad except Exception with specific errors", + "priority": "high", + "status": "done", + "categories": [ + "error-handling" + ], + "files": [ + "src/ccbot/bot.py", + "src/ccbot/handlers/message_queue.py", + "src/ccbot/handlers/message_sender.py", + "src/ccbot/tmux_manager.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/message_sender.py" + ], + "depends_on": [], + "estimated_risk": "medium", + "acceptance_criteria": [ + "No silent exception swallowing" + ], + "status_updated_at": "2026-07-18T09:02:54.406187+00:00" + }, + { + "id": "REF-004", + "title": "Simplify session migration and stale ID logic", + "priority": "high", + "status": "done", + "categories": [ + "architecture" + ], + "files": [ + "src/ccbot/session.py" + ], + "related_nodes": [ + "file:src/ccbot/session.py" + ], + "depends_on": [], + "estimated_risk": "high", + "acceptance_criteria": [ + "Remove legacy window_name support" + ], + "status_updated_at": "2026-07-18T10:07:41.319621+00:00" + }, + { + "id": "REF-005", + "title": "Encapsulate global mutable state", + "priority": "high", + "status": "done", + "categories": [ + "concurrency" + ], + "files": [ + "src/ccbot/handlers/message_queue.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/message_queue.py" + ], + "depends_on": [], + "estimated_risk": "high", + "acceptance_criteria": [ + "MessageQueueManager class added" + ], + "status_updated_at": "2026-07-18T10:44:01.212763+00:00", + "status_note": "Class shell closed; ISS-003 DONE (public get_queue/get_lock, no external _queues/_locks). ISS-010 still open for bash_capture_tasks." + }, + { + "id": "REF-006", + "title": "Migrate callback_data parsing to dataclasses", + "priority": "medium", + "status": "done", + "categories": [ + "validation" + ], + "files": [ + "src/ccbot/handlers/callback_data.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/callback_data.py" + ], + "depends_on": [ + "REF-002" + ], + "estimated_risk": "low", + "acceptance_criteria": [ + "String split replaced with typed dataclasses" + ], + "status_updated_at": "2026-07-17T15:54:36.021680+00:00", + "status_note": "Typed encode/parse dataclasses for callback_data; producers+router migrated; tests added" + }, + { + "id": "REF-007", + "title": "Rename ambiguous variables (wid/tid/skey)", + "priority": "medium", + "status": "DONE", + "categories": [ + "readability" + ], + "files": [ + "src/ccbot/tmux_manager.py", + "src/ccbot/binding_store.py", + "src/ccbot/session.py", + "src/ccbot/handlers/message_queue.py", + "src/ccbot/handlers/status_polling.py", + "src/ccbot/handlers/directory_browser.py", + "src/ccbot/handlers/notifications.py", + "src/ccbot/handlers/command_handlers.py", + "src/ccbot/handlers/message_handlers.py", + "tests/ccbot/test_tmux_manager.py", + "tests/ccbot/test_session_guard.py" + ], + "related_nodes": [ + "file:src/ccbot/tmux_manager.py", + "file:src/ccbot/binding_store.py", + "file:src/ccbot/session.py", + "file:src/ccbot/handlers/message_queue.py", + "file:src/ccbot/handlers/status_polling.py", + "file:src/ccbot/handlers/directory_browser.py", + "file:src/ccbot/handlers/notifications.py", + "file:src/ccbot/handlers/command_handlers.py", + "file:src/ccbot/handlers/message_handlers.py", + "file:tests/ccbot/test_tmux_manager.py", + "file:tests/ccbot/test_session_guard.py" + ], + "depends_on": [ + "REF-001" + ], + "estimated_risk": "low", + "acceptance_criteria": [ + "wid->window_id, tid->thread_id globally" + ], + "kind": "structural", + "evidence": { + "grep": [ + { + "pattern": "\\bwid\\b", + "path": "src", + "expect": "absent" + }, + { + "pattern": "\\btid\\b", + "path": "src", + "expect": "absent" + } + ] + }, + "subphases": { + "R7a": { + "status": "DONE", + "scope": "wid -> window_id globally (10 files, 67 hits)", + "pr": "#26", + "merged_commit": "f9209fa", + "merged_at": "2026-08-11T00:00:00Z", + "resolution": "wid -> window_id: 67 renamed lines +3 wraps (line-length 88), 11 files; evidence wid absent=OK (tid FAIL expected until R7b); suite 397; PR #26 merged f9209fa" + }, + "R7b": { + "status": "DONE", + "scope": "tid -> thread_id globally (3 files, 26 hits)", + "pr": "#27", + "merged_commit": "9533103", + "merged_at": "2026-08-11T00:00:00Z", + "resolution": "tid -> thread_id: 28 replacements, 3 files; 2 semantic traps (tid local vs live thread_id param) inlined not reassigned (enqueue_status_update, set_group_chat_id); wraps for line-length 88; evidence tid absent=OK; validate --task REF-007 full green; suite 397; PR #27 merged 9533103" + } + }, + "pr": "#27", + "merged_commit": "9533103", + "merged_at": "2026-08-11T00:00:00Z", + "status_updated_at": "2026-08-11T00:00:00+00:00", + "updated_at": "2026-08-11T00:00:00+00:00", + "resolution": "wid->window_id (R7a #26 f9209fa, 67 lines, 11 files) + tid->thread_id (R7b #27 9533103, 28 replacements, 3 files); skey = 0 hits (no-op); evidence-gated: validate --task REF-007 full green (wid/tid absent in src); suite 397 throughout; first structural tenant of ISS-013 evidence mechanism" + }, + { + "id": "REF-008", + "title": "Unify Markdown/plain fallback and RetryAfter handling", + "priority": "medium", + "status": "done", + "categories": [ + "dry" + ], + "files": [ + "src/ccbot/handlers/message_sender.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/message_sender.py" + ], + "depends_on": [ + "REF-003" + ], + "estimated_risk": "medium", + "acceptance_criteria": [ + "Single MessageFormatter used" + ], + "status_updated_at": "2026-07-18T09:48:47.555552+00:00" + }, + { + "id": "REF-009", + "title": "Fill test gaps for bot/tmux/queue hotspots", + "priority": "medium", + "status": "done", + "categories": [ + "testing" + ], + "files": [ + "tests/" + ], + "related_nodes": [], + "depends_on": [], + "estimated_risk": "low", + "acceptance_criteria": [ + "Coverage for uncovered hotspots" + ], + "status_updated_at": "2026-07-18T09:57:27.416343+00:00" + }, + { + "id": "ISS-001", + "title": "Migrate handlers onto require_session / require_user / require_bound_window_id", + "priority": "high", + "status": "DONE", + "categories": [ + "dry", + "dead-abstraction" + ], + "files": [ + "src/ccbot/handlers/command_handlers.py", + "src/ccbot/handlers/message_handlers.py", + "src/ccbot/session_guard.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/command_handlers.py", + "file:src/ccbot/handlers/message_handlers.py", + "file:src/ccbot/session_guard.py" + ], + "depends_on": [ + "ISS-002" + ], + "estimated_risk": "medium", + "problem": "session_guard.require_* exists and is tested but production handlers still hand-roll auth→thread→window ladder (~14+ sites). Dead abstraction / incomplete REF-001.", + "solution": "Rewrite command/message handlers to use require_user / require_bound_window_id / require_session + SessionContext; remove copy-paste ladder; keep UX strings stable or unify explicitly.", + "acceptance_criteria": [ + "rg require_session|require_bound_window_id|require_user in src/ccbot/handlers shows >= 8 production call sites", + "Old manual is_user_allowed(user.id) ladder removed or only compatible re-export path", + "Tests unauthorized / unbound thread / missing window green", + "UX strings stabilized or explicitly unified" + ], + "ref_note": "REF-001 reopen/follow-up", + "last_proposal_id": "ISS-001_20260728T183408Z", + "updated_at": "2026-07-28T18:34:08.762974+00:00", + "merged_commit": "a36b018", + "pr": "#12", + "status_updated_at": "2026-07-28T19:31:36Z", + "resolution": "handlers migrated to require_*; residual topic_closed/kill via require_session; PR #12 merged a36b018" + }, + { + "id": "ISS-002", + "title": "Eliminate dual source of truth: handlers/auth.py vs session_guard.py", + "priority": "high", + "status": "DONE", + "categories": [ + "dry", + "architecture" + ], + "files": [ + "src/ccbot/handlers/auth.py", + "src/ccbot/session_guard.py", + "src/ccbot/bot.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/auth.py", + "file:src/ccbot/session_guard.py", + "file:src/ccbot/bot.py" + ], + "depends_on": [], + "estimated_risk": "medium", + "problem": "is_user_allowed and get_thread_id are defined in both handlers/auth.py and session_guard.py; behavior can diverge (General topic, message vs callback).", + "solution": "One canonical module (prefer session_guard); other file thin re-export only; update bot.py aliases and all production imports.", + "acceptance_criteria": [ + "Single canonical def is_user_allowed / get_thread_id (or one re-export)", + "All production imports use one path", + "Auth/session resolution tests green", + "No divergent General-topic or callback vs message path behavior" + ], + "ref_note": "REF-001", + "status_updated_at": "2026-07-28T00:00:00+00:00", + "pr": "#10", + "merged_at": "2026-07-28T11:10:50Z", + "resolution": "canonical auth via session_guard; dual SoT removed; evidence in proposal ISS-002_20260727T175352Z; validate green; PR #10 merged" + }, + { + "id": "ISS-003", + "title": "Really encapsulate MessageQueueManager (finish REF-005)", + "priority": "high", + "status": "done", + "categories": [ + "encapsulation", + "concurrency" + ], + "files": [ + "src/ccbot/handlers/message_queue.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/message_queue.py" + ], + "depends_on": [], + "estimated_risk": "high", + "problem": "MessageQueueManager owns dicts but worker is free function; external queue_manager._queues/_locks; thin wrappers keep old global API. Theatrical encapsulation.", + "solution": "Move worker/merge/process onto class methods; forbid external private field access; shrink or remove module wrappers; optional injectable manager for tests.", + "acceptance_criteria": [ + "No external queue_manager._queues / _locks access outside class", + "Worker and process logic are methods of owner class", + "Queue/flood/status tests green", + "If injectable: test with two independent manager instances" + ], + "ref_note": "REF-005 reopen/follow-up", + "status_updated_at": "2026-07-29T00:00:00+00:00", + "resolution": "encapsulate _queues/_locks via public get_queue/get_lock; worker no longer touches private fields; queue tests green. Residual (worker as class methods, injectable dual-manager) optional under Phase 2, not reopening ISS-003." + }, + { + "id": "ISS-004", + "title": "Table-driven callback_router dispatch + single topic-guard", + "priority": "high", + "status": "DONE", + "categories": [ + "spaghetti", + "decomposition" + ], + "files": [ + "src/ccbot/handlers/callback_router.py", + "src/ccbot/handlers/callback_data.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/callback_router.py", + "file:src/ccbot/handlers/callback_data.py" + ], + "depends_on": [], + "estimated_risk": "high", + "problem": "Giant if/elif prefix routing; stale-topic guard copy-pasted ~10 times; ask-keys nearly identical branches. Wire-format must stay stable.", + "solution": "assert_same_topic helper; parse once; registry type/prefix→handler; ask-keys map-driven; no wire-format change without approve.", + "acceptance_criteria": [ + "Single assert_same_topic helper; no copy-paste pending_tid guards", + "Parse/dispatch registry exists", + "Callback wire-format parity via tests or manual matrix", + "Wire-format unchanged without explicit approve" + ], + "last_proposal_id": "ISS-004_20260808T102953Z", + "pr": "#17", + "merged_commit": "1773c43", + "merged_at": "2026-08-08T00:00:00Z", + "status_updated_at": "2026-08-09T00:00:00+00:00", + "updated_at": "2026-08-09T00:00:00+00:00", + "resolution": "Table-driven dispatch: _EXACT_ROUTES/_PREFIX_ROUTES/_match_route; single _check_same_topic guard + _clear_pending_thread; _ASK_ACTIONS table; wire format unchanged; parity matrix; PR #17 merged 1773c43" + }, + { + "id": "ISS-005", + "title": "Split god-object SessionManager into stores + facade", + "priority": "high", + "status": "DONE", + "categories": [ + "decomposition", + "encapsulation" + ], + "files": [ + "src/ccbot/session.py", + "src/ccbot/session_migration.py", + "src/ccbot/handlers/window_bind.py" + ], + "related_nodes": [ + "file:src/ccbot/session.py", + "file:src/ccbot/session_migration.py", + "file:src/ccbot/handlers/window_bind.py" + ], + "depends_on": [ + "ISS-009" + ], + "estimated_risk": "high", + "problem": "SessionManager (~723+ LOC) owns persist, bindings, window state, session_map, routing; public fields; external _save_state.", + "solution": "Split into BindingStore, WindowStateStore, SessionMapRepository + thin facade; keep session_migration pure; no state.json break without versioned migration.", + "acceptance_criteria": [ + "Clear store ownership (bindings / window state / session_map)", + "No external session_manager._save_state calls", + "session.py not on 1k LOC trajectory or split complete", + "Load/save/migration/binding/window_bind tests green" + ], + "ref_note": "beyond REF-004", + "subphases": { + "4a": { + "status": "DONE", + "pr": "#20", + "merged_commit": "d6bc24a", + "merged_at": "2026-08-10T00:00:00Z", + "resolution": "BindingStore owns thread_bindings + group_chat_ids; facade property delegates; state.json byte-for-byte; parity matrix 17/17 + edge cases; suite 383; PR #20 merged d6bc24a" + }, + "4b": { + "status": "DONE", + "pr": "#21", + "merged_commit": "4b43cef", + "merged_at": "2026-08-10T00:00:00Z", + "resolution": "WindowStateStore owns window_states + window_display_names; WindowState moved with re-export; facade property delegates; user_window_offsets residual at facade (gap resolved); state.json byte-for-byte; parity matrix 14/14 incl load_session_map risk node; suite 383; PR #21 merged 4b43cef" + }, + "4c": { + "status": "DONE", + "pr": "#22", + "merged_commit": "6164989", + "merged_at": "2026-08-10T00:00:00Z", + "resolution": "SessionMapRepository owns session_map.json file access + flock RMW (mutate_locked verbatim); dynamic config lookup; facade keeps orchestration and private delegates used by tests; load_session_map cross-store stays at facade; parity matrix 12/12; suite 383; PR #22 merged 6164989" + }, + "4d": { + "status": "DONE", + "pr": "#23", + "merged_commit": "b5638d6", + "merged_at": "2026-08-10T00:00:00Z", + "resolution": "TranscriptReader owns transcript JSONL reads; ClaudeSession re-exported; facade keeps window->session orchestration; session.py 857->649 LOC across epic; parity matrix 12/12 with real JSONL; suite 383; PR #23 merged b5638d6" + } + }, + "status_updated_at": "2026-08-10T00:00:00+00:00", + "updated_at": "2026-08-10T00:00:00+00:00", + "pr": "#23", + "merged_commit": "b5638d6", + "merged_at": "2026-08-10T00:00:00Z", + "resolution": "Split complete: BindingStore (4a #20 d6bc24a), WindowStateStore (4b #21 4b43cef, user_window_offsets residual at facade), SessionMapRepository (4c #22 6164989), TranscriptReader + thin facade (4d #23 b5638d6); session.py 857->649 LOC; state.json byte-for-byte; external _save_state = 0 (ISS-009); session_migration untouched pure; suite 383 green throughout" + }, + { + "id": "ISS-006", + "title": "Compose require_session from require_bound_window_id", + "priority": "medium", + "status": "DONE", + "categories": [ + "dry" + ], + "files": [ + "src/ccbot/session_guard.py" + ], + "related_nodes": [ + "file:src/ccbot/session_guard.py" + ], + "depends_on": [ + "ISS-002" + ], + "estimated_risk": "low", + "problem": "require_session and require_bound_window_id duplicate ~80% of body (auth→thread→resolve→reply).", + "solution": "require_session = require_bound_window_id + live window + optional unbind + SessionContext; unify error/reply paths; update unit tests.", + "acceptance_criteria": [ + "require_session uses require_bound_window_id or shared internal pipeline", + "No ~80% copy-paste between the two functions", + "session_guard unit tests green", + "Error/reply paths unified" + ], + "ref_note": "REF-001", + "last_proposal_id": "ISS-006_20260728T141904Z", + "proposal": "ISS-006_20260728T141904Z", + "pr": "#11", + "merged_commit": "ee8c0b2", + "merged_at": "2026-07-28T16:08:01Z", + "status_updated_at": "2026-07-28T16:08:01+00:00", + "updated_at": "2026-07-28T16:08:01+00:00", + "resolution": "require_session composes require_bound_window_id; unit tests green; PR #11 merged at ee8c0b2; evidence grep PASS" + }, + { + "id": "ISS-007", + "title": "Unify auth+window ladder in command handlers via SessionContext", + "priority": "medium", + "status": "DONE", + "categories": [ + "dry" + ], + "files": [ + "src/ccbot/handlers/command_handlers.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/command_handlers.py" + ], + "depends_on": [ + "ISS-001" + ], + "estimated_risk": "low", + "problem": "screenshot/esc/usage each repeat a ~15-line auth→thread→resolve→window ladder with minor error-string drift.", + "solution": "After require_session migration, commands only hold business logic; unify user-facing error strings.", + "acceptance_criteria": [ + "screenshot_command, esc_command, usage_command use require_session or shared helper", + "Manual 15-line ladder removed", + "User-facing error strings unified or documented", + "Command handler tests green" + ], + "ref_note": "May merge with ISS-001", + "status_updated_at": "2026-07-28T19:31:36Z", + "merged_commit": "a36b018", + "pr": "#12", + "resolution": "unified DEFAULT_* error strings in session_guard; screenshot/esc/usage on require_session; merged with ISS-001 PR #12" + }, + { + "id": "ISS-008", + "title": "Remove type: ignore on _send_kwargs — explicit typed contract", + "priority": "medium", + "status": "DONE", + "categories": [ + "types", + "boundary" + ], + "files": [ + "src/ccbot/handlers/message_queue.py", + "src/ccbot/handlers/message_sender.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/message_queue.py", + "file:src/ccbot/handlers/message_sender.py" + ], + "depends_on": [], + "estimated_risk": "medium", + "problem": "_send_kwargs returns dict and call sites use type: ignore[arg-type]; signature and send API disagree.", + "solution": "TypedDict / Unpack / explicit optional message_thread_id kwargs or overloads; 0 type: ignore on send path; pyright clean.", + "acceptance_criteria": [ + "0 # type: ignore on send kwargs path in message_queue.py", + "uv run pyright src/ccbot/handlers/message_queue.py clean", + "message_thread_id contract is explicit (TypedDict / optional kwarg / overload)" + ], + "last_proposal_id": "ISS-008_20260806T094220Z", + "pr": "#14", + "merged_commit": "1fff3af", + "merged_at": "2026-08-06T11:58:02Z", + "status_updated_at": "2026-08-06T14:26:48+00:00", + "updated_at": "2026-08-06T14:26:48+00:00", + "resolution": "explicit message_thread_id on send_with_fallback/send_photo/safe_send; _send_kwargs removed; 0 type: ignore on send path; PR #14 merged 1fff3af; validate gate deviation (WinError 5) closed post-factum via PR #15" + }, + { + "id": "ISS-009", + "title": "Forbid external session_manager._save_state — public domain API", + "priority": "medium", + "status": "DONE", + "categories": [ + "encapsulation", + "api" + ], + "files": [ + "src/ccbot/handlers/window_bind.py", + "src/ccbot/session.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/window_bind.py", + "file:src/ccbot/session.py" + ], + "depends_on": [], + "estimated_risk": "medium", + "problem": "window_bind mutates WindowState and calls private _save_state(); boundary leak before SessionManager split.", + "solution": "Public set_window_session(...) / persist(); window_bind uses only public API; _save_state stays private.", + "acceptance_criteria": [ + "rg _save_state in src/ccbot/handlers shows 0", + "window_bind uses only public domain API", + "Public method like set_window_session(...) or persist() exists", + "window_bind tests green" + ], + "ref_note": "Precondition for ISS-005", + "pr": "#19", + "merged_commit": "65907e5", + "merged_at": "2026-08-09T00:00:00Z", + "status_updated_at": "2026-08-09T00:00:00+00:00", + "updated_at": "2026-08-09T00:00:00+00:00", + "resolution": "Public set_window_session; window_bind only public API; external _save_state = 0; 3 new tests (38/38, suite 383); PR #19 merged 65907e5; precondition for ISS-005 done" + }, + { + "id": "ISS-010", + "title": "Encapsulate bash_capture_tasks global mutable dict", + "priority": "medium", + "status": "DONE", + "categories": [ + "concurrency", + "encapsulation" + ], + "files": [ + "src/ccbot/handlers/message_handlers.py", + "src/ccbot/handlers/capture_registry.py", + "src/ccbot/handlers/cleanup.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/message_handlers.py", + "file:src/ccbot/handlers/capture_registry.py", + "file:src/ccbot/handlers/cleanup.py" + ], + "depends_on": [ + "ISS-003" + ], + "estimated_risk": "medium", + "problem": "bash_capture_tasks is a process-wide module-level dict outside the queue manager (incomplete REF-005 scope).", + "solution": "Own via MessageQueueManager or CaptureTaskRegistry; cleanup hook on topic close; remove module-level global.", + "acceptance_criteria": [ + "No module-level bash_capture_tasks global dict", + "Owner with cleanup on topic close", + "Tests or manual matrix show no capture-task leak" + ], + "ref_note": "REF-005 scope", + "last_proposal_id": "ISS-010_20260806T152347Z", + "pr": "#16", + "merged_commit": "fc861f4", + "merged_at": "2026-08-06T15:37:17Z", + "status_updated_at": "2026-08-06T15:37:17+00:00", + "updated_at": "2026-08-06T15:37:17+00:00", + "resolution": "CaptureTaskRegistry + capture_tasks singleton; dual module-level dicts removed; clear_topic_state cancels capture task; 4 registry tests; PR #16 merged fc861f4; Phase 2 CLOSED" + }, + { + "id": "ISS-011", + "title": "Split callback_router into sub-handlers before 1k LOC", + "priority": "medium", + "status": "DONE", + "categories": [ + "decomposition", + "file-size" + ], + "files": [ + "src/ccbot/handlers/callback_router.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/callback_router.py" + ], + "depends_on": [ + "ISS-004" + ], + "estimated_risk": "medium", + "problem": "callback_router ~630 LOC and growing; mixes history, directory, pickers, screenshot keys, interactive UI.", + "solution": "After table-driven dispatch, extract callback_history / callback_directory / callback_pickers / callback_interactive; entry ~150–200 LOC.", + "acceptance_criteria": [ + "Entry router only auth, parse, dispatch, thin entry", + "Entry LOC roughly <= 150–200", + "Submodules exist for major feature groups", + "Callback behavior parity preserved" + ], + "pr": "#18", + "merged_commit": "4894eac", + "merged_at": "2026-08-09T00:00:00Z", + "status_updated_at": "2026-08-09T00:00:00+00:00", + "updated_at": "2026-08-09T00:00:00+00:00", + "resolution": "Split into 6 submodules; entry 768->141 LOC (auth+capture+dispatch only); guard in callback_topic_guard; handler bodies 1:1; parity matrix 60/60; wire format unchanged; PR #18 merged 4894eac" + }, + { + "id": "ISS-012", + "title": "Remove duplicate IMAGES_DIR initialization", + "priority": "low", + "status": "DONE", + "categories": [ + "cleanup" + ], + "files": [ + "src/ccbot/handlers/message_handlers.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/message_handlers.py" + ], + "depends_on": [], + "estimated_risk": "low", + "problem": "IMAGES_DIR = ccbot_dir()/images and mkdir appear twice in message_handlers.py (paste noise).", + "solution": "Single definition + mkdir, or lazy ensure_images_dir() helper.", + "acceptance_criteria": [ + "One IMAGES_DIR definition in message_handlers.py", + "One mkdir or lazy helper", + "Image save still works" + ], + "pr": "#24", + "merged_commit": "25966bc", + "merged_at": "2026-08-11T00:00:00Z", + "status_updated_at": "2026-08-11T00:00:00+00:00", + "updated_at": "2026-08-11T00:00:00+00:00", + "resolution": "Single IMAGES_DIR definition + single mkdir (paste noise removed); call site untouched; evidence rg: def=1, mkdir=1; suite 383 green; PR #24 merged 25966bc" + }, + { + "id": "ISS-013", + "title": "Harden agentic structural-task acceptance criteria", + "priority": "low", + "status": "DONE", + "categories": [ + "process", + "agentic" + ], + "files": [ + ".agentic/backlog/tasks.json", + "scripts/agentic/" + ], + "related_nodes": [], + "depends_on": [], + "estimated_risk": "low", + "problem": "Tasks can be marked done when a class/file exists without call-site migration or deleted old path (false-done / rubber-stamp).", + "solution": "Structural acceptance template: call sites, deleted old path, tests, grep proof; validate evidence patterns; block done without production proof.", + "acceptance_criteria": [ + "Structural task template requires call sites + deleted old path + tests + grep proof", + "validate can check evidence patterns", + "Demo where validate fails if old path remains", + "status done impossible without production call-site proof" + ], + "pr": "#25", + "merged_commit": "dfaf796", + "merged_at": "2026-08-11T00:00:00Z", + "status_updated_at": "2026-08-11T00:00:00+00:00", + "updated_at": "2026-08-11T00:00:00+00:00", + "resolution": "check_evidence (deleted_paths/call_sites/grep/tests) in validate_changes + validate --task; done-gate done_block_reasons blocks done without production proof; evidence_plan carries kind/evidence; README template section; tests/agentic 14 green (suite 397); PR #25 merged dfaf796" + }, + { + "id": "ISS-014", + "title": "Ignore .understand-anything trash/intermediate noise", + "priority": "low", + "status": "done", + "categories": [ + "repo-hygiene" + ], + "files": [ + ".gitignore" + ], + "related_nodes": [], + "depends_on": [], + "estimated_risk": "low", + "problem": "Large .understand-anything/.trash-* and intermediate dumps pollute git status and PR diffs.", + "solution": "Add gitignore rules for trash/intermediate; untrack already-committed trash dumps; keep canonical knowledge-graph.json and meta.json.", + "acceptance_criteria": [ + ".gitignore ignores .understand-anything/.trash-* and intermediate dumps", + "git status does not show untracked .understand-anything/.trash-* artifacts", + "Canonical knowledge-graph.json / meta.json remain available if needed" + ], + "status_updated_at": "2026-07-22T00:00:00+00:00", + "status_note": "gitignore rules added; trash+intermediate removed from index; canonical KG/meta kept tracked" + }, + { + "id": "ISS-015", + "title": "ruff format hygiene: 12 unformatted files outside src/tests", + "priority": "low", + "status": "DONE", + "categories": [ + "repo-hygiene" + ], + "files": [ + ".agentic/IMPLEMENTATION_PLAN.md", + "scripts/agentic/analyze_graph.py", + "scripts/agentic/cli.py", + "scripts/agentic/mcp_server.py", + "scripts/agentic/mcp_smoke_test.py", + "scripts/agentic/paths.py", + "scripts/agentic/prioritize.py", + "scripts/agentic/propose.py", + "scripts/agentic/render_prompt.py", + "scripts/agentic/service.py", + "scripts/agentic/sync_issues.py", + "scripts/agentic/validate_changes.py" + ], + "related_nodes": [], + "depends_on": [], + "estimated_risk": "low", + "problem": "ruff format --check on main flags 12 files as would-be-reformatted (.agentic/IMPLEMENTATION_PLAN.md parses as Python + 11 scripts/agentic modules); the robot does not lint itself. ruff check is clean (no lint noise).", + "solution": "Machine-generated diff only: run `ruff format` on main, capture `git diff HEAD` binary into .agentic/tmp, route through standard propose -> approve -> apply -> validate cycle; no hand edits.", + "acceptance_criteria": [ + "ruff format --check: 111 already formatted, 1 documented residual (propose.py EOF trailing newline -> debt)", + "ruff check: All checks passed", + "pytest suite 397 passed; pyright 0 errors", + "diff is format-only (no semantic edits); all paths within declared files list" + ], + "pr": "#28", + "merged_commit": "562c687", + "merged_at": "2026-08-12T00:00:00Z", + "status_updated_at": "2026-08-12T00:00:00+00:00", + "updated_at": "2026-08-12T00:00:00+00:00", + "resolution": "Machine ruff-format diff through full HITL cycle: 12 files +139/-42, zero hand edits. Key lesson: with core.autocrlf=true `git diff HEAD` emits LF-normalized diff that does not apply to CRLF worktree, and propose staging (read_text + text-mode temp write) rewrites any input to all-CRLF which breaks git-format headers (diff --git/index) on git 2.45.1.windows -> difflib-style diff (no git headers) is the working form. Residual: propose.py EOF trailing newline hunk blocked (staging mangles '\\ No newline' hunks) -> debt: binary-safe staging in propose.py, then land the newline. validate green (guardrails + ruff + pyright + pytest 397); ruff format --check: 111 formatted, 1 residual; ruff check clean; PR #28 merged 562c687" + }, + { + "id": "ISS-016", + "title": "Binary-safe diff staging in propose.py", + "priority": "low", + "status": "DONE", + "categories": [ + "agentic", + "process" + ], + "files": [ + "scripts/agentic/propose.py", + "tests/agentic/test_binary_staging.py" + ], + "related_nodes": [], + "depends_on": [], + "estimated_risk": "low", + "problem": "propose staging reads diff via read_text (universal newlines strips CR) and rewrites temp via text-mode NamedTemporaryFile (all CRLF on Windows): git-format headers (diff --git/index) break on git 2.45.1.windows, and '\\ No newline at end of file' hunks gain a spurious CR and never apply. Only difflib-style diffs without EOF hunks survive (ISS-015 workaround; propose.py trailing newline still unlanded).", + "solution": "Byte-exact diff path: read_bytes for --diff input and proposal.diff, binary temp staging (write_bytes / 'wb'), write proposal.diff as bytes; keep a decoded str only for parsing (numstat/paths/proposal.md). Add tests/agentic/test_binary_staging.py: staged temp bytes must equal input bytes (CRLF content + git headers + EOF marker).", + "acceptance_criteria": [ + "propose accepts git-format diff (diff --git/index headers) and EOF-marker hunks", + "staged temp diff is byte-identical to input for LF and CRLF inputs", + "tests/agentic binary-staging tests green; full suite 397+ green; pyright 0", + "follow-up unblocked: propose.py EOF trailing newline can be proposed next" + ], + "pr": "#29", + "merged_commit": "8a7b4bf", + "merged_at": "2026-08-12T00:00:00Z", + "status_updated_at": "2026-08-12T00:00:00+00:00", + "updated_at": "2026-08-12T00:00:00+00:00", + "resolution": "Bytes diff path: _write_temp_diff wb + byte-exact staging (no trailing-newline append); check_diff_applies/diff_numstat/validate_proposal_diff take bytes, decode only for parsing; create_proposal_artifacts diff_bytes + write_bytes for proposal.diff (apply reads artifact directly = byte-safe E2E); read_bytes for --diff/import/apply; tests/agentic/test_binary_staging.py 8 tests (staged==input: LF/CRLF/git headers/EOF marker/no trailing NL); suite 405; EOF trailing newline of propose.py intentionally left for ISS-017; PR #29 merged 8a7b4bf" + }, + { + "id": "ISS-017", + "title": "Land propose.py EOF trailing newline (ISS-015 residual)", + "priority": "low", + "status": "DONE", + "categories": [ + "repo-hygiene" + ], + "files": [ + "scripts/agentic/propose.py" + ], + "related_nodes": [], + "depends_on": [ + "ISS-016" + ], + "estimated_risk": "low", + "problem": "scripts/agentic/propose.py misses the trailing newline at EOF (ruff format flags it); the hunk was deferred from ISS-015 because staging mangled '\\ No newline at end of file' hunks. ISS-016 made staging byte-safe.", + "solution": "Machine diff: run `ruff format scripts/agentic/propose.py` on the branch, capture `git diff HEAD` binary, route through the standard pipeline; dogfoods the ISS-016 binary staging (EOF-marker hunk + git-format headers + LF diff).", + "acceptance_criteria": [ + "ruff format --check: 0 files would be reformatted (112 already formatted)", + "EOF-marker hunk applies through the new binary staging", + "suite 405 green; pyright 0; ruff check clean" + ], + "pr": "#30", + "merged_commit": "654d042", + "merged_at": "2026-08-12T00:00:00Z", + "status_updated_at": "2026-08-12T00:00:00+00:00", + "updated_at": "2026-08-12T00:00:00+00:00", + "resolution": "Single ruff-format hunk: EOF trailing newline in propose.py ('\\ No newline' marker). Raw `git diff HEAD` output passed propose with zero re-synthesis - dogfood of ISS-016 binary staging (git headers + LF + EOF-marker hunk all accepted). ruff format --check: 113 files already formatted, 0 would be reformatted; ruff check clean; suite 405; PR #30 merged 654d042; hygiene arc (ISS-015/016/017) fully closed" + }, + { + "id": "ISS-018", + "title": "Sort import blocks (ruff I001) across 12 files", + "priority": "low", + "status": "DONE", + "categories": [ + "repo-hygiene" + ], + "files": [ + "scripts/agentic/analyze_graph.py", + "scripts/agentic/cli.py", + "scripts/agentic/mcp_server.py", + "scripts/agentic/prioritize.py", + "src/ccbot/handlers/command_handlers.py", + "src/ccbot/handlers/directory_browser.py", + "src/ccbot/handlers/message_handlers.py", + "src/ccbot/handlers/status_polling.py", + "src/ccbot/session.py", + "src/ccbot/session_monitor.py", + "tests/agentic/test_binary_staging.py", + "tests/ccbot/handlers/test_interactive_ui.py" + ], + "related_nodes": [], + "depends_on": [], + "estimated_risk": "low", + "problem": "12 I001 findings (un-sorted/un-formatted import blocks) sit outside the ruff select gate (E4,E7,E9,F): 4 in scripts/agentic, 6 in src/ccbot, 2 in tests. Pre-existing debt ('I001 pre-existing' in handoff).", + "solution": "Machine fix: `ruff check --select I001 --fix`, then verify no E402 fallout (the cli.py fix splits `from propose import (...)` and the new block loses `# noqa: E402`; E402 is in the E4 gate) - restore noqa pointwise if needed; capture git diff binary; standard pipeline.", + "acceptance_criteria": [ + "ruff check --select I001: clean (0 findings)", + "ruff check (gate select E4,E7,E9,F): no new findings incl. E402 in scripts/agentic/cli.py", + "ruff format --check: 113 already formatted, 0 would be reformatted", + "suite 405 green; pyright 0", + "import reordering only; zero hand edits except possible noqa restore in cli.py" + ], + "pr": "#31", + "merged_commit": "5374113", + "merged_at": "2026-08-12T00:00:00Z", + "status_updated_at": "2026-08-12T00:00:00+00:00", + "updated_at": "2026-08-12T00:00:00+00:00", + "resolution": "Machine `ruff check --select I001 --fix` across 12 files (4 scripts/agentic, 6 src/ccbot, 2 tests) plus one manual touch: restored `# noqa: E402` in cli.py after the fixer split the propose import block (anticipated in acceptance criteria). Gates: ruff check clean (I001 = 0 findings), ruff format 113/113, pyright OK, suite 405, validate ok:true, guardrails 12/12 changed==considered. PR #31 merged 5374113." + }, + { + "id": "ISS-019", + "title": "Fix stale-task evict race in CaptureTaskRegistry.discard", + "priority": "medium", + "status": "DONE", + "categories": [ + "concurrency" + ], + "files": [ + "src/ccbot/handlers/capture_registry.py", + "src/ccbot/handlers/message_handlers.py", + "tests/ccbot/test_capture_registry.py" + ], + "related_nodes": [ + "file:src/ccbot/handlers/capture_registry.py", + "file:src/ccbot/handlers/message_handlers.py" + ], + "depends_on": [], + "estimated_risk": "medium", + "problem": "discard() pops whatever is registered for (user_id, thread_id) with no identity check. Race: cancel() pops+cancels stale task A, handler registers new task B for the same topic, then A's finally: discard() (message_handlers.py:256) evicts B from the registry - clear_topic_state (cleanup.py:39) can no longer cancel B, leaking a live capture watcher. Same window exists on natural 30s timeout vs concurrent register. Pre-existing race (ISS-010 residual debt).", + "solution": "Identity-checked discard: discard(user_id, thread_id, task=None) pops only when the registered entry IS the finishing task; self-cleanup call site passes asyncio.current_task(); regression test: stale discard does not evict newer registration; legacy discard without task keeps popping (backward compat).", + "acceptance_criteria": [ + "discard with mismatched task identity is a no-op", + "capture_bash_output finally passes its own task (asyncio.current_task())", + "regression test: cancel(A) -> register(B) -> discard(A) leaves B registered; discard(B) clears", + "existing capture-registry tests stay green; suite green; pyright 0" + ], + "pr": "#32", + "merged_commit": "f511920", + "merged_at": "2026-08-12T00:00:00Z", + "status_updated_at": "2026-08-12T00:00:00+00:00", + "updated_at": "2026-08-12T00:00:00+00:00", + "resolution": "Identity-checked discard: discard(user_id, thread_id, task=None) pops only when registered entry IS the finishing task; capture_bash_output finally passes asyncio.current_task(); +2 regression tests (stale-evict, matching-remove) appended to tests/ccbot/test_capture_registry.py (existing 4 untouched). Gates: ruff clean, pyright 0, suite green (6 capture-registry tests), validate ok. PR #32 merged f511920." + }, + { + "id": "ISS-020", + "title": "port upstream PR #86 allowed_updates + edit-guards", + "priority": "medium", + "status": "DONE", + "categories": [ + "upstream", + "telegram" + ], + "files": [ + "src/ccbot/main.py", + "src/ccbot/bot.py" + ], + "related_nodes": [ + "file:src/ccbot/main.py", + "file:src/ccbot/bot.py" + ], + "depends_on": [], + "estimated_risk": "low", + "problem": "Fork still polls with allowed_updates=[message, callback_query]. Upstream PR #86 (six-ddc/ccbot) showed Telegram silently dropping forum_topic_edited service messages on that minimal subscription (mid-May 2026); topic-rename -> tmux window rename never fires. Broadening the list restores delivery, but also admits edited_message / channel_post, which PTB MessageHandler matches by default — TEXT/PHOTO/VOICE/catch-all would re-process edits and channel posts as new user content.", + "solution": "Port upstream PR #86 allowed_updates (8 types) into main.py run_polling, plus a second hunk: exclude filters.UpdateType.EDITED and filters.UpdateType.CHANNEL_POST on the four content handlers in bot.py (TEXT, PHOTO, VOICE, catch-all). Topic closed/edited handlers stay unguarded so forum service messages still reach them.", + "acceptance_criteria": [ + "main.py содержит 8-элементный список allowed_updates (evidence: grep)", + "В bot.py у handlers TEXT/PHOTO/VOICE/catch-all присутствуют фильтры-исключения edited/channel_post (evidence: grep)", + "ruff check clean; ruff format --check = 113/113 (новые строки комментария PR #86 — до 88 символов, список уже в ruff-стиле; проверить фактом, не предполагать)", + "pyright = 0 errors", + "pytest suite = 407 passed" + ], + "pr": "#33", + "merged_commit": "3e06992", + "merged_at": "2026-08-14T00:00:00Z", + "status_updated_at": "2026-08-14T00:00:00+00:00", + "updated_at": "2026-08-14T00:00:00+00:00", + "resolution": "Ported upstream PR #86 allowed_updates (8 types) into main.py run_polling + edit-guards on TEXT/PHOTO/VOICE/catch-all (filters.UpdateType.EDITED and CHANNEL_POST). Topic closed/edited handlers unguarded. PR #33 merged 3e06992. Suite 407.", + "debt": [ + { + "title": "edit-guard for forward_command_handler (filters.COMMAND)", + "severity": "low", + "files": [ + "src/ccbot/bot.py" + ], + "notes": "ISS-020 guarded TEXT/PHOTO/VOICE/catch-all. MessageHandler(filters.COMMAND, forward_command_handler) still matches edited_message / channel_post by default." + }, + { + "title": "smoke ISS-020", + "status": "blocked", + "blocked": "требуется выделенный сервер с живой tmux-сессией", + "checklist": ".agentic/out/notes/2026-08-14-iss-020-smoke-checklist.md" + } + ], + "evidence": { + "grep": [ + { + "pattern": "edited_message", + "path": "src/ccbot/main.py", + "expect": "present" + }, + { + "pattern": "edited_channel_post", + "path": "src/ccbot/main.py", + "expect": "present" + }, + { + "pattern": "my_chat_member", + "path": "src/ccbot/main.py", + "expect": "present" + }, + { + "pattern": "chat_join_request", + "path": "src/ccbot/main.py", + "expect": "present" + }, + { + "pattern": "UpdateType\\.EDITED", + "path": "src/ccbot/bot.py", + "expect": "present" + }, + { + "pattern": "UpdateType\\.CHANNEL_POST", + "path": "src/ccbot/bot.py", + "expect": "present" + } + ] + } + }, + { + "id": "ISS-021", + "title": "port upstream PR #89 documents handler (require_session rewrite)", + "priority": "medium", + "status": "DONE", + "categories": [ + "upstream", + "telegram" + ], + "files": [ + "src/ccbot/handlers/document.py", + "src/ccbot/bot.py", + "src/ccbot/handlers/command_handlers.py", + "tests/ccbot/handlers/test_document.py" + ], + "related_nodes": [ + "file:src/ccbot/bot.py", + "file:src/ccbot/handlers/message_handlers.py", + "file:src/ccbot/session_guard.py" + ], + "depends_on": [], + "estimated_risk": "low", + "problem": "Upstream PR #89 adds document/file uploads (PDF etc.) in a bound topic: download to ~/.ccbot/documents/ and forward '(file attached: )' to the Claude session. The upstream handler still uses the old dual-path (is_user_allowed / _get_thread_id from bot.py, hand-rolled window lookup). Porting it as-is would reintroduce the ISS-001/002 dual SoT.", + "solution": "Port PR #89 behavior (document_handler + filters.Document.ALL registration + 20 MB cap + filename sanitize + caption) but rewrite auth/session onto require_user + require_session (same contract as photo_handler / voice_handler). No lazy import from bot.py.", + "acceptance_criteria": [ + "src/ccbot/handlers/document.py exists; bot.py registers MessageHandler(filters.Document.ALL, document_handler) (evidence: grep)", + "document_handler uses require_user + require_session; absent: is_user_allowed, _get_thread_id, lazy import from bot.py (evidence: grep)", + "files saved under the ccbot documents dir; optional caption preserved; path forwarded as (file attached: )", + "files above Telegram 20 MB bot-download cap rejected before getFile", + "unsupported-content UX mentions documents/files", + "ruff check clean; ruff format --check clean on touched files", + "pyright = 0 errors", + "pytest suite = 419 passed" + ], + "pr": "#34", + "merged_commit": "8120171", + "merged_at": "2026-08-14T00:00:00Z", + "status_updated_at": "2026-08-14T00:00:00+00:00", + "updated_at": "2026-08-14T00:00:00+00:00", + "resolution": "Ported upstream PR #89 document handler with require_session rewrite (no is_user_allowed / _get_thread_id). filters.Document.ALL + edit-guards; 20 MB cap; caption + (file attached: path); supported-types UX; +12 tests. PR #34 merged 8120171. Suite 419.", + "debt": [ + { + "title": "naming DOCS_DIR vs _MAX_DOC_BYTES", + "severity": "cosmetic", + "notes": "THERMO review: public DOCS_DIR vs leading-underscore _MAX_DOC_BYTES in document.py. Cosmetics only." + }, + { + "title": "import-time mkdir of cache dirs → lazy init", + "severity": "low", + "notes": "THERMO review candidate: DOCS_DIR.mkdir at import; same pattern as IMAGES_DIR. Lazy init would cover both." + }, + { + "title": "time.time() filename collision window → file_unique_id", + "severity": "low", + "notes": "THERMO review: document filenames use int(time.time()) prefix; collision window under concurrent uploads. Prefer Telegram file_unique_id." + } + ], + "evidence": { + "grep": [ + { + "pattern": "document_handler", + "path": "src/ccbot/bot.py", + "expect": "present" + }, + { + "pattern": "filters\\.Document\\.ALL", + "path": "src/ccbot/bot.py", + "expect": "present" + }, + { + "pattern": "require_session", + "path": "src/ccbot/handlers/document.py", + "expect": "present" + }, + { + "pattern": "is_user_allowed|_get_thread_id", + "path": "src/ccbot/handlers/document.py", + "expect": "absent" + } + ] + } + }, + { + "id": "ISS-022", + "title": "port upstream PR #53 local whisper backend + CCBOT_WHISPER_LANGUAGE (auto|en|ru)", + "priority": "medium", + "status": "DONE", + "categories": [ + "upstream", + "telegram" + ], + "files": [ + "src/ccbot/transcribe.py", + "src/ccbot/config.py", + "src/ccbot/handlers/message_handlers.py", + "tests/ccbot/test_transcribe.py", + ".env.example", + "pyproject.toml" + ], + "related_nodes": [ + "file:src/ccbot/transcribe.py", + "file:src/ccbot/config.py", + "file:src/ccbot/handlers/message_handlers.py" + ], + "depends_on": [], + "estimated_risk": "medium", + "problem": "Fork transcribes voice only via OpenAI (transcribe_voice + hardcoded gpt-4o-transcribe). Upstream PR #53 adds a local faster-whisper backend (CCBOT_WHISPER_BACKEND openai|local|off) but hardcodes English. This fork needs the port plus CCBOT_WHISPER_LANGUAGE=auto|en|ru (no language=\"en\" hardcode).", + "solution": "Port PR #53 dual backend (unified transcribe() entry, optional [voice] extra, env docs). Replace transcribe_voice call sites. Language from CCBOT_WHISPER_LANGUAGE for both local and openai paths. Matrix tests local/openai x auto|en|ru.", + "acceptance_criteria": [ + "transcribe_voice = 0 hits under src/ and tests/ (evidence: grep absent)", + "language=\"en\" хардкода нет (evidence: grep absent)", + "CCBOT_WHISPER_LANGUAGE accepts auto|en|ru; both local and openai backends honor it (языковая матрица)", + "unified transcribe() routes on CCBOT_WHISPER_BACKEND (openai|local|off)", + "pyproject [voice] extra for faster-whisper; .env.example documents backend/model/language", + "ruff check clean; ruff format --check clean on touched files", + "pyright = 0 errors", + "pytest suite = 436", + "voice_handler в message_handlers.py: файловое скачивание + finally: file_path.unlink(missing_ok=True); ветки TranscriptionDisabled (« Voice not available») / TranscriptionError («❌ Transcription failed») / generic Exception (logger.exception); каноничная require_user/require_session сохранена", + "config.py: whisper_language из CCBOT_WHISPER_LANGUAGE, значения auto|en|ru (дефолт auto); trim/lower; невалидное/пустое → logger.warning + fallback \"auto\" (паттерн whisper_backend)", + "гейты: ruff check clean; ruff format --check rc=0; pyright 0 (допустим # type: ignore[import-untyped] для faster_whisper с комментарием); validate ok:true, guardrails 12/12" + ], + "pr": "#35", + "merged_commit": "236b13c", + "merged_at": "2026-08-14T00:00:00Z", + "status_updated_at": "2026-08-14T00:00:00+00:00", + "updated_at": "2026-08-14T00:00:00+00:00", + "resolution": "Ported upstream PR #53 local whisper backend + CCBOT_WHISPER_LANGUAGE auto|en|ru. transcribe() SoT; transcribe_voice absent; no language=en hardcode; voice_handler file download + finally unlink; require_session canon. PR #35 merged 236b13c. Suite 436. THERMO 7/7.", + "evidence": { + "suite": 436, + "pr": "#35", + "thermo": "7/7", + "grep": [ + { + "pattern": "transcribe_voice", + "path": "src", + "expect": "absent" + }, + { + "pattern": "transcribe_voice", + "path": "tests", + "expect": "absent" + }, + { + "pattern": "language\\s*=\\s*[\"']en[\"']", + "path": "src/ccbot", + "expect": "absent" + }, + { + "pattern": "CCBOT_WHISPER_LANGUAGE|whisper_language", + "path": "src/ccbot/config.py", + "expect": "present" + } + ] + } + }, + { + "id": "ISS-023", + "title": "Lazy init of cache dirs (IMAGES_DIR/DOCS_DIR/_AUDIO_DIR) — no import-time mkdir", + "priority": "low", + "status": "DONE", + "categories": [ + "refactor", + "import-time-side-effects" + ], + "files": [ + "src/ccbot/utils.py", + "src/ccbot/handlers/message_handlers.py", + "src/ccbot/handlers/document.py", + "tests/ccbot/test_lazy_dirs.py" + ], + "related_nodes": [ + "file:src/ccbot/utils.py", + "file:src/ccbot/handlers/message_handlers.py", + "file:src/ccbot/handlers/document.py" + ], + "depends_on": [], + "estimated_risk": "low", + "kind": "structural", + "ref_note": "THERMO P2 debt from ISS-021/ISS-022", + "problem": "IMAGES_DIR/_AUDIO_DIR (message_handlers.py:52,55) and DOCS_DIR (document.py:33) run mkdir at module import time — import side effects; THERMO P2 debt. config.py:30 config_dir mkdir is out of scope (root dir is the config contract itself); hook.py/utils.py mkdirs are already lazy (inside functions).", + "solution": "Add ensure_dir(path) -> Path helper in utils.py next to ccbot_dir(); remove the three module-level mkdir calls; call ensure_dir() at the three save call sites (message_handlers photo/voice, document). Paths, flags (parents=True, exist_ok=True) and behavior unchanged.", + "acceptance_criteria": [ + "No module-level mkdir for IMAGES_DIR/_AUDIO_DIR/DOCS_DIR (evidence: grep absent in src)", + "ensure_dir helper exists in utils.py and is used at all three save call sites (evidence: grep present)", + "Saved paths unchanged (~/.ccbot/images, ~/.ccbot/audio, ~/.ccbot/documents)", + "New tests/ccbot/test_lazy_dirs.py: ensure_dir unit tests + reload no-side-effect regression; suite 436 -> 439", + "ruff check clean; ruff format --check clean on touched files; pyright 0 errors" + ], + "pr": "#36", + "merged_commit": "353ce24", + "merged_at": "2026-08-15T00:00:00Z", + "status_updated_at": "2026-08-15T00:00:00+00:00", + "updated_at": "2026-08-15T00:00:00+00:00", + "resolution": "ensure_dir(path)->Path helper in utils.py (next to ccbot_dir); 3 module-level mkdir removed (message_handlers IMAGES_DIR/_AUDIO_DIR, document DOCS_DIR); 3 save call sites via ensure_dir; paths/flags/behavior unchanged; +3 tests (tests/ccbot/test_lazy_dirs.py: ensure_dir unit x2 + reload no-side-effect regression via CCBOT_DIR env). Gates: ruff clean, format OK, pyright 0 (src), validate ok:true, guardrails 4/4, evidence greps 4/4; suite 436 -> 439. PR #36 merged 353ce24. Process incident: `uv run` regenerates stale uv.lock mid-command (hit propose and validate) -> workaround `uv run --no-sync` for all agentic CLI calls + git checkout uv.lock; permanent fix = backlog debt #2 (uv.lock regen chore).", + "evidence": { + "grep": [ + { + "pattern": "(IMAGES_DIR|_AUDIO_DIR|DOCS_DIR)\\.mkdir", + "path": "src", + "expect": "absent" + }, + { + "pattern": "def ensure_dir", + "path": "src/ccbot/utils.py", + "expect": "present" + }, + { + "pattern": "ensure_dir\\(IMAGES_DIR\\)|ensure_dir\\(_AUDIO_DIR\\)", + "path": "src/ccbot/handlers/message_handlers.py", + "expect": "present" + }, + { + "pattern": "ensure_dir\\(DOCS_DIR\\)", + "path": "src/ccbot/handlers/document.py", + "expect": "present" + } + ] + } + }, + { + "id": "RR-0", + "title": "Robot HITL skeleton: propose → approve → apply → validate", + "priority": "high", + "status": "DONE", + "categories": [ + "process", + "agentic", + "infrastructure" + ], + "files": [ + "scripts/agentic/", + ".agentic/" + ], + "related_nodes": [], + "depends_on": [], + "estimated_risk": "medium", + "problem": "Repair robot needs a non-silent HITL loop so AI never applies patches without human approve.", + "solution": "Implement propose (artifacts only), reject (reason + clean tree), approve (base_sha gated), apply only after approved, evidence checker, draft PR after validate.", + "acceptance_criteria": [ + "propose creates artifacts and does not modify working tree", + "reject leaves tree clean and stores reason", + "approve without matching base_sha is blocked", + "apply works only after decision=approved", + "evidence checker fails if old path remains", + "draft PR only after validate" + ], + "pr": "#25", + "merged_commit": "dfaf796", + "merged_at": "2026-08-11T00:00:00Z", + "status_updated_at": "2026-08-11T00:00:00+00:00", + "updated_at": "2026-08-11T00:00:00+00:00", + "resolution": "HITL skeleton in production use since Phase 3 (cycles ISS-004 #17 ... ISS-013 #25): propose no-tree-change, approve base_sha freshness, apply approval+feature-branch guards, validate guardrails+quality, draft PR only after green; evidence checker (last open criterion) delivered by ISS-013 (PR #25 dfaf796)", + "ref_note": "Infrastructure task for repair robot; not a product ISS from REFACTORING_BACKLOG_ISSUES.md" + } + ], + "updated_at": "2026-08-12T00:00:00Z", + "phases": { + "Phase 1": { + "name": "Canonical auth + REF-001 done-for-real", + "status": "CLOSED", + "closed_at": "2026-07-28", + "tasks": [ + "ISS-002", + "ISS-006", + "ISS-001", + "ISS-007" + ], + "merged_commits": { + "ISS-002": "PR #10", + "ISS-006": "ee8c0b2", + "ISS-001": "a36b018", + "ISS-007": "a36b018 (with ISS-001)" + } + }, + "Phase 4": { + "name": "SessionManager split god-object into stores + facade", + "status": "CLOSED", + "closed_at": "2026-08-10", + "tasks": [ + "ISS-009", + "ISS-005" + ], + "merged_commits": { + "ISS-009": "PR #19 65907e5", + "ISS-005": "PRs #20-#23 (d6bc24a, 4b43cef, 6164989, b5638d6)" + } + } + } +} diff --git a/.agentic/config.json b/.agentic/config.json new file mode 100644 index 000000000..60ed00755 --- /dev/null +++ b/.agentic/config.json @@ -0,0 +1,118 @@ +{ + "$schema": "./schema/config.schema.json", + "name": "ccbot-agentic-pipeline", + "version": "1.0.0", + "repository": "ju1ian0701/ccbot_ju1ian", + "default_branch": "main", + "knowledge_graph": { + "path": ".understand-anything/knowledge-graph.json", + "meta_path": ".understand-anything/meta.json", + "scan_path": ".understand-anything/intermediate/scan-result.json" + }, + "backlog": { + "path": ".agentic/backlog/tasks.json" + }, + "outputs": { + "dir": ".agentic/out", + "analysis_report": "analysis-report.json", + "analysis_markdown": "analysis-report.md", + "plan": "plan.json", + "plan_markdown": "plan.md", + "selected_task": "selected-task.json", + "implement_prompt": "implement-prompt.md", + "validation": "validation.json" + }, + "prioritization": { + "priority_weights": { + "critical": 100, + "high": 70, + "medium": 40, + "low": 15 + }, + "graph_signals": { + "complex_file_bonus": 15, + "untested_file_bonus": 12, + "high_fan_in_bonus": 10, + "high_fan_out_bonus": 8, + "orphan_edge_penalty": 0, + "hotspot_threshold_edges": 20 + }, + "max_open_agent_prs": 2, + "prefer_ready_status": ["ready", "planned"] + }, + "implementation": { + "branch_prefix": "agentic/", + "commit_prefix": "refactor(agentic):", + "draft_pr": true, + "labels": ["agentic", "refactor", "automated"], + "max_files_changed": 40, + "require_tests": true, + "require_ruff": true, + "require_pyright": true + }, + "guardrails": { + "allowed_path_globs": [ + "src/ccbot/**/*.py", + "tests/**", + "tests/**/*.py", + "doc/**/*.md", + "README*.md", + "AGENTS.md", + "CLAUDE.md", + ".agentic/**", + "scripts/agentic/**", + "scripts/**", + ".github/workflows/agentic-*.yml", + ".github/workflows/agentic-*.yaml", + ".grok/**", + ".gitignore", + "pyproject.toml", + "pytest.ini", + "pyrightconfig.json", + "conftest.py", + "scripts/typecheck.py", + "scripts/run_tests.py", + "tests/conftest.py" + ], + "ignored_path_globs": [ + ".understand-anything/**", + ".agentic/out/**", + ".agentic/state/**", + ".venv/**", + "uv.lock", + "*.egg-info/**" + ], + "blocked_path_globs": [ + ".env", + ".env.*", + "**/*secret*", + "**/*credential*", + "**/session_map.json", + "**/monitor_state.json", + "**/state.json", + ".github/workflows/check.yml" + ], + "blocked_commands": [ + "rm -rf /", + "git push --force", + "git push -f", + "gh secret", + "curl *| sh", + "wget *| sh" + ], + "max_loc_delta": 2000 + }, + "github": { + "issue_label_map": { + "high": ["priority:high", "agentic"], + "medium": ["priority:medium", "agentic"], + "low": ["priority:low", "agentic"] + }, + "pr_title_template": "[agentic] {task_id}: {title}", + "pr_body_template_path": ".agentic/prompts/pr-body.md" + }, + "triggers": { + "analyze_schedule": "0 6 * * 1", + "orchestrator_manual_only_default": true + } +} diff --git a/.agentic/mcp/README.md b/.agentic/mcp/README.md new file mode 100644 index 000000000..90c818736 --- /dev/null +++ b/.agentic/mcp/README.md @@ -0,0 +1,61 @@ +# MCP server: `ccbot-agentic` + +Stdio MCP server that exposes the agentic pipeline (knowledge graph + refactoring backlog) to **Grok Build** and other MCP hosts. + +## Tools + +| Tool | Description | +|------|-------------| +| `pipeline_status` | Graph/backlog presence, artifacts, selected task | +| `list_tasks` | Backlog with ranking scores | +| `get_task` | Full task JSON (`REF-001` …) | +| `select_next_task` | Analyze → plan → select → render prompts + context pack | +| `get_analysis` | Slim hotspot analysis | +| `get_context_pack` | Task-scoped subgraph from `knowledge-graph.json` | +| `get_implement_prompt` | Full implement prompt for coding agent | +| `mark_task_status` | Update task status in `tasks.json` | +| `run_analyze` | Recompute analysis | +| `run_validate` | Guardrails (+ optional quality gates) | + +Qualified names in Grok: `ccbot-agentic__list_tasks`, etc. + +## Configuration + +Project file (committed): + +```toml +# .grok/config.toml +[mcp_servers.ccbot-agentic] +command = "python" +args = ["scripts/agentic/mcp_server.py"] +env = { CCBOT_REPO_ROOT = "D:\\CCbot_tmux\\ccbot\\ccbot_ju1ian" } +enabled = true +``` + +After cloning on another machine, update `CCBOT_REPO_ROOT` or run MCP from the repo root without that env (server uses `find_repo_root()`). + +### Enable in Grok + +1. Open the project folder (or set cwd to the clone). +2. Trust the folder: `/hooks-trust` or `grok --trust` (required for project MCP). +3. `/mcps` → confirm `ccbot-agentic` is enabled (refresh with `r` if needed). +4. Or: `grok mcp doctor ccbot-agentic` + +### Manual smoke test + +```powershell +cd D:\CCbot_tmux\ccbot\ccbot_ju1ian +$env:CCBOT_REPO_ROOT = (Get-Location).Path +python scripts/agentic/mcp_smoke_test.py +``` + +## Implementation + +| File | Role | +|------|------| +| `scripts/agentic/mcp_server.py` | JSON-RPC MCP over stdio | +| `scripts/agentic/service.py` | Shared logic for CLI + MCP | +| `scripts/agentic/build_context_pack.py` | Subgraph slice | +| `.grok/config.toml` | Project MCP registration | + +No third-party MCP SDK required (stdlib only). diff --git a/.agentic/out/proposals/ISS-002_20260727T175352Z/evidence.txt b/.agentic/out/proposals/ISS-002_20260727T175352Z/evidence.txt new file mode 100644 index 000000000..f989e7f88 --- /dev/null +++ b/.agentic/out/proposals/ISS-002_20260727T175352Z/evidence.txt @@ -0,0 +1,49 @@ +# ISS-002 Evidence + +## Date: 2026-07-27T18:54:09.8092385Z +## Branch: agentic/ISS-002-canonical-auth +## Commit: 5fa790d881c5d241e88ad035018a7d94c8706c5c (5fa790d) +## Product commit: 90e0cdc refactor(agentic): ISS-002 single SoT auth via session_guard +## Proposal: ISS-002_20260727T175352Z (post-factum applied) + +### Single Source of Truth verification + +#### One canonical definition — is_user_allowed +``` +src/ccbot\config.py:125: def is_user_allowed(self, user_id: int) -> bool: +src/ccbot\session_guard.py:43:def is_user_allowed(user_id: int | None) -> bool: +``` + +#### One canonical definition — get_thread_id +``` +src/ccbot\session_guard.py:48:def get_thread_id(update: Update) -> int | None: +``` + +#### Production imports use session_guard +``` +src/ccbot\bot.py:47:from .session_guard import get_thread_id, is_user_allowed +src/ccbot\handlers\auth.py:10:from ..session_guard import get_thread_id, is_user_allowed +src/ccbot\handlers\callback_router.py:20:from ..session_guard import get_thread_id, is_user_allowed +src/ccbot\handlers\command_handlers.py:23:from ..session_guard import get_thread_id, is_user_allowed +src/ccbot\handlers\message_handlers.py:28:from ..session_guard import get_thread_id, is_user_allowed +``` + +#### No dual definition in handlers/auth.py +``` +(none — re-export only) +``` + +### Quality gates + +| Gate | Result | +|------|--------| +| ruff check | PASS | +| ruff format --check | PASS | +| pyright src/ccbot | PASS | +| pytest tests/ | PASS (373) | +| agentic validate --base-ref origin/main | PASS | + +### Notes + +- apply skipped post-factum (patch already in tree via 90e0cdc) +- see .agentic/out/notes/process-deviation-ISS-002-apply-skipped.md diff --git a/.agentic/policies.json b/.agentic/policies.json new file mode 100644 index 000000000..0b62f453b --- /dev/null +++ b/.agentic/policies.json @@ -0,0 +1,36 @@ +{ + "description": "Hard guardrails for agentic implementers (Claude Code Action / local agents).", + "must": [ + "Respect AGENTS.md and CLAUDE.md core design constraints (topic-only, 1 topic = 1 window = 1 session).", + "Keep public behavior stable unless the task explicitly allows a breaking change.", + "Run ruff check, ruff format --check, pyright, and pytest before finishing.", + "Add or update unit tests for non-trivial logic changes.", + "Prefer small, reviewable diffs over large multi-concern rewrites.", + "Use module-level docstrings on every new .py file.", + "Preserve atomic_write_json and sensitive-env scrubbing patterns.", + "Never commit secrets, tokens, or real ~/.ccbot state files." + ], + "must_not": [ + "Reintroduce non-topic / General-topic routing modes.", + "Key internal routing by window name instead of tmux window id (@N).", + "Truncate messages at parse layer (splitting only at send layer).", + "Disable or weaken auth checks (is_user_allowed).", + "Force-push to main or rewrite protected history.", + "Modify CI quality gates to skip failing checks.", + "Install arbitrary network-fetched scripts.", + "Change bot token handling or env scrubbing in a less safe direction." + ], + "scope_rules": { + "single_task": true, + "one_pr_per_task": true, + "split_if_exceeds_max_files": true, + "prefer_extract_over_rewrite": true + }, + "review_checklist": [ + "Does the change match the selected task id and acceptance criteria?", + "Are new helpers pure / testable where practical?", + "Are exception handlers specific (not bare except Exception: pass)?", + "Do imports stay within allowed architectural layers?", + "Is MarkdownV2 still sent only via safe_* helpers for user-facing paths?" + ] +} diff --git a/.agentic/prompts/analyze.md b/.agentic/prompts/analyze.md new file mode 100644 index 000000000..50749e7f0 --- /dev/null +++ b/.agentic/prompts/analyze.md @@ -0,0 +1,28 @@ +# Stage: ANALYZE + +You are analyzing the **ccbot** codebase using the knowledge graph and source tree. + +## Goals + +1. Summarize architecture layers and critical dependency paths. +2. Identify hotspots: high complexity, high fan-in/fan-out, untested files. +3. Cross-check `.agentic/backlog/tasks.json` against current code reality. +4. Emit a structured analysis report (do not modify product code in this stage). + +## Inputs + +- `.understand-anything/knowledge-graph.json` +- `.agentic/backlog/tasks.json` +- `.agentic/config.json` +- `AGENTS.md`, `CLAUDE.md`, `.claude/rules/*` + +## Outputs + +- `.agentic/out/analysis-report.json` +- `.agentic/out/analysis-report.md` + +## Rules + +- Read-only for `src/` and `tests/` unless fixing analysis tooling itself. +- Prefer evidence from the graph + file sizes over speculation. +- Flag backlog tasks that appear already partially done. diff --git a/.agentic/prompts/implement.md b/.agentic/prompts/implement.md new file mode 100644 index 000000000..b64be4c4d --- /dev/null +++ b/.agentic/prompts/implement.md @@ -0,0 +1,47 @@ +# Stage: IMPLEMENT + +You implement **exactly one** refactoring task for the ccbot repository. + +## Mandatory context + +Read first: + +1. `.agentic/out/selected-task.json` (or the task JSON embedded below) +2. `.agentic/policies.json` +3. `AGENTS.md` and `CLAUDE.md` +4. Files listed in the task `files` array + +## Task + +{{TASK_JSON}} + +## Implementation protocol + +1. **Explore** — confirm the problem still exists; note partial fixes. +2. **Design minimally** — smallest change that meets acceptance criteria. +3. **Implement** — only within allowed path globs from `.agentic/config.json`. +4. **Test** — add/update tests; run: + ```bash + uv sync --all-extras + uv run ruff check src/ tests/ + uv run ruff format src/ tests/ + uv run pyright src/ccbot/ + uv run pytest --tb=short -q + ``` +5. **Self-review** against policies `review_checklist`. +6. **Commit** on branch `agentic/{{TASK_ID}}-short-slug` with message: + `refactor(agentic): {{TASK_ID}} ` +7. **Open draft PR** using the project PR template prompt if `gh` is available. + +## Hard constraints + +- Do **not** implement multiple backlog tasks in one PR. +- Do **not** force-push or alter `main` directly. +- Do **not** weaken auth, topic-only architecture, or CI gates. +- If blocked, write `.agentic/out/blocked.md` explaining why and stop. + +## Done definition + +- Acceptance criteria checked off in the PR body +- All quality gates green +- Diff limited to the task scope (split if too large) diff --git a/.agentic/prompts/plan.md b/.agentic/prompts/plan.md new file mode 100644 index 000000000..ebf82866d --- /dev/null +++ b/.agentic/prompts/plan.md @@ -0,0 +1,31 @@ +# Stage: PLAN + +You turn analysis + backlog into an executable plan for agentic implementation. + +## Goals + +1. Rank ready tasks using priority, dependencies, and graph hotspots. +2. Select **one** next task (or a small coherent set if explicitly allowed). +3. Expand acceptance criteria into a concrete implementation checklist. +4. Produce GitHub issue body / PR plan notes. + +## Inputs + +- `.agentic/out/analysis-report.json` +- `.agentic/backlog/tasks.json` +- `.agentic/policies.json` +- Optional: open PRs / issues list (from `gh`) + +## Outputs + +- `.agentic/out/plan.json` +- `.agentic/out/plan.md` +- `.agentic/out/selected-task.json` + +## Selection rules + +- Never pick a task whose `depends_on` are incomplete. +- Prefer `status: ready` over `planned`. +- Prefer high priority + high graph signal. +- Skip tasks already covered by open agentic PRs (`branch_prefix` / labels). +- Default: **one task per implementation run**. diff --git a/.agentic/prompts/pr-body.md b/.agentic/prompts/pr-body.md new file mode 100644 index 000000000..81d386413 --- /dev/null +++ b/.agentic/prompts/pr-body.md @@ -0,0 +1,35 @@ +## Agentic refactor + +| Field | Value | +|-------|-------| +| Task | `{{TASK_ID}}` | +| Priority | {{PRIORITY}} | +| Pipeline | analyze → plan → implement | +| Graph commit | {{GRAPH_COMMIT}} | + +### Problem + +{{PROBLEM}} + +### Solution + +{{SOLUTION}} + +### Acceptance criteria + +{{ACCEPTANCE_CHECKLIST}} + +### Validation + +- [ ] `ruff check` / `ruff format --check` +- [ ] `pyright` +- [ ] `pytest` +- [ ] Manual reasoning: topic-only + auth invariants preserved + +### Risk notes + +{{RISK}} + +### Agent notes + +{{AGENT_NOTES}} diff --git a/.agentic/prompts/review.md b/.agentic/prompts/review.md new file mode 100644 index 000000000..5d882394b --- /dev/null +++ b/.agentic/prompts/review.md @@ -0,0 +1,24 @@ +# Stage: REVIEW (agentic PR) + +Review this pull request as a senior Python engineer familiar with Telegram bots, asyncio, and tmux session bridging. + +## Focus + +1. Correctness vs the claimed task id and acceptance criteria +2. Architecture constraints from AGENTS.md / CLAUDE.md +3. Concurrency and error-handling regressions +4. Test adequacy +5. Scope creep beyond the single task + +## Output format + +- **Summary** (2–4 sentences) +- **Must-fix** (blocking) +- **Should-fix** (non-blocking) +- **Nits** +- **Verdict**: `approve` | `request_changes` | `comment` + +## Do not + +- Rewrite the PR yourself in review-only mode +- Approve if quality gates fail or auth/topic constraints are violated diff --git a/.env.example b/.env.example index d36e2b0ed..b0366a34b 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,19 @@ CLAUDE_COMMAND=claude # Monitor polling interval in seconds (optional, defaults to 2.0) MONITOR_POLL_INTERVAL=2.0 + +# Voice transcription backend (optional, defaults to "openai") +# "openai" = OpenAI API (requires OPENAI_API_KEY) +# "local" = faster-whisper on CPU (free, requires ffmpeg + uv pip install -e ".[voice]") +# "off" = disable voice messages +CCBOT_WHISPER_BACKEND=openai + +# Whisper model size for local backend (optional, defaults to "small") +# Options: tiny, base, small, medium, large-v3 +CCBOT_WHISPER_MODEL=small + +# Transcription language for local and openai backends (optional, defaults to "auto") +# "auto" = detect (omit language on OpenAI POST; faster-whisper language=None) +# "en" / "ru" = force that language on both backends +# Invalid or empty values log a warning and fall back to "auto" +CCBOT_WHISPER_LANGUAGE=auto diff --git a/.github/workflows/agentic-analyze.yml b/.github/workflows/agentic-analyze.yml new file mode 100644 index 000000000..587bc8ea3 --- /dev/null +++ b/.github/workflows/agentic-analyze.yml @@ -0,0 +1,62 @@ +name: Agentic Analyze + +on: + schedule: + # Mondays 06:00 UTC — matches .agentic/config.json triggers.analyze_schedule + - cron: "0 6 * * 1" + workflow_dispatch: + push: + paths: + - ".understand-anything/knowledge-graph.json" + - ".agentic/backlog/tasks.json" + - "scripts/agentic/**" + - ".agentic/config.json" + +permissions: + contents: read + +jobs: + analyze: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Analyze knowledge graph + run: python scripts/agentic/cli.py analyze + + - name: Rank backlog (plan only) + run: python scripts/agentic/cli.py plan + + - name: Upload analysis artifacts + uses: actions/upload-artifact@v4 + with: + name: agentic-analysis + path: | + .agentic/out/analysis-report.json + .agentic/out/analysis-report.md + .agentic/out/plan.json + .agentic/out/plan.md + .agentic/out/selected-task.json + if-no-files-found: error + + - name: Job summary + if: always() + run: | + { + echo "## Agentic analysis" + echo "" + if [ -f .agentic/out/analysis-report.md ]; then + cat .agentic/out/analysis-report.md + fi + echo "" + if [ -f .agentic/out/plan.md ]; then + echo "---" + cat .agentic/out/plan.md + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/agentic-implement.yml b/.github/workflows/agentic-implement.yml new file mode 100644 index 000000000..fff755191 --- /dev/null +++ b/.github/workflows/agentic-implement.yml @@ -0,0 +1,219 @@ +name: Agentic Implement + +# Dispatches Claude Code Action to implement one backlog task on a feature branch +# and open a draft PR. Never pushes to main. + +on: + workflow_dispatch: + inputs: + task_id: + description: "Task id to implement (e.g. REF-001). Empty = auto-select highest score." + required: false + type: string + dry_run: + description: "If true, only analyze/plan/render prompts — do not call Claude" + required: false + type: boolean + default: false + workflow_call: + inputs: + task_id: + description: "Task id to implement" + required: false + type: string + default: "" + dry_run: + description: "Prepare only" + required: false + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + +concurrency: + group: agentic-implement-${{ github.repository }} + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + task_id: ${{ steps.meta.outputs.task_id }} + branch: ${{ steps.meta.outputs.branch }} + has_task: ${{ steps.meta.outputs.has_task }} + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Analyze, select, render prompts + env: + TASK_ID: ${{ inputs.task_id }} + run: | + python scripts/agentic/cli.py analyze + if [ -n "$TASK_ID" ]; then + python scripts/agentic/cli.py select --task "$TASK_ID" + else + python scripts/agentic/cli.py select + fi + + - name: Export task metadata + id: meta + run: | + if [ ! -f .agentic/out/selected-task.json ]; then + echo "has_task=false" >> "$GITHUB_OUTPUT" + echo "task_id=" >> "$GITHUB_OUTPUT" + echo "branch=" >> "$GITHUB_OUTPUT" + exit 0 + fi + TID=$(python -c "import json; print(json.load(open('.agentic/out/selected-task.json'))['id'])") + SLUG=$(python -c "import json,re; t=json.load(open('.agentic/out/selected-task.json')); s=re.sub(r'[^a-z0-9]+','-',t.get('title','').lower()).strip('-')[:40]; print(s or 'task')") + echo "has_task=true" >> "$GITHUB_OUTPUT" + echo "task_id=$TID" >> "$GITHUB_OUTPUT" + echo "branch=agentic/${TID}-${SLUG}" >> "$GITHUB_OUTPUT" + + - name: Upload implement context + uses: actions/upload-artifact@v4 + with: + name: agentic-implement-context-${{ github.run_id }} + path: .agentic/out/ + + - name: Summary + run: | + { + echo "## Prepare implement" + echo "" + echo "- task: \`${{ steps.meta.outputs.task_id }}\`" + echo "- branch: \`${{ steps.meta.outputs.branch }}\`" + echo "- dry_run: \`${{ inputs.dry_run }}\`" + } >> "$GITHUB_STEP_SUMMARY" + + implement: + needs: prepare + if: needs.prepare.outputs.has_task == 'true' && inputs.dry_run != true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Download context + uses: actions/download-artifact@v4 + with: + name: agentic-implement-context-${{ github.run_id }} + path: .agentic/out + + - name: Create agentic branch + env: + BRANCH: ${{ needs.prepare.outputs.branch }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + + - name: Set up uv / Python + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Install project (quality gates available to agent) + run: uv sync --all-extras + + - name: Build Claude prompt (multiline output) + id: prompt + env: + TASK_ID: ${{ needs.prepare.outputs.task_id }} + BRANCH: ${{ needs.prepare.outputs.branch }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REPO: ${{ github.repository }} + run: | + { + echo "prompt<" + echo "Labels: agentic, refactor, automated" + echo "" + echo "Policies JSON:" + cat .agentic/policies.json + echo "" + echo "----- BEGIN IMPLEMENT PROMPT -----" + cat .agentic/out/implement-prompt.md + echo "----- END IMPLEMENT PROMPT -----" + echo "AGENTIC_PROMPT_EOF" + } >> "$GITHUB_OUTPUT" + + - name: Run Claude Code (implement) + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + base_branch: ${{ github.event.repository.default_branch }} + branch_prefix: agentic/ + additional_permissions: | + actions: read + prompt: ${{ steps.prompt.outputs.prompt }} + claude_args: "--allowedTools Bash Edit Write Read Glob Grep" + + - name: Post-implement validation + if: always() + run: | + git fetch origin "${{ github.event.repository.default_branch }}" || true + python scripts/agentic/cli.py validate \ + --base-ref "origin/${{ github.event.repository.default_branch }}" || true + + - name: Upload validation + if: always() + uses: actions/upload-artifact@v4 + with: + name: agentic-validation-${{ github.run_id }} + path: .agentic/out/validation.json + if-no-files-found: ignore + + dry-run-summary: + needs: prepare + if: inputs.dry_run == true + runs-on: ubuntu-latest + steps: + - name: Download context + uses: actions/download-artifact@v4 + with: + name: agentic-implement-context-${{ github.run_id }} + path: .agentic/out + - name: Show selected task + run: | + { + echo "## Dry run — no code changes" + echo "" + echo "Would implement \`${{ needs.prepare.outputs.task_id }}\` on \`${{ needs.prepare.outputs.branch }}\`" + echo "" + if [ -f .agentic/out/implement-prompt.md ]; then + echo "
Implement prompt" + echo "" + echo '```markdown' + head -n 120 .agentic/out/implement-prompt.md + echo '```' + echo "
" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/agentic-orchestrator.yml b/.github/workflows/agentic-orchestrator.yml new file mode 100644 index 000000000..4ce32f167 --- /dev/null +++ b/.github/workflows/agentic-orchestrator.yml @@ -0,0 +1,124 @@ +name: Agentic Orchestrator + +# Full pipeline: analyze → plan → (optional) implement. +# Default is manual. Set repository variable AGENTIC_AUTO_IMPLEMENT=true +# to allow scheduled full runs that chain into implement. + +on: + workflow_dispatch: + inputs: + task_id: + description: "Optional force task id" + required: false + type: string + implement: + description: "Chain into Agentic Implement after plan" + required: false + type: boolean + default: false + dry_run_implement: + description: "If implementing, only prepare prompts" + required: false + type: boolean + default: true + sync_issues: + description: "Sync backlog to GitHub issues during plan" + required: false + type: boolean + default: false + schedule: + # Weekly analysis only (implement still gated) + - cron: "0 6 * * 1" + +permissions: + contents: write + pull-requests: write + issues: write + actions: write + id-token: write + +jobs: + analyze_and_plan: + runs-on: ubuntu-latest + outputs: + task_id: ${{ steps.out.outputs.task_id }} + should_implement: ${{ steps.out.outputs.should_implement }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run analyze + select + env: + TASK_ID: ${{ inputs.task_id }} + run: | + python scripts/agentic/cli.py analyze + if [ -n "${TASK_ID:-}" ]; then + python scripts/agentic/cli.py select --task "$TASK_ID" + else + python scripts/agentic/cli.py select || true + fi + + - name: Sync issues (optional) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.sync_issues }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for label in agentic priority:high priority:medium priority:low; do + gh label create "$label" --force 2>/dev/null || true + done + python scripts/agentic/cli.py sync-issues --apply + + - name: Decide implement chain + id: out + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_IMPLEMENT: ${{ inputs.implement }} + AUTO: ${{ vars.AGENTIC_AUTO_IMPLEMENT }} + run: | + TID="" + if [ -f .agentic/out/selected-task.json ]; then + TID=$(python -c "import json; print(json.load(open('.agentic/out/selected-task.json')).get('id',''))") + fi + echo "task_id=$TID" >> "$GITHUB_OUTPUT" + + SHOULD=false + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "$INPUT_IMPLEMENT" = "true" ]; then + SHOULD=true + fi + # Scheduled runs implement only if repo variable explicitly enables it + if [ "$EVENT_NAME" = "schedule" ] && [ "$AUTO" = "true" ]; then + SHOULD=true + fi + echo "should_implement=$SHOULD" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v4 + with: + name: agentic-orchestrator-out + path: .agentic/out/ + + - name: Summary + run: | + { + echo "## Orchestrator" + echo "" + echo "- event: \`${{ github.event_name }}\`" + echo "- selected: \`${{ steps.out.outputs.task_id }}\`" + echo "- chain_implement: \`${{ steps.out.outputs.should_implement }}\`" + echo "" + [ -f .agentic/out/analysis-report.md ] && cat .agentic/out/analysis-report.md + echo "" + [ -f .agentic/out/plan.md ] && cat .agentic/out/plan.md + } >> "$GITHUB_STEP_SUMMARY" + + call_implement: + needs: analyze_and_plan + if: needs.analyze_and_plan.outputs.should_implement == 'true' && needs.analyze_and_plan.outputs.task_id != '' + uses: ./.github/workflows/agentic-implement.yml + secrets: inherit + with: + task_id: ${{ needs.analyze_and_plan.outputs.task_id }} + # workflow_dispatch: honor dry_run_implement (default true). schedule+AUTO: real implement. + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run_implement || false }} diff --git a/.github/workflows/agentic-plan.yml b/.github/workflows/agentic-plan.yml new file mode 100644 index 000000000..8b8969f12 --- /dev/null +++ b/.github/workflows/agentic-plan.yml @@ -0,0 +1,82 @@ +name: Agentic Plan + +on: + workflow_dispatch: + inputs: + task_id: + description: "Optional force task id (e.g. REF-001). Empty = auto-select." + required: false + type: string + sync_issues: + description: "Create GitHub issues from backlog (requires labels)" + required: false + type: boolean + default: false + +permissions: + contents: read + issues: write + +jobs: + plan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Analyze + plan + env: + TASK_ID: ${{ inputs.task_id }} + run: | + python scripts/agentic/cli.py analyze + if [ -n "$TASK_ID" ]; then + python scripts/agentic/cli.py select --task "$TASK_ID" + else + python scripts/agentic/cli.py select + fi + + - name: Ensure agentic labels exist + if: ${{ inputs.sync_issues }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for label in agentic priority:high priority:medium priority:low category:dry category:solid category:error-handling category:architecture category:concurrency category:validation category:readability category:testing category:maintainability; do + gh label create "$label" --force 2>/dev/null || true + done + + - name: Sync backlog → GitHub issues + if: ${{ inputs.sync_issues }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python scripts/agentic/cli.py sync-issues --apply + + - name: Upload plan artifacts + uses: actions/upload-artifact@v4 + with: + name: agentic-plan + path: | + .agentic/out/ + if-no-files-found: error + + - name: Job summary + run: | + { + echo "## Selected task" + echo "" + if [ -f .agentic/out/selected-task.json ]; then + echo '```json' + cat .agentic/out/selected-task.json + echo '```' + else + echo "_No task selected_" + fi + echo "" + if [ -f .agentic/out/plan.md ]; then + cat .agentic/out/plan.md + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 4f2c2d9ae..53982d84f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports +test-clone/ htmlcov/ .tox/ .nox/ @@ -45,6 +46,8 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +.pytest_tmp/ +.ruff_cache/ # Translations *.mo @@ -68,4 +71,40 @@ venv.bak/ # Project specific .uv/ -uv.lock +# uv.lock + +# Agentic pipeline generated outputs (reports regenerated in CI) +.agentic/out/ +.agentic/state/ + +# OS-specific +.DS_Store +Thumbs.db +Desktop.ini + +# Claude Code (локальные конфиги и персональные заметки) +.claude/ +CLAUDE.local.md + +# Logs and temp +*.log +coverage.json +fix_tasks.py + +# Secrets (на всякий случай) +*.key +*.pem +*.p12 +*.jks +secrets.json +credentials.json + +# Pytest async/cov artifacts +.mypy_cache/ +.dmypy.json +dmypy.json +.agentic/tmp/ + +# Knowledge-graph pipeline trash / intermediate dumps (keep canonical graph/meta) +.understand-anything/.trash-* +.understand-anything/intermediate/ diff --git a/.grok/config.toml b/.grok/config.toml new file mode 100644 index 000000000..0327c0de9 --- /dev/null +++ b/.grok/config.toml @@ -0,0 +1,18 @@ +# Project-scoped Grok config for ccbot_ju1ian agentic pipeline. +# Requires folder trust: /hooks-trust or --trust (same gate as MCP/LSP). +# +# Tools appear as: ccbot-agentic__list_tasks, ccbot-agentic__select_next_task, ... + +[mcp_servers.ccbot-agentic] +command = "python" +args = ["scripts/agentic/mcp_server.py"] +# Pin repo root so the server works even if Grok cwd differs. +env = { CCBOT_REPO_ROOT = "D:\\CCbot_tmux\\ccbot\\ccbot_ju1ian" } +enabled = true +startup_timeout_sec = 45 +tool_timeout_sec = 600 +tool_timeouts = { select_next_task = 300, run_analyze = 180, run_validate = 600, get_context_pack = 120 } + +[mcp] +# Analysis / context packs can be large; raise default 20k cap for this project. +max_output_bytes = 80000 diff --git a/.understand-anything/.understandignore b/.understand-anything/.understandignore new file mode 100644 index 000000000..4fb9c32ac --- /dev/null +++ b/.understand-anything/.understandignore @@ -0,0 +1,94 @@ +# .understandignore — patterns for files/dirs to exclude from analysis +# Syntax: same as .gitignore (globs, # comments, ! negation, trailing / for dirs) +# Lines below are suggestions — uncomment to activate. +# Use ! prefix to force-include something excluded by defaults. +# +# Built-in defaults (always excluded unless negated): +# node_modules/, .git/, dist/, build/, obj/, *.lock, *.min.js, etc. +# + +# --- From .gitignore (uncomment to exclude) --- + +# *.py[cod] +# *$py.class +# *.so +# .Python +# develop-eggs/ +# downloads/ +# eggs/ +# .eggs/ +# lib/ +# lib64/ +# parts/ +# sdist/ +# var/ +# wheels/ +# *.egg-info/ +# .installed.cfg +# *.egg +# *.manifest +# *.spec +# pip-log.txt +# pip-delete-this-directory.txt +# htmlcov/ +# .tox/ +# .nox/ +# .coverage +# .coverage.* +# nosetests.xml +# coverage.xml +# *.cover +# *.py,cover +# .hypothesis/ +.pytest_cache/ +# *.mo +# *.pot +.env +# env/ +# ENV/ +# env.bak/ +venv.bak/ +# *.swp +# *.swo +# *~ +.uv/ +# uv.lock + +# --- User-specified exclusions --- +.venv/ +.agentic/out/ + +# --- Detected directories (uncomment to exclude) --- + +# scripts/ +# tests/ + +# --- Test file patterns (uncomment to exclude) --- + +# JS / TS +# *.test.* +# *.spec.* +# *.snap +# C# / .NET +# **/*Tests.cs +# **/*Test.cs +# **/*Fixture.cs +# **/*.Tests.csproj +# Java / Kotlin +# **/src/test/** +# **/*Test.java +# **/*IT.java +# **/*Spec.kt +# Go +# **/*_test.go +# C++ +# **/*_test.cc +# **/*_test.cpp +# **/*_test.cxx +# **/*Test.cc +# **/*Test.cpp +# **/*_unittest.cc +# **/*_unittest.cpp +# **/*_browsertest.cc +# **/*_benchmark.cc +# **/*Benchmark.cpp diff --git a/.understand-anything/fingerprints.json b/.understand-anything/fingerprints.json new file mode 100644 index 000000000..7305a43b3 --- /dev/null +++ b/.understand-anything/fingerprints.json @@ -0,0 +1,11116 @@ +{ + "version": "1.0.0", + "gitCommitHash": "2adfbb565fe08e66128e949f3538b5efe128c8c6", + "generatedAt": "2026-08-12T18:53:58.107Z", + "files": { + ".agentic/backlog/source-report.md": { + "filePath": ".agentic/backlog/source-report.md", + "contentHash": "2d06910ac1362c79e8d133535d71bfa2d1b7ccc1bf00d605b3c6586313856395", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 223, + "hasStructuralAnalysis": true + }, + ".agentic/backlog/tasks.json": { + "filePath": ".agentic/backlog/tasks.json", + "contentHash": "1fdbcfb65dad62cd4afb285edd1f7dec601520964c54ae5f9a5693d72ecb26c3", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 1012, + "hasStructuralAnalysis": true + }, + ".agentic/config.json": { + "filePath": ".agentic/config.json", + "contentHash": "e2d8c73e555f3969510fb9ca3ce09e337e7c90067a4ead0215dbc20cb512d565", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 119, + "hasStructuralAnalysis": true + }, + ".agentic/HANDOFF_REF-006.md": { + "filePath": ".agentic/HANDOFF_REF-006.md", + "contentHash": "60997c2245a7e3f3a6683017fa175bfba8510a9fe1c97c0aaf42a098761d0325", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 135, + "hasStructuralAnalysis": true + }, + ".agentic/HANDOFF.md": { + "filePath": ".agentic/HANDOFF.md", + "contentHash": "c8b6469c748c9befdf2fc5f6399f6e8d510aed5e69973afcd4e2f468e858ca75", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 400, + "hasStructuralAnalysis": true + }, + ".agentic/IMPLEMENTATION_PLAN.md": { + "filePath": ".agentic/IMPLEMENTATION_PLAN.md", + "contentHash": "0241324be1481d29fd959003dcd15565912acb04d87e78c173ee2f522f1c90cb", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 1028, + "hasStructuralAnalysis": true + }, + ".agentic/mcp/README.md": { + "filePath": ".agentic/mcp/README.md", + "contentHash": "59db41327a574f66f2a85712cffde6f3b0ebc08c8cd726a0d741b3df62ed9489", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 62, + "hasStructuralAnalysis": true + }, + ".agentic/policies.json": { + "filePath": ".agentic/policies.json", + "contentHash": "15d8e63f4aa0a9a104efdb18985923d75a5483fa669807eb278cb7dfd0db64f9", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 37, + "hasStructuralAnalysis": true + }, + ".agentic/prompts/analyze.md": { + "filePath": ".agentic/prompts/analyze.md", + "contentHash": "a2357630fa757fd1d7ce7a758ddb3eae5269cc5fedf88220d0f4fdad5b036b42", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 29, + "hasStructuralAnalysis": true + }, + ".agentic/prompts/implement.md": { + "filePath": ".agentic/prompts/implement.md", + "contentHash": "f387be59c88cd31a2f237ecc268bd09dc5e954ce881ca8edbaac4e015238a49e", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 48, + "hasStructuralAnalysis": true + }, + ".agentic/prompts/plan.md": { + "filePath": ".agentic/prompts/plan.md", + "contentHash": "98af87a4bfa0500a961fb578599365fe5b37fcfccf1f6a315cba5801a3433e1b", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 32, + "hasStructuralAnalysis": true + }, + ".agentic/prompts/pr-body.md": { + "filePath": ".agentic/prompts/pr-body.md", + "contentHash": "f41b3652237f2df9663dccc7f40d2340c68d30d4a212480eee9c4c95999eb88b", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 36, + "hasStructuralAnalysis": true + }, + ".agentic/prompts/review.md": { + "filePath": ".agentic/prompts/review.md", + "contentHash": "074e9ba34986580a6d5b4ada3609753499ee1498321c63574c70a20bc7d4d46b", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 25, + "hasStructuralAnalysis": true + }, + ".agentic/README.md": { + "filePath": ".agentic/README.md", + "contentHash": "4f88bc968c6f7c6798a6b48f8de449f6794b192cea675450c8d712b68189a42f", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 92, + "hasStructuralAnalysis": true + }, + ".claude/rules/architecture.md": { + "filePath": ".claude/rules/architecture.md", + "contentHash": "d0ce02af7d201a40af573aa8f62dab3c0055347099df93c8ddc9d87fc525d4fd", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 103, + "hasStructuralAnalysis": true + }, + ".claude/rules/message-handling.md": { + "filePath": ".claude/rules/message-handling.md", + "contentHash": "477286fda626bf01ba91b634690f24cb0f78aeaf40ade73297aeb73ca209e7a8", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 42, + "hasStructuralAnalysis": true + }, + ".claude/rules/topic-architecture.md": { + "filePath": ".claude/rules/topic-architecture.md", + "contentHash": "73fc143c52629add96ba578bf926a38c4684cae4fccfe7bb2b9d2c15498df326", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 74, + "hasStructuralAnalysis": true + }, + ".env.example": { + "filePath": ".env.example", + "contentHash": "de445bb1c75950f8400f1f29b59591e4a3941552137dd5f41991f60183e6112d", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 15, + "hasStructuralAnalysis": true + }, + ".github/workflows/agentic-analyze.yml": { + "filePath": ".github/workflows/agentic-analyze.yml", + "contentHash": "88c6cbcc08bded52935204a19cd21d5dd89e47f21edf9656f8979fa7e959ed73", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 63, + "hasStructuralAnalysis": true + }, + ".github/workflows/agentic-implement.yml": { + "filePath": ".github/workflows/agentic-implement.yml", + "contentHash": "1ea6a7d9c0b53ea118554147069b6b82918fbe780a964b6ca86398c831add01a", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 220, + "hasStructuralAnalysis": true + }, + ".github/workflows/agentic-orchestrator.yml": { + "filePath": ".github/workflows/agentic-orchestrator.yml", + "contentHash": "246ec22739a9970a8bb67bbe2aa837e7e1e12e4ac73321b705b7407d4ad0e3fd", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 125, + "hasStructuralAnalysis": true + }, + ".github/workflows/agentic-plan.yml": { + "filePath": ".github/workflows/agentic-plan.yml", + "contentHash": "0dff4329b597bbc3077691be3f256cfd5773708e7a9e7d62da720ec7eec0e6e4", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 83, + "hasStructuralAnalysis": true + }, + ".github/workflows/check.yml": { + "filePath": ".github/workflows/check.yml", + "contentHash": "75da606b5ca87e8077cd2548b7b25ddda2e2da2718aad756b26d6c612b579f2a", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 29, + "hasStructuralAnalysis": true + }, + ".github/workflows/claude-code-review.yml": { + "filePath": ".github/workflows/claude-code-review.yml", + "contentHash": "08068cdb733294f737db6dcb21609370f05c9cf9b9b9e00e240cb10f992589b3", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 46, + "hasStructuralAnalysis": true + }, + ".github/workflows/claude.yml": { + "filePath": ".github/workflows/claude.yml", + "contentHash": "08333ae929477fac81fb2f2b5b7deffd9b5c1b1629438087900a7fe98061163b", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 51, + "hasStructuralAnalysis": true + }, + ".grok/config.toml": { + "filePath": ".grok/config.toml", + "contentHash": "2ac5c17b1fa79d7bacd36847517b1f3212430073ae3d0b3c8eb9bd6a1178e068", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 19, + "hasStructuralAnalysis": true + }, + ".understand-anything/.understandignore": { + "filePath": ".understand-anything/.understandignore", + "contentHash": "a76e6701c0faff9ed387ae2237c1090ed3fec8806fb353181b2f53856196c668", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 95, + "hasStructuralAnalysis": false + }, + ".understand-anything/config.json": { + "filePath": ".understand-anything/config.json", + "contentHash": "a6a692217d2d03dbfbacee175523f866d24b30ec67638d71170ddb5c826b42dd", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 3, + "hasStructuralAnalysis": true + }, + ".understand-anything/fingerprints.json": { + "filePath": ".understand-anything/fingerprints.json", + "contentHash": "5592d5accf70a2e85f7584a72bfcab660e68d46c94fecb9d15c30ea7a77c8574", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 10493, + "hasStructuralAnalysis": true + }, + ".understand-anything/knowledge-graph.json": { + "filePath": ".understand-anything/knowledge-graph.json", + "contentHash": "55ea8836f21d85dc21f519bc418807d7e2db4f89528a7158e83921bbc184bf6c", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 16608, + "hasStructuralAnalysis": true + }, + ".understand-anything/meta.json": { + "filePath": ".understand-anything/meta.json", + "contentHash": "043bdfa95e69306ec2b5ac59af7715927567b18dc72354cbe553a6b222f6d691", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 6, + "hasStructuralAnalysis": true + }, + "AGENTS.md": { + "filePath": "AGENTS.md", + "contentHash": "02999723d2cca79b0b3ffd7ac51a59cd797204ef1f254dc63d0ecaf3b3e74e1a", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 63, + "hasStructuralAnalysis": true + }, + "CLAUDE.md": { + "filePath": "CLAUDE.md", + "contentHash": "7f44e594da5ab0bd5b30df7e1df97aa303bd8551b6c194ee1d06bf7018a25ed5", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 63, + "hasStructuralAnalysis": true + }, + "doc/telegram-bot-features.md": { + "filePath": "doc/telegram-bot-features.md", + "contentHash": "5441594679e86cca223223eff4b78b0954b50f6d45046f0112acfef40e2b364b", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 204, + "hasStructuralAnalysis": true + }, + "doc/WEBSOCKET_PROTOCOL_REVERSED.md": { + "filePath": "doc/WEBSOCKET_PROTOCOL_REVERSED.md", + "contentHash": "ccf585f5fc6db5206a768ca625f617e88b07f4fa4896da7af6658af7926932c2", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 1356, + "hasStructuralAnalysis": true + }, + "HANDOFF_REPAIR_ROBOT.md": { + "filePath": "HANDOFF_REPAIR_ROBOT.md", + "contentHash": "f29c401a162fcee4dd504540e68503f901c3c3ee8bc6d22c9ffb48e4ccee1f09", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 274, + "hasStructuralAnalysis": true + }, + "pyproject.toml": { + "filePath": "pyproject.toml", + "contentHash": "877ee6a85a42886d93ce1ba88650698481e1dc5a498736fcdd4de9f0d86a7965", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 84, + "hasStructuralAnalysis": true + }, + "README_CN.md": { + "filePath": "README_CN.md", + "contentHash": "645ab0a254a99d9ed442c6daa91f5a5e4a6422f2907935073b506e3e94465969", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 301, + "hasStructuralAnalysis": true + }, + "README_RU.md": { + "filePath": "README_RU.md", + "contentHash": "080794405d16d8a83a549eb184d555f60aecb30fff59b10ea05785f650c46bd0", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 296, + "hasStructuralAnalysis": true + }, + "README.md": { + "filePath": "README.md", + "contentHash": "7d9cf91a41d43b79d99d3808a983721eb76e933ed997118516e0414e7e3d542e", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 287, + "hasStructuralAnalysis": true + }, + "scripts/agentic/__init__.py": { + "filePath": "scripts/agentic/__init__.py", + "contentHash": "5b073341580d0c6de7576771c558b07a024deba830bd4193935cca85c50b2bef", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 4, + "hasStructuralAnalysis": true + }, + "scripts/agentic/analyze_graph.py": { + "filePath": "scripts/agentic/analyze_graph.py", + "contentHash": "be4e5c85a5da3a0cea99412f3d5d9c83a00608eaa40e6005eedff3140f05f539", + "functions": [ + { + "name": "_edge_type", + "params": [ + "edge" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "_normalize_path", + "params": [ + "path" + ], + "returnType": "str | None", + "exported": true, + "lineCount": 4 + }, + { + "name": "analyze_graph", + "params": [ + "graph", + "config" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 140 + }, + { + "name": "_recommendations", + "params": [ + "hotspots", + "untested", + "complex_files" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 28 + }, + { + "name": "render_markdown", + "params": [ + "report" + ], + "returnType": "str", + "exported": true, + "lineCount": 33 + }, + { + "name": "run", + "params": [ + "repo_root" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 19 + } + ], + "classes": [], + "imports": [ + { + "source": "collections", + "specifiers": [ + "Counter", + "defaultdict" + ] + }, + { + "source": "datetime", + "specifiers": [ + "datetime", + "timezone" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "load_json", + "out_dir", + "write_json", + "write_text" + ] + } + ], + "exports": [ + "_edge_type", + "_normalize_path", + "analyze_graph", + "_recommendations", + "render_markdown", + "run" + ], + "totalLines": 277, + "hasStructuralAnalysis": true + }, + "scripts/agentic/build_context_pack.py": { + "filePath": "scripts/agentic/build_context_pack.py", + "contentHash": "2704cfa189b292d9b3a2bdf128f8cbdc1561f929e7433b1cd699f99c207c6d1a", + "functions": [ + { + "name": "_edge_type", + "params": [ + "edge" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "_node_path", + "params": [ + "node" + ], + "returnType": "str | None", + "exported": true, + "lineCount": 8 + }, + { + "name": "_seed_ids", + "params": [ + "task", + "node_by_id" + ], + "returnType": "set[str]", + "exported": true, + "lineCount": 26 + }, + { + "name": "build_context_pack", + "params": [ + "graph", + "task", + "hops", + "max_nodes", + "max_edges" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 113 + }, + { + "name": "run", + "params": [ + "task_id", + "repo_root", + "hops" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 24 + } + ], + "classes": [], + "imports": [ + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "load_json", + "out_dir", + "write_json" + ] + } + ], + "exports": [ + "_edge_type", + "_node_path", + "_seed_ids", + "build_context_pack", + "run" + ], + "totalLines": 204, + "hasStructuralAnalysis": true + }, + "scripts/agentic/cli.py": { + "filePath": "scripts/agentic/cli.py", + "contentHash": "0f7ba3c5ac52a58c6b8e8a420440a190935e0597389f0dc4983901a80c7e16f4", + "functions": [ + { + "name": "cmd_analyze", + "params": [ + "_args" + ], + "returnType": "int", + "exported": true, + "lineCount": 8 + }, + { + "name": "cmd_plan", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 8 + }, + { + "name": "cmd_select", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 10 + }, + { + "name": "cmd_validate", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 35 + }, + { + "name": "cmd_sync", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 4 + }, + { + "name": "cmd_run", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 27 + }, + { + "name": "cmd_context", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 7 + }, + { + "name": "cmd_status", + "params": [ + "_args" + ], + "returnType": "int", + "exported": true, + "lineCount": 3 + }, + { + "name": "cmd_list", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 3 + }, + { + "name": "build_parser", + "params": [], + "returnType": "argparse.ArgumentParser", + "exported": true, + "lineCount": 49 + }, + { + "name": "main", + "params": [ + "argv" + ], + "returnType": "int", + "exported": true, + "lineCount": 19 + } + ], + "classes": [], + "imports": [ + { + "source": "argparse", + "specifiers": [ + "argparse" + ] + }, + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "analyze_graph", + "specifiers": [ + "run_analyze" + ] + }, + { + "source": "build_context_pack", + "specifiers": [ + "run_context" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root" + ] + }, + { + "source": "prioritize", + "specifiers": [ + "run_plan" + ] + }, + { + "source": "propose", + "specifiers": [ + "HITL_HANDLERS", + "PROPOSE_HANDLERS", + "register_hitl_commands", + "register_propose_commands" + ] + }, + { + "source": "propose", + "specifiers": [ + "svc_load_task" + ] + }, + { + "source": "render_prompt", + "specifiers": [ + "run_render" + ] + }, + { + "source": "service", + "specifiers": [ + "svc_list_tasks" + ] + }, + { + "source": "service", + "specifiers": [ + "svc_pipeline_status" + ] + }, + { + "source": "sync_issues", + "specifiers": [ + "run_sync" + ] + }, + { + "source": "validate_changes", + "specifiers": [ + "run_validate" + ] + } + ], + "exports": [ + "cmd_analyze", + "cmd_plan", + "cmd_select", + "cmd_validate", + "cmd_sync", + "cmd_run", + "cmd_context", + "cmd_status", + "cmd_list", + "build_parser", + "main" + ], + "totalLines": 272, + "hasStructuralAnalysis": true + }, + "scripts/agentic/mcp_server.py": { + "filePath": "scripts/agentic/mcp_server.py", + "contentHash": "6bae30893d507dbd35cf48790a5c63f3e9a3f25718a47c8129c022bcfa5e4cc3", + "functions": [ + { + "name": "_root", + "params": [], + "returnType": "Path", + "exported": true, + "lineCount": 5 + }, + { + "name": "_ok_text", + "params": [ + "data" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 3 + }, + { + "name": "_err_text", + "params": [ + "message" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 2 + }, + { + "name": "tool_list_tasks", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 8 + }, + { + "name": "tool_get_task", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 5 + }, + { + "name": "tool_select_next_task", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 8 + }, + { + "name": "tool_get_analysis", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 4 + }, + { + "name": "tool_get_context_pack", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 10 + }, + { + "name": "tool_get_implement_prompt", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 5 + }, + { + "name": "tool_mark_task_status", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 13 + }, + { + "name": "tool_run_validate", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 8 + }, + { + "name": "tool_pipeline_status", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 2 + }, + { + "name": "tool_run_analyze", + "params": [ + "args" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 15 + }, + { + "name": "_tools_list_payload", + "params": [], + "returnType": "list[dict[str, Any]]", + "exported": true, + "lineCount": 9 + }, + { + "name": "_handle_tools_call", + "params": [ + "params" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 11 + }, + { + "name": "_dispatch", + "params": [ + "msg" + ], + "returnType": "dict[str, Any] | None", + "exported": true, + "lineCount": 49 + }, + { + "name": "_read_message", + "params": [], + "returnType": "dict[str, Any] | None", + "exported": true, + "lineCount": 30 + }, + { + "name": "_write_message", + "params": [ + "msg" + ], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "main", + "params": [], + "returnType": "int", + "exported": true, + "lineCount": 31 + } + ], + "classes": [], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "traceback", + "specifiers": [ + "traceback" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any", + "Callable" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root" + ] + }, + { + "source": "service", + "specifiers": [ + "dumps_result", + "get_analysis", + "get_context_pack", + "get_implement_prompt", + "get_task", + "list_tasks", + "mark_task_status", + "pipeline_status", + "select_next_task", + "validate" + ] + } + ], + "exports": [ + "_root", + "_ok_text", + "_err_text", + "tool_list_tasks", + "tool_get_task", + "tool_select_next_task", + "tool_get_analysis", + "tool_get_context_pack", + "tool_get_implement_prompt", + "tool_mark_task_status", + "tool_run_validate", + "tool_pipeline_status", + "tool_run_analyze", + "_tools_list_payload", + "_handle_tools_call", + "_dispatch", + "_read_message", + "_write_message", + "main" + ], + "totalLines": 500, + "hasStructuralAnalysis": true + }, + "scripts/agentic/mcp_smoke_test.py": { + "filePath": "scripts/agentic/mcp_smoke_test.py", + "contentHash": "f51f638c0116aa0d5b96f049b79d7eb3b314b5ad303401e47ac705b45b3d8b47", + "functions": [ + { + "name": "_frame", + "params": [ + "obj" + ], + "returnType": "bytes", + "exported": true, + "lineCount": 3 + }, + { + "name": "_read_one", + "params": [ + "proc" + ], + "returnType": "dict", + "exported": true, + "lineCount": 16 + }, + { + "name": "main", + "params": [], + "returnType": "int", + "exported": true, + "lineCount": 114 + } + ], + "classes": [], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "subprocess", + "specifiers": [ + "subprocess" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + } + ], + "exports": [ + "_frame", + "_read_one", + "main" + ], + "totalLines": 157, + "hasStructuralAnalysis": true + }, + "scripts/agentic/paths.py": { + "filePath": "scripts/agentic/paths.py", + "contentHash": "feb4c22aa7a6b26f3a36396e65931a3f8cf8c15a588cab665bab065110ea0e50", + "functions": [ + { + "name": "find_repo_root", + "params": [ + "start" + ], + "returnType": "Path", + "exported": true, + "lineCount": 11 + }, + { + "name": "load_json", + "params": [ + "path" + ], + "returnType": "Any", + "exported": true, + "lineCount": 3 + }, + { + "name": "write_json", + "params": [ + "path", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "write_text", + "params": [ + "path", + "text" + ], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "load_config", + "params": [ + "repo_root" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 3 + }, + { + "name": "out_dir", + "params": [ + "repo_root", + "config" + ], + "returnType": "Path", + "exported": true, + "lineCount": 2 + } + ], + "classes": [], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + } + ], + "exports": [ + "find_repo_root", + "load_json", + "write_json", + "write_text", + "load_config", + "out_dir" + ], + "totalLines": 47, + "hasStructuralAnalysis": true + }, + "scripts/agentic/prioritize.py": { + "filePath": "scripts/agentic/prioritize.py", + "contentHash": "cc6d9a94efb2bdcccce150ac88261d8fbdf11e9490f016ff673973ad27c7a653", + "functions": [ + { + "name": "_task_file_paths", + "params": [ + "task" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 9 + }, + { + "name": "_deps_satisfied", + "params": [ + "task", + "by_id" + ], + "returnType": "bool", + "exported": true, + "lineCount": 8 + }, + { + "name": "score_task", + "params": [ + "task", + "config", + "path_scores", + "by_id" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 82 + }, + { + "name": "build_plan", + "params": [ + "backlog", + "analysis", + "config" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 33 + }, + { + "name": "render_plan_md", + "params": [ + "plan" + ], + "returnType": "str", + "exported": true, + "lineCount": 45 + }, + { + "name": "run", + "params": [ + "repo_root", + "task_id" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 33 + } + ], + "classes": [], + "imports": [ + { + "source": "datetime", + "specifiers": [ + "datetime", + "timezone" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "load_json", + "out_dir", + "write_json", + "write_text" + ] + } + ], + "exports": [ + "_task_file_paths", + "_deps_satisfied", + "score_task", + "build_plan", + "render_plan_md", + "run" + ], + "totalLines": 247, + "hasStructuralAnalysis": true + }, + "scripts/agentic/propose.py": { + "filePath": "scripts/agentic/propose.py", + "contentHash": "ea540b769f9384dcd59cf942234ef3d4619bc5ee3a1bbb5f9e6cf3d997476d9a", + "functions": [ + { + "name": "utc_stamp", + "params": [], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "iso_now", + "params": [], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "run", + "params": [ + "cmd", + "cwd", + "check", + "env" + ], + "returnType": "subprocess.CompletedProcess[str]", + "exported": true, + "lineCount": 18 + }, + { + "name": "git", + "params": [ + "cwd" + ], + "returnType": "str", + "exported": true, + "lineCount": 3 + }, + { + "name": "load_json", + "params": [ + "path" + ], + "exported": true, + "lineCount": 6 + }, + { + "name": "write_json", + "params": [ + "path", + "payload" + ], + "returnType": "None", + "exported": true, + "lineCount": 8 + }, + { + "name": "write_text", + "params": [ + "path", + "text" + ], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "write_bytes", + "params": [ + "path", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "deep_merge", + "params": [ + "base", + "override" + ], + "returnType": "dict", + "exported": true, + "lineCount": 8 + }, + { + "name": "load_config", + "params": [], + "returnType": "dict", + "exported": true, + "lineCount": 9 + }, + { + "name": "task_id_from", + "params": [ + "task" + ], + "returnType": "str | None", + "exported": true, + "lineCount": 2 + }, + { + "name": "iter_tasks", + "params": [ + "data" + ], + "exported": true, + "lineCount": 21 + }, + { + "name": "load_task", + "params": [ + "task_id" + ], + "returnType": "dict", + "exported": true, + "lineCount": 11 + }, + { + "name": "update_task_status", + "params": [ + "task_id", + "status", + "proposal_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 19 + }, + { + "name": "ensure_clean_tree", + "params": [ + "allow_dirty" + ], + "returnType": "None", + "exported": true, + "lineCount": 25 + }, + { + "name": "_task_acceptance", + "params": [ + "task" + ], + "returnType": "object", + "exported": true, + "lineCount": 7 + }, + { + "name": "render_default_prompt", + "params": [ + "task" + ], + "returnType": "str", + "exported": true, + "lineCount": 58 + }, + { + "name": "proposal_dir_for", + "params": [ + "task_id", + "stamp" + ], + "returnType": "Path", + "exported": true, + "lineCount": 3 + }, + { + "name": "latest_proposal_dir", + "params": [ + "task_id" + ], + "returnType": "Path | None", + "exported": true, + "lineCount": 37 + }, + { + "name": "resolve_proposal_dir", + "params": [ + "proposal", + "latest", + "task_id" + ], + "returnType": "Path", + "exported": true, + "lineCount": 35 + }, + { + "name": "git_base_sha", + "params": [ + "base_ref" + ], + "returnType": "str", + "exported": true, + "lineCount": 7 + }, + { + "name": "capture_working_tree_diff", + "params": [ + "base_ref" + ], + "returnType": "str", + "exported": true, + "lineCount": 16 + }, + { + "name": "list_changed_paths_from_diff", + "params": [ + "diff_text" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 28 + }, + { + "name": "_glob_to_regex", + "params": [ + "pattern" + ], + "returnType": "str", + "exported": true, + "lineCount": 23 + }, + { + "name": "_match_glob", + "params": [ + "path", + "pattern" + ], + "returnType": "bool", + "exported": true, + "lineCount": 52 + }, + { + "name": "path_allowed", + "params": [ + "path", + "config" + ], + "returnType": "tuple[bool, str]", + "exported": true, + "lineCount": 28 + }, + { + "name": "_is_empty_diff", + "params": [ + "diff_text" + ], + "returnType": "bool", + "exported": true, + "lineCount": 9 + }, + { + "name": "check_no_markdown_fences", + "params": [ + "diff_text" + ], + "returnType": "None", + "exported": true, + "lineCount": 8 + }, + { + "name": "check_no_diff_comments", + "params": [ + "diff_text" + ], + "returnType": "None", + "exported": true, + "lineCount": 8 + }, + { + "name": "check_pure_unified_diff", + "params": [ + "diff_text" + ], + "returnType": "None", + "exported": true, + "lineCount": 7 + }, + { + "name": "_write_temp_diff", + "params": [ + "diff_bytes" + ], + "returnType": "Path", + "exported": true, + "lineCount": 13 + }, + { + "name": "check_diff_applies", + "params": [ + "diff_bytes" + ], + "returnType": "None", + "exported": true, + "lineCount": 57 + }, + { + "name": "diff_numstat", + "params": [ + "diff_bytes" + ], + "returnType": "list[dict]", + "exported": true, + "lineCount": 48 + }, + { + "name": "validate_proposal_diff", + "params": [ + "diff_bytes", + "allow_empty" + ], + "returnType": "None", + "exported": true, + "lineCount": 23 + }, + { + "name": "build_files_json", + "params": [ + "numstat", + "config" + ], + "returnType": "dict", + "exported": true, + "lineCount": 29 + }, + { + "name": "build_risk_json", + "params": [ + "task", + "paths" + ], + "returnType": "dict", + "exported": true, + "lineCount": 20 + }, + { + "name": "build_evidence_plan", + "params": [ + "task" + ], + "returnType": "dict", + "exported": true, + "lineCount": 53 + }, + { + "name": "build_proposal_md", + "params": [ + "task", + "proposal_id", + "base_sha", + "paths" + ], + "returnType": "str", + "exported": true, + "lineCount": 59 + }, + { + "name": "create_proposal_artifacts", + "params": [ + "task", + "base_ref", + "diff_bytes", + "allow_empty" + ], + "returnType": "Path", + "exported": true, + "lineCount": 132 + }, + { + "name": "prepare_pipeline_context", + "params": [ + "task_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 22 + }, + { + "name": "_proposal_id_of", + "params": [ + "dest", + "meta" + ], + "returnType": "str", + "exported": true, + "lineCount": 4 + }, + { + "name": "_load_proposal_meta", + "params": [ + "dest" + ], + "returnType": "dict", + "exported": true, + "lineCount": 8 + }, + { + "name": "_update_proposal_meta", + "params": [ + "dest", + "**fields" + ], + "returnType": "dict", + "exported": true, + "lineCount": 6 + }, + { + "name": "_current_base_sha", + "params": [ + "base_ref" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "_assert_base_sha_fresh", + "params": [ + "expected_sha", + "base_ref", + "context" + ], + "returnType": "str", + "exported": true, + "lineCount": 19 + }, + { + "name": "_current_branch", + "params": [], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "_assert_feature_branch", + "params": [], + "returnType": "str", + "exported": true, + "lineCount": 12 + }, + { + "name": "reject", + "params": [ + "proposal_id", + "reason", + "task_id" + ], + "returnType": "Path", + "exported": true, + "lineCount": 41 + }, + { + "name": "approve", + "params": [ + "proposal_id", + "message", + "task_id", + "base_ref", + "approver" + ], + "returnType": "Path", + "exported": true, + "lineCount": 56 + }, + { + "name": "request_changes", + "params": [ + "proposal_id", + "notes", + "task_id" + ], + "returnType": "Path", + "exported": true, + "lineCount": 78 + }, + { + "name": "apply", + "params": [ + "proposal_id", + "task_id", + "base_ref", + "allow_on_main" + ], + "returnType": "Path", + "exported": true, + "lineCount": 111 + }, + { + "name": "cmd_propose", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 69 + }, + { + "name": "cmd_show_proposal", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 32 + }, + { + "name": "cmd_import_proposal", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 144 + }, + { + "name": "cmd_reject", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 15 + }, + { + "name": "cmd_approve", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 19 + }, + { + "name": "cmd_request_changes", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 14 + }, + { + "name": "cmd_apply", + "params": [ + "args" + ], + "returnType": "int", + "exported": true, + "lineCount": 18 + }, + { + "name": "register_propose_commands", + "params": [ + "sub" + ], + "returnType": "None", + "exported": true, + "lineCount": 57 + }, + { + "name": "register_hitl_commands", + "params": [ + "sub" + ], + "returnType": "None", + "exported": true, + "lineCount": 70 + } + ], + "classes": [], + "imports": [ + { + "source": "argparse", + "specifiers": [ + "argparse" + ] + }, + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "shutil", + "specifiers": [ + "shutil" + ] + }, + { + "source": "subprocess", + "specifiers": [ + "subprocess" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "tempfile", + "specifiers": [ + "tempfile" + ] + }, + { + "source": "datetime", + "specifiers": [ + "datetime", + "timezone" + ] + }, + { + "source": "fnmatch", + "specifiers": [ + "fnmatch" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + } + ], + "exports": [ + "utc_stamp", + "iso_now", + "run", + "git", + "load_json", + "write_json", + "write_text", + "write_bytes", + "deep_merge", + "load_config", + "task_id_from", + "iter_tasks", + "load_task", + "update_task_status", + "ensure_clean_tree", + "_task_acceptance", + "render_default_prompt", + "proposal_dir_for", + "latest_proposal_dir", + "resolve_proposal_dir", + "git_base_sha", + "capture_working_tree_diff", + "list_changed_paths_from_diff", + "_glob_to_regex", + "_match_glob", + "path_allowed", + "_is_empty_diff", + "check_no_markdown_fences", + "check_no_diff_comments", + "check_pure_unified_diff", + "_write_temp_diff", + "check_diff_applies", + "diff_numstat", + "validate_proposal_diff", + "build_files_json", + "build_risk_json", + "build_evidence_plan", + "build_proposal_md", + "create_proposal_artifacts", + "prepare_pipeline_context", + "_proposal_id_of", + "_load_proposal_meta", + "_update_proposal_meta", + "_current_base_sha", + "_assert_base_sha_fresh", + "_current_branch", + "_assert_feature_branch", + "reject", + "approve", + "request_changes", + "apply", + "cmd_propose", + "cmd_show_proposal", + "cmd_import_proposal", + "cmd_reject", + "cmd_approve", + "cmd_request_changes", + "cmd_apply", + "register_propose_commands", + "register_hitl_commands" + ], + "totalLines": 1975, + "hasStructuralAnalysis": true + }, + "scripts/agentic/README.md": { + "filePath": "scripts/agentic/README.md", + "contentHash": "42c247f5e8edd1eeec48cce0bbbdc1c226a804631f1a7b5f12b08bf6d581eeba", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 57, + "hasStructuralAnalysis": true + }, + "scripts/agentic/render_prompt.py": { + "filePath": "scripts/agentic/render_prompt.py", + "contentHash": "a0b6cc15ded98daf26caddcb972c74423183d6159a83f2567d52162636d70fd5", + "functions": [ + { + "name": "_checklist", + "params": [ + "items" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "render_implement_prompt", + "params": [ + "template", + "task" + ], + "returnType": "str", + "exported": true, + "lineCount": 5 + }, + { + "name": "render_pr_body", + "params": [ + "template", + "task", + "graph_commit" + ], + "returnType": "str", + "exported": true, + "lineCount": 19 + }, + { + "name": "run", + "params": [ + "repo_root" + ], + "returnType": "dict[str, str]", + "exported": true, + "lineCount": 50 + } + ], + "classes": [], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "load_json", + "out_dir", + "write_text" + ] + } + ], + "exports": [ + "_checklist", + "render_implement_prompt", + "render_pr_body", + "run" + ], + "totalLines": 100, + "hasStructuralAnalysis": true + }, + "scripts/agentic/service.py": { + "filePath": "scripts/agentic/service.py", + "contentHash": "e785808dcbd590051bc3819a3621aa6cf35820270ba98fcb79944714b1ca47ea", + "functions": [ + { + "name": "repo_root", + "params": [ + "cwd" + ], + "returnType": "Path", + "exported": true, + "lineCount": 4 + }, + { + "name": "list_tasks", + "params": [ + "status", + "root", + "include_ranking" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 64 + }, + { + "name": "get_task", + "params": [ + "task_id", + "root" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 8 + }, + { + "name": "select_next_task", + "params": [ + "task_id", + "root", + "skip_analyze" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 52 + }, + { + "name": "get_analysis", + "params": [ + "root", + "refresh" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 25 + }, + { + "name": "get_context_pack", + "params": [ + "task_id", + "hops", + "root", + "refresh" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 17 + }, + { + "name": "get_implement_prompt", + "params": [ + "root" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 21 + }, + { + "name": "mark_task_status", + "params": [ + "task_id", + "status", + "note", + "root" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 28 + }, + { + "name": "validate", + "params": [ + "base_ref", + "skip_quality", + "root" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 25 + }, + { + "name": "pipeline_status", + "params": [ + "root" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 36 + }, + { + "name": "dumps_result", + "params": [ + "data" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + } + ], + "classes": [], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "datetime", + "specifiers": [ + "datetime", + "timezone" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "analyze_graph", + "specifiers": [ + "run_analyze" + ] + }, + { + "source": "build_context_pack", + "specifiers": [ + "run_context_pack" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "load_json", + "out_dir", + "write_json" + ] + }, + { + "source": "prioritize", + "specifiers": [ + "run_plan" + ] + }, + { + "source": "render_prompt", + "specifiers": [ + "run_render" + ] + }, + { + "source": "validate_changes", + "specifiers": [ + "run_validate" + ] + } + ], + "exports": [ + "repo_root", + "list_tasks", + "get_task", + "select_next_task", + "get_analysis", + "get_context_pack", + "get_implement_prompt", + "mark_task_status", + "validate", + "pipeline_status", + "dumps_result" + ], + "totalLines": 320, + "hasStructuralAnalysis": true + }, + "scripts/agentic/sync_issues.py": { + "filePath": "scripts/agentic/sync_issues.py", + "contentHash": "55ca7c3a6c77c3447b5cc8f47b2782d034fc057c665245070a4486794705ad3a", + "functions": [ + { + "name": "_gh", + "params": [ + "args", + "cwd" + ], + "returnType": "subprocess.CompletedProcess[str]", + "exported": true, + "lineCount": 10 + }, + { + "name": "_issue_body", + "params": [ + "task" + ], + "returnType": "str", + "exported": true, + "lineCount": 34 + }, + { + "name": "list_existing_task_issues", + "params": [ + "root" + ], + "returnType": "dict[str, int]", + "exported": true, + "lineCount": 31 + }, + { + "name": "sync", + "params": [ + "repo_root", + "dry_run", + "statuses" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 54 + }, + { + "name": "main", + "params": [ + "argv" + ], + "returnType": "int", + "exported": true, + "lineCount": 22 + } + ], + "classes": [], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "subprocess", + "specifiers": [ + "subprocess" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "load_json" + ] + } + ], + "exports": [ + "_gh", + "_issue_body", + "list_existing_task_issues", + "sync", + "main" + ], + "totalLines": 177, + "hasStructuralAnalysis": true + }, + "scripts/agentic/update_backlog_status.py": { + "filePath": "scripts/agentic/update_backlog_status.py", + "contentHash": "5f305c2c4468408d9b365bf69daaf4ec81d1ff2d8027b22dcd9c5b91f1a97a4c", + "functions": [ + { + "name": "done_block_reasons", + "params": [ + "root", + "task" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 19 + }, + { + "name": "main", + "params": [ + "argv" + ], + "returnType": "int", + "exported": true, + "lineCount": 34 + } + ], + "classes": [], + "imports": [ + { + "source": "argparse", + "specifiers": [ + "argparse" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "datetime", + "specifiers": [ + "datetime", + "timezone" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "load_json", + "write_json" + ] + }, + { + "source": "validate_changes", + "specifiers": [ + "check_evidence" + ] + } + ], + "exports": [ + "done_block_reasons", + "main" + ], + "totalLines": 79, + "hasStructuralAnalysis": true + }, + "scripts/agentic/validate_changes.py": { + "filePath": "scripts/agentic/validate_changes.py", + "contentHash": "d308771899fd79bb55932290883125ea43028d799688d37d6c3ef81d32443c20", + "functions": [ + { + "name": "_venv_python", + "params": [ + "root" + ], + "returnType": "Path", + "exported": true, + "lineCount": 4 + }, + { + "name": "_run", + "params": [ + "cmd", + "cwd" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 26 + }, + { + "name": "_ensure_dev_env", + "params": [ + "root" + ], + "returnType": "dict[str, Any] | None", + "exported": true, + "lineCount": 15 + }, + { + "name": "_git_changed_files", + "params": [ + "root", + "base_ref" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 32 + }, + { + "name": "_match_any", + "params": [ + "path", + "globs" + ], + "returnType": "bool", + "exported": true, + "lineCount": 24 + }, + { + "name": "check_guardrails", + "params": [ + "changed", + "config" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 42 + }, + { + "name": "_read_text_safe", + "params": [ + "path" + ], + "returnType": "str", + "exported": true, + "lineCount": 6 + }, + { + "name": "check_evidence", + "params": [ + "root", + "evidence" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 80 + }, + { + "name": "run", + "params": [ + "repo_root", + "base_ref", + "skip_quality", + "evidence" + ], + "returnType": "dict[str, Any]", + "exported": true, + "lineCount": 71 + }, + { + "name": "main", + "params": [ + "argv" + ], + "returnType": "int", + "exported": true, + "lineCount": 10 + }, + { + "name": "json_dumps_summary", + "params": [ + "report" + ], + "returnType": "str", + "exported": true, + "lineCount": 13 + } + ], + "classes": [], + "imports": [ + { + "source": "fnmatch", + "specifiers": [ + "fnmatch" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "subprocess", + "specifiers": [ + "subprocess" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "datetime", + "specifiers": [ + "datetime", + "timezone" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "paths", + "specifiers": [ + "find_repo_root", + "load_config", + "out_dir", + "write_json" + ] + } + ], + "exports": [ + "_venv_python", + "_run", + "_ensure_dev_env", + "_git_changed_files", + "_match_any", + "check_guardrails", + "_read_text_safe", + "check_evidence", + "run", + "main", + "json_dumps_summary" + ], + "totalLines": 364, + "hasStructuralAnalysis": true + }, + "scripts/restart.sh": { + "filePath": "scripts/restart.sh", + "contentHash": "94f01002b60f5b53aba102e23edf7ab9479e10e53b53a0934af040d53f38f6a3", + "functions": [ + { + "name": "is_ccbot_running", + "params": [], + "exported": false, + "lineCount": 3 + } + ], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 81, + "hasStructuralAnalysis": true + }, + "src/ccbot/__init__.py": { + "filePath": "src/ccbot/__init__.py", + "contentHash": "2fc32613fd03c93db12c9bc0671cc45c785080258f99b9f5307ad80d029ffb1c", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 8, + "hasStructuralAnalysis": true + }, + "src/ccbot/binding_store.py": { + "filePath": "src/ccbot/binding_store.py", + "contentHash": "8a8e18cdf900e437a4f0dd97014d4a602d01b9f601a16d0c45f5d77596c13974", + "functions": [], + "classes": [ + { + "name": "BindingStore", + "methods": [ + "to_state", + "load_state", + "reset", + "bind_thread", + "unbind_thread", + "get_window_for_thread", + "iter_thread_bindings", + "set_group_chat_id", + "resolve_chat_id" + ], + "properties": [ + "thread_bindings", + "group_chat_ids" + ], + "exported": true, + "lineCount": 111 + } + ], + "imports": [ + { + "source": "collections.abc", + "specifiers": [ + "Iterator" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass", + "field" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + } + ], + "exports": [ + "BindingStore" + ], + "totalLines": 130, + "hasStructuralAnalysis": true + }, + "src/ccbot/bot.py": { + "filePath": "src/ccbot/bot.py", + "contentHash": "a0a2dab9337b9acc1051385cfd136609a529912b944b5fe2967edf2844ca3258", + "functions": [ + { + "name": "post_init", + "params": [ + "application" + ], + "returnType": "None", + "exported": true, + "lineCount": 47 + }, + { + "name": "post_shutdown", + "params": [ + "application" + ], + "returnType": "None", + "exported": true, + "lineCount": 21 + }, + { + "name": "create_bot", + "params": [], + "returnType": "Application", + "exported": true, + "lineCount": 49 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "telegram", + "specifiers": [ + "BotCommand" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "AIORateLimiter", + "Application", + "CallbackQueryHandler", + "CommandHandler", + "MessageHandler", + "filters" + ] + }, + { + "source": ".config", + "specifiers": [ + "config" + ] + }, + { + "source": ".handlers.callback_router", + "specifiers": [ + "callback_handler" + ] + }, + { + "source": ".handlers.command_handlers", + "specifiers": [ + "esc_command", + "forward_command_handler", + "history_command", + "screenshot_command", + "start_command", + "topic_closed_handler", + "topic_edited_handler", + "unbind_command", + "unsupported_content_handler", + "usage_command" + ] + }, + { + "source": ".handlers.message_handlers", + "specifiers": [ + "photo_handler", + "text_handler", + "voice_handler" + ] + }, + { + "source": ".handlers.message_queue", + "specifiers": [ + "shutdown_workers" + ] + }, + { + "source": ".handlers.notifications", + "specifiers": [ + "handle_new_message" + ] + }, + { + "source": ".handlers.status_polling", + "specifiers": [ + "status_poll_loop" + ] + }, + { + "source": ".session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": ".session_guard", + "specifiers": [ + "get_thread_id", + "is_user_allowed" + ] + }, + { + "source": ".session_monitor", + "specifiers": [ + "NewMessage", + "SessionMonitor" + ] + }, + { + "source": ".transcribe", + "specifiers": [ + "close_transcribe_client" + ] + } + ], + "exports": [ + "post_init", + "post_shutdown", + "create_bot" + ], + "totalLines": 220, + "hasStructuralAnalysis": true + }, + "src/ccbot/config.py": { + "filePath": "src/ccbot/config.py", + "contentHash": "216c91dac606be738a3a15010783e5ee7e5d94618b0fefcc67066f8d4fd4e6ea", + "functions": [], + "classes": [ + { + "name": "Config", + "methods": [ + "__init__", + "is_user_allowed" + ], + "properties": [], + "exported": true, + "lineCount": 103 + } + ], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "dotenv", + "specifiers": [ + "load_dotenv" + ] + }, + { + "source": ".utils", + "specifiers": [ + "ccbot_dir" + ] + } + ], + "exports": [ + "Config" + ], + "totalLines": 131, + "hasStructuralAnalysis": true + }, + "src/ccbot/errors.py": { + "filePath": "src/ccbot/errors.py", + "contentHash": "3be0cfe46bcf2ce74ab29eae57f3519eaf234c9a120b0f5772fe1b3dd174b838", + "functions": [ + { + "name": "log_exception", + "params": [ + "log", + "message", + "exc", + "level" + ], + "returnType": "None", + "exported": true, + "lineCount": 12 + }, + { + "name": "reraise_retry_after", + "params": [ + "exc" + ], + "returnType": "None", + "exported": true, + "lineCount": 4 + }, + { + "name": "retry_after_seconds", + "params": [ + "exc" + ], + "returnType": "float", + "exported": true, + "lineCount": 9 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "datetime", + "specifiers": [ + "timedelta" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "telegram.error", + "specifiers": [ + "BadRequest", + "Forbidden", + "NetworkError", + "RetryAfter", + "TelegramError", + "TimedOut" + ] + } + ], + "exports": [ + "log_exception", + "reraise_retry_after", + "retry_after_seconds" + ], + "totalLines": 78, + "hasStructuralAnalysis": true + }, + "src/ccbot/fonts/LICENSE-JetBrainsMono.txt": { + "filePath": "src/ccbot/fonts/LICENSE-JetBrainsMono.txt", + "contentHash": "3d5fbb2d0e83919c8d9c6aa7a30e9554ef3448b0270f52a7520d49cc9ffa588f", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 94, + "hasStructuralAnalysis": false + }, + "src/ccbot/fonts/LICENSE-NotoSansMono.txt": { + "filePath": "src/ccbot/fonts/LICENSE-NotoSansMono.txt", + "contentHash": "88f117575237307bdd86a17ef15e21790fc9a662fe4dfb103ca1ca077f0d9982", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 93, + "hasStructuralAnalysis": false + }, + "src/ccbot/fonts/LICENSE-Symbola.txt": { + "filePath": "src/ccbot/fonts/LICENSE-Symbola.txt", + "contentHash": "cb8625185f5f10437b20755200acbdefc8b69504ff78a2aa4988a0110ca0275e", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 9, + "hasStructuralAnalysis": false + }, + "src/ccbot/fonts/NotoSansMonoCJKsc-Regular.otf": { + "filePath": "src/ccbot/fonts/NotoSansMonoCJKsc-Regular.otf", + "contentHash": "7d8cc59fb59b82034c1a81348cba552bd0c18f9e33ad9d3753c9defb9136bff2", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 215957, + "hasStructuralAnalysis": false + }, + "src/ccbot/handlers/__init__.py": { + "filePath": "src/ccbot/handlers/__init__.py", + "contentHash": "898107b0c120c7dd27d72f82816bcd65c399d26c219d7e9501cdcd53a3337994", + "functions": [], + "classes": [], + "imports": [], + "exports": [], + "totalLines": 21, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/auth.py": { + "filePath": "src/ccbot/handlers/auth.py", + "contentHash": "ba6c0d835659c3a3f4868a438563bf04da1ac9b69e704fe2cd20370b5a289ba2", + "functions": [], + "classes": [], + "imports": [ + { + "source": "..session_guard", + "specifiers": [ + "get_thread_id", + "is_user_allowed" + ] + } + ], + "exports": [], + "totalLines": 13, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_data.py": { + "filePath": "src/ccbot/handlers/callback_data.py", + "contentHash": "e86576598df0778e9831c132522a13940e2d18e36d48e5be11ea7274ecb5c05a", + "functions": [ + { + "name": "safe_callback_data", + "params": [ + "cb" + ], + "returnType": "str", + "exported": true, + "lineCount": 21 + }, + { + "name": "encode_history", + "params": [ + "cb" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "encode_history_page", + "params": [ + "older", + "page", + "window_id", + "start_byte", + "end_byte" + ], + "returnType": "str", + "exported": true, + "lineCount": 17 + }, + { + "name": "parse_history", + "params": [ + "data" + ], + "returnType": "HistoryCallback | None", + "exported": true, + "lineCount": 37 + }, + { + "name": "encode_dir_select", + "params": [ + "index" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "parse_dir_select", + "params": [ + "data" + ], + "returnType": "DirSelectCallback | None", + "exported": true, + "lineCount": 7 + }, + { + "name": "encode_dir_page", + "params": [ + "page" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "parse_dir_page", + "params": [ + "data" + ], + "returnType": "DirPageCallback | None", + "exported": true, + "lineCount": 7 + }, + { + "name": "encode_win_bind", + "params": [ + "index" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "parse_win_bind", + "params": [ + "data" + ], + "returnType": "WinBindCallback | None", + "exported": true, + "lineCount": 7 + }, + { + "name": "encode_session_select", + "params": [ + "index" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "parse_session_select", + "params": [ + "data" + ], + "returnType": "SessionSelectCallback | None", + "exported": true, + "lineCount": 7 + }, + { + "name": "encode_screenshot_refresh", + "params": [ + "window_id" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "parse_screenshot_refresh", + "params": [ + "data" + ], + "returnType": "ScreenshotRefreshCallback | None", + "exported": true, + "lineCount": 10 + }, + { + "name": "encode_ask", + "params": [ + "prefix", + "window_id" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "parse_ask", + "params": [ + "data", + "prefix" + ], + "returnType": "AskCallback | None", + "exported": true, + "lineCount": 11 + }, + { + "name": "encode_key", + "params": [ + "key_id", + "window_id" + ], + "returnType": "str", + "exported": true, + "lineCount": 2 + }, + { + "name": "parse_key", + "params": [ + "data" + ], + "returnType": "KeyCallback | None", + "exported": true, + "lineCount": 16 + } + ], + "classes": [ + { + "name": "HistoryCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "older", + "page", + "window_id", + "start_byte", + "end_byte" + ], + "exported": true, + "lineCount": 21 + }, + { + "name": "DirSelectCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "index" + ], + "exported": true, + "lineCount": 11 + }, + { + "name": "DirPageCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "page" + ], + "exported": true, + "lineCount": 11 + }, + { + "name": "WinBindCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "index" + ], + "exported": true, + "lineCount": 11 + }, + { + "name": "SessionSelectCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "index" + ], + "exported": true, + "lineCount": 11 + }, + { + "name": "AskCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "prefix", + "window_id" + ], + "exported": true, + "lineCount": 14 + }, + { + "name": "ScreenshotRefreshCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "window_id" + ], + "exported": true, + "lineCount": 11 + }, + { + "name": "KeyCallback", + "methods": [ + "__post_init__", + "to_old_string" + ], + "properties": [ + "key_id", + "window_id" + ], + "exported": true, + "lineCount": 14 + } + ], + "imports": [ + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "typing", + "specifiers": [ + "Union" + ] + } + ], + "exports": [ + "HistoryCallback", + "DirSelectCallback", + "DirPageCallback", + "WinBindCallback", + "SessionSelectCallback", + "AskCallback", + "ScreenshotRefreshCallback", + "KeyCallback", + "safe_callback_data", + "encode_history", + "encode_history_page", + "parse_history", + "encode_dir_select", + "parse_dir_select", + "encode_dir_page", + "parse_dir_page", + "encode_win_bind", + "parse_win_bind", + "encode_session_select", + "parse_session_select", + "encode_screenshot_refresh", + "parse_screenshot_refresh", + "encode_ask", + "parse_ask", + "encode_key", + "parse_key" + ], + "totalLines": 429, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_directory.py": { + "filePath": "src/ccbot/handlers/callback_directory.py", + "contentHash": "17ee2e9f1313162f294278ce834fbc22d7aae3349630c0ad260e2d4610aad634", + "functions": [ + { + "name": "_handle_dir_select", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 49 + }, + { + "name": "_handle_dir_up", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 31 + }, + { + "name": "_handle_dir_page", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 30 + }, + { + "name": "_handle_dir_confirm", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 42 + }, + { + "name": "_handle_dir_cancel", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 15 + } + ], + "classes": [], + "imports": [ + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "telegram", + "specifiers": [ + "CallbackQuery", + "Update", + "User" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "DirPageCallback", + "DirSelectCallback", + "parse_dir_page", + "parse_dir_select" + ] + }, + { + "source": ".callback_topic_guard", + "specifiers": [ + "_check_same_topic", + "_clear_pending_thread" + ] + }, + { + "source": ".directory_browser", + "specifiers": [ + "BROWSE_DIRS_KEY", + "BROWSE_PAGE_KEY", + "BROWSE_PATH_KEY", + "SESSIONS_KEY", + "STATE_KEY", + "STATE_SELECTING_SESSION", + "build_directory_browser", + "build_session_picker", + "clear_browse_state" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "safe_edit" + ] + }, + { + "source": ".window_bind", + "specifiers": [ + "create_and_bind_window" + ] + } + ], + "exports": [ + "_handle_dir_select", + "_handle_dir_up", + "_handle_dir_page", + "_handle_dir_confirm", + "_handle_dir_cancel" + ], + "totalLines": 208, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_history.py": { + "filePath": "src/ccbot/handlers/callback_history.py", + "contentHash": "1a29c1d0c3cb22495afeb997dbe7c30213e732dc34076e02088cb860e4257e07", + "functions": [ + { + "name": "_handle_history", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 28 + } + ], + "classes": [], + "imports": [ + { + "source": "telegram", + "specifiers": [ + "CallbackQuery", + "Update", + "User" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "HistoryCallback", + "parse_history" + ] + }, + { + "source": ".history", + "specifiers": [ + "send_history" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "safe_edit" + ] + } + ], + "exports": [ + "_handle_history" + ], + "totalLines": 42, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_interactive.py": { + "filePath": "src/ccbot/handlers/callback_interactive.py", + "contentHash": "3645eb50227919b5c22fa52d49a17a897d8e81b5ae4051e62b82db28a50f3302", + "functions": [ + { + "name": "_handle_ask", + "params": [ + "prefix", + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 31 + } + ], + "classes": [ + { + "name": "_AskAction", + "methods": [], + "properties": [ + "tmux_key", + "answer", + "clear" + ], + "exported": true, + "lineCount": 6 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "telegram", + "specifiers": [ + "CallbackQuery", + "Update", + "User" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..session_guard", + "specifiers": [ + "get_thread_id" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "CB_ASK_DOWN", + "CB_ASK_ENTER", + "CB_ASK_ESC", + "CB_ASK_LEFT", + "CB_ASK_REFRESH", + "CB_ASK_RIGHT", + "CB_ASK_SPACE", + "CB_ASK_TAB", + "CB_ASK_UP", + "AskCallback", + "parse_ask" + ] + }, + { + "source": ".interactive_ui", + "specifiers": [ + "clear_interactive_msg", + "handle_interactive_ui" + ] + } + ], + "exports": [ + "_AskAction", + "_handle_ask" + ], + "totalLines": 82, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_pickers.py": { + "filePath": "src/ccbot/handlers/callback_pickers.py", + "contentHash": "1c06da5a69e1b4062b6467d2939238c02d8bc865d0030b9002d365cc549b5167", + "functions": [ + { + "name": "_handle_session_select", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 42 + }, + { + "name": "_handle_session_new", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 21 + }, + { + "name": "_handle_session_cancel", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 17 + }, + { + "name": "_handle_win_bind", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 65 + }, + { + "name": "_handle_win_new", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 22 + }, + { + "name": "_handle_win_cancel", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 15 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "telegram", + "specifiers": [ + "CallbackQuery", + "Update", + "User" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..session_guard", + "specifiers": [ + "get_thread_id" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "SessionSelectCallback", + "WinBindCallback", + "parse_session_select", + "parse_win_bind" + ] + }, + { + "source": ".callback_topic_guard", + "specifiers": [ + "_check_same_topic", + "_clear_pending_thread" + ] + }, + { + "source": ".directory_browser", + "specifiers": [ + "BROWSE_DIRS_KEY", + "BROWSE_PAGE_KEY", + "BROWSE_PATH_KEY", + "SESSIONS_KEY", + "STATE_BROWSING_DIRECTORY", + "STATE_KEY", + "UNBOUND_WINDOWS_KEY", + "build_directory_browser", + "clear_session_picker_state", + "clear_window_picker_state" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "safe_edit", + "safe_send" + ] + }, + { + "source": ".window_bind", + "specifiers": [ + "create_and_bind_window" + ] + } + ], + "exports": [ + "_handle_session_select", + "_handle_session_new", + "_handle_session_cancel", + "_handle_win_bind", + "_handle_win_new", + "_handle_win_cancel" + ], + "totalLines": 231, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_router.py": { + "filePath": "src/ccbot/handlers/callback_router.py", + "contentHash": "753df3dacb3142ad792c3d59f8c5fb2ebb26344929c402dca1a1eff733819a51", + "functions": [ + { + "name": "_handle_noop", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 9 + }, + { + "name": "_match_route", + "params": [ + "data" + ], + "returnType": "RouteHandler | None", + "exported": true, + "lineCount": 9 + }, + { + "name": "callback_handler", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 25 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "functools", + "specifiers": [ + "partial" + ] + }, + { + "source": "telegram", + "specifiers": [ + "CallbackQuery", + "Update", + "User" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..session_guard", + "specifiers": [ + "get_thread_id", + "require_user" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "CB_DIR_CANCEL", + "CB_DIR_CONFIRM", + "CB_DIR_PAGE", + "CB_DIR_SELECT", + "CB_DIR_UP", + "CB_HISTORY_NEXT", + "CB_HISTORY_PREV", + "CB_KEYS_PREFIX", + "CB_SCREENSHOT_REFRESH", + "CB_SESSION_CANCEL", + "CB_SESSION_NEW", + "CB_SESSION_SELECT", + "CB_WIN_BIND", + "CB_WIN_CANCEL", + "CB_WIN_NEW" + ] + }, + { + "source": ".callback_directory", + "specifiers": [ + "_handle_dir_cancel", + "_handle_dir_confirm", + "_handle_dir_page", + "_handle_dir_select", + "_handle_dir_up" + ] + }, + { + "source": ".callback_history", + "specifiers": [ + "_handle_history" + ] + }, + { + "source": ".callback_interactive", + "specifiers": [ + "_ASK_ACTIONS", + "_handle_ask" + ] + }, + { + "source": ".callback_pickers", + "specifiers": [ + "_handle_session_cancel", + "_handle_session_new", + "_handle_session_select", + "_handle_win_bind", + "_handle_win_cancel", + "_handle_win_new" + ] + }, + { + "source": ".callback_screenshot", + "specifiers": [ + "_handle_key", + "_handle_screenshot_refresh" + ] + }, + { + "source": ".callback_topic_guard", + "specifiers": [ + "RouteHandler" + ] + } + ], + "exports": [ + "_handle_noop", + "_match_route", + "callback_handler" + ], + "totalLines": 142, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_screenshot.py": { + "filePath": "src/ccbot/handlers/callback_screenshot.py", + "contentHash": "3ced9686bf0ce9ec365276336e0475f23a4592d3dd8c122203ab922f37692168", + "functions": [ + { + "name": "_handle_screenshot_refresh", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 36 + }, + { + "name": "_handle_key", + "params": [ + "update", + "context", + "user", + "query", + "data" + ], + "returnType": "None", + "exported": true, + "lineCount": 45 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "io", + "specifiers": [ + "io" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "telegram", + "specifiers": [ + "CallbackQuery", + "InputMediaDocument", + "Update", + "User" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..screenshot", + "specifiers": [ + "text_to_image" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "KeyCallback", + "ScreenshotRefreshCallback", + "parse_key", + "parse_screenshot_refresh" + ] + }, + { + "source": ".screenshot_controls", + "specifiers": [ + "KEY_LABELS", + "KEYS_SEND_MAP", + "build_screenshot_keyboard" + ] + } + ], + "exports": [ + "_handle_screenshot_refresh", + "_handle_key" + ], + "totalLines": 108, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/callback_topic_guard.py": { + "filePath": "src/ccbot/handlers/callback_topic_guard.py", + "contentHash": "6a9a156c9cd019e84b6eeb1738f4bd7d2aa9c878b12774866b2242ead0bb017d", + "functions": [ + { + "name": "_check_same_topic", + "params": [ + "update", + "context", + "query", + "recover", + "stale_label", + "on_stale" + ], + "returnType": "_TopicCheck", + "exported": true, + "lineCount": 29 + }, + { + "name": "_clear_pending_thread", + "params": [ + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 5 + } + ], + "classes": [ + { + "name": "_TopicCheck", + "methods": [], + "properties": [ + "ok", + "pending_tid" + ], + "exported": true, + "lineCount": 5 + } + ], + "imports": [ + { + "source": "collections.abc", + "specifiers": [ + "Awaitable", + "Callable" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "telegram", + "specifiers": [ + "CallbackQuery", + "Update", + "User" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..session_guard", + "specifiers": [ + "get_thread_id" + ] + } + ], + "exports": [ + "_TopicCheck", + "_check_same_topic", + "_clear_pending_thread" + ], + "totalLines": 68, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/capture_registry.py": { + "filePath": "src/ccbot/handlers/capture_registry.py", + "contentHash": "52f72321a724a7a3b7a9cb9fd738a0051591d207a7f49fd3e58ceaa41c21f85c", + "functions": [], + "classes": [ + { + "name": "CaptureTaskRegistry", + "methods": [ + "__init__", + "register", + "cancel", + "discard" + ], + "properties": [], + "exported": true, + "lineCount": 38 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + } + ], + "exports": [ + "CaptureTaskRegistry" + ], + "totalLines": 58, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/cleanup.py": { + "filePath": "src/ccbot/handlers/cleanup.py", + "contentHash": "b419b49da14dff952e52b3f515e24066804fec9e3971bd2d4565d9f86ea975c9", + "functions": [ + { + "name": "clear_topic_state", + "params": [ + "user_id", + "thread_id", + "bot", + "user_data" + ], + "returnType": "None", + "exported": true, + "lineCount": 36 + } + ], + "classes": [], + "imports": [ + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot" + ] + }, + { + "source": ".capture_registry", + "specifiers": [ + "capture_tasks" + ] + }, + { + "source": ".interactive_ui", + "specifiers": [ + "clear_interactive_msg" + ] + }, + { + "source": ".message_queue", + "specifiers": [ + "clear_status_msg_info", + "clear_tool_msg_ids_for_topic" + ] + } + ], + "exports": [ + "clear_topic_state" + ], + "totalLines": 55, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/command_handlers.py": { + "filePath": "src/ccbot/handlers/command_handlers.py", + "contentHash": "ae8dc025e9e723667f39b600f2028e03fc70ba12cca152573d12f9dc7ca345ac", + "functions": [ + { + "name": "start_command", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 13 + }, + { + "name": "history_command", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 8 + }, + { + "name": "screenshot_command", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 21 + }, + { + "name": "unbind_command", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 33 + }, + { + "name": "esc_command", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 10 + }, + { + "name": "usage_command", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 35 + }, + { + "name": "topic_closed_handler", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 53 + }, + { + "name": "topic_edited_handler", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 39 + }, + { + "name": "forward_command_handler", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 50 + }, + { + "name": "unsupported_content_handler", + "params": [ + "update", + "_context" + ], + "returnType": "None", + "exported": true, + "lineCount": 15 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "io", + "specifiers": [ + "io" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Update" + ] + }, + { + "source": "telegram.constants", + "specifiers": [ + "ChatAction" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..screenshot", + "specifiers": [ + "text_to_image" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..session_guard", + "specifiers": [ + "get_thread_id", + "require_bound_window_id", + "require_session", + "require_user" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".cleanup", + "specifiers": [ + "clear_topic_state" + ] + }, + { + "source": ".directory_browser", + "specifiers": [ + "clear_browse_state" + ] + }, + { + "source": ".history", + "specifiers": [ + "send_history" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "safe_reply" + ] + }, + { + "source": ".screenshot_controls", + "specifiers": [ + "build_screenshot_keyboard" + ] + } + ], + "exports": [ + "start_command", + "history_command", + "screenshot_command", + "unbind_command", + "esc_command", + "usage_command", + "topic_closed_handler", + "topic_edited_handler", + "forward_command_handler", + "unsupported_content_handler" + ], + "totalLines": 333, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/directory_browser.py": { + "filePath": "src/ccbot/handlers/directory_browser.py", + "contentHash": "c2108727beb985bdb19a4d9946dd66c85abe092020daa533663b6f307a60a7da", + "functions": [ + { + "name": "clear_browse_state", + "params": [ + "user_data" + ], + "returnType": "None", + "exported": true, + "lineCount": 7 + }, + { + "name": "clear_window_picker_state", + "params": [ + "user_data" + ], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "clear_session_picker_state", + "params": [ + "user_data" + ], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "build_window_picker", + "params": [ + "windows" + ], + "returnType": "tuple[str, InlineKeyboardMarkup, list[str]]", + "exported": true, + "lineCount": 48 + }, + { + "name": "build_directory_browser", + "params": [ + "current_path", + "page" + ], + "returnType": "tuple[str, InlineKeyboardMarkup, list[str]]", + "exported": true, + "lineCount": 88 + }, + { + "name": "_relative_time", + "params": [ + "file_path" + ], + "returnType": "str", + "exported": true, + "lineCount": 17 + }, + { + "name": "build_session_picker", + "params": [ + "sessions" + ], + "returnType": "tuple[str, InlineKeyboardMarkup]", + "exported": true, + "lineCount": 49 + } + ], + "classes": [], + "imports": [ + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "time", + "specifiers": [ + "time" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "telegram", + "specifiers": [ + "InlineKeyboardButton", + "InlineKeyboardMarkup" + ] + }, + { + "source": "..config", + "specifiers": [ + "config" + ] + }, + { + "source": "..session", + "specifiers": [ + "ClaudeSession" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "CB_DIR_CANCEL", + "CB_DIR_CONFIRM", + "CB_DIR_UP", + "CB_SESSION_CANCEL", + "CB_SESSION_NEW", + "CB_WIN_CANCEL", + "CB_WIN_NEW", + "DirPageCallback", + "DirSelectCallback", + "SessionSelectCallback", + "WinBindCallback", + "safe_callback_data" + ] + } + ], + "exports": [ + "clear_browse_state", + "clear_window_picker_state", + "clear_session_picker_state", + "build_window_picker", + "build_directory_browser", + "_relative_time", + "build_session_picker" + ], + "totalLines": 285, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/history.py": { + "filePath": "src/ccbot/handlers/history.py", + "contentHash": "16dee2aa6943d223e9fa9391c522f36e20f39f2ac1584cf08a54b5b1ad0e1733", + "functions": [ + { + "name": "_build_history_keyboard", + "params": [ + "window_id", + "page_index", + "total_pages", + "start_byte", + "end_byte" + ], + "returnType": "InlineKeyboardMarkup | None", + "exported": true, + "lineCount": 56 + }, + { + "name": "send_history", + "params": [ + "target", + "window_id", + "offset", + "edit", + "start_byte", + "end_byte", + "user_id", + "bot", + "message_thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 166 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot", + "InlineKeyboardButton", + "InlineKeyboardMarkup" + ] + }, + { + "source": "..config", + "specifiers": [ + "config" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..telegram_sender", + "specifiers": [ + "split_message" + ] + }, + { + "source": "..transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "HistoryCallback", + "safe_callback_data" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "safe_edit", + "safe_reply", + "safe_send" + ] + } + ], + "exports": [ + "_build_history_keyboard", + "send_history" + ], + "totalLines": 249, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/interactive_ui.py": { + "filePath": "src/ccbot/handlers/interactive_ui.py", + "contentHash": "8fa5f72a9dd88bec91e6dcb79380c0c9d86e8d6a29be4db6b4f7a4436a624170", + "functions": [ + { + "name": "get_interactive_window", + "params": [ + "user_id", + "thread_id" + ], + "returnType": "str | None", + "exported": true, + "lineCount": 3 + }, + { + "name": "set_interactive_mode", + "params": [ + "user_id", + "window_id", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 13 + }, + { + "name": "clear_interactive_mode", + "params": [ + "user_id", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 4 + }, + { + "name": "get_interactive_msg_id", + "params": [ + "user_id", + "thread_id" + ], + "returnType": "int | None", + "exported": true, + "lineCount": 3 + }, + { + "name": "_build_interactive_keyboard", + "params": [ + "window_id", + "ui_name" + ], + "returnType": "InlineKeyboardMarkup", + "exported": true, + "lineCount": 46 + }, + { + "name": "handle_interactive_ui", + "params": [ + "bot", + "user_id", + "window_id", + "thread_id" + ], + "returnType": "bool", + "exported": true, + "lineCount": 107 + }, + { + "name": "clear_interactive_msg", + "params": [ + "user_id", + "bot", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 21 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot", + "InlineKeyboardButton", + "InlineKeyboardMarkup" + ] + }, + { + "source": "telegram.error", + "specifiers": [ + "BadRequest" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..terminal_parser", + "specifiers": [ + "extract_interactive_content", + "is_interactive_ui" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "CB_ASK_DOWN", + "CB_ASK_ENTER", + "CB_ASK_ESC", + "CB_ASK_LEFT", + "CB_ASK_REFRESH", + "CB_ASK_RIGHT", + "CB_ASK_SPACE", + "CB_ASK_TAB", + "CB_ASK_UP", + "AskCallback", + "safe_callback_data" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "NO_LINK_PREVIEW" + ] + } + ], + "exports": [ + "get_interactive_window", + "set_interactive_mode", + "clear_interactive_mode", + "get_interactive_msg_id", + "_build_interactive_keyboard", + "handle_interactive_ui", + "clear_interactive_msg" + ], + "totalLines": 261, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/message_handlers.py": { + "filePath": "src/ccbot/handlers/message_handlers.py", + "contentHash": "816e01868ca5db4598aa87970ac906ff1c8ba4416f2449aafcd8f0cefcee7069", + "functions": [ + { + "name": "photo_handler", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 54 + }, + { + "name": "voice_handler", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 60 + }, + { + "name": "cancel_bash_capture", + "params": [ + "user_id", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "capture_bash_output", + "params": [ + "bot", + "user_id", + "thread_id", + "window_id", + "command" + ], + "returnType": "None", + "exported": true, + "lineCount": 78 + }, + { + "name": "text_handler", + "params": [ + "update", + "context" + ], + "returnType": "None", + "exported": true, + "lineCount": 200 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "time", + "specifiers": [ + "time" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot", + "Update" + ] + }, + { + "source": "telegram.constants", + "specifiers": [ + "ChatAction" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..config", + "specifiers": [ + "config" + ] + }, + { + "source": "..markdown_v2", + "specifiers": [ + "convert_markdown" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..session_guard", + "specifiers": [ + "get_thread_id", + "require_session", + "require_user" + ] + }, + { + "source": "..terminal_parser", + "specifiers": [ + "extract_bash_output", + "is_interactive_ui" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": "..transcribe", + "specifiers": [ + "transcribe_voice" + ] + }, + { + "source": "..utils", + "specifiers": [ + "ccbot_dir" + ] + }, + { + "source": ".capture_registry", + "specifiers": [ + "capture_tasks" + ] + }, + { + "source": ".directory_browser", + "specifiers": [ + "BROWSE_DIRS_KEY", + "BROWSE_PAGE_KEY", + "BROWSE_PATH_KEY", + "STATE_BROWSING_DIRECTORY", + "STATE_KEY", + "STATE_SELECTING_SESSION", + "STATE_SELECTING_WINDOW", + "UNBOUND_WINDOWS_KEY", + "build_directory_browser", + "build_window_picker", + "clear_browse_state", + "clear_session_picker_state", + "clear_window_picker_state" + ] + }, + { + "source": ".interactive_ui", + "specifiers": [ + "get_interactive_window", + "handle_interactive_ui" + ] + }, + { + "source": ".message_queue", + "specifiers": [ + "clear_status_msg_info", + "enqueue_status_update" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "NO_LINK_PREVIEW", + "safe_reply", + "send_with_fallback" + ] + } + ], + "exports": [ + "photo_handler", + "voice_handler", + "cancel_bash_capture", + "capture_bash_output", + "text_handler" + ], + "totalLines": 459, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/message_queue.py": { + "filePath": "src/ccbot/handlers/message_queue.py", + "contentHash": "0ba409631d35e67dabb134ee6e0c8508ae013c9e4f9199dd81a94871d3efbdc5", + "functions": [ + { + "name": "get_message_queue", + "params": [ + "user_id" + ], + "returnType": "asyncio.Queue[MessageTask] | None", + "exported": true, + "lineCount": 3 + }, + { + "name": "get_or_create_queue", + "params": [ + "bot", + "user_id" + ], + "returnType": "asyncio.Queue[MessageTask]", + "exported": true, + "lineCount": 3 + }, + { + "name": "clear_status_msg_info", + "params": [ + "user_id", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "clear_tool_msg_ids_for_topic", + "params": [ + "user_id", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "shutdown_workers", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "_inspect_queue", + "params": [ + "queue" + ], + "returnType": "list[MessageTask]", + "exported": true, + "lineCount": 13 + }, + { + "name": "_can_merge_tasks", + "params": [ + "base", + "candidate" + ], + "returnType": "bool", + "exported": true, + "lineCount": 14 + }, + { + "name": "_merge_content_tasks", + "params": [ + "queue", + "first", + "lock" + ], + "returnType": "tuple[MessageTask, int]", + "exported": true, + "lineCount": 62 + }, + { + "name": "_process_content_with_retry", + "params": [ + "bot", + "user_id", + "task", + "max_attempts" + ], + "returnType": "None", + "exported": true, + "lineCount": 36 + }, + { + "name": "_message_queue_worker", + "params": [ + "bot", + "user_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 92 + }, + { + "name": "_send_task_images", + "params": [ + "bot", + "chat_id", + "task" + ], + "returnType": "None", + "exported": true, + "lineCount": 15 + }, + { + "name": "_process_content_task", + "params": [ + "bot", + "user_id", + "task" + ], + "returnType": "None", + "exported": true, + "lineCount": 67 + }, + { + "name": "_convert_status_to_content", + "params": [ + "bot", + "user_id", + "thread_id_or_0", + "window_id", + "content_text" + ], + "returnType": "int | None", + "exported": true, + "lineCount": 36 + }, + { + "name": "_process_status_update_task", + "params": [ + "bot", + "user_id", + "task" + ], + "returnType": "None", + "exported": true, + "lineCount": 52 + }, + { + "name": "_do_send_status_message", + "params": [ + "bot", + "user_id", + "thread_id_or_0", + "window_id", + "text" + ], + "returnType": "None", + "exported": true, + "lineCount": 40 + }, + { + "name": "_do_clear_status_message", + "params": [ + "bot", + "user_id", + "thread_id_or_0" + ], + "returnType": "None", + "exported": true, + "lineCount": 19 + }, + { + "name": "_check_and_send_status", + "params": [ + "bot", + "user_id", + "window_id", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 23 + }, + { + "name": "enqueue_content_message", + "params": [ + "bot", + "user_id", + "window_id", + "parts", + "tool_use_id", + "content_type", + "text", + "thread_id", + "image_data" + ], + "returnType": "None", + "exported": true, + "lineCount": 31 + }, + { + "name": "enqueue_status_update", + "params": [ + "bot", + "user_id", + "window_id", + "status_text", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 31 + } + ], + "classes": [ + { + "name": "MessageTask", + "methods": [], + "properties": [ + "task_type", + "text", + "window_id", + "parts", + "tool_use_id", + "content_type", + "thread_id", + "image_data" + ], + "exported": true, + "lineCount": 12 + }, + { + "name": "StatusTracker", + "methods": [ + "__init__", + "set_status", + "get_status", + "pop_status", + "set_tool_msg_id", + "get_tool_msg_id", + "pop_tool_msg_id", + "clear_tools_for_topic", + "clear_status_for_topic", + "clear_all" + ], + "properties": [], + "exported": true, + "lineCount": 62 + }, + { + "name": "MessageQueueManager", + "methods": [ + "__init__", + "get_queue", + "get_or_create_queue", + "set_flood", + "is_flooded", + "flood_remaining", + "clear_flood", + "get_lock", + "shutdown" + ], + "properties": [], + "exported": true, + "lineCount": 57 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "time", + "specifiers": [ + "time" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass", + "field" + ] + }, + { + "source": "typing", + "specifiers": [ + "Literal" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot" + ] + }, + { + "source": "telegram.constants", + "specifiers": [ + "ChatAction" + ] + }, + { + "source": "..errors", + "specifiers": [ + "RetryAfter", + "TelegramError", + "log_exception", + "retry_after_seconds" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..terminal_parser", + "specifiers": [ + "parse_status_line" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "edit_with_fallback", + "send_photo", + "send_with_fallback" + ] + } + ], + "exports": [ + "MessageTask", + "StatusTracker", + "MessageQueueManager", + "get_message_queue", + "get_or_create_queue", + "clear_status_msg_info", + "clear_tool_msg_ids_for_topic", + "shutdown_workers", + "_inspect_queue", + "_can_merge_tasks", + "_merge_content_tasks", + "_process_content_with_retry", + "_message_queue_worker", + "_send_task_images", + "_process_content_task", + "_convert_status_to_content", + "_process_status_update_task", + "_do_send_status_message", + "_do_clear_status_message", + "_check_and_send_status", + "enqueue_content_message", + "enqueue_status_update" + ], + "totalLines": 783, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/message_sender.py": { + "filePath": "src/ccbot/handlers/message_sender.py", + "contentHash": "5bc9c54c64b1f35545c8f5b8d644a3a07136f340a6f4b76635832158a9498b73", + "functions": [ + { + "name": "strip_sentinels", + "params": [ + "text" + ], + "returnType": "str", + "exported": true, + "lineCount": 8 + }, + { + "name": "_run_markdown_fallback", + "params": [ + "text", + "markdown_call", + "plain_call", + "on_failure", + "reraise_final", + "failure_level", + "failure_message" + ], + "returnType": "T | None", + "exported": true, + "lineCount": 63 + }, + { + "name": "send_with_fallback", + "params": [ + "bot", + "chat_id", + "text", + "message_thread_id" + ], + "returnType": "Message | None", + "exported": true, + "lineCount": 46 + }, + { + "name": "edit_with_fallback", + "params": [ + "bot", + "chat_id", + "message_id", + "text" + ], + "returnType": "bool", + "exported": true, + "lineCount": 43 + }, + { + "name": "send_photo", + "params": [ + "bot", + "chat_id", + "image_data", + "message_thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 44 + }, + { + "name": "safe_reply", + "params": [ + "message", + "text" + ], + "returnType": "Message", + "exported": true, + "lineCount": 25 + }, + { + "name": "safe_edit", + "params": [ + "target", + "text" + ], + "returnType": "None", + "exported": true, + "lineCount": 23 + }, + { + "name": "safe_send", + "params": [ + "bot", + "chat_id", + "text", + "message_thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 15 + } + ], + "classes": [ + { + "name": "MessageFormatter", + "methods": [ + "to_markdown_v2", + "to_plain" + ], + "properties": [ + "parse_mode" + ], + "exported": true, + "lineCount": 18 + } + ], + "imports": [ + { + "source": "io", + "specifiers": [ + "io" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "collections.abc", + "specifiers": [ + "Awaitable", + "Callable" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any", + "TypeVar" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot", + "InputMediaPhoto", + "LinkPreviewOptions", + "Message" + ] + }, + { + "source": "telegram.error", + "specifiers": [ + "RetryAfter", + "TelegramError" + ] + }, + { + "source": "..markdown_v2", + "specifiers": [ + "convert_markdown" + ] + }, + { + "source": "..transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + } + ], + "exports": [ + "strip_sentinels", + "MessageFormatter", + "_run_markdown_fallback", + "send_with_fallback", + "edit_with_fallback", + "send_photo", + "safe_reply", + "safe_edit", + "safe_send" + ], + "totalLines": 356, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/notifications.py": { + "filePath": "src/ccbot/handlers/notifications.py", + "contentHash": "caa6be8cda402e2d6f51f7ec13ebc50c642be2dcc2bb2f179342c0236bb2959d", + "functions": [ + { + "name": "handle_new_message", + "params": [ + "msg", + "bot" + ], + "returnType": "None", + "exported": true, + "lineCount": 92 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot" + ] + }, + { + "source": "..config", + "specifiers": [ + "config" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..session_monitor", + "specifiers": [ + "NewMessage" + ] + }, + { + "source": ".interactive_ui", + "specifiers": [ + "INTERACTIVE_TOOL_NAMES", + "clear_interactive_mode", + "clear_interactive_msg", + "get_interactive_msg_id", + "handle_interactive_ui", + "set_interactive_mode" + ] + }, + { + "source": ".message_queue", + "specifiers": [ + "enqueue_content_message", + "get_message_queue" + ] + }, + { + "source": ".response_builder", + "specifiers": [ + "build_response_parts" + ] + } + ], + "exports": [ + "handle_new_message" + ], + "totalLines": 124, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/response_builder.py": { + "filePath": "src/ccbot/handlers/response_builder.py", + "contentHash": "1269e88a24c19f001132e821bbfaf4624dfe9bc0872030257b18d64da4c3c7a3", + "functions": [ + { + "name": "build_response_parts", + "params": [ + "text", + "is_complete", + "content_type", + "role" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 77 + } + ], + "classes": [], + "imports": [ + { + "source": "..markdown_v2", + "specifiers": [ + "convert_markdown_tables" + ] + }, + { + "source": "..telegram_sender", + "specifiers": [ + "split_message" + ] + }, + { + "source": "..transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + } + ], + "exports": [ + "build_response_parts" + ], + "totalLines": 98, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/screenshot_controls.py": { + "filePath": "src/ccbot/handlers/screenshot_controls.py", + "contentHash": "bc9c6423149fe109c57406b62d534d5fe3f488c20a3d33dbaa45086e353e5734", + "functions": [ + { + "name": "build_screenshot_keyboard", + "params": [ + "window_id" + ], + "returnType": "InlineKeyboardMarkup", + "exported": true, + "lineCount": 26 + } + ], + "classes": [], + "imports": [ + { + "source": "telegram", + "specifiers": [ + "InlineKeyboardButton", + "InlineKeyboardMarkup" + ] + }, + { + "source": ".callback_data", + "specifiers": [ + "KeyCallback", + "ScreenshotRefreshCallback", + "safe_callback_data" + ] + } + ], + "exports": [ + "build_screenshot_keyboard" + ], + "totalLines": 70, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/status_polling.py": { + "filePath": "src/ccbot/handlers/status_polling.py", + "contentHash": "086393692ca2ba4da9821a925cfeac628d7fd60ef7b7175d92951554b7cf343f", + "functions": [ + { + "name": "update_status_message", + "params": [ + "bot", + "user_id", + "window_id", + "thread_id", + "skip_status" + ], + "returnType": "None", + "exported": true, + "lineCount": 74 + }, + { + "name": "status_poll_loop", + "params": [ + "bot" + ], + "returnType": "None", + "exported": true, + "lineCount": 85 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "time", + "specifiers": [ + "time" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Bot" + ] + }, + { + "source": "telegram.error", + "specifiers": [ + "BadRequest" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..terminal_parser", + "specifiers": [ + "is_interactive_ui", + "parse_status_line" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".cleanup", + "specifiers": [ + "clear_topic_state" + ] + }, + { + "source": ".interactive_ui", + "specifiers": [ + "clear_interactive_msg", + "get_interactive_window", + "handle_interactive_ui" + ] + }, + { + "source": ".message_queue", + "specifiers": [ + "enqueue_status_update", + "get_message_queue" + ] + } + ], + "exports": [ + "update_status_message", + "status_poll_loop" + ], + "totalLines": 207, + "hasStructuralAnalysis": true + }, + "src/ccbot/handlers/window_bind.py": { + "filePath": "src/ccbot/handlers/window_bind.py", + "contentHash": "b7c44c6b448a6b65477c51e76bd75918c122a9dfbea01f9924c8886be86b6d5a", + "functions": [ + { + "name": "create_and_bind_window", + "params": [ + "query", + "context", + "user", + "selected_path", + "pending_thread_id", + "resume_session_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 129 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "telegram.ext", + "specifiers": [ + "ContextTypes" + ] + }, + { + "source": "..session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": "..tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".message_sender", + "specifiers": [ + "safe_edit", + "safe_send" + ] + } + ], + "exports": [ + "create_and_bind_window" + ], + "totalLines": 148, + "hasStructuralAnalysis": true + }, + "src/ccbot/hook.py": { + "filePath": "src/ccbot/hook.py", + "contentHash": "6f0e214a46bc2e76adc966c96650f14b48e43fad13d475d2a13bdfd83a537bf1", + "functions": [ + { + "name": "_find_ccbot_path", + "params": [], + "returnType": "str", + "exported": true, + "lineCount": 21 + }, + { + "name": "_is_hook_installed", + "params": [ + "settings" + ], + "returnType": "bool", + "exported": true, + "lineCount": 20 + }, + { + "name": "_install_hook", + "params": [], + "returnType": "int", + "exported": true, + "lineCount": 51 + }, + { + "name": "hook_main", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 143 + } + ], + "classes": [], + "imports": [ + { + "source": "argparse", + "specifiers": [ + "argparse" + ] + }, + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "shutil", + "specifiers": [ + "shutil" + ] + }, + { + "source": "subprocess", + "specifiers": [ + "subprocess" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": ".utils", + "specifiers": [ + "LOCK_EX", + "LOCK_UN", + "flock" + ] + } + ], + "exports": [ + "_find_ccbot_path", + "_is_hook_installed", + "_install_hook", + "hook_main" + ], + "totalLines": 278, + "hasStructuralAnalysis": true + }, + "src/ccbot/main.py": { + "filePath": "src/ccbot/main.py", + "contentHash": "5f501d8dff85b194ed1eafd737b2c8fd457ac22e756507b2e470c351158671ef", + "functions": [ + { + "name": "main", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 59 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + } + ], + "exports": [ + "main" + ], + "totalLines": 76, + "hasStructuralAnalysis": true + }, + "src/ccbot/markdown_v2.py": { + "filePath": "src/ccbot/markdown_v2.py", + "contentHash": "854800b2b27c955987d00dc81a2234c390743f723a07b9abeee2bf872073fe24", + "functions": [ + { + "name": "_split_table_row", + "params": [ + "line" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 5 + }, + { + "name": "convert_markdown_tables", + "params": [ + "text" + ], + "returnType": "str", + "exported": true, + "lineCount": 72 + }, + { + "name": "_escape_mdv2", + "params": [ + "text" + ], + "returnType": "str", + "exported": true, + "lineCount": 3 + }, + { + "name": "_render_expandable_quote", + "params": [ + "m" + ], + "returnType": "str", + "exported": true, + "lineCount": 30 + }, + { + "name": "_markdownify", + "params": [ + "text" + ], + "returnType": "str", + "exported": true, + "lineCount": 17 + }, + { + "name": "convert_markdown", + "params": [ + "text" + ], + "returnType": "str", + "exported": true, + "lineCount": 31 + } + ], + "classes": [], + "imports": [ + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "mistletoe", + "specifiers": [ + "mistletoe" + ] + }, + { + "source": "mistletoe.block_token", + "specifiers": [ + "BlockCode", + "remove_token" + ] + }, + { + "source": "telegramify_markdown", + "specifiers": [ + "_update_block", + "escape_latex" + ] + }, + { + "source": "telegramify_markdown.render", + "specifiers": [ + "TelegramMarkdownRenderer" + ] + }, + { + "source": ".transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + } + ], + "exports": [ + "_split_table_row", + "convert_markdown_tables", + "_escape_mdv2", + "_render_expandable_quote", + "_markdownify", + "convert_markdown" + ], + "totalLines": 206, + "hasStructuralAnalysis": true + }, + "src/ccbot/monitor_state.py": { + "filePath": "src/ccbot/monitor_state.py", + "contentHash": "2ebb61de75dd327fbfa3f7c0cb433e2b93422597f91f649864edb85ffb99a516", + "functions": [], + "classes": [ + { + "name": "TrackedSession", + "methods": [ + "to_dict", + "from_dict" + ], + "properties": [ + "session_id", + "file_path", + "last_byte_offset" + ], + "exported": true, + "lineCount": 19 + }, + { + "name": "MonitorState", + "methods": [ + "load", + "save", + "get_session", + "update_session", + "remove_session", + "save_if_dirty" + ], + "properties": [ + "state_file", + "tracked_sessions", + "_dirty" + ], + "exported": true, + "lineCount": 68 + } + ], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "asdict", + "dataclass", + "field" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + } + ], + "exports": [ + "TrackedSession", + "MonitorState" + ], + "totalLines": 110, + "hasStructuralAnalysis": true + }, + "src/ccbot/screenshot.py": { + "filePath": "src/ccbot/screenshot.py", + "contentHash": "2bdec6abe1dc1772174a09bec4bb5d70ab1b1d939967c4ed726a2d2d2a0dfd46", + "functions": [ + { + "name": "_load_font", + "params": [ + "path", + "size" + ], + "returnType": "ImageFont.FreeTypeFont | ImageFont.ImageFont", + "exported": true, + "lineCount": 7 + }, + { + "name": "_font_tier", + "params": [ + "ch" + ], + "returnType": "int", + "exported": true, + "lineCount": 21 + }, + { + "name": "_parse_ansi_line", + "params": [ + "line" + ], + "returnType": "list[StyledSegment]", + "exported": true, + "lineCount": 36 + }, + { + "name": "_apply_ansi_codes", + "params": [ + "style", + "codes" + ], + "returnType": "TextStyle", + "exported": true, + "lineCount": 58 + }, + { + "name": "_approximate_256_color", + "params": [ + "idx" + ], + "returnType": "tuple[int, int, int]", + "exported": true, + "lineCount": 15 + }, + { + "name": "_split_line_segments_plain", + "params": [ + "line" + ], + "returnType": "list[tuple[str, int]]", + "exported": true, + "lineCount": 18 + }, + { + "name": "text_to_image", + "params": [ + "text", + "font_size", + "with_ansi" + ], + "returnType": "bytes", + "exported": true, + "lineCount": 78 + } + ], + "classes": [ + { + "name": "TextStyle", + "methods": [], + "properties": [ + "fg_color", + "bg_color" + ], + "exported": true, + "lineCount": 5 + }, + { + "name": "StyledSegment", + "methods": [], + "properties": [ + "text", + "style", + "font_tier" + ], + "exported": true, + "lineCount": 6 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "io", + "specifiers": [ + "io" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "PIL", + "specifiers": [ + "Image", + "ImageDraw", + "ImageFont" + ] + } + ], + "exports": [ + "TextStyle", + "StyledSegment", + "_load_font", + "_font_tier", + "_parse_ansi_line", + "_apply_ansi_codes", + "_approximate_256_color", + "_split_line_segments_plain", + "text_to_image" + ], + "totalLines": 337, + "hasStructuralAnalysis": true + }, + "src/ccbot/session_guard.py": { + "filePath": "src/ccbot/session_guard.py", + "contentHash": "a63e1f12037d87636bcb6ad6105e11e9016f614b27263fd8fb42ec5a0a2f28fd", + "functions": [ + { + "name": "is_user_allowed", + "params": [ + "user_id" + ], + "returnType": "bool", + "exported": true, + "lineCount": 3 + }, + { + "name": "get_thread_id", + "params": [ + "update" + ], + "returnType": "int | None", + "exported": true, + "lineCount": 14 + }, + { + "name": "require_user", + "params": [ + "update", + "reply_unauthorized", + "unauthorized_text", + "callback_unauthorized_text" + ], + "returnType": "User | None", + "exported": true, + "lineCount": 32 + }, + { + "name": "require_bound_window_id", + "params": [ + "update", + "reply_unauthorized", + "require_message", + "use_resolve", + "no_session_text", + "user" + ], + "returnType": "tuple[User, int | None, str] | None", + "exported": true, + "lineCount": 37 + }, + { + "name": "require_session", + "params": [ + "update", + "reply_unauthorized", + "require_message", + "use_resolve", + "require_named_topic", + "named_topic_text", + "no_session_text", + "missing_window_text", + "unbind_if_missing", + "user" + ], + "returnType": "SessionContext | None", + "exported": true, + "lineCount": 73 + } + ], + "classes": [ + { + "name": "SessionContext", + "methods": [], + "properties": [ + "user", + "thread_id", + "window_id", + "window" + ], + "exported": true, + "lineCount": 7 + } + ], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "telegram", + "specifiers": [ + "Update", + "User" + ] + }, + { + "source": "telegram.error", + "specifiers": [ + "RetryAfter", + "TelegramError" + ] + }, + { + "source": ".config", + "specifiers": [ + "config" + ] + }, + { + "source": ".handlers.message_sender", + "specifiers": [ + "safe_reply" + ] + }, + { + "source": ".session", + "specifiers": [ + "session_manager" + ] + }, + { + "source": ".tmux_manager", + "specifiers": [ + "TmuxWindow", + "tmux_manager" + ] + } + ], + "exports": [ + "SessionContext", + "is_user_allowed", + "get_thread_id", + "require_user", + "require_bound_window_id", + "require_session" + ], + "totalLines": 216, + "hasStructuralAnalysis": true + }, + "src/ccbot/session_map_repository.py": { + "filePath": "src/ccbot/session_map_repository.py", + "contentHash": "aa4d88acf492c54e0808640ae05820147b618e630da454f18017d0df1a2d17c8", + "functions": [], + "classes": [ + { + "name": "SessionMapRepository", + "methods": [ + "map_file", + "prefix", + "exists", + "read", + "write", + "mutate_locked" + ], + "properties": [], + "exported": true, + "lineCount": 66 + } + ], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "collections.abc", + "specifiers": [ + "Callable" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "aiofiles", + "specifiers": [ + "aiofiles" + ] + }, + { + "source": ".config", + "specifiers": [ + "config" + ] + }, + { + "source": ".utils", + "specifiers": [ + "LOCK_EX", + "LOCK_UN", + "atomic_write_json", + "flock" + ] + } + ], + "exports": [ + "SessionMapRepository" + ], + "totalLines": 89, + "hasStructuralAnalysis": true + }, + "src/ccbot/session_migration.py": { + "filePath": "src/ccbot/session_migration.py", + "contentHash": "14a74f5ce1ac486ba914a01db8b95f46580201ba3cb67094facaee047df7049e", + "functions": [ + { + "name": "is_window_id", + "params": [ + "key" + ], + "returnType": "bool", + "exported": true, + "lineCount": 3 + }, + { + "name": "build_old_names_snapshot", + "params": [ + "window_states", + "window_display_names" + ], + "returnType": "dict[str, str]", + "exported": true, + "lineCount": 19 + }, + { + "name": "apply_startup_state_migration", + "params": [ + "window_states", + "thread_bindings", + "user_window_offsets", + "window_display_names", + "live_by_name", + "live_ids" + ], + "returnType": "StartupMigrationResult", + "exported": true, + "lineCount": 138 + }, + { + "name": "migrate_session_map_old_format", + "params": [ + "session_map", + "live_by_name", + "session_prefix" + ], + "returnType": "bool", + "exported": true, + "lineCount": 37 + }, + { + "name": "cleanup_stale_session_map_entries", + "params": [ + "session_map", + "live_ids", + "session_prefix" + ], + "returnType": "bool", + "exported": true, + "lineCount": 22 + }, + { + "name": "state_needs_legacy_migration", + "params": [ + "window_states", + "thread_bindings" + ], + "returnType": "bool", + "exported": true, + "lineCount": 13 + } + ], + "classes": [ + { + "name": "StartupMigrationResult", + "methods": [], + "properties": [ + "window_states", + "thread_bindings", + "user_window_offsets", + "window_display_names", + "changed", + "events" + ], + "exported": true, + "lineCount": 9 + } + ], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass", + "field" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + } + ], + "exports": [ + "is_window_id", + "StartupMigrationResult", + "build_old_names_snapshot", + "apply_startup_state_migration", + "migrate_session_map_old_format", + "cleanup_stale_session_map_entries", + "state_needs_legacy_migration" + ], + "totalLines": 289, + "hasStructuralAnalysis": true + }, + "src/ccbot/session_monitor.py": { + "filePath": "src/ccbot/session_monitor.py", + "contentHash": "bc9a758bd62d5ee2de94510f2c2c6e6e118735e160419987638148f3496daf83", + "functions": [], + "classes": [ + { + "name": "SessionInfo", + "methods": [], + "properties": [ + "session_id", + "file_path" + ], + "exported": true, + "lineCount": 5 + }, + { + "name": "NewMessage", + "methods": [], + "properties": [ + "session_id", + "text", + "is_complete", + "content_type", + "tool_use_id", + "role", + "tool_name", + "image_data" + ], + "exported": true, + "lineCount": 11 + }, + { + "name": "SessionMonitor", + "methods": [ + "__init__", + "set_message_callback", + "_get_active_cwds", + "scan_projects", + "_read_new_lines", + "check_for_updates", + "_load_current_session_map", + "_cleanup_all_stale_sessions", + "_detect_and_cleanup_changes", + "_monitor_loop", + "start", + "stop" + ], + "properties": [], + "exported": true, + "lineCount": 507 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any", + "Awaitable", + "Callable" + ] + }, + { + "source": "aiofiles", + "specifiers": [ + "aiofiles" + ] + }, + { + "source": ".config", + "specifiers": [ + "config" + ] + }, + { + "source": ".monitor_state", + "specifiers": [ + "MonitorState", + "TrackedSession" + ] + }, + { + "source": ".tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + }, + { + "source": ".utils", + "specifiers": [ + "read_cwd_from_jsonl" + ] + } + ], + "exports": [ + "SessionInfo", + "NewMessage", + "SessionMonitor" + ], + "totalLines": 561, + "hasStructuralAnalysis": true + }, + "src/ccbot/session.py": { + "filePath": "src/ccbot/session.py", + "contentHash": "4468d87cfb0396850a9af7386b85a00f6c0c664e7bcbcdb6b004725151de1318", + "functions": [], + "classes": [ + { + "name": "SessionManager", + "methods": [ + "__post_init__", + "thread_bindings", + "thread_bindings", + "group_chat_ids", + "group_chat_ids", + "window_states", + "window_states", + "window_display_names", + "window_display_names", + "_save_state", + "_is_window_id", + "_load_state", + "resolve_stale_ids", + "_migrate_old_format_map", + "_mutate_session_map_locked", + "override_session_map_entry", + "_migrate_old_format_session_map_keys", + "_cleanup_stale_session_map_entries", + "get_display_name", + "update_display_name", + "set_group_chat_id", + "resolve_chat_id", + "wait_for_session_map_entry", + "load_session_map", + "get_window_state", + "clear_window_session", + "set_window_session", + "list_sessions_for_directory", + "resolve_session_for_window", + "update_user_window_offset", + "bind_thread", + "unbind_thread", + "get_window_for_thread", + "resolve_window_for_thread", + "iter_thread_bindings", + "find_users_for_session", + "send_to_window", + "get_recent_messages" + ], + "properties": [ + "user_window_offsets", + "_bindings", + "_window_store", + "_session_map", + "_transcripts" + ], + "exported": true, + "lineCount": 597 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "collections.abc", + "specifiers": [ + "Callable", + "Iterator" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass", + "field" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + }, + { + "source": ".binding_store", + "specifiers": [ + "BindingStore" + ] + }, + { + "source": ".config", + "specifiers": [ + "config" + ] + }, + { + "source": ".session_map_repository", + "specifiers": [ + "SessionMapRepository" + ] + }, + { + "source": ".session_migration", + "specifiers": [ + "apply_startup_state_migration", + "cleanup_stale_session_map_entries", + "is_window_id", + "migrate_session_map_old_format", + "state_needs_legacy_migration" + ] + }, + { + "source": ".tmux_manager", + "specifiers": [ + "tmux_manager" + ] + }, + { + "source": ".transcript_reader", + "specifiers": [ + "ClaudeSession", + "TranscriptReader" + ] + }, + { + "source": ".utils", + "specifiers": [ + "atomic_write_json" + ] + }, + { + "source": ".window_state_store", + "specifiers": [ + "WindowState", + "WindowStateStore" + ] + } + ], + "exports": [ + "SessionManager" + ], + "totalLines": 650, + "hasStructuralAnalysis": true + }, + "src/ccbot/telegram_sender.py": { + "filePath": "src/ccbot/telegram_sender.py", + "contentHash": "214b0ed8e36c5135e59b0fd2eacba8c195b5ea47a8fba80a658cbf4e849178d5", + "functions": [ + { + "name": "utf16_len", + "params": [ + "text" + ], + "returnType": "int", + "exported": true, + "lineCount": 7 + }, + { + "name": "_force_split", + "params": [ + "line", + "budget" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 16 + }, + { + "name": "split_message", + "params": [ + "text", + "max_length" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 81 + } + ], + "classes": [], + "imports": [], + "exports": [ + "utf16_len", + "_force_split", + "split_message" + ], + "totalLines": 123, + "hasStructuralAnalysis": true + }, + "src/ccbot/terminal_parser.py": { + "filePath": "src/ccbot/terminal_parser.py", + "contentHash": "8f2f65249863e1fb18f012619a4489853594b0d99569fe47eb8e16ed50387763", + "functions": [ + { + "name": "_shorten_separators", + "params": [ + "text" + ], + "returnType": "str", + "exported": true, + "lineCount": 5 + }, + { + "name": "_try_extract", + "params": [ + "lines", + "pattern" + ], + "returnType": "InteractiveUIContent | None", + "exported": true, + "lineCount": 33 + }, + { + "name": "extract_interactive_content", + "params": [ + "pane_text" + ], + "returnType": "InteractiveUIContent | None", + "exported": true, + "lineCount": 15 + }, + { + "name": "is_interactive_ui", + "params": [ + "pane_text" + ], + "returnType": "bool", + "exported": true, + "lineCount": 3 + }, + { + "name": "parse_status_line", + "params": [ + "pane_text" + ], + "returnType": "str | None", + "exported": true, + "lineCount": 37 + }, + { + "name": "strip_pane_chrome", + "params": [ + "lines" + ], + "returnType": "list[str]", + "exported": true, + "lineCount": 20 + }, + { + "name": "extract_bash_output", + "params": [ + "pane_text", + "command" + ], + "returnType": "str | None", + "exported": true, + "lineCount": 35 + }, + { + "name": "parse_usage_output", + "params": [ + "pane_text" + ], + "returnType": "UsageInfo | None", + "exported": true, + "lineCount": 52 + } + ], + "classes": [ + { + "name": "InteractiveUIContent", + "methods": [], + "properties": [ + "content", + "name" + ], + "exported": true, + "lineCount": 5 + }, + { + "name": "UIPattern", + "methods": [], + "properties": [ + "name", + "top", + "bottom", + "min_gap" + ], + "exported": true, + "lineCount": 16 + }, + { + "name": "UsageInfo", + "methods": [], + "properties": [ + "raw_text", + "parsed_lines" + ], + "exported": true, + "lineCount": 5 + } + ], + "imports": [ + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + } + ], + "exports": [ + "InteractiveUIContent", + "UIPattern", + "_shorten_separators", + "_try_extract", + "extract_interactive_content", + "is_interactive_ui", + "parse_status_line", + "strip_pane_chrome", + "extract_bash_output", + "UsageInfo", + "parse_usage_output" + ], + "totalLines": 366, + "hasStructuralAnalysis": true + }, + "src/ccbot/tmux_manager.py": { + "filePath": "src/ccbot/tmux_manager.py", + "contentHash": "a75c486b7f3206b7129a4b69063879966cc9102a5ca1992c2af80bf46bc2e48d", + "functions": [], + "classes": [ + { + "name": "TmuxWindow", + "methods": [], + "properties": [ + "window_id", + "window_name", + "cwd", + "pane_current_command" + ], + "exported": true, + "lineCount": 7 + }, + { + "name": "TmuxManager", + "methods": [ + "__init__", + "server", + "get_session", + "get_or_create_session", + "_scrub_session_env", + "list_windows", + "find_window_by_name", + "find_window_by_id", + "capture_pane", + "send_keys", + "rename_window", + "kill_window", + "create_window" + ], + "properties": [], + "exported": true, + "lineCount": 464 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "libtmux", + "specifiers": [ + "libtmux" + ] + }, + { + "source": "libtmux.exc", + "specifiers": [ + "LibTmuxException" + ] + }, + { + "source": ".config", + "specifiers": [ + "SENSITIVE_ENV_VARS", + "config" + ] + }, + { + "source": ".errors", + "specifiers": [ + "log_exception" + ] + } + ], + "exports": [ + "TmuxWindow", + "TmuxManager" + ], + "totalLines": 517, + "hasStructuralAnalysis": true + }, + "src/ccbot/transcribe.py": { + "filePath": "src/ccbot/transcribe.py", + "contentHash": "0677519c894710805a706fe4fa3078a75934700c87fac485af4b78887e87b7e6", + "functions": [ + { + "name": "_get_client", + "params": [], + "returnType": "httpx.AsyncClient", + "exported": true, + "lineCount": 6 + }, + { + "name": "transcribe_voice", + "params": [ + "ogg_data" + ], + "returnType": "str", + "exported": true, + "lineCount": 21 + }, + { + "name": "close_client", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 6 + } + ], + "classes": [], + "imports": [ + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "httpx", + "specifiers": [ + "httpx" + ] + }, + { + "source": ".config", + "specifiers": [ + "config" + ] + } + ], + "exports": [ + "_get_client", + "transcribe_voice", + "close_client" + ], + "totalLines": 57, + "hasStructuralAnalysis": true + }, + "src/ccbot/transcript_parser.py": { + "filePath": "src/ccbot/transcript_parser.py", + "contentHash": "28a7ffd92a58d5e5a96f36f9f5a9b29453278a8ed63bcb7770864e401b1f1e4f", + "functions": [], + "classes": [ + { + "name": "ParsedMessage", + "methods": [], + "properties": [ + "message_type", + "text", + "tool_name" + ], + "exported": true, + "lineCount": 6 + }, + { + "name": "ParsedEntry", + "methods": [], + "properties": [ + "role", + "text", + "content_type", + "tool_use_id", + "timestamp", + "tool_name", + "image_data" + ], + "exported": true, + "lineCount": 16 + }, + { + "name": "PendingToolInfo", + "methods": [], + "properties": [ + "summary", + "tool_name", + "input_data" + ], + "exported": true, + "lineCount": 6 + }, + { + "name": "TranscriptParser", + "methods": [ + "parse_line", + "get_message_type", + "is_user_message", + "extract_text_only", + "_format_edit_diff", + "format_tool_use_summary", + "extract_tool_result_text", + "extract_tool_result_images", + "parse_message", + "get_timestamp", + "_format_expandable_quote", + "_format_tool_result_text", + "parse_entries" + ], + "properties": [], + "exported": true, + "lineCount": 715 + } + ], + "imports": [ + { + "source": "base64", + "specifiers": [ + "base64" + ] + }, + { + "source": "difflib", + "specifiers": [ + "difflib" + ] + }, + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + } + ], + "exports": [ + "ParsedMessage", + "ParsedEntry", + "PendingToolInfo", + "TranscriptParser" + ], + "totalLines": 777, + "hasStructuralAnalysis": true + }, + "src/ccbot/transcript_reader.py": { + "filePath": "src/ccbot/transcript_reader.py", + "contentHash": "51be8200b7d5f81b18f190715b5fd7f2234b3cd0222dd14a6b64ff8860856949", + "functions": [], + "classes": [ + { + "name": "ClaudeSession", + "methods": [], + "properties": [ + "session_id", + "summary", + "message_count", + "file_path" + ], + "exported": true, + "lineCount": 7 + }, + { + "name": "TranscriptReader", + "methods": [ + "projects_path", + "encode_cwd", + "build_session_file_path", + "get_session_direct", + "list_sessions_for_directory", + "read_recent_messages" + ], + "properties": [], + "exported": true, + "lineCount": 161 + } + ], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "logging", + "specifiers": [ + "logging" + ] + }, + { + "source": "re", + "specifiers": [ + "re" + ] + }, + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "aiofiles", + "specifiers": [ + "aiofiles" + ] + }, + { + "source": ".config", + "specifiers": [ + "config" + ] + }, + { + "source": ".transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + } + ], + "exports": [ + "ClaudeSession", + "TranscriptReader" + ], + "totalLines": 196, + "hasStructuralAnalysis": true + }, + "src/ccbot/utils.py": { + "filePath": "src/ccbot/utils.py", + "contentHash": "af1ee250981b3e13b361b878aa18ef2c808acf0b861bd7a8b74100ff7c8181d0", + "functions": [ + { + "name": "ccbot_dir", + "params": [], + "returnType": "Path", + "exported": true, + "lineCount": 4 + }, + { + "name": "atomic_write_json", + "params": [ + "path", + "data", + "indent" + ], + "returnType": "None", + "exported": true, + "lineCount": 26 + }, + { + "name": "read_cwd_from_jsonl", + "params": [ + "file_path" + ], + "returnType": "str", + "exported": true, + "lineCount": 21 + } + ], + "classes": [], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "tempfile", + "specifiers": [ + "tempfile" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "typing", + "specifiers": [ + "IO", + "Any" + ] + } + ], + "exports": [ + "ccbot_dir", + "atomic_write_json", + "read_cwd_from_jsonl" + ], + "totalLines": 96, + "hasStructuralAnalysis": true + }, + "src/ccbot/window_state_store.py": { + "filePath": "src/ccbot/window_state_store.py", + "contentHash": "ae3e581c6d2a57df47940186b2bbc89b02270a98728b33705781defb28d07818", + "functions": [], + "classes": [ + { + "name": "WindowState", + "methods": [ + "to_dict", + "from_dict" + ], + "properties": [ + "session_id", + "cwd", + "window_name" + ], + "exported": true, + "lineCount": 29 + }, + { + "name": "WindowStateStore", + "methods": [ + "to_state", + "load_state", + "reset", + "get_window_state", + "set_window_session", + "clear_window_session", + "get_display_name", + "update_display_name" + ], + "properties": [ + "window_states", + "window_display_names" + ], + "exported": true, + "lineCount": 69 + } + ], + "imports": [ + { + "source": "dataclasses", + "specifiers": [ + "dataclass", + "field" + ] + }, + { + "source": "typing", + "specifiers": [ + "Any" + ] + } + ], + "exports": [ + "WindowState", + "WindowStateStore" + ], + "totalLines": 119, + "hasStructuralAnalysis": true + }, + "tests/agentic/conftest.py": { + "filePath": "tests/agentic/conftest.py", + "contentHash": "15186d151aeefd0eadcf6f40aa5def308f35854caf3d0d891eb81e7cf1a18c75", + "functions": [], + "classes": [], + "imports": [ + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + } + ], + "exports": [], + "totalLines": 11, + "hasStructuralAnalysis": true + }, + "tests/agentic/test_binary_staging.py": { + "filePath": "tests/agentic/test_binary_staging.py", + "contentHash": "baf8d4507df01670fbb913ae5701bf4addebf72a866954738a242c826dcaf769", + "functions": [ + { + "name": "_git_format_diff", + "params": [], + "returnType": "bytes", + "exported": true, + "lineCount": 12 + }, + { + "name": "_crlf_diff", + "params": [], + "returnType": "bytes", + "exported": true, + "lineCount": 2 + }, + { + "name": "_eof_marker_diff", + "params": [], + "returnType": "bytes", + "exported": true, + "lineCount": 10 + }, + { + "name": "test_write_temp_diff_is_byte_exact", + "params": [ + "name" + ], + "exported": true, + "lineCount": 6 + }, + { + "name": "test_write_bytes_creates_parents_and_preserves_bytes", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 6 + }, + { + "name": "test_check_diff_applies_rejects_markdown_fence_with_crlf", + "params": [], + "exported": true, + "lineCount": 5 + }, + { + "name": "test_check_diff_applies_rejects_comment_with_crlf", + "params": [], + "exported": true, + "lineCount": 5 + }, + { + "name": "test_validate_proposal_diff_empty_gate", + "params": [], + "exported": true, + "lineCount": 5 + } + ], + "classes": [], + "imports": [ + { + "source": "propose", + "specifiers": [ + "propose" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + } + ], + "exports": [ + "_git_format_diff", + "_crlf_diff", + "_eof_marker_diff", + "test_write_temp_diff_is_byte_exact", + "test_write_bytes_creates_parents_and_preserves_bytes", + "test_check_diff_applies_rejects_markdown_fence_with_crlf", + "test_check_diff_applies_rejects_comment_with_crlf", + "test_validate_proposal_diff_empty_gate" + ], + "totalLines": 85, + "hasStructuralAnalysis": true + }, + "tests/agentic/test_evidence_checks.py": { + "filePath": "tests/agentic/test_evidence_checks.py", + "contentHash": "e3a4e867332faa8b5dd77d1ba6ed72cd185bba4ab6247338a46f129b3c20ab3e", + "functions": [ + { + "name": "_write", + "params": [ + "root", + "rel", + "text" + ], + "returnType": "None", + "exported": true, + "lineCount": 4 + }, + { + "name": "test_deleted_path_absent_passes", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 4 + }, + { + "name": "test_deleted_path_still_exists_fails", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 7 + }, + { + "name": "test_call_site_found_in_src", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 4 + }, + { + "name": "test_call_site_missing_fails", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 5 + }, + { + "name": "test_call_site_in_tests_does_not_count", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 5 + }, + { + "name": "test_grep_present_and_absent", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 14 + }, + { + "name": "test_grep_absent_but_found_fails", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 7 + }, + { + "name": "test_grep_bad_regex_fails", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 4 + }, + { + "name": "test_test_file_missing_fails", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 3 + }, + { + "name": "test_test_file_exists_passes", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 4 + }, + { + "name": "test_done_block_reasons_plain_task_allowed", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 2 + }, + { + "name": "test_done_block_reasons_structural_without_evidence_blocked", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 4 + }, + { + "name": "test_done_block_reasons_failing_evidence_blocked", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 6 + }, + { + "name": "test_done_block_reasons_passing_evidence_allowed", + "params": [ + "tmp_path" + ], + "exported": true, + "lineCount": 3 + } + ], + "classes": [], + "imports": [ + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "update_backlog_status", + "specifiers": [ + "done_block_reasons" + ] + }, + { + "source": "validate_changes", + "specifiers": [ + "check_evidence" + ] + } + ], + "exports": [ + "_write", + "test_deleted_path_absent_passes", + "test_deleted_path_still_exists_fails", + "test_call_site_found_in_src", + "test_call_site_missing_fails", + "test_call_site_in_tests_does_not_count", + "test_grep_present_and_absent", + "test_grep_absent_but_found_fails", + "test_grep_bad_regex_fails", + "test_test_file_missing_fails", + "test_test_file_exists_passes", + "test_done_block_reasons_plain_task_allowed", + "test_done_block_reasons_structural_without_evidence_blocked", + "test_done_block_reasons_failing_evidence_blocked", + "test_done_block_reasons_passing_evidence_allowed" + ], + "totalLines": 115, + "hasStructuralAnalysis": true + }, + "tests/ccbot/conftest.py": { + "filePath": "tests/ccbot/conftest.py", + "contentHash": "83e8c73e9c56b985989e34ae70315c0e34e3bd2d990cb2bd48ae108a45a5f37d", + "functions": [ + { + "name": "make_jsonl_entry", + "params": [], + "exported": true, + "lineCount": 24 + }, + { + "name": "make_text_block", + "params": [], + "exported": true, + "lineCount": 7 + }, + { + "name": "make_tool_use_block", + "params": [], + "exported": true, + "lineCount": 16 + }, + { + "name": "make_tool_result_block", + "params": [], + "exported": true, + "lineCount": 19 + }, + { + "name": "make_thinking_block", + "params": [], + "exported": true, + "lineCount": 7 + }, + { + "name": "sample_pane_exit_plan", + "params": [], + "exported": true, + "lineCount": 8 + }, + { + "name": "sample_pane_ask_user_multi_tab", + "params": [], + "exported": true, + "lineCount": 2 + }, + { + "name": "sample_pane_ask_user_single_tab", + "params": [], + "exported": true, + "lineCount": 2 + }, + { + "name": "sample_pane_permission", + "params": [], + "exported": true, + "lineCount": 2 + }, + { + "name": "chrome", + "params": [], + "exported": true, + "lineCount": 2 + }, + { + "name": "sample_pane_status_line", + "params": [], + "exported": true, + "lineCount": 2 + }, + { + "name": "sample_pane_settings", + "params": [], + "exported": true, + "lineCount": 14 + }, + { + "name": "sample_pane_no_ui", + "params": [], + "exported": true, + "lineCount": 2 + } + ], + "classes": [], + "imports": [ + { + "source": "time", + "specifiers": [ + "time" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + } + ], + "exports": [ + "make_jsonl_entry", + "make_text_block", + "make_tool_use_block", + "make_tool_result_block", + "make_thinking_block", + "sample_pane_exit_plan", + "sample_pane_ask_user_multi_tab", + "sample_pane_ask_user_single_tab", + "sample_pane_permission", + "chrome", + "sample_pane_status_line", + "sample_pane_settings", + "sample_pane_no_ui" + ], + "totalLines": 169, + "hasStructuralAnalysis": true + }, + "tests/ccbot/handlers/test_callback_data.py": { + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "contentHash": "0fe98dd0e34cbf0bf3834a8ab38779d4b2ac96276ed28115b6ebc4cb70e614f7", + "functions": [], + "classes": [ + { + "name": "TestHistoryParse", + "methods": [ + "test_current_format_newer", + "test_legacy_format", + "test_window_id_with_colons", + "test_malformed_returns_none", + "test_negative_page_rejected" + ], + "properties": [], + "exported": true, + "lineCount": 33 + }, + { + "name": "TestIndexParsers", + "methods": [ + "test_dir_select", + "test_dir_page_win_session" + ], + "properties": [], + "exported": true, + "lineCount": 10 + }, + { + "name": "TestWindowAndKeyParsers", + "methods": [ + "test_screenshot_refresh", + "test_ask", + "test_key" + ], + "properties": [], + "exported": true, + "lineCount": 17 + }, + { + "name": "TestRoundTrip", + "methods": [ + "test_serialize_then_parse" + ], + "properties": [], + "exported": true, + "lineCount": 33 + }, + { + "name": "TestValidation", + "methods": [ + "test_negative_index_construction", + "test_empty_window_id" + ], + "properties": [], + "exported": true, + "lineCount": 8 + }, + { + "name": "TestImmutability", + "methods": [ + "test_frozen_prevents_mutation" + ], + "properties": [], + "exported": true, + "lineCount": 5 + }, + { + "name": "TestSafeSerialization", + "methods": [ + "test_within_limit", + "test_string_passthrough", + "test_empty_raises", + "test_exceeds_limit_raises", + "test_multibyte_over_limit" + ], + "properties": [], + "exported": true, + "lineCount": 26 + } + ], + "imports": [ + { + "source": "dataclasses", + "specifiers": [ + "FrozenInstanceError" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.handlers.callback_data", + "specifiers": [ + "CB_ASK_UP", + "CB_DIR_SELECT", + "CB_HISTORY_NEXT", + "CB_HISTORY_PREV", + "CB_KEYS_PREFIX", + "CB_SCREENSHOT_REFRESH", + "MAX_CALLBACK_DATA_BYTES", + "AskCallback", + "DirPageCallback", + "DirSelectCallback", + "HistoryCallback", + "KeyCallback", + "ScreenshotRefreshCallback", + "SessionSelectCallback", + "WinBindCallback", + "parse_ask", + "parse_dir_page", + "parse_dir_select", + "parse_history", + "parse_key", + "parse_screenshot_refresh", + "parse_session_select", + "parse_win_bind", + "safe_callback_data" + ] + } + ], + "exports": [ + "TestHistoryParse", + "TestIndexParsers", + "TestWindowAndKeyParsers", + "TestRoundTrip", + "TestValidation", + "TestImmutability", + "TestSafeSerialization" + ], + "totalLines": 179, + "hasStructuralAnalysis": true + }, + "tests/ccbot/handlers/test_command_handlers.py": { + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "contentHash": "c7b77c617d88407d37f2880a89ab56835d8b05b5677224fc337990af1d9dacf9", + "functions": [ + { + "name": "_user", + "params": [ + "uid" + ], + "returnType": "MagicMock", + "exported": true, + "lineCount": 4 + }, + { + "name": "_update", + "params": [ + "user_id", + "message", + "thread_id" + ], + "returnType": "MagicMock", + "exported": true, + "lineCount": 18 + }, + { + "name": "_context", + "params": [], + "returnType": "MagicMock", + "exported": true, + "lineCount": 5 + }, + { + "name": "_session_ctx", + "params": [ + "user", + "thread_id", + "window_id" + ], + "returnType": "SessionContext", + "exported": true, + "lineCount": 11 + }, + { + "name": "test_start_unauthorized_no_welcome", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 10 + }, + { + "name": "test_start_authorized_sends_welcome", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 12 + }, + { + "name": "test_esc_requires_session", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 12 + }, + { + "name": "test_esc_sends_escape_key", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 13 + }, + { + "name": "test_unbind_requires_topic", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 10 + }, + { + "name": "test_unbind_clears_binding", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 21 + }, + { + "name": "test_esc_missing_window", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 11 + } + ], + "classes": [], + "imports": [ + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.handlers.command_handlers", + "specifiers": [ + "esc_command", + "start_command", + "unbind_command" + ] + }, + { + "source": "ccbot.session_guard", + "specifiers": [ + "SessionContext" + ] + }, + { + "source": "ccbot.tmux_manager", + "specifiers": [ + "TmuxWindow" + ] + } + ], + "exports": [ + "_user", + "_update", + "_context", + "_session_ctx", + "test_start_unauthorized_no_welcome", + "test_start_authorized_sends_welcome", + "test_esc_requires_session", + "test_esc_sends_escape_key", + "test_unbind_requires_topic", + "test_unbind_clears_binding", + "test_esc_missing_window" + ], + "totalLines": 174, + "hasStructuralAnalysis": true + }, + "tests/ccbot/handlers/test_interactive_ui.py": { + "filePath": "tests/ccbot/handlers/test_interactive_ui.py", + "contentHash": "8dae5d7bbec1de243c05f25d1fb226689fe169b7f2b15e52de4e9a86dc0d6f64", + "functions": [ + { + "name": "mock_bot", + "params": [], + "exported": true, + "lineCount": 6 + }, + { + "name": "_clear_interactive_state", + "params": [], + "exported": true, + "lineCount": 9 + } + ], + "classes": [ + { + "name": "TestHandleInteractiveUI", + "methods": [ + "test_handle_settings_ui_sends_keyboard", + "test_handle_no_ui_returns_false" + ], + "properties": [], + "exported": true, + "lineCount": 49 + }, + { + "name": "TestKeyboardLayoutForSettings", + "methods": [ + "test_settings_keyboard_includes_all_nav_keys" + ], + "properties": [], + "exported": true, + "lineCount": 16 + } + ], + "imports": [ + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.handlers.callback_data", + "specifiers": [ + "CB_ASK_DOWN", + "CB_ASK_ENTER", + "CB_ASK_ESC", + "CB_ASK_LEFT", + "CB_ASK_RIGHT", + "CB_ASK_SPACE", + "CB_ASK_TAB", + "CB_ASK_UP" + ] + }, + { + "source": "ccbot.handlers.interactive_ui", + "specifiers": [ + "_build_interactive_keyboard", + "handle_interactive_ui" + ] + } + ], + "exports": [ + "mock_bot", + "_clear_interactive_state", + "TestHandleInteractiveUI", + "TestKeyboardLayoutForSettings" + ], + "totalLines": 112, + "hasStructuralAnalysis": true + }, + "tests/ccbot/handlers/test_message_queue_enqueue.py": { + "filePath": "tests/ccbot/handlers/test_message_queue_enqueue.py", + "contentHash": "f8d5b4e6e0dac9680debc6ba27829a4d5aa3c8ca5f63a8544b0af8931c8b42b2", + "functions": [ + { + "name": "_isolate_queue_state", + "params": [], + "exported": true, + "lineCount": 5 + }, + { + "name": "test_enqueue_content_creates_queue_and_task", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 20 + }, + { + "name": "test_enqueue_status_skipped_during_flood", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 12 + }, + { + "name": "test_enqueue_status_dedupes_same_text", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 26 + }, + { + "name": "test_enqueue_status_clear", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 15 + }, + { + "name": "test_worker_processes_content_and_marks_done", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 32 + }, + { + "name": "test_get_or_create_queue_starts_worker_once", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 7 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.handlers", + "specifiers": [ + "mq" + ] + }, + { + "source": "ccbot.handlers.message_queue", + "specifiers": [ + "MessageTask", + "enqueue_content_message", + "enqueue_status_update", + "get_message_queue", + "get_or_create_queue", + "queue_manager", + "shutdown_workers" + ] + } + ], + "exports": [ + "_isolate_queue_state", + "test_enqueue_content_creates_queue_and_task", + "test_enqueue_status_skipped_during_flood", + "test_enqueue_status_dedupes_same_text", + "test_enqueue_status_clear", + "test_worker_processes_content_and_marks_done", + "test_get_or_create_queue_starts_worker_once" + ], + "totalLines": 158, + "hasStructuralAnalysis": true + }, + "tests/ccbot/handlers/test_message_queue_state.py": { + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "contentHash": "59ddb1205f04a8cadac18379e8107700f9be5bb0600db5bb8e34c7a7f0abbd64", + "functions": [ + { + "name": "test_can_merge_same_window_text", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 4 + }, + { + "name": "test_cannot_merge_tool_use", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 6 + }, + { + "name": "test_merge_content_tasks_respects_limit", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 17 + }, + { + "name": "test_module_singleton_exists", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "_concurrent_status_increment", + "params": [ + "st", + "lock", + "user_id", + "thread_id" + ], + "returnType": "None", + "exported": true, + "lineCount": 24 + }, + { + "name": "test_gather_50_status_updates_lose_without_lock", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 11 + }, + { + "name": "test_gather_50_status_updates_safe_with_lock", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 10 + } + ], + "classes": [ + { + "name": "TestStatusTracker", + "methods": [ + "test_status_roundtrip", + "test_tool_ids_and_topic_clear", + "test_clear_all" + ], + "properties": [], + "exported": true, + "lineCount": 25 + }, + { + "name": "TestMessageQueueManager", + "methods": [ + "test_flood_lifecycle", + "test_get_queue_none_until_created", + "test_shutdown_clears_state" + ], + "properties": [], + "exported": true, + "lineCount": 34 + } + ], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.handlers.message_queue", + "specifiers": [ + "MERGE_MAX_LENGTH", + "MessageQueueManager", + "MessageTask", + "StatusTracker", + "_can_merge_tasks", + "_merge_content_tasks", + "queue_manager" + ] + } + ], + "exports": [ + "TestStatusTracker", + "TestMessageQueueManager", + "test_can_merge_same_window_text", + "test_cannot_merge_tool_use", + "test_merge_content_tasks_respects_limit", + "test_module_singleton_exists", + "_concurrent_status_increment", + "test_gather_50_status_updates_lose_without_lock", + "test_gather_50_status_updates_safe_with_lock" + ], + "totalLines": 176, + "hasStructuralAnalysis": true + }, + "tests/ccbot/handlers/test_response_builder.py": { + "filePath": "tests/ccbot/handlers/test_response_builder.py", + "contentHash": "ab3ea51e1b082a527cb569625489c4761eec6185944deecc71d1f56b22d4539c", + "functions": [], + "classes": [ + { + "name": "TestBuildResponseParts", + "methods": [ + "test_user_message_has_emoji_prefix", + "test_user_message_truncated_at_3000_chars", + "test_thinking_content_truncated_at_500_chars", + "test_plain_text_single_part", + "test_plain_text_multi_part_has_page_suffix", + "test_expandable_quote_stays_atomic", + "test_thinking_has_prefix", + "test_assistant_text_no_prefix" + ], + "properties": [], + "exported": true, + "lineCount": 51 + } + ], + "imports": [ + { + "source": "ccbot.handlers.response_builder", + "specifiers": [ + "build_response_parts" + ] + }, + { + "source": "ccbot.transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + } + ], + "exports": [ + "TestBuildResponseParts" + ], + "totalLines": 61, + "hasStructuralAnalysis": true + }, + "tests/ccbot/handlers/test_status_polling.py": { + "filePath": "tests/ccbot/handlers/test_status_polling.py", + "contentHash": "5151ecfe96b7d4b5857ebda7701d9285f856d8b7e23cafe1f329a46b77f76bc3", + "functions": [ + { + "name": "mock_bot", + "params": [], + "exported": true, + "lineCount": 6 + }, + { + "name": "_clear_interactive_state", + "params": [], + "exported": true, + "lineCount": 9 + } + ], + "classes": [ + { + "name": "TestStatusPollerSettingsDetection", + "methods": [ + "test_settings_ui_detected_and_keyboard_sent", + "test_normal_pane_no_interactive_ui", + "test_settings_ui_end_to_end_sends_telegram_keyboard" + ], + "properties": [], + "exported": true, + "lineCount": 105 + } + ], + "imports": [ + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.handlers.status_polling", + "specifiers": [ + "update_status_message" + ] + } + ], + "exports": [ + "mock_bot", + "_clear_interactive_state", + "TestStatusPollerSettingsDetection" + ], + "totalLines": 142, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_capture_registry.py": { + "filePath": "tests/ccbot/test_capture_registry.py", + "contentHash": "5c42fe49fc8428e861db3e0d769d5d8d841fb50b303637f9b39e125ae556888b", + "functions": [ + { + "name": "_never", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 2 + }, + { + "name": "test_register_and_discard_does_not_cancel", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 10 + }, + { + "name": "test_cancel_running_task", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 8 + }, + { + "name": "test_cancel_missing_key_is_noop", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 3 + }, + { + "name": "test_cancel_done_task_does_not_raise", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 11 + }, + { + "name": "test_discard_stale_task_does_not_evict_newer", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 21 + }, + { + "name": "test_discard_matching_task_removes", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 15 + } + ], + "classes": [], + "imports": [ + { + "source": "asyncio", + "specifiers": [ + "asyncio" + ] + }, + { + "source": "contextlib", + "specifiers": [ + "contextlib" + ] + }, + { + "source": "ccbot.handlers.capture_registry", + "specifiers": [ + "CaptureTaskRegistry" + ] + } + ], + "exports": [ + "_never", + "test_register_and_discard_does_not_cancel", + "test_cancel_running_task", + "test_cancel_missing_key_is_noop", + "test_cancel_done_task_does_not_raise", + "test_discard_stale_task_does_not_evict_newer", + "test_discard_matching_task_removes" + ], + "totalLines": 91, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_config.py": { + "filePath": "tests/ccbot/test_config.py", + "contentHash": "8771f586c9c128929bdeadedc0bdd6ec11eea57b9ae03fb7e5885cbc6339df69", + "functions": [ + { + "name": "_base_env", + "params": [ + "monkeypatch", + "tmp_path" + ], + "exported": true, + "lineCount": 6 + } + ], + "classes": [ + { + "name": "TestConfigValid", + "methods": [ + "test_valid_config", + "test_custom_tmux_session_name", + "test_custom_monitor_poll_interval", + "test_is_user_allowed_true", + "test_is_user_allowed_false" + ], + "properties": [], + "exported": true, + "lineCount": 23 + }, + { + "name": "TestConfigMissingEnv", + "methods": [ + "test_missing_telegram_bot_token", + "test_missing_allowed_users", + "test_non_numeric_allowed_users" + ], + "properties": [], + "exported": true, + "lineCount": 15 + }, + { + "name": "TestConfigClaudeProjectsPath", + "methods": [ + "test_default_claude_projects_path", + "test_custom_claude_projects_path", + "test_claude_config_dir_projects_path", + "test_ccbot_projects_path_takes_priority" + ], + "properties": [], + "exported": true, + "lineCount": 29 + }, + { + "name": "TestConfigOpenAI", + "methods": [ + "test_openai_defaults", + "test_openai_api_key", + "test_openai_base_url", + "test_openai_api_key_scrubbed_from_env" + ], + "properties": [], + "exported": true, + "lineCount": 24 + } + ], + "imports": [ + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.config", + "specifiers": [ + "Config" + ] + } + ], + "exports": [ + "_base_env", + "TestConfigValid", + "TestConfigMissingEnv", + "TestConfigClaudeProjectsPath", + "TestConfigOpenAI" + ], + "totalLines": 120, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_forward_command.py": { + "filePath": "tests/ccbot/test_forward_command.py", + "contentHash": "e98acf2c0bec28d5269c4cd04f51605959f8d068ed73d408639d3b0fe3baa63f", + "functions": [ + { + "name": "_make_update", + "params": [ + "text", + "user_id", + "thread_id" + ], + "returnType": "MagicMock", + "exported": true, + "lineCount": 15 + }, + { + "name": "_make_context", + "params": [], + "returnType": "MagicMock", + "exported": true, + "lineCount": 6 + }, + { + "name": "_sctx", + "params": [ + "user", + "window_id" + ], + "returnType": "SessionContext", + "exported": true, + "lineCount": 3 + } + ], + "classes": [ + { + "name": "TestForwardCommand", + "methods": [ + "test_model_sends_command_to_tmux", + "test_cost_sends_command_to_tmux", + "test_clear_clears_session" + ], + "properties": [], + "exported": true, + "lineCount": 82 + } + ], + "imports": [ + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.session_guard", + "specifiers": [ + "SessionContext" + ] + }, + { + "source": "ccbot.tmux_manager", + "specifiers": [ + "TmuxWindow" + ] + } + ], + "exports": [ + "_make_update", + "_make_context", + "_sctx", + "TestForwardCommand" + ], + "totalLines": 126, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_hook.py": { + "filePath": "tests/ccbot/test_hook.py", + "contentHash": "079b425af605cbf373cc412aeba03d91f8f68beb4ccfc7c21b3720db1757d0a5", + "functions": [], + "classes": [ + { + "name": "TestUuidRegex", + "methods": [ + "test_valid_uuid_matches", + "test_invalid_uuid_no_match" + ], + "properties": [], + "exported": true, + "lineCount": 25 + }, + { + "name": "TestIsHookInstalled", + "methods": [ + "test_hook_present", + "test_no_hooks_key", + "test_different_hook_command", + "test_full_path_matches" + ], + "properties": [], + "exported": true, + "lineCount": 45 + }, + { + "name": "TestHookMainValidation", + "methods": [ + "_run_hook_main", + "test_missing_session_id", + "test_invalid_uuid_format", + "test_relative_cwd", + "test_non_session_start_event" + ], + "properties": [], + "exported": true, + "lineCount": 61 + } + ], + "imports": [ + { + "source": "io", + "specifiers": [ + "io" + ] + }, + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.hook", + "specifiers": [ + "_UUID_RE", + "_is_hook_installed", + "hook_main" + ] + } + ], + "exports": [ + "TestUuidRegex", + "TestIsHookInstalled", + "TestHookMainValidation" + ], + "totalLines": 147, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_markdown_v2.py": { + "filePath": "tests/ccbot/test_markdown_v2.py", + "contentHash": "c4fee00b64eadf06ae73e1ea2ae8aa1749ae3d26295d56aa3a097834a13e72d0", + "functions": [], + "classes": [ + { + "name": "TestEscapeMdv2", + "methods": [ + "test_escape" + ], + "properties": [], + "exported": true, + "lineCount": 15 + }, + { + "name": "TestConvertMarkdown", + "methods": [ + "test_plain_text", + "test_bold", + "test_code_block_preserved", + "test_expandable_quote_sentinels", + "test_mixed_text_and_expandable_quote" + ], + "properties": [], + "exported": true, + "lineCount": 30 + } + ], + "imports": [ + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.markdown_v2", + "specifiers": [ + "_escape_mdv2", + "convert_markdown" + ] + }, + { + "source": "ccbot.transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + } + ], + "exports": [ + "TestEscapeMdv2", + "TestConvertMarkdown" + ], + "totalLines": 59, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_message_formatter.py": { + "filePath": "tests/ccbot/test_message_formatter.py", + "contentHash": "877ff8c0bfa47be57c5ac772f9ff26cb14963f500e84f1974737b25362c97c8a", + "functions": [ + { + "name": "test_single_message_formatter_singleton", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 7 + }, + { + "name": "test_formatter_to_markdown_v2_uses_convert", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "test_send_with_fallback_uses_formatter_path", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 12 + }, + { + "name": "test_send_with_fallback_reraises_retry_after", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "test_send_with_fallback_none_when_both_fail", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 6 + }, + { + "name": "test_edit_with_fallback_markdown_then_plain", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 11 + }, + { + "name": "test_edit_with_fallback_false_when_both_fail", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 6 + }, + { + "name": "test_safe_send_delegates_to_send_with_fallback", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 19 + }, + { + "name": "test_safe_reply_reraises_retry_after_on_plain", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 7 + }, + { + "name": "test_run_markdown_fallback_shared_by_safe_edit", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 8 + } + ], + "classes": [], + "imports": [ + { + "source": "datetime", + "specifiers": [ + "timedelta" + ] + }, + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "telegram.error", + "specifiers": [ + "BadRequest", + "NetworkError", + "RetryAfter" + ] + }, + { + "source": "ccbot.handlers", + "specifiers": [ + "ms" + ] + }, + { + "source": "ccbot.handlers.message_sender", + "specifiers": [ + "MessageFormatter", + "formatter" + ] + }, + { + "source": "ccbot.transcript_parser", + "specifiers": [ + "TranscriptParser" + ] + } + ], + "exports": [ + "test_single_message_formatter_singleton", + "test_formatter_to_markdown_v2_uses_convert", + "test_send_with_fallback_uses_formatter_path", + "test_send_with_fallback_reraises_retry_after", + "test_send_with_fallback_none_when_both_fail", + "test_edit_with_fallback_markdown_then_plain", + "test_edit_with_fallback_false_when_both_fail", + "test_safe_send_delegates_to_send_with_fallback", + "test_safe_reply_reraises_retry_after_on_plain", + "test_run_markdown_fallback_shared_by_safe_edit" + ], + "totalLines": 128, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_message_sender_errors.py": { + "filePath": "tests/ccbot/test_message_sender_errors.py", + "contentHash": "106154d04bef13e5efb3a04ea76dc92210d9520811ed81041f2290e1ac55e652", + "functions": [ + { + "name": "test_send_with_fallback_retries_plain_on_bad_request", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 11 + }, + { + "name": "test_send_with_fallback_reraises_retry_after", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 7 + }, + { + "name": "test_send_with_fallback_returns_none_when_both_fail", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 8 + }, + { + "name": "test_safe_reply_reraises_retry_after_on_plain", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 8 + }, + { + "name": "test_edit_with_fallback_markdown_then_plain", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 10 + }, + { + "name": "test_edit_with_fallback_false_when_both_fail", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 7 + }, + { + "name": "test_safe_send_delegates_to_send_with_fallback", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 18 + }, + { + "name": "test_retry_after_seconds_accepts_int_and_timedelta", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 11 + } + ], + "classes": [], + "imports": [ + { + "source": "datetime", + "specifiers": [ + "timedelta" + ] + }, + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "telegram.error", + "specifiers": [ + "BadRequest", + "NetworkError", + "RetryAfter" + ] + }, + { + "source": "ccbot.errors", + "specifiers": [ + "retry_after_seconds" + ] + }, + { + "source": "ccbot.handlers", + "specifiers": [ + "ms" + ] + } + ], + "exports": [ + "test_send_with_fallback_retries_plain_on_bad_request", + "test_send_with_fallback_reraises_retry_after", + "test_send_with_fallback_returns_none_when_both_fail", + "test_safe_reply_reraises_retry_after_on_plain", + "test_edit_with_fallback_markdown_then_plain", + "test_edit_with_fallback_false_when_both_fail", + "test_safe_send_delegates_to_send_with_fallback", + "test_retry_after_seconds_accepts_int_and_timedelta" + ], + "totalLines": 116, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_monitor_state.py": { + "filePath": "tests/ccbot/test_monitor_state.py", + "contentHash": "c690e93fa6aed5fad41fcf78721ea4f11f72634fa4b1e071364a648fd5f2810c", + "functions": [], + "classes": [ + { + "name": "TestTrackedSession", + "methods": [ + "test_to_dict_from_dict_roundtrip", + "test_from_dict_missing_fields_uses_defaults" + ], + "properties": [], + "exported": true, + "lineCount": 17 + }, + { + "name": "TestMonitorStateLoad", + "methods": [ + "test_load_missing_file", + "test_load_valid_json", + "test_load_corrupt_json" + ], + "properties": [], + "exported": true, + "lineCount": 29 + }, + { + "name": "TestMonitorStateSave", + "methods": [ + "test_save_writes_via_atomic_write" + ], + "properties": [], + "exported": true, + "lineCount": 19 + }, + { + "name": "TestMonitorStateOperations", + "methods": [ + "state", + "test_get_session", + "test_update_session_adds_new", + "test_update_session_sets_dirty", + "test_remove_session_deletes", + "test_remove_session_missing_no_error" + ], + "properties": [], + "exported": true, + "lineCount": 40 + }, + { + "name": "TestSaveIfDirty", + "methods": [ + "test_dirty_saves", + "test_not_dirty_skips_save" + ], + "properties": [], + "exported": true, + "lineCount": 23 + } + ], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.monitor_state", + "specifiers": [ + "MonitorState", + "TrackedSession" + ] + } + ], + "exports": [ + "TestTrackedSession", + "TestMonitorStateLoad", + "TestMonitorStateSave", + "TestMonitorStateOperations", + "TestSaveIfDirty" + ], + "totalLines": 146, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_session_guard.py": { + "filePath": "tests/ccbot/test_session_guard.py", + "contentHash": "a0c68562bf09c01cf5cacf2197169b3f187dabc8c7b6945a05acb3abff91f2c6", + "functions": [ + { + "name": "_user", + "params": [ + "uid" + ], + "returnType": "MagicMock", + "exported": true, + "lineCount": 4 + }, + { + "name": "_message", + "params": [ + "thread_id" + ], + "returnType": "MagicMock", + "exported": true, + "lineCount": 4 + }, + { + "name": "_update", + "params": [ + "user", + "message", + "callback_query" + ], + "returnType": "MagicMock", + "exported": true, + "lineCount": 11 + }, + { + "name": "test_require_user_unauthorized_replies", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 11 + }, + { + "name": "test_require_user_authorized", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 6 + }, + { + "name": "test_require_session_authorized_bound_live", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 28 + }, + { + "name": "test_require_session_unbound", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 13 + }, + { + "name": "test_require_session_missing_window", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 24 + }, + { + "name": "test_require_bound_window_id_ok", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 14 + }, + { + "name": "test_require_session_composes_require_bound_window_id", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 31 + }, + { + "name": "test_require_session_unbind_if_missing", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 30 + }, + { + "name": "test_require_session_require_named_topic", + "params": [ + "monkeypatch" + ], + "returnType": "None", + "exported": true, + "lineCount": 13 + } + ], + "classes": [ + { + "name": "TestIsUserAllowed", + "methods": [ + "test_none_disallowed", + "test_delegates_to_config" + ], + "properties": [], + "exported": true, + "lineCount": 11 + }, + { + "name": "TestGetThreadId", + "methods": [ + "test_from_message", + "test_general_topic_is_none", + "test_from_callback" + ], + "properties": [], + "exported": true, + "lineCount": 14 + } + ], + "imports": [ + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "MagicMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.session_guard", + "specifiers": [ + "SessionContext", + "get_thread_id", + "is_user_allowed", + "require_bound_window_id", + "require_session", + "require_user" + ] + }, + { + "source": "ccbot.tmux_manager", + "specifiers": [ + "TmuxWindow" + ] + } + ], + "exports": [ + "_user", + "_message", + "_update", + "TestIsUserAllowed", + "TestGetThreadId", + "test_require_user_unauthorized_replies", + "test_require_user_authorized", + "test_require_session_authorized_bound_live", + "test_require_session_unbound", + "test_require_session_missing_window", + "test_require_bound_window_id_ok", + "test_require_session_composes_require_bound_window_id", + "test_require_session_unbind_if_missing", + "test_require_session_require_named_topic" + ], + "totalLines": 269, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_session_migration.py": { + "filePath": "tests/ccbot/test_session_migration.py", + "contentHash": "9684604980c632f5994640489fb1c21b94177269d634dd29ebf4f2e2c17e2968", + "functions": [ + { + "name": "test_apply_startup_state_migration", + "params": [ + "case" + ], + "returnType": "None", + "exported": true, + "lineCount": 16 + }, + { + "name": "test_migrate_old_format_to_window_id", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 10 + }, + { + "name": "test_migrate_old_format_orphan_dropped", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "test_migrate_old_format_superseded_by_existing_id", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 9 + }, + { + "name": "test_cleanup_stale_ids_only", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 9 + }, + { + "name": "test_cleanup_ignores_old_format_keys", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 5 + }, + { + "name": "test_detects_name_keys", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 2 + }, + { + "name": "test_detects_name_bindings", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 2 + }, + { + "name": "test_clean_state", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 2 + }, + { + "name": "test_session_manager_resolve_stale_ids_uses_migration", + "params": [], + "returnType": "None", + "exported": true, + "lineCount": 36 + } + ], + "classes": [ + { + "name": "FakeWS", + "methods": [], + "properties": [ + "session_id", + "cwd", + "window_name" + ], + "exported": true, + "lineCount": 4 + }, + { + "name": "TestIsWindowId", + "methods": [ + "test_is_window_id" + ], + "properties": [], + "exported": true, + "lineCount": 16 + }, + { + "name": "StateCase", + "methods": [], + "properties": [ + "name", + "window_states", + "thread_bindings", + "offsets", + "display_names", + "live_by_name", + "live_ids", + "expect_ws_keys", + "expect_binding", + "expect_changed", + "expect_event_substr" + ], + "exported": true, + "lineCount": 12 + } + ], + "imports": [ + { + "source": "dataclasses", + "specifiers": [ + "dataclass" + ] + }, + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.session_migration", + "specifiers": [ + "apply_startup_state_migration", + "cleanup_stale_session_map_entries", + "is_window_id", + "migrate_session_map_old_format", + "state_needs_legacy_migration" + ] + } + ], + "exports": [ + "FakeWS", + "TestIsWindowId", + "StateCase", + "test_apply_startup_state_migration", + "test_migrate_old_format_to_window_id", + "test_migrate_old_format_orphan_dropped", + "test_migrate_old_format_superseded_by_existing_id", + "test_cleanup_stale_ids_only", + "test_cleanup_ignores_old_format_keys", + "test_detects_name_keys", + "test_detects_name_bindings", + "test_clean_state", + "test_session_manager_resolve_stale_ids_uses_migration" + ], + "totalLines": 260, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_session_monitor.py": { + "filePath": "tests/ccbot/test_session_monitor.py", + "contentHash": "31a61221b8d44bf6c506c3aa1969e6e740b7ea26bdab4854ed6c8cde0fe796e7", + "functions": [ + { + "name": "_write_jsonl", + "params": [ + "path", + "text" + ], + "returnType": "None", + "exported": true, + "lineCount": 7 + } + ], + "classes": [ + { + "name": "TestReadNewLinesOffsetRecovery", + "methods": [ + "monitor", + "test_mid_line_offset_recovery", + "test_valid_offset_reads_normally", + "test_poison_line_skipped", + "test_partial_line_at_eof_retried", + "test_truncation_detection" + ], + "properties": [], + "exported": true, + "lineCount": 138 + } + ], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.monitor_state", + "specifiers": [ + "TrackedSession" + ] + }, + { + "source": "ccbot.session_monitor", + "specifiers": [ + "SessionMonitor" + ] + } + ], + "exports": [ + "_write_jsonl", + "TestReadNewLinesOffsetRecovery" + ], + "totalLines": 158, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_session.py": { + "filePath": "tests/ccbot/test_session.py", + "contentHash": "d4682e29ea8a8c2ec976d95c5bb2b8de5ab7cdbf345d35da6fa393b3cea82bef", + "functions": [ + { + "name": "mgr", + "params": [ + "monkeypatch" + ], + "returnType": "SessionManager", + "exported": true, + "lineCount": 4 + } + ], + "classes": [ + { + "name": "TestThreadBindings", + "methods": [ + "test_bind_and_get", + "test_bind_unbind_get_returns_none", + "test_unbind_nonexistent_returns_none", + "test_iter_thread_bindings" + ], + "properties": [], + "exported": true, + "lineCount": 19 + }, + { + "name": "TestGroupChatId", + "methods": [ + "test_resolve_with_stored_group_id", + "test_resolve_without_group_id_falls_back_to_user_id", + "test_resolve_none_thread_id_falls_back_to_user_id", + "test_set_group_chat_id_overwrites", + "test_multiple_threads_independent", + "test_multiple_users_independent", + "test_set_group_chat_id_with_none_thread" + ], + "properties": [], + "exported": true, + "lineCount": 55 + }, + { + "name": "TestWindowState", + "methods": [ + "test_get_creates_new", + "test_get_returns_existing", + "test_clear_window_session" + ], + "properties": [], + "exported": true, + "lineCount": 16 + }, + { + "name": "TestSetWindowSession", + "methods": [ + "test_sets_all_fields", + "test_partial_update_preserves_cwd_and_name", + "test_persists_state" + ], + "properties": [], + "exported": true, + "lineCount": 23 + }, + { + "name": "TestResolveWindowForThread", + "methods": [ + "test_none_thread_id_returns_none", + "test_unbound_thread_returns_none", + "test_bound_thread_returns_window" + ], + "properties": [], + "exported": true, + "lineCount": 10 + }, + { + "name": "TestDisplayNames", + "methods": [ + "test_get_display_name_fallback", + "test_set_and_get_display_name", + "test_set_display_name_update", + "test_bind_thread_sets_display_name", + "test_bind_thread_without_name_no_display" + ], + "properties": [], + "exported": true, + "lineCount": 22 + }, + { + "name": "TestIsWindowId", + "methods": [ + "test_valid_ids", + "test_invalid_ids" + ], + "properties": [], + "exported": true, + "lineCount": 11 + }, + { + "name": "TestMigrateOldFormatMap", + "methods": [ + "test_migrates_name_to_window_id", + "test_drops_orphan_without_live_window", + "test_prefers_existing_window_id_key", + "test_noop_for_window_id_keys", + "test_ignores_other_tmux_sessions" + ], + "properties": [], + "exported": true, + "lineCount": 44 + }, + { + "name": "TestResolveStaleIds", + "methods": [ + "_setup", + "test_tmux_restart_remaps_bindings_and_offsets", + "test_binding_without_window_state_still_remaps", + "test_unmatched_binding_dropped", + "test_live_ids_untouched" + ], + "properties": [], + "exported": true, + "lineCount": 85 + }, + { + "name": "TestLoadSessionMapMigration", + "methods": [ + "_mock_windows", + "test_old_format_key_populates_window_state", + "test_no_tmux_lookup_when_no_old_keys" + ], + "properties": [], + "exported": true, + "lineCount": 48 + } + ], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.session", + "specifiers": [ + "session_mod" + ] + }, + { + "source": "ccbot.session", + "specifiers": [ + "SessionManager" + ] + }, + { + "source": "ccbot.tmux_manager", + "specifiers": [ + "TmuxWindow" + ] + } + ], + "exports": [ + "mgr", + "TestThreadBindings", + "TestGroupChatId", + "TestWindowState", + "TestSetWindowSession", + "TestResolveWindowForThread", + "TestDisplayNames", + "TestIsWindowId", + "TestMigrateOldFormatMap", + "TestResolveStaleIds", + "TestLoadSessionMapMigration" + ], + "totalLines": 370, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_telegram_sender.py": { + "filePath": "tests/ccbot/test_telegram_sender.py", + "contentHash": "f2219b62d2cdc501390a6513e1a2fd5899560c9752c021d4cf9f5fd271809404", + "functions": [], + "classes": [ + { + "name": "TestSplitMessage", + "methods": [ + "test_single_chunk_returned", + "test_split_on_newline_boundaries", + "test_single_long_line_force_split", + "test_custom_max_length", + "test_custom_max_length_force_split", + "test_trailing_newline_handling", + "test_mixed_lines_grouping", + "test_chunk_count", + "test_all_chunks_within_max_length", + "test_code_block_split_closes_and_reopens", + "test_code_block_not_split_fits", + "test_text_before_and_after_code_block", + "test_multiple_code_blocks" + ], + "properties": [], + "exported": true, + "lineCount": 109 + }, + { + "name": "TestUtf16Len", + "methods": [ + "test_ascii", + "test_bmp_cjk", + "test_emoji_counts_double" + ], + "properties": [], + "exported": true, + "lineCount": 14 + }, + { + "name": "TestSplitMessageUtf16", + "methods": [ + "test_emoji_text_within_utf16_budget", + "test_emoji_lines_within_utf16_budget", + "test_force_split_never_splits_surrogate_pair" + ], + "properties": [], + "exported": true, + "lineCount": 27 + }, + { + "name": "TestSplitMessageCodeBlockForceSplit", + "methods": [ + "test_long_line_in_code_block_gets_fenced_pieces" + ], + "properties": [], + "exported": true, + "lineCount": 12 + } + ], + "imports": [ + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.telegram_sender", + "specifiers": [ + "split_message", + "utf16_len" + ] + } + ], + "exports": [ + "TestSplitMessage", + "TestUtf16Len", + "TestSplitMessageUtf16", + "TestSplitMessageCodeBlockForceSplit" + ], + "totalLines": 176, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_terminal_parser.py": { + "filePath": "tests/ccbot/test_terminal_parser.py", + "contentHash": "ac2c4e0e951d64790d1c00cd84aa691f5084ff0e5d6846a0020bcefe582223ce", + "functions": [], + "classes": [ + { + "name": "TestParseStatusLine", + "methods": [ + "test_spinner_chars", + "test_returns_none", + "test_no_chrome_returns_none", + "test_blank_line_between_status_and_chrome", + "test_idle_no_status", + "test_false_positive_bullet", + "test_uses_fixture" + ], + "properties": [], + "exported": true, + "lineCount": 48 + }, + { + "name": "TestExtractInteractiveContent", + "methods": [ + "test_exit_plan_mode", + "test_exit_plan_mode_variant", + "test_ask_user_multi_tab", + "test_ask_user_single_tab", + "test_permission_prompt", + "test_restore_checkpoint", + "test_settings", + "test_settings_model_picker", + "test_settings_esc_to_cancel_bottom", + "test_settings_esc_to_exit_bottom", + "test_returns_none", + "test_min_gap_too_small_returns_none" + ], + "properties": [], + "exported": true, + "lineCount": 106 + }, + { + "name": "TestIsInteractiveUI", + "methods": [ + "test_true_when_ui_present", + "test_false_when_no_ui", + "test_settings_is_interactive", + "test_false_for_empty_string" + ], + "properties": [], + "exported": true, + "lineCount": 12 + }, + { + "name": "TestStripPaneChrome", + "methods": [ + "test_strips_from_separator", + "test_no_separator_returns_all", + "test_short_separator_not_triggered", + "test_only_searches_last_10_lines" + ], + "properties": [], + "exported": true, + "lineCount": 24 + }, + { + "name": "TestExtractBashOutput", + "methods": [ + "test_extracts_command_output", + "test_command_not_found_returns_none", + "test_chrome_stripped", + "test_prefix_match_long_command", + "test_trailing_blank_lines_stripped" + ], + "properties": [], + "exported": true, + "lineCount": 40 + } + ], + "imports": [ + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.terminal_parser", + "specifiers": [ + "extract_bash_output", + "extract_interactive_content", + "is_interactive_ui", + "parse_status_line", + "strip_pane_chrome" + ] + } + ], + "exports": [ + "TestParseStatusLine", + "TestExtractInteractiveContent", + "TestIsInteractiveUI", + "TestStripPaneChrome", + "TestExtractBashOutput" + ], + "totalLines": 266, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_tmux_manager.py": { + "filePath": "tests/ccbot/test_tmux_manager.py", + "contentHash": "028e69048a7c303db085eff6cfe9892cba3533e228be6588b600109382c841b3", + "functions": [ + { + "name": "mgr", + "params": [], + "returnType": "TmuxManager", + "exported": true, + "lineCount": 2 + }, + { + "name": "_fake_window", + "params": [ + "window_id", + "window_name", + "cwd", + "pane_cmd" + ], + "returnType": "MagicMock", + "exported": true, + "lineCount": 15 + } + ], + "classes": [ + { + "name": "TestGetSession", + "methods": [ + "test_returns_none_on_error", + "test_returns_session" + ], + "properties": [], + "exported": true, + "lineCount": 13 + }, + { + "name": "TestListWindows", + "methods": [ + "test_empty_when_no_session", + "test_skips_main_window", + "test_find_window_by_id", + "test_find_window_by_name" + ], + "properties": [], + "exported": true, + "lineCount": 42 + }, + { + "name": "TestScrubEnv", + "methods": [ + "test_scrub_swallows_missing_vars" + ], + "properties": [], + "exported": true, + "lineCount": 9 + }, + { + "name": "TestCapturePane", + "methods": [ + "test_plain_capture_joins_lines", + "test_plain_capture_none_without_session" + ], + "properties": [], + "exported": true, + "lineCount": 15 + }, + { + "name": "TestCreateWindowValidation", + "methods": [ + "test_rejects_missing_directory", + "test_rejects_non_uuid_resume" + ], + "properties": [], + "exported": true, + "lineCount": 21 + } + ], + "imports": [ + { + "source": "unittest.mock", + "specifiers": [ + "MagicMock", + "patch" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.tmux_manager", + "specifiers": [ + "TmuxManager", + "TmuxWindow" + ] + } + ], + "exports": [ + "mgr", + "_fake_window", + "TestGetSession", + "TestListWindows", + "TestScrubEnv", + "TestCapturePane", + "TestCreateWindowValidation" + ], + "totalLines": 142, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_transcribe.py": { + "filePath": "tests/ccbot/test_transcribe.py", + "contentHash": "ed95eeaaec8ff518e1952914e079c9c8f2ffabf4564d55d82ee404d5a4461420", + "functions": [ + { + "name": "_reset_client", + "params": [], + "exported": true, + "lineCount": 5 + }, + { + "name": "mock_config", + "params": [], + "exported": true, + "lineCount": 6 + }, + { + "name": "_mock_response", + "params": [ + "json_data", + "status_code" + ], + "returnType": "httpx.Response", + "exported": true, + "lineCount": 5 + } + ], + "classes": [ + { + "name": "TestTranscribeVoice", + "methods": [ + "test_success", + "test_empty_transcription_raises", + "test_whitespace_only_raises", + "test_missing_text_field_raises", + "test_api_error_raises", + "test_custom_base_url", + "test_base_url_trailing_slash_stripped" + ], + "properties": [], + "exported": true, + "lineCount": 74 + }, + { + "name": "TestCloseClient", + "methods": [ + "test_close_client_when_open", + "test_close_client_when_none" + ], + "properties": [], + "exported": true, + "lineCount": 13 + } + ], + "imports": [ + { + "source": "unittest.mock", + "specifiers": [ + "AsyncMock", + "patch" + ] + }, + { + "source": "httpx", + "specifiers": [ + "httpx" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot", + "specifiers": [ + "transcribe" + ] + } + ], + "exports": [ + "_reset_client", + "mock_config", + "_mock_response", + "TestTranscribeVoice", + "TestCloseClient" + ], + "totalLines": 124, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_transcript_parser.py": { + "filePath": "tests/ccbot/test_transcript_parser.py", + "contentHash": "865f334fe906281e1402c10bb9c06617c60a31e6a75259251fe227b02816f18e", + "functions": [], + "classes": [ + { + "name": "TestParseLine", + "methods": [ + "test_parse_line" + ], + "properties": [], + "exported": true, + "lineCount": 13 + }, + { + "name": "TestExtractTextOnly", + "methods": [ + "test_extract_text_only" + ], + "properties": [], + "exported": true, + "lineCount": 23 + }, + { + "name": "TestFormatToolUseSummary", + "methods": [ + "test_tool_summary", + "test_non_dict_input", + "test_truncation_at_200_chars" + ], + "properties": [], + "exported": true, + "lineCount": 63 + }, + { + "name": "TestExtractToolResultText", + "methods": [ + "test_extract_tool_result_text" + ], + "properties": [], + "exported": true, + "lineCount": 19 + }, + { + "name": "TestParseMessage", + "methods": [ + "test_user_text", + "test_assistant_text", + "test_local_command_with_stdout", + "test_local_command_invoke", + "test_non_user_assistant_returns_none", + "test_string_content" + ], + "properties": [], + "exported": true, + "lineCount": 67 + }, + { + "name": "TestFormatEditDiff", + "methods": [ + "test_format_edit_diff" + ], + "properties": [], + "exported": true, + "lineCount": 25 + }, + { + "name": "TestFormatToolResultText", + "methods": [ + "test_format_tool_result_text", + "test_write_counts_lines_from_input_content", + "test_write_trailing_newline_not_counted_extra" + ], + "properties": [], + "exported": true, + "lineCount": 76 + }, + { + "name": "TestParseEntries", + "methods": [ + "test_assistant_text", + "test_user_text", + "test_tool_use_and_result_pairing", + "test_thinking_block", + "test_local_command_with_stdout", + "test_exit_plan_mode_emits_plan", + "test_edit_tool_diff_stats", + "test_error_tool_result", + "test_interrupted_tool_result", + "test_pending_tools_carry_over", + "test_pending_tools_flushed_without_carry_over", + "test_system_tag_filtered" + ], + "properties": [], + "exported": true, + "lineCount": 192 + } + ], + "imports": [ + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.transcript_parser", + "specifiers": [ + "ParsedMessage", + "TranscriptParser" + ] + } + ], + "exports": [ + "TestParseLine", + "TestExtractTextOnly", + "TestFormatToolUseSummary", + "TestExtractToolResultText", + "TestParseMessage", + "TestFormatEditDiff", + "TestFormatToolResultText", + "TestParseEntries" + ], + "totalLines": 530, + "hasStructuralAnalysis": true + }, + "tests/ccbot/test_utils.py": { + "filePath": "tests/ccbot/test_utils.py", + "contentHash": "4fae5266457fa0688742104a3ecfb283d841f8929ac3bd03cb66d0f1cebd4f08", + "functions": [], + "classes": [ + { + "name": "TestCcbotDir", + "methods": [ + "test_returns_env_var_path", + "test_returns_default_without_env" + ], + "properties": [], + "exported": true, + "lineCount": 8 + }, + { + "name": "TestAtomicWriteJson", + "methods": [ + "test_writes_valid_json", + "test_creates_parent_directories", + "test_round_trip", + "test_no_temp_files_left_on_success" + ], + "properties": [], + "exported": true, + "lineCount": 24 + }, + { + "name": "TestReadCwdFromJsonl", + "methods": [ + "test_cwd_in_first_entry", + "test_cwd_in_second_entry", + "test_no_cwd_returns_empty", + "test_missing_file_returns_empty" + ], + "properties": [], + "exported": true, + "lineCount": 26 + } + ], + "imports": [ + { + "source": "json", + "specifiers": [ + "json" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + }, + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.utils", + "specifiers": [ + "atomic_write_json", + "ccbot_dir", + "read_cwd_from_jsonl" + ] + } + ], + "exports": [ + "TestCcbotDir", + "TestAtomicWriteJson", + "TestReadCwdFromJsonl" + ], + "totalLines": 73, + "hasStructuralAnalysis": true + }, + "tests/conftest.py": { + "filePath": "tests/conftest.py", + "contentHash": "9df142f705e3e4383754bdc715c10c06677e13625075aa505e6648458399f9b0", + "functions": [], + "classes": [], + "imports": [ + { + "source": "atexit", + "specifiers": [ + "atexit" + ] + }, + { + "source": "os", + "specifiers": [ + "os" + ] + }, + { + "source": "shutil", + "specifiers": [ + "shutil" + ] + }, + { + "source": "sys", + "specifiers": [ + "sys" + ] + }, + { + "source": "tempfile", + "specifiers": [ + "tempfile" + ] + }, + { + "source": "types", + "specifiers": [ + "types" + ] + }, + { + "source": "pathlib", + "specifiers": [ + "Path" + ] + } + ], + "exports": [], + "totalLines": 63, + "hasStructuralAnalysis": true + }, + "tests/integration/test_config_integration.py": { + "filePath": "tests/integration/test_config_integration.py", + "contentHash": "36344cc6f452ecdb4c768937e37ce5e450e6ded86b6a12b1fb6de8b851abcbb1", + "functions": [], + "classes": [ + { + "name": "TestConfigIntegration", + "methods": [ + "test_reads_env_file_from_config_dir", + "test_creates_config_dir_if_missing", + "test_multiple_comma_separated_allowed_users" + ], + "properties": [], + "exported": true, + "lineCount": 34 + } + ], + "imports": [ + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.config", + "specifiers": [ + "Config" + ] + } + ], + "exports": [ + "TestConfigIntegration" + ], + "totalLines": 44, + "hasStructuralAnalysis": true + }, + "tests/integration/test_monitor_state_integration.py": { + "filePath": "tests/integration/test_monitor_state_integration.py", + "contentHash": "fe963e118b24cb00f00af97dc2873ce33df9fc0dc6553bf60df9b2e779f4aee7", + "functions": [], + "classes": [ + { + "name": "TestMonitorStateIntegration", + "methods": [ + "test_save_load_round_trip", + "test_corrupt_file_recovery", + "test_dirty_tracking_with_save_if_dirty", + "test_remove_session_and_save" + ], + "properties": [], + "exported": true, + "lineCount": 58 + } + ], + "imports": [ + { + "source": "pytest", + "specifiers": [ + "pytest" + ] + }, + { + "source": "ccbot.monitor_state", + "specifiers": [ + "MonitorState", + "TrackedSession" + ] + } + ], + "exports": [ + "TestMonitorStateIntegration" + ], + "totalLines": 68, + "hasStructuralAnalysis": true + } + } +} \ No newline at end of file diff --git a/.understand-anything/knowledge-graph.json b/.understand-anything/knowledge-graph.json new file mode 100644 index 000000000..d7f981e25 --- /dev/null +++ b/.understand-anything/knowledge-graph.json @@ -0,0 +1,16608 @@ +{ + "version": "1.0.0", + "project": { + "name": "ccbot", + "languages": [ + "config", + "json", + "markdown", + "otf", + "python", + "shell", + "toml", + "txt", + "unknown", + "yaml" + ], + "frameworks": [ + "pytest", + "GitHub Actions", + "python-telegram-bot" + ], + "description": "CCBot — a Telegram bot that lets you monitor, interact with, and manage Claude Code sessions running in tmux from Telegram. Each Telegram topic maps 1:1 to a tmux window with a Claude session; it reads terminal output, sends keystrokes, renders screenshots, transcribes voice via OpenAI, and handles interactive UI prompts via inline keyboards.", + "analyzedAt": "2026-08-12T18:53:41.315Z", + "gitCommitHash": "2adfbb565fe08e66128e949f3538b5efe128c8c6" + }, + "nodes": [ + { + "id": "file:src/ccbot/handlers/auth.py", + "type": "file", + "name": "auth.py", + "filePath": "src/ccbot/handlers/auth.py", + "summary": "Thin backward-compatibility re-export of auth/topic helpers (get_thread_id, is_user_allowed) whose canonical definitions live in ccbot.session_guard (ISS-002); adds no logic of its own.", + "tags": [ + "barrel", + "re-export", + "auth", + "compatibility" + ], + "complexity": "simple", + "languageNotes": "Pure re-export barrel module; keep implementations in the canonical module." + }, + { + "id": "file:src/ccbot/handlers/callback_data.py", + "type": "file", + "name": "callback_data.py", + "filePath": "src/ccbot/handlers/callback_data.py", + "summary": "Central definition of all CB_* callback prefixes, typed frozen dataclass payloads, and encode/parse helpers that enforce Telegram's 64-byte callback_data limit for every inline keyboard in the bot.", + "tags": [ + "serialization", + "callback-data", + "validation", + "type-definition", + "tested" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/handlers/callback_directory.py", + "type": "file", + "name": "callback_directory.py", + "filePath": "src/ccbot/handlers/callback_directory.py", + "summary": "Routed sub-handlers for the directory browser (db:*): enter subdirectory, navigate up, page, confirm, and cancel, all gated by the stale-topic guard with user_data caching and session picker hand-off.", + "tags": [ + "api-handler", + "callback", + "directory-browser", + "topic-guard" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/callback_history.py", + "type": "file", + "name": "callback_history.py", + "filePath": "src/ccbot/handlers/callback_history.py", + "summary": "Routed handler for history pagination callbacks (hp:/hn:): parses HistoryCallback data, resolves the tmux window, and edits the current message with the requested history page.", + "tags": [ + "api-handler", + "callback", + "history", + "pagination" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/handlers/callback_interactive.py", + "type": "file", + "name": "callback_interactive.py", + "filePath": "src/ccbot/handlers/callback_interactive.py", + "summary": "Table-driven handler for interactive-UI actions (aq:*): maps each CB_ASK_* prefix to a tmux key sequence or a refresh-only display, then re-renders the interactive window via interactive_ui.", + "tags": [ + "api-handler", + "callback", + "interactive-ui", + "tmux-keys" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/callback_pickers.py", + "type": "file", + "name": "callback_pickers.py", + "filePath": "src/ccbot/handlers/callback_pickers.py", + "summary": "Routed handlers for session/window pickers (rs:*, wb:*): resume existing sessions, start new ones, bind unbound tmux windows to topics, forward pending text, and transition into the directory browser flow.", + "tags": [ + "api-handler", + "callback", + "picker", + "session-binding" + ], + "complexity": "complex" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:HistoryCallback", + "type": "class", + "name": "HistoryCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 86, + 106 + ], + "summary": "Frozen dataclass payload for history pagination (hp:/hn:) with field validation and on-wire serialization.", + "tags": [ + "dataclass", + "callback-data", + "serialization", + "validation" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:DirSelectCallback", + "type": "class", + "name": "DirSelectCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 110, + 120 + ], + "summary": "Frozen payload for directory-browser entry selection (db:sel:).", + "tags": [ + "dataclass", + "callback-data", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:DirPageCallback", + "type": "class", + "name": "DirPageCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 124, + 134 + ], + "summary": "Frozen payload for directory-browser page navigation (db:page:).", + "tags": [ + "dataclass", + "callback-data", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:WinBindCallback", + "type": "class", + "name": "WinBindCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 138, + 148 + ], + "summary": "Frozen payload for window-picker binding (wb:sel:).", + "tags": [ + "dataclass", + "callback-data", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:SessionSelectCallback", + "type": "class", + "name": "SessionSelectCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 152, + 162 + ], + "summary": "Frozen payload for session-picker resumption (rs:sel:).", + "tags": [ + "dataclass", + "callback-data", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:AskCallback", + "type": "class", + "name": "AskCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 166, + 179 + ], + "summary": "Frozen payload for interactive-UI actions (aq::) with prefix validation.", + "tags": [ + "dataclass", + "callback-data", + "interactive-ui" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:ScreenshotRefreshCallback", + "type": "class", + "name": "ScreenshotRefreshCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 183, + 193 + ], + "summary": "Frozen payload for screenshot refresh (ss:ref:).", + "tags": [ + "dataclass", + "callback-data", + "screenshot" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/callback_data.py:KeyCallback", + "type": "class", + "name": "KeyCallback", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 197, + 210 + ], + "summary": "Frozen payload for screenshot control keys (kb::).", + "tags": [ + "dataclass", + "callback-data", + "screenshot" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "function", + "name": "safe_callback_data", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 227, + 247 + ], + "summary": "Serializes callback data (string or typed payload) with an explicit 64-byte UTF-8 limit check that raises instead of truncating.", + "tags": [ + "serialization", + "validation", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:encode_history_page", + "type": "function", + "name": "encode_history_page", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 257, + 273 + ], + "summary": "Builds a HistoryCallback wire string for a history page given older/next direction, page, window and byte range.", + "tags": [ + "serialization", + "history", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_history", + "type": "function", + "name": "parse_history", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 276, + 312 + ], + "summary": "Parses hp:/hn: callback data into a HistoryCallback, supporting legacy page:window_id forms and current page:window_id:start:end forms.", + "tags": [ + "parsing", + "history", + "callback-data" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_dir_select", + "type": "function", + "name": "parse_dir_select", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 322, + 328 + ], + "summary": "Parses db:sel: into a DirSelectCallback or None on failure.", + "tags": [ + "parsing", + "directory-browser", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_dir_page", + "type": "function", + "name": "parse_dir_page", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 335, + 341 + ], + "summary": "Parses db:page: into a DirPageCallback or None on failure.", + "tags": [ + "parsing", + "directory-browser", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_win_bind", + "type": "function", + "name": "parse_win_bind", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 348, + 354 + ], + "summary": "Parses wb:sel: into a WinBindCallback or None on failure.", + "tags": [ + "parsing", + "window-picker", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_session_select", + "type": "function", + "name": "parse_session_select", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 361, + 367 + ], + "summary": "Parses rs:sel: into a SessionSelectCallback or None on failure.", + "tags": [ + "parsing", + "session-picker", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_screenshot_refresh", + "type": "function", + "name": "parse_screenshot_refresh", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 377, + 386 + ], + "summary": "Parses ss:ref: into a ScreenshotRefreshCallback or None on failure.", + "tags": [ + "parsing", + "screenshot", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_ask", + "type": "function", + "name": "parse_ask", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 393, + 403 + ], + "summary": "Parses an aq:: payload for a known prefix into an AskCallback.", + "tags": [ + "parsing", + "interactive-ui", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_data.py:parse_key", + "type": "function", + "name": "parse_key", + "filePath": "src/ccbot/handlers/callback_data.py", + "lineRange": [ + 413, + 428 + ], + "summary": "Parses kb::, tolerating colons inside window_id.", + "tags": [ + "parsing", + "screenshot", + "callback-data" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_select", + "type": "function", + "name": "_handle_dir_select", + "filePath": "src/ccbot/handlers/callback_directory.py", + "lineRange": [ + 33, + 81 + ], + "summary": "Directory browser: enters the selected subdirectory (looked up by index from the cached listing) and re-renders the browser.", + "tags": [ + "callback", + "directory-browser", + "navigation" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_up", + "type": "function", + "name": "_handle_dir_up", + "filePath": "src/ccbot/handlers/callback_directory.py", + "lineRange": [ + 84, + 114 + ], + "summary": "Directory browser: navigates one level up to the parent directory and re-renders.", + "tags": [ + "callback", + "directory-browser", + "navigation" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_page", + "type": "function", + "name": "_handle_dir_page", + "filePath": "src/ccbot/handlers/callback_directory.py", + "lineRange": [ + 117, + 146 + ], + "summary": "Directory browser: switches the visible page of the cached subdirectory listing.", + "tags": [ + "callback", + "directory-browser", + "pagination" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_confirm", + "type": "function", + "name": "_handle_dir_confirm", + "filePath": "src/ccbot/handlers/callback_directory.py", + "lineRange": [ + 149, + 190 + ], + "summary": "Directory browser: confirms the current path; shows the session picker when sessions exist in the directory, otherwise creates and binds a new window directly.", + "tags": [ + "callback", + "directory-browser", + "session-picker" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_cancel", + "type": "function", + "name": "_handle_dir_cancel", + "filePath": "src/ccbot/handlers/callback_directory.py", + "lineRange": [ + 193, + 207 + ], + "summary": "Directory browser: clears browse and pending-thread state and cancels the flow.", + "tags": [ + "callback", + "directory-browser", + "cleanup" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_history.py:_handle_history", + "type": "function", + "name": "_handle_history", + "filePath": "src/ccbot/handlers/callback_history.py", + "lineRange": [ + 14, + 41 + ], + "summary": "History pagination callback: parses the HistoryCallback, finds the tmux window, and edits the message with the requested page of history.", + "tags": [ + "callback", + "history", + "pagination" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_interactive.py:_handle_ask", + "type": "function", + "name": "_handle_ask", + "filePath": "src/ccbot/handlers/callback_interactive.py", + "lineRange": [ + 51, + 81 + ], + "summary": "Table-driven interactive-UI callback: dispatches CB_ASK_* prefixes to tmux key presses (with post-key refresh), refresh-only display, or interactive-message clearing.", + "tags": [ + "callback", + "interactive-ui", + "tmux-keys" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_select", + "type": "function", + "name": "_handle_session_select", + "filePath": "src/ccbot/handlers/callback_pickers.py", + "lineRange": [ + 39, + 80 + ], + "summary": "Session picker: resumes the selected cached session by creating and binding a window with resume_session_id.", + "tags": [ + "callback", + "session-picker", + "session-binding" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_new", + "type": "function", + "name": "_handle_session_new", + "filePath": "src/ccbot/handlers/callback_pickers.py", + "lineRange": [ + 83, + 103 + ], + "summary": "Session picker: starts a brand-new session in the selected directory.", + "tags": [ + "callback", + "session-picker", + "session-binding" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_cancel", + "type": "function", + "name": "_handle_session_cancel", + "filePath": "src/ccbot/handlers/callback_pickers.py", + "lineRange": [ + 106, + 122 + ], + "summary": "Session picker: clears picker and pending-thread state and cancels.", + "tags": [ + "callback", + "session-picker", + "cleanup" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "type": "function", + "name": "_handle_win_bind", + "filePath": "src/ccbot/handlers/callback_pickers.py", + "lineRange": [ + 125, + 189 + ], + "summary": "Window picker: binds an existing unbound tmux window to the topic, forwards any pending text, and reports send failures.", + "tags": [ + "callback", + "window-picker", + "session-binding" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_new", + "type": "function", + "name": "_handle_win_new", + "filePath": "src/ccbot/handlers/callback_pickers.py", + "lineRange": [ + 192, + 213 + ], + "summary": "Window picker: transitions to the directory browser to create a new session.", + "tags": [ + "callback", + "window-picker", + "directory-browser" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_cancel", + "type": "function", + "name": "_handle_win_cancel", + "filePath": "src/ccbot/handlers/callback_pickers.py", + "lineRange": [ + 216, + 230 + ], + "summary": "Window picker: clears picker and pending-thread state and cancels.", + "tags": [ + "callback", + "window-picker", + "cleanup" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/handlers/callback_router.py", + "type": "file", + "name": "callback_router.py", + "filePath": "src/ccbot/handlers/callback_router.py", + "summary": "Central callback query router: authenticates the user, captures the group chat id for supergroup topic routing, and dispatches every CB_* payload through exact-match and prefix route registries to the callback_* sub-handlers.", + "tags": [ + "api-handler", + "router", + "dispatch", + "callback" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/callback_screenshot.py", + "type": "file", + "name": "callback_screenshot.py", + "filePath": "src/ccbot/handlers/callback_screenshot.py", + "summary": "Handlers for screenshot callbacks (sr:*, kb:*): refresh pane captures in place and send quick control keys to the tmux window, re-rendering the PNG screenshot after each interaction.", + "tags": [ + "api-handler", + "callback", + "screenshot", + "tmux-keys" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/callback_topic_guard.py", + "type": "file", + "name": "callback_topic_guard.py", + "filePath": "src/ccbot/handlers/callback_topic_guard.py", + "summary": "Shared stale-topic guard for picker/browser callback flows: verifies a callback comes from the topic that started the pending flow, answers with a stale alert on mismatch, and hosts pending-thread state cleanup plus the RouteHandler signature.", + "tags": [ + "middleware", + "topic-guard", + "validation", + "callback" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/command_handlers.py", + "type": "file", + "name": "command_handlers.py", + "filePath": "src/ccbot/handlers/command_handlers.py", + "summary": "Telegram command and topic lifecycle handlers: /start, /history, /screenshot, /unbind, /esc, /usage, topic-closed and topic-edited sync, unknown-slash forwarding to Claude Code, and unsupported-content rejection.", + "tags": [ + "api-handler", + "command", + "topic-lifecycle", + "event-handler", + "tested" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/handlers/directory_browser.py", + "type": "file", + "name": "directory_browser.py", + "filePath": "src/ccbot/handlers/directory_browser.py", + "summary": "Builds Telegram UIs for session creation: a picker of unbound tmux windows, a paginated directory browser over the filesystem, and a session-resumption picker, with user_data state keys and relative mtime formatting.", + "tags": [ + "component", + "directory-browser", + "keyboard-ui", + "session-picker" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/handlers/history.py", + "type": "file", + "name": "history.py", + "filePath": "src/ccbot/handlers/history.py", + "summary": "Sends or edits paginated message history for a tmux window's session, supporting byte-range unread views, MarkdownV2-aware page splitting, read-offset tracking, and an inline prev/next pagination keyboard.", + "tags": [ + "history", + "pagination", + "service", + "formatting" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/handlers/callback_router.py:_match_route", + "type": "function", + "name": "_match_route", + "filePath": "src/ccbot/handlers/callback_router.py", + "lineRange": [ + 106, + 114 + ], + "summary": "Resolves callback data to a handler: exact-match table first, then prefix table.", + "tags": [ + "router", + "dispatch", + "callback" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_router.py:callback_handler", + "type": "function", + "name": "callback_handler", + "filePath": "src/ccbot/handlers/callback_router.py", + "lineRange": [ + 117, + 141 + ], + "summary": "Entry point for all inline keyboards: requires an authorized user, captures the group chat id for topic routing, then dispatches via the route registries.", + "tags": [ + "api-handler", + "router", + "auth" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_screenshot.py:_handle_screenshot_refresh", + "type": "function", + "name": "_handle_screenshot_refresh", + "filePath": "src/ccbot/handlers/callback_screenshot.py", + "lineRange": [ + 25, + 60 + ], + "summary": "Screenshot refresh callback: recaptures the pane, re-renders the PNG, and edits the message media in place.", + "tags": [ + "callback", + "screenshot", + "image-rendering" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_screenshot.py:_handle_key", + "type": "function", + "name": "_handle_key", + "filePath": "src/ccbot/handlers/callback_screenshot.py", + "lineRange": [ + 63, + 107 + ], + "summary": "Screenshot key callback: maps a key_id to a tmux key sequence, sends it, then refreshes the screenshot in place.", + "tags": [ + "callback", + "screenshot", + "tmux-keys" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "function", + "name": "_check_same_topic", + "filePath": "src/ccbot/handlers/callback_topic_guard.py", + "lineRange": [ + 32, + 60 + ], + "summary": "Stale-topic guard: verifies the callback comes from the topic that started the pending flow, recovering the marker when configured, and answers with a stale alert on mismatch.", + "tags": [ + "middleware", + "topic-guard", + "validation" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/callback_topic_guard.py:_clear_pending_thread", + "type": "function", + "name": "_clear_pending_thread", + "filePath": "src/ccbot/handlers/callback_topic_guard.py", + "lineRange": [ + 63, + 67 + ], + "summary": "Drops the pending topic flow markers (_pending_thread_id/_pending_thread_text) from user_data.", + "tags": [ + "cleanup", + "topic-guard", + "state" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:start_command", + "type": "function", + "name": "start_command", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 38, + 50 + ], + "summary": "/start: authorizes the user, clears browser state, and shows the intro text.", + "tags": [ + "command", + "entry-point", + "onboarding" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:screenshot_command", + "type": "function", + "name": "screenshot_command", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 63, + 83 + ], + "summary": "/screenshot: captures the pane, renders it to PNG, and sends it with the screenshot control keyboard.", + "tags": [ + "command", + "screenshot", + "image-rendering" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "type": "function", + "name": "unbind_command", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 86, + 118 + ], + "summary": "/unbind: detaches the topic from its tmux window without killing the session and clears topic state.", + "tags": [ + "command", + "session-binding", + "cleanup" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:esc_command", + "type": "function", + "name": "esc_command", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 121, + 130 + ], + "summary": "/esc: sends the Escape control character to the window to interrupt Claude.", + "tags": [ + "command", + "tmux-keys", + "interrupt" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:usage_command", + "type": "function", + "name": "usage_command", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 133, + 167 + ], + "summary": "/usage: sends /usage to the TUI, captures and parses the output, falling back to a raw trimmed capture.", + "tags": [ + "command", + "monitoring", + "parsing" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "type": "function", + "name": "topic_closed_handler", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 170, + 222 + ], + "summary": "Topic-closed lifecycle: kills the bound tmux window and always unbinds/clears topic state, silently when nothing is bound.", + "tags": [ + "event-handler", + "topic-lifecycle", + "cleanup" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:topic_edited_handler", + "type": "function", + "name": "topic_edited_handler", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 225, + 263 + ], + "summary": "Topic-edited lifecycle: syncs a forum topic rename to the tmux window and internal display name.", + "tags": [ + "event-handler", + "topic-lifecycle", + "synchronization" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:forward_command_handler", + "type": "function", + "name": "forward_command_handler", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 266, + 315 + ], + "summary": "Forwards any non-bot slash command to the active Claude Code session, clearing the session association after /clear.", + "tags": [ + "command", + "forwarding", + "session-binding" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/command_handlers.py:unsupported_content_handler", + "type": "function", + "name": "unsupported_content_handler", + "filePath": "src/ccbot/handlers/command_handlers.py", + "lineRange": [ + 318, + 332 + ], + "summary": "Rejects non-text/non-photo/non-voice content with a short explanation.", + "tags": [ + "event-handler", + "validation", + "media-filter" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/directory_browser.py:clear_browse_state", + "type": "function", + "name": "clear_browse_state", + "filePath": "src/ccbot/handlers/directory_browser.py", + "lineRange": [ + 54, + 60 + ], + "summary": "Clears directory-browsing state keys from user_data.", + "tags": [ + "cleanup", + "state", + "directory-browser" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/directory_browser.py:clear_window_picker_state", + "type": "function", + "name": "clear_window_picker_state", + "filePath": "src/ccbot/handlers/directory_browser.py", + "lineRange": [ + 63, + 67 + ], + "summary": "Clears window-picker state keys from user_data.", + "tags": [ + "cleanup", + "state", + "window-picker" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/directory_browser.py:clear_session_picker_state", + "type": "function", + "name": "clear_session_picker_state", + "filePath": "src/ccbot/handlers/directory_browser.py", + "lineRange": [ + 70, + 74 + ], + "summary": "Clears session-picker state keys from user_data.", + "tags": [ + "cleanup", + "state", + "session-picker" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/directory_browser.py:build_window_picker", + "type": "function", + "name": "build_window_picker", + "filePath": "src/ccbot/handlers/directory_browser.py", + "lineRange": [ + 77, + 124 + ], + "summary": "Builds the unbound-window picker UI with two-per-row buttons and New Session / Cancel actions.", + "tags": [ + "component", + "keyboard-ui", + "window-picker" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "type": "function", + "name": "build_directory_browser", + "filePath": "src/ccbot/handlers/directory_browser.py", + "lineRange": [ + 127, + 214 + ], + "summary": "Builds the paginated directory browser UI with two-folders-per-row layout, page navigation, and up/select/cancel actions.", + "tags": [ + "component", + "keyboard-ui", + "directory-browser" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/directory_browser.py:_relative_time", + "type": "function", + "name": "_relative_time", + "filePath": "src/ccbot/handlers/directory_browser.py", + "lineRange": [ + 217, + 233 + ], + "summary": "Formats a file's mtime as a human-readable relative time (m/h/d ago).", + "tags": [ + "utility", + "formatting", + "time" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/directory_browser.py:build_session_picker", + "type": "function", + "name": "build_session_picker", + "filePath": "src/ccbot/handlers/directory_browser.py", + "lineRange": [ + 236, + 284 + ], + "summary": "Builds the session-resumption picker UI with per-session summaries, message counts, and relative times.", + "tags": [ + "component", + "keyboard-ui", + "session-picker" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/history.py:_build_history_keyboard", + "type": "function", + "name": "_build_history_keyboard", + "filePath": "src/ccbot/handlers/history.py", + "lineRange": [ + 25, + 80 + ], + "summary": "Builds the history pagination keyboard (Older/page/Newer) with byte-range preservation, or None for a single page.", + "tags": [ + "component", + "keyboard-ui", + "pagination" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/history.py:send_history", + "type": "function", + "name": "send_history", + "filePath": "src/ccbot/handlers/history.py", + "lineRange": [ + 83, + 248 + ], + "summary": "Sends or edits paginated message history for a window's session: unread byte-range views, assistant-only filtering, MarkdownV2-safe page splitting, and read-offset updates.", + "tags": [ + "service", + "history", + "pagination", + "formatting" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/message_sender.py", + "type": "file", + "name": "message_sender.py", + "filePath": "src/ccbot/handlers/message_sender.py", + "summary": "Safe Telegram send/edit helpers with automatic MarkdownV2 to plain-text fallback funneled through MessageFormatter and _run_markdown_fallback, consistent RetryAfter re-raise, photo/media-group sending, and disabled link previews.", + "tags": [ + "service", + "telegram-send", + "markdown-v2", + "fallback", + "middleware", + "tested" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/handlers/screenshot_controls.py", + "type": "file", + "name": "screenshot_controls.py", + "filePath": "src/ccbot/handlers/screenshot_controls.py", + "summary": "Builds the inline control keyboard shown under terminal screenshots and maps compact key_ids to tmux key sequences and display labels for KB_* callbacks.", + "tags": [ + "component", + "keyboard-ui", + "screenshot", + "tmux-keys" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/window_bind.py", + "type": "file", + "name": "window_bind.py", + "filePath": "src/ccbot/handlers/window_bind.py", + "summary": "Creates a tmux window (optionally resuming an existing session), waits for Claude Code's SessionStart hook with per-mode timeouts, applies session-map overrides when needed, binds the topic thread, and forwards pending text.", + "tags": [ + "service", + "session-binding", + "tmux", + "window-creation" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/screenshot.py", + "type": "file", + "name": "screenshot.py", + "filePath": "src/ccbot/screenshot.py", + "summary": "Renders terminal text (with optional full ANSI 16/256/RGB color parsing) into dark-background PNG images using a three-tier font fallback chain, running the CPU-heavy rendering in a thread pool.", + "tags": [ + "utility", + "image-rendering", + "ansi-parsing", + "pillow" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/session_guard.py", + "type": "file", + "name": "session_guard.py", + "filePath": "src/ccbot/session_guard.py", + "summary": "Shared auth and topic-to-window resolution guards for bot handlers: require_user, require_bound_window_id, and require_session compose into a SessionContext, with a config-based user allowlist and unified UX strings (ISS-007).", + "tags": [ + "middleware", + "auth", + "session-resolution", + "guard", + "tested" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:strip_sentinels", + "type": "function", + "name": "strip_sentinels", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 41, + 48 + ], + "summary": "Strips expandable-quote sentinel markers for plain-text fallback.", + "tags": [ + "utility", + "markdown", + "sanitization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/message_sender.py:MessageFormatter", + "type": "class", + "name": "MessageFormatter", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 57, + 74 + ], + "summary": "Single source of MarkdownV2 formatting and plain-text fallback conversion used by every send/edit/reply helper.", + "tags": [ + "factory", + "markdown-v2", + "formatting" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:_run_markdown_fallback", + "type": "function", + "name": "_run_markdown_fallback", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 81, + 143 + ], + "summary": "Tries the MarkdownV2 send call, then falls back to plain text, always re-raising RetryAfter and logging failures with structured context.", + "tags": [ + "service", + "fallback", + "error-handling" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:send_with_fallback", + "type": "function", + "name": "send_with_fallback", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 150, + 195 + ], + "summary": "Sends a message with MarkdownV2 and plain-text fallback to a chat (optionally into a forum topic).", + "tags": [ + "service", + "telegram-send", + "fallback" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:edit_with_fallback", + "type": "function", + "name": "edit_with_fallback", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 198, + 240 + ], + "summary": "Edits a message with MarkdownV2 and plain-text fallback; returns True only on success.", + "tags": [ + "service", + "telegram-send", + "fallback" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:send_photo", + "type": "function", + "name": "send_photo", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 243, + 286 + ], + "summary": "Sends one or more images as a single photo or a media group, logging (not raising) non-rate-limit Telegram errors.", + "tags": [ + "service", + "telegram-send", + "media" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "function", + "name": "safe_reply", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 289, + 313 + ], + "summary": "Replies to a message with MarkdownV2 and plain-text fallback, re-raising final Telegram errors.", + "tags": [ + "service", + "telegram-send", + "fallback" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "function", + "name": "safe_edit", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 316, + 338 + ], + "summary": "Edits a target message with MarkdownV2 and plain-text fallback (used by callback handlers).", + "tags": [ + "service", + "telegram-send", + "fallback" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_sender.py:safe_send", + "type": "function", + "name": "safe_send", + "filePath": "src/ccbot/handlers/message_sender.py", + "lineRange": [ + 341, + 355 + ], + "summary": "Thin wrapper around send_with_fallback for direct bot+chat sends.", + "tags": [ + "service", + "telegram-send", + "fallback" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/screenshot_controls.py:build_screenshot_keyboard", + "type": "function", + "name": "build_screenshot_keyboard", + "filePath": "src/ccbot/handlers/screenshot_controls.py", + "lineRange": [ + 44, + 69 + ], + "summary": "Builds the screenshot control keyboard: navigation arrows, Tab/Space/Enter/Esc, Ctrl-C, and a refresh button.", + "tags": [ + "component", + "keyboard-ui", + "screenshot" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "type": "function", + "name": "create_and_bind_window", + "filePath": "src/ccbot/handlers/window_bind.py", + "lineRange": [ + 19, + 147 + ], + "summary": "Creates a tmux window (optionally resuming a session), waits for the SessionStart hook, forces session-map fixes for resumed sessions, binds the thread, and forwards pending text.", + "tags": [ + "service", + "session-binding", + "tmux", + "window-creation" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/screenshot.py:_font_tier", + "type": "function", + "name": "_font_tier", + "filePath": "src/ccbot/screenshot.py", + "lineRange": [ + 101, + 121 + ], + "summary": "Assigns a font tier (JetBrains/JB, Noto CJK, Symbola) to a character based on codepoint ranges.", + "tags": [ + "utility", + "font-handling", + "unicode" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/screenshot.py:_parse_ansi_line", + "type": "function", + "name": "_parse_ansi_line", + "filePath": "src/ccbot/screenshot.py", + "lineRange": [ + 124, + 159 + ], + "summary": "Splits a line into styled segments by parsing ANSI SGR escape sequences.", + "tags": [ + "utility", + "ansi-parsing", + "parsing" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/screenshot.py:_apply_ansi_codes", + "type": "function", + "name": "_apply_ansi_codes", + "filePath": "src/ccbot/screenshot.py", + "lineRange": [ + 162, + 219 + ], + "summary": "Applies ANSI color codes to a TextStyle, covering basic 16 colors, 256-color palette, and RGB extensions.", + "tags": [ + "utility", + "ansi-parsing", + "color" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/screenshot.py:_approximate_256_color", + "type": "function", + "name": "_approximate_256_color", + "filePath": "src/ccbot/screenshot.py", + "lineRange": [ + 222, + 236 + ], + "summary": "Maps a 256-color palette index to an approximate RGB tuple (cube or grayscale).", + "tags": [ + "utility", + "color", + "conversion" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/screenshot.py:_split_line_segments_plain", + "type": "function", + "name": "_split_line_segments_plain", + "filePath": "src/ccbot/screenshot.py", + "lineRange": [ + 239, + 256 + ], + "summary": "Splits a plain line into (text, font_tier) segments, merging consecutive same-tier characters.", + "tags": [ + "utility", + "font-handling", + "parsing" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/screenshot.py:text_to_image", + "type": "function", + "name": "text_to_image", + "filePath": "src/ccbot/screenshot.py", + "lineRange": [ + 259, + 336 + ], + "summary": "Public entry point: renders text (optionally ANSI-colored) onto a dark PNG image via a font fallback chain, run in a thread pool.", + "tags": [ + "service", + "image-rendering", + "ansi-parsing", + "pillow" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "function", + "name": "get_thread_id", + "filePath": "src/ccbot/session_guard.py", + "lineRange": [ + 54, + 67 + ], + "summary": "Extracts the forum thread id from an update, treating General topic id 1 as no-topic.", + "tags": [ + "utility", + "topic-resolution", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/session_guard.py:require_user", + "type": "function", + "name": "require_user", + "filePath": "src/ccbot/session_guard.py", + "lineRange": [ + 70, + 101 + ], + "summary": "Authorizes the effective user against the config allowlist, answering or replying with the unauthorized message on failure.", + "tags": [ + "middleware", + "auth", + "guard" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/session_guard.py:require_bound_window_id", + "type": "function", + "name": "require_bound_window_id", + "filePath": "src/ccbot/session_guard.py", + "lineRange": [ + 104, + 140 + ], + "summary": "Auth plus topic-to-window id resolution without requiring a live tmux window.", + "tags": [ + "middleware", + "auth", + "session-resolution" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/session_guard.py:require_session", + "type": "function", + "name": "require_session", + "filePath": "src/ccbot/session_guard.py", + "lineRange": [ + 143, + 215 + ], + "summary": "Full guard: auth, named-topic check, window resolution, and live tmux window lookup, returning a SessionContext or None.", + "tags": [ + "middleware", + "auth", + "session-resolution" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/binding_store.py", + "type": "file", + "name": "binding_store.py", + "filePath": "src/ccbot/binding_store.py", + "summary": "In-memory storage of thread→window bindings and group chat IDs for Telegram topic routing, with byte-for-byte state.json (de)serialization. Pure in-memory dataclass store; persistence is handled by the SessionManager facade.", + "tags": [ + "data-model", + "state-management", + "routing", + "serialization" + ], + "complexity": "moderate" + }, + { + "id": "class:src/ccbot/binding_store.py:BindingStore", + "type": "class", + "name": "BindingStore", + "filePath": "src/ccbot/binding_store.py", + "lineRange": [ + 19, + 129 + ], + "summary": "Owns thread_bindings (user→thread→window) and group_chat_ids (user:thread→supergroup chat) routing dicts plus their state.json serialization; mutating methods do not persist, leaving save responsibility to the caller.", + "tags": [ + "data-model", + "routing", + "serialization", + "state-management" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/bot.py", + "type": "file", + "name": "bot.py", + "filePath": "src/ccbot/bot.py", + "summary": "Telegram bot application factory and lifecycle: builds the PTB Application, registers all command/callback/message handlers, sets the bot command menu, starts the session monitor and status polling task on init, and tears them down on shutdown.", + "tags": [ + "entry-point", + "telegram-bot", + "lifecycle", + "handler-registration" + ], + "complexity": "moderate", + "languageNotes": "Uses python-telegram-bot Application builder with post_init/post_shutdown hooks and an AIORateLimiter; re-exports handler symbols for stable imports." + }, + { + "id": "function:src/ccbot/bot.py:post_init", + "type": "function", + "name": "post_init", + "filePath": "src/ccbot/bot.py", + "lineRange": [ + 99, + 145 + ], + "summary": "Async application init hook: registers the bot command menu, re-resolves stale window IDs against live tmux windows, pre-fills the rate limiter bucket, starts the SessionMonitor with a message callback wired to handle_new_message, and launches the status polling task.", + "tags": [ + "lifecycle", + "setup", + "monitoring", + "telegram-bot" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/bot.py:post_shutdown", + "type": "function", + "name": "post_shutdown", + "filePath": "src/ccbot/bot.py", + "lineRange": [ + 148, + 168 + ], + "summary": "Async shutdown hook that cancels the status polling task, stops all message queue workers, stops the session monitor, and closes the transcription HTTP client.", + "tags": [ + "lifecycle", + "cleanup", + "shutdown" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/bot.py:create_bot", + "type": "function", + "name": "create_bot", + "filePath": "src/ccbot/bot.py", + "lineRange": [ + 171, + 219 + ], + "summary": "Builds and returns the PTB Application instance: registers command handlers, callback query routing, forum topic closed/edited handlers, command/text/photo/voice message handlers, and a catch-all for unsupported content.", + "tags": [ + "factory", + "handler-registration", + "telegram-bot" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/config.py", + "type": "file", + "name": "config.py", + "filePath": "src/ccbot/config.py", + "summary": "Application configuration loaded from environment variables with layered .env support; exposes a module-level `config` singleton imported by nearly every module and scrubs sensitive env vars from child processes.", + "tags": [ + "configuration", + "env-vars", + "singleton", + "validation", + "tested" + ], + "complexity": "moderate" + }, + { + "id": "class:src/ccbot/config.py:Config", + "type": "class", + "name": "Config", + "filePath": "src/ccbot/config.py", + "lineRange": [ + 25, + 127 + ], + "summary": "Loads and validates all runtime settings (Telegram token, allowed users, tmux/Claude paths, monitoring intervals, display toggles, OpenAI transcription) from env vars, resolving the Claude projects path with a priority chain and removing sensitive vars from the environment.", + "tags": [ + "configuration", + "validation", + "env-vars", + "singleton" + ], + "complexity": "moderate", + "languageNotes": "Uses dotenv load_dotenv with override=False so the first-loaded .env wins; Config instance is instantiated at module import time." + }, + { + "id": "file:src/ccbot/handlers/notifications.py", + "type": "file", + "name": "notifications.py", + "filePath": "src/ccbot/handlers/notifications.py", + "summary": "Outbound assistant-message routing: delivers NewMessage events from the SessionMonitor to the correct Telegram topic via thread bindings, handing interactive tool messages to a dedicated UI handler and enqueueing content tasks per user.", + "tags": [ + "event-handler", + "routing", + "telegram", + "monitoring" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/notifications.py:handle_new_message", + "type": "function", + "name": "handle_new_message", + "filePath": "src/ccbot/handlers/notifications.py", + "lineRange": [ + 32, + 123 + ], + "summary": "Routes a NewMessage to all users whose thread-bound window maps to the session: special-cases interactive tool_use messages, clears stale interactive UI, respects the show_tool_calls toggle, and enqueues content messages while advancing per-user read offsets.", + "tags": [ + "event-handler", + "routing", + "queueing", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/hook.py", + "type": "file", + "name": "hook.py", + "filePath": "src/ccbot/hook.py", + "summary": "Claude Code SessionStart hook subcommand: maintains the window↔session mapping in session_map.json under a file lock and can auto-install itself into ~/.claude/settings.json. Deliberately does not import config.py since hooks run inside tmux panes without bot env vars.", + "tags": [ + "entry-point", + "subprocess", + "file-locking", + "integration", + "tested" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/hook.py:_find_ccbot_path", + "type": "function", + "name": "_find_ccbot_path", + "filePath": "src/ccbot/hook.py", + "lineRange": [ + 37, + 57 + ], + "summary": "Locates the ccbot executable for the hook command, preferring PATH lookup and falling back to the Python interpreter's directory (venv installs).", + "tags": [ + "utility", + "path-resolution", + "setup" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/hook.py:_is_hook_installed", + "type": "function", + "name": "_is_hook_installed", + "filePath": "src/ccbot/hook.py", + "lineRange": [ + 60, + 79 + ], + "summary": "Checks whether a 'ccbot hook' command entry already exists in Claude's SessionStart hooks inside a parsed settings dict.", + "tags": [ + "utility", + "setup", + "detection" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/hook.py:_install_hook", + "type": "function", + "name": "_install_hook", + "filePath": "src/ccbot/hook.py", + "lineRange": [ + 82, + 132 + ], + "summary": "Installs the ccbot hook command into ~/.claude/settings.json (preserving existing settings), skipping if already installed; returns 0 on success and 1 on error.", + "tags": [ + "setup", + "configuration", + "file-io" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/hook.py:hook_main", + "type": "function", + "name": "hook_main", + "filePath": "src/ccbot/hook.py", + "lineRange": [ + 135, + 277 + ], + "summary": "CLI entry point: handles the --install flag or processes a SessionStart hook event from stdin, resolving the tmux window via TMUX_PANE, validating the payload, and atomically updating session_map.json under a flock while removing old-format keys.", + "tags": [ + "entry-point", + "subprocess", + "file-locking", + "state-update" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/monitor_state.py", + "type": "file", + "name": "monitor_state.py", + "filePath": "src/ccbot/monitor_state.py", + "summary": "Persistence for the session monitor: tracks per-session JSONL byte offsets and file paths in monitor_state.json so incremental reading resumes after restarts without re-sending old messages.", + "tags": [ + "state-management", + "persistence", + "data-model", + "monitoring", + "tested" + ], + "complexity": "moderate" + }, + { + "id": "class:src/ccbot/monitor_state.py:TrackedSession", + "type": "class", + "name": "TrackedSession", + "filePath": "src/ccbot/monitor_state.py", + "lineRange": [ + 20, + 38 + ], + "summary": "Dataclass record for a monitored session holding session_id, JSONL file path, and last read byte offset, with to_dict/from_dict conversion for JSON persistence.", + "tags": [ + "data-model", + "persistence", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/monitor_state.py:MonitorState", + "type": "class", + "name": "MonitorState", + "filePath": "src/ccbot/monitor_state.py", + "lineRange": [ + 42, + 109 + ], + "summary": "Persistent store of tracked sessions keyed by session_id with load/save, add/remove/update operations and a dirty flag so frequent poll cycles only write when changed; saves atomically via utils.", + "tags": [ + "state-management", + "persistence", + "monitoring" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/session.py", + "type": "file", + "name": "session.py", + "filePath": "src/ccbot/session.py", + "summary": "Core state hub: the SessionManager singleton orchestrates window↔session mappings, thread bindings, per-user read offsets, session_map.json synchronization, tmux keystroke delivery, and message history, delegating storage to dedicated store/repository classes.", + "tags": [ + "service", + "state-management", + "routing", + "facade", + "tested" + ], + "complexity": "complex", + "languageNotes": "Dataclass facade that composes BindingStore, WindowStateStore, SessionMapRepository and TranscriptReader via property delegates, keeping backward-compatible access while delegating ownership (ISS-005)." + }, + { + "id": "class:src/ccbot/session.py:SessionManager", + "type": "class", + "name": "SessionManager", + "filePath": "src/ccbot/session.py", + "lineRange": [ + 50, + 646 + ], + "summary": "Facade managing all session state: persists state.json, syncs window states from the hook-written session_map, re-resolves stale window IDs at startup, resolves windows to Claude sessions, binds threads, tracks user read offsets, sends keys to tmux windows, and reads message history.", + "tags": [ + "service", + "facade", + "state-management", + "routing" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/session_map_repository.py", + "type": "file", + "name": "session_map_repository.py", + "filePath": "src/ccbot/session_map_repository.py", + "summary": "File-access repository for session_map.json (written by the SessionStart hook): async reads, atomic writes, and the flock read-modify-write discipline shared with the hook to prevent lost updates.", + "tags": [ + "persistence", + "file-io", + "locking", + "repository" + ], + "complexity": "moderate" + }, + { + "id": "class:src/ccbot/session_map_repository.py:SessionMapRepository", + "type": "class", + "name": "SessionMapRepository", + "filePath": "src/ccbot/session_map_repository.py", + "lineRange": [ + 23, + 88 + ], + "summary": "Owns file access to session_map.json: exposes the map file path and key prefix dynamically from config, reads/writes atomically, and provides mutate_locked for flock-protected read-modify-write cycles (call via asyncio.to_thread from async code).", + "tags": [ + "repository", + "file-io", + "locking", + "persistence" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/session_migration.py", + "type": "file", + "name": "session_migration.py", + "filePath": "src/ccbot/session_migration.py", + "summary": "Pure (no I/O, no tmux) startup migration helpers that convert legacy window_name routing keys to canonical @window_id keys and re-resolve or drop stale IDs across state.json and session_map.json stores, keeping the logic unit-testable.", + "tags": [ + "migration", + "state-management", + "data-transformation", + "backward-compatibility", + "tested" + ], + "complexity": "complex", + "languageNotes": "Deliberately pure functions so migrations are unit-testable; SessionManager.resolve_stale_ids orchestrates the live tmux data around them." + }, + { + "id": "function:src/ccbot/session_migration.py:is_window_id", + "type": "function", + "name": "is_window_id", + "filePath": "src/ccbot/session_migration.py", + "lineRange": [ + 35, + 37 + ], + "summary": "Returns True if a key looks like a tmux window ID ('@0', '@12').", + "tags": [ + "utility", + "validation", + "tmux" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/session_migration.py:StartupMigrationResult", + "type": "class", + "name": "StartupMigrationResult", + "filePath": "src/ccbot/session_migration.py", + "lineRange": [ + 41, + 49 + ], + "summary": "Dataclass outcome of apply_startup_state_migration carrying the migrated dicts, a changed flag, and a human-readable list of migration events.", + "tags": [ + "data-model", + "migration", + "result" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/session_migration.py:build_old_names_snapshot", + "type": "function", + "name": "build_old_names_snapshot", + "filePath": "src/ccbot/session_migration.py", + "lineRange": [ + 52, + 70 + ], + "summary": "Snapshots the old_id→display_name view before mutation so thread bindings and offsets can still resolve stale IDs against the pre-migration names.", + "tags": [ + "utility", + "migration", + "data-transformation" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/session_migration.py:apply_startup_state_migration", + "type": "function", + "name": "apply_startup_state_migration", + "filePath": "src/ccbot/session_migration.py", + "lineRange": [ + 73, + 210 + ], + "summary": "Migrates in-memory state (window_states, thread_bindings, user_window_offsets, window_display_names) to live @window_id keys: migrates legacy name keys, re-resolves stale IDs by display name, or drops them — returns a StartupMigrationResult; caller persists if changed.", + "tags": [ + "migration", + "state-management", + "data-transformation" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/session_migration.py:migrate_session_map_old_format", + "type": "function", + "name": "migrate_session_map_old_format", + "filePath": "src/ccbot/session_migration.py", + "lineRange": [ + 213, + 249 + ], + "summary": "Rewrites old-format session_map keys ('session:window_name') to '@window_id' form in place, resolving names against live windows and discarding superseded or orphaned entries.", + "tags": [ + "migration", + "data-transformation", + "backward-compatibility" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/session_migration.py:cleanup_stale_session_map_entries", + "type": "function", + "name": "cleanup_stale_session_map_entries", + "filePath": "src/ccbot/session_migration.py", + "lineRange": [ + 252, + 273 + ], + "summary": "Removes session_map entries whose window @id is no longer among the live tmux windows, returning whether anything was removed.", + "tags": [ + "cleanup", + "state-management", + "garbage-collection" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/session_migration.py:state_needs_legacy_migration", + "type": "function", + "name": "state_needs_legacy_migration", + "filePath": "src/ccbot/session_migration.py", + "lineRange": [ + 276, + 288 + ], + "summary": "Detects whether persisted window_states or thread_bindings still contain pre-@id (window_name) keys requiring migration.", + "tags": [ + "detection", + "migration", + "validation" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/session_monitor.py", + "type": "file", + "name": "session_monitor.py", + "filePath": "src/ccbot/session_monitor.py", + "summary": "Async polling service that watches Claude Code JSONL transcripts: scans session files for active projects, reads new lines via byte-offset tracking (with truncation/corruption recovery), parses entries, and emits NewMessage objects to a callback with cleanup of replaced sessions.", + "tags": [ + "service", + "monitoring", + "async", + "event-handler", + "tested" + ], + "complexity": "complex", + "languageNotes": "Poll-loop pattern with asyncio + aiofiles; mtime cache skips unchanged files and binary-mode reads keep byte offsets stable across platforms." + }, + { + "id": "class:src/ccbot/session_monitor.py:SessionInfo", + "type": "class", + "name": "SessionInfo", + "filePath": "src/ccbot/session_monitor.py", + "lineRange": [ + 33, + 37 + ], + "summary": "Dataclass describing a discoverable Claude session with its session_id and JSONL file path.", + "tags": [ + "data-model", + "monitoring", + "metadata" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/session_monitor.py:NewMessage", + "type": "class", + "name": "NewMessage", + "filePath": "src/ccbot/session_monitor.py", + "lineRange": [ + 41, + 51 + ], + "summary": "Dataclass payload emitted for each new assistant message: text, completeness flag, content type, tool_use metadata, role, and optional embedded image data from tool results.", + "tags": [ + "data-model", + "event", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/session_monitor.py:SessionMonitor", + "type": "class", + "name": "SessionMonitor", + "filePath": "src/ccbot/session_monitor.py", + "lineRange": [ + 54, + 560 + ], + "summary": "Async monitor that polls session_map and transcript files: scans projects with active tmux windows, incrementally reads new JSONL lines per byte offset, carries pending tool_use state across cycles, detects session_map changes, cleans up stale sessions, and invokes a message callback.", + "tags": [ + "service", + "monitoring", + "async", + "polling" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/transcribe.py", + "type": "file", + "name": "transcribe.py", + "filePath": "src/ccbot/transcribe.py", + "summary": "Voice-to-text transcription of Telegram voice messages via OpenAI's audio API using a lazily-initialized httpx client (no OpenAI SDK dependency).", + "tags": [ + "service", + "api-client", + "transcription", + "async", + "tested" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/transcribe.py:_get_client", + "type": "function", + "name": "_get_client", + "filePath": "src/ccbot/transcribe.py", + "lineRange": [ + 20, + 25 + ], + "summary": "Returns a lazily-initialized singleton httpx.AsyncClient, recreating it when closed.", + "tags": [ + "utility", + "singleton", + "api-client" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/transcribe.py:transcribe_voice", + "type": "function", + "name": "transcribe_voice", + "filePath": "src/ccbot/transcribe.py", + "lineRange": [ + 28, + 48 + ], + "summary": "Posts OGG voice bytes to the OpenAI audio transcriptions endpoint (gpt-4o-transcribe) and returns the trimmed text, raising on HTTP errors or an empty transcription.", + "tags": [ + "api-client", + "transcription", + "validation" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/transcribe.py:close_client", + "type": "function", + "name": "close_client", + "filePath": "src/ccbot/transcribe.py", + "lineRange": [ + 51, + 56 + ], + "summary": "Closes and clears the singleton httpx client; called during bot shutdown.", + "tags": [ + "lifecycle", + "cleanup", + "api-client" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/transcript_reader.py", + "type": "file", + "name": "transcript_reader.py", + "filePath": "src/ccbot/transcript_reader.py", + "summary": "JSONL transcript reading (ISS-005 4d): constructs Claude session file paths from encoded cwds, extracts session summaries, lists sessions per directory, and reads byte-ranged message history with TranscriptParser.", + "tags": [ + "file-io", + "serialization", + "data-model", + "service" + ], + "complexity": "moderate" + }, + { + "id": "class:src/ccbot/transcript_reader.py:ClaudeSession", + "type": "class", + "name": "ClaudeSession", + "filePath": "src/ccbot/transcript_reader.py", + "lineRange": [ + 26, + 32 + ], + "summary": "Dataclass describing a Claude Code session: session_id, summary text, message count, and JSONL file path.", + "tags": [ + "data-model", + "serialization", + "metadata" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/transcript_reader.py:TranscriptReader", + "type": "class", + "name": "TranscriptReader", + "filePath": "src/ccbot/transcript_reader.py", + "lineRange": [ + 35, + 195 + ], + "summary": "Reads Claude session transcripts under the projects path: encodes cwds to directory names, resolves session files directly or by glob fallback, scans JSONL for summaries, lists up to 10 recent sessions per directory, and reads byte-ranged message lists.", + "tags": [ + "file-io", + "service", + "serialization", + "monitoring" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/utils.py", + "type": "file", + "name": "utils.py", + "filePath": "src/ccbot/utils.py", + "summary": "Shared utilities used across CCBot modules: config directory resolution, crash-safe atomic JSON writes via temp+rename, first-entry cwd extraction from JSONL, and flock helpers with a Windows no-op fallback.", + "tags": [ + "utility", + "file-io", + "atomic-write", + "locking", + "tested" + ], + "complexity": "moderate", + "languageNotes": "Provides POSIX fcntl flock with a no-op stub on Windows so unit tests can import on any platform." + }, + { + "id": "function:src/ccbot/utils.py:ccbot_dir", + "type": "function", + "name": "ccbot_dir", + "filePath": "src/ccbot/utils.py", + "lineRange": [ + 41, + 44 + ], + "summary": "Resolves the CCBot config directory from the CCBOT_DIR env var, falling back to ~/.ccbot.", + "tags": [ + "utility", + "path-resolution", + "configuration" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/utils.py:atomic_write_json", + "type": "function", + "name": "atomic_write_json", + "filePath": "src/ccbot/utils.py", + "lineRange": [ + 47, + 72 + ], + "summary": "Writes JSON data atomically by writing to a same-directory temp file, fsyncing, then renaming over the target; cleans up the temp file on failure to prevent corruption.", + "tags": [ + "utility", + "file-io", + "atomic-write" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/utils.py:read_cwd_from_jsonl", + "type": "function", + "name": "read_cwd_from_jsonl", + "filePath": "src/ccbot/utils.py", + "lineRange": [ + 75, + 95 + ], + "summary": "Reads the cwd field from the first parseable JSONL entry in a session file; shared by session.py and session_monitor.py.", + "tags": [ + "utility", + "file-io", + "parsing" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/window_state_store.py", + "type": "file", + "name": "window_state_store.py", + "filePath": "src/ccbot/window_state_store.py", + "summary": "In-memory storage of window states (window_id → session_id/cwd/name) and display names with byte-for-byte state.json (de)serialization; pure dataclass store whose persistence is the SessionManager facade's job.", + "tags": [ + "data-model", + "state-management", + "serialization", + "routing" + ], + "complexity": "moderate" + }, + { + "id": "class:src/ccbot/window_state_store.py:WindowState", + "type": "class", + "name": "WindowState", + "filePath": "src/ccbot/window_state_store.py", + "lineRange": [ + 18, + 46 + ], + "summary": "Dataclass record of persistent per-window state (session_id, cwd, window_name) with to_dict/from_dict serialization helpers.", + "tags": [ + "data-model", + "serialization", + "state-management" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/window_state_store.py:WindowStateStore", + "type": "class", + "name": "WindowStateStore", + "filePath": "src/ccbot/window_state_store.py", + "lineRange": [ + 50, + 118 + ], + "summary": "Owns window_states and window_display_names dicts with state.json serialization plus get/set/clear window session and display name operations; mutations do not persist by design.", + "tags": [ + "data-model", + "state-management", + "serialization", + "routing" + ], + "complexity": "moderate" + }, + { + "id": "file:scripts/agentic/analyze_graph.py", + "type": "file", + "name": "analyze_graph.py", + "filePath": "scripts/agentic/analyze_graph.py", + "summary": "Analyzes the project knowledge-graph.json to produce hotspot, untested-file, and complex-file signals with per-path scores used to boost backlog prioritization, then writes a JSON report plus Markdown summary to .agentic/out.", + "tags": [ + "knowledge-graph", + "analysis", + "hotspot-detection", + "debt-analysis", + "pipeline" + ], + "complexity": "complex" + }, + { + "id": "function:scripts/agentic/analyze_graph.py:analyze_graph", + "type": "function", + "name": "analyze_graph", + "filePath": "scripts/agentic/analyze_graph.py", + "lineRange": [ + 45, + 184 + ], + "summary": "Core analysis pass: computes fan-in/fan-out and edge-type counts over graph nodes and edges, scores code-file hotspots from complexity, tested, and degree signals, and returns stats, layers, hotspots, orphans, and path scores.", + "tags": [ + "knowledge-graph", + "hotspot-detection", + "scoring", + "analysis" + ], + "complexity": "complex" + }, + { + "id": "function:scripts/agentic/analyze_graph.py:_recommendations", + "type": "function", + "name": "_recommendations", + "filePath": "scripts/agentic/analyze_graph.py", + "lineRange": [ + 187, + 214 + ], + "summary": "Generates human-readable refactoring recommendations from hotspot, untested-file, and complex-file signals with project-specific guidance for bot.py hotspots.", + "tags": [ + "recommendations", + "refactoring", + "analysis" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/analyze_graph.py:render_markdown", + "type": "function", + "name": "render_markdown", + "filePath": "scripts/agentic/analyze_graph.py", + "lineRange": [ + 217, + 249 + ], + "summary": "Renders the analysis report as a Markdown document with layers, a top-hotspots table, recommendations, and untested-file samples.", + "tags": [ + "markdown", + "reporting", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/analyze_graph.py:run", + "type": "function", + "name": "run", + "filePath": "scripts/agentic/analyze_graph.py", + "lineRange": [ + 252, + 270 + ], + "summary": "Loads knowledge-graph.json and config via paths helpers, runs analyze_graph, attaches meta, and writes the analysis report JSON and Markdown to the configured output dir.", + "tags": [ + "orchestration", + "pipeline", + "file-io" + ], + "complexity": "moderate" + }, + { + "id": "file:scripts/agentic/build_context_pack.py", + "type": "file", + "name": "build_context_pack.py", + "filePath": "scripts/agentic/build_context_pack.py", + "summary": "Builds a task-scoped subgraph slice (context pack) from knowledge-graph.json by hop-expanding from task seed nodes through the graph adjacency, bounded by node/edge caps, and writes context-pack.json.", + "tags": [ + "knowledge-graph", + "context-pack", + "subgraph", + "pipeline", + "utility" + ], + "complexity": "moderate" + }, + { + "id": "function:scripts/agentic/build_context_pack.py:_seed_ids", + "type": "function", + "name": "_seed_ids", + "filePath": "scripts/agentic/build_context_pack.py", + "lineRange": [ + 25, + 50 + ], + "summary": "Determines seed node ids from a task's related_nodes and files lists, matching exact file nodes or directory-prefix node types like file/config/module.", + "tags": [ + "seed-selection", + "knowledge-graph", + "utility" + ], + "complexity": "moderate" + }, + { + "id": "function:scripts/agentic/build_context_pack.py:build_context_pack", + "type": "function", + "name": "build_context_pack", + "filePath": "scripts/agentic/build_context_pack.py", + "lineRange": [ + 53, + 165 + ], + "summary": "Performs breadth-first hop expansion from seed nodes over the undirected adjacency of the graph (capped by max_nodes/max_edges), then slices nodes, edges, and layers into the task context pack with seed ids and task metadata.", + "tags": [ + "graph-traversal", + "context-pack", + "clustering", + "knowledge-graph" + ], + "complexity": "complex" + }, + { + "id": "function:scripts/agentic/build_context_pack.py:run", + "type": "function", + "name": "run", + "filePath": "scripts/agentic/build_context_pack.py", + "lineRange": [ + 168, + 191 + ], + "summary": "Loads graph and backlog, resolves the target task id (selected task by default), builds the context pack, and writes it to context-pack.json in the output dir.", + "tags": [ + "orchestration", + "pipeline", + "file-io" + ], + "complexity": "moderate" + }, + { + "id": "file:scripts/agentic/cli.py", + "type": "file", + "name": "cli.py", + "filePath": "scripts/agentic/cli.py", + "summary": "CLI entry point for the agentic pipeline wiring analyze, plan, select, context, status, list, validate, sync-issues, and run commands, plus the propose and human-in-the-loop proposal subcommands registered by propose.py.", + "tags": [ + "entry-point", + "cli", + "pipeline", + "argparse", + "command-dispatch" + ], + "complexity": "complex" + }, + { + "id": "function:scripts/agentic/cli.py:cmd_select", + "type": "function", + "name": "cmd_select", + "filePath": "scripts/agentic/cli.py", + "lineRange": [ + 95, + 104 + ], + "summary": "Runs the plan step via prioritize.run then renders the implement prompt, printing the selected task id and artifact paths or failing when no task is selectable.", + "tags": [ + "cli-handler", + "pipeline", + "task-selection" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/cli.py:cmd_validate", + "type": "function", + "name": "cmd_validate", + "filePath": "scripts/agentic/cli.py", + "lineRange": [ + 107, + 141 + ], + "summary": "Runs guardrail, quality, and task-evidence validation via validate_changes.run (loading task evidence when a task id is given), printing the JSON verdict and per-check output.", + "tags": [ + "cli-handler", + "validation", + "guardrails" + ], + "complexity": "moderate" + }, + { + "id": "function:scripts/agentic/cli.py:cmd_run", + "type": "function", + "name": "cmd_run", + "filePath": "scripts/agentic/cli.py", + "lineRange": [ + 150, + 176 + ], + "summary": "Runs the full local pipeline analyze, plan, render prompts, build context pack, then workspace validation, with no LLM implement step.", + "tags": [ + "cli-handler", + "pipeline", + "orchestration" + ], + "complexity": "moderate" + }, + { + "id": "function:scripts/agentic/cli.py:build_parser", + "type": "function", + "name": "build_parser", + "filePath": "scripts/agentic/cli.py", + "lineRange": [ + 198, + 246 + ], + "summary": "Builds the argparse subcommand parser with metavar help listing every command, and registers propose and human-in-the-loop subcommands from propose.py.", + "tags": [ + "argparse", + "cli", + "command-registration" + ], + "complexity": "moderate" + }, + { + "id": "function:scripts/agentic/cli.py:main", + "type": "function", + "name": "main", + "filePath": "scripts/agentic/cli.py", + "lineRange": [ + 249, + 267 + ], + "summary": "Entry point that resolves the repo root, builds the parser, dispatches to command handlers including the propose and HITL handler maps, and returns the exit code.", + "tags": [ + "entry-point", + "cli", + "dispatch" + ], + "complexity": "simple" + }, + { + "id": "file:scripts/agentic/mcp_server.py", + "type": "file", + "name": "mcp_server.py", + "filePath": "scripts/agentic/mcp_server.py", + "summary": "Stdio JSON-RPC 2.0 MCP server (ccbot-agentic) exposing the agentic pipeline as MCP tools for task listing, selection, analysis, context packs, prompts, status updates, validation, and pipeline health checks.", + "tags": [ + "mcp-server", + "json-rpc", + "api-handler", + "integration", + "stdio" + ], + "complexity": "complex" + }, + { + "id": "function:scripts/agentic/mcp_server.py:tool_get_context_pack", + "type": "function", + "name": "tool_get_context_pack", + "filePath": "scripts/agentic/mcp_server.py", + "lineRange": [ + 114, + 123 + ], + "summary": "MCP tool handler returning a task-scoped context pack via service.get_context_pack with configurable hops and refresh flags.", + "tags": [ + "mcp-tool", + "context-pack", + "knowledge-graph" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/mcp_server.py:tool_mark_task_status", + "type": "function", + "name": "tool_mark_task_status", + "filePath": "scripts/agentic/mcp_server.py", + "lineRange": [ + 133, + 145 + ], + "summary": "MCP tool handler validating task_id/status arguments and delegating status updates to service.mark_task_status.", + "tags": [ + "mcp-tool", + "backlog", + "task-status" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/mcp_server.py:tool_run_analyze", + "type": "function", + "name": "tool_run_analyze", + "filePath": "scripts/agentic/mcp_server.py", + "lineRange": [ + 162, + 176 + ], + "summary": "MCP tool handler that lazily imports analyze_graph.run to recompute hotspot analysis and returns a slim summary of stats, hotspots, and recommendations.", + "tags": [ + "mcp-tool", + "analysis", + "knowledge-graph", + "lazy-import" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/mcp_server.py:_handle_tools_call", + "type": "function", + "name": "_handle_tools_call", + "filePath": "scripts/agentic/mcp_server.py", + "lineRange": [ + 362, + 372 + ], + "summary": "Resolves the requested tool name in the TOOLS registry and invokes its handler, converting any exception into an MCP error payload with a traceback tail.", + "tags": [ + "mcp", + "dispatch", + "error-handling" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/mcp_server.py:_dispatch", + "type": "function", + "name": "_dispatch", + "filePath": "scripts/agentic/mcp_server.py", + "lineRange": [ + 375, + 423 + ], + "summary": "Routes JSON-RPC 2.0 methods (initialize, ping, tools/list, tools/call, resources/prompts list) to protocol-correct responses, ignoring notifications and answering unknown methods with an error.", + "tags": [ + "json-rpc", + "mcp", + "protocol", + "dispatch" + ], + "complexity": "moderate" + }, + { + "id": "function:scripts/agentic/mcp_server.py:_read_message", + "type": "function", + "name": "_read_message", + "filePath": "scripts/agentic/mcp_server.py", + "lineRange": [ + 426, + 455 + ], + "summary": "Reads one framed JSON-RPC message from stdin using Content-Length framing with a newline-delimited JSON fallback.", + "tags": [ + "mcp", + "stdio", + "protocol", + "framing" + ], + "complexity": "moderate" + }, + { + "id": "function:scripts/agentic/mcp_server.py:main", + "type": "function", + "name": "main", + "filePath": "scripts/agentic/mcp_server.py", + "lineRange": [ + 465, + 495 + ], + "summary": "MCP server loop: resolves the repo root, reads messages until EOF, dispatches them, and writes Content-Length framed responses to stdout while logging diagnostics to stderr.", + "tags": [ + "entry-point", + "mcp", + "event-loop" + ], + "complexity": "moderate" + }, + { + "id": "file:scripts/agentic/paths.py", + "type": "file", + "name": "paths.py", + "summary": "Shared path and config resolution helpers: walks parents to locate the repo root (pyproject.toml plus .agentic), loads and writes JSON/text under .agentic, and resolves the configured output dir. This is the common I/O API used by every other agentic script.", + "tags": [ + "utility", + "path-resolution", + "io-helpers", + "shared", + "config" + ], + "complexity": "simple", + "filePath": "scripts/agentic/paths.py", + "languageNotes": "BOM-tolerant JSON reading (utf-8-sig) and ensure_ascii=False writing for non-ASCII content." + }, + { + "id": "function:scripts/agentic/paths.py:find_repo_root", + "type": "function", + "name": "find_repo_root", + "summary": "Walks the starting directory and its parents until it finds a directory containing both pyproject.toml and .agentic/, falling back to scripts/agentic/../.. when none is found.", + "tags": [ + "path-resolution", + "repo-root", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/paths.py", + "lineRange": [ + 10, + 20 + ] + }, + { + "id": "function:scripts/agentic/paths.py:load_json", + "type": "function", + "name": "load_json", + "summary": "Reads and parses a JSON file, tolerating a UTF-8 BOM.", + "tags": [ + "file-io", + "json", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/paths.py", + "lineRange": [ + 23, + 25 + ] + }, + { + "id": "function:scripts/agentic/paths.py:write_json", + "type": "function", + "name": "write_json", + "summary": "Creates parent directories and writes pretty-printed JSON (indent 2, ensure_ascii False) with a trailing newline.", + "tags": [ + "file-io", + "json", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/paths.py", + "lineRange": [ + 28, + 32 + ] + }, + { + "id": "function:scripts/agentic/paths.py:write_text", + "type": "function", + "name": "write_text", + "summary": "Creates parent directories and writes a UTF-8 text file.", + "tags": [ + "file-io", + "text", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/paths.py", + "lineRange": [ + 35, + 37 + ] + }, + { + "id": "function:scripts/agentic/paths.py:load_config", + "type": "function", + "name": "load_config", + "summary": "Loads the .agentic/config.json pipeline configuration for a repo root, defaulting to the auto-detected root.", + "tags": [ + "config", + "file-io", + "pipeline" + ], + "complexity": "simple", + "filePath": "scripts/agentic/paths.py", + "lineRange": [ + 40, + 42 + ] + }, + { + "id": "function:scripts/agentic/paths.py:out_dir", + "type": "function", + "name": "out_dir", + "summary": "Returns the pipeline output directory from the config outputs.dir setting joined onto the repo root.", + "tags": [ + "path-resolution", + "config", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/paths.py", + "lineRange": [ + 45, + 46 + ] + }, + { + "id": "file:scripts/agentic/prioritize.py", + "type": "file", + "name": "prioritize.py", + "summary": "Ranks backlog tasks by combining configured priority weights, dependency satisfaction, and knowledge-graph path-score bonuses, then selects the next task and writes plan.json plus a Markdown plan to .agentic/out.", + "tags": [ + "prioritization", + "backlog", + "ranking", + "planning", + "pipeline" + ], + "complexity": "complex", + "filePath": "scripts/agentic/prioritize.py" + }, + { + "id": "function:scripts/agentic/prioritize.py:score_task", + "type": "function", + "name": "score_task", + "summary": "Computes a task score from priority weights plus knowledge-graph path-score bonuses (exact or prefix match) and related-node boosts, marking dependency satisfaction and selectability.", + "tags": [ + "scoring", + "prioritization", + "backlog", + "dependency-check" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/prioritize.py", + "lineRange": [ + 43, + 124 + ] + }, + { + "id": "function:scripts/agentic/prioritize.py:build_plan", + "type": "function", + "name": "build_plan", + "summary": "Ranks all backlog tasks, selects the next selectable task (or a forced task id), and returns a plan with ranked_tasks, the selected task, and selection reasons.", + "tags": [ + "planning", + "ranking", + "task-selection", + "pipeline" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/prioritize.py", + "lineRange": [ + 127, + 159 + ] + }, + { + "id": "function:scripts/agentic/prioritize.py:render_plan_md", + "type": "function", + "name": "render_plan_md", + "summary": "Renders the plan as a Markdown document showing the selected task, ranked tasks with scores and selectability, and dependency blockers.", + "tags": [ + "markdown", + "reporting", + "planning" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/prioritize.py", + "lineRange": [ + 162, + 206 + ] + }, + { + "id": "function:scripts/agentic/prioritize.py:run", + "type": "function", + "name": "run", + "summary": "Loads config, analysis report, and backlog, runs build_plan, writes plan.json and plan.md to the output dir, and returns the plan.", + "tags": [ + "orchestration", + "pipeline", + "file-io" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/prioritize.py", + "lineRange": [ + 209, + 241 + ] + }, + { + "id": "file:scripts/agentic/render_prompt.py", + "type": "file", + "name": "render_prompt.py", + "summary": "Renders the implement prompt and PR body from templates using the selected task, writes them to .agentic/out, and bundles policies, the task, and the prompt into an agent-context file.", + "tags": [ + "prompt-rendering", + "templating", + "codegen", + "pipeline" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/render_prompt.py" + }, + { + "id": "function:scripts/agentic/render_prompt.py:render_pr_body", + "type": "function", + "name": "render_pr_body", + "summary": "Fills a PR body template with the task's id, problem/solution, acceptance checklist, risk, and graph commit.", + "tags": [ + "templating", + "pr-body", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/render_prompt.py", + "lineRange": [ + 23, + 41 + ] + }, + { + "id": "function:scripts/agentic/render_prompt.py:run", + "type": "function", + "name": "run", + "summary": "Orchestrates prompt rendering: loads the selected task and plan, reads implement/PR templates and policies, and writes implement-prompt.md, pr-body.md, and agent-context.md.", + "tags": [ + "orchestration", + "pipeline", + "templating" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/render_prompt.py", + "lineRange": [ + 44, + 93 + ] + }, + { + "id": "file:scripts/agentic/propose.py", + "type": "file", + "name": "propose.py", + "summary": "Proposal subsystem implementing propose, import-proposal, show-proposal, reject, approve, request-changes, and apply: captures and validates diffs, enforces guardrails and base-SHA freshness, and manages proposal artifacts under .agentic/out/proposals behind a human-in-the-loop approval gate.", + "tags": [ + "proposal", + "human-in-the-loop", + "diff-validation", + "guardrails", + "git", + "tested" + ], + "complexity": "complex", + "filePath": "scripts/agentic/propose.py" + }, + { + "id": "function:scripts/agentic/propose.py:run", + "type": "function", + "name": "run", + "summary": "Wraps subprocess.run with command echoing and optional check failure, used for every git and tool invocation in the subsystem.", + "tags": [ + "subprocess", + "utility", + "git" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 101, + 118 + ] + }, + { + "id": "function:scripts/agentic/propose.py:iter_tasks", + "type": "function", + "name": "iter_tasks", + "summary": "Iterates over backlog tasks yielding (task_id, task) pairs while reconciling proposal status metadata against the tasks.json file.", + "tags": [ + "backlog", + "iteration", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 184, + 204 + ] + }, + { + "id": "function:scripts/agentic/propose.py:load_task", + "type": "function", + "name": "load_task", + "summary": "Loads a single task by id from .agentic/backlog/tasks.json, raising a targeted error when the task or file is missing.", + "tags": [ + "backlog", + "file-io", + "task-loading" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 207, + 217 + ] + }, + { + "id": "function:scripts/agentic/propose.py:update_task_status", + "type": "function", + "name": "update_task_status", + "summary": "Updates a task's status and status metadata (timestamp, optional proposal id) in the backlog JSON file.", + "tags": [ + "backlog", + "task-status", + "file-io" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 220, + 238 + ] + }, + { + "id": "function:scripts/agentic/propose.py:ensure_clean_tree", + "type": "function", + "name": "ensure_clean_tree", + "summary": "Checks that the git working tree is clean (or explicitly allowed dirty) before the proposal flow so diffs are captured from a known state.", + "tags": [ + "git", + "working-tree", + "precondition-check" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 246, + 270 + ] + }, + { + "id": "function:scripts/agentic/propose.py:render_default_prompt", + "type": "function", + "name": "render_default_prompt", + "summary": "Builds the default implementation prompt template for a task, filling in problem, solution, files, acceptance criteria, dependency, and risk placeholders.", + "tags": [ + "prompt-templating", + "task-rendering", + "pipeline" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 287, + 344 + ] + }, + { + "id": "function:scripts/agentic/propose.py:latest_proposal_dir", + "type": "function", + "name": "latest_proposal_dir", + "summary": "Finds the newest proposal directory for a task by scanning timestamped proposal directories and their meta.json files, raising when none exists.", + "tags": [ + "proposal", + "directory-scan", + "utility" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 357, + 393 + ] + }, + { + "id": "function:scripts/agentic/propose.py:resolve_proposal_dir", + "type": "function", + "name": "resolve_proposal_dir", + "summary": "Resolves a proposal's target directory from an explicit id or 'latest' keyword, with task-scoped search and targeted errors for missing or mismatched proposals.", + "tags": [ + "proposal", + "resolution", + "error-handling" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 396, + 430 + ] + }, + { + "id": "function:scripts/agentic/propose.py:capture_working_tree_diff", + "type": "function", + "name": "capture_working_tree_diff", + "summary": "Captures the working-tree diff against a base ref via piped git diff output.", + "tags": [ + "git", + "diff", + "capture" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 442, + 457 + ] + }, + { + "id": "function:scripts/agentic/propose.py:list_changed_paths_from_diff", + "type": "function", + "name": "list_changed_paths_from_diff", + "summary": "Parses a unified diff to list changed file paths with their change statuses (added/modified/deleted).", + "tags": [ + "diff-parsing", + "git", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 460, + 487 + ] + }, + { + "id": "function:scripts/agentic/propose.py:_glob_to_regex", + "type": "function", + "name": "_glob_to_regex", + "summary": "Converts a guardrail glob pattern into an anchored regular expression for path matching.", + "tags": [ + "glob", + "regex", + "path-matching" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 490, + 512 + ] + }, + { + "id": "function:scripts/agentic/propose.py:_match_glob", + "type": "function", + "name": "_match_glob", + "summary": "Matches a path against a single glob pattern supporting ** recursion, trailing-slash directory matches, extension wildcards, and negation rules.", + "tags": [ + "glob", + "path-matching", + "guardrails" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 515, + 566 + ] + }, + { + "id": "function:scripts/agentic/propose.py:path_allowed", + "type": "function", + "name": "path_allowed", + "summary": "Determines whether a changed path passes the config guardrails, where block patterns win over allow patterns.", + "tags": [ + "guardrails", + "path-matching", + "security" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 569, + 596 + ] + }, + { + "id": "function:scripts/agentic/propose.py:_write_temp_diff", + "type": "function", + "name": "_write_temp_diff", + "summary": "Writes diff bytes to a temporary file so git apply checks can run against it.", + "tags": [ + "temp-file", + "diff", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 639, + 651 + ] + }, + { + "id": "function:scripts/agentic/propose.py:check_diff_applies", + "type": "function", + "name": "check_diff_applies", + "summary": "Validates that a diff applies cleanly via git apply --check, and rejects when it does not.", + "tags": [ + "diff-validation", + "git", + "apply-check" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 654, + 710 + ] + }, + { + "id": "function:scripts/agentic/propose.py:diff_numstat", + "type": "function", + "name": "diff_numstat", + "summary": "Runs git diff --numstat on a recorded diff and returns per-file add/delete statistics with metadata, matching it against changed paths.", + "tags": [ + "git", + "numstat", + "diff-stats" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 713, + 760 + ] + }, + { + "id": "function:scripts/agentic/propose.py:validate_proposal_diff", + "type": "function", + "name": "validate_proposal_diff", + "summary": "Top-level diff validation: rejects empty diffs, markdown fences, diff comments, and non-unified-diff output, and confirms the diff applies cleanly.", + "tags": [ + "diff-validation", + "guardrails", + "validation" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 763, + 785 + ] + }, + { + "id": "function:scripts/agentic/propose.py:build_files_json", + "type": "function", + "name": "build_files_json", + "summary": "Builds the proposal files.json from numstat output, enforcing config max files/LOC deltas and the guardrail allow/block lists.", + "tags": [ + "proposal", + "guardrails", + "file-manifest" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 788, + 816 + ] + }, + { + "id": "function:scripts/agentic/propose.py:build_risk_json", + "type": "function", + "name": "build_risk_json", + "summary": "Builds a risk-assessment entry for the task from its category, estimated risk, and touched paths.", + "tags": [ + "risk-assessment", + "proposal", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 819, + 838 + ] + }, + { + "id": "function:scripts/agentic/propose.py:build_evidence_plan", + "type": "function", + "name": "build_evidence_plan", + "summary": "Builds an evidence plan mapping files, source patterns, directories, and JSON blobs the task must verify after application.", + "tags": [ + "evidence-plan", + "proposal", + "verification" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 841, + 893 + ] + }, + { + "id": "function:scripts/agentic/propose.py:build_proposal_md", + "type": "function", + "name": "build_proposal_md", + "summary": "Renders the human-readable proposal.md from the task, proposal id, base sha, and changed paths.", + "tags": [ + "proposal", + "markdown", + "reporting" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 896, + 954 + ] + }, + { + "id": "function:scripts/agentic/propose.py:create_proposal_artifacts", + "type": "function", + "name": "create_proposal_artifacts", + "summary": "Creates the full proposal artifact set (diff, files.json, risk, evidence plan, proposal.md, meta) in timestamped directories under .agentic/out/proposals.", + "tags": [ + "proposal", + "artifact-creation", + "file-io" + ], + "complexity": "complex", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 957, + 1088 + ] + }, + { + "id": "function:scripts/agentic/propose.py:prepare_pipeline_context", + "type": "function", + "name": "prepare_pipeline_context", + "summary": "Prepares the agentic pipeline context (plan, implement prompt, context pack) needed for task proposals.", + "tags": [ + "pipeline", + "context-pack", + "proposal" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1091, + 1112 + ] + }, + { + "id": "function:scripts/agentic/propose.py:_assert_base_sha_fresh", + "type": "function", + "name": "_assert_base_sha_fresh", + "summary": "Verifies the recorded base SHA still matches the current base ref, blocking stale proposals with a clear error.", + "tags": [ + "base-sha", + "freshness-check", + "git" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1148, + 1166 + ] + }, + { + "id": "function:scripts/agentic/propose.py:_assert_feature_branch", + "type": "function", + "name": "_assert_feature_branch", + "summary": "Blocks approve/apply on protected branches (main/master) unless explicitly allowed.", + "tags": [ + "branch-protection", + "guardrails", + "git" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1173, + 1184 + ] + }, + { + "id": "function:scripts/agentic/propose.py:reject", + "type": "function", + "name": "reject", + "summary": "Writes a reject artifact with the reason and reviewer, leaving the working tree untouched.", + "tags": [ + "proposal", + "rejection", + "git" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1187, + 1227 + ] + }, + { + "id": "function:scripts/agentic/propose.py:approve", + "type": "function", + "name": "approve", + "summary": "Records approval.json after base-SHA freshness and feature-branch checks, capturing the approver identity and message.", + "tags": [ + "proposal", + "approval", + "human-gate" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1230, + 1285 + ] + }, + { + "id": "function:scripts/agentic/propose.py:request_changes", + "type": "function", + "name": "request_changes", + "summary": "Archives the proposal and saves reviewer notes as a request-changes artifact.", + "tags": [ + "proposal", + "review-notes", + "human-gate" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1288, + 1365 + ] + }, + { + "id": "function:scripts/agentic/propose.py:apply", + "type": "function", + "name": "apply", + "summary": "Applies an approved proposal diff to the working tree only when the base SHA matches, approval exists, and the branch is allowed.", + "tags": [ + "proposal", + "apply", + "git", + "human-gate" + ], + "complexity": "complex", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1368, + 1478 + ] + }, + { + "id": "function:scripts/agentic/propose.py:cmd_propose", + "type": "function", + "name": "cmd_propose", + "summary": "Propose command handler: captures the working-tree diff for the task, validates it, and creates the proposal artifacts.", + "tags": [ + "cli-handler", + "proposal", + "diff-capture" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1486, + 1554 + ] + }, + { + "id": "function:scripts/agentic/propose.py:cmd_show_proposal", + "type": "function", + "name": "cmd_show_proposal", + "summary": "Shows proposal artifacts (markdown, files, risk, meta) for the latest or a given proposal.", + "tags": [ + "cli-handler", + "proposal", + "reporting" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1557, + 1588 + ] + }, + { + "id": "function:scripts/agentic/propose.py:cmd_import_proposal", + "type": "function", + "name": "cmd_import_proposal", + "summary": "Imports an externally created proposal.diff: validates it, creates artifacts, and finalizes the proposal record.", + "tags": [ + "cli-handler", + "import", + "diff-validation" + ], + "complexity": "complex", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1591, + 1734 + ] + }, + { + "id": "function:scripts/agentic/propose.py:cmd_reject", + "type": "function", + "name": "cmd_reject", + "summary": "CLI handler for the reject command with --proposal and --reason options.", + "tags": [ + "cli-handler", + "proposal", + "rejection" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1737, + 1751 + ] + }, + { + "id": "function:scripts/agentic/propose.py:cmd_approve", + "type": "function", + "name": "cmd_approve", + "summary": "CLI handler for the approve command with --proposal, --message, and approver identity.", + "tags": [ + "cli-handler", + "proposal", + "approval" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1754, + 1772 + ] + }, + { + "id": "function:scripts/agentic/propose.py:cmd_request_changes", + "type": "function", + "name": "cmd_request_changes", + "summary": "CLI handler for the request-changes command saving reviewer notes.", + "tags": [ + "cli-handler", + "proposal", + "review-notes" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1775, + 1788 + ] + }, + { + "id": "function:scripts/agentic/propose.py:cmd_apply", + "type": "function", + "name": "cmd_apply", + "summary": "CLI handler for the apply command with --base-ref and --allow-on-main flags.", + "tags": [ + "cli-handler", + "proposal", + "apply" + ], + "complexity": "simple", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1791, + 1808 + ] + }, + { + "id": "function:scripts/agentic/propose.py:register_propose_commands", + "type": "function", + "name": "register_propose_commands", + "summary": "Registers the propose pipeline subcommands (propose, show-proposal, import-proposal) on the argparse subparser and returns their handler map.", + "tags": [ + "argparse", + "command-registration", + "proposal" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1811, + 1867 + ] + }, + { + "id": "function:scripts/agentic/propose.py:register_hitl_commands", + "type": "function", + "name": "register_hitl_commands", + "summary": "Registers the human-in-the-loop subcommands (reject, approve, request-changes, apply) with approval-gate semantics and returns their handler map.", + "tags": [ + "argparse", + "command-registration", + "human-in-the-loop" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/propose.py", + "lineRange": [ + 1870, + 1939 + ] + }, + { + "id": "file:scripts/agentic/service.py", + "type": "file", + "name": "service.py", + "summary": "Shared service layer (single source of truth) used by both the CLI and MCP server: task listing/selection, analysis, context packs, prompts, status updates, validation, and pipeline health checks.", + "tags": [ + "service-layer", + "orchestration", + "pipeline", + "shared", + "facade" + ], + "complexity": "complex", + "filePath": "scripts/agentic/service.py" + }, + { + "id": "function:scripts/agentic/service.py:list_tasks", + "type": "function", + "name": "list_tasks", + "summary": "Lists backlog tasks with an optional status filter, attaches plan ranking scores (soft-falling back to an on-demand plan), and sorts by score descending.", + "tags": [ + "backlog", + "listing", + "ranking", + "service" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 24, + 87 + ] + }, + { + "id": "function:scripts/agentic/service.py:get_task", + "type": "function", + "name": "get_task", + "summary": "Returns a single backlog task by id, raising KeyError when unknown.", + "tags": [ + "backlog", + "lookup", + "service" + ], + "complexity": "simple", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 90, + 97 + ] + }, + { + "id": "function:scripts/agentic/service.py:select_next_task", + "type": "function", + "name": "select_next_task", + "summary": "Analyzes the knowledge graph (unless skipped), plans to select the next task, renders prompts and a context pack, and returns the artifacts.", + "tags": [ + "task-selection", + "pipeline", + "orchestration" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 100, + 151 + ] + }, + { + "id": "function:scripts/agentic/service.py:get_analysis", + "type": "function", + "name": "get_analysis", + "summary": "Returns a slimmed analysis report, recomputing via analyze_graph.run when refresh is requested or the report file is missing.", + "tags": [ + "analysis", + "knowledge-graph", + "service" + ], + "complexity": "simple", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 154, + 178 + ] + }, + { + "id": "function:scripts/agentic/service.py:get_context_pack", + "type": "function", + "name": "get_context_pack", + "summary": "Returns the context pack for a task, rebuilding when refresh is set or the cached pack belongs to another task.", + "tags": [ + "context-pack", + "knowledge-graph", + "service" + ], + "complexity": "simple", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 181, + 197 + ] + }, + { + "id": "function:scripts/agentic/service.py:get_implement_prompt", + "type": "function", + "name": "get_implement_prompt", + "summary": "Returns the rendered implement prompt and PR body from .agentic/out together with the selected task id.", + "tags": [ + "prompt", + "service", + "pipeline" + ], + "complexity": "simple", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 200, + 220 + ] + }, + { + "id": "function:scripts/agentic/service.py:mark_task_status", + "type": "function", + "name": "mark_task_status", + "summary": "Updates a task's status, status timestamp, and optional note in the backlog, validating status values and persisting via paths.write_json.", + "tags": [ + "backlog", + "task-status", + "service" + ], + "complexity": "simple", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 223, + 250 + ] + }, + { + "id": "function:scripts/agentic/service.py:validate", + "type": "function", + "name": "validate", + "summary": "Runs change validation via validate_changes.run and returns a compact verdict with guardrail, quality, and evidence information.", + "tags": [ + "validation", + "guardrails", + "quality-gates" + ], + "complexity": "simple", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 253, + 277 + ] + }, + { + "id": "function:scripts/agentic/service.py:pipeline_status", + "type": "function", + "name": "pipeline_status", + "summary": "Health check of the pipeline: reports repo root, knowledge graph/backlog presence, generated artifacts, proposals, and the selected task.", + "tags": [ + "health-check", + "pipeline", + "monitoring" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/service.py", + "lineRange": [ + 280, + 315 + ] + }, + { + "id": "file:scripts/agentic/sync_issues.py", + "type": "file", + "name": "sync_issues.py", + "summary": "Creates or updates GitHub issues from backlog tasks via the gh CLI, mapping tasks to open issues through an agentic-task-id marker comment and supporting dry-run.", + "tags": [ + "github", + "issues", + "integration", + "backlog-sync", + "cli" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/sync_issues.py" + }, + { + "id": "function:scripts/agentic/sync_issues.py:_gh", + "type": "function", + "name": "_gh", + "summary": "Runs the gh CLI with args in the repo cwd, capturing output as UTF-8 text without raising on non-zero exit.", + "tags": [ + "subprocess", + "gh-cli", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/sync_issues.py", + "lineRange": [ + 14, + 23 + ] + }, + { + "id": "function:scripts/agentic/sync_issues.py:_issue_body", + "type": "function", + "name": "_issue_body", + "summary": "Renders a GitHub issue body from a backlog task including acceptance criteria, file list, metadata JSON, and the agentic-task-id marker.", + "tags": [ + "issue-rendering", + "github", + "templating" + ], + "complexity": "simple", + "filePath": "scripts/agentic/sync_issues.py", + "lineRange": [ + 26, + 59 + ] + }, + { + "id": "function:scripts/agentic/sync_issues.py:list_existing_task_issues", + "type": "function", + "name": "list_existing_task_issues", + "summary": "Queries open agentic-labeled issues via gh and maps task ids to issue numbers by parsing the marker line in each body.", + "tags": [ + "github", + "issue-lookup", + "parsing" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/sync_issues.py", + "lineRange": [ + 62, + 92 + ] + }, + { + "id": "function:scripts/agentic/sync_issues.py:sync", + "type": "function", + "name": "sync", + "summary": "Creates or updates GitHub issues for backlog tasks in the given statuses, dry-run by default, returning the planned actions or gh command output.", + "tags": [ + "github", + "issue-sync", + "backlog", + "integration" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/sync_issues.py", + "lineRange": [ + 95, + 148 + ] + }, + { + "id": "function:scripts/agentic/sync_issues.py:main", + "type": "function", + "name": "main", + "summary": "Argparse entry point for the sync-issues command with --apply and --statuses flags, printing the sync result as JSON.", + "tags": [ + "entry-point", + "cli", + "github" + ], + "complexity": "simple", + "filePath": "scripts/agentic/sync_issues.py", + "lineRange": [ + 151, + 172 + ] + }, + { + "id": "file:scripts/agentic/update_backlog_status.py", + "type": "file", + "name": "update_backlog_status.py", + "summary": "Local helper CLI to update task status in .agentic/backlog/tasks.json, enforcing done-blocking evidence checks for structural tasks and tasks with evidence blocks.", + "tags": [ + "backlog", + "task-status", + "cli", + "evidence-check", + "tested" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/update_backlog_status.py" + }, + { + "id": "function:scripts/agentic/update_backlog_status.py:done_block_reasons", + "type": "function", + "name": "done_block_reasons", + "summary": "Computes reasons why a task cannot be marked done: structural tasks need an evidence block and must pass check_evidence against the working tree.", + "tags": [ + "evidence-check", + "backlog", + "guardrails" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/update_backlog_status.py", + "lineRange": [ + 20, + 38 + ] + }, + { + "id": "function:scripts/agentic/update_backlog_status.py:main", + "type": "function", + "name": "main", + "summary": "Argparse CLI updating a task's status, blocking done without valid evidence, and persisting timestamps and optional notes.", + "tags": [ + "entry-point", + "cli", + "backlog" + ], + "complexity": "simple", + "filePath": "scripts/agentic/update_backlog_status.py", + "lineRange": [ + 41, + 74 + ] + }, + { + "id": "file:scripts/agentic/validate_changes.py", + "type": "file", + "name": "validate_changes.py", + "summary": "Validates agent changes: checks path guardrails (allow/ignore/block globs) against git-changed files, runs quality gates (ruff/pyright/pytest) in the venv, and verifies task evidence patterns against the working tree.", + "tags": [ + "validation", + "guardrails", + "quality-gates", + "evidence", + "git-diff", + "tested" + ], + "complexity": "complex", + "filePath": "scripts/agentic/validate_changes.py" + }, + { + "id": "function:scripts/agentic/validate_changes.py:_run", + "type": "function", + "name": "_run", + "summary": "Runs a subprocess with text capture, returning cmd, returncode, ok, and stdout/stderr tails, and handles missing executables.", + "tags": [ + "subprocess", + "utility", + "execution" + ], + "complexity": "simple", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 23, + 48 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:_ensure_dev_env", + "type": "function", + "name": "_ensure_dev_env", + "summary": "Syncs dev extras via uv when the venv or python is missing so pyright can resolve imports.", + "tags": [ + "environment-setup", + "venv", + "uv" + ], + "complexity": "simple", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 51, + 65 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:_git_changed_files", + "type": "function", + "name": "_git_changed_files", + "summary": "Lists files changed against a base ref using git diff (or merge-base diff), with porcelain and status parsing into normalized paths.", + "tags": [ + "git", + "diff", + "file-listing" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 68, + 99 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:_match_any", + "type": "function", + "name": "_match_any", + "summary": "Matches a path against a list of glob patterns supporting **/ prefixes, directory suffixes, and fnmatch semantics.", + "tags": [ + "glob", + "path-matching", + "guardrails" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 102, + 125 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:check_guardrails", + "type": "function", + "name": "check_guardrails", + "summary": "Evaluates allow, ignore, and block guardrail lists against changed paths, flagging violations and blocking config breaches or size-limit violations.", + "tags": [ + "guardrails", + "security", + "validation" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 128, + 169 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:check_evidence", + "type": "function", + "name": "check_evidence", + "summary": "Verifies task evidence against the working tree: file existence, source and path patterns, directory scanning, JSON blob sizes, and regex matches.", + "tags": [ + "evidence-check", + "verification", + "validation" + ], + "complexity": "moderate", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 180, + 259 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:run", + "type": "function", + "name": "run", + "summary": "Runs the full validation: changed files via git, guardrails, optional quality gates (ruff/pyright/pytest), then evidence checks, and writes the report to the output dir.", + "tags": [ + "orchestration", + "validation", + "quality-gates", + "pipeline" + ], + "complexity": "complex", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 262, + 332 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:main", + "type": "function", + "name": "main", + "summary": "Argparse entry point for the validate command with --base-ref, --skip-quality, and --evidence flags.", + "tags": [ + "entry-point", + "cli", + "validation" + ], + "complexity": "simple", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 335, + 344 + ] + }, + { + "id": "function:scripts/agentic/validate_changes.py:json_dumps_summary", + "type": "function", + "name": "json_dumps_summary", + "summary": "Summarizes the validation report as a single-line JSON verdict with guardrail, quality, and evidence counts.", + "tags": [ + "summary", + "reporting", + "utility" + ], + "complexity": "simple", + "filePath": "scripts/agentic/validate_changes.py", + "lineRange": [ + 347, + 359 + ] + }, + { + "id": "file:src/ccbot/errors.py", + "type": "file", + "name": "errors.py", + "filePath": "src/ccbot/errors.py", + "summary": "Centralizes Telegram error classification (expected/stale/transient), logging helpers, and RetryAfter flood-control normalization used across Telegram/tmux hot paths.", + "tags": [ + "error-handling", + "telegram", + "logging", + "utility", + "tested" + ], + "complexity": "moderate", + "languageNotes": "PTB v22+ RetryAfter.retry_after may be a timedelta; retry_after_seconds normalizes both shapes." + }, + { + "id": "function:src/ccbot/errors.py:log_exception", + "type": "function", + "name": "log_exception", + "filePath": "src/ccbot/errors.py", + "lineRange": [ + 33, + 44 + ], + "summary": "Logs an exception with its type name, message, and optional key=value context pairs at a configurable log level.", + "tags": [ + "logging", + "error-handling", + "utility" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/errors.py:reraise_retry_after", + "type": "function", + "name": "reraise_retry_after", + "filePath": "src/ccbot/errors.py", + "lineRange": [ + 47, + 50 + ], + "summary": "Re-raises RetryAfter exceptions unchanged so higher layers can apply flood-control backoff; no-op for all other exceptions.", + "tags": [ + "telegram", + "rate-limiting", + "error-handling" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/errors.py:retry_after_seconds", + "type": "function", + "name": "retry_after_seconds", + "filePath": "src/ccbot/errors.py", + "lineRange": [ + 53, + 61 + ], + "summary": "Converts a RetryAfter exception to the flood-control wait in seconds, supporting both legacy int and timedelta retry_after values.", + "tags": [ + "telegram", + "rate-limiting", + "utility" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/handlers/capture_registry.py", + "type": "file", + "name": "capture_registry.py", + "filePath": "src/ccbot/handlers/capture_registry.py", + "summary": "Process-wide registry mapping (user_id, thread_id) to active asyncio bash-capture tasks, providing cancel and identity-checked discard so topic teardown never leaves a running watcher behind.", + "tags": [ + "asyncio", + "task-management", + "registry", + "state", + "tested" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/capture_registry.py:CaptureTaskRegistry", + "type": "class", + "name": "CaptureTaskRegistry", + "filePath": "src/ccbot/handlers/capture_registry.py", + "lineRange": [ + 16, + 53 + ], + "summary": "Stores, cancels, and discards bash-capture asyncio tasks keyed by (user_id, thread_id); discard uses identity checks so a stale watcher cannot evict a newer registration.", + "tags": [ + "asyncio", + "task-management", + "registry" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/handlers/cleanup.py", + "type": "file", + "name": "cleanup.py", + "filePath": "src/ccbot/handlers/cleanup.py", + "summary": "Unified topic cleanup API that cancels bash capture tasks, clears status/tool-message tracking and interactive UI state, and pops pending user_data when a topic is deleted or its binding goes stale.", + "tags": [ + "cleanup", + "state-management", + "lifecycle", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "type": "function", + "name": "clear_topic_state", + "filePath": "src/ccbot/handlers/cleanup.py", + "lineRange": [ + 19, + 54 + ], + "summary": "Teardown of all per-topic memory state: cancels running capture watchers, clears status/tool message IDs, deletes the interactive UI message, and removes pending thread-state from user_data.", + "tags": [ + "cleanup", + "state-management", + "lifecycle" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/handlers/interactive_ui.py", + "type": "file", + "name": "interactive_ui.py", + "filePath": "src/ccbot/handlers/interactive_ui.py", + "summary": "Detects Claude Code interactive terminal UIs (AskUserQuestion, ExitPlanMode, Permission Prompt, RestoreCheckpoint) from captured pane text and renders them in Telegram with navigation keyboards, tracking interactive mode per (user, thread).", + "tags": [ + "interactive-ui", + "telegram", + "keyboard", + "terminal", + "tested" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/handlers/interactive_ui.py:set_interactive_mode", + "type": "function", + "name": "set_interactive_mode", + "filePath": "src/ccbot/handlers/interactive_ui.py", + "lineRange": [ + 57, + 69 + ], + "summary": "Records the window currently in interactive mode for a user/thread so later messages can refresh that UI.", + "tags": [ + "state-tracking", + "interactive-ui", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/interactive_ui.py:_build_interactive_keyboard", + "type": "function", + "name": "_build_interactive_keyboard", + "filePath": "src/ccbot/handlers/interactive_ui.py", + "lineRange": [ + 83, + 128 + ], + "summary": "Builds the inline navigation keyboard (space/arrows/tab, esc/refresh/enter) for an interactive UI, omitting horizontal keys when the UI name is RestoreCheckpoint.", + "tags": [ + "telegram", + "keyboard", + "interactive-ui" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "type": "function", + "name": "handle_interactive_ui", + "filePath": "src/ccbot/handlers/interactive_ui.py", + "lineRange": [ + 131, + 237 + ], + "summary": "Captures the tmux pane, checks for a Claude Code interactive UI, extracts its content, and sends it as a Telegram message with a navigation keyboard, editing in place when a message already exists.", + "tags": [ + "interactive-ui", + "telegram", + "tmux", + "event-handler" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/handlers/interactive_ui.py:clear_interactive_msg", + "type": "function", + "name": "clear_interactive_msg", + "filePath": "src/ccbot/handlers/interactive_ui.py", + "lineRange": [ + 240, + 260 + ], + "summary": "Removes the tracked interactive message for a user/thread, deletes it from the chat when possible, and exits interactive mode.", + "tags": [ + "cleanup", + "interactive-ui", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/handlers/message_handlers.py", + "type": "file", + "name": "message_handlers.py", + "filePath": "src/ccbot/handlers/message_handlers.py", + "summary": "Telegram message handlers for text, photos, and voice: routes input to bound tmux windows, downloads and forwards images, transcribes voice via OpenAI, and runs background bash-output capture for `!` commands.", + "tags": [ + "telegram", + "event-handler", + "message-routing", + "voice", + "tmux" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "type": "function", + "name": "photo_handler", + "filePath": "src/ccbot/handlers/message_handlers.py", + "lineRange": [ + 56, + 109 + ], + "summary": "Downloads the highest-resolution photo to the images directory and forwards its path, with optional caption, to the bound Claude Code window.", + "tags": [ + "telegram", + "photo", + "file-handling" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "type": "function", + "name": "voice_handler", + "filePath": "src/ccbot/handlers/message_handlers.py", + "lineRange": [ + 112, + 171 + ], + "summary": "Downloads an OGG voice message, transcribes it via the OpenAI-backed transcriber, and forwards the transcript to the bound window with user confirmation.", + "tags": [ + "voice", + "transcription", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "type": "function", + "name": "capture_bash_output", + "filePath": "src/ccbot/handlers/message_handlers.py", + "lineRange": [ + 179, + 256 + ], + "summary": "Background asyncio task that polls the tmux pane for `!` command output, sends the first capture as a new message and edits it in place as output appears, until 30 seconds elapse or the task is cancelled.", + "tags": [ + "asyncio", + "tmux", + "bash", + "background-task" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "type": "function", + "name": "text_handler", + "filePath": "src/ccbot/handlers/message_handlers.py", + "lineRange": [ + 259, + 458 + ], + "summary": "Routes user text to the bound tmux window: guards picker/browser/session states, shows window picker and directory browser for unbound topics, injects text into the session, starts `!` bash capture, and refreshes pending interactive UIs.", + "tags": [ + "telegram", + "message-routing", + "state-machine", + "tmux" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/handlers/message_queue.py", + "type": "file", + "name": "message_queue.py", + "filePath": "src/ccbot/handlers/message_queue.py", + "summary": "Per-user asyncio message queue system for ordered Telegram delivery: FIFO workers with flood control, content-message merging, status/tool-message tracking, and topic-aware sending via MessageTask.thread_id.", + "tags": [ + "message-queue", + "asyncio", + "telegram", + "rate-limiting", + "state", + "tested" + ], + "complexity": "complex", + "languageNotes": "Combines asyncio.Queue workers, per-user locks, monotonic flood-control timestamps, and in-queue task coalescing for Telegram 4096-char limits." + }, + { + "id": "class:src/ccbot/handlers/message_queue.py:MessageTask", + "type": "class", + "name": "MessageTask", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 57, + 68 + ], + "summary": "Dataclass describing a queued message task with type (content/status_update/status_clear), optional thread_id, content parts, tool_use_id, and attached image data.", + "tags": [ + "data-model", + "message-queue", + "dataclass" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/message_queue.py:StatusTracker", + "type": "class", + "name": "StatusTracker", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 71, + 132 + ], + "summary": "Per-user maps for status message IDs (message_id, window_id, text) and tool_use message IDs, with set/get/pop accessors, clear-for-topic helpers, and full reset.", + "tags": [ + "state-tracking", + "message-queue", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/handlers/message_queue.py:MessageQueueManager", + "type": "class", + "name": "MessageQueueManager", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 135, + 191 + ], + "summary": "Owns per-user asyncio queues, worker tasks, locks, and monotonic flood-control timestamps plus the StatusTracker; provides queue lifecycle, flood queries, and shutdown.", + "tags": [ + "message-queue", + "asyncio", + "singleton", + "flood-control" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:get_message_queue", + "type": "function", + "name": "get_message_queue", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 201, + 203 + ], + "summary": "Returns the asyncio queue for a user if one exists; part of the stable public wrapper surface over the queue_manager singleton.", + "tags": [ + "message-queue", + "utility", + "asyncio" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:get_or_create_queue", + "type": "function", + "name": "get_or_create_queue", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 206, + 208 + ], + "summary": "Returns the user's queue, lazily creating it and starting its worker task if it does not yet exist.", + "tags": [ + "message-queue", + "asyncio", + "lazy-initialization" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:clear_status_msg_info", + "type": "function", + "name": "clear_status_msg_info", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 211, + 213 + ], + "summary": "Clears status-message tracking for a user and optional thread via the shared StatusTracker; used by message handlers and cleanup.", + "tags": [ + "state-tracking", + "cleanup", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:clear_tool_msg_ids_for_topic", + "type": "function", + "name": "clear_tool_msg_ids_for_topic", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 216, + 218 + ], + "summary": "Removes all tool_use to message-ID tracking entries for a topic; used during topic cleanup.", + "tags": [ + "state-tracking", + "cleanup", + "tool-usage" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:shutdown_workers", + "type": "function", + "name": "shutdown_workers", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 221, + 223 + ], + "summary": "Cancels all queue workers and clears queue state; invoked during bot shutdown.", + "tags": [ + "lifecycle", + "message-queue", + "shutdown" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_inspect_queue", + "type": "function", + "name": "_inspect_queue", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 226, + 238 + ], + "summary": "Non-destructively drains a queue with get_nowait to list its contents so the caller can merge or refill them.", + "tags": [ + "message-queue", + "utility", + "inspection" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_can_merge_tasks", + "type": "function", + "name": "_can_merge_tasks", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 241, + 254 + ], + "summary": "Determines whether two consecutive content tasks can be merged, breaking the chain at tool_use/tool_result boundaries and window switches.", + "tags": [ + "message-queue", + "merging", + "logic" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_merge_content_tasks", + "type": "function", + "name": "_merge_content_tasks", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 257, + 318 + ], + "summary": "Merges queued content tasks from the same window into a single task while preserving order and respecting size limits and non-mergeable boundaries.", + "tags": [ + "message-queue", + "merging", + "asyncio" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_process_content_with_retry", + "type": "function", + "name": "_process_content_with_retry", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 321, + 356 + ], + "summary": "Processes a content task with retry and backoff when Telegram raises RetryAfter flood limits, marking the user's queue as flooded while waiting.", + "tags": [ + "retry", + "rate-limiting", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_message_queue_worker", + "type": "function", + "name": "_message_queue_worker", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 359, + 450 + ], + "summary": "Per-user worker loop that fetches tasks, merges content tasks, processes them under a lock, honors flood-control waits, and logs exceptions without crashing.", + "tags": [ + "asyncio", + "worker", + "message-queue", + "flood-control" + ], + "complexity": "complex" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_send_task_images", + "type": "function", + "name": "_send_task_images", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 453, + 467 + ], + "summary": "Sends image data attached to a content task as Telegram photos to the resolved chat.", + "tags": [ + "telegram", + "photo", + "sending" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_process_content_task", + "type": "function", + "name": "_process_content_task", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 470, + 536 + ], + "summary": "Delivers a content message: edits earlier tool_use messages via tracked IDs, sends fallback content, sends attached images, and schedules status checks.", + "tags": [ + "telegram", + "message-sending", + "content-delivery" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_convert_status_to_content", + "type": "function", + "name": "_convert_status_to_content", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 539, + 574 + ], + "summary": "Promotes a pending status message into a full content message by deleting the status message and sending or editing the real content text.", + "tags": [ + "telegram", + "status", + "merge" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_process_status_update_task", + "type": "function", + "name": "_process_status_update_task", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 577, + 628 + ], + "summary": "Handles status_update tasks: clears stale status messages, sends fresh ones with chat actions, and keeps the StatusTracker maps consistent.", + "tags": [ + "telegram", + "status", + "message-sending" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_do_send_status_message", + "type": "function", + "name": "_do_send_status_message", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 631, + 670 + ], + "summary": "Sends or edits a status message in the topic with Markdown fallback, updating the StatusTracker and handling delete/send races gracefully.", + "tags": [ + "telegram", + "status", + "message-sending" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_do_clear_status_message", + "type": "function", + "name": "_do_clear_status_message", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 673, + 691 + ], + "summary": "Deletes the tracked status message for a topic and removes its entry from the StatusTracker.", + "tags": [ + "telegram", + "status", + "cleanup" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:_check_and_send_status", + "type": "function", + "name": "_check_and_send_status", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 694, + 716 + ], + "summary": "When a user's queue is idle, polls the tmux pane for a status line and enqueues a status update for the window.", + "tags": [ + "status", + "tmux", + "polling" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:enqueue_content_message", + "type": "function", + "name": "enqueue_content_message", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 719, + 749 + ], + "summary": "Builds a content MessageTask (text/parts, optional thread_id and image data) and enqueues it on the user's queue in receive order.", + "tags": [ + "message-queue", + "telegram", + "enqueue" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/message_queue.py:enqueue_status_update", + "type": "function", + "name": "enqueue_status_update", + "filePath": "src/ccbot/handlers/message_queue.py", + "lineRange": [ + 752, + 782 + ], + "summary": "Enqueues a status_update task, skipping redundant updates while flooded or when the same status is already pending, and enqueuing delete tasks for None text.", + "tags": [ + "message-queue", + "status", + "flood-control" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/status_polling.py", + "type": "file", + "name": "status_polling.py", + "filePath": "src/ccbot/handlers/status_polling.py", + "summary": "Background 1s polling loop checking each thread-bound tmux window for Claude Code status lines and interactive UIs, enqueuing updates, and probing topic existence every 60s to kill windows and unbind deleted topics.", + "tags": [ + "polling", + "background-task", + "tmux", + "telegram", + "lifecycle", + "tested" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "type": "function", + "name": "update_status_message", + "filePath": "src/ccbot/handlers/status_polling.py", + "lineRange": [ + 46, + 119 + ], + "summary": "Polls a window's pane: reports missing windows, detects and clears interactive UI states per user/thread, and enqueues status updates unless skip_status is set.", + "tags": [ + "polling", + "interactive-ui", + "status", + "tmux" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "type": "function", + "name": "status_poll_loop", + "filePath": "src/ccbot/handlers/status_polling.py", + "lineRange": [ + 122, + 206 + ], + "summary": "Infinite polling task iterating thread bindings: periodic topic-existence probe (kills windows and unbinds on Topic_id_invalid), stale-binding cleanup, and per-window status updates that skip status when the queue is busy.", + "tags": [ + "polling", + "background-task", + "lifecycle", + "tmux" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/terminal_parser.py", + "type": "file", + "name": "terminal_parser.py", + "filePath": "src/ccbot/terminal_parser.py", + "summary": "Regex-based parser for captured tmux pane text: detects Claude Code interactive UIs via top/bottom marker patterns, extracts status lines with spinner detection, strips pane chrome, and locates bash command output.", + "tags": [ + "parsing", + "regex", + "terminal", + "tmux", + "claude-code", + "tested" + ], + "complexity": "complex", + "languageNotes": "UI patterns are tuples of compiled regexes to tolerate Claude Code wording changes across versions; first matching pattern wins." + }, + { + "id": "class:src/ccbot/terminal_parser.py:InteractiveUIContent", + "type": "class", + "name": "InteractiveUIContent", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 21, + 25 + ], + "summary": "Dataclass holding the extracted interactive UI display content and the name of the pattern that matched.", + "tags": [ + "data-model", + "dataclass", + "parsing" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/terminal_parser.py:UIPattern", + "type": "class", + "name": "UIPattern", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 29, + 44 + ], + "summary": "Frozen dataclass pairing top/bottom regex-marker tuples that delimit an interactive UI region, with a minimum line gap; any single marker match suffices.", + "tags": [ + "data-model", + "regex", + "pattern" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/terminal_parser.py:_try_extract", + "type": "function", + "name": "_try_extract", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 136, + 168 + ], + "summary": "Scans lines for a single UI pattern's top and bottom markers and extracts the bounded content region, shortening long separator lines.", + "tags": [ + "parsing", + "regex", + "utility" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/terminal_parser.py:extract_interactive_content", + "type": "function", + "name": "extract_interactive_content", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 174, + 188 + ], + "summary": "Tries every registered UI pattern in order and returns the first InteractiveUIContent match extracted from the pane text.", + "tags": [ + "parsing", + "interactive-ui", + "regex" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/terminal_parser.py:is_interactive_ui", + "type": "function", + "name": "is_interactive_ui", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 191, + 193 + ], + "summary": "Returns True when extract_interactive_content finds a match; this cheap gate is used by polling and message handlers before rendering a UI.", + "tags": [ + "parsing", + "interactive-ui", + "detection" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/terminal_parser.py:parse_status_line", + "type": "function", + "name": "parse_status_line", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 202, + 238 + ], + "summary": "Scans pane text from the bottom up for a spinner-plus-status line and returns the cleaned status text, or None when no status line is present.", + "tags": [ + "parsing", + "status", + "regex" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/terminal_parser.py:strip_pane_chrome", + "type": "function", + "name": "strip_pane_chrome", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 244, + 263 + ], + "summary": "Removes empty and border-artifact lines around pane content so downstream extraction operates on clean text.", + "tags": [ + "parsing", + "terminal", + "utility" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/terminal_parser.py:extract_bash_output", + "type": "function", + "name": "extract_bash_output", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 266, + 300 + ], + "summary": "Finds the output following a `!` command sent to a pane, stripping the echoed command line and trailing artifacts.", + "tags": [ + "parsing", + "bash", + "terminal" + ], + "complexity": "moderate" + }, + { + "id": "class:src/ccbot/terminal_parser.py:UsageInfo", + "type": "class", + "name": "UsageInfo", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 307, + 311 + ], + "summary": "Dataclass carrying the raw /usage panel text and the cleaned parsed lines.", + "tags": [ + "data-model", + "dataclass", + "parsing" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/terminal_parser.py:parse_usage_output", + "type": "function", + "name": "parse_usage_output", + "filePath": "src/ccbot/terminal_parser.py", + "lineRange": [ + 314, + 365 + ], + "summary": "Parses a /usage terminal panel into raw text plus cleaned lines, stripping block-graphic padding characters from each line.", + "tags": [ + "parsing", + "regex", + "usage" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/tmux_manager.py", + "type": "file", + "name": "tmux_manager.py", + "filePath": "src/ccbot/tmux_manager.py", + "summary": "Async-friendly wrapper around libtmux for a single tmux session: lists and finds windows, captures panes, sends keys, renames, kills, and creates windows while scrubbing sensitive env vars from the session.", + "tags": [ + "tmux", + "terminal", + "session-management", + "asyncio", + "tested" + ], + "complexity": "complex", + "languageNotes": "All blocking libtmux calls are wrapped in asyncio.to_thread; session env is scrubbed so child processes like Claude Code never inherit secrets." + }, + { + "id": "class:src/ccbot/tmux_manager.py:TmuxWindow", + "type": "class", + "name": "TmuxWindow", + "filePath": "src/ccbot/tmux_manager.py", + "lineRange": [ + 40, + 46 + ], + "summary": "Dataclass describing a tmux window: window id, name, cwd, and the command running in its active pane.", + "tags": [ + "data-model", + "dataclass", + "tmux" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "class", + "name": "TmuxManager", + "filePath": "src/ccbot/tmux_manager.py", + "lineRange": [ + 49, + 512 + ], + "summary": "Singleton managing the tmux session: session get/create with env scrubbing, window discovery and lookup, async pane capture (plain or ANSI via subprocess), key injection, and rename/kill/create lifecycle operations.", + "tags": [ + "tmux", + "session-management", + "asyncio", + "singleton" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/handlers/response_builder.py", + "type": "file", + "name": "response_builder.py", + "filePath": "src/ccbot/handlers/response_builder.py", + "summary": "Builds paginated Telegram response messages from Claude Code output, handling content types (text, thinking, tool_use, tool_result), truncating thinking blocks, and splitting long text into chunks within Telegram's 4096-char limit without doing MarkdownV2 conversion (deferred to the send layer).", + "tags": [ + "pagination", + "message-formatting", + "content-types", + "telegram", + "utility", + "tested" + ], + "complexity": "moderate", + "languageNotes": "Prefers chunk sizes conservative of the 3000-char budget so MarkdownV2 expansion at the send layer never overflows the message limit." + }, + { + "id": "function:src/ccbot/handlers/response_builder.py:build_response_parts", + "type": "function", + "name": "build_response_parts", + "filePath": "src/ccbot/handlers/response_builder.py", + "lineRange": [ + 21, + 97 + ], + "summary": "Transforms raw Claude Code output into a list of Telegram-ready markdown strings: prefixes user messages, truncates completed thinking blocks to 500 chars, keeps expandable quote sentinels atomic, converts tables to card-style, and pages long text with [i/N] suffixes.", + "tags": [ + "pagination", + "formatting", + "telegram", + "truncation" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/markdown_v2.py", + "type": "file", + "name": "markdown_v2.py", + "filePath": "src/ccbot/markdown_v2.py", + "summary": "Markdown to Telegram MarkdownV2 conversion layer that wraps telegramify_markdown, converts markdown tables into card-style key-value output (skipping tables inside code blocks), and renders expandable blockquotes delimited by TranscriptParser sentinel tokens so the library does not mangle them.", + "tags": [ + "markdown-v2", + "conversion", + "escaping", + "telegram-formatting", + "utility", + "tested" + ], + "complexity": "moderate", + "languageNotes": "Uses a TelegramMarkdownRenderer context manager directly instead of telegramify_markdown.markdownify so module-level token rules are not reset on exit." + }, + { + "id": "function:src/ccbot/markdown_v2.py:convert_markdown_tables", + "type": "function", + "name": "convert_markdown_tables", + "filePath": "src/ccbot/markdown_v2.py", + "lineRange": [ + 30, + 101 + ], + "summary": "Rewrites pipe-delimited markdown tables into card-style **Header**: value rows separated by horizontal lines, tracking and skipping fenced code blocks so their contents are untouched.", + "tags": [ + "tables", + "conversion", + "formatting", + "regex" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/markdown_v2.py:_render_expandable_quote", + "type": "function", + "name": "_render_expandable_quote", + "filePath": "src/ccbot/markdown_v2.py", + "lineRange": [ + 124, + 153 + ], + "summary": "Renders an expandable blockquote match as raw >…|| MarkdownV2 syntax, escaping special characters and truncating line-by-line to stay within the 3800-char rendering budget.", + "tags": [ + "blockquote", + "escaping", + "truncation", + "markdown-v2" + ], + "complexity": "moderate" + }, + { + "id": "function:src/ccbot/markdown_v2.py:_markdownify", + "type": "function", + "name": "_markdownify", + "filePath": "src/ccbot/markdown_v2.py", + "lineRange": [ + 156, + 172 + ], + "summary": "Custom markdownify pipeline that drives TelegramMarkdownRenderer directly, disabling indented code blocks (only fenced blocks count) while rendering text through mistletoe with latex escaping.", + "tags": [ + "markdownify", + "rendering", + "mistletoe", + "utility" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/markdown_v2.py:convert_markdown", + "type": "function", + "name": "convert_markdown", + "filePath": "src/ccbot/markdown_v2.py", + "lineRange": [ + 175, + 205 + ], + "summary": "Public entry point converting standard Markdown to Telegram MarkdownV2: converts tables to cards, extracts expandable quote segments for dedicated rendering, and markdownifies the remaining text.", + "tags": [ + "markdown-v2", + "conversion", + "entry-point", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/telegram_sender.py", + "type": "file", + "name": "telegram_sender.py", + "filePath": "src/ccbot/telegram_sender.py", + "summary": "Message splitting utility that chunks long text into Telegram-safe pieces measured in UTF-16 code units (Telegram's length accounting), preferring line boundaries and auto-closing/re-opening fenced code blocks so every chunk stays valid markdown.", + "tags": [ + "message-splitting", + "utf16", + "code-block", + "telegram-limit", + "utility", + "tested" + ], + "complexity": "moderate", + "languageNotes": "Lengths use UTF-16 code units (emoji and non-BMP chars count as 2), matching Telegram's 4096 limit exactly instead of Python len() code points." + }, + { + "id": "function:src/ccbot/telegram_sender.py:_force_split", + "type": "function", + "name": "_force_split", + "filePath": "src/ccbot/telegram_sender.py", + "lineRange": [ + 24, + 39 + ], + "summary": "Splits a single overlong line into pieces that each fit within a UTF-16 budget, used as a last resort when a line alone exceeds Telegram's message limit.", + "tags": [ + "splitting", + "utf16", + "utility" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/telegram_sender.py:split_message", + "type": "function", + "name": "split_message", + "filePath": "src/ccbot/telegram_sender.py", + "lineRange": [ + 42, + 122 + ], + "summary": "Splits a message into Telegram-safe chunks up to max_length UTF-16 units, accumulating whole lines where possible, reserving headroom inside code blocks for a closing fence, and re-opening fences after each split.", + "tags": [ + "splitting", + "utf16", + "code-block", + "telegram-limit" + ], + "complexity": "complex" + }, + { + "id": "file:src/ccbot/transcript_parser.py", + "type": "file", + "name": "transcript_parser.py", + "filePath": "src/ccbot/transcript_parser.py", + "summary": "JSONL transcript parser for Claude Code session files that extracts structured, display-ready messages (text, thinking, tool_use, tool_result, local_command), pairing tool_use blocks with their tool_result via tool_use_id and formatting per-tool result statistics with expandable quotes; shared by both history and real-time monitoring.", + "tags": [ + "jsonl-parser", + "claude-code", + "transcript", + "tool-pairing", + "session", + "tested" + ], + "complexity": "complex", + "languageNotes": "Uses control-character sentinels (\\x02EXPQUOTE_START\\x02) to mark expandable quote spans that are later rendered by the MarkdownV2 layer." + }, + { + "id": "class:src/ccbot/transcript_parser.py:ParsedEntry", + "type": "class", + "name": "ParsedEntry", + "filePath": "src/ccbot/transcript_parser.py", + "lineRange": [ + 35, + 50 + ], + "summary": "Dataclass representing a single display-ready parsed message entry carrying role, pre-formatted text, content type, tool_use_id, timestamp, tool name, and optional decoded image data for tool results.", + "tags": [ + "data-model", + "dataclass", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:src/ccbot/transcript_parser.py:TranscriptParser", + "type": "class", + "name": "TranscriptParser", + "filePath": "src/ccbot/transcript_parser.py", + "lineRange": [ + 62, + 776 + ], + "summary": "Static-method parser for Claude Code JSONL session files: parses raw lines, extracts text and images, formats tool_use summaries and per-tool result statistics, and converts entry streams into flat ParsedEntry lists with tool_use/tool_result pairing and optional pending-tool carry-over for polling consumers.", + "tags": [ + "parser", + "jsonl", + "claude-code", + "tool-pairing", + "formatter" + ], + "complexity": "complex" + }, + { + "id": "pipeline:.github/workflows/agentic-analyze.yml", + "type": "pipeline", + "name": "agentic-analyze.yml", + "filePath": ".github/workflows/agentic-analyze.yml", + "summary": "Scheduled (weekly Monday 06:00 UTC, aligned with .agentic/config.json) and push-triggered workflow that analyzes the repository knowledge graph and ranks backlog tasks via scripts/agentic/cli.py (analyze, plan), uploading analysis reports and the selected-task artifact.", + "tags": [ + "ci-cd", + "deployment", + "automation", + "agentic", + "scheduled" + ], + "complexity": "simple" + }, + { + "id": "pipeline:.github/workflows/agentic-implement.yml", + "type": "pipeline", + "name": "agentic-implement.yml", + "filePath": ".github/workflows/agentic-implement.yml", + "summary": "Configurable workflow (workflow_dispatch/workflow_call with task_id and dry_run inputs) that selects a backlog task, creates an agentic/ feature branch, dispatches the Claude Code Action to implement it and open a draft PR, then validates the changes with cli.py validate. Includes a concurrency guard and a dry-run summary job.", + "tags": [ + "ci-cd", + "deployment", + "automation", + "agentic", + "pull-request" + ], + "complexity": "complex", + "languageNotes": "Orchestrates three jobs with inline bash/python metadata extraction, artifact passing between jobs, and a workflow_call reusable-entry interface." + }, + { + "id": "pipeline:.github/workflows/agentic-orchestrator.yml", + "type": "pipeline", + "name": "agentic-orchestrator.yml", + "filePath": ".github/workflows/agentic-orchestrator.yml", + "summary": "Manual/scheduled pipeline chaining the agentic flow: runs analyze, select, and optional issue sync via cli.py, then conditionally calls the Agentic Implement workflow (workflow_call) when a task is selected and implement is enabled (manual input or AGENTIC_AUTO_IMPLEMENT repo variable).", + "tags": [ + "ci-cd", + "deployment", + "automation", + "agentic", + "orchestration" + ], + "complexity": "moderate" + }, + { + "id": "pipeline:.github/workflows/agentic-plan.yml", + "type": "pipeline", + "name": "agentic-plan.yml", + "filePath": ".github/workflows/agentic-plan.yml", + "summary": "Manual workflow that analyzes the knowledge graph, selects the highest-scoring backlog task via scripts/agentic/cli.py, optionally syncs the backlog to GitHub issues with labels, and uploads plan artifacts (.agentic/out/).", + "tags": [ + "ci-cd", + "deployment", + "automation", + "agentic", + "planning" + ], + "complexity": "moderate" + }, + { + "id": "pipeline:.github/workflows/check.yml", + "type": "pipeline", + "name": "check.yml", + "filePath": ".github/workflows/check.yml", + "summary": "CI workflow running on push and pull requests to main that installs the project with uv and runs the quality gates across a Python 3.12/3.13 matrix: ruff lint and format check, pyright type checking, and pytest tests.", + "tags": [ + "ci-cd", + "deployment", + "quality-gate", + "linting", + "testing" + ], + "complexity": "simple" + }, + { + "id": "pipeline:.github/workflows/claude-code-review.yml", + "type": "pipeline", + "name": "claude-code-review.yml", + "filePath": ".github/workflows/claude-code-review.yml", + "summary": "Pull-request-triggered workflow that runs the Claude Code Action with the code-review plugin on opened/synchronized PRs, posting AI-generated code reviews with pull-request write permissions.", + "tags": [ + "ci-cd", + "deployment", + "code-review", + "automation", + "ai-assistant" + ], + "complexity": "simple" + }, + { + "id": "pipeline:.github/workflows/claude.yml", + "type": "pipeline", + "name": "claude.yml", + "filePath": ".github/workflows/claude.yml", + "summary": "Event-driven workflow that invokes the Claude Code Action whenever @claude is mentioned in an issue, issue/PR review comment, or PR review body, giving Claude read access to the repo and CI results.", + "tags": [ + "ci-cd", + "deployment", + "automation", + "ai-assistant", + "issue-triggered" + ], + "complexity": "simple" + }, + { + "id": "step:.github/workflows/agentic-analyze.yml:analyze", + "type": "step", + "name": "analyze", + "filePath": ".github/workflows/agentic-analyze.yml", + "summary": "Main job that runs python scripts/agentic/cli.py analyze and plan, uploads the analysis/plan artifacts from .agentic/out/, and prints a job summary.", + "tags": [ + "agentic", + "analysis", + "automation" + ], + "complexity": "simple" + }, + { + "id": "step:.github/workflows/agentic-implement.yml:prepare", + "type": "step", + "name": "prepare", + "filePath": ".github/workflows/agentic-implement.yml", + "summary": "Analyzes the knowledge graph, selects a backlog task (by id or highest score), exports task_id/branch/has_task outputs, and uploads the implement context artifact.", + "tags": [ + "agentic", + "task-selection", + "preparation" + ], + "complexity": "moderate" + }, + { + "id": "step:.github/workflows/agentic-implement.yml:implement", + "type": "step", + "name": "implement", + "filePath": ".github/workflows/agentic-implement.yml", + "summary": "Checks out, creates the agentic/- branch, installs the project with uv sync, builds a Claude Code prompt from .agentic policies and implement prompt, runs the Claude Code Action to commit and open a draft PR, then validates the result with cli.py validate.", + "tags": [ + "agentic", + "implementation", + "branch-creation", + "claude-code" + ], + "complexity": "complex" + }, + { + "id": "step:.github/workflows/agentic-orchestrator.yml:analyze_and_plan", + "type": "step", + "name": "analyze_and_plan", + "filePath": ".github/workflows/agentic-orchestrator.yml", + "summary": "Runs cli.py analyze and select, optionally syncs issues via gh and cli.py sync-issues, decides whether to chain into implement (task id and should_implement outputs), and uploads orchestrator artifacts.", + "tags": [ + "agentic", + "orchestration", + "planning" + ], + "complexity": "moderate" + }, + { + "id": "step:.github/workflows/agentic-orchestrator.yml:call_implement", + "type": "step", + "name": "call_implement", + "filePath": ".github/workflows/agentic-orchestrator.yml", + "summary": "Reusable-workflow job that invokes agentic-implement.yml via workflow_call with the selected task id and a dry_run flag depending on event type and repo variables.", + "tags": [ + "agentic", + "orchestration", + "workflow-call" + ], + "complexity": "simple" + }, + { + "id": "step:.github/workflows/agentic-plan.yml:plan", + "type": "step", + "name": "plan", + "filePath": ".github/workflows/agentic-plan.yml", + "summary": "Main job running cli.py analyze and select, creating agentic/priority labels and syncing backlog tasks to GitHub issues when requested, then uploading plan artifacts.", + "tags": [ + "agentic", + "planning", + "issue-sync" + ], + "complexity": "moderate" + }, + { + "id": "step:.github/workflows/check.yml:check", + "type": "step", + "name": "check", + "filePath": ".github/workflows/check.yml", + "summary": "Matrix job (Python 3.12 and 3.13) that runs uv sync --all-extras, ruff check/format, pyright on src/ccbot/, and pytest as the repository quality gate.", + "tags": [ + "quality-gate", + "matrix", + "linting", + "testing" + ], + "complexity": "simple" + }, + { + "id": "step:.github/workflows/claude-code-review.yml:claude-review", + "type": "step", + "name": "claude-review", + "filePath": ".github/workflows/claude-code-review.yml", + "summary": "Runs the Claude Code Action with the code-review plugin on pull requests, authenticating with CLAUDE_CODE_OAUTH_TOKEN and posting review results back to the PR.", + "tags": [ + "code-review", + "claude-code", + "automation" + ], + "complexity": "simple" + }, + { + "id": "step:.github/workflows/claude.yml:claude", + "type": "step", + "name": "claude", + "filePath": ".github/workflows/claude.yml", + "summary": "Conditional job (only when @claude is mentioned) that runs the Claude Code Action with read permissions, including access to CI results via actions: read.", + "tags": [ + "claude-code", + "automation", + "assistant" + ], + "complexity": "simple" + }, + { + "id": "document:.agentic/HANDOFF.md", + "type": "document", + "name": "HANDOFF.md", + "filePath": ".agentic/HANDOFF.md", + "summary": "Project handoff document (Russian) for the ccbot_ju1ian agentic pipeline + refactoring work: goal, completed REF-001..REF-009 refactors, current git state, remaining steps, key decisions, artifacts, and a bootstrap prompt for the incoming agent.", + "tags": [ + "documentation", + "handoff", + "refactoring", + "development", + "workflow" + ], + "complexity": "complex" + }, + { + "id": "document:.agentic/HANDOFF_REF-006.md", + "type": "document", + "name": "HANDOFF_REF-006.md", + "filePath": ".agentic/HANDOFF_REF-006.md", + "summary": "Handoff for the REF-006 task (callback_data string parsing to typed frozen dataclasses): pipeline status, completed refactors, REF-006 key achievements and next steps, decisions, artifacts, and context for the next agent.", + "tags": [ + "documentation", + "handoff", + "refactoring", + "development" + ], + "complexity": "moderate" + }, + { + "id": "document:.agentic/IMPLEMENTATION_PLAN.md", + "type": "document", + "name": "IMPLEMENTATION_PLAN.md", + "filePath": ".agentic/IMPLEMENTATION_PLAN.md", + "summary": "Comprehensive executable implementation plan (Russian) for the ccbot agentic pipeline: goal/boundaries, task structuring from findings to atomic tasks, automation loop with human gates, Grok (xAI) integration, GitHub Actions workflow set, full file inventory with examples, sub-agents and MCP servers, phased rollout plan, action log, risks, and definition of done.", + "tags": [ + "documentation", + "planning", + "automation", + "workflow", + "ci-cd" + ], + "complexity": "complex" + }, + { + "id": "document:.agentic/README.md", + "type": "document", + "name": "README.md", + "filePath": ".agentic/README.md", + "summary": "Entry-point readme for the agentic pipeline: ANALYZE→PLAN→IMPLEMENT→VALIDATE stage flow with local CLI commands and GitHub workflow equivalents, directory layout, Grok MCP registration, secrets/permissions, safety model, and typical human loop.", + "tags": [ + "documentation", + "entry-point", + "automation", + "workflow" + ], + "complexity": "moderate" + }, + { + "id": "config:.agentic/config.json", + "type": "config", + "name": "config.json", + "filePath": ".agentic/config.json", + "summary": "Central pipeline configuration for the ccbot agentic toolkit: knowledge-graph paths, backlog/output file locations, task prioritization weights and graph signals, implementation conventions (branch/commit prefixes, draft PRs, quality gates), guardrails (allowed/ignored/blocked path globs, blocked commands, max LOC delta), GitHub issue labels/PR templates, and trigger schedule.", + "tags": [ + "configuration", + "guardrails", + "automation", + "workflow" + ], + "complexity": "moderate" + }, + { + "id": "config:.agentic/policies.json", + "type": "config", + "name": "policies.json", + "filePath": ".agentic/policies.json", + "summary": "Hard guardrail policies for agentic implementers (Claude Code Action / local agents): must/must_not rules covering quality gates, auth and routing constraints, security practices, scope rules, and a code review checklist.", + "tags": [ + "configuration", + "guardrails", + "security", + "development" + ], + "complexity": "simple" + }, + { + "id": "document:.agentic/prompts/analyze.md", + "type": "document", + "name": "analyze.md", + "filePath": ".agentic/prompts/analyze.md", + "summary": "Prompt template for the ANALYZE stage of the agentic workflow: instructs the agent to summarize architecture layers from the knowledge graph, identify hotspots (complexity, fan-in/fan-out, untested code), cross-check backlog tasks against code reality, and emit a structured analysis report without modifying product code.", + "tags": [ + "documentation", + "prompt-template", + "code-analysis", + "agentic-workflow" + ], + "complexity": "simple" + }, + { + "id": "document:.agentic/prompts/plan.md", + "type": "document", + "name": "plan.md", + "filePath": ".agentic/prompts/plan.md", + "summary": "Prompt template for the PLAN stage of the agentic workflow: ranks ready backlog tasks using priority, dependencies, and graph hotspots, selects exactly one next task per run, expands acceptance criteria into an implementation checklist, and writes plan.json/plan.md/selected-task.json outputs.", + "tags": [ + "documentation", + "prompt-template", + "task-planning", + "agentic-workflow" + ], + "complexity": "simple" + }, + { + "id": "document:.agentic/prompts/implement.md", + "type": "document", + "name": "implement.md", + "filePath": ".agentic/prompts/implement.md", + "summary": "Prompt template for the IMPLEMENT stage of the agentic workflow: instructs the agent to execute exactly one backlog refactoring task via an explore-design-implement-test-commit protocol, enforcing quality gates (ruff, pyright, pytest), path-glob scope limits, single-task commits, draft PRs, and a defined done criterion.", + "tags": [ + "documentation", + "prompt-template", + "implementation", + "agentic-workflow" + ], + "complexity": "simple" + }, + { + "id": "document:.agentic/prompts/pr-body.md", + "type": "document", + "name": "pr-body.md", + "filePath": ".agentic/prompts/pr-body.md", + "summary": "Markdown template for agentic pull request bodies: renders task metadata, problem, solution, acceptance-criteria checklist, validation gates, risk notes, and agent notes via {{PLACEHOLDER}} substitution when assembled by render_prompt.py.", + "tags": [ + "documentation", + "prompt-template", + "pull-request", + "agentic-workflow" + ], + "complexity": "simple" + }, + { + "id": "document:.agentic/prompts/review.md", + "type": "document", + "name": "review.md", + "filePath": ".agentic/prompts/review.md", + "summary": "Prompt template for the REVIEW stage of the agentic workflow: instructs a senior Python engineer persona to review agentic PRs for correctness against claimed task and acceptance criteria, architecture constraints, concurrency and error-handling regressions, test adequacy, and scope creep, returning a structured verdict (approve/request_changes/comment).", + "tags": [ + "documentation", + "prompt-template", + "code-review", + "agentic-workflow" + ], + "complexity": "simple" + }, + { + "id": "document:.claude/rules/architecture.md", + "type": "document", + "name": "architecture.md", + "filePath": ".claude/rules/architecture.md", + "summary": "Project rule set defining the system architecture of ccbot: topic-based routing (1 topic = 1 window = 1 session), the SessionMonitor/TmuxManager/TranscriptParser pipeline, handler module layout, state files, and key design decisions (window-ID-centric state, hook-based session tracking, MarkdownV2 with fallback, startup re-resolution).", + "tags": [ + "documentation", + "architecture", + "conventions", + "telegram-bot", + "tmux" + ], + "complexity": "moderate" + }, + { + "id": "document:.claude/rules/message-handling.md", + "type": "document", + "name": "message-handling.md", + "filePath": ".claude/rules/message-handling.md", + "summary": "Project rule set for message handling conventions: per-user FIFO message queues with a merging worker, status message edit-in-place conversion and dedup, AIORateLimiter flood-control settings, mtime-cache and byte-offset incremental reads, and the no-truncation-at-parse-layer policy (splitting only at the send layer).", + "tags": [ + "documentation", + "architecture", + "message-queue", + "rate-limiting", + "conventions" + ], + "complexity": "simple" + }, + { + "id": "document:.claude/rules/topic-architecture.md", + "type": "document", + "name": "topic-architecture.md", + "filePath": ".claude/rules/topic-architecture.md", + "summary": "Project rule set mandating a topic-only architecture: every Telegram topic maps to exactly one tmux window and one Claude session via thread_bindings (state.json) and session_map.json (hook-written), covering outbound/inbound message flows, directory-browser session creation and resume flows, and session window lifecycle cleanup.", + "tags": [ + "documentation", + "architecture", + "topics", + "session-management", + "tmux" + ], + "complexity": "moderate" + }, + { + "id": "config:.env.example", + "type": "config", + "name": ".env.example", + "filePath": ".env.example", + "summary": "Example environment template declaring the bot's runtime variables: required TELEGRAM_BOT_TOKEN and ALLOWED_USERS, plus optional TMUX_SESSION_NAME, CLAUDE_COMMAND, and MONITOR_POLL_INTERVAL with defaults.", + "tags": [ + "configuration", + "environment", + "runtime", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "document:AGENTS.md", + "type": "document", + "name": "AGENTS.md", + "filePath": "AGENTS.md", + "summary": "Agent instructions for the Codex toolchain: common dev commands (ruff, pyright, pytest, restart.sh), core design constraints (1 topic = 1 window = 1 session keyed by tmux window ID, topic-only, MarkdownV2, hook-based session tracking), code conventions, config layout, and architecture references.", + "tags": [ + "documentation", + "conventions", + "agent-instructions", + "development" + ], + "complexity": "simple" + }, + { + "id": "document:CLAUDE.md", + "type": "document", + "name": "CLAUDE.md", + "filePath": "CLAUDE.md", + "summary": "Agent instructions for Claude Code mirroring AGENTS.md: dev commands, core design constraints (tmux window routing, topic-only mode, MarkdownV2 helpers, message queue, rate limiting), code conventions, configuration directory layout, and SessionStart hook setup.", + "tags": [ + "documentation", + "conventions", + "agent-instructions", + "development" + ], + "complexity": "simple" + }, + { + "id": "document:HANDOFF_REPAIR_ROBOT.md", + "type": "document", + "name": "HANDOFF_REPAIR_ROBOT.md", + "filePath": "HANDOFF_REPAIR_ROBOT.md", + "summary": "Handoff document for a repair robot, tracking completed phases (canonical auth, queue encapsulation, callback_router table-driven dispatch, SessionManager store split) with evidence greps, PR lists, lessons learned, and the next step.", + "tags": [ + "documentation", + "handoff", + "development", + "refactoring" + ], + "complexity": "moderate" + }, + { + "id": "document:README.md", + "type": "document", + "name": "README.md", + "filePath": "README.md", + "summary": "Main project documentation for CCBot, a Telegram bot that bridges Forum topics to Claude Code sessions via tmux windows; covers why/features, installation, environment configuration, hook setup, commands, topic workflow, message history, notifications, data storage, and the src/ccbot file structure.", + "tags": [ + "documentation", + "entry-point", + "overview", + "telegram" + ], + "complexity": "complex" + }, + { + "id": "document:README_CN.md", + "type": "document", + "name": "README_CN.md", + "filePath": "README_CN.md", + "summary": "Chinese translation of the main README covering CCBot's features, installation, configuration, hook setup, usage commands, topic workflow, message history, notifications, architecture overview, and data storage.", + "tags": [ + "documentation", + "translation", + "chinese" + ], + "complexity": "complex" + }, + { + "id": "document:README_RU.md", + "type": "document", + "name": "README_RU.md", + "filePath": "README_RU.md", + "summary": "Russian translation of the main README covering CCBot's features, installation, configuration, hook setup, usage commands, topic workflow, message history, notifications, architecture overview, and data storage.", + "tags": [ + "documentation", + "translation", + "russian" + ], + "complexity": "complex" + }, + { + "id": "config:pyproject.toml", + "type": "config", + "name": "pyproject.toml", + "filePath": "pyproject.toml", + "summary": "PEP 621 build configuration for the ccbot package (hatchling, src layout, Python 3.12) defining the `ccbot` CLI script entry point and dev tooling: ruff rule selection, black target, pyright basic type checking against the project venv, pytest src-layout/asyncio options, and coverage reporting.", + "tags": [ + "configuration", + "build-system", + "python", + "dev-tooling" + ], + "complexity": "moderate" + }, + { + "id": "config:.understand-anything/config.json", + "type": "config", + "name": "config.json", + "filePath": ".understand-anything/config.json", + "summary": "Understand-anything tool configuration declaring the output language (en) that governs the language of all summaries, tags and notes produced for the project's knowledge graph.", + "tags": [ + "configuration", + "tooling", + "data", + "metadata" + ], + "complexity": "simple" + }, + { + "id": "config:.understand-anything/fingerprints.json", + "type": "config", + "name": "fingerprints.json", + "filePath": ".understand-anything/fingerprints.json", + "summary": "Machine-generated freshness fingerprints (per-file content hashes, language, functions and classes) from the previous analysis run, used by the understand-anything pipelines to detect changed files for incremental updates.", + "tags": [ + "metadata", + "data", + "tooling", + "incremental-analysis" + ], + "complexity": "complex", + "languageNotes": "Large machine-generated artifact (~10.5k lines); one entry per project file keyed by relative path." + }, + { + "id": "config:.understand-anything/knowledge-graph.json", + "type": "config", + "name": "knowledge-graph.json", + "filePath": ".understand-anything/knowledge-graph.json", + "summary": "Output of the previous understand-anything analysis run: the project's knowledge graph (version, nodes, edges, layers, tour) with project metadata such as languages, frameworks and description. Self-referential tool artifact treated as pass-through data.", + "tags": [ + "data", + "metadata", + "tooling", + "knowledge-graph" + ], + "complexity": "simple" + }, + { + "id": "config:.understand-anything/meta.json", + "type": "config", + "name": "meta.json", + "filePath": ".understand-anything/meta.json", + "summary": "Analysis metadata from the previous understand-anything run: last analysis timestamp, git commit hash (2cd062e0...), tool version 1.0.0, and the count of analyzed files (132).", + "tags": [ + "metadata", + "data", + "tooling", + "audit" + ], + "complexity": "simple" + }, + { + "id": "document:src/ccbot/fonts/LICENSE-JetBrainsMono.txt", + "type": "document", + "name": "LICENSE-JetBrainsMono.txt", + "filePath": "src/ccbot/fonts/LICENSE-JetBrainsMono.txt", + "summary": "SIL Open Font License Version 1.1 (Copyright 2020 The JetBrains Mono Project Authors) covering the bundled JetBrains Mono font, permitting free use, modification and redistribution.", + "tags": [ + "documentation", + "license", + "font", + "open-source" + ], + "complexity": "moderate" + }, + { + "id": "document:src/ccbot/fonts/LICENSE-NotoSansMono.txt", + "type": "document", + "name": "LICENSE-NotoSansMono.txt", + "filePath": "src/ccbot/fonts/LICENSE-NotoSansMono.txt", + "summary": "SIL Open Font License Version 1.1 covering the bundled Noto Sans Mono (CJK SC) font, permitting free use, modification and redistribution.", + "tags": [ + "documentation", + "license", + "font", + "open-source" + ], + "complexity": "moderate" + }, + { + "id": "document:src/ccbot/fonts/LICENSE-Symbola.txt", + "type": "document", + "name": "LICENSE-Symbola.txt", + "filePath": "src/ccbot/fonts/LICENSE-Symbola.txt", + "summary": "Free-use license notice for the Symbola font by George Douros (2007-2015), stating the font may be opened, edited, modified, regenerated, packaged and redistributed for any use.", + "tags": [ + "documentation", + "license", + "font", + "freeware" + ], + "complexity": "simple" + }, + { + "id": "document:.agentic/backlog/source-report.md", + "type": "document", + "name": "source-report.md", + "filePath": ".agentic/backlog/source-report.md", + "summary": "Refactoring backlog report (in Russian) generated from the knowledge graph listing 10 HIGH/MEDIUM issues: DRY violations in auth/window resolution, bot.py god module, broad exception swallowing, session migration tech debt, global mutable state, and fragile callback_data parsing, each with file:line references and proposed fixes.", + "tags": [ + "documentation", + "backlog", + "refactoring", + "code-quality" + ], + "complexity": "moderate" + }, + { + "id": "config:.agentic/backlog/tasks.json", + "type": "config", + "name": "tasks.json", + "filePath": ".agentic/backlog/tasks.json", + "summary": "Machine-readable refactoring backlog consumed by the agentic pipeline: structured tasks (REF-001...) with priority, status, categories, files, related knowledge-graph nodes, dependency links, acceptance criteria, status history and output phases, plus updated_at tracking.", + "tags": [ + "configuration", + "backlog", + "task-tracking", + "data" + ], + "complexity": "complex" + }, + { + "id": "document:.agentic/mcp/README.md", + "type": "document", + "name": "README.md", + "filePath": ".agentic/mcp/README.md", + "summary": "Documents the stdio MCP server `ccbot-agentic` exposing the agentic pipeline (pipeline_status, list_tasks, select_next_task, get_context_pack, run_analyze, run_validate...) to Grok Build, with Grok enablement steps, manual smoke-test instructions, and implementation file roles.", + "tags": [ + "documentation", + "mcp", + "agentic", + "integration" + ], + "complexity": "simple" + }, + { + "id": "config:.grok/config.toml", + "type": "config", + "name": "config.toml", + "filePath": ".grok/config.toml", + "summary": "Project-scoped Grok configuration registering the `ccbot-agentic` MCP server (python scripts/agentic/mcp_server.py) with a pinned CCBOT_REPO_ROOT env var, per-tool timeouts, and a raised 80KB max_output_bytes cap for analysis/context-pack tools.", + "tags": [ + "configuration", + "mcp", + "grok", + "agentic" + ], + "complexity": "simple" + }, + { + "id": "config:.understand-anything/.understandignore", + "type": "config", + "name": ".understandignore", + "filePath": ".understand-anything/.understandignore", + "summary": "Gitignore-style exclusion rules for the understand-anything analysis tool: activates ignores for .pytest_cache, .env, venv.bak, .uv, .venv and .agentic/out, with commented template blocks for Python artifacts and test-file patterns.", + "tags": [ + "configuration", + "ignore-rules", + "analysis", + "tooling" + ], + "complexity": "simple" + }, + { + "id": "document:doc/telegram-bot-features.md", + "type": "document", + "name": "telegram-bot-features.md", + "filePath": "doc/telegram-bot-features.md", + "summary": "Research document surveying Telegram Bot API capabilities (rich text, interactive components, media, message streaming, forum topics, payments) mapped to ccbot's implementation status, plus a Claude Code slash-command forwarding reference with recommendations and Bot API version notes.", + "tags": [ + "documentation", + "telegram-bot", + "research", + "feature-reference" + ], + "complexity": "moderate" + }, + { + "id": "document:doc/WEBSOCKET_PROTOCOL_REVERSED.md", + "type": "document", + "name": "WEBSOCKET_PROTOCOL_REVERSED.md", + "filePath": "doc/WEBSOCKET_PROTOCOL_REVERSED.md", + "summary": "Deep reverse-engineered reference (81 sections, 1100+ non-empty lines) of the Claude Code WebSocket SDK protocol: launch flags, transport architecture and class hierarchy, complete NDJSON message-type reference, 13 control subtypes, permission/tool-approval flow, session management, reconnection, and a Bun implementation guide.", + "tags": [ + "documentation", + "protocol", + "websocket", + "reverse-engineering", + "claude-code" + ], + "complexity": "complex" + }, + { + "id": "document:scripts/agentic/README.md", + "type": "document", + "name": "README.md", + "filePath": "scripts/agentic/README.md", + "summary": "Documents the stdlib-only scripts/agentic toolkit: module roles table (cli, analyze_graph, prioritize, render_prompt, validate_changes, sync_issues...), CLI usage examples, and the structural-task evidence schema (deleted_paths, call_sites, grep, tests) enforced by validate and update_backlog_status.", + "tags": [ + "documentation", + "agentic", + "pipeline", + "evidence" + ], + "complexity": "simple" + }, + { + "id": "file:scripts/agentic/__init__.py", + "type": "file", + "name": "__init__.py", + "filePath": "scripts/agentic/__init__.py", + "summary": "Package version marker for the agentic pipeline toolkit (analyze -> plan -> implement -> validate), exporting __version__ 1.0.0.", + "tags": [ + "entry-point", + "package", + "agentic", + "version" + ], + "complexity": "simple" + }, + { + "id": "file:scripts/agentic/mcp_smoke_test.py", + "type": "file", + "name": "mcp_smoke_test.py", + "filePath": "scripts/agentic/mcp_smoke_test.py", + "summary": "Manual smoke test for the ccbot-agentic MCP server: spawns the stdio server as a subprocess, frames JSON-RPC requests with Content-Length headers, and exercises pipeline_status, list_tasks, get_task, select_next_task and pack/task calls before killing the process and reporting stderr.", + "tags": [ + "test", + "mcp", + "smoke-test", + "agentic" + ], + "complexity": "moderate" + }, + { + "id": "file:scripts/restart.sh", + "type": "file", + "name": "restart.sh", + "filePath": "scripts/restart.sh", + "summary": "Ops script that restarts the ccbot process inside the tmux `ccbot:__main__` window: detects a running `uv run ccbot` via pgrep, escalates Ctrl-C -> SIGTERM -> SIGKILL with a 10s grace wait, then relaunches the bot and prints captured pane logs.", + "tags": [ + "script", + "ops", + "tmux", + "restart", + "deployment" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/__init__.py", + "type": "file", + "name": "__init__.py", + "filePath": "src/ccbot/__init__.py", + "summary": "ccbot package entry point exporting only the 0.1.0 version string; all functional modules are lazily imported by main.py to keep startup fast.", + "tags": [ + "entry-point", + "package", + "version" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/fonts/NotoSansMonoCJKsc-Regular.otf", + "type": "file", + "name": "NotoSansMonoCJKsc-Regular.otf", + "filePath": "src/ccbot/fonts/NotoSansMonoCJKsc-Regular.otf", + "summary": "Bundled Noto Sans Mono CJK SC font asset loaded by screenshot.py as the rendering font for Claude Code terminal pane captures, providing monospaced glyphs with CJK support.", + "tags": [ + "asset", + "font", + "ui", + "rendering" + ], + "complexity": "simple", + "languageNotes": "Binary font asset; the flat line count reflects raw binary data scanned as text." + }, + { + "id": "function:scripts/agentic/mcp_smoke_test.py:_read_one", + "type": "function", + "name": "_read_one", + "filePath": "scripts/agentic/mcp_smoke_test.py", + "lineRange": [ + 21, + 36 + ], + "summary": "Reads one MCP JSON-RPC response frame from the server's stdout by parsing Content-Length headers and decoding the JSON body.", + "tags": [ + "utility", + "mcp", + "protocol", + "parsing" + ], + "complexity": "simple" + }, + { + "id": "function:scripts/agentic/mcp_smoke_test.py:main", + "type": "function", + "name": "main", + "filePath": "scripts/agentic/mcp_smoke_test.py", + "lineRange": [ + 39, + 152 + ], + "summary": "Launch, probe and teardown of the MCP server: builds the subprocess env with CCBOT_REPO_ROOT, sends initialize/list_tasks/get_task/select_next_task JSON-RPC calls, validates tool responses, and kills the process while printing stderr.", + "tags": [ + "entry-point", + "mcp", + "smoke-test", + "agentic" + ], + "complexity": "moderate" + }, + { + "id": "file:src/ccbot/handlers/__init__.py", + "type": "file", + "name": "__init__.py", + "filePath": "src/ccbot/handlers/__init__.py", + "summary": "Handlers package docstring describing the modular Telegram handler architecture (auth, callback_data, callback_router, command_handlers, message_queue, interactive_ui, response_builder, window_bind, etc.) split by functionality.", + "tags": [ + "entry-point", + "package", + "handlers", + "documentation" + ], + "complexity": "simple" + }, + { + "id": "file:src/ccbot/main.py", + "type": "file", + "name": "main.py", + "filePath": "src/ccbot/main.py", + "summary": "CLI entry point and bot bootstrap: `ccbot hook` delegates to hook.hook_main() for Claude Code hooks; the default mode configures logging, validates .env through config (printing setup hints on failure), ensures the tmux session exists via tmux_manager, and starts the Telegram bot polling loop via bot.create_bot().", + "tags": [ + "entry-point", + "cli", + "bootstrap", + "telegram-bot" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/agentic/conftest.py", + "type": "file", + "name": "conftest.py", + "filePath": "tests/agentic/conftest.py", + "summary": "Pytest sys.path bootstrap (ISS-013) that prepends scripts/agentic to sys.path so the stdlib agentic toolkit modules can be imported directly by the agentic robot tests.", + "tags": [ + "test", + "conftest", + "fixture", + "sys-path" + ], + "complexity": "simple" + }, + { + "id": "file:tests/agentic/test_binary_staging.py", + "type": "file", + "name": "test_binary_staging.py", + "filePath": "tests/agentic/test_binary_staging.py", + "summary": "Tests binary-safe diff staging in propose.py (ISS-016): byte-exact write_temp_diff output, write_bytes parent creation with byte preservation, CRLF + markdown-fence rejection in check_diff_applies, and the empty-diff gate in validate_proposal_diff.", + "tags": [ + "test", + "diff", + "binary", + "agentic" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/agentic/test_evidence_checks.py", + "type": "file", + "name": "test_evidence_checks.py", + "filePath": "tests/agentic/test_evidence_checks.py", + "summary": "Tests structural-task evidence checks from validate_changes.check_evidence and update_backlog_status.done_block_reasons: deleted_paths, call_sites (src-only, tests excluded), grep present/absent/invalid-regex, test-file existence, and done-blocking of structural tasks without passing evidence.", + "tags": [ + "test", + "evidence", + "validation", + "agentic" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/conftest.py", + "type": "file", + "name": "conftest.py", + "filePath": "tests/ccbot/conftest.py", + "summary": "Shared pytest fixtures for ccbot handler tests: JSONL transcript-row and message-block factories (text, tool_use, tool_result, thinking), sample pane render strings (exit plan, ask-user tabs, permission, status line, settings), and a chrome fixture for terminal rendering.", + "tags": [ + "test", + "conftest", + "fixtures", + "factory" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/handlers/test_callback_data.py", + "type": "file", + "name": "test_callback_data.py", + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "summary": "Unit tests for callback_data encoding/parsing helpers: history/dir-select/dir-page/win-bind/session-select/screenshot/ask/key parsers, dataclass round-trips, negative/empty validation, immutability, and safe_callback_data size limits including multibyte payloads.", + "tags": [ + "test", + "callback-data", + "serialization", + "unit" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/handlers/test_command_handlers.py", + "type": "file", + "name": "test_command_handlers.py", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "summary": "Tests slash-command handlers: /start authorization gating and welcome message, /esc session/window requirements and key sending, and /unbind topic binding lifecycle, using mocked bot/context/tmux objects.", + "tags": [ + "test", + "command-handlers", + "telegram-bot", + "mock" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/handlers/test_interactive_ui.py", + "type": "file", + "name": "test_interactive_ui.py", + "filePath": "tests/ccbot/handlers/test_interactive_ui.py", + "summary": "Tests interactive UI handling: settings panes result in a navigation keyboard being sent, no-UI panes return False without sending, and the settings keyboard layout includes all required navigation keys.", + "tags": [ + "test", + "interactive-ui", + "keyboard", + "telegram-bot" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "type": "file", + "name": "test_message_queue_enqueue.py", + "filePath": "tests/ccbot/handlers/test_message_queue_enqueue.py", + "summary": "Tests the message_queue enqueue path: content messages create a queue and worker task, status updates are skipped during flood mode, duplicate text is deduped, clear empties the queue, the worker processes content and marks tasks done, and get_or_create_queue starts exactly one worker.", + "tags": [ + "test", + "message-queue", + "async", + "concurrency" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/handlers/test_message_queue_state.py", + "type": "file", + "name": "test_message_queue_state.py", + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "summary": "Tests message-queue state internals: StatusTracker roundtrip/tool-msg-id/topic-clear, MessageQueueManager flood lifecycle, shutdown and get_queue behavior, task merging limits, and concurrency without/with a lock across 50 status updates.", + "tags": [ + "test", + "message-queue", + "concurrency", + "state" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/handlers/test_response_builder.py", + "type": "file", + "name": "test_response_builder.py", + "filePath": "tests/ccbot/handlers/test_response_builder.py", + "summary": "Unit tests for response_builder.build_response_parts pagination: emoji prefix on user messages, 3000-char user / 500-char thinking truncation, single vs multi-part plain text with page suffixes, atomic expandable quotes, and per-block prefix rules.", + "tags": [ + "test", + "response-builder", + "pagination", + "format" + ], + "complexity": "simple" + }, + { + "id": "function:src/ccbot/main.py:main", + "type": "function", + "name": "main", + "filePath": "src/ccbot/main.py", + "lineRange": [ + 13, + 71 + ], + "summary": "CLI dispatcher: rejects unknown args, runs hook_main() for `ccbot hook`, or bootstraps logging, validates config/.env, ensures the tmux session, and starts the Telegram bot polling loop.", + "tags": [ + "entry-point", + "cli", + "bootstrap", + "telegram-bot" + ], + "complexity": "moderate" + }, + { + "id": "function:tests/agentic/test_binary_staging.py:_git_format_diff", + "type": "function", + "name": "_git_format_diff", + "filePath": "tests/agentic/test_binary_staging.py", + "lineRange": [ + 11, + 22 + ], + "summary": "Builds a canonical git-format diff sample by running git diff and capturing stdout, used as the baseline for byte-exactness assertions.", + "tags": [ + "test", + "helper", + "diff", + "git" + ], + "complexity": "simple" + }, + { + "id": "function:tests/agentic/test_binary_staging.py:_eof_marker_diff", + "type": "function", + "name": "_eof_marker_diff", + "filePath": "tests/agentic/test_binary_staging.py", + "lineRange": [ + 29, + 38 + ], + "summary": "Builds a diff sample with a trailing no-newline EOF marker to exercise byte-level staging edge cases.", + "tags": [ + "test", + "helper", + "diff", + "binary" + ], + "complexity": "simple" + }, + { + "id": "function:tests/agentic/test_evidence_checks.py:test_grep_present_and_absent", + "type": "function", + "name": "test_grep_present_and_absent", + "filePath": "tests/agentic/test_evidence_checks.py", + "lineRange": [ + 52, + 65 + ], + "summary": "Checks both evidence branches: grep patterns marked present must match, patterns marked absent must not, within scoped paths.", + "tags": [ + "test", + "evidence", + "grep", + "validation" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/conftest.py:make_jsonl_entry", + "type": "function", + "name": "make_jsonl_entry", + "filePath": "tests/ccbot/conftest.py", + "lineRange": [ + 15, + 38 + ], + "summary": "Factory building a transcript JSONL row (type, content blocks, timestamp, participant, reply-to) for session-render test fixtures.", + "tags": [ + "test", + "fixture", + "factory", + "jsonl" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/conftest.py:make_tool_use_block", + "type": "function", + "name": "make_tool_use_block", + "filePath": "tests/ccbot/conftest.py", + "lineRange": [ + 52, + 67 + ], + "summary": "Factory producing a tool_use content block (id, name, input) for messages in pane-render fixtures.", + "tags": [ + "test", + "fixture", + "factory", + "tool-use" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/conftest.py:make_tool_result_block", + "type": "function", + "name": "make_tool_result_block", + "filePath": "tests/ccbot/conftest.py", + "lineRange": [ + 71, + 89 + ], + "summary": "Factory producing a tool_result content block (tool_use_id, content, is_error) for messages in pane-render fixtures.", + "tags": [ + "test", + "fixture", + "factory", + "tool-result" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/conftest.py:sample_pane_settings", + "type": "function", + "name": "sample_pane_settings", + "filePath": "tests/ccbot/conftest.py", + "lineRange": [ + 150, + 163 + ], + "summary": "Fixture returning a sample pane capture of the settings model dialog, used by interactive-UI handler tests.", + "tags": [ + "test", + "fixture", + "sample", + "settings" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_callback_data.py:TestHistoryParse", + "type": "class", + "name": "TestHistoryParse", + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "lineRange": [ + 35, + 67 + ], + "summary": "Tests parse_history: current and legacy formats, window IDs containing colons, malformed payloads returning None, and negative page rejection.", + "tags": [ + "test", + "callback-data", + "parsing", + "unit" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_callback_data.py:TestIndexParsers", + "type": "class", + "name": "TestIndexParsers", + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "lineRange": [ + 70, + 79 + ], + "summary": "Tests dir-select and dir-page parsing, including the win/session suffix forms of dir-page payloads.", + "tags": [ + "test", + "callback-data", + "parsing", + "unit" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_callback_data.py:TestWindowAndKeyParsers", + "type": "class", + "name": "TestWindowAndKeyParsers", + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "lineRange": [ + 82, + 98 + ], + "summary": "Tests screenshot-refresh, ask, and key callback-data parsers across valid and malformed payloads.", + "tags": [ + "test", + "callback-data", + "parsing", + "unit" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_callback_data.py:TestRoundTrip", + "type": "class", + "name": "TestRoundTrip", + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "lineRange": [ + 101, + 133 + ], + "summary": "Verifies serialize-then-parse round trips keep callback payloads intact across all callback kinds.", + "tags": [ + "test", + "callback-data", + "round-trip", + "serialization" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_callback_data.py:TestValidation", + "type": "class", + "name": "TestValidation", + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "lineRange": [ + 136, + 143 + ], + "summary": "Tests construction-time validation: negative indexes and empty window IDs raise errors.", + "tags": [ + "test", + "callback-data", + "validation", + "unit" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_callback_data.py:TestSafeSerialization", + "type": "class", + "name": "TestSafeSerialization", + "filePath": "tests/ccbot/handlers/test_callback_data.py", + "lineRange": [ + 153, + 178 + ], + "summary": "Tests safe_callback_data: within-limit payloads pass through, empty payloads and over-limit (including multibyte) payloads raise.", + "tags": [ + "test", + "callback-data", + "serialization", + "limits" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:_update", + "type": "function", + "name": "_update", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 26, + 43 + ], + "summary": "Builds a mocked Update with a message, user, and optional thread id for command handler tests.", + "tags": [ + "test", + "helper", + "mock", + "telegram" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:_session_ctx", + "type": "function", + "name": "_session_ctx", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 53, + 63 + ], + "summary": "Builds a CallbackContext whose session-owned state exposes a TmuxWindow and SessionContext for handler assertions.", + "tags": [ + "test", + "helper", + "mock", + "session" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:test_start_unauthorized_no_welcome", + "type": "function", + "name": "test_start_unauthorized_no_welcome", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 67, + 76 + ], + "summary": "Asserts /start for unauthorized users sends no welcome and no session clear.", + "tags": [ + "test", + "auth", + "start-command", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:test_start_authorized_sends_welcome", + "type": "function", + "name": "test_start_authorized_sends_welcome", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 80, + 91 + ], + "summary": "Asserts /start for authorized users clears interactive state and sends the welcome message.", + "tags": [ + "test", + "auth", + "start-command", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:test_esc_requires_session", + "type": "function", + "name": "test_esc_requires_session", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 95, + 106 + ], + "summary": "Asserts /esc without a bound session does not send keys and replies with a session-required message.", + "tags": [ + "test", + "esc-command", + "session", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:test_esc_sends_escape_key", + "type": "function", + "name": "test_esc_sends_escape_key", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 110, + 122 + ], + "summary": "Asserts /esc with a session sends the Escape key via tmux and confirms with a reply.", + "tags": [ + "test", + "esc-command", + "tmux", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:test_unbind_requires_topic", + "type": "function", + "name": "test_unbind_requires_topic", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 126, + 135 + ], + "summary": "Asserts /unbind outside a topic group replies with a topic-required message and does not unbind.", + "tags": [ + "test", + "unbind-command", + "topic", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:test_unbind_clears_binding", + "type": "function", + "name": "test_unbind_clears_binding", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 139, + 159 + ], + "summary": "Asserts /unbind in a topic unbinds the thread from its session, clears interactive state, and confirms the reply.", + "tags": [ + "test", + "unbind-command", + "topic", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_command_handlers.py:test_esc_missing_window", + "type": "function", + "name": "test_esc_missing_window", + "filePath": "tests/ccbot/handlers/test_command_handlers.py", + "lineRange": [ + 163, + 173 + ], + "summary": "Asserts /esc with a bound session whose tmux window disappeared sends no keys and replies without error.", + "tags": [ + "test", + "esc-command", + "missing-window", + "unit" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_interactive_ui.py:TestHandleInteractiveUI", + "type": "class", + "name": "TestHandleInteractiveUI", + "filePath": "tests/ccbot/handlers/test_interactive_ui.py", + "lineRange": [ + 45, + 93 + ], + "summary": "Tests handle_interactive_ui: settings panes send a navigation keyboard message and no-UI panes return False without sending anything.", + "tags": [ + "test", + "interactive-ui", + "keyboard", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_content_creates_queue_and_task", + "type": "function", + "name": "test_enqueue_content_creates_queue_and_task", + "filePath": "tests/ccbot/handlers/test_message_queue_enqueue.py", + "lineRange": [ + 31, + 50 + ], + "summary": "Asserts enqueue_content_message creates a per-user queue and enqueues a MessageTask for delivery.", + "tags": [ + "test", + "message-queue", + "enqueue", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_status_skipped_during_flood", + "type": "function", + "name": "test_enqueue_status_skipped_during_flood", + "filePath": "tests/ccbot/handlers/test_message_queue_enqueue.py", + "lineRange": [ + 54, + 65 + ], + "summary": "Asserts status updates are skipped rather than queued while flood mode is active.", + "tags": [ + "test", + "message-queue", + "flood", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_status_dedupes_same_text", + "type": "function", + "name": "test_enqueue_status_dedupes_same_text", + "filePath": "tests/ccbot/handlers/test_message_queue_enqueue.py", + "lineRange": [ + 69, + 94 + ], + "summary": "Asserts repeated status updates with identical text are deduped to a single queued entry.", + "tags": [ + "test", + "message-queue", + "dedupe", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_status_clear", + "type": "function", + "name": "test_enqueue_status_clear", + "filePath": "tests/ccbot/handlers/test_message_queue_enqueue.py", + "lineRange": [ + 98, + 112 + ], + "summary": "Asserts a clear status update empties pending status entries already queued.", + "tags": [ + "test", + "message-queue", + "clear", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_worker_processes_content_and_marks_done", + "type": "function", + "name": "test_worker_processes_content_and_marks_done", + "filePath": "tests/ccbot/handlers/test_message_queue_enqueue.py", + "lineRange": [ + 116, + 147 + ], + "summary": "Starts the real worker task against an isolated queue and asserts each content message is processed and marked done.", + "tags": [ + "test", + "message-queue", + "worker", + "async" + ], + "complexity": "moderate" + }, + { + "id": "class:tests/ccbot/handlers/test_message_queue_state.py:TestStatusTracker", + "type": "class", + "name": "TestStatusTracker", + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "lineRange": [ + 20, + 44 + ], + "summary": "Tests status roundtrips, tool message-id tracking with per-topic clearing, and clear_all on the StatusTracker singleton state.", + "tags": [ + "test", + "message-queue", + "state", + "unit" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_message_queue_state.py:TestMessageQueueManager", + "type": "class", + "name": "TestMessageQueueManager", + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "lineRange": [ + 47, + 80 + ], + "summary": "Tests MessageQueueManager flood lifecycle (set/clear, remaining counts), get_queue returning None until created, and shutdown clearing all state.", + "tags": [ + "test", + "message-queue", + "manager", + "unit" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_state.py:test_merge_content_tasks_respects_limit", + "type": "function", + "name": "test_merge_content_tasks_respects_limit", + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "lineRange": [ + 98, + 114 + ], + "summary": "Asserts mergeable queued content tasks are combined but never beyond the configured per-window length limit.", + "tags": [ + "test", + "message-queue", + "merge", + "limits" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_state.py:_concurrent_status_increment", + "type": "function", + "name": "_concurrent_status_increment", + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "lineRange": [ + 125, + 148 + ], + "summary": "Concurrent status-update body used by the lock/no-lock contention tests: simulates N in-flight read-modify-write status bumps.", + "tags": [ + "test", + "helper", + "concurrency", + "status" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_state.py:test_gather_50_status_updates_lose_without_lock", + "type": "function", + "name": "test_gather_50_status_updates_lose_without_lock", + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "lineRange": [ + 152, + 162 + ], + "summary": "Demonstrates that 50 concurrent status updates without a lock lose increments, motivating the locking requirement.", + "tags": [ + "test", + "message-queue", + "concurrency", + "race" + ], + "complexity": "simple" + }, + { + "id": "function:tests/ccbot/handlers/test_message_queue_state.py:test_gather_50_status_updates_safe_with_lock", + "type": "function", + "name": "test_gather_50_status_updates_safe_with_lock", + "filePath": "tests/ccbot/handlers/test_message_queue_state.py", + "lineRange": [ + 166, + 175 + ], + "summary": "Asserts the same 50 concurrent updates with an asyncio.Lock preserve every increment.", + "tags": [ + "test", + "message-queue", + "concurrency", + "lock" + ], + "complexity": "simple" + }, + { + "id": "class:tests/ccbot/handlers/test_response_builder.py:TestBuildResponseParts", + "type": "class", + "name": "TestBuildResponseParts", + "filePath": "tests/ccbot/handlers/test_response_builder.py", + "lineRange": [ + 10, + 60 + ], + "summary": "Tests build_response_parts pagination behavior: emoji/prefix rules per block type, truncation limits (3000 user / 500 thinking), single vs multi-part splitting with page suffixes, and atomic expandable quotes.", + "tags": [ + "test", + "response-builder", + "pagination", + "format" + ], + "complexity": "simple" + }, + { + "id": "file:tests/ccbot/handlers/test_status_polling.py", + "type": "file", + "name": "test_status_polling.py", + "filePath": "tests/ccbot/handlers/test_status_polling.py", + "summary": "Tests the status poller's detection of Claude Code interactive UI settings screens and verifies the Telegram keyboard is sent only when interactive UI is present.", + "tags": [ + "test", + "status-polling", + "interactive-ui-detection", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_capture_registry.py", + "type": "file", + "name": "test_capture_registry.py", + "filePath": "tests/ccbot/test_capture_registry.py", + "summary": "Unit tests for CaptureTaskRegistry, verifying register/discard/cancel semantics for asyncio capture tasks including stale-task and done-task edge cases.", + "tags": [ + "test", + "capture-registry", + "asyncio", + "task-management" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_config.py", + "type": "file", + "name": "test_config.py", + "filePath": "tests/ccbot/test_config.py", + "summary": "Tests the Config singleton: env-var parsing, user allowlist validation, Claude projects path resolution, OpenAI settings, and secret scrubbing from env.", + "tags": [ + "test", + "configuration", + "env-vars", + "validation" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_forward_command.py", + "type": "file", + "name": "test_forward_command.py", + "filePath": "tests/ccbot/test_forward_command.py", + "summary": "Tests the /model, /cost, and /clear forward-command handler that sends Claude Code commands to tmux windows via the session manager.", + "tags": [ + "test", + "command-handling", + "forward-command", + "tmux", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_hook.py", + "type": "file", + "name": "test_hook.py", + "filePath": "tests/ccbot/test_hook.py", + "summary": "Tests Claude Code hook script behavior: UUID regex validation, hook installation detection, and hook_main validation of session-start events.", + "tags": [ + "test", + "hook", + "uuid-validation", + "claude-code-integration" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_markdown_v2.py", + "type": "file", + "name": "test_markdown_v2.py", + "filePath": "tests/ccbot/test_markdown_v2.py", + "summary": "Tests Telegram MarkdownV2 escaping and markdown conversion, including code-block preservation and expandable-quote sentinel handling.", + "tags": [ + "test", + "markdown-v2", + "escaping", + "formatting" + ], + "complexity": "simple" + }, + { + "id": "file:tests/ccbot/test_message_formatter.py", + "type": "file", + "name": "test_message_formatter.py", + "filePath": "tests/ccbot/test_message_formatter.py", + "summary": "Tests the MessageFormatter singleton and send/edit fallback paths (markdown then plain text) including RetryAfter re-raise behavior.", + "tags": [ + "test", + "message-formatting", + "fallback", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_message_sender_errors.py", + "type": "file", + "name": "test_message_sender_errors.py", + "filePath": "tests/ccbot/test_message_sender_errors.py", + "summary": "Tests error handling in the message sender: BadRequest fallback to plain text, RetryAfter re-raising, and retry_after_seconds coercion.", + "tags": [ + "test", + "error-handling", + "message-sender", + "fallback", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_monitor_state.py", + "type": "file", + "name": "test_monitor_state.py", + "filePath": "tests/ccbot/test_monitor_state.py", + "summary": "Tests MonitorState persistence: TrackedSession dict round-trip, JSON load with corrupt-file recovery, atomic save, and dirty-tracking behavior.", + "tags": [ + "test", + "state-management", + "persistence", + "json" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_session_guard.py", + "type": "file", + "name": "test_session_guard.py", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Tests session guard decorators: user authorization, thread-id resolution, require_session and require_bound_window_id behavior across bound/unbound/missing window cases.", + "tags": [ + "test", + "session-guard", + "authorization", + "decorators", + "telegram" + ], + "complexity": "complex" + }, + { + "id": "file:tests/ccbot/test_session.py", + "type": "file", + "name": "test_session.py", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests the SessionManager: thread bindings, group-chat id resolution, window state, display names, legacy format migration, and stale id remapping.", + "tags": [ + "test", + "session-manager", + "state-management", + "migration", + "routing" + ], + "complexity": "complex" + }, + { + "id": "function:tests/ccbot/test_capture_registry.py:test_register_and_discard_does_not_cancel", + "type": "function", + "name": "test_register_and_discard_does_not_cancel", + "filePath": "tests/ccbot/test_capture_registry.py", + "summary": "Verifies discarding a registered task does not cancel it.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 13, + 22 + ] + }, + { + "id": "function:tests/ccbot/test_capture_registry.py:test_cancel_done_task_does_not_raise", + "type": "function", + "name": "test_cancel_done_task_does_not_raise", + "filePath": "tests/ccbot/test_capture_registry.py", + "summary": "Verifies cancelling an already-done task raises no error.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 40, + 50 + ] + }, + { + "id": "function:tests/ccbot/test_capture_registry.py:test_discard_stale_task_does_not_evict_newer", + "type": "function", + "name": "test_discard_stale_task_does_not_evict_newer", + "filePath": "tests/ccbot/test_capture_registry.py", + "summary": "Verifies discarding a stale task by old reference leaves the newer task untouched.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 53, + 73 + ] + }, + { + "id": "function:tests/ccbot/test_capture_registry.py:test_discard_matching_task_removes", + "type": "function", + "name": "test_discard_matching_task_removes", + "filePath": "tests/ccbot/test_capture_registry.py", + "summary": "Verifies discarding a task matching the current reference removes it from the registry.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 76, + 90 + ] + }, + { + "id": "function:tests/ccbot/test_forward_command.py:_make_update", + "type": "function", + "name": "_make_update", + "filePath": "tests/ccbot/test_forward_command.py", + "summary": "Builds a mocked Telegram Update with text, user id, and thread id for handler tests.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 11, + 25 + ] + }, + { + "id": "function:tests/ccbot/test_message_formatter.py:test_send_with_fallback_uses_formatter_path", + "type": "function", + "name": "test_send_with_fallback_uses_formatter_path", + "filePath": "tests/ccbot/test_message_formatter.py", + "summary": "Verifies send_with_fallback formats via the formatter and falls back to plain text on BadRequest.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 33, + 44 + ] + }, + { + "id": "function:tests/ccbot/test_message_formatter.py:test_edit_with_fallback_markdown_then_plain", + "type": "function", + "name": "test_edit_with_fallback_markdown_then_plain", + "filePath": "tests/ccbot/test_message_formatter.py", + "summary": "Verifies edit_with_fallback tries markdown first, then plain text.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 65, + 75 + ] + }, + { + "id": "function:tests/ccbot/test_message_formatter.py:test_safe_send_delegates_to_send_with_fallback", + "type": "function", + "name": "test_safe_send_delegates_to_send_with_fallback", + "filePath": "tests/ccbot/test_message_formatter.py", + "summary": "Verifies safe_send delegates to send_with_fallback using the shared formatter.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 88, + 106 + ] + }, + { + "id": "function:tests/ccbot/test_message_sender_errors.py:test_send_with_fallback_retries_plain_on_bad_request", + "type": "function", + "name": "test_send_with_fallback_retries_plain_on_bad_request", + "filePath": "tests/ccbot/test_message_sender_errors.py", + "summary": "Verifies send_with_fallback retries with plain text when markdown raises BadRequest.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 16, + 26 + ] + }, + { + "id": "function:tests/ccbot/test_message_sender_errors.py:test_edit_with_fallback_markdown_then_plain", + "type": "function", + "name": "test_edit_with_fallback_markdown_then_plain", + "filePath": "tests/ccbot/test_message_sender_errors.py", + "summary": "Verifies edit_with_fallback falls back from markdown to plain text on BadRequest.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 62, + 71 + ] + }, + { + "id": "function:tests/ccbot/test_message_sender_errors.py:test_safe_send_delegates_to_send_with_fallback", + "type": "function", + "name": "test_safe_send_delegates_to_send_with_fallback", + "filePath": "tests/ccbot/test_message_sender_errors.py", + "summary": "Verifies safe_send delegates to send_with_fallback and returns its result.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 85, + 102 + ] + }, + { + "id": "function:tests/ccbot/test_message_sender_errors.py:test_retry_after_seconds_accepts_int_and_timedelta", + "type": "function", + "name": "test_retry_after_seconds_accepts_int_and_timedelta", + "filePath": "tests/ccbot/test_message_sender_errors.py", + "summary": "Verifies retry_after_seconds accepts both int and timedelta inputs.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 105, + 115 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:_update", + "type": "function", + "name": "_update", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Builds a mocked Telegram Update with user, message, and optional callback query.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 32, + 42 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_user_unauthorized_replies", + "type": "function", + "name": "test_require_user_unauthorized_replies", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_user replies with an access-denied message for unauthorized users.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 75, + 85 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_session_authorized_bound_live", + "type": "function", + "name": "test_require_session_authorized_bound_live", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_session passes through for an authorized, bound, live window.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 98, + 125 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_session_unbound", + "type": "function", + "name": "test_require_session_unbound", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_session rejects when the thread has no bound window.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 129, + 141 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_session_missing_window", + "type": "function", + "name": "test_require_session_missing_window", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_session rejects when the bound window can no longer be found in tmux.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 145, + 168 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_bound_window_id_ok", + "type": "function", + "name": "test_require_bound_window_id_ok", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_bound_window_id passes when the thread is bound to a window id.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 172, + 185 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_session_composes_require_bound_window_id", + "type": "function", + "name": "test_require_session_composes_require_bound_window_id", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_session composes the bound-window-id check and returns an enriched context.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 189, + 219 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_session_unbind_if_missing", + "type": "function", + "name": "test_require_session_unbind_if_missing", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_session unbinds the thread binding when the window is missing.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 223, + 252 + ] + }, + { + "id": "function:tests/ccbot/test_session_guard.py:test_require_session_require_named_topic", + "type": "function", + "name": "test_require_session_require_named_topic", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Verifies require_session rejects when the message arrives in the unnamed general topic.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 256, + 268 + ] + }, + { + "id": "class:tests/ccbot/handlers/test_status_polling.py:TestStatusPollerSettingsDetection", + "type": "class", + "name": "TestStatusPollerSettingsDetection", + "filePath": "tests/ccbot/handlers/test_status_polling.py", + "summary": "Tests settings-screen detection in the status poller and that the interactive UI keyboard is sent correctly, including an end-to-end flow.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 37, + 141 + ] + }, + { + "id": "class:tests/ccbot/test_config.py:TestConfigValid", + "type": "class", + "name": "TestConfigValid", + "filePath": "tests/ccbot/test_config.py", + "summary": "Tests valid Config construction: defaults, custom tmux session name, poll interval, and user allowlist checks.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 20, + 42 + ] + }, + { + "id": "class:tests/ccbot/test_config.py:TestConfigMissingEnv", + "type": "class", + "name": "TestConfigMissingEnv", + "filePath": "tests/ccbot/test_config.py", + "summary": "Tests Config startup failures when required env vars are missing or malformed.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 46, + 60 + ] + }, + { + "id": "class:tests/ccbot/test_config.py:TestConfigClaudeProjectsPath", + "type": "class", + "name": "TestConfigClaudeProjectsPath", + "filePath": "tests/ccbot/test_config.py", + "summary": "Tests Claude projects path resolution: home default, CLAUDE_CONFIG_DIR, and CCBOT_PROJECTS_DIR priority.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 64, + 92 + ] + }, + { + "id": "class:tests/ccbot/test_config.py:TestConfigOpenAI", + "type": "class", + "name": "TestConfigOpenAI", + "filePath": "tests/ccbot/test_config.py", + "summary": "Tests OpenAI settings defaults, api key/base url overrides, and api key scrubbing from the env snapshot.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 96, + 119 + ] + }, + { + "id": "class:tests/ccbot/test_forward_command.py:TestForwardCommand", + "type": "class", + "name": "TestForwardCommand", + "filePath": "tests/ccbot/test_forward_command.py", + "summary": "Tests /model, /cost, and /clear commands forward text to tmux and clear window sessions via the session manager.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 44, + 125 + ] + }, + { + "id": "class:tests/ccbot/test_hook.py:TestUuidRegex", + "type": "class", + "name": "TestUuidRegex", + "filePath": "tests/ccbot/test_hook.py", + "summary": "Tests the UUID regex used by the hook to validate session ids.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 12, + 36 + ] + }, + { + "id": "class:tests/ccbot/test_hook.py:TestIsHookInstalled", + "type": "class", + "name": "TestIsHookInstalled", + "filePath": "tests/ccbot/test_hook.py", + "summary": "Tests hook installation detection across hooks-key presence, command variants, and full paths.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 39, + 83 + ] + }, + { + "id": "class:tests/ccbot/test_hook.py:TestHookMainValidation", + "type": "class", + "name": "TestHookMainValidation", + "filePath": "tests/ccbot/test_hook.py", + "summary": "Tests hook_main validation: missing/invalid session ids, relative cwd rejection, and non-session-start events.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 86, + 146 + ] + }, + { + "id": "class:tests/ccbot/test_markdown_v2.py:TestConvertMarkdown", + "type": "class", + "name": "TestConvertMarkdown", + "filePath": "tests/ccbot/test_markdown_v2.py", + "summary": "Tests markdown-to-MarkdownV2 conversion for plain text, bold, code blocks, and expandable-quote sentinels.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 29, + 58 + ] + }, + { + "id": "class:tests/ccbot/test_monitor_state.py:TestTrackedSession", + "type": "class", + "name": "TestTrackedSession", + "filePath": "tests/ccbot/test_monitor_state.py", + "summary": "Tests TrackedSession dict round-trip serialization and defaults for missing fields.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 10, + 26 + ] + }, + { + "id": "class:tests/ccbot/test_monitor_state.py:TestMonitorStateLoad", + "type": "class", + "name": "TestMonitorStateLoad", + "filePath": "tests/ccbot/test_monitor_state.py", + "summary": "Tests MonitorState.load for missing, valid, and corrupt JSON state files.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 29, + 57 + ] + }, + { + "id": "class:tests/ccbot/test_monitor_state.py:TestMonitorStateOperations", + "type": "class", + "name": "TestMonitorStateOperations", + "filePath": "tests/ccbot/test_monitor_state.py", + "summary": "Tests MonitorState get/update/remove session operations including dirty-flag setting.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 81, + 120 + ] + }, + { + "id": "class:tests/ccbot/test_monitor_state.py:TestSaveIfDirty", + "type": "class", + "name": "TestSaveIfDirty", + "filePath": "tests/ccbot/test_monitor_state.py", + "summary": "Tests save_if_dirty saves only when the state has been dirtied.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 123, + 145 + ] + }, + { + "id": "class:tests/ccbot/test_session_guard.py:TestIsUserAllowed", + "type": "class", + "name": "TestIsUserAllowed", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Tests is_user_allowed delegation to Config for none and plain ids.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 45, + 55 + ] + }, + { + "id": "class:tests/ccbot/test_session_guard.py:TestGetThreadId", + "type": "class", + "name": "TestGetThreadId", + "filePath": "tests/ccbot/test_session_guard.py", + "summary": "Tests thread-id extraction from messages, the general topic case, and callback queries.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 58, + 71 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestThreadBindings", + "type": "class", + "name": "TestThreadBindings", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests thread-to-window binding lifecycle: bind, get, unbind, and iteration.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 19, + 37 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestGroupChatId", + "type": "class", + "name": "TestGroupChatId", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests group-chat id resolution and override per user and thread.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 40, + 94 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestWindowState", + "type": "class", + "name": "TestWindowState", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests window-state get-or-create behavior and window session clearing.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 97, + 112 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestSetWindowSession", + "type": "class", + "name": "TestSetWindowSession", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests set_window_session field updates, partial preservation, and persistence.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 115, + 137 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestResolveWindowForThread", + "type": "class", + "name": "TestResolveWindowForThread", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests resolving a window for unbound and none thread ids.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 140, + 149 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestDisplayNames", + "type": "class", + "name": "TestDisplayNames", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests display-name get/set and automatic display name assignment on thread bind.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 152, + 173 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestIsWindowId", + "type": "class", + "name": "TestIsWindowId", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests window-id key format detection for valid and invalid ids.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 176, + 186 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestMigrateOldFormatMap", + "type": "class", + "name": "TestMigrateOldFormatMap", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests legacy name-keyed session map migration incl. orphan drops, existing-id preference, and other-session ignores.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 189, + 232 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestResolveStaleIds", + "type": "class", + "name": "TestResolveStaleIds", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests stale-id remapping after tmux restarts, unmatched binding drops, and untouched live ids.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 235, + 319 + ] + }, + { + "id": "class:tests/ccbot/test_session.py:TestLoadSessionMapMigration", + "type": "class", + "name": "TestLoadSessionMapMigration", + "filePath": "tests/ccbot/test_session.py", + "summary": "Tests session map load migrating old-format keys into window state.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 322, + 369 + ] + }, + { + "id": "file:tests/ccbot/test_session_migration.py", + "type": "file", + "name": "test_session_migration.py", + "filePath": "tests/ccbot/test_session_migration.py", + "summary": "Tests legacy session-map migration: name-based key migration to window ids, stale entry cleanup, and SessionManager integration of the migration.", + "tags": [ + "test", + "migration", + "session-map", + "backward-compatibility" + ], + "complexity": "complex" + }, + { + "id": "file:tests/ccbot/test_session_monitor.py", + "type": "file", + "name": "test_session_monitor.py", + "filePath": "tests/ccbot/test_session_monitor.py", + "summary": "Tests SessionMonitor JSONL reading with byte-offset recovery for mid-line truncation, poison lines, partial trailing lines, and truncation detection.", + "tags": [ + "test", + "session-monitor", + "jsonl", + "offset-tracking", + "recovery" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_telegram_sender.py", + "type": "file", + "name": "test_telegram_sender.py", + "filePath": "tests/ccbot/test_telegram_sender.py", + "summary": "Tests Telegram message splitting into chunked messages, respecting UTF-16 length budgets, code-block fencing, and surrogate-pair safety.", + "tags": [ + "test", + "message-splitting", + "utf16", + "code-block", + "telegram" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_terminal_parser.py", + "type": "file", + "name": "test_terminal_parser.py", + "filePath": "tests/ccbot/test_terminal_parser.py", + "summary": "Tests terminal pane parsing: status-line extraction, interactive UI content detection, pane-chrome stripping, and bash command output extraction.", + "tags": [ + "test", + "terminal-parsing", + "claude-code", + "pattern-matching" + ], + "complexity": "complex" + }, + { + "id": "file:tests/ccbot/test_tmux_manager.py", + "type": "file", + "name": "test_tmux_manager.py", + "filePath": "tests/ccbot/test_tmux_manager.py", + "summary": "Tests the TmuxManager wrapper: session lookup, window listing/finding, env scrubbing, capture-pane, and create-window validation.", + "tags": [ + "test", + "tmux", + "session-manager", + "mocking" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_transcribe.py", + "type": "file", + "name": "test_transcribe.py", + "filePath": "tests/ccbot/test_transcribe.py", + "summary": "Tests the transcription API client: success path, empty/missing transcription errors, custom base URLs, and client close lifecycle.", + "tags": [ + "test", + "transcription", + "api-client", + "httpx", + "async" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/ccbot/test_transcript_parser.py", + "type": "file", + "name": "test_transcript_parser.py", + "filePath": "tests/ccbot/test_transcript_parser.py", + "summary": "Comprehensive tests for TranscriptParser line/message parsing, tool-use summaries, edit-diff formatting, and JSONL entries with pending-tool handling.", + "tags": [ + "test", + "transcript-parser", + "claude-code", + "jsonl", + "assertions" + ], + "complexity": "complex" + }, + { + "id": "file:tests/ccbot/test_utils.py", + "type": "file", + "name": "test_utils.py", + "filePath": "tests/ccbot/test_utils.py", + "summary": "Tests utility helpers: ccbot_dir resolution, atomic JSON writes with temp-file cleanup, and cwd extraction from JSONL transcript files.", + "tags": [ + "test", + "utility", + "atomic-write", + "file-io" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/conftest.py", + "type": "file", + "name": "conftest.py", + "filePath": "tests/conftest.py", + "summary": "Root pytest fixture setup: inserts src into sys.path, stubs fcntl for Windows, pins test env vars (TELEGRAM_BOT_TOKEN, ALLOWED_USERS, CCBOT_DIR), and directs pytest temp roots into the repo.", + "tags": [ + "test", + "fixtures", + "setup", + "environment", + "windows" + ], + "complexity": "moderate" + }, + { + "id": "file:tests/integration/test_config_integration.py", + "type": "file", + "name": "test_config_integration.py", + "filePath": "tests/integration/test_config_integration.py", + "summary": "Integration tests for Config reading .env files from the config directory and comma-separated allowed-users parsing.", + "tags": [ + "test", + "integration", + "configuration", + "env-files" + ], + "complexity": "simple" + }, + { + "id": "file:tests/integration/test_monitor_state_integration.py", + "type": "file", + "name": "test_monitor_state_integration.py", + "filePath": "tests/integration/test_monitor_state_integration.py", + "summary": "Integration tests for MonitorState save/load round-trips, corrupt-file recovery, dirty tracking, and session removal across real temp directories.", + "tags": [ + "test", + "integration", + "state-management", + "persistence" + ], + "complexity": "moderate" + }, + { + "id": "function:tests/ccbot/test_session_migration.py:test_apply_startup_state_migration", + "type": "function", + "name": "test_apply_startup_state_migration", + "filePath": "tests/ccbot/test_session_migration.py", + "summary": "Parameterized tests applying startup state migration across many window-state scenarios.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 145, + 160 + ] + }, + { + "id": "function:tests/ccbot/test_session_migration.py:test_migrate_old_format_to_window_id", + "type": "function", + "name": "test_migrate_old_format_to_window_id", + "filePath": "tests/ccbot/test_session_migration.py", + "summary": "Verifies legacy name-keyed session map entries migrate to window-id keys.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 163, + 172 + ] + }, + { + "id": "function:tests/ccbot/test_session_migration.py:test_session_manager_resolve_stale_ids_uses_migration", + "type": "function", + "name": "test_session_manager_resolve_stale_ids_uses_migration", + "filePath": "tests/ccbot/test_session_migration.py", + "summary": "Verifies SessionManager.resolve_stale_ids uses the shared migration to remap stale window ids.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 224, + 259 + ] + }, + { + "id": "function:tests/ccbot/test_tmux_manager.py:_fake_window", + "type": "function", + "name": "_fake_window", + "filePath": "tests/ccbot/test_tmux_manager.py", + "summary": "Builds a fake tmux window object with id, name, cwd, and pane command for manager tests.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 17, + 31 + ] + }, + { + "id": "class:tests/ccbot/test_session_monitor.py:TestReadNewLinesOffsetRecovery", + "type": "class", + "name": "TestReadNewLinesOffsetRecovery", + "filePath": "tests/ccbot/test_session_monitor.py", + "summary": "Tests SessionMonitor JSONL reading: mid-line offset recovery, valid offsets, poison lines, partial EOF lines, and truncation detection.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 20, + 157 + ] + }, + { + "id": "class:tests/ccbot/test_telegram_sender.py:TestSplitMessage", + "type": "class", + "name": "TestSplitMessage", + "filePath": "tests/ccbot/test_telegram_sender.py", + "summary": "Tests message splitting on newline boundaries, forced splits, code-block preservation, and chunk-size limits.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 8, + 116 + ] + }, + { + "id": "class:tests/ccbot/test_telegram_sender.py:TestUtf16Len", + "type": "class", + "name": "TestUtf16Len", + "filePath": "tests/ccbot/test_telegram_sender.py", + "summary": "Tests UTF-16 length calculation for ASCII, CJK, and emoji.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 119, + 132 + ] + }, + { + "id": "class:tests/ccbot/test_telegram_sender.py:TestSplitMessageUtf16", + "type": "class", + "name": "TestSplitMessageUtf16", + "filePath": "tests/ccbot/test_telegram_sender.py", + "summary": "Tests split_message respecting UTF-16 budgets without splitting surrogate pairs.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 135, + 161 + ] + }, + { + "id": "class:tests/ccbot/test_terminal_parser.py:TestParseStatusLine", + "type": "class", + "name": "TestParseStatusLine", + "filePath": "tests/ccbot/test_terminal_parser.py", + "summary": "Tests status-line parsing for spinner states, blank-line offsets, idles, and false positives.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 16, + 63 + ] + }, + { + "id": "class:tests/ccbot/test_terminal_parser.py:TestExtractInteractiveContent", + "type": "class", + "name": "TestExtractInteractiveContent", + "filePath": "tests/ccbot/test_terminal_parser.py", + "summary": "Tests interactive content extraction for plan mode, asks, permission prompts, settings screens, and min-gap constraints.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 69, + 174 + ] + }, + { + "id": "class:tests/ccbot/test_terminal_parser.py:TestIsInteractiveUI", + "type": "class", + "name": "TestIsInteractiveUI", + "filePath": "tests/ccbot/test_terminal_parser.py", + "summary": "Tests interactive-UI detection across content types and empty strings.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 180, + 191 + ] + }, + { + "id": "class:tests/ccbot/test_terminal_parser.py:TestStripPaneChrome", + "type": "class", + "name": "TestStripPaneChrome", + "filePath": "tests/ccbot/test_terminal_parser.py", + "summary": "Tests pane-chrome stripping from separators with short-separator and last-10-lines guards.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 197, + 220 + ] + }, + { + "id": "class:tests/ccbot/test_terminal_parser.py:TestExtractBashOutput", + "type": "class", + "name": "TestExtractBashOutput", + "filePath": "tests/ccbot/test_terminal_parser.py", + "summary": "Tests bash command output extraction incl. command-not-found, chrome stripping, and trailing blank lines.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 226, + 265 + ] + }, + { + "id": "class:tests/ccbot/test_tmux_manager.py:TestGetSession", + "type": "class", + "name": "TestGetSession", + "filePath": "tests/ccbot/test_tmux_manager.py", + "summary": "Tests session lookup success and error-tolerant none results.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 34, + 46 + ] + }, + { + "id": "class:tests/ccbot/test_tmux_manager.py:TestListWindows", + "type": "class", + "name": "TestListWindows", + "filePath": "tests/ccbot/test_tmux_manager.py", + "summary": "Tests window listing: empty sessions, main-window skipping, and find by id or name.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 49, + 90 + ] + }, + { + "id": "class:tests/ccbot/test_tmux_manager.py:TestCapturePane", + "type": "class", + "name": "TestCapturePane", + "filePath": "tests/ccbot/test_tmux_manager.py", + "summary": "Tests capture-pane line joining and none result without a session.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 104, + 118 + ] + }, + { + "id": "class:tests/ccbot/test_tmux_manager.py:TestCreateWindowValidation", + "type": "class", + "name": "TestCreateWindowValidation", + "filePath": "tests/ccbot/test_tmux_manager.py", + "summary": "Tests create-window validation rejecting missing directories and non-UUID resume values.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 121, + 141 + ] + }, + { + "id": "class:tests/ccbot/test_transcribe.py:TestTranscribeVoice", + "type": "class", + "name": "TestTranscribeVoice", + "filePath": "tests/ccbot/test_transcribe.py", + "summary": "Tests transcribe_voice success, empty/whitespace/missing-text errors, API errors, and base-url handling.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 35, + 108 + ] + }, + { + "id": "class:tests/ccbot/test_transcribe.py:TestCloseClient", + "type": "class", + "name": "TestCloseClient", + "filePath": "tests/ccbot/test_transcribe.py", + "summary": "Tests closing the httpx client when open and when already none.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 111, + 123 + ] + }, + { + "id": "class:tests/ccbot/test_transcript_parser.py:TestExtractTextOnly", + "type": "class", + "name": "TestExtractTextOnly", + "filePath": "tests/ccbot/test_transcript_parser.py", + "summary": "Tests text-only extraction from assistant messages.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 35, + 57 + ] + }, + { + "id": "class:tests/ccbot/test_transcript_parser.py:TestFormatToolUseSummary", + "type": "class", + "name": "TestFormatToolUseSummary", + "filePath": "tests/ccbot/test_transcript_parser.py", + "summary": "Tests tool-use summary formatting incl. non-dict inputs and 200-char truncation.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 63, + 125 + ] + }, + { + "id": "class:tests/ccbot/test_transcript_parser.py:TestParseMessage", + "type": "class", + "name": "TestParseMessage", + "filePath": "tests/ccbot/test_transcript_parser.py", + "summary": "Tests single-message parsing for user/assistant roles, local commands, and string content.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 155, + 221 + ] + }, + { + "id": "class:tests/ccbot/test_transcript_parser.py:TestFormatEditDiff", + "type": "class", + "name": "TestFormatEditDiff", + "filePath": "tests/ccbot/test_transcript_parser.py", + "summary": "Tests edit-diff formatting from tool results.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 227, + 251 + ] + }, + { + "id": "class:tests/ccbot/test_transcript_parser.py:TestFormatToolResultText", + "type": "class", + "name": "TestFormatToolResultText", + "filePath": "tests/ccbot/test_transcript_parser.py", + "summary": "Tests tool-result text formatting incl. write-tool line counting from input content.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 257, + 332 + ] + }, + { + "id": "class:tests/ccbot/test_transcript_parser.py:TestParseEntries", + "type": "class", + "name": "TestParseEntries", + "filePath": "tests/ccbot/test_transcript_parser.py", + "summary": "Tests full JSONL entry parsing: tool use/result pairing, thinking blocks, edit diffs, pending tool carry-over, and system-tag filtering.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 338, + 529 + ] + }, + { + "id": "class:tests/ccbot/test_utils.py:TestCcbotDir", + "type": "class", + "name": "TestCcbotDir", + "filePath": "tests/ccbot/test_utils.py", + "summary": "Tests ccbot_dir resolution from env var and default.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 11, + 18 + ] + }, + { + "id": "class:tests/ccbot/test_utils.py:TestAtomicWriteJson", + "type": "class", + "name": "TestAtomicWriteJson", + "filePath": "tests/ccbot/test_utils.py", + "summary": "Tests atomic JSON writes: validity, parent dir creation, round-trip, and no leftover temp files.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "simple", + "lineRange": [ + 21, + 44 + ] + }, + { + "id": "class:tests/ccbot/test_utils.py:TestReadCwdFromJsonl", + "type": "class", + "name": "TestReadCwdFromJsonl", + "filePath": "tests/ccbot/test_utils.py", + "summary": "Tests cwd extraction from JSONL transcripts incl. missing file and missing cwd cases.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 47, + 72 + ] + }, + { + "id": "class:tests/integration/test_config_integration.py:TestConfigIntegration", + "type": "class", + "name": "TestConfigIntegration", + "filePath": "tests/integration/test_config_integration.py", + "summary": "Tests Config reading .env from the config dir, auto-creating the dir, and parsing comma-separated allowed users.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 10, + 43 + ] + }, + { + "id": "class:tests/integration/test_monitor_state_integration.py:TestMonitorStateIntegration", + "type": "class", + "name": "TestMonitorStateIntegration", + "filePath": "tests/integration/test_monitor_state_integration.py", + "summary": "Tests MonitorState save/load round trip, corrupt-file recovery, dirty tracking, and session removal.", + "tags": [ + "test", + "unit-test", + "pytest" + ], + "complexity": "moderate", + "lineRange": [ + 10, + 67 + ] + } + ], + "edges": [ + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:HistoryCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:DirSelectCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:DirPageCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:WinBindCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:SessionSelectCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:AskCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:ScreenshotRefreshCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "class:src/ccbot/handlers/callback_data.py:KeyCallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:encode_history_page", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_history", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_dir_select", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_dir_page", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_win_bind", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_session_select", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_screenshot_refresh", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_ask", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_key", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_select", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_up", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_page", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_confirm", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_cancel", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_history.py", + "target": "function:src/ccbot/handlers/callback_history.py:_handle_history", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_interactive.py", + "target": "function:src/ccbot/handlers/callback_interactive.py:_handle_ask", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_select", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_new", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_cancel", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_new", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_cancel", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:encode_history_page", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_history", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_dir_select", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_dir_page", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_win_bind", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_session_select", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_screenshot_refresh", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_ask", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "function:src/ccbot/handlers/callback_data.py:parse_key", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/auth.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "file:src/ccbot/handlers/callback_topic_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "file:src/ccbot/handlers/directory_browser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "file:src/ccbot/handlers/window_bind.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_directory.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_history.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_history.py", + "target": "file:src/ccbot/handlers/history.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_history.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_history.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_interactive.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_interactive.py", + "target": "file:src/ccbot/handlers/interactive_ui.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_interactive.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_interactive.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/handlers/callback_topic_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/handlers/directory_browser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/handlers/window_bind.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_pickers.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_select", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_select", + "target": "function:src/ccbot/handlers/callback_data.py:parse_dir_select", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_select", + "target": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_select", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_up", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_up", + "target": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_up", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_page", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_page", + "target": "function:src/ccbot/handlers/callback_data.py:parse_dir_page", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_page", + "target": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_page", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_confirm", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_confirm", + "target": "function:src/ccbot/handlers/directory_browser.py:build_session_picker", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_confirm", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_confirm", + "target": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_confirm", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_cancel", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_cancel", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_clear_pending_thread", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_directory.py:_handle_dir_cancel", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_history.py:_handle_history", + "target": "function:src/ccbot/handlers/callback_data.py:parse_history", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_history.py:_handle_history", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_history.py:_handle_history", + "target": "function:src/ccbot/handlers/history.py:send_history", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_history.py:_handle_history", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_interactive.py:_handle_ask", + "target": "function:src/ccbot/handlers/callback_data.py:parse_ask", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_interactive.py:_handle_ask", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_interactive.py:_handle_ask", + "target": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_interactive.py:_handle_ask", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_interactive.py:_handle_ask", + "target": "function:src/ccbot/handlers/interactive_ui.py:clear_interactive_msg", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_select", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_select", + "target": "function:src/ccbot/handlers/callback_data.py:parse_session_select", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_select", + "target": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_new", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_new", + "target": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_cancel", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_cancel", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_clear_pending_thread", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_session_cancel", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "function:src/ccbot/handlers/callback_data.py:parse_win_bind", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_clear_pending_thread", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_bind", + "target": "function:src/ccbot/handlers/message_sender.py:safe_send", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_new", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_new", + "target": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_new", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_cancel", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_cancel", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_clear_pending_thread", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_pickers.py:_handle_win_cancel", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "function:src/ccbot/handlers/callback_router.py:_match_route", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "function:src/ccbot/handlers/callback_router.py:callback_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_screenshot.py", + "target": "function:src/ccbot/handlers/callback_screenshot.py:_handle_screenshot_refresh", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_screenshot.py", + "target": "function:src/ccbot/handlers/callback_screenshot.py:_handle_key", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_topic_guard.py", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_topic_guard.py", + "target": "function:src/ccbot/handlers/callback_topic_guard.py:_clear_pending_thread", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:start_command", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:screenshot_command", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:esc_command", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:usage_command", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:topic_edited_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:forward_command_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "function:src/ccbot/handlers/command_handlers.py:unsupported_content_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "function:src/ccbot/handlers/directory_browser.py:clear_browse_state", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "function:src/ccbot/handlers/directory_browser.py:clear_window_picker_state", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "function:src/ccbot/handlers/directory_browser.py:clear_session_picker_state", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "function:src/ccbot/handlers/directory_browser.py:build_window_picker", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "function:src/ccbot/handlers/directory_browser.py:_relative_time", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "function:src/ccbot/handlers/directory_browser.py:build_session_picker", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "function:src/ccbot/handlers/history.py:_build_history_keyboard", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "function:src/ccbot/handlers/history.py:send_history", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/handlers/callback_directory.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/handlers/callback_history.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/handlers/callback_interactive.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/handlers/callback_pickers.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/handlers/callback_screenshot.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/handlers/callback_topic_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_router.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_screenshot.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_screenshot.py", + "target": "file:src/ccbot/handlers/screenshot_controls.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_screenshot.py", + "target": "file:src/ccbot/screenshot.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_screenshot.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/callback_topic_guard.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/handlers/cleanup.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/handlers/directory_browser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/handlers/history.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/handlers/screenshot_controls.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/screenshot.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/directory_browser.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "file:src/ccbot/telegram_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/history.py", + "target": "file:src/ccbot/transcript_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "function:src/ccbot/handlers/callback_router.py:callback_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_router.py:callback_handler", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_router.py:callback_handler", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_router.py:callback_handler", + "target": "function:src/ccbot/handlers/callback_router.py:_match_route", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_screenshot_refresh", + "target": "function:src/ccbot/handlers/callback_data.py:parse_screenshot_refresh", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_screenshot_refresh", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_screenshot_refresh", + "target": "function:src/ccbot/screenshot.py:text_to_image", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_screenshot_refresh", + "target": "function:src/ccbot/handlers/screenshot_controls.py:build_screenshot_keyboard", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_key", + "target": "function:src/ccbot/handlers/callback_data.py:parse_key", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_key", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_key", + "target": "function:src/ccbot/screenshot.py:text_to_image", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_screenshot.py:_handle_key", + "target": "function:src/ccbot/handlers/screenshot_controls.py:build_screenshot_keyboard", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/callback_topic_guard.py:_check_same_topic", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:start_command", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:start_command", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:start_command", + "target": "function:src/ccbot/handlers/directory_browser.py:clear_browse_state", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:screenshot_command", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:screenshot_command", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:screenshot_command", + "target": "function:src/ccbot/screenshot.py:text_to_image", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:screenshot_command", + "target": "function:src/ccbot/handlers/screenshot_controls.py:build_screenshot_keyboard", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "target": "function:src/ccbot/session_guard.py:require_bound_window_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "target": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unbind_command", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:esc_command", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:esc_command", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:esc_command", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:usage_command", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:usage_command", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:usage_command", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_closed_handler", + "target": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_edited_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_edited_handler", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_edited_handler", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:topic_edited_handler", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:forward_command_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:forward_command_handler", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:forward_command_handler", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:forward_command_handler", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:forward_command_handler", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unsupported_content_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/command_handlers.py:unsupported_content_handler", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/directory_browser.py:build_window_picker", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/directory_browser.py:build_session_picker", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/history.py:_build_history_keyboard", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/history.py:send_history", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/history.py:send_history", + "target": "function:src/ccbot/telegram_sender.py:split_message", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/history.py:send_history", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/history.py:send_history", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/history.py:send_history", + "target": "function:src/ccbot/handlers/message_sender.py:safe_send", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:strip_sentinels", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "class:src/ccbot/handlers/message_sender.py:MessageFormatter", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:_run_markdown_fallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:send_with_fallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:edit_with_fallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:send_photo", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "function:src/ccbot/handlers/message_sender.py:safe_send", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/screenshot_controls.py", + "target": "function:src/ccbot/handlers/screenshot_controls.py:build_screenshot_keyboard", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/window_bind.py", + "target": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/screenshot.py", + "target": "function:src/ccbot/screenshot.py:_font_tier", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/screenshot.py", + "target": "function:src/ccbot/screenshot.py:_parse_ansi_line", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/screenshot.py", + "target": "function:src/ccbot/screenshot.py:_apply_ansi_codes", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/screenshot.py", + "target": "function:src/ccbot/screenshot.py:_approximate_256_color", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/screenshot.py", + "target": "function:src/ccbot/screenshot.py:_split_line_segments_plain", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/screenshot.py", + "target": "function:src/ccbot/screenshot.py:text_to_image", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "function:src/ccbot/session_guard.py:require_bound_window_id", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "file:src/ccbot/markdown_v2.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "file:src/ccbot/transcript_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/screenshot_controls.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/window_bind.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/window_bind.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/window_bind.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "class:src/ccbot/handlers/message_sender.py:MessageFormatter", + "target": "function:src/ccbot/markdown_v2.py:convert_markdown", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/screenshot_controls.py:build_screenshot_keyboard", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "target": "function:src/ccbot/handlers/message_sender.py:safe_edit", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/window_bind.py:create_and_bind_window", + "target": "function:src/ccbot/handlers/message_sender.py:safe_send", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/session_guard.py:require_user", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/session_guard.py:require_bound_window_id", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/session_guard.py:require_bound_window_id", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/session_guard.py:require_session", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/session_guard.py:require_session", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/session_guard.py:require_session", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/binding_store.py", + "target": "class:src/ccbot/binding_store.py:BindingStore", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "function:src/ccbot/bot.py:post_init", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "function:src/ccbot/bot.py:post_shutdown", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "function:src/ccbot/bot.py:create_bot", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/config.py", + "target": "class:src/ccbot/config.py:Config", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/notifications.py", + "target": "function:src/ccbot/handlers/notifications.py:handle_new_message", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/hook.py", + "target": "function:src/ccbot/hook.py:_find_ccbot_path", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/hook.py", + "target": "function:src/ccbot/hook.py:_is_hook_installed", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/hook.py", + "target": "function:src/ccbot/hook.py:_install_hook", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/hook.py", + "target": "function:src/ccbot/hook.py:hook_main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/monitor_state.py", + "target": "class:src/ccbot/monitor_state.py:TrackedSession", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/monitor_state.py", + "target": "class:src/ccbot/monitor_state.py:MonitorState", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session.py", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_map_repository.py", + "target": "class:src/ccbot/session_map_repository.py:SessionMapRepository", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "function:src/ccbot/session_migration.py:is_window_id", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "class:src/ccbot/session_migration.py:StartupMigrationResult", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "function:src/ccbot/session_migration.py:build_old_names_snapshot", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "function:src/ccbot/session_migration.py:apply_startup_state_migration", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "function:src/ccbot/session_migration.py:migrate_session_map_old_format", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "function:src/ccbot/session_migration.py:cleanup_stale_session_map_entries", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "function:src/ccbot/session_migration.py:state_needs_legacy_migration", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "class:src/ccbot/session_monitor.py:SessionInfo", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "class:src/ccbot/session_monitor.py:NewMessage", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "class:src/ccbot/session_monitor.py:SessionMonitor", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/transcribe.py", + "target": "function:src/ccbot/transcribe.py:_get_client", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/transcribe.py", + "target": "function:src/ccbot/transcribe.py:transcribe_voice", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/transcribe.py", + "target": "function:src/ccbot/transcribe.py:close_client", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/transcript_reader.py", + "target": "class:src/ccbot/transcript_reader.py:ClaudeSession", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/transcript_reader.py", + "target": "class:src/ccbot/transcript_reader.py:TranscriptReader", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/utils.py", + "target": "function:src/ccbot/utils.py:ccbot_dir", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/utils.py", + "target": "function:src/ccbot/utils.py:atomic_write_json", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/utils.py", + "target": "function:src/ccbot/utils.py:read_cwd_from_jsonl", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/window_state_store.py", + "target": "class:src/ccbot/window_state_store.py:WindowState", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/window_state_store.py", + "target": "class:src/ccbot/window_state_store.py:WindowStateStore", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/handlers/callback_router.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/handlers/command_handlers.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/handlers/message_handlers.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/handlers/message_queue.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/handlers/notifications.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/handlers/status_polling.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/session_monitor.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/bot.py", + "target": "file:src/ccbot/transcribe.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/config.py", + "target": "file:src/ccbot/utils.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/notifications.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/notifications.py", + "target": "file:src/ccbot/handlers/interactive_ui.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/notifications.py", + "target": "file:src/ccbot/handlers/message_queue.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/notifications.py", + "target": "file:src/ccbot/handlers/response_builder.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/notifications.py", + "target": "file:src/ccbot/session_monitor.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/notifications.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/hook.py", + "target": "file:src/ccbot/utils.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/binding_store.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/session_map_repository.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/session_migration.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/transcript_reader.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/utils.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:src/ccbot/window_state_store.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_map_repository.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_map_repository.py", + "target": "file:src/ccbot/utils.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "file:src/ccbot/monitor_state.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "file:src/ccbot/transcript_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "file:src/ccbot/utils.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/transcribe.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/transcript_reader.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/transcript_reader.py", + "target": "file:src/ccbot/transcript_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "function:src/ccbot/bot.py:post_init", + "target": "class:src/ccbot/session_monitor.py:SessionMonitor", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/bot.py:post_init", + "target": "function:src/ccbot/handlers/notifications.py:handle_new_message", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/bot.py:post_init", + "target": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/bot.py:post_init", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/bot.py:post_shutdown", + "target": "function:src/ccbot/handlers/message_queue.py:shutdown_workers", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/bot.py:post_shutdown", + "target": "class:src/ccbot/session_monitor.py:SessionMonitor", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/bot.py:post_shutdown", + "target": "function:src/ccbot/transcribe.py:close_client", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/config.py:Config", + "target": "function:src/ccbot/utils.py:ccbot_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/notifications.py:handle_new_message", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/notifications.py:handle_new_message", + "target": "function:src/ccbot/handlers/message_queue.py:enqueue_content_message", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/notifications.py:handle_new_message", + "target": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/notifications.py:handle_new_message", + "target": "function:src/ccbot/handlers/response_builder.py:build_response_parts", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/hook.py:hook_main", + "target": "function:src/ccbot/utils.py:atomic_write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/hook.py:hook_main", + "target": "function:src/ccbot/utils.py:ccbot_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/monitor_state.py:MonitorState", + "target": "function:src/ccbot/utils.py:atomic_write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "class:src/ccbot/binding_store.py:BindingStore", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "class:src/ccbot/window_state_store.py:WindowStateStore", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "class:src/ccbot/session_map_repository.py:SessionMapRepository", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "class:src/ccbot/transcript_reader.py:TranscriptReader", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "function:src/ccbot/session_migration.py:apply_startup_state_migration", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "function:src/ccbot/session_migration.py:migrate_session_map_old_format", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "function:src/ccbot/session_migration.py:cleanup_stale_session_map_entries", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "function:src/ccbot/session_migration.py:is_window_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "function:src/ccbot/utils.py:atomic_write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session.py:SessionManager", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session_map_repository.py:SessionMapRepository", + "target": "function:src/ccbot/utils.py:atomic_write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session_monitor.py:SessionMonitor", + "target": "class:src/ccbot/monitor_state.py:MonitorState", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session_monitor.py:SessionMonitor", + "target": "class:src/ccbot/monitor_state.py:TrackedSession", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session_monitor.py:SessionMonitor", + "target": "class:src/ccbot/transcript_parser.py:TranscriptParser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session_monitor.py:SessionMonitor", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session_monitor.py:SessionMonitor", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/session_monitor.py:SessionMonitor", + "target": "function:src/ccbot/utils.py:read_cwd_from_jsonl", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/transcribe.py:transcribe_voice", + "target": "class:src/ccbot/config.py:Config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/transcript_reader.py:TranscriptReader", + "target": "class:src/ccbot/transcript_parser.py:TranscriptParser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/transcript_reader.py:TranscriptReader", + "target": "class:src/ccbot/config.py:Config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:scripts/agentic/analyze_graph.py", + "target": "function:scripts/agentic/analyze_graph.py:analyze_graph", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/analyze_graph.py", + "target": "function:scripts/agentic/analyze_graph.py:_recommendations", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/analyze_graph.py", + "target": "function:scripts/agentic/analyze_graph.py:render_markdown", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/analyze_graph.py", + "target": "function:scripts/agentic/analyze_graph.py:run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/build_context_pack.py", + "target": "function:scripts/agentic/build_context_pack.py:_seed_ids", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/build_context_pack.py", + "target": "function:scripts/agentic/build_context_pack.py:build_context_pack", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/build_context_pack.py", + "target": "function:scripts/agentic/build_context_pack.py:run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "function:scripts/agentic/cli.py:cmd_select", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "function:scripts/agentic/cli.py:cmd_validate", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "function:scripts/agentic/cli.py:cmd_run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "function:scripts/agentic/cli.py:build_parser", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "function:scripts/agentic/cli.py:main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "function:scripts/agentic/mcp_server.py:tool_get_context_pack", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "function:scripts/agentic/mcp_server.py:tool_mark_task_status", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "function:scripts/agentic/mcp_server.py:tool_run_analyze", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "function:scripts/agentic/mcp_server.py:_handle_tools_call", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "function:scripts/agentic/mcp_server.py:_dispatch", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "function:scripts/agentic/mcp_server.py:_read_message", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "function:scripts/agentic/mcp_server.py:main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/analyze_graph.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/build_context_pack.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/analyze_graph.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/build_context_pack.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/prioritize.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/propose.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/render_prompt.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/service.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/sync_issues.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/cli.py", + "target": "file:scripts/agentic/validate_changes.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "file:scripts/agentic/service.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/mcp_server.py", + "target": "file:scripts/agentic/analyze_graph.py", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_select", + "target": "function:scripts/agentic/prioritize.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_select", + "target": "function:scripts/agentic/render_prompt.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_validate", + "target": "function:scripts/agentic/validate_changes.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_validate", + "target": "function:scripts/agentic/propose.py:load_task", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_run", + "target": "function:scripts/agentic/analyze_graph.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_run", + "target": "function:scripts/agentic/prioritize.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_run", + "target": "function:scripts/agentic/render_prompt.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_run", + "target": "function:scripts/agentic/build_context_pack.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:cmd_run", + "target": "function:scripts/agentic/validate_changes.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:build_parser", + "target": "function:scripts/agentic/propose.py:register_propose_commands", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/cli.py:build_parser", + "target": "function:scripts/agentic/propose.py:register_hitl_commands", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/mcp_server.py:tool_get_context_pack", + "target": "function:scripts/agentic/service.py:get_context_pack", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/mcp_server.py:tool_mark_task_status", + "target": "function:scripts/agentic/service.py:mark_task_status", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/mcp_server.py:tool_run_analyze", + "target": "function:scripts/agentic/analyze_graph.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/analyze_graph.py:run", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/analyze_graph.py:run", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/analyze_graph.py:run", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/analyze_graph.py:run", + "target": "function:scripts/agentic/paths.py:out_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/analyze_graph.py:run", + "target": "function:scripts/agentic/paths.py:write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/analyze_graph.py:run", + "target": "function:scripts/agentic/paths.py:write_text", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/build_context_pack.py:run", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/build_context_pack.py:run", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/build_context_pack.py:run", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/build_context_pack.py:run", + "target": "function:scripts/agentic/paths.py:out_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/build_context_pack.py:run", + "target": "function:scripts/agentic/paths.py:write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:scripts/agentic/paths.py", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/paths.py", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/paths.py", + "target": "function:scripts/agentic/paths.py:write_json", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/paths.py", + "target": "function:scripts/agentic/paths.py:write_text", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/paths.py", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/paths.py", + "target": "function:scripts/agentic/paths.py:out_dir", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/prioritize.py", + "target": "function:scripts/agentic/prioritize.py:score_task", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/prioritize.py", + "target": "function:scripts/agentic/prioritize.py:build_plan", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/prioritize.py", + "target": "function:scripts/agentic/prioritize.py:render_plan_md", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/prioritize.py", + "target": "function:scripts/agentic/prioritize.py:run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/render_prompt.py", + "target": "function:scripts/agentic/render_prompt.py:render_pr_body", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/render_prompt.py", + "target": "function:scripts/agentic/render_prompt.py:run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:iter_tasks", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:load_task", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:update_task_status", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:ensure_clean_tree", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:render_default_prompt", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:latest_proposal_dir", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:resolve_proposal_dir", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:capture_working_tree_diff", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:list_changed_paths_from_diff", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:_glob_to_regex", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:_match_glob", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:path_allowed", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:_write_temp_diff", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:check_diff_applies", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:diff_numstat", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:validate_proposal_diff", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:build_files_json", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:build_risk_json", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:build_evidence_plan", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:build_proposal_md", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:create_proposal_artifacts", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:prepare_pipeline_context", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:_assert_base_sha_fresh", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:_assert_feature_branch", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:reject", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:approve", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:request_changes", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:apply", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:cmd_propose", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:cmd_show_proposal", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:cmd_import_proposal", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:cmd_reject", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:cmd_approve", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:cmd_request_changes", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:cmd_apply", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:register_propose_commands", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "function:scripts/agentic/propose.py:register_hitl_commands", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/prioritize.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/render_prompt.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "file:scripts/agentic/paths.py", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "function:scripts/agentic/prioritize.py:run", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/prioritize.py:run", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/prioritize.py:run", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/prioritize.py:run", + "target": "function:scripts/agentic/paths.py:out_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/prioritize.py:run", + "target": "function:scripts/agentic/paths.py:write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/prioritize.py:run", + "target": "function:scripts/agentic/paths.py:write_text", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/render_prompt.py:run", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/render_prompt.py:run", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/render_prompt.py:run", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/render_prompt.py:run", + "target": "function:scripts/agentic/paths.py:out_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/render_prompt.py:run", + "target": "function:scripts/agentic/paths.py:write_text", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:list_tasks", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:get_task", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:select_next_task", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:get_analysis", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:get_context_pack", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:get_implement_prompt", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:mark_task_status", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:validate", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "function:scripts/agentic/service.py:pipeline_status", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/sync_issues.py", + "target": "function:scripts/agentic/sync_issues.py:_gh", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/sync_issues.py", + "target": "function:scripts/agentic/sync_issues.py:_issue_body", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/sync_issues.py", + "target": "function:scripts/agentic/sync_issues.py:list_existing_task_issues", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/sync_issues.py", + "target": "function:scripts/agentic/sync_issues.py:sync", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/sync_issues.py", + "target": "function:scripts/agentic/sync_issues.py:main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/update_backlog_status.py", + "target": "function:scripts/agentic/update_backlog_status.py:done_block_reasons", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/update_backlog_status.py", + "target": "function:scripts/agentic/update_backlog_status.py:main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:_run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:_ensure_dev_env", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:_git_changed_files", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:_match_any", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:check_guardrails", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:check_evidence", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:run", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "function:scripts/agentic/validate_changes.py:json_dumps_summary", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "file:scripts/agentic/analyze_graph.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "file:scripts/agentic/build_context_pack.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "file:scripts/agentic/prioritize.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "file:scripts/agentic/render_prompt.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/service.py", + "target": "file:scripts/agentic/validate_changes.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/sync_issues.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/update_backlog_status.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/update_backlog_status.py", + "target": "file:scripts/agentic/validate_changes.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "file:scripts/agentic/paths.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "function:scripts/agentic/service.py:list_tasks", + "target": "function:scripts/agentic/prioritize.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:select_next_task", + "target": "function:scripts/agentic/analyze_graph.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:select_next_task", + "target": "function:scripts/agentic/prioritize.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:select_next_task", + "target": "function:scripts/agentic/render_prompt.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:select_next_task", + "target": "function:scripts/agentic/build_context_pack.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:get_analysis", + "target": "function:scripts/agentic/analyze_graph.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:get_context_pack", + "target": "function:scripts/agentic/build_context_pack.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:validate", + "target": "function:scripts/agentic/validate_changes.py:run", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:mark_task_status", + "target": "function:scripts/agentic/paths.py:write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:pipeline_status", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/service.py:pipeline_status", + "target": "function:scripts/agentic/paths.py:out_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/update_backlog_status.py:done_block_reasons", + "target": "function:scripts/agentic/validate_changes.py:check_evidence", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/update_backlog_status.py:main", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/update_backlog_status.py:main", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/update_backlog_status.py:main", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/update_backlog_status.py:main", + "target": "function:scripts/agentic/paths.py:write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/sync_issues.py:sync", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/sync_issues.py:sync", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/sync_issues.py:sync", + "target": "function:scripts/agentic/paths.py:load_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/validate_changes.py:run", + "target": "function:scripts/agentic/paths.py:find_repo_root", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/validate_changes.py:run", + "target": "function:scripts/agentic/paths.py:load_config", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/validate_changes.py:run", + "target": "function:scripts/agentic/paths.py:out_dir", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:scripts/agentic/validate_changes.py:run", + "target": "function:scripts/agentic/paths.py:write_json", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/errors.py", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/errors.py", + "target": "function:src/ccbot/errors.py:reraise_retry_after", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/errors.py", + "target": "function:src/ccbot/errors.py:retry_after_seconds", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/errors.py", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/errors.py", + "target": "function:src/ccbot/errors.py:reraise_retry_after", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/errors.py", + "target": "function:src/ccbot/errors.py:retry_after_seconds", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/capture_registry.py", + "target": "class:src/ccbot/handlers/capture_registry.py:CaptureTaskRegistry", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/capture_registry.py", + "target": "class:src/ccbot/handlers/capture_registry.py:CaptureTaskRegistry", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/cleanup.py", + "target": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/cleanup.py", + "target": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:set_interactive_mode", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:_build_interactive_keyboard", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:clear_interactive_msg", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:set_interactive_mode", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:_build_interactive_keyboard", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "function:src/ccbot/handlers/interactive_ui.py:clear_interactive_msg", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/cleanup.py", + "target": "file:src/ccbot/handlers/capture_registry.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/cleanup.py", + "target": "file:src/ccbot/handlers/interactive_ui.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/cleanup.py", + "target": "file:src/ccbot/handlers/message_queue.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "file:src/ccbot/terminal_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/handlers/capture_registry.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/handlers/directory_browser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/handlers/interactive_ui.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/handlers/message_queue.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/markdown_v2.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/session_guard.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/terminal_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/transcribe.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_handlers.py", + "target": "file:src/ccbot/utils.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "target": "class:src/ccbot/handlers/capture_registry.py:CaptureTaskRegistry", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "target": "function:src/ccbot/handlers/interactive_ui.py:clear_interactive_msg", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "target": "function:src/ccbot/handlers/message_queue.py:clear_status_msg_info", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "target": "function:src/ccbot/handlers/message_queue.py:clear_tool_msg_ids_for_topic", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "target": "function:src/ccbot/terminal_parser.py:extract_interactive_content", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "target": "function:src/ccbot/terminal_parser.py:is_interactive_ui", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/interactive_ui.py:_build_interactive_keyboard", + "target": "function:src/ccbot/handlers/callback_data.py:safe_callback_data", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/interactive_ui.py:_build_interactive_keyboard", + "target": "class:src/ccbot/handlers/callback_data.py:AskCallback", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/interactive_ui.py:clear_interactive_msg", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:photo_handler", + "target": "function:src/ccbot/handlers/message_queue.py:clear_status_msg_info", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "target": "function:src/ccbot/transcribe.py:transcribe_voice", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:voice_handler", + "target": "function:src/ccbot/handlers/message_queue.py:clear_status_msg_info", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "target": "class:src/ccbot/handlers/capture_registry.py:CaptureTaskRegistry", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "target": "function:src/ccbot/terminal_parser.py:extract_bash_output", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "target": "function:src/ccbot/handlers/message_sender.py:send_with_fallback", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "target": "function:src/ccbot/markdown_v2.py:convert_markdown", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:capture_bash_output", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/handlers/directory_browser.py:build_window_picker", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/handlers/directory_browser.py:build_directory_browser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/handlers/directory_browser.py:clear_browse_state", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/session_guard.py:require_user", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/session_guard.py:require_session", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/session_guard.py:get_thread_id", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/handlers/message_sender.py:safe_reply", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/handlers/message_queue.py:enqueue_status_update", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_handlers.py:text_handler", + "target": "function:src/ccbot/handlers/message_queue.py:clear_status_msg_info", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/errors.py", + "target": "file:tests/ccbot/test_message_sender_errors.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/capture_registry.py", + "target": "file:tests/ccbot/test_capture_registry.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/interactive_ui.py", + "target": "file:tests/ccbot/handlers/test_interactive_ui.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "class:src/ccbot/handlers/message_queue.py:MessageTask", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "class:src/ccbot/handlers/message_queue.py:StatusTracker", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "class:src/ccbot/handlers/message_queue.py:MessageQueueManager", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:get_message_queue", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:get_or_create_queue", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:clear_status_msg_info", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:clear_tool_msg_ids_for_topic", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:shutdown_workers", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_inspect_queue", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_can_merge_tasks", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_merge_content_tasks", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_process_content_with_retry", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_message_queue_worker", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_send_task_images", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_process_content_task", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_convert_status_to_content", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_process_status_update_task", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_do_send_status_message", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_do_clear_status_message", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_check_and_send_status", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:enqueue_content_message", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:enqueue_status_update", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "class:src/ccbot/handlers/message_queue.py:MessageTask", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "class:src/ccbot/handlers/message_queue.py:StatusTracker", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "class:src/ccbot/handlers/message_queue.py:MessageQueueManager", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:get_message_queue", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:get_or_create_queue", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:clear_status_msg_info", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:clear_tool_msg_ids_for_topic", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:shutdown_workers", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_inspect_queue", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_can_merge_tasks", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_merge_content_tasks", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_process_content_with_retry", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_message_queue_worker", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_send_task_images", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_process_content_task", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_convert_status_to_content", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_process_status_update_task", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_do_send_status_message", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_do_clear_status_message", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:_check_and_send_status", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:enqueue_content_message", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "function:src/ccbot/handlers/message_queue.py:enqueue_status_update", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "class:src/ccbot/terminal_parser.py:InteractiveUIContent", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "class:src/ccbot/terminal_parser.py:UIPattern", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:_try_extract", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:extract_interactive_content", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:is_interactive_ui", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:parse_status_line", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:strip_pane_chrome", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:extract_bash_output", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "class:src/ccbot/terminal_parser.py:UsageInfo", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:parse_usage_output", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "class:src/ccbot/terminal_parser.py:InteractiveUIContent", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "class:src/ccbot/terminal_parser.py:UIPattern", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:_try_extract", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:extract_interactive_content", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:is_interactive_ui", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:parse_status_line", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:strip_pane_chrome", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:extract_bash_output", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "class:src/ccbot/terminal_parser.py:UsageInfo", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "function:src/ccbot/terminal_parser.py:parse_usage_output", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "class:src/ccbot/tmux_manager.py:TmuxWindow", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "class:src/ccbot/tmux_manager.py:TmuxWindow", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "file:src/ccbot/errors.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "file:src/ccbot/terminal_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "file:src/ccbot/handlers/cleanup.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "file:src/ccbot/handlers/interactive_ui.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "file:src/ccbot/handlers/message_queue.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "file:src/ccbot/session.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "file:src/ccbot/terminal_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "file:src/ccbot/tmux_manager.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "file:src/ccbot/config.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "file:src/ccbot/errors.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_process_content_with_retry", + "target": "function:src/ccbot/errors.py:retry_after_seconds", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_message_queue_worker", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_message_queue_worker", + "target": "function:src/ccbot/errors.py:retry_after_seconds", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_process_content_task", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_process_content_task", + "target": "function:src/ccbot/handlers/message_sender.py:edit_with_fallback", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_process_content_task", + "target": "function:src/ccbot/handlers/message_sender.py:send_with_fallback", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_convert_status_to_content", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_convert_status_to_content", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_process_status_update_task", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_process_status_update_task", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_do_send_status_message", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_do_send_status_message", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_do_send_status_message", + "target": "function:src/ccbot/handlers/message_sender.py:send_with_fallback", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_do_clear_status_message", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_do_clear_status_message", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_check_and_send_status", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_check_and_send_status", + "target": "function:src/ccbot/terminal_parser.py:parse_status_line", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/message_queue.py:_send_task_images", + "target": "function:src/ccbot/handlers/message_sender.py:send_photo", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "target": "function:src/ccbot/handlers/message_queue.py:enqueue_status_update", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "target": "function:src/ccbot/handlers/interactive_ui.py:clear_interactive_msg", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "target": "function:src/ccbot/handlers/interactive_ui.py:handle_interactive_ui", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "target": "function:src/ccbot/terminal_parser.py:parse_status_line", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:update_status_message", + "target": "function:src/ccbot/terminal_parser.py:is_interactive_ui", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "target": "class:src/ccbot/session.py:SessionManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "target": "class:src/ccbot/tmux_manager.py:TmuxManager", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "target": "function:src/ccbot/handlers/cleanup.py:clear_topic_state", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/status_polling.py:status_poll_loop", + "target": "function:src/ccbot/handlers/message_queue.py:get_message_queue", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "class:src/ccbot/tmux_manager.py:TmuxManager", + "target": "function:src/ccbot/errors.py:log_exception", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "file:tests/ccbot/handlers/test_message_queue_state.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/message_queue.py", + "target": "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/status_polling.py", + "target": "file:tests/ccbot/handlers/test_status_polling.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/terminal_parser.py", + "target": "file:tests/ccbot/test_terminal_parser.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "file:tests/ccbot/test_tmux_manager.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/response_builder.py", + "target": "file:src/ccbot/markdown_v2.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/response_builder.py", + "target": "file:src/ccbot/telegram_sender.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/response_builder.py", + "target": "file:src/ccbot/transcript_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "file:src/ccbot/transcript_parser.py", + "type": "imports", + "direction": "forward", + "weight": 0.7 + }, + { + "source": "file:src/ccbot/handlers/response_builder.py", + "target": "function:src/ccbot/handlers/response_builder.py:build_response_parts", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "function:src/ccbot/markdown_v2.py:convert_markdown_tables", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "function:src/ccbot/markdown_v2.py:_render_expandable_quote", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "function:src/ccbot/markdown_v2.py:_markdownify", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "function:src/ccbot/markdown_v2.py:convert_markdown", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/telegram_sender.py", + "target": "function:src/ccbot/telegram_sender.py:_force_split", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/telegram_sender.py", + "target": "function:src/ccbot/telegram_sender.py:split_message", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/transcript_parser.py", + "target": "class:src/ccbot/transcript_parser.py:ParsedEntry", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/transcript_parser.py", + "target": "class:src/ccbot/transcript_parser.py:TranscriptParser", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/handlers/response_builder.py", + "target": "function:src/ccbot/handlers/response_builder.py:build_response_parts", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "function:src/ccbot/markdown_v2.py:convert_markdown_tables", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "function:src/ccbot/markdown_v2.py:convert_markdown", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/telegram_sender.py", + "target": "function:src/ccbot/telegram_sender.py:split_message", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/transcript_parser.py", + "target": "class:src/ccbot/transcript_parser.py:ParsedEntry", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/transcript_parser.py", + "target": "class:src/ccbot/transcript_parser.py:TranscriptParser", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/response_builder.py:build_response_parts", + "target": "function:src/ccbot/markdown_v2.py:convert_markdown_tables", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/response_builder.py:build_response_parts", + "target": "function:src/ccbot/telegram_sender.py:split_message", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/handlers/response_builder.py:build_response_parts", + "target": "class:src/ccbot/transcript_parser.py:TranscriptParser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "function:src/ccbot/markdown_v2.py:convert_markdown", + "target": "class:src/ccbot/transcript_parser.py:TranscriptParser", + "type": "calls", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:src/ccbot/handlers/response_builder.py", + "target": "file:tests/ccbot/handlers/test_response_builder.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/markdown_v2.py", + "target": "file:tests/ccbot/test_markdown_v2.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/telegram_sender.py", + "target": "file:tests/ccbot/test_telegram_sender.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/transcript_parser.py", + "target": "file:tests/ccbot/test_transcript_parser.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "pipeline:.github/workflows/agentic-analyze.yml", + "target": "step:.github/workflows/agentic-analyze.yml:analyze", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/agentic-implement.yml", + "target": "step:.github/workflows/agentic-implement.yml:prepare", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/agentic-implement.yml", + "target": "step:.github/workflows/agentic-implement.yml:implement", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/agentic-orchestrator.yml", + "target": "step:.github/workflows/agentic-orchestrator.yml:analyze_and_plan", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/agentic-orchestrator.yml", + "target": "step:.github/workflows/agentic-orchestrator.yml:call_implement", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/agentic-plan.yml", + "target": "step:.github/workflows/agentic-plan.yml:plan", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/check.yml", + "target": "step:.github/workflows/check.yml:check", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/claude-code-review.yml", + "target": "step:.github/workflows/claude-code-review.yml:claude-review", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/claude.yml", + "target": "step:.github/workflows/claude.yml:claude", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "pipeline:.github/workflows/agentic-analyze.yml", + "target": "file:scripts/agentic/cli.py", + "type": "triggers", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-implement.yml", + "target": "file:scripts/agentic/cli.py", + "type": "triggers", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-orchestrator.yml", + "target": "file:scripts/agentic/cli.py", + "type": "triggers", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-plan.yml", + "target": "file:scripts/agentic/cli.py", + "type": "triggers", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/check.yml", + "target": "file:tests/conftest.py", + "type": "triggers", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/check.yml", + "target": "file:src/ccbot/main.py", + "type": "triggers", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-analyze.yml", + "target": "config:.agentic/config.json", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-implement.yml", + "target": "config:pyproject.toml", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-implement.yml", + "target": "config:.agentic/policies.json", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-orchestrator.yml", + "target": "pipeline:.github/workflows/agentic-implement.yml", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/check.yml", + "target": "config:pyproject.toml", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "pipeline:.github/workflows/agentic-analyze.yml", + "target": "pipeline:.github/workflows/agentic-plan.yml", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "pipeline:.github/workflows/agentic-analyze.yml", + "target": "pipeline:.github/workflows/agentic-orchestrator.yml", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "pipeline:.github/workflows/agentic-plan.yml", + "target": "pipeline:.github/workflows/agentic-orchestrator.yml", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "pipeline:.github/workflows/claude.yml", + "target": "pipeline:.github/workflows/claude-code-review.yml", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "pipeline:.github/workflows/check.yml", + "target": "pipeline:.github/workflows/agentic-implement.yml", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/cli.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/service.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/analyze_graph.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/prioritize.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/propose.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/validate_changes.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/build_context_pack.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/config.json", + "target": "file:scripts/agentic/mcp_server.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "file:scripts/agentic/cli.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "file:scripts/agentic/service.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "file:scripts/agentic/mcp_server.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "file:scripts/agentic/build_context_pack.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "file:scripts/agentic/update_backlog_status.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "config:.agentic/config.json", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "config:.agentic/policies.json", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF_REF-006.md", + "target": "file:scripts/agentic/cli.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF_REF-006.md", + "target": "file:scripts/agentic/service.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF_REF-006.md", + "target": "file:scripts/agentic/update_backlog_status.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/cli.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/service.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/analyze_graph.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/prioritize.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/propose.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/validate_changes.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/build_context_pack.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/IMPLEMENTATION_PLAN.md", + "target": "file:scripts/agentic/sync_issues.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/README.md", + "target": "file:scripts/agentic/cli.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/README.md", + "target": "file:scripts/agentic/service.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/README.md", + "target": "file:scripts/agentic/mcp_server.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/README.md", + "target": "file:scripts/agentic/mcp_smoke_test.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/README.md", + "target": "file:scripts/agentic/build_context_pack.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/README.md", + "target": "config:.agentic/config.json", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF.md", + "target": "document:.agentic/IMPLEMENTATION_PLAN.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/HANDOFF_REF-006.md", + "target": "document:.agentic/HANDOFF.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/prompts/implement.md", + "target": "file:scripts/agentic/render_prompt.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/prompts/pr-body.md", + "target": "file:scripts/agentic/render_prompt.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/prompts/analyze.md", + "target": "document:.agentic/prompts/plan.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/prompts/plan.md", + "target": "document:.agentic/prompts/implement.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/prompts/implement.md", + "target": "document:.agentic/prompts/pr-body.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/prompts/implement.md", + "target": "document:.agentic/prompts/review.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/prompts/pr-body.md", + "target": "document:.agentic/prompts/review.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/bot.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/markdown_v2.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/telegram_sender.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/terminal_parser.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/session_monitor.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/tmux_manager.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/transcript_parser.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/hook.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/session.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/monitor_state.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/screenshot.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/transcribe.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/main.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/utils.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/message_queue.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/status_polling.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/response_builder.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/interactive_ui.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/directory_browser.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/cleanup.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "file:src/ccbot/handlers/callback_data.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "file:src/ccbot/bot.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "file:src/ccbot/telegram_sender.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "file:src/ccbot/session_monitor.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "file:src/ccbot/monitor_state.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "file:src/ccbot/handlers/message_queue.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "file:src/ccbot/handlers/message_sender.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "file:src/ccbot/handlers/status_polling.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/session.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/hook.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/session_monitor.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/tmux_manager.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/bot.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/binding_store.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/session_map_repository.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/handlers/directory_browser.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/handlers/cleanup.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/handlers/status_polling.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/topic-architecture.md", + "target": "file:src/ccbot/handlers/window_bind.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "document:.claude/rules/message-handling.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/architecture.md", + "target": "document:.claude/rules/topic-architecture.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.claude/rules/message-handling.md", + "target": "document:.claude/rules/topic-architecture.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README.md", + "target": "file:src/ccbot/main.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README.md", + "target": "file:src/ccbot/bot.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README.md", + "target": "file:src/ccbot/config.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README.md", + "target": "file:src/ccbot/hook.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README.md", + "target": "file:src/ccbot/session.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README.md", + "target": "file:src/ccbot/session_monitor.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README.md", + "target": "file:src/ccbot/tmux_manager.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README_CN.md", + "target": "document:README.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:README_RU.md", + "target": "document:README.md", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:AGENTS.md", + "target": "file:src/ccbot/main.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:AGENTS.md", + "target": "file:src/ccbot/config.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:AGENTS.md", + "target": "file:src/ccbot/tmux_manager.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:AGENTS.md", + "target": "file:scripts/restart.sh", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:CLAUDE.md", + "target": "file:src/ccbot/main.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:CLAUDE.md", + "target": "file:src/ccbot/config.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:CLAUDE.md", + "target": "file:src/ccbot/tmux_manager.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:CLAUDE.md", + "target": "file:scripts/restart.sh", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "config:pyproject.toml", + "target": "file:src/ccbot/main.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:pyproject.toml", + "target": "file:tests/conftest.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:pyproject.toml", + "target": "file:scripts/restart.sh", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.env.example", + "target": "file:src/ccbot/config.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.understand-anything/config.json", + "target": "config:.understand-anything/knowledge-graph.json", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.understand-anything/knowledge-graph.json", + "target": "config:.understand-anything/fingerprints.json", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "config:.understand-anything/knowledge-graph.json", + "target": "config:.understand-anything/meta.json", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:src/ccbot/fonts/LICENSE-NotoSansMono.txt", + "target": "file:src/ccbot/fonts/NotoSansMonoCJKsc-Regular.otf", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:scripts/agentic/mcp_smoke_test.py", + "target": "function:scripts/agentic/mcp_smoke_test.py:_read_one", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_smoke_test.py", + "target": "function:scripts/agentic/mcp_smoke_test.py:main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/mcp_smoke_test.py", + "target": "function:scripts/agentic/mcp_smoke_test.py:main", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "document:.agentic/backlog/source-report.md", + "target": "file:src/ccbot/bot.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/backlog/source-report.md", + "target": "file:src/ccbot/session.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/backlog/source-report.md", + "target": "config:.agentic/backlog/tasks.json", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "config:.agentic/backlog/tasks.json", + "target": "file:scripts/agentic/service.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "config:.agentic/backlog/tasks.json", + "target": "file:scripts/agentic/mcp_server.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "document:.agentic/mcp/README.md", + "target": "file:scripts/agentic/mcp_server.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/mcp/README.md", + "target": "file:scripts/agentic/service.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:.agentic/mcp/README.md", + "target": "config:.grok/config.toml", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "config:.grok/config.toml", + "target": "file:scripts/agentic/mcp_server.py", + "type": "configures", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "document:scripts/agentic/README.md", + "target": "file:scripts/agentic/cli.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:scripts/agentic/README.md", + "target": "file:scripts/agentic/validate_changes.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:scripts/agentic/README.md", + "target": "file:scripts/agentic/update_backlog_status.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:doc/telegram-bot-features.md", + "target": "file:src/ccbot/bot.py", + "type": "documents", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "document:doc/WEBSOCKET_PROTOCOL_REVERSED.md", + "target": "file:src/ccbot/tmux_manager.py", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:scripts/restart.sh", + "target": "file:src/ccbot/main.py", + "type": "depends_on", + "direction": "forward", + "weight": 0.6 + }, + { + "source": "file:src/ccbot/fonts/NotoSansMonoCJKsc-Regular.otf", + "target": "file:src/ccbot/screenshot.py", + "type": "related", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/main.py", + "target": "function:src/ccbot/main.py:main", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/main.py", + "target": "function:src/ccbot/main.py:main", + "type": "exports", + "direction": "forward", + "weight": 0.8 + }, + { + "source": "file:tests/agentic/test_binary_staging.py", + "target": "function:tests/agentic/test_binary_staging.py:_git_format_diff", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/agentic/test_binary_staging.py", + "target": "function:tests/agentic/test_binary_staging.py:_eof_marker_diff", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/agentic/test_evidence_checks.py", + "target": "function:tests/agentic/test_evidence_checks.py:test_grep_present_and_absent", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/conftest.py", + "target": "function:tests/ccbot/conftest.py:make_jsonl_entry", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/conftest.py", + "target": "function:tests/ccbot/conftest.py:make_tool_use_block", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/conftest.py", + "target": "function:tests/ccbot/conftest.py:make_tool_result_block", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/conftest.py", + "target": "function:tests/ccbot/conftest.py:sample_pane_settings", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_callback_data.py", + "target": "class:tests/ccbot/handlers/test_callback_data.py:TestHistoryParse", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_callback_data.py", + "target": "class:tests/ccbot/handlers/test_callback_data.py:TestIndexParsers", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_callback_data.py", + "target": "class:tests/ccbot/handlers/test_callback_data.py:TestWindowAndKeyParsers", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_callback_data.py", + "target": "class:tests/ccbot/handlers/test_callback_data.py:TestRoundTrip", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_callback_data.py", + "target": "class:tests/ccbot/handlers/test_callback_data.py:TestValidation", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_callback_data.py", + "target": "class:tests/ccbot/handlers/test_callback_data.py:TestSafeSerialization", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:_update", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:_session_ctx", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:test_start_unauthorized_no_welcome", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:test_start_authorized_sends_welcome", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:test_esc_requires_session", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:test_esc_sends_escape_key", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:test_unbind_requires_topic", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:test_unbind_clears_binding", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_command_handlers.py", + "target": "function:tests/ccbot/handlers/test_command_handlers.py:test_esc_missing_window", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_interactive_ui.py", + "target": "class:tests/ccbot/handlers/test_interactive_ui.py:TestHandleInteractiveUI", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "target": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_content_creates_queue_and_task", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "target": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_status_skipped_during_flood", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "target": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_status_dedupes_same_text", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "target": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_enqueue_status_clear", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "target": "function:tests/ccbot/handlers/test_message_queue_enqueue.py:test_worker_processes_content_and_marks_done", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_state.py", + "target": "class:tests/ccbot/handlers/test_message_queue_state.py:TestStatusTracker", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_state.py", + "target": "class:tests/ccbot/handlers/test_message_queue_state.py:TestMessageQueueManager", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_state.py", + "target": "function:tests/ccbot/handlers/test_message_queue_state.py:test_merge_content_tasks_respects_limit", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_state.py", + "target": "function:tests/ccbot/handlers/test_message_queue_state.py:_concurrent_status_increment", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_state.py", + "target": "function:tests/ccbot/handlers/test_message_queue_state.py:test_gather_50_status_updates_lose_without_lock", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_message_queue_state.py", + "target": "function:tests/ccbot/handlers/test_message_queue_state.py:test_gather_50_status_updates_safe_with_lock", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_response_builder.py", + "target": "class:tests/ccbot/handlers/test_response_builder.py:TestBuildResponseParts", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:scripts/agentic/propose.py", + "target": "file:tests/agentic/test_binary_staging.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:scripts/agentic/validate_changes.py", + "target": "file:tests/agentic/test_evidence_checks.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:scripts/agentic/update_backlog_status.py", + "target": "file:tests/agentic/test_evidence_checks.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/callback_data.py", + "target": "file:tests/ccbot/handlers/test_callback_data.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:tests/ccbot/handlers/test_command_handlers.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:tests/ccbot/test_capture_registry.py", + "target": "function:tests/ccbot/test_capture_registry.py:test_register_and_discard_does_not_cancel", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_capture_registry.py", + "target": "function:tests/ccbot/test_capture_registry.py:test_cancel_done_task_does_not_raise", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_capture_registry.py", + "target": "function:tests/ccbot/test_capture_registry.py:test_discard_stale_task_does_not_evict_newer", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_capture_registry.py", + "target": "function:tests/ccbot/test_capture_registry.py:test_discard_matching_task_removes", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_forward_command.py", + "target": "function:tests/ccbot/test_forward_command.py:_make_update", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_message_formatter.py", + "target": "function:tests/ccbot/test_message_formatter.py:test_send_with_fallback_uses_formatter_path", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_message_formatter.py", + "target": "function:tests/ccbot/test_message_formatter.py:test_edit_with_fallback_markdown_then_plain", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_message_formatter.py", + "target": "function:tests/ccbot/test_message_formatter.py:test_safe_send_delegates_to_send_with_fallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_message_sender_errors.py", + "target": "function:tests/ccbot/test_message_sender_errors.py:test_send_with_fallback_retries_plain_on_bad_request", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_message_sender_errors.py", + "target": "function:tests/ccbot/test_message_sender_errors.py:test_edit_with_fallback_markdown_then_plain", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_message_sender_errors.py", + "target": "function:tests/ccbot/test_message_sender_errors.py:test_safe_send_delegates_to_send_with_fallback", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_message_sender_errors.py", + "target": "function:tests/ccbot/test_message_sender_errors.py:test_retry_after_seconds_accepts_int_and_timedelta", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:_update", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_user_unauthorized_replies", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_session_authorized_bound_live", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_session_unbound", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_session_missing_window", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_bound_window_id_ok", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_session_composes_require_bound_window_id", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_session_unbind_if_missing", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "function:tests/ccbot/test_session_guard.py:test_require_session_require_named_topic", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/handlers/test_status_polling.py", + "target": "class:tests/ccbot/handlers/test_status_polling.py:TestStatusPollerSettingsDetection", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_config.py", + "target": "class:tests/ccbot/test_config.py:TestConfigValid", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_config.py", + "target": "class:tests/ccbot/test_config.py:TestConfigMissingEnv", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_config.py", + "target": "class:tests/ccbot/test_config.py:TestConfigClaudeProjectsPath", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_config.py", + "target": "class:tests/ccbot/test_config.py:TestConfigOpenAI", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_forward_command.py", + "target": "class:tests/ccbot/test_forward_command.py:TestForwardCommand", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_hook.py", + "target": "class:tests/ccbot/test_hook.py:TestUuidRegex", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_hook.py", + "target": "class:tests/ccbot/test_hook.py:TestIsHookInstalled", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_hook.py", + "target": "class:tests/ccbot/test_hook.py:TestHookMainValidation", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_markdown_v2.py", + "target": "class:tests/ccbot/test_markdown_v2.py:TestConvertMarkdown", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_monitor_state.py", + "target": "class:tests/ccbot/test_monitor_state.py:TestTrackedSession", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_monitor_state.py", + "target": "class:tests/ccbot/test_monitor_state.py:TestMonitorStateLoad", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_monitor_state.py", + "target": "class:tests/ccbot/test_monitor_state.py:TestMonitorStateOperations", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_monitor_state.py", + "target": "class:tests/ccbot/test_monitor_state.py:TestSaveIfDirty", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "class:tests/ccbot/test_session_guard.py:TestIsUserAllowed", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_guard.py", + "target": "class:tests/ccbot/test_session_guard.py:TestGetThreadId", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestThreadBindings", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestGroupChatId", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestWindowState", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestSetWindowSession", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestResolveWindowForThread", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestDisplayNames", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestIsWindowId", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestMigrateOldFormatMap", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestResolveStaleIds", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session.py", + "target": "class:tests/ccbot/test_session.py:TestLoadSessionMapMigration", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/config.py", + "target": "file:tests/ccbot/test_config.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/command_handlers.py", + "target": "file:tests/ccbot/test_forward_command.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "file:tests/ccbot/test_forward_command.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "file:tests/ccbot/test_forward_command.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/hook.py", + "target": "file:tests/ccbot/test_hook.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/transcript_parser.py", + "target": "file:tests/ccbot/test_markdown_v2.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "file:tests/ccbot/test_message_formatter.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/transcript_parser.py", + "target": "file:tests/ccbot/test_message_formatter.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/handlers/message_sender.py", + "target": "file:tests/ccbot/test_message_sender_errors.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/monitor_state.py", + "target": "file:tests/ccbot/test_monitor_state.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/session.py", + "target": "file:tests/ccbot/test_session.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "file:tests/ccbot/test_session.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/session_guard.py", + "target": "file:tests/ccbot/test_session_guard.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/tmux_manager.py", + "target": "file:tests/ccbot/test_session_guard.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:tests/ccbot/test_session_migration.py", + "target": "function:tests/ccbot/test_session_migration.py:test_apply_startup_state_migration", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_migration.py", + "target": "function:tests/ccbot/test_session_migration.py:test_migrate_old_format_to_window_id", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_migration.py", + "target": "function:tests/ccbot/test_session_migration.py:test_session_manager_resolve_stale_ids_uses_migration", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_tmux_manager.py", + "target": "function:tests/ccbot/test_tmux_manager.py:_fake_window", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_session_monitor.py", + "target": "class:tests/ccbot/test_session_monitor.py:TestReadNewLinesOffsetRecovery", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_telegram_sender.py", + "target": "class:tests/ccbot/test_telegram_sender.py:TestSplitMessage", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_telegram_sender.py", + "target": "class:tests/ccbot/test_telegram_sender.py:TestUtf16Len", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_telegram_sender.py", + "target": "class:tests/ccbot/test_telegram_sender.py:TestSplitMessageUtf16", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_terminal_parser.py", + "target": "class:tests/ccbot/test_terminal_parser.py:TestParseStatusLine", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_terminal_parser.py", + "target": "class:tests/ccbot/test_terminal_parser.py:TestExtractInteractiveContent", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_terminal_parser.py", + "target": "class:tests/ccbot/test_terminal_parser.py:TestIsInteractiveUI", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_terminal_parser.py", + "target": "class:tests/ccbot/test_terminal_parser.py:TestStripPaneChrome", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_terminal_parser.py", + "target": "class:tests/ccbot/test_terminal_parser.py:TestExtractBashOutput", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_tmux_manager.py", + "target": "class:tests/ccbot/test_tmux_manager.py:TestGetSession", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_tmux_manager.py", + "target": "class:tests/ccbot/test_tmux_manager.py:TestListWindows", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_tmux_manager.py", + "target": "class:tests/ccbot/test_tmux_manager.py:TestCapturePane", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_tmux_manager.py", + "target": "class:tests/ccbot/test_tmux_manager.py:TestCreateWindowValidation", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcribe.py", + "target": "class:tests/ccbot/test_transcribe.py:TestTranscribeVoice", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcribe.py", + "target": "class:tests/ccbot/test_transcribe.py:TestCloseClient", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcript_parser.py", + "target": "class:tests/ccbot/test_transcript_parser.py:TestExtractTextOnly", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcript_parser.py", + "target": "class:tests/ccbot/test_transcript_parser.py:TestFormatToolUseSummary", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcript_parser.py", + "target": "class:tests/ccbot/test_transcript_parser.py:TestParseMessage", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcript_parser.py", + "target": "class:tests/ccbot/test_transcript_parser.py:TestFormatEditDiff", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcript_parser.py", + "target": "class:tests/ccbot/test_transcript_parser.py:TestFormatToolResultText", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_transcript_parser.py", + "target": "class:tests/ccbot/test_transcript_parser.py:TestParseEntries", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_utils.py", + "target": "class:tests/ccbot/test_utils.py:TestCcbotDir", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_utils.py", + "target": "class:tests/ccbot/test_utils.py:TestAtomicWriteJson", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/ccbot/test_utils.py", + "target": "class:tests/ccbot/test_utils.py:TestReadCwdFromJsonl", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/integration/test_config_integration.py", + "target": "class:tests/integration/test_config_integration.py:TestConfigIntegration", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:tests/integration/test_monitor_state_integration.py", + "target": "class:tests/integration/test_monitor_state_integration.py:TestMonitorStateIntegration", + "type": "contains", + "direction": "forward", + "weight": 1 + }, + { + "source": "file:src/ccbot/session_migration.py", + "target": "file:tests/ccbot/test_session_migration.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/session_monitor.py", + "target": "file:tests/ccbot/test_session_monitor.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/monitor_state.py", + "target": "file:tests/ccbot/test_session_monitor.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/transcribe.py", + "target": "file:tests/ccbot/test_transcribe.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/utils.py", + "target": "file:tests/ccbot/test_utils.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/config.py", + "target": "file:tests/integration/test_config_integration.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "file:src/ccbot/monitor_state.py", + "target": "file:tests/integration/test_monitor_state_integration.py", + "type": "tested_by", + "direction": "forward", + "weight": 0.5 + }, + { + "source": "config:.agentic/policies.json", + "target": "pipeline:.github/workflows/agentic-implement.yml", + "type": "configures", + "direction": "forward", + "weight": 0.6 + } + ], + "layers": [ + { + "id": "layer:core", + "name": "Core Application", + "description": "Central bot infrastructure: entry points (main.py, bot.py), tmux session lifecycle (tmux_manager, session, session_monitor, session_guard, session_migration, session_map_repository, window_state_store, binding_store, monitor_state), and shared services (hook, telegram_sender, markdown_v2, transcript_parser, transcript_reader, transcribe, screenshot, terminal_parser, config, utils, errors) that all handlers orchestrate.", + "nodeIds": [ + "file:src/ccbot/main.py", + "file:src/ccbot/bot.py", + "file:src/ccbot/tmux_manager.py", + "file:src/ccbot/session.py", + "file:src/ccbot/session_monitor.py", + "file:src/ccbot/session_guard.py", + "file:src/ccbot/session_migration.py", + "file:src/ccbot/session_map_repository.py", + "file:src/ccbot/window_state_store.py", + "file:src/ccbot/binding_store.py", + "file:src/ccbot/monitor_state.py", + "file:src/ccbot/hook.py", + "file:src/ccbot/telegram_sender.py", + "file:src/ccbot/markdown_v2.py", + "file:src/ccbot/transcript_parser.py", + "file:src/ccbot/transcript_reader.py", + "file:src/ccbot/transcribe.py", + "file:src/ccbot/screenshot.py", + "file:src/ccbot/terminal_parser.py", + "file:src/ccbot/config.py", + "file:src/ccbot/utils.py", + "file:src/ccbot/errors.py", + "file:src/ccbot/__init__.py" + ] + }, + { + "id": "layer:handlers", + "name": "Telegram Handlers", + "description": "python-telegram-bot update, callback, and command handlers plus the interactive UI, callback-data codec, message queue, status polling, cleanup, and history/directory-browser features that translate Telegram topics and inline keyboards into tmux session operations.", + "nodeIds": [ + "file:src/ccbot/handlers/__init__.py", + "file:src/ccbot/handlers/auth.py", + "file:src/ccbot/handlers/callback_data.py", + "file:src/ccbot/handlers/callback_router.py", + "file:src/ccbot/handlers/callback_topic_guard.py", + "file:src/ccbot/handlers/callback_directory.py", + "file:src/ccbot/handlers/callback_history.py", + "file:src/ccbot/handlers/callback_interactive.py", + "file:src/ccbot/handlers/callback_pickers.py", + "file:src/ccbot/handlers/callback_screenshot.py", + "file:src/ccbot/handlers/capture_registry.py", + "file:src/ccbot/handlers/cleanup.py", + "file:src/ccbot/handlers/command_handlers.py", + "file:src/ccbot/handlers/directory_browser.py", + "file:src/ccbot/handlers/history.py", + "file:src/ccbot/handlers/interactive_ui.py", + "file:src/ccbot/handlers/message_handlers.py", + "file:src/ccbot/handlers/message_queue.py", + "file:src/ccbot/handlers/message_sender.py", + "file:src/ccbot/handlers/notifications.py", + "file:src/ccbot/handlers/response_builder.py", + "file:src/ccbot/handlers/screenshot_controls.py", + "file:src/ccbot/handlers/status_polling.py", + "file:src/ccbot/handlers/window_bind.py" + ] + }, + { + "id": "layer:agentic-tooling", + "name": "Agentic Tooling", + "description": "Self-contained repair-robot toolkit: the scripts/agentic CLI (cli.py), MCP server (mcp_server.py), and issue-analysis/proposal/change-validation services, plus its .agentic workspace configuration (config.json, policies.json, backlog/tasks.json) and package README.", + "nodeIds": [ + "file:scripts/agentic/__init__.py", + "file:scripts/agentic/cli.py", + "file:scripts/agentic/mcp_server.py", + "file:scripts/agentic/mcp_smoke_test.py", + "file:scripts/agentic/service.py", + "file:scripts/agentic/paths.py", + "file:scripts/agentic/analyze_graph.py", + "file:scripts/agentic/build_context_pack.py", + "file:scripts/agentic/prioritize.py", + "file:scripts/agentic/propose.py", + "file:scripts/agentic/render_prompt.py", + "file:scripts/agentic/sync_issues.py", + "file:scripts/agentic/update_backlog_status.py", + "file:scripts/agentic/validate_changes.py", + "document:scripts/agentic/README.md", + "config:.agentic/config.json", + "config:.agentic/policies.json", + "config:.agentic/backlog/tasks.json" + ] + }, + { + "id": "layer:test", + "name": "Tests", + "description": "pytest suite (pytest-asyncio) mirroring src/ccbot and scripts/agentic 1:1 — unit, handler, and integration tests with shared conftest fixtures — used to verify session, tmux, transcript, and handler behavior in CI.", + "nodeIds": [ + "file:tests/conftest.py", + "file:tests/ccbot/conftest.py", + "file:tests/agentic/conftest.py", + "file:tests/ccbot/handlers/test_callback_data.py", + "file:tests/ccbot/handlers/test_command_handlers.py", + "file:tests/ccbot/handlers/test_interactive_ui.py", + "file:tests/ccbot/handlers/test_message_queue_enqueue.py", + "file:tests/ccbot/handlers/test_message_queue_state.py", + "file:tests/ccbot/handlers/test_response_builder.py", + "file:tests/ccbot/handlers/test_status_polling.py", + "file:tests/ccbot/test_capture_registry.py", + "file:tests/ccbot/test_config.py", + "file:tests/ccbot/test_forward_command.py", + "file:tests/ccbot/test_hook.py", + "file:tests/ccbot/test_markdown_v2.py", + "file:tests/ccbot/test_message_formatter.py", + "file:tests/ccbot/test_message_sender_errors.py", + "file:tests/ccbot/test_monitor_state.py", + "file:tests/ccbot/test_session.py", + "file:tests/ccbot/test_session_guard.py", + "file:tests/ccbot/test_session_migration.py", + "file:tests/ccbot/test_session_monitor.py", + "file:tests/ccbot/test_telegram_sender.py", + "file:tests/ccbot/test_terminal_parser.py", + "file:tests/ccbot/test_tmux_manager.py", + "file:tests/ccbot/test_transcribe.py", + "file:tests/ccbot/test_transcript_parser.py", + "file:tests/ccbot/test_utils.py", + "file:tests/agentic/test_binary_staging.py", + "file:tests/agentic/test_evidence_checks.py", + "file:tests/integration/test_config_integration.py", + "file:tests/integration/test_monitor_state_integration.py" + ] + }, + { + "id": "layer:ci-cd", + "name": "CI/CD Pipelines", + "description": "GitHub Actions workflows that run checks on the codebase, drive Claude Code reviews, and orchestrate the agentic analyze/plan/implement pipeline with issue-driven backlogs.", + "nodeIds": [ + "pipeline:.github/workflows/check.yml", + "pipeline:.github/workflows/claude.yml", + "pipeline:.github/workflows/claude-code-review.yml", + "pipeline:.github/workflows/agentic-orchestrator.yml", + "pipeline:.github/workflows/agentic-analyze.yml", + "pipeline:.github/workflows/agentic-plan.yml", + "pipeline:.github/workflows/agentic-implement.yml" + ] + }, + { + "id": "layer:documentation", + "name": "Documentation & Workflow Meta", + "description": "User and developer documentation: root READMEs (EN/CN/RU), AGENTS/CLAUDE agent guidelines, repair-robot handoff notes, feature docs under doc/, .claude coding rules, and .agentic workflow documents (handoffs, implementation plans, prompt templates, MCP notes, source reports).", + "nodeIds": [ + "document:README.md", + "document:README_CN.md", + "document:README_RU.md", + "document:AGENTS.md", + "document:CLAUDE.md", + "document:HANDOFF_REPAIR_ROBOT.md", + "document:doc/telegram-bot-features.md", + "document:doc/WEBSOCKET_PROTOCOL_REVERSED.md", + "document:.claude/rules/architecture.md", + "document:.claude/rules/message-handling.md", + "document:.claude/rules/topic-architecture.md", + "document:.agentic/README.md", + "document:.agentic/HANDOFF.md", + "document:.agentic/HANDOFF_REF-006.md", + "document:.agentic/IMPLEMENTATION_PLAN.md", + "document:.agentic/prompts/analyze.md", + "document:.agentic/prompts/plan.md", + "document:.agentic/prompts/implement.md", + "document:.agentic/prompts/pr-body.md", + "document:.agentic/prompts/review.md", + "document:.agentic/backlog/source-report.md", + "document:.agentic/mcp/README.md" + ] + }, + { + "id": "layer:config", + "name": "Configuration, Assets & Ops", + "description": "Project configuration (pyproject.toml, .env.example, .grok/config.toml), understand-anything analysis metadata (config.json, meta.json, knowledge-graph.json, fingerprints.json, ignore rules), the bundled CJK-capable screen-rendering font with its licenses, and the restart.sh ops script.", + "nodeIds": [ + "config:pyproject.toml", + "config:.env.example", + "config:.grok/config.toml", + "config:.understand-anything/config.json", + "config:.understand-anything/meta.json", + "config:.understand-anything/knowledge-graph.json", + "config:.understand-anything/fingerprints.json", + "config:.understand-anything/.understandignore", + "file:scripts/restart.sh", + "file:src/ccbot/fonts/NotoSansMonoCJKsc-Regular.otf", + "document:src/ccbot/fonts/LICENSE-JetBrainsMono.txt", + "document:src/ccbot/fonts/LICENSE-NotoSansMono.txt", + "document:src/ccbot/fonts/LICENSE-Symbola.txt" + ] + } + ], + "tour": [ + { + "order": 1, + "title": "Project Overview", + "description": "Start with the README to understand what CCBot is: a Telegram bot that bridges each Forum topic to a tmux window running Claude Code, controlling the session by reading terminal output and sending keystrokes rather than using the Claude Code SDK. It outlines the feature set (topic-based sessions, real-time notifications, interactive UIs, voice, screenshots, message history) and the src/ccbot file structure that the rest of this tour walks through.", + "nodeIds": [ + "document:README.md" + ] + }, + { + "order": 2, + "title": "Entry Point & Bootstrap", + "description": "pyproject.toml registers the `ccbot` console script whose target is main(). main.py validates the environment through config, makes sure the tmux session exists via tmux_manager, and then hands off to bot.create_bot(). bot.py is the application factory: it builds the python-telegram-bot Application, wires every command/callback/message handler, sets the bot command menu, and starts the session monitor and status-polling background tasks in post_init, tearing them down in post_shutdown.", + "nodeIds": [ + "file:src/ccbot/main.py", + "file:src/ccbot/bot.py", + "config:pyproject.toml" + ], + "languageLesson": "pyproject.toml [project.scripts] entries map a console command name to a Python callable ('ccbot = ccbot.main:main'); pip installs a thin executable that imports the module and calls main(), making entry points robust across platforms." + }, + { + "order": 3, + "title": "Configuration & Environment", + "description": "config.py exposes a module-level `config` singleton loaded from layered .env files — the second-most imported file in the codebase, depended on by nearly every module. It requires TELEGRAM_BOT_TOKEN and ALLOWED_USERS, defaults TMUX_SESSION_NAME, CLAUDE_COMMAND and MONITOR_POLL_INTERVAL, and scrubs sensitive env vars from spawned child processes. .env.example documents these variables so a first-time deployer knows exactly what to set.", + "nodeIds": [ + "file:src/ccbot/config.py", + "config:.env.example" + ] + }, + { + "order": 4, + "title": "tmux Bridge & Session Core", + "description": "tmux_manager.py is an async-friendly wrapper around libtmux for a single tmux session — listing and finding windows, capturing panes, sending keys, renaming, creating and killing windows. SessionManager (the single most-referenced class in the graph, with a fan-in of 27) orchestrates everything around it: window↔session mappings, thread bindings, per-user read offsets, session_map.json sync, keystroke delivery and message history. Together they implement the README's core insight: control Claude Code by manipulating tmux itself.", + "nodeIds": [ + "file:src/ccbot/tmux_manager.py", + "class:src/ccbot/session.py:SessionManager", + "file:src/ccbot/session.py" + ], + "languageLesson": "A module-level singleton (config and SessionManager both use it) gives every handler access to shared state without dependency injection; blocking libtmux calls are wrapped with asyncio.to_thread/executor so the PTB event loop never blocks." + }, + { + "order": 5, + "title": "Hook-Based Session Tracking", + "description": "Because CCBot operates on tmux rather than the SDK, it learns about new sessions from a Claude Code SessionStart hook: hook.py maintains the window↔session mapping in session_map.json under a file lock and can auto-install itself into ~/.claude/settings.json. session_map_repository.py owns that file's async reads, atomic writes and flock read-modify-write discipline (shared with the hook itself), while session_migration.py converts legacy window_name keys to canonical @window_id keys at startup.", + "nodeIds": [ + "file:src/ccbot/hook.py", + "file:src/ccbot/session_map_repository.py", + "file:src/ccbot/session_migration.py" + ], + "languageLesson": "flock file locking makes concurrent readers/writers (the hook runs inside tmux panes; the bot in a separate process) coordinate without lost updates; atomic write via temp file + rename is the standard crash-safe persistence pattern." + }, + { + "order": 6, + "title": "Telegram Handlers & Callback Routing", + "description": "This layer turns Telegram topics into session operations. message_handlers.py routes text/photos/voice to bound tmux windows, forwards images, transcribes voice, and shells out for `!` bash commands; command_handlers.py implements /start, /history, /screenshot, /unbind and slash-command forwarding into Claude Code. Every inline button comes back through callback_router.py, which authenticates the user and dispatches CB_* payloads via exact-match and prefix registries to the callback_* sub-handlers.", + "nodeIds": [ + "file:src/ccbot/handlers/message_handlers.py", + "file:src/ccbot/handlers/command_handlers.py", + "file:src/ccbot/handlers/callback_router.py", + "file:src/ccbot/handlers/callback_data.py" + ], + "languageLesson": "Telegram callback_data is limited to 64 bytes, so callback_data.py defines a custom prefix codec ('cb:...' typed dataclass payloads) packed into that budget — a common pattern when inline keyboards must carry rich state." + }, + { + "order": 7, + "title": "Interactive UI & Session Creation", + "description": "interactive_ui.py detects Claude Code's interactive terminal UIs (AskUserQuestion, ExitPlanMode, Permission Prompt, RestoreCheckpoint) from captured pane text and renders them as Telegram keyboards with navigation, tracking interactive mode per user/thread — this is what makes the bot feel like a remote control rather than a dumb pipe. directory_browser.py builds the creation UX: a picker of unbound tmux windows, a paginated filesystem browser, and a session-resumption picker.", + "nodeIds": [ + "file:src/ccbot/handlers/interactive_ui.py", + "file:src/ccbot/handlers/directory_browser.py" + ] + }, + { + "order": 8, + "title": "Monitoring & Message Pipeline", + "description": "session_monitor.py (the bot's fan-out champion, message_queue.py) polls Claude Code JSONL transcripts with byte-offset tracking so it only reads new lines, emitting NewMessage events; status_polling.py runs a 1s loop checking each bound window for status lines and interactive UIs. notifications.py routes those events to the right Telegram topic via thread bindings, and message_queue.py — the highest fan-out file in the project — serializes delivery through per-user FIFO workers with flood control, content-message merging and topic-aware MessageTask.thread_id.", + "nodeIds": [ + "file:src/ccbot/session_monitor.py", + "file:src/ccbot/handlers/notifications.py", + "file:src/ccbot/handlers/message_queue.py", + "file:src/ccbot/handlers/status_polling.py" + ], + "languageLesson": "asyncio background tasks (started in post_init) run concurrent services, while per-user FIFO queues guarantee Telegram messages arrive in order and respect flood limits without ever blocking the event loop." + }, + { + "order": 9, + "title": "Parsing Output & Formatting", + "description": "Two parsers interpret what Claude Code emits: transcript_parser.py turns JSONL session entries into display-ready messages (text, thinking, tool_use paired with tool_result) and terminal_parser.py regexes captured pane text for status lines, spinners and interactive UI markers. markdown_v2.py then converts markdown via chatgpt-md-converter-equivalent telegramify wrapping, rendering tables as card-style key-value output, and telegram_sender.py chunks the text into Telegram-safe pieces measured in UTF-16 code units, re-opening fenced code blocks so every chunk stays valid.", + "nodeIds": [ + "file:src/ccbot/transcript_parser.py", + "file:src/ccbot/terminal_parser.py", + "file:src/ccbot/markdown_v2.py", + "file:src/ccbot/telegram_sender.py" + ], + "languageLesson": "Telegram counts message length in UTF-16 code units (max ~4096), not characters; splitting on line boundaries while re-opening/closing code fences keeps each chunk both within limits and valid MarkdownV2." + }, + { + "order": 10, + "title": "Persistent State", + "description": "A cluster of thin dataclass stores keeps the bot's memory across restarts: binding_store.py (thread→window bindings and group chat IDs), window_state_store.py (window_id → session/cwd/name), and monitor_state.py (per-session JSONL byte offsets so readers resume without re-sending old messages). They serialize byte-for-byte to state.json / monitor_state.json, with persistence delegated to the SessionManager facade — separating 'in-memory shape' from 'I/O strategy' makes each store trivially testable.", + "nodeIds": [ + "file:src/ccbot/binding_store.py", + "file:src/ccbot/window_state_store.py", + "file:src/ccbot/monitor_state.py" + ] + }, + { + "order": 11, + "title": "Screenshots & Voice", + "description": "screenshot.py renders terminal text — including full ANSI 16/256/RGB color parsing — into dark-background PNGs via a three-tier font fallback chain, offloading the CPU-heavy rendering to a thread pool; capture_registry.py tracks active captures. transcribe.py converts Telegram voice messages to text with a lazily-initialized httpx client and no OpenAI SDK dependency. These two channels make the bot useful on mobile, where plain text from tmux would otherwise be unreadable.", + "nodeIds": [ + "file:src/ccbot/screenshot.py", + "file:src/ccbot/handlers/capture_registry.py", + "file:src/ccbot/transcribe.py" + ], + "languageLesson": "Lazy initialization (build the httpx client only on first voice message) avoids failing startup when optional capabilities are unused, while delegating CPU-bound pixel work to a thread pool keeps the async event loop responsive." + }, + { + "order": 12, + "title": "Agentic Repair-Robot Toolkit", + "description": "A self-contained secondary subsystem under scripts/agentic: paths.py is the shared I/O API (repo-root discovery, .agentic JSON reads/writes), service.py is the single source of truth for task listing, analysis, context packs, prompts, status and validation, exported both as a CLI (cli.py wiring analyze/plan/select/propose commands) and as a stdio JSON-RPC 2.0 MCP server (mcp_server.py, tool 'ccbot-agentic'). It lets Claude Code itself drive an ANALYZE→PLAN→IMPLEMENT→VALIDATE pipeline against an issue backlog.", + "nodeIds": [ + "file:scripts/agentic/cli.py", + "file:scripts/agentic/mcp_server.py", + "file:scripts/agentic/service.py", + "file:scripts/agentic/paths.py" + ], + "languageLesson": "MCP (Model Context Protocol) servers speak JSON-RPC 2.0 over stdio: the LLM host spawns the server process and exchanges request/response frames, turning internal tooling into model-callable tools without any network service." + }, + { + "order": 13, + "title": "Testing, CI & Putting It All Together", + "description": "The pytest suite mirrors src/ccbot 1:1 — conftest.py provides shared fixtures (JSONL row factories, sample pane renders, chrome handlers) used by tests like test_session.py that verify bindings, window state and migration. .github/workflows/check.yml runs these quality gates (ruff, pyright, pytest) across a Python 3.12/3.13 matrix on every push, and doc/telegram-bot-features.md ties the whole picture back to the Telegram API capabilities the bot exploits. Tracing the full path — entry point → bootstrap → tmux bridge → hook mapping → handlers → monitor pipeline → formatting → persistence → media → the agentic toolkit — gives the complete mental model of the project.", + "nodeIds": [ + "pipeline:.github/workflows/check.yml", + "file:tests/ccbot/test_session.py", + "file:tests/ccbot/conftest.py", + "document:doc/telegram-bot-features.md" + ], + "languageLesson": "GitHub Actions uses `on` triggers to fire workflows, `jobs` for steps with `strategy.matrix` to run a job across multiple Python versions, and caching (here uv's) to speed up dependency installation; pytest-asyncio marks async tests so coroutines run inside the asyncio loop." + } + ] +} \ No newline at end of file diff --git a/.understand-anything/meta.json b/.understand-anything/meta.json new file mode 100644 index 000000000..d21882e2e --- /dev/null +++ b/.understand-anything/meta.json @@ -0,0 +1,6 @@ +{ + "lastAnalyzedAt": "2026-08-12T20:54:01.9395213+02:00", + "gitCommitHash": "2adfbb565fe08e66128e949f3538b5efe128c8c6", + "version": "1.0.0", + "analyzedFiles": 139 +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index fa89a284e..b3c8c0437 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,10 @@ Tech stack: Python, python-telegram-bot, tmux, uv. ```bash uv run ruff check src/ tests/ # Lint — MUST pass before committing uv run ruff format src/ tests/ # Format — auto-fix, then verify with --check -uv run pyright src/ccbot/ # Type check — MUST be 0 errors before committing +uv run pyright src/ccbot/ # Type check — MUST be 0 errors (venv set in pyproject [tool.pyright]) +uv run pytest --tb=short -q # Tests — MUST pass (pythonpath set in pyproject [tool.pytest.ini_options]) +# on a fresh checkout first: uv sync --frozen --extra voice --extra dev +# (scripts/typecheck.py and scripts/run_tests.py do not exist in this repo) ./scripts/restart.sh # Restart the ccbot service after code changes ccbot hook --install # Auto-install Codex SessionStart hook ``` diff --git a/CLAUDE.md b/CLAUDE.md index b9d91576e..c1e5db32e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,10 @@ Tech stack: Python, python-telegram-bot, tmux, uv. ```bash uv run ruff check src/ tests/ # Lint — MUST pass before committing uv run ruff format src/ tests/ # Format — auto-fix, then verify with --check -uv run pyright src/ccbot/ # Type check — MUST be 0 errors before committing +uv run pyright src/ccbot/ # Type check — MUST be 0 errors (venv set in pyproject [tool.pyright]) +uv run pytest --tb=short -q # Tests — MUST pass (pythonpath set in pyproject [tool.pytest.ini_options]) +# on a fresh checkout first: uv sync --frozen --extra voice --extra dev +# (scripts/typecheck.py and scripts/run_tests.py do not exist in this repo) ./scripts/restart.sh # Restart the ccbot service after code changes ccbot hook --install # Auto-install Claude Code SessionStart hook ``` diff --git a/HANDOFF_REPAIR_ROBOT.md b/HANDOFF_REPAIR_ROBOT.md new file mode 100644 index 000000000..5c47f1197 --- /dev/null +++ b/HANDOFF_REPAIR_ROBOT.md @@ -0,0 +1,307 @@ +# Handoff: Repair Robot (ccbot_ju1ian) + +**Дата снимка:** 2026-08-14 +**Репозиторий:** https://github.com/ju1ian0701/ccbot_ju1ian +**Локальный клон:** `D:\CCbot_tmux\ccbot\ccbot_ju1ian\` +**Инструкция:** `REPAIR_ROBOT_INSTRUCTION.md` +**Backlog:** `.agentic/backlog/tasks.json` + +**Инвариант продукта:** topic-only — `1 topic = 1 window = 1 session`. + +--- + +## Последний завершённый этап + +Последний завершённый этап — **ISS-023 DONE: lazy init cache dirs (IMAGES_DIR/DOCS_DIR/_AUDIO_DIR) — no import-time mkdir (PR #36 `353ce24`)**. + +- `utils.py`: +`ensure_dir(path) -> Path` рядом с `ccbot_dir()`; +- `message_handlers.py` / `document.py`: −3 module-level mkdir, 3 call sites на `ensure_dir`; пути/флаги/поведение без изменений; +- +3 теста `tests/ccbot/test_lazy_dirs.py` (unit ×2 + reload no-side-effect regression); suite 439; validate ok:true, evidence 4/4. + +Предыдущий этап — **ISS-022 DONE: port upstream PR #53 local whisper backend + CCBOT_WHISPER_LANGUAGE (auto|en|ru) (PR #35 `236b13c`)**. + +- `transcribe.py`: unified `transcribe()`; local faster-whisper + openai; `CCBOT_WHISPER_LANGUAGE` auto|en|ru (без хардкода `language="en"`); +- `voice_handler`: файловое скачивание + `finally: unlink`; `TranscriptionDisabled` / `TranscriptionError` / generic; сначала 🎤, потом forward; +- Suite 436; THERMO 7/7. + +Предыдущий этап — **ISS-021 DONE: port upstream PR #89 documents handler (require_session rewrite) (PR #34 `8120171`)**. + +- `handlers/document.py`: `require_user` + `require_session`; 20 MB cap; caption + `(file attached: )`; suite 419. + +Предыдущий этап — **ISS-020 DONE: port upstream PR #86 allowed_updates + edit-guards (PR #33 `3e06992`)**. + +- `main.py`: `allowed_updates` 2 → 8; `bot.py` TEXT/PHOTO/VOICE/catch-all edit-guards; suite 407. + +Предыдущий этап — **ISS-019 DONE: identity-checked `CaptureTaskRegistry.discard` (PR #32 `f511920`)**. + +- Identity-checked discard: `discard(user_id, thread_id, task=None)` — при `task is None` безусловный pop (legacy); при `task` — pop только если `self._tasks.get(key) is task` (stale finally не выкидывает новую регистрацию); +- Call site: `capture_bash_output` finally → `capture_tasks.discard(user_id, thread_id, asyncio.current_task())`; +- +2 regression tests (stale-evict, matching-remove) в `tests/ccbot/test_capture_registry.py` (существующие 4 без правок); suite capture 6/6, ruff/pyright green. + +Предыдущий этап — **ISS-018 DONE: ruff I001 import sort, 12 файлов (PR #31 `5374113`)**. + +- Машинный `ruff check --select I001 --fix` (4 scripts/agentic + 6 src/ccbot + 2 tests) + одна ручная правка: восстановлен `# noqa: E402` в cli.py (фиксер расщепил `from propose import (...)`, новый блок потерял noqa — предусмотрено acceptance); +- Гейты: I001 = 0 findings, `ruff check` clean, `ruff format --check` = **113/113**, pyright 0, suite 405, validate `ok: true`, guardrails 12/12. + +Предыдущий этап — **ISS-017 DONE: trailing newline propose.py (PR #30 `654d042`)**. Hygiene-дуга ISS-015/016/017 закрыта: `ruff format --check` = **113/113**, backlog пуст. + +- Единственный hunk: EOF trailing newline в propose.py (`\ No newline` маркер); сырой `git diff HEAD` прошёл propose без пересборки — dogfood binary staging из ISS-016 доказан делом; +- Suite 405, pyright 0, ruff clean. + +Предыдущий этап — **ISS-016 DONE: binary-safe staging в propose.py (PR #29 `8a7b4bf`)**. + +- Байтовый путь diff: `_write_temp_diff` = `bytes` + `NamedTemporaryFile("wb")` (staged == input, без дозаписи `\n`); `check_diff_applies`/`diff_numstat`/`validate_proposal_diff` принимают `bytes`, decode только для парсинга; `create_proposal_artifacts(diff_bytes=...)`, `proposal.diff` пишется через `write_bytes` (apply читает артефакт напрямую → байт-безопасно end-to-end); все чтения diff → `read_bytes`; +- Новый `tests/agentic/test_binary_staging.py` — 8 тестов (staged == input: LF / CRLF / git-заголовки / EOF-маркер / patch без финального `\n`); suite **397 → 405**; +- Trailing newline propose.py осознанно не тронут — уехал в ISS-017 (теперь staging переживёт EOF-marker hunk). + +Предыдущий этап — **ISS-015 DONE: ruff-format hygiene (PR #28 `562c687`)**. + +- Машинный diff `ruff format` через полный HITL-цикл: 12 файлов вне src/tests (`.agentic/IMPLEMENTATION_PLAN.md` + 11 в `scripts/agentic/`), +139/−42, ноль ручных правок; +- Residual (задокументирован): trailing newline в конце `scripts/agentic/propose.py` — hunk не переживает staging (см. уроки ниже); `ruff format --check` = 111 formatted + 1 residual; +- Validate green (guardrails + ruff + pyright + pytest 397); suite не пострадал. + +### Уроки ISS-015 (line-endings и staging propose) + +- При `core.autocrlf=true` `git diff HEAD` выдаёт **LF-нормализованный** diff — не применяется к CRLF-дереву; `git -c core.autocrlf=false diff` даёт whole-file diff (блобы LF, дерево CRLF) — обе формы непригодны; +- **Staging propose переписывает любой вход в all-CRLF** (`read_text` срезает `\r`, `NamedTemporaryFile("w")` на Windows переводит `\n`→`\r\n`): git-заголовки (`diff --git`/`index`) с `\r` ломают парсер git 2.45.1.windows → рабочая форма diff для propose — **difflib-стиль без git-заголовков** (как REF-007); +- Hunk с `\ No newline at end of file` в staged-форме не применяется (минус-строка получает `\r`, которого нет на диске) → такие hunk'и вырезать, residual фиксировать в debt; +- Проверки propose — **ИЛИ** (первый rc=0 из `--check` / `--cached --check` / `--reverse --check`), `--cached` против LF-блобов с `\r`-контентом падает всегда — это штатно; +- Debt: перевести staging propose на binary (`read_bytes`/`write_bytes`) — после этого trailing newline propose.py доедет маленькой задачей. + +- R7a: `wid` → `window_id` — 67 строк в 11 файлах + 3 переноса под line-length 88; +- R7b: `tid` → `thread_id` — 28 замен в 3 файлах; 2 семантические ловушки (`tid`-локалка при живом параметре `thread_id` в `enqueue_status_update` и `set_group_chat_id`) — инлайн `thread_id or 0`, не реассайн параметра; +- `skey` = 0 hits — no-op (зафиксировано в resolution); +- REF-007 — первый structural tenant механизма ISS-013: `validate --task REF-007` после R7a давал штатный FAIL по `tid`, после R7b — full green (оба absent); done-gate разблокирован; +- Suite **397 passed** на всём протяжении; pyright 0, ruff clean. + +--- + +## Phase 1 — Canonical auth + REF-001 done-for-real + +**Status: CLOSED (2026-07-28)** + +| Task | Status | Evidence | PR | +|------|--------|----------|-----| +| ISS-002 | DONE | canonical auth; 1 SoT | #10 (merged) | +| ISS-006 | DONE | compose require_session; tests green | #11 (merged ee8c0b2) | +| ISS-001 | DONE | migrate handlers to require_*; ≥8 call sites | #12 (merged a36b018) | +| ISS-007 | DONE | unified DEFAULT_* error strings; commands on require_session | #12 (with ISS-001) | + +> Note: PR #11 merged with claude-review failures (2×). Merge owner decision logged. + +### Детали Phase 1 + +| ID | Что | Артефакты | +|----|-----|-----------| +| **ISS-002** | Один SoT: `is_user_allowed` / `get_thread_id` в `session_guard`; `handlers/auth.py` — thin re-export | merged `2026-07-28T11:10:50Z`; proposal `ISS-002_20260727T175352Z`; PR #10 | +| **ISS-006** | `require_session` = `require_bound_window_id` + live window + optional unbind + `SessionContext` | proposal `ISS-006_20260728T141904Z`; merge commit `ee8c0b2`; PR #11 | +| **ISS-001** | Мигрировать handlers на `require_*` (≥8 call sites); residual topic_closed/kill via require_session | proposal `ISS-001_20260728T183408Z`; merge commit `a36b018`; PR #12 | +| **ISS-007** | Унифицировать error strings + ladder в screenshot/esc/usage | merged with ISS-001 (`a36b018`) | + +### Evidence greps (Phase 1 exit) + +```text +rg "is_user_allowed\(user.id\)" src/ccbot/handlers → 0 +rg "require_session|require_user|require_bound" src/ccbot/handlers → ≥8 +rg "require_bound_window_id" src/ccbot/session_guard.py +# require_session composes require_bound_window_id +``` + +### PRs + +| PR | Title | State | +|----|-------|-------| +| [#10](https://github.com/ju1ian0701/ccbot_ju1ian/pull/10) | ISS-002 canonical auth | **MERGED** | +| [#11](https://github.com/ju1ian0701/ccbot_ju1ian/pull/11) | ISS-006 compose require_session | **MERGED** (`ee8c0b2`) | +| [#12](https://github.com/ju1ian0701/ccbot_ju1ian/pull/12) | ISS-001 migrate handlers + ISS-007 strings | **MERGED** (`a36b018`) | + +--- + +## Phase 2 — Queue encapsulation + concurrency surface (REF-005 real) + +**Status: CLOSED (2026-08-06)** + +| Task | Status | Evidence | PR | +|------|--------|----------|-----| +| ISS-003 | DONE | encapsulate `_queues`/`_locks` | #13 (merged) | +| ISS-008 | DONE | explicit `message_thread_id` typed contract | #14 (merged `1fff3af`) | +| ISS-010 | DONE | extract `bash_capture_tasks` → `CaptureTaskRegistry` | #16 (merged `fc861f4`) | + +Phase 2: **CLOSED** + +- ISS-003 (инкапсуляция MessageQueueManager) — **DONE** (PR #13); +- ISS-008 (typed `message_thread_id` contract) — **DONE** (PR #14); +- ISS-010 (extract `bash_capture_tasks`) — **DONE** (PR #16); +- Следующая фаза: **Phase 3** — ISS-004 (callback_router spaghetti → table-driven dispatch), затем ISS-011 (split sub-handlers). WIP-слот свободен. + +### Детали Phase 2 + +| ID | Что | Артефакты | +|----|-----|-----------| +| **ISS-003** | Public accessors `get_queue` / `get_lock`; `_message_queue_worker` больше не трогает `queue_manager._queues` / `._locks` | local apply 2026-07-29; PR #13; evidence greps ниже; tests `test_message_queue_*` green | +| **ISS-008** | Explicit `message_thread_id` на send path; drop `_send_kwargs` + `type: ignore` | proposal `ISS-008_20260806T094220Z`; PR #14 `1fff3af`; env gate deviation → PR #15 | +| **ISS-010** | `CaptureTaskRegistry` + `capture_tasks`; clear-on-topic cancel; dual dicts removed | proposal `ISS-010_20260806T152347Z`; PR #16 `fc861f4`; tests `test_capture_registry.py` | + +### Evidence greps (ISS-003) + +```text +rg "queue_manager\._queues|queue_manager\._locks" src → 0 +# self._queues / self._locks only inside MessageQueueManager +# worker uses get_queue() / get_lock() +``` + +### Evidence greps (ISS-008) + +```text +rg "_send_kwargs" src/ccbot/handlers → 0 +rg "type: ignore" src/ccbot/handlers/message_queue.py → 0 (send path) +rg "message_thread_id" src/ccbot/handlers/message_sender.py → explicit params +``` + +### Evidence greps (ISS-010) + +```text +rg "bash_capture_tasks\s*=" src/ccbot → 0 (live dict) +rg "bash_capture_tasks" src/ccbot → docstring-only residual OK +rg "CaptureTaskRegistry|capture_tasks" src/ccbot/handlers +``` + +### PRs (Phase 2) + +| PR | Title | State | +|----|-------|-------| +| [#13](https://github.com/ju1ian0701/ccbot_ju1ian/pull/13) | ISS-003 encapsulate MessageQueueManager | **MERGED** | +| [#14](https://github.com/ju1ian0701/ccbot_ju1ian/pull/14) | ISS-008 explicit message_thread_id | **MERGED** (`1fff3af`) | +| [#15](https://github.com/ju1ian0701/ccbot_ju1ian/pull/15) | chore: pytest Windows basetemp (WinError 5) | **MERGED** (`fe022b6`) | +| [#16](https://github.com/ju1ian0701/ccbot_ju1ian/pull/16) | ISS-010 CaptureTaskRegistry | **MERGED** (`fc861f4`) | + +--- + +## Phase 3 — callback_router: table-driven dispatch + split sub-handlers + +**Status: CLOSED (2026-08-09)** + +| Task | Status | Evidence | PR | +|------|--------|----------|-----| +| ISS-004 | DONE | table-driven dispatch; single topic-guard | #17 (merged `1773c43`) | +| ISS-011 | DONE | split into 6 submodules; entry 141 LOC | #18 (merged `4894eac`) | + +### Детали Phase 3 + +| ID | Что | Артефакты | +|----|-----|-----------| +| **ISS-004** | Registry `_EXACT_ROUTES`/`_PREFIX_ROUTES`/`_match_route`; единый guard `_check_same_topic` + `_clear_pending_thread` (10 copy-paste → 1 helper); `_ASK_ACTIONS` (9 aq-веток → 1 handler); wire format unchanged; parity matrix 43 кейса (поймала порядок cleanup→answer в `db:confirm`) | proposal `ISS-004_20260808T102953Z`; PR #17 `1773c43` | +| **ISS-011** | Entry 768→141 LOC (auth+capture+dispatch only); 6 submodules (guard/history/directory/pickers/screenshot/interactive); guard вынесен в `callback_topic_guard` (иначе циклический импорт); тела 1:1; parity 60/60 | PR #18 `4894eac` | + +### Evidence greps (Phase 3 exit) + +```text +rg "_pending_thread_id" src/ccbot/handlers → helpers + docstring only +rg -n "def _handle_" .../callback_router.py → только _handle_noop +rg -c "_check_same_topic" src/ccbot/handlers → 1 def + 11 call sites + # нюанс: rg -c считает и import-строки submodules +pytest tests/ccbot/handlers/test_callback_data.py → 27 passed +``` + +### PRs (Phase 3) + +| PR | Title | State | +|----|-------|-------| +| [#17](https://github.com/ju1ian0701/ccbot_ju1ian/pull/17) | ISS-004 table-driven callback_router | **MERGED** (`1773c43`) | +| [#18](https://github.com/ju1ian0701/ccbot_ju1ian/pull/18) | ISS-011 split callback_router sub-handlers | **MERGED** (`4894eac`) | + +### Уроки Phase 3 + +- Паритет-матрица на stub-окружении — обязательный evidence при рефакторинге роутеров; ловит и поведенческие (порядок side effects), и сборочные (пропущенный импорт) ошибки; +- `on_stale`-колбэк в guard — способ сохранить порядок «cleanup → answer» при выносе guard в helper; +- `gh pr merge --squash --delete-branch` сам делает checkout main + pull + удаление веток (шаги 21–22 автоматом); +- `analyze` генерирует untracked `.understand-anything/config.json` → `Remove-Item` после прогона; canonical KG (fingerprints/knowledge-graph/meta) — tracked, обновления коммитятся мета-коммитом `chore(kg)`; +- CRLF-диффы захватывать бинарно (universal newlines съедает `\r` → ложный `patch does not apply`); +- Мета-коммит шага 23 проверять фактом (`git log --oneline -3`) при возобновлении ветки, а не предполагать (ISS-004 закрыт задним числом). + + +## Phase 4 — SessionManager: split into stores + thin facade (зона Z4) + +**Status: CLOSED (2026-08-10)** + +| Task | Status | Evidence | PR | +|------|--------|----------|-----| +| ISS-009 | DONE | external `_save_state` = 0; TTL/backup сохранены | #19 (merged `65907e5`) | +| ISS-005 | DONE | split 4a–4d; `session.py` 857→649 LOC | #20–#23 (merged `b5638d6`) | + +### Детали Phase 4 + +| ID | Что | Артефакты | +|----|-----|-----------| +| **ISS-009** | `state.json` пишется только через `SessionManager._save_state`; внешних писателей нет | rg `_save_state` = 1 def + internal calls | +| **ISS-005 4a** | `BindingStore`: `thread_bindings` + `group_chat_ids` | `binding_store.py` (125 LOC); паритет **17/17** | +| **ISS-005 4b** | `WindowStateStore`: `window_states` + `window_display_names`; `WindowState` re-export | `window_state_store.py` (118 LOC); паритет **14/14** | +| **ISS-005 4c** | `SessionMapRepository`: `read`/`write`/`exists` + `mutate_locked` (flock RMW дословно) | `session_map_repository.py` (88 LOC); паритет **12/12** | +| **ISS-005 4d** | `TranscriptReader` (JSONL-семейство) + thin facade; `ClaudeSession` re-export | `transcript_reader.py` (195 LOC); паритет **12/12** | + +### Evidence greps (Phase 4 exit) + +```text +rg "class BindingStore|class WindowStateStore|class SessionMapRepository|class TranscriptReader" src/ccbot → 4 store-модуля +rg "def (thread_bindings|group_chat_ids|window_states|window_display_names)" src/ccbot/session.py → property-делегаты с сеттерами +rg "_save_state" src/ccbot → 1 def + internal calls (external = 0) +pytest → 383 passed; pyright → 0 errors; ruff → clean (1 pre-existing I001) +``` + +### PRs (Phase 4) + +| PR | Title | State | +|----|-------|-------| +| [#19](https://github.com/ju1ian0701/ccbot_ju1ian/pull/19) | ISS-009 single-writer state.json | **MERGED** (`65907e5`) | +| [#20](https://github.com/ju1ian0701/ccbot_ju1ian/pull/20) | ISS-005 4a extract BindingStore | **MERGED** (`d6bc24a`) | +| [#21](https://github.com/ju1ian0701/ccbot_ju1ian/pull/21) | ISS-005 4b extract WindowStateStore | **MERGED** (`4b43cef`) | +| [#22](https://github.com/ju1ian0701/ccbot_ju1ian/pull/22) | ISS-005 4c extract SessionMapRepository | **MERGED** (`6164989`) | +| [#23](https://github.com/ju1ian0701/ccbot_ju1ian/pull/23) | ISS-005 4d TranscriptReader + thin facade | **MERGED** (`b5638d6`) | + +### Уроки Phase 4 + +- Store-модули — чистые (без persist/логирования); кросс-store логика (`bind_thread`/`load_session_map`/`resolve_stale_ids`) остаётся в фасаде; +- Тесты пишут `mgr.thread_bindings = {...}` напрямую → property-**сеттеры** обязательны, не только геттеры; +- Dynamic config lookup (property, не захват в `__init__`) — тестовые фикстуры патчат `config.session_map_file`/`claude_projects_path`; +- Gap-анализ до propose: `user_window_offsets` не вошёл ни в один store → осознанный residual у фасада (решение фиксировать в дизайне итерации); +- Validate/apply запускать строго по артефактам (`approval.json`, наличие файла в tree), не по памяти — инцидент 4b (validate до apply); +- Мета-правки коммитить **до** старта цикла: dirty tree (M HANDOFF) блокирует propose (recurrence ISS-009 → 4a); +- Свои F401 отслеживать `ruff check` в песочнице до сборки diff (инцидент 4c: `flock`/`LOCK_EX`/`LOCK_UN`); +- `/tmp` в песочнице неперсистентен — diff-артефакты и патчи дублировать в output. + +### Уроки (операционные, накопились к ISS-022) + +- **(а)** Meta-шаг завершён только при пустом `git log origin/main..main` (факт на remote, не локальный orphan-коммит). +- **(б)** Перед любым вызовом `agentic ` — сверка флагов через `-h`. Три инцидента: `--notes` обязателен у `request-changes` (×2), позиционный аргумент `approve`. +- **(в)** GH_TOKEN-разделение: `gh` / token-действия — человек; робот останавливается на `HANDOFF TO HUMAN`. +- **(г)** `uv run` при устаревшем uv.lock регенерирует его ВНУТРИ команды (грязное дерево на propose/validate; 2 срабатывания за ISS-023). Обход: все вызовы agentic CLI через `uv run --no-sync`; устойчивое лечение — закрыть долг №2 (chore-коммит регенерации uv.lock). + +--- + +## Следующий шаг + +1. Phase 1 — **CLOSED** (ISS-002, ISS-006, ISS-001, ISS-007). +2. Phase 2 — **CLOSED** (ISS-003, ISS-008, ISS-010). +3. Phase 3 — **CLOSED** (ISS-004 #17, ISS-011 #18). +4. Phase 4 — **CLOSED** (ISS-009 #19 `65907e5`; ISS-005 #20–#23 `b5638d6`: stores + thin facade, `session.py` 857→649 LOC). +5. **ISS-023 DONE** (PR #36 `353ce24`). Следующий product-цикл — только после явного выбора/добавления ready-задачи. +6. **Debt (только по явному решению, без отдельной ISS):** + - smoke-тесты Phase 1 (чек-лист в `.agentic/out/notes/2026-08-11-smoke-phase1.md`, отложены); + - **format-scope validate** — `validate`/`ruff format --check` scope сейчас завязан на full-tree / pre-existing baseline; при hygiene-задачах (ISS-015+) и точечных product-diff полезен scoped format-check (только changed files / allowlist), чтобы residual вне scope не маскировал fail и наоборот. Полноценная ISS — отдельное решение о приоритете; до того — debt-заметка здесь (как smoke Phase 1). + - **uv.lock regen (долг №2)** — `uv run` регенерирует устаревший lock внутри команды (грязное дерево на propose/validate). Обход: `uv run --no-sync` + `git checkout uv.lock`. Постоянный фикс — chore-коммит регенерации. + - ISS-020 residual: edit-guard для `forward_command_handler` (`filters.COMMAND`) — severity low; + - smoke ISS-020 — чек-лист `.agentic/out/notes/2026-08-14-iss-020-smoke-checklist.md`; blocked: требуется выделенный сервер с живой tmux-сессией; + - ISS-021 residual (THERMO): naming `DOCS_DIR` vs `_MAX_DOC_BYTES` (косметика); + - ISS-021 residual (THERMO): коллизионное окно `time.time()` в именах файлов → `file_unique_id` (low); + - smoke ISS-021 — те же серверные предусловия; PDF ≤20 MB (инъекция пути + caption) и >20 MB (отказ, без скачивания); чек-лист `.agentic/out/notes/2026-08-14-iss-020-smoke-checklist.md`; + - `pyright tests/`: 4× `reportOptionalMemberAccess` на `AsyncMock.await_args` в `test_document.py` (Optional в свежих typeshed после uv.lock regen) — env/типстабы, не продукт; каноничный гейт `pyright src/ccbot/` = 0. +7. Meta (backlog/handoff) — commit immediately; product — only via propose → approve → apply. +8. Gate: не мержить product PR при красном validate; env-фиксы — отдельной веткой (как PR #15). + +--- + +## HITL reminder + +`propose → approve → apply → validate → commit → draft PR` +No silent apply. Product done = call sites + evidence, not “file exists”. +Meta changes (backlog, handoff, docs) — commit immediately, never mix with product diffs. diff --git a/pyproject.toml b/pyproject.toml index 0d4760885..72b3c5e82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ dev = [ "pytest-cov>=6.0", "ruff>=0.8.0", ] +voice = [ + "faster-whisper>=1.0.0", +] [build-system] requires = ["hatchling"] @@ -40,9 +43,32 @@ target-version = ["py312"] [tool.ruff] target-version = "py312" +# Pin the pre-0.16 default rule set (E4/E7/E9/F). Ruff 0.16 expanded defaults +# from 59 → 413 rules; without an explicit select, bare `ruff check` on 0.16+ +# flags hundreds of pre-existing findings this repo has not adopted yet. +[tool.ruff.lint] +select = ["E4", "E7", "E9", "F"] + +# Point bare `pyright` / IDE at the project venv so third-party imports resolve +# without requiring `uv run` (system Python otherwise reports reportMissingImports). +[tool.pyright] +include = ["src/ccbot"] +venvPath = "." +venv = ".venv" +pythonVersion = "3.12" +typeCheckingMode = "basic" + [tool.pytest.ini_options] testpaths = ["tests"] +# src-layout: let bare `pytest` resolve `import ccbot` without an editable install +# (otherwise collection ERROR on every tests/ccbot/** file under system Python). +pythonpath = ["src"] asyncio_mode = "auto" +# Explicit loop scope avoids pytest-asyncio deprecation (future default change). +asyncio_default_fixture_loop_scope = "function" +# Keep fewer basetemp trees — reduces Windows WinError 5 cleanup flakes. +tmp_path_retention_count = 1 +tmp_path_retention_policy = "failed" markers = [ "integration: tests using real filesystem or external processes", ] diff --git a/scripts/agentic/README.md b/scripts/agentic/README.md new file mode 100644 index 000000000..cff947724 --- /dev/null +++ b/scripts/agentic/README.md @@ -0,0 +1,56 @@ +# scripts/agentic + +Stdlib Python toolkit (3.12+) for the agentic pipeline. No extra package deps. + +| Module | Role | +|--------|------| +| `cli.py` | CLI: analyze / plan / select / validate / sync-issues / run | +| `analyze_graph.py` | Hotspots + path scores from knowledge graph | +| `prioritize.py` | Rank backlog tasks, pick next | +| `render_prompt.py` | Build implement prompt + PR body | +| `validate_changes.py` | Path guardrails + ruff/pyright/pytest | +| `sync_issues.py` | Optional GitHub issue sync via `gh` | +| `update_backlog_status.py` | Mark tasks done/blocked/… | +| `paths.py` | Repo root + JSON helpers | + +```bash +# from repo root +python scripts/agentic/cli.py analyze +python scripts/agentic/cli.py plan +python scripts/agentic/cli.py select --task REF-001 +python scripts/agentic/cli.py validate --skip-quality +python scripts/agentic/cli.py run --skip-quality +python scripts/agentic/update_backlog_status.py REF-001 in_progress +``` + +## Structural task evidence + +Tasks with `"kind": "structural"` must carry an `evidence` block in +`.agentic/backlog/tasks.json`; tasks with `evidence` get proof-checks even +without the kind. Supported keys (all optional): + +```json +"kind": "structural", +"evidence": { + "deleted_paths": ["src/ccbot/old_module.py"], + "call_sites": ["SessionMapRepository\("], + "grep": [{"pattern": "_save_state", "path": "src/ccbot/handlers", "expect": "absent"}], + "tests": ["tests/ccbot/test_session.py"] +} +``` + +- `deleted_paths`: each path must NOT exist (old path removed). +- `call_sites`: each regex must match at least once under `src/` + (production call-site proof; matches in `tests/` do not count). +- `grep`: `{pattern, path, expect: present|absent}` regex checks scoped to + a file or directory (recursive over `*.py`). +- `tests`: each test file must exist. + +Enforcement: + +- `python scripts/agentic/cli.py validate --task ISS-XXX` runs the evidence + checks and fails validation when any check fails. +- `update_backlog_status.py done` is blocked for structural/evidence + tasks until the evidence passes (false-done guard). + +See [../../.agentic/README.md](../../.agentic/README.md) for the full pipeline. diff --git a/scripts/agentic/__init__.py b/scripts/agentic/__init__.py new file mode 100644 index 000000000..e1fcb144c --- /dev/null +++ b/scripts/agentic/__init__.py @@ -0,0 +1,3 @@ +"""Agentic pipeline toolkit for ccbot (analyze → plan → implement → validate).""" + +__version__ = "1.0.0" diff --git a/scripts/agentic/analyze_graph.py b/scripts/agentic/analyze_graph.py new file mode 100644 index 000000000..b182b8679 --- /dev/null +++ b/scripts/agentic/analyze_graph.py @@ -0,0 +1,276 @@ +"""Analyze knowledge-graph.json and produce hotspot / debt signals.""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from paths import ( + find_repo_root, + load_config, + load_json, + out_dir, + write_json, + write_text, +) + +FILE_LEVEL_TYPES = frozenset( + { + "file", + "config", + "document", + "service", + "pipeline", + "table", + "schema", + "resource", + "endpoint", + "module", + } +) + + +def _edge_type(edge: dict[str, Any]) -> str: + return edge.get("type") or edge.get("label") or "related" + + +def _normalize_path(path: str | None) -> str | None: + if not path: + return None + return path.replace("\\", "/").lstrip("./") + + +def analyze_graph(graph: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]: + nodes: list[dict[str, Any]] = graph.get("nodes") or [] + edges: list[dict[str, Any]] = graph.get("edges") or [] + layers: list[dict[str, Any]] = graph.get("layers") or [] + tour: list[dict[str, Any]] = graph.get("tour") or [] + + fan_in: dict[str, int] = defaultdict(int) + fan_out: dict[str, int] = defaultdict(int) + edge_types: Counter[str] = Counter() + tested_targets: set[str] = set() + + for e in edges: + et = _edge_type(e) + edge_types[et] += 1 + src, tgt = e.get("source"), e.get("target") + if src: + fan_out[src] += 1 + if tgt: + fan_in[tgt] += 1 + if et == "tested_by": + # production → test in merged graphs; either endpoint may be production + if src: + tested_targets.add(src) + if tgt: + tested_targets.add(tgt) + + threshold = int( + config["prioritization"]["graph_signals"]["hotspot_threshold_edges"] + ) + signals = config["prioritization"]["graph_signals"] + + file_nodes = [ + n + for n in nodes + if n.get("type") in FILE_LEVEL_TYPES + or ( + n.get("filePath") + and str(n.get("type", "")).lower() in {"file", "filenameNode".lower()} + ) + ] + # Prefer explicit type==file for hotspot scoring of product code + code_files = [n for n in nodes if n.get("type") == "file"] + + hotspots: list[dict[str, Any]] = [] + untested: list[dict[str, Any]] = [] + complex_files: list[dict[str, Any]] = [] + + for n in code_files: + nid = n["id"] + fpath = _normalize_path(n.get("filePath") or n.get("name")) + degree = fan_in[nid] + fan_out[nid] + tags = {t.lower() for t in (n.get("tags") or [])} + is_tested = "tested" in tags or nid in tested_targets + complexity = (n.get("complexity") or "").lower() + score = 0 + reasons: list[str] = [] + + if complexity in {"complex", "high"}: + score += int(signals["complex_file_bonus"]) + reasons.append(f"complexity={complexity}") + complex_files.append({"id": nid, "path": fpath, "complexity": complexity}) + + if not is_tested and fpath and fpath.startswith("src/"): + score += int(signals["untested_file_bonus"]) + reasons.append("no_tested_tag_or_edge") + untested.append({"id": nid, "path": fpath}) + + if fan_in[nid] >= threshold // 2: + score += int(signals["high_fan_in_bonus"]) + reasons.append(f"fan_in={fan_in[nid]}") + if fan_out[nid] >= threshold // 2: + score += int(signals["high_fan_out_bonus"]) + reasons.append(f"fan_out={fan_out[nid]}") + if degree >= threshold: + reasons.append(f"degree={degree}") + + if score > 0 or degree >= threshold: + hotspots.append( + { + "id": nid, + "path": fpath, + "score": score, + "fan_in": fan_in[nid], + "fan_out": fan_out[nid], + "complexity": complexity or None, + "tested": is_tested, + "reasons": reasons, + "summary": (n.get("summary") or "")[:240], + } + ) + + hotspots.sort( + key=lambda h: (-h["score"], -(h["fan_in"] + h["fan_out"]), h.get("path") or "") + ) + + layer_summary = [ + { + "id": layer.get("id"), + "name": layer.get("name"), + "node_count": len(layer.get("nodeIds") or []), + "description": layer.get("description"), + } + for layer in layers + ] + + node_types = Counter(n.get("type") or "unknown" for n in nodes) + orphans = [ + {"id": n["id"], "type": n.get("type"), "name": n.get("name")} + for n in nodes + if n.get("id") and fan_in[n["id"]] == 0 and fan_out[n["id"]] == 0 + ] + + # Map file path → hotspot score for backlog boosting + path_scores: dict[str, int] = {} + for h in hotspots: + if h.get("path"): + path_scores[h["path"]] = max(path_scores.get(h["path"], 0), int(h["score"])) + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "project": graph.get("project") or {}, + "stats": { + "nodes": len(nodes), + "edges": len(edges), + "layers": len(layers), + "tour_steps": len(tour), + "code_files": len(code_files), + "file_level_nodes": len(file_nodes), + "node_types": dict(node_types), + "edge_types": dict(edge_types), + "orphan_nodes": len(orphans), + }, + "layers": layer_summary, + "hotspots": hotspots[:30], + "complex_files": complex_files, + "untested_src_files": untested, + "orphan_sample": orphans[:20], + "path_scores": path_scores, + "recommendations": _recommendations(hotspots, untested, complex_files), + } + + +def _recommendations( + hotspots: list[dict[str, Any]], + untested: list[dict[str, Any]], + complex_files: list[dict[str, Any]], +) -> list[str]: + recs: list[str] = [] + top = hotspots[:5] + if top: + paths = ", ".join(h.get("path") or h["id"] for h in top) + recs.append(f"Prioritize refactors touching hotspots: {paths}") + bot = next((h for h in hotspots if (h.get("path") or "").endswith("bot.py")), None) + if bot: + recs.append( + "bot.py remains a structural hotspot — prefer extract-helpers (REF-001) before full split (REF-002)." + ) + if len(untested) >= 3: + recs.append( + f"{len(untested)} src file nodes lack tested signals — schedule REF-009 coverage for top hotspots." + ) + if len(complex_files) >= 2: + recs.append( + "Multiple complex files detected — avoid multi-file god refactors in a single agent PR." + ) + if not recs: + recs.append( + "Graph looks balanced; proceed with highest-priority ready backlog tasks." + ) + return recs + + +def render_markdown(report: dict[str, Any]) -> str: + project = report.get("project") or {} + stats = report.get("stats") or {} + lines = [ + f"# Knowledge graph analysis — {project.get('name', 'project')}", + "", + f"- Generated: `{report.get('generated_at')}`", + f"- Graph commit: `{project.get('gitCommitHash', 'unknown')}`", + f"- Nodes: **{stats.get('nodes')}** · Edges: **{stats.get('edges')}** · Layers: **{stats.get('layers')}**", + "", + "## Layers", + "", + ] + for layer in report.get("layers") or []: + lines.append( + f"- **{layer.get('name')}** (`{layer.get('id')}`): {layer.get('node_count')} nodes" + ) + lines += ["", "## Top hotspots", ""] + lines.append("| Path | Score | Fan-in | Fan-out | Complexity | Tested | Reasons |") + lines.append("|------|------:|-------:|--------:|------------|--------|---------|") + for h in (report.get("hotspots") or [])[:15]: + lines.append( + f"| `{h.get('path')}` | {h.get('score')} | {h.get('fan_in')} | {h.get('fan_out')} | " + f"{h.get('complexity') or '-'} | {h.get('tested')} | {', '.join(h.get('reasons') or [])} |" + ) + lines += ["", "## Recommendations", ""] + for r in report.get("recommendations") or []: + lines.append(f"- {r}") + lines += ["", "## Untested src files (sample)", ""] + for u in (report.get("untested_src_files") or [])[:20]: + lines.append(f"- `{u.get('path')}`") + lines.append("") + return "\n".join(lines) + + +def run(repo_root: Path | None = None) -> dict[str, Any]: + root = repo_root or find_repo_root() + config = load_config(root) + graph_path = root / config["knowledge_graph"]["path"] + if not graph_path.is_file(): + raise FileNotFoundError( + f"Knowledge graph not found: {graph_path}. Run /understand first." + ) + graph = load_json(graph_path) + report = analyze_graph(graph, config) + + meta_path = root / config["knowledge_graph"]["meta_path"] + if meta_path.is_file(): + report["meta"] = load_json(meta_path) + + out = out_dir(root, config) + write_json(out / config["outputs"]["analysis_report"], report) + write_text(out / config["outputs"]["analysis_markdown"], render_markdown(report)) + return report + + +if __name__ == "__main__": + result = run() + print(f"Hotspots: {len(result.get('hotspots') or [])}") + print("Wrote analysis to .agentic/out/") diff --git a/scripts/agentic/build_context_pack.py b/scripts/agentic/build_context_pack.py new file mode 100644 index 000000000..c29fdd6f7 --- /dev/null +++ b/scripts/agentic/build_context_pack.py @@ -0,0 +1,203 @@ +"""Build a task-scoped subgraph slice from knowledge-graph.json.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from paths import find_repo_root, load_config, load_json, out_dir, write_json + + +def _edge_type(edge: dict[str, Any]) -> str: + return edge.get("type") or edge.get("label") or "related" + + +def _node_path(node: dict[str, Any]) -> str | None: + fp = node.get("filePath") or node.get("path") + if fp: + return str(fp).replace("\\", "/") + nid = node.get("id") or "" + if isinstance(nid, str) and nid.startswith("file:"): + return nid[len("file:") :] + return None + + +def _seed_ids(task: dict[str, Any], node_by_id: dict[str, dict[str, Any]]) -> set[str]: + seeds: set[str] = set() + for nid in task.get("related_nodes") or []: + if nid in node_by_id: + seeds.add(nid) + for fpath in task.get("files") or []: + p = str(fpath).replace("\\", "/").rstrip("/") + # exact file node + candidate = f"file:{p}" + if candidate in node_by_id: + seeds.add(candidate) + # directory prefix match + for nid, node in node_by_id.items(): + np = _node_path(node) + if not np: + continue + if np == p or np.startswith(p + "/") or p.startswith(np): + if node.get("type") in { + "file", + "config", + "module", + "document", + "pipeline", + }: + seeds.add(nid) + return seeds + + +def build_context_pack( + graph: dict[str, Any], + task: dict[str, Any], + *, + hops: int = 1, + max_nodes: int = 80, + max_edges: int = 120, +) -> dict[str, Any]: + nodes: list[dict[str, Any]] = graph.get("nodes") or [] + edges: list[dict[str, Any]] = graph.get("edges") or [] + node_by_id = {n["id"]: n for n in nodes if n.get("id")} + + seeds = _seed_ids(task, node_by_id) + selected: set[str] = set(seeds) + + # adjacency undirected for hop expansion + adj: dict[str, set[str]] = {nid: set() for nid in node_by_id} + for e in edges: + s, t = e.get("source"), e.get("target") + if s in adj and t in adj: + adj[s].add(t) + adj[t].add(s) + + frontier = set(seeds) + for _ in range(max(0, hops)): + nxt: set[str] = set() + for nid in frontier: + for nb in adj.get(nid, ()): + if nb not in selected: + nxt.add(nb) + selected |= nxt + frontier = nxt + if len(selected) >= max_nodes: + break + + # Prefer keeping seeds if we overflow + if len(selected) > max_nodes: + rest = [n for n in selected if n not in seeds] + selected = set(seeds) | set(rest[: max(0, max_nodes - len(seeds))]) + + pack_nodes: list[dict[str, Any]] = [] + for nid in sorted(selected): + n = node_by_id.get(nid) + if not n: + continue + pack_nodes.append( + { + "id": n.get("id"), + "type": n.get("type"), + "name": n.get("name"), + "filePath": _node_path(n), + "summary": (n.get("summary") or "")[:400], + "tags": n.get("tags") or [], + "complexity": n.get("complexity"), + } + ) + + pack_edges: list[dict[str, Any]] = [] + for e in edges: + s, t = e.get("source"), e.get("target") + if s in selected and t in selected: + pack_edges.append( + { + "source": s, + "target": t, + "type": _edge_type(e), + } + ) + if len(pack_edges) >= max_edges: + break + + layers = [] + for layer in graph.get("layers") or []: + ids = [i for i in (layer.get("nodeIds") or []) if i in selected] + if ids: + layers.append( + { + "id": layer.get("id"), + "name": layer.get("name"), + "nodeIds": ids, + } + ) + + return { + "task_id": task.get("id"), + "title": task.get("title"), + "hops": hops, + "seed_ids": sorted(seeds), + "stats": { + "nodes": len(pack_nodes), + "edges": len(pack_edges), + "seeds": len(seeds), + }, + "project": { + "name": (graph.get("project") or {}).get("name"), + "gitCommitHash": (graph.get("project") or {}).get("gitCommitHash"), + }, + "nodes": pack_nodes, + "edges": pack_edges, + "layers": layers, + "task": { + "id": task.get("id"), + "priority": task.get("priority"), + "status": task.get("status"), + "files": task.get("files") or [], + "problem": task.get("problem"), + "solution": task.get("solution"), + "acceptance_criteria": task.get("acceptance_criteria") or [], + "depends_on": task.get("depends_on") or [], + "estimated_risk": task.get("estimated_risk"), + "atomic_steps": task.get("atomic_steps") or [], + }, + } + + +def run( + task_id: str | None = None, + repo_root: Path | None = None, + hops: int = 1, +) -> dict[str, Any]: + root = repo_root or find_repo_root() + config = load_config(root) + graph = load_json(root / config["knowledge_graph"]["path"]) + backlog = load_json(root / config["backlog"]["path"]) + tasks = {t["id"]: t for t in backlog.get("tasks") or [] if t.get("id")} + + if not task_id: + selected_path = out_dir(root, config) / config["outputs"]["selected_task"] + if selected_path.is_file(): + task_id = load_json(selected_path).get("id") + if not task_id: + raise ValueError("task_id required (or run select first)") + if task_id not in tasks: + raise KeyError(f"Unknown task: {task_id}") + + pack = build_context_pack(graph, tasks[task_id], hops=hops) + out = out_dir(root, config) + write_json(out / "context-pack.json", pack) + return pack + + +if __name__ == "__main__": + import argparse + import json + + p = argparse.ArgumentParser() + p.add_argument("--task", default=None) + p.add_argument("--hops", type=int, default=1) + args = p.parse_args() + result = run(task_id=args.task, hops=args.hops) + print(json.dumps(result["stats"], indent=2)) diff --git a/scripts/agentic/cli.py b/scripts/agentic/cli.py new file mode 100644 index 000000000..eddffe262 --- /dev/null +++ b/scripts/agentic/cli.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""CLI entrypoint for the ccbot agentic pipeline. + +Usage (all commands; also listed by ``--help``): + python scripts/agentic/cli.py analyze + python scripts/agentic/cli.py plan [--task REF-001] + python scripts/agentic/cli.py select [--task REF-001] + python scripts/agentic/cli.py context [--task REF-001] [--hops N] + python scripts/agentic/cli.py status + python scripts/agentic/cli.py list [--status ready] + python scripts/agentic/cli.py validate [--base-ref origin/main] [--skip-quality] + python scripts/agentic/cli.py sync-issues [--apply] + python scripts/agentic/cli.py run [--task REF-001] [--skip-quality] + python scripts/agentic/cli.py propose --task ISS-014 [--diff-file PATH] + python scripts/agentic/cli.py show-proposal --latest + python scripts/agentic/cli.py import-proposal --task ISS-014 [--diff-file PATH] + python scripts/agentic/cli.py reject --proposal latest --reason "..." + python scripts/agentic/cli.py approve --proposal latest --message "..." + python scripts/agentic/cli.py request-changes --proposal latest --notes "..." + python scripts/agentic/cli.py apply --proposal latest +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Allow running as script: python scripts/agentic/cli.py +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from analyze_graph import run as run_analyze # noqa: E402 +from build_context_pack import run as run_context # noqa: E402 +from paths import find_repo_root # noqa: E402 +from prioritize import run as run_plan # noqa: E402 +from propose import ( # noqa: E402 + HITL_HANDLERS, + PROPOSE_HANDLERS, + register_hitl_commands, + register_propose_commands, +) +from propose import ( # noqa: E402 + load_task as svc_load_task, +) +from render_prompt import run as run_render # noqa: E402 +from service import list_tasks as svc_list_tasks # noqa: E402 +from service import pipeline_status as svc_pipeline_status # noqa: E402 +from sync_issues import sync as run_sync # noqa: E402 +from validate_changes import run as run_validate # noqa: E402 + +# Keep in sync with build_parser() + PROPOSE_HANDLERS + HITL_HANDLERS. +_ALL_COMMANDS = ( + "analyze", + "plan", + "select", + "context", + "status", + "list", + "validate", + "sync-issues", + "run", + "propose", + "show-proposal", + "import-proposal", + "reject", + "approve", + "request-changes", + "apply", +) + + +def cmd_analyze(_args: argparse.Namespace) -> int: + report = run_analyze() + stats = report.get("stats") or {} + print( + f"analyze_ok nodes={stats.get('nodes')} edges={stats.get('edges')} " + f"hotspots={len(report.get('hotspots') or [])}" + ) + return 0 + + +def cmd_plan(args: argparse.Namespace) -> int: + plan = run_plan(task_id=args.task) + print(f"plan_ok selected={plan.get('selected_task_id')}") + for r in (plan.get("ranked_tasks") or [])[:5]: + print( + f" {r.get('score'):4} {r.get('id')} {r.get('status')} {r.get('title')}" + ) + return 0 + + +def cmd_select(args: argparse.Namespace) -> int: + plan = run_plan(task_id=args.task) + if not plan.get("selected_task_id"): + print("select_failed: no selectable task", file=sys.stderr) + return 1 + paths = run_render() + print(f"select_ok task={plan.get('selected_task_id')}") + for k, v in paths.items(): + print(f" {k}={v}") + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + evidence = None + if args.task: + try: + task = svc_load_task(args.task) + except RuntimeError as exc: + print(f"validate_failed: {exc}", file=sys.stderr) + return 2 + evidence = task.get("evidence") or {} + if str(task.get("kind") or "") == "structural" and not evidence: + print( + f"validate_failed: structural task {args.task} has no evidence block", + file=sys.stderr, + ) + return 2 + report = run_validate( + base_ref=args.base_ref, + skip_quality=args.skip_quality, + evidence=evidence, + ) + print( + json.dumps( + {"ok": report.get("ok"), "guardrails": report.get("guardrails")}, indent=2 + ) + ) + for q in report.get("quality") or []: + status = "OK" if q.get("ok") else "FAIL" + print(f"[{status}] {' '.join(q.get('cmd') or [])}") + evidence_report = report.get("evidence") or {} + for c in evidence_report.get("checks") or []: + status = "OK" if c.get("ok") else "FAIL" + print( + f"[{status}] evidence {c.get('check')}: {c.get('target')} ({c.get('detail')})" + ) + return 0 if report.get("ok") else 1 + + +def cmd_sync(args: argparse.Namespace) -> int: + result = run_sync(dry_run=not args.apply) + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + """Full local pipeline without LLM implement step.""" + print("== ANALYZE ==") + run_analyze() + print("== PLAN ==") + plan = run_plan(task_id=args.task) + print(f"selected={plan.get('selected_task_id')}") + if not plan.get("selected_task_id"): + print("No task selected; stop before implement.") + return 1 + print("== RENDER PROMPTS ==") + paths = run_render() + for k, v in paths.items(): + print(f" {k}={v}") + print("== CONTEXT PACK ==") + pack = run_context(task_id=plan.get("selected_task_id")) + print( + f" nodes={pack.get('stats', {}).get('nodes')} edges={pack.get('stats', {}).get('edges')}" + ) + print("== VALIDATE (workspace) ==") + report = run_validate(base_ref=args.base_ref, skip_quality=args.skip_quality) + print(f"validate_ok={report.get('ok')}") + print() + print("Next: feed .agentic/out/implement-prompt.md to Claude Code / agent,") + print("or use MCP tools: ccbot-agentic__get_implement_prompt") + print("then re-run: python scripts/agentic/cli.py validate") + return 0 if report.get("ok") or args.skip_quality else 0 + + +def cmd_context(args: argparse.Namespace) -> int: + pack = run_context(task_id=args.task, hops=args.hops) + print( + f"context_ok task={pack.get('task_id')} " + f"nodes={pack['stats']['nodes']} edges={pack['stats']['edges']}" + ) + return 0 + + +def cmd_status(_args: argparse.Namespace) -> int: + print(json.dumps(svc_pipeline_status(), indent=2, ensure_ascii=False)) + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + print(json.dumps(svc_list_tasks(status=args.status), indent=2, ensure_ascii=False)) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="agentic", + description="ccbot agentic pipeline CLI", + ) + # title + explicit metavar so --help always lists every subcommand + sub = p.add_subparsers( + dest="command", + required=True, + title="commands", + metavar="{" + ",".join(_ALL_COMMANDS) + "}", + ) + + sub.add_parser("analyze", help="Analyze knowledge graph → .agentic/out/") + + p_plan = sub.add_parser("plan", help="Rank backlog and select next task") + p_plan.add_argument("--task", help="Force task id (e.g. REF-001)") + + p_sel = sub.add_parser("select", help="Plan + render implement prompt") + p_sel.add_argument("--task", help="Force task id") + + p_ctx = sub.add_parser( + "context", help="Build knowledge-graph context pack for a task" + ) + p_ctx.add_argument("--task", default=None, help="Task id (default: selected)") + p_ctx.add_argument("--hops", type=int, default=1) + + sub.add_parser("status", help="Pipeline / artifact health check") + + p_list = sub.add_parser("list", help="List backlog tasks") + p_list.add_argument("--status", default=None, help="Filter e.g. ready,planned") + + p_val = sub.add_parser("validate", help="Guardrails + ruff/pyright/pytest") + p_val.add_argument("--base-ref", default=None) + p_val.add_argument("--skip-quality", action="store_true") + p_val.add_argument("--task", help="Also check task evidence patterns (ISS-XXX)") + + p_sync = sub.add_parser("sync-issues", help="Create GitHub issues from backlog") + p_sync.add_argument("--apply", action="store_true") + + p_run = sub.add_parser("run", help="analyze → plan → select (no auto-edit)") + p_run.add_argument("--task", help="Force task id") + p_run.add_argument("--base-ref", default=None) + p_run.add_argument("--skip-quality", action="store_true") + + register_propose_commands(sub) + register_hitl_commands(sub) + + return p + + +def main(argv: list[str] | None = None) -> int: + # Ensure cwd-friendly root detection + find_repo_root() + parser = build_parser() + args = parser.parse_args(argv) + handlers = { + "analyze": cmd_analyze, + "plan": cmd_plan, + "select": cmd_select, + "context": cmd_context, + "status": cmd_status, + "list": cmd_list, + "validate": cmd_validate, + "sync-issues": cmd_sync, + "run": cmd_run, + } + handlers.update(PROPOSE_HANDLERS) + handlers.update(HITL_HANDLERS) + return handlers[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/agentic/mcp_server.py b/scripts/agentic/mcp_server.py new file mode 100644 index 000000000..64b49fcb3 --- /dev/null +++ b/scripts/agentic/mcp_server.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +"""Stdio MCP server: ccbot-agentic. + +Exposes the agentic pipeline (knowledge graph + backlog + validate) as MCP tools +for Grok / Claude / any MCP host. + +Transport: JSON-RPC 2.0 over stdin/stdout (Model Context Protocol subset). +Dependencies: Python 3.12+ stdlib only. + +Configure (project scope):: + + # .grok/config.toml + [mcp_servers.ccbot-agentic] + command = "python" + args = ["scripts/agentic/mcp_server.py"] + env = { CCBOT_REPO_ROOT = "D:\\\\CCbot_tmux\\\\ccbot\\\\ccbot_ju1ian" } + enabled = true + +Or with absolute script path so cwd does not matter. +""" + +from __future__ import annotations + +import json +import os +import sys +import traceback +from pathlib import Path +from typing import Any, Callable + +# Ensure scripts/agentic is on path when launched as a script +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +# Resolve repo root early (env override for MCP hosts that set cwd elsewhere) +_REPO_ENV = os.environ.get("CCBOT_REPO_ROOT") or os.environ.get("AGENTIC_REPO_ROOT") +if _REPO_ENV: + os.chdir(_REPO_ENV) + +from paths import find_repo_root # noqa: E402 +from service import ( # noqa: E402 + dumps_result, + get_analysis, + get_context_pack, + get_implement_prompt, + get_task, + list_tasks, + mark_task_status, + pipeline_status, + select_next_task, + validate, +) + +SERVER_NAME = "ccbot-agentic" +SERVER_VERSION = "1.0.0" +PROTOCOL_VERSION = "2024-11-05" + + +def _root() -> Path: + env = os.environ.get("CCBOT_REPO_ROOT") or os.environ.get("AGENTIC_REPO_ROOT") + if env: + return Path(env).resolve() + return find_repo_root(Path.cwd()) + + +def _ok_text(data: Any) -> dict[str, Any]: + text = data if isinstance(data, str) else dumps_result(data) + return {"content": [{"type": "text", "text": text}], "isError": False} + + +def _err_text(message: str) -> dict[str, Any]: + return {"content": [{"type": "text", "text": message}], "isError": True} + + +# --------------------------------------------------------------------------- +# Tool handlers +# --------------------------------------------------------------------------- + + +def tool_list_tasks(args: dict[str, Any]) -> dict[str, Any]: + return _ok_text( + list_tasks( + status=args.get("status"), + root=_root(), + include_ranking=bool(args.get("include_ranking", True)), + ) + ) + + +def tool_get_task(args: dict[str, Any]) -> dict[str, Any]: + task_id = args.get("task_id") + if not task_id: + return _err_text("task_id is required") + return _ok_text(get_task(str(task_id), root=_root())) + + +def tool_select_next_task(args: dict[str, Any]) -> dict[str, Any]: + return _ok_text( + select_next_task( + task_id=args.get("task_id") or None, + root=_root(), + skip_analyze=bool(args.get("skip_analyze", False)), + ) + ) + + +def tool_get_analysis(args: dict[str, Any]) -> dict[str, Any]: + return _ok_text( + get_analysis(root=_root(), refresh=bool(args.get("refresh", False))) + ) + + +def tool_get_context_pack(args: dict[str, Any]) -> dict[str, Any]: + hops = int(args.get("hops") or 1) + return _ok_text( + get_context_pack( + task_id=args.get("task_id") or None, + hops=hops, + root=_root(), + refresh=bool(args.get("refresh", True)), + ) + ) + + +def tool_get_implement_prompt(args: dict[str, Any]) -> dict[str, Any]: + # optional re-select + if args.get("task_id"): + select_next_task(task_id=str(args["task_id"]), root=_root()) + return _ok_text(get_implement_prompt(root=_root())) + + +def tool_mark_task_status(args: dict[str, Any]) -> dict[str, Any]: + task_id = args.get("task_id") + status = args.get("status") + if not task_id or not status: + return _err_text("task_id and status are required") + return _ok_text( + mark_task_status( + str(task_id), + str(status), + note=str(args.get("note") or ""), + root=_root(), + ) + ) + + +def tool_run_validate(args: dict[str, Any]) -> dict[str, Any]: + return _ok_text( + validate( + base_ref=args.get("base_ref"), + skip_quality=bool(args.get("skip_quality", True)), + root=_root(), + ) + ) + + +def tool_pipeline_status(args: dict[str, Any]) -> dict[str, Any]: + return _ok_text(pipeline_status(root=_root())) + + +def tool_run_analyze(args: dict[str, Any]) -> dict[str, Any]: + from analyze_graph import run as run_analyze + + report = run_analyze(repo_root=_root()) + return _ok_text( + { + "ok": True, + "stats": report.get("stats"), + "hotspots_top": [ + {"path": h.get("path"), "score": h.get("score")} + for h in (report.get("hotspots") or [])[:10] + ], + "recommendations": report.get("recommendations"), + } + ) + + +TOOLS: dict[str, dict[str, Any]] = { + "list_tasks": { + "description": ( + "List agentic backlog tasks (REF-*) with optional status filter and ranking scores. " + "Use to see what refactoring work is ready." + ), + "inputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": ( + "Comma-separated statuses: ready,planned,in_progress,done,blocked,cancelled. " + "Omit for all." + ), + }, + "include_ranking": { + "type": "boolean", + "description": "Include score/selectable from plan (default true).", + "default": True, + }, + }, + }, + "handler": tool_list_tasks, + }, + "get_task": { + "description": "Get full JSON for one backlog task by id (e.g. REF-001).", + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id, e.g. REF-001", + } + }, + "required": ["task_id"], + }, + "handler": tool_get_task, + }, + "select_next_task": { + "description": ( + "Analyze knowledge graph (unless skip_analyze), rank backlog, select next task, " + "render implement-prompt.md and context-pack.json. Optionally force task_id." + ), + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Force this task id instead of auto-select.", + }, + "skip_analyze": { + "type": "boolean", + "description": "Skip knowledge-graph analyze step (default false).", + "default": False, + }, + }, + }, + "handler": tool_select_next_task, + }, + "get_analysis": { + "description": ( + "Return slim knowledge-graph analysis: hotspots, layers, recommendations. " + "Set refresh=true to recompute from knowledge-graph.json." + ), + "inputSchema": { + "type": "object", + "properties": { + "refresh": { + "type": "boolean", + "default": False, + } + }, + }, + "handler": tool_get_analysis, + }, + "get_context_pack": { + "description": ( + "Build or return a task-scoped subgraph slice from the knowledge graph " + "(nodes/edges/layers around task files) for agent prompts." + ), + "inputSchema": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id; default = currently selected task.", + }, + "hops": { + "type": "integer", + "description": "Graph neighborhood hops (default 1).", + "default": 1, + }, + "refresh": { + "type": "boolean", + "default": True, + }, + }, + }, + "handler": tool_get_context_pack, + }, + "get_implement_prompt": { + "description": ( + "Return the full implement-prompt.md (+ pr body) for the selected task. " + "Optionally pass task_id to re-select first." + ), + "inputSchema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + }, + }, + "handler": tool_get_implement_prompt, + }, + "mark_task_status": { + "description": "Update backlog task status in .agentic/backlog/tasks.json.", + "inputSchema": { + "type": "object", + "properties": { + "task_id": {"type": "string"}, + "status": { + "type": "string", + "enum": [ + "ready", + "planned", + "in_progress", + "done", + "blocked", + "cancelled", + ], + }, + "note": {"type": "string"}, + }, + "required": ["task_id", "status"], + }, + "handler": tool_mark_task_status, + }, + "run_validate": { + "description": ( + "Run path guardrails (+ optional ruff/pyright/pytest) on the working tree. " + "skip_quality=true (default) only checks path allow/deny lists." + ), + "inputSchema": { + "type": "object", + "properties": { + "base_ref": { + "type": "string", + "description": "Git base ref for diff, e.g. origin/main", + }, + "skip_quality": { + "type": "boolean", + "default": True, + }, + }, + }, + "handler": tool_run_validate, + }, + "run_analyze": { + "description": "Recompute knowledge-graph hotspot analysis into .agentic/out/.", + "inputSchema": {"type": "object", "properties": {}}, + "handler": tool_run_analyze, + }, + "pipeline_status": { + "description": ( + "Health check: repo root, graph/backlog presence, generated artifacts, selected task." + ), + "inputSchema": {"type": "object", "properties": {}}, + "handler": tool_pipeline_status, + }, +} + + +def _tools_list_payload() -> list[dict[str, Any]]: + return [ + { + "name": name, + "description": meta["description"], + "inputSchema": meta["inputSchema"], + } + for name, meta in TOOLS.items() + ] + + +def _handle_tools_call(params: dict[str, Any]) -> dict[str, Any]: + name = params.get("name") + arguments = params.get("arguments") or {} + if name not in TOOLS: + return _err_text(f"Unknown tool: {name}") + handler: Callable[[dict[str, Any]], dict[str, Any]] = TOOLS[name]["handler"] + try: + return handler(arguments if isinstance(arguments, dict) else {}) + except Exception as exc: # noqa: BLE001 — return to MCP host + tb = traceback.format_exc(limit=8) + return _err_text(f"{type(exc).__name__}: {exc}\n{tb}") + + +def _dispatch(msg: dict[str, Any]) -> dict[str, Any] | None: + """Return JSON-RPC response dict, or None for notifications.""" + method = msg.get("method") + msg_id = msg.get("id", None) + params = msg.get("params") or {} + + # Notifications (no id) — acknowledge silently + if msg_id is None and method and not method.startswith("tools/"): + if method == "notifications/initialized": + return None + return None + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": msg_id, + "result": { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + }, + } + + if method == "ping": + return {"jsonrpc": "2.0", "id": msg_id, "result": {}} + + if method == "tools/list": + return { + "jsonrpc": "2.0", + "id": msg_id, + "result": {"tools": _tools_list_payload()}, + } + + if method == "tools/call": + result = _handle_tools_call(params if isinstance(params, dict) else {}) + return {"jsonrpc": "2.0", "id": msg_id, "result": result} + + if method == "resources/list": + return {"jsonrpc": "2.0", "id": msg_id, "result": {"resources": []}} + + if method == "prompts/list": + return {"jsonrpc": "2.0", "id": msg_id, "result": {"prompts": []}} + + # Unknown method + return { + "jsonrpc": "2.0", + "id": msg_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + } + + +def _read_message() -> dict[str, Any] | None: + """Read one JSON-RPC message (Content-Length framing or newline-delimited).""" + # Prefer Content-Length framing (MCP standard) + header_lines: list[str] = [] + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + if line in (b"\r\n", b"\n"): + break + header_lines.append(line.decode("utf-8", errors="replace").rstrip("\r\n")) + # Newline-delimited JSON fallback: first line is full JSON + if not header_lines[0].lower().startswith("content-length:") and header_lines[ + 0 + ].lstrip().startswith("{"): + return json.loads(header_lines[0]) + + headers = {} + for h in header_lines: + if ":" in h: + k, v = h.split(":", 1) + headers[k.strip().lower()] = v.strip() + + length = int(headers.get("content-length", "0")) + if length <= 0: + return None + body = sys.stdin.buffer.read(length) + if not body: + return None + return json.loads(body.decode("utf-8")) + + +def _write_message(msg: dict[str, Any]) -> None: + raw = json.dumps(msg, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("ascii")) + sys.stdout.buffer.write(raw) + sys.stdout.buffer.flush() + + +def main() -> int: + # Log root to stderr only (stdout is protocol) + try: + root = _root() + sys.stderr.write(f"[ccbot-agentic] repo_root={root}\n") + sys.stderr.flush() + except Exception as exc: # noqa: BLE001 + sys.stderr.write(f"[ccbot-agentic] failed to resolve root: {exc}\n") + sys.stderr.flush() + + while True: + try: + msg = _read_message() + except json.JSONDecodeError as exc: + _write_message( + { + "jsonrpc": "2.0", + "id": None, + "error": {"code": -32700, "message": f"Parse error: {exc}"}, + } + ) + continue + if msg is None: + break + if not isinstance(msg, dict): + continue + # batch not supported + resp = _dispatch(msg) + if resp is not None: + _write_message(resp) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/agentic/mcp_smoke_test.py b/scripts/agentic/mcp_smoke_test.py new file mode 100644 index 000000000..8e0952293 --- /dev/null +++ b/scripts/agentic/mcp_smoke_test.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Smoke-test ccbot-agentic MCP server over stdio (Content-Length framing).""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SERVER = ROOT / "scripts" / "agentic" / "mcp_server.py" + + +def _frame(obj: dict) -> bytes: + raw = json.dumps(obj, separators=(",", ":")).encode("utf-8") + return f"Content-Length: {len(raw)}\r\n\r\n".encode("ascii") + raw + + +def _read_one(proc: subprocess.Popen[bytes]) -> dict: + assert proc.stdout is not None + headers: dict[str, str] = {} + while True: + line = proc.stdout.readline() + if not line: + raise RuntimeError("EOF from server") + if line in (b"\r\n", b"\n"): + break + text = line.decode("utf-8", errors="replace").rstrip("\r\n") + if ":" in text: + k, v = text.split(":", 1) + headers[k.strip().lower()] = v.strip() + n = int(headers.get("content-length", "0")) + body = proc.stdout.read(n) + return json.loads(body.decode("utf-8")) + + +def main() -> int: + env = os.environ.copy() + env["CCBOT_REPO_ROOT"] = str(ROOT) + env["PYTHONIOENCODING"] = "utf-8" + proc = subprocess.Popen( + [sys.executable, str(SERVER)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=str(ROOT), + env=env, + ) + assert proc.stdin and proc.stdout + + failures = 0 + + def call(method: str, params: dict | None = None, msg_id: int = 1) -> dict: + msg = {"jsonrpc": "2.0", "id": msg_id, "method": method} + if params is not None: + msg["params"] = params + proc.stdin.write(_frame(msg)) + proc.stdin.flush() + return _read_one(proc) + + try: + init = call( + "initialize", + { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "mcp_smoke_test", "version": "1.0"}, + }, + msg_id=1, + ) + assert "result" in init, init + print("OK initialize", init["result"]["serverInfo"]) + + # notification — no response expected; send and continue + proc.stdin.write( + _frame({"jsonrpc": "2.0", "method": "notifications/initialized"}) + ) + proc.stdin.flush() + + tools = call("tools/list", {}, msg_id=2) + names = [t["name"] for t in tools["result"]["tools"]] + print("OK tools/list", len(names), "tools:", ", ".join(names)) + required = { + "list_tasks", + "get_task", + "select_next_task", + "get_analysis", + "get_context_pack", + "get_implement_prompt", + "mark_task_status", + "run_validate", + "run_analyze", + "pipeline_status", + } + missing = required - set(names) + if missing: + print("FAIL missing tools", missing) + failures += 1 + + status = call( + "tools/call", + {"name": "pipeline_status", "arguments": {}}, + msg_id=3, + ) + body = status["result"]["content"][0]["text"] + data = json.loads(body) + print("OK pipeline_status graph_present=", data.get("graph_present")) + + listed = call( + "tools/call", + {"name": "list_tasks", "arguments": {"status": "ready"}}, + msg_id=4, + ) + listed_data = json.loads(listed["result"]["content"][0]["text"]) + print("OK list_tasks count=", listed_data.get("count")) + + pack = call( + "tools/call", + { + "name": "get_context_pack", + "arguments": {"task_id": "REF-001", "hops": 1}, + }, + msg_id=5, + ) + pack_data = json.loads(pack["result"]["content"][0]["text"]) + if pack["result"].get("isError"): + print("FAIL get_context_pack", pack_data) + failures += 1 + else: + print( + "OK get_context_pack", + pack_data.get("stats"), + "task=", + pack_data.get("task_id"), + ) + + except Exception as exc: # noqa: BLE001 + print("FAIL", type(exc).__name__, exc) + err = ( + proc.stderr.read().decode("utf-8", errors="replace") if proc.stderr else "" + ) + if err: + print("--- stderr ---") + print(err[-3000:]) + failures += 1 + finally: + proc.kill() + proc.wait(timeout=5) + + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/agentic/paths.py b/scripts/agentic/paths.py new file mode 100644 index 000000000..64ac0eca5 --- /dev/null +++ b/scripts/agentic/paths.py @@ -0,0 +1,46 @@ +"""Resolve repository root and .agentic paths.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def find_repo_root(start: Path | None = None) -> Path: + """Walk parents until pyproject.toml + .agentic/ are found.""" + cur = (start or Path.cwd()).resolve() + for candidate in [cur, *cur.parents]: + if (candidate / "pyproject.toml").is_file() and ( + candidate / ".agentic" + ).is_dir(): + return candidate + # Fallback: scripts/agentic/../../ + here = Path(__file__).resolve().parent + return here.parent.parent + + +def load_json(path: Path) -> Any: + with path.open(encoding="utf-8-sig") as f: + return json.load(f) + + +def write_json(path: Path, data: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write("\n") + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def load_config(repo_root: Path | None = None) -> dict[str, Any]: + root = repo_root or find_repo_root() + return load_json(root / ".agentic" / "config.json") + + +def out_dir(repo_root: Path, config: dict[str, Any]) -> Path: + return repo_root / config["outputs"]["dir"] diff --git a/scripts/agentic/prioritize.py b/scripts/agentic/prioritize.py new file mode 100644 index 000000000..4866523d2 --- /dev/null +++ b/scripts/agentic/prioritize.py @@ -0,0 +1,246 @@ +"""Rank backlog tasks using priority weights + graph path scores.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from paths import ( + find_repo_root, + load_config, + load_json, + out_dir, + write_json, + write_text, +) + +TERMINAL_STATUS = frozenset({"done", "cancelled", "wontfix"}) +ACTIVE_STATUS = frozenset({"ready", "planned", "in_progress", "blocked"}) + + +def _task_file_paths(task: dict[str, Any]) -> list[str]: + paths: list[str] = [] + for f in task.get("files") or []: + p = str(f).replace("\\", "/") + if p.endswith("/"): + paths.append(p.rstrip("/")) + else: + paths.append(p) + return paths + + +def _deps_satisfied(task: dict[str, Any], by_id: dict[str, dict[str, Any]]) -> bool: + for dep in task.get("depends_on") or []: + other = by_id.get(dep) + if not other: + return False + if other.get("status") != "done": + return False + return True + + +def score_task( + task: dict[str, Any], + config: dict[str, Any], + path_scores: dict[str, int], + by_id: dict[str, dict[str, Any]], +) -> dict[str, Any]: + weights = config["prioritization"]["priority_weights"] + priority = str(task.get("priority") or "low").lower() + base = int(weights.get(priority, weights.get("low", 15))) + status = str(task.get("status") or "planned").lower() + + graph_bonus = 0 + matched_paths: list[str] = [] + for fpath in _task_file_paths(task): + # exact + prefix match for directories + if fpath in path_scores: + graph_bonus += path_scores[fpath] + matched_paths.append(fpath) + else: + for p, sc in path_scores.items(): + if p.startswith(fpath.rstrip("/") + "/") or fpath.startswith(p): + graph_bonus += sc + matched_paths.append(p) + break + + # Related graph nodes + for nid in task.get("related_nodes") or []: + # node id file:src/... → path + if isinstance(nid, str) and nid.startswith("file:"): + p = nid[len("file:") :] + if p in path_scores: + graph_bonus += path_scores[p] + matched_paths.append(p) + + # Cap graph bonus to avoid drowning priority + graph_bonus = min(graph_bonus, 40) + + status_bonus = 0 + if status == "ready": + status_bonus = 10 + elif status == "in_progress": + status_bonus = 5 + elif status == "planned": + status_bonus = 0 + elif status == "blocked": + status_bonus = -50 + + deps_ok = _deps_satisfied(task, by_id) + dep_penalty = 0 if deps_ok else -100 + + risk = str(task.get("estimated_risk") or "medium").lower() + risk_adj = {"low": 3, "medium": 0, "high": -5}.get(risk, 0) + + total = base + graph_bonus + status_bonus + dep_penalty + risk_adj + selectable = ( + status in {"ready", "planned", "in_progress"} + and deps_ok + and status != "blocked" + and status not in TERMINAL_STATUS + ) + + return { + "id": task.get("id"), + "title": task.get("title"), + "priority": priority, + "status": status, + "score": total, + "components": { + "base_priority": base, + "graph_bonus": graph_bonus, + "status_bonus": status_bonus, + "dep_penalty": dep_penalty, + "risk_adj": risk_adj, + }, + "deps_satisfied": deps_ok, + "depends_on": list(task.get("depends_on") or []), + "matched_hotspot_paths": sorted(set(matched_paths)), + "selectable": selectable, + "estimated_risk": risk, + "categories": list(task.get("categories") or []), + "files": list(task.get("files") or []), + } + + +def build_plan( + backlog: dict[str, Any], + analysis: dict[str, Any] | None, + config: dict[str, Any], +) -> dict[str, Any]: + tasks = list(backlog.get("tasks") or []) + by_id = {t["id"]: t for t in tasks if t.get("id")} + path_scores = (analysis or {}).get("path_scores") or {} + + ranked = [score_task(t, config, path_scores, by_id) for t in tasks] + ranked.sort(key=lambda r: (-r["score"], r.get("id") or "")) + + selectable = [r for r in ranked if r["selectable"]] + selected = selectable[0] if selectable else None + + selected_full = None + if selected: + selected_full = dict(by_id[selected["id"]]) + selected_full["_ranking"] = selected + + return { + "generated_at": datetime.now(timezone.utc).isoformat(), + "graph_commit": ((analysis or {}).get("project") or {}).get("gitCommitHash"), + "ranked_tasks": ranked, + "selected_task_id": selected["id"] if selected else None, + "selected_task": selected_full, + "blocked_or_waiting": [ + r + for r in ranked + if not r["selectable"] and r.get("status") not in TERMINAL_STATUS + ], + "recommendations": (analysis or {}).get("recommendations") or [], + } + + +def render_plan_md(plan: dict[str, Any]) -> str: + lines = [ + "# Agentic implementation plan", + "", + f"- Generated: `{plan.get('generated_at')}`", + f"- Graph commit: `{plan.get('graph_commit')}`", + f"- Selected: **{plan.get('selected_task_id') or 'none'}**", + "", + "## Ranking", + "", + "| Rank | ID | Score | Priority | Status | Deps OK | Title |", + "|-----:|----|------:|----------|--------|---------|-------|", + ] + for i, r in enumerate(plan.get("ranked_tasks") or [], 1): + lines.append( + f"| {i} | `{r.get('id')}` | {r.get('score')} | {r.get('priority')} | " + f"{r.get('status')} | {r.get('deps_satisfied')} | {r.get('title')} |" + ) + sel = plan.get("selected_task") + if sel: + lines += [ + "", + f"## Selected task `{sel.get('id')}`", + "", + f"**{sel.get('title')}**", + "", + f"- Priority: {sel.get('priority')}", + f"- Risk: {sel.get('estimated_risk')}", + f"- Files: {', '.join(f'`{f}`' for f in (sel.get('files') or []))}", + "", + "### Problem", + "", + str(sel.get("problem") or ""), + "", + "### Solution", + "", + str(sel.get("solution") or ""), + "", + "### Acceptance criteria", + "", + ] + for c in sel.get("acceptance_criteria") or []: + lines.append(f"- [ ] {c}") + lines.append("") + return "\n".join(lines) + + +def run( + repo_root: Path | None = None, + task_id: str | None = None, +) -> dict[str, Any]: + root = repo_root or find_repo_root() + config = load_config(root) + backlog = load_json(root / config["backlog"]["path"]) + out = out_dir(root, config) + analysis_path = out / config["outputs"]["analysis_report"] + analysis = load_json(analysis_path) if analysis_path.is_file() else None + + plan = build_plan(backlog, analysis, config) + + if task_id: + by_id = {t["id"]: t for t in backlog.get("tasks") or []} + if task_id not in by_id: + raise KeyError(f"Unknown task id: {task_id}") + # Re-score forced task; mark selected even if not top + path_scores = (analysis or {}).get("path_scores") or {} + ranking = score_task(by_id[task_id], config, path_scores, by_id) + full = dict(by_id[task_id]) + full["_ranking"] = ranking + plan["selected_task_id"] = task_id + plan["selected_task"] = full + plan["forced_task_id"] = task_id + + write_json(out / config["outputs"]["plan"], plan) + write_text(out / config["outputs"]["plan_markdown"], render_plan_md(plan)) + + selected = plan.get("selected_task") + if selected: + write_json(out / config["outputs"]["selected_task"], selected) + return plan + + +if __name__ == "__main__": + p = run() + print(f"Selected: {p.get('selected_task_id')}") diff --git a/scripts/agentic/propose.py b/scripts/agentic/propose.py new file mode 100644 index 000000000..1d798b9a5 --- /dev/null +++ b/scripts/agentic/propose.py @@ -0,0 +1,1982 @@ +#!/usr/bin/env python3 +"""Proposal subsystem for the ccbot agentic pipeline. + +Adds commands: + propose create proposal artifacts without applying changes + import-proposal validate/finalize a manually created proposal.diff + show-proposal show proposal artifacts + reject write reject artifact; no working-tree change + approve create approval.json after base_sha freshness check + request-changes archive proposal and save reviewer notes + apply apply diff only with approval + matching base_sha (feature branch) + +This module intentionally reuses existing pipeline functions: + prioritize.run -- select/plan task + render_prompt.run -- render implement prompt + build_context_pack.run -- build context pack + paths.find_repo_root -- repo root detection +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from fnmatch import fnmatch +from pathlib import Path + +try: + from paths import find_repo_root + + ROOT = Path(find_repo_root()) +except Exception: + ROOT = Path(__file__).resolve().parents[2] + +AGENTIC_DIR = ROOT / ".agentic" +OUT_DIR = AGENTIC_DIR / "out" +PROPOSALS_DIR = OUT_DIR / "proposals" +REJECTS_DIR = OUT_DIR / "rejects" +ARCHIVE_DIR = PROPOSALS_DIR / "archive" +TMP_DIR = AGENTIC_DIR / "tmp" + +CONFIG_PATH = AGENTIC_DIR / "config.json" +TASKS_PATH = AGENTIC_DIR / "backlog" / "tasks.json" + +DEFAULT_BASE_REF = "origin/main" +PROTECTED_BRANCHES = frozenset({"main", "master"}) + +DEFAULT_CONFIG = { + "implementation": { + "max_files_changed": 40, + "max_loc_delta": 2000, + }, + "guardrails": { + "allow": [ + "src/ccbot/**/*.py", + "tests/**", + ".agentic/**", + "scripts/agentic/**", + ".gitignore", + "REPAIR_ROBOT_INSTRUCTION.md", + ], + "block": [ + ".env", + ".env.*", + "**/.env", + "**/.env.*", + "state.json", + "**/state.json", + "session_map.json", + "**/session_map.json", + ".github/workflows/check.yml", + ], + }, + "human_gate": { + "required_before_apply": True, + "allow_auto_propose": True, + "allow_auto_apply": False, + }, +} + + +# --------------------------------------------------------------------------- +# Basic utilities +# --------------------------------------------------------------------------- + + +def utc_stamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def iso_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def run( + cmd: list[str | Path], + cwd: Path = ROOT, + check: bool = True, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + cmd = [str(item) for item in cmd] + printable = " ".join(cmd) + print(f"[propose] + {printable}") + + return subprocess.run( + cmd, + cwd=str(cwd), + check=check, + text=True, + capture_output=True, + env=env, + ) + + +def git(*args: str, cwd: Path = ROOT) -> str: + result = run(["git", *args], cwd=cwd) + return result.stdout.strip() + + +def load_json(path: Path): + if not path.exists(): + return {} + + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def write_json(path: Path, payload) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + + with tmp.open("w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + + tmp.replace(path) + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def write_bytes(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def deep_merge(base: dict, override: dict) -> dict: + for key, value in override.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + deep_merge(base[key], value) + else: + base[key] = value + + return base + + +def load_config() -> dict: + config = json.loads(json.dumps(DEFAULT_CONFIG)) + + if CONFIG_PATH.exists(): + user_config = load_json(CONFIG_PATH) + if isinstance(user_config, dict): + deep_merge(config, user_config) + + return config + + +# --------------------------------------------------------------------------- +# Task loading +# --------------------------------------------------------------------------- + + +def task_id_from(task: dict) -> str | None: + return task.get("id") or task.get("task_id") or task.get("name") + + +def iter_tasks(data): + if isinstance(data, list): + for task in data: + if isinstance(task, dict): + yield task + return + + if isinstance(data, dict): + tasks = data.get("tasks") + if isinstance(tasks, list): + for task in tasks: + if isinstance(task, dict): + yield task + return + + # Fallback: dict keyed by task id. + for key, value in data.items(): + if isinstance(value, dict): + if task_id_from(value) is None: + value.setdefault("id", key) + yield value + + +def load_task(task_id: str) -> dict: + if not TASKS_PATH.exists(): + raise RuntimeError(f"Tasks file not found: {TASKS_PATH}") + + data = load_json(TASKS_PATH) + + for task in iter_tasks(data): + if task_id_from(task) == task_id: + return task + + raise RuntimeError(f"Task not found: {task_id}") + + +def update_task_status(task_id: str, status: str, proposal_id: str) -> None: + if not TASKS_PATH.exists(): + raise RuntimeError(f"Tasks file not found: {TASKS_PATH}") + + data = load_json(TASKS_PATH) + changed = False + + for task in iter_tasks(data): + if task_id_from(task) == task_id: + task["status"] = status + task["last_proposal_id"] = proposal_id + task["updated_at"] = iso_now() + changed = True + break + + if not changed: + raise RuntimeError(f"Task not found for status update: {task_id}") + + write_json(TASKS_PATH, data) + + +# --------------------------------------------------------------------------- +# Git safety +# --------------------------------------------------------------------------- + + +def ensure_clean_tree(allow_dirty: bool) -> None: + status = git("status", "--porcelain") + + if not status: + return + + relevant_lines = [] + + for line in status.splitlines(): + # Porcelain format: XY PATH + path = line[3:].strip().strip('"').replace("\\", "/") + + if path.startswith(".agentic/out/"): + continue + + if path.startswith(".agentic/tmp/"): + continue + + relevant_lines.append(line) + + if relevant_lines and not allow_dirty: + raise RuntimeError( + "Working tree is dirty. Commit/stash changes or use --allow-dirty.\n" + + "\n".join(relevant_lines) + ) + + +# --------------------------------------------------------------------------- +# Context / prompt preparation: reuse existing pipeline functions +# --------------------------------------------------------------------------- + + +def _task_acceptance(task: dict) -> object: + """Prefer structured acceptance_criteria; fall back to acceptance blob.""" + if task.get("acceptance_criteria") is not None: + return task.get("acceptance_criteria") + if task.get("acceptance") is not None: + return task.get("acceptance") + return [] + + +def render_default_prompt(task: dict) -> str: + """Build implement prompt without triple-quoted f-strings (syntax-safe).""" + task_id = task_id_from(task) or "UNKNOWN" + title = str(task.get("title") or task.get("name") or "") + acceptance = _task_acceptance(task) + acceptance_json = json.dumps(acceptance, ensure_ascii=False, indent=2) + problem = str(task.get("problem") or "") or "(see backlog)" + solution = str(task.get("solution") or "") or "(see backlog)" + + # IMPORTANT: do not use return f"""...""" here — nested ```json fences + # previously caused "unterminated triple-quoted f-string" when the file + # was truncated mid-literal. Join plain strings instead. + fence = chr(96) * 3 # ``` + lines = [ + "# Implement proposal for " + task_id, + "", + "## Task", + title, + "", + "## Problem", + problem, + "", + "## Solution direction", + solution, + "", + "## Rules", + "- Work only on this task.", + "- Do not modify files outside the allowed context.", + "- Do not add a new abstraction if acceptance requires deleting an old path.", + "- Preserve product invariants:", + " - topic-only routing;", + " - unauthorized user denied;", + " - no General-topic routing;", + " - no callback wire-format change without explicit approval.", + "- Add or update tests if product code changes.", + "- Return a unified diff only (proposal artifacts).", + "- Do not commit.", + "- Do not push.", + "- Do not merge.", + "- Do not modify .git.", + "- Do not apply to the working tree without human approve.", + "", + "## Acceptance", + fence + "json", + acceptance_json, + fence, + "", + "## Output", + "Write proposal artifacts under `.agentic/out/proposals/_/`:", + "- proposal.md", + "- proposal.diff", + "- files.json", + "- risk.json", + "- evidence_plan.json", + "- meta.json", + "", + ] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Proposal artifact helpers +# --------------------------------------------------------------------------- + + +def proposal_dir_for(task_id: str, stamp: str | None = None) -> Path: + stamp = stamp or utc_stamp() + return PROPOSALS_DIR / f"{task_id}_{stamp}" + + +def latest_proposal_dir(task_id: str | None = None) -> Path | None: + if not PROPOSALS_DIR.is_dir(): + return None + + if task_id: + pointer = PROPOSALS_DIR / f".latest-{task_id}.json" + if pointer.is_file(): + data = load_json(pointer) + rel = data.get("path") + if rel: + pointed = ROOT / rel + if pointed.is_dir() and (pointed / "meta.json").is_file(): + return pointed + + candidates: list[tuple[int, float, Path]] = [] + for path in PROPOSALS_DIR.iterdir(): + if not path.is_dir() or path.name.startswith("."): + continue + # Pointer dirs like proposals/ISS-014/latest.json only — skip unless full package. + has_package = (path / "proposal.md").is_file() and ( + path / "meta.json" + ).is_file() + if not has_package: + continue + if task_id and not ( + path.name == task_id + or path.name.startswith(f"{task_id}_") + or path.name.startswith(f"{task_id}-") + ): + continue + # Prefer stamped dirs TASK_YYYY... over bare TASK id aliases. + stamp_bonus = 1 if task_id and path.name.startswith(f"{task_id}_") else 0 + candidates.append((stamp_bonus, path.stat().st_mtime, path)) + if not candidates: + return None + candidates.sort(key=lambda item: (item[0], item[1]), reverse=True) + return candidates[0][2] + + +def resolve_proposal_dir( + proposal: str | None, latest: bool, task_id: str | None = None +) -> Path: + if latest or proposal in (None, "", "latest"): + found = latest_proposal_dir(task_id=task_id) + if not found: + raise RuntimeError( + "No proposal artifacts found under .agentic/out/proposals/" + ) + return found + + assert proposal is not None + raw = Path(proposal) + if raw.is_dir(): + return raw.resolve() + + # Accept bare id or folder name under proposals/ + candidate = PROPOSALS_DIR / proposal + if candidate.is_dir(): + return candidate + + # Prefix match: ISS-014 -> ISS-014_* + matches = sorted( + [ + p + for p in PROPOSALS_DIR.iterdir() + if p.is_dir() and (p.name == proposal or p.name.startswith(f"{proposal}_")) + ], + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if matches: + return matches[0] + + raise RuntimeError(f"Proposal not found: {proposal}") + + +def git_base_sha(base_ref: str) -> str: + """Resolve proposal base_sha from base_ref (e.g. origin/main), not feature HEAD.""" + try: + return git("rev-parse", base_ref) + except Exception: + # Fallback only if base_ref is missing/unresolvable (e.g. no origin/main yet). + return git("rev-parse", "HEAD") + + +def capture_working_tree_diff(base_ref: str) -> str: + """Capture branch changes vs base_ref as a pure unified diff. + + Only ``git diff base_ref...HEAD --binary`` output is allowed here — never + markdown fences, ``#`` comments, or non-diff section markers. + + Without ``--diff-file``, the proposal diff must be against ``base_ref`` + (e.g. origin/main), not only uncommitted changes vs HEAD. + """ + result = run(["git", "diff", f"{base_ref}...HEAD", "--binary"], check=False) + diff = result.stdout or "" + + if not diff.strip(): + return "" + + return diff if diff.endswith("\n") else diff + "\n" + + +def list_changed_paths_from_diff(diff_text: str) -> list[str]: + paths: list[str] = [] + for line in diff_text.splitlines(): + if line.startswith("diff --git "): + # diff --git a/path b/path + parts = line.split() + if len(parts) >= 4: + b_path = parts[3] + if b_path.startswith("b/"): + b_path = b_path[2:] + paths.append(b_path.replace("\\", "/")) + elif line.startswith("D\t") or line.startswith("D "): + paths.append(line[1:].strip().replace("\\", "/")) + elif line.startswith("# ---") or line.startswith("#"): + continue + elif "\t" in line and line[:1] in "AMDCR": + # name-status lines: D\tpath + _, _, rest = line.partition("\t") + if rest: + paths.append(rest.strip().replace("\\", "/")) + # unique preserve order + seen: set[str] = set() + out: list[str] = [] + for p in paths: + if p and p not in seen: + seen.add(p) + out.append(p) + return out + + +def _glob_to_regex(pattern: str) -> str: + """Translate a gitignore-like glob (with ``**``) to a full-match regex.""" + i = 0 + out: list[str] = [] + n = len(pattern) + while i < n: + if pattern.startswith("**/", i): + out.append("(?:.*/)?") + i += 3 + continue + if pattern.startswith("**", i): + out.append(".*") + i += 2 + continue + ch = pattern[i] + if ch == "*": + out.append("[^/]*") + elif ch == "?": + out.append("[^/]") + else: + out.append(re.escape(ch)) + i += 1 + return "^" + "".join(out) + "$" + + +def _match_glob(path: str, pattern: str) -> bool: + """Match path against a guardrail glob (supports ``**`` and directory prefixes).""" + path = path.replace("\\", "/").lstrip("./") + pattern = pattern.replace("\\", "/").lstrip("./") + + if not pattern: + return False + + if fnmatch(path, pattern): + return True + if fnmatch(Path(path).name, pattern): + return True + + try: + if re.fullmatch(_glob_to_regex(pattern), path) is not None: + return True + except re.error: + pass + + # Patterns like **/state.json or **/*secret* + if pattern.startswith("**/"): + suffix = pattern[3:] + if fnmatch(path, suffix) or fnmatch(Path(path).name, suffix): + return True + parts = path.split("/") + for i in range(len(parts)): + sub = "/".join(parts[i:]) + if fnmatch(sub, suffix): + return True + try: + if re.fullmatch(_glob_to_regex(suffix), sub) is not None: + return True + except re.error: + pass + + # Bare filename pattern (e.g. state.json) against nested path + if "/" not in pattern and fnmatch(Path(path).name, pattern): + return True + + clean_pattern = pattern.rstrip("/") + + if path == clean_pattern: + return True + + if path.startswith(clean_pattern + "/"): + return True + + if pattern.endswith("/**"): + prefix = pattern[:-3] + return path == prefix or path.startswith(prefix + "/") + + return False + + +def path_allowed(path: str, config: dict) -> tuple[bool, str]: + path = path.replace("\\", "/") + # Capture basename before lstrip("./"): that charset would turn + # ".env.example" into "env.example" and miss the template exemption. + basename = Path(path).name + path = path.lstrip("./") + + guard = config.get("guardrails") or {} + blocked = list(guard.get("blocked_path_globs") or guard.get("block") or []) + allowed = list(guard.get("allowed_path_globs") or guard.get("allow") or []) + ignored = list(guard.get("ignored_path_globs") or []) + + # template files are documentation, not secrets + if basename in {".env.example", ".env.sample", ".env.template"}: + return True, "env template (documentation, not secrets)" + + for pattern in blocked: + if _match_glob(path, pattern): + return False, f"blocked by {pattern}" + + for pattern in ignored: + if _match_glob(path, pattern): + return True, f"ignored {pattern}" + + if not allowed: + return True, "no allow list" + + for pattern in allowed: + if _match_glob(path, pattern): + return True, f"allowed by {pattern}" + + # Repo-hygiene path always allowed when listed tasks touch gitignore. + if path == ".gitignore": + return True, "gitignore hygiene" + + return False, "not in allow list" + + +def _is_empty_diff(diff_text: str) -> bool: + stripped = (diff_text or "").strip() + + if not stripped: + return True + + # Legacy empty placeholder (older propose wrote comment-only skeletons). + first_line = stripped.splitlines()[0] + return first_line.startswith("# Empty proposal.diff") + + +def check_no_markdown_fences(diff_text: str) -> None: + """Reject markdown code fences that corrupt git apply.""" + for i, line in enumerate(diff_text.splitlines(), start=1): + if line.startswith("```"): + raise RuntimeError( + f"proposal.diff contains markdown fence at line {i}. " + "proposal.diff must be a pure unified diff." + ) + + +def check_no_diff_comments(diff_text: str) -> None: + """Reject # comment lines (e.g. staged/name-status section markers).""" + for i, line in enumerate(diff_text.splitlines(), start=1): + if line.startswith("#"): + raise RuntimeError( + f"proposal.diff contains comment at line {i}. " + "Remove comments like '# --- staged ---' or '# --- name-status ---'." + ) + + +def check_pure_unified_diff(diff_text: str) -> None: + """Ensure proposal.diff is pure unified diff before git apply.""" + if _is_empty_diff(diff_text): + return + + check_no_markdown_fences(diff_text) + check_no_diff_comments(diff_text) + + +def _write_temp_diff(diff_bytes: bytes) -> Path: + """Stage diff bytes 1:1 — text-mode staging corrupts CRLF patches (ISS-016).""" + fd = tempfile.NamedTemporaryFile( + "wb", + suffix=".diff", + delete=False, + ) + path = Path(fd.name) + + fd.write(diff_bytes) + fd.close() + + return path + + +def check_diff_applies(diff_bytes: bytes) -> None: + diff_text = diff_bytes.decode("utf-8") + + if _is_empty_diff(diff_text): + return + + check_pure_unified_diff(diff_text) + + tmp_path = _write_temp_diff(diff_bytes) + + try: + attempts = [ + # Обычная проверка против текущего дерева. + [ + "git", + "apply", + "--check", + "--whitespace=nowarn", + str(tmp_path), + ], + # Если diff сделан из unstaged working tree, проверяем против index/HEAD. + [ + "git", + "apply", + "--cached", + "--check", + "--whitespace=nowarn", + str(tmp_path), + ], + # Если изменение уже в working tree, reverse-check подтверждает, + # что diff соответствует текущему состоянию. + [ + "git", + "apply", + "--reverse", + "--check", + "--whitespace=nowarn", + str(tmp_path), + ], + ] + + last_result = None + + for cmd in attempts: + last_result = run(cmd, check=False) + + if last_result.returncode == 0: + return + + raise RuntimeError( + "Diff does not apply cleanly.\n" + f"stdout:\n{last_result.stdout}\n" + f"stderr:\n{last_result.stderr}" + ) + + finally: + tmp_path.unlink(missing_ok=True) + + +def diff_numstat(diff_bytes: bytes) -> list[dict]: + """Parse ``git apply --numstat`` for a unified diff.""" + diff_text = diff_bytes.decode("utf-8") + + if _is_empty_diff(diff_text): + return [] + + # Reject fences/comments before git apply --numstat (corrupt patches). + check_pure_unified_diff(diff_text) + + tmp_path = _write_temp_diff(diff_bytes) + + try: + result = run( + ["git", "apply", "--numstat", str(tmp_path)], + check=False, + ) + + if result.returncode != 0: + raise RuntimeError( + "git apply --numstat failed.\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + + files: list[dict] = [] + + for line in (result.stdout or "").splitlines(): + parts = line.split("\t") + if len(parts) < 3: + continue + + added_raw, deleted_raw, path = parts[0], parts[1], parts[2] + + added = int(added_raw) if added_raw.isdigit() else 0 + deleted = int(deleted_raw) if deleted_raw.isdigit() else 0 + + files.append( + { + "path": path.replace("\\", "/"), + "added": added, + "deleted": deleted, + } + ) + + return files + finally: + tmp_path.unlink(missing_ok=True) + + +def validate_proposal_diff(diff_bytes: bytes, *, allow_empty: bool = False) -> None: + """Validate that proposal.diff is empty (if allowed) or a usable unified diff.""" + diff_text = diff_bytes.decode("utf-8") + + if _is_empty_diff(diff_text): + if allow_empty: + return + raise RuntimeError( + "Empty proposal.diff is not allowed. " + "Make changes, pass --diff-file, or use --allow-empty." + ) + + check_pure_unified_diff(diff_text) + + has_git_header = any( + line.startswith("diff --git ") for line in diff_text.splitlines() + ) + if not has_git_header: + raise RuntimeError( + "proposal.diff is not a valid unified diff (missing 'diff --git' headers)" + ) + + check_diff_applies(diff_bytes) + + +def build_files_json(numstat: list[dict], config: dict) -> dict: + """Build files.json from numstat rows + path guardrails.""" + entries: list[dict] = [] + + for item in numstat: + path = str(item.get("path") or "").replace("\\", "/") + ok, reason = path_allowed(path, config) + + entries.append( + { + "path": path, + "added": item.get("added", 0), + "deleted": item.get("deleted", 0), + "allowed": ok, + "reason": reason, + } + ) + + loc_delta = sum( + int(entry.get("added") or 0) + int(entry.get("deleted") or 0) + for entry in entries + ) + + return { + "files": entries, + "count": len(entries), + "blocked_count": sum(1 for entry in entries if not entry["allowed"]), + "loc_delta": loc_delta, + } + + +def build_risk_json(task: dict, paths: list[str]) -> dict: + risk = str(task.get("estimated_risk") or "medium").lower() + blast = {"low": "low", "medium": "med", "high": "high"}.get(risk, "med") + return { + "task_id": task_id_from(task), + "blast_radius": blast, + "regression_surfaces": [ + "git status / review noise", + "knowledge-graph tooling paths", + ] + if any(p.startswith(".understand-anything") or p == ".gitignore" for p in paths) + else ["see proposal.md"], + "invariants": [ + "topic-only routing", + "no silent apply without approve", + "no secrets/state in proposal", + ], + "files_touched": paths, + "rollback": "git checkout HEAD -- ; drop proposal branch if unmerged", + } + + +def build_evidence_plan(task: dict) -> dict: + criteria = _task_acceptance(task) + + if isinstance(criteria, list): + checks = [str(c) for c in criteria] + else: + checks = [str(criteria)] + + acceptance = task.get("acceptance") or task.get("acceptance_criteria") or {} + + if not isinstance(acceptance, dict): + acceptance = {} + + grep = acceptance.get("grep", []) + tests = acceptance.get("tests", []) + + if not isinstance(grep, list): + grep = [str(grep)] if grep else [] + else: + grep = [str(x) for x in grep] + + if not isinstance(tests, list): + tests = [str(tests)] if tests else [] + else: + tests = [str(x) for x in tests] + + manual = acceptance.get("manual", []) + + if not manual: + manual = checks + elif not isinstance(manual, list): + manual = [str(manual)] + else: + manual = [str(x) for x in manual] + + commands = [ + "git status --porcelain", + "git diff --stat", + ] + + if isinstance(grep, list): + commands.extend(str(cmd) for cmd in grep) + + return { + "task_id": task_id_from(task), + "kind": task.get("kind") or "", + "acceptance_criteria": checks, + "evidence": task.get("evidence") or {}, + "grep": grep, + "tests": tests, + "manual": manual, + "commands": commands, + } + + +def build_proposal_md( + task: dict, proposal_id: str, base_sha: str, paths: list[str] +) -> str: + task_id = task_id_from(task) or "UNKNOWN" + title = task.get("title") or "" + problem = task.get("problem") or "" + solution = task.get("solution") or "" + criteria = _task_acceptance(task) + if isinstance(criteria, list): + crit_lines = "\n".join(f"- {c}" for c in criteria) + else: + crit_lines = f"- {criteria}" + + path_lines = ( + "\n".join(f"- `{p}`" for p in paths) or "- (no paths yet — fill proposal.diff)" + ) + + return "\n".join( + [ + f"# Proposal: {task_id} — {title}", + "", + f"**Proposal ID:** `{proposal_id}` ", + f"**Task ID:** `{task_id}` ", + f"**Base SHA:** `{base_sha}` ", + "**Status:** proposed (awaiting human approve)", + "", + "## Why", + str(problem) or "(see backlog)", + "", + "## What", + str(solution) or "(see backlog / proposal.diff)", + "", + "## Files", + path_lines, + "", + "## Acceptance", + crit_lines, + "", + "## Risks", + "See `risk.json`.", + "", + "## Evidence plan", + "See `evidence_plan.json`.", + "", + "## Rollback", + "- Unmerged: drop branch / restore files from HEAD.", + "- Merged: `git revert` and re-open issue if needed.", + "", + "## HITL", + "- Do **not** apply without human approve.", + "- Do **not** commit to `main` without PR.", + "", + "## Decision", + "- [ ] approved", + "- [ ] rejected (reason required)", + "- [ ] needs-changes", + "", + ] + ) + + +def create_proposal_artifacts( + task: dict, + *, + base_ref: str, + diff_bytes: bytes | None = None, + allow_empty: bool = True, +) -> Path: + config = load_config() + task_id = task_id_from(task) or "UNKNOWN" + stamp = utc_stamp() + proposal_id = f"{task_id}_{stamp}" + dest = proposal_dir_for(task_id, stamp) + dest.mkdir(parents=True, exist_ok=True) + + base_sha = git_base_sha(base_ref) + if diff_bytes is None: + diff_bytes = capture_working_tree_diff(base_ref).encode("utf-8") + + diff_text = diff_bytes.decode("utf-8") + + if _is_empty_diff(diff_text): + if not allow_empty: + raise RuntimeError( + "No changes to propose (empty unified diff). " + "Make changes, pass --diff-file, or use --allow-empty." + ) + # Pure empty file — no # comments / markdown (those break git apply). + diff_text = "" + diff_bytes = b"" + + # Format guards before any git apply --numstat / --check. + check_pure_unified_diff(diff_text) + + numstat = diff_numstat(diff_bytes) + check_diff_applies(diff_bytes) + + paths = [item["path"] for item in numstat] or list_changed_paths_from_diff( + diff_text + ) + files_payload = build_files_json(numstat, config) + + if files_payload.get("blocked_count", 0) > 0: + blocked = [ + entry["path"] + for entry in files_payload.get("files", []) + if not entry.get("allowed") + ] + raise RuntimeError( + "Proposal contains blocked paths:\n" + + "\n".join(f"- {path}" for path in blocked) + ) + + max_files = int(config.get("implementation", {}).get("max_files_changed", 40)) + max_loc = int(config.get("implementation", {}).get("max_loc_delta", 2000)) + + if files_payload.get("count", 0) > max_files: + raise RuntimeError( + f"Too many files changed: {files_payload.get('count')} > {max_files}" + ) + + if files_payload.get("loc_delta", 0) > max_loc: + raise RuntimeError( + f"Too large diff: {files_payload.get('loc_delta')} > {max_loc}" + ) + + risk_payload = build_risk_json(task, paths) + evidence_payload = build_evidence_plan(task) + meta = { + "proposal_id": proposal_id, + "task_id": task_id, + "title": task.get("title"), + "base_sha": base_sha, + "base_ref": base_ref, + "head_sha": git("rev-parse", "HEAD"), + "loc_delta": files_payload.get("loc_delta", 0), + "created_at": iso_now(), + "status": "proposed", + "decision": None, + "approver": None, + "branch": git("rev-parse", "--abbrev-ref", "HEAD"), + "files_count": files_payload.get("count", len(paths)), + "blocked_count": files_payload.get("blocked_count", 0), + "max_files_changed": max_files, + "max_loc_delta": max_loc, + } + + write_bytes(dest / "proposal.diff", diff_bytes) + write_text( + dest / "proposal.md", build_proposal_md(task, proposal_id, base_sha, paths) + ) + write_json(dest / "files.json", files_payload) + write_json(dest / "risk.json", risk_payload) + write_json(dest / "evidence_plan.json", evidence_payload) + write_json(dest / "meta.json", meta) + + prompt_dest = dest / "implement-prompt.md" + + if not prompt_dest.exists(): + default_prompt = OUT_DIR / "implement-prompt.md" + + if default_prompt.is_file(): + shutil.copy2(default_prompt, prompt_dest) + else: + write_text(prompt_dest, render_default_prompt(task)) + + context_dest = dest / "context-pack.json" + + if not context_dest.exists(): + default_context = OUT_DIR / "context-pack.json" + + if default_context.is_file(): + shutil.copy2(default_context, context_dest) + else: + write_json( + context_dest, + { + "task_id": task_id, + "note": "Context pack not found. Run select/context before propose.", + }, + ) + + # Pointer file (does not create a competing proposal package directory) + write_json( + PROPOSALS_DIR / f".latest-{task_id}.json", + { + "latest_proposal_id": proposal_id, + "path": str(dest.relative_to(ROOT)).replace("\\", "/"), + "updated_at": iso_now(), + }, + ) + + return dest + + +def prepare_pipeline_context(task_id: str) -> None: + """Best-effort: run plan/select + context + prompt render for the task.""" + try: + from prioritize import run as run_plan + + run_plan(task_id=task_id) + except Exception as exc: # noqa: BLE001 + print(f"[propose] warn: plan/select skipped: {exc}") + + try: + from render_prompt import run as run_render + + run_render() + except Exception as exc: # noqa: BLE001 + print(f"[propose] warn: render_prompt skipped: {exc}") + + try: + from build_context_pack import run as run_context + + run_context(task_id=task_id) + except Exception as exc: # noqa: BLE001 + print(f"[propose] warn: context pack skipped: {exc}") + + +# --------------------------------------------------------------------------- +# HITL: reject / approve / request_changes / apply +# --------------------------------------------------------------------------- + + +def _proposal_id_of(dest: Path, meta: dict | None = None) -> str: + if meta and meta.get("proposal_id"): + return str(meta["proposal_id"]) + return dest.name + + +def _load_proposal_meta(dest: Path) -> dict: + meta_path = dest / "meta.json" + if not meta_path.is_file(): + raise RuntimeError(f"meta.json missing in proposal: {dest}") + meta = load_json(meta_path) + if not isinstance(meta, dict): + raise RuntimeError(f"meta.json is not an object: {meta_path}") + return meta + + +def _update_proposal_meta(dest: Path, **fields) -> dict: + meta = _load_proposal_meta(dest) + meta.update(fields) + meta["updated_at"] = iso_now() + write_json(dest / "meta.json", meta) + return meta + + +def _current_base_sha(base_ref: str | None = None) -> str: + return git_base_sha(base_ref or DEFAULT_BASE_REF) + + +def _assert_base_sha_fresh( + expected_sha: str | None, + *, + base_ref: str | None = None, + context: str = "proposal", +) -> str: + """Require that proposal/approval base_sha still matches origin/main (or base_ref).""" + if not expected_sha: + raise RuntimeError(f"{context}: missing base_sha in metadata") + + ref = base_ref or DEFAULT_BASE_REF + current = _current_base_sha(ref) + if str(expected_sha) != current: + raise RuntimeError( + f"stale base_sha for {context}: " + f"recorded={expected_sha} current({ref})={current}. " + "Re-propose from a fresh base before approve/apply." + ) + return current + + +def _current_branch() -> str: + return git("rev-parse", "--abbrev-ref", "HEAD") + + +def _assert_feature_branch() -> str: + branch = _current_branch() + if branch in PROTECTED_BRANCHES: + raise RuntimeError( + f"apply blocked on protected branch '{branch}'. " + "Checkout a feature branch first." + ) + if branch == "HEAD": + raise RuntimeError( + "apply blocked in detached HEAD state; checkout a feature branch." + ) + return branch + + +def reject(proposal_id: str, reason: str, *, task_id: str | None = None) -> Path: + """Reject a proposal: write reject artifact under .agentic/out/rejects/. + + Does **not** modify the git working tree (only writes under .agentic/out/, + which is gitignored). Does not mark the task done. + """ + reason = (reason or "").strip() + if not reason: + raise RuntimeError("reject requires a non-empty reason") + + dest = resolve_proposal_dir(proposal=proposal_id, latest=False, task_id=task_id) + meta = _load_proposal_meta(dest) + pid = _proposal_id_of(dest, meta) + + payload = { + "proposal_id": pid, + "task_id": meta.get("task_id"), + "decision": "rejected", + "reason": reason, + "rejected_at": iso_now(), + "base_sha": meta.get("base_sha"), + "base_ref": meta.get("base_ref") or DEFAULT_BASE_REF, + "proposal_path": str(dest.relative_to(ROOT)).replace("\\", "/"), + "branch": _current_branch(), + } + + reject_path = REJECTS_DIR / f"{pid}.json" + write_json(reject_path, payload) + + _update_proposal_meta( + dest, + status="rejected", + decision="rejected", + reject_reason=reason, + rejected_at=payload["rejected_at"], + ) + + # Side-car in proposal dir (gitignored via .agentic/out/). + write_json(dest / "reject.json", payload) + + return reject_path + + +def approve( + proposal_id: str, + message: str, + *, + task_id: str | None = None, + base_ref: str | None = None, + approver: str | None = None, +) -> Path: + """Approve a proposal: create approval.json after base_sha check. + + Does **not** apply the diff — call apply() separately. + """ + dest = resolve_proposal_dir(proposal=proposal_id, latest=False, task_id=task_id) + meta = _load_proposal_meta(dest) + pid = _proposal_id_of(dest, meta) + ref = base_ref or meta.get("base_ref") or DEFAULT_BASE_REF + + current_sha = _assert_base_sha_fresh( + meta.get("base_sha"), + base_ref=ref, + context=f"approve({pid})", + ) + + notes = (message or "").strip() + who = (approver or "").strip() or ( + os.environ.get("USER") or os.environ.get("USERNAME") or "human" + ) + + approval = { + "proposal_id": pid, + "task_id": meta.get("task_id"), + "decision": "approved", + "approver": who, + "timestamp": iso_now(), + "base_sha": current_sha, + "base_ref": ref, + "notes": notes, + "message": notes, + "proposal_path": str(dest.relative_to(ROOT)).replace("\\", "/"), + "branch": _current_branch(), + } + + approval_path = dest / "approval.json" + write_json(approval_path, approval) + + _update_proposal_meta( + dest, + status="approved", + decision="approved", + approver=who, + approved_at=approval["timestamp"], + approval_notes=notes, + approval_base_sha=current_sha, + ) + + return approval_path + + +def request_changes( + proposal_id: str, + notes: str, + *, + task_id: str | None = None, +) -> Path: + """Request changes: archive the old proposal and persist reviewer notes. + + Does **not** modify the git working tree. + """ + notes = (notes or "").strip() + if not notes: + raise RuntimeError("request_changes requires non-empty notes") + + dest = resolve_proposal_dir(proposal=proposal_id, latest=False, task_id=task_id) + meta = _load_proposal_meta(dest) + pid = _proposal_id_of(dest, meta) + task = meta.get("task_id") or task_id + + changes_payload = { + "proposal_id": pid, + "task_id": task, + "decision": "changes_requested", + "notes": notes, + "requested_at": iso_now(), + "base_sha": meta.get("base_sha"), + "base_ref": meta.get("base_ref") or DEFAULT_BASE_REF, + "proposal_path": str(dest.relative_to(ROOT)).replace("\\", "/"), + "branch": _current_branch(), + } + + write_json(dest / "request_changes.json", changes_payload) + _update_proposal_meta( + dest, + status="changes_requested", + decision="changes_requested", + change_notes=notes, + changes_requested_at=changes_payload["requested_at"], + ) + + ARCHIVE_DIR.mkdir(parents=True, exist_ok=True) + archive_dest = ARCHIVE_DIR / dest.name + if archive_dest.exists(): + archive_dest = ARCHIVE_DIR / f"{dest.name}_{utc_stamp()}" + + shutil.copytree(dest, archive_dest) + write_json(archive_dest / "request_changes.json", changes_payload) + + # Point latest pointer away from the archived package if it was current. + if task: + pointer = PROPOSALS_DIR / f".latest-{task}.json" + if pointer.is_file(): + data = load_json(pointer) + rel = str(data.get("path") or "").replace("\\", "/") + current_rel = str(dest.relative_to(ROOT)).replace("\\", "/") + if rel == current_rel or data.get("latest_proposal_id") == pid: + write_json( + pointer, + { + "latest_proposal_id": pid, + "path": str(archive_dest.relative_to(ROOT)).replace("\\", "/"), + "status": "changes_requested", + "archived": True, + "updated_at": iso_now(), + }, + ) + + # Keep live package for show-proposal, but mark archived path. + _update_proposal_meta( + dest, + archived_to=str(archive_dest.relative_to(ROOT)).replace("\\", "/"), + ) + write_json( + dest / "request_changes.json", + {**changes_payload, "archived_to": str(archive_dest)}, + ) + + return archive_dest + + +def apply( + proposal_id: str, + *, + task_id: str | None = None, + base_ref: str | None = None, + allow_on_main: bool = False, +) -> Path: + """Apply proposal.diff only with approval.json + matching base_sha. + + Blocks apply on main/master unless allow_on_main is explicitly True + (never recommended; reserved for emergency tooling). + """ + dest = resolve_proposal_dir(proposal=proposal_id, latest=False, task_id=task_id) + meta = _load_proposal_meta(dest) + pid = _proposal_id_of(dest, meta) + ref = base_ref or meta.get("base_ref") or DEFAULT_BASE_REF + + config = load_config() + human_gate = config.get("human_gate") or {} + if human_gate.get("required_before_apply", True): + approval_path = dest / "approval.json" + if not approval_path.is_file(): + raise RuntimeError( + f"apply blocked: approval.json missing for {pid}. Run approve first." + ) + approval = load_json(approval_path) + if not isinstance(approval, dict): + raise RuntimeError(f"approval.json is not an object: {approval_path}") + if approval.get("decision") != "approved": + raise RuntimeError( + f"apply blocked: decision={approval.get('decision')!r}, " + "expected 'approved'" + ) + expected_sha = approval.get("base_sha") or meta.get("base_sha") + _assert_base_sha_fresh( + expected_sha, + base_ref=approval.get("base_ref") or ref, + context=f"apply({pid})", + ) + # Also require meta base_sha matches approval (tamper / re-base safety). + if meta.get("base_sha") and approval.get("base_sha"): + if str(meta["base_sha"]) != str(approval["base_sha"]): + raise RuntimeError( + f"apply blocked: meta.base_sha ({meta['base_sha']}) != " + f"approval.base_sha ({approval['base_sha']})" + ) + else: + _assert_base_sha_fresh( + meta.get("base_sha"), base_ref=ref, context=f"apply({pid})" + ) + + if not allow_on_main: + _assert_feature_branch() + + diff_path = dest / "proposal.diff" + if not diff_path.is_file(): + raise RuntimeError(f"proposal.diff missing: {diff_path}") + + diff_bytes = diff_path.read_bytes() + diff_text = diff_bytes.decode("utf-8") + if _is_empty_diff(diff_text): + raise RuntimeError("apply blocked: empty proposal.diff") + + check_pure_unified_diff(diff_text) + + # Check then apply against the working tree (feature branch only). + check_result = run( + ["git", "apply", "--check", "--whitespace=nowarn", str(diff_path)], + check=False, + ) + if check_result.returncode != 0: + raise RuntimeError( + "apply blocked: git apply --check failed.\n" + f"stdout:\n{check_result.stdout}\n" + f"stderr:\n{check_result.stderr}" + ) + + apply_result = run( + ["git", "apply", "--whitespace=nowarn", str(diff_path)], + check=False, + ) + if apply_result.returncode != 0: + raise RuntimeError( + "apply failed: git apply returned non-zero.\n" + f"stdout:\n{apply_result.stdout}\n" + f"stderr:\n{apply_result.stderr}" + ) + + applied_at = iso_now() + _update_proposal_meta( + dest, + status="applied", + decision="applied", + applied_at=applied_at, + applied_branch=_current_branch(), + applied_head_sha=git("rev-parse", "HEAD"), + ) + write_json( + dest / "apply.json", + { + "proposal_id": pid, + "task_id": meta.get("task_id"), + "decision": "applied", + "applied_at": applied_at, + "branch": _current_branch(), + "base_sha": meta.get("base_sha"), + "base_ref": ref, + }, + ) + + return dest + + +# --------------------------------------------------------------------------- +# CLI handlers +# --------------------------------------------------------------------------- + + +def cmd_propose(args: argparse.Namespace) -> int: + task_id = args.task + if not task_id: + # Try selected-task.json + selected = load_json(OUT_DIR / "selected-task.json") + task_id = selected.get("id") or selected.get("task_id") + if not task_id: + print( + "propose_failed: --task is required (or run select first)", file=sys.stderr + ) + return 2 + + try: + ensure_clean_tree(allow_dirty=bool(args.allow_dirty)) + except RuntimeError as exc: + print(f"propose_failed: {exc}", file=sys.stderr) + return 1 + + try: + task = load_task(task_id) + except RuntimeError as exc: + print(f"propose_failed: {exc}", file=sys.stderr) + return 1 + + base_ref = args.base_ref or DEFAULT_BASE_REF + + if not args.skip_context: + prepare_pipeline_context(task_id) + + diff_bytes = None + if args.diff_file: + diff_path = Path(args.diff_file) + if not diff_path.is_file(): + print(f"propose_failed: diff file not found: {diff_path}", file=sys.stderr) + return 1 + diff_bytes = diff_path.read_bytes() + + try: + dest = create_proposal_artifacts( + task, + base_ref=base_ref, + diff_bytes=diff_bytes, + allow_empty=bool(args.allow_empty), + ) + except RuntimeError as exc: + print(f"propose_failed: {exc}", file=sys.stderr) + return 1 + + # Soft status update: PROPOSE (do not mark done) + try: + if args.update_status: + update_task_status(task_id, "PROPOSE", dest.name) + except RuntimeError as exc: + print(f"[propose] warn: status update skipped: {exc}") + + meta = load_json(dest / "meta.json") + print(f"propose_ok task={task_id} proposal={meta.get('proposal_id')}") + print(f" dir={dest}") + for name in ( + "proposal.md", + "proposal.diff", + "files.json", + "risk.json", + "evidence_plan.json", + "meta.json", + ): + print(f" {name}={(dest / name)}") + print(" status=proposed (awaiting human approve; no apply performed)") + return 0 + + +def cmd_show_proposal(args: argparse.Namespace) -> int: + try: + dest = resolve_proposal_dir( + proposal=args.proposal, + latest=bool(args.latest) or not args.proposal, + task_id=args.task, + ) + except RuntimeError as exc: + print(f"show_proposal_failed: {exc}", file=sys.stderr) + return 1 + + meta = load_json(dest / "meta.json") if (dest / "meta.json").is_file() else {} + print(f"proposal_dir={dest}") + if meta: + print(json.dumps(meta, indent=2, ensure_ascii=False)) + for name in ( + "proposal.md", + "proposal.diff", + "files.json", + "risk.json", + "evidence_plan.json", + "meta.json", + ): + path = dest / name + status = "OK" if path.is_file() else "MISSING" + size = path.stat().st_size if path.is_file() else 0 + print(f" [{status}] {name} ({size} bytes)") + + if args.full and (dest / "proposal.md").is_file(): + print("\n----- proposal.md -----\n") + print((dest / "proposal.md").read_text(encoding="utf-8")) + return 0 + + +def cmd_import_proposal(args: argparse.Namespace) -> int: + """Validate/finalize proposal.diff into artifact package.""" + config = load_config() + + # Mode 1: import external diff file as a new proposal. + if args.diff_file: + if not args.task: + print( + "import_proposal_failed: --task is required with --diff-file", + file=sys.stderr, + ) + return 2 + + try: + task = load_task(args.task) + except RuntimeError as exc: + print(f"import_proposal_failed: {exc}", file=sys.stderr) + return 1 + + diff_path = Path(args.diff_file) + + if not diff_path.is_file(): + print( + f"import_proposal_failed: diff file not found: {diff_path}", + file=sys.stderr, + ) + return 1 + + diff_bytes = diff_path.read_bytes() + base_ref = args.base_ref or DEFAULT_BASE_REF + + try: + dest = create_proposal_artifacts( + task, + base_ref=base_ref, + diff_bytes=diff_bytes, + allow_empty=False, + ) + except RuntimeError as exc: + print(f"import_proposal_failed: {exc}", file=sys.stderr) + return 1 + + meta = load_json(dest / "meta.json") + print(f"import_proposal_ok task={args.task} proposal={meta.get('proposal_id')}") + print(f" dir={dest}") + return 0 + + # Mode 2: finalize existing proposal directory. + try: + dest = resolve_proposal_dir( + proposal=args.proposal, + latest=bool(args.latest) or not args.proposal, + task_id=args.task, + ) + except RuntimeError as exc: + print(f"import_proposal_failed: {exc}", file=sys.stderr) + return 1 + + diff_path = dest / "proposal.diff" + + if not diff_path.is_file(): + print( + f"import_proposal_failed: proposal.diff not found in {dest}", + file=sys.stderr, + ) + return 1 + + diff_bytes = diff_path.read_bytes() + diff_text = diff_bytes.decode("utf-8") + + try: + check_pure_unified_diff(diff_text) + numstat = diff_numstat(diff_bytes) + check_diff_applies(diff_bytes) + except RuntimeError as exc: + print(f"import_proposal_failed: {exc}", file=sys.stderr) + return 1 + + files_payload = build_files_json(numstat, config) + + if files_payload.get("blocked_count", 0) > 0: + blocked = [ + entry["path"] + for entry in files_payload.get("files", []) + if not entry.get("allowed") + ] + print( + "import_proposal_failed: blocked paths present in diff:\n" + + "\n".join(f"- {path}" for path in blocked), + file=sys.stderr, + ) + return 1 + + max_files = int(config.get("implementation", {}).get("max_files_changed", 40)) + max_loc = int(config.get("implementation", {}).get("max_loc_delta", 2000)) + + if files_payload.get("count", 0) > max_files: + print( + f"import_proposal_failed: too many files changed: " + f"{files_payload.get('count')} > {max_files}", + file=sys.stderr, + ) + return 1 + + if files_payload.get("loc_delta", 0) > max_loc: + print( + f"import_proposal_failed: too large diff: " + f"{files_payload.get('loc_delta')} > {max_loc}", + file=sys.stderr, + ) + return 1 + + write_json(dest / "files.json", files_payload) + + meta_path = dest / "meta.json" + meta = load_json(meta_path) if meta_path.is_file() else {} + + if not isinstance(meta, dict): + meta = {} + + meta.update( + { + "status": "proposed", + "imported_at": iso_now(), + "blocked_count": files_payload.get("blocked_count", 0), + "loc_delta": files_payload.get("loc_delta", 0), + } + ) + + write_json(meta_path, meta) + + if args.update_status: + task_id = args.task or meta.get("task_id") + + if task_id: + try: + update_task_status(task_id, "PROPOSE", dest.name) + except RuntimeError as exc: + print(f"[propose] warn: status update skipped: {exc}") + + print(f"import_proposal_ok proposal={dest.name}") + print(f" dir={dest}") + + return 0 + + +def cmd_reject(args: argparse.Namespace) -> int: + try: + reject_path = reject( + args.proposal or "latest", + args.reason, + task_id=args.task, + ) + except RuntimeError as exc: + print(f"reject_failed: {exc}", file=sys.stderr) + return 1 + + print(f"reject_ok proposal={reject_path.stem}") + print(f" reject_artifact={reject_path}") + print(" tree=untouched (artifacts under .agentic/out/ only)") + return 0 + + +def cmd_approve(args: argparse.Namespace) -> int: + try: + approval_path = approve( + args.proposal or "latest", + args.message or "", + task_id=args.task, + base_ref=args.base_ref, + approver=args.approver, + ) + except RuntimeError as exc: + print(f"approve_failed: {exc}", file=sys.stderr) + return 1 + + approval = load_json(approval_path) + print(f"approve_ok proposal={approval.get('proposal_id')}") + print(f" approval={approval_path}") + print(f" base_sha={approval.get('base_sha')}") + print(" status=approved (diff not applied; run apply next)") + return 0 + + +def cmd_request_changes(args: argparse.Namespace) -> int: + try: + archive_dest = request_changes( + args.proposal or "latest", + args.notes, + task_id=args.task, + ) + except RuntimeError as exc: + print(f"request_changes_failed: {exc}", file=sys.stderr) + return 1 + + print(f"request_changes_ok archive={archive_dest}") + print(" tree=untouched (artifacts under .agentic/out/ only)") + return 0 + + +def cmd_apply(args: argparse.Namespace) -> int: + try: + dest = apply( + args.proposal or "latest", + task_id=args.task, + base_ref=args.base_ref, + allow_on_main=bool(args.allow_on_main), + ) + except RuntimeError as exc: + print(f"apply_failed: {exc}", file=sys.stderr) + return 1 + + meta = load_json(dest / "meta.json") + print(f"apply_ok proposal={meta.get('proposal_id')}") + print(f" dir={dest}") + print(f" branch={meta.get('applied_branch') or _current_branch()}") + print(" next: uv run python scripts/agentic/cli.py validate") + return 0 + + +def register_propose_commands(sub: argparse._SubParsersAction) -> None: + p_prop = sub.add_parser( + "propose", + help="Create proposal artifacts without applying changes (HITL)", + ) + p_prop.add_argument("--task", help="Task id (e.g. ISS-014)") + p_prop.add_argument( + "--base-ref", + default=DEFAULT_BASE_REF, + help=f"Base ref for metadata (default: {DEFAULT_BASE_REF})", + ) + p_prop.add_argument( + "--allow-dirty", + action="store_true", + help="Allow dirty working tree outside .agentic/out", + ) + p_prop.add_argument( + "--allow-empty", + action="store_true", + default=False, + help="Allow empty proposal.diff skeleton", + ) + p_prop.add_argument( + "--diff-file", + help="Use an existing unified diff file instead of capturing working tree", + ) + p_prop.add_argument( + "--skip-context", + action="store_true", + help="Skip plan/context/prompt preparation", + ) + p_prop.add_argument( + "--update-status", + action="store_true", + help="Write last_proposal_id onto the backlog task", + ) + + p_show = sub.add_parser("show-proposal", help="Show proposal artifacts") + p_show.add_argument("--proposal", help="Proposal id, path, or folder name") + p_show.add_argument("--task", help="Filter latest proposal by task id") + p_show.add_argument("--latest", action="store_true", help="Show latest proposal") + p_show.add_argument("--full", action="store_true", help="Print proposal.md body") + + p_imp = sub.add_parser( + "import-proposal", + help="Validate/finalize a manual proposal.diff into artifact package", + ) + p_imp.add_argument("--task", required=False, help="Task id") + p_imp.add_argument("--proposal", help="Proposal id, path, or folder name") + p_imp.add_argument("--latest", action="store_true", help="Use latest proposal") + p_imp.add_argument("--diff-file", required=False, help="Path to unified diff") + p_imp.add_argument("--base-ref", default=DEFAULT_BASE_REF) + p_imp.add_argument( + "--update-status", + action="store_true", + help="Set backlog task status to PROPOSE", + ) + + +def register_hitl_commands(sub: argparse._SubParsersAction) -> None: + """Register human-in-the-loop gate commands: reject / approve / request-changes / apply.""" + p_rej = sub.add_parser( + "reject", + help="Reject a proposal (writes .agentic/out/rejects/; no tree change)", + ) + p_rej.add_argument( + "--proposal", + default="latest", + help="Proposal id, path, folder name, or 'latest'", + ) + p_rej.add_argument("--task", help="Filter latest proposal by task id") + p_rej.add_argument("--reason", required=True, help="Rejection reason (required)") + + p_app = sub.add_parser( + "approve", + help="Approve a proposal (creates approval.json; checks base_sha vs origin/main)", + ) + p_app.add_argument( + "--proposal", + default="latest", + help="Proposal id, path, folder name, or 'latest'", + ) + p_app.add_argument("--task", help="Filter latest proposal by task id") + p_app.add_argument( + "--message", + default="", + help="Approval notes / message", + ) + p_app.add_argument( + "--base-ref", + default=DEFAULT_BASE_REF, + help=f"Base ref for freshness check (default: {DEFAULT_BASE_REF})", + ) + p_app.add_argument( + "--approver", default=None, help="Approver identity (default: $USER)" + ) + + p_rc = sub.add_parser( + "request-changes", + help="Request changes: archive proposal and save notes (no tree change)", + ) + p_rc.add_argument( + "--proposal", + default="latest", + help="Proposal id, path, folder name, or 'latest'", + ) + p_rc.add_argument("--task", help="Filter latest proposal by task id") + p_rc.add_argument("--notes", required=True, help="Reviewer notes for re-propose") + + p_apply = sub.add_parser( + "apply", + help="Apply proposal.diff only with approval.json + matching base_sha (feature branch)", + ) + p_apply.add_argument( + "--proposal", + default="latest", + help="Proposal id, path, folder name, or 'latest'", + ) + p_apply.add_argument("--task", help="Filter latest proposal by task id") + p_apply.add_argument( + "--base-ref", + default=None, + help=f"Override base ref for freshness check (default: proposal meta / {DEFAULT_BASE_REF})", + ) + p_apply.add_argument( + "--allow-on-main", + action="store_true", + help="Dangerous: allow apply on main/master (default: blocked)", + ) + + +PROPOSE_HANDLERS = { + "propose": cmd_propose, + "show-proposal": cmd_show_proposal, + "import-proposal": cmd_import_proposal, +} + +HITL_HANDLERS = { + "reject": cmd_reject, + "approve": cmd_approve, + "request-changes": cmd_request_changes, + "apply": cmd_apply, +} + + +__all__ = [ + "PROPOSE_HANDLERS", + "HITL_HANDLERS", + "register_propose_commands", + "register_hitl_commands", + "cmd_propose", + "cmd_show_proposal", + "cmd_import_proposal", + "cmd_reject", + "cmd_approve", + "cmd_request_changes", + "cmd_apply", + "reject", + "approve", + "request_changes", + "apply", + "create_proposal_artifacts", + "render_default_prompt", +] diff --git a/scripts/agentic/render_prompt.py b/scripts/agentic/render_prompt.py new file mode 100644 index 000000000..11c95aeba --- /dev/null +++ b/scripts/agentic/render_prompt.py @@ -0,0 +1,99 @@ +"""Render implement prompt and PR body from selected task.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from paths import find_repo_root, load_config, load_json, out_dir, write_text + + +def _checklist(items: list[str]) -> str: + return "\n".join(f"- [ ] {c}" for c in items) + + +def render_implement_prompt(template: str, task: dict[str, Any]) -> str: + task_json = json.dumps(task, indent=2, ensure_ascii=False) + text = template.replace("{{TASK_JSON}}", task_json) + text = text.replace("{{TASK_ID}}", str(task.get("id") or "TASK")) + return text + + +def render_pr_body( + template: str, task: dict[str, Any], graph_commit: str | None +) -> str: + text = template + replacements = { + "{{TASK_ID}}": str(task.get("id") or ""), + "{{PRIORITY}}": str(task.get("priority") or ""), + "{{GRAPH_COMMIT}}": graph_commit or "unknown", + "{{PROBLEM}}": str(task.get("problem") or ""), + "{{SOLUTION}}": str(task.get("solution") or ""), + "{{ACCEPTANCE_CHECKLIST}}": _checklist( + list(task.get("acceptance_criteria") or []) + ), + "{{RISK}}": str(task.get("estimated_risk") or "medium"), + "{{AGENT_NOTES}}": "Generated by agentic pipeline (scripts/agentic).", + } + for k, v in replacements.items(): + text = text.replace(k, v) + return text + + +def run(repo_root: Path | None = None) -> dict[str, str]: + root = repo_root or find_repo_root() + config = load_config(root) + out = out_dir(root, config) + selected_path = out / config["outputs"]["selected_task"] + if not selected_path.is_file(): + raise FileNotFoundError( + f"Missing {selected_path}; run: python scripts/agentic/cli.py plan" + ) + task = load_json(selected_path) + plan_path = out / config["outputs"]["plan"] + graph_commit = None + if plan_path.is_file(): + graph_commit = load_json(plan_path).get("graph_commit") + + impl_tmpl = (root / ".agentic" / "prompts" / "implement.md").read_text( + encoding="utf-8" + ) + pr_tmpl = (root / ".agentic" / "prompts" / "pr-body.md").read_text(encoding="utf-8") + + implement_md = render_implement_prompt(impl_tmpl, task) + pr_body = render_pr_body(pr_tmpl, task, graph_commit) + + write_text(out / config["outputs"]["implement_prompt"], implement_md) + write_text(out / "pr-body.md", pr_body) + + policies = (root / ".agentic" / "policies.json").read_text(encoding="utf-8") + write_text( + out / "agent-context.md", + "\n\n".join( + [ + "# Agent context bundle", + "## Policies", + "```json", + policies, + "```", + "## Selected task", + "```json", + json.dumps(task, indent=2, ensure_ascii=False), + "```", + "## Implementation prompt", + implement_md, + ] + ), + ) + return { + "implement_prompt": str(out / config["outputs"]["implement_prompt"]), + "pr_body": str(out / "pr-body.md"), + "agent_context": str(out / "agent-context.md"), + } + + +if __name__ == "__main__": + paths = run() + for k, v in paths.items(): + print(f"{k}: {v}") diff --git a/scripts/agentic/service.py b/scripts/agentic/service.py new file mode 100644 index 000000000..6d27e4a65 --- /dev/null +++ b/scripts/agentic/service.py @@ -0,0 +1,319 @@ +"""Shared service layer for CLI and MCP server (single source of truth).""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from analyze_graph import run as run_analyze +from build_context_pack import run as run_context_pack +from paths import find_repo_root, load_config, load_json, out_dir, write_json +from prioritize import run as run_plan +from render_prompt import run as run_render +from validate_changes import run as run_validate + + +def repo_root(cwd: str | None = None) -> Path: + if cwd: + return find_repo_root(Path(cwd)) + return find_repo_root() + + +def list_tasks( + status: str | None = None, + *, + root: Path | None = None, + include_ranking: bool = True, +) -> dict[str, Any]: + root = root or repo_root() + config = load_config(root) + backlog = load_json(root / config["backlog"]["path"]) + tasks = list(backlog.get("tasks") or []) + if status: + wanted = {s.strip() for s in status.split(",") if s.strip()} + tasks = [t for t in tasks if t.get("status") in wanted] + + ranking_by_id: dict[str, Any] = {} + if include_ranking: + out = out_dir(root, config) + plan_path = out / config["outputs"]["plan"] + if plan_path.is_file(): + plan = load_json(plan_path) + for r in plan.get("ranked_tasks") or []: + if r.get("id"): + ranking_by_id[r["id"]] = r + else: + # lightweight rank without requiring prior analyze file + try: + plan = run_plan(repo_root=root) + for r in plan.get("ranked_tasks") or []: + if r.get("id"): + ranking_by_id[r["id"]] = r + except Exception as exc: # noqa: BLE001 — surface soft failure + ranking_by_id = {"_error": str(exc)} + + items = [] + for t in tasks: + item = { + "id": t.get("id"), + "title": t.get("title"), + "priority": t.get("priority"), + "status": t.get("status"), + "estimated_risk": t.get("estimated_risk"), + "depends_on": t.get("depends_on") or [], + "files": t.get("files") or [], + "categories": t.get("categories") or [], + } + rank = ranking_by_id.get(t.get("id") or "") + if isinstance(rank, dict): + item["score"] = rank.get("score") + item["selectable"] = rank.get("selectable") + item["deps_satisfied"] = rank.get("deps_satisfied") + items.append(item) + + items.sort(key=lambda x: (-(x.get("score") or 0), x.get("id") or "")) + return { + "count": len(items), + "tasks": items, + "selected_task_id": ( + load_json(out_dir(root, config) / config["outputs"]["selected_task"]).get( + "id" + ) + if (out_dir(root, config) / config["outputs"]["selected_task"]).is_file() + else None + ), + } + + +def get_task(task_id: str, *, root: Path | None = None) -> dict[str, Any]: + root = root or repo_root() + config = load_config(root) + backlog = load_json(root / config["backlog"]["path"]) + for t in backlog.get("tasks") or []: + if t.get("id") == task_id: + return {"task": t} + raise KeyError(f"Unknown task: {task_id}") + + +def select_next_task( + task_id: str | None = None, + *, + root: Path | None = None, + skip_analyze: bool = False, +) -> dict[str, Any]: + root = root or repo_root() + if not skip_analyze: + try: + run_analyze(repo_root=root) + except FileNotFoundError as exc: + # allow plan on ranking-only if graph missing + analysis_error = str(exc) + else: + analysis_error = None + else: + analysis_error = None + + plan = run_plan(repo_root=root, task_id=task_id) + if not plan.get("selected_task_id"): + return { + "ok": False, + "error": "no selectable task", + "plan_summary": { + "ranked": [ + { + "id": r.get("id"), + "score": r.get("score"), + "selectable": r.get("selectable"), + } + for r in (plan.get("ranked_tasks") or [])[:10] + ] + }, + "analysis_error": analysis_error, + } + paths = run_render(repo_root=root) + pack = None + try: + pack = run_context_pack(task_id=plan["selected_task_id"], repo_root=root) + except Exception as exc: # noqa: BLE001 + pack = {"error": str(exc)} + + return { + "ok": True, + "selected_task_id": plan.get("selected_task_id"), + "selected_task": plan.get("selected_task"), + "artifacts": paths, + "context_pack_stats": (pack or {}).get("stats") + if isinstance(pack, dict) + else None, + "analysis_error": analysis_error, + } + + +def get_analysis(*, root: Path | None = None, refresh: bool = False) -> dict[str, Any]: + root = root or repo_root() + config = load_config(root) + out = out_dir(root, config) + path = out / config["outputs"]["analysis_report"] + if refresh or not path.is_file(): + report = run_analyze(repo_root=root) + else: + report = load_json(path) + # Truncate for MCP payload limits + slim = { + "generated_at": report.get("generated_at"), + "project": report.get("project"), + "stats": report.get("stats"), + "layers": report.get("layers"), + "hotspots": (report.get("hotspots") or [])[:15], + "recommendations": report.get("recommendations"), + "untested_src_files": (report.get("untested_src_files") or [])[:20], + "path_scores_top": dict( + sorted((report.get("path_scores") or {}).items(), key=lambda kv: -kv[1])[ + :20 + ] + ), + } + return slim + + +def get_context_pack( + task_id: str | None = None, + *, + hops: int = 1, + root: Path | None = None, + refresh: bool = True, +) -> dict[str, Any]: + root = root or repo_root() + config = load_config(root) + out = out_dir(root, config) + path = out / "context-pack.json" + if refresh or not path.is_file(): + return run_context_pack(task_id=task_id, repo_root=root, hops=hops) + pack = load_json(path) + if task_id and pack.get("task_id") != task_id: + return run_context_pack(task_id=task_id, repo_root=root, hops=hops) + return pack + + +def get_implement_prompt(*, root: Path | None = None) -> dict[str, Any]: + root = root or repo_root() + config = load_config(root) + out = out_dir(root, config) + prompt_path = out / config["outputs"]["implement_prompt"] + selected_path = out / config["outputs"]["selected_task"] + if not prompt_path.is_file(): + raise FileNotFoundError( + "implement-prompt.md missing; call select_next_task first" + ) + return { + "selected_task_id": ( + load_json(selected_path).get("id") if selected_path.is_file() else None + ), + "implement_prompt_path": str(prompt_path), + "implement_prompt": prompt_path.read_text(encoding="utf-8"), + "pr_body_path": str(out / "pr-body.md"), + "pr_body": (out / "pr-body.md").read_text(encoding="utf-8") + if (out / "pr-body.md").is_file() + else None, + } + + +def mark_task_status( + task_id: str, + status: str, + note: str = "", + *, + root: Path | None = None, +) -> dict[str, Any]: + valid = {"ready", "planned", "in_progress", "done", "blocked", "cancelled"} + if status not in valid: + raise ValueError(f"status must be one of {sorted(valid)}") + root = root or repo_root() + config = load_config(root) + path = root / config["backlog"]["path"] + data = load_json(path) + found = False + for task in data.get("tasks") or []: + if task.get("id") == task_id: + task["status"] = status + task["status_updated_at"] = datetime.now(timezone.utc).isoformat() + if note: + task["status_note"] = note + found = True + break + if not found: + raise KeyError(f"Unknown task: {task_id}") + data["updated_at"] = datetime.now(timezone.utc).date().isoformat() + write_json(path, data) + return {"ok": True, "task_id": task_id, "status": status, "note": note or None} + + +def validate( + base_ref: str | None = None, + skip_quality: bool = True, + *, + root: Path | None = None, +) -> dict[str, Any]: + root = root or repo_root() + report = run_validate(repo_root=root, base_ref=base_ref, skip_quality=skip_quality) + # slim quality stdout for MCP + quality = [] + for q in report.get("quality") or []: + quality.append( + { + "cmd": q.get("cmd"), + "ok": q.get("ok"), + "returncode": q.get("returncode"), + "stderr_tail": (q.get("stderr_tail") or "")[-500:], + } + ) + return { + "ok": report.get("ok"), + "guardrails": report.get("guardrails"), + "quality": quality, + "generated_at": report.get("generated_at"), + } + + +def pipeline_status(*, root: Path | None = None) -> dict[str, Any]: + root = root or repo_root() + config = load_config(root) + out = out_dir(root, config) + graph_path = root / config["knowledge_graph"]["path"] + backlog_path = root / config["backlog"]["path"] + + def _exists(name: str) -> bool: + return (out / name).is_file() + + artifacts = { + "analysis_report": _exists(config["outputs"]["analysis_report"]), + "plan": _exists(config["outputs"]["plan"]), + "selected_task": _exists(config["outputs"]["selected_task"]), + "implement_prompt": _exists(config["outputs"]["implement_prompt"]), + "context_pack": (out / "context-pack.json").is_file(), + "validation": _exists(config["outputs"]["validation"]), + } + selected = None + if artifacts["selected_task"]: + selected = load_json(out / config["outputs"]["selected_task"]).get("id") + + meta = None + meta_path = root / config["knowledge_graph"]["meta_path"] + if meta_path.is_file(): + meta = load_json(meta_path) + + return { + "repo_root": str(root), + "graph_present": graph_path.is_file(), + "backlog_present": backlog_path.is_file(), + "graph_meta": meta, + "artifacts": artifacts, + "selected_task_id": selected, + "out_dir": str(out), + } + + +def dumps_result(data: Any) -> str: + return json.dumps(data, ensure_ascii=False, indent=2, default=str) diff --git a/scripts/agentic/sync_issues.py b/scripts/agentic/sync_issues.py new file mode 100644 index 000000000..a5a6b8b05 --- /dev/null +++ b/scripts/agentic/sync_issues.py @@ -0,0 +1,176 @@ +"""Create or update GitHub issues from backlog tasks (via gh CLI).""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +from paths import find_repo_root, load_config, load_json + + +def _gh(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["gh", *args], + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + + +def _issue_body(task: dict[str, Any]) -> str: + ac = "\n".join(f"- [ ] {c}" for c in (task.get("acceptance_criteria") or [])) + files = ", ".join(f"`{f}`" for f in (task.get("files") or [])) + return f"""## Agentic backlog task `{task.get("id")}` + +**Priority:** {task.get("priority")} +**Status:** {task.get("status")} +**Risk:** {task.get("estimated_risk")} +**Categories:** {", ".join(task.get("categories") or [])} + +### Problem + +{task.get("problem")} + +### Proposed solution + +{task.get("solution")} + +### Files + +{files} + +### Acceptance criteria + +{ac} + +### Metadata + +```json +{json.dumps({"id": task.get("id"), "depends_on": task.get("depends_on") or [], "labels": task.get("labels") or []}, indent=2)} +``` + + +""" + + +def list_existing_task_issues(root: Path) -> dict[str, int]: + """Map task id → issue number for open issues with agentic marker.""" + proc = _gh( + [ + "issue", + "list", + "--state", + "open", + "--label", + "agentic", + "--limit", + "100", + "--json", + "number,body,title", + ], + root, + ) + if proc.returncode != 0: + return {} + try: + items = json.loads(proc.stdout or "[]") + except json.JSONDecodeError: + return {} + found: dict[str, int] = {} + for it in items: + body = it.get("body") or "" + for line in body.splitlines(): + if "agentic-task-id:" in line: + tid = line.split("agentic-task-id:", 1)[1].strip().rstrip(" -->") + found[tid] = int(it["number"]) + return found + + +def sync( + repo_root: Path | None = None, + dry_run: bool = True, + statuses: set[str] | None = None, +) -> dict[str, Any]: + root = repo_root or find_repo_root() + config = load_config(root) + backlog = load_json(root / config["backlog"]["path"]) + want = statuses or {"ready", "planned", "in_progress"} + existing = {} if dry_run else list_existing_task_issues(root) + + actions: list[dict[str, Any]] = [] + for task in backlog.get("tasks") or []: + if task.get("status") not in want: + continue + tid = task.get("id") + title = f"[agentic] {tid}: {task.get('title')}" + body = _issue_body(task) + labels = list(task.get("labels") or []) + if "agentic" not in labels: + labels.append("agentic") + + if tid in existing: + actions.append({"action": "exists", "task_id": tid, "issue": existing[tid]}) + continue + + entry: dict[str, Any] = { + "action": "create", + "task_id": tid, + "title": title, + "labels": labels, + "dry_run": dry_run, + } + if dry_run: + actions.append(entry) + continue + + cmd = [ + "issue", + "create", + "--title", + title, + "--body", + body, + ] + for lab in labels: + cmd.extend(["--label", lab]) + proc = _gh(cmd, root) + entry["returncode"] = proc.returncode + entry["stdout"] = (proc.stdout or "").strip() + entry["stderr"] = (proc.stderr or "").strip() + actions.append(entry) + + return {"dry_run": dry_run, "actions": actions, "count": len(actions)} + + +def main(argv: list[str] | None = None) -> int: + import argparse + + p = argparse.ArgumentParser(description="Sync backlog tasks to GitHub issues") + p.add_argument( + "--apply", action="store_true", help="Actually create issues (needs gh)" + ) + p.add_argument( + "--statuses", + default="ready,planned,in_progress", + help="Comma-separated statuses to sync", + ) + args = p.parse_args(argv) + statuses = {s.strip() for s in args.statuses.split(",") if s.strip()} + result = sync(dry_run=not args.apply, statuses=statuses) + print(json.dumps(result, indent=2, ensure_ascii=False)) + if args.apply and any( + a.get("action") == "create" and a.get("returncode") not in (0, None) + for a in result["actions"] + ): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/agentic/update_backlog_status.py b/scripts/agentic/update_backlog_status.py new file mode 100644 index 000000000..f936c47b5 --- /dev/null +++ b/scripts/agentic/update_backlog_status.py @@ -0,0 +1,78 @@ +"""Update task status in .agentic/backlog/tasks.json (local helper).""" + +from __future__ import annotations + +import argparse +import sys +from datetime import datetime, timezone +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +from paths import find_repo_root, load_config, load_json, write_json # noqa: E402 +from validate_changes import check_evidence # noqa: E402 + +VALID = frozenset({"ready", "planned", "in_progress", "done", "blocked", "cancelled"}) + + +def done_block_reasons(root: Path, task: dict) -> list[str]: + """Reasons that block marking a task done (empty list = allowed). + + Tasks with kind="structural" or an evidence block must pass + check_evidence against the working tree; structural tasks without + an evidence block are always blocked (false-done guard). + """ + evidence = task.get("evidence") or {} + kind = str(task.get("kind") or "") + if kind != "structural" and not evidence: + return [] + if kind == "structural" and not evidence: + return [f"structural task {task.get('id')} has no evidence block"] + report = check_evidence(root, evidence) + reasons = [] + for c in report.get("checks") or []: + if not c.get("ok"): + reasons.append(f"{c.get('check')}: {c.get('target')} ({c.get('detail')})") + return reasons + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description="Update agentic backlog task status") + p.add_argument("task_id", help="e.g. REF-001") + p.add_argument("status", choices=sorted(VALID)) + p.add_argument("--note", default="", help="Optional note stored on task") + args = p.parse_args(argv) + + root = find_repo_root() + config = load_config(root) + path = root / config["backlog"]["path"] + data = load_json(path) + found = False + for task in data.get("tasks") or []: + if task.get("id") == args.task_id: + found = True + if args.status == "done": + reasons = done_block_reasons(root, task) + if reasons: + print(f"done blocked for {args.task_id}:", file=sys.stderr) + for r in reasons: + print(f" FAIL {r}", file=sys.stderr) + return 1 + task["status"] = args.status + task["status_updated_at"] = datetime.now(timezone.utc).isoformat() + if args.note: + task["status_note"] = args.note + break + if not found: + print(f"Unknown task: {args.task_id}", file=sys.stderr) + return 1 + data["updated_at"] = datetime.now(timezone.utc).date().isoformat() + write_json(path, data) + print(f"updated {args.task_id} -> {args.status}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/agentic/validate_changes.py b/scripts/agentic/validate_changes.py new file mode 100644 index 000000000..270a663f9 --- /dev/null +++ b/scripts/agentic/validate_changes.py @@ -0,0 +1,366 @@ +"""Validate agent changes: quality gates + path guardrails.""" + +from __future__ import annotations + +import fnmatch +import os +import re +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from paths import find_repo_root, load_config, out_dir, write_json + + +def _venv_python(root: Path) -> Path: + if sys.platform == "win32": + return root / ".venv" / "Scripts" / "python.exe" + return root / ".venv" / "bin" / "python" + + +def _run(cmd: list[str], cwd: Path) -> dict[str, Any]: + try: + proc = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + return { + "cmd": cmd, + "returncode": proc.returncode, + "ok": proc.returncode == 0, + "stdout_tail": (proc.stdout or "")[-4000:], + "stderr_tail": (proc.stderr or "")[-4000:], + } + except FileNotFoundError as exc: + return { + "cmd": cmd, + "returncode": 127, + "ok": False, + "stdout_tail": "", + "stderr_tail": str(exc), + } + + +def _ensure_dev_env(root: Path) -> dict[str, Any] | None: + """Sync dev deps when venv/python is missing so pyright can resolve imports.""" + py = _venv_python(root) + site = root / ".venv" / "Lib" / "site-packages" + if sys.platform != "win32": + # Unix venv layout + for candidate in (root / ".venv" / "lib",): + if candidate.is_dir(): + matches = list(candidate.glob("python*/site-packages")) + if matches: + site = matches[0] + break + if py.is_file() and site.is_dir() and (site / "telegram").is_dir(): + return None + return _run(["uv", "sync", "--extra", "dev"], root) + + +def _git_changed_files(root: Path, base_ref: str | None) -> list[str]: + if base_ref: + proc = subprocess.run( + ["git", "diff", "--name-only", f"{base_ref}...HEAD"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode == 0 and proc.stdout.strip(): + return [ + ln.strip().replace("\\", "/") + for ln in proc.stdout.splitlines() + if ln.strip() + ] + # Uncommitted + untracked fallback + proc = subprocess.run( + ["git", "status", "--porcelain"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + files: list[str] = [] + for ln in (proc.stdout or "").splitlines(): + if len(ln) < 4: + continue + path = ln[3:].strip().replace("\\", "/") + if " -> " in path: + path = path.split(" -> ", 1)[1] + files.append(path) + return files + + +def _match_any(path: str, globs: list[str]) -> bool: + """Match a changed path against allow/deny globs (files or untracked dirs).""" + path = path.replace("\\", "/").rstrip("/") + for pattern in globs: + pat = pattern.replace("\\", "/") + if fnmatch.fnmatch(path, pat): + return True + if fnmatch.fnmatch(path + "/", pat): + return True + # prefix/** covers the directory itself and all descendants + if pat.endswith("/**"): + prefix = pat[:-3].rstrip("/") + if path == prefix or path.startswith(prefix + "/"): + return True + # tests/**/*.py style: allow the tests/ tree when used as dir entry + if "/**/" in pat: + root = pat.split("/**/", 1)[0].rstrip("/") + if root and (path == root or path.startswith(root + "/")): + return True + if pat.endswith("/**/*") or pat.endswith("/**/*.py"): + prefix = pat.split("/**", 1)[0].rstrip("/") + if prefix and (path == prefix or path.startswith(prefix + "/")): + return True + return False + + +def check_guardrails( + changed: list[str], + config: dict[str, Any], +) -> dict[str, Any]: + g = config.get("guardrails") or {} + allowed = list(g.get("allowed_path_globs") or ["**/*"]) + blocked = list(g.get("blocked_path_globs") or []) + ignored = list(g.get("ignored_path_globs") or []) + max_files = int((config.get("implementation") or {}).get("max_files_changed") or 40) + + violations: list[str] = [] + blocked_hits: list[str] = [] + disallowed: list[str] = [] + ignored_hits: list[str] = [] + considered: list[str] = [] + + for path in changed: + if ignored and _match_any(path, ignored): + ignored_hits.append(path) + continue + considered.append(path) + # template files are documentation, not secrets + if Path(path).name in {".env.example", ".env.sample", ".env.template"}: + continue + if _match_any(path, blocked): + blocked_hits.append(path) + violations.append(f"blocked path: {path}") + continue + if allowed and not _match_any(path, allowed): + disallowed.append(path) + violations.append(f"path not in allow-list: {path}") + + if len(considered) > max_files: + violations.append(f"too many files changed: {len(considered)} > {max_files}") + + return { + "changed_files": changed, + "considered_files": considered, + "changed_count": len(considered), + "ignored_paths": ignored_hits, + "blocked_hits": blocked_hits, + "disallowed_paths": disallowed, + "violations": violations, + "ok": not violations, + } + + +def _read_text_safe(path: Path) -> str: + """Read a source file for pattern matching; unreadable files match nothing.""" + try: + return path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + +def check_evidence(root: Path, evidence: dict[str, Any]) -> dict[str, Any]: + """Check structural-task evidence patterns against the working tree. + + Supported keys (all optional): + deleted_paths: list[str] -- each path must NOT exist (old path removed) + call_sites: list[str] -- each regex must match at least once under src/ + grep: list[dict] -- {pattern, path, expect: "present"|"absent"} + tests: list[str] -- each test file must exist + + Returns {"checks": [...], "ok": bool}; empty evidence is trivially ok. + """ + checks: list[dict[str, Any]] = [] + + def add(name: str, target: str, ok: bool, detail: str = "") -> None: + checks.append( + {"check": name, "target": target, "ok": bool(ok), "detail": detail} + ) + + for rel in evidence.get("deleted_paths") or []: + rel = str(rel) + exists = (root / rel).exists() + add("deleted_path", rel, not exists, "still exists" if exists else "absent") + + src_dir = root / "src" + src_files = sorted(src_dir.rglob("*.py")) if src_dir.is_dir() else [] + src_cache: dict[Path, str] = {} + + def src_match(pattern: str) -> bool: + rx = re.compile(pattern) + for f in src_files: + if f not in src_cache: + src_cache[f] = _read_text_safe(f) + if rx.search(src_cache[f]): + return True + return False + + for pattern in evidence.get("call_sites") or []: + pattern = str(pattern) + try: + found = src_match(pattern) + except re.error as exc: + add("call_site", pattern, False, f"bad regex: {exc}") + continue + add( + "call_site", + pattern, + found, + "found in src/" if found else "no match in src/", + ) + + for item in evidence.get("grep") or []: + if not isinstance(item, dict): + add("grep", str(item), False, "grep entry must be an object") + continue + pattern = str(item.get("pattern") or "") + scope = str(item.get("path") or "src/") + expect = str(item.get("expect") or "present") + base = root / scope + if base.is_file(): + files = [base] + elif base.is_dir(): + files = sorted(base.rglob("*.py")) + else: + add("grep", f"{pattern} @ {scope}", False, f"scope not found: {scope}") + continue + try: + rx = re.compile(pattern) + except re.error as exc: + add("grep", f"{pattern} @ {scope}", False, f"bad regex: {exc}") + continue + found = any(rx.search(_read_text_safe(f)) for f in files) + ok = found if expect == "present" else not found + add("grep", f"{pattern} @ {scope}", ok, f"expect={expect} found={found}") + + for rel in evidence.get("tests") or []: + rel = str(rel) + exists = (root / rel).is_file() + add("test_file", rel, exists, "exists" if exists else "missing") + + return {"checks": checks, "ok": all(c["ok"] for c in checks) if checks else True} + + +def run( + repo_root: Path | None = None, + base_ref: str | None = None, + skip_quality: bool = False, + evidence: dict[str, Any] | None = None, +) -> dict[str, Any]: + root = repo_root or find_repo_root() + config = load_config(root) + impl = config.get("implementation") or {} + + base = ( + base_ref + or os.environ.get("AGENTIC_BASE_REF") + or os.environ.get("GITHUB_BASE_REF") + ) + if base and not base.startswith("origin/") and os.environ.get("GITHUB_ACTIONS"): + # workflow often checks out merge ref; optional + pass + + changed = _git_changed_files(root, base_ref=base if base_ref else None) + guard = check_guardrails(changed, config) + + quality: list[dict[str, Any]] = [] + if not skip_quality: + # Prefer uv if available + uv = "uv" + steps: list[list[str]] = [] + if impl.get("require_ruff", True): + steps.append([uv, "run", "ruff", "check", "src/", "tests/"]) + steps.append([uv, "run", "ruff", "format", "--check", "src/", "tests/"]) + if impl.get("require_pyright", True): + # Force venv interpreter so telegram/dotenv/etc. resolve even when the + # process default is system Python without project deps. + sync_result = _ensure_dev_env(root) + if sync_result is not None: + quality.append(sync_result) + vpy = str(_venv_python(root)) + steps.append( + [ + uv, + "run", + "pyright", + "--pythonpath", + vpy, + "--project", + str(root), + "src/ccbot/", + ] + ) + if impl.get("require_tests", True): + # Always uv run from repo root so src-layout + deps resolve + # (bare system pytest → collection ERROR ModuleNotFoundError: ccbot). + steps.append([uv, "run", "pytest", "--tb=short", "-q"]) + for cmd in steps: + quality.append(_run(cmd, root)) + + quality_ok = all(s.get("ok") for s in quality) if quality else True + + evidence_report = check_evidence(root, evidence) if evidence is not None else None + evidence_ok = evidence_report.get("ok") if evidence_report is not None else True + + report = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "guardrails": guard, + "quality": quality, + "evidence": evidence_report, + "ok": bool(guard.get("ok")) and quality_ok and evidence_ok, + } + out = out_dir(root, config) + write_json(out / config["outputs"]["validation"], report) + return report + + +def main(argv: list[str] | None = None) -> int: + import argparse + + p = argparse.ArgumentParser(description="Validate agentic changes") + p.add_argument("--base-ref", default=None) + p.add_argument("--skip-quality", action="store_true") + args = p.parse_args(argv) + report = run(base_ref=args.base_ref, skip_quality=args.skip_quality) + print(json_dumps_summary(report)) + return 0 if report.get("ok") else 1 + + +def json_dumps_summary(report: dict[str, Any]) -> str: + g = report.get("guardrails") or {} + lines = [ + f"validation_ok={report.get('ok')}", + f"files={g.get('changed_count')}", + f"guardrail_ok={g.get('ok')}", + ] + for v in g.get("violations") or []: + lines.append(f"violation: {v}") + for q in report.get("quality") or []: + cmd = " ".join(q.get("cmd") or []) + lines.append(f"quality[{'OK' if q.get('ok') else 'FAIL'}]: {cmd}") + return "\n".join(lines) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ccbot/binding_store.py b/src/ccbot/binding_store.py new file mode 100644 index 000000000..3b075c3cb --- /dev/null +++ b/src/ccbot/binding_store.py @@ -0,0 +1,129 @@ +"""Thread binding and group chat ID storage (ISS-005 phase 4a). + +Owns the two routing dicts previously held by ``SessionManager``: + + thread_bindings: user_id -> {thread_id -> window_id} + group_chat_ids: "user_id:thread_id" -> group chat_id (supergroup routing) + +plus their (de)serialization in the byte-for-byte ``state.json`` format. +Persist (atomic write) and logging stay with the ``SessionManager`` +facade; all methods here are pure in-memory operations. +""" + +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class BindingStore: + """Owner of thread_bindings and group_chat_ids (ISS-005 4a). + + thread_bindings: user_id -> {thread_id -> window_id} + group_chat_ids: "user_id:thread_id" -> group chat_id + + IMPORTANT: group_chat_ids is essential for supergroup/forum topic + support. Telegram Bot API requires the group chat_id (negative number + like -100xxx) as the chat_id parameter when sending messages to forum + topics. Using user_id as chat_id will fail with "Message thread not + found". See: https://core.telegram.org/bots/api#sendmessage + History: originally added in 5afc111, erroneously removed in 26cb81f, + restored in PR #23. + + Mutating methods do not persist: the caller (SessionManager facade) + is responsible for saving state after mutations. + """ + + thread_bindings: dict[int, dict[int, str]] = field(default_factory=dict) + group_chat_ids: dict[str, int] = field(default_factory=dict) + + # --- state.json (de)serialization (byte-for-byte format) --- + + def to_state(self) -> dict[str, Any]: + """Serialize owned dicts to state.json sections.""" + return { + "thread_bindings": { + str(uid): { + str(thread_id): window_id + for thread_id, window_id in bindings.items() + } + for uid, bindings in self.thread_bindings.items() + }, + "group_chat_ids": self.group_chat_ids, + } + + def load_state(self, state: dict[str, Any]) -> None: + """Load owned dicts from a parsed state.json dict.""" + self.thread_bindings = { + int(uid): { + int(thread_id): window_id for thread_id, window_id in bindings.items() + } + for uid, bindings in state.get("thread_bindings", {}).items() + } + self.group_chat_ids = { + k: int(v) for k, v in state.get("group_chat_ids", {}).items() + } + + def reset(self) -> None: + """Clear both dicts (corrupt-state fallback).""" + self.thread_bindings = {} + self.group_chat_ids = {} + + # --- Thread binding operations --- + + def bind_thread(self, user_id: int, thread_id: int, window_id: str) -> None: + """Bind a Telegram topic thread to a tmux window.""" + if user_id not in self.thread_bindings: + self.thread_bindings[user_id] = {} + self.thread_bindings[user_id][thread_id] = window_id + + def unbind_thread(self, user_id: int, thread_id: int) -> str | None: + """Remove a thread binding. Returns the previously bound window_id, or None.""" + bindings = self.thread_bindings.get(user_id) + if not bindings or thread_id not in bindings: + return None + window_id = bindings.pop(thread_id) + if not bindings: + del self.thread_bindings[user_id] + return window_id + + def get_window_for_thread(self, user_id: int, thread_id: int) -> str | None: + """Look up the window_id bound to a thread.""" + bindings = self.thread_bindings.get(user_id) + if not bindings: + return None + return bindings.get(thread_id) + + def iter_thread_bindings(self) -> Iterator[tuple[int, int, str]]: + """Iterate all thread bindings as (user_id, thread_id, window_id).""" + for user_id, bindings in self.thread_bindings.items(): + for thread_id, window_id in bindings.items(): + yield user_id, thread_id, window_id + + # --- Group chat ID operations (supergroup forum topic routing) --- + + def set_group_chat_id( + self, user_id: int, thread_id: int | None, chat_id: int + ) -> bool: + """Store the group chat_id for a user+thread combination. + + Returns True if the mapping was changed (caller should persist). + """ + key = f"{user_id}:{thread_id or 0}" + if self.group_chat_ids.get(key) != chat_id: + self.group_chat_ids[key] = chat_id + return True + return False + + def resolve_chat_id(self, user_id: int, thread_id: int | None = None) -> int: + """Resolve the correct chat_id for sending messages. + + Returns the stored group chat_id when a thread_id is present and a + mapping exists, otherwise falls back to user_id (for private chats). + """ + if thread_id is not None: + key = f"{user_id}:{thread_id}" + group_id = self.group_chat_ids.get(key) + if group_id is not None: + return group_id + return user_id diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 809efcc9d..baa7fb2a0 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -1,142 +1,57 @@ -"""Telegram bot handlers — the main UI layer of CCBot. +"""Telegram bot application factory — registration and lifecycle only. -Registers all command/callback/message handlers and manages the bot lifecycle. -Each Telegram topic maps 1:1 to a tmux window (Claude session). +Handler implementations live under ``ccbot.handlers``: + - command_handlers: /start /history /screenshot /esc /unbind /usage /forward /topics + - message_handlers: text / photo / voice + bash capture + - document: files (PDF, etc.) downloaded and forwarded as paths (document_handler) + - callback_router: inline keyboard dispatch + - notifications: SessionMonitor → Telegram delivery + - window_bind / screenshot_controls: shared UI helpers -Core responsibilities: - - Command handlers: /start, /history, /screenshot, /esc, /kill, /unbind, - plus forwarding unknown /commands to Claude Code via tmux. - - Callback query handler: directory browser, history pagination, - interactive UI navigation, screenshot refresh. - - Topic-based routing: each named topic binds to one tmux window. - Unbound topics trigger the directory browser to create a new session. - - Photo handling: photos sent by user are downloaded and forwarded - to Claude Code as file paths (photo_handler). - - Voice handling: voice messages are transcribed via OpenAI API and - forwarded as text (voice_handler). - - Automatic cleanup: closing a topic kills the associated window - (topic_closed_handler). Unsupported content (stickers, etc.) - is rejected with a warning (unsupported_content_handler). - - Bot lifecycle management: post_init, post_shutdown, create_bot. - -Handler modules (in handlers/): - - callback_data: Callback data constants - - message_queue: Per-user message queue management - - message_sender: Safe message sending helpers - - history: Message history pagination - - directory_browser: Directory browser UI - - interactive_ui: Interactive UI handling - - status_polling: Terminal status polling - - response_builder: Response message building - -Key functions: create_bot(), handle_new_message(). +Public re-exports keep existing imports (``from ccbot.bot import …``) stable. """ +from __future__ import annotations + import asyncio -import io import logging -import time -from pathlib import Path -from telegram import ( - Bot, - BotCommand, - InlineKeyboardButton, - InlineKeyboardMarkup, - InputMediaDocument, - Update, -) -from telegram.constants import ChatAction +from telegram import BotCommand, BotCommandScopeAllGroupChats from telegram.ext import ( AIORateLimiter, Application, CallbackQueryHandler, CommandHandler, - ContextTypes, MessageHandler, filters, ) from .config import config -from .handlers.callback_data import ( - CB_ASK_DOWN, - CB_ASK_ENTER, - CB_ASK_ESC, - CB_ASK_LEFT, - CB_ASK_REFRESH, - CB_ASK_RIGHT, - CB_ASK_SPACE, - CB_ASK_TAB, - CB_ASK_UP, - CB_DIR_CANCEL, - CB_DIR_CONFIRM, - CB_DIR_PAGE, - CB_DIR_SELECT, - CB_DIR_UP, - CB_HISTORY_NEXT, - CB_HISTORY_PREV, - CB_SESSION_CANCEL, - CB_SESSION_NEW, - CB_SESSION_SELECT, - CB_KEYS_PREFIX, - CB_SCREENSHOT_REFRESH, - CB_WIN_BIND, - CB_WIN_CANCEL, - CB_WIN_NEW, -) -from .handlers.directory_browser import ( - BROWSE_DIRS_KEY, - BROWSE_PAGE_KEY, - BROWSE_PATH_KEY, - SESSIONS_KEY, - STATE_BROWSING_DIRECTORY, - STATE_KEY, - STATE_SELECTING_SESSION, - STATE_SELECTING_WINDOW, - UNBOUND_WINDOWS_KEY, - build_directory_browser, - build_session_picker, - build_window_picker, - clear_browse_state, - clear_session_picker_state, - clear_window_picker_state, +from .handlers.callback_router import callback_handler +from .handlers.document import document_handler +from .handlers.command_handlers import ( + esc_command, + forward_command_handler, + history_command, + screenshot_command, + start_command, + topic_closed_handler, + topic_edited_handler, + unbind_command, + unsupported_content_handler, + usage_command, ) -from .handlers.cleanup import clear_topic_state -from .handlers.history import send_history -from .handlers.interactive_ui import ( - INTERACTIVE_TOOL_NAMES, - clear_interactive_mode, - clear_interactive_msg, - get_interactive_msg_id, - get_interactive_window, - handle_interactive_ui, - set_interactive_mode, -) -from .handlers.message_queue import ( - clear_status_msg_info, - enqueue_content_message, - enqueue_status_update, - get_message_queue, - shutdown_workers, -) -from .handlers.message_sender import ( - NO_LINK_PREVIEW, - safe_edit, - safe_reply, - safe_send, - send_with_fallback, -) -from .markdown_v2 import convert_markdown -from .handlers.response_builder import build_response_parts +from .handlers.message_handlers import photo_handler, text_handler, voice_handler +from .handlers.message_queue import shutdown_workers +from .handlers.notifications import handle_new_message from .handlers.status_polling import status_poll_loop -from .screenshot import text_to_image from .session import session_manager +from .session_guard import get_thread_id, is_user_allowed from .session_monitor import NewMessage, SessionMonitor -from .terminal_parser import extract_bash_output, is_interactive_ui -from .tmux_manager import tmux_manager from .transcribe import close_client as close_transcribe_client -from .transcribe import transcribe_voice -from .utils import ccbot_dir + +# Back-compat alias (older tests patched ccbot.bot._get_thread_id) +_get_thread_id = get_thread_id logger = logging.getLogger(__name__) @@ -156,1682 +71,42 @@ "model": "↗ Switch AI model", } - -def is_user_allowed(user_id: int | None) -> bool: - return user_id is not None and config.is_user_allowed(user_id) - - -def _get_thread_id(update: Update) -> int | None: - """Extract thread_id from an update, returning None if not in a named topic.""" - msg = update.message or ( - update.callback_query.message if update.callback_query else None - ) - if msg is None: - return None - tid = getattr(msg, "message_thread_id", None) - if tid is None or tid == 1: - return None - return tid - - -# --- Command handlers --- - - -async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - if not user or not is_user_allowed(user.id): - if update.message: - await safe_reply(update.message, "You are not authorized to use this bot.") - return - - clear_browse_state(context.user_data) - - if update.message: - await safe_reply( - update.message, - "🤖 *Claude Code Monitor*\n\n" - "Each topic is a session. Create a new topic to start.", - ) - - -async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Show message history for the active session or bound thread.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - if not update.message: - return - - thread_id = _get_thread_id(update) - wid = session_manager.resolve_window_for_thread(user.id, thread_id) - if not wid: - await safe_reply(update.message, "❌ No session bound to this topic.") - return - - await send_history(update.message, wid) - - -async def screenshot_command( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Capture the current tmux pane and send it as an image.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - if not update.message: - return - - thread_id = _get_thread_id(update) - wid = session_manager.resolve_window_for_thread(user.id, thread_id) - if not wid: - await safe_reply(update.message, "❌ No session bound to this topic.") - return - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply(update.message, f"❌ Window '{display}' no longer exists.") - return - - text = await tmux_manager.capture_pane(w.window_id, with_ansi=True) - if not text: - await safe_reply(update.message, "❌ Failed to capture pane content.") - return - - png_bytes = await text_to_image(text, with_ansi=True) - keyboard = _build_screenshot_keyboard(wid) - await update.message.reply_document( - document=io.BytesIO(png_bytes), - filename="screenshot.png", - reply_markup=keyboard, - ) - - -async def unbind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Unbind this topic from its Claude session without killing the window.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - if not update.message: - return - - thread_id = _get_thread_id(update) - if thread_id is None: - await safe_reply(update.message, "❌ This command only works in a topic.") - return - - wid = session_manager.get_window_for_thread(user.id, thread_id) - if not wid: - await safe_reply(update.message, "❌ No session bound to this topic.") - return - - display = session_manager.get_display_name(wid) - session_manager.unbind_thread(user.id, thread_id) - await clear_topic_state(user.id, thread_id, context.bot, context.user_data) - - await safe_reply( - update.message, - f"✅ Topic unbound from window '{display}'.\n" - "The Claude session is still running in tmux.\n" - "Send a message to bind to a new session.", - ) - - -async def esc_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Send Escape key to interrupt Claude.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - if not update.message: - return - - thread_id = _get_thread_id(update) - wid = session_manager.resolve_window_for_thread(user.id, thread_id) - if not wid: - await safe_reply(update.message, "❌ No session bound to this topic.") - return - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply(update.message, f"❌ Window '{display}' no longer exists.") - return - - # Send Escape control character (no enter) - await tmux_manager.send_keys(w.window_id, "\x1b", enter=False) - await safe_reply(update.message, "⎋ Sent Escape") - - -async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Fetch Claude Code usage stats from TUI and send to Telegram.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - if not update.message: - return - - thread_id = _get_thread_id(update) - wid = session_manager.resolve_window_for_thread(user.id, thread_id) - if not wid: - await safe_reply(update.message, "No session bound to this topic.") - return - - w = await tmux_manager.find_window_by_id(wid) - if not w: - await safe_reply(update.message, f"Window '{wid}' no longer exists.") - return - - # Send /usage command to Claude Code TUI - await tmux_manager.send_keys(w.window_id, "/usage") - # Wait for the modal to render - await asyncio.sleep(2.0) - # Capture the pane content - pane_text = await tmux_manager.capture_pane(w.window_id) - # Dismiss the modal - await tmux_manager.send_keys(w.window_id, "Escape", enter=False, literal=False) - - if not pane_text: - await safe_reply(update.message, "Failed to capture usage info.") - return - - # Try to parse structured usage info - from .terminal_parser import parse_usage_output - - usage = parse_usage_output(pane_text) - if usage and usage.parsed_lines: - text = "\n".join(usage.parsed_lines) - await safe_reply(update.message, f"```\n{text}\n```") - else: - # Fallback: send raw pane capture trimmed - trimmed = pane_text.strip() - if len(trimmed) > 3000: - trimmed = trimmed[:3000] + "\n... (truncated)" - await safe_reply(update.message, f"```\n{trimmed}\n```") - - -# --- Screenshot keyboard with quick control keys --- - -# key_id → (tmux_key, enter, literal) -_KEYS_SEND_MAP: dict[str, tuple[str, bool, bool]] = { - "up": ("Up", False, False), - "dn": ("Down", False, False), - "lt": ("Left", False, False), - "rt": ("Right", False, False), - "esc": ("Escape", False, False), - "ent": ("Enter", False, False), - "spc": ("Space", False, False), - "tab": ("Tab", False, False), - "cc": ("C-c", False, False), -} - -# key_id → display label (shown in callback answer toast) -_KEY_LABELS: dict[str, str] = { - "up": "↑", - "dn": "↓", - "lt": "←", - "rt": "→", - "esc": "⎋ Esc", - "ent": "⏎ Enter", - "spc": "␣ Space", - "tab": "⇥ Tab", - "cc": "^C", -} - - -def _build_screenshot_keyboard(window_id: str) -> InlineKeyboardMarkup: - """Build inline keyboard for screenshot: control keys + refresh.""" - - def btn(label: str, key_id: str) -> InlineKeyboardButton: - return InlineKeyboardButton( - label, - callback_data=f"{CB_KEYS_PREFIX}{key_id}:{window_id}"[:64], - ) - - return InlineKeyboardMarkup( - [ - [btn("␣ Space", "spc"), btn("↑", "up"), btn("⇥ Tab", "tab")], - [btn("←", "lt"), btn("↓", "dn"), btn("→", "rt")], - [btn("⎋ Esc", "esc"), btn("^C", "cc"), btn("⏎ Enter", "ent")], - [ - InlineKeyboardButton( - "🔄 Refresh", - callback_data=f"{CB_SCREENSHOT_REFRESH}{window_id}"[:64], - ) - ], - ] - ) - - -async def topic_closed_handler( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Handle topic closure — kill the associated tmux window and clean up state.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - - thread_id = _get_thread_id(update) - if thread_id is None: - return - - wid = session_manager.get_window_for_thread(user.id, thread_id) - if wid: - display = session_manager.get_display_name(wid) - w = await tmux_manager.find_window_by_id(wid) - if w: - await tmux_manager.kill_window(w.window_id) - logger.info( - "Topic closed: killed window %s (user=%d, thread=%d)", - display, - user.id, - thread_id, - ) - else: - logger.info( - "Topic closed: window %s already gone (user=%d, thread=%d)", - display, - user.id, - thread_id, - ) - session_manager.unbind_thread(user.id, thread_id) - # Clean up all memory state for this topic - await clear_topic_state(user.id, thread_id, context.bot, context.user_data) - else: - logger.debug( - "Topic closed: no binding (user=%d, thread=%d)", user.id, thread_id - ) - - -async def topic_edited_handler( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Handle topic rename — sync new name to tmux window and internal state.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - - msg = update.message - if not msg or not msg.forum_topic_edited: - return - - new_name = msg.forum_topic_edited.name - if new_name is None: - # Icon-only change, no rename needed - return - - thread_id = _get_thread_id(update) - if thread_id is None: - return - - wid = session_manager.get_window_for_thread(user.id, thread_id) - if not wid: - logger.debug( - "Topic edited: no binding (user=%d, thread=%d)", user.id, thread_id - ) - return - - old_name = session_manager.get_display_name(wid) - await tmux_manager.rename_window(wid, new_name) - session_manager.update_display_name(wid, new_name) - logger.info( - "Topic renamed: '%s' -> '%s' (window=%s, user=%d, thread=%d)", - old_name, - new_name, - wid, - user.id, - thread_id, - ) - - -async def forward_command_handler( - update: Update, context: ContextTypes.DEFAULT_TYPE -) -> None: - """Forward any non-bot command as a slash command to the active Claude Code session.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - if not update.message: - return - - thread_id = _get_thread_id(update) - - # Capture group chat_id for supergroup forum topic routing. - # Required: Telegram Bot API needs group chat_id (not user_id) to send - # messages with message_thread_id. Do NOT remove — see session.py docs. - chat = update.effective_chat - if chat and chat.type in ("group", "supergroup"): - session_manager.set_group_chat_id(user.id, thread_id, chat.id) - - cmd_text = update.message.text or "" - # The full text is already a slash command like "/clear" or "/compact foo" - cc_slash = cmd_text.split("@")[0] # strip bot mention - wid = session_manager.resolve_window_for_thread(user.id, thread_id) - if not wid: - await safe_reply(update.message, "❌ No session bound to this topic.") - return - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - await safe_reply(update.message, f"❌ Window '{display}' no longer exists.") - return - - display = session_manager.get_display_name(wid) - logger.info( - "Forwarding command %s to window %s (user=%d)", cc_slash, display, user.id - ) - try: - await update.message.chat.send_action(ChatAction.TYPING) - except Exception as e: - logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) - success, message = await session_manager.send_to_window(wid, cc_slash) - if success: - await safe_reply(update.message, f"⚡ [{display}] Sent: {cc_slash}") - # If /clear command was sent, clear the session association - # so we can detect the new session after first message - if cc_slash.strip().lower() == "/clear": - logger.info("Clearing session for window %s after /clear", display) - session_manager.clear_window_session(wid) - - # Interactive commands (e.g. /model) render a terminal-based UI - # with no JSONL tool_use entry. The status poller already detects - # interactive UIs every 1s (status_polling.py), so no - # proactive detection needed here — the poller handles it. - else: - await safe_reply(update.message, f"❌ {message}") - - -async def unsupported_content_handler( - update: Update, - _context: ContextTypes.DEFAULT_TYPE, -) -> None: - """Reply to non-text messages (stickers, video, etc.).""" - if not update.message: - return - user = update.effective_user - if not user or not is_user_allowed(user.id): - return - logger.debug("Unsupported content from user %d", user.id) - await safe_reply( - update.message, - "⚠ Only text, photo, and voice messages are supported. Stickers, video, and other media cannot be forwarded to Claude Code.", - ) - - -# --- Image directory for incoming photos --- -_IMAGES_DIR = ccbot_dir() / "images" -_IMAGES_DIR.mkdir(parents=True, exist_ok=True) - - -async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle photos sent by the user: download and forward path to Claude Code.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - if update.message: - await safe_reply(update.message, "You are not authorized to use this bot.") - return - - if not update.message or not update.message.photo: - return - - chat = update.message.chat - thread_id = _get_thread_id(update) - if chat.type in ("group", "supergroup") and thread_id is not None: - session_manager.set_group_chat_id(user.id, thread_id, chat.id) - - # Must be in a named topic - if thread_id is None: - await safe_reply( - update.message, - "❌ Please use a named topic. Create a new topic to start a session.", - ) - return - - wid = session_manager.get_window_for_thread(user.id, thread_id) - if wid is None: - await safe_reply( - update.message, - "❌ No session bound to this topic. Send a text message first to create one.", - ) - return - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - session_manager.unbind_thread(user.id, thread_id) - await safe_reply( - update.message, - f"❌ Window '{display}' no longer exists. Binding removed.\n" - "Send a message to start a new session.", - ) - return - - # Download the highest-resolution photo - photo = update.message.photo[-1] - tg_file = await photo.get_file() - - # Save to ~/.ccbot/images/_.jpg - filename = f"{int(time.time())}_{photo.file_unique_id}.jpg" - file_path = _IMAGES_DIR / filename - await tg_file.download_to_drive(file_path) - - # Build the message to send to Claude Code - caption = update.message.caption or "" - if caption: - text_to_send = f"{caption}\n\n(image attached: {file_path})" - else: - text_to_send = f"(image attached: {file_path})" - - try: - await update.message.chat.send_action(ChatAction.TYPING) - except Exception as e: - logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) - clear_status_msg_info(user.id, thread_id) - - success, message = await session_manager.send_to_window(wid, text_to_send) - if not success: - await safe_reply(update.message, f"❌ {message}") - return - - # Confirm to user - await safe_reply(update.message, "📷 Image sent to Claude Code.") - - -async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - """Handle voice messages: transcribe via OpenAI and forward text to Claude Code.""" - user = update.effective_user - if not user or not is_user_allowed(user.id): - if update.message: - await safe_reply(update.message, "You are not authorized to use this bot.") - return - - if not update.message or not update.message.voice: - return - - if not config.openai_api_key: - await safe_reply( - update.message, - "⚠ Voice transcription requires an OpenAI API key.\n" - "Set `OPENAI_API_KEY` in your `.env` file and restart the bot.", - ) - return - - chat = update.message.chat - thread_id = _get_thread_id(update) - if chat.type in ("group", "supergroup") and thread_id is not None: - session_manager.set_group_chat_id(user.id, thread_id, chat.id) - - if thread_id is None: - await safe_reply( - update.message, - "❌ Please use a named topic. Create a new topic to start a session.", - ) - return - - wid = session_manager.get_window_for_thread(user.id, thread_id) - if wid is None: - await safe_reply( - update.message, - "❌ No session bound to this topic. Send a text message first to create one.", - ) - return - - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - session_manager.unbind_thread(user.id, thread_id) - await safe_reply( - update.message, - f"❌ Window '{display}' no longer exists. Binding removed.\n" - "Send a message to start a new session.", - ) - return - - # Download voice as in-memory bytes - voice_file = await update.message.voice.get_file() - ogg_data = bytes(await voice_file.download_as_bytearray()) - - # Transcribe - try: - text = await transcribe_voice(ogg_data) - except ValueError as e: - await safe_reply(update.message, f"⚠ {e}") - return - except Exception as e: - logger.error("Voice transcription failed: %s", e) - await safe_reply(update.message, f"⚠ Transcription failed: {e}") - return - - try: - await update.message.chat.send_action(ChatAction.TYPING) - except Exception as e: - logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) - clear_status_msg_info(user.id, thread_id) - - success, message = await session_manager.send_to_window(wid, text) - if not success: - await safe_reply(update.message, f"❌ {message}") - return - - await safe_reply(update.message, f'🎤 "{text}"') - - -# Active bash capture tasks: (user_id, thread_id) → asyncio.Task -_bash_capture_tasks: dict[tuple[int, int], asyncio.Task[None]] = {} - - -def _cancel_bash_capture(user_id: int, thread_id: int) -> None: - """Cancel any running bash capture for this topic.""" - key = (user_id, thread_id) - task = _bash_capture_tasks.pop(key, None) - if task and not task.done(): - task.cancel() - - -async def _capture_bash_output( - bot: Bot, - user_id: int, - thread_id: int, - window_id: str, - command: str, -) -> None: - """Background task: capture ``!`` bash command output from tmux pane. - - Sends the first captured output as a new message, then edits it - in-place as more output appears. Stops after 30 s or when cancelled - (e.g. user sends a new message, which pushes content down). - """ - try: - # Wait for the command to start producing output - await asyncio.sleep(2.0) - - chat_id = session_manager.resolve_chat_id(user_id, thread_id) - msg_id: int | None = None - last_output: str = "" - - for _ in range(30): - raw = await tmux_manager.capture_pane(window_id) - if raw is None: - return - - output = extract_bash_output(raw, command) - if not output: - await asyncio.sleep(1.0) - continue - - # Skip edit if nothing changed - if output == last_output: - await asyncio.sleep(1.0) - continue - - last_output = output - - # Truncate to fit Telegram's 4096-char limit - if len(output) > 3800: - output = "… " + output[-3800:] - - if msg_id is None: - # First capture — send a new message - sent = await send_with_fallback( - bot, - chat_id, - output, - message_thread_id=thread_id, - ) - if sent: - msg_id = sent.message_id - else: - # Subsequent captures — edit in place - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=convert_markdown(output), - parse_mode="MarkdownV2", - link_preview_options=NO_LINK_PREVIEW, - ) - except Exception: - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=output, - link_preview_options=NO_LINK_PREVIEW, - ) - except Exception: - pass - - await asyncio.sleep(1.0) - except asyncio.CancelledError: - return - finally: - _bash_capture_tasks.pop((user_id, thread_id), None) - - -async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - user = update.effective_user - if not user or not is_user_allowed(user.id): - if update.message: - await safe_reply(update.message, "You are not authorized to use this bot.") - return - - if not update.message or not update.message.text: - return - - thread_id = _get_thread_id(update) - - # Capture group chat_id for supergroup forum topic routing. - # Required: Telegram Bot API needs group chat_id (not user_id) to send - # messages with message_thread_id. Do NOT remove — see session.py docs. - chat = update.effective_chat - if chat and chat.type in ("group", "supergroup"): - session_manager.set_group_chat_id(user.id, thread_id, chat.id) - - text = update.message.text - - # Ignore text in window picker mode (only for the same thread) - if context.user_data and context.user_data.get(STATE_KEY) == STATE_SELECTING_WINDOW: - pending_tid = context.user_data.get("_pending_thread_id") - if pending_tid == thread_id: - await safe_reply( - update.message, - "Please use the window picker above, or tap Cancel.", - ) - return - # Stale picker state from a different thread — clear it - clear_window_picker_state(context.user_data) - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - - # Ignore text in directory browsing mode (only for the same thread) - if ( - context.user_data - and context.user_data.get(STATE_KEY) == STATE_BROWSING_DIRECTORY - ): - pending_tid = context.user_data.get("_pending_thread_id") - if pending_tid == thread_id: - await safe_reply( - update.message, - "Please use the directory browser above, or tap Cancel.", - ) - return - # Stale browsing state from a different thread — clear it - clear_browse_state(context.user_data) - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - - # Ignore text in session picker mode (only for the same thread) - if ( - context.user_data - and context.user_data.get(STATE_KEY) == STATE_SELECTING_SESSION - ): - pending_tid = context.user_data.get("_pending_thread_id") - if pending_tid == thread_id: - await safe_reply( - update.message, - "Please use the session picker above, or tap Cancel.", - ) - return - # Stale picker state from a different thread — clear it - clear_session_picker_state(context.user_data) - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - context.user_data.pop("_selected_path", None) - - # Must be in a named topic - if thread_id is None: - await safe_reply( - update.message, - "❌ Please use a named topic. Create a new topic to start a session.", - ) - return - - wid = session_manager.get_window_for_thread(user.id, thread_id) - if wid is None: - # Unbound topic — check for unbound windows first - all_windows = await tmux_manager.list_windows() - bound_ids = {wid for _, _, wid in session_manager.iter_thread_bindings()} - unbound = [ - (w.window_id, w.window_name, w.cwd) - for w in all_windows - if w.window_id not in bound_ids - ] - logger.debug( - "Window picker check: all=%s, bound=%s, unbound=%s", - [w.window_name for w in all_windows], - bound_ids, - [name for _, name, _ in unbound], - ) - - if unbound: - # Show window picker - logger.info( - "Unbound topic: showing window picker (%d unbound windows, user=%d, thread=%d)", - len(unbound), - user.id, - thread_id, - ) - msg_text, keyboard, win_ids = build_window_picker(unbound) - if context.user_data is not None: - context.user_data[STATE_KEY] = STATE_SELECTING_WINDOW - context.user_data[UNBOUND_WINDOWS_KEY] = win_ids - context.user_data["_pending_thread_id"] = thread_id - context.user_data["_pending_thread_text"] = text - await safe_reply(update.message, msg_text, reply_markup=keyboard) - return - - # No unbound windows — show directory browser to create a new session - logger.info( - "Unbound topic: showing directory browser (user=%d, thread=%d)", - user.id, - thread_id, - ) - start_path = str(Path.cwd()) - msg_text, keyboard, subdirs = build_directory_browser(start_path) - if context.user_data is not None: - context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY - context.user_data[BROWSE_PATH_KEY] = start_path - context.user_data[BROWSE_PAGE_KEY] = 0 - context.user_data[BROWSE_DIRS_KEY] = subdirs - context.user_data["_pending_thread_id"] = thread_id - context.user_data["_pending_thread_text"] = text - await safe_reply(update.message, msg_text, reply_markup=keyboard) - return - - # Bound topic — forward to bound window - w = await tmux_manager.find_window_by_id(wid) - if not w: - display = session_manager.get_display_name(wid) - logger.info( - "Stale binding: window %s gone, unbinding (user=%d, thread=%d)", - display, - user.id, - thread_id, - ) - session_manager.unbind_thread(user.id, thread_id) - await safe_reply( - update.message, - f"❌ Window '{display}' no longer exists. Binding removed.\n" - "Send a message to start a new session.", - ) - return - - # Cosmetic / outbound-Telegram steps below must NEVER abort the handler - # before the message is injected into tmux. On flaky networks the "typing…" - # indicator (send_action) and status enqueue time out (telegram.error.TimedOut); - # since the update offset has already advanced, Telegram won't redeliver, so - # any exception here silently drops the user's message and forces a resend. - try: - await update.message.chat.send_action(ChatAction.TYPING) - except Exception as e: - logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) - try: - await enqueue_status_update( - context.bot, user.id, wid, None, thread_id=thread_id - ) - except Exception as e: - logger.warning("enqueue_status_update failed, continuing to injection: %s", e) - - # Cancel any running bash capture — new message pushes pane content down - _cancel_bash_capture(user.id, thread_id) - - # Check for pending interactive UI before sending text. - # This catches UIs (permission prompts, etc.) that status polling might have missed. - # capture_pane is a local tmux call, but handle_interactive_ui hits the network — - # isolate the whole block so a failure can't prevent the injection below. - try: - pane_text = await tmux_manager.capture_pane(w.window_id) - if pane_text and is_interactive_ui(pane_text): - # UI detected — show it to user, then send text (acts as Enter) - logger.info( - "Detected pending interactive UI before sending text (user=%d, thread=%s)", - user.id, - thread_id, - ) - await handle_interactive_ui(context.bot, user.id, wid, thread_id) - # Small delay to let UI render in Telegram before text arrives - await asyncio.sleep(0.3) - except Exception as e: - logger.warning("interactive-UI precheck failed, continuing to injection: %s", e) - - success, message = await session_manager.send_to_window(wid, text) - if not success: - await safe_reply(update.message, f"❌ {message}") - return - - # Start background capture for ! bash command output - if text.startswith("!") and len(text) > 1: - bash_cmd = text[1:] # strip leading "!" - task = asyncio.create_task( - _capture_bash_output(context.bot, user.id, thread_id, wid, bash_cmd) - ) - _bash_capture_tasks[(user.id, thread_id)] = task - - # If in interactive mode, refresh the UI after sending text - interactive_window = get_interactive_window(user.id, thread_id) - if interactive_window and interactive_window == wid: - await asyncio.sleep(0.2) - await handle_interactive_ui(context.bot, user.id, wid, thread_id) - - -# --- Window creation helper --- - - -async def _create_and_bind_window( - query: object, - context: ContextTypes.DEFAULT_TYPE, - user: object, - selected_path: str, - pending_thread_id: int | None, - resume_session_id: str | None = None, -) -> None: - """Create a tmux window, bind it to a topic, and forward pending text. - - Shared by CB_DIR_CONFIRM (no sessions), CB_SESSION_NEW, and CB_SESSION_SELECT. - """ - from telegram import CallbackQuery, User - - assert isinstance(query, CallbackQuery) - assert isinstance(user, User) - - success, message, created_wname, created_wid = await tmux_manager.create_window( - selected_path, resume_session_id=resume_session_id - ) - if success: - logger.info( - "Window created: %s (id=%s) at %s (user=%d, thread=%s, resume=%s)", - created_wname, - created_wid, - selected_path, - user.id, - pending_thread_id, - resume_session_id, - ) - # Wait for Claude Code's SessionStart hook to register in session_map. - # Resume sessions take longer to start (loading session state), so use - # a longer timeout to avoid silently dropping messages. - hook_timeout = 15.0 if resume_session_id else 5.0 - hook_ok = await session_manager.wait_for_session_map_entry( - created_wid, timeout=hook_timeout - ) - - # --resume: messages keep writing to the resumed session's JSONL, and - # current Claude Code reports the original session_id in the - # SessionStart hook (source="resume"), so normally nothing to fix up. - # If the hook timed out or reported a different id (older CC versions), - # force both window_state AND session_map to the resumed id — - # session_map drives the monitor's watch list, and load_session_map() - # would revert a window_state-only override on the next poll cycle. - if resume_session_id: - ws = session_manager.get_window_state(created_wid) - if not hook_ok: - logger.warning( - "Hook timed out for resume window %s, " - "manually setting session_id=%s cwd=%s", - created_wid, - resume_session_id, - selected_path, - ) - ws.session_id = resume_session_id - ws.cwd = str(selected_path) - ws.window_name = created_wname - session_manager._save_state() - elif ws.session_id != resume_session_id: - logger.info( - "Resume override: window %s session_id %s -> %s", - created_wid, - ws.session_id, - resume_session_id, - ) - ws.session_id = resume_session_id - session_manager._save_state() - await session_manager.override_session_map_entry( - created_wid, - resume_session_id, - cwd=str(selected_path), - window_name=created_wname, - ) - - if pending_thread_id is not None: - # Thread bind flow: bind thread to newly created window - session_manager.bind_thread( - user.id, pending_thread_id, created_wid, window_name=created_wname - ) - - status = "Resumed" if resume_session_id else "Created" - await safe_edit( - query, - f"✅ {message}\n\n{status}. Send messages here.", - ) - - # Send pending text if any - pending_text = ( - context.user_data.get("_pending_thread_text") - if context.user_data - else None - ) - if pending_text: - logger.debug( - "Forwarding pending text to window %s (len=%d)", - created_wname, - len(pending_text), - ) - if context.user_data is not None: - context.user_data.pop("_pending_thread_text", None) - context.user_data.pop("_pending_thread_id", None) - send_ok, send_msg = await session_manager.send_to_window( - created_wid, - pending_text, - ) - if not send_ok: - logger.warning("Failed to forward pending text: %s", send_msg) - resolved_chat = session_manager.resolve_chat_id( - user.id, pending_thread_id - ) - await safe_send( - context.bot, - resolved_chat, - f"❌ Failed to send pending message: {send_msg}", - message_thread_id=pending_thread_id, - ) - elif context.user_data is not None: - context.user_data.pop("_pending_thread_id", None) - else: - # Should not happen in topic-only mode, but handle gracefully - await safe_edit(query, f"✅ {message}") - else: - await safe_edit(query, f"❌ {message}") - if pending_thread_id is not None and context.user_data is not None: - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - await query.answer("Created" if success else "Failed") - - -# --- Callback query handler --- - - -async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - query = update.callback_query - if not query or not query.data: - return - - user = update.effective_user - if not user or not is_user_allowed(user.id): - await query.answer("Not authorized") - return - - data = query.data - - # Capture group chat_id for supergroup forum topic routing. - # Required: Telegram Bot API needs group chat_id (not user_id) to send - # messages with message_thread_id. Do NOT remove — see session.py docs. - cb_thread_id = _get_thread_id(update) - chat = update.effective_chat - if chat and chat.type in ("group", "supergroup"): - session_manager.set_group_chat_id(user.id, cb_thread_id, chat.id) - - # History: older/newer pagination - # Format: hp:::: or hn:::: - if data.startswith(CB_HISTORY_PREV) or data.startswith(CB_HISTORY_NEXT): - prefix_len = len(CB_HISTORY_PREV) # same length for both - rest = data[prefix_len:] - try: - parts = rest.split(":") - if len(parts) < 4: - # Old format without byte range: page:window_id - offset_str, window_id = rest.split(":", 1) - start_byte, end_byte = 0, 0 - else: - # New format: page:window_id:start:end (window_id may contain colons) - offset_str = parts[0] - start_byte = int(parts[-2]) - end_byte = int(parts[-1]) - window_id = ":".join(parts[1:-2]) - offset = int(offset_str) - except (ValueError, IndexError): - await query.answer("Invalid data") - return - - w = await tmux_manager.find_window_by_id(window_id) - if w: - await send_history( - query, - window_id, - offset=offset, - edit=True, - start_byte=start_byte, - end_byte=end_byte, - # Don't pass user_id for pagination - offset update only on initial view - # This prevents offset from going backwards if new messages arrive while paging - ) - else: - await safe_edit(query, "Window no longer exists.") - await query.answer("Page updated") - - # Directory browser handlers - elif data.startswith(CB_DIR_SELECT): - # Validate: callback must come from the same topic that started browsing - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale browser (topic mismatch)", show_alert=True) - return - # callback_data contains index, not dir name (to avoid 64-byte limit) - try: - idx = int(data[len(CB_DIR_SELECT) :]) - except ValueError: - await query.answer("Invalid data") - return - - # Look up dir name from cached subdirs - cached_dirs: list[str] = ( - context.user_data.get(BROWSE_DIRS_KEY, []) if context.user_data else [] - ) - if idx < 0 or idx >= len(cached_dirs): - await query.answer( - "Directory list changed, please refresh", show_alert=True - ) - return - subdir_name = cached_dirs[idx] - - default_path = str(Path.cwd()) - current_path = ( - context.user_data.get(BROWSE_PATH_KEY, default_path) - if context.user_data - else default_path - ) - new_path = (Path(current_path) / subdir_name).resolve() - - if not new_path.exists() or not new_path.is_dir(): - await query.answer("Directory not found", show_alert=True) - return - - new_path_str = str(new_path) - if context.user_data is not None: - context.user_data[BROWSE_PATH_KEY] = new_path_str - context.user_data[BROWSE_PAGE_KEY] = 0 - - msg_text, keyboard, subdirs = build_directory_browser(new_path_str) - if context.user_data is not None: - context.user_data[BROWSE_DIRS_KEY] = subdirs - await safe_edit(query, msg_text, reply_markup=keyboard) - await query.answer() - - elif data == CB_DIR_UP: - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale browser (topic mismatch)", show_alert=True) - return - default_path = str(Path.cwd()) - current_path = ( - context.user_data.get(BROWSE_PATH_KEY, default_path) - if context.user_data - else default_path - ) - current = Path(current_path).resolve() - parent = current.parent - # No restriction - allow navigating anywhere - - parent_path = str(parent) - if context.user_data is not None: - context.user_data[BROWSE_PATH_KEY] = parent_path - context.user_data[BROWSE_PAGE_KEY] = 0 - - msg_text, keyboard, subdirs = build_directory_browser(parent_path) - if context.user_data is not None: - context.user_data[BROWSE_DIRS_KEY] = subdirs - await safe_edit(query, msg_text, reply_markup=keyboard) - await query.answer() - - elif data.startswith(CB_DIR_PAGE): - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale browser (topic mismatch)", show_alert=True) - return - try: - pg = int(data[len(CB_DIR_PAGE) :]) - except ValueError: - await query.answer("Invalid data") - return - default_path = str(Path.cwd()) - current_path = ( - context.user_data.get(BROWSE_PATH_KEY, default_path) - if context.user_data - else default_path - ) - if context.user_data is not None: - context.user_data[BROWSE_PAGE_KEY] = pg - - msg_text, keyboard, subdirs = build_directory_browser(current_path, pg) - if context.user_data is not None: - context.user_data[BROWSE_DIRS_KEY] = subdirs - await safe_edit(query, msg_text, reply_markup=keyboard) - await query.answer() - - elif data == CB_DIR_CONFIRM: - default_path = str(Path.cwd()) - selected_path = ( - context.user_data.get(BROWSE_PATH_KEY, default_path) - if context.user_data - else default_path - ) - # Check if this was initiated from a thread bind flow - pending_thread_id: int | None = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - - # Validate: confirm button must come from the same topic that started browsing - confirm_thread_id = _get_thread_id(update) - if pending_thread_id is not None and confirm_thread_id != pending_thread_id: - clear_browse_state(context.user_data) - if context.user_data is not None: - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - await query.answer("Stale browser (topic mismatch)", show_alert=True) - return - - clear_browse_state(context.user_data) - - # Check for existing sessions in this directory - sessions = await session_manager.list_sessions_for_directory(selected_path) - if sessions: - # Show session picker — store state for later - if context.user_data is not None: - context.user_data[STATE_KEY] = STATE_SELECTING_SESSION - context.user_data[SESSIONS_KEY] = sessions - context.user_data["_selected_path"] = selected_path - text, keyboard = build_session_picker(sessions) - await safe_edit(query, text, reply_markup=keyboard) - await query.answer() - return - - # No existing sessions — create new window directly - await _create_and_bind_window( - query, context, user, selected_path, pending_thread_id - ) - - elif data == CB_DIR_CANCEL: - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale browser (topic mismatch)", show_alert=True) - return - clear_browse_state(context.user_data) - if context.user_data is not None: - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - await safe_edit(query, "Cancelled") - await query.answer("Cancelled") - - # Session picker: resume existing session - elif data.startswith(CB_SESSION_SELECT): - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - # Fallback: if _pending_thread_id was cleared (e.g. by a message in - # another topic), recover it from the callback query's message context - if pending_tid is None: - pending_tid = _get_thread_id(update) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale picker (topic mismatch)", show_alert=True) - return - try: - idx = int(data[len(CB_SESSION_SELECT) :]) - except ValueError: - await query.answer("Invalid data") - return - - cached_sessions = ( - context.user_data.get(SESSIONS_KEY, []) if context.user_data else [] - ) - if idx < 0 or idx >= len(cached_sessions): - await query.answer("Session not found") - return - - session = cached_sessions[idx] - selected_path = ( - context.user_data.get("_selected_path", str(Path.cwd())) - if context.user_data - else str(Path.cwd()) - ) - clear_session_picker_state(context.user_data) - if context.user_data is not None: - context.user_data.pop("_selected_path", None) - - await _create_and_bind_window( - query, - context, - user, - selected_path, - pending_tid, - resume_session_id=session.session_id, - ) - - elif data == CB_SESSION_NEW: - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is None: - pending_tid = _get_thread_id(update) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale picker (topic mismatch)", show_alert=True) - return - selected_path = ( - context.user_data.get("_selected_path", str(Path.cwd())) - if context.user_data - else str(Path.cwd()) - ) - clear_session_picker_state(context.user_data) - if context.user_data is not None: - context.user_data.pop("_selected_path", None) - - await _create_and_bind_window(query, context, user, selected_path, pending_tid) - - elif data == CB_SESSION_CANCEL: - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale picker (topic mismatch)", show_alert=True) - return - clear_session_picker_state(context.user_data) - if context.user_data is not None: - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - context.user_data.pop("_selected_path", None) - await safe_edit(query, "Cancelled") - await query.answer("Cancelled") - - # Window picker: bind existing window - elif data.startswith(CB_WIN_BIND): - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale picker (topic mismatch)", show_alert=True) - return - try: - idx = int(data[len(CB_WIN_BIND) :]) - except ValueError: - await query.answer("Invalid data") - return - - cached_windows: list[str] = ( - context.user_data.get(UNBOUND_WINDOWS_KEY, []) if context.user_data else [] - ) - if idx < 0 or idx >= len(cached_windows): - await query.answer("Window list changed, please retry", show_alert=True) - return - selected_wid = cached_windows[idx] - - # Verify window still exists - w = await tmux_manager.find_window_by_id(selected_wid) - if not w: - display = session_manager.get_display_name(selected_wid) - await query.answer(f"Window '{display}' no longer exists", show_alert=True) - return - - thread_id = _get_thread_id(update) - if thread_id is None: - await query.answer("Not in a topic", show_alert=True) - return - - display = w.window_name - clear_window_picker_state(context.user_data) - session_manager.bind_thread( - user.id, thread_id, selected_wid, window_name=display - ) - - await safe_edit( - query, - f"✅ Bound to window `{display}`", - ) - - # Forward pending text if any - pending_text = ( - context.user_data.get("_pending_thread_text") if context.user_data else None - ) - if context.user_data is not None: - context.user_data.pop("_pending_thread_text", None) - context.user_data.pop("_pending_thread_id", None) - if pending_text: - send_ok, send_msg = await session_manager.send_to_window( - selected_wid, pending_text - ) - if not send_ok: - logger.warning("Failed to forward pending text: %s", send_msg) - resolved_chat = session_manager.resolve_chat_id(user.id, thread_id) - await safe_send( - context.bot, - resolved_chat, - f"❌ Failed to send pending message: {send_msg}", - message_thread_id=thread_id, - ) - await query.answer("Bound") - - # Window picker: new session → transition to directory browser - elif data == CB_WIN_NEW: - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale picker (topic mismatch)", show_alert=True) - return - # Preserve pending thread info, clear only picker state - clear_window_picker_state(context.user_data) - start_path = str(Path.cwd()) - msg_text, keyboard, subdirs = build_directory_browser(start_path) - if context.user_data is not None: - context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY - context.user_data[BROWSE_PATH_KEY] = start_path - context.user_data[BROWSE_PAGE_KEY] = 0 - context.user_data[BROWSE_DIRS_KEY] = subdirs - await safe_edit(query, msg_text, reply_markup=keyboard) - await query.answer() - - # Window picker: cancel - elif data == CB_WIN_CANCEL: - pending_tid = ( - context.user_data.get("_pending_thread_id") if context.user_data else None - ) - if pending_tid is not None and _get_thread_id(update) != pending_tid: - await query.answer("Stale picker (topic mismatch)", show_alert=True) - return - clear_window_picker_state(context.user_data) - if context.user_data is not None: - context.user_data.pop("_pending_thread_id", None) - context.user_data.pop("_pending_thread_text", None) - await safe_edit(query, "Cancelled") - await query.answer("Cancelled") - - # Screenshot: Refresh - elif data.startswith(CB_SCREENSHOT_REFRESH): - window_id = data[len(CB_SCREENSHOT_REFRESH) :] - w = await tmux_manager.find_window_by_id(window_id) - if not w: - await query.answer("Window no longer exists", show_alert=True) - return - - text = await tmux_manager.capture_pane(w.window_id, with_ansi=True) - if not text: - await query.answer("Failed to capture pane", show_alert=True) - return - - png_bytes = await text_to_image(text, with_ansi=True) - keyboard = _build_screenshot_keyboard(window_id) - try: - await query.edit_message_media( - media=InputMediaDocument( - media=io.BytesIO(png_bytes), filename="screenshot.png" - ), - reply_markup=keyboard, - ) - await query.answer("Refreshed") - except Exception as e: - logger.error(f"Failed to refresh screenshot: {e}") - await query.answer("Failed to refresh", show_alert=True) - - elif data == "noop": - await query.answer() - - # Interactive UI: Up arrow - elif data.startswith(CB_ASK_UP): - window_id = data[len(CB_ASK_UP) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys(w.window_id, "Up", enter=False, literal=False) - await asyncio.sleep(0.5) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer() - - # Interactive UI: Down arrow - elif data.startswith(CB_ASK_DOWN): - window_id = data[len(CB_ASK_DOWN) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys( - w.window_id, "Down", enter=False, literal=False - ) - await asyncio.sleep(0.5) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer() - - # Interactive UI: Left arrow - elif data.startswith(CB_ASK_LEFT): - window_id = data[len(CB_ASK_LEFT) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys( - w.window_id, "Left", enter=False, literal=False - ) - await asyncio.sleep(0.5) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer() - - # Interactive UI: Right arrow - elif data.startswith(CB_ASK_RIGHT): - window_id = data[len(CB_ASK_RIGHT) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys( - w.window_id, "Right", enter=False, literal=False - ) - await asyncio.sleep(0.5) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer() - - # Interactive UI: Escape - elif data.startswith(CB_ASK_ESC): - window_id = data[len(CB_ASK_ESC) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys( - w.window_id, "Escape", enter=False, literal=False - ) - await clear_interactive_msg(user.id, context.bot, thread_id) - await query.answer("⎋ Esc") - - # Interactive UI: Enter - elif data.startswith(CB_ASK_ENTER): - window_id = data[len(CB_ASK_ENTER) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys( - w.window_id, "Enter", enter=False, literal=False - ) - await asyncio.sleep(0.5) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer("⏎ Enter") - - # Interactive UI: Space - elif data.startswith(CB_ASK_SPACE): - window_id = data[len(CB_ASK_SPACE) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys( - w.window_id, "Space", enter=False, literal=False - ) - await asyncio.sleep(0.5) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer("␣ Space") - - # Interactive UI: Tab - elif data.startswith(CB_ASK_TAB): - window_id = data[len(CB_ASK_TAB) :] - thread_id = _get_thread_id(update) - w = await tmux_manager.find_window_by_id(window_id) - if w: - await tmux_manager.send_keys(w.window_id, "Tab", enter=False, literal=False) - await asyncio.sleep(0.5) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer("⇥ Tab") - - # Interactive UI: refresh display - elif data.startswith(CB_ASK_REFRESH): - window_id = data[len(CB_ASK_REFRESH) :] - thread_id = _get_thread_id(update) - await handle_interactive_ui(context.bot, user.id, window_id, thread_id) - await query.answer("🔄") - - # Screenshot quick keys: send key to tmux window - elif data.startswith(CB_KEYS_PREFIX): - rest = data[len(CB_KEYS_PREFIX) :] - colon_idx = rest.find(":") - if colon_idx < 0: - await query.answer("Invalid data") - return - key_id = rest[:colon_idx] - window_id = rest[colon_idx + 1 :] - - key_info = _KEYS_SEND_MAP.get(key_id) - if not key_info: - await query.answer("Unknown key") - return - - tmux_key, enter, literal = key_info - w = await tmux_manager.find_window_by_id(window_id) - if not w: - await query.answer("Window not found", show_alert=True) - return - - await tmux_manager.send_keys( - w.window_id, tmux_key, enter=enter, literal=literal - ) - await query.answer(_KEY_LABELS.get(key_id, key_id)) - - # Refresh screenshot after key press - await asyncio.sleep(0.5) - text = await tmux_manager.capture_pane(w.window_id, with_ansi=True) - if text: - png_bytes = await text_to_image(text, with_ansi=True) - keyboard = _build_screenshot_keyboard(window_id) - try: - await query.edit_message_media( - media=InputMediaDocument( - media=io.BytesIO(png_bytes), - filename="screenshot.png", - ), - reply_markup=keyboard, - ) - except Exception: - pass # Screenshot unchanged or message too old - - -# --- Streaming response / notifications --- - - -async def handle_new_message(msg: NewMessage, bot: Bot) -> None: - """Handle a new assistant message — enqueue for sequential processing. - - Messages are queued per-user to ensure status messages always appear last. - Routes via thread_bindings to deliver to the correct topic. - """ - status = "complete" if msg.is_complete else "streaming" - logger.info( - f"handle_new_message [{status}]: session={msg.session_id}, " - f"text_len={len(msg.text)}" - ) - - # Find users whose thread-bound window matches this session - active_users = await session_manager.find_users_for_session(msg.session_id) - - if not active_users: - logger.info(f"No active users for session {msg.session_id}") - return - - for user_id, wid, thread_id in active_users: - # Handle interactive tools specially - capture terminal and send UI - if msg.tool_name in INTERACTIVE_TOOL_NAMES and msg.content_type == "tool_use": - # Mark interactive mode BEFORE sleeping so polling skips this window - set_interactive_mode(user_id, wid, thread_id) - # Flush pending messages (e.g. plan content) before sending interactive UI - queue = get_message_queue(user_id) - if queue: - await queue.join() - # Wait briefly for Claude Code to render the question UI - await asyncio.sleep(0.3) - handled = await handle_interactive_ui(bot, user_id, wid, thread_id) - if handled: - # Update user's read offset - session = await session_manager.resolve_session_for_window(wid) - if session and session.file_path: - try: - file_size = Path(session.file_path).stat().st_size - session_manager.update_user_window_offset( - user_id, wid, file_size - ) - except OSError: - pass - continue # Don't send the normal tool_use message - else: - # UI not rendered — clear the early-set mode - clear_interactive_mode(user_id, thread_id) - - # Any non-interactive message means the interaction is complete — delete the UI message - if get_interactive_msg_id(user_id, thread_id): - await clear_interactive_msg(user_id, bot, thread_id) - - # Skip tool call notifications when CCBOT_SHOW_TOOL_CALLS=false - if not config.show_tool_calls and msg.content_type in ( - "tool_use", - "tool_result", - ): - continue - - parts = build_response_parts( - msg.text, - msg.is_complete, - msg.content_type, - msg.role, - ) - - if msg.is_complete: - # Enqueue content message task - # Note: tool_result editing is handled inside _process_content_task - # to ensure sequential processing with tool_use message sending - await enqueue_content_message( - bot=bot, - user_id=user_id, - window_id=wid, - parts=parts, - tool_use_id=msg.tool_use_id, - content_type=msg.content_type, - text=msg.text, - thread_id=thread_id, - image_data=msg.image_data, - ) - - # Update user's read offset to current file position - # This marks these messages as "read" for this user - session = await session_manager.resolve_session_for_window(wid) - if session and session.file_path: - try: - file_size = Path(session.file_path).stat().st_size - session_manager.update_user_window_offset(user_id, wid, file_size) - except OSError: - pass - - -# --- App lifecycle --- +# Stable re-exports for tests and external callers +__all__ = [ + "CC_COMMANDS", + "callback_handler", + "create_bot", + "esc_command", + "forward_command_handler", + "get_thread_id", + "handle_new_message", + "history_command", + "is_user_allowed", + "photo_handler", + "post_init", + "post_shutdown", + "screenshot_command", + "session_monitor", + "start_command", + "text_handler", + "topic_closed_handler", + "topic_edited_handler", + "unbind_command", + "unsupported_content_handler", + "usage_command", + "voice_handler", +] async def post_init(application: Application) -> None: global session_monitor, _status_poll_task - await application.bot.delete_my_commands() + # Register for the default scope and explicitly for group chats: a menu + # defined only for the default scope can show up empty in a freshly created + # forum group (observed in the 2026-10-12 smoke run). + command_scopes = (None, BotCommandScopeAllGroupChats()) + for scope in command_scopes: + await application.bot.delete_my_commands(scope=scope) bot_commands = [ BotCommand("start", "Show welcome message"), @@ -1846,7 +121,8 @@ async def post_init(application: Application) -> None: for cmd_name, desc in CC_COMMANDS.items(): bot_commands.append(BotCommand(cmd_name, desc)) - await application.bot.set_my_commands(bot_commands) + for scope in command_scopes: + await application.bot.set_my_commands(bot_commands, scope=scope) # Re-resolve stale window IDs from persisted state against live tmux windows await session_manager.resolve_stale_ids() @@ -1934,16 +210,49 @@ def create_bot() -> Application: # Forward any other /command to Claude Code application.add_handler(MessageHandler(filters.COMMAND, forward_command_handler)) application.add_handler( - MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler) + MessageHandler( + filters.TEXT + & ~filters.COMMAND + & ~filters.UpdateType.EDITED + & ~filters.UpdateType.CHANNEL_POST, + text_handler, + ) ) # Photos: download and forward file path to Claude Code - application.add_handler(MessageHandler(filters.PHOTO, photo_handler)) + application.add_handler( + MessageHandler( + filters.PHOTO + & ~filters.UpdateType.EDITED + & ~filters.UpdateType.CHANNEL_POST, + photo_handler, + ) + ) # Voice: transcribe via OpenAI and forward text to Claude Code - application.add_handler(MessageHandler(filters.VOICE, voice_handler)) + application.add_handler( + MessageHandler( + filters.VOICE + & ~filters.UpdateType.EDITED + & ~filters.UpdateType.CHANNEL_POST, + voice_handler, + ) + ) + # Documents (PDF, etc.): download and forward file path to Claude Code + application.add_handler( + MessageHandler( + filters.Document.ALL + & ~filters.UpdateType.EDITED + & ~filters.UpdateType.CHANNEL_POST, + document_handler, + ) + ) # Catch-all: non-text content (stickers, video, etc.) application.add_handler( MessageHandler( - ~filters.COMMAND & ~filters.TEXT & ~filters.StatusUpdate.ALL, + ~filters.COMMAND + & ~filters.TEXT + & ~filters.StatusUpdate.ALL + & ~filters.UpdateType.EDITED + & ~filters.UpdateType.CHANNEL_POST, unsupported_content_handler, ) ) diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 22d1de768..7db35f8ce 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -107,6 +107,25 @@ def __init__(self) -> None: "OPENAI_BASE_URL", "https://api.openai.com/v1" ) + # Voice transcription backend: "openai", "local" (faster-whisper), or "off" + self.whisper_backend: str = os.getenv("CCBOT_WHISPER_BACKEND", "openai").lower() + if self.whisper_backend not in ("local", "openai", "off"): + logger.warning( + "Unknown CCBOT_WHISPER_BACKEND=%r, defaulting to 'off'", + self.whisper_backend, + ) + self.whisper_backend = "off" + self.whisper_model: str = os.getenv("CCBOT_WHISPER_MODEL", "small") + + raw_lang = os.getenv("CCBOT_WHISPER_LANGUAGE", "auto").strip().lower() + if raw_lang not in ("auto", "en", "ru"): + logger.warning( + "Unknown CCBOT_WHISPER_LANGUAGE=%r, defaulting to 'auto'", + raw_lang, + ) + raw_lang = "auto" + self.whisper_language: str = raw_lang + # Scrub sensitive vars from os.environ so child processes never inherit them. # Values are already captured in Config attributes above. for var in SENSITIVE_ENV_VARS: diff --git a/src/ccbot/errors.py b/src/ccbot/errors.py new file mode 100644 index 000000000..a791b16d6 --- /dev/null +++ b/src/ccbot/errors.py @@ -0,0 +1,77 @@ +"""Typed error helpers for Telegram / tmux hot paths (REF-003). + +Centralizes which exceptions are expected/transient vs fatal, and how they +are logged. RetryAfter is always re-raised for rate-limit handling. + +With ``PTB_TIMEDELTA=1`` (set in main / conftest), ``RetryAfter.retry_after`` +is a ``datetime.timedelta``; ``retry_after_seconds`` normalizes both shapes. +""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from typing import Any + +from telegram.error import ( + BadRequest, + Forbidden, + NetworkError, + RetryAfter, + TelegramError, + TimedOut, +) + +# Expected Telegram failures (edit/send/delete race, network blips, etc.) +TELEGRAM_EXPECTED = (BadRequest, Forbidden, TimedOut, NetworkError, TelegramError) +# Message/topic already gone or no longer editable +TELEGRAM_STALE = (BadRequest, Forbidden) +# Transient transport failures worth retry/backoff at higher layers +TELEGRAM_TRANSIENT = (TimedOut, NetworkError) + + +def log_exception( + log: logging.Logger, + message: str, + exc: BaseException, + *, + level: int = logging.DEBUG, + **ctx: Any, +) -> None: + """Log ``exc`` with type name and optional context keys.""" + parts = [f"{k}={v}" for k, v in ctx.items() if v is not None] + suffix = f" ({', '.join(parts)})" if parts else "" + log.log(level, "%s: %s: %s%s", message, type(exc).__name__, exc, suffix) + + +def reraise_retry_after(exc: BaseException) -> None: + """Re-raise RetryAfter; no-op for other exceptions.""" + if isinstance(exc, RetryAfter): + raise + + +def retry_after_seconds(exc: RetryAfter) -> float: + """Return flood-control wait in seconds from a RetryAfter exception. + + Supports both legacy ``int`` and PTB v22.2+ ``timedelta`` values. + """ + value = exc.retry_after + if isinstance(value, timedelta): + return float(value.total_seconds()) + return float(value) + + +__all__ = [ + "BadRequest", + "Forbidden", + "NetworkError", + "RetryAfter", + "TELEGRAM_EXPECTED", + "TELEGRAM_STALE", + "TELEGRAM_TRANSIENT", + "TelegramError", + "TimedOut", + "log_exception", + "reraise_retry_after", + "retry_after_seconds", +] diff --git a/src/ccbot/handlers/__init__.py b/src/ccbot/handlers/__init__.py index 40d7b88dc..9efd72bc4 100644 --- a/src/ccbot/handlers/__init__.py +++ b/src/ccbot/handlers/__init__.py @@ -1,12 +1,20 @@ """Telegram bot handlers package — modular handler organization. This package contains the Telegram bot handlers split by functionality: - - callback_data: Callback data constants (CB_* prefixes) + - auth: thin re-export of is_user_allowed / get_thread_id (canonical: session_guard) + - callback_data: CB_* prefixes, typed payloads, encode_*/parse_* + - callback_router: Central inline-keyboard dispatch + - command_handlers: Slash commands and topic lifecycle + - message_handlers: text / photo / voice + bash capture - message_queue: Per-user message queue management - message_sender: Safe message sending helpers with MarkdownV2 fallback - history: Message history pagination - directory_browser: Directory selection UI - interactive_ui: Interactive UI (AskUserQuestion, Permission Prompt, etc.) + - notifications: SessionMonitor → Telegram delivery - status_polling: Terminal status line polling - response_builder: Build paginated response messages + - screenshot_controls: Screenshot keyboard + key maps + - window_bind: Create+bind tmux window to topic + - cleanup: Topic state cleanup """ diff --git a/src/ccbot/handlers/auth.py b/src/ccbot/handlers/auth.py new file mode 100644 index 000000000..0674d6e9f --- /dev/null +++ b/src/ccbot/handlers/auth.py @@ -0,0 +1,12 @@ +"""Thin re-export of auth/topic helpers (ISS-002). + +Canonical definitions live in ``ccbot.session_guard``. This module exists for +backward-compatible imports (``from ccbot.handlers.auth import …``) only — +do not reintroduce independent implementations here. +""" + +from __future__ import annotations + +from ..session_guard import get_thread_id, is_user_allowed + +__all__ = ["get_thread_id", "is_user_allowed"] diff --git a/src/ccbot/handlers/callback_data.py b/src/ccbot/handlers/callback_data.py index e4846aff4..5ed3e5722 100644 --- a/src/ccbot/handlers/callback_data.py +++ b/src/ccbot/handlers/callback_data.py @@ -1,17 +1,30 @@ -"""Callback data constants for Telegram inline keyboards. +"""Callback data constants, typed payloads, and encode/parse helpers. Defines all CB_* prefixes used for routing callback queries in the bot. Each prefix identifies a specific action or navigation target. -Constants: - - CB_HISTORY_*: History pagination - - CB_DIR_*: Directory browser navigation - - CB_WIN_*: Window picker (bind existing unbound window) - - CB_SCREENSHOT_*: Screenshot refresh - - CB_ASK_*: Interactive UI navigation (arrows, enter, esc) - - CB_KEYS_PREFIX: Screenshot control keys (kb::) +Typed frozen dataclasses replace ad-hoc string splits. Wire formats are +unchanged for backward compatibility with existing Telegram keyboards: + + - History: ``hp|hn::::`` (legacy without range) + - Dir select/page: ``db:sel:``, ``db:page:`` + - Window/session pickers: ``wb:sel:``, ``rs:sel:`` + - Screenshot: ``ss:ref:``, keys ``kb::`` + - Interactive UI: ``aq::`` + +Always pass payloads through ``safe_callback_data`` when building +InlineKeyboardButton — never silent ``[:64]`` truncation. """ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Union + +# Telegram Bot API hard limit for callback_data (UTF-8 bytes). +MAX_CALLBACK_DATA_BYTES = 64 +CALLBACK_DATA_MAX = MAX_CALLBACK_DATA_BYTES # alias + # History pagination CB_HISTORY_PREV = "hp:" # history page older CB_HISTORY_NEXT = "hn:" # history page newer @@ -49,3 +62,367 @@ # Screenshot control keys CB_KEYS_PREFIX = "kb:" # kb:: + +# Known interactive-UI prefixes (for validation / docs) +CB_ASK_PREFIXES = frozenset( + { + CB_ASK_UP, + CB_ASK_DOWN, + CB_ASK_LEFT, + CB_ASK_RIGHT, + CB_ASK_ESC, + CB_ASK_ENTER, + CB_ASK_SPACE, + CB_ASK_TAB, + CB_ASK_REFRESH, + } +) + + +# ── Typed callback dataclasses ─────────────────────────────────────────── + + +@dataclass(frozen=True) +class HistoryCallback: + """History pagination: ``hp|hn::::``.""" + + older: bool # True → CB_HISTORY_PREV, False → CB_HISTORY_NEXT + page: int + window_id: str + start_byte: int = 0 + end_byte: int = 0 + + def __post_init__(self) -> None: + if self.page < 0: + raise ValueError(f"page must be non-negative, got {self.page}") + if not self.window_id: + raise ValueError("window_id must be non-empty") + if self.start_byte < 0 or self.end_byte < 0: + raise ValueError("byte range must be non-negative") + + def to_old_string(self) -> str: + """Serialize to on-wire format (no length check).""" + prefix = CB_HISTORY_PREV if self.older else CB_HISTORY_NEXT + return f"{prefix}{self.page}:{self.window_id}:{self.start_byte}:{self.end_byte}" + + +@dataclass(frozen=True) +class DirSelectCallback: + """Directory browser entry: ``db:sel:`` (index into cached list).""" + + index: int + + def __post_init__(self) -> None: + if self.index < 0: + raise ValueError(f"index must be non-negative, got {self.index}") + + def to_old_string(self) -> str: + return f"{CB_DIR_SELECT}{self.index}" + + +@dataclass(frozen=True) +class DirPageCallback: + """Directory browser page: ``db:page:``.""" + + page: int + + def __post_init__(self) -> None: + if self.page < 0: + raise ValueError(f"page must be non-negative, got {self.page}") + + def to_old_string(self) -> str: + return f"{CB_DIR_PAGE}{self.page}" + + +@dataclass(frozen=True) +class WinBindCallback: + """Window picker: ``wb:sel:`` (index into unbound windows cache).""" + + index: int + + def __post_init__(self) -> None: + if self.index < 0: + raise ValueError(f"index must be non-negative, got {self.index}") + + def to_old_string(self) -> str: + return f"{CB_WIN_BIND}{self.index}" + + +@dataclass(frozen=True) +class SessionSelectCallback: + """Session picker: ``rs:sel:`` (index into sessions cache).""" + + index: int + + def __post_init__(self) -> None: + if self.index < 0: + raise ValueError(f"index must be non-negative, got {self.index}") + + def to_old_string(self) -> str: + return f"{CB_SESSION_SELECT}{self.index}" + + +@dataclass(frozen=True) +class AskCallback: + """Interactive UI action: ``aq::``.""" + + prefix: str + window_id: str + + def __post_init__(self) -> None: + if self.prefix not in CB_ASK_PREFIXES: + raise ValueError(f"unknown ask prefix: {self.prefix!r}") + if not self.window_id: + raise ValueError("window_id must be non-empty") + + def to_old_string(self) -> str: + return f"{self.prefix}{self.window_id}" + + +@dataclass(frozen=True) +class ScreenshotRefreshCallback: + """Screenshot refresh: ``ss:ref:``.""" + + window_id: str + + def __post_init__(self) -> None: + if not self.window_id: + raise ValueError("window_id must be non-empty") + + def to_old_string(self) -> str: + return f"{CB_SCREENSHOT_REFRESH}{self.window_id}" + + +@dataclass(frozen=True) +class KeyCallback: + """Screenshot control key: ``kb::``.""" + + key_id: str + window_id: str + + def __post_init__(self) -> None: + if not self.key_id: + raise ValueError("key_id must be non-empty") + if not self.window_id: + raise ValueError("window_id must be non-empty") + + def to_old_string(self) -> str: + return f"{CB_KEYS_PREFIX}{self.key_id}:{self.window_id}" + + +# Union of typed payloads (plus plain str for constants like CB_DIR_UP / "noop"). +CallbackPayload = Union[ + str, + HistoryCallback, + DirSelectCallback, + DirPageCallback, + WinBindCallback, + SessionSelectCallback, + AskCallback, + ScreenshotRefreshCallback, + KeyCallback, +] + + +def safe_callback_data(cb: CallbackPayload) -> str: + """Serialize callback_data with Telegram 64-byte UTF-8 limit check. + + Accepts a wire string or a typed payload with ``to_old_string()``. + Raises ``ValueError`` on empty or oversized data — never truncates. + """ + if isinstance(cb, str): + data = cb + else: + data = cb.to_old_string() + + if not data: + raise ValueError("callback_data must be non-empty") + + encoded_len = len(data.encode("utf-8")) + if encoded_len > MAX_CALLBACK_DATA_BYTES: + raise ValueError( + f"callback_data too long ({encoded_len}B > {MAX_CALLBACK_DATA_BYTES}B): " + f"{data!r}" + ) + return data + + +# ── History ────────────────────────────────────────────────────────────── + + +def encode_history(cb: HistoryCallback) -> str: + return safe_callback_data(cb) + + +def encode_history_page( + *, + older: bool, + page: int, + window_id: str, + start_byte: int = 0, + end_byte: int = 0, +) -> str: + return safe_callback_data( + HistoryCallback( + older=older, + page=page, + window_id=window_id, + start_byte=start_byte, + end_byte=end_byte, + ) + ) + + +def parse_history(data: str) -> HistoryCallback | None: + """Parse history callback_data; None if not history or invalid.""" + if data.startswith(CB_HISTORY_PREV): + older = True + rest = data[len(CB_HISTORY_PREV) :] + elif data.startswith(CB_HISTORY_NEXT): + older = False + rest = data[len(CB_HISTORY_NEXT) :] + else: + return None + + try: + parts = rest.split(":") + if len(parts) < 4: + # Legacy: page:window_id (window_id may contain colons) + page_str, window_id = rest.split(":", 1) + return HistoryCallback( + older=older, + page=int(page_str), + window_id=window_id, + start_byte=0, + end_byte=0, + ) + # Current: page:window_id:start:end + page = int(parts[0]) + start_byte = int(parts[-2]) + end_byte = int(parts[-1]) + window_id = ":".join(parts[1:-2]) + return HistoryCallback( + older=older, + page=page, + window_id=window_id, + start_byte=start_byte, + end_byte=end_byte, + ) + except (ValueError, IndexError): + return None + + +# ── Index-based pickers ────────────────────────────────────────────────── + + +def encode_dir_select(index: int) -> str: + return safe_callback_data(DirSelectCallback(index=index)) + + +def parse_dir_select(data: str) -> DirSelectCallback | None: + if not data.startswith(CB_DIR_SELECT): + return None + try: + return DirSelectCallback(index=int(data[len(CB_DIR_SELECT) :])) + except ValueError: + return None + + +def encode_dir_page(page: int) -> str: + return safe_callback_data(DirPageCallback(page=page)) + + +def parse_dir_page(data: str) -> DirPageCallback | None: + if not data.startswith(CB_DIR_PAGE): + return None + try: + return DirPageCallback(page=int(data[len(CB_DIR_PAGE) :])) + except ValueError: + return None + + +def encode_win_bind(index: int) -> str: + return safe_callback_data(WinBindCallback(index=index)) + + +def parse_win_bind(data: str) -> WinBindCallback | None: + if not data.startswith(CB_WIN_BIND): + return None + try: + return WinBindCallback(index=int(data[len(CB_WIN_BIND) :])) + except ValueError: + return None + + +def encode_session_select(index: int) -> str: + return safe_callback_data(SessionSelectCallback(index=index)) + + +def parse_session_select(data: str) -> SessionSelectCallback | None: + if not data.startswith(CB_SESSION_SELECT): + return None + try: + return SessionSelectCallback(index=int(data[len(CB_SESSION_SELECT) :])) + except ValueError: + return None + + +# ── Window-id suffix (screenshot + interactive UI) ─────────────────────── + + +def encode_screenshot_refresh(window_id: str) -> str: + return safe_callback_data(ScreenshotRefreshCallback(window_id=window_id)) + + +def parse_screenshot_refresh(data: str) -> ScreenshotRefreshCallback | None: + if not data.startswith(CB_SCREENSHOT_REFRESH): + return None + window_id = data[len(CB_SCREENSHOT_REFRESH) :] + if not window_id: + return None + try: + return ScreenshotRefreshCallback(window_id=window_id) + except ValueError: + return None + + +def encode_ask(prefix: str, window_id: str) -> str: + return safe_callback_data(AskCallback(prefix=prefix, window_id=window_id)) + + +def parse_ask(data: str, prefix: str) -> AskCallback | None: + """Parse interactive-UI action for a known CB_ASK_* prefix.""" + if not data.startswith(prefix): + return None + window_id = data[len(prefix) :] + if not window_id: + return None + try: + return AskCallback(prefix=prefix, window_id=window_id) + except ValueError: + return None + + +# ── Screenshot control keys ────────────────────────────────────────────── + + +def encode_key(key_id: str, window_id: str) -> str: + return safe_callback_data(KeyCallback(key_id=key_id, window_id=window_id)) + + +def parse_key(data: str) -> KeyCallback | None: + """Parse ``kb::``; window_id may contain colons.""" + if not data.startswith(CB_KEYS_PREFIX): + return None + rest = data[len(CB_KEYS_PREFIX) :] + colon_idx = rest.find(":") + if colon_idx < 0: + return None + key_id = rest[:colon_idx] + window_id = rest[colon_idx + 1 :] + if not key_id or not window_id: + return None + try: + return KeyCallback(key_id=key_id, window_id=window_id) + except ValueError: + return None diff --git a/src/ccbot/handlers/callback_directory.py b/src/ccbot/handlers/callback_directory.py new file mode 100644 index 000000000..ed3e4a59e --- /dev/null +++ b/src/ccbot/handlers/callback_directory.py @@ -0,0 +1,207 @@ +"""Directory browser callbacks (db:*) — extracted from callback_router (ISS-011).""" + +from __future__ import annotations + +from pathlib import Path + +from telegram import CallbackQuery, Update, User +from telegram.ext import ContextTypes + +from ..session import session_manager +from .callback_data import ( + DirPageCallback, + DirSelectCallback, + parse_dir_page, + parse_dir_select, +) +from .callback_topic_guard import _check_same_topic, _clear_pending_thread +from .directory_browser import ( + BROWSE_DIRS_KEY, + BROWSE_PAGE_KEY, + BROWSE_PATH_KEY, + SESSIONS_KEY, + STATE_KEY, + STATE_SELECTING_SESSION, + build_directory_browser, + build_session_picker, + clear_browse_state, +) +from .message_sender import safe_edit +from .window_bind import create_and_bind_window + + +async def _handle_dir_select( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Directory browser: enter the selected subdirectory.""" + topic = await _check_same_topic(update, context, query, stale_label="browser") + if not topic.ok: + return + # callback_data contains index, not dir name (to avoid 64-byte limit) + dir_select = parse_dir_select(data) + if not isinstance(dir_select, DirSelectCallback): + await query.answer("Invalid data") + return + idx = dir_select.index + + # Look up dir name from cached subdirs + cached_dirs: list[str] = ( + context.user_data.get(BROWSE_DIRS_KEY, []) if context.user_data else [] + ) + if idx < 0 or idx >= len(cached_dirs): + await query.answer("Directory list changed, please refresh", show_alert=True) + return + subdir_name = cached_dirs[idx] + + default_path = str(Path.cwd()) + current_path = ( + context.user_data.get(BROWSE_PATH_KEY, default_path) + if context.user_data + else default_path + ) + new_path = (Path(current_path) / subdir_name).resolve() + + if not new_path.exists() or not new_path.is_dir(): + await query.answer("Directory not found", show_alert=True) + return + + new_path_str = str(new_path) + if context.user_data is not None: + context.user_data[BROWSE_PATH_KEY] = new_path_str + context.user_data[BROWSE_PAGE_KEY] = 0 + + msg_text, keyboard, subdirs = build_directory_browser(new_path_str) + if context.user_data is not None: + context.user_data[BROWSE_DIRS_KEY] = subdirs + await safe_edit(query, msg_text, reply_markup=keyboard) + await query.answer() + + +async def _handle_dir_up( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Directory browser: navigate to the parent directory.""" + topic = await _check_same_topic(update, context, query, stale_label="browser") + if not topic.ok: + return + default_path = str(Path.cwd()) + current_path = ( + context.user_data.get(BROWSE_PATH_KEY, default_path) + if context.user_data + else default_path + ) + current = Path(current_path).resolve() + parent = current.parent + # No restriction - allow navigating anywhere + + parent_path = str(parent) + if context.user_data is not None: + context.user_data[BROWSE_PATH_KEY] = parent_path + context.user_data[BROWSE_PAGE_KEY] = 0 + + msg_text, keyboard, subdirs = build_directory_browser(parent_path) + if context.user_data is not None: + context.user_data[BROWSE_DIRS_KEY] = subdirs + await safe_edit(query, msg_text, reply_markup=keyboard) + await query.answer() + + +async def _handle_dir_page( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Directory browser: switch page of the current directory listing.""" + topic = await _check_same_topic(update, context, query, stale_label="browser") + if not topic.ok: + return + dir_page = parse_dir_page(data) + if not isinstance(dir_page, DirPageCallback): + await query.answer("Invalid data") + return + pg = dir_page.page + default_path = str(Path.cwd()) + current_path = ( + context.user_data.get(BROWSE_PATH_KEY, default_path) + if context.user_data + else default_path + ) + if context.user_data is not None: + context.user_data[BROWSE_PAGE_KEY] = pg + + msg_text, keyboard, subdirs = build_directory_browser(current_path, pg) + if context.user_data is not None: + context.user_data[BROWSE_DIRS_KEY] = subdirs + await safe_edit(query, msg_text, reply_markup=keyboard) + await query.answer() + + +async def _handle_dir_confirm( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Directory browser: confirm path; pick existing session or bind new.""" + + def _clear_stale_state() -> None: + clear_browse_state(context.user_data) + _clear_pending_thread(context) + + topic = await _check_same_topic( + update, context, query, stale_label="browser", on_stale=_clear_stale_state + ) + default_path = str(Path.cwd()) + selected_path = ( + context.user_data.get(BROWSE_PATH_KEY, default_path) + if context.user_data + else default_path + ) + if not topic.ok: + return + + clear_browse_state(context.user_data) + + # Check for existing sessions in this directory + sessions = await session_manager.list_sessions_for_directory(selected_path) + if sessions: + # Show session picker — store state for later + if context.user_data is not None: + context.user_data[STATE_KEY] = STATE_SELECTING_SESSION + context.user_data[SESSIONS_KEY] = sessions + context.user_data["_selected_path"] = selected_path + text, keyboard = build_session_picker(sessions) + await safe_edit(query, text, reply_markup=keyboard) + await query.answer() + return + + # No existing sessions — create new window directly + await create_and_bind_window(query, context, user, selected_path, topic.pending_tid) + + +async def _handle_dir_cancel( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Directory browser: cancel browsing.""" + topic = await _check_same_topic(update, context, query, stale_label="browser") + if not topic.ok: + return + clear_browse_state(context.user_data) + _clear_pending_thread(context) + await safe_edit(query, "Cancelled") + await query.answer("Cancelled") diff --git a/src/ccbot/handlers/callback_history.py b/src/ccbot/handlers/callback_history.py new file mode 100644 index 000000000..2f0d06e59 --- /dev/null +++ b/src/ccbot/handlers/callback_history.py @@ -0,0 +1,41 @@ +"""History pagination callback (hp:/hn:) — extracted from callback_router (ISS-011).""" + +from __future__ import annotations + +from telegram import CallbackQuery, Update, User +from telegram.ext import ContextTypes + +from ..tmux_manager import tmux_manager +from .callback_data import HistoryCallback, parse_history +from .history import send_history +from .message_sender import safe_edit + + +async def _handle_history( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """History pagination: hp|hn::::.""" + history_cb = parse_history(data) + if not isinstance(history_cb, HistoryCallback): + await query.answer("Invalid data") + return + window_id = history_cb.window_id + w = await tmux_manager.find_window_by_id(window_id) + if w: + await send_history( + query, + window_id, + offset=history_cb.page, + edit=True, + start_byte=history_cb.start_byte, + end_byte=history_cb.end_byte, + # Don't pass user_id for pagination - offset update only on initial view + # This prevents offset from going backwards if new messages arrive while paging + ) + else: + await safe_edit(query, "Window no longer exists.") + await query.answer("Page updated") diff --git a/src/ccbot/handlers/callback_interactive.py b/src/ccbot/handlers/callback_interactive.py new file mode 100644 index 000000000..bdec83ec9 --- /dev/null +++ b/src/ccbot/handlers/callback_interactive.py @@ -0,0 +1,81 @@ +"""Interactive UI callbacks (aq:*) — extracted from callback_router (ISS-011).""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass + +from telegram import CallbackQuery, Update, User +from telegram.ext import ContextTypes + +from ..session_guard import get_thread_id +from ..tmux_manager import tmux_manager +from .callback_data import ( + CB_ASK_DOWN, + CB_ASK_ENTER, + CB_ASK_ESC, + CB_ASK_LEFT, + CB_ASK_REFRESH, + CB_ASK_RIGHT, + CB_ASK_SPACE, + CB_ASK_TAB, + CB_ASK_UP, + AskCallback, + parse_ask, +) +from .interactive_ui import clear_interactive_msg, handle_interactive_ui + + +@dataclass(frozen=True, slots=True) +class _AskAction: + """Behavior of one interactive-UI (aq:*) action.""" + + tmux_key: str | None # None → refresh display only (no key press) + answer: str | None # None → silent query.answer() + clear: bool = False # True → clear interactive message instead of UI refresh + + +_ASK_ACTIONS: dict[str, _AskAction] = { + CB_ASK_UP: _AskAction("Up", None), + CB_ASK_DOWN: _AskAction("Down", None), + CB_ASK_LEFT: _AskAction("Left", None), + CB_ASK_RIGHT: _AskAction("Right", None), + CB_ASK_ESC: _AskAction("Escape", "⎋ Esc", clear=True), + CB_ASK_ENTER: _AskAction("Enter", "⏎ Enter"), + CB_ASK_SPACE: _AskAction("Space", "␣ Space"), + CB_ASK_TAB: _AskAction("Tab", "⇥ Tab"), + CB_ASK_REFRESH: _AskAction(None, "🔄"), +} + + +async def _handle_ask( + prefix: str, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Interactive UI: single table-driven body for all aq:* actions.""" + ask_cb = parse_ask(data, prefix) + if not isinstance(ask_cb, AskCallback): + await query.answer("Invalid data") + return + action = _ASK_ACTIONS[prefix] + window_id = ask_cb.window_id + thread_id = get_thread_id(update) + if action.tmux_key is None: + # Refresh display: no key press, no window lookup. + await handle_interactive_ui(context.bot, user.id, window_id, thread_id) + else: + w = await tmux_manager.find_window_by_id(window_id) + if w: + await tmux_manager.send_keys( + w.window_id, action.tmux_key, enter=False, literal=False + ) + if action.clear: + await clear_interactive_msg(user.id, context.bot, thread_id) + else: + await asyncio.sleep(0.5) + await handle_interactive_ui(context.bot, user.id, window_id, thread_id) + await query.answer(action.answer) diff --git a/src/ccbot/handlers/callback_pickers.py b/src/ccbot/handlers/callback_pickers.py new file mode 100644 index 000000000..0ecb66bb2 --- /dev/null +++ b/src/ccbot/handlers/callback_pickers.py @@ -0,0 +1,230 @@ +"""Session/window picker callbacks (rs:*, wb:*) — extracted from callback_router (ISS-011).""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from telegram import CallbackQuery, Update, User +from telegram.ext import ContextTypes + +from ..session import session_manager +from ..session_guard import get_thread_id +from ..tmux_manager import tmux_manager +from .callback_data import ( + SessionSelectCallback, + WinBindCallback, + parse_session_select, + parse_win_bind, +) +from .callback_topic_guard import _check_same_topic, _clear_pending_thread +from .directory_browser import ( + BROWSE_DIRS_KEY, + BROWSE_PAGE_KEY, + BROWSE_PATH_KEY, + SESSIONS_KEY, + STATE_BROWSING_DIRECTORY, + STATE_KEY, + UNBOUND_WINDOWS_KEY, + build_directory_browser, + clear_session_picker_state, + clear_window_picker_state, +) +from .message_sender import safe_edit, safe_send +from .window_bind import create_and_bind_window + +logger = logging.getLogger(__name__) + + +async def _handle_session_select( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Session picker: resume the selected existing session.""" + topic = await _check_same_topic(update, context, query, recover=True) + if not topic.ok: + return + session_select = parse_session_select(data) + if not isinstance(session_select, SessionSelectCallback): + await query.answer("Invalid data") + return + idx = session_select.index + + cached_sessions = ( + context.user_data.get(SESSIONS_KEY, []) if context.user_data else [] + ) + if idx < 0 or idx >= len(cached_sessions): + await query.answer("Session not found") + return + + session = cached_sessions[idx] + selected_path = ( + context.user_data.get("_selected_path", str(Path.cwd())) + if context.user_data + else str(Path.cwd()) + ) + clear_session_picker_state(context.user_data) + if context.user_data is not None: + context.user_data.pop("_selected_path", None) + + await create_and_bind_window( + query, + context, + user, + selected_path, + topic.pending_tid, + resume_session_id=session.session_id, + ) + + +async def _handle_session_new( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Session picker: start a new session in the selected directory.""" + topic = await _check_same_topic(update, context, query, recover=True) + if not topic.ok: + return + selected_path = ( + context.user_data.get("_selected_path", str(Path.cwd())) + if context.user_data + else str(Path.cwd()) + ) + clear_session_picker_state(context.user_data) + if context.user_data is not None: + context.user_data.pop("_selected_path", None) + + await create_and_bind_window(query, context, user, selected_path, topic.pending_tid) + + +async def _handle_session_cancel( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Session picker: cancel selection.""" + topic = await _check_same_topic(update, context, query) + if not topic.ok: + return + clear_session_picker_state(context.user_data) + _clear_pending_thread(context) + if context.user_data is not None: + context.user_data.pop("_selected_path", None) + await safe_edit(query, "Cancelled") + await query.answer("Cancelled") + + +async def _handle_win_bind( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Window picker: bind an existing unbound window to this topic.""" + topic = await _check_same_topic(update, context, query) + if not topic.ok: + return + win_bind = parse_win_bind(data) + if not isinstance(win_bind, WinBindCallback): + await query.answer("Invalid data") + return + idx = win_bind.index + + cached_windows: list[str] = ( + context.user_data.get(UNBOUND_WINDOWS_KEY, []) if context.user_data else [] + ) + if idx < 0 or idx >= len(cached_windows): + await query.answer("Window list changed, please retry", show_alert=True) + return + selected_wid = cached_windows[idx] + + # Verify window still exists + w = await tmux_manager.find_window_by_id(selected_wid) + if not w: + display = session_manager.get_display_name(selected_wid) + await query.answer(f"Window '{display}' no longer exists", show_alert=True) + return + + thread_id = get_thread_id(update) + if thread_id is None: + await query.answer("Not in a topic", show_alert=True) + return + + display = w.window_name + clear_window_picker_state(context.user_data) + session_manager.bind_thread(user.id, thread_id, selected_wid, window_name=display) + + await safe_edit( + query, + f"✅ Bound to window `{display}`", + ) + + # Forward pending text if any + pending_text = ( + context.user_data.get("_pending_thread_text") if context.user_data else None + ) + _clear_pending_thread(context) + if pending_text: + send_ok, send_msg = await session_manager.send_to_window( + selected_wid, pending_text + ) + if not send_ok: + logger.warning("Failed to forward pending text: %s", send_msg) + resolved_chat = session_manager.resolve_chat_id(user.id, thread_id) + await safe_send( + context.bot, + resolved_chat, + f"❌ Failed to send pending message: {send_msg}", + message_thread_id=thread_id, + ) + await query.answer("Bound") + + +async def _handle_win_new( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Window picker: new session — transition to the directory browser.""" + topic = await _check_same_topic(update, context, query) + if not topic.ok: + return + # Preserve pending thread info, clear only picker state + clear_window_picker_state(context.user_data) + start_path = str(Path.cwd()) + msg_text, keyboard, subdirs = build_directory_browser(start_path) + if context.user_data is not None: + context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY + context.user_data[BROWSE_PATH_KEY] = start_path + context.user_data[BROWSE_PAGE_KEY] = 0 + context.user_data[BROWSE_DIRS_KEY] = subdirs + await safe_edit(query, msg_text, reply_markup=keyboard) + await query.answer() + + +async def _handle_win_cancel( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Window picker: cancel binding.""" + topic = await _check_same_topic(update, context, query) + if not topic.ok: + return + clear_window_picker_state(context.user_data) + _clear_pending_thread(context) + await safe_edit(query, "Cancelled") + await query.answer("Cancelled") diff --git a/src/ccbot/handlers/callback_router.py b/src/ccbot/handlers/callback_router.py new file mode 100644 index 000000000..0c895cd19 --- /dev/null +++ b/src/ccbot/handlers/callback_router.py @@ -0,0 +1,141 @@ +"""Central callback query router for inline keyboards. + +Dispatches all CB_* interactions: history pagination, directory browser, +window/session pickers, screenshot keys, and interactive UI navigation. + +Routing is table-driven (ISS-004): ``callback_handler`` performs only +auth + group-chat capture, then resolves the handler via the +_EXACT_ROUTES / _PREFIX_ROUTES registry. + +Sub-handlers live in the callback_* submodules (ISS-011); the stale-topic +guard lives in callback_topic_guard. This module keeps only auth, +group-chat capture, and registry dispatch. +""" + +from __future__ import annotations + +import logging +from functools import partial + +from telegram import CallbackQuery, Update, User +from telegram.ext import ContextTypes + +from ..session import session_manager +from ..session_guard import get_thread_id, require_user +from .callback_data import ( + CB_DIR_CANCEL, + CB_DIR_CONFIRM, + CB_DIR_PAGE, + CB_DIR_SELECT, + CB_DIR_UP, + CB_HISTORY_NEXT, + CB_HISTORY_PREV, + CB_KEYS_PREFIX, + CB_SCREENSHOT_REFRESH, + CB_SESSION_CANCEL, + CB_SESSION_NEW, + CB_SESSION_SELECT, + CB_WIN_BIND, + CB_WIN_CANCEL, + CB_WIN_NEW, +) +from .callback_directory import ( + _handle_dir_cancel, + _handle_dir_confirm, + _handle_dir_page, + _handle_dir_select, + _handle_dir_up, +) +from .callback_history import _handle_history +from .callback_interactive import _ASK_ACTIONS, _handle_ask +from .callback_pickers import ( + _handle_session_cancel, + _handle_session_new, + _handle_session_select, + _handle_win_bind, + _handle_win_cancel, + _handle_win_new, +) +from .callback_screenshot import _handle_key, _handle_screenshot_refresh +from .callback_topic_guard import RouteHandler + +logger = logging.getLogger(__name__) + + +async def _handle_noop( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """No-op button (pagination labels etc.): acknowledge only.""" + await query.answer() + + +_EXACT_ROUTES: dict[str, RouteHandler] = { + CB_DIR_UP: _handle_dir_up, + CB_DIR_CONFIRM: _handle_dir_confirm, + CB_DIR_CANCEL: _handle_dir_cancel, + CB_SESSION_NEW: _handle_session_new, + CB_SESSION_CANCEL: _handle_session_cancel, + CB_WIN_NEW: _handle_win_new, + CB_WIN_CANCEL: _handle_win_cancel, + "noop": _handle_noop, +} + +_ASK_ROUTES: tuple[tuple[str, RouteHandler], ...] = tuple( + (prefix, partial(_handle_ask, prefix)) for prefix in _ASK_ACTIONS +) + +# Prefix order is irrelevant (all prefixes are mutually exclusive); exact +# matches are checked first in _match_route. +_PREFIX_ROUTES: tuple[tuple[str, RouteHandler], ...] = ( + (CB_HISTORY_PREV, _handle_history), + (CB_HISTORY_NEXT, _handle_history), + (CB_DIR_SELECT, _handle_dir_select), + (CB_DIR_PAGE, _handle_dir_page), + (CB_SESSION_SELECT, _handle_session_select), + (CB_WIN_BIND, _handle_win_bind), + (CB_SCREENSHOT_REFRESH, _handle_screenshot_refresh), + *_ASK_ROUTES, + (CB_KEYS_PREFIX, _handle_key), +) + + +def _match_route(data: str) -> RouteHandler | None: + """Resolve callback_data: exact-match table first, then prefix table.""" + handler = _EXACT_ROUTES.get(data) + if handler is not None: + return handler + for prefix, prefix_handler in _PREFIX_ROUTES: + if data.startswith(prefix): + return prefix_handler + return None + + +async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Entry point: auth + group-chat capture, then registry dispatch.""" + query = update.callback_query + if not query or not query.data: + return + + user = await require_user(update, reply_unauthorized=True) + if user is None: + return + + data = query.data + + # Capture group chat_id for supergroup forum topic routing. + # Required: Telegram Bot API needs group chat_id (not user_id) to send + # messages with message_thread_id. Do NOT remove — see session.py docs. + cb_thread_id = get_thread_id(update) + chat = update.effective_chat + if chat and chat.type in ("group", "supergroup"): + session_manager.set_group_chat_id(user.id, cb_thread_id, chat.id) + + handler = _match_route(data) + if handler is None: + logger.debug("Unhandled callback data: %r", data) + return + await handler(update, context, user, query, data) diff --git a/src/ccbot/handlers/callback_screenshot.py b/src/ccbot/handlers/callback_screenshot.py new file mode 100644 index 000000000..79c378bff --- /dev/null +++ b/src/ccbot/handlers/callback_screenshot.py @@ -0,0 +1,107 @@ +"""Screenshot callbacks (sr:*, key:*) — extracted from callback_router (ISS-011).""" + +from __future__ import annotations + +import asyncio +import io +import logging + +from telegram import CallbackQuery, InputMediaDocument, Update, User +from telegram.ext import ContextTypes + +from ..screenshot import text_to_image +from ..tmux_manager import tmux_manager +from .callback_data import ( + KeyCallback, + ScreenshotRefreshCallback, + parse_key, + parse_screenshot_refresh, +) +from .screenshot_controls import KEY_LABELS, KEYS_SEND_MAP, build_screenshot_keyboard + +logger = logging.getLogger(__name__) + + +async def _handle_screenshot_refresh( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Screenshot: refresh the pane capture in place.""" + ss_refresh = parse_screenshot_refresh(data) + if not isinstance(ss_refresh, ScreenshotRefreshCallback): + await query.answer("Invalid data") + return + window_id = ss_refresh.window_id + w = await tmux_manager.find_window_by_id(window_id) + if not w: + await query.answer("Window no longer exists", show_alert=True) + return + + text = await tmux_manager.capture_pane(w.window_id, with_ansi=True) + if not text: + await query.answer("Failed to capture pane", show_alert=True) + return + + png_bytes = await text_to_image(text, with_ansi=True) + keyboard = build_screenshot_keyboard(window_id) + try: + await query.edit_message_media( + media=InputMediaDocument( + media=io.BytesIO(png_bytes), filename="screenshot.png" + ), + reply_markup=keyboard, + ) + await query.answer("Refreshed") + except Exception as e: + logger.error(f"Failed to refresh screenshot: {e}") + await query.answer("Failed to refresh", show_alert=True) + + +async def _handle_key( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + user: User, + query: CallbackQuery, + data: str, +) -> None: + """Screenshot quick keys: send key to tmux window, refresh screenshot.""" + key_cb = parse_key(data) + if not isinstance(key_cb, KeyCallback): + await query.answer("Invalid data") + return + key_id = key_cb.key_id + window_id = key_cb.window_id + + key_info = KEYS_SEND_MAP.get(key_id) + if not key_info: + await query.answer("Unknown key") + return + + tmux_key, enter, literal = key_info + w = await tmux_manager.find_window_by_id(window_id) + if not w: + await query.answer("Window not found", show_alert=True) + return + + await tmux_manager.send_keys(w.window_id, tmux_key, enter=enter, literal=literal) + await query.answer(KEY_LABELS.get(key_id, key_id)) + + # Refresh screenshot after key press + await asyncio.sleep(0.5) + text = await tmux_manager.capture_pane(w.window_id, with_ansi=True) + if text: + png_bytes = await text_to_image(text, with_ansi=True) + keyboard = build_screenshot_keyboard(window_id) + try: + await query.edit_message_media( + media=InputMediaDocument( + media=io.BytesIO(png_bytes), + filename="screenshot.png", + ), + reply_markup=keyboard, + ) + except Exception: + pass # Screenshot unchanged or message too old diff --git a/src/ccbot/handlers/callback_topic_guard.py b/src/ccbot/handlers/callback_topic_guard.py new file mode 100644 index 000000000..4977caf68 --- /dev/null +++ b/src/ccbot/handlers/callback_topic_guard.py @@ -0,0 +1,67 @@ +"""Shared stale-topic guard for picker/browser callback flows (ISS-011). + +Also hosts ``RouteHandler`` — the common signature of all routed +callback sub-handlers. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from telegram import CallbackQuery, Update, User +from telegram.ext import ContextTypes + +from ..session_guard import get_thread_id + +# Handler signature for all routed callbacks: (update, context, user, query, data). +RouteHandler = Callable[ + [Update, ContextTypes.DEFAULT_TYPE, User, CallbackQuery, str], + Awaitable[None], +] + + +@dataclass(frozen=True, slots=True) +class _TopicCheck: + """Result of the stale-topic guard for picker/browser flows.""" + + ok: bool + pending_tid: int | None + + +async def _check_same_topic( + update: Update, + context: ContextTypes.DEFAULT_TYPE, + query: CallbackQuery, + *, + recover: bool = False, + stale_label: str = "picker", + on_stale: Callable[[], None] | None = None, +) -> _TopicCheck: + """Single stale-topic guard (ISS-004): pending flow vs callback topic. + + The callback must come from the same topic that started the pending + flow (``_pending_thread_id`` in user_data). With ``recover``, a missing + marker is recovered from the callback's own message context (e.g. after + it was cleared by a message in another topic). On mismatch ``on_stale`` + runs first (state cleanup ordering), then the query is answered with a + stale alert and ``ok=False`` is returned. + """ + pending_tid = ( + context.user_data.get("_pending_thread_id") if context.user_data else None + ) + if pending_tid is None and recover: + pending_tid = get_thread_id(update) + if pending_tid is not None and get_thread_id(update) != pending_tid: + if on_stale is not None: + on_stale() + await query.answer(f"Stale {stale_label} (topic mismatch)", show_alert=True) + return _TopicCheck(ok=False, pending_tid=None) + return _TopicCheck(ok=True, pending_tid=pending_tid) + + +def _clear_pending_thread(context: ContextTypes.DEFAULT_TYPE) -> None: + """Drop the pending topic flow markers from user_data.""" + if context.user_data is not None: + context.user_data.pop("_pending_thread_id", None) + context.user_data.pop("_pending_thread_text", None) diff --git a/src/ccbot/handlers/capture_registry.py b/src/ccbot/handlers/capture_registry.py new file mode 100644 index 000000000..39d100c1c --- /dev/null +++ b/src/ccbot/handlers/capture_registry.py @@ -0,0 +1,57 @@ +"""Registry for active bash capture background tasks. + +Owns the process-wide mapping ``(user_id, thread_id) -> asyncio.Task`` for +``!`` bash output capture watchers. Replaces the former module-level mutable +``bash_capture_tasks`` dict in ``message_handlers.py`` (ISS-010). + +``cleanup.clear_topic_state`` cancels captures through this registry so topic +teardown never leaves a running watcher behind. +""" + +from __future__ import annotations + +import asyncio + + +class CaptureTaskRegistry: + """Process-wide owner of active bash capture tasks.""" + + def __init__(self) -> None: + self._tasks: dict[tuple[int, int], asyncio.Task[None]] = {} + + def register(self, user_id: int, thread_id: int, task: asyncio.Task[None]) -> None: + """Store the capture task for a topic (overwrites any previous entry).""" + self._tasks[(user_id, thread_id)] = task + + def cancel(self, user_id: int, thread_id: int) -> None: + """Cancel the running capture task for a topic, if any.""" + task = self._tasks.pop((user_id, thread_id), None) + if task and not task.done(): + task.cancel() + + def discard( + self, + user_id: int, + thread_id: int, + task: asyncio.Task[None] | None = None, + ) -> None: + """Remove registry entry without cancelling (task self-cleanup). + + When ``task`` is None, pop unconditionally (legacy contract used by + existing tests and callers that intentionally clear the key). + + When ``task`` is provided, pop only if the registered entry is that + same task object (identity check). A finishing stale watcher must not + evict a newer registration for the same topic (cancel(A) → register(B) + → discard(A) must leave B registered). + """ + key = (user_id, thread_id) + if task is None: + self._tasks.pop(key, None) + return + if self._tasks.get(key) is task: + self._tasks.pop(key, None) + + +# Process-wide singleton +capture_tasks = CaptureTaskRegistry() diff --git a/src/ccbot/handlers/cleanup.py b/src/ccbot/handlers/cleanup.py index cd7e75805..e52fdc90f 100644 --- a/src/ccbot/handlers/cleanup.py +++ b/src/ccbot/handlers/cleanup.py @@ -11,6 +11,7 @@ from telegram import Bot +from .capture_registry import capture_tasks from .interactive_ui import clear_interactive_msg from .message_queue import clear_status_msg_info, clear_tool_msg_ids_for_topic @@ -28,11 +29,15 @@ async def clear_topic_state( - A thread binding becomes stale (window deleted externally) Cleans up: + - bash capture tasks (running ``!`` output watchers) - _status_msg_info (status message tracking) - _tool_msg_ids (tool_use → message_id mapping) - _interactive_msgs and _interactive_mode (interactive UI state) - user_data pending state (_pending_thread_id, _pending_thread_text) """ + # Cancel any running bash capture task for the topic + capture_tasks.cancel(user_id, thread_id) + # Clear status message tracking clear_status_msg_info(user_id, thread_id) diff --git a/src/ccbot/handlers/command_handlers.py b/src/ccbot/handlers/command_handlers.py new file mode 100644 index 000000000..e708080af --- /dev/null +++ b/src/ccbot/handlers/command_handlers.py @@ -0,0 +1,335 @@ +"""Telegram command and topic lifecycle handlers. + +Handlers: + - /start, /history, /screenshot, /unbind, /esc, /usage + - topic_closed_handler, topic_edited_handler + - forward_command_handler (unknown /slash → Claude Code) + - unsupported_content_handler +""" + +from __future__ import annotations + +import asyncio +import io +import logging + +from telegram import Update +from telegram.constants import ChatAction +from telegram.ext import ContextTypes + +from ..screenshot import text_to_image +from ..session import session_manager +from ..session_guard import ( + get_thread_id, + require_bound_window_id, + require_session, + require_user, +) +from ..tmux_manager import tmux_manager +from .cleanup import clear_topic_state +from .directory_browser import clear_browse_state +from .history import send_history +from .message_sender import safe_reply +from .screenshot_controls import build_screenshot_keyboard + +logger = logging.getLogger(__name__) + + +async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = await require_user(update, reply_unauthorized=True) + if user is None: + return + + clear_browse_state(context.user_data) + + if update.message: + await safe_reply( + update.message, + "🤖 *Claude Code Monitor*\n\n" + "Each topic is a session. Create a new topic to start.", + ) + + +async def history_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show message history for the active session or bound thread.""" + bound = await require_bound_window_id(update, reply_unauthorized=False) + if bound is None: + return + _user, _thread_id, window_id = bound + assert update.message is not None + await send_history(update.message, window_id) + + +async def screenshot_command( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Capture the current tmux pane and send it as an image.""" + ctx = await require_session(update, reply_unauthorized=False) + if ctx is None: + return + assert update.message is not None + + text = await tmux_manager.capture_pane(ctx.window.window_id, with_ansi=True) + if not text: + await safe_reply(update.message, "❌ Failed to capture pane content.") + return + + png_bytes = await text_to_image(text, with_ansi=True) + keyboard = build_screenshot_keyboard(ctx.window_id) + await update.message.reply_document( + document=io.BytesIO(png_bytes), + filename="screenshot.png", + reply_markup=keyboard, + ) + + +async def unbind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Unbind this topic from its Claude session without killing the window.""" + user = await require_user(update, reply_unauthorized=False) + if user is None: + return + if not update.message: + return + + thread_id = get_thread_id(update) + if thread_id is None: + await safe_reply(update.message, "❌ This command only works in a topic.") + return + + bound = await require_bound_window_id( + update, + reply_unauthorized=False, + use_resolve=False, + user=user, + ) + if bound is None: + return + _user, _tid, window_id = bound + + display = session_manager.get_display_name(window_id) + session_manager.unbind_thread(user.id, thread_id) + await clear_topic_state(user.id, thread_id, context.bot, context.user_data) + + await safe_reply( + update.message, + f"✅ Topic unbound from window '{display}'.\n" + "The Claude session is still running in tmux.\n" + "Send a message to bind to a new session.", + ) + + +async def esc_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Send Escape key to interrupt Claude.""" + ctx = await require_session(update, reply_unauthorized=False) + if ctx is None: + return + assert update.message is not None + + # Send Escape control character (no enter) + await tmux_manager.send_keys(ctx.window.window_id, "\x1b", enter=False) + await safe_reply(update.message, "⎋ Sent Escape") + + +async def usage_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Fetch Claude Code usage stats from TUI and send to Telegram.""" + ctx = await require_session(update, reply_unauthorized=False) + if ctx is None: + return + assert update.message is not None + + # Send /usage command to Claude Code TUI + await tmux_manager.send_keys(ctx.window.window_id, "/usage") + # Wait for the modal to render + await asyncio.sleep(2.0) + # Capture the pane content + pane_text = await tmux_manager.capture_pane(ctx.window.window_id) + # Dismiss the modal + await tmux_manager.send_keys( + ctx.window.window_id, "Escape", enter=False, literal=False + ) + + if not pane_text: + await safe_reply(update.message, "Failed to capture usage info.") + return + + # Try to parse structured usage info + from ..terminal_parser import parse_usage_output + + usage = parse_usage_output(pane_text) + if usage and usage.parsed_lines: + text = "\n".join(usage.parsed_lines) + await safe_reply(update.message, f"```\n{text}\n```") + else: + # Fallback: send raw pane capture trimmed + trimmed = pane_text.strip() + if len(trimmed) > 3000: + trimmed = trimmed[:3000] + "\n... (truncated)" + await safe_reply(update.message, f"```\n{trimmed}\n```") + + +async def topic_closed_handler( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle topic closure — kill the associated tmux window and clean up state. + + Uses require_session for auth+bind+live window. Failures are silent + (topic lifecycle must not spam UX). Always unbind/clear when a binding + existed, including when the live window is already gone. + """ + user = await require_user(update, reply_unauthorized=False) + if user is None: + return + + thread_id = get_thread_id(update) + if thread_id is None: + return + + # Silent: no_session / missing_window must not reply on topic close. + ctx = await require_session( + update, + reply_unauthorized=False, + require_message=False, + use_resolve=False, + no_session_text="", + missing_window_text="", + user=user, + ) + if ctx is not None: + display = session_manager.get_display_name(ctx.window_id) + await tmux_manager.kill_window(ctx.window.window_id) + logger.info( + "Topic closed: killed window %s (user=%d, thread=%d)", + display, + user.id, + thread_id, + ) + else: + window_id = session_manager.get_window_for_thread(user.id, thread_id) + if not window_id: + logger.debug( + "Topic closed: no binding (user=%d, thread=%d)", user.id, thread_id + ) + return + display = session_manager.get_display_name(window_id) + logger.info( + "Topic closed: window %s already gone (user=%d, thread=%d)", + display, + user.id, + thread_id, + ) + + session_manager.unbind_thread(user.id, thread_id) + await clear_topic_state(user.id, thread_id, context.bot, context.user_data) + + +async def topic_edited_handler( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle topic rename — sync new name to tmux window and internal state.""" + user = await require_user(update, reply_unauthorized=False) + if user is None: + return + + msg = update.message + if not msg or not msg.forum_topic_edited: + return + + new_name = msg.forum_topic_edited.name + if new_name is None: + # Icon-only change, no rename needed + return + + thread_id = get_thread_id(update) + if thread_id is None: + return + + window_id = session_manager.get_window_for_thread(user.id, thread_id) + if not window_id: + logger.debug( + "Topic edited: no binding (user=%d, thread=%d)", user.id, thread_id + ) + return + + old_name = session_manager.get_display_name(window_id) + await tmux_manager.rename_window(window_id, new_name) + session_manager.update_display_name(window_id, new_name) + await session_manager.rename_session_map_window(window_id, new_name) + logger.info( + "Topic renamed: '%s' -> '%s' (window=%s, user=%d, thread=%d)", + old_name, + new_name, + window_id, + user.id, + thread_id, + ) + + +async def forward_command_handler( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Forward any non-bot command as a slash command to the active Claude Code session.""" + user = await require_user(update, reply_unauthorized=False) + if user is None: + return + if not update.message: + return + + thread_id = get_thread_id(update) + + # Capture group chat_id for supergroup forum topic routing. + # Required: Telegram Bot API needs group chat_id (not user_id) to send + # messages with message_thread_id. Do NOT remove — see session.py docs. + chat = update.effective_chat + if chat and chat.type in ("group", "supergroup"): + session_manager.set_group_chat_id(user.id, thread_id, chat.id) + + cmd_text = update.message.text or "" + # The full text is already a slash command like "/clear" or "/compact foo" + cc_slash = cmd_text.split("@")[0] # strip bot mention + + ctx = await require_session(update, reply_unauthorized=False, user=user) + if ctx is None: + return + + display = session_manager.get_display_name(ctx.window_id) + logger.info( + "Forwarding command %s to window %s (user=%d)", cc_slash, display, user.id + ) + try: + await update.message.chat.send_action(ChatAction.TYPING) + except Exception as e: + logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) + success, message = await session_manager.send_to_window(ctx.window_id, cc_slash) + if success: + await safe_reply(update.message, f"⚡ [{display}] Sent: {cc_slash}") + # If /clear command was sent, clear the session association + # so we can detect the new session after first message + if cc_slash.strip().lower() == "/clear": + logger.info("Clearing session for window %s after /clear", display) + session_manager.clear_window_session(ctx.window_id) + + # Interactive commands (e.g. /model) render a terminal-based UI + # with no JSONL tool_use entry. The status poller already detects + # interactive UIs every 1s (status_polling.py), so no + # proactive detection needed here — the poller handles it. + else: + await safe_reply(update.message, f"❌ {message}") + + +async def unsupported_content_handler( + update: Update, + _context: ContextTypes.DEFAULT_TYPE, +) -> None: + """Reply to non-text messages (stickers, video, etc.).""" + if not update.message: + return + user = await require_user(update, reply_unauthorized=False) + if user is None: + return + logger.debug("Unsupported content from user %d", user.id) + await safe_reply( + update.message, + "⚠ Only text, photo, voice, and document (file) messages are " + "supported. Stickers, video, and other media cannot be forwarded " + "to Claude Code.", + ) diff --git a/src/ccbot/handlers/directory_browser.py b/src/ccbot/handlers/directory_browser.py index b443e16e0..8c82b9889 100644 --- a/src/ccbot/handlers/directory_browser.py +++ b/src/ccbot/handlers/directory_browser.py @@ -19,21 +19,21 @@ from telegram import InlineKeyboardButton, InlineKeyboardMarkup -from ..session import ClaudeSession - from ..config import config +from ..session import ClaudeSession from .callback_data import ( CB_DIR_CANCEL, CB_DIR_CONFIRM, - CB_DIR_PAGE, - CB_DIR_SELECT, CB_DIR_UP, CB_SESSION_CANCEL, CB_SESSION_NEW, - CB_SESSION_SELECT, - CB_WIN_BIND, CB_WIN_CANCEL, CB_WIN_NEW, + DirPageCallback, + DirSelectCallback, + SessionSelectCallback, + WinBindCallback, + safe_callback_data, ) # Directories per page in directory browser @@ -84,7 +84,7 @@ def build_window_picker( Returns: (text, keyboard, window_ids) where window_ids is the ordered list for caching. """ - window_ids = [wid for wid, _, _ in windows] + window_ids = [window_id for window_id, _, _ in windows] lines = [ "*Bind to Existing Window*\n", @@ -103,15 +103,20 @@ def build_window_picker( display = name[:12] + "…" if len(name) > 13 else name row.append( InlineKeyboardButton( - f"🖥 {display}", callback_data=f"{CB_WIN_BIND}{i + j}" + f"🖥 {display}", + callback_data=safe_callback_data(WinBindCallback(index=i + j)), ) ) buttons.append(row) buttons.append( [ - InlineKeyboardButton("➕ New Session", callback_data=CB_WIN_NEW), - InlineKeyboardButton("Cancel", callback_data=CB_WIN_CANCEL), + InlineKeyboardButton( + "➕ New Session", callback_data=safe_callback_data(CB_WIN_NEW) + ), + InlineKeyboardButton( + "Cancel", callback_data=safe_callback_data(CB_WIN_CANCEL) + ), ] ) @@ -156,7 +161,8 @@ def build_directory_browser( idx = start + i + j row.append( InlineKeyboardButton( - f"📁 {display}", callback_data=f"{CB_DIR_SELECT}{idx}" + f"📁 {display}", + callback_data=safe_callback_data(DirSelectCallback(index=idx)), ) ) buttons.append(row) @@ -165,23 +171,38 @@ def build_directory_browser( nav: list[InlineKeyboardButton] = [] if page > 0: nav.append( - InlineKeyboardButton("◀", callback_data=f"{CB_DIR_PAGE}{page - 1}") + InlineKeyboardButton( + "◀", + callback_data=safe_callback_data(DirPageCallback(page=page - 1)), + ) ) nav.append( - InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="noop") + InlineKeyboardButton( + f"{page + 1}/{total_pages}", + callback_data=safe_callback_data("noop"), + ) ) if page < total_pages - 1: nav.append( - InlineKeyboardButton("▶", callback_data=f"{CB_DIR_PAGE}{page + 1}") + InlineKeyboardButton( + "▶", + callback_data=safe_callback_data(DirPageCallback(page=page + 1)), + ) ) buttons.append(nav) action_row: list[InlineKeyboardButton] = [] # Allow going up unless at filesystem root if path != path.parent: - action_row.append(InlineKeyboardButton("..", callback_data=CB_DIR_UP)) - action_row.append(InlineKeyboardButton("Select", callback_data=CB_DIR_CONFIRM)) - action_row.append(InlineKeyboardButton("Cancel", callback_data=CB_DIR_CANCEL)) + action_row.append( + InlineKeyboardButton("..", callback_data=safe_callback_data(CB_DIR_UP)) + ) + action_row.append( + InlineKeyboardButton("Select", callback_data=safe_callback_data(CB_DIR_CONFIRM)) + ) + action_row.append( + InlineKeyboardButton("Cancel", callback_data=safe_callback_data(CB_DIR_CANCEL)) + ) buttons.append(action_row) display_path = str(path).replace(str(Path.home()), "~") @@ -240,15 +261,22 @@ def build_session_picker( label = s.summary[:14] + "…" if len(s.summary) > 14 else s.summary row.append( InlineKeyboardButton( - f"▶ {label}", callback_data=f"{CB_SESSION_SELECT}{i + j}" + f"▶ {label}", + callback_data=safe_callback_data( + SessionSelectCallback(index=i + j) + ), ) ) buttons.append(row) buttons.append( [ - InlineKeyboardButton("➕ New Session", callback_data=CB_SESSION_NEW), - InlineKeyboardButton("Cancel", callback_data=CB_SESSION_CANCEL), + InlineKeyboardButton( + "➕ New Session", callback_data=safe_callback_data(CB_SESSION_NEW) + ), + InlineKeyboardButton( + "Cancel", callback_data=safe_callback_data(CB_SESSION_CANCEL) + ), ] ) diff --git a/src/ccbot/handlers/document.py b/src/ccbot/handlers/document.py new file mode 100644 index 000000000..89bcb58a4 --- /dev/null +++ b/src/ccbot/handlers/document.py @@ -0,0 +1,128 @@ +"""Telegram document (file) upload handler. + +Downloads files sent to a bound topic into ``~/.ccbot/documents/`` and +forwards the saved path to the Claude Code session as +``(file attached: )``. + +Auth and session resolution use the fork canon (``require_user`` / +``require_session``), not the pre-ISS-001 dual path. +""" + +from __future__ import annotations + +import logging +import re +import time +from pathlib import Path + +from telegram import Update +from telegram.constants import ChatAction +from telegram.error import BadRequest +from telegram.ext import ContextTypes + +from ..session import session_manager +from ..session_guard import get_thread_id, require_session, require_user +from ..utils import ccbot_dir, ensure_dir +from .message_queue import clear_status_msg_info +from .message_sender import safe_reply + +logger = logging.getLogger(__name__) + +# Incoming files are saved here before the path is forwarded to Claude Code. +# The directory is created lazily on first save (no import-time side effects). +DOCS_DIR = ccbot_dir() / "documents" + +# Telegram Bot API caps bot downloads (getFile) at 20 MB; larger files cannot +# be fetched and must be rejected before attempting the download. +_MAX_DOC_BYTES = 20 * 1024 * 1024 + + +def _format_size(num_bytes: int) -> str: + """Render a byte count as a human-readable size (e.g. '24.3 MB').""" + size = float(num_bytes) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} GB" + + +def _safe_filename(name: str) -> str: + """Sanitize a Telegram-provided filename for safe use as a path component.""" + name = Path(name).name + name = re.sub(r"[^A-Za-z0-9._-]", "_", name) + return name.strip("._") or "file" + + +async def document_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle documents (PDF, etc.): download and forward path to Claude Code.""" + user = await require_user(update, reply_unauthorized=True) + if user is None: + return + + if not update.message or not update.message.document: + return + + chat = update.message.chat + thread_id = get_thread_id(update) + if chat.type in ("group", "supergroup") and thread_id is not None: + session_manager.set_group_chat_id(user.id, thread_id, chat.id) + + ctx = await require_session( + update, + reply_unauthorized=False, + user=user, + use_resolve=False, + require_named_topic=True, + unbind_if_missing=True, + ) + if ctx is None: + return + + doc = update.message.document + if doc.file_size and doc.file_size > _MAX_DOC_BYTES: + await safe_reply( + update.message, + f"❌ File is too large ({_format_size(doc.file_size)}). " + f"Telegram only lets bots download files up to " + f"{_format_size(_MAX_DOC_BYTES)}.", + ) + return + + original = doc.file_name or f"{doc.file_unique_id}" + filename = f"{int(time.time())}_{_safe_filename(original)}" + file_path = ensure_dir(DOCS_DIR) / filename + + try: + tg_file = await doc.get_file() + await tg_file.download_to_drive(file_path) + except BadRequest as exc: + logger.warning("Document download failed for user %d: %s", user.id, exc) + await safe_reply( + update.message, + f"❌ Could not download the file. It may exceed Telegram's " + f"{_format_size(_MAX_DOC_BYTES)} download limit for bots.", + ) + return + + caption = update.message.caption or "" + if caption: + text_to_send = f"{caption}\n\n(file attached: {file_path})" + else: + text_to_send = f"(file attached: {file_path})" + + try: + await update.message.chat.send_action(ChatAction.TYPING) + except Exception as e: + logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) + clear_status_msg_info(user.id, ctx.thread_id) + + success, message = await session_manager.send_to_window(ctx.window_id, text_to_send) + if not success: + await safe_reply(update.message, f"❌ {message}") + return + + await safe_reply( + update.message, + f"📎 File sent to Claude Code: {doc.file_name or filename}", + ) diff --git a/src/ccbot/handlers/history.py b/src/ccbot/handlers/history.py index 9771b3181..c1bffaeff 100644 --- a/src/ccbot/handlers/history.py +++ b/src/ccbot/handlers/history.py @@ -16,7 +16,7 @@ from ..session import session_manager from ..telegram_sender import split_message from ..transcript_parser import TranscriptParser -from .callback_data import CB_HISTORY_NEXT, CB_HISTORY_PREV +from .callback_data import HistoryCallback, safe_callback_data from .message_sender import safe_edit, safe_reply, safe_send logger = logging.getLogger(__name__) @@ -39,28 +39,41 @@ def _build_history_keyboard( buttons = [] if page_index > 0: - cb_data = ( - f"{CB_HISTORY_PREV}{page_index - 1}:{window_id}:{start_byte}:{end_byte}" - ) buttons.append( InlineKeyboardButton( "◀ Older", - callback_data=cb_data[:64], + callback_data=safe_callback_data( + HistoryCallback( + older=True, + page=page_index - 1, + window_id=window_id, + start_byte=start_byte, + end_byte=end_byte, + ) + ), ) ) buttons.append( - InlineKeyboardButton(f"{page_index + 1}/{total_pages}", callback_data="noop") + InlineKeyboardButton( + f"{page_index + 1}/{total_pages}", + callback_data=safe_callback_data("noop"), + ) ) if page_index < total_pages - 1: - cb_data = ( - f"{CB_HISTORY_NEXT}{page_index + 1}:{window_id}:{start_byte}:{end_byte}" - ) buttons.append( InlineKeyboardButton( "Newer ▶", - callback_data=cb_data[:64], + callback_data=safe_callback_data( + HistoryCallback( + older=False, + page=page_index + 1, + window_id=window_id, + start_byte=start_byte, + end_byte=end_byte, + ) + ), ) ) diff --git a/src/ccbot/handlers/interactive_ui.py b/src/ccbot/handlers/interactive_ui.py index 7d975fe77..5d8428d53 100644 --- a/src/ccbot/handlers/interactive_ui.py +++ b/src/ccbot/handlers/interactive_ui.py @@ -32,6 +32,8 @@ CB_ASK_SPACE, CB_ASK_TAB, CB_ASK_UP, + AskCallback, + safe_callback_data, ) from .message_sender import NO_LINK_PREVIEW @@ -89,53 +91,38 @@ def _build_interactive_keyboard( """ vertical_only = ui_name == "RestoreCheckpoint" + def _ask(prefix: str) -> str: + return safe_callback_data(AskCallback(prefix=prefix, window_id=window_id)) + rows: list[list[InlineKeyboardButton]] = [] # Row 1: directional keys rows.append( [ - InlineKeyboardButton( - "␣ Space", callback_data=f"{CB_ASK_SPACE}{window_id}"[:64] - ), - InlineKeyboardButton("↑", callback_data=f"{CB_ASK_UP}{window_id}"[:64]), - InlineKeyboardButton( - "⇥ Tab", callback_data=f"{CB_ASK_TAB}{window_id}"[:64] - ), + InlineKeyboardButton("␣ Space", callback_data=_ask(CB_ASK_SPACE)), + InlineKeyboardButton("↑", callback_data=_ask(CB_ASK_UP)), + InlineKeyboardButton("⇥ Tab", callback_data=_ask(CB_ASK_TAB)), ] ) if vertical_only: rows.append( [ - InlineKeyboardButton( - "↓", callback_data=f"{CB_ASK_DOWN}{window_id}"[:64] - ), + InlineKeyboardButton("↓", callback_data=_ask(CB_ASK_DOWN)), ] ) else: rows.append( [ - InlineKeyboardButton( - "←", callback_data=f"{CB_ASK_LEFT}{window_id}"[:64] - ), - InlineKeyboardButton( - "↓", callback_data=f"{CB_ASK_DOWN}{window_id}"[:64] - ), - InlineKeyboardButton( - "→", callback_data=f"{CB_ASK_RIGHT}{window_id}"[:64] - ), + InlineKeyboardButton("←", callback_data=_ask(CB_ASK_LEFT)), + InlineKeyboardButton("↓", callback_data=_ask(CB_ASK_DOWN)), + InlineKeyboardButton("→", callback_data=_ask(CB_ASK_RIGHT)), ] ) # Row 2: action keys rows.append( [ - InlineKeyboardButton( - "⎋ Esc", callback_data=f"{CB_ASK_ESC}{window_id}"[:64] - ), - InlineKeyboardButton( - "🔄", callback_data=f"{CB_ASK_REFRESH}{window_id}"[:64] - ), - InlineKeyboardButton( - "⏎ Enter", callback_data=f"{CB_ASK_ENTER}{window_id}"[:64] - ), + InlineKeyboardButton("⎋ Esc", callback_data=_ask(CB_ASK_ESC)), + InlineKeyboardButton("🔄", callback_data=_ask(CB_ASK_REFRESH)), + InlineKeyboardButton("⏎ Enter", callback_data=_ask(CB_ASK_ENTER)), ] ) return InlineKeyboardMarkup(rows) diff --git a/src/ccbot/handlers/message_handlers.py b/src/ccbot/handlers/message_handlers.py new file mode 100644 index 000000000..e6e5af596 --- /dev/null +++ b/src/ccbot/handlers/message_handlers.py @@ -0,0 +1,462 @@ +"""Text, photo, and voice message handlers plus bash output capture. + +Handlers: + - text_handler: route user text to bound tmux window / pickers + - photo_handler: download image and forward path to Claude Code + - voice_handler: download voice file, transcribe, show text, then forward + - capture_bash_output: stream !-prefixed bash command output +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from pathlib import Path + +from telegram import Bot, Update +from telegram.constants import ChatAction +from telegram.ext import ContextTypes + +from ..markdown_v2 import convert_markdown +from ..session import session_manager +from ..session_guard import get_thread_id, require_session, require_user +from ..terminal_parser import extract_bash_output, is_interactive_ui +from ..tmux_manager import tmux_manager +from ..transcribe import TranscriptionDisabled, TranscriptionError, transcribe +from ..utils import ccbot_dir, ensure_dir +from .capture_registry import capture_tasks +from .directory_browser import ( + BROWSE_DIRS_KEY, + BROWSE_PAGE_KEY, + BROWSE_PATH_KEY, + STATE_BROWSING_DIRECTORY, + STATE_KEY, + STATE_SELECTING_SESSION, + STATE_SELECTING_WINDOW, + UNBOUND_WINDOWS_KEY, + build_directory_browser, + build_window_picker, + clear_browse_state, + clear_session_picker_state, + clear_window_picker_state, +) +from .interactive_ui import get_interactive_window, handle_interactive_ui +from .message_queue import clear_status_msg_info, enqueue_status_update +from .message_sender import NO_LINK_PREVIEW, safe_reply, send_with_fallback + +logger = logging.getLogger(__name__) + +# Image directory for incoming photos (created lazily on first save) +IMAGES_DIR = ccbot_dir() / "images" +_AUDIO_DIR = ccbot_dir() / "audio" + + +async def photo_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle photos sent by the user: download and forward path to Claude Code.""" + user = await require_user(update, reply_unauthorized=True) + if user is None: + return + + if not update.message or not update.message.photo: + return + + chat = update.message.chat + thread_id = get_thread_id(update) + if chat.type in ("group", "supergroup") and thread_id is not None: + session_manager.set_group_chat_id(user.id, thread_id, chat.id) + + ctx = await require_session( + update, + reply_unauthorized=False, + user=user, + use_resolve=False, + require_named_topic=True, + unbind_if_missing=True, + ) + if ctx is None: + return + + # Download the highest-resolution photo + photo = update.message.photo[-1] + tg_file = await photo.get_file() + + # Save to ~/.ccbot/images/_.jpg + filename = f"{int(time.time())}_{photo.file_unique_id}.jpg" + file_path = ensure_dir(IMAGES_DIR) / filename + await tg_file.download_to_drive(file_path) + + # Build the message to send to Claude Code + caption = update.message.caption or "" + if caption: + text_to_send = f"{caption}\n\n(image attached: {file_path})" + else: + text_to_send = f"(image attached: {file_path})" + + try: + await update.message.chat.send_action(ChatAction.TYPING) + except Exception as e: + logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) + clear_status_msg_info(user.id, ctx.thread_id) + + success, message = await session_manager.send_to_window(ctx.window_id, text_to_send) + if not success: + await safe_reply(update.message, f"❌ {message}") + return + + # Confirm to user + await safe_reply(update.message, "📷 Image sent to Claude Code.") + + +async def voice_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle voice messages: download, transcribe, show text, then forward.""" + user = await require_user(update, reply_unauthorized=True) + if user is None: + return + + if not update.message or not update.message.voice: + return + + chat = update.message.chat + thread_id = get_thread_id(update) + if chat.type in ("group", "supergroup") and thread_id is not None: + session_manager.set_group_chat_id(user.id, thread_id, chat.id) + + ctx = await require_session( + update, + reply_unauthorized=False, + user=user, + use_resolve=False, + require_named_topic=True, + unbind_if_missing=True, + ) + if ctx is None: + return + + voice = update.message.voice + tg_file = await voice.get_file() + filename = f"{int(time.time())}_{voice.file_unique_id}.ogg" + file_path = ensure_dir(_AUDIO_DIR) / filename + await tg_file.download_to_drive(file_path) + + try: + try: + await update.message.chat.send_action(ChatAction.TYPING) + except Exception as e: + logger.warning( + "send_action(TYPING) failed, continuing to transcription: %s", e + ) + text = await transcribe(file_path) + except TranscriptionDisabled as e: + await safe_reply(update.message, f"🎤 Voice not available: {e}") + return + except TranscriptionError as e: + await safe_reply(update.message, f"❌ Transcription failed: {e}") + return + except Exception: + logger.exception("Unexpected transcription error") + await safe_reply(update.message, "❌ Transcription failed unexpectedly.") + return + finally: + file_path.unlink(missing_ok=True) + + # Show transcription first, then forward (package §3.2). + await safe_reply(update.message, f'🎤 "{text}"') + + try: + await update.message.chat.send_action(ChatAction.TYPING) + except Exception as e: + logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) + clear_status_msg_info(user.id, ctx.thread_id) + + success, message = await session_manager.send_to_window(ctx.window_id, text) + if not success: + await safe_reply(update.message, f"❌ {message}") + return + + +def cancel_bash_capture(user_id: int, thread_id: int) -> None: + """Cancel any running bash capture for this topic.""" + capture_tasks.cancel(user_id, thread_id) + + +async def capture_bash_output( + bot: Bot, + user_id: int, + thread_id: int, + window_id: str, + command: str, +) -> None: + """Background task: capture ``!`` bash command output from tmux pane. + + Sends the first captured output as a new message, then edits it + in-place as more output appears. Stops after 30 s or when cancelled + (e.g. user sends a new message, which pushes content down). + """ + try: + # Wait for the command to start producing output + await asyncio.sleep(2.0) + + chat_id = session_manager.resolve_chat_id(user_id, thread_id) + msg_id: int | None = None + last_output: str = "" + + for _ in range(30): + raw = await tmux_manager.capture_pane(window_id) + if raw is None: + return + + output = extract_bash_output(raw, command) + if not output: + await asyncio.sleep(1.0) + continue + + # Skip edit if nothing changed + if output == last_output: + await asyncio.sleep(1.0) + continue + + last_output = output + + # Truncate to fit Telegram's 4096-char limit + if len(output) > 3800: + output = "… " + output[-3800:] + + if msg_id is None: + # First capture — send a new message + sent = await send_with_fallback( + bot, + chat_id, + output, + message_thread_id=thread_id, + ) + if sent: + msg_id = sent.message_id + else: + # Subsequent captures — edit in place + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=msg_id, + text=convert_markdown(output), + parse_mode="MarkdownV2", + link_preview_options=NO_LINK_PREVIEW, + ) + except Exception: + try: + await bot.edit_message_text( + chat_id=chat_id, + message_id=msg_id, + text=output, + link_preview_options=NO_LINK_PREVIEW, + ) + except Exception: + pass + + await asyncio.sleep(1.0) + except asyncio.CancelledError: + return + finally: + capture_tasks.discard(user_id, thread_id, asyncio.current_task()) + + +async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + user = await require_user(update, reply_unauthorized=True) + if user is None: + return + + if not update.message or not update.message.text: + return + + thread_id = get_thread_id(update) + + # Capture group chat_id for supergroup forum topic routing. + # Required: Telegram Bot API needs group chat_id (not user_id) to send + # messages with message_thread_id. Do NOT remove — see session.py docs. + chat = update.effective_chat + if chat and chat.type in ("group", "supergroup"): + session_manager.set_group_chat_id(user.id, thread_id, chat.id) + + text = update.message.text + + # Ignore text in window picker mode (only for the same thread) + if context.user_data and context.user_data.get(STATE_KEY) == STATE_SELECTING_WINDOW: + pending_tid = context.user_data.get("_pending_thread_id") + if pending_tid == thread_id: + await safe_reply( + update.message, + "Please use the window picker above, or tap Cancel.", + ) + return + # Stale picker state from a different thread — clear it + clear_window_picker_state(context.user_data) + context.user_data.pop("_pending_thread_id", None) + context.user_data.pop("_pending_thread_text", None) + + # Ignore text in directory browsing mode (only for the same thread) + if ( + context.user_data + and context.user_data.get(STATE_KEY) == STATE_BROWSING_DIRECTORY + ): + pending_tid = context.user_data.get("_pending_thread_id") + if pending_tid == thread_id: + await safe_reply( + update.message, + "Please use the directory browser above, or tap Cancel.", + ) + return + # Stale browsing state from a different thread — clear it + clear_browse_state(context.user_data) + context.user_data.pop("_pending_thread_id", None) + context.user_data.pop("_pending_thread_text", None) + + # Ignore text in session picker mode (only for the same thread) + if ( + context.user_data + and context.user_data.get(STATE_KEY) == STATE_SELECTING_SESSION + ): + pending_tid = context.user_data.get("_pending_thread_id") + if pending_tid == thread_id: + await safe_reply( + update.message, + "Please use the session picker above, or tap Cancel.", + ) + return + # Stale picker state from a different thread — clear it + clear_session_picker_state(context.user_data) + context.user_data.pop("_pending_thread_id", None) + context.user_data.pop("_pending_thread_text", None) + context.user_data.pop("_selected_path", None) + + # Must be in a named topic + if thread_id is None: + await safe_reply( + update.message, + "❌ Please use a named topic. Create a new topic to start a session.", + ) + return + + window_id = session_manager.get_window_for_thread(user.id, thread_id) + if window_id is None: + # Unbound topic — check for unbound windows first + all_windows = await tmux_manager.list_windows() + bound_ids = { + window_id for _, _, window_id in session_manager.iter_thread_bindings() + } + unbound = [ + (w.window_id, w.window_name, w.cwd) + for w in all_windows + if w.window_id not in bound_ids + ] + logger.debug( + "Window picker check: all=%s, bound=%s, unbound=%s", + [w.window_name for w in all_windows], + bound_ids, + [name for _, name, _ in unbound], + ) + + if unbound: + # Show window picker + logger.info( + "Unbound topic: showing window picker (%d unbound windows, user=%d, thread=%d)", + len(unbound), + user.id, + thread_id, + ) + msg_text, keyboard, win_ids = build_window_picker(unbound) + if context.user_data is not None: + context.user_data[STATE_KEY] = STATE_SELECTING_WINDOW + context.user_data[UNBOUND_WINDOWS_KEY] = win_ids + context.user_data["_pending_thread_id"] = thread_id + context.user_data["_pending_thread_text"] = text + await safe_reply(update.message, msg_text, reply_markup=keyboard) + return + + # No unbound windows — show directory browser to create a new session + logger.info( + "Unbound topic: showing directory browser (user=%d, thread=%d)", + user.id, + thread_id, + ) + start_path = str(Path.cwd()) + msg_text, keyboard, subdirs = build_directory_browser(start_path) + if context.user_data is not None: + context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY + context.user_data[BROWSE_PATH_KEY] = start_path + context.user_data[BROWSE_PAGE_KEY] = 0 + context.user_data[BROWSE_DIRS_KEY] = subdirs + context.user_data["_pending_thread_id"] = thread_id + context.user_data["_pending_thread_text"] = text + await safe_reply(update.message, msg_text, reply_markup=keyboard) + return + + # Bound topic — live window via shared session guard (unbind if gone). + # Unbound path above must NOT call require_session (opens browser/picker). + ctx = await require_session( + update, + reply_unauthorized=False, + user=user, + use_resolve=False, + unbind_if_missing=True, + ) + if ctx is None: + return + window_id = ctx.window_id + w = ctx.window + + # Cosmetic / outbound-Telegram steps below must NEVER abort the handler + # before the message is injected into tmux. On flaky networks the "typing…" + # indicator (send_action) and status enqueue time out (telegram.error.TimedOut); + # since the update offset has already advanced, Telegram won't redeliver, so + # any exception here silently drops the user's message and forces a resend. + try: + await update.message.chat.send_action(ChatAction.TYPING) + except Exception as e: + logger.warning("send_action(TYPING) failed, continuing to injection: %s", e) + try: + await enqueue_status_update( + context.bot, user.id, window_id, None, thread_id=thread_id + ) + except Exception as e: + logger.warning("enqueue_status_update failed, continuing to injection: %s", e) + + # Cancel any running bash capture — new message pushes pane content down + cancel_bash_capture(user.id, thread_id) + + # Check for pending interactive UI before sending text. + # This catches UIs (permission prompts, etc.) that status polling might have missed. + # capture_pane is a local tmux call, but handle_interactive_ui hits the network — + # isolate the whole block so a failure can't prevent the injection below. + try: + pane_text = await tmux_manager.capture_pane(w.window_id) + if pane_text and is_interactive_ui(pane_text): + # UI detected — show it to user, then send text (acts as Enter) + logger.info( + "Detected pending interactive UI before sending text (user=%d, thread=%s)", + user.id, + thread_id, + ) + await handle_interactive_ui(context.bot, user.id, window_id, thread_id) + # Small delay to let UI render in Telegram before text arrives + await asyncio.sleep(0.3) + except Exception as e: + logger.warning("interactive-UI precheck failed, continuing to injection: %s", e) + + success, message = await session_manager.send_to_window(window_id, text) + if not success: + await safe_reply(update.message, f"❌ {message}") + return + + # Start background capture for ! bash command output + if text.startswith("!") and len(text) > 1: + bash_cmd = text[1:] # strip leading "!" + task = asyncio.create_task( + capture_bash_output(context.bot, user.id, thread_id, window_id, bash_cmd) + ) + capture_tasks.register(user.id, thread_id, task) + + # If in interactive mode, refresh the UI after sending text + interactive_window = get_interactive_window(user.id, thread_id) + if interactive_window and interactive_window == window_id: + await asyncio.sleep(0.2) + await handle_interactive_ui(context.bot, user.id, window_id, thread_id) diff --git a/src/ccbot/handlers/message_queue.py b/src/ccbot/handlers/message_queue.py index 536c21fee..d65a59077 100644 --- a/src/ccbot/handlers/message_queue.py +++ b/src/ccbot/handlers/message_queue.py @@ -11,12 +11,14 @@ Key components: - MessageTask: Dataclass representing a queued message task (with thread_id) - - get_or_create_queue: Get or create queue and worker for a user - - Message queue worker: Background task processing user's queue - - Content task processing with tool_use/tool_result handling - - Status message tracking and conversion (keyed by (user_id, thread_id)) + - StatusTracker: status + tool_use message id maps + - MessageQueueManager: queues, workers, flood control, status tracker + - queue_manager: process-wide singleton + - Public wrappers (get_or_create_queue, enqueue_*, clear_*, shutdown_workers) """ +from __future__ import annotations + import asyncio import logging import time @@ -25,31 +27,31 @@ from telegram import Bot from telegram.constants import ChatAction -from telegram.error import RetryAfter -from ..markdown_v2 import convert_markdown +from ..errors import ( + RetryAfter, + TelegramError, + log_exception, + retry_after_seconds, +) from ..session import session_manager from ..terminal_parser import parse_status_line from ..tmux_manager import tmux_manager from .message_sender import ( - NO_LINK_PREVIEW, - PARSE_MODE, + edit_with_fallback, send_photo, send_with_fallback, - strip_sentinels, ) logger = logging.getLogger(__name__) -def _ensure_formatted(text: str) -> str: - """Convert markdown to MarkdownV2.""" - return convert_markdown(text) - - # Merge limit for content messages MERGE_MAX_LENGTH = 3800 # Leave room for markdown conversion overhead +# Max seconds to wait for flood control before dropping tasks +FLOOD_CONTROL_MAX_WAIT = 10 + @dataclass class MessageTask: @@ -66,40 +68,159 @@ class MessageTask: image_data: list[tuple[str, bytes]] | None = None # From tool_result images -# Per-user message queues and worker tasks -_message_queues: dict[int, asyncio.Queue[MessageTask]] = {} -_queue_workers: dict[int, asyncio.Task[None]] = {} -_queue_locks: dict[int, asyncio.Lock] = {} # Protect drain/refill operations +class StatusTracker: + """Tracks Telegram status messages and tool_use message IDs.""" + + def __init__(self) -> None: + # (user_id, thread_id_or_0) -> (message_id, window_id, last_text) + self._status: dict[tuple[int, int], tuple[int, str, str]] = {} + # (tool_use_id, user_id, thread_id_or_0) -> telegram message_id + self._tool_msg_ids: dict[tuple[str, int, int], int] = {} + + def set_status( + self, + user_id: int, + thread_id_or_0: int, + message_id: int, + window_id: str, + text: str, + ) -> None: + self._status[(user_id, thread_id_or_0)] = (message_id, window_id, text) + + def get_status( + self, user_id: int, thread_id_or_0: int = 0 + ) -> tuple[int, str, str] | None: + return self._status.get((user_id, thread_id_or_0)) + + def pop_status( + self, user_id: int, thread_id_or_0: int = 0 + ) -> tuple[int, str, str] | None: + return self._status.pop((user_id, thread_id_or_0), None) + + def set_tool_msg_id( + self, + tool_use_id: str, + user_id: int, + thread_id_or_0: int, + message_id: int, + ) -> None: + self._tool_msg_ids[(tool_use_id, user_id, thread_id_or_0)] = message_id + + def get_tool_msg_id( + self, tool_use_id: str, user_id: int, thread_id_or_0: int + ) -> int | None: + return self._tool_msg_ids.get((tool_use_id, user_id, thread_id_or_0)) + + def pop_tool_msg_id( + self, tool_use_id: str, user_id: int, thread_id_or_0: int + ) -> int | None: + return self._tool_msg_ids.pop((tool_use_id, user_id, thread_id_or_0), None) + + def clear_tools_for_topic(self, user_id: int, thread_id: int | None = None) -> None: + thread_id = thread_id or 0 + keys = [k for k in self._tool_msg_ids if k[1] == user_id and k[2] == thread_id] + for key in keys: + self._tool_msg_ids.pop(key, None) + + def clear_status_for_topic( + self, user_id: int, thread_id: int | None = None + ) -> None: + self._status.pop((user_id, thread_id or 0), None) + + def clear_all(self) -> None: + self._status.clear() + self._tool_msg_ids.clear() + + +class MessageQueueManager: + """Owns per-user queues, workers, flood control, and status tracking.""" + + def __init__(self) -> None: + self._queues: dict[int, asyncio.Queue[MessageTask]] = {} + self._workers: dict[int, asyncio.Task[None]] = {} + self._locks: dict[int, asyncio.Lock] = {} + # user_id -> monotonic time when flood ban expires + self._flood_until: dict[int, float] = {} + self.status = StatusTracker() + + def get_queue(self, user_id: int) -> asyncio.Queue[MessageTask] | None: + """Get the message queue for a user (if exists).""" + return self._queues.get(user_id) + + def get_or_create_queue(self, bot: Bot, user_id: int) -> asyncio.Queue[MessageTask]: + """Get or create message queue and worker for a user.""" + if user_id not in self._queues: + self._queues[user_id] = asyncio.Queue() + self._locks[user_id] = asyncio.Lock() + self._workers[user_id] = asyncio.create_task( + _message_queue_worker(bot, user_id) + ) + return self._queues[user_id] -# Map (tool_use_id, user_id, thread_id_or_0) -> telegram message_id -# for editing tool_use messages with results -_tool_msg_ids: dict[tuple[str, int, int], int] = {} + def set_flood(self, user_id: int, retry_secs: float) -> None: + """Pause the user queue until now + retry_secs.""" + self._flood_until[user_id] = time.monotonic() + retry_secs -# Status message tracking: (user_id, thread_id_or_0) -> (message_id, window_id, last_text) -_status_msg_info: dict[tuple[int, int], tuple[int, str, str]] = {} + def is_flooded(self, user_id: int) -> bool: + return self._flood_until.get(user_id, 0) > time.monotonic() -# Flood control: user_id -> monotonic time when ban expires -_flood_until: dict[int, float] = {} + def flood_remaining(self, user_id: int) -> float: + rem = self._flood_until.get(user_id, 0) - time.monotonic() + return rem if rem > 0 else 0.0 -# Max seconds to wait for flood control before dropping tasks -FLOOD_CONTROL_MAX_WAIT = 10 + def clear_flood(self, user_id: int) -> None: + self._flood_until.pop(user_id, None) + + def get_lock(self, user_id: int) -> asyncio.Lock: + """Public accessor for user's queue lock.""" + return self._locks[user_id] + + async def shutdown(self) -> None: + """Stop all queue workers and clear queue state.""" + for _, worker in list(self._workers.items()): + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass + self._workers.clear() + self._queues.clear() + self._locks.clear() + self._flood_until.clear() + self.status.clear_all() + logger.info("Message queue workers stopped") + + +# Process-wide singleton +queue_manager = MessageQueueManager() + + +# --- Public wrappers (stable import surface) --- def get_message_queue(user_id: int) -> asyncio.Queue[MessageTask] | None: """Get the message queue for a user (if exists).""" - return _message_queues.get(user_id) + return queue_manager.get_queue(user_id) def get_or_create_queue(bot: Bot, user_id: int) -> asyncio.Queue[MessageTask]: """Get or create message queue and worker for a user.""" - if user_id not in _message_queues: - _message_queues[user_id] = asyncio.Queue() - _queue_locks[user_id] = asyncio.Lock() - # Start worker task for this user - _queue_workers[user_id] = asyncio.create_task( - _message_queue_worker(bot, user_id) - ) - return _message_queues[user_id] + return queue_manager.get_or_create_queue(bot, user_id) + + +def clear_status_msg_info(user_id: int, thread_id: int | None = None) -> None: + """Clear status message tracking for a user (and optionally a specific thread).""" + queue_manager.status.clear_status_for_topic(user_id, thread_id) + + +def clear_tool_msg_ids_for_topic(user_id: int, thread_id: int | None = None) -> None: + """Clear tool message ID tracking for a specific topic.""" + queue_manager.status.clear_tools_for_topic(user_id, thread_id) + + +async def shutdown_workers() -> None: + """Stop all queue workers (called during bot shutdown).""" + await queue_manager.shutdown() def _inspect_queue(queue: asyncio.Queue[MessageTask]) -> list[MessageTask]: @@ -212,14 +333,10 @@ async def _process_content_with_retry( await _process_content_task(bot, user_id, task) return except RetryAfter as e: - retry_secs = ( - e.retry_after - if isinstance(e.retry_after, int) - else int(e.retry_after.total_seconds()) - ) + retry_secs = int(retry_after_seconds(e)) if retry_secs > FLOOD_CONTROL_MAX_WAIT: # Long ban — also pause subsequent queued tasks - _flood_until[user_id] = time.monotonic() + retry_secs + queue_manager.set_flood(user_id, retry_secs) if attempt >= max_attempts: logger.error( "Dropping content message for user %d after %d flood-control " @@ -241,8 +358,12 @@ async def _process_content_with_retry( async def _message_queue_worker(bot: Bot, user_id: int) -> None: """Process message tasks for a user sequentially.""" - queue = _message_queues[user_id] - lock = _queue_locks[user_id] + queue = queue_manager.get_queue(user_id) + if queue is None: + # Worker is only started from get_or_create_queue after the queue exists. + logger.error("Message queue worker started without queue for user %d", user_id) + return + lock = queue_manager.get_lock(user_id) logger.info(f"Message queue worker started for user {user_id}") while True: @@ -250,22 +371,20 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: task = await queue.get() try: # Flood control: drop status, wait for content - flood_end = _flood_until.get(user_id, 0) - if flood_end > 0: - remaining = flood_end - time.monotonic() - if remaining > 0: - if task.task_type != "content": - # Status is ephemeral — safe to drop - continue - # Content is actual Claude output — wait then send - logger.debug( - "Flood controlled: waiting %.0fs for content (user %d)", - remaining, - user_id, - ) - await asyncio.sleep(remaining) + remaining = queue_manager.flood_remaining(user_id) + if remaining > 0: + if task.task_type != "content": + # Status is ephemeral — safe to drop + continue + # Content is actual Claude output — wait then send + logger.debug( + "Flood controlled: waiting %.0fs for content (user %d)", + remaining, + user_id, + ) + await asyncio.sleep(remaining) # Ban expired - _flood_until.pop(user_id, None) + queue_manager.clear_flood(user_id) logger.info("Flood control lifted for user %d", user_id) if task.task_type == "content": @@ -284,13 +403,9 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: elif task.task_type == "status_clear": await _do_clear_status_message(bot, user_id, task.thread_id or 0) except RetryAfter as e: - retry_secs = ( - e.retry_after - if isinstance(e.retry_after, int) - else int(e.retry_after.total_seconds()) - ) + retry_secs = int(retry_after_seconds(e)) if retry_secs > FLOOD_CONTROL_MAX_WAIT: - _flood_until[user_id] = time.monotonic() + retry_secs + queue_manager.set_flood(user_id, retry_secs) logger.warning( "Flood control for user %d: retry_after=%ds, " "pausing queue until ban expires", @@ -304,22 +419,35 @@ async def _message_queue_worker(bot: Bot, user_id: int) -> None: retry_secs, ) await asyncio.sleep(retry_secs) + except TelegramError as e: + log_exception( + logger, + "Telegram error processing message task", + e, + level=logging.ERROR, + user_id=user_id, + ) except Exception as e: - logger.error(f"Error processing message task for user {user_id}: {e}") + log_exception( + logger, + "Error processing message task", + e, + level=logging.ERROR, + user_id=user_id, + ) finally: queue.task_done() except asyncio.CancelledError: logger.info(f"Message queue worker cancelled for user {user_id}") break except Exception as e: - logger.error(f"Unexpected error in queue worker for user {user_id}: {e}") - - -def _send_kwargs(thread_id: int | None) -> dict[str, int]: - """Build message_thread_id kwargs for bot.send_message().""" - if thread_id is not None: - return {"message_thread_id": thread_id} - return {} + log_exception( + logger, + "Unexpected error in queue worker", + e, + level=logging.ERROR, + user_id=user_id, + ) async def _send_task_images(bot: Bot, chat_id: int, task: MessageTask) -> None: @@ -335,56 +463,37 @@ async def _send_task_images(bot: Bot, chat_id: int, task: MessageTask) -> None: bot, chat_id, task.image_data, - **_send_kwargs(task.thread_id), # type: ignore[arg-type] + message_thread_id=task.thread_id, ) async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> None: """Process a content message task.""" - wid = task.window_id or "" - tid = task.thread_id or 0 + window_id = task.window_id or "" + thread_id = task.thread_id or 0 chat_id = session_manager.resolve_chat_id(user_id, task.thread_id) + st = queue_manager.status # 1. Handle tool_result editing (merged parts are edited together) if task.content_type == "tool_result" and task.tool_use_id: - _tkey = (task.tool_use_id, user_id, tid) - edit_msg_id = _tool_msg_ids.pop(_tkey, None) + edit_msg_id = st.pop_tool_msg_id(task.tool_use_id, user_id, thread_id) if edit_msg_id is not None: # Clear status message first - await _do_clear_status_message(bot, user_id, tid) + await _do_clear_status_message(bot, user_id, thread_id) # Join all parts for editing (merged content goes together) full_text = "\n\n".join(task.parts) - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=edit_msg_id, - text=_ensure_formatted(full_text), - parse_mode=PARSE_MODE, - link_preview_options=NO_LINK_PREVIEW, - ) + edited = await edit_with_fallback( + bot, + chat_id, + edit_msg_id, + full_text, + ) + if edited: await _send_task_images(bot, chat_id, task) - await _check_and_send_status(bot, user_id, wid, task.thread_id) + await _check_and_send_status(bot, user_id, window_id, task.thread_id) return - except RetryAfter: - raise - except Exception: - try: - # Fallback: plain text with sentinels stripped - plain_text = strip_sentinels(task.text or full_text) - await bot.edit_message_text( - chat_id=chat_id, - message_id=edit_msg_id, - text=plain_text, - link_preview_options=NO_LINK_PREVIEW, - ) - await _send_task_images(bot, chat_id, task) - await _check_and_send_status(bot, user_id, wid, task.thread_id) - return - except RetryAfter: - raise - except Exception: - logger.debug(f"Failed to edit tool msg {edit_msg_id}, sending new") - # Fall through to send as new message + logger.debug("Failed to edit tool msg %s, sending new", edit_msg_id) + # Fall through to send as new message # 2. Send content messages, converting status message to first content part first_part = True @@ -398,8 +507,8 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No converted_msg_id = await _convert_status_to_content( bot, user_id, - tid, - wid, + thread_id, + window_id, part, ) if converted_msg_id is not None: @@ -410,7 +519,7 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No bot, chat_id, part, - **_send_kwargs(task.thread_id), # type: ignore[arg-type] + message_thread_id=task.thread_id, ) if sent: @@ -418,13 +527,13 @@ async def _process_content_task(bot: Bot, user_id: int, task: MessageTask) -> No # 3. Record tool_use message ID for later editing if last_msg_id and task.tool_use_id and task.content_type == "tool_use": - _tool_msg_ids[(task.tool_use_id, user_id, tid)] = last_msg_id + st.set_tool_msg_id(task.tool_use_id, user_id, thread_id, last_msg_id) # 4. Send images if present (from tool_result with base64 image blocks) await _send_task_images(bot, chat_id, task) # 5. After content, check and send status - await _check_and_send_status(bot, user_id, wid, task.thread_id) + await _check_and_send_status(bot, user_id, window_id, task.thread_id) async def _convert_status_to_content( @@ -438,8 +547,7 @@ async def _convert_status_to_content( Returns the message_id if converted successfully, None otherwise. """ - skey = (user_id, thread_id_or_0) - info = _status_msg_info.pop(skey, None) + info = queue_manager.status.pop_status(user_id, thread_id_or_0) if not info: return None @@ -449,65 +557,49 @@ async def _convert_status_to_content( # Different window, just delete the old status try: await bot.delete_message(chat_id=chat_id, message_id=msg_id) - except Exception: - pass + except TelegramError as e: + log_exception( + logger, + "Failed to delete status on window change", + e, + message_id=msg_id, + ) return None # Edit status message to show content - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=_ensure_formatted(content_text), - parse_mode=PARSE_MODE, - link_preview_options=NO_LINK_PREVIEW, - ) + edited = await edit_with_fallback(bot, chat_id, msg_id, content_text) + if edited: return msg_id - except RetryAfter: - raise - except Exception: - try: - # Fallback to plain text with sentinels stripped - plain = strip_sentinels(content_text) - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=plain, - link_preview_options=NO_LINK_PREVIEW, - ) - return msg_id - except RetryAfter: - raise - except Exception as e: - logger.debug(f"Failed to convert status to content: {e}") - # Message might be deleted or too old, caller will send new message - return None + # Message might be deleted or too old, caller will send new message + return None async def _process_status_update_task( bot: Bot, user_id: int, task: MessageTask ) -> None: """Process a status update task.""" - wid = task.window_id or "" - tid = task.thread_id or 0 + window_id = task.window_id or "" + thread_id = task.thread_id or 0 chat_id = session_manager.resolve_chat_id(user_id, task.thread_id) - skey = (user_id, tid) status_text = task.text or "" + st = queue_manager.status if not status_text: # No status text means clear status - await _do_clear_status_message(bot, user_id, tid) + await _do_clear_status_message(bot, user_id, thread_id) return - current_info = _status_msg_info.get(skey) + current_info = st.get_status(user_id, thread_id) if current_info: msg_id, stored_wid, last_text = current_info - if stored_wid != wid: + if stored_wid != window_id: # Window changed - delete old and send new - await _do_clear_status_message(bot, user_id, tid) - await _do_send_status_message(bot, user_id, tid, wid, status_text) + await _do_clear_status_message(bot, user_id, thread_id) + await _do_send_status_message( + bot, user_id, thread_id, window_id, status_text + ) elif status_text == last_text: # Same content, skip edit return @@ -521,37 +613,19 @@ async def _process_status_update_task( ) except RetryAfter: raise - except Exception: - pass - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=_ensure_formatted(status_text), - parse_mode=PARSE_MODE, - link_preview_options=NO_LINK_PREVIEW, + except TelegramError as e: + log_exception(logger, "Failed to send typing action", e) + edited = await edit_with_fallback(bot, chat_id, msg_id, status_text) + if edited: + st.set_status(user_id, thread_id, msg_id, window_id, status_text) + else: + st.pop_status(user_id, thread_id) + await _do_send_status_message( + bot, user_id, thread_id, window_id, status_text ) - _status_msg_info[skey] = (msg_id, wid, status_text) - except RetryAfter: - raise - except Exception: - try: - await bot.edit_message_text( - chat_id=chat_id, - message_id=msg_id, - text=status_text, - link_preview_options=NO_LINK_PREVIEW, - ) - _status_msg_info[skey] = (msg_id, wid, status_text) - except RetryAfter: - raise - except Exception as e: - logger.debug(f"Failed to edit status message: {e}") - _status_msg_info.pop(skey, None) - await _do_send_status_message(bot, user_id, tid, wid, status_text) else: # No existing status message, send new - await _do_send_status_message(bot, user_id, tid, wid, status_text) + await _do_send_status_message(bot, user_id, thread_id, window_id, status_text) async def _do_send_status_message( @@ -562,33 +636,38 @@ async def _do_send_status_message( text: str, ) -> None: """Send a new status message and track it (internal, called from worker).""" - skey = (user_id, thread_id_or_0) + st = queue_manager.status thread_id: int | None = thread_id_or_0 if thread_id_or_0 != 0 else None chat_id = session_manager.resolve_chat_id(user_id, thread_id) # Safety net: delete any orphaned status message before sending a new one. # This catches edge cases where tracking was cleared without deleting the message. - old = _status_msg_info.pop(skey, None) + old = st.pop_status(user_id, thread_id_or_0) if old: try: await bot.delete_message(chat_id=chat_id, message_id=old[0]) - except Exception: - pass + except TelegramError as e: + log_exception( + logger, + "Failed to delete orphaned status message", + e, + message_id=old[0], + ) # Send typing indicator when Claude is working if "esc to interrupt" in text.lower(): try: await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) except RetryAfter: raise - except Exception: - pass + except TelegramError as e: + log_exception(logger, "Failed to send typing action", e) sent = await send_with_fallback( bot, chat_id, text, - **_send_kwargs(thread_id), # type: ignore[arg-type] + message_thread_id=thread_id, ) if sent: - _status_msg_info[skey] = (sent.message_id, window_id, text) + st.set_status(user_id, thread_id_or_0, sent.message_id, window_id, text) async def _do_clear_status_message( @@ -597,15 +676,19 @@ async def _do_clear_status_message( thread_id_or_0: int = 0, ) -> None: """Delete the status message for a user (internal, called from worker).""" - skey = (user_id, thread_id_or_0) - info = _status_msg_info.pop(skey, None) + info = queue_manager.status.pop_status(user_id, thread_id_or_0) if info: msg_id = info[0] chat_id = session_manager.resolve_chat_id(user_id, thread_id_or_0 or None) try: await bot.delete_message(chat_id=chat_id, message_id=msg_id) - except Exception as e: - logger.debug(f"Failed to delete status message {msg_id}: {e}") + except TelegramError as e: + log_exception( + logger, + "Failed to delete status message", + e, + message_id=msg_id, + ) async def _check_and_send_status( @@ -616,7 +699,7 @@ async def _check_and_send_status( ) -> None: """Check terminal for status line and send status message if present.""" # Skip if there are more messages pending in the queue - queue = _message_queues.get(user_id) + queue = queue_manager.get_queue(user_id) if queue and not queue.empty(): return w = await tmux_manager.find_window_by_id(window_id) @@ -627,10 +710,10 @@ async def _check_and_send_status( if not pane_text: return - tid = thread_id or 0 + thread_id = thread_id or 0 status_line = parse_status_line(pane_text) if status_line: - await _do_send_status_message(bot, user_id, tid, window_id, status_line) + await _do_send_status_message(bot, user_id, thread_id, window_id, status_line) async def enqueue_content_message( @@ -675,16 +758,12 @@ async def enqueue_status_update( ) -> None: """Enqueue status update. Skipped if text unchanged or during flood control.""" # Don't enqueue during flood control — they'd just be dropped - flood_end = _flood_until.get(user_id, 0) - if flood_end > time.monotonic(): + if queue_manager.is_flooded(user_id): return - tid = thread_id or 0 - # Deduplicate: skip if text matches what's already displayed if status_text: - skey = (user_id, tid) - info = _status_msg_info.get(skey) + info = queue_manager.status.get_status(user_id, thread_id or 0) if info and info[1] == window_id and info[2] == status_text: return @@ -701,37 +780,3 @@ async def enqueue_status_update( task = MessageTask(task_type="status_clear", thread_id=thread_id) queue.put_nowait(task) - - -def clear_status_msg_info(user_id: int, thread_id: int | None = None) -> None: - """Clear status message tracking for a user (and optionally a specific thread).""" - skey = (user_id, thread_id or 0) - _status_msg_info.pop(skey, None) - - -def clear_tool_msg_ids_for_topic(user_id: int, thread_id: int | None = None) -> None: - """Clear tool message ID tracking for a specific topic. - - Removes all entries in _tool_msg_ids that match the given user and thread. - """ - tid = thread_id or 0 - # Find and remove all matching keys - keys_to_remove = [ - key for key in _tool_msg_ids if key[1] == user_id and key[2] == tid - ] - for key in keys_to_remove: - _tool_msg_ids.pop(key, None) - - -async def shutdown_workers() -> None: - """Stop all queue workers (called during bot shutdown).""" - for _, worker in list(_queue_workers.items()): - worker.cancel() - try: - await worker - except asyncio.CancelledError: - pass - _queue_workers.clear() - _message_queues.clear() - _queue_locks.clear() - logger.info("Message queue workers stopped") diff --git a/src/ccbot/handlers/message_sender.py b/src/ccbot/handlers/message_sender.py index d54cd1396..f19d1b095 100644 --- a/src/ccbot/handlers/message_sender.py +++ b/src/ccbot/handlers/message_sender.py @@ -5,8 +5,12 @@ Uses telegramify-markdown for MarkdownV2 formatting. +All Markdown→plain fallbacks go through a single ``MessageFormatter`` +(``_run_markdown_fallback``) so RetryAfter handling stays consistent. + Functions: - send_with_fallback: Send with formatting → plain text fallback + - edit_with_fallback: Edit with formatting → plain text fallback - send_photo: Photo sending (single or media group) - safe_reply: Reply with formatting, fallback to plain text - safe_edit: Edit message with formatting, fallback to plain text @@ -16,18 +20,23 @@ RetryAfter exceptions are re-raised so callers (queue worker) can handle them. """ +from __future__ import annotations + import io import logging -from typing import Any +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar from telegram import Bot, InputMediaPhoto, LinkPreviewOptions, Message -from telegram.error import RetryAfter +from telegram.error import RetryAfter, TelegramError from ..markdown_v2 import convert_markdown from ..transcript_parser import TranscriptParser logger = logging.getLogger(__name__) +T = TypeVar("T") + def strip_sentinels(text: str) -> str: """Strip expandable quote sentinel markers for plain text fallback.""" @@ -39,55 +48,203 @@ def strip_sentinels(text: str) -> str: return text -def _ensure_formatted(text: str) -> str: - """Convert markdown to MarkdownV2.""" - return convert_markdown(text) - - PARSE_MODE = "MarkdownV2" - # Disable link previews in all messages to reduce visual noise NO_LINK_PREVIEW = LinkPreviewOptions(is_disabled=True) +class MessageFormatter: + """Single place for MarkdownV2 formatting and plain-text fallback. + + All send/edit/reply helpers use this class so parse_mode, sentinel + stripping, and RetryAfter re-raise behavior stay identical. + """ + + parse_mode: str = PARSE_MODE + + @staticmethod + def to_markdown_v2(text: str) -> str: + """Convert source markdown to Telegram MarkdownV2.""" + return convert_markdown(text) + + @staticmethod + def to_plain(text: str) -> str: + """Plain-text fallback body (sentinels stripped).""" + return strip_sentinels(text) + + +# Module-level singleton — acceptance: single MessageFormatter used +formatter = MessageFormatter() + + +async def _run_markdown_fallback( + text: str, + markdown_call: Callable[[str], Awaitable[T]], + plain_call: Callable[[str], Awaitable[T]], + *, + on_failure: Callable[[BaseException], T | None] | None = None, + reraise_final: bool = False, + failure_level: int = logging.ERROR, + failure_message: str = "Markdown/plain fallback failed", + **log_ctx: Any, +) -> T | None: + """Try MarkdownV2 call, then plain text. Always re-raise RetryAfter. + + Args: + text: Source message text (pre-conversion). + markdown_call: Awaitable factory receiving MarkdownV2 text. + plain_call: Awaitable factory receiving plain text. + on_failure: Optional mapper when both attempts fail (return value). + reraise_final: If True, re-raise the plain-path TelegramError. + failure_level: Log level when both fail and not re-raised. + failure_message: Log prefix when both fail. + **log_ctx: Extra structured fields for failure logs. + """ + try: + return await markdown_call(formatter.to_markdown_v2(text)) + except RetryAfter: + raise + except TelegramError: + try: + return await plain_call(formatter.to_plain(text)) + except RetryAfter: + raise + except TelegramError as e: + if reraise_final: + logger.log( + failure_level, + "%s: %s: %s", + failure_message, + type(e).__name__, + e, + ) + raise + if log_ctx: + ctx = " ".join(f"{k}={v}" for k, v in log_ctx.items() if v is not None) + logger.log( + failure_level, + "%s: %s: %s (%s)", + failure_message, + type(e).__name__, + e, + ctx, + ) + else: + logger.log( + failure_level, + "%s: %s: %s", + failure_message, + type(e).__name__, + e, + ) + if on_failure is not None: + return on_failure(e) + return None + + +# Back-compat alias used by older helpers / tests +_ensure_formatted = formatter.to_markdown_v2 + + async def send_with_fallback( bot: Bot, chat_id: int, text: str, + message_thread_id: int | None = None, **kwargs: Any, ) -> Message | None: """Send message with MarkdownV2, falling back to plain text on failure. + Args: + bot: Telegram Bot instance + chat_id: Target chat ID + text: Source message text (pre-conversion) + message_thread_id: Optional forum topic thread ID + **kwargs: Extra kwargs passed to bot.send_message + Returns the sent Message on success, None on failure. RetryAfter is re-raised for caller handling. """ kwargs.setdefault("link_preview_options", NO_LINK_PREVIEW) - try: + + async def _md(body: str) -> Message: return await bot.send_message( chat_id=chat_id, - text=_ensure_formatted(text), - parse_mode=PARSE_MODE, + text=body, + parse_mode=formatter.parse_mode, + message_thread_id=message_thread_id, **kwargs, ) - except RetryAfter: - raise - except Exception: - try: - return await bot.send_message( - chat_id=chat_id, text=strip_sentinels(text), **kwargs - ) - except RetryAfter: - raise - except Exception as e: - logger.error(f"Failed to send message to {chat_id}: {e}") - return None + + async def _plain(body: str) -> Message: + return await bot.send_message( + chat_id=chat_id, + text=body, + message_thread_id=message_thread_id, + **kwargs, + ) + + return await _run_markdown_fallback( + text, + _md, + _plain, + failure_level=logging.ERROR, + failure_message="Failed to send message", + chat_id=chat_id, + ) + + +async def edit_with_fallback( + bot: Bot, + chat_id: int, + message_id: int, + text: str, + **kwargs: Any, +) -> bool: + """Edit message with MarkdownV2, falling back to plain text on failure. + + Returns True on success, False if both attempts fail. + RetryAfter is re-raised for caller handling. + """ + kwargs.setdefault("link_preview_options", NO_LINK_PREVIEW) + + async def _md(body: str) -> bool: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=body, + parse_mode=formatter.parse_mode, + **kwargs, + ) + return True + + async def _plain(body: str) -> bool: + await bot.edit_message_text( + chat_id=chat_id, + message_id=message_id, + text=body, + **kwargs, + ) + return True + + result = await _run_markdown_fallback( + text, + _md, + _plain, + failure_level=logging.DEBUG, + failure_message="Failed to edit message", + chat_id=chat_id, + message_id=message_id, + ) + return bool(result) async def send_photo( bot: Bot, chat_id: int, image_data: list[tuple[str, bytes]], + message_thread_id: int | None = None, **kwargs: Any, ) -> None: """Send photo(s) to chat. Sends as media group if multiple images. @@ -98,6 +255,7 @@ async def send_photo( bot: Telegram Bot instance chat_id: Target chat ID image_data: List of (media_type, raw_bytes) tuples + message_thread_id: Optional forum topic thread ID **kwargs: Extra kwargs passed to send_photo/send_media_group """ if not image_data: @@ -108,6 +266,7 @@ async def send_photo( await bot.send_photo( chat_id=chat_id, photo=io.BytesIO(raw_bytes), + message_thread_id=message_thread_id, **kwargs, ) else: @@ -118,53 +277,65 @@ async def send_photo( await bot.send_media_group( chat_id=chat_id, media=media, + message_thread_id=message_thread_id, **kwargs, ) except RetryAfter: raise - except Exception as e: - logger.error("Failed to send photo to %d: %s", chat_id, e) + except TelegramError as e: + logger.error("Failed to send photo to %d: %s: %s", chat_id, type(e).__name__, e) async def safe_reply(message: Message, text: str, **kwargs: Any) -> Message: """Reply with formatting, falling back to plain text on failure.""" kwargs.setdefault("link_preview_options", NO_LINK_PREVIEW) - try: + + async def _md(body: str) -> Message: return await message.reply_text( - _ensure_formatted(text), - parse_mode=PARSE_MODE, + body, + parse_mode=formatter.parse_mode, **kwargs, ) - except RetryAfter: - raise - except Exception: - try: - return await message.reply_text(strip_sentinels(text), **kwargs) - except RetryAfter: - raise - except Exception as e: - logger.error(f"Failed to reply: {e}") - raise + + async def _plain(body: str) -> Message: + return await message.reply_text(body, **kwargs) + + result = await _run_markdown_fallback( + text, + _md, + _plain, + reraise_final=True, + failure_level=logging.ERROR, + failure_message="Failed to reply", + ) + # reraise_final ensures we never get None here on TelegramError + assert result is not None + return result async def safe_edit(target: Any, text: str, **kwargs: Any) -> None: """Edit message with formatting, falling back to plain text on failure.""" kwargs.setdefault("link_preview_options", NO_LINK_PREVIEW) - try: + + async def _md(body: str) -> bool: await target.edit_message_text( - _ensure_formatted(text), - parse_mode=PARSE_MODE, + body, + parse_mode=formatter.parse_mode, **kwargs, ) - except RetryAfter: - raise - except Exception: - try: - await target.edit_message_text(strip_sentinels(text), **kwargs) - except RetryAfter: - raise - except Exception as e: - logger.error("Failed to edit message: %s", e) + return True + + async def _plain(body: str) -> bool: + await target.edit_message_text(body, **kwargs) + return True + + await _run_markdown_fallback( + text, + _md, + _plain, + failure_level=logging.ERROR, + failure_message="Failed to edit message", + ) async def safe_send( @@ -175,24 +346,10 @@ async def safe_send( **kwargs: Any, ) -> None: """Send message with formatting, falling back to plain text on failure.""" - kwargs.setdefault("link_preview_options", NO_LINK_PREVIEW) - if message_thread_id is not None: - kwargs.setdefault("message_thread_id", message_thread_id) - try: - await bot.send_message( - chat_id=chat_id, - text=_ensure_formatted(text), - parse_mode=PARSE_MODE, - **kwargs, - ) - except RetryAfter: - raise - except Exception: - try: - await bot.send_message( - chat_id=chat_id, text=strip_sentinels(text), **kwargs - ) - except RetryAfter: - raise - except Exception as e: - logger.error(f"Failed to send message to {chat_id}: {e}") + await send_with_fallback( + bot, + chat_id, + text, + message_thread_id=message_thread_id, + **kwargs, + ) diff --git a/src/ccbot/handlers/notifications.py b/src/ccbot/handlers/notifications.py new file mode 100644 index 000000000..dcc2105ea --- /dev/null +++ b/src/ccbot/handlers/notifications.py @@ -0,0 +1,123 @@ +"""Outbound assistant message routing from SessionMonitor. + +handle_new_message delivers Claude Code JSONL updates to the correct +Telegram topic via thread_bindings and the per-user message queue. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path + +from telegram import Bot + +from ..config import config +from ..session import session_manager +from ..session_monitor import NewMessage +from .interactive_ui import ( + INTERACTIVE_TOOL_NAMES, + clear_interactive_mode, + clear_interactive_msg, + get_interactive_msg_id, + handle_interactive_ui, + set_interactive_mode, +) +from .message_queue import enqueue_content_message, get_message_queue +from .response_builder import build_response_parts + +logger = logging.getLogger(__name__) + + +async def handle_new_message(msg: NewMessage, bot: Bot) -> None: + """Handle a new assistant message — enqueue for sequential processing. + + Messages are queued per-user to ensure status messages always appear last. + Routes via thread_bindings to deliver to the correct topic. + """ + status = "complete" if msg.is_complete else "streaming" + logger.info( + f"handle_new_message [{status}]: session={msg.session_id}, " + f"text_len={len(msg.text)}" + ) + + # Find users whose thread-bound window matches this session + active_users = await session_manager.find_users_for_session(msg.session_id) + + if not active_users: + logger.info(f"No active users for session {msg.session_id}") + return + + for user_id, window_id, thread_id in active_users: + # Handle interactive tools specially - capture terminal and send UI + if msg.tool_name in INTERACTIVE_TOOL_NAMES and msg.content_type == "tool_use": + # Mark interactive mode BEFORE sleeping so polling skips this window + set_interactive_mode(user_id, window_id, thread_id) + # Flush pending messages (e.g. plan content) before sending interactive UI + queue = get_message_queue(user_id) + if queue: + await queue.join() + # Wait briefly for Claude Code to render the question UI + await asyncio.sleep(0.3) + handled = await handle_interactive_ui(bot, user_id, window_id, thread_id) + if handled: + # Update user's read offset + session = await session_manager.resolve_session_for_window(window_id) + if session and session.file_path: + try: + file_size = Path(session.file_path).stat().st_size + session_manager.update_user_window_offset( + user_id, window_id, file_size + ) + except OSError: + pass + continue # Don't send the normal tool_use message + else: + # UI not rendered — clear the early-set mode + clear_interactive_mode(user_id, thread_id) + + # Any non-interactive message means the interaction is complete — delete the UI message + if get_interactive_msg_id(user_id, thread_id): + await clear_interactive_msg(user_id, bot, thread_id) + + # Skip tool call notifications when CCBOT_SHOW_TOOL_CALLS=false + if not config.show_tool_calls and msg.content_type in ( + "tool_use", + "tool_result", + ): + continue + + parts = build_response_parts( + msg.text, + msg.is_complete, + msg.content_type, + msg.role, + ) + + if msg.is_complete: + # Enqueue content message task + # Note: tool_result editing is handled inside _process_content_task + # to ensure sequential processing with tool_use message sending + await enqueue_content_message( + bot=bot, + user_id=user_id, + window_id=window_id, + parts=parts, + tool_use_id=msg.tool_use_id, + content_type=msg.content_type, + text=msg.text, + thread_id=thread_id, + image_data=msg.image_data, + ) + + # Update user's read offset to current file position + # This marks these messages as "read" for this user + session = await session_manager.resolve_session_for_window(window_id) + if session and session.file_path: + try: + file_size = Path(session.file_path).stat().st_size + session_manager.update_user_window_offset( + user_id, window_id, file_size + ) + except OSError: + pass diff --git a/src/ccbot/handlers/screenshot_controls.py b/src/ccbot/handlers/screenshot_controls.py new file mode 100644 index 000000000..e98646731 --- /dev/null +++ b/src/ccbot/handlers/screenshot_controls.py @@ -0,0 +1,69 @@ +"""Screenshot control keyboard and key-send maps. + +Builds the inline keyboard shown under terminal screenshots and maps +compact key_ids to tmux key sequences for CB_KEYS_* callbacks. +""" + +from __future__ import annotations + +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +from .callback_data import ( + KeyCallback, + ScreenshotRefreshCallback, + safe_callback_data, +) + +# key_id → (tmux_key, enter, literal) +KEYS_SEND_MAP: dict[str, tuple[str, bool, bool]] = { + "up": ("Up", False, False), + "dn": ("Down", False, False), + "lt": ("Left", False, False), + "rt": ("Right", False, False), + "esc": ("Escape", False, False), + "ent": ("Enter", False, False), + "spc": ("Space", False, False), + "tab": ("Tab", False, False), + "cc": ("C-c", False, False), +} + +# key_id → display label (shown in callback answer toast) +KEY_LABELS: dict[str, str] = { + "up": "↑", + "dn": "↓", + "lt": "←", + "rt": "→", + "esc": "⎋ Esc", + "ent": "⏎ Enter", + "spc": "␣ Space", + "tab": "⇥ Tab", + "cc": "^C", +} + + +def build_screenshot_keyboard(window_id: str) -> InlineKeyboardMarkup: + """Build inline keyboard for screenshot: control keys + refresh.""" + + def btn(label: str, key_id: str) -> InlineKeyboardButton: + return InlineKeyboardButton( + label, + callback_data=safe_callback_data( + KeyCallback(key_id=key_id, window_id=window_id) + ), + ) + + return InlineKeyboardMarkup( + [ + [btn("␣ Space", "spc"), btn("↑", "up"), btn("⇥ Tab", "tab")], + [btn("←", "lt"), btn("↓", "dn"), btn("→", "rt")], + [btn("⎋ Esc", "esc"), btn("^C", "cc"), btn("⏎ Enter", "ent")], + [ + InlineKeyboardButton( + "🔄 Refresh", + callback_data=safe_callback_data( + ScreenshotRefreshCallback(window_id=window_id) + ), + ) + ], + ] + ) diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index c4de1c6ec..da5178d7e 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -26,12 +26,12 @@ from ..session import session_manager from ..terminal_parser import is_interactive_ui, parse_status_line from ..tmux_manager import tmux_manager +from .cleanup import clear_topic_state from .interactive_ui import ( clear_interactive_msg, get_interactive_window, handle_interactive_ui, ) -from .cleanup import clear_topic_state from .message_queue import enqueue_status_update, get_message_queue logger = logging.getLogger(__name__) @@ -129,7 +129,7 @@ async def status_poll_loop(bot: Bot) -> None: now = time.monotonic() if now - last_topic_check >= TOPIC_CHECK_INTERVAL: last_topic_check = now - for user_id, thread_id, wid in list( + for user_id, thread_id, window_id in list( session_manager.iter_thread_bindings() ): try: @@ -140,7 +140,7 @@ async def status_poll_loop(bot: Bot) -> None: except BadRequest as e: if "Topic_id_invalid" in str(e): # Topic deleted — kill window, unbind, and clean up state - w = await tmux_manager.find_window_by_id(wid) + w = await tmux_manager.find_window_by_id(window_id) if w: await tmux_manager.kill_window(w.window_id) session_manager.unbind_thread(user_id, thread_id) @@ -148,27 +148,29 @@ async def status_poll_loop(bot: Bot) -> None: logger.info( "Topic deleted: killed window_id '%s' and " "unbound thread %d for user %d", - wid, + window_id, thread_id, user_id, ) else: logger.debug( "Topic probe error for %s: %s", - wid, + window_id, e, ) except Exception as e: logger.debug( "Topic probe error for %s: %s", - wid, + window_id, e, ) - for user_id, thread_id, wid in list(session_manager.iter_thread_bindings()): + for user_id, thread_id, window_id in list( + session_manager.iter_thread_bindings() + ): try: # Clean up stale bindings (window no longer exists) - w = await tmux_manager.find_window_by_id(wid) + w = await tmux_manager.find_window_by_id(window_id) if not w: session_manager.unbind_thread(user_id, thread_id) await clear_topic_state(user_id, thread_id, bot) @@ -176,7 +178,7 @@ async def status_poll_loop(bot: Bot) -> None: "Cleaned up stale binding: user=%d thread=%d window_id=%s", user_id, thread_id, - wid, + window_id, ) continue @@ -189,7 +191,7 @@ async def status_poll_loop(bot: Bot) -> None: await update_status_message( bot, user_id, - wid, + window_id, thread_id=thread_id, skip_status=skip_status, ) diff --git a/src/ccbot/handlers/window_bind.py b/src/ccbot/handlers/window_bind.py new file mode 100644 index 000000000..ad4ede11b --- /dev/null +++ b/src/ccbot/handlers/window_bind.py @@ -0,0 +1,147 @@ +"""Create a tmux window and bind it to a Telegram forum topic. + +Shared by directory-confirm, session-new, and session-select callback flows. +""" + +from __future__ import annotations + +import logging + +from telegram.ext import ContextTypes + +from ..session import session_manager +from ..tmux_manager import tmux_manager +from .message_sender import safe_edit, safe_send + +logger = logging.getLogger(__name__) + + +async def create_and_bind_window( + query: object, + context: ContextTypes.DEFAULT_TYPE, + user: object, + selected_path: str, + pending_thread_id: int | None, + resume_session_id: str | None = None, +) -> None: + """Create a tmux window, bind it to a topic, and forward pending text. + + Shared by CB_DIR_CONFIRM (no sessions), CB_SESSION_NEW, and CB_SESSION_SELECT. + """ + from telegram import CallbackQuery, User + + assert isinstance(query, CallbackQuery) + assert isinstance(user, User) + + success, message, created_wname, created_wid = await tmux_manager.create_window( + selected_path, resume_session_id=resume_session_id + ) + if success: + logger.info( + "Window created: %s (id=%s) at %s (user=%d, thread=%s, resume=%s)", + created_wname, + created_wid, + selected_path, + user.id, + pending_thread_id, + resume_session_id, + ) + # Wait for Claude Code's SessionStart hook to register in session_map. + # Resume sessions take longer to start (loading session state), so use + # a longer timeout to avoid silently dropping messages. + hook_timeout = 15.0 if resume_session_id else 5.0 + hook_ok = await session_manager.wait_for_session_map_entry( + created_wid, timeout=hook_timeout + ) + + # --resume: messages keep writing to the resumed session's JSONL, and + # current Claude Code reports the original session_id in the + # SessionStart hook (source="resume"), so normally nothing to fix up. + # If the hook timed out or reported a different id (older CC versions), + # force both window_state AND session_map to the resumed id — + # session_map drives the monitor's watch list, and load_session_map() + # would revert a window_state-only override on the next poll cycle. + if resume_session_id: + ws = session_manager.get_window_state(created_wid) + if not hook_ok: + logger.warning( + "Hook timed out for resume window %s, " + "manually setting session_id=%s cwd=%s", + created_wid, + resume_session_id, + selected_path, + ) + session_manager.set_window_session( + created_wid, + resume_session_id, + cwd=str(selected_path), + window_name=created_wname, + ) + elif ws.session_id != resume_session_id: + logger.info( + "Resume override: window %s session_id %s -> %s", + created_wid, + ws.session_id, + resume_session_id, + ) + session_manager.set_window_session(created_wid, resume_session_id) + await session_manager.override_session_map_entry( + created_wid, + resume_session_id, + cwd=str(selected_path), + window_name=created_wname, + ) + + if pending_thread_id is not None: + # Thread bind flow: bind thread to newly created window + session_manager.bind_thread( + user.id, pending_thread_id, created_wid, window_name=created_wname + ) + + status = "Resumed" if resume_session_id else "Created" + await safe_edit( + query, + f"✅ {message}\n\n{status}. Send messages here.", + ) + + # Send pending text if any + pending_text = ( + context.user_data.get("_pending_thread_text") + if context.user_data + else None + ) + if pending_text: + logger.debug( + "Forwarding pending text to window %s (len=%d)", + created_wname, + len(pending_text), + ) + if context.user_data is not None: + context.user_data.pop("_pending_thread_text", None) + context.user_data.pop("_pending_thread_id", None) + send_ok, send_msg = await session_manager.send_to_window( + created_wid, + pending_text, + ) + if not send_ok: + logger.warning("Failed to forward pending text: %s", send_msg) + resolved_chat = session_manager.resolve_chat_id( + user.id, pending_thread_id + ) + await safe_send( + context.bot, + resolved_chat, + f"❌ Failed to send pending message: {send_msg}", + message_thread_id=pending_thread_id, + ) + elif context.user_data is not None: + context.user_data.pop("_pending_thread_id", None) + else: + # Should not happen in topic-only mode, but handle gracefully + await safe_edit(query, f"✅ {message}") + else: + await safe_edit(query, f"❌ {message}") + if pending_thread_id is not None and context.user_data is not None: + context.user_data.pop("_pending_thread_id", None) + context.user_data.pop("_pending_thread_text", None) + await query.answer("Created" if success else "Failed") diff --git a/src/ccbot/hook.py b/src/ccbot/hook.py index eaf9f4115..9fc96b351 100644 --- a/src/ccbot/hook.py +++ b/src/ccbot/hook.py @@ -12,7 +12,6 @@ """ import argparse -import fcntl import json import logging import os @@ -22,6 +21,8 @@ import sys from pathlib import Path +from .utils import LOCK_EX, LOCK_UN, flock + logger = logging.getLogger(__name__) # Validate session_id looks like a UUID @@ -190,7 +191,16 @@ def hook_main() -> None: # TMUX_PANE is set by tmux for every process inside a pane. pane_id = os.environ.get("TMUX_PANE", "") if not pane_id: - logger.warning("TMUX_PANE not set, cannot determine window") + # E.g. SessionStart:fork runs without TMUX_PANE; the monitor follows the + # fork via the transcript's continued-in record. Log enough to diagnose. + logger.warning( + "TMUX_PANE not set, cannot determine window " + "(session_id=%s, source=%s, cwd=%s, payload_keys=%s)", + session_id, + payload.get("source", ""), + cwd, + sorted(payload), + ) return result = subprocess.run( @@ -236,7 +246,7 @@ def hook_main() -> None: lock_path = map_file.with_suffix(".lock") try: with open(lock_path, "w") as lock_f: - fcntl.flock(lock_f, fcntl.LOCK_EX) + flock(lock_f, LOCK_EX) logger.debug("Acquired lock on %s", lock_path) try: session_map: dict[str, dict[str, str]] = {} @@ -271,6 +281,6 @@ def hook_main() -> None: cwd, ) finally: - fcntl.flock(lock_f, fcntl.LOCK_UN) + flock(lock_f, LOCK_UN) except OSError as e: logger.error("Failed to write session_map: %s", e) diff --git a/src/ccbot/main.py b/src/ccbot/main.py index 935bb2294..1713c144b 100644 --- a/src/ccbot/main.py +++ b/src/ccbot/main.py @@ -68,7 +68,27 @@ def main() -> None: from .bot import create_bot application = create_bot() - application.run_polling(allowed_updates=["message", "callback_query"]) + # Subscribe to a broader set of update types than the original + # ["message", "callback_query"]. Empirically, Telegram stopped delivering + # forum_topic_edited service messages (which arrive as `message` updates + # per the docs) when only "message" and "callback_query" were subscribed — + # observed silently dropped renames between May 15 and May 20, 2026, even + # though Telegram's own docs list forum_topic_edited as a Message field. + # Adding the broader set below restored delivery. Suspect cause: a + # server-side change where Telegram now treats minimal allowed_updates as + # a signal to suppress peripheral service messages. + application.run_polling( + allowed_updates=[ + "message", + "edited_message", + "channel_post", + "edited_channel_post", + "callback_query", + "my_chat_member", + "chat_member", + "chat_join_request", + ] + ) if __name__ == "__main__": diff --git a/src/ccbot/session.py b/src/ccbot/session.py index 6035909dd..c660fd78e 100644 --- a/src/ccbot/session.py +++ b/src/ccbot/session.py @@ -22,67 +22,33 @@ """ import asyncio -import fcntl import json import logging -import re +import shutil +import time +from collections.abc import Callable, Iterator from dataclasses import dataclass, field from pathlib import Path -from collections.abc import Callable, Iterator from typing import Any -import aiofiles - +from .binding_store import BindingStore from .config import config +from .session_map_repository import SessionMapRepository +from .session_migration import ( + apply_startup_state_migration, + cleanup_stale_session_map_entries, + is_window_id, + migrate_session_map_old_format, + state_needs_legacy_migration, +) from .tmux_manager import tmux_manager -from .transcript_parser import TranscriptParser +from .transcript_reader import ClaudeSession, TranscriptReader from .utils import atomic_write_json +from .window_state_store import WindowState, WindowStateStore logger = logging.getLogger(__name__) -@dataclass -class WindowState: - """Persistent state for a tmux window. - - Attributes: - session_id: Associated Claude session ID (empty if not yet detected) - cwd: Working directory for direct file path construction - window_name: Display name of the window - """ - - session_id: str = "" - cwd: str = "" - window_name: str = "" - - def to_dict(self) -> dict[str, Any]: - d: dict[str, Any] = { - "session_id": self.session_id, - "cwd": self.cwd, - } - if self.window_name: - d["window_name"] = self.window_name - return d - - @classmethod - def from_dict(cls, data: dict[str, Any]) -> "WindowState": - return cls( - session_id=data.get("session_id", ""), - cwd=data.get("cwd", ""), - window_name=data.get("window_name", ""), - ) - - -@dataclass -class ClaudeSession: - """Information about a Claude Code session.""" - - session_id: str - summary: str - message_count: int - file_path: str - - @dataclass class SessionManager: """Manages session state for Claude Code. @@ -97,333 +63,229 @@ class SessionManager: group_chat_ids: "user_id:thread_id" -> group chat_id (for supergroup routing) """ - window_states: dict[str, WindowState] = field(default_factory=dict) user_window_offsets: dict[int, dict[str, int]] = field(default_factory=dict) - thread_bindings: dict[int, dict[int, str]] = field(default_factory=dict) - # window_id -> display name (window_name) - window_display_names: dict[str, str] = field(default_factory=dict) - # "user_id:thread_id" -> group chat_id (for supergroup forum topic routing) - # IMPORTANT: This mapping is essential for supergroup/forum topic support. - # Telegram Bot API requires group chat_id (negative number like -100xxx) - # as the chat_id parameter when sending messages to forum topics. - # Using user_id as chat_id will fail with "Message thread not found". - # See: https://core.telegram.org/bots/api#sendmessage - # History: originally added in 5afc111, erroneously removed in 26cb81f, - # restored in PR #23. - group_chat_ids: dict[str, int] = field(default_factory=dict) + # thread_bindings and group_chat_ids are owned by BindingStore (ISS-005 4a), + # window_states and window_display_names by WindowStateStore (ISS-005 4b), + # session_map.json file access by SessionMapRepository (ISS-005 4c), + # transcript JSONL reads by TranscriptReader (ISS-005 4d); + # facade property delegates below keep backward-compatible access. + _bindings: BindingStore = field(default_factory=BindingStore, repr=False) + _window_store: WindowStateStore = field( + default_factory=WindowStateStore, repr=False + ) + _session_map: SessionMapRepository = field( + default_factory=SessionMapRepository, repr=False + ) + _transcripts: TranscriptReader = field(default_factory=TranscriptReader, repr=False) def __post_init__(self) -> None: self._load_state() + # --- BindingStore delegates (ISS-005 4a) --- + + @property + def thread_bindings(self) -> dict[int, dict[int, str]]: + """Thread bindings (user_id -> {thread_id -> window_id}), owned by BindingStore.""" + return self._bindings.thread_bindings + + @thread_bindings.setter + def thread_bindings(self, value: dict[int, dict[int, str]]) -> None: + self._bindings.thread_bindings = value + + @property + def group_chat_ids(self) -> dict[str, int]: + """Group chat IDs ("user_id:thread_id" -> chat_id), owned by BindingStore. + + IMPORTANT: This mapping is essential for supergroup/forum topic support. + Telegram Bot API requires group chat_id (negative number like -100xxx) + as the chat_id parameter when sending messages to forum topics. + Using user_id as chat_id will fail with "Message thread not found". + See: https://core.telegram.org/bots/api#sendmessage + History: originally added in 5afc111, erroneously removed in 26cb81f, + restored in PR #23. + """ + return self._bindings.group_chat_ids + + @group_chat_ids.setter + def group_chat_ids(self, value: dict[str, int]) -> None: + self._bindings.group_chat_ids = value + + # --- WindowStateStore delegates (ISS-005 4b) --- + + @property + def window_states(self) -> dict[str, WindowState]: + """Window states (window_id -> WindowState), owned by WindowStateStore.""" + return self._window_store.window_states + + @window_states.setter + def window_states(self, value: dict[str, WindowState]) -> None: + self._window_store.window_states = value + + @property + def window_display_names(self) -> dict[str, str]: + """Display names (window_id -> window_name), owned by WindowStateStore.""" + return self._window_store.window_display_names + + @window_display_names.setter + def window_display_names(self, value: dict[str, str]) -> None: + self._window_store.window_display_names = value + def _save_state(self) -> None: + bindings_state = self._bindings.to_state() + window_state = self._window_store.to_state() state: dict[str, Any] = { - "window_states": {k: v.to_dict() for k, v in self.window_states.items()}, + "window_states": window_state["window_states"], "user_window_offsets": { str(uid): offsets for uid, offsets in self.user_window_offsets.items() }, - "thread_bindings": { - str(uid): {str(tid): wid for tid, wid in bindings.items()} - for uid, bindings in self.thread_bindings.items() - }, - "window_display_names": self.window_display_names, - "group_chat_ids": self.group_chat_ids, + "thread_bindings": bindings_state["thread_bindings"], + "window_display_names": window_state["window_display_names"], + "group_chat_ids": bindings_state["group_chat_ids"], } + self._backup_valid_state_file() atomic_write_json(config.state_file, state) logger.debug("State saved to %s", config.state_file) + def _backup_valid_state_file(self) -> None: + """Keep the previous valid state.json as state.json.bak before overwriting. + + Only a file that parses to a JSON object is copied, so a damaged file + can never replace the last good backup. + """ + path = config.state_file + try: + if not path.exists(): + return + if not isinstance(json.loads(path.read_text()), dict): + return + shutil.copy2(path, path.with_name(f"{path.name}.bak")) + except (OSError, ValueError) as e: + logger.debug("State backup skipped: %s", e) + def _is_window_id(self, key: str) -> bool: """Check if a key looks like a tmux window ID (e.g. '@0', '@12').""" - return key.startswith("@") and len(key) > 1 and key[1:].isdigit() + return is_window_id(key) + + def _apply_state_file(self, path: Path) -> None: + """Parse `path` and apply it to the stores; raises on any bad content.""" + state = json.loads(path.read_text()) + if not isinstance(state, dict): + raise ValueError( + f"state root must be an object, got {type(state).__name__}" + ) + self._window_store.load_state(state) + self.user_window_offsets = { + int(uid): offsets + for uid, offsets in state.get("user_window_offsets", {}).items() + } + self._bindings.load_state(state) + + def _reset_state(self) -> None: + self._window_store.reset() + self.user_window_offsets = {} + self._bindings.reset() def _load_state(self) -> None: """Load state synchronously during initialization. Detects old-format state (window_name keys without '@' prefix) and - marks for migration on next startup re-resolution. + logs that startup re-resolution will migrate them once to @id keys. + + A damaged state.json (bad JSON or valid JSON of the wrong shape) never + aborts startup: the file is preserved as state.json.corrupt-, then + state.json.bak (last valid state) is tried, else state starts empty. """ - if config.state_file.exists(): + path = config.state_file + if not path.exists(): + return + load_errors = ( + json.JSONDecodeError, + ValueError, + TypeError, + AttributeError, + KeyError, + ) + try: + self._apply_state_file(path) + except load_errors as e: + self._reset_state() + corrupt = path.with_name(f"{path.name}.corrupt-{int(time.time())}") try: - state = json.loads(config.state_file.read_text()) - self.window_states = { - k: WindowState.from_dict(v) - for k, v in state.get("window_states", {}).items() - } - self.user_window_offsets = { - int(uid): offsets - for uid, offsets in state.get("user_window_offsets", {}).items() - } - self.thread_bindings = { - int(uid): {int(tid): wid for tid, wid in bindings.items()} - for uid, bindings in state.get("thread_bindings", {}).items() - } - self.window_display_names = state.get("window_display_names", {}) - self.group_chat_ids = { - k: int(v) for k, v in state.get("group_chat_ids", {}).items() - } + shutil.copy2(path, corrupt) + except OSError as copy_err: + logger.error("Could not preserve damaged state file: %s", copy_err) + corrupt = None + logger.error( + "Failed to load state (%s: %s); damaged file preserved as %s", + type(e).__name__, + e, + corrupt, + ) + backup = path.with_name(f"{path.name}.bak") + if backup.exists(): + try: + self._apply_state_file(backup) + logger.warning("Restored state from %s", backup) + except load_errors as be: + logger.error("Backup %s is unusable: %s", backup, be) + self._reset_state() + return - # Detect old format: keys that don't look like window IDs - needs_migration = False - for k in self.window_states: - if not self._is_window_id(k): - needs_migration = True - break - if not needs_migration: - for bindings in self.thread_bindings.values(): - for wid in bindings.values(): - if not self._is_window_id(wid): - needs_migration = True - break - if needs_migration: - break - - if needs_migration: - logger.info( - "Detected old-format state (window_name keys), " - "will re-resolve on startup" - ) - pass - - except (json.JSONDecodeError, ValueError) as e: - logger.warning("Failed to load state: %s", e) - self.window_states = {} - self.user_window_offsets = {} - self.thread_bindings = {} - self.window_display_names = {} - self.group_chat_ids = {} - pass + if state_needs_legacy_migration(self.window_states, self.thread_bindings): + logger.info( + "Detected old-format state (window_name keys), " + "will re-resolve on startup" + ) async def resolve_stale_ids(self) -> None: """Re-resolve persisted window IDs against live tmux windows. - Called on startup. Handles two cases: - 1. Old-format migration: window_name keys → window_id keys - 2. Stale IDs: window_id no longer exists but display name matches a live window - - Builds {window_name: window_id} from live windows, then remaps or drops entries. + Thin orchestrator: lists live windows, applies pure + :func:`apply_startup_state_migration`, persists, then cleans + session_map.json. Canonical routing keys are always ``@N``. """ windows = await tmux_manager.list_windows() - live_by_name: dict[str, str] = {} # window_name -> window_id + live_by_name: dict[str, str] = {} live_ids: set[str] = set() for w in windows: live_by_name[w.window_name] = w.window_id live_ids.add(w.window_id) - # Snapshot old_id -> display_name BEFORE any mutation: the loops below - # rewrite window_display_names as they go, and thread_bindings / - # user_window_offsets must still resolve stale IDs against the old view. - old_names: dict[str, str] = dict(self.window_display_names) - for key, ws in self.window_states.items(): - if ws.window_name and key not in old_names: - old_names[key] = ws.window_name - - changed = False - - # --- Migrate window_states --- - new_window_states: dict[str, WindowState] = {} - for key, ws in self.window_states.items(): - if self._is_window_id(key): - if key in live_ids: - new_window_states[key] = ws - else: - # Stale ID — try re-resolve by display name - display = old_names.get(key, key) - new_id = live_by_name.get(display) - if new_id: - logger.info( - "Re-resolved stale window_id %s -> %s (name=%s)", - key, - new_id, - display, - ) - new_window_states[new_id] = ws - ws.window_name = display - self.window_display_names[new_id] = display - self.window_display_names.pop(key, None) - changed = True - else: - logger.info( - "Dropping stale window_state: %s (name=%s)", key, display - ) - changed = True - else: - # Old format: key is window_name - new_id = live_by_name.get(key) - if new_id: - logger.info("Migrating window_state key %s -> %s", key, new_id) - ws.window_name = key - new_window_states[new_id] = ws - self.window_display_names[new_id] = key - changed = True - else: - logger.info( - "Dropping old-format window_state: %s (no live window)", key - ) - changed = True - self.window_states = new_window_states - - # --- Migrate thread_bindings --- - for uid, bindings in self.thread_bindings.items(): - new_bindings: dict[int, str] = {} - for tid, val in bindings.items(): - if self._is_window_id(val): - if val in live_ids: - new_bindings[tid] = val - else: - display = old_names.get(val, val) - new_id = live_by_name.get(display) - if new_id: - logger.info( - "Re-resolved thread binding %s -> %s (name=%s)", - val, - new_id, - display, - ) - new_bindings[tid] = new_id - self.window_display_names[new_id] = display - changed = True - else: - logger.info( - "Dropping stale thread binding: user=%d, thread=%d, wid=%s", - uid, - tid, - val, - ) - changed = True - else: - # Old format: val is window_name - new_id = live_by_name.get(val) - if new_id: - logger.info("Migrating thread binding %s -> %s", val, new_id) - new_bindings[tid] = new_id - self.window_display_names[new_id] = val - changed = True - else: - logger.info( - "Dropping old-format thread binding: user=%d, thread=%d, name=%s", - uid, - tid, - val, - ) - changed = True - self.thread_bindings[uid] = new_bindings - - # Remove empty user entries - empty_users = [uid for uid, b in self.thread_bindings.items() if not b] - for uid in empty_users: - del self.thread_bindings[uid] - - # --- Migrate user_window_offsets --- - for uid, offsets in self.user_window_offsets.items(): - new_offsets: dict[str, int] = {} - for key, offset in offsets.items(): - if self._is_window_id(key): - if key in live_ids: - new_offsets[key] = offset - else: - display = old_names.get(key, key) - new_id = live_by_name.get(display) - if new_id: - new_offsets[new_id] = offset - changed = True - else: - changed = True - else: - new_id = live_by_name.get(key) - if new_id: - new_offsets[new_id] = offset - changed = True - else: - changed = True - self.user_window_offsets[uid] = new_offsets + result = apply_startup_state_migration( + window_states=self.window_states, + thread_bindings=self.thread_bindings, + user_window_offsets=self.user_window_offsets, + window_display_names=self.window_display_names, + live_by_name=live_by_name, + live_ids=live_ids, + ) + self.window_states = result.window_states # type: ignore[assignment] + self.thread_bindings = result.thread_bindings + self.user_window_offsets = result.user_window_offsets + self.window_display_names = result.window_display_names - if changed: + if result.changed: self._save_state() logger.info("Startup re-resolution complete") - # Clean up session_map.json: stale window IDs, migrate old-format keys await self._cleanup_stale_session_map_entries(live_ids) await self._migrate_old_format_session_map_keys(live_by_name) def _migrate_old_format_map( self, session_map: dict[str, dict], live_by_name: dict[str, str] ) -> bool: - """Migrate old-format session_map keys to the @window_id form in place. - - Old hook versions keyed session_map by window_name (e.g. "ccbot:ccmux") - instead of window_id ("ccbot:@4"). Such keys are invisible to the - window_id-based delivery path (load_session_map skips them), which - silently drops inbound messages. This resolves each old-format key's - window_name against live tmux windows and rewrites it to the @window_id - form, preserving session_id/cwd and backfilling window_name. Keys with - no matching live window are dropped as orphans; if the @window_id key - already exists it wins and the old-format one is discarded. - - Mutates session_map in place. Returns True if anything changed. - """ - prefix = f"{config.tmux_session_name}:" - old_keys = [ - key - for key in session_map - if key.startswith(prefix) and not self._is_window_id(key[len(prefix) :]) - ] - changed = False - for key in old_keys: - window_name = key[len(prefix) :] - info = session_map.pop(key) - changed = True - new_id = live_by_name.get(window_name) - if not new_id: - logger.info("Dropping orphan old-format session_map key: %s", key) - continue - new_key = f"{prefix}{new_id}" - if new_key in session_map: - logger.info( - "Discarding old-format session_map key %s (superseded by %s)", - key, - new_key, - ) - continue - info.setdefault("window_name", window_name) - session_map[new_key] = info - logger.info("Migrated old-format session_map key %s -> %s", key, new_key) - return changed + """Migrate old-format session_map keys to @window_id (delegates to pure helper).""" + return migrate_session_map_old_format( + session_map, + live_by_name, + f"{config.tmux_session_name}:", + ) def _mutate_session_map_locked( self, mutate: Callable[[dict[str, dict]], bool] ) -> bool: - """Read-modify-write session_map.json under the same flock the hook uses. - - The SessionStart hook serializes its writes via session_map.lock; - any bot-side read-modify-write MUST take the same lock or it can - overwrite a concurrent hook write (lost update). Synchronous — - call via asyncio.to_thread from async code. - - Returns True if `mutate` reported changes and the file was rewritten. - """ - map_file = config.session_map_file - lock_path = map_file.with_suffix(".lock") - try: - with open(lock_path, "w") as lock_f: - fcntl.flock(lock_f, fcntl.LOCK_EX) - try: - session_map: dict[str, dict] = {} - if map_file.exists(): - try: - session_map = json.loads(map_file.read_text()) - except (json.JSONDecodeError, OSError): - logger.warning( - "Unreadable session_map.json, skipping mutation" - ) - return False - if not mutate(session_map): - return False - atomic_write_json(map_file, session_map) - return True - finally: - fcntl.flock(lock_f, fcntl.LOCK_UN) - except OSError as e: - logger.error("Failed to update session_map.json: %s", e) - return False + """Read-modify-write session_map.json under the hook's flock (ISS-005 4c).""" + return self._session_map.mutate_locked(mutate) async def override_session_map_entry( self, window_id: str, session_id: str, cwd: str = "", window_name: str = "" @@ -454,6 +316,25 @@ def mutate(session_map: dict[str, dict]) -> bool: if await asyncio.to_thread(self._mutate_session_map_locked, mutate): logger.info("session_map override: %s -> session_id=%s", key, session_id) + async def rename_session_map_window(self, window_id: str, new_name: str) -> None: + """Write a renamed window's display name into its session_map entry. + + load_session_map() copies ``window_name`` from session_map into + window_display_names on every poll; the hook only writes it at session + start, so without this a topic rename is reverted to the old name. + """ + key = f"{config.tmux_session_name}:{window_id}" + + def mutate(session_map: dict[str, dict]) -> bool: + info = session_map.get(key) + if info is None or info.get("window_name") == new_name: + return False + info["window_name"] = new_name + return True + + if await asyncio.to_thread(self._mutate_session_map_locked, mutate): + logger.info("session_map rename: %s -> window_name=%s", key, new_name) + async def _migrate_old_format_session_map_keys( self, live_by_name: dict[str, str] ) -> None: @@ -480,17 +361,7 @@ async def _cleanup_stale_session_map_entries(self, live_ids: set[str]) -> None: prefix = f"{config.tmux_session_name}:" def mutate(session_map: dict[str, dict]) -> bool: - stale_keys = [ - key - for key in session_map - if key.startswith(prefix) - and self._is_window_id(key[len(prefix) :]) - and key[len(prefix) :] not in live_ids - ] - for key in stale_keys: - del session_map[key] - logger.info("Removed stale session_map entry: %s", key) - return bool(stale_keys) + return cleanup_stale_session_map_entries(session_map, live_ids, prefix) if await asyncio.to_thread(self._mutate_session_map_locked, mutate): logger.info( @@ -501,14 +372,11 @@ def mutate(session_map: dict[str, dict]) -> bool: def get_display_name(self, window_id: str) -> str: """Get display name for a window_id, fallback to window_id itself.""" - return self.window_display_names.get(window_id, window_id) + return self._window_store.get_display_name(window_id) def update_display_name(self, window_id: str, new_name: str) -> None: """Update the display name for a window and persist state.""" - self.window_display_names[window_id] = new_name - # Also update WindowState.window_name if it exists - if window_id in self.window_states: - self.window_states[window_id].window_name = new_name + self._window_store.update_display_name(window_id, new_name) self._save_state() logger.info("Updated display name: window_id %s -> '%s'", window_id, new_name) @@ -528,10 +396,7 @@ def set_group_chat_id( Without it, all outbound messages in forum topics fail with "Message thread not found". See commit history: 5afc111 → 26cb81f → PR #23. """ - tid = thread_id or 0 - key = f"{user_id}:{tid}" - if self.group_chat_ids.get(key) != chat_id: - self.group_chat_ids[key] = chat_id + if self._bindings.set_group_chat_id(user_id, thread_id, chat_id): self._save_state() logger.debug( "Stored group chat_id: user=%d, thread=%s, chat_id=%d", @@ -551,12 +416,7 @@ def resolve_chat_id(self, user_id: int, thread_id: int | None = None) -> int: this method instead of raw user_id. Using user_id directly breaks supergroup forum topic routing. """ - if thread_id is not None: - key = f"{user_id}:{thread_id}" - group_id = self.group_chat_ids.get(key) - if group_id is not None: - return group_id - return user_id + return self._bindings.resolve_chat_id(user_id, thread_id) async def wait_for_session_map_entry( self, window_id: str, timeout: float = 5.0, interval: float = 0.5 @@ -573,21 +433,14 @@ async def wait_for_session_map_entry( key = f"{config.tmux_session_name}:{window_id}" deadline = asyncio.get_event_loop().time() + timeout while asyncio.get_event_loop().time() < deadline: - try: - if config.session_map_file.exists(): - async with aiofiles.open(config.session_map_file, "r") as f: - content = await f.read() - session_map = json.loads(content) - info = session_map.get(key, {}) - if info.get("session_id"): - # Found — load into window_states immediately - logger.debug( - "session_map entry found for window_id %s", window_id - ) - await self.load_session_map() - return True - except (json.JSONDecodeError, OSError): - pass + session_map = await self._session_map.read() + if session_map is not None: + info = session_map.get(key, {}) + if info.get("session_id"): + # Found — load into window_states immediately + logger.debug("session_map entry found for window_id %s", window_id) + await self.load_session_map() + return True await asyncio.sleep(interval) logger.warning( "Timed out waiting for session_map entry: window_id=%s", window_id @@ -602,13 +455,8 @@ async def load_session_map(self) -> None: Also cleans up window_states entries not in current session_map. Updates window_display_names from the "window_name" field in values. """ - if not config.session_map_file.exists(): - return - try: - async with aiofiles.open(config.session_map_file, "r") as f: - content = await f.read() - session_map = json.loads(content) - except (json.JSONDecodeError, OSError): + session_map = await self._session_map.read() + if session_map is None: return prefix = f"{config.tmux_session_name}:" @@ -625,7 +473,7 @@ async def load_session_map(self) -> None: windows = await tmux_manager.list_windows() live_by_name = {w.window_name: w.window_id for w in windows} if self._migrate_old_format_map(session_map, live_by_name): - atomic_write_json(config.session_map_file, session_map) + self._session_map.write(session_map) logger.info("Migrated old-format session_map keys during load") valid_wids: set[str] = set() @@ -664,9 +512,9 @@ async def load_session_map(self) -> None: # Clean up window_states entries not in current session_map. stale_wids = [w for w in self.window_states if w and w not in valid_wids] - for wid in stale_wids: - logger.info("Removing stale window_state: %s", wid) - del self.window_states[wid] + for window_id in stale_wids: + logger.info("Removing stale window_state: %s", window_id) + del self.window_states[window_id] changed = True if changed: @@ -676,88 +524,27 @@ async def load_session_map(self) -> None: def get_window_state(self, window_id: str) -> WindowState: """Get or create window state.""" - if window_id not in self.window_states: - self.window_states[window_id] = WindowState() - return self.window_states[window_id] + return self._window_store.get_window_state(window_id) def clear_window_session(self, window_id: str) -> None: """Clear session association for a window (e.g., after /clear command).""" - state = self.get_window_state(window_id) - state.session_id = "" + self._window_store.clear_window_session(window_id) self._save_state() logger.info("Cleared session for window_id %s", window_id) - @staticmethod - def _encode_cwd(cwd: str) -> str: - """Encode a cwd path to match Claude Code's project directory naming. + def set_window_session( + self, window_id: str, session_id: str, cwd: str = "", window_name: str = "" + ) -> None: + """Set a window's session association and persist state (ISS-009). - Replaces all non-alphanumeric characters (except dash) with dashes. - E.g. /home/user_name/Code/project -> -home-user-name-Code-project + Public write API for the window→session mapping; non-empty + ``cwd``/``window_name`` update the corresponding fields. + ``_save_state`` stays private. """ - return re.sub(r"[^a-zA-Z0-9-]", "-", cwd) - - def _build_session_file_path(self, session_id: str, cwd: str) -> Path | None: - """Build the direct file path for a session from session_id and cwd.""" - if not session_id or not cwd: - return None - encoded_cwd = self._encode_cwd(cwd) - return config.claude_projects_path / encoded_cwd / f"{session_id}.jsonl" - - async def _get_session_direct( - self, session_id: str, cwd: str - ) -> ClaudeSession | None: - """Get a ClaudeSession directly from session_id and cwd (no scanning).""" - file_path = self._build_session_file_path(session_id, cwd) - - # Fallback: glob search if direct path doesn't exist - if not file_path or not file_path.exists(): - pattern = f"*/{session_id}.jsonl" - matches = list(config.claude_projects_path.glob(pattern)) - if matches: - file_path = matches[0] - logger.debug("Found session via glob: %s", file_path) - else: - return None - - # Single pass: read file once, extract summary + count messages - summary = "" - last_user_msg = "" - message_count = 0 - try: - async with aiofiles.open(file_path, "r", encoding="utf-8") as f: - async for line in f: - line = line.strip() - if not line: - continue - message_count += 1 - try: - data = json.loads(line) - # Check for summary - if data.get("type") == "summary": - s = data.get("summary", "") - if s: - summary = s - # Track last user message as fallback - elif TranscriptParser.is_user_message(data): - parsed = TranscriptParser.parse_message(data) - if parsed and parsed.text.strip(): - last_user_msg = parsed.text.strip() - except json.JSONDecodeError: - continue - except OSError: - return None - - if not summary: - summary = last_user_msg[:50] if last_user_msg else "Untitled" - - return ClaudeSession( - session_id=session_id, - summary=summary, - message_count=message_count, - file_path=str(file_path), - ) + self._window_store.set_window_session(window_id, session_id, cwd, window_name) + self._save_state() - # --- Directory session listing --- + # --- Directory session listing (ISS-005 4d delegate) --- async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]: """List existing Claude sessions for a directory. @@ -768,30 +555,7 @@ async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]: Returns a list sorted by mtime (most recent first), capped at 10. """ - encoded_cwd = self._encode_cwd(cwd) - project_dir = config.claude_projects_path / encoded_cwd - if not project_dir.is_dir(): - return [] - - # Collect JSONL files sorted by mtime (newest first) - jsonl_files = sorted( - project_dir.glob("*.jsonl"), - key=lambda p: p.stat().st_mtime, - reverse=True, - ) - - # Skip sessions-index and cap at 10 - sessions: list[ClaudeSession] = [] - for f in jsonl_files: - if f.stem == "sessions-index": - continue - if len(sessions) >= 10: - break - session_id = f.stem - session = await self._get_session_direct(session_id, cwd) - if session and session.message_count > 0: - sessions.append(session) - return sessions + return await self._transcripts.list_sessions_for_directory(cwd) # --- Window → Session resolution --- @@ -806,7 +570,9 @@ async def resolve_session_for_window(self, window_id: str) -> ClaudeSession | No if not state.session_id or not state.cwd: return None - session = await self._get_session_direct(state.session_id, state.cwd) + session = await self._transcripts.get_session_direct( + state.session_id, state.cwd + ) if session: return session @@ -846,9 +612,7 @@ def bind_thread( window_id: Tmux window ID (e.g. '@0') window_name: Display name for the window (optional) """ - if user_id not in self.thread_bindings: - self.thread_bindings[user_id] = {} - self.thread_bindings[user_id][thread_id] = window_id + self._bindings.bind_thread(user_id, thread_id, window_id) if window_name: self.window_display_names[window_id] = window_name self._save_state() @@ -863,12 +627,9 @@ def bind_thread( def unbind_thread(self, user_id: int, thread_id: int) -> str | None: """Remove a thread binding. Returns the previously bound window_id, or None.""" - bindings = self.thread_bindings.get(user_id) - if not bindings or thread_id not in bindings: + window_id = self._bindings.unbind_thread(user_id, thread_id) + if window_id is None: return None - window_id = bindings.pop(thread_id) - if not bindings: - del self.thread_bindings[user_id] self._save_state() logger.info( "Unbound thread %d (was %s) for user %d", @@ -880,10 +641,7 @@ def unbind_thread(self, user_id: int, thread_id: int) -> str | None: def get_window_for_thread(self, user_id: int, thread_id: int) -> str | None: """Look up the window_id bound to a thread.""" - bindings = self.thread_bindings.get(user_id) - if not bindings: - return None - return bindings.get(thread_id) + return self._bindings.get_window_for_thread(user_id, thread_id) def resolve_window_for_thread( self, @@ -904,9 +662,7 @@ def iter_thread_bindings(self) -> Iterator[tuple[int, int, str]]: Provides encapsulated access to thread_bindings without exposing the internal data structure directly. """ - for user_id, bindings in self.thread_bindings.items(): - for thread_id, window_id in bindings.items(): - yield user_id, thread_id, window_id + yield from self._bindings.iter_thread_bindings() async def find_users_for_session( self, @@ -965,47 +721,9 @@ async def get_recent_messages( if not session or not session.file_path: return [], 0 - file_path = Path(session.file_path) - if not file_path.exists(): - return [], 0 - - # Read JSONL entries (optionally filtered by byte range) - entries: list[dict] = [] - try: - async with aiofiles.open(file_path, "r", encoding="utf-8") as f: - if start_byte > 0: - await f.seek(start_byte) - - while True: - # Check byte limit before reading - if end_byte is not None: - current_pos = await f.tell() - if current_pos >= end_byte: - break - - line = await f.readline() - if not line: - break - - data = TranscriptParser.parse_line(line) - if data: - entries.append(data) - except OSError as e: - logger.error("Error reading session file %s: %s", file_path, e) - return [], 0 - - parsed_entries, _ = TranscriptParser.parse_entries(entries) - all_messages = [ - { - "role": e.role, - "text": e.text, - "content_type": e.content_type, - "timestamp": e.timestamp, - } - for e in parsed_entries - ] - - return all_messages, len(all_messages) + return await self._transcripts.read_recent_messages( + session.file_path, start_byte=start_byte, end_byte=end_byte + ) session_manager = SessionManager() diff --git a/src/ccbot/session_guard.py b/src/ccbot/session_guard.py new file mode 100644 index 000000000..9da063951 --- /dev/null +++ b/src/ccbot/session_guard.py @@ -0,0 +1,215 @@ +"""Shared Telegram auth + topic→window session resolution for bot handlers. + +Extracts the repeated pattern from bot.py: + authorize user → resolve thread → resolve window_id → find live tmux window. + +Key types: + - SessionContext: authorized user + topic + window binding for a request + - require_user: auth only + - require_bound_window_id: auth + window_id (no live tmux check) + - require_session: compose bound id + live TmuxWindow + optional unbind +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from telegram import Update, User +from telegram.error import RetryAfter, TelegramError + +from .config import config +from .handlers.message_sender import safe_reply +from .session import session_manager +from .tmux_manager import TmuxWindow, tmux_manager + +logger = logging.getLogger(__name__) + +# Unified UX strings (ISS-007). Handlers should not override without cause. +DEFAULT_UNAUTHORIZED_TEXT = "You are not authorized to use this bot." +DEFAULT_NO_SESSION_TEXT = "No active session. Use /start to bind a topic." +DEFAULT_MISSING_WINDOW_TEXT = "Window not found. Re-binding..." + +# Back-compat aliases used by require_* defaults and older call sites. +DEFAULT_UNAUTHORIZED = DEFAULT_UNAUTHORIZED_TEXT +DEFAULT_NO_SESSION = DEFAULT_NO_SESSION_TEXT +DEFAULT_MISSING_WINDOW = DEFAULT_MISSING_WINDOW_TEXT + + +@dataclass(frozen=True, slots=True) +class SessionContext: + """Resolved session binding for a single Telegram update.""" + + user: User + thread_id: int | None + window_id: str + window: TmuxWindow + + +def is_user_allowed(user_id: int | None) -> bool: + """Return True if user_id is present and allowed by config.""" + return user_id is not None and config.is_user_allowed(user_id) + + +def get_thread_id(update: Update) -> int | None: + """Extract thread_id from an update; None if not in a named topic. + + Topic id ``1`` (General) is treated as non-topic (topic-only architecture). + """ + msg = update.message or ( + update.callback_query.message if update.callback_query else None + ) + if msg is None: + return None + thread_id = getattr(msg, "message_thread_id", None) + if thread_id is None or thread_id == 1: + return None + return thread_id + + +async def require_user( + update: Update, + *, + reply_unauthorized: bool = True, + unauthorized_text: str = DEFAULT_UNAUTHORIZED_TEXT, + callback_unauthorized_text: str = "Not authorized", +) -> User | None: + """Authorize the effective user or notify and return None.""" + user = update.effective_user + if user and is_user_allowed(user.id): + return user + + if not reply_unauthorized: + return None + + if update.callback_query is not None: + try: + await update.callback_query.answer(callback_unauthorized_text) + except RetryAfter: + raise + except TelegramError as exc: + logger.warning("callback answer unauthorized failed: %s", exc) + return None + + if update.message is not None: + try: + await safe_reply(update.message, unauthorized_text) + except RetryAfter: + raise + except TelegramError as exc: + logger.warning("safe_reply unauthorized failed: %s", exc) + return None + + +async def require_bound_window_id( + update: Update, + *, + reply_unauthorized: bool = False, + require_message: bool = True, + use_resolve: bool = True, + no_session_text: str = DEFAULT_NO_SESSION_TEXT, + user: User | None = None, +) -> tuple[User, int | None, str] | None: + """Auth + resolve window_id for the topic (no live tmux window required).""" + if user is None: + user = await require_user(update, reply_unauthorized=reply_unauthorized) + if user is None: + return None + if require_message and update.message is None: + return None + + thread_id = get_thread_id(update) + if use_resolve: + window_id = session_manager.resolve_window_for_thread(user.id, thread_id) + else: + if thread_id is None: + window_id = None + else: + window_id = session_manager.get_window_for_thread(user.id, thread_id) + + if not window_id: + if update.message is not None and no_session_text: + try: + await safe_reply(update.message, no_session_text) + except RetryAfter: + raise + except TelegramError as exc: + logger.warning("safe_reply no_session failed: %s", exc) + return None + + return user, thread_id, window_id + + +async def require_session( + update: Update, + *, + reply_unauthorized: bool = False, + require_message: bool = True, + use_resolve: bool = True, + require_named_topic: bool = False, + named_topic_text: str = ( + "❌ Please use a named topic. Create a new topic to start a session." + ), + no_session_text: str = DEFAULT_NO_SESSION_TEXT, + missing_window_text: str = DEFAULT_MISSING_WINDOW_TEXT, + unbind_if_missing: bool = False, + user: User | None = None, +) -> SessionContext | None: + """Full auth + window resolve + live tmux window lookup. + + Composes :func:`require_bound_window_id` with live window lookup and + optional unbind. On failure, sends the appropriate user-facing message + (when configured) and returns None. + """ + # Named-topic guard is require_session-only; keep auth→message order. + if require_named_topic: + if user is None: + user = await require_user(update, reply_unauthorized=reply_unauthorized) + if user is None: + return None + if require_message and update.message is None: + return None + if get_thread_id(update) is None: + if update.message is not None: + try: + await safe_reply(update.message, named_topic_text) + except RetryAfter: + raise + except TelegramError as exc: + logger.warning("safe_reply named_topic failed: %s", exc) + return None + + bound = await require_bound_window_id( + update, + reply_unauthorized=reply_unauthorized, + require_message=require_message, + use_resolve=use_resolve, + no_session_text=no_session_text, + user=user, + ) + if bound is None: + return None + + user, thread_id, window_id = bound + + window = await tmux_manager.find_window_by_id(window_id) + if not window: + display = session_manager.get_display_name(window_id) + if unbind_if_missing and thread_id is not None: + session_manager.unbind_thread(user.id, thread_id) + if update.message is not None and missing_window_text: + text = missing_window_text.format(display=display, window_id=window_id) + try: + await safe_reply(update.message, text) + except RetryAfter: + raise + except TelegramError as exc: + logger.warning("safe_reply missing_window failed: %s", exc) + return None + + return SessionContext( + user=user, + thread_id=thread_id, + window_id=window_id, + window=window, + ) diff --git a/src/ccbot/session_map_repository.py b/src/ccbot/session_map_repository.py new file mode 100644 index 000000000..c62ec4294 --- /dev/null +++ b/src/ccbot/session_map_repository.py @@ -0,0 +1,88 @@ +"""session_map.json repository (ISS-005 phase 4c). + +Owns file access to ``session_map.json`` (written by the SessionStart +hook): async reads, atomic writes, and the flock read-modify-write +discipline shared with the hook. Applying map data to window state +stores stays with the ``SessionManager`` facade. Config paths are +resolved dynamically so test fixtures that patch them keep working. +""" + +import json +import logging +from collections.abc import Callable +from pathlib import Path + +import aiofiles + +from .config import config +from .utils import LOCK_EX, LOCK_UN, atomic_write_json, flock + +logger = logging.getLogger(__name__) + + +class SessionMapRepository: + """File access to session_map.json with the hook's flock discipline (ISS-005 4c).""" + + @property + def map_file(self) -> Path: + """session_map.json path (read dynamically: tests patch config).""" + return config.session_map_file + + @property + def prefix(self) -> str: + """Entry key prefix: "tmux_session:".""" + return f"{config.tmux_session_name}:" + + def exists(self) -> bool: + """Whether session_map.json exists.""" + return self.map_file.exists() + + async def read(self) -> dict[str, dict] | None: + """Read session_map.json; None if missing or unreadable.""" + if not self.map_file.exists(): + return None + try: + async with aiofiles.open(self.map_file, "r") as f: + content = await f.read() + return json.loads(content) + except (json.JSONDecodeError, OSError): + return None + + def write(self, session_map: dict[str, dict]) -> None: + """Atomically rewrite session_map.json.""" + atomic_write_json(self.map_file, session_map) + + def mutate_locked(self, mutate: Callable[[dict[str, dict]], bool]) -> bool: + """Read-modify-write session_map.json under the same flock the hook uses. + + The SessionStart hook serializes its writes via session_map.lock; + any bot-side read-modify-write MUST take the same lock or it can + overwrite a concurrent hook write (lost update). Synchronous — + call via asyncio.to_thread from async code. + + Returns True if `mutate` reported changes and the file was rewritten. + """ + map_file = self.map_file + lock_path = map_file.with_suffix(".lock") + try: + with open(lock_path, "w") as lock_f: + flock(lock_f, LOCK_EX) + try: + session_map: dict[str, dict] = {} + if map_file.exists(): + try: + session_map = json.loads(map_file.read_text()) + except (json.JSONDecodeError, OSError): + logger.warning( + "Unreadable session_map.json, skipping mutation" + ) + return False + if not mutate(session_map): + return False + atomic_write_json(map_file, session_map) + return True + finally: + flock(lock_f, LOCK_UN) + except OSError as e: + logger.error("Failed to update session_map.json: %s", e) + return False diff --git a/src/ccbot/session_migration.py b/src/ccbot/session_migration.py new file mode 100644 index 000000000..df9dc953f --- /dev/null +++ b/src/ccbot/session_migration.py @@ -0,0 +1,288 @@ +"""Startup migration / stale window-id re-resolution (REF-004). + +## Authoritative state model (topic-only) + +| Store | File | Authority | Notes | +|-------|------|-----------|-------| +| **thread_bindings** | ``state.json`` | Topic → window @id | Routing key for inbound/outbound Telegram | +| **window_states** | ``state.json`` | window @id → Claude session + cwd | Filled/updated from session_map | +| **window_display_names** | ``state.json`` | window @id → display name | UI + re-resolve after tmux restart | +| **user_window_offsets** | ``state.json`` | read cursor per user/window | Non-authoritative for routing | +| **group_chat_ids** | ``state.json`` | user:thread → supergroup chat_id | Outbound send target | +| **session_map** | ``session_map.json`` | Hook-written window@id → session | Ephemeral; monitor + load_session_map | +| **monitor_state** | ``monitor_state.json`` | JSONL byte offsets | Independent of bindings | + +**Canonical key form:** tmux window id ``@N`` (never window_name for routing). + +Legacy keys (window_name without ``@``) are migrated once at startup via +:func:`apply_startup_state_migration` / :func:`migrate_session_map_old_format`. +After a successful migrate, new writes always use ``@id``. Runtime code must +not introduce window_name as a routing key. + +This module is intentionally **pure** (no I/O, no tmux) so migrations are +unit-testable. ``SessionManager.resolve_stale_ids`` orchestrates live data. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +def is_window_id(key: str) -> bool: + """Return True if *key* looks like a tmux window ID (``@0``, ``@12``).""" + return key.startswith("@") and len(key) > 1 and key[1:].isdigit() + + +@dataclass +class StartupMigrationResult: + """Outcome of applying in-memory state migration.""" + + window_states: dict[str, Any] + thread_bindings: dict[int, dict[int, str]] + user_window_offsets: dict[int, dict[str, int]] + window_display_names: dict[str, str] + changed: bool + events: list[str] = field(default_factory=list) + + +def build_old_names_snapshot( + window_states: dict[str, Any], + window_display_names: dict[str, str], +) -> dict[str, str]: + """Snapshot old_id → display_name before mutation. + + ``resolve_stale_ids`` rewrites ``window_display_names`` as it goes; + thread_bindings / offsets must still resolve stale IDs against the old view. + """ + old_names: dict[str, str] = dict(window_display_names) + for key, ws in window_states.items(): + name = "" + if hasattr(ws, "window_name"): + name = ws.window_name or "" + elif isinstance(ws, dict): + name = ws.get("window_name", "") or "" + if name and key not in old_names: + old_names[key] = name + return old_names + + +def apply_startup_state_migration( + window_states: dict[str, Any], + thread_bindings: dict[int, dict[int, str]], + user_window_offsets: dict[int, dict[str, int]], + window_display_names: dict[str, str], + live_by_name: dict[str, str], + live_ids: set[str], +) -> StartupMigrationResult: + """Migrate in-memory state to live ``@window_id`` keys. + + Handles: + 1. Old-format keys (window_name) → window_id + 2. Stale IDs (window gone) re-resolved by display name, or dropped + + Does not touch disk. Caller persists when ``result.changed``. + """ + old_names = build_old_names_snapshot(window_states, window_display_names) + display_names: dict[str, str] = dict(window_display_names) + events: list[str] = [] + changed = False + + # --- window_states --- + new_window_states: dict[str, Any] = {} + for key, ws in window_states.items(): + if is_window_id(key): + if key in live_ids: + new_window_states[key] = ws + else: + display = old_names.get(key, key) + new_id = live_by_name.get(display) + if new_id: + events.append( + f"re-resolve window_state {key} -> {new_id} ({display})" + ) + new_window_states[new_id] = ws + if hasattr(ws, "window_name"): + ws.window_name = display + display_names[new_id] = display + display_names.pop(key, None) + changed = True + else: + events.append(f"drop stale window_state {key} ({display})") + display_names.pop(key, None) + changed = True + else: + # Legacy: key is window_name — migrate once then never re-key by name + new_id = live_by_name.get(key) + if new_id: + events.append(f"migrate window_state key {key} -> {new_id}") + if hasattr(ws, "window_name"): + ws.window_name = key + new_window_states[new_id] = ws + display_names[new_id] = key + changed = True + else: + events.append(f"drop old-format window_state {key}") + changed = True + + # --- thread_bindings --- + new_thread_bindings: dict[int, dict[int, str]] = {} + for uid, bindings in thread_bindings.items(): + new_bindings: dict[int, str] = {} + for thread_id, val in bindings.items(): + if is_window_id(val): + if val in live_ids: + new_bindings[thread_id] = val + else: + display = old_names.get(val, val) + new_id = live_by_name.get(display) + if new_id: + events.append( + f"re-resolve binding user={uid} thread={thread_id} " + f"{val} -> {new_id}" + ) + new_bindings[thread_id] = new_id + display_names[new_id] = display + changed = True + else: + events.append( + f"drop stale binding user={uid} thread={thread_id} " + f"window_id={val}" + ) + changed = True + else: + new_id = live_by_name.get(val) + if new_id: + events.append( + f"migrate binding user={uid} thread={thread_id} " + f"{val} -> {new_id}" + ) + new_bindings[thread_id] = new_id + display_names[new_id] = val + changed = True + else: + events.append( + f"drop old-format binding user={uid} thread={thread_id} " + f"name={val}" + ) + changed = True + if new_bindings: + new_thread_bindings[uid] = new_bindings + + # --- user_window_offsets --- + new_offsets_all: dict[int, dict[str, int]] = {} + for uid, offsets in user_window_offsets.items(): + new_offsets: dict[str, int] = {} + for key, offset in offsets.items(): + if is_window_id(key): + if key in live_ids: + new_offsets[key] = offset + else: + display = old_names.get(key, key) + new_id = live_by_name.get(display) + if new_id: + new_offsets[new_id] = offset + changed = True + else: + changed = True + else: + new_id = live_by_name.get(key) + if new_id: + new_offsets[new_id] = offset + changed = True + else: + changed = True + new_offsets_all[uid] = new_offsets + + for ev in events: + logger.info("%s", ev) + + return StartupMigrationResult( + window_states=new_window_states, + thread_bindings=new_thread_bindings, + user_window_offsets=new_offsets_all, + window_display_names=display_names, + changed=changed, + events=events, + ) + + +def migrate_session_map_old_format( + session_map: dict[str, dict], + live_by_name: dict[str, str], + session_prefix: str, +) -> bool: + """Migrate old-format session_map keys (window_name) to @window_id in place. + + Keys look like ``{tmux_session}:name`` vs ``{tmux_session}:@N``. + Returns True if *session_map* was mutated. + """ + prefix = session_prefix if session_prefix.endswith(":") else f"{session_prefix}:" + old_keys = [ + key + for key in session_map + if key.startswith(prefix) and not is_window_id(key[len(prefix) :]) + ] + changed = False + for key in old_keys: + window_name = key[len(prefix) :] + info = session_map.pop(key) + changed = True + new_id = live_by_name.get(window_name) + if not new_id: + logger.info("Dropping orphan old-format session_map key: %s", key) + continue + new_key = f"{prefix}{new_id}" + if new_key in session_map: + logger.info( + "Discarding old-format session_map key %s (superseded by %s)", + key, + new_key, + ) + continue + info.setdefault("window_name", window_name) + session_map[new_key] = info + logger.info("Migrated old-format session_map key %s -> %s", key, new_key) + return changed + + +def cleanup_stale_session_map_entries( + session_map: dict[str, dict], + live_ids: set[str], + session_prefix: str, +) -> bool: + """Remove session_map entries whose window @id is not live. + + Only touches keys that already use @window_id form. + Returns True if anything was removed. + """ + prefix = session_prefix if session_prefix.endswith(":") else f"{session_prefix}:" + stale_keys = [ + key + for key in session_map + if key.startswith(prefix) + and is_window_id(key[len(prefix) :]) + and key[len(prefix) :] not in live_ids + ] + for key in stale_keys: + del session_map[key] + logger.info("Removed stale session_map entry: %s", key) + return bool(stale_keys) + + +def state_needs_legacy_migration( + window_states: dict[str, Any], + thread_bindings: dict[int, dict[int, str]], +) -> bool: + """Detect pre-@id keys still present in persisted state.""" + for k in window_states: + if not is_window_id(k): + return True + for bindings in thread_bindings.values(): + for window_id in bindings.values(): + if not is_window_id(window_id): + return True + return False diff --git a/src/ccbot/session_monitor.py b/src/ccbot/session_monitor.py index f8f728272..1eb55bc73 100644 --- a/src/ccbot/session_monitor.py +++ b/src/ccbot/session_monitor.py @@ -5,6 +5,9 @@ 2. Detects session_map changes (new/changed/deleted windows) and cleans up. 3. Reads new JSONL lines from each session file using byte-offset tracking. 4. Parses entries via TranscriptParser and emits NewMessage objects to a callback. + 5. Follows ``continued-in`` records (Claude Code forked the session into a new + session_id) by re-pointing the window's session_map entry to the new session, + because the SessionStart hook of a forked session may run without TMUX_PANE. Optimizations: mtime cache skips unchanged files; byte offset avoids re-reading. @@ -16,7 +19,7 @@ import logging from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Awaitable +from typing import Any, Awaitable, Callable import aiofiles @@ -84,6 +87,8 @@ def __init__( self._last_session_map: dict[str, str] = {} # window_key -> session_id # In-memory mtime cache for quick file change detection (not persisted) self._file_mtimes: dict[str, float] = {} # session_id -> last_seen_mtime + # Session forks seen in transcripts: old session_id -> new session_id + self._continuations: dict[str, str] = {} def set_message_callback( self, callback: Callable[[NewMessage], Awaitable[None]] @@ -198,12 +203,16 @@ async def _read_new_lines( ) -> list[dict]: """Read new lines from a session file using byte offset for efficiency. + Opens the file in binary mode so ``last_byte_offset`` is a true OS + byte position on every platform (Windows text mode would expand + ``\\n`` ↔ ``\\r\\n`` and desync offsets). + Detects file truncation (e.g. after /clear) and resets offset. Recovers from corrupted offsets (mid-line) by scanning to next line. """ new_entries = [] try: - async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + async with aiofiles.open(file_path, "rb") as f: # Get file size to detect truncation await f.seek(0, 2) # Seek to end file_size = await f.tell() @@ -227,7 +236,7 @@ async def _read_new_lines( # the state file was manually edited or corrupted. if session.last_byte_offset > 0: first_char = await f.read(1) - if first_char and first_char != "{": + if first_char and first_char != b"{": logger.warning( "Corrupted offset %d in session %s (mid-line), " "scanning to next line", @@ -246,18 +255,36 @@ async def _read_new_lines( # (newline-terminated) that fails to parse is permanently # corrupt; skip it, or it would stall this session forever. safe_offset = session.last_byte_offset - async for line in f: + async for raw_line in f: + try: + line = raw_line.decode("utf-8") + except UnicodeDecodeError: + if not raw_line.endswith(b"\n"): + logger.debug( + "Partial UTF-8 sequence in session %s, " + "will retry next cycle", + session.session_id, + ) + break + logger.warning( + "Skipping undecodable JSONL line in session %s (%d bytes)", + session.session_id, + len(raw_line), + ) + safe_offset = await f.tell() + continue + data = TranscriptParser.parse_line(line) if data: new_entries.append(data) safe_offset = await f.tell() elif line.strip(): - if line.endswith("\n"): + if raw_line.endswith(b"\n"): logger.warning( "Skipping unparseable JSONL line in session %s " "(%d bytes)", session.session_id, - len(line), + len(raw_line), ) safe_offset = await f.tell() else: @@ -340,6 +367,8 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa ) self._file_mtimes[session_info.session_id] = current_mtime + self._collect_continuations(session_info.session_id, new_entries) + if new_entries: logger.debug( f"Read {len(new_entries)} new entries for " @@ -384,6 +413,45 @@ async def check_for_updates(self, active_session_ids: set[str]) -> list[NewMessa self.state.save_if_dirty() return new_messages + def _collect_continuations(self, session_id: str, entries: list[dict]) -> None: + """Remember ``continued-in`` records: Claude Code forked this session. + + The record is the last line of the old transcript and names the new + session (``continuedInSessionId``). Applied by ``_apply_continuations``. + """ + for entry in entries: + if entry.get("type") != "continued-in": + continue + new_id = entry.get("continuedInSessionId") + if isinstance(new_id, str) and new_id and new_id != session_id: + self._continuations[session_id] = new_id + + async def _apply_continuations(self, current_map: dict[str, str]) -> None: + """Re-point session_map entries of forked sessions to the new session_id. + + The hook of a forked session can fire without TMUX_PANE and so never + updates session_map; without this the monitor keeps reading the dead + transcript and no replies reach Telegram. The new session's file is + tracked from its end on the next poll (history is a copy of the old one). + """ + if not self._continuations: + return + # Deferred import to avoid circular dependency + from .session import session_manager + + for old_id, new_id in list(self._continuations.items()): + window_ids = [w for w, sid in current_map.items() if sid == old_id] + for window_id in window_ids: + logger.info( + "Session continued: %s -> %s (window=%s)", + old_id, + new_id, + window_id, + ) + await session_manager.override_session_map_entry(window_id, new_id) + # Applied, or the window no longer maps to the old session + del self._continuations[old_id] + async def _load_current_session_map(self) -> dict[str, str]: """Load current session_map and return window_key -> session_id mapping. @@ -505,6 +573,9 @@ async def _monitor_loop(self) -> None: # Check for new messages (all I/O is async) new_messages = await self.check_for_updates(active_session_ids) + # Follow session forks (continued-in) by updating session_map + await self._apply_continuations(current_map) + for msg in new_messages: status = "complete" if msg.is_complete else "streaming" preview = msg.text[:80] + ("..." if len(msg.text) > 80 else "") diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 1b880baca..d07cd81ca 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -20,11 +20,16 @@ from pathlib import Path import libtmux +from libtmux.exc import LibTmuxException from .config import SENSITIVE_ENV_VARS, config +from .errors import log_exception logger = logging.getLogger(__name__) +# libtmux + subprocess / OS failures around the tmux server +_TMUX_ERRORS = (LibTmuxException, OSError, RuntimeError, ValueError, KeyError) + # Claude session IDs are UUIDs (JSONL filename stems) _UUID_RE = re.compile( r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" @@ -64,7 +69,10 @@ def get_session(self) -> libtmux.Session | None: """Get the tmux session if it exists.""" try: return self.server.sessions.get(session_name=self.session_name) - except Exception: + except _TMUX_ERRORS as e: + log_exception( + logger, "tmux get_session failed", e, session=self.session_name + ) return None def get_or_create_session(self) -> libtmux.Session: @@ -95,8 +103,9 @@ def _scrub_session_env(session: libtmux.Session) -> None: for var in SENSITIVE_ENV_VARS: try: session.unset_environment(var) - except Exception: - pass # var not set in session env — nothing to remove + except _TMUX_ERRORS as e: + # var not set in session env — nothing to remove + log_exception(logger, "tmux unset_environment skipped", e, var=var) async def list_windows(self) -> list[TmuxWindow]: """List all windows in the session with their working directories. @@ -136,8 +145,8 @@ def _sync_list_windows() -> list[TmuxWindow]: pane_current_command=pane_cmd, ) ) - except Exception as e: - logger.debug(f"Error getting window info: {e}") + except _TMUX_ERRORS as e: + log_exception(logger, "Error getting window info", e) return windows @@ -205,8 +214,14 @@ async def capture_pane(self, window_id: str, with_ansi: bool = False) -> str | N f"Failed to capture pane {window_id}: {stderr.decode('utf-8')}" ) return None - except Exception as e: - logger.error(f"Unexpected error capturing pane {window_id}: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Unexpected error capturing pane", + e, + level=logging.ERROR, + window_id=window_id, + ) return None # Original implementation for plain text - wrap in thread @@ -223,8 +238,14 @@ def _sync_capture() -> str | None: return None lines = pane.capture_pane() return "\n".join(lines) if isinstance(lines, list) else str(lines) - except Exception as e: - logger.error(f"Failed to capture pane {window_id}: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Failed to capture pane", + e, + level=logging.ERROR, + window_id=window_id, + ) return None return await asyncio.to_thread(_sync_capture) @@ -266,8 +287,14 @@ def _send_literal(chars: str) -> bool: return False pane.send_keys(chars, enter=False, literal=True) return True - except Exception as e: - logger.error(f"Failed to send keys to window {window_id}: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Failed to send keys to window", + e, + level=logging.ERROR, + window_id=window_id, + ) return False def _send_enter() -> bool: @@ -283,8 +310,14 @@ def _send_enter() -> bool: return False pane.send_keys("", enter=True, literal=False) return True - except Exception as e: - logger.error(f"Failed to send Enter to window {window_id}: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Failed to send Enter to window", + e, + level=logging.ERROR, + window_id=window_id, + ) return False # Claude Code's ! command mode: send "!" first so the TUI @@ -324,8 +357,14 @@ def _sync_send_keys() -> bool: pane.send_keys(text, enter=enter, literal=literal) return True - except Exception as e: - logger.error(f"Failed to send keys to window {window_id}: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Failed to send keys to window", + e, + level=logging.ERROR, + window_id=window_id, + ) return False return await asyncio.to_thread(_sync_send_keys) @@ -344,8 +383,14 @@ def _sync_rename() -> bool: window.rename_window(new_name) logger.info("Renamed window %s to '%s'", window_id, new_name) return True - except Exception as e: - logger.error(f"Failed to rename window {window_id}: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Failed to rename window", + e, + level=logging.ERROR, + window_id=window_id, + ) return False return await asyncio.to_thread(_sync_rename) @@ -364,8 +409,14 @@ def _sync_kill() -> bool: window.kill() logger.info("Killed window %s", window_id) return True - except Exception as e: - logger.error(f"Failed to kill window {window_id}: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Failed to kill window", + e, + level=logging.ERROR, + window_id=window_id, + ) return False return await asyncio.to_thread(_sync_kill) @@ -422,7 +473,7 @@ def _create_and_start() -> tuple[bool, str, str, str]: start_directory=str(path), ) - wid = window.window_id or "" + window_id = window.window_id or "" # Prevent Claude Code from overriding window name window.set_window_option("allow-rename", "off") @@ -439,18 +490,23 @@ def _create_and_start() -> tuple[bool, str, str, str]: logger.info( "Created window '%s' (id=%s) at %s", final_window_name, - wid, + window_id, path, ) return ( True, f"Created window '{final_window_name}' at {path}", final_window_name, - wid, + window_id, ) - except Exception as e: - logger.error(f"Failed to create window: {e}") + except _TMUX_ERRORS as e: + log_exception( + logger, + "Failed to create window", + e, + level=logging.ERROR, + ) return False, f"Failed to create window: {e}", "", "" return await asyncio.to_thread(_create_and_start) diff --git a/src/ccbot/transcribe.py b/src/ccbot/transcribe.py index 8e62a4cd2..904d16829 100644 --- a/src/ccbot/transcribe.py +++ b/src/ccbot/transcribe.py @@ -1,12 +1,17 @@ -"""Voice-to-text transcription via OpenAI's audio API. +"""Voice message transcription — converts audio to text. -Provides a single async function to transcribe voice messages using -the gpt-4o-transcribe model. Uses httpx directly (no OpenAI SDK needed). +Supports two backends (configured via CCBOT_WHISPER_BACKEND): + - "local" — faster-whisper on CPU (free, no API key, requires ffmpeg) + - "openai" — OpenAI API (gpt-4o-transcribe, requires OPENAI_API_KEY) + - "off" — voice messages disabled -Key function: transcribe_voice(ogg_data) -> str +Key function: transcribe() — async, returns transcribed text. """ +import asyncio import logging +from pathlib import Path +from typing import Any import httpx @@ -14,6 +19,23 @@ logger = logging.getLogger(__name__) +# Install hint covering both a dev checkout and a `uv tool` install. +_VOICE_INSTALL_HINT = ( + 'uv pip install -e ".[voice]" (source checkout) or ' + "uv tool install --reinstall --with faster-whisper (uv tool)" +) + + +class TranscriptionError(Exception): + """Raised when transcription fails.""" + + +class TranscriptionDisabled(Exception): + """Raised when voice transcription is disabled or unavailable.""" + + +# --- OpenAI backend --- + _client: httpx.AsyncClient | None = None @@ -25,26 +47,30 @@ def _get_client() -> httpx.AsyncClient: return _client -async def transcribe_voice(ogg_data: bytes) -> str: - """Transcribe OGG voice data to text via OpenAI API. - - Raises: - httpx.HTTPStatusError: On API errors (401, 429, 5xx, etc.) - ValueError: If the API returns an empty transcription. - """ +async def _transcribe_openai(file_path: Path) -> str: + """Transcribe via OpenAI API (gpt-4o-transcribe).""" + if not config.openai_api_key: + raise TranscriptionDisabled( + "Voice transcription requires OPENAI_API_KEY in .env.\n" + "Or set CCBOT_WHISPER_BACKEND=local for free local transcription " + f"(requires the voice extra: {_VOICE_INSTALL_HINT})." + ) url = f"{config.openai_base_url.rstrip('/')}/audio/transcriptions" client = _get_client() + ogg_data = file_path.read_bytes() + post_data: dict[str, str] = {"model": "gpt-4o-transcribe"} + if config.whisper_language != "auto": + post_data["language"] = config.whisper_language response = await client.post( url, headers={"Authorization": f"Bearer {config.openai_api_key}"}, files={"file": ("voice.ogg", ogg_data, "audio/ogg")}, - data={"model": "gpt-4o-transcribe"}, + data=post_data, ) response.raise_for_status() - text = response.json().get("text", "").strip() if not text: - raise ValueError("Empty transcription returned by API") + raise TranscriptionError("Empty transcription returned by OpenAI API") return text @@ -54,3 +80,76 @@ async def close_client() -> None: if _client is not None and not _client.is_closed: await _client.aclose() _client = None + + +# --- Local (faster-whisper) backend --- + +_local_model: Any = None + + +def _get_local_model() -> Any: + """Lazy-load the faster-whisper model (downloads on first use).""" + global _local_model + if _local_model is not None: + return _local_model + try: + # faster-whisper has no stubs; keep import-untyped rather than fail pyright. + from faster_whisper import WhisperModel # type: ignore[import-untyped] + except ImportError: + raise TranscriptionDisabled( + "faster-whisper is not installed. " + f"Install with: {_VOICE_INSTALL_HINT}\n" + "Or set CCBOT_WHISPER_BACKEND=openai/off." + ) + logger.info( + "Loading faster-whisper model '%s' (may download on first use)...", + config.whisper_model, + ) + _local_model = WhisperModel(config.whisper_model, device="cpu", compute_type="int8") + logger.info("faster-whisper model loaded successfully") + return _local_model + + +def _transcribe_local_sync(file_path: Path) -> str: + """Synchronous transcription using faster-whisper (CPU-bound).""" + model = _get_local_model() + language = None if config.whisper_language == "auto" else config.whisper_language + segments, info = model.transcribe(str(file_path), beam_size=5, language=language) + text = " ".join(segment.text.strip() for segment in segments) + if not text.strip(): + raise TranscriptionError("Transcription produced empty text") + logger.info( + "Transcribed %s: language=%s, duration=%.1fs, text_len=%d", + file_path.name, + info.language, + info.duration, + len(text), + ) + return text.strip() + + +# --- Unified entry point --- + + +async def transcribe(file_path: Path) -> str: + """Transcribe an audio file to text using the configured backend. + + Args: + file_path: Path to the audio file (OGG/Opus from Telegram). + + Returns: + Transcribed text string. + + Raises: + TranscriptionDisabled: Voice is disabled or dependency missing. + TranscriptionError: Transcription failed or produced empty text. + """ + if config.whisper_backend == "off": + raise TranscriptionDisabled("Voice transcription is disabled.") + + if config.whisper_backend == "openai": + return await _transcribe_openai(file_path) + + # local backend + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, _transcribe_local_sync, file_path) diff --git a/src/ccbot/transcript_reader.py b/src/ccbot/transcript_reader.py new file mode 100644 index 000000000..452dc02c8 --- /dev/null +++ b/src/ccbot/transcript_reader.py @@ -0,0 +1,195 @@ +"""Transcript JSONL reading (ISS-005 phase 4d). + +Owns file reads of Claude Code session transcripts under +``~/.claude/projects/``: direct path construction, summary extraction, +directory listing, and byte-ranged message history. Window→session +resolution and cross-store orchestration stay with the +``SessionManager`` facade. Config paths are resolved dynamically so +test fixtures that patch them keep working. +""" + +import json +import logging +import re +from dataclasses import dataclass +from pathlib import Path + +import aiofiles + +from .config import config +from .transcript_parser import TranscriptParser + +logger = logging.getLogger(__name__) + + +@dataclass +class ClaudeSession: + """Information about a Claude Code session.""" + + session_id: str + summary: str + message_count: int + file_path: str + + +class TranscriptReader: + """Reads Claude Code session transcripts (JSONL) from disk (ISS-005 4d).""" + + @property + def projects_path(self) -> Path: + """Claude projects root (read dynamically: tests patch config).""" + return config.claude_projects_path + + @staticmethod + def encode_cwd(cwd: str) -> str: + """Encode a cwd path to match Claude Code's project directory naming. + + Replaces all non-alphanumeric characters (except dash) with dashes. + E.g. /home/user_name/Code/project -> -home-user-name-Code-project + """ + return re.sub(r"[^a-zA-Z0-9-]", "-", cwd) + + def build_session_file_path(self, session_id: str, cwd: str) -> Path | None: + """Build the direct file path for a session from session_id and cwd.""" + if not session_id or not cwd: + return None + encoded_cwd = self.encode_cwd(cwd) + return self.projects_path / encoded_cwd / f"{session_id}.jsonl" + + async def get_session_direct( + self, session_id: str, cwd: str + ) -> ClaudeSession | None: + """Get a ClaudeSession directly from session_id and cwd (no scanning).""" + file_path = self.build_session_file_path(session_id, cwd) + + # Fallback: glob search if direct path doesn't exist + if not file_path or not file_path.exists(): + pattern = f"*/{session_id}.jsonl" + matches = list(self.projects_path.glob(pattern)) + if matches: + file_path = matches[0] + logger.debug("Found session via glob: %s", file_path) + else: + return None + + # Single pass: read file once, extract summary + count messages + summary = "" + last_user_msg = "" + message_count = 0 + try: + async with aiofiles.open(file_path, "r", encoding="utf-8") as f: + async for line in f: + line = line.strip() + if not line: + continue + message_count += 1 + try: + data = json.loads(line) + # Check for summary + if data.get("type") == "summary": + s = data.get("summary", "") + if s: + summary = s + # Track last user message as fallback + elif TranscriptParser.is_user_message(data): + parsed = TranscriptParser.parse_message(data) + if parsed and parsed.text.strip(): + last_user_msg = parsed.text.strip() + except json.JSONDecodeError: + continue + except OSError: + return None + + if not summary: + summary = last_user_msg[:50] if last_user_msg else "Untitled" + + return ClaudeSession( + session_id=session_id, + summary=summary, + message_count=message_count, + file_path=str(file_path), + ) + + async def list_sessions_for_directory(self, cwd: str) -> list[ClaudeSession]: + """List existing Claude sessions for a directory. + + Encodes the cwd path to find the project directory under + ~/.claude/projects/{encoded_cwd}/, globs *.jsonl files, and + extracts summary info from each. + + Returns a list sorted by mtime (most recent first), capped at 10. + """ + encoded_cwd = self.encode_cwd(cwd) + project_dir = self.projects_path / encoded_cwd + if not project_dir.is_dir(): + return [] + + # Collect JSONL files sorted by mtime (newest first) + jsonl_files = sorted( + project_dir.glob("*.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + # Skip sessions-index and cap at 10 + sessions: list[ClaudeSession] = [] + for f in jsonl_files: + if f.stem == "sessions-index": + continue + if len(sessions) >= 10: + break + session_id = f.stem + session = await self.get_session_direct(session_id, cwd) + if session and session.message_count > 0: + sessions.append(session) + return sessions + + async def read_recent_messages( + self, + file_path: str, + *, + start_byte: int = 0, + end_byte: int | None = None, + ) -> tuple[list[dict], int]: + """Read user/assistant messages from a session JSONL (byte range).""" + path = Path(file_path) + if not path.exists(): + return [], 0 + + # Read JSONL entries (optionally filtered by byte range) + entries: list[dict] = [] + try: + async with aiofiles.open(path, "r", encoding="utf-8") as f: + if start_byte > 0: + await f.seek(start_byte) + + while True: + # Check byte limit before reading + if end_byte is not None: + current_pos = await f.tell() + if current_pos >= end_byte: + break + + line = await f.readline() + if not line: + break + + data = TranscriptParser.parse_line(line) + if data: + entries.append(data) + except OSError as e: + logger.error("Error reading session file %s: %s", path, e) + return [], 0 + + parsed_entries, _ = TranscriptParser.parse_entries(entries) + all_messages = [ + { + "role": e.role, + "text": e.text, + "content_type": e.content_type, + "timestamp": e.timestamp, + } + for e in parsed_entries + ] + + return all_messages, len(all_messages) diff --git a/src/ccbot/utils.py b/src/ccbot/utils.py index c86de615a..a227568ef 100644 --- a/src/ccbot/utils.py +++ b/src/ccbot/utils.py @@ -4,16 +4,40 @@ - ccbot_dir(): resolve config directory from CCBOT_DIR env var. - atomic_write_json(): crash-safe JSON file writes via temp+rename. - read_cwd_from_jsonl(): extract the cwd field from the first JSONL entry. + - ensure_dir(): create a directory lazily on first use (no import-time mkdir). + - flock()/LOCK_EX/LOCK_UN: advisory file locks (fcntl on POSIX; no-op on Windows). """ +from __future__ import annotations + import json import os +import sys import tempfile from pathlib import Path -from typing import Any +from typing import IO, Any CCBOT_DIR_ENV = "CCBOT_DIR" +# fcntl is POSIX-only. On Windows, provide a no-op lock so imports/type-check +# succeed for unit tests; production bot + hook run on Linux with real locks. +if sys.platform != "win32": + import fcntl as _fcntl + + LOCK_EX = _fcntl.LOCK_EX + LOCK_UN = _fcntl.LOCK_UN + + def flock(fd: IO[Any] | int, operation: int) -> None: + """Apply an advisory lock via fcntl.flock (POSIX).""" + _fcntl.flock(fd, operation) +else: + LOCK_EX = 2 + LOCK_UN = 8 + + def flock(fd: IO[Any] | int, operation: int) -> None: + """No-op on Windows (fcntl is unavailable).""" + return None + def ccbot_dir() -> Path: """Resolve config directory from CCBOT_DIR env var or default ~/.ccbot.""" @@ -21,6 +45,16 @@ def ccbot_dir() -> Path: return Path(raw) if raw else Path.home() / ".ccbot" +def ensure_dir(path: Path) -> Path: + """Create a directory on first use and return it (lazy init helper). + + Used by handlers to avoid import-time mkdir side effects on cache dirs + (IMAGES_DIR / _AUDIO_DIR / DOCS_DIR). + """ + path.mkdir(parents=True, exist_ok=True) + return path + + def atomic_write_json(path: Path, data: Any, indent: int = 2) -> None: """Write JSON data to a file atomically. diff --git a/src/ccbot/window_state_store.py b/src/ccbot/window_state_store.py new file mode 100644 index 000000000..0d84c204b --- /dev/null +++ b/src/ccbot/window_state_store.py @@ -0,0 +1,118 @@ +"""Window state and display name storage (ISS-005 phase 4b). + +Owns the two window-related dicts previously held by ``SessionManager``: + + window_states: window_id -> WindowState (session_id, cwd, window_name) + window_display_names: window_id -> window_name (for display) + +plus their (de)serialization in the byte-for-byte ``state.json`` format. +Persist (atomic write) and logging stay with the ``SessionManager`` +facade; all methods here are pure in-memory operations. +""" + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class WindowState: + """Persistent state for a tmux window. + + Attributes: + session_id: Associated Claude session ID (empty if not yet detected) + cwd: Working directory for direct file path construction + window_name: Display name of the window + """ + + session_id: str = "" + cwd: str = "" + window_name: str = "" + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "session_id": self.session_id, + "cwd": self.cwd, + } + if self.window_name: + d["window_name"] = self.window_name + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "WindowState": + return cls( + session_id=data.get("session_id", ""), + cwd=data.get("cwd", ""), + window_name=data.get("window_name", ""), + ) + + +@dataclass +class WindowStateStore: + """Owner of window_states and window_display_names (ISS-005 4b). + + Mutating methods do not persist: the caller (SessionManager facade) + is responsible for saving state after mutations. + """ + + window_states: dict[str, WindowState] = field(default_factory=dict) + # window_id -> display name (window_name) + window_display_names: dict[str, str] = field(default_factory=dict) + + # --- state.json (de)serialization (byte-for-byte format) --- + + def to_state(self) -> dict[str, Any]: + """Serialize owned dicts to state.json sections.""" + return { + "window_states": {k: v.to_dict() for k, v in self.window_states.items()}, + "window_display_names": self.window_display_names, + } + + def load_state(self, state: dict[str, Any]) -> None: + """Load owned dicts from a parsed state.json dict.""" + self.window_states = { + k: WindowState.from_dict(v) + for k, v in state.get("window_states", {}).items() + } + self.window_display_names = state.get("window_display_names", {}) + + def reset(self) -> None: + """Clear both dicts (corrupt-state fallback).""" + self.window_states = {} + self.window_display_names = {} + + # --- Window state operations --- + + def get_window_state(self, window_id: str) -> WindowState: + """Get or create window state.""" + if window_id not in self.window_states: + self.window_states[window_id] = WindowState() + return self.window_states[window_id] + + def set_window_session( + self, window_id: str, session_id: str, cwd: str = "", window_name: str = "" + ) -> None: + """Set session association; non-empty cwd/window_name update fields.""" + state = self.get_window_state(window_id) + state.session_id = session_id + if cwd: + state.cwd = cwd + if window_name: + state.window_name = window_name + + def clear_window_session(self, window_id: str) -> None: + """Clear session association for a window (e.g., after /clear command).""" + state = self.get_window_state(window_id) + state.session_id = "" + + # --- Display name operations --- + + def get_display_name(self, window_id: str) -> str: + """Get display name for a window_id, fallback to window_id itself.""" + return self.window_display_names.get(window_id, window_id) + + def update_display_name(self, window_id: str, new_name: str) -> None: + """Update display name for a window (and WindowState.window_name).""" + self.window_display_names[window_id] = new_name + # Also update WindowState.window_name if it exists + if window_id in self.window_states: + self.window_states[window_id].window_name = new_name diff --git a/tests/agentic/conftest.py b/tests/agentic/conftest.py new file mode 100644 index 000000000..3f3f63227 --- /dev/null +++ b/tests/agentic/conftest.py @@ -0,0 +1,10 @@ +"""Pytest sys.path setup for agentic robot tests (ISS-013).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SCRIPT_DIR = Path(__file__).resolve().parents[2] / "scripts" / "agentic" +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) diff --git a/tests/agentic/test_binary_staging.py b/tests/agentic/test_binary_staging.py new file mode 100644 index 000000000..ca3fbe171 --- /dev/null +++ b/tests/agentic/test_binary_staging.py @@ -0,0 +1,84 @@ +"""Binary-safe diff staging in propose.py (ISS-016). + +Staging must be byte-exact: text-mode staging strips CR and breaks +git-format headers and '\\ No newline at end of file' hunks on Windows git. +""" + +import propose +import pytest + + +def _git_format_diff() -> bytes: + return ( + b"diff --git a/f.py b/f.py\n" + b"index 1111111..2222222 100644\n" + b"--- a/f.py\n" + b"+++ b/f.py\n" + b"@@ -1,3 +1,3 @@\n" + b" a\n" + b"-b\n" + b"+bb\n" + b" c\n" + ) + + +def _crlf_diff() -> bytes: + return _git_format_diff().replace(b"\n", b"\r\n") + + +def _eof_marker_diff() -> bytes: + return ( + b"--- a/g.py\n" + b"+++ b/g.py\n" + b"@@ -1,2 +1,2 @@\n" + b" l1\n" + b"-]\n" + b"\\ No newline at end of file\n" + b"+]\n" + ) + + +SAMPLES = { + "lf": _git_format_diff(), + "crlf": _crlf_diff(), + "eof_marker": _eof_marker_diff(), + "no_trailing_newline": _git_format_diff()[:-1], +} + + +@pytest.mark.parametrize("name", sorted(SAMPLES)) +def test_write_temp_diff_is_byte_exact(name): + tmp_path = propose._write_temp_diff(SAMPLES[name]) + try: + assert tmp_path.read_bytes() == SAMPLES[name] + finally: + tmp_path.unlink(missing_ok=True) + + +def test_write_bytes_creates_parents_and_preserves_bytes(tmp_path): + target = tmp_path / "nested" / "proposal.diff" + + propose.write_bytes(target, b"a\r\nb\n]") + + assert target.read_bytes() == b"a\r\nb\n]" + + +def test_check_diff_applies_rejects_markdown_fence_with_crlf(): + bad = b"```\r\n" + _crlf_diff() + + with pytest.raises(RuntimeError, match="markdown fence"): + propose.check_diff_applies(bad) + + +def test_check_diff_applies_rejects_comment_with_crlf(): + bad = b"# staged\r\n" + _crlf_diff() + + with pytest.raises(RuntimeError, match="comment"): + propose.check_diff_applies(bad) + + +def test_validate_proposal_diff_empty_gate(): + with pytest.raises(RuntimeError, match="Empty proposal.diff"): + propose.validate_proposal_diff(b"") + + propose.validate_proposal_diff(b"", allow_empty=True) diff --git a/tests/agentic/test_evidence_checks.py b/tests/agentic/test_evidence_checks.py new file mode 100644 index 000000000..62289f52d --- /dev/null +++ b/tests/agentic/test_evidence_checks.py @@ -0,0 +1,114 @@ +"""Tests for structural-task evidence checks (ISS-013).""" + +from __future__ import annotations + +from pathlib import Path + +from update_backlog_status import done_block_reasons +from validate_changes import check_evidence + + +def _write(root: Path, rel: str, text: str) -> None: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_deleted_path_absent_passes(tmp_path): + report = check_evidence(tmp_path, {"deleted_paths": ["src/ccbot/old.py"]}) + assert report["ok"] is True + assert report["checks"][0]["detail"] == "absent" + + +def test_deleted_path_still_exists_fails(tmp_path): + _write(tmp_path, "src/ccbot/old.py", "x = 1\n") + report = check_evidence(tmp_path, {"deleted_paths": ["src/ccbot/old.py"]}) + assert report["ok"] is False + check = report["checks"][0] + assert check["check"] == "deleted_path" + assert check["detail"] == "still exists" + + +def test_call_site_found_in_src(tmp_path): + _write(tmp_path, "src/ccbot/session.py", "store = BindingStore()\n") + report = check_evidence(tmp_path, {"call_sites": [r"BindingStore\("]}) + assert report["ok"] is True + + +def test_call_site_missing_fails(tmp_path): + _write(tmp_path, "src/ccbot/session.py", "store = object()\n") + report = check_evidence(tmp_path, {"call_sites": [r"BindingStore\("]}) + assert report["ok"] is False + assert report["checks"][0]["detail"] == "no match in src/" + + +def test_call_site_in_tests_does_not_count(tmp_path): + # production proof: matches outside src/ are ignored + _write(tmp_path, "tests/ccbot/test_x.py", "BindingStore()\n") + report = check_evidence(tmp_path, {"call_sites": [r"BindingStore\("]}) + assert report["ok"] is False + + +def test_grep_present_and_absent(tmp_path): + _write(tmp_path, "src/ccbot/a.py", "require_session(update)\n") + evidence = { + "grep": [ + {"pattern": "require_session", "path": "src/", "expect": "present"}, + { + "pattern": r"is_user_allowed\(user.id\)", + "path": "src/", + "expect": "absent", + }, + ] + } + report = check_evidence(tmp_path, evidence) + assert report["ok"] is True + + +def test_grep_absent_but_found_fails(tmp_path): + _write(tmp_path, "src/ccbot/a.py", "is_user_allowed(user.id)\n") + evidence = { + "grep": [{"pattern": "is_user_allowed", "path": "src/", "expect": "absent"}] + } + report = check_evidence(tmp_path, evidence) + assert report["ok"] is False + + +def test_grep_bad_regex_fails(tmp_path): + _write(tmp_path, "src/ccbot/a.py", "x = 1\n") + report = check_evidence(tmp_path, {"grep": [{"pattern": "([", "path": "src/"}]}) + assert report["ok"] is False + + +def test_test_file_missing_fails(tmp_path): + report = check_evidence(tmp_path, {"tests": ["tests/ccbot/test_x.py"]}) + assert report["ok"] is False + + +def test_test_file_exists_passes(tmp_path): + _write(tmp_path, "tests/ccbot/test_x.py", "def test_x():\n pass\n") + report = check_evidence(tmp_path, {"tests": ["tests/ccbot/test_x.py"]}) + assert report["ok"] is True + + +def test_done_block_reasons_plain_task_allowed(tmp_path): + assert done_block_reasons(tmp_path, {"id": "T-1"}) == [] + + +def test_done_block_reasons_structural_without_evidence_blocked(tmp_path): + reasons = done_block_reasons(tmp_path, {"id": "T-2", "kind": "structural"}) + assert reasons + assert "no evidence block" in reasons[0] + + +def test_done_block_reasons_failing_evidence_blocked(tmp_path): + _write(tmp_path, "src/ccbot/old.py", "x = 1\n") + task = {"id": "T-3", "evidence": {"deleted_paths": ["src/ccbot/old.py"]}} + reasons = done_block_reasons(tmp_path, task) + assert reasons + assert "deleted_path" in reasons[0] + + +def test_done_block_reasons_passing_evidence_allowed(tmp_path): + task = {"id": "T-4", "evidence": {"deleted_paths": ["src/ccbot/old.py"]}} + assert done_block_reasons(tmp_path, task) == [] diff --git a/tests/agentic/test_pipeline.py b/tests/agentic/test_pipeline.py new file mode 100644 index 000000000..50f93d29b --- /dev/null +++ b/tests/agentic/test_pipeline.py @@ -0,0 +1,59 @@ +"""Guardrail tests for propose/validate blocked path globs.""" + +from __future__ import annotations + +import propose +from validate_changes import check_guardrails + +_BLOCKED = [".env", ".env.*", "**/.env", "**/.env.*"] + +_GUARD_CFG = { + "guardrails": { + "allowed_path_globs": ["**/*"], + "blocked_path_globs": _BLOCKED, + "block": _BLOCKED, + "allow": ["**/*"], + } +} + + +def test_env_example_not_blocked() -> None: + ok, reason = propose.path_allowed(".env.example", _GUARD_CFG) + assert ok is True + report = check_guardrails([".env.example"], _GUARD_CFG) + assert report["ok"] is True + assert ".env.example" not in report["blocked_hits"] + + +def test_env_secret_blocked() -> None: + ok, _reason = propose.path_allowed(".env", _GUARD_CFG) + assert ok is False + report = check_guardrails([".env"], _GUARD_CFG) + assert report["ok"] is False + assert ".env" in report["blocked_hits"] + + +def test_env_local_blocked() -> None: + ok, _reason = propose.path_allowed(".env.local", _GUARD_CFG) + assert ok is False + report = check_guardrails([".env.local"], _GUARD_CFG) + assert report["ok"] is False + assert ".env.local" in report["blocked_hits"] + + +def test_nested_env_production_blocked() -> None: + path = "sub/.env.production" + ok, _reason = propose.path_allowed(path, _GUARD_CFG) + assert ok is False + report = check_guardrails([path], _GUARD_CFG) + assert report["ok"] is False + assert path in report["blocked_hits"] + + +def test_nested_env_example_not_blocked() -> None: + path = "sub/.env.example" + ok, _reason = propose.path_allowed(path, _GUARD_CFG) + assert ok is True + report = check_guardrails([path], _GUARD_CFG) + assert report["ok"] is True + assert path not in report["blocked_hits"] diff --git a/tests/ccbot/handlers/test_callback_data.py b/tests/ccbot/handlers/test_callback_data.py new file mode 100644 index 000000000..a27b78ec9 --- /dev/null +++ b/tests/ccbot/handlers/test_callback_data.py @@ -0,0 +1,178 @@ +"""Tests for REF-006 typed callback_data: parse, round-trip, validation, safety.""" + +from dataclasses import FrozenInstanceError + +import pytest + +from ccbot.handlers.callback_data import ( + CB_ASK_UP, + CB_DIR_SELECT, + CB_HISTORY_NEXT, + CB_HISTORY_PREV, + CB_KEYS_PREFIX, + CB_SCREENSHOT_REFRESH, + MAX_CALLBACK_DATA_BYTES, + AskCallback, + DirPageCallback, + DirSelectCallback, + HistoryCallback, + KeyCallback, + ScreenshotRefreshCallback, + SessionSelectCallback, + WinBindCallback, + parse_ask, + parse_dir_page, + parse_dir_select, + parse_history, + parse_key, + parse_screenshot_refresh, + parse_session_select, + parse_win_bind, + safe_callback_data, +) + + +class TestHistoryParse: + def test_current_format_newer(self): + raw = f"{CB_HISTORY_NEXT}2:@12:100:500" + cb = parse_history(raw) + assert cb == HistoryCallback( + older=False, page=2, window_id="@12", start_byte=100, end_byte=500 + ) + + def test_legacy_format(self): + raw = f"{CB_HISTORY_PREV}3:@9" + cb = parse_history(raw) + assert cb is not None + assert cb.page == 3 + assert cb.window_id == "@9" + assert cb.start_byte == 0 + assert cb.end_byte == 0 + + def test_window_id_with_colons(self): + raw = f"{CB_HISTORY_PREV}1:sess:abc:def:10:20" + cb = parse_history(raw) + assert cb is not None + assert cb.window_id == "sess:abc:def" + assert cb.start_byte == 10 + assert cb.end_byte == 20 + + def test_malformed_returns_none(self): + assert parse_history("hp:") is None + assert parse_history("hp:not-a-number:@1:0:0") is None + assert parse_history("noop") is None + assert parse_history("db:sel:1") is None + + def test_negative_page_rejected(self): + assert parse_history(f"{CB_HISTORY_PREV}-1:@1:0:0") is None + + +class TestIndexParsers: + def test_dir_select(self): + assert parse_dir_select(f"{CB_DIR_SELECT}7") == DirSelectCallback(index=7) + assert parse_dir_select(f"{CB_DIR_SELECT}x") is None + assert parse_dir_select("noop") is None + + def test_dir_page_win_session(self): + assert parse_dir_page("db:page:3") == DirPageCallback(page=3) + assert parse_win_bind("wb:sel:0") == WinBindCallback(index=0) + assert parse_session_select("rs:sel:4") == SessionSelectCallback(index=4) + + +class TestWindowAndKeyParsers: + def test_screenshot_refresh(self): + cb = parse_screenshot_refresh(f"{CB_SCREENSHOT_REFRESH}@42") + assert cb == ScreenshotRefreshCallback(window_id="@42") + assert parse_screenshot_refresh(CB_SCREENSHOT_REFRESH) is None + + def test_ask(self): + raw = f"{CB_ASK_UP}@7" + cb = parse_ask(raw, CB_ASK_UP) + assert cb == AskCallback(prefix=CB_ASK_UP, window_id="@7") + assert parse_ask("aq:up:", CB_ASK_UP) is None + + def test_key(self): + cb = parse_key(f"{CB_KEYS_PREFIX}spc:@3") + assert cb == KeyCallback(key_id="spc", window_id="@3") + assert parse_key(f"{CB_KEYS_PREFIX}nocolon") is None + assert parse_key(f"{CB_KEYS_PREFIX}:@1") is None + + +class TestRoundTrip: + @pytest.mark.parametrize( + "cb", + [ + HistoryCallback( + older=True, page=0, window_id="@1", start_byte=0, end_byte=0 + ), + HistoryCallback( + older=False, page=2, window_id="@12", start_byte=10, end_byte=20 + ), + DirSelectCallback(index=7), + DirPageCallback(page=3), + WinBindCallback(index=1), + SessionSelectCallback(index=2), + AskCallback(prefix=CB_ASK_UP, window_id="@5"), + ScreenshotRefreshCallback(window_id="@9"), + KeyCallback(key_id="esc", window_id="@0"), + ], + ) + def test_serialize_then_parse(self, cb): + serialized = safe_callback_data(cb) + parsers = { + HistoryCallback: parse_history, + DirSelectCallback: parse_dir_select, + DirPageCallback: parse_dir_page, + WinBindCallback: parse_win_bind, + SessionSelectCallback: parse_session_select, + AskCallback: lambda s: parse_ask(s, CB_ASK_UP), + ScreenshotRefreshCallback: parse_screenshot_refresh, + KeyCallback: parse_key, + } + parser = parsers[type(cb)] + assert parser(serialized) == cb + + +class TestValidation: + def test_negative_index_construction(self): + with pytest.raises(ValueError, match="non-negative"): + DirSelectCallback(index=-1) + + def test_empty_window_id(self): + with pytest.raises(ValueError, match="window_id"): + KeyCallback(key_id="up", window_id="") + + +class TestImmutability: + def test_frozen_prevents_mutation(self): + cb = HistoryCallback(older=True, page=0, window_id="@x") + with pytest.raises(FrozenInstanceError): + cb.page = 1 # type: ignore[misc] + + +class TestSafeSerialization: + def test_within_limit(self): + cb = HistoryCallback(older=True, page=0, window_id="@1") + assert safe_callback_data(cb) == cb.to_old_string() + + def test_string_passthrough(self): + assert safe_callback_data("noop") == "noop" + assert safe_callback_data("db:up") == "db:up" + + def test_empty_raises(self): + with pytest.raises(ValueError, match="non-empty"): + safe_callback_data("") + + def test_exceeds_limit_raises(self): + long_window = "w" * 100 + cb = KeyCallback(key_id="up", window_id=long_window) + with pytest.raises(ValueError, match="too long"): + safe_callback_data(cb) + + def test_multibyte_over_limit(self): + # 22 * 3 bytes = 66 > 64 + payload = "€" * 22 + assert len(payload) < MAX_CALLBACK_DATA_BYTES + assert len(payload.encode("utf-8")) > MAX_CALLBACK_DATA_BYTES + with pytest.raises(ValueError, match="too long"): + safe_callback_data(payload) diff --git a/tests/ccbot/handlers/test_command_handlers.py b/tests/ccbot/handlers/test_command_handlers.py new file mode 100644 index 000000000..d9190b602 --- /dev/null +++ b/tests/ccbot/handlers/test_command_handlers.py @@ -0,0 +1,173 @@ +"""Unit tests for command_handlers auth/session paths (REF-009 / ISS-001).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ccbot.handlers.command_handlers import ( + esc_command, + start_command, + unbind_command, +) +from ccbot.session_guard import SessionContext +from ccbot.tmux_manager import TmuxWindow + +_CH = "ccbot.handlers.command_handlers" + + +def _user(uid: int = 1) -> MagicMock: + u = MagicMock() + u.id = uid + return u + + +def _update( + *, + user_id: int = 1, + message: bool = True, + thread_id: int | None = 42, +) -> MagicMock: + upd = MagicMock() + user = _user(user_id) + upd.effective_user = user + if message: + msg = MagicMock() + msg.message_thread_id = thread_id + msg.reply_document = AsyncMock() + upd.message = msg + else: + upd.message = None + upd.callback_query = None + return upd + + +def _context() -> MagicMock: + ctx = MagicMock() + ctx.user_data = {} + ctx.bot = AsyncMock() + return ctx + + +def _session_ctx( + *, + user: MagicMock | None = None, + thread_id: int = 42, + window_id: str = "@8", +) -> SessionContext: + user = user or _user() + win = TmuxWindow(window_id=window_id, window_name="p", cwd="/t") + return SessionContext( + user=user, thread_id=thread_id, window_id=window_id, window=win + ) + + +@pytest.mark.asyncio +async def test_start_unauthorized_no_welcome() -> None: + upd = _update(user_id=99) + ctx = _context() + with ( + patch(f"{_CH}.require_user", new_callable=AsyncMock, return_value=None), + patch(f"{_CH}.safe_reply", new_callable=AsyncMock) as reply, + ): + await start_command(upd, ctx) + # Unauthorized reply is owned by require_user, not the handler. + reply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_start_authorized_sends_welcome() -> None: + upd = _update() + ctx = _context() + with ( + patch(f"{_CH}.require_user", new_callable=AsyncMock, return_value=_user()), + patch(f"{_CH}.clear_browse_state") as clear, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock) as reply, + ): + await start_command(upd, ctx) + clear.assert_called_once_with(ctx.user_data) + reply.assert_awaited_once() + assert "Claude Code Monitor" in reply.await_args.args[1] + + +@pytest.mark.asyncio +async def test_esc_requires_session() -> None: + upd = _update() + ctx = _context() + with ( + patch(f"{_CH}.require_session", new_callable=AsyncMock, return_value=None), + patch(f"{_CH}.tmux_manager") as tmux, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock) as reply, + ): + await esc_command(upd, ctx) + tmux.send_keys.assert_not_called() + # No-session reply is owned by require_session. + reply.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_esc_sends_escape_key() -> None: + upd = _update() + ctx = _context() + sctx = _session_ctx() + with ( + patch(f"{_CH}.require_session", new_callable=AsyncMock, return_value=sctx), + patch(f"{_CH}.tmux_manager") as tmux, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock) as reply, + ): + tmux.send_keys = AsyncMock(return_value=True) + await esc_command(upd, ctx) + tmux.send_keys.assert_awaited_once_with("@8", "\x1b", enter=False) + reply.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_unbind_requires_topic() -> None: + upd = _update(thread_id=None) + ctx = _context() + with ( + patch(f"{_CH}.require_user", new_callable=AsyncMock, return_value=_user()), + patch(f"{_CH}.get_thread_id", return_value=None), + patch(f"{_CH}.safe_reply", new_callable=AsyncMock) as reply, + ): + await unbind_command(upd, ctx) + assert "only works in a topic" in reply.await_args.args[1] + + +@pytest.mark.asyncio +async def test_unbind_clears_binding() -> None: + upd = _update(thread_id=9) + ctx = _context() + user = _user() + with ( + patch(f"{_CH}.require_user", new_callable=AsyncMock, return_value=user), + patch(f"{_CH}.get_thread_id", return_value=9), + patch( + f"{_CH}.require_bound_window_id", + new_callable=AsyncMock, + return_value=(user, 9, "@3"), + ), + patch(f"{_CH}.session_manager") as sm, + patch(f"{_CH}.clear_topic_state", new_callable=AsyncMock) as clear, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock) as reply, + ): + sm.get_display_name.return_value = "proj" + await unbind_command(upd, ctx) + sm.unbind_thread.assert_called_once_with(1, 9) + clear.assert_awaited_once() + assert "unbound" in reply.await_args.args[1].lower() + + +@pytest.mark.asyncio +async def test_esc_missing_window() -> None: + upd = _update() + ctx = _context() + with ( + patch(f"{_CH}.require_session", new_callable=AsyncMock, return_value=None), + patch(f"{_CH}.tmux_manager") as tmux, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock) as reply, + ): + await esc_command(upd, ctx) + tmux.send_keys.assert_not_called() + reply.assert_not_awaited() diff --git a/tests/ccbot/handlers/test_document.py b/tests/ccbot/handlers/test_document.py new file mode 100644 index 000000000..f62e0f58c --- /dev/null +++ b/tests/ccbot/handlers/test_document.py @@ -0,0 +1,205 @@ +"""Tests for document_handler (ISS-021 / upstream PR #89, require_session rewrite). + +Pure helpers are tested without stubs. Handler paths use the ISS-004 +stub-environment style (MagicMock/AsyncMock + patch of require_*). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram.error import BadRequest + +from ccbot.handlers.document import ( + _MAX_DOC_BYTES, + _format_size, + _safe_filename, + document_handler, +) +from ccbot.session_guard import SessionContext +from ccbot.tmux_manager import TmuxWindow + +_DOC = "ccbot.handlers.document" + + +def _user(uid: int = 1) -> MagicMock: + u = MagicMock() + u.id = uid + return u + + +def _session_ctx( + *, + user: MagicMock | None = None, + thread_id: int = 42, + window_id: str = "@8", +) -> SessionContext: + user = user or _user() + win = TmuxWindow(window_id=window_id, window_name="p", cwd="/t") + return SessionContext( + user=user, thread_id=thread_id, window_id=window_id, window=win + ) + + +def _doc_update( + *, + file_size: int | None = 100, + file_name: str | None = "note.pdf", + file_unique_id: str = "uid1", + caption: str | None = None, + chat_type: str = "private", +) -> MagicMock: + upd = MagicMock() + user = _user() + upd.effective_user = user + msg = MagicMock() + msg.caption = caption + msg.chat = MagicMock() + msg.chat.type = chat_type + msg.chat.send_action = AsyncMock() + doc = MagicMock() + doc.file_size = file_size + doc.file_name = file_name + doc.file_unique_id = file_unique_id + doc.get_file = AsyncMock() + msg.document = doc + upd.message = msg + upd.callback_query = None + return upd + + +class TestSafeFilename: + """Pure _safe_filename: traversal, unicode, empty/dotfile-only.""" + + def test_path_traversal_uses_basename(self) -> None: + assert _safe_filename("../../etc/passwd") == "passwd" + assert _safe_filename("..\\..\\windows\\system32\\config") == "config" + + def test_special_and_unicode_become_underscore(self) -> None: + assert _safe_filename("hello world.pdf") == "hello_world.pdf" + assert _safe_filename("caf\u00e9.pdf") == "caf_.pdf" + assert _safe_filename("file/name?.txt") == "name_.txt" + + def test_empty_or_dotfile_only_becomes_file(self) -> None: + assert _safe_filename("") == "file" + assert _safe_filename(".") == "file" + assert _safe_filename("..") == "file" + assert _safe_filename("._") == "file" + assert _safe_filename("...") == "file" + + +class TestFormatSize: + """Pure _format_size unit boundaries (B / KB / MB / GB).""" + + def test_bytes_below_1024(self) -> None: + assert _format_size(0) == "0.0 B" + assert _format_size(1023) == "1023.0 B" + + def test_kilobytes_at_1024(self) -> None: + assert _format_size(1024) == "1.0 KB" + + def test_megabytes_at_1_mib(self) -> None: + assert _format_size(1024 * 1024) == "1.0 MB" + assert _format_size(_MAX_DOC_BYTES) == "20.0 MB" + + def test_gigabytes_at_1_gib(self) -> None: + assert _format_size(1024 * 1024 * 1024) == "1.0 GB" + + +class TestDocumentHandler: + """document_handler on ISS-004-style stubs: reject, assemble, fallback, abort.""" + + @pytest.mark.asyncio + async def test_oversize_rejects_without_download(self) -> None: + upd = _doc_update(file_size=_MAX_DOC_BYTES + 1) + sctx = _session_ctx() + with ( + patch(f"{_DOC}.require_user", new_callable=AsyncMock, return_value=_user()), + patch(f"{_DOC}.require_session", new_callable=AsyncMock, return_value=sctx), + patch(f"{_DOC}.get_thread_id", return_value=42), + patch(f"{_DOC}.safe_reply", new_callable=AsyncMock) as reply, + patch(f"{_DOC}.session_manager") as sm, + ): + await document_handler(upd, MagicMock()) + upd.message.document.get_file.assert_not_called() + sm.send_to_window.assert_not_called() + reply.assert_awaited_once() + text = reply.await_args.args[1] + assert "too large" in text + assert _format_size(_MAX_DOC_BYTES + 1) in text + + @pytest.mark.asyncio + async def test_caption_assembles_attached_path(self) -> None: + upd = _doc_update(caption="please read") + tg_file = MagicMock() + tg_file.download_to_drive = AsyncMock() + upd.message.document.get_file = AsyncMock(return_value=tg_file) + sctx = _session_ctx() + with ( + patch(f"{_DOC}.require_user", new_callable=AsyncMock, return_value=_user()), + patch(f"{_DOC}.require_session", new_callable=AsyncMock, return_value=sctx), + patch(f"{_DOC}.get_thread_id", return_value=42), + patch(f"{_DOC}.safe_reply", new_callable=AsyncMock), + patch(f"{_DOC}.clear_status_msg_info"), + patch(f"{_DOC}.session_manager") as sm, + ): + sm.send_to_window = AsyncMock(return_value=(True, "ok")) + await document_handler(upd, MagicMock()) + sm.send_to_window.assert_awaited_once() + sent = sm.send_to_window.await_args.args[1] + assert sent.startswith("please read\n\n(file attached: ") + assert sent.endswith(")") + + @pytest.mark.asyncio + async def test_no_caption_is_attached_path_only(self) -> None: + upd = _doc_update(caption=None) + tg_file = MagicMock() + tg_file.download_to_drive = AsyncMock() + upd.message.document.get_file = AsyncMock(return_value=tg_file) + sctx = _session_ctx() + with ( + patch(f"{_DOC}.require_user", new_callable=AsyncMock, return_value=_user()), + patch(f"{_DOC}.require_session", new_callable=AsyncMock, return_value=sctx), + patch(f"{_DOC}.get_thread_id", return_value=42), + patch(f"{_DOC}.safe_reply", new_callable=AsyncMock), + patch(f"{_DOC}.clear_status_msg_info"), + patch(f"{_DOC}.session_manager") as sm, + ): + sm.send_to_window = AsyncMock(return_value=(True, "ok")) + await document_handler(upd, MagicMock()) + sent = sm.send_to_window.await_args.args[1] + assert sent.startswith("(file attached: ") + assert "\n\n" not in sent + + @pytest.mark.asyncio + async def test_badrequest_on_get_file_replies_without_raising(self) -> None: + upd = _doc_update() + upd.message.document.get_file = AsyncMock(side_effect=BadRequest("too big")) + sctx = _session_ctx() + with ( + patch(f"{_DOC}.require_user", new_callable=AsyncMock, return_value=_user()), + patch(f"{_DOC}.require_session", new_callable=AsyncMock, return_value=sctx), + patch(f"{_DOC}.get_thread_id", return_value=42), + patch(f"{_DOC}.safe_reply", new_callable=AsyncMock) as reply, + patch(f"{_DOC}.session_manager") as sm, + ): + await document_handler(upd, MagicMock()) + sm.send_to_window.assert_not_called() + reply.assert_awaited_once() + assert "Could not download" in reply.await_args.args[1] + + @pytest.mark.asyncio + async def test_require_session_none_is_noop(self) -> None: + upd = _doc_update() + with ( + patch(f"{_DOC}.require_user", new_callable=AsyncMock, return_value=_user()), + patch(f"{_DOC}.require_session", new_callable=AsyncMock, return_value=None), + patch(f"{_DOC}.get_thread_id", return_value=42), + patch(f"{_DOC}.safe_reply", new_callable=AsyncMock) as reply, + patch(f"{_DOC}.session_manager") as sm, + ): + await document_handler(upd, MagicMock()) + upd.message.document.get_file.assert_not_called() + sm.send_to_window.assert_not_called() + reply.assert_not_awaited() diff --git a/tests/ccbot/handlers/test_interactive_ui.py b/tests/ccbot/handlers/test_interactive_ui.py index 8d6a98e49..f446ed0fc 100644 --- a/tests/ccbot/handlers/test_interactive_ui.py +++ b/tests/ccbot/handlers/test_interactive_ui.py @@ -4,10 +4,6 @@ import pytest -from ccbot.handlers.interactive_ui import ( - _build_interactive_keyboard, - handle_interactive_ui, -) from ccbot.handlers.callback_data import ( CB_ASK_DOWN, CB_ASK_ENTER, @@ -18,6 +14,10 @@ CB_ASK_TAB, CB_ASK_UP, ) +from ccbot.handlers.interactive_ui import ( + _build_interactive_keyboard, + handle_interactive_ui, +) @pytest.fixture diff --git a/tests/ccbot/handlers/test_message_queue_enqueue.py b/tests/ccbot/handlers/test_message_queue_enqueue.py new file mode 100644 index 000000000..3506412a3 --- /dev/null +++ b/tests/ccbot/handlers/test_message_queue_enqueue.py @@ -0,0 +1,157 @@ +"""Focused tests for message queue enqueue / flood / worker (REF-009 + REF-005).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ccbot.handlers import message_queue as mq +from ccbot.handlers.message_queue import ( + MessageTask, + enqueue_content_message, + enqueue_status_update, + get_message_queue, + get_or_create_queue, + queue_manager, + shutdown_workers, +) + + +@pytest.fixture(autouse=True) +async def _isolate_queue_state(): + """Reset queue_manager state around each test (no leftover workers).""" + await shutdown_workers() + yield + await shutdown_workers() + + +@pytest.mark.asyncio +async def test_enqueue_content_creates_queue_and_task() -> None: + bot = MagicMock() + with patch.object(mq, "_message_queue_worker", new_callable=AsyncMock): + await enqueue_content_message( + bot, + user_id=1, + window_id="@1", + parts=["hello"], + content_type="text", + thread_id=7, + ) + q = get_message_queue(1) + assert q is not None + assert q.qsize() == 1 + task = q.get_nowait() + assert isinstance(task, MessageTask) + assert task.task_type == "content" + assert task.parts == ["hello"] + assert task.window_id == "@1" + assert task.thread_id == 7 + + +@pytest.mark.asyncio +async def test_enqueue_status_skipped_during_flood() -> None: + bot = MagicMock() + queue_manager.set_flood(1, 99) + with patch.object(mq, "_message_queue_worker", new_callable=AsyncMock): + await enqueue_status_update( + bot, + user_id=1, + window_id="@1", + status_text="working…", + thread_id=3, + ) + assert get_message_queue(1) is None + + +@pytest.mark.asyncio +async def test_enqueue_status_dedupes_same_text() -> None: + bot = MagicMock() + with patch.object(mq, "_message_queue_worker", new_callable=AsyncMock): + queue_manager.status.set_status(1, 5, 10, "@2", "same") + await enqueue_status_update( + bot, + user_id=1, + window_id="@2", + status_text="same", + thread_id=5, + ) + assert get_message_queue(1) is None + + await enqueue_status_update( + bot, + user_id=1, + window_id="@2", + status_text="changed", + thread_id=5, + ) + q = get_message_queue(1) + assert q is not None + task = q.get_nowait() + assert task.task_type == "status_update" + assert task.text == "changed" + assert task.thread_id == 5 + + +@pytest.mark.asyncio +async def test_enqueue_status_clear() -> None: + bot = MagicMock() + with patch.object(mq, "_message_queue_worker", new_callable=AsyncMock): + await enqueue_status_update( + bot, + user_id=1, + window_id="@1", + status_text=None, + thread_id=2, + ) + q = get_message_queue(1) + assert q is not None + task = q.get_nowait() + assert task.task_type == "status_clear" + assert task.thread_id == 2 + + +@pytest.mark.asyncio +async def test_worker_processes_content_and_marks_done() -> None: + bot = MagicMock() + processed: list[MessageTask] = [] + + async def fake_process(_bot, _user_id, task: MessageTask) -> None: + processed.append(task) + + with patch.object(mq, "_process_content_with_retry", side_effect=fake_process): + q: asyncio.Queue[MessageTask] = asyncio.Queue() + queue_manager._queues[3] = q + queue_manager._locks[3] = asyncio.Lock() + worker = asyncio.create_task(mq._message_queue_worker(bot, 3)) + queue_manager._workers[3] = worker + await q.put( + MessageTask( + task_type="content", + window_id="@9", + parts=["hi"], + thread_id=0, + ) + ) + for _ in range(50): + if processed: + break + await asyncio.sleep(0.01) + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass + assert len(processed) == 1 + assert processed[0].parts == ["hi"] + + +@pytest.mark.asyncio +async def test_get_or_create_queue_starts_worker_once() -> None: + bot = MagicMock() + with patch.object(mq, "_message_queue_worker", new_callable=AsyncMock) as worker_fn: + q1 = get_or_create_queue(bot, 42) + q2 = get_or_create_queue(bot, 42) + assert q1 is q2 + assert worker_fn.call_count == 1 diff --git a/tests/ccbot/handlers/test_message_queue_state.py b/tests/ccbot/handlers/test_message_queue_state.py new file mode 100644 index 000000000..477e190e5 --- /dev/null +++ b/tests/ccbot/handlers/test_message_queue_state.py @@ -0,0 +1,175 @@ +"""Unit tests for MessageQueueManager / StatusTracker (REF-005).""" + +from __future__ import annotations + +import asyncio + +import pytest + +from ccbot.handlers.message_queue import ( + MERGE_MAX_LENGTH, + MessageQueueManager, + MessageTask, + StatusTracker, + _can_merge_tasks, + _merge_content_tasks, + queue_manager, +) + + +class TestStatusTracker: + def test_status_roundtrip(self) -> None: + st = StatusTracker() + st.set_status(1, 0, 10, "@1", "hello") + assert st.get_status(1, 0) == (10, "@1", "hello") + assert st.pop_status(1, 0) == (10, "@1", "hello") + assert st.get_status(1, 0) is None + + def test_tool_ids_and_topic_clear(self) -> None: + st = StatusTracker() + st.set_tool_msg_id("tu1", 1, 5, 100) + st.set_tool_msg_id("tu2", 1, 5, 101) + st.set_tool_msg_id("tu3", 1, 9, 102) + st.clear_tools_for_topic(1, 5) + assert st.get_tool_msg_id("tu1", 1, 5) is None + assert st.get_tool_msg_id("tu2", 1, 5) is None + assert st.get_tool_msg_id("tu3", 1, 9) == 102 + + def test_clear_all(self) -> None: + st = StatusTracker() + st.set_status(1, 0, 1, "@1", "x") + st.set_tool_msg_id("t", 1, 0, 2) + st.clear_all() + assert st.get_status(1, 0) is None + assert st.get_tool_msg_id("t", 1, 0) is None + + +class TestMessageQueueManager: + def test_flood_lifecycle(self) -> None: + mgr = MessageQueueManager() + assert mgr.is_flooded(1) is False + mgr.set_flood(1, 30.0) + assert mgr.is_flooded(1) is True + assert mgr.flood_remaining(1) > 0 + mgr.clear_flood(1) + assert mgr.is_flooded(1) is False + assert mgr.flood_remaining(1) == 0.0 + + def test_get_queue_none_until_created(self) -> None: + mgr = MessageQueueManager() + assert mgr.get_queue(99) is None + + @pytest.mark.asyncio + async def test_shutdown_clears_state(self) -> None: + mgr = MessageQueueManager() + q: asyncio.Queue[MessageTask] = asyncio.Queue() + mgr._queues[1] = q + mgr._locks[1] = asyncio.Lock() + + async def _noop() -> None: + await asyncio.Event().wait() + + mgr._workers[1] = asyncio.create_task(_noop()) + mgr.status.set_status(1, 0, 1, "@1", "x") + mgr.set_flood(1, 10.0) + await mgr.shutdown() + assert mgr.get_queue(1) is None + assert mgr.status.get_status(1) is None + assert mgr.is_flooded(1) is False + assert mgr._workers == {} + assert mgr._locks == {} + + +def test_can_merge_same_window_text() -> None: + a = MessageTask(task_type="content", window_id="@1", parts=["a"]) + b = MessageTask(task_type="content", window_id="@1", parts=["b"]) + assert _can_merge_tasks(a, b) is True + + +def test_cannot_merge_tool_use() -> None: + a = MessageTask( + task_type="content", window_id="@1", parts=["a"], content_type="tool_use" + ) + b = MessageTask(task_type="content", window_id="@1", parts=["b"]) + assert _can_merge_tasks(a, b) is False + + +@pytest.mark.asyncio +async def test_merge_content_tasks_respects_limit() -> None: + q: asyncio.Queue[MessageTask] = asyncio.Queue() + first = MessageTask(task_type="content", window_id="@1", parts=["x" * 100]) + # next chunk would exceed limit + big = MessageTask( + task_type="content", + window_id="@1", + parts=["y" * (MERGE_MAX_LENGTH)], + ) + small = MessageTask(task_type="content", window_id="@1", parts=["z"]) + q.put_nowait(big) + q.put_nowait(small) + lock = asyncio.Lock() + merged, count = await _merge_content_tasks(q, first, lock) + assert count == 0 + assert merged is first + assert q.qsize() == 2 + + +def test_module_singleton_exists() -> None: + assert isinstance(queue_manager, MessageQueueManager) + assert isinstance(queue_manager.status, StatusTracker) + + +N_PARALLEL = 50 + + +async def _concurrent_status_increment( + st: StatusTracker, + *, + lock: asyncio.Lock | None, + user_id: int = 1, + thread_id: int = 0, +) -> None: + """Read-modify-write status message_id with optional critical section. + + ``await asyncio.sleep(0)`` yields so 50 concurrent tasks interleave between + read and write — classic lost-update race without a lock. + """ + + async def _body() -> None: + info = st.get_status(user_id, thread_id) + current = info[0] if info else 0 + await asyncio.sleep(0) + st.set_status(user_id, thread_id, current + 1, "@1", "inc") + + if lock is None: + await _body() + else: + async with lock: + await _body() + + +@pytest.mark.asyncio +async def test_gather_50_status_updates_lose_without_lock() -> None: + """Without asyncio.Lock, parallel RMW loses updates (counter < 50).""" + st = StatusTracker() + await asyncio.gather( + *[_concurrent_status_increment(st, lock=None) for _ in range(N_PARALLEL)] + ) + final = st.get_status(1, 0) + assert final is not None + assert final[0] < N_PARALLEL, ( + f"expected lost updates without lock, got message_id={final[0]}" + ) + + +@pytest.mark.asyncio +async def test_gather_50_status_updates_safe_with_lock() -> None: + """With asyncio.Lock, all 50 parallel RMW complete (counter == 50).""" + st = StatusTracker() + lock = asyncio.Lock() + await asyncio.gather( + *[_concurrent_status_increment(st, lock=lock) for _ in range(N_PARALLEL)] + ) + final = st.get_status(1, 0) + assert final is not None + assert final[0] == N_PARALLEL diff --git a/tests/ccbot/test_capture_registry.py b/tests/ccbot/test_capture_registry.py new file mode 100644 index 000000000..b8426e0c1 --- /dev/null +++ b/tests/ccbot/test_capture_registry.py @@ -0,0 +1,90 @@ +"""Tests for CaptureTaskRegistry (ISS-010).""" + +import asyncio +import contextlib + +from ccbot.handlers.capture_registry import CaptureTaskRegistry + + +async def _never() -> None: + await asyncio.sleep(3600) + + +async def test_register_and_discard_does_not_cancel() -> None: + reg = CaptureTaskRegistry() + task = asyncio.create_task(_never()) + reg.register(1, 100, task) + reg.discard(1, 100) + # discard removes the entry without cancelling the task + assert not task.done() + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +async def test_cancel_running_task() -> None: + reg = CaptureTaskRegistry() + task = asyncio.create_task(_never()) + reg.register(1, 100, task) + reg.cancel(1, 100) + with contextlib.suppress(asyncio.CancelledError): + await task + assert task.cancelled() + + +async def test_cancel_missing_key_is_noop() -> None: + reg = CaptureTaskRegistry() + reg.cancel(999, 999) + + +async def test_cancel_done_task_does_not_raise() -> None: + reg = CaptureTaskRegistry() + + async def done_worker() -> None: + return None + + task = asyncio.create_task(done_worker()) + await task + reg.register(1, 100, task) + reg.cancel(1, 100) + assert task.done() + + +async def test_discard_stale_task_does_not_evict_newer() -> None: + """Stale finally-discard must not pop a newer registration (stale-evict).""" + reg = CaptureTaskRegistry() + task_a = asyncio.create_task(_never()) + task_b = asyncio.create_task(_never()) + try: + reg.register(1, 100, task_a) + reg.cancel(1, 100) + reg.register(1, 100, task_b) + # A's finally: identity-checked discard must leave B registered + reg.discard(1, 100, task_a) + reg.cancel(1, 100) + with contextlib.suppress(asyncio.CancelledError): + await task_b + assert task_b.cancelled() + finally: + for t in (task_a, task_b): + if not t.done(): + t.cancel() + with contextlib.suppress(asyncio.CancelledError): + await t + + +async def test_discard_matching_task_removes() -> None: + """Matching identity removes the entry without cancelling (matching-remove).""" + reg = CaptureTaskRegistry() + task = asyncio.create_task(_never()) + try: + reg.register(1, 100, task) + reg.discard(1, 100, task) + # entry gone → cancel is no-op; task still running (discard does not cancel) + reg.cancel(1, 100) + assert not task.done() + finally: + if not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task diff --git a/tests/ccbot/test_forward_command.py b/tests/ccbot/test_forward_command.py index 05f759bcf..8c3c5f7d8 100644 --- a/tests/ccbot/test_forward_command.py +++ b/tests/ccbot/test_forward_command.py @@ -4,6 +4,9 @@ import pytest +from ccbot.session_guard import SessionContext +from ccbot.tmux_manager import TmuxWindow + def _make_update(text: str, user_id: int = 1, thread_id: int = 42) -> MagicMock: """Build a minimal mock Update with message text in a forum topic.""" @@ -15,6 +18,7 @@ def _make_update(text: str, user_id: int = 1, thread_id: int = 42) -> MagicMock: update.message.message_thread_id = thread_id update.message.chat = MagicMock() update.message.chat.send_action = AsyncMock() + update.callback_query = None update.effective_chat = MagicMock() update.effective_chat.type = "supergroup" update.effective_chat.id = 100 @@ -29,26 +33,37 @@ def _make_context() -> MagicMock: return context +def _sctx(user: MagicMock, window_id: str = "@5") -> SessionContext: + win = TmuxWindow(window_id=window_id, window_name="project", cwd="/t") + return SessionContext(user=user, thread_id=42, window_id=window_id, window=win) + + +_CH = "ccbot.handlers.command_handlers" + + class TestForwardCommand: @pytest.mark.asyncio async def test_model_sends_command_to_tmux(self): """/model → send_to_window called with "/model".""" update = _make_update("/model") context = _make_context() + user = update.effective_user with ( - patch("ccbot.bot.is_user_allowed", return_value=True), - patch("ccbot.bot._get_thread_id", return_value=42), - patch("ccbot.bot.session_manager") as mock_sm, - patch("ccbot.bot.tmux_manager") as mock_tmux, - patch("ccbot.bot.safe_reply", new_callable=AsyncMock), + patch(f"{_CH}.require_user", new_callable=AsyncMock, return_value=user), + patch(f"{_CH}.get_thread_id", return_value=42), + patch( + f"{_CH}.require_session", + new_callable=AsyncMock, + return_value=_sctx(user), + ), + patch(f"{_CH}.session_manager") as mock_sm, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock), ): - mock_sm.resolve_window_for_thread.return_value = "@5" mock_sm.get_display_name.return_value = "project" - mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock()) mock_sm.send_to_window = AsyncMock(return_value=(True, "ok")) - from ccbot.bot import forward_command_handler + from ccbot.handlers.command_handlers import forward_command_handler await forward_command_handler(update, context) @@ -59,20 +74,23 @@ async def test_cost_sends_command_to_tmux(self): """/cost → send_to_window called with "/cost".""" update = _make_update("/cost") context = _make_context() + user = update.effective_user with ( - patch("ccbot.bot.is_user_allowed", return_value=True), - patch("ccbot.bot._get_thread_id", return_value=42), - patch("ccbot.bot.session_manager") as mock_sm, - patch("ccbot.bot.tmux_manager") as mock_tmux, - patch("ccbot.bot.safe_reply", new_callable=AsyncMock), + patch(f"{_CH}.require_user", new_callable=AsyncMock, return_value=user), + patch(f"{_CH}.get_thread_id", return_value=42), + patch( + f"{_CH}.require_session", + new_callable=AsyncMock, + return_value=_sctx(user), + ), + patch(f"{_CH}.session_manager") as mock_sm, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock), ): - mock_sm.resolve_window_for_thread.return_value = "@5" mock_sm.get_display_name.return_value = "project" - mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock()) mock_sm.send_to_window = AsyncMock(return_value=(True, "ok")) - from ccbot.bot import forward_command_handler + from ccbot.handlers.command_handlers import forward_command_handler await forward_command_handler(update, context) @@ -83,20 +101,23 @@ async def test_clear_clears_session(self): """/clear → send_to_window + clear_window_session.""" update = _make_update("/clear") context = _make_context() + user = update.effective_user with ( - patch("ccbot.bot.is_user_allowed", return_value=True), - patch("ccbot.bot._get_thread_id", return_value=42), - patch("ccbot.bot.session_manager") as mock_sm, - patch("ccbot.bot.tmux_manager") as mock_tmux, - patch("ccbot.bot.safe_reply", new_callable=AsyncMock), + patch(f"{_CH}.require_user", new_callable=AsyncMock, return_value=user), + patch(f"{_CH}.get_thread_id", return_value=42), + patch( + f"{_CH}.require_session", + new_callable=AsyncMock, + return_value=_sctx(user), + ), + patch(f"{_CH}.session_manager") as mock_sm, + patch(f"{_CH}.safe_reply", new_callable=AsyncMock), ): - mock_sm.resolve_window_for_thread.return_value = "@5" mock_sm.get_display_name.return_value = "project" - mock_tmux.find_window_by_id = AsyncMock(return_value=MagicMock()) mock_sm.send_to_window = AsyncMock(return_value=(True, "ok")) - from ccbot.bot import forward_command_handler + from ccbot.handlers.command_handlers import forward_command_handler await forward_command_handler(update, context) diff --git a/tests/ccbot/test_lazy_dirs.py b/tests/ccbot/test_lazy_dirs.py new file mode 100644 index 000000000..e60df62c8 --- /dev/null +++ b/tests/ccbot/test_lazy_dirs.py @@ -0,0 +1,44 @@ +"""ISS-023: lazy init of cache dirs — no import-time mkdir side effects.""" + +from __future__ import annotations + +import importlib +from pathlib import Path + +from ccbot.handlers import document, message_handlers +from ccbot.utils import ensure_dir + + +def test_ensure_dir_creates_and_returns_path(tmp_path: Path) -> None: + target = tmp_path / "nested" / "cache" + result = ensure_dir(target) + assert result == target + assert target.is_dir() + + +def test_ensure_dir_idempotent(tmp_path: Path) -> None: + target = tmp_path / "cache" + ensure_dir(target) + ensure_dir(target) # second call must not raise + assert target.is_dir() + + +def test_handler_imports_do_not_create_dirs(tmp_path: Path, monkeypatch) -> None: + """Reloading handler modules with a fresh CCBOT_DIR must not mkdir.""" + monkeypatch.setenv("CCBOT_DIR", str(tmp_path)) + try: + importlib.reload(message_handlers) + importlib.reload(document) + + assert not (tmp_path / "images").exists() + assert not (tmp_path / "audio").exists() + assert not (tmp_path / "documents").exists() + + # Path constants still resolve under the patched CCBOT_DIR + assert message_handlers.IMAGES_DIR == tmp_path / "images" + assert message_handlers._AUDIO_DIR == tmp_path / "audio" + assert document.DOCS_DIR == tmp_path / "documents" + finally: + monkeypatch.undo() + importlib.reload(message_handlers) + importlib.reload(document) diff --git a/tests/ccbot/test_message_formatter.py b/tests/ccbot/test_message_formatter.py new file mode 100644 index 000000000..201fdda61 --- /dev/null +++ b/tests/ccbot/test_message_formatter.py @@ -0,0 +1,127 @@ +"""Tests for MessageFormatter / unified markdown fallback (REF-008).""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest +from telegram.error import BadRequest, NetworkError, RetryAfter + +from ccbot.handlers import message_sender as ms +from ccbot.handlers.message_sender import MessageFormatter, formatter +from ccbot.transcript_parser import TranscriptParser + + +def test_single_message_formatter_singleton() -> None: + assert isinstance(formatter, MessageFormatter) + assert ms.formatter is formatter + assert formatter.parse_mode == "MarkdownV2" + sample = f"x{TranscriptParser.EXPANDABLE_QUOTE_START}y" + assert formatter.to_plain(sample) == ms.strip_sentinels(sample) + assert TranscriptParser.EXPANDABLE_QUOTE_START not in formatter.to_plain(sample) + + +def test_formatter_to_markdown_v2_uses_convert() -> None: + # Plain word should still produce a string (may be escaped) + out = formatter.to_markdown_v2("hello") + assert isinstance(out, str) + assert len(out) > 0 + + +@pytest.mark.asyncio +async def test_send_with_fallback_uses_formatter_path() -> None: + bot = MagicMock() + bot.send_message = AsyncMock( + side_effect=[BadRequest("can't parse entities"), MagicMock(name="msg")] + ) + result = await ms.send_with_fallback(bot, 1, "**hi**") + assert result is not None + assert bot.send_message.await_count == 2 + first_kwargs = bot.send_message.await_args_list[0].kwargs + second_kwargs = bot.send_message.await_args_list[1].kwargs + assert first_kwargs.get("parse_mode") == formatter.parse_mode + assert second_kwargs.get("parse_mode") is None + + +@pytest.mark.asyncio +async def test_send_with_fallback_reraises_retry_after() -> None: + bot = MagicMock() + bot.send_message = AsyncMock(side_effect=RetryAfter(timedelta(seconds=3))) + with pytest.raises(RetryAfter): + await ms.send_with_fallback(bot, 1, "x") + + +@pytest.mark.asyncio +async def test_send_with_fallback_none_when_both_fail() -> None: + bot = MagicMock() + bot.send_message = AsyncMock( + side_effect=[BadRequest("bad md"), NetworkError("down")] + ) + assert await ms.send_with_fallback(bot, 1, "x") is None + + +@pytest.mark.asyncio +async def test_edit_with_fallback_markdown_then_plain() -> None: + bot = MagicMock() + bot.edit_message_text = AsyncMock( + side_effect=[BadRequest("can't parse entities"), None] + ) + ok = await ms.edit_with_fallback(bot, 1, 42, "**x**") + assert ok is True + assert bot.edit_message_text.await_count == 2 + assert bot.edit_message_text.await_args_list[0].kwargs.get("parse_mode") == ( + formatter.parse_mode + ) + + +@pytest.mark.asyncio +async def test_edit_with_fallback_false_when_both_fail() -> None: + bot = MagicMock() + bot.edit_message_text = AsyncMock( + side_effect=[BadRequest("bad"), NetworkError("down")] + ) + assert await ms.edit_with_fallback(bot, 1, 42, "hi") is False + + +@pytest.mark.asyncio +async def test_safe_send_delegates_to_send_with_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: dict[str, object] = {} + + async def fake_send(bot: object, chat_id: int, text: str, **kwargs: object) -> None: + called["chat_id"] = chat_id + called["text"] = text + called["thread"] = kwargs.get("message_thread_id") + called["called"] = True + + monkeypatch.setattr(ms, "send_with_fallback", fake_send) + await ms.safe_send(MagicMock(), 7, "hello", message_thread_id=99) + assert called == { + "chat_id": 7, + "text": "hello", + "thread": 99, + "called": True, + } + + +@pytest.mark.asyncio +async def test_safe_reply_reraises_retry_after_on_plain() -> None: + message = MagicMock() + message.reply_text = AsyncMock( + side_effect=[BadRequest("bad md"), RetryAfter(timedelta(seconds=2))] + ) + with pytest.raises(RetryAfter): + await ms.safe_reply(message, "hello") + + +@pytest.mark.asyncio +async def test_run_markdown_fallback_shared_by_safe_edit() -> None: + target = MagicMock() + target.edit_message_text = AsyncMock(side_effect=[BadRequest("bad"), None]) + await ms.safe_edit(target, "**z**") + assert target.edit_message_text.await_count == 2 + assert target.edit_message_text.await_args_list[0].kwargs.get("parse_mode") == ( + formatter.parse_mode + ) diff --git a/tests/ccbot/test_message_sender_errors.py b/tests/ccbot/test_message_sender_errors.py new file mode 100644 index 000000000..845964b88 --- /dev/null +++ b/tests/ccbot/test_message_sender_errors.py @@ -0,0 +1,115 @@ +"""Tests for typed Telegram error handling in message_sender (REF-003).""" + +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest +from telegram.error import BadRequest, NetworkError, RetryAfter + +from ccbot.errors import retry_after_seconds +from ccbot.handlers import message_sender as ms + + +@pytest.mark.asyncio +async def test_send_with_fallback_retries_plain_on_bad_request() -> None: + bot = MagicMock() + bot.send_message = AsyncMock( + side_effect=[BadRequest("can't parse entities"), MagicMock()] + ) + result = await ms.send_with_fallback(bot, 1, "**hi**") + assert result is not None + assert bot.send_message.await_count == 2 + second_kwargs = bot.send_message.await_args_list[1].kwargs + assert second_kwargs.get("parse_mode") is None + assert "parse_mode" not in second_kwargs or second_kwargs["parse_mode"] is None + + +@pytest.mark.asyncio +async def test_send_with_fallback_reraises_retry_after() -> None: + bot = MagicMock() + wait = timedelta(seconds=5) + bot.send_message = AsyncMock(side_effect=RetryAfter(wait)) + with pytest.raises(RetryAfter) as exc_info: + await ms.send_with_fallback(bot, 1, "x") + assert retry_after_seconds(exc_info.value) == 5.0 + + +@pytest.mark.asyncio +async def test_send_with_fallback_returns_none_when_both_fail() -> None: + bot = MagicMock() + bot.send_message = AsyncMock( + side_effect=[BadRequest("bad md"), NetworkError("down")] + ) + result = await ms.send_with_fallback(bot, 1, "x") + assert result is None + assert bot.send_message.await_count == 2 + + +@pytest.mark.asyncio +async def test_safe_reply_reraises_retry_after_on_plain() -> None: + message = MagicMock() + message.chat = MagicMock() + wait = timedelta(seconds=2) + message.reply_text = AsyncMock(side_effect=[BadRequest("bad md"), RetryAfter(wait)]) + with pytest.raises(RetryAfter) as exc_info: + await ms.safe_reply(message, "hello") + assert retry_after_seconds(exc_info.value) == 2.0 + + +@pytest.mark.asyncio +async def test_edit_with_fallback_markdown_then_plain() -> None: + bot = MagicMock() + bot.edit_message_text = AsyncMock( + side_effect=[BadRequest("can't parse entities"), None] + ) + ok = await ms.edit_with_fallback(bot, 1, 42, "**x**") + assert ok is True + assert bot.edit_message_text.await_count == 2 + second = bot.edit_message_text.await_args_list[1].kwargs + assert second.get("parse_mode") is None + + +@pytest.mark.asyncio +async def test_edit_with_fallback_false_when_both_fail() -> None: + bot = MagicMock() + bot.edit_message_text = AsyncMock( + side_effect=[BadRequest("bad"), NetworkError("down")] + ) + ok = await ms.edit_with_fallback(bot, 1, 42, "hi") + assert ok is False + + +@pytest.mark.asyncio +async def test_safe_send_delegates_to_send_with_fallback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + called: dict[str, object] = {} + + async def fake_send(bot: object, chat_id: int, text: str, **kwargs: object) -> None: + called["chat_id"] = chat_id + called["text"] = text + called["thread"] = kwargs.get("message_thread_id") + called["called"] = True + + monkeypatch.setattr(ms, "send_with_fallback", fake_send) + bot = MagicMock() + await ms.safe_send(bot, 7, "hello", message_thread_id=99) + assert called.get("called") is True + assert called["chat_id"] == 7 + assert called["text"] == "hello" + assert called["thread"] == 99 + + +def test_retry_after_seconds_accepts_int_and_timedelta() -> None: + """Helper stays compatible if PTB_TIMEDELTA is off or on.""" + from unittest.mock import MagicMock as MM + + td_exc = MM(spec=RetryAfter) + td_exc.retry_after = timedelta(seconds=3) + assert retry_after_seconds(td_exc) == 3.0 + + int_exc = MM(spec=RetryAfter) + int_exc.retry_after = 7 + assert retry_after_seconds(int_exc) == 7.0 diff --git a/tests/ccbot/test_session.py b/tests/ccbot/test_session.py index cd0fdd0c2..6f755d5f5 100644 --- a/tests/ccbot/test_session.py +++ b/tests/ccbot/test_session.py @@ -112,6 +112,31 @@ def test_clear_window_session(self, mgr: SessionManager) -> None: assert mgr.get_window_state("@1").session_id == "" +class TestSetWindowSession: + """ISS-009: public write API replacing direct WindowState mutation + _save_state.""" + + def test_sets_all_fields(self, mgr: SessionManager) -> None: + mgr.set_window_session("@5", "sid-1", cwd="/proj", window_name="proj") + state = mgr.get_window_state("@5") + assert state.session_id == "sid-1" + assert state.cwd == "/proj" + assert state.window_name == "proj" + + def test_partial_update_preserves_cwd_and_name(self, mgr: SessionManager) -> None: + mgr.set_window_session("@5", "sid-1", cwd="/proj", window_name="proj") + mgr.set_window_session("@5", "sid-2") + state = mgr.get_window_state("@5") + assert state.session_id == "sid-2" + assert state.cwd == "/proj" + assert state.window_name == "proj" + + def test_persists_state(self, mgr: SessionManager, monkeypatch) -> None: + calls = [] + monkeypatch.setattr(SessionManager, "_save_state", lambda self: calls.append(1)) + mgr.set_window_session("@5", "sid-1") + assert calls == [1] + + class TestResolveWindowForThread: def test_none_thread_id_returns_none(self, mgr: SessionManager) -> None: assert mgr.resolve_window_for_thread(100, None) is None diff --git a/tests/ccbot/test_session_guard.py b/tests/ccbot/test_session_guard.py new file mode 100644 index 000000000..a50ad841a --- /dev/null +++ b/tests/ccbot/test_session_guard.py @@ -0,0 +1,268 @@ +"""Unit tests for session_guard (REF-001 auth + session resolution helper).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ccbot.session_guard import ( + SessionContext, + get_thread_id, + is_user_allowed, + require_bound_window_id, + require_session, + require_user, +) +from ccbot.tmux_manager import TmuxWindow + + +def _user(uid: int = 42) -> MagicMock: + u = MagicMock() + u.id = uid + return u + + +def _message(*, thread_id: int | None = 7) -> MagicMock: + msg = MagicMock() + msg.message_thread_id = thread_id + return msg + + +def _update( + *, + user: MagicMock | None = None, + message: MagicMock | None = None, + callback_query: MagicMock | None = None, +) -> MagicMock: + upd = MagicMock() + upd.effective_user = user + upd.message = message + upd.callback_query = callback_query + return upd + + +class TestIsUserAllowed: + def test_none_disallowed(self) -> None: + assert is_user_allowed(None) is False + + def test_delegates_to_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "ccbot.session_guard.config.is_user_allowed", + lambda uid: uid == 99, + ) + assert is_user_allowed(99) is True + assert is_user_allowed(1) is False + + +class TestGetThreadId: + def test_from_message(self) -> None: + upd = _update(message=_message(thread_id=12)) + assert get_thread_id(upd) == 12 + + def test_general_topic_is_none(self) -> None: + upd = _update(message=_message(thread_id=1)) + assert get_thread_id(upd) is None + + def test_from_callback(self) -> None: + cq = MagicMock() + cq.message = _message(thread_id=5) + upd = _update(message=None, callback_query=cq) + assert get_thread_id(upd) == 5 + + +@pytest.mark.asyncio +async def test_require_user_unauthorized_replies( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: False) + msg = _message() + upd = _update(user=_user(1), message=msg) + with patch("ccbot.session_guard.safe_reply", new_callable=AsyncMock) as reply: + result = await require_user(upd, reply_unauthorized=True) + assert result is None + reply.assert_awaited_once() + assert "not authorized" in reply.await_args.args[1].lower() + + +@pytest.mark.asyncio +async def test_require_user_authorized(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: True) + user = _user(7) + upd = _update(user=user, message=_message()) + result = await require_user(upd) + assert result is user + + +@pytest.mark.asyncio +async def test_require_session_authorized_bound_live( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: True) + user = _user(10) + msg = _message(thread_id=3) + upd = _update(user=user, message=msg) + win = TmuxWindow(window_id="@9", window_name="w", cwd="/tmp") + + monkeypatch.setattr( + "ccbot.session_guard.session_manager.resolve_window_for_thread", + lambda _uid, _tid: "@9", + ) + monkeypatch.setattr( + "ccbot.session_guard.session_manager.get_display_name", + lambda window_id: "disp", + ) + monkeypatch.setattr( + "ccbot.session_guard.tmux_manager.find_window_by_id", + AsyncMock(return_value=win), + ) + + ctx = await require_session(upd, reply_unauthorized=False) + assert isinstance(ctx, SessionContext) + assert ctx.user is user + assert ctx.thread_id == 3 + assert ctx.window_id == "@9" + assert ctx.window is win + + +@pytest.mark.asyncio +async def test_require_session_unbound(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: True) + msg = _message(thread_id=3) + upd = _update(user=_user(10), message=msg) + monkeypatch.setattr( + "ccbot.session_guard.session_manager.resolve_window_for_thread", + lambda _uid, _tid: None, + ) + with patch("ccbot.session_guard.safe_reply", new_callable=AsyncMock) as reply: + ctx = await require_session(upd, reply_unauthorized=False) + assert ctx is None + reply.assert_awaited_once() + assert "No active session" in reply.await_args.args[1] + + +@pytest.mark.asyncio +async def test_require_session_missing_window( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: True) + msg = _message(thread_id=3) + upd = _update(user=_user(10), message=msg) + monkeypatch.setattr( + "ccbot.session_guard.session_manager.resolve_window_for_thread", + lambda _uid, _tid: "@gone", + ) + monkeypatch.setattr( + "ccbot.session_guard.session_manager.get_display_name", + lambda window_id: "mywin", + ) + monkeypatch.setattr( + "ccbot.session_guard.tmux_manager.find_window_by_id", + AsyncMock(return_value=None), + ) + with patch("ccbot.session_guard.safe_reply", new_callable=AsyncMock) as reply: + ctx = await require_session(upd, reply_unauthorized=False) + assert ctx is None + reply.assert_awaited_once() + assert "Window not found" in reply.await_args.args[1] + assert "Re-binding" in reply.await_args.args[1] + + +@pytest.mark.asyncio +async def test_require_bound_window_id_ok(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: True) + user = _user(5) + upd = _update(user=user, message=_message(thread_id=2)) + monkeypatch.setattr( + "ccbot.session_guard.session_manager.resolve_window_for_thread", + lambda _uid, _tid: "@1", + ) + result = await require_bound_window_id(upd, reply_unauthorized=False) + assert result is not None + u, thread_id, window_id = result + assert u is user + assert thread_id == 2 + assert window_id == "@1" + + +@pytest.mark.asyncio +async def test_require_session_composes_require_bound_window_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ISS-006: require_session must call require_bound_window_id (not reimplement).""" + user = _user(10) + upd = _update(user=user, message=_message(thread_id=3)) + win = TmuxWindow(window_id="@9", window_name="w", cwd="/tmp") + bound = AsyncMock(return_value=(user, 3, "@9")) + monkeypatch.setattr("ccbot.session_guard.require_bound_window_id", bound) + monkeypatch.setattr( + "ccbot.session_guard.tmux_manager.find_window_by_id", + AsyncMock(return_value=win), + ) + + ctx = await require_session( + upd, + reply_unauthorized=False, + require_message=True, + use_resolve=False, + no_session_text="CUSTOM_NO_SESSION", + ) + + bound.assert_awaited_once() + kwargs = bound.await_args.kwargs + assert kwargs["reply_unauthorized"] is False + assert kwargs["require_message"] is True + assert kwargs["use_resolve"] is False + assert kwargs["no_session_text"] == "CUSTOM_NO_SESSION" + assert isinstance(ctx, SessionContext) + assert ctx.window is win + assert ctx.window_id == "@9" + + +@pytest.mark.asyncio +async def test_require_session_unbind_if_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: True) + user = _user(10) + msg = _message(thread_id=3) + upd = _update(user=user, message=msg) + unbind = MagicMock() + monkeypatch.setattr( + "ccbot.session_guard.session_manager.resolve_window_for_thread", + lambda _uid, _tid: "@gone", + ) + monkeypatch.setattr( + "ccbot.session_guard.session_manager.get_display_name", + lambda window_id: "mywin", + ) + monkeypatch.setattr( + "ccbot.session_guard.session_manager.unbind_thread", + unbind, + ) + monkeypatch.setattr( + "ccbot.session_guard.tmux_manager.find_window_by_id", + AsyncMock(return_value=None), + ) + with patch("ccbot.session_guard.safe_reply", new_callable=AsyncMock): + ctx = await require_session( + upd, reply_unauthorized=False, unbind_if_missing=True + ) + assert ctx is None + unbind.assert_called_once_with(10, 3) + + +@pytest.mark.asyncio +async def test_require_session_require_named_topic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("ccbot.session_guard.config.is_user_allowed", lambda _u: True) + msg = _message(thread_id=1) # General → get_thread_id None + upd = _update(user=_user(10), message=msg) + with patch("ccbot.session_guard.safe_reply", new_callable=AsyncMock) as reply: + ctx = await require_session( + upd, reply_unauthorized=False, require_named_topic=True + ) + assert ctx is None + reply.assert_awaited_once() + assert "named topic" in reply.await_args.args[1].lower() diff --git a/tests/ccbot/test_session_migration.py b/tests/ccbot/test_session_migration.py new file mode 100644 index 000000000..4f51fce10 --- /dev/null +++ b/tests/ccbot/test_session_migration.py @@ -0,0 +1,259 @@ +"""Table-driven tests for startup migration / stale-id resolution (REF-004).""" + +from __future__ import annotations + +from dataclasses import dataclass +from unittest.mock import AsyncMock, patch + +import pytest + +from ccbot.session_migration import ( + apply_startup_state_migration, + cleanup_stale_session_map_entries, + is_window_id, + migrate_session_map_old_format, + state_needs_legacy_migration, +) + + +@dataclass +class FakeWS: + session_id: str = "" + cwd: str = "" + window_name: str = "" + + +class TestIsWindowId: + @pytest.mark.parametrize( + ("key", "expected"), + [ + ("@0", True), + ("@12", True), + ("@999", True), + ("proj", False), + ("@", False), + ("@x", False), + ("", False), + ("@1a", False), + ], + ) + def test_is_window_id(self, key: str, expected: bool) -> None: + assert is_window_id(key) is expected + + +@dataclass +class StateCase: + name: str + window_states: dict + thread_bindings: dict + offsets: dict + display_names: dict + live_by_name: dict + live_ids: set[str] + expect_ws_keys: set[str] + expect_binding: dict | None + expect_changed: bool + expect_event_substr: str | None = None + + +STATE_CASES = [ + StateCase( + name="stale_id_reresolved_by_display_name", + window_states={"@1": FakeWS(session_id="s1", window_name="proj")}, + thread_bindings={1: {10: "@1"}}, + offsets={1: {"@1": 42}}, + display_names={"@1": "proj"}, + live_by_name={"proj": "@9"}, + live_ids={"@9"}, + expect_ws_keys={"@9"}, + expect_binding={1: {10: "@9"}}, + expect_changed=True, + expect_event_substr="re-resolve", + ), + StateCase( + name="stale_id_dropped_when_no_live_name", + window_states={"@1": FakeWS(session_id="s1", window_name="gone")}, + thread_bindings={1: {10: "@1"}}, + offsets={}, + display_names={"@1": "gone"}, + live_by_name={}, + live_ids=set(), + expect_ws_keys=set(), + expect_binding={}, + expect_changed=True, + expect_event_substr="drop stale", + ), + StateCase( + name="legacy_window_name_key_migrated", + window_states={"myproj": FakeWS(session_id="s2")}, + thread_bindings={2: {5: "myproj"}}, + offsets={2: {"myproj": 7}}, + display_names={}, + live_by_name={"myproj": "@4"}, + live_ids={"@4"}, + expect_ws_keys={"@4"}, + expect_binding={2: {5: "@4"}}, + expect_changed=True, + expect_event_substr="migrate", + ), + StateCase( + name="legacy_orphan_dropped", + window_states={"ghost": FakeWS(session_id="x")}, + thread_bindings={1: {1: "ghost"}}, + offsets={}, + display_names={}, + live_by_name={"other": "@1"}, + live_ids={"@1"}, + expect_ws_keys=set(), + expect_binding={}, + expect_changed=True, + expect_event_substr="drop old-format", + ), + StateCase( + name="live_id_unchanged", + window_states={"@3": FakeWS(session_id="ok", window_name="w")}, + thread_bindings={1: {2: "@3"}}, + offsets={1: {"@3": 1}}, + display_names={"@3": "w"}, + live_by_name={"w": "@3"}, + live_ids={"@3"}, + expect_ws_keys={"@3"}, + expect_binding={1: {2: "@3"}}, + expect_changed=False, + expect_event_substr=None, + ), + StateCase( + name="mixed_stale_and_live", + window_states={ + "@1": FakeWS(session_id="a", window_name="keep"), + "@2": FakeWS(session_id="b", window_name="move"), + }, + thread_bindings={1: {1: "@1", 2: "@2"}}, + offsets={}, + display_names={"@1": "keep", "@2": "move"}, + live_by_name={"keep": "@1", "move": "@8"}, + live_ids={"@1", "@8"}, + expect_ws_keys={"@1", "@8"}, + expect_binding={1: {1: "@1", 2: "@8"}}, + expect_changed=True, + expect_event_substr="re-resolve", + ), +] + + +@pytest.mark.parametrize("case", STATE_CASES, ids=lambda c: c.name) +def test_apply_startup_state_migration(case: StateCase) -> None: + result = apply_startup_state_migration( + window_states=dict(case.window_states), + thread_bindings={u: dict(b) for u, b in case.thread_bindings.items()}, + user_window_offsets={u: dict(o) for u, o in case.offsets.items()}, + window_display_names=dict(case.display_names), + live_by_name=case.live_by_name, + live_ids=case.live_ids, + ) + assert set(result.window_states.keys()) == case.expect_ws_keys + if case.expect_binding is not None: + assert result.thread_bindings == case.expect_binding + assert result.changed is case.expect_changed + if case.expect_event_substr: + joined = " ".join(result.events) + assert case.expect_event_substr in joined + + +def test_migrate_old_format_to_window_id() -> None: + smap = { + "ccbot:myproj": {"session_id": "sid", "cwd": "/tmp"}, + } + changed = migrate_session_map_old_format(smap, {"myproj": "@4"}, "ccbot:") + assert changed is True + assert "ccbot:@4" in smap + assert "ccbot:myproj" not in smap + assert smap["ccbot:@4"]["window_name"] == "myproj" + assert smap["ccbot:@4"]["session_id"] == "sid" + + +def test_migrate_old_format_orphan_dropped() -> None: + smap = {"ccbot:ghost": {"session_id": "x"}} + changed = migrate_session_map_old_format(smap, {"other": "@1"}, "ccbot:") + assert changed is True + assert smap == {} + + +def test_migrate_old_format_superseded_by_existing_id() -> None: + smap = { + "ccbot:name": {"session_id": "old"}, + "ccbot:@7": {"session_id": "new", "window_name": "name"}, + } + changed = migrate_session_map_old_format(smap, {"name": "@7"}, "ccbot:") + assert changed is True + assert "ccbot:name" not in smap + assert smap["ccbot:@7"]["session_id"] == "new" + + +def test_cleanup_stale_ids_only() -> None: + smap = { + "ccbot:@1": {"session_id": "a"}, + "ccbot:@99": {"session_id": "b"}, + "other:@1": {"session_id": "c"}, + } + changed = cleanup_stale_session_map_entries(smap, {"@1"}, "ccbot:") + assert changed is True + assert set(smap.keys()) == {"ccbot:@1", "other:@1"} + + +def test_cleanup_ignores_old_format_keys() -> None: + smap = {"ccbot:name": {"session_id": "a"}} + changed = cleanup_stale_session_map_entries(smap, set(), "ccbot:") + assert changed is False + assert "ccbot:name" in smap + + +def test_detects_name_keys() -> None: + assert state_needs_legacy_migration({"foo": FakeWS()}, {}) is True + + +def test_detects_name_bindings() -> None: + assert state_needs_legacy_migration({"@1": FakeWS()}, {1: {1: "name"}}) is True + + +def test_clean_state() -> None: + assert state_needs_legacy_migration({"@1": FakeWS()}, {1: {1: "@1"}}) is False + + +@pytest.mark.asyncio +async def test_session_manager_resolve_stale_ids_uses_migration() -> None: + """Integration: SessionManager applies result from pure migrator.""" + from ccbot.session import SessionManager, WindowState + from ccbot.tmux_manager import TmuxWindow + + with ( + patch.object(SessionManager, "_load_state", lambda self: None), + patch.object(SessionManager, "_save_state", lambda self: None), + ): + mgr = SessionManager() + mgr.window_states = { + "@1": WindowState(session_id="s", cwd="/t", window_name="proj") + } + mgr.thread_bindings = {1: {42: "@1"}} + mgr.window_display_names = {"@1": "proj"} + mgr.user_window_offsets = {1: {"@1": 0}} + + live = [TmuxWindow(window_id="@9", window_name="proj", cwd="/t")] + with ( + patch( + "ccbot.session.tmux_manager.list_windows", + new_callable=AsyncMock, + return_value=live, + ), + patch.object( + mgr, "_cleanup_stale_session_map_entries", new_callable=AsyncMock + ), + patch.object( + mgr, "_migrate_old_format_session_map_keys", new_callable=AsyncMock + ), + ): + await mgr.resolve_stale_ids() + + assert set(mgr.window_states.keys()) == {"@9"} + assert mgr.thread_bindings[1][42] == "@9" + assert mgr.window_display_names.get("@9") == "proj" diff --git a/tests/ccbot/test_session_monitor.py b/tests/ccbot/test_session_monitor.py index 5b3e3526f..22fb3e943 100644 --- a/tests/ccbot/test_session_monitor.py +++ b/tests/ccbot/test_session_monitor.py @@ -8,6 +8,15 @@ from ccbot.session_monitor import SessionMonitor +def _write_jsonl(path, text: str) -> None: + """Write JSONL with LF line endings (matches Claude Code / Linux production). + + Path.write_text / text-mode open on Windows would expand ``\\n`` to + ``\\r\\n`` and break byte-offset assertions. + """ + path.write_bytes(text.encode("utf-8")) + + class TestReadNewLinesOffsetRecovery: """Tests for _read_new_lines offset corruption recovery.""" @@ -26,9 +35,9 @@ async def test_mid_line_offset_recovery(self, monitor, tmp_path, make_jsonl_entr jsonl_file = tmp_path / "session.jsonl" entry1 = make_jsonl_entry(msg_type="assistant", content="first message") entry2 = make_jsonl_entry(msg_type="assistant", content="second message") - jsonl_file.write_text( + _write_jsonl( + jsonl_file, json.dumps(entry1) + "\n" + json.dumps(entry2) + "\n", - encoding="utf-8", ) # Calculate offset pointing into the middle of line 1 @@ -57,9 +66,9 @@ async def test_valid_offset_reads_normally( jsonl_file = tmp_path / "session.jsonl" entry1 = make_jsonl_entry(msg_type="assistant", content="first") entry2 = make_jsonl_entry(msg_type="assistant", content="second") - jsonl_file.write_text( + _write_jsonl( + jsonl_file, json.dumps(entry1) + "\n" + json.dumps(entry2) + "\n", - encoding="utf-8", ) # Offset at 0 should read both lines @@ -83,9 +92,9 @@ async def test_poison_line_skipped(self, monitor, tmp_path, make_jsonl_entry): """ jsonl_file = tmp_path / "session.jsonl" good = make_jsonl_entry(msg_type="assistant", content="ok") - jsonl_file.write_text( + _write_jsonl( + jsonl_file, json.dumps(good) + "\n" + "{corrupt json!!!\n" + json.dumps(good) + "\n", - encoding="utf-8", ) session = TrackedSession( session_id="test-session", @@ -107,7 +116,7 @@ async def test_partial_line_at_eof_retried( jsonl_file = tmp_path / "session.jsonl" good = make_jsonl_entry(msg_type="assistant", content="ok") good_line = json.dumps(good) + "\n" - jsonl_file.write_text(good_line + '{"type": "assis', encoding="utf-8") + _write_jsonl(jsonl_file, good_line + '{"type": "assis') session = TrackedSession( session_id="test-session", file_path=str(jsonl_file), @@ -120,9 +129,9 @@ async def test_partial_line_at_eof_retried( # Offset stops at the partial line, not EOF assert session.last_byte_offset == len(good_line.encode("utf-8")) - # Writer completes the line — next cycle picks it up - with open(jsonl_file, "a", encoding="utf-8") as f: - f.write('tant", "message": {"content": "done"}}\n') + # Writer completes the line — next cycle picks it up (binary append, LF) + with open(jsonl_file, "ab") as f: + f.write(b'tant", "message": {"content": "done"}}\n') result2 = await monitor._read_new_lines(session, jsonl_file) assert len(result2) == 1 assert session.last_byte_offset == jsonl_file.stat().st_size @@ -132,7 +141,7 @@ async def test_truncation_detection(self, monitor, tmp_path, make_jsonl_entry): """Detect file truncation and reset offset.""" jsonl_file = tmp_path / "session.jsonl" entry = make_jsonl_entry(msg_type="assistant", content="content") - jsonl_file.write_text(json.dumps(entry) + "\n", encoding="utf-8") + _write_jsonl(jsonl_file, json.dumps(entry) + "\n") # Set offset beyond file size (simulates truncation) session = TrackedSession( diff --git a/tests/ccbot/test_smoke_2026_10_12_regressions.py b/tests/ccbot/test_smoke_2026_10_12_regressions.py new file mode 100644 index 000000000..1ebf09333 --- /dev/null +++ b/tests/ccbot/test_smoke_2026_10_12_regressions.py @@ -0,0 +1,306 @@ +"""Regression tests for incidents found by the 2026-10-12 smoke run. + +Covers: Claude Code session fork (continued-in) following in the monitor and the +hook diagnostics (incident 1), damaged state.json handling (incident 2), topic +rename surviving session_map sync (incident 4), and the voice install hint +(incident 6). +""" + +import io +import json +import logging +import sys +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +import ccbot.session as session_mod +import ccbot.transcribe as transcribe_mod +from ccbot.hook import hook_main +from ccbot.session import SessionManager +from ccbot.session_monitor import SessionMonitor + +OLD_SID = "318e5ba6-39bb-4576-82da-ce2362d29fe6" +NEW_SID = "21171c80-7c8f-46ee-bbe1-bdd73a2196a2" + + +# ── Incident 1: session fork (continued-in) ────────────────────────────── + + +@pytest.fixture +def monitor(tmp_path) -> SessionMonitor: + return SessionMonitor( + projects_path=tmp_path / "projects", + state_file=tmp_path / "monitor_state.json", + ) + + +class TestContinuedIn: + def test_collect_records_new_session(self, monitor: SessionMonitor) -> None: + entries = [ + {"type": "assistant"}, + { + "type": "continued-in", + "sessionId": OLD_SID, + "continuedInSessionId": NEW_SID, + }, + ] + monitor._collect_continuations(OLD_SID, entries) + assert monitor._continuations == {OLD_SID: NEW_SID} + + @pytest.mark.parametrize( + "entry", + [ + {"type": "continued-in"}, + {"type": "continued-in", "continuedInSessionId": ""}, + {"type": "continued-in", "continuedInSessionId": 123}, + {"type": "continued-in", "continuedInSessionId": OLD_SID}, + {"type": "assistant", "continuedInSessionId": NEW_SID}, + ], + ids=["missing", "empty", "not-str", "self", "wrong-type"], + ) + def test_collect_ignores_invalid(self, monitor: SessionMonitor, entry) -> None: + monitor._collect_continuations(OLD_SID, [entry]) + assert monitor._continuations == {} + + @pytest.mark.asyncio + async def test_read_new_lines_then_collect( + self, monitor: SessionMonitor, tmp_path + ) -> None: + """The record written last in the old transcript is picked up end to end.""" + from ccbot.monitor_state import TrackedSession + + old_file = tmp_path / f"{OLD_SID}.jsonl" + lines = [ + {"type": "user", "message": {"content": "hi"}}, + { + "type": "continued-in", + "timestamp": "2026-10-12T18:44:42.164Z", + "sessionId": OLD_SID, + "continuedInSessionId": NEW_SID, + }, + ] + old_file.write_bytes( + ("\n".join(json.dumps(x) for x in lines) + "\n").encode("utf-8") + ) + tracked = TrackedSession( + session_id=OLD_SID, file_path=str(old_file), last_byte_offset=0 + ) + entries = await monitor._read_new_lines(tracked, old_file) + monitor._collect_continuations(OLD_SID, entries) + assert monitor._continuations == {OLD_SID: NEW_SID} + + @pytest.mark.asyncio + async def test_apply_repoints_window_and_clears( + self, monitor: SessionMonitor, monkeypatch + ) -> None: + override = AsyncMock() + monkeypatch.setattr( + session_mod.session_manager, "override_session_map_entry", override + ) + monitor._continuations = {OLD_SID: NEW_SID} + + await monitor._apply_continuations({"@2": OLD_SID, "@5": "other-session"}) + + override.assert_awaited_once_with("@2", NEW_SID) + assert monitor._continuations == {} + + @pytest.mark.asyncio + async def test_apply_drops_continuation_without_window( + self, monitor: SessionMonitor, monkeypatch + ) -> None: + override = AsyncMock() + monkeypatch.setattr( + session_mod.session_manager, "override_session_map_entry", override + ) + monitor._continuations = {OLD_SID: NEW_SID} + + await monitor._apply_continuations({"@5": "other-session"}) + + override.assert_not_awaited() + assert monitor._continuations == {} + + +class TestHookWithoutTmuxPane: + def test_warning_carries_diagnostics_and_map_untouched( + self, monkeypatch: pytest.MonkeyPatch, tmp_path, caplog + ) -> None: + monkeypatch.setenv("CCBOT_DIR", str(tmp_path)) + monkeypatch.setattr(sys, "argv", ["ccbot", "hook"]) + payload = { + "session_id": NEW_SID, + "cwd": "/tmp", + "hook_event_name": "SessionStart", + "source": "fork", + } + monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload))) + monkeypatch.delenv("TMUX_PANE", raising=False) + + with caplog.at_level(logging.WARNING, logger="ccbot.hook"): + hook_main() + + text = caplog.text + assert "TMUX_PANE not set" in text + assert NEW_SID in text + assert "source=fork" in text + assert not (tmp_path / "session_map.json").exists() + + +# ── Incident 2: damaged state.json ─────────────────────────────────────── + +GOOD_STATE = { + "window_states": {"@1": {"session_id": "sid-1", "cwd": "/p", "window_name": "p"}}, + "thread_bindings": {"100": {"1": "@1"}}, + "window_display_names": {"@1": "p"}, +} + + +@pytest.fixture +def state_path(monkeypatch, tmp_path): + path = tmp_path / "state.json" + monkeypatch.setattr(session_mod.config, "state_file", path) + return path + + +class TestDamagedStateFile: + @pytest.mark.parametrize( + "content", + [ + "", + '{"window_states": {"@1": ', + "[]", + '"just a string"', + "42", + '{"window_states": []}', + '{"window_states": {"@1": 5}}', + '{"thread_bindings": []}', + '{"thread_bindings": {"1": []}}', + '{"thread_bindings": {"not-a-number": {}}}', + '{"user_window_offsets": []}', + ], + ids=[ + "empty", + "truncated", + "root-list", + "root-string", + "root-number", + "window-states-list", + "window-state-not-object", + "bindings-list", + "user-bindings-list", + "non-numeric-user-id", + "offsets-list", + ], + ) + def test_startup_survives_and_preserves_file(self, state_path, content) -> None: + state_path.write_text(content) + + mgr = SessionManager() # must not raise + + assert mgr.thread_bindings == {} + assert mgr.window_states == {} + preserved = list(state_path.parent.glob("state.json.corrupt-*")) + assert len(preserved) == 1 + assert preserved[0].read_text() == content + + def test_restores_from_backup(self, state_path) -> None: + state_path.with_name("state.json.bak").write_text(json.dumps(GOOD_STATE)) + state_path.write_text("[]") + + mgr = SessionManager() + + assert mgr.get_window_for_thread(100, 1) == "@1" + assert list(state_path.parent.glob("state.json.corrupt-*")) + + def test_unusable_backup_falls_back_to_empty(self, state_path) -> None: + state_path.with_name("state.json.bak").write_text("also broken") + state_path.write_text("[]") + + mgr = SessionManager() + + assert mgr.thread_bindings == {} + + def test_valid_state_loads_without_artifacts(self, state_path) -> None: + state_path.write_text(json.dumps(GOOD_STATE)) + + mgr = SessionManager() + + assert mgr.get_window_for_thread(100, 1) == "@1" + assert not list(state_path.parent.glob("state.json.corrupt-*")) + + def test_save_keeps_last_valid_state_as_backup(self, state_path) -> None: + state_path.write_text(json.dumps(GOOD_STATE)) + mgr = SessionManager() + backup = state_path.with_name("state.json.bak") + + mgr._save_state() + assert json.loads(backup.read_text()) == GOOD_STATE + + # A damaged file must never replace the last good backup. + state_path.write_text("[]") + mgr._save_state() + assert json.loads(backup.read_text()) == GOOD_STATE + + +# ── Incident 4: topic rename must survive session_map sync ─────────────── + + +class TestRenameSurvivesSync: + @pytest.fixture + def mgr(self, monkeypatch, tmp_path) -> SessionManager: + monkeypatch.setattr(SessionManager, "_load_state", lambda self: None) + monkeypatch.setattr(SessionManager, "_save_state", lambda self: None) + map_file = tmp_path / "session_map.json" + map_file.write_text( + json.dumps( + { + "ccbot:@2": { + "session_id": "sid-1", + "cwd": "/proj", + "window_name": "test", + } + } + ) + ) + monkeypatch.setattr(session_mod.config, "session_map_file", map_file) + self.map_file = map_file + return SessionManager() + + @pytest.mark.asyncio + async def test_rename_is_stable_after_sync(self, mgr: SessionManager) -> None: + await mgr.load_session_map() + mgr.update_display_name("@2", "renamed") + await mgr.rename_session_map_window("@2", "renamed") + + await mgr.load_session_map() + + assert mgr.get_display_name("@2") == "renamed" + entry = json.loads(self.map_file.read_text())["ccbot:@2"] + assert entry["window_name"] == "renamed" + assert entry["session_id"] == "sid-1" + + @pytest.mark.asyncio + async def test_rename_unknown_window_is_noop(self, mgr: SessionManager) -> None: + before = self.map_file.read_text() + await mgr.rename_session_map_window("@99", "x") + assert self.map_file.read_text() == before + + +# ── Incident 6: voice install hint ─────────────────────────────────────── + + +class TestVoiceInstallHint: + @pytest.mark.asyncio + async def test_openai_without_key_mentions_uv_tool(self, monkeypatch) -> None: + monkeypatch.setattr(transcribe_mod.config, "openai_api_key", "") + with pytest.raises(transcribe_mod.TranscriptionDisabled) as exc: + await transcribe_mod._transcribe_openai(Path("unused.ogg")) + message = str(exc.value) + assert "OPENAI_API_KEY" in message + assert "uv tool" in message + + def test_hint_covers_checkout_and_uv_tool(self) -> None: + hint = transcribe_mod._VOICE_INSTALL_HINT + assert "uv pip install" in hint + assert "uv tool" in hint diff --git a/tests/ccbot/test_tmux_manager.py b/tests/ccbot/test_tmux_manager.py new file mode 100644 index 000000000..96fabc567 --- /dev/null +++ b/tests/ccbot/test_tmux_manager.py @@ -0,0 +1,141 @@ +"""Unit tests for TmuxManager hot paths (REF-009) — no real tmux required.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from ccbot.tmux_manager import TmuxManager, TmuxWindow + + +@pytest.fixture +def mgr() -> TmuxManager: + return TmuxManager(session_name="ccbot-test") + + +def _fake_window( + window_id: str, + window_name: str, + cwd: str = "/tmp", + pane_cmd: str = "claude", +) -> MagicMock: + win = MagicMock() + win.window_id = window_id + win.window_name = window_name + pane = MagicMock() + pane.pane_current_path = cwd + pane.pane_current_command = pane_cmd + pane.capture_pane.return_value = ["line1", "line2"] + win.active_pane = pane + return win + + +class TestGetSession: + def test_returns_none_on_error(self, mgr: TmuxManager) -> None: + server = MagicMock() + server.sessions.get.side_effect = RuntimeError("no server") + mgr._server = server + assert mgr.get_session() is None + + def test_returns_session(self, mgr: TmuxManager) -> None: + sess = MagicMock() + server = MagicMock() + server.sessions.get.return_value = sess + mgr._server = server + assert mgr.get_session() is sess + + +class TestListWindows: + @pytest.mark.asyncio + async def test_empty_when_no_session(self, mgr: TmuxManager) -> None: + with patch.object(mgr, "get_session", return_value=None): + assert await mgr.list_windows() == [] + + @pytest.mark.asyncio + async def test_skips_main_window(self, mgr: TmuxManager) -> None: + from ccbot import config as cfg + + main_name = cfg.config.tmux_main_window_name + sess = MagicMock() + sess.windows = [ + _fake_window("@0", main_name), + _fake_window("@2", "work", "/home/u/proj"), + ] + with patch.object(mgr, "get_session", return_value=sess): + windows = await mgr.list_windows() + assert len(windows) == 1 + assert windows[0] == TmuxWindow( + window_id="@2", + window_name="work", + cwd="/home/u/proj", + pane_current_command="claude", + ) + + @pytest.mark.asyncio + async def test_find_window_by_id(self, mgr: TmuxManager) -> None: + w1 = TmuxWindow(window_id="@1", window_name="a", cwd="/a") + w2 = TmuxWindow(window_id="@2", window_name="b", cwd="/b") + with patch.object(mgr, "list_windows", return_value=[w1, w2]): + found = await mgr.find_window_by_id("@2") + missing = await mgr.find_window_by_id("@9") + assert found is w2 + assert missing is None + + @pytest.mark.asyncio + async def test_find_window_by_name(self, mgr: TmuxManager) -> None: + w1 = TmuxWindow(window_id="@1", window_name="alpha", cwd="/a") + with patch.object(mgr, "list_windows", return_value=[w1]): + assert await mgr.find_window_by_name("alpha") is w1 + assert await mgr.find_window_by_name("nope") is None + + +class TestScrubEnv: + def test_scrub_swallows_missing_vars(self, mgr: TmuxManager) -> None: + # libtmux raises ValueError when unset-environment fails (var missing) + sess = MagicMock() + sess.unset_environment.side_effect = ValueError( + "tmux set-environment stderr: not set" + ) + mgr._scrub_session_env(sess) # must not raise + assert sess.unset_environment.called + + +class TestCapturePane: + @pytest.mark.asyncio + async def test_plain_capture_joins_lines(self, mgr: TmuxManager) -> None: + sess = MagicMock() + win = _fake_window("@1", "w") + win.active_pane.capture_pane.return_value = ["line1", "line2"] + sess.windows.get.return_value = win + with patch.object(mgr, "get_session", return_value=sess): + text = await mgr.capture_pane("@1", with_ansi=False) + assert text == "line1\nline2" + + @pytest.mark.asyncio + async def test_plain_capture_none_without_session(self, mgr: TmuxManager) -> None: + with patch.object(mgr, "get_session", return_value=None): + assert await mgr.capture_pane("@1", with_ansi=False) is None + + +class TestCreateWindowValidation: + @pytest.mark.asyncio + async def test_rejects_missing_directory(self, mgr: TmuxManager) -> None: + ok, msg, name, window_id = await mgr.create_window( + "/nonexistent/path/ccbot-ref009", + start_claude=False, + ) + assert ok is False + assert "does not exist" in msg + assert name == "" + assert window_id == "" + + @pytest.mark.asyncio + async def test_rejects_non_uuid_resume(self, mgr: TmuxManager, tmp_path) -> None: + ok, msg, name, window_id = await mgr.create_window( + str(tmp_path), + start_claude=False, + resume_session_id="not-a-uuid", + ) + assert ok is False + assert "Invalid session ID" in msg diff --git a/tests/ccbot/test_transcribe.py b/tests/ccbot/test_transcribe.py index b1f979b95..0cb2d841a 100644 --- a/tests/ccbot/test_transcribe.py +++ b/tests/ccbot/test_transcribe.py @@ -1,11 +1,12 @@ -"""Unit tests for transcribe — voice-to-text via OpenAI API.""" +"""Unit tests for transcribe — dual backend + CCBOT_WHISPER_LANGUAGE matrix.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from ccbot import transcribe +from ccbot.config import Config @pytest.fixture(autouse=True) @@ -18,13 +19,23 @@ def _reset_client(): @pytest.fixture def mock_config(): - """Patch config with test values.""" + """Patch config with test values for OpenAI backend.""" with patch.object(transcribe, "config") as cfg: cfg.openai_api_key = "sk-test-key" cfg.openai_base_url = "https://api.openai.com/v1" + cfg.whisper_backend = "openai" + cfg.whisper_language = "auto" yield cfg +@pytest.fixture +def ogg_file(tmp_path): + """Create a temporary OGG file with fake data.""" + f = tmp_path / "test.ogg" + f.write_bytes(b"fake-ogg-data") + return f + + def _mock_response(*, json_data: dict, status_code: int = 200) -> httpx.Response: """Build a fake httpx.Response.""" request = httpx.Request("POST", "https://api.openai.com/v1/audio/transcriptions") @@ -32,14 +43,14 @@ def _mock_response(*, json_data: dict, status_code: int = 200) -> httpx.Response return resp -class TestTranscribeVoice: +class TestTranscribeOpenAI: @pytest.mark.asyncio - async def test_success(self, mock_config): + async def test_success(self, mock_config, ogg_file): resp = _mock_response(json_data={"text": "Hello world"}) with patch.object( httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp ) as mock_post: - result = await transcribe.transcribe_voice(b"fake-ogg-data") + result = await transcribe.transcribe(ogg_file) assert result == "Hello world" mock_post.assert_called_once() @@ -47,66 +58,87 @@ async def test_success(self, mock_config): assert "Bearer sk-test-key" in str(call_kwargs) @pytest.mark.asyncio - async def test_empty_transcription_raises(self, mock_config): + async def test_empty_transcription_raises(self, mock_config, ogg_file): resp = _mock_response(json_data={"text": ""}) with patch.object( httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp ): - with pytest.raises(ValueError, match="Empty transcription"): - await transcribe.transcribe_voice(b"fake-ogg-data") + with pytest.raises( + transcribe.TranscriptionError, match="Empty transcription" + ): + await transcribe.transcribe(ogg_file) @pytest.mark.asyncio - async def test_whitespace_only_raises(self, mock_config): + async def test_whitespace_only_raises(self, mock_config, ogg_file): resp = _mock_response(json_data={"text": " "}) with patch.object( httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp ): - with pytest.raises(ValueError, match="Empty transcription"): - await transcribe.transcribe_voice(b"fake-ogg-data") + with pytest.raises( + transcribe.TranscriptionError, match="Empty transcription" + ): + await transcribe.transcribe(ogg_file) @pytest.mark.asyncio - async def test_missing_text_field_raises(self, mock_config): + async def test_missing_text_field_raises(self, mock_config, ogg_file): resp = _mock_response(json_data={"result": "something"}) with patch.object( httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp ): - with pytest.raises(ValueError, match="Empty transcription"): - await transcribe.transcribe_voice(b"fake-ogg-data") + with pytest.raises( + transcribe.TranscriptionError, match="Empty transcription" + ): + await transcribe.transcribe(ogg_file) @pytest.mark.asyncio - async def test_api_error_raises(self, mock_config): + async def test_api_error_raises(self, mock_config, ogg_file): resp = _mock_response(json_data={"error": "Unauthorized"}, status_code=401) with patch.object( httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp ): with pytest.raises(httpx.HTTPStatusError): - await transcribe.transcribe_voice(b"fake-ogg-data") + await transcribe.transcribe(ogg_file) @pytest.mark.asyncio - async def test_custom_base_url(self, mock_config): + async def test_custom_base_url(self, mock_config, ogg_file): mock_config.openai_base_url = "https://proxy.example.com/v1" resp = _mock_response(json_data={"text": "Transcribed"}) with patch.object( httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp ) as mock_post: - result = await transcribe.transcribe_voice(b"fake-ogg-data") + result = await transcribe.transcribe(ogg_file) assert result == "Transcribed" url_arg = mock_post.call_args[0][0] assert url_arg == "https://proxy.example.com/v1/audio/transcriptions" @pytest.mark.asyncio - async def test_base_url_trailing_slash_stripped(self, mock_config): + async def test_base_url_trailing_slash_stripped(self, mock_config, ogg_file): mock_config.openai_base_url = "https://proxy.example.com/v1/" resp = _mock_response(json_data={"text": "OK"}) with patch.object( httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp ) as mock_post: - await transcribe.transcribe_voice(b"fake-ogg-data") + await transcribe.transcribe(ogg_file) url_arg = mock_post.call_args[0][0] assert url_arg == "https://proxy.example.com/v1/audio/transcriptions" + @pytest.mark.asyncio + async def test_missing_api_key_raises_disabled(self, mock_config, ogg_file): + mock_config.openai_api_key = "" + with pytest.raises(transcribe.TranscriptionDisabled, match="OPENAI_API_KEY"): + await transcribe.transcribe(ogg_file) + + +class TestTranscribeDisabled: + @pytest.mark.asyncio + async def test_off_backend_raises(self, ogg_file): + with patch.object(transcribe, "config") as cfg: + cfg.whisper_backend = "off" + with pytest.raises(transcribe.TranscriptionDisabled, match="disabled"): + await transcribe.transcribe(ogg_file) + class TestCloseClient: @pytest.mark.asyncio @@ -121,3 +153,99 @@ async def test_close_client_when_none(self): assert transcribe._client is None await transcribe.close_client() assert transcribe._client is None + + +@pytest.fixture +def _config_env(monkeypatch, tmp_path): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test:token") + monkeypatch.setenv("ALLOWED_USERS", "12345") + monkeypatch.setenv("CCBOT_DIR", str(tmp_path)) + + +@pytest.mark.usefixtures("_config_env") +class TestWhisperLanguageConfig: + """Config validation: auto|en|ru; junk/empty → auto + warning.""" + + def test_default_is_auto(self, monkeypatch): + monkeypatch.delenv("CCBOT_WHISPER_LANGUAGE", raising=False) + assert Config().whisper_language == "auto" + + def test_trim_lower_en(self, monkeypatch): + monkeypatch.setenv("CCBOT_WHISPER_LANGUAGE", " EN ") + assert Config().whisper_language == "en" + + def test_ru_accepted(self, monkeypatch): + monkeypatch.setenv("CCBOT_WHISPER_LANGUAGE", "ru") + assert Config().whisper_language == "ru" + + def test_empty_falls_back_auto(self, monkeypatch, caplog): + monkeypatch.setenv("CCBOT_WHISPER_LANGUAGE", " ") + with caplog.at_level("WARNING"): + cfg = Config() + assert cfg.whisper_language == "auto" + assert "Unknown CCBOT_WHISPER_LANGUAGE" in caplog.text + + def test_junk_falls_back_auto(self, monkeypatch, caplog): + monkeypatch.setenv("CCBOT_WHISPER_LANGUAGE", "de") + with caplog.at_level("WARNING"): + cfg = Config() + assert cfg.whisper_language == "auto" + assert "Unknown CCBOT_WHISPER_LANGUAGE" in caplog.text + + +class TestLanguageMatrix: + """Pass-through of whisper_language to local and openai backends.""" + + @pytest.mark.asyncio + async def test_openai_auto_omits_language(self, mock_config, ogg_file): + mock_config.whisper_language = "auto" + resp = _mock_response(json_data={"text": "hi"}) + with patch.object( + httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp + ) as mock_post: + await transcribe.transcribe(ogg_file) + data = mock_post.call_args.kwargs["data"] + assert "language" not in data + + @pytest.mark.asyncio + async def test_openai_en_sets_language(self, mock_config, ogg_file): + mock_config.whisper_language = "en" + resp = _mock_response(json_data={"text": "hi"}) + with patch.object( + httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp + ) as mock_post: + await transcribe.transcribe(ogg_file) + assert mock_post.call_args.kwargs["data"]["language"] == "en" + + @pytest.mark.asyncio + async def test_openai_ru_sets_language(self, mock_config, ogg_file): + mock_config.whisper_language = "ru" + resp = _mock_response(json_data={"text": "привет"}) + with patch.object( + httpx.AsyncClient, "post", new_callable=AsyncMock, return_value=resp + ) as mock_post: + await transcribe.transcribe(ogg_file) + assert mock_post.call_args.kwargs["data"]["language"] == "ru" + + def test_local_auto_passes_none(self, mock_config, ogg_file): + mock_config.whisper_language = "auto" + model = MagicMock() + seg = MagicMock() + seg.text = "hello" + info = MagicMock(language="en", duration=1.0) + model.transcribe.return_value = ([seg], info) + with patch.object(transcribe, "_get_local_model", return_value=model): + transcribe._transcribe_local_sync(ogg_file) + assert model.transcribe.call_args.kwargs["language"] is None + + def test_local_ru_passes_ru(self, mock_config, ogg_file): + mock_config.whisper_language = "ru" + model = MagicMock() + seg = MagicMock() + seg.text = "привет" + info = MagicMock(language="ru", duration=1.0) + model.transcribe.return_value = ([seg], info) + with patch.object(transcribe, "_get_local_model", return_value=model): + transcribe._transcribe_local_sync(ogg_file) + assert model.transcribe.call_args.kwargs["language"] == "ru" diff --git a/tests/conftest.py b/tests/conftest.py index 536a37438..91c2f72b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,58 @@ discovers any test that transitively imports ccbot. """ +from __future__ import annotations + +import atexit import os +import shutil +import sys import tempfile +import types +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# Belt-and-suspenders for src-layout: even if pytest's pythonpath option is +# ignored (old pytest / wrong rootdir / bare invocation), tests can import ccbot. +_SRC = _REPO_ROOT / "src" +if _SRC.is_dir() and str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) + +# Keep pytest's numbered basetemp under the repo, not %TEMP%/pytest-of-. +# On Windows, cleanup of that shared tree frequently raises +# PermissionError: [WinError 5] Access is denied (pytest#7491), marking every +# tmp_path-using test as ERROR. Project-local root avoids the polluted ACL tree. +_PYTEST_TEMPROOT = _REPO_ROOT / ".pytest_tmp" +try: + _PYTEST_TEMPROOT.mkdir(parents=True, exist_ok=True) + os.environ.setdefault("PYTEST_DEBUG_TEMPROOT", str(_PYTEST_TEMPROOT)) + _temp_parent: str | None = str(_PYTEST_TEMPROOT) +except OSError: + # Fall back to system temp if the repo path is not writable. + _temp_parent = None + +# Windows has no fcntl; session.py / hook.py import it for file locking. +# Stub before any ccbot import so unit tests can run on Windows CI/dev hosts. +if sys.platform == "win32" and "fcntl" not in sys.modules: + _fcntl = types.ModuleType("fcntl") + _fcntl.LOCK_EX = 2 # type: ignore[attr-defined] + _fcntl.LOCK_UN = 8 # type: ignore[attr-defined] + _fcntl.LOCK_SH = 1 # type: ignore[attr-defined] + _fcntl.LOCK_NB = 4 # type: ignore[attr-defined] + + def _flock(_fd: int, _op: int) -> None: + return None + + _fcntl.flock = _flock # type: ignore[attr-defined] + sys.modules["fcntl"] = _fcntl # Force-set (not setdefault) to prevent real env vars from leaking into tests os.environ["TELEGRAM_BOT_TOKEN"] = "test:0000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" os.environ["ALLOWED_USERS"] = "12345" -os.environ["CCBOT_DIR"] = tempfile.mkdtemp(prefix="ccbot-test-") +# Isolate session CCBOT_DIR under the same project temp root (and clean it up). +_CCBOT_TEST_DIR = tempfile.mkdtemp(prefix="ccbot-test-", dir=_temp_parent) +os.environ["CCBOT_DIR"] = _CCBOT_TEST_DIR +atexit.register(lambda: shutil.rmtree(_CCBOT_TEST_DIR, ignore_errors=True)) +# PTB v22.2+: RetryAfter.retry_after as timedelta (must be set before telegram import) +os.environ["PTB_TIMEDELTA"] = "1" diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..73da4d4ac --- /dev/null +++ b/uv.lock @@ -0,0 +1,848 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiolimiter" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/23/b52debf471f7a1e42e362d959a3982bdcb4fe13a5d46e63d28868807a79c/aiolimiter-1.2.1.tar.gz", hash = "sha256:e02a37ea1a855d9e832252a105420ad4d15011505512a1a1d814647451b5cca9", size = 7185, upload-time = "2024-12-08T15:31:51.496Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/ba/df6e8e1045aebc4778d19b8a3a9bc1808adb1619ba94ca354d9ba17d86c3/aiolimiter-1.2.1-py3-none-any.whl", hash = "sha256:d3f249e9059a20badcb56b61601a83556133655c11d1eb3dd3e04ff069e5f3c7", size = 6711, upload-time = "2024-12-08T15:31:49.874Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "av" +version = "18.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/f4/f22114d30d3435e38c6af2b4870f37b864403dca6ae7af747a289ce0a18e/av-18.1.0.tar.gz", hash = "sha256:47bfc286e1bc9de7ab4681fc2b575cd2460a66919d31ffe1bd5aa54fae531a28", size = 4451061, upload-time = "2026-08-12T22:28:18.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/d4/d7cdc8bff143c17a6d35924375ae28dd692cacde38700a7d419fde54f44a/av-18.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ae75d8bb6467895ed1f8572ededf7ffa49eac07f6e483222f5d7d62a41d12f04", size = 22546147, upload-time = "2026-08-12T22:27:11.851Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c9/37a619297492256b77d5ed906e7d8166c10a26ed251dccf1ae03ab19bff6/av-18.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:b30a4e8d934558e19602b68998a4d9ac9f250fa0dacef216f7e8e40153b13316", size = 18217603, upload-time = "2026-08-12T22:27:14.713Z" }, + { url = "https://files.pythonhosted.org/packages/d9/84/2464ffb64c08c5ce8b522c8e74594714414e3b0575267652c5c51c0574b9/av-18.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6fc837cc51adf80331ac850779cd53b5d4c4460b0ebe9057a02a921c6736f19d", size = 33640142, upload-time = "2026-08-12T22:27:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/204dbfc3e08eb4cdc6e6ff57be02150bc44523ebdb50182d10025792ebd9/av-18.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:8a032e8d8ebc73dec079364b9b4a6837638a2d106e8472314e685ffbf163e700", size = 35786210, upload-time = "2026-08-12T22:27:20.984Z" }, + { url = "https://files.pythonhosted.org/packages/e1/99/b0d04ec553ff9a7e00455458dfa3a39c8a8f627b273056b4e5fe57d590de/av-18.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:3c8b1f8b46f99d52e2d8b0ed5d0cdadf172d24794d46e2077b16e44ed08e26ff", size = 39379798, upload-time = "2026-08-12T22:27:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/56/b1/e00d4feae59160149df6126585e726fdc6300798fd40c5dd324879e81f68/av-18.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab5ac081bc9eaf54109120d4e56284674fecfbe520d9aa1707c7fa911ec5f4d2", size = 34690321, upload-time = "2026-08-12T22:27:27.769Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/836fa987e3084d11a21489f11357fb24843ef3aa8faf74ddddfc603d5062/av-18.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:191224788d87af06c31784a395bb73f14b72f33d7f4871ace0157de2abdc6276", size = 36859932, upload-time = "2026-08-12T22:27:31.403Z" }, + { url = "https://files.pythonhosted.org/packages/33/b4/76ba21e46704f632004276b85289a1582e95f5eff760436d6149875a1881/av-18.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:ea1480b7a8d5405cb5f382b344731bf125fd2c1c6fae3964f6c48595628387ff", size = 27595679, upload-time = "2026-08-12T22:27:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ad/a3135884c5753b09773176b97201ae602f67ad14206c395ff838d66bf9b0/av-18.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:5509ec12aaa19fd6601de13cfa6f4cdad450da07982118510592875d970454d6", size = 20257584, upload-time = "2026-08-12T22:27:38.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5b/4a756265d7fb164336c8d377bca21c39cfa2c178be23cedee840a69b59c5/av-18.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b36b0bae9e4c62f9487c99481ec15e4e3870fcc868522cd6d18fc2d6bfa04f01", size = 22795654, upload-time = "2026-08-12T22:27:42.016Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cc/1bc841462114a1adf4f7d87456ab78a6972e23271e71865fcd2bbd0e7360/av-18.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:025f84494cb23278498f03b0d8117d3e47a1cbc9c44b97eb31875cf02251e46b", size = 18435735, upload-time = "2026-08-12T22:27:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/b8/20/005500ed17a2e62a5e4bb94aa3786942560ec2f55ec1895ebf174c87abef/av-18.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:08a9ae288299cfcbf739dba4ad0c53b9b71f45184303dd45947920d022fed695", size = 37090807, upload-time = "2026-08-12T22:27:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f7/11e7f6d848d3690c31ca4f8578167393e619177f1493ccc93b9400852d4e/av-18.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cf8a17466bef07765dbdecc9e66ed9b25d20b4e14f654fbf35345a58ac45fa0c", size = 38976836, upload-time = "2026-08-12T22:27:54.565Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/b271473b24e806062d31191e40c6d65545e9cf59f80f044eba56dcbba0f4/av-18.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d49a5c542dfdc00f43c6cdb6cc41dac1781ee206fe180b56aa7433dfa816dfae", size = 40896630, upload-time = "2026-08-12T22:27:59.118Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9f/2ab7fa292a947ad3466ed8e655eefa3b82f535d7ea598c297b4471a937c4/av-18.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5548b79e2bf1f59b3e9aedc918a72d9dc45b9adaac10ff9470d5dbdda0002e47", size = 37895673, upload-time = "2026-08-12T22:28:03.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/04507c57249b399c3e4f23f01d221532f357338b5316fd2858fbd343127d/av-18.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7ea063f6690193ea335a1d592d6e0274350d45e2ed6af83ee107cb90cbfd84f", size = 39992431, upload-time = "2026-08-12T22:28:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d6/bc4b95bea9c2353a7e4d62a3fcfad9adcf0f881741c6ce01ee179d539ce3/av-18.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e4d48b9f12cad009cc72fe4f4099107de5e819c95f82767f4fd01a01481c0661", size = 28497798, upload-time = "2026-08-12T22:28:13.003Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d2/0c277a46f12647c1833f40496e132fb6001e0d19e6144b5ea30896461feb/av-18.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5cd9085028902c9880622bd37a12fd4b33060f06a52311f6f4867ca9f29a2c3b", size = 21421979, upload-time = "2026-08-12T22:28:16.48Z" }, +] + +[[package]] +name = "ccbot" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "httpx" }, + { name = "libtmux" }, + { name = "pillow" }, + { name = "python-dotenv" }, + { name = "python-telegram-bot", extra = ["rate-limiter"] }, + { name = "telegramify-markdown" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +voice = [ + { name = "faster-whisper" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=24.0.0" }, + { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "libtmux", specifier = ">=0.37.0" }, + { name = "pillow", specifier = ">=10.0.0" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-telegram-bot", extras = ["rate-limiter"], specifier = ">=21.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "telegramify-markdown", specifier = ">=0.5.0,<1.0.0" }, +] +provides-extras = ["dev", "voice"] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/83/6051c2a2feab48ae5bd27c84ef047191d2d4a3172f689e38eaa48ed17db1/coverage-7.15.1.tar.gz", hash = "sha256:165e9949eaf222ef1f018635d0d7f368a23bfe0212af558534c40d8c04686d67", size = 927640, upload-time = "2026-07-12T20:58:19.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/76/32c1826309beaf4604c54accef108fdd611e5e5e93f2f5192f050cd5f6bd/coverage-7.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d9476292594309db922cc841dd13b303b3c388f4c25d279884f7e2341c681f80", size = 221497, upload-time = "2026-07-12T20:56:37.628Z" }, + { url = "https://files.pythonhosted.org/packages/db/5c/b88ce0d68fa550c7f3b58617fbf363bce64df5bf8295a01b627e4696e022/coverage-7.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c579056b0de461b3a62318b63d0b6ce90aed7f8158d3f00da094df82f29d189", size = 221854, upload-time = "2026-07-12T20:56:39.033Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/8509fd2a66fc4e0a829f76a0f0b1dc3cc163368352435b5f243168658077/coverage-7.15.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:23214bdbe226f2b0e9c66a7d6a1d59d4a88045dcf86e702cf0fe0d0935e3d615", size = 253359, upload-time = "2026-07-12T20:56:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/e5/81/c7009ed7ea9765adb2b9d095054d748266fae5f07ac6c5f925f33715fcde/coverage-7.15.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df164be93b46b4825cc39339440a05edc54c4d1d865ba4a60fd43d151a2a1cd3", size = 256096, upload-time = "2026-07-12T20:56:42.115Z" }, + { url = "https://files.pythonhosted.org/packages/21/52/dc8ee03968a5ba86e2da5aa48ddc9e3747bd65d63825fdce2d96acb9c5ff/coverage-7.15.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a524fca1a6f08927d9dc2d4c873cfb7bd7202c247f08b14bdc02424071b8b304", size = 257211, upload-time = "2026-07-12T20:56:43.513Z" }, + { url = "https://files.pythonhosted.org/packages/b8/27/95d7623908da8937deb53d48efcdbf423907a47540e63c62fa21372c652b/coverage-7.15.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d70f3542cd38de85a9e257dcb1ac4c1ab4b6d7d2c2a645809207556628755d1c", size = 259473, upload-time = "2026-07-12T20:56:44.974Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3b/730d761928de97d585465680b568ae69622fb40716babadeabffe75cb51b/coverage-7.15.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d78aa537237212c4313aabe5e964b66acc86350ed19ebc56a3e202df33b6077b", size = 253759, upload-time = "2026-07-12T20:56:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fc/6b9277acff1f9484b6c12857af5774689d1a6a95e13265f7405329d2f5da/coverage-7.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a318112bb4f79d9d04766196d5a3388caa825908a6a9b052aa87de3d9aea7c61", size = 255131, upload-time = "2026-07-12T20:56:48.073Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f2/c704f86129594ba34e25a64695d2068c71d51c2b98907184d716c94f4aec/coverage-7.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e55d24cada901963eed5bc89fa562aa033f0d84b9d3de4ecf363737c13aed11e", size = 253275, upload-time = "2026-07-12T20:56:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/29/80fee8af47de4a6dce71ccf2938491f444687a756af258a56d8469b8f1b0/coverage-7.15.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3c78f0cea7275342cf2adc2ad5fdd0aafa106ad91e66d573568f2fcf62c41df5", size = 257345, upload-time = "2026-07-12T20:56:51.038Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/a1e7d7ed1b48a8adf8fd5154d9e83fcc5ad8e6ff20ae00e44865057dce8d/coverage-7.15.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:86bd37eabe39977216f630a7fc1b698e7f5e81a191c7186013245c6c3d313f9d", size = 252844, upload-time = "2026-07-12T20:56:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/a4bc26e6ee207d412f3678f04d74be1550e83140563ca0e4997510579712/coverage-7.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6db15c217693bdc3ca0b84de1ba9afafe1c14c26a8a29d77f4ed0de2b6132e2", size = 254716, upload-time = "2026-07-12T20:56:53.968Z" }, + { url = "https://files.pythonhosted.org/packages/11/9d/8ad0266ecfada6353cf6627a1a02294cf55a907521b6ee0bd7b770cfd659/coverage-7.15.1-cp312-cp312-win32.whl", hash = "sha256:359f3fbe09a51500c51966596ee4ee4070b356552c70b3b2420eb200d68e0f76", size = 223554, upload-time = "2026-07-12T20:56:55.583Z" }, + { url = "https://files.pythonhosted.org/packages/81/6d/24224929e06c6e05a93f738bc5f9e8e6ab658f8f1d9b823e7b85430e28b8/coverage-7.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:fa75dc099c126e941a9c0baa8ebd2cbc78bd778687534fe410baf754f6d9e374", size = 224087, upload-time = "2026-07-12T20:56:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/35/23/f81441dd01de88e53c97842e706907b307d9078918c3f4998b11e9ac7250/coverage-7.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:26f89cf6d0634375f454fa71057945ad18edb0f1607a90fecf22c57dc3dc289a", size = 223472, upload-time = "2026-07-12T20:56:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/6fa289d7993a2a39f1b283ddb58c4bfec80f7800be654b8ba8a9f6a07c63/coverage-7.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71ac4ca1658ca99160fd58cc6967110e989c34b04627f24ed6ec9f70fb24571a", size = 221519, upload-time = "2026-07-12T20:57:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/0db4902a0588234a70ab0218073c0b20fbc5c740aa35f91d360160a2ebc9/coverage-7.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26a40cbf2b13bd94af53ee02a424cb3bb96a9edfac0d00834bd068512a62714b", size = 221895, upload-time = "2026-07-12T20:57:01.867Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cb/3719783865092dac5e08df842730305ee9ab1973ae7ddb6fbdf27d401f30/coverage-7.15.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4c5a5eff4ad4f9f7088fd3fc7a66d98d06566ee294b3b053309fb0a3b45be1e", size = 252882, upload-time = "2026-07-12T20:57:03.459Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5e/caf3abbdbb22629626160ffc9c017eb995b7cb11c0be46b974834cef1792/coverage-7.15.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:962aa56c1c9b016d681265880eb6acc9966029d2c4c559319cc43a1abbb9b59a", size = 255479, upload-time = "2026-07-12T20:57:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f1/d60f375bfe095fef944f0f19427aefdbf9bdd5a9571c41a4bf6e2f5fdb81/coverage-7.15.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1678eb2dc57a8ce67601b029582ef6d41e9e6ca22692aaeccd4107e40f27386c", size = 256715, upload-time = "2026-07-12T20:57:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/d7/17/8b0cbc90d02dc5adad4d9034c1824ec3fa567771b4c39d9c1e3f9b1431b8/coverage-7.15.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1174900a43f6f8c425fee10d7dbddc308adefcdc78aaced32357f5ab750a0e90", size = 258845, upload-time = "2026-07-12T20:57:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/c5e69f5fb75c322e9a3e4ef64d02eebfc3d66efceccc8514ff80a3c13a56/coverage-7.15.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98847557a6859cadf693792ce89f440cb89692993f60dc6d3a7e35f3d340216f", size = 253098, upload-time = "2026-07-12T20:57:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/21144252fdd0c01d707d48fbcea13a80b0b7c42ced3f299f885ab8978c3a/coverage-7.15.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8697b2edb57143546a24389efc11e1b000cd5800fc20d84f04edb601e4a7cfb8", size = 254844, upload-time = "2026-07-12T20:57:11.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/2a/499a28a322b0ce6768328e6c5bb2e2ad00ac068a7c7adb2ecd8533c8c5d9/coverage-7.15.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6827ac0519be3fe91bf96b4060eb00d1d24f82649b29862cd75a3cfca248b02a", size = 252807, upload-time = "2026-07-12T20:57:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/928a95da5da8b60f2b00e1482c7787b3316188e6d2d227fb8e124ada43a1/coverage-7.15.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2de8ecbbc77c7e4d22572779920ed8979c69168675e96be3a548c996568c6c31", size = 256965, upload-time = "2026-07-12T20:57:14.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/10/889adbc1b8c9f866ed51e18a98bcafc0259fb9d29b81f50a719407c64ea8/coverage-7.15.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b25f0f0fa5260df9d7bb55d47c8bdc23fa3382c1a18f7c9cae122e6c320b1ad", size = 252628, upload-time = "2026-07-12T20:57:15.892Z" }, + { url = "https://files.pythonhosted.org/packages/1a/30/a5e1871e5d93416511f8e359d1ccebfe0cbb050a1bbf7dd20228533ec0cf/coverage-7.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a2effcbd93ae340a58db718fe4181d967f84d352c4cefeaab4ff82ce813901a", size = 254399, upload-time = "2026-07-12T20:57:17.703Z" }, + { url = "https://files.pythonhosted.org/packages/2d/26/c36fbffd549dadbdd1a75827528fb00a4c46aa3187b007b750b1e2cebbf2/coverage-7.15.1-cp313-cp313-win32.whl", hash = "sha256:895e65c96aef0cecea250f6e35e9a32f11375514e1a0cb5210e0fda128c04e8e", size = 223564, upload-time = "2026-07-12T20:57:19.253Z" }, + { url = "https://files.pythonhosted.org/packages/16/fc/becbb9d2c4206d242b9b1e1e8e24a42f7926c0200dd3c788b9fab4bb96d5/coverage-7.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6d0a28b63a0d75f9ed5118105d1154fc3aa40a8605a30d5d87e3d043ad90fe7", size = 224106, upload-time = "2026-07-12T20:57:21.108Z" }, + { url = "https://files.pythonhosted.org/packages/d3/30/1cfc641461369b6858799fca61c0a8b5edc490c519bf7c636ffa6bbf556f/coverage-7.15.1-cp313-cp313-win_arm64.whl", hash = "sha256:b4ee9818e8bae3544379ad2c09b851c4fb886aaa8860d57a1c1316ddcb16db49", size = 223497, upload-time = "2026-07-12T20:57:22.734Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/81961952e7aebfb38ad0ae4264e8954cc607a7af9e7ac111f9fa986595cc/coverage-7.15.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a886af95f59edf67d5770fd3564d53f4a8af93f25f8c1d60d27e00d7f5674ee8", size = 221560, upload-time = "2026-07-12T20:57:24.282Z" }, + { url = "https://files.pythonhosted.org/packages/13/d2/ee14d715889f216baf47301d9f469e08fff6995552aaf67e897b282865f6/coverage-7.15.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:985657ebd707941de90d488d1cbb5efac20bdf81f7b91eba771624ccda4d36f4", size = 221894, upload-time = "2026-07-12T20:57:25.87Z" }, + { url = "https://files.pythonhosted.org/packages/f3/38/f830bc6e6c2c5f23f43847125e6c650d378872f7eeba8d49f1d42193e8a9/coverage-7.15.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5bbe2a06e0a5e1404d9ffbdb49b819bbd6a3bb198ebea4c8dfe7ad9f1e1c2e81", size = 252938, upload-time = "2026-07-12T20:57:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/e1/53/0d3dd963631259d794c898735d5436e68d6a8d40749c419a07ff7c171469/coverage-7.15.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde0fe24083d0b7b3dbafa7a09f0796410af1afa2523f28f5f208d8340a4aaca", size = 255445, upload-time = "2026-07-12T20:57:29.234Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/aabed228557565c958259251b89bab8c5669b31291fa63b3e2154ebb017a/coverage-7.15.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f89f7453d6d46db14cf233e2cd8edcd78de2b9c49d4f1dc109590b4e5dbfbb74", size = 256790, upload-time = "2026-07-12T20:57:30.826Z" }, + { url = "https://files.pythonhosted.org/packages/bc/aa/1cc888e5d3623e603c4e5399653cb25728bb2b40d7519188a3e293d24620/coverage-7.15.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc3656c9ecc27b36bd0907455b77f83c0069ca9ad4a66dec892b76c696eb6047", size = 259104, upload-time = "2026-07-12T20:57:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/fc16d5f5e53098dae41efa21e8ccc611a9b4fe922750dd03dc56db552182/coverage-7.15.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:24d8e85a2a45e44883b488c2659f51fa761dad5353fdb319b672a93facbd2ca9", size = 252956, upload-time = "2026-07-12T20:57:34.316Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f3/52384668c3de4519ca770bf1975a89e4d6eb5aa2faf0da0577a14008cba4/coverage-7.15.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68931b5fe746ed4fdaa8892989cab9e6c35781eeb3b0ab2ded893d561e1b3652", size = 254797, upload-time = "2026-07-12T20:57:35.947Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/54b807e7c1868178e902fd8360b5d4e559394462f97285c50edf1c4608db/coverage-7.15.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ce6947e2a95534ecaa5a15e73c21e550514c980d80eda204d064d789a95f6a4", size = 252762, upload-time = "2026-07-12T20:57:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/7b/48/dde8adf0338e3ace738757dccf1ce817e5fdcadfae77e1b48a77e5a3b265/coverage-7.15.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:841befdbc89b9c82435fc25b0f4f41858b6238693e45af758bec4cfc1968171c", size = 257037, upload-time = "2026-07-12T20:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/179dd88cf60a0aeeee16a970ffe250dccea8b80ed4beab4c5d3f6c41ad4b/coverage-7.15.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5d3de58b837375e7f4c0e1a088ccab5f655efb2fd7427b729df02c862a559633", size = 252577, upload-time = "2026-07-12T20:57:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/2c/37/8a593d69ab521beb6a105a2017cac4ba94425ee0a8349e29c3c0b522d24f/coverage-7.15.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b1801963f9f44ae0c0f6d737bc7aeb2bbcde7d1fe7e3b43cddc1961af42d3b41", size = 254235, upload-time = "2026-07-12T20:57:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/bc9b3bced66f2cdad4bf5e57ae51c54ea226e8aaaebfc9370a9a11877bf3/coverage-7.15.1-cp314-cp314-win32.whl", hash = "sha256:8c7953c4128ef53b6ffb5f90d87c87d4ce26731df294760bb2314eb0e069e44b", size = 223771, upload-time = "2026-07-12T20:57:44.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/4d20337bed61915d14349e62b88d5e4144d5a9872b64adbe90e9906db6db/coverage-7.15.1-cp314-cp314-win_amd64.whl", hash = "sha256:6f0bab60a582d415f0fb535ccff13ba334a47a1538f98913330a525d23bd535a", size = 224257, upload-time = "2026-07-12T20:57:46.412Z" }, + { url = "https://files.pythonhosted.org/packages/7b/df/bbfeae4948f3ded516f92b32f2d57952427fc5ecfc0924487bb6ee6a5f38/coverage-7.15.1-cp314-cp314-win_arm64.whl", hash = "sha256:0f410ee8f0ac4ec7db71bc0b7632a8b9994e1cad2755bd1566c17e6a162caa74", size = 223683, upload-time = "2026-07-12T20:57:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/35/65/0b431856064e387d1f5cf474625e4a0465e907024d42f35de6af19ced0be/coverage-7.15.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc868bab88e049d41fcd41766810d790a8b960053be2a45e060f5ce0d31d258b", size = 222298, upload-time = "2026-07-12T20:57:49.882Z" }, + { url = "https://files.pythonhosted.org/packages/a6/96/50eac9bd49df8a3df5f3d38746d1bf332299dffb554486c94ebd55c9dc49/coverage-7.15.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:206d4ec6028f2773b40932d09f074539d6bcdd8f6b318d40cb04bdbd68ed0b49", size = 222561, upload-time = "2026-07-12T20:57:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/e0/5b/6ba1c4a27e10b8816fd2622b98162c83d3bdf1185097360373611bf96364/coverage-7.15.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:620482ef1c9f4e61f962e159325fe77dea59d16e39d9c9470d069053b244d864", size = 263923, upload-time = "2026-07-12T20:57:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/e4/59/fe03ade97a3ca2d890e98c572cf48a99fda9adba85757c34b823f41efe1e/coverage-7.15.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d385fc9b054e309ad3cecdc77b586d2af0c98aeec2fdb3773544586f366e817c", size = 266043, upload-time = "2026-07-12T20:57:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/e0/55c4b1217a572a43e13b39e1eb78d0da29fb23679003bd0cdf22c50b1978/coverage-7.15.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1198bca9c0dd7c188aae1f185b0c0b5fc4f0a2b6909000858c29550320bdb07", size = 268465, upload-time = "2026-07-12T20:57:57.017Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/ee47944f76afc03909119b036fe9e0da8cbd274a5141287de79791a0fb6d/coverage-7.15.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d0297e6a070eadb49df7cddd0ab6f420b8b689dd8904c7dd815a323168fa57e", size = 269584, upload-time = "2026-07-12T20:57:58.958Z" }, + { url = "https://files.pythonhosted.org/packages/cb/8a/6b4d9779c7b2e21c3d12c3425e3261aa7411399319e27aa402dfec4db5d0/coverage-7.15.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916fcf2214f56960e409561b37fc32a160a42b6e85483d0652d7b70fa55d707e", size = 263019, upload-time = "2026-07-12T20:58:00.979Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1e/db5c7fa0c8ba5ece390a1e1a3f30db71d440240a80589df28e66a7503c40/coverage-7.15.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f837bae572c7869ffaa502e604c87e182543012831cf87aae4586ad090ac6dcf", size = 265916, upload-time = "2026-07-12T20:58:03.005Z" }, + { url = "https://files.pythonhosted.org/packages/83/53/fe5176682b00709b13fab36addd26883139d0dea430816fea412e69255e2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3ea65e3ee6c7c32349fd00559927a9e577bdd72386087eeed1c42b62dfce9b82", size = 263520, upload-time = "2026-07-12T20:58:04.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e7/16f15127be93fbc70c667df5ec5dce934fc76c9b0888d84969a5d5341e2c/coverage-7.15.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:345034976f46a1c54bd17f4e43eb30bb92cb7082fcddff03250cff136cc4eb82", size = 267254, upload-time = "2026-07-12T20:58:06.824Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/e5119111f6f065376395a525f7ce6e9174d83f3db6d217ea0211a61cca4d/coverage-7.15.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4f051a64eb8f8addb4661c2b41d6eea5b7ebc68ad4b2baea8d9bc54e1956e5f7", size = 262366, upload-time = "2026-07-12T20:58:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/d7/9c/6d0a81182df18a73b081e7a8630f0e2a52b12dfd7898c6ab839551a454d2/coverage-7.15.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a7625770f7720b49bb30d194ad2f8d50fab3c5177874af3d2399676f95f9c594", size = 264680, upload-time = "2026-07-12T20:58:10.359Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a2/bac0cbd4450638f1be2041a464b1766c8cc94abf705a2df6f1c8d4be870d/coverage-7.15.1-cp314-cp314t-win32.whl", hash = "sha256:81e503d130a472ad1bd38199ecd35116b40d92bcd31e27a2cacde035381f2070", size = 224077, upload-time = "2026-07-12T20:58:12.065Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b2/d83c5403155172a43ba47c08641bad3f89822d8405102423a41339d2c857/coverage-7.15.1-cp314-cp314t-win_amd64.whl", hash = "sha256:724e878b213b302ad46e9f2fc872d386613f20ebfc492a211482d917ea76c14f", size = 224908, upload-time = "2026-07-12T20:58:13.956Z" }, + { url = "https://files.pythonhosted.org/packages/cc/41/442b74cad832cc77712080585455482e7cc4f4a9a13192f65731dcd18231/coverage-7.15.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ce2f05c14d077f406fefc4fa5e4f093ad0e0787549f6582535d6e28766f0361b", size = 224219, upload-time = "2026-07-12T20:58:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/34/98/07a67cf1a26e795d617ed5c540c042b0ac87b72f810c30c07f076cf334f3/coverage-7.15.1-py3-none-any.whl", hash = "sha256:717d01e6e00bed56ad13306f19e0dd2f4f645ee8159d2c72c72301d6cfc7090c", size = 213284, upload-time = "2026-07-12T20:58:18.079Z" }, +] + +[[package]] +name = "ctranslate2" +version = "4.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/09/9a50eeab00db68aeac08f6ab7f98b5c36abd26b89cbd707ea39e70656500/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e", size = 1270522, upload-time = "2026-07-03T12:39:13.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/6c41c4d3ae539ec76b1943c362184677befd7c1d5290d2ec361182cdb1e0/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1", size = 11930367, upload-time = "2026-07-03T12:39:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/03428106134a0a58922461074f8942f92c5ed0bb3a8d018677ad64a9c476/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f", size = 16882768, upload-time = "2026-07-03T12:39:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/47/c9/976a565398a03fb2973cbe5edd5ca03c4332d86b634799e0ee562420d3bc/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6", size = 39529060, upload-time = "2026-07-03T12:39:20.468Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/0a5f7f2b03b4e10aacb3146715724e1b96bb993cc7d199be28c9825aa120/ctranslate2-4.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251", size = 19220789, upload-time = "2026-07-03T12:39:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" }, + { url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" }, + { url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" }, + { url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" }, +] + +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/64/a02e6765de08964ed371eca577870593245afc9dfac16d037de7c10d18e6/filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f", size = 218135, upload-time = "2026-08-13T16:00:05.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/8e/50f46a9c0ce8d2861a394c1347caae037ea0431d2f67d7feb151cbc4649a/filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09", size = 98901, upload-time = "2026-08-13T16:00:03.974Z" }, +] + +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ab/522a2ab67f27971a9d48ca666d4fca85ef7d5282d142e31fd087e27b1bbe/hf_xet-1.6.0.tar.gz", hash = "sha256:2e58454a340b3556dfa4972d5451aff4fba8dd42a236600ba1a1d2b1514f0fef", size = 920527, upload-time = "2026-08-03T22:33:13.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/62/3c062f593bd92ef4e77a0ef39541e3d82a0a1d3947c8a777a02a13a27828/hf_xet-1.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:70cbb9c896901600128cb9b6f06e132954fbede1db30f31f7c6c63f84cb7c31d", size = 4074584, upload-time = "2026-08-03T22:32:47.364Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1e/c0ad437dd267a8e435bef594acf781bbc3874ff0b6435b4962d03ecf7cc4/hf_xet-1.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23379c2f9ec8696d952b16414a2bae72cad86a52df869b050698ba60f538c675", size = 3867381, upload-time = "2026-08-03T22:32:49.049Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ee/7c0d7b6ab336167531b1c30af2af003f054af4c749becbd7209ae33a77c3/hf_xet-1.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f7278c05c22fd60cb436cda1269649b3e81db65ecdc8496e5e164aa4143e7b", size = 4453982, upload-time = "2026-08-03T22:32:50.568Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/ad8eab1c9525246650cbaa821caa3cdbaca734ab1a5b8c91bea09cbd8d69/hf_xet-1.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:948f15d3a9545cfe5932f6bd8b440f6ae630aee108f14b7bd6c561f7c2dcc522", size = 4249445, upload-time = "2026-08-03T22:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/26/1eee8aedb0dafc1ab9717dc9ac602cde33361b232dc06803f1f6ed18b58c/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5153e6bb103ad49d6ea9f1b2e230db5a2ea32551ad09a706d2f61d7c7c80d80e", size = 4451099, upload-time = "2026-08-03T22:32:54.114Z" }, + { url = "https://files.pythonhosted.org/packages/67/57/0b88af1f194ab6c9c650547d9cc06bfeaab836ae4dcdb331676bfb8be95a/hf_xet-1.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:35cec30d75c6f9eb9c16a77cef68e85a103b72e24d4b473714ec9ff06428bab9", size = 4664712, upload-time = "2026-08-03T22:32:55.547Z" }, + { url = "https://files.pythonhosted.org/packages/53/a0/26b717a9d1840e8abf48dcec64b5ed8fbe472671d38ad28d30e147132b33/hf_xet-1.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5789835d7c6bc9436962853192082374297fb72d7eff7e7762ec25ceb7e25338", size = 4025906, upload-time = "2026-08-03T22:32:57.391Z" }, + { url = "https://files.pythonhosted.org/packages/49/f6/4a9966633c6fef83af997e2cff68ec1963676d412bdfd096df2a93b8e185/hf_xet-1.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:75765820ce4700db3750c94acc8fe27c5fae4c9ec000a0dbac3ca082acf97765", size = 3849221, upload-time = "2026-08-03T22:32:59.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/50/7afa2c9c787405864fc47a0d1bbc02c62e9101947ed43c1f43899fc7d91d/hf_xet-1.6.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:633dc0cd71d32da58ab8c03ad38e2fac452c15c2b0a2866ebf6ededfe0a5061d", size = 4071729, upload-time = "2026-08-03T22:33:00.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/69/55b8dcf636142ae660fec1869fcac14c4da2e8412e14d6eee1523be77e9f/hf_xet-1.6.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:f0906082d9932ae0c0057fa194041c22b4e2cdb46b2592ef3b91f020d62a081a", size = 3876287, upload-time = "2026-08-03T22:33:02.251Z" }, + { url = "https://files.pythonhosted.org/packages/67/4e/a28359bf1c1ecf11eba22123168c138698f7cb576ac678f5a2e16cd5da08/hf_xet-1.6.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d62671bb130879cef0ee4c9ebe47a14af6c66ec53e6d84dc15936e5ffdfac82f", size = 4464663, upload-time = "2026-08-03T22:33:03.802Z" }, + { url = "https://files.pythonhosted.org/packages/9a/69/1f0cbc2fb22ae6082d094f743d1b8945a3f36f6089cb95f42b7ee348cda7/hf_xet-1.6.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0e6e21fa3cdfcdcd76748564bf593870a5e013f47d97cf10aed63aa222cff5b7", size = 4262538, upload-time = "2026-08-03T22:33:05.287Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3a/4f4f2301ade26e404462d3336fa11f7958d914cabbabdd6e03c3c5d5658c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4fc74352a17015bd0ee90038bc9efe38db894cde45f268b6712b04fce8cd0acb", size = 4460520, upload-time = "2026-08-03T22:33:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/311725e2a905534dfee2dcb5b08414f249147f1f12252bfc2bd24caa075c/hf_xet-1.6.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4f71cba6129110c3374a33f919001ff130488fc23553698e34cc1c2a1198c", size = 4675937, upload-time = "2026-08-03T22:33:08.616Z" }, + { url = "https://files.pythonhosted.org/packages/98/b7/8c59a66d15205024662f1d66968136f13893f96df1ddc5087e2e281fc95f/hf_xet-1.6.0-cp38-abi3-win_amd64.whl", hash = "sha256:fb4fadde1b2b70bf4c0c14a6dccbe7194b1c28947fefd5bbe3fed9d940676c3b", size = 4033128, upload-time = "2026-08-03T22:33:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/ca511b6f802f28cf3489b280fe77475bcca8de85e81a6299d7916b5b5555/hf_xet-1.6.0-cp38-abi3-win_arm64.whl", hash = "sha256:3dc3e35441ba395006af5aaacc40ef2e603c51ef46c3530b9156185f00935ea3", size = 3859359, upload-time = "2026-08-03T22:33:11.725Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df", size = 942668, upload-time = "2026-08-07T12:48:05.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d", size = 784926, upload-time = "2026-08-07T12:48:02.905Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "libtmux" +version = "0.62.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/b8/0410c8487f7673926d141a5aaaf60133984a564b3c90eb4f3f99e92e7740/libtmux-0.62.0.tar.gz", hash = "sha256:41e9e80602b2656fd119b13253b27bad46af5cfef32099be53cdd391e2936b61", size = 571757, upload-time = "2026-07-12T21:48:02.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/96/471ac01844ee157fe794446e17568e9bf219d323575c2acc7957a9a2d8c9/libtmux-0.62.0-py3-none-any.whl", hash = "sha256:626aa6fae45e3a423e1acc81604efafa63b7262b1d67b2c7a8778b1ad691456b", size = 127700, upload-time = "2026-07-12T21:48:01.52Z" }, +] + +[[package]] +name = "mistletoe" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/96/ea46a376a7c4cd56955ecdfff0ea68de43996a4e6d1aee4599729453bd11/mistletoe-1.4.0.tar.gz", hash = "sha256:1630f906e5e4bbe66fdeb4d29d277e2ea515d642bb18a9b49b136361a9818c9d", size = 107203, upload-time = "2024-07-14T10:17:35.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/0f/b5e545f0c7962be90366af3418989b12cf441d9da1e5d89d88f2f3e5cf8f/mistletoe-1.4.0-py3-none-any.whl", hash = "sha256:44a477803861de1237ba22e375c6b617690a31d2902b47279d1f8f7ed498a794", size = 51304, upload-time = "2024-07-14T10:17:33.243Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/80/db0b4559e57ec36362bedbb05530a87fafbcb6067708c946967a41d449e7/numpy-2.5.2.tar.gz", hash = "sha256:d482d171c406ae88c5b19cad3b6a1c4c5209f886ab74bc44c2c865c23f52d860", size = 20773161, upload-time = "2026-08-09T13:48:27.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/72/dccb0aaf40972777283303919f613964227266d0c13adebb79ac124f1c3e/numpy-2.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:14e373cfc6387177e8409dac3c7159be8eb05cd77096cd7c950268b86f62831c", size = 16891693, upload-time = "2026-08-09T13:44:51.702Z" }, + { url = "https://files.pythonhosted.org/packages/60/2e/b5aee50a1f74ac815cf8331812cb8251e29024025de462e0c047641c614c/numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbd96c833ecc8cc069ce518078fc8c60cb9cbfb0fea5b7a803ad65035596d03", size = 11903109, upload-time = "2026-08-09T13:44:55.501Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f4/29e78102a80601cf034d4e9767022cffeca2c3b4c926e1754572ca95593d/numpy-2.5.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:6e8172ddfcf5cf74b811d372b570b83c60bd2de87a6fbfbebdadb4a9bd9c6cbb", size = 5350202, upload-time = "2026-08-09T13:44:58.401Z" }, + { url = "https://files.pythonhosted.org/packages/11/4b/dcd3b7eadaf4035d2c7a4289d232523a6964f602598ef7674e4bd7291f93/numpy-2.5.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f188481f1669e26f62b701e8205d19e460fa4a9b52a1414ba382330e4a3414", size = 6687736, upload-time = "2026-08-09T13:45:00.813Z" }, + { url = "https://files.pythonhosted.org/packages/e5/21/4947e0e9d6c9fc2e2ff15b8949049ee44f63adb9cacc729ab8793f97e712/numpy-2.5.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ee9c4eeb8454b3660a8b53493563c3e121c2fc94fbd72b848ef814ed7b676a9", size = 15612696, upload-time = "2026-08-09T13:45:04.151Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5f/62d28cf019460c7f1394105b4d49d9911a9c444cb77ab0bd95a204c5a6de/numpy-2.5.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3cdec01fa790a186d430433fdd4d4ffb70eed6f0eeb4bf05c8dbe2dce0a9bcb8", size = 16722264, upload-time = "2026-08-09T13:45:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/3f0be4c1b9fdf5dd5e708a6806978564d7c46a055c000496309ff2a2f8af/numpy-2.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7999d4ddb0c4025018373fd787510d46e04c769467af22869707b3c1cfd459ab", size = 16974396, upload-time = "2026-08-09T13:45:11.316Z" }, + { url = "https://files.pythonhosted.org/packages/22/72/6262cbdeeb45da9d971e40715f579d791603ba8ec0b5e2db1ac55454421d/numpy-2.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c1f017dc0875c9209d219f97feceb7d54c2661bb243deb4114478e1295808af7", size = 18476044, upload-time = "2026-08-09T13:45:14.869Z" }, + { url = "https://files.pythonhosted.org/packages/36/33/29208b8b075bde62d26a81d14b358c42b0f69b6cabd98d4ff97f37f22b05/numpy-2.5.2-cp312-cp312-win32.whl", hash = "sha256:d6a48072864e3324e194a8fbb3c657bcc5b5c869dbc64c9537b1d5c862572c0a", size = 6072817, upload-time = "2026-08-09T13:45:17.867Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/87fea2769fe1c47c1b5b01d8310772c9d1a85d485de7cf386ef7a3332b02/numpy-2.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:28ac63476ec7651484215ee7fa15a1f78b57c14621f01e392afe17b9a1390ce4", size = 12464674, upload-time = "2026-08-09T13:45:20.734Z" }, + { url = "https://files.pythonhosted.org/packages/14/52/032b97e00461ab0809bbe4c588b035620e5a14b8cdee47ecddefc7b17d33/numpy-2.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:27650bb0e7140fa3d37b9923b4803645e0b125d190f326eecfd3f4dad8e8ade1", size = 10397131, upload-time = "2026-08-09T13:45:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/6b24738a0ef4557d189b150046cd07823c50e4273e8aebd651222e24306f/numpy-2.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8e4cb9a754c8a0c62eaa88273a5fba3391f4a610d1dee893c0755da31c083f15", size = 16886595, upload-time = "2026-08-09T13:45:27.323Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/f2d208d366f263f39c6e69ed309290717aab41078b6d04c9be2a84fa2a07/numpy-2.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:52c808f96484f5571a5cc863775ce50247c17dfb3b0361f8ed6b4b0456f80080", size = 11896845, upload-time = "2026-08-09T13:45:31.638Z" }, + { url = "https://files.pythonhosted.org/packages/3c/79/81e0bf24f4d020a2b1d5cd297a9f60c3f24eeb116f9bba5870443f7b6a4a/numpy-2.5.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:29d81e97f668489cba8ebfd796b9bdd453525d35dd9e162e2daec94bf3fc7740", size = 5343880, upload-time = "2026-08-09T13:45:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/e3141cf06d1a8a2c7e107543fe1269c1d1af760d4d683c0794a4ee1127c2/numpy-2.5.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:afb3f0632d6b2e3ba04dbce8d1e48d321b369138b73830b5ca371a0e8d479d56", size = 6682264, upload-time = "2026-08-09T13:45:36.7Z" }, + { url = "https://files.pythonhosted.org/packages/29/f1/2a64a307d92c5d98f5255a4014eb43bb6103ee477087b61ecae44a3aa9b9/numpy-2.5.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0aadf13b60048d501e05fa699efaf7734e2494f3498a4c2a5521d822640324f3", size = 15609566, upload-time = "2026-08-09T13:45:39.518Z" }, + { url = "https://files.pythonhosted.org/packages/7b/44/59a1eb68e773c4098d107ef34a0dbdeca501d72ffcfbff9a7707343921ce/numpy-2.5.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29b86ff8a6cc556b47ec6b64b194815cc80e6bf5eedcc6cddfd65318cb0b4eee", size = 16709995, upload-time = "2026-08-09T13:45:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4c/3e54d4ddbc359a1295f8b633e8106bcd4d7d4a206e82df051bdfb3058755/numpy-2.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6950c4b7dd562453090548ba7f5da7e59f57f85663f15d5dcc60e249192f7e59", size = 16972511, upload-time = "2026-08-09T13:45:47.094Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9f/02e371638ebf19b66d46231e4be52999e87f32d1961b113bc45656608b22/numpy-2.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9727f472d2f3888053b8a75ab0cb94745a9de224bb5846dbadc0092101bc71d", size = 18465609, upload-time = "2026-08-09T13:45:50.808Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/ad6645abc7a3510fe48e8ea1ab4598166f500057ef4ebf38bfad4f1577de/numpy-2.5.2-cp313-cp313-win32.whl", hash = "sha256:4f9744f9fbdcea0bc552e8f19e1f141f811a3f9bc2be2cc6e86d982cab23e3f4", size = 6070204, upload-time = "2026-08-09T13:45:54.111Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/f3489f86d81ea460b2bcdceaed094142ca6579f6be0ec527b781d39afe68/numpy-2.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:85aaccb24182c25df891ad0ec333585967e115269d5f1b17f2c9ae005bc96657", size = 12460532, upload-time = "2026-08-09T13:45:57.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/21/35b31dde1b283b79de828b80f876afd8c94e28fe1e9c375f89e261cc4c0d/numpy-2.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:bd68ece1553d2023c09a4226d9e41c586ad2d20594d1a456186c33513d2cb3f2", size = 10396725, upload-time = "2026-08-09T13:46:00.478Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/c3b222bf075b50afd8e949a07a15c4b312a4a84bd8102a332bcd953cbbb4/numpy-2.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d787cf769c3baeb5f6235e778edb52c08dfa923789b5958f28e6450f96107cb1", size = 16885180, upload-time = "2026-08-09T13:46:03.939Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/2c1d4b1987795a92b5bbf7c24fe249ab96aa2573ab0d7604802c189d7b86/numpy-2.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:24b9dc2e3d84aa58523798805194e23e736f3f6ce2d1a5b92583ae734e6dbda8", size = 11907878, upload-time = "2026-08-09T13:46:07.045Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ee/d08226fc858044355983a6e5b94f08ff6f3969e0a2b160a4a89f0ddb3445/numpy-2.5.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:9e9413326d726c2545bfa65d2c0876871e8d8386e77f992c1d426e180bbd4323", size = 5354922, upload-time = "2026-08-09T13:46:10.04Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/6d3d933056440ebbc5e6bad92065fc6c26a48a84a36b1208580e94eea76c/numpy-2.5.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:60e902ac295855348a5ca2ea4c89108989a9f5fddfad3dfc0a8f36b10358567e", size = 6679168, upload-time = "2026-08-09T13:46:12.275Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/ecd49dd90033cceb2704d88ca905d4d7d89b0e8c739608754ffd325fa820/numpy-2.5.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e500dc868e9313530ce12ba470fe50ff3afe3d62993ed6eff652dacd555b65", size = 15624501, upload-time = "2026-08-09T13:46:15.322Z" }, + { url = "https://files.pythonhosted.org/packages/c7/99/461bd36dbdfac6c1c53efa370bd55a83227542d0d118f1677dbf1a3dacd5/numpy-2.5.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318b9a4c845dbea06708a29c84ee429cc3065048db34cdb799047643492050ee", size = 16713701, upload-time = "2026-08-09T13:46:18.949Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9c/2b251df9e8a5d647b62b0cbc1b90a91850c1cf4859ecb532fd0b4eacff6c/numpy-2.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:34c319e2963be042673fb46570501b2f06c41924e17e3563d58646b4380dfb68", size = 16986065, upload-time = "2026-08-09T13:46:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/8f/25/20de43f53ff1390534a124475055a19f01fe10c920a0fd11b8e18d6d6052/numpy-2.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f06571a052127dc1b4e8b83029b4d1b20daa2b64a31cdd181fc6bc774e9000eb", size = 18470031, upload-time = "2026-08-09T13:46:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/56/5e/0c577ca308d6da5eb79b546ba10bbe5b60148192194e2da060913b1de4f1/numpy-2.5.2-cp314-cp314-win32.whl", hash = "sha256:2cc779226e476d1e1f08c74068c419e60f41a9e0e069c92f6671d31d5c985e98", size = 6121028, upload-time = "2026-08-09T13:46:30.046Z" }, + { url = "https://files.pythonhosted.org/packages/15/5c/7bcbd5b11f94199073320410cddcbb80cee62415bfeb540874b265c2d922/numpy-2.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:7587f53dfbd5edc0f7b87c6217b4c6d2d1f2ef9c3da70bc1315e7db5f8d7ec9d", size = 12597627, upload-time = "2026-08-09T13:46:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/87/bc/4d0b06fba0da90ccc75af62823cb9dcedb6c9ea0cffa058cb2c9ee773a77/numpy-2.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3e4c367352d3747784248a227fbec218e193b56f7e6692e3b64fc805478ecfdf", size = 10680414, upload-time = "2026-08-09T13:46:36.036Z" }, + { url = "https://files.pythonhosted.org/packages/cd/17/f429aac9dc08833a0d0f188eba38c532a751b1a1f2ca6018a37b455cb321/numpy-2.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b879fb674276e331513fb136b78dbc6bd3c848309e0d841cfd63be3896c4cfc1", size = 12026967, upload-time = "2026-08-09T13:46:39.084Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9f/d0849de96a2a4ceaa16662f18ee13eaa9c0aa418269fdc8c4857c56b11da/numpy-2.5.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:fd0d703772bba096843785bd38371e31bb4a0c1151497ad5739d182114a73f7f", size = 5473874, upload-time = "2026-08-09T13:46:42.075Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/8df216d4a4a5422a3de045301cf7df8ea47286d76f5cb7160b0128ac26b7/numpy-2.5.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:3a2f061cebd9e3d23bdcfaaded5e2293a4c6a5b60fa42df85d410a725ce621bf", size = 6789276, upload-time = "2026-08-09T13:46:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/20d7e9891c4ddfadd6ff8d95bf4b29f353d8e1770553de2099880551dfb9/numpy-2.5.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6df895598c0edcb41030126c89e0f353b07d93238116143b7405e937359736c4", size = 15659154, upload-time = "2026-08-09T13:46:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d6/f3aa3d2688bf501b858835c6bd087ae9b51a56ae6fca8e2b0990abd177af/numpy-2.5.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ab3d4a901f844ea836c3e80bf463c6a27d7f3c14e8e292fcf28d348b25b9bce", size = 16748909, upload-time = "2026-08-09T13:46:51.442Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8f/1c5cae8d2baf86ab802ae97a00be55bc7e21ebc11b12bbc33376c5f05342/numpy-2.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cebc2d6dbb605a7703d59751dea4bd6b0ab127a5a4338a6f432df1936fef8b26", size = 17027685, upload-time = "2026-08-09T13:46:55.095Z" }, + { url = "https://files.pythonhosted.org/packages/5c/27/71d3467404aedc1c24ce79610f91b52b0b0f466c43a701aa56fc75c145ab/numpy-2.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eaca7ff36f0f52e2111ec71f169d8fd3e889e7ddc0d2592e0d703fd8d3ce8fac", size = 18501181, upload-time = "2026-08-09T13:46:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/14/2f/42921d27c40aea7e077f4a423ae509fd9220b028cd787bafefd8ab2b3a5f/numpy-2.5.2-cp314-cp314t-win32.whl", hash = "sha256:ddf47472af2e4280d79bac82304f5e80150211f1b9e614b760061d5fdfbb6eba", size = 6271085, upload-time = "2026-08-09T13:47:01.903Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/bad5f5d56de9b1971bac959963dda276d35c40f1854475005434bbe08692/numpy-2.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:44ef9675d908e65f9953063837c3277730f3f4437615a4cdab67b366cabaf884", size = 12787971, upload-time = "2026-08-09T13:47:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/05/f608795cb34391acd67e38d94a3c36abd8d8576293a3a80727d7595c372c/numpy-2.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:eaa088384c46f519dacb93b7ec483a6d6b19a4a2085ae4f25ab9b1c43d387d1e", size = 10750306, upload-time = "2026-08-09T13:47:07.976Z" }, + { url = "https://files.pythonhosted.org/packages/33/c6/28de0191c5f82b7d42a0a51390ba98587048aa93a39fafb05bdbe6e8d00c/numpy-2.5.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:078f9b027b478c9379b9677babbf0f8b8f1ecfada27636d7b9a93990c638739f", size = 16885274, upload-time = "2026-08-09T13:47:11.439Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/973ca116000d244897e468ea1aff30b589e5022e3c8744b71706fe33bd57/numpy-2.5.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:50a68f4bacd8a2b33d8da3d2269d0d78500f86ea582e4786dc10f5ef2c2c6842", size = 11907846, upload-time = "2026-08-09T13:47:15.128Z" }, + { url = "https://files.pythonhosted.org/packages/78/d9/8c4b3937ef204cb2fd88d389ccd0f265a2ffb11f35a01d2064cf46714bd6/numpy-2.5.2-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:e79aba74ffaf5f78a050d777c184cddf8fdffabab38acf5f3ef1fecbc17895d6", size = 5354892, upload-time = "2026-08-09T13:47:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/b6ee65ea2999fdb7023935e108e6fb776ee4082aa15f159acfa857e578c8/numpy-2.5.2-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:9a0731745a72a184490a582fb4af2533512bd071ace67785b5fdffc0ae58dce8", size = 6679309, upload-time = "2026-08-09T13:47:20.456Z" }, + { url = "https://files.pythonhosted.org/packages/43/f3/acb18d8b137a393c8e7803a8c994c9e64bde3930692a69d826993113a159/numpy-2.5.2-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ec954036759bcee3aa484f8603bd9c14f3e776293b85578b8734c2d72777c69", size = 15625850, upload-time = "2026-08-09T13:47:24.365Z" }, + { url = "https://files.pythonhosted.org/packages/a9/bf/a8e9bb0db815a0e265b5744ebedd3af0bd5faad8604e5b50a1cd012f3c91/numpy-2.5.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc649493697006bc90614a5f0bbc8cb3cb1866715c474e473694968d7e6b99ab", size = 16713664, upload-time = "2026-08-09T13:47:27.965Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/6e913736b3dd6582344af32418b5fb9dab34282e8a8174ae1d54ceb0fc13/numpy-2.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cf7de32f486e4ac9e2d93b810f9e9ac72a728dd46a32a0bb403222f27f653514", size = 16986749, upload-time = "2026-08-09T13:47:31.541Z" }, + { url = "https://files.pythonhosted.org/packages/80/09/7d3b23eff5c7428ef6c01e6f7052bb60d504c4d33e317b36b8959c24ad97/numpy-2.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2ffa7bacab3e2ee1b19ed31766bb60bb380b68c23f051e199c5cc598afd68710", size = 18470495, upload-time = "2026-08-09T13:47:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a4/68a321d825374f6eb677ffe8ef8c6b9a328304e6fd2e39d9530822776607/numpy-2.5.2-cp315-cp315-win32.whl", hash = "sha256:6b588cc8f902d6bff201c19fd00c43ab8545671e3554d014e12e14139e5e8617", size = 6120696, upload-time = "2026-08-09T13:47:38.561Z" }, + { url = "https://files.pythonhosted.org/packages/c8/23/deafbb1700f79fae9cd1e91220f133d124cc267de1b584da3fbf6db2f6cd/numpy-2.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:07d4e89f3a9ab0a9ba24264ccdb642b3dd951b2281e8883a5481a4aa79cc31a7", size = 12597324, upload-time = "2026-08-09T13:47:41.401Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/3272ba105e3bbbdaeb11357eda31e7a6825ffe159e8171665660299a948f/numpy-2.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:a610dc7e3c52edd39c2bc2375ff9c3fd59cb3ad00e4472d36f83bc1457145788", size = 10680466, upload-time = "2026-08-09T13:47:44.873Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0e/58370637b1bb70a5c9ce2b43f4b521ccb224e36ccb76a6596b17ae4b447c/numpy-2.5.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:40f4d451aed46a8046a1aae41c4e55fb3612273df9c502480135e1501576a34b", size = 16993947, upload-time = "2026-08-09T13:47:48.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/93/2abcb807712b289d6d60fe4cf30532f98974a8396d885650f3ba5a13026e/numpy-2.5.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c081cbe16ba1ab53078e5ff29013621e33c509eedab055775d956427712c236e", size = 12025331, upload-time = "2026-08-09T13:47:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3a/2898e003a5fbaf87e76c039b4ee1f5eb390471b4ffe74887c1f34c4e791e/numpy-2.5.2-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:0090ccdd57ec2703e9b49d0bf554767370581c1dd0a6b2bb2b2d9def317d042a", size = 5472336, upload-time = "2026-08-09T13:47:55.403Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/23f69d07c544597b29758b31b55c27dc9d541012a2c1496189fef702aec2/numpy-2.5.2-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:6a9bb119fb8dd21ba30b3f0e555b7e2b081bd9883af21ec9c1c633d161cda3a8", size = 6788387, upload-time = "2026-08-09T13:47:58.192Z" }, + { url = "https://files.pythonhosted.org/packages/15/ea/c0dbdbcf22f43782510a3e492dd3da73c6112b69cac8929d16d127536fc4/numpy-2.5.2-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a839318485284a6fb31be4f8f2c91c8f2cb22f4543c4a8903f12b0671ffe07cc", size = 15667096, upload-time = "2026-08-09T13:48:01.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/29c73c31748cdb0f7566642125ba17fd5b56780cddf891b085dab27e4466/numpy-2.5.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba0a474801b8dc67b66bf465548abc90e82b44d2611b5770f33008dcabffe8ec", size = 16751730, upload-time = "2026-08-09T13:48:05.706Z" }, + { url = "https://files.pythonhosted.org/packages/47/95/02501e8454796bb58dadf7a99d3181e0b464bf264e1003039572f9779fac/numpy-2.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a4035ae1129ff8777f08bfbd44f1e5d8e9c049ce0c2dd78fc0d92c13e7251c0", size = 17038686, upload-time = "2026-08-09T13:48:09.627Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b5/53a681d91b5c82687067d8ea5035e02d917b5509d6f334cb06484a954714/numpy-2.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:77843ca236b777e67f8d6b3660ea116e499612703a0ecd7093f316201eb9d8e2", size = 18507727, upload-time = "2026-08-09T13:48:13.744Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/6e11443f7b64ee376c860506091103bf68f92d2cab9e8d96d4501babf07c/numpy-2.5.2-cp315-cp315t-win32.whl", hash = "sha256:7354826bc6f8f69402e9b7fe28d15fcd34feebd74f856f111585c5b0c9fb0251", size = 6269775, upload-time = "2026-08-09T13:48:17.543Z" }, + { url = "https://files.pythonhosted.org/packages/f1/18/195d6b86cd72dbbc501edfa778005fa6b87afd34c153e46028cd3a0938f4/numpy-2.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:e5651f3f87add730ee6608d915009e19c911fba0cb000c7e3ea994b7d768eb12", size = 12782559, upload-time = "2026-08-09T13:48:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, +] + +[[package]] +name = "onnxruntime" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, + { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, + { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, + { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, + { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, + { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, + { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-telegram-bot" +version = "22.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpcore", marker = "python_full_version >= '3.14'" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/77/153517bb1ac1bba670c6fb1dbf09e1fd0730494b1705934e715391413a0d/python_telegram_bot-22.8.tar.gz", hash = "sha256:f9d3847fcb23ee603477e442800b33bb4adf851a73e0619d2050be879decf1ef", size = 1551700, upload-time = "2026-06-12T08:10:29.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/7c/ed7d4dd94280bd434173cae9f7a7aedaaab9af128ae4f494423a5687c820/python_telegram_bot-22.8-py3-none-any.whl", hash = "sha256:42373918097f1b837cc4e717d588c19ea79651497ec712bb5b0c76e5e63c50e1", size = 769397, upload-time = "2026-06-12T08:10:27.066Z" }, +] + +[package.optional-dependencies] +rate-limiter = [ + { name = "aiolimiter" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, +] + +[[package]] +name = "setuptools" +version = "84.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" }, +] + +[[package]] +name = "telegramify-markdown" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mistletoe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/eb/8a3a557eec87c0fcd4c0939232fa5ea407801050370596daa4ca3e51a1db/telegramify_markdown-0.5.4.tar.gz", hash = "sha256:c32bd04e5a1c22519c011ccf7350a01b6d162e6cc9a9d89c83eff964d491007e", size = 40370, upload-time = "2025-12-20T06:43:11.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/f0/4d07bcada3cddb66bccf061661b733e8512c5580e1bd11fba2aea1488d70/telegramify_markdown-0.5.4-py3-none-any.whl", hash = "sha256:7c806e12b6c7045d7723e064a0ff25afcb16c92c0d95385b61a57b8c53a430d3", size = 33536, upload-time = "2025-12-20T06:43:10.153Z" }, +] + +[[package]] +name = "tokenizers" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]