From 25ea89b74e6e7f7b4ee318b50ae9b8c0c6eba451 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Fri, 13 Feb 2026 16:13:42 +0000 Subject: [PATCH 01/39] feat: rebuild bub as batteries-included hook-first framework --- .gitignore | 3 + README.md | 34 +- docs/CNAME | 1 - docs/architecture.md | 66 ++- docs/assets/images/baby-bub.png | Bin 826409 -> 0 bytes docs/cli.md | 77 +-- docs/features.md | 57 +-- docs/index.md | 43 +- ...2025-07-16-baby-bub-bootstrap-milestone.md | 114 ----- docs/telegram.md | 42 -- mkdocs.yml | 6 +- pyproject.toml | 1 + src/bub/__init__.py | 8 +- src/bub/app/__init__.py | 6 - src/bub/app/bootstrap.py | 48 -- src/bub/app/jobstore.py | 143 ------ src/bub/app/runtime.py | 212 -------- src/bub/bus.py | 50 ++ src/bub/channels/__init__.py | 17 - src/bub/channels/base.py | 61 --- src/bub/channels/cli.py | 148 ------ src/bub/channels/manager.py | 84 --- src/bub/channels/telegram.py | 410 --------------- src/bub/cli.py | 25 + src/bub/cli/__init__.py | 5 - src/bub/cli/app.py | 199 -------- src/bub/cli/render.py | 46 -- src/bub/config/__init__.py | 5 - src/bub/config/settings.py | 80 --- src/bub/core/__init__.py | 16 - src/bub/core/agent_loop.py | 70 --- src/bub/core/command_detector.py | 91 ---- src/bub/core/commands.py | 72 --- src/bub/core/model_runner.py | 266 ---------- src/bub/core/router.py | 370 -------------- src/bub/core/types.py | 41 -- src/bub/envelope.py | 42 ++ src/bub/framework.py | 187 +++++++ src/bub/hook_runtime.py | 179 +++++++ src/bub/hookspecs.py | 74 +++ src/bub/integrations/republic_client.py | 48 -- src/bub/logging_utils.py | 65 --- src/bub/skills/__init__.py | 7 +- src/bub/skills/builtin/__init__.py | 1 + src/bub/skills/builtin/cli_core/SKILL.md | 10 + src/bub/skills/builtin/cli_core/__init__.py | 1 + src/bub/skills/builtin/cli_core/plugin.py | 83 +++ src/bub/skills/builtin/common.py | 16 + src/bub/skills/builtin/input_bus/SKILL.md | 10 + src/bub/skills/builtin/input_bus/__init__.py | 1 + src/bub/skills/builtin/input_bus/plugin.py | 38 ++ src/bub/skills/builtin/memory_tape/SKILL.md | 10 + .../skills/builtin/memory_tape/__init__.py | 1 + src/bub/skills/builtin/memory_tape/plugin.py | 28 + src/bub/skills/builtin/model_echo/SKILL.md | 10 + src/bub/skills/builtin/model_echo/__init__.py | 1 + src/bub/skills/builtin/model_echo/plugin.py | 24 + src/bub/skills/builtin/output_stdout/SKILL.md | 10 + .../skills/builtin/output_stdout/__init__.py | 1 + .../skills/builtin/output_stdout/plugin.py | 33 ++ src/bub/skills/loader.py | 87 +++- src/bub/skills/skill-creator/SKILL.md | 366 -------------- src/bub/skills/skill-creator/license.txt | 202 -------- .../skill-creator/scripts/init_skill.py | 386 -------------- .../skill-creator/scripts/quick_validate.py | 106 ---- src/bub/skills/skill-installer/LICENSE.txt | 202 -------- src/bub/skills/skill-installer/SKILL.md | 64 --- src/bub/skills/view.py | 31 -- src/bub/tape/__init__.py | 8 - src/bub/tape/anchors.py | 13 - src/bub/tape/context.py | 101 ---- src/bub/tape/service.py | 216 -------- src/bub/tape/store.py | 220 -------- src/bub/tools/__init__.py | 6 - src/bub/tools/builtin.py | 478 ------------------ src/bub/tools/progressive.py | 63 --- src/bub/tools/registry.py | 212 -------- src/bub/tools/schedule.py | 36 -- src/bub/tools/view.py | 15 - src/bub/types.py | 19 + tests/conftest.py | 33 -- tests/test_agent_loop.py | 76 --- tests/test_bus.py | 25 + tests/test_channels.py | 72 --- tests/test_cli_app.py | 275 ---------- tests/test_cli_channel.py | 64 --- tests/test_command_detector.py | 36 -- tests/test_fault_tolerance.py | 89 ++++ tests/test_framework_flow.py | 69 +++ tests/test_model_runner.py | 454 ----------------- tests/test_router.py | 238 --------- tests/test_runtime_event_loop.py | 51 -- tests/test_runtime_filters.py | 24 - tests/test_skill_loader.py | 69 +++ tests/test_skill_override.py | 82 +++ tests/test_skills_loader.py | 17 - tests/test_tape_context.py | 49 -- tests/test_tape_service.py | 120 ----- tests/test_tape_store.py | 133 ----- tests/test_telegram_channel.py | 113 ----- tests/test_telegram_filter.py | 100 ---- tests/test_tool_registry.py | 100 ---- tests/test_tools_builtin.py | 442 ---------------- tests/test_tools_schedule.py | 41 -- uv.lock | 2 + 105 files changed, 1329 insertions(+), 8073 deletions(-) delete mode 100644 docs/CNAME delete mode 100644 docs/assets/images/baby-bub.png delete mode 100644 docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md delete mode 100644 docs/telegram.md delete mode 100644 src/bub/app/__init__.py delete mode 100644 src/bub/app/bootstrap.py delete mode 100644 src/bub/app/jobstore.py delete mode 100644 src/bub/app/runtime.py create mode 100644 src/bub/bus.py delete mode 100644 src/bub/channels/__init__.py delete mode 100644 src/bub/channels/base.py delete mode 100644 src/bub/channels/cli.py delete mode 100644 src/bub/channels/manager.py delete mode 100644 src/bub/channels/telegram.py create mode 100644 src/bub/cli.py delete mode 100644 src/bub/cli/__init__.py delete mode 100644 src/bub/cli/app.py delete mode 100644 src/bub/cli/render.py delete mode 100644 src/bub/config/__init__.py delete mode 100644 src/bub/config/settings.py delete mode 100644 src/bub/core/__init__.py delete mode 100644 src/bub/core/agent_loop.py delete mode 100644 src/bub/core/command_detector.py delete mode 100644 src/bub/core/commands.py delete mode 100644 src/bub/core/model_runner.py delete mode 100644 src/bub/core/router.py delete mode 100644 src/bub/core/types.py create mode 100644 src/bub/envelope.py create mode 100644 src/bub/framework.py create mode 100644 src/bub/hook_runtime.py create mode 100644 src/bub/hookspecs.py delete mode 100644 src/bub/integrations/republic_client.py delete mode 100644 src/bub/logging_utils.py create mode 100644 src/bub/skills/builtin/__init__.py create mode 100644 src/bub/skills/builtin/cli_core/SKILL.md create mode 100644 src/bub/skills/builtin/cli_core/__init__.py create mode 100644 src/bub/skills/builtin/cli_core/plugin.py create mode 100644 src/bub/skills/builtin/common.py create mode 100644 src/bub/skills/builtin/input_bus/SKILL.md create mode 100644 src/bub/skills/builtin/input_bus/__init__.py create mode 100644 src/bub/skills/builtin/input_bus/plugin.py create mode 100644 src/bub/skills/builtin/memory_tape/SKILL.md create mode 100644 src/bub/skills/builtin/memory_tape/__init__.py create mode 100644 src/bub/skills/builtin/memory_tape/plugin.py create mode 100644 src/bub/skills/builtin/model_echo/SKILL.md create mode 100644 src/bub/skills/builtin/model_echo/__init__.py create mode 100644 src/bub/skills/builtin/model_echo/plugin.py create mode 100644 src/bub/skills/builtin/output_stdout/SKILL.md create mode 100644 src/bub/skills/builtin/output_stdout/__init__.py create mode 100644 src/bub/skills/builtin/output_stdout/plugin.py delete mode 100644 src/bub/skills/skill-creator/SKILL.md delete mode 100644 src/bub/skills/skill-creator/license.txt delete mode 100644 src/bub/skills/skill-creator/scripts/init_skill.py delete mode 100644 src/bub/skills/skill-creator/scripts/quick_validate.py delete mode 100644 src/bub/skills/skill-installer/LICENSE.txt delete mode 100644 src/bub/skills/skill-installer/SKILL.md delete mode 100644 src/bub/skills/view.py delete mode 100644 src/bub/tape/__init__.py delete mode 100644 src/bub/tape/anchors.py delete mode 100644 src/bub/tape/context.py delete mode 100644 src/bub/tape/service.py delete mode 100644 src/bub/tape/store.py delete mode 100644 src/bub/tools/__init__.py delete mode 100644 src/bub/tools/builtin.py delete mode 100644 src/bub/tools/progressive.py delete mode 100644 src/bub/tools/registry.py delete mode 100644 src/bub/tools/schedule.py delete mode 100644 src/bub/tools/view.py create mode 100644 src/bub/types.py delete mode 100644 tests/conftest.py delete mode 100644 tests/test_agent_loop.py create mode 100644 tests/test_bus.py delete mode 100644 tests/test_channels.py delete mode 100644 tests/test_cli_app.py delete mode 100644 tests/test_cli_channel.py delete mode 100644 tests/test_command_detector.py create mode 100644 tests/test_fault_tolerance.py create mode 100644 tests/test_framework_flow.py delete mode 100644 tests/test_model_runner.py delete mode 100644 tests/test_router.py delete mode 100644 tests/test_runtime_event_loop.py delete mode 100644 tests/test_runtime_filters.py create mode 100644 tests/test_skill_loader.py create mode 100644 tests/test_skill_override.py delete mode 100644 tests/test_skills_loader.py delete mode 100644 tests/test_tape_context.py delete mode 100644 tests/test_tape_service.py delete mode 100644 tests/test_tape_store.py delete mode 100644 tests/test_telegram_channel.py delete mode 100644 tests/test_telegram_filter.py delete mode 100644 tests/test_tool_registry.py delete mode 100644 tests/test_tools_builtin.py delete mode 100644 tests/test_tools_schedule.py diff --git a/.gitignore b/.gitignore index d736de38..e7bed02c 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ cython_debug/ # Reference directory - ignore all reference projects reference/ + +# Local legacy backups created during framework migrations +backup/ diff --git a/README.md b/README.md index f88c5a03..be452ba7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Commit activity](https://img.shields.io/github/commit-activity/m/bubbuild/bub)](https://github.com/bubbuild/bub/graphs/commit-activity) [![License](https://img.shields.io/github/license/bubbuild/bub)](LICENSE) -> Bub it. Build it. +Bub is a **batteries-included, hook-first AI framework**. Bub is a collaborative agent for shared delivery workflows, evolving into a framework that helps other agents operate with the same collaboration model. It is not a personal-assistant shell: it is designed for shared environments where work must be inspectable, handoff-friendly, and operationally reliable. @@ -29,17 +29,21 @@ This aligns with [Socialized Evaluation](https://psiace.me/posts/im-and-socializ git clone https://github.com/bubbuild/bub.git cd bub uv sync -cp env.example .env +uv run bub run "hello" +uv run bub hooks +uv run bub skills ``` -Minimal `.env`: +## Skill Layout ```bash BUB_MODEL=openrouter:qwen/qwen3-coder-next -LLM_API_KEY=your_key_here +BUB_API_KEY=your_key_here ``` -Start interactive CLI: +1. `/.agents/skills` +2. `~/.agents/skills` +3. `src/bub_skills/` ```bash uv run bub @@ -68,24 +72,13 @@ Common commands: ,quit ``` -## Channel Runtime (Optional) +## Channel Runtime Telegram: ```bash -BUB_TELEGRAM_ENABLED=true BUB_TELEGRAM_TOKEN=123456:token -BUB_TELEGRAM_ALLOW_FROM='["123456789","your_username"]' -uv run bub message -``` - -Discord: - -```bash -BUB_DISCORD_ENABLED=true -BUB_DISCORD_TOKEN=discord_bot_token -BUB_DISCORD_ALLOW_FROM='["123456789012345678","your_discord_name"]' -BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' +BUB_TELEGRAM_ALLOW_USERS=123456789,your_username uv run bub message ``` @@ -95,9 +88,4 @@ uv run bub message uv run ruff check . uv run mypy uv run pytest -q -just docs-test ``` - -## License - -[Apache 2.0](./LICENSE) diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index b1d389ec..00000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -bub.build diff --git a/docs/architecture.md b/docs/architecture.md index 1fecdabd..b6003637 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,49 +1,45 @@ # Architecture -This page is for developers and advanced users who need to understand why Bub behavior is deterministic and how to extend it safely. +## Framework Kernel -## Core Principles +- `BubFramework` +- `BubHookSpecs` +- `Skill Loader` -1. One session, one append-only tape. -2. Same routing rules for user input and assistant output. -3. Command execution and model reasoning are explicit layers. -4. Phase transitions are represented by `anchor/handoff`, not hidden state jumps. -5. Human and agent operators follow the same collaboration boundaries. +The kernel only coordinates a turn. It does not own channel, model, or tool behavior. +Those concerns are provided by skills through hooks. -## Runtime Topology +The framework is batteries-included: default skills provide a runnable baseline, +while every battery can be overridden by project or global skills. -```text -input -> InputRouter -> AgentLoop -> ModelRunner -> InputRouter(assistant output) -> ... - \-> direct command response -``` +## Hook Pipeline -Key modules: +1. `normalize_inbound` +2. `resolve_session` +3. `load_state` +4. `build_prompt` +5. `run_model` +6. `save_state` +7. `render_outbound` +8. `dispatch_outbound` -- `src/bub/core/router.py`: command detection, execution, and failure context wrapping. -- `src/bub/core/agent_loop.py`: turn orchestration and stop conditions. -- `src/bub/core/model_runner.py`: bounded model loop and user-driven skill-hint activation. -- `src/bub/tape/service.py`: tape read/write, anchor/handoff, reset, and search. -- `src/bub/tools/*`: unified registry and progressive tool view. +## Extension Ownership -## Single Turn Flow +- `cli` commands are registered by `register_cli_commands`. +- `bus` instances are provided by `provide_bus`. +- `message` shape is defined by users and adapted by skills. -1. `InputRouter.route_user` checks whether input starts with `,`. -2. If command succeeds, return output directly. -3. If command fails, generate a `` block for model context. -4. `ModelRunner` gets assistant output. -5. `route_assistant` applies the same command parsing/execution rules. -6. Loop ends on plain final text, explicit quit, or `max_steps`. +## Runtime Safety -## Tape, Anchor, Handoff +- Skill load failures are isolated and tracked in `failed_skills`. +- Hook runtime failures are isolated per plugin and reported via `on_error`. +- If no model skill returns output, the framework falls back to prompt echo to keep the process alive. -- Tape is workspace-level JSONL for replay and audit. -- `handoff` writes an anchor with optional `summary` and `next_steps`. -- `anchors` lists phase boundaries. -- `tape.reset` clears active context (optionally archiving first). +## Skill Resolution -## Tools and Skills +1. workspace `.agent/skills` +2. user `~/.agent/skills` +3. builtin skills -- Built-in tools and skills live in one registry. -- System prompt starts with compact tool descriptions. -- Full tool schema is expanded on demand (`tool.describe` or explicit selection). -- `$name` hints progressively expand tool/skill details from either user input or model output. +If two skills share the same name, higher precedence source wins. +At runtime, project skills execute before global and builtin implementations. diff --git a/docs/assets/images/baby-bub.png b/docs/assets/images/baby-bub.png deleted file mode 100644 index cf16edc20bfb7434ae44c3b4679327ca1928d336..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 826409 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�UB>it@%D~nXwo@iL6m&^j0d$5{=?{ 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%0DU>$_Pm{^- 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%0DqfEaVYAFR;IEfc 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

Mr!o))u%04_v!?+R#NbrvH3g!TjW@(t1SJG; zy&_IV&AwCPQ8SSu44^^G*;~++|3AX||JiaL1PuadI*D}cv*`PvU_Z;SU*Ytj8s&!k zgeCg&u^bzeg$lXdt`?N#A6zjEi}LPOYrRt9ejx(o5~#N*ss9v-CX}P)mnhG*ej99* zgq&+iQ=Z!$#c?ceFJlA0G88#q?G9jUwour?U_gQNnFQZaYFZslTh*FccD+U4tIU*K zv~%>Ej{a@>ivfj~?K9GyhDE6?i%fi<;8uubqg}T|3&qqh? ze1BT^3--+ye7ViE)woMstF9zk^VIhIL!~7`}M26k2m9M_m$WTB2kMfhxuYlX4PseaNpC`r;$))h0S7WBy$Fd1D|*O= z&@Lp@i3(HvOGU4yW7KY>w&hA^RpaJ3TqqMHP?care8k;m+jM4oEPLMAxYFiUyRxQX z$No|Ot8PRxVNPS05?Sh~GnUbk)%En3qXy+;C|m4!N{Q?hPrhp%=Cvl($z@PlnF>zj zMyElo&&Vx|4*>gRJTdzvqoa<|4xVKKVTNg_3aVOM5c@0M;sEI0wxVPO6yVs zpLNYGAqgu`)*VyA7pRGk59|u4gm2OcTRlM=8tj*Im1K^5U#(u5F@_3VjLU+8`pMkL zg*Q=d-s+hBl~#>e`KME^)^I?CaE2~($`Y6O;;pj#&mm$2%rzq?$JP*}3s&)kxflgZ zQS-Xujb-5g=z#0!nEU}Dk=~Fbu`1y<+#Lkmu9#8ll;bjn&{Go^71-8sRNWx9r2>X* znIL=u9V1(T^3IA5Wx}>Sv`{7?L1WMRMcvKTbGRu2rm|s19K5vwN4$Hf{~}=5z<_sb zAIImQ?$KFA&KFAnQggmmS?kd5i^UXl8~8t(B5V4RNvVLQhi{_6KfuKl3pCWrs7X|? z9jrh@*d94?SgWg^md*pZ<{fnIQzKC?(xe!I;(tTYAek%oo|na1ER|@v2PX^%9k#mqQ56en)DlDe;1BQ zCsOR{7v7IrdfL>e{qH7@{6KBRLa9)iBm)c6&|QdoXAUU8U{4Ubj8Z2vP2J5*nL-mB zux89DCWNPg{KslnEvY8HPK;lkFZdbjlVo7Ws-$`Za)HC&tRNDNlVXe_*z^5KC%Vtu2bN5Hl0dlAm?t3xnRwqO_h!8 zbIs>5^e^|~HoVegjQ5;F!3kjA2UND=Hfwz52qOYx2>0Y1ms@?IweZ1H`EYd;Oo<}B z6N$_Ap>0e`(Kz@e7z1t=2*U(kajya=Z>~vm$nCH~3d6~F89e0G2UamPJCu0Ui+JIv z{%<4E|5@*d5y6G!ZfqbII1W;wICzAyAdNg6IN^tkgH#jjJYc~573v3r;t`lGB>C_w zNGXGI$OO^0D;RcFH=~PN)xq`LAIvNi!ku)7K21_lO_tp)b)`q4Z5uOzg}FarHE|8X zQvDixJ0Y0>bM(@1t0c5G$|seg#ZLCGada@m4WCQZ%T@S2Y_F(TocfWvf1~-0mJN+- z*}Byy^-$E9zcLwr6HzivJU)SXY8M=p3-RfZAEo6R9@>maL)ZJVL2DqmLhwi{M*udl zHb)VP<6g(ODJR!o9yi()GkRh&Xjk&0h2^p1d-pA*O78u#qvuVWYE$c6F5zTV7t7Zr zXSI{Z+bJ-1vB62$jU%w*$|ECBqn$8)eARAfbvRq{d6V7jQz!Z;X;_6YK(Je&Yc;#! zI$2nJ^pN7)bd6rJ??RboOWy%Ei>>=t%^pVpl7b71wbP!5j3=|lFU?6itW@paKOpE_ zdpt_G(A+u_T^8I=syXWRVi!jJL^^2rU>4G+*adRRkqPEhk@ZHz@U#GoIeU6W4s1lU zE@;jKW#$@tps`K~Rnvcacir=VChi^*AO<+oiKkVwo=^?THLZxzICK%I@%fQX0 z6ekLRmCgx&2|D$NC4s7gfE;||m-0I)20*_@r6a|~tkH@9KkdrtIVzT?)S9~}*9P-A zSBj+KaNp#1*#Mr$(r2YbWQ%n5W?qmO z>Wfm(m5?N%m+P@ks#>iRiWW_wD@ty$PCMb)9;M07D-p5b6Zv+QVtVJ`3d?h&Dw&~B z{wkwI#Ad&ma?(&}%M9C6e%6~-i!$%XwhcPzuvPLXztEmoIj7s}ib+a4;)Z!cf zB&AY|I*R&5wez1P7)jVSWYpGG)CSZj9{2^QqYdTeRC+YWS1-&Xk;*OLW!1~Tcc!$6XHv+i9U9s2z3R{tSMYs+@D&MrhBZ-yM` ztKbAgsgJp-P;LV*Sp90-X3i9C(qkbhdRewF@>JuKdyp;!hfJ83<6U+`Y`qN zLt$b2xagHoYG6l$4-pPkpwp}2#v&e3QEHNye&O)929!nLH5t&r8o;>p*}Qy>`A&#= zmZVI0;&T7tagDdUyzhI8y^t>tCMzpXdU6{&J!;|U<)uE1Xl6e9$XZPu8vK+QteGtK(uR8Wo`_RY- zyG}JYz7AXrI2L=X>;G>x z1UN8kiJ?wJ-YW?lD+Qo$y#}-{DH>;Z6v_ii}afNZ(dM704O{ zC5i7V-{D=bPsxU^>x8T}dPmJV^?rp)b<=R97-~$YRxb11&U`eg-5H*>#rvHPv^^&6 z*Tzt6=#Cdl4V3n*X$qS%>07Yz;JFxH`z|fMDhqj)m@Q@2cMcWn{R~i+L}%sT84;%wD#ts!~4Q7TImdqRsoog-`hO&`Qb+Vk=+9 zdvBoA3iQDL{orXh0V(7l7^T4Kk%@nFz2Y^4_K|= z(DK?86QlB%^qqq&#|byvFj(naq@2LVDA$S2sf13M;wQVbw=3Src`j7?WN+1?K-uQU zWdr>K1yEY*IOgTn*iCimC_kL2!s%gA{pkh-t-DW;_z)CxGm~BIM*ma0^~(PwOb>#~ z`Tl_*CNVPe=6an;G9QDF`bLXbwgoaBh=V2x96jk-S&cXu7k@RDno>m!BDboDh{NDz zytrBfVXx-v0rs`9Gwa(sZvV^@D5^0Vjh4y8XhpVuAi9#i(QhL*L=CHp-C`9@QG**f zFcrrqm9^Hwl$}F22mN){)#To_y0bB(mQ2qfKEK!X?X9`5yR;G(UP_wmm+672*8i*k zm-M~2{&$(%h<@`(Wy}g6!*=%~c>A&abufI#LZ^N&Adz7{%(0wkjtF%{TOJxy9xmiO z_wzZo@+`>ckY8V>Oj*oSjJ^tE29sL8|33?w2`GPwNv}I1x1Nj|QFKk>euy!HysxLe zMij4dd`ZG-)dh<0Q8YCgEq2+JQmjgq9SX!usIF3wZx|Jjp+^|D5-dS7y~Lfpv31sc zO(6!H(fN#8HFGDA4e>k|SsT;PbX{itEb}O9zM^#bX})h0;S;8VkJo91g$L>B)bs|) zt1=1L^rq?g&!)+(Fi?@n7tS@fTxb66^h$pBCRJ*mY(5tV?B?ee!oDp(MLyd=(17Q+ zBLU*)+pVYWC++PQEH3fK)s?E~bfpHZo@rBFY2aKcNeuFVaLZoT?Q`T!VqUz6lYX3U zSWCpf3kYTelT34@!cBDW?7L%&bB8-LPXg$Q{JsU#eF<|`mMwf@)Yqg z^HVzN^U5;61__F9rI1@MzpE(!X!UGhFrv=e0$lbux=*mIgIw_wC(2B~S@JDC$j{r6LK5RURw zdlR7z*LHbZ=K2ie%lStYK?JyM9W%MA4ZYo6^jE+CLsFUwBuq2fZX zSa)WIZ6e#pky$@&9P_lzXI-Mjt7{H)xhie3>Sojhc?~NU5y-m4&D$3%0TScYIq!N$ z9bHSK8n&;~wio?6jxOEGTXS^sO(07j^U^s+I}|>dWOlXNdg1fsnvSn(J{Kqio|&5q zL<(BYGe1qS^V6+uEtxBSXs=xRn|ymHSExYK1m88L=dD`Y+P!%jS_3W&`D9%P;Z6A~ zI^}uQty`O@vN|PX&2sH9PlH+&KV@wEW!lBvx`S02&)!`+_T*(rr#{W4_Cj{HG`g65ywMdsXA>ivc7AjCSL*iV-Ef|`w~;jJ;WSj11C>q0 zjP5j00zj<8}6cGBl~Y#_q6S@8KA)O5YrJ)HGO7Bws0Hk?s1P4*Y*W#1?Ku_ z^V_o3H0Eu#QX<`B63(@LdFI&y{-SWGewSOl7H;iphKk>0PWsTUMmgt_-GP?#g;R~n z6gHgxedTuNS@xCp2+!7o{OA3QTHfNZvW&vi%CnBTpJT3W%TtYQ;t|1sQ1|z&-G+W; zcJU0@!nZxhLAQ1v{T*C=}42z7RT$o}O}`#Euv-kba6=;cS) z1oyB8ZRAeltLK34ctC3@PKn|s;pqsbE=KG-A7lfuN?Ja}Fp_nX!NK>1=+K(5WfPV2 z$Q`{U&h0Ud_GcyA!E?4X;84`$safgzabxB?lyb!{#$enO%j2#;Kdb8rJi&@JRA?ik z+#W*=eZrHlO8@K>((C+*^NL`KLF5AyZf%AIe{DKOduf^Egaz2< z%0n4{VrDF|Wk_`)N@1FPf@p#dZl6%tIw)od3v${khd5^QRs9U$jFCtA^m3Mk=}4#d zcdRjUHPKv)L|EcA^x0a_SYMsO5lmu?3UKAQ47ri;XoL`c8lx&BH^y4~je$&y+n<&_ zWiaQE5sBe#ad=H-bykW3%Gqeq6cw8h1K0bUS2`56a`@S52-)29`nf996IWz@j)8{l zgSseks3oaR(D)Q6g{y*dAl3#4sWfy@I^KWwSpGpK3N#%{>9LOgyx9J@0Kvt0=}G!G z1wgF_?E{3D0nwT_STh|1l_hnrE?yk`Xi}Z>vbOQDakF!Cagb z?GR5LNeV9BCFZh;-N^<2&H-$AQpQSxNZ` zH&|R*dy#!VtQJqc6@v0xeqNTK6RwmDlXN3vFNIP4xo1Uo7)}PT*($ahax^^AcZg0} zcZdh_ntW!|17|p5G8iq?1ZT|bmdV$~AV;?48ysvXP zCB%%w4oP@*4>+$>%Ahia#)Oxg44+^2M|)o*viFe&2~%fr;nQVPDzzH58O52h0*{#z ze#Sh5*Di{gai#sYgwV&I|2Y5<)&y&w#k)6BceJ^tL~V^47VC@})v@_3qT>7MP-!Qk z=9ox2c&iEOiG}jvl3>utpGo(gPZ}lJzcm+zes&fZV1SS}>7yK`DvFW@gU;zo*x-^;#A|A-G~KPf)s* z`p~)Bx@`q2o3K3V$-md}KhnJ$kfQL zJJPH=HfHHadt~!!E1cM`10*T49hOe_{OPp!e_dzcF~_^xtWR`xzFUroQ#U)(20jpG zBClM1Lw-&si{~|};ZJ8+azEdA%(l;cm?zfX$f8-as>Hur8E|E}Tc=t|ag)*a`-IuL zdvtHT_;|J7YPLOM@B^)nKBp6*4cQn@O-w&qStK@g@&@djXV+XSwJ0!(WENlMB(^cp zmv7*T<+>AUv);R|?6?6eUyC?WXBC9mMn`^%2iR#-M&mociN77b*64^1;;`~ zNMCd0mx#mpoyvn?Bz7SGX6Rkd@4%*XkPaXb>% zZ>6Hg5rGdT)xjXSu9~P&=Or`x2b=s%+ipu;++|cc*I6N$`c*DHI+Tg8SVUwlR>Sjz8AF;|lco@?_t;_IOI0OI+ORRH8V>Hsly zZFHUw|8?G^_o0qJbv*vXCJAh#RtQYNvg=>co1yH#qQJAP%V&W&R_U{W2KQ=PMuhRv zwPCf1UqJRf7U}$h&^$pmHjOUNJ6BHq`tL{n>nQ?JXzTEibRQDqeMGr=Gf#?6{|R7w zph6!zzgP=cy);yboI^imf5)+G4$r?PZGWS3* z+HjzaF&u=-3UFjs!>PY0E!ts3S*nyp1 zS$EMWuB;e6hLyl=#gHo3huM9@&IWk<4Ovxs2;~yNI+L9P;TmSwGV(UADBpGGwGW2a zhGXlWO(|ev+5Ed<%=quuQ8hXcw#N7Y^iTB%5!C|QRQn8G0H1uMZXmR*W-dU_iy9xC zAbB*2;(N(|v9G;-1_?5nc4#u@Un%wMxtz=`7MDVt;b9|qGW9rUy_Ng@-3gDDY~oDP zizeg=v|2TTzFVNR_T78e}&@WD+u-_&5vAAUl(DNOq{#>v-kPx46?^-9! zb=fukp-SDT(NBF+Ur8yx`h4jB2B4u|Rjsbd$muq7sams{;eT|DZ~KHGUVFOCn0l>F z&*?JrDG(piH2*k%zj{+CXH=vg56i5mswH+Dw9S1uPaYno$+qjtednIbxoXM?UGBLg zW@!8C6gRVYB**uV5>Di%(6RVE_FKcJVW@nO|- zo{kHPqMGbw?`rGso0h7Q#>ZeOuW=)H;*6@{3EL-gjk+~$)CW;*v()Tw3${dtl9h$; zl$%8$HO*cqv@Ff>1(U^kRRiMG$7l0Q&)gaTU7{BRI0pFtGvhB*LCrwXFdx4EBP+U^ zG9BYPS7c{HW<4HVw8JDVMXG`SefeXdAl42kW&vxj2jgl{ZDo$;N%IJTkdor|fboJD zn@lLyvBjH{zYj4+qU?78$`>yU`hb5f^d+jKiOKfUa4>BXW_(&!oW#d~_U`5_FDF4TB@Mw@?B+r7#2PyL|$P1$P)`m}jK`kqI z`v}!Ap@my0txQ4#7>CMKF~x632Le}z8<+>!T;rkGkNX)%{*6x3gxCi}cfJCrz)usP zF)B<{rDmT7%P0hUBKJ@|;|Zg~{b-)0^>n|Q@esbOU^xXPe7-XB|fva_RsoQyFAo*$4H!F|cmP@|&(jfnfVcr>&h9 zPa!S~sNr(YV%WOmwYa0_hN>RsrIzkswur`b3Dp`|v|w>O2d|*Lh6sI1n9!2!Bx%0q zIkTq?6yThZrGx%0i7fp#>;~e1Ca}(J6@a{;WJYE%yU(ZUJ!{0^zOke8|JY}rXa4UD zw>|`|{N-$H(4@e7ey3#%lW)P6K$|zB>m6kO74T+S(Bg8N&*IMaljZUX6*$E<#Yzjq z()3>(60%_dv$Vo05}hHAWJ~Cc6&>x3q7!;?5DbW8R=~qyjLd}QG?u`9S%PMKvMy;A(+BewQO+yd26O_>=_ z`SizJcDzq7#3g_U)>E4)Rp6TQ-`o7-{v7E4`xH}(ETzZyAbQEBYK_70Q`?U79e?|6&j0=G2wUd{csDIZ zEh7`7>$O_zn0L!MBXD(elyx^yPK^(8VN+5r$U(7-)|nTEENh>~7vg58S1kY7TAJ}h z?pVHzRY?T~`hLIFTmotqj>KeY0&Q&n0B1elQqC&GI;wuMmWh|wqNym#9Ztt&>fGZJ zQQ1*>22pOlFa@gxt%~kyu*5V@#)ze~o3h*ZIa;OSUY=;qc&Sw})oN+8#3Zi5h!E1T zL&FTQ$jD#I8miKEqRVmatj_qcd;hJhwf)7_k8_wGYhr42-!Dlsi=vY%5FOiJQXdEe z0igh>i`6FM*j&`yAV9VPZ=##)8>(5ts!sFt7&1_q$G@6jkbGuAPDs-9n<8*#kN~??cxW%1MVqh@rO)VFlZMm&j`&!otIT#_RAg&^!m_tFV_924EpLh5^wM0W+NRcx!hWgEt@ zU?1l1=TiTd(oQl1ZiQ43yZ*CJ1%$?^4oGVb91xVS$oFjSbx5#_5IILE)(3;3X@ z!wBC5Ti7$)ARjoNlw{Cygq=3~bwuee$goo;pd1^K^CXjo6H85PX}zZSYX%9~F!P_U z#P4fDi~krUh8|vN+JSi_S5zEr>xR#D4_xQ`yQq~UC1fnUP^3LH4)946pG5DupLhNw zy&jOMor$HBzbFWY~fE01`| z^lj|4T3*B$ylzYZb^!A;Z8rDLfoi??HGu5p%Ew!e-{bn$yCQ15f4`YW%)X2$!GSuuI79RMNra1!`&%ax*Kxc+$YdE5M?^EX;r0kF5X z7>wMuxZQc|xwlu?;P`o!x!rB;bqeY^;5(|iGesu$>7XZaFmgd!I+50Y6K_+fP8mKl z!!KMlAIvbd!yx-qPu1+;7kJG>CyK1jz{##7yZ~iGWDIf4Bkiim<0?A!u{j z1oQGjWQslSw^{%}kp!Omi6C~B_7u-J+>EL4PD~m6@9@smLAY_&g&>oWI71DYmQLyC zHLS=<&Dybf%TNu;s5U(upWYs<9Mj0q`s)~LTMgj|mOVUOea}K15qQ*Y#5xNYixZBa zpdwKT-Kw9DBwqWBTCOog1aP4pteab5snW18k9)obLCNq$$p_+z@KNrE<)Vrf0n!~t z7&uxrFT3U2pzLD)XQPNxD$|!mT|zUeJDdqL@JFCQX={T#2pi(s>9W zKT^sCwJ(2uZ)(3`Fe|B*Kxnzh*8;}AVw33IzaBx;%EZU{(g{LVG~ z*R^FWaueY{pr((nZrag9GZLYRAoQ;;%zy7tFUK>5jH#9S$p=m;QFq~I4+}$BZ5xLS$-+kYABFQ?Vs>zj4#d?w zmcbfQ^L%DXVX=rxdUmY)A97-QyUu_hU9>l2lzUZFP*0TT<`1QnI(BX}w-jD@2<+e# z!R=Y<8p?8`Wl1nDrp`31!1fIhnZT0~_>(fbD=|O`MvH7l9=DwWBGu(fxMLHYt+I%|z*gjw4 znqx~1!SMeLY*X@fDY%_}>b$t6X~A#O!Fx#k9KzQ1+hL$Tyhd&5xWH8RsCTbMUar+| zK}|Yv@^iBj9~y?8diMFI-f_On!|?WuiH>gF3CX*>;`n52FTk#M`KZM?9_{C9S;Nub zWwiHZbJ?J;-{pGtC=T@N7(nI-(KTG3(rM8pthCL^u8xn3dA-Iqpn)F!`Sl^H9&kw! z<+a%{?%4fh8`bVit+qT>-0eIKk5F^5`RAceD!WBH=!g3QgUW(Pf(zwm2Z(RO9HK^H z+jSVu!ybCcR3~P=84uGS`3d#azDEIFYg@Xz- zc3J_W5d{?ugS~>>H}U{4vf=(w)kdL;N?z6PIyK+45DjTB%WyW;hL|-JE%l z=OeY6#=-&NLTo)(*q$t* zg*u<@na5ZPX~T&^Z^^kNWo?QkV`6$5aWWiVISUQLykg=!NDi$G z&QUlzdieEj|1P6EjSTwCbPjFSVyZXGsT4Y@0J|wdE{TaOjGcgquF7d#)3+@ExKt25 zDH~_-ew|NyQw71+W8-HP2U`b1DLhK?v$JDCOG1d#zkVCZs}auO<*WHhdrsbqO^GYb zu=y@2*rWXVZ4?oSo;kt%n|ZZiYVuC>9G&-Js5NBSE=E)1+!ru9W~&{n3&gu*d?QxY zgoRC3M^Bub8azPu<4;D^w_B4s(R@A!-(dn=bxki~rU3^;gJuE~9+fnsRODB0NLG=slkV|(|KEMuz~W`=YTP;Bbyma3>|15Kq{apfCH9wSKe5pQI|=pBRvd~eRBM5o zLG(nSJ_|{(fsP%iYg8>zgbR^D0Xq;ObLf%Oc{6lS4=Ht*91l60)E}{GpRG+jjg1)= zN*y;3n^Z(24mnAY4TH3*Ko>T(Xe4R!&B}YR%WCk4E19YuE8c}xcT#?54^Eqnxr($uVo0Ej^dpq01vt?w< zP`_kLO2_M5xbF2f52ae|dVN<Ev<0*%97>8-kqNxj&LvIYoAy}yky3vc{`ctV9MKF&5NtC!CS4yrXuUMJQ6vkf7lq$VUy+a5Q z018HG)vMq#RrMQa6&S#X;apiLuvCWhFtrDI;Jv_=DHtiP%Jz@n;N$Dz-yTL-hotX= z_~ny*XA8beDDszSU-P)Yb35;C3(uGxe7|DqCpON{?>ob>H8xOXZp0jle-P6UkxX2%aB^}w1ko?|%mF;1i)ai`>aR()tdB2^9L z6`M8y1znFkz7@Oho6E>o5Esk|E8N=g$Dfh;u^OaMtp#`7 ze>RA~t;R%c77ij@;~)(V`gEmtz(T z#TzAwT6QDN)wwTE3>!~@PbZIfuf%na3^{j$<{v|Q%{Fbf8_xcZwZzZyL4#|Nj;jUF zdN45u{T2+{yw|C(#C|KbT2n;ai$^65*g)$hx@Dbt`lGKre@7=6xC_@U<73zSm-ypH zNo;iH!Vq!Hf(z6-2nnk0C!I=T0ZrCI<%{>S&qV7DE~|2Okg&gPfR&nRBnf-Q7QT>J za}x>o`E205>XX6=oF$lPYC+P7B7JF3X}(qA?W=>YC?Xw$yF!ne0`2It;2h)_{tu+M zlM&BI6wz3SRmU^~+~n011B%jAhOzJi2|}>ch#1mY1=lF5gK2~a+GKojK2#z}o;5~j zmb07Sl;xpXadk)|MK#zzmM(EenKv<+@u;bSEqCL($yJ4WQC!+(!O>%fN%I5;R7W^< z{fbf3JX!%9&%hdA|hBe)tyY{q}EY5oe? zW75ozN}DMk8-5tPMvsdv)xS*wXn1e`9p>;cjU&sOMZl{PgIvEJTc3 z_NXR%KfQO(g5D&=hB>H`&Wm=-X|KSZ;#HNjQWPFuzo|Jj8ZnU|8}2eztPjB%#xgpM zlY`T)Tu3qrkTNjhbV~x0Zk#o%3LK(fX2Qz;p->Q&#EAy$4@z|^`xzRnmJ|#vrh&6N zjC&1yLVsY88eR+=8`8QLdoq!aL$ zSjd23^msScIJUr@Hb6r+i@}bnu<%Jq-DLlZVdn*rW<4%7(7X>Fllwvk`~5e~stP-x z_MW>ts0pYx!7l|gduaEOyU+Q&FXfL;w`_?jT!iB`FLg7786H{QUnA?F%w(Adf+kgK z+@537dT$M-eD8xGPR`e4cjNrY8wK{#29KPtUNGxXE6%++cv^Q&p6oRJ1cDJRc2$%=K-VX1xC zFJ1z>q9%1@Bx(l=^L}u5(LeTCUv=4qFH3iB1a}gRKhT~y*e+J#LM48)q_@Z*k(ec= z)FRvtiR+t#yfAu6U}Kulkfwp83uW@+7aM8$KT_`;&Q%Y@ZkXIdcR*+y1k(m6t9otXFkq53+lvXGm zV;~O|(9TgO5*gSh9c)(nhQlRx)g+leLkn*rdkQwTU-)g__LUp91CJ_6Xhd zAO`2+IhKOq{e7ve`%?Hm7U#aB%mcJA)x`WNm6x1izgYLxe0450DRY(LUqUqO7U@;J zyvs)G;p6mb)oi!082;0(d3j1Cz7az8#T>B~v*nBX$^at=W$DTc>NIyo;`I;@O1I?U z2+O4;0|kS+)(z-d_-u8<#emo<#oPFx1>!PYlf?$cSn!G9)1o+ke$~l6hf}do4w(F5 zilegd_04+_P1`4pS~?=;@|4%r1!0i)_Z3=TYj!wN;@W?+etZes_7+wu~E=t${?`G@eweRMs8wWKKn>%qWT!$+>Ra$&&R?`9pMN~OA7sOuc-8vPz zFPW8(9g90%av#aWpR_@XInes=Ggo8k6YeJZaR=U?p!?bM-08|%6Ml*NH2az^M%|m7 z3!`D~wJ>);>*oETU!_jOg(Hg;LTyU%J7(0TUZ%aYgZAm`Z?8Ozk>ZPGH#XG6QABRi zOqs06VH@x%)_iZl9kk2gB6KnardbMSyvYnv;a@1RLS~^>Z?H&^E^Tgg6l@ET7>baU zp+y>6VZ)G`!uNxDajf4}#K@9HKk97q*xO0;b&Aufca6wG>gx8@dF;AN{L6#7T z0VkwqSVIZsOYCjFyEra~zw~g40hfrN`fa!8Oi5LfjqbGi?;^oSak)>ON4N{&Ip-Uv zcCG^LtFmCmlH6S&5P|0Cw4n!@aYe^E>6n9No@w^!w5j;5T|G+gYC1+t4u&q!OlKRS zF&YIPI2hY~pJUTMQzZAxCkEbsIurZHo7&0PT$irtORzamt0_UE4K} zXjy#ZV^?XO=9!v|1eSH0{mNvXQl@^gCMNB3J>SJ?bzr$x20e$*)nPBjPG6h%{l+#tu zq{=GiK2nHNhN@{Ujj+L}q$OJ+R?9=>P=fJ$bvJZ~RrF1~1@OKlr1&cOcsfnJyGc9W zOL6%`@dIsyUu|m14B0uEH5dld;ngCt+v^l+N#-UA`Q$M1e0=FA#o59VC-cArMiDF3 z8M1y!Pbwgyy84RviAj_&4@dmyqZs0^CK3L?1E~7d8vr|~NBMgj3=`pxPz~wY2bMO+ zd0V-}7HUS*9QHxvZu1yT50GQpK77ex_7I}mFKi+BTdm@L8jlF#KypRvjq47S17|;P zhAwtMKnyv`Jn>5d{g8J7tAk!q162cI&rm(gDne;%Ii zE$;wnMkAG}{afn*?@|Hti8+J{HS$EY^EliJ7#5o)*Xmk-SFeoHb#3y>5dvBU>Dm==MxdcPrJfETEO(3}q@d(SKBg$mqQll3Sqv=BrM^4t7=8 z4x9*c+$Bb`n9O*c=GMfEi>e{qx{6Uvc%aA;=-oHN`g2_>>h9X#9egCM|H$3)4lXQk zdrTK#_@eCnUoI_%B#a<7udgG+3Mr3tRC4HvBED~Pu9IV@9xvbgHz6FM% zfL#Q%P{RVbgl^~Pq`PiW+qbk?u)mZ$?O3Qy^9C|udl0|R^6*0TNC}%g@KThf;~-c4 zaktwQY*5NilXXb`VtRyz2j zYK@6uXg;w-S6E0mDBZyVnBg2gr*Pc<&;)up7iwl@*vZQsg7o8x35IokCg?!NllV%t zYA#T@Xy(Nwo3=ae-RjJVTa@*(O{$qTb-enILK8Ey!+@AxEf6W-?MNBb7Xb96x1p@_Jl+aWWMGOpVRrHq`x~5lc*AJF6fBT%v^V6Eih6*~B zw>^HZNc^WkuI*=1yGL~Wp;&|zuj>Vc*^mT&k8zG|9~L#XRr{XzS#Waczo2gdU`dWP zVY9(#QRMp)q%}-Gewb@} ziz~c$QREPvI$rxi8yfb|-yM{h%iCI;vhRY@8yvq8uRw*Z+g6&X7K;AI`QgJT4eXl! zjt3jCGs?PG=8%jH5k&&Ee}-E!QcW%&3Rm3bA(h5WG<8LYENV9aVb z_bX6yhk+Wr+O>H3)$Ul1K{sRgGfjc{e=G*$*Fwz5<>0WsQb*48HG$~ZwKhaGbNa%U}iJpGmFTe8Lu75r(d5mlB>-%u;J}Yx4_Mco@ zxBJC_h^0MoK^Us*z8HX62`LQiyqd23|_`PI3r@r+s|o5dznqfTFtklO-1 z?{F(dtqo`|pB$Lxxw1)L2lY%$!nIh5njm8~h5n7ELEvKOyzcLM+VbuTf)X#pgD#?Q z{~vt4gL|9-8}1p~wrx#}#Q_N8XFU%u^ZdA8mn;{XQyYcv*(=M@7w=iu6gI3 zNB8}^ZTyV?@zDbfW;*PU3(_~&+~j>ttHZs_jUd#DTU$=srG6#Iq2F5TKt##6Ldde9 zOcR5Xj%s=J*CtJFiw$FH`E@T0crE%iN*$tKkqwBI4N|5e%7pjEy_7MR=wDqzW&NYTsm1z98gRzBvtWoWcn^@ zG!r6w;#F`NO_EceHk*;f@TDy7s7{1eu+2|)#%lU=-upOTcM|v8jzjBKe&fuj%7gFC z)UUs(ml{tSzWz=TYunp%%a1MIcUBH9#simm4^mIQFE9v_*M_E$Pv{bga;m3f2S^Sa}unteW4go*j2_G&H=s zRDDhmfMd8 zy6{YShX)kUN|I*hZdzc5LjB=uvoOfc2sIvV=@DQ)`fNfu0#L2PvNF-B^5;lLhsd-3 zV{(%W;L9k0FElqhH5J6#hN5+Zh*jgb;XBTu3=J##Iv!75zw7<`f?bS@=+#~c2A)un zjNRJ%POx$g`d+t*F%%O4w-yOTjY-4fxy1UM!&8-()jH`Y`BR2-IIvNinjYEy134Qc zu2rSRgRr5dJnI6HyI>-4WY(2}c)lMf3p0s6z>?@Oi-5b0+Hu#lFQHeB(lxGhwqlgwuxM zRXyVmg!FkXnXe$5)`l;x7f}b4Y!HcqP$#T(spqCkgwg)fc?@u*EXOrEPfR$}T_2;2 zJ;6ca#3dn>J&Ma4pTy(&{y4=B6st4?ASKE` zd8_)%Kzg3J@q-*;u~@cOzymN(hvU?Ec(qyw$S0y22rdspGjR0D#mbN!PBT{ehu!L1 zDTOY1`QCk!=9ZR+gQq4nk3MLdWlE9>nR z8eX#eijD5$6KLjkWR(Cx;gjUJkm6UE*2XkWstjh}#AF=_)!+J{anUq0*}3tWev5(| zRt>~5?v1mN`ER++2*Hwgk-g7l%#Ev<*p1?QEsxNrL`ZNE*hu|JHZve`9VKs)0gxZ4 zo{*7SH}Jg0cKpNH!dSs^O*RGRDD%nT>v_t~5H!GMP&>pR&mi@q3L==H(vPsWxN=ws zhs<0@x#7XBJt{SmY2cG*tV4o?^2O<9GdW5{2X8dE0nVwFYo*v&B&GzGzO+K z5w72KLRqydyDdujo|Tz56r~R);64TA_kx6_ofYT+hR2{5xBwLABa)Jq05+y(_(+I< z*!7UJfr;}i_2{_9oJd)DT3#e$O%!6+X~EW|DqYUAJ)KC4oSEG3Q?g35Q&Swj4F=BP zzQKW~iZ^tG;5#CNahtYtty(7yhcv|Unjhgm|(Dj6`@(R z%sBcS|BB7Cq_ICqQ}h;g?Ci*J4(-Q?rWju1(Hxzq@3xh36K(C|1fSj58kHdQsPj9_ zssGCZ2(*ue2kn1G5wzCYPiNGGXJ-}#QRuS>sMQ)a=&0Srs}f&HByf;r8`f;nGfeuv z!ziG&5nJnD5fJBNn+{CBM--3|zV3Y|mzT9|GR>B$QmNUu%L268ZFeD$N`X`lDQRgF zmVwb>bXZ}o<2ae-WKCZ1-y5g6_e04}psyK{o0Z!Ezh;{Wj&&xNYVfqUCy%A7eIHy{ zVYJ>YoB7O&)uyWSa`~EkLz*}K=#dKu84;$V=T6va$$IOrZQQd%5I(%hwe5clY!`^d zoAlBy%ZQBr?WQ4&N+vrE93=kH;NqDRBBEZne-eV@;e&4k$4l%^lpLozP7Agf9*2y% zS;VL+X441LQ!=VHm|Jo5U}YTFNi68a!UPefWxy*$hCynTt?4C5&UNlGzQo)f*thm{ z?9#+O_c&!ULLlDP^PS?d7w;Ug$}z70+<6SM8UL~z$0vy2!rfvon4jSP%-QV%2U+ekKwjI248;2&pF_B9jgA~|I?PsXw#i);q~t=sa` z!fTj~v*ki+*o~U=sS2SX9DGSlo1aNsJYMX87v?OV_3ZmW0o*_E1rmHmN$|*0jaO_) z0LQPY53P zbAjT0g#tPZYQ^gYkaKLYdJ%iok#BhFBbF1Zh}q}_&f{(R zI1TqPXFBle?cROOkrWW852*h0(<)5|Z$DVO<=@l-MI1hwLl8$ki#(gp?!0=kfki18g_-c*2<-QYuWv&6}|;Om$Z z*pUEfL;;h7sk{84d1@(<(BaOz(9A!d3Y;M=}GC!yfW>OKi-Oa6Lt5&)C*6Y-&?tM*VIlyiwH`b}^b` zV<2`BQP*B5ELOV-mJ2p?o>@k_#lcA={qr#&PR?ybAzBx`ba$g`pl1w_L|cVYW!GdM z%vH{%%$BK>AP$_@nfrF%WuU8*T^RokZ4F?{!TeBOK5?w^ycf#&vv6E0u&lfzrFsV( zzhaT~Pi6WKc|+1CY>jZyMM2;(7#M>fiZK}vZzE}FB~4XB8gyeB)+85yx+I{Gi4xT{ zHb;*oz`Nj-AV2Oy$cI4nJv}8;@V|WP`4+o2tfPA8XPWL-*#j??ovm}H!lma;GGhxS z6{yRTSpy?hYs?Hykz?PY$t(4CK`uGUQm>eHu6&dZunG93)mhS!=qR%ZbSwKQ-fmY8 zG4qo=yTH<#SUnBh+f&w^E5|XML2x6wZ{~z!mTLC#7?kOiDzr(uzHr`8kNk)x&+O zx?dTZk@3`#g{XZCZwjJjlW20-%InTYLXOf@v5I4W5cA75h-cyD`fHKOKhM{BDr5gd zPJ1lB_Kqkka=}L=r7L(yEE9j0~ohf zxvF6lna2982{v5wq7J7whC?K-{UXo2oR|r@IotArDZK)jO0q;Hq^T*;_+sk@VtKW5 zUwS1Ywad(0<%wF-k3LR=NtBt(s&0Pah+~c-{s>7hD#h6P(85MSD$}nxxi@2Cp{yZX zJj4QOJd076kv9WfVpzOlIkry(W`<$E8KCXsJpI||^zGSI8?caCBe&$yY?8lMU7P4Z zvLBGT;PG1LPQ)c@2FWR^G(4t((nJPTyr;N8cYT^B-l-f2UR>$rioQ)>L1eBKpJiIoJZGVNzxh~d49x+SU>W$>@ADBm zi5LRg^o zi4ewTy4VTNTc5fWvuS zV_UiW)LXhNmhmW!DoN|zqj`5TY-TGaHEBFpZ~J$(+=!V&uS5@^t)9UWr<+~G z8Uiz_Z_cbOx{QE+dzYhH{}i5JlBA}Yp>FJ_hU=kU3l#4}2SPc+L~X8ROUi|u&9|-@ zz?deW0-S! zSu&w3N-Ghn&m-bHa#Ns%m`prnQRA(rDEZ5KMyWh`WcR&l|5{&ZPe zT9UN)z?Z}SF3KknF-sr@Tyh|AB}wtd^nDz+#S2{*>oG2+2}OcV4+XOtR-LgTQ{@{A zxi|?p9G78X%?XYDfrwF2}A(2EXB{nzTv<&h3>Nw5ya@iSkyGsioxFF-O;kTXQ4bE5WhP?SFwe z(krtoBdP(0N(lpEQEHY>`mF7!$`7dcTa{LaHM6W81+QlyWePK+PiB>{&ZUqUB053{+F}XPW8Z@Y<8%5FzTB$K^cGIiLKG*9r=Km+ z3H*SpAqX%_tjRU7N`h<4lib*+%{2fly-KP=zHdxJ}D zL2}#xQ2cH*UdRldBND}e31IP%`SBjMq!Y7#bw!5C*0oq%p_M#+g#l#;z5DHb>iw zpBYW4$aTDCQPSHUDF|hchsWEg|BCHV0WULZMI=$7@y$F_L%{I+kDw_1)13cY+ZTp< zGqDG^?v;JH#X7T0|!^L(`$N^Bpr> z0NCrB?gEv)GRNml97Nfyritbgyele-Z5U7|CpobVhKT?)<+ci@Rb z&_T!?7Jz$80rZ10_% zG&y>DJ>LQ?7Z<5DbT~Jb_#kHl@9*zDzI}_5ge1oE_j|m^tn;3-OMC?RT(+O?jFMLx z-jTBO9n@7=EbLyYqaB=B*Vh6c^%$)9)5q(NWw_})7+08q&1Un8Vp*wwSI_jSt( zl4d7k89kfFj}e{t&otZR9M|T5$@jQ;+c-C^Zq6?+v#&YICLoAOW1wW$&1@!&@2GMf%2i+=t>UsRYdG)`-)hrUMZ}{ zDAUhSGzimC~SfB%FZyf_1ipg`L+sCm(>a-{qqMdVgfXqi+bo#2uSl`F?pHj)51 z6E2^EjK0#E*`Q@+=!fv4a5UHH#)}eo=54R9PTddoFqehCAm2aFwwGDz#ZV1!GK2`& zOFl8hy_AK*6x-N$g?2Xbw4lveF7bFk3lM{bFYR) ziRv1as&!gWT6ZrLQz`IZW9Q=$nP<}&jpIu-9RrAwhH`#YbUo-8^u z1XC27*C~|qH^QKr_l(fr`^M@=z+ zQ?Rc0vZQbR3Hvp!Q`LFdpbC$cC1_5CocKSLt6kCraL$5^V7||Q?uZLe46mXjhcuv` zYo8h|LzW=}ZCGKWCXsCCI-Dm%nE-+2pl}*03Xdyuah9(Mo?lEsZjEuozhN>Iv9C2zl>BO_;u1*jwCk=<=A#GC4fq?!EDC%(xzGtAnv-d-N6Ng;~jkT0cLrCsRGgbnPa;Y=*GVSX&jJT5>RQOl*E49z1Vpk1)6D-d(CL=9SW@RrLI$M+N zYdtj%=u(?SOo=cCGJFSkZy^L21C4nPOhRJG3sKEU1v?%^G`GP911dzLII&Um4EPbr zL-w~3to=!rTxBlKgaKx4$9cZHNd*KUXo7Q;wetJY?vKM6;|xxjQnJRHPHZub8X;#G z3=R*)9A;MObgnqT?DA3jp|JM$O>#^?mB!vYsB~Fu1pZL^F&-+FX z^dg++qN=pemG=zhJqWg=iNoWHo=!@FC!ewb!sXodLZZ5B9|pXy1O$zcKbmUnMu?|h z`K)xtqA|fcgz|z26dL0MNHFkbmV%v(cE=sEk_Z<-7+Jdg-$fd6B&u2o$1;lU`w}>| zXhrRG3~QWbl(nkn#>P?xY1fPir1^U7LnLN59SI0MO`MIpfmQO zO*R}W<{l@;|6eAw4!rUnBKwZ6x)9RJ=yu0RSH-|78D_2Ms9L_R{%}G^>tSoX>0$e2 zq=gIiqs&-EVnqP}*8>0@uvR<8iQnli-^ZlE$HMH!35c_Co~-gF8-j2;y0U*EaI=R# ztC{^1&dm;l+9Pn_ekOIWarkp-v~ECmoXz-46sC+?7Hove%X+mz)YSYiik9^1#F21P z3;k$){cZ?{w4`Bonm~bHChBEv+a$<#;r1{Fo+7cz$?jaQ=_x95NY6x9lP%#x4Ma&;+hh? zCcRdQHLzWrv=6h;19nN)T8|LgsQRBU?jJD;p3AYXnA|L}_nWqx+3+kvoD{ixwC%E) zHkY=zk{3nsZV=B${_~_2D2=wg$iRJ0^4}c_I=UV<1bjHIO^kPQUUaVK0>ABCO--tq zBL^Q0pv1-M`iKmGuCpcGyJygef&?chG@^qA7js{`}^QST>u(dq+>XXAn$<19>39-}`0y%ez^Ekfi~+M9L^MSb7&9Pk#kD zCpVY`&b)RM#a?1Dnm72saAr`Xsc2D@oCt?~L4PX29++`~0d83&lEj{oF4?)syY+R! z2Z()Q(RP?3?ulI*+BkQG5r(U`FN%P{epXa=5GkA5TcGD23Ww0e6#SJf)+BXu^{0aL ze;&9Ma3KRcgD+-46ori0ES{{+ECmoyY6jp@(!Axl^Sv~Ty#^r9)opG@WACq|*c*vo zJuoSv2<5XVo*VPI(|a- zrY?TZD>yiU2T6`Ui@v$UL6xe43AUVwuhHy-Uoiv9&ldIN$PxQcIZDI~N#w=rkOKU* z-xL$$P4QBEFh+t|Eu@L5CWll+l6k|Mhv6o)O3j+kaErP=QbuPdc)p1Z4I0-WpU{uB zodU$xJ190S6%@^WJnjSpaQBg;#zG1T_Vc;dVl`QmGNtL0K($J&s7P2;xJ;M+K~G!x z>RUrZz9$if^=;jTc1mc7OUtEM6Fx(AkTXI|T~8cmQBLjimi6KzE5&F%jbprgF&4c3^vf z^fCO8Nr-F>#$?dtM0=PSH@x6D=`#fyx-TsGUccc#@2{P5;0lGUwG#_SMB5s)@Ni_@ zEgY5*2l0$U(jq!|o5P}kG#yf+1tOqeZhpikP)7I=5rbPlw&2sE`7JNuJBx{$fwUk2 z!fu6x_ErE-J*+;Gy@g7jaUI8h>}JW>+2WAiw%{AM+ulC@_K+g{SgVkX1WlT`Hhgn? zAk=r=X)Rw%CY9tQ(y~F);O@Nr!eUZEAi;6QRF+Hxci%iydKOxvQKRB8%2XKJ`|>!K zrug)dwe>m?r;Y=*4CIC4I=C~Vm~sUnlbxO2-{j8p+nU5mclVYw#_g8U=2{J?K>#@S z#uef9BPdSKf_pM|VWr2ksbX>aR8y^LTMw62x1`fM_q=lnQ<(sb(XE=s@Lz`klfwGBoJeA#|o3<=Bo;E%}Omk@e>R~rhK8pevy=5aIkqn zh4bjakso0={=N4}uI~9-#*hVHEo81s@I5t^DoACG!{ff_xlEAH5Z%JS981BuK(Spf zTIp(wiv!`c=J4co;n}4;630+a7YsGn+|pF3Rz&hYLdO>0tw=EE`b5C7v$Z#7=j(Vz-m2a5Q66$+GFMlGof3J$UT zL&7H%TH{&&Yqd?oGeg7yB$_>hUZqLhr;1L=!p71dCJP9Jcw^|frAfQ~Cs2+N;18C< zixl5HUt!n~>n{;T0u??S=Z3!ZTXggKi0A;Uj?b7*Q+6n%4>)asMm$Bx&8tD#(P^7u z!>E`Fx1wcTE1?>XB3TfENo9wDj_>??l19E%&vGAQ8qX;lU%NyhP*+Y=AI!GK%dH$- zff0$46weCaSJY~G!J)QaGrVMuKqRI{XoD7vyDyiSrR?U(!#2VT1;e%n3gCR&&ji=q zC&j>1fpu2V!!2WKTT0A7ftzl|UjK^YcSd6t8`No!$H|rmb+~D`hR>PE1_zcEp^Pk9 z7sL62>y-5~fkv+n6CFPk8CA@ggZf`@`m`6vXpOBBN;JuP_R$HS7j&8C0v#pXFH1Hj z_}|_;!~gN#9R<-;l_pUoXJBVHWMK|psIF0E_KHSu2nnAQgerme53AJ_D)DUCskAnRB=d_sJCD!sVWfx9yTv54 zdgcsXLw$xe7&jQx{%-r-)q%1G&ch=PR0}tJmU2GM4=mF-1Z9*ljOLWE}erRBe#_2~6v<*6Z{AZ;IXI z5^>AXro)VtWAl<$wtd4GC8~(ec}?riw+#p1i%j^hty?eIzE|B&UZKM)YPE?eGJ+ac zemlmG%Z=~0%=oqAjEoOP@{W53Lo2qAp_X-d1!P9)dff-qtVwmMqKt(m756@`ptNR2$8^_K6@pQOri*KTPszeDUk` zkBs^*v{|~5f4d8i5qW>sB@e`^`KOo6NRP2_`BnSaKJ%UBgAJ|bi9F8cwPU3_-ZWCj z&>0je@Cjlv`p?CDHHSU~g*2!$K3P*U0a_szD?9#;irDD|cOj&2yZ>-XbP4?Dp!n;& zO-Wot@-ME8=Q3KfMWbtl!+pJ()FR_J^CY0yWewE?Fq8Yn{C6fr^;wl-cOSv*xF-rA z{;)BbQNRro5!k%Oj0pRL;p%2^P$mTAi16!0@o=N%g}18*=5Y13MRhXlR4VafCNkKs zo={i^p|Uh01aR`EhM(Q6Meb0h55aWTULn5ehTmLd-S zMpw!V-v41nK}A+j)i4uV>3vxJWG?^=vnYjL4$r91JOv?%oo1fiq;NZ z$s9H=$GV9azn1(A82wZjD$(S8|p3!cBucbkQ-QTk~JR*F`3~7Pk^xACCuWOKuyHgj>6B3={*#%S{iCOT4mhizn|qvHZEbM z7bA%ZlJK}$VYzv~+1pD>{%(humX0!=%WjvV$Su4FNjrJ=`1RAeR?Tla&v)muGlHf2 zV1wu5H_pyHLkl)6Y`D#4Q`x^^v`}tPaC6Om=Y@5?h5oyWT!qQoW_Ml`>FAia??wA- zT+h=IiLc;{n0P9x$lK321sg=V=1PS~dmCrz7CbjD?Nq|(wcFm7sZqh`Q50lSUQRou9$@VjX&ORTRb$}}a_B(v zo%&BAAxEWmUsyoVGmW7OPNtMZ8O^)m@(p94r$C4yxFkOO3sVq>MlGs-U!0u?7#r zy&oI%9aK)`YTF*f3lr!r4)}7Fx)G38Y3f4En^rx;`st#@|Kbnx>xoYxr~U8(1_vBdbkeFv!zn{vFRk$`ahHesH(CEbXD9e>L&DGQvVyO}fC{?yuJX_vMwh*~DgH(A#bHpRWDz?5nq>%fC!#`g7f)=j^nOYq1}$1etvX=+Ju+Q>VP>;7DwH`gM`h&&h4%3q4Q6q*milDbKIVK;nF! zvI^eS&2{&!nE>xQ2hSg_12b!OopPYSkm;dAOFTx!b|w9lmIJF6?irh@8G=)%(-ZyK z`A(OS**CucD`^u;M8%E@wMwMaJL=EkwHKq*di!&O&w&#E#XfjTVFg&N};f+d? z>bsptk}N>}dUCIUUNvlB>Y~Ipnr9nE?)Vtr&_b{}2ao-5a-Y-}jTtoriSmEc_#Xm= zabNhcI z=!QN_sj3I$6e^~WN?->?CB&6oe zEo#~d4Kwnej>(q%6L6!t{^-1RE)_dXbTh9p$+Cv9V-SP_z^*F-e2*TWmL z-)Tvd(y8v(Q6sd3u-ZQa2+jjOjswIfzWX$Zps1qS{2R2J(w znhieA*uP9|K&>m`TF$1 z->!z=Szb2XeS;-^Ls+;CLGjt5HtH+_Hve>AODWRv9pPB{0k3Zk*<_#s@H?}EQx^QF#r>8Bc$D;xpX^?;cjp2^w z<{o*S9fX#@DKX%v|9u>K0Mbb#>GP;`34FW$3)gw4PlT3zZ!fks3$>WC-%989VSVss zrOAAPK?xaJt}3gWKgb;0PcJ}FuBBFW>_6^dEb*dsDGQZxf`Z2+5yu{mT#+QK+fc}T zMxSf9ReKbwDiIV)LD77%nTn{bXM>p1$+Xp9D6QWI1pl&?64@}_zX~NA#pq(Sc`MsE zj3<(#@1RH-ehj+ZUK92cntHow2i(AiE6tsPRSnVx36y-xU~XE(+3nb0Xi!1albo|a z5sHU8jg-XEI5NU|kWtw+dhs_guY|4XkFP6dZ#;N@yPHWr1)(#Fc!UUkCkrD>zWYC} zS#8im*yO-P`0)x!6S-1T>c6ItErpgdgicRxtETS8XHQ zW4+TA7J$p31y&{YS`;H2)?26Lb0aA}*hQ=XF6{*9Mfb->w|mlEJ&%CfaDHj#I-~jU zMl^4uo5~tWD?@5zq_%Bl21N;mUO^e%`8=A`c$HVYmhc!ib9lg zH6us;SGCgb@y=&(Gr9Kbf$hQVdQ-`S1k@yt%9O8HcDPdG1HUW}=ly*Dz??4#_jnGa z%BRKI=pw{wOy{CztBlGB}KIz0HxHUHcjuB8r zo~}bsh1KYfTi0DTObf?KVFxwU{pnv3R+h94|L#I2v|x3R`Weqn@oWhTO1Ls#bk6t# z8=rh<-C4q-a=OL=Rn)6t}^!(>hTT0J6_jqx)1riUD~(it z?{&xsIR)yXf>Ic@FM7BGy+Mc_QS*gyYACu0~Zc>PPTKl55mIaB!e6wtw`#H7$w1m7q#x$f1qnoEVmeBBor}*RW6#e z&Gj8Hz2W*cW!S#zs9!&4l~)KY<3ZbPRr-d~^D%U6#|rd`c66nBCig?Mv9@UBn8KlB zU8`ImtOp&zyL;#?JWN`_ZzD|Kl@Cw=r7C@Nkd;f7L)~*dy+mx0)czfy&O6SCw&qJS zbKpJsy(L(EGU)@0Pqagb_g2!wz z%$kzZM&z8xSx%$L4K{+2e>RgB7Ua2<1nPL6|2~jrZPb{x$fL*OO3zX)OYI6oH>99o zI8g_{fT6E(d=nxOAKf_ymAHKIr@0(m${Y__6?}1}c$YySr$*v6Dh3$8WS^5BWe!I_ z7#>OuS;7uA0ZS7LI*Z~puPSF>Pqx^T=K`M&1}-)V^wb_!e*##tr2P-=O6C7NuSel~ zt;01Om0R{(c9oXWL>%RmhFMk`J@w<7m)mzrrP9<}Cf6GlukFwHKXRt{)o}52yQhq!02%aBkqn&mJj4j`~lDHAFLCvEO3r$99!RKZxI+*4a9S5lM= zCM~yerG1b%^sYBSzRptJ!vi?=1)q?i7fp09m^ly-Oci=VNdf5)$K$AZCj5YcK9Eg* zkS0G@(`bg~N@AlRJ}PVT=XU=JoV?HPCdP=1yUBGduXI?XHc%VwN7j)VZ*<;$g$GLo zGfrA1mbRM%VUVYMaI;^SN+amHRl$CA32Up4GJ$#-X^UPTR-gEjHpm;3s6HwFOWd1s zGYNT@>`qD;LJSu*t%p3chkzm%~!3$637t5H6LB6WQGm_qN z+;vq>6&Ol5@B}NQk(6z?vKF?+Q0Yve$;1;EMdNTqrUw|dM);4dAJeM=Jne_Hyg>+( zWH*@4%WP$0|K%DT6;w8r25lKt8a6Efu5O{Og((>kW@wJ^>#?%I%7(Cc&7zZcQ&(>O#Y-}J@OU1$4_)862&x7Up z4;@Q;xk=f=c3^S$gmia#5EP|Axt3IRxKsn{3Tndr95?r-^~beJg#p3k2?GjJk{mtR zVcPh~?V*t2mYdw+ufjqgF|p!=_Psrq(>FW9D6ZD?KLYm;@fuPcx9!Vaz>0P?J9%%( zncqjjx!~(=VXi=%wt$~?#f@$5-uZE`(KTB!>5n*iuFqf z`YlbZRc@sY#>{h5RN)FgmcA`gtet&!3FbMRM8z?^n-c0)cKQ~HmMhaEb^tIcKD7kh^PFT&nA zxRS43|BfcMZQFJ-v2EMd#I|iuY;(uX#G2T)XOemMoT|5e&-0w`Ip?okUDdl*uU@@+ z^}6rt{#-EpxfiSzChW9h!ldfA^*mI<(WnQUZ8`7-gjf2 zJPMJ?R!b=U&#<}+UjkZXk`@HBFDk_2(O=RCTOJphw2P^x7v|@0y?O<*wVbNRT1*^I zKy5|Kkg!_-;1dQn)$D|~G`5!dXw%Hiy8@HvN3#I^qb_anUMKwbnY-pfCOj`80GU5uv}C}gE+*l`QyweR;gxj}Ue0ORusX`7uYl{Jef6}wm8 z(!+OviGz!}U>`NWE$_Hy|`{XXaNTqP(gN&9alxB zTEzs|_&)p?uXo?EhA_fAI0!Ax1U~8A@J$^n_c#F+P+HKp=(u|j_18Da`El0=ak{s; zWs9df^5X|P@&s`K_F*fqFo+34RnB!-p=@hwv0w8i|4CB`%tA{;o>z0G*!^B*XMOd+9QU zW=)h+;;VKkgI`HWyl;1Z?oj?dA2A_9G0m5o%(A)0&AzxtVs?*&MN4`1b!{u3?k?>_ zov9GLaf6}%U6wTej7T>~JwF88@7*iaA&2#Tzqw_v!VL!UCE8D62~(Md5{%=k+}lb)ew&7$btx;(u&tc7o4Rc&c8v?$5l6I(z(Qtl)p+rHY`%nS*NCwD`z~4=;8z%4QX^h$)$PFe$ z4-4F{)5!Fcwnq|(f#P%`75{OsS0+FuzB_TZ@1|B@hXH(lb@`25PYqvx6}%iU1}sY|fs*I8O6(mZBv8K_>Y^WBqY9 zgRw+>sh-M&yT&bYgZjNH*O%>A_FOj##Ix`GY#yh$i0bFKL}<)D7JdQ9jCs5%r*y2~LBa|vUYqN56o$U=0l@NAP) zTT6=W%m@BD#=tiaunEL=o4L~l%PF9lllV&MldL><_ohG4KfBF6qzB%#<)`qF=P3PP zD}y!2V$Lp&BzNQaJ&jK+>A@ss%eL$smJWgM?Evn*(OpgOl2>A%6DNUtF6tq&d$pK%%?|Px3#XF@r1`}#P4$kD6p!@1fIXT80 z6Jg6gd&@tdovp)zM};3v2r~NIp&RGK`K~Xj#=ns(JMe%9t*rC1BhRkGPnSpn5w2m> z0YUE{Pzo3&zVk$EVap`0bff#{`e$4gB|~=`vk$HsE*N%cP6qZN;N*MZRAyghSN@)h z>b&&Y!HF=qnICk7;pYb-3>hNqRys-M+U;wQbm^ia6m6_l2~IpdPJT1 zPb`KLWp$C9d_HpVcS_9asEfe1vdo!35fV-H3%ckzw&e)gQ=KNw44jQ@AbTu3I(lus z(t?_&x$|8EzIf=z41X@HbOYA+lb%+0Zui1u3j{2?x61@}x*l<5_2|l;@1-b7TUyzf zbb7o7j`0|EH%tT829kp69Ckjh>CtlzPxFq={S?_x)Y;=wC)q4LWUCUH{<5mb(H;Fw z0jNKY2P=9}WXSyFJ!q(pH+VN{-6*h$ku8)lnGCw_j@h7@o^^rySrp5Cg+8F%=>BP? zME%Yv%x)C9Ew4J8ex_wJB|LYSKvQ))$jorhWRVyR!F1&1vb)QB?a6b?3qQKM3N9Qh!yrUqG`CJ|6L9n~?K z91aN?0rF6Of8BjUNsQ~2j*MI3fa12shP=hkBxlIZcf_6LZ%JN)ceQg8n;YDeL zQy?x(QaYM*3D~x&uFZW@Lj&NJ73M3_3=aqnMmaQtDM0L_!G8|yqS5XxNg-d6+{*6; z+_yKF!71d8P(>eBLo$D0L7{fW+5rjaHwTUN0dyA+>1W=X ziAS?*A%y_nC7$r!Un^upb=G`CaP$I?5Ii*h3003vJ+-=_{tNvfoaI2i4Eb(AtN`~L z5b5RkJ!X8C6AP36p#+U&0GPxHEfkIyiY4_V=u5Dar&AOC={NXLoD5+H>H>hK(KtDm zm;NfQAIP;nq7)nZ>Xwm(u^8v169GrXhH?arO$DoCtllf@dyb;lIYkA+cn2RcP)=li zk~DvFS)y1=n??gJsh@j*6Ny`gUL|L?@EshxE|iU!WuiznjnUg7-arjZHJB$;)%FcP`8H zzJ2#WkR~tRXNp|e0 zIoXK*!bFB9YR5gtYPk}NF$E<-|6p@%XZtkUhfz87;kOR=eQDg+wX=kdi@m3qhKt|x zI=8NF)}+Y2FkmjDBeH)P!9GVv$8{uKrp}w2?V5f%KSfza3SwKCx`qF)gDJUno!#TY zo|8`V-49_J5&F4$>l0_`*0`$~g21H`g+jHeMj?qKE|9)h@orj}YGrzb_Li9Rn;*(X zMEr{CFxXSBxUR)8t~~;bpm|jkV|*Nw_UKCW{QNZgQWWugrXDK@q){GT<4Uk{cPJ4E zCp&Q(e+4MemR%={&hiN#^<8_=bkR7IU@rJPodpn%FLA7N6QYCLbu^@8#0-*8BrEBy zq%_n&RHi|SI_m@m$uFv9p`}{sRR?NNy?eG>&`ZWEUOBSNEZ@l<|kDRWKPHQvIB&V9J;S42^825 z^~BGdB5xIa_tE+YkC9;D#lM$UBl(9S&lfQd6nT0z7q4ffUS8M}qGgc|Lk_4oS=CV{ ztB>-lA);`2Mp7dqIgmsUfk%mu<#HYwRjRLvOYo07+wDYQaCE_Zo%C8^_8Nsu+PLYg zF$E!(q-cJ{z)f%kBisao+#Bd@0>{obM*+{VB$r}PS{~Ixpz=C(!O{m>i<8Nt<9y?< zPNkflCFCW9&*}Q(AVG)Sax_QM$^>L6wz;op|5c~PQ7+w@Dmmx;PiYk>$Z~lt?T9t^ zoXp_x8Y7uAW(Ib$xQU|5@?eeG>1n(fwk`8NKnyoa`3%rUxx**TBEcdSK6jmtdnspS zp@(~Kpma5MV}spQLDrvLO%X`2qv~!EqHrwUEn-Dt0gHb;mvCw(Elnmi(?iMP)m` z-%OPMzO|~mpC)Ka+W_*`q^F;R40+D4^!G}PbC@zM4MWpc=1pwPC-z5}_;$=P;wvL)O5dU0Pg>!KKU*HyUF?whbc;{wtAP6~4xETtP3=jr{zp&1-1 zEhXF+MmAMM)m@5XVe7D|6$2yM0j4NKZ1%Of>{8IAdYxYg8N?oS9XdxSiY7IWHy0A) zd@}6xk~N}C0_QQ`j9r0Aj_~PHRwrCe6=;6=@zhZUg&J5-kC-4zCZG8jZq1?o!WL8u zUmqLn4hl@FPhyg~?gh_ELPw3X`@(I!(@W>61Th4ppwKePNi7v3uh9ZBrV1lya#(8h z8)%0yA51tGXqaFRl3Qz<3tmOOtCmGYA^r87r+L3r5s=(H5;sD>$N9f09_!#6pl)jx zD7h?fET(BrodS|4>7#!P^NC%M8HrQ(Ovpxh_8btnQSa6D+{>sq@{dm&rKD0Gutnpx z?;C|f!SRH6WcUl4EHnJD^Ga!AI|)x=J*;q|k!dJmXy3pkfyL#-E%5O9l8+F8ZmJA!|0X_e_F6Q5>^)!c~j)+*8hF60MYLJ&2ors+JVLCtgacib2pSO zuUgoKJcf>akVX=*^GId@8=7{B`V?Bf959WxhrF6KR7pjkf{uo9%or|}( zM2=W9nTPY6pPDQvembGQvI8O(!H!vqlO9J5dI*Ah%Y<@rZKuqNg61Gd7kd4cCbz@= z&lWtMg$M`R=x?hL9;)-@j?f*R|3O;WfcRh1%2EinpBuhEAeQlenp2xoM%YX<=#Sc^ z_Z|SXe62mR46a`-;R0}-2Zo%-2--(5b(x8iUOnP)RpnTD==9u$CTzYCv>A3iSg z3!eCI>2D8QEg97vY!+>T&%*`2v$pg5LR;RewYB!0_U%9K85{+cSeN^PC}u|XaJCqJ zY`Kmp`rXGO<-G?1X_IwFE0{T>>3kdAG;TnzR8U>VX76W?2E9dX%UpJT{!GWoKRZbE z=MDwNzcyreGUSix+5`xA_ZjMS+k6{T2zhq_AJP_>x6e=7Nt*O#F6XDWN=KeAj6lsc zkhsd}$a|UiMb~a~UEOyFX3U}M?027kFcu8W;(#))-b6gU%tRioP~4?E`=9V2bLTFL zzgc!*L6?-G@=EP{s<^CKd+-gA^Il4sI4$^jm!N7Ni&<|`;I<%bhQ9RQSY*prSHHAg;j(_vQrzOyFt7Y0LucS#$nvgrb@YJdDoWW}4{%H*JvBkWnU zf)Brmlp2fFwvsd97Kj@Z$dU@;`t!zf&7}FojScF~n44WFrn5_$qGFHsgav;6!WnKZ zUO@V?29s)rLzb)C-L&zvK;n$kX%0-d7?Fni>}$amb-t_!;Qm#Pg^`^BYiYaVUST>T z=w?FX%>CD7Mb<&9nt(3|vO8>Yo5x5*1h(E?7Ag^-R0;+!Voj`mEG6ZX-X5o+rE8G^ z(T^zBvhxqna8H>`#IR(L7SE@^LA2!f)5sJ_yDNW6Dx&dS!W(8LgssbnGu=%oQsbl` zKoDDa<5WXFmIdc7oRI{m0F$i{H%3GHA}fZjaElxF1CFU@&S^iFh+r?yCF~0vrtdLZ z0SmV9{@l3^7&{4`HKN$HX8(KIfRPFrp+44xgwRKj+s7iPpkmO@Y>B%JOexbVv z17wD5&*b<3l%kR4@R8%*6z;0J**GVGi5N%ZGQqx>>BLuGV^zTPT@JU(eHifP_!BM zlx(;EA@|A4oLDIuZ>9Yo1U{DU2ooXb=8$!xS@QZFlm!kWS)h9W9ZuB?peXnS$UJ6~ z=EQTe7PaEEO1Ad5+OCU23g`zqCN744e&uVZB%<0I(`Pfj;kGrGu;I0Fu=d9zg=03D zZ(R+NSyb?Z!NJmZLor1&LKw=YEDmRRIg?6~WU1%${O9w2R9EBCi*H+{cQT;h&xB}o zS*zeAhwXfdEH7Wrb`Bo)W!^f+0ayE$=QYpC%adnIE0_LlSo4*#lJ%s9Eu(%DuwKc* z_D5gG)rCO};f=su@AsD%cyqciNg09%pC9}eZZ4kw>^sfHYSX&zKU+-BlH<76g(o%j zyq8A|dwY~lb(*~98NXdRP4n%{FR$;g%W#}Nsy7T2Bs-hLF}ToA5sdv zN5FzoX-U@@%UW@k>C8Kzmb;6PCu(H~u%G}|eGCuJS&_Q+1yba%%vFF$x-s~=ag2~Y z&X<))(+(T&e9+ni&zv@=o4h$NG~J71+W};M!8oeFsZjfu99l&~65kKL8-Cx(ue#iC zX46!%ibS#;5;k+x*J}>dX`q2hVjOmZ3Fe)|l~nw_K(BUZIGx;YkbcV1{0v8Qbsb|b+iFx7a#Ka(*=4kS z3^md51Sb`^mR7w{%#M-RD-`UQ3fDI^NHIpyM9(q_2eEqkM_gQ#SpciOtWOD%c5&p1 zlwp>eRb%rE=6Sp#FjQ;y18+VZ9j>#funU7hNg5NWYb_ic8+@54r6Lp%>3}Ux@p)9C zdO0U!i3E?X%O~n(Lkz@%_47-mfN^Xh(ExB^B_FBqfkEIy&3WL?kW}2DI*kW{0}@im z!TLx^=+g-2V;??^$kKrzG0{(;2k=18u6FD7v-ivX9%1zH$XmDTY}z`0}3AA8Hwi* zx$*E~F3sIpW)Z{)u$^&Xau_*9!VeoB-OYH%lnxZr7TCpr+8tV`{k%nVdvOV<-rzt4| z1n3}oDwxZb`)Z}#VL;VWi?T>H!4A8Qegd$v-l6ky!7)jsz{|IBqP|mT{@E2vZ*5D^ zb^=Yn=ojf!>@|+K7H0&}=j=s&_W*4j23`70HV>MtVA=BWyoLE`ZToMZvjw@}%Nrv6 zQzBaS_HDn_-u5jY$#36QYqDIsx-H)UYePtaK9f)i_OvI*_RRhLT;C!*C-y%beUE<1 zVm-klq;BZw@Ktt}{T>tYi0`$H32U2|Y0F%Nn&ez}Yvyz8zc97TmERyMy5N{jsiG+g z$13*u#_)aS*{lL_(~cT^!mjE!d(-MFiJlIEWF>@Hd5f22d#2~MCPBIvo&p}(oeHCF+F_E4tv=P_VaKRoX0lP@EQ$iX2)B2c90wJ)eV zTd*x)?YPYj=rK|GbFSaJgQ-VBkaDX-HHgVV(MX`P$zAM(Nww<|nV4Wcg`Te=u72S6l5%6 z@kHeGP!7xi?uSIls2PUGv;{-@Ns8y*$>|Bkp6V zk)Q2FKH?)jsc4v@Gt)PYbm=c-FS)Ka$LN3|LL)@WSPd0{xn^d#p$(Pns~WoP1%-62 zEm*128vEr@%vByi3%F!+?-46_UCs2gp}IrT2S5K^?MG!4SF zND2J%4+LZRy?B5%)HwS5|FZzv`PEOcXj${$t30u}wH4m20~etegh>PsI_}?1nSbxR z`v_8i{h(kW3>vEg6F{_Ct|l3^#jkR%1XL(nEFlv^k(V%O3=Jve`Xq6AV3%M@g`~+I z$dXJ)F_s$_MhB7*r#MFhVaLoGv=PH81}g9`^1y!A&9o4_59ahpOVK)sZ>p&t!(#Fh z=ec-Py#T_8DNW?yM_|(8vmrVk&RuCd@zHn}03Q`Di=Z{f$U07|Mug=fz7?|F>Wz)HjP6kEZav;P@_=WPLW*0$za9cP|F-u+0N?5e@KFq zWrDYr20k-^z3F>Qp)X?U*jr&TFb}fyYV0pBJjW z$0H^$KilTkO9AlOv?z_Krhvq#Yp(R}-Vyt;X+8h9x)ev>a|Xvd0@x&+P|?8Yw%5Uu6Z?!;izWo%#>{!ggiNEei(P zn_TVoW3Sfjy)#RPmsEltpJh91t>zYOKX?A{={GSR=pH{KzG{a2Iv!T3UeCKute4MA}&w1o-bzda+}N~|J_Hhz*TRR zQjAJ0YiBtyH!Ivo@Jq ztcs;9pY~EcnMczgDr|_jz{Ey+SNa=hN1);$wJoWk3Cj_MeSJDeki{+7xqV#b`Z~&UPu6cbP6t&7#Svw*mR-dc?eQzfS^<8 z(lO6{gbL2clW)h0tAAcm2>HOCa@UTdO@cjFZOU$%JZ~1ibQ_7`i<&b1HI<5d(peomr!??8MVPEoc83zaYM!-mmkaiA@9RpE3~9~T?2PG z3I-p~y#l8x{~wd~?-Z6q0oO1{=S=6HT|GoxCu1bBov@s@U5-Y%IIkJ11!+cXVOEHT zQBBWV$uc{{t>L`#Y0gd~==k$-Bcw%nZTP6ds^vy_I|*h;^eiS^GbaY){RK|#ev%bT zV^;+nqDH?Nq6`9_16GvwcAQAmOW$48qh|Rocs0s-r__PTGkuf_cN?;MLHm@$Ck2h| z0d6+L-c5Gup;>n$M)u*O>^Xg%;8y^Vm7TZwtgZ=`2IRM~L-)Nb{EP#U@lD=8qgCbSgWV-)e{mHE{^_TXu4} z|AyhKTYEaXYYgvg=}kM2qM;IZyG+-g(W@fP-p*UUP!s^GhI#GBU#; zz>8#3t9pr?MMOGW&gW|o)&hR<*Ip$Qep(S1>$x}%(6D9rc_nxV?1fNQjbvF|__@!U z*~Lnlb$qE|bwGt2Ddj+k!LUpQG|y8qJq)vfm5C_RQ$z1_YyHzN<*owfQ5@u|rQst0 zF_Kv7H#`^rhsidj_zEPvR96iqr7xVufZbF@kuL&~3%FoKE^rl%i)j2ZkIPA-z_}~q zQw6iERx1Ri*c&Vs#1qIfPMwfg5^k|lS+RTbk};|f83l2{T`nyg!N;d@pHr*GX0oH9g@Xf{K4e(#>RN@iN1 zI5cP*f1zkL<(vG|{Is8Zs4;WoEK6+~)9{j$v6b(+tT0-I6x_EOYIFF*IOo>DLYnlliz80J(>qs z;!Zk{43YI+Wdu_Sc~H^YDu@bXiMshqGb8OX;Bq=No;Iy>lx)%(FBa#}Qpf$aWQtL*R*eykF{l=g0nm@sMz`u9=-BAgOycMH zzn^W#Ti)5FS^X-)C<)uAo=aa$@8h))Q4mt6U6%Sf!K=*Zo=j1F7l{obK}~1i1s%K4 zKmV>#vg5!(PL(at@5QmA-KO_Kuy=N)8*k4+**1gEwwn|fn7rC%J`2mXn{AGMN8^OtHJC91rq(XM zDVCY*rRmq%#?R?{-;(Cd{Oq;2<$7N+i8bE-?w%&U>WsPk4ogi>AK$UZrFef&lb5;? z3-Chw+RtGv*Wy*2Kz@r~i1jWF53`!JM9*_K`UWRCs1m6ig`xQ%WxSaYr z8g{vk#_YzwLZ2UM^~x6QVE294l$0iM39BHnPlaIngf#6;5;v3CwaA^ zHS&5hiop`(aTx`~92S&~5$Z06slw@iita?D2%q*|FFCsu7QPC4h-q!9A$bo{&hXkc zEJ!swQ4A7~R=+MmaYIdXDi^FmVum8RM>vp>J(@i`4&?|@Teb>+Tur)DM*p!4)V8f7XvpJh8lT=rJcO72&xs@@M;{cH?fk; z2nW}rD4Ou8P$dnX=Jy(9pq3bsz-e)MvNHF;%1C733_c6Nda(vhdshl*Y%JI!2y~I& zV4#?v`(F9Gc%ea4<6vxl5jWf1J0&(4(u5p0hy-7Wn@bQg5dve-T=9f3pTwS=z+zWJ zFb-v)I3kq08w}^noli+Yj_^H;2K6wEk*~6<I|Lyw>4*g zsUq{VYAA2nVS2DMq$9~*`sbRD$EHBFww{(!w>I9^%{0(?Ga9Qf>DXDIeU4(>kjl#Y zV$N%1QbdrxBtR)m6LWk`@Xy;1s+19OX&HJ?@MV=187(Hm!M~h`>>VW&Ud%FZaz1jf zChpavs`%9VFE`P`=+8bW`i_wne*c{pSGCB7! z3^eXy^?&>Lf}>)s@zfiS1@o8_Ct!ND?ee0g>vlza>GtBDt_iHYfVfFX6SQebzkF@M zsHGFww)%Dluy+WEu@tyC`&jX9W|=4a*=hN*yuv z6Tic|!MqTCA}10j8QxoYcfuhPFa|xQHjdLDP319j+Lm0RcjXVYmm+ce&g3zLmk(KuThj4_-1)9<0X1QE?_9-GM*-#1K?g2 zhO|MC&jw^B86QBVVJ#N?qt!loUJ?mpY4#ec+~^nh!Rl8OXhkxiXQFe3eM2=!+s26xPyQ2x+6GFiskeyM`iLs2z`LzRYANrpd%y zpz!!a#S%OE_dZ^g5o%XPUqE0KVw?R632PbK9Cde) zFiyp{?T36IE@JVcAsP3#mAdu!DosK->j8R=?-%p zUFZ`LNuWb}XL~Q@{Dr*{?={V{G9L6!KydE&`>$*L||I%Ozne#)BHX@TS!jvF!->zAfK3u~p# znkdd~f;$M^QW8Zi?)_N9@BOq!wq;&q9N)iwVrX9e$#&{@pPL+y z{Hj4$DWM;4L&%B8UzaINQ=B^=BbHTpG+WFi_o`rTS+;ufmfuuAie!+FeB6(q{!nEo zertcfdIr}>YeI1UefBeYH&;_SdLo9Y54*Q7C)R#lD?l_6SuI4%GWk?Jo%F>40hFms zmV4(Zy~W)-kMc&xX%^D#u^9J{$7c5z_>^wqmyuSG3OP*hKTwbm4MrYbnKf^lzlE%7 z>;79%Nl1pFV}O_vqUg z?ZdVN*rBx(eo!5Oa-tIX2ki$r8+4|N*LLT@>qOMq5rv~@YVJL)L46c<|9$k91F7CAsp07cEuEFQzCP->t=UARxBQY!6Z9q7 z`0@or>M}&vNt9(|yDVPnCfq8_pUJ2TEO75C_>avVe-!0y%zuc{Ft9i+QJIx}QKo{S zFr~mtYx)SW(daj1YBz@}Hsng4NFG`^RFO1%^hj4H@n*OSSjtSh9K|z9%GD2=>?0H6 zYF7-Olb{p2KcqmOlFh4}$a3^E^ylb0?AH84z`^K9t5g&z9kJ#LEp>F6n5Hmu!C|JK zRHKeCtD24@UVn|4<}E40Ucn;w=yfwt9^6kzn*o~+wZbg835Pe!}*^;_OW|OjX+oZZQ7A+p+F9ZtJWdF4DWZgy~ zsb?9J5eqKAgy^EK6%kT2N2^c>N5+?v0sEXZIlDEI?v|Fhvlp{bdp=I&Ro-1P91wp| z6vQv3E+*=8UFf}5)0zi<+WE0Uc*w|&I`-dN6;&-(s4JYuQKJLoB_c|%CC;Th{!|QE zK*uKewo^?2i&LY0hmtI3l}}_r86hS79Nx9)UHPfio`Okt_*_xJLwt01eAtuQ%^DWEMTo`qNiYbl>8f$r9l zE(=W_Q~Q4^Jf3ku3ixXRHx+lA#H)ziXspaXK#&V)^9alsyDwSaw2fU;urw2=O9eQHH^lpkA*4T2^7uSSajyJB8E*2pd)j1lSsK>iBh5f_p=N^$a&qk3 zAF=-XL!<;B8dM@kLKj4VM=ewgpx~jokfWPUz2Zt`PvufhJRv=1H3Z4H(vi22_L{I) zm%|SQy_9}l&x|r4rz)s$@%IOPNJ&|=xviA5Fc~!1*syj-=Eai^i{y2#pkI$O*gPj2 zi;(t}>5ta+)>?O}r<^-SeUri*MI02}pzioWT_iGQW{qUgy}>yP(q=C<+FUoAPvXH? zz1~rNt_-jD3*IXnI0L51PB+Fhq5ok3I_lV%U$dLZ9O^I;d*qI67K1Xxrf4;he8^dA zKFl&w9o?&SwQnk&DbeWM1-o!gi4qqb312zhz^9y9I9k1*PYG{D4gW3j`LA(MfO|-g zosVqSeJ~v3Zt9hDa{78h@e@F-dMYG0g0pfusc#ZCjo=q1-BZ9ntd{@&{>hmJfd0he zT34y;cVNA>?>0HufA4LqtiSx@&IU1A{qJkdzaNQN7P^>4%&JQxniHR`0(pmamvepU z+W+Ha{|)Bq^y^;{aQcyg$W9SX=24Yb!wiW24Z<`YAa?j5+%MPW$f@-!?jE zGDBZg&QS5PX5xo|Vh`(j_4r&usLd*s=j|+od5k~;E~pp z@KvJzl$8EIKJ)*&^fgEW6Y?G@kqoW>emDN%sK&eywaFHz^5VZW_n$rFi~@cvIG{F` zwf(PG`S)+Ff176@bJzc0#}jzP07@1mn00y5wxxeq4F2`86=rEj2R2`4URw9R-&9~X z*2p1gNxG$n(>MOlKCQ3a{s@d^xo~^^OI9yma^jv}U8)6ku9#Z0XEeIk9b0+h47%qp>g=vK z+Za6(axzaDm>$b<;cAbLGcj$R<>NRu&`e{t$_%Q{b|xn$UteDr7Lr~2cO2V)ZaK8C z+c~)LpJrud&h;*yl{(h1mQcN8EVVxq*D`f~LX3d4@^EGV6p6>%eV8Kef;JGtuu)t{Ya%#cF|3k$1q zIj_ejjN19kh?ESYZRX zQByVoK~Y#O_jR&At_kC9D#3JTXk0@ut|g%S`**UNn(&b9@ls?^^K2-cXCmd1V#di( zb-LRrl;R2(YCh;hd>@EdO3GqT(Kc4hJsC80+9;ZaGEK9khD=HV5z@j$HVtW&FJd(* zOQkgcz0_0lR5F|(En_K3bNSE`)vSj1r3$w*Im=tLIhJ<}b{bbuY_OolPdU~lP%?OG zk`fiBS8J!UnwqFKtHRBszS+iN3u9yBsdR=YUfAV?eO0D=cDhgs^Dr%A{VdMfN9OZf z##!_r>n@eaOk(@~@1zQN0{sz&0+{wHLBjq{G3@dCO!bu_N5ZR)>$A_=laX#UhTK4M1 z;BBrZ#1%`(1{}3fB=Vc3v=LLM&eF+gMbPRJ!a=bHDCLuA@YuDqLm_kW*tXHiy})iU zC9{Z7#)sCa)d{J}M!n~{&nMZoggHIAE!?nH=%E5LmG9${;e=spUlhH2M?zMGZO!tc z8bx_1;EVQ!DT_!;1TnpfGMZ(^rD-p!S$(UfErVoh&yM6IGOF)7aqMd5B&gJ95fj%; zuy)nYm#j>zTCrlr2*1_znL+cV%wxGEpM&^sk9ifbsxf4H!A-trV6ok{is`_6Fj+a$G1q#I88MTk>sfU#8nn z$;!sS{j#8>YM+(!KWNgQiJG`xGjsKktzX~nwyay#7kn#4!+f4~|K_*Y)MhpyEzIg$=hNl1%-3{kUevEGV=PD%?mOx9=W5@&y7#E?m$t#4ZN#qEkpb@_3+Y zo325`IMfu+gMNrmLAfKfS;0M&|4Np(!AIv!F%DKu1^mD)Ij?lFPX95JnhxdQKi|$$8iyET0B%-&zn_qnqg1{`I?=H9^ z_g?6qkDWglc1EYNB;8s^VPF?t%)G{dknz;2*q~o(#q^2G`P8Aw<(m84f>TR#WLS0T zf%0a2m63F|Ec>5r^HE)aB22$~NZFPxxYv78){9+YMR6M+LSmWr-Ei04Bbr;paUrlxLC==Aef4jjgSI(ZfBu zQDTm0M|F7}LYjfPi+=vE&69j!-o<;tN4$QUGMkt@3rec+{Cdst;iq5%m{IRp zA_%jNhOYH7O?{`)xE1Ab@&>ao9*~AH`CdhJ>7XLU3RVq^)GM4o6$seHWwmy_&9=5V zn-}-qgb2KuK;t2@n^Wo7K0F1gd z+)GO1u-*9#fN$B(lMi}0%h}L>xeJHHCe~Al&LSe7CrkAap%9tQq9iX_MlZxKC^#~= zDJT=#2)a_YjbGq(+2=~ag)2aY%2=iEt-&%w6nz#_2%j4wvhaSd27F-gcU6}L(IyGA zHp@uRDA%h~uv8(VzND>32Z~mVJ7Kea5x-MrVnrm#(=@KO%%**J*VIz)%WtWo`Ij&L zU1Ipm3bXjV-!0z-;!3l^o8NOWG!nosBGM(*s|@XBjBi(u$O5`!=6Ta~H^H^u+ri#G=RFIT-+%C$qQHH(mexu4XaDM?=g(F~Z2P5)P7`;2 zW_E6F8{!R9PL?|)4np&?gJYsawoSr1&(E{E%dPggECYQ+=k)G6A5m>t7ftw#E^<%P(DL30f!?(|1w(SqB|TZ0H zpw-{k^%keMA=p)YQ@*Kcsp{4;H#me>#rT-)ha_}mhF5Ww9W!MCDO}n%!dOrpJ|pc zsaCB5tHEW7iM>zi+?wVQzHFX&@7G4IC5U7gC`CYpf451R5=w|`_n6KPw@EE5u3mA_U$0_}_Hi_?DPhQ<;m>tmg+EL^F7{)RRFI2s0i{m$Q7W%%nUkmQES@ z0j5d7h>pgOu25)Es{_G)YVkaSuDSC3&SCXJn=;_1AU=c&oy8^%1b(*L;pcgt6nt6a z$n#$c70u`YXmlg^JT~G-bT)7bI+03@at< z$?ueeXJ3VgFsRie2rQf&3LQPWe=5I*G5HRF<->IxVI9q;rf7yo`?nZLBfQ}=dW$gF zhfZ(@8p|RqijI^OIa|k8Eckr{At~7ZBWp?mz;(lI6wI~ay#U}g14 zDjmfFd`VJ&ZjGE_)9nYLnkzIy$Fg?AfMmjSkRMEA@^{&)-%P{W-NMl-hC>*WMIQJb)UlvU53#jWRSUH&@> zF7JKS{P*G$Ay1Pj7Q-$#&2Kix7T>q-INQ5TzmtT_owa8-8L>9>V*J$kxn;2fYwc{D z$jU~NQln#_x53JmQ)$Un8E#?fb8$K8)!n_RfBuY_uE}fQIhTLhf@^w^BJaq9eW>fh z@Y6~+1hG65u{GJA#7|0>wn3J{fz_qB{GwBwX}Ob!8?qO?}iY zU9qXL-e;(UG9Y>?TO%-=;slyhRvtizL^ zMc6enH35<9Q5KkjYCx^2YkC8C3W%kM)lw=9cg0AXNNL2cM^ia%1T3xTLOQc?)-6q7 zdfCc4(Z1!;3Sy59_7-dEB$}W*y7q%@!Xm0>o*PW+c zpG)r#8*k>ZlpBoIFtjejD&o0gU>9RTCU@FGS4TDAwqqOV94NR1zoWWZ9YKG0Du%CO zJWNA#G!u?qO{M0mp8k;KRzn_z{Pd2XGe6m!b@A0Bt_(kDm@mv2R6R?-o<0vXnf+vwrw`H-PmSh+g9VGv3+7RZfx8APVX4s823Kym-BSS+H0*n|8xFk z|1ULBErICc;2?`Kd|BLd6o@R0BU&k!iLL^=9K7J{&2tNfYA5K{0p%aghPD${TUY*1 zZ|(VSJa!?mEI-FnX<+e?z(U!*l3@joMa*JS;8YUcK}}j)50O&*5X1&x#mtKp6Hd|Y z?7S#%F7S}W@{Ts$==~U1C^v$l=!b4|b6N%H-3VG!`jYWt%#kFpUJB%+h4QNV*JIBZ z3;KSd5+12G;jer@0*WmnV-XKX@v~g1>$ZgHXO#l2U$ONXA+nkGqT^=E>KIS;y-{cg zv64EJAciE>W!mZg2y3aP|mB+qW&quI7nORi%9zU+BYNvj`Es;VW+^0&`W$B9_n0 zH3!jhi4pCL=h>i87j2M5`wPMuU;(sg2qr?A}qb2_yJ{M>s zqdlUvg9&phX+VtfUT$~=@#a5A{m)0)3nKx|pQndREl<6FKOS}q7y%EevsLH(DI}MU z$AY3Loz3{H&Eob&D%TbF@Qh$+arPN%gY06{Izdq?_oDPhdR0gI6V1k2hOe;%xVPgK zeI2ra@W=Ez_ZkL&U*eQ_;StNNEChCHDO&o+5nO^9L?(#_yunZGED--HZ&fw^x0pl` z8wB#}y&bwt`)v|lZk{n|FK=9@=-Tl-hL*N(b}yTO9IoG$yid)29#Jqz5GVh!?izd? z{GTq{8xBKP4Vsqj%fQzYfP)?Mx%5xK7N6M68|#zwgRs(*THUo|OvKz_10TEksg^%~ z1liWD88~#hU-G^Y-BXoO=cw|cw>)lWMJmQG;;g&F8EY+|foVz;Ws^MvFoP9gLo}ya_&<{Qb&M>sdi9L3>Q%hKCab`}V4JstCd>_vC%?1$ zn#HNGW)A6+?kFeA5oiG_J43WOD$EG-zJ}Zsq{5`a0Y-jI-fNVxsq?MxiW`8dXrjwA zi>LN<&Z~@1`WEo2|O+Qsv-5I!rK%NmoQY(%d6h+N{S-=1gI5w7tiE&mI+iq+> zrD0Yd`~r9~jcYT0Cl_Rx6z(3H*ghBQa1Y{6GN>aBE9F^ugOP$&iKBY)q9>Ka%TXi+ zF>8qug~tRN*d(b#6N{!-Xo4Y$J~6fE9YRaD{TgP7F%1tvtEefy z)Y(}~)QJdw&CyR1w?Go9?xTpf1g%Xf_7R0tTNKtTR@L2DMFVAM3_zC?e6N!zlku9> zEVNO#vy4_Ufl)>ofI>8>Pg(FqLKW+zqBE}fCf%t9HkB&LMlY!(!NO9Icfm;91f7}| zk-ED%oYE#WHWK1sS_VlQ#GD~+nUl2p=)a^)*Mp#L`OGr;0>3bb^KnU942ki9f-ag( zn!?qvl*?z=4dS0;|7e}?2F@Qcu?W2u3n75+xY~{^99=M}ocdpCzHA+-!Z@TnOjSQs z{F(JLqw&)+g8a6NK#gxmmM9h*gjS8U5xc$O%tdT3tRQtE~T zlN}E$al0UyvkIwA3=>Ispg4-y@){~eM7$ zz!#OuK3ix7$q9TRVkTnN=7Ux4iQ@`ygv#Atm63eWd!H+Nicm;+99b#3sXG86SK@&` z3LhK3punFq2yLLxEEWbkP34@BfG)0L)73Ug;uu*OFsZv0oDUkAU$uiAM?bdy0iQp2 zNIrLl_-~1<#zD<9v_+O;Pv{3&1QbTI=Fe8bS+eGuzKoGvDIKoqi7J_pUXJe-fC?!%uU78r8p&>h#U%Lf!u%_U5iKdPSy5?0c2)CVZf3YTBnJT6eZ&Ks zJ}_cpzop;*qiZG3^o@n}w?J<~?Cp`fRdDq_zD|WrPweHrFK+taj#_#U`xL9i@ty2f zX08q&Q1(td`1C&csMfOSw#iT1>0ffCcYH|NZoJIv`#i3h{xThXzL#&C%dt`b$^+Ru zVoMdjZJqsi@!})y_IA43E$U{B&fVHSU;bS2Y!YDM=r$jgGtCYOOJ0ienARaQr>|}= z7;HA^ekp>ln2fzG?!>P;p{1mXD@(Pgut>`+$R4eB)jG{E$#O_yXEBc)c3rF*7gy^x zua~Ks#EQ&}&oa<$rqS(6BvXo~aM5(fX2oq=oRbab2~Wc1yy3__Ao|_sax$A+;>Bzn zNLkppSP~>r3x+m*8|b{Y&-W8XsMtw0^2^vsC=Ms77w?Y%s}|ldF=&Q;6j@=7aYWvR z(FNdMWsza2m#??=Qppx&*hCt=b~0@plBX~0=w?eLRA|pYAV5qKBp^3WMKsSl)Pc9%9FTMekS^JL*!{O zfZO)3#KMXec7~=n4Ds4Z!*r|%R8sW!edcdhI^pL6sITuD5E6@BYi6_z4R~(h4OGum zF^cQ8d7e&emh6=kvxsHjz9Ef}Mpcs9DUXn_f}ge67#}=;G|r;in(JK?coXX<#Y9m_ zFNvQkREP_~fW{Lp3dJ)vSGd$O;*M+^0&~{-Rw;*~PNR${q$HCBOKRDTRUpfM49974 zjBXkl%fxR}q!|Pj=1mj|tH5p)|EWAmj`2KARO`IpV4%oyF&{+3XsX1s@+?j4oO;iF zR=tfkOl|zmZB-_YyIcq@I9CMEY?HZ1B$~2gwumpCzK|& zhh<_D!)lM9QZe}|=HatipxVc5tpDycqv>D+&7X9KHJn5RYAR2mXTJVBef2&~xOE*a z^smaWGXIrU()jTQUY$wcs@fB&JDZcd@|Xw8=`Oe4OzA_O0UHNlcRX{r*QKcvcYEEP zxI{D+WR;9a0v-iRh-zD2!9g5)e+Gfyf`E$I*fGE;P|{1_M*z`XgQ)R6GdR*&5f)Os z9*&zwrZQAE(x{8j4`Io1JlP$yNU#00^3_qwmM&7^)X3O35k9P{%hN%aD^I}n?MmX? zTbSW<*uQJK>roN;8t1DQ`(lMGZ#$&@Zt@%0C)4?y?j8Z6{XD+c_&n&WgC%qLK6v2&wqX6oO6P>&xS z@4HVCa4!`Ah3y}MvR|PLBY3l?lth zOh+-=`NUhX-gr`W@?!+iP1@0zU~fFF@#mzWCMvML$e6~E`tmPpGA7x4I&4GU=*o_> zQFEZhW-0qivfSIeK2r@%|4itQ8(B?f#!aSZyXbDL^C6NznijPrYKzfOD8Zo?%dwbD z^~8TqBIAT%;(ie|ZlPbNarK@Y^u7?(`93TdwDnwE#0k8Vr5ieryBORzzm)b*&%Shb z3f_&!d*D39w()jP--`a8L@IN1pVZwr6MPc9P;9e}@sipJ17&m4r{C6l-$sTFg)A)2 z&hLTPcP`}{_bHuu{(l%oFIU*O{)Q<9JgWVDOuXE^1-3H$`FkC4X^Z6l_Xs45Ziig? zE1A|Ww9ak*-3_kQwG6o5iC<~7zZT$*X1T&)-vgp${k3=fI%T4OiQnkmyO4Y73SwWZ zt8eK@$WCIM4U&A;x3>sAQ@@#xocNiQ75dAJU&62ddT&jNOL#sh>5>l=Ra0WK)Au#K zw0J{pGG52{7u?To$8hId@3|g8v}eADUI%eD{)s@vaTX0pDDE3$#oFvi<7+d~SOqat{jIaFjn>Fc+4MZ^gUXyN#eaQu2H4WeWkVJV4P}>XmVa-s`29d z=cs&8swny!d&V(^CX+1~!o|zODHnbBKT}@9b0z&Wfh?%_dB7#E(RY-BtWa=*_mb*2%WP)S}1$9ip zc5;BBEd~EkB!3E%$THBZb(d3N#S+p$@x*gO^V!%VhOOPk-S45HyKNZi|My`r-9Cr( zAH3$)5d?P0hmXK9=xp zaJc}{a=zn%g;w^h+~< zCvYLxL7b*AnaAXkqF-iRSrC3!GJ`4M?g<>#GcdOF5-a^5nT3BB174oFM1JeIlVbzx zlGgCoNYl6e&-LC9LQE?b179+#ZFK0??fVAk&PiG~_Iw!YsQq7=xVQd{{!at;j zGZ>G}2-sp2?ZQX@#IhgNqx^>~bUXbkHU*~+W-q(CqxYTU25 z7QU-A3*-ln>6F56g}%_gbjFQ`)=dhF=p5_;`JOo}ApvQ#aG zLV%*VTFbL$aAzNH2TxMZc~EBviJ1g4J<5apTYYjkB-rA=20uw0W^}hYpUufL4(RsW zML`8$azx^I^p$()_}fBKjikaUvJBe$#oH2SgI~PWLHo)1?&raU}696NjZ)p~5gVaIVOL0xt4}Md??ZQpOBgqDP#zCFvXZABCJ7~bIbob_65p3EHV$%Cp0z76l`+FhbFS_ zdTufm>u&BXKi@a6Ui8ziRt37vBicW*|2R+62Yg`HO4co1$u|?kdkuxeuktSGubj1Y zq?I{dXGjTnGq>13GX80@HF05}kXeK`7jT*W=Rx2D>)5?NI{t0#0y{XYQ{_3z z^&l=jEU?H=E2dh=EY5bFpcq@tyjdU}eeF6DP-3zr=56BDD3}`HSOrgY{&h>F`Mtx6 z5(sU6wvOAi4Sl$Z7x;In&c8+bmv#$dJuR~oYg0hC!RvgwtV~~BUJS|LO|5&if-Q3t zgLwamYMdC=&30&wb?m?ccVP>0^2KYw->bjl8kF|-Yq2vN1kUhu`x7vcz08vyd!s)T zw_v5KCzzx94Bv+h+5NtW6*?YUMv-?1y9hc*(2e8TK7cU0t3zK)YnB+`zk=xvNK&Ua;R?!T81x`A|4Lx zLPtC^UfYl)8O)>w#Zw{?h^*w^+flb6M?e#{vb6rkRf9%&xlQCu(h94#C z8-WOqMZ5WAGb{l_UgxGHXJ>!OVoQ1^oNH#hmz(%MhRDFasLbvo5wcjCPY{iXq#QM| z;#+dJ7k7R3DTSkQhF3s{EsuW-|IkMKyYjeeC%dwgOYBj~Y@~8H44J^tz45ytSiXw* z^E$#*hCed-L2{|$ca<<(?uHO(ei6S>=+4!NK}gsU&mG62#GH7N2`W_}6($hJ7mgQ< zXF?~}gx^oQheaJ1gd%`QvPzej%BL=c7I1g=!O+C}H^UG}+z4@&VL1v)kh;Wt{EI(B zqjqNh0BRCXQz-_th4dy-!2ovMu$BY`v3!FO$+%MRTzSyem)4PToB z3Q^|0y*|Gx#IxN9odCSnc2=VzW3hZG-nhmy_rOdB#y7QOfo#CI*fa;^g{s;mVgUL} zAFN=)wg>eDQilRNt&hKjd~BVPy=j$c2P%g^GY0&p`$1Gc`Dm`OCkVlG<(QA~scgC7 z28BA_=!O)P`EZA}fDNIyy=md=K){50EiYYiLK&u@0>tdwb0$`!dFTnw|5(ir>n~5z zeMeQ#|9=+166T=(UR(WhQ6QH{q_D#+Tm)EjPDnzTo7j`e?n3O|T{=&^N4za2Qpng3 z>TGL37@lN0Uigp*s}cjZSHHHtZ%U*R_7-r__9Fduf#`W0(mc?_7Y~j9Lg>d)A@Uu7nue?}@WF}~e zV5Vt$B1?70!ZD$P%CG8@a5qbp%2T1kfHY&{CwHs!9I){QordfGH-}51wPWj^t`O9Z z_~)`x$;aCT7~@;i*?b-J^tsot-uCs9=zT0?d^dU8dl{1CeYSmgdO+6H#@n`uqha4i zZV!`f^vhd}rO>D@N?K>FV(+G-5(l+K8lin1`L%aQ!(%9}`Gc-$qZH%YminxtySHCE z!h%|pKpyI0F?|Th4f7xyxcPKK1`Ve23Jr&Jo3X-Ukj|Yxrh6vN^0^z!7;H%?kvbKoae%4QuQOM$0w5Za`Mb zdcDPxc#{5P`?Dw+GERP@BZ6yHy79}#k)S0idc|Xj^sG3j2V5)J$E1o)XKsx zwsvbi(u|!xaf;n+xIT}k32k`-cQME0!qfj!Ltp1o->K9tbQ~7 z+hG_QZe(zcd3t4Z=HKZmqA9NP#UvkAPy`+cz=vAl#aUsOMq=kFtwu5|GN=r;tX7fL z*&u^O8JDv-C;cx7tyerTl~WYlixS=TerfpNMm%Ih)`W=oytOpv$lx(e<$2y!HJM?t zR0&p^v(ZwjcWb8BAb^Fb$zJu;pJ9NY$gbuDNY76?DFb zeUUto837y!PYtL_MPzYLdYEtFlS1eH7OMqa=;$-q&3?UFf= z0EX%^f#RcR-Vs*%+?=LXJT7T%n#+pmdRin|vA#`_VXRD1OL|&g_~ihhE18j<@q5JE z#J!7n$^qySpFgs%fHG?@1AAkAQjUkt85y&9dRfHAC8Xvc^c>9w@fS0JCK z2W^iOiBSra$&!sBo6n8bUCHtL zVpe6u+DD5Mbd#7q1DEfbS1O1Z6O;{W%72JHr~!1l$l~#A#@?7 z6nzN9B?XOR37VE3EPyBtC7o0~G;4^yh+slxZ7EEQhkfhiO`3(Tp1!`%XoW#*M<5xV zZV!<;;`3$N8*Jgc=}-jK8!apk=@akDG~JDOCqglRYKCN|*?IL(tG(^E^Yd>ao#`^L z7>E_p(sOx2y2aVU#<>3$AAVxPggYL!YZeS0PYk?Ajplm}|CF2Y`OSCb|K}<%W3$!g zkDy_BZvJ``cbn5dTBQ*GlS21!n55&i4G0+W8Q0diF2K0n?XkYEUOksW(eXTAx4A>? zL7b0gcz>t2&?NK_3L=}F8#*Y+U+G>lK4o2uXZm~mdEO6AUEcD0Q%p&+YJQEiC|wSu|G$=Zvj%tyD#yjOLEs`I}`cZQDlu&D; zyV;-py(*vhEjW!AiXMJ`cPJ`d^$d!VK%0f9j~0szJIA0*J{&w>EdPpnVrJit^*-~q zg6lgsp3iwZ!Z;uiKFY*}&itK($H1{fR+DcI2c8FWLTn#*L(#P)gg(^L$EAmmJ{+m0 z#Sa*&bM}VM2BUgbvM9=`q8$BV(`X!~60qa@T#qD-1J16PT`!s!x|1Cuu*R8IuaV{o zQ@M$Ggk*iP2?`!>Jr-mni?8hrD^~S5!J!ml3TOtk-=-;BY4;m@HHMh3Mvbsy`&Np~ zUGQb_<+Dw+3iUp!^2BHZ0omtqrij!-kK`1Ws+wz^=t!mdIYvH&QFFNPkLs$ITg{!?vcq7;^N)-{qtJ;) zh^Q`h?Z36YleFW{DW(#fNH@a`h~3-><2#rjF;BDFi~+d%(TL{_awxyvQnsgPr<*0t zj_ad_TcRjkz^0cvpJc=8^arl?i=>LZVC5G<_zwaMsR-rf4P&%|`g6(U&Q1UcPLopH z(Che?Ifd8_^EZv2%kLx6SI#UGoC-c~5jjMH@RKqEpefgr2Nh6FBA&rotA!-C*M0ne zERDr{S6Jt#d*)RVEgFPf40D>}Y^SAwo1bo4w%yJKKHY5wY=%S+WHG5jI}gI3u@_?y zm+aqJ@4&m}@9P*&gyb7d4vQar>QS=5NCer?h@43N+&~o^c(E&rR5LB9f|Syl{Eb+- zs?drU!c(Nu4FrB^mo`?iK!JB`tH+c(@V2?5b(;5Gg__qb$Y^;n6JNhOiiG=cAU3)L>ooUDY%PG*#qxz5c1=%H6@?*sop}s|N-a zaWD`FVDxs!cUI~-aQb2+?69S3e#f1)D-Tab`SCh_ug_-G1we)OPL|kCxd7k!-=#`6 z0ayEpvhfb^9Dh8Svxw{An>eOBLSYF}vQh;|HaL0;`WRvEStGvxsl+AxN0(>L*9Bgb zZ6$Bqi_vR{9g+3ER`;Hwnnpro_#+4uJd@8k9`ig0yl?1Tso1OxBqi>KvFe_-(^eTL z$TzoOS`%yMHC*!fC%SI;YvW3Z+E&2Fp?1eZWh;FlVV9Q+`Efcc0T1(q8PCMhoC@nP z`}Oc?C8dz{qTv8vg(_4KCA8TC4$e`AT(xo}m@fiv?hg?%?Ky(a}WIW?& zt}vjdE*#t_)8N&%SqUdzn@tHlj7!NU90<=s`{`%!3))$#U>Y`5H{)#^oQV?&3PU(7 zGjQztwK^tA#eS9{nV_6~WoWIC!nPNIW z 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

h*yLT<_CU94c@u8QZT$2?NC58mw;Y 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℘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

    @F5>-!?<<;Z)E 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%0A-328`dM%qOxdz|o9D1LxS=(@T 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 S23&yvKYj>H3snf{`u!hJ8-7Os diff --git a/docs/cli.md b/docs/cli.md index 13ab6d08..8286a33c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,82 +1,19 @@ -# Interactive CLI +# CLI -`uv run bub chat` runs the local `cli` channel adapter (same channel pipeline as other channels, but local-only). - -## Run Commands +## Run one message ```bash -uv run bub chat +uv run bub run "hello" --channel stdout --chat-id local ``` -Optional chat flags: +## List skills ```bash -uv run bub chat \ - --workspace /path/to/repo \ - --model openrouter:qwen/qwen3-coder-next \ - --max-tokens 1400 \ - --session-id cli-main -``` - -Other run modes: - -- `uv run bub run "summarize current repo status"`: one-shot message and exit. -- `uv run bub message`: run enabled message channels (Telegram/Discord). -- `uv run bub idle`: run scheduler only (no interactive CLI). - -## How Input Is Interpreted - -- Only lines starting with `,` are interpreted as commands. -- Registered names like `,help` are internal commands. -- Other comma-prefixed lines run through shell, for example `,git status`. -- Non-comma input is always treated as natural language. - -This rule is shared by both user input and assistant output. - -## Runtime Behavior - -- `cli` channel disables debounce, so each input is executed immediately. -- Command inputs (`,...`) are executed directly and are not buffered into later batched prompts. - -## Shell Mode - -Press `Ctrl-X` to toggle between `agent` and `shell` mode. - -- `agent` mode: send input as typed. -- `shell` mode: if input does not start with `,`, Bub auto-normalizes it to `, `. - -Use shell mode when you want to run multiple shell commands quickly. - -## Typical Workflow - -1. Check repo status: `,git status` -2. Read files: `,fs.read path=README.md` -3. Edit files: `,fs.edit path=foo.py old=... new=...` -4. Validate: `uv run pytest -q` -5. Mark phase transition: `,tape.handoff name=phase-x summary="tests pass"` - -## Session Context Commands - -```text -,tape.info -,tape.search query=error -,tape.anchors -,tape.reset archive=true +uv run bub skills ``` -- `,tape.reset archive=true` archives then clears current tape. -- `,tape.anchors` shows phase boundaries. - -## One-Shot Examples +## List hook bindings ```bash -uv run bub run ",help" -uv run bub run --tools fs.read,fs.glob --skills friendly-python "inspect Python layout" -uv run bub run --disable-scheduler "quick reasoning task" +uv run bub hooks ``` - -## Troubleshooting - -- `command not found`: verify whether it should be an internal command (`,help` for list). -- `bub message` exits immediately: no message channel is enabled in `.env`. -- Context is too heavy: add a handoff anchor, then reset tape when needed. diff --git a/docs/features.md b/docs/features.md index eb2c5be4..257a43ca 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,54 +1,7 @@ # Key Features -This page summarizes the capabilities operators rely on most when working with Bub. - -## 1. Deterministic Command Routing - -- Command mode is explicit: only line-start `,` triggers command parsing. -- Known names map to internal commands (for example `,help`, `,tools`, `,tape.info`). -- Other comma-prefixed lines run as shell commands (for example `,git status`). - -Why it matters: fewer accidental tool calls and more predictable behavior. - -## 2. Command Failure Recovery - -- Successful commands return directly. -- Failed commands are wrapped as structured command blocks and sent back to the model loop. - -Why it matters: the assistant can debug based on real command output instead of generic guesses. - -## 3. Verifiable Session History and Context Assembly - -- Bub records session activity as append-only, verifiable history. -- `,tape.anchors` and `,tape.handoff` mark phase transitions. -- `,tape.search` and `,tape.info` help inspect context quickly. - -Why it matters: long tasks stay traceable and easier to resume. - -## 4. Unified Tool + Skill View - -- Built-in tools and skills share one registry. -- Prompt includes compact tool descriptions first. -- Tool details expand on explicit selection (for example `,tool.describe name=fs.read`). -- `$name` hints expand details progressively for both tools and skills. -- Hints can come from user input or model output (for example `$fs.read`, `$friendly-python`). - -Why it matters: prompt stays focused while advanced capabilities remain available on demand. - -## 5. Interactive CLI Focused on Real Work - -- Rich interactive shell with history and completions. -- `Ctrl-X` toggles shell mode for faster command execution. -- Same behavior model as channel integrations. - -Why it matters: local debugging and implementation loops are fast and consistent. - -## 6. Message Channel Integration (Telegram + Discord) - -- Optional long-polling Telegram adapter. -- Optional Discord bot adapter. -- Per-chat session isolation (`telegram:`). -- Per-channel session isolation (`discord:`). -- Optional sender/chat allowlist for access control. - -Why it matters: you can continue lightweight operations from mobile or remote environments. +- Batteries-included baseline skills for input, memory, model, output, and CLI +- Hook-based extension model using Pluggy +- Skill discovery with project/global/builtin override order +- Envelope-and-bus oriented runtime model +- Fault isolation when skill loading fails diff --git a/docs/index.md b/docs/index.md index ed34692a..d077dd6f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,39 +1,14 @@ -# Bub Docs +# Bub Framework -Bub is a collaborative agent for shared delivery workflows, evolving into a framework that helps other agents operate with the same collaboration model. -If you only remember one thing from this page, remember this: Bub is built for shared delivery workflows where execution must be inspectable, handoff-friendly, and repeatable. +Bub it. Build it. -Under the hood, Bub uses [Republic](https://github.com/bubbuild/republic) to assemble context from traceable history instead of inheriting opaque state. -Its operating philosophy follows [Socialized Evaluation](https://psiace.me/posts/im-and-socialized-evaluation/): quality is judged by whether teams can inspect decisions and continue work safely. +Bub is a batteries-included, hooks-first AI framework. -## What Bub Is (and Is Not) +Kernel responsibilities: -- Bub is a collaboration agent for human and agent operators. -- Bub is not a personal-assistant-only chat shell. -- Bub keeps command execution explicit, reviewable, and recoverable. +1. load skills +2. run hook pipeline +3. orchestrate envelopes over the bus +4. keep failure boundaries small -## How Bub Works - -1. Input boundary: only lines starting with `,` are treated as commands. -2. Unified routing: the same routing rules apply to user input and assistant output. -3. Structured fallback: failed commands are returned to the model with execution evidence. -4. Persistent evidence: interaction history is append-only (`tape`) and can be searched. -5. Explicit transitions: `anchor` and `handoff` represent phase changes and responsibility transfer. - -## Checklist - -1. Start with model + API key in `.env`. -2. Run `uv run bub` and ask a normal question. -3. Run `,help` and `,tools` to inspect available capabilities. -4. Execute one shell command like `,git status`. -5. Create one handoff: `,tape.handoff name=phase-1 summary="..."`. -6. Verify history using `,tape.info` or `,tape.search query=...`. - -## Where To Read Next - -- [Key Features](features.md): capability-level overview. -- [Interactive CLI](cli.md): interactive workflow and troubleshooting. -- [Architecture](architecture.md): runtime boundaries and internals. -- [Deployment Guide](deployment.md): local and Docker operations. -- [Channels](channels.md): CLI/Telegram/Discord runtime model. -- [Post: Socialized Evaluation and Agent Partnership](posts/2026-03-01-bub-socialized-evaluation-and-agent-partnership.md): project position and principles. +Everything else ships as skills and can be replaced by user-provided skills. diff --git a/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md b/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md deleted file mode 100644 index a0152412..00000000 --- a/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: "Baby Bub: From Inspiration to Bootstrap Milestone" -date: 2025-07-16 -description: "How Bub draws from modern agent design, and why fixing a single mypy issue is a meaningful step toward self-improving AI." ---- - -# Baby Bub: From Inspiration to Bootstrap Milestone - -## Genesis: Inspiration from Modern Agents - -Bub is a CLI-first AI agent, built to "Bub it. Build it." The project draws direct inspiration from [How to Build an Agent](https://ampcode.com/how-to-build-an-agent) and [Tiny Agents: Building LLM-Powered Agents from Scratch](https://huggingface.co/blog/tiny-agents). Both resources distill the essence of tool-using, loop-based, composable, and extensible agents. - -![Baby Bub](../assets/images/baby-bub.png) - -But Bub is also a response to the new wave of self-improving, self-hosting agents: think Claude Code, SWE-agent, and the broader "self-bootstrapping" movement. The goal: an agent that can not only help you build, but can help build (and fix) itself. - -## Architecture: ReAct Loop, Tools, and CLI - -### The ReAct Loop - -At the heart of Bub is a classic ReAct loop, implemented in [`src/bub/agent/core.py`](https://github.com/PsiACE/bub/blob/19c015/src/bub/agent/core.py): - -```python -class Agent: - ... - def chat(self, message: str, on_step: Optional[Callable[[str, str], None]] = None) -> str: - self.conversation_history.append(Message(role="user", content=message)) - while True: - ... - response = litellm.completion(...) - assistant_message = str(response.choices[0].message.content) - self.conversation_history.append(Message(role="assistant", content=assistant_message)) - ... - tool_calls = self.tool_executor.extract_tool_calls(assistant_message) - if tool_calls: - for tool_call in tool_calls: - ... - result = self.tool_executor.execute_tool(tool_name, **parameters) - observation = f"Observation: {result.format_result()}" - self.conversation_history.append(Message(role="user", content=observation)) - ... - continue - else: - return assistant_message -``` - -This loop enables the agent to: - -- Parse LLM output for tool calls (ReAct pattern: Thought, Action, Action Input, Observation). -- Execute tools (file read/write/edit, shell commands) and feed results back into the conversation. -- Iterate until a "Final Answer" is produced. - -### Tool System: Extensible and Safe - -Tools are registered via a `ToolRegistry` ([`src/bub/agent/tools.py`](https://github.com/psiace/bub/blob/19c015/src/bub/agent/tools.py)), and each tool is a Pydantic model with validation and metadata. For example, the `RunCommandTool` blocks dangerous commands and validates input: - -```python -class RunCommandTool(Tool): - ... - DANGEROUS_COMMANDS: ClassVar[set[str]] = {"rm", "del", ...} - def _validate_command(self) -> Optional[str]: - ... - if base_cmd in self.DANGEROUS_COMMANDS: - return f"Dangerous command blocked: {base_cmd}" -``` - -This design makes it possible for the agent to safely self-modify, run tests, or even edit its own codebase—crucial for self-improvement. - -### CLI: User Experience and Debuggability - -The CLI ([`src/bub/cli/app.py`](https://github.com/psiace/bub/blob/19c015/src/bub/cli/app.py)) is built with Typer and Rich, providing a modern, user-friendly interface. The renderer ([`src/bub/cli/render.py`](https://github.com/psiace/bub/blob/19c015/src/bub/cli/render.py)) supports debug toggling, minimal/verbose TAAO (Thought/Action/Action Input/Observation) output, and clear error reporting. - -```python -class Renderer: - def __init__(self) -> None: - self.console: Console = Console() - self._show_debug: bool = False - ... -``` - -## Milestone: The First mypy Fix (and Why It Matters) - -Bub aspires to self-improvement. The first tangible milestone? Fixing the very first mypy error: adding a missing return type annotation to `Renderer.__init__`, check out the [commit](https://github.com/PsiACE/bub/commit/87cdcc). - -```diff -- def __init__(self): -- self.console = Console() -- self._show_debug = False -+ def __init__(self) -> None: -+ self.console: Console = Console() -+ self._show_debug: bool = False -``` - -This change reduced the mypy error count from 24 to 23. Trivial? Maybe. But it's a proof of concept: the agent can reason about, locate, and fix type errors in its own codebase. This is the first step toward a self-hosting, self-healing agent loop—one that can eventually: - -- Run static analysis on itself -- Propose and apply code fixes -- Test and validate improvements - -## Looking Forward: Bub as a Bootstrap Agent - -Bub is still early. But the architecture is in place for: - -- LLM-driven code editing and refactoring -- Automated type and lint fixes -- CLI-driven, user-friendly agent workflows - -The journey from "fixing a mypy annotation" to "full agent self-improvement" is long, but every bootstrap starts with a single, type-safe step. - ---- - -- [Project on GitHub](https://github.com/psiace/bub) -- Inspired by [ampcode.com/how-to-build-an-agent](https://ampcode.com/how-to-build-an-agent) and [huggingface.co/blog/tiny-agents](https://huggingface.co/blog/tiny-agents) -- See also: Claude Code, SWE-agent, and the broader self-bootstrapping movement diff --git a/docs/telegram.md b/docs/telegram.md deleted file mode 100644 index 04375535..00000000 --- a/docs/telegram.md +++ /dev/null @@ -1,42 +0,0 @@ -# Telegram Integration - -Telegram allows Bub to run as a remote collaboration entry point for lightweight operations. - -## Configure - -```bash -BUB_TELEGRAM_ENABLED=true -BUB_TELEGRAM_TOKEN=123456:token -BUB_TELEGRAM_ALLOW_FROM='["123456789","your_username"]' -BUB_TELEGRAM_ALLOW_CHATS='["123456789","-1001234567890"]' -``` - -Notes: - -- If `BUB_TELEGRAM_ALLOW_FROM` is empty, all senders are accepted. -- If `BUB_TELEGRAM_ALLOW_CHATS` is empty, all chats are accepted. -- If `BUB_TELEGRAM_ALLOW_CHATS` is not empty, only listed `chat_id` values are allowed. -- In production, use a strict allowlist. - -## Run - -```bash -uv run bub message -``` - -## Run Behavior - -- Uses long polling. -- Each Telegram chat maps to `telegram:` session key. -- Inbound text enters the same `AgentLoop` used by CLI. -- Outbound messages are sent by `ChannelManager`. -- Typing indicator is emitted while processing. -- In group chats, Bub only processes messages that mention/reply to the bot. - -## Security and Operations - -1. Keep bot token only in `.env` or a secret manager. -2. Use a dedicated bot account. -3. Keep allowlist updated with valid user IDs/usernames. -4. If no response is observed, check network, token, allowlists, then service/model logs. -5. If `uv run bub message` exits quickly, verify at least one channel is enabled (`BUB_TELEGRAM_ENABLED=true`). diff --git a/mkdocs.yml b/mkdocs.yml index 1fee5e7b..2a46f343 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,11 +15,7 @@ nav: - "2025-07-16 · Baby Bub Bootstrap Milestone": posts/2025-07-16-baby-bub-bootstrap-milestone.md - Deployment: deployment.md - Architecture: architecture.md - - Channels: - - Overview: channels.md - - CLI (Local): cli.md - - Telegram: telegram.md - - Discord: discord.md + - CLI: cli.md plugins: - search diff --git a/pyproject.toml b/pyproject.toml index 529c2104..51d161ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "pyyaml>=6.0.0", + "pluggy>=1.6.0", "typer>=0.9.0", "republic>=0.5.2", "rich>=13.0.0", diff --git a/src/bub/__init__.py b/src/bub/__init__.py index b74281cc..41b0ad98 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,6 +1,6 @@ -"""Bub package.""" +"""Bub framework package.""" -from bub.cli import app +from bub.framework import BubFramework -__all__ = ["app"] -__version__ = "0.2.3" +__all__ = ["BubFramework"] +__version__ = "0.3.0" diff --git a/src/bub/app/__init__.py b/src/bub/app/__init__.py deleted file mode 100644 index 08482d1a..00000000 --- a/src/bub/app/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Application runtime package.""" - -from bub.app.bootstrap import build_runtime, get_runtime -from bub.app.runtime import AppRuntime, SessionRuntime - -__all__ = ["AppRuntime", "SessionRuntime", "build_runtime", "get_runtime"] diff --git a/src/bub/app/bootstrap.py b/src/bub/app/bootstrap.py deleted file mode 100644 index 109fba07..00000000 --- a/src/bub/app/bootstrap.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Runtime bootstrap helpers.""" - -from __future__ import annotations - -from pathlib import Path - -from bub.app.runtime import AppRuntime -from bub.config import load_settings - -# Global singleton runtime instance -_runtime: AppRuntime | None = None - - -def get_runtime() -> AppRuntime: - """Get or create the global app runtime.""" - if _runtime is None: - raise RuntimeError("AppRuntime is not initialized. Call build_runtime() first.") - return _runtime - - -def build_runtime( - workspace: Path, - *, - model: str | None = None, - max_tokens: int | None = None, - allowed_tools: set[str] | None = None, - allowed_skills: set[str] | None = None, - enable_scheduler: bool = True, -) -> AppRuntime: - """Build app runtime for one workspace.""" - - global _runtime - settings = load_settings(workspace) - updates: dict[str, object] = {} - if model: - updates["model"] = model - if max_tokens is not None: - updates["max_tokens"] = max_tokens - if updates: - settings = settings.model_copy(update=updates) - _runtime = AppRuntime( - workspace, - settings, - allowed_tools=allowed_tools, - allowed_skills=allowed_skills, - enable_scheduler=enable_scheduler, - ) - return _runtime diff --git a/src/bub/app/jobstore.py b/src/bub/app/jobstore.py deleted file mode 100644 index 7e66e4a1..00000000 --- a/src/bub/app/jobstore.py +++ /dev/null @@ -1,143 +0,0 @@ -import base64 -import json -import pickle -import threading -from datetime import datetime -from pathlib import Path -from typing import Any - -from apscheduler.job import Job -from apscheduler.jobstores.base import BaseJobStore, ConflictingIdError, JobLookupError -from loguru import logger - - -class JSONJobStore(BaseJobStore): - """ - A simple JSON-based job store for APScheduler. - - Jobs are serialized using pickle and stored as base64-encoded strings in a JSON file. - This provides persistence across restarts without requiring SQLAlchemy. - """ - - def __init__(self, file_path: str | Path): - super().__init__() - self.file_path = Path(file_path) - self._lock = threading.RLock() - self._jobs: dict[str, dict[str, Any]] = self._load() - - def _load(self) -> dict[str, dict[str, Any]]: - """Load jobs from JSON file.""" - if self.file_path.exists(): - try: - with open(self.file_path, encoding="utf-8") as f: - loaded_jobs = json.load(f) - return loaded_jobs # type: ignore[no-any-return] - except (OSError, json.JSONDecodeError) as e: - logger.error(f"Error loading job store: {e}") - return {} - - def _save(self): - """Save jobs to JSON file.""" - self.file_path.parent.mkdir(parents=True, exist_ok=True) - try: - with open(self.file_path, "w", encoding="utf-8") as f: - json.dump(self._jobs, f, ensure_ascii=False, indent=2) - except OSError as e: - logger.error(f"Error saving job store: {e}") - - def _serialize_job(self, job: Job) -> dict[str, Any]: - """Serialize a job to a storable format.""" - return { - "id": job.id, - "data": base64.b64encode(pickle.dumps(job)).decode("ascii"), - "next_run_time": (job.next_run_time.isoformat() if job.next_run_time else None), - } - - def _deserialize_job(self, job_data: dict[str, Any]) -> Job | None: - """Deserialize a job from stored format.""" - try: - job = pickle.loads(base64.b64decode(job_data["data"])) # noqa: S301 - job._scheduler = self._scheduler - job._jobstore_alias = self._alias - except Exception as e: - logger.error(f"Error deserializing job {job_data.get('id')}: {e}") - return None - else: - return job - - def shutdown(self): - """Called when the scheduler shuts down.""" - with self._lock: - self._save() - - def lookup_job(self, job_id: str) -> Job | None: - """Look up a job by its ID.""" - with self._lock: - job_data = self._jobs.get(job_id) - if job_data: - return self._deserialize_job(job_data) - return None - - def get_due_jobs(self, now: datetime) -> list[Job]: - """Get jobs that are due to be run.""" - with self._lock: - due_jobs = [] - for job_data in self._jobs.values(): - next_run_time_str = job_data.get("next_run_time") - if next_run_time_str: - next_run_time = datetime.fromisoformat(next_run_time_str) - if next_run_time <= now: - job = self._deserialize_job(job_data) - if job: - due_jobs.append(job) - return due_jobs - - def get_next_run_time(self) -> datetime | None: - """Get the earliest next run time among all jobs.""" - with self._lock: - next_times = [] - for job_data in self._jobs.values(): - next_run_time_str = job_data.get("next_run_time") - if next_run_time_str: - next_times.append(datetime.fromisoformat(next_run_time_str)) - return min(next_times) if next_times else None - - def get_all_jobs(self) -> list[Job]: - """Get all jobs in the store.""" - with self._lock: - jobs = [] - for job_data in self._jobs.values(): - job = self._deserialize_job(job_data) - if job: - jobs.append(job) - return jobs - - def add_job(self, job: Job): - """Add a job to the store.""" - with self._lock: - if job.id in self._jobs: - raise ConflictingIdError(job.id) - self._jobs[job.id] = self._serialize_job(job) - self._save() - - def update_job(self, job: Job): - """Update a job in the store.""" - with self._lock: - if job.id not in self._jobs: - raise JobLookupError(job.id) - self._jobs[job.id] = self._serialize_job(job) - self._save() - - def remove_job(self, job_id: str): - """Remove a job from the store.""" - with self._lock: - if job_id not in self._jobs: - raise JobLookupError(job_id) - del self._jobs[job_id] - self._save() - - def remove_all_jobs(self): - """Remove all jobs from the store.""" - with self._lock: - self._jobs.clear() - self._save() diff --git a/src/bub/app/runtime.py b/src/bub/app/runtime.py deleted file mode 100644 index 2fa31f17..00000000 --- a/src/bub/app/runtime.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Application runtime and session management.""" - -from __future__ import annotations - -import asyncio -import contextlib -import importlib -import os -import signal -from collections.abc import AsyncGenerator -from contextlib import suppress -from dataclasses import dataclass -from hashlib import md5 -from pathlib import Path -from types import SimpleNamespace -from typing import TYPE_CHECKING - -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.schedulers.base import BaseScheduler -from loguru import logger - -from bub.app.jobstore import JSONJobStore -from bub.config.settings import Settings -from bub.core import AgentLoop, InputRouter, LoopResult, ModelRunner -from bub.integrations.republic_client import build_llm, build_tape_store, read_workspace_agents_prompt -from bub.skills.loader import SkillMetadata, discover_skills -from bub.tape import TapeService, default_tape_context -from bub.tools import ProgressiveToolView, ToolRegistry -from bub.tools.builtin import register_builtin_tools - -if TYPE_CHECKING: - from bub.channels.manager import ChannelManager - - -def _session_slug(session_id: str) -> str: - return md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 - - -@dataclass -class SessionRuntime: - """Runtime state for one deterministic session.""" - - session_id: str - loop: AgentLoop - tape: TapeService - model_runner: ModelRunner - tool_view: ProgressiveToolView - - async def handle_input(self, text: str) -> LoopResult: - await self.tape.ensure_bootstrap_anchor() - with self.tape.fork_tape() as tape: - tape.context = default_tape_context({"session_id": self.session_id}) - return await self.loop.handle_input(text) - - def reset_context(self) -> None: - """Clear volatile in-memory context while keeping the same session identity.""" - self.model_runner.reset_context() - self.tool_view.reset() - - -class AppRuntime: - """Global runtime that manages multiple session loops.""" - - def __init__( - self, - workspace: Path, - settings: Settings, - *, - allowed_tools: set[str] | None = None, - allowed_skills: set[str] | None = None, - enable_scheduler: bool = True, - ) -> None: - self.workspace = workspace.resolve() - self.settings = settings - self._allowed_skills = _normalize_name_set(allowed_skills) - self._allowed_tools = _normalize_name_set(allowed_tools) - self._store = build_tape_store(settings, self.workspace) - self.scheduler = self._default_scheduler() - self._llm = build_llm(settings, self._store) - self._sessions: dict[str, SessionRuntime] = {} - self._active_inputs: set[asyncio.Task[LoopResult]] = set() - self._enable_scheduler = enable_scheduler - - def _default_scheduler(self) -> BaseScheduler: - job_store = JSONJobStore(self.settings.resolve_home() / "jobs.json") - return BackgroundScheduler(daemon=True, jobstores={"default": job_store}) - - def __enter__(self) -> AppRuntime: - if not self.scheduler.running and self._enable_scheduler: - self.scheduler.start() - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - if self.scheduler.running and self._enable_scheduler: - with suppress(Exception): - self.scheduler.shutdown() - - def discover_skills(self) -> list[SkillMetadata]: - discovered = discover_skills(self.workspace) - if self._allowed_skills is None: - return discovered - return [skill for skill in discovered if skill.name.casefold() in self._allowed_skills] - - def get_session(self, session_id: str) -> SessionRuntime: - existing = self._sessions.get(session_id) - if existing is not None: - return existing - - tape_name = f"{self.settings.tape_name}:{_session_slug(session_id)}" - tape = TapeService(self._llm, tape_name, store=self._store) - - registry = ToolRegistry(self._allowed_tools) - register_builtin_tools(registry, workspace=self.workspace, tape=tape, runtime=self) - tool_view = ProgressiveToolView(registry) - router = InputRouter(registry, tool_view, tape, self.workspace) - runner = ModelRunner( - tape=tape, - router=router, - tool_view=tool_view, - tools=registry.model_tools(), - list_skills=self.discover_skills, - model=self.settings.model, - max_steps=self.settings.max_steps, - max_tokens=self.settings.max_tokens, - model_timeout_seconds=self.settings.model_timeout_seconds, - base_system_prompt=self.settings.system_prompt, - get_workspace_system_prompt=lambda: read_workspace_agents_prompt(self.workspace), - ) - loop = AgentLoop(router=router, model_runner=runner, tape=tape) - runtime = SessionRuntime(session_id=session_id, loop=loop, tape=tape, model_runner=runner, tool_view=tool_view) - self._sessions[session_id] = runtime - return runtime - - async def handle_input(self, session_id: str, text: str) -> LoopResult: - session = self.get_session(session_id) - task = asyncio.create_task(session.handle_input(text)) - self._active_inputs.add(task) - try: - return await task - finally: - self._active_inputs.discard(task) - - async def _cancel_active_inputs(self) -> int: - """Cancel all in-flight input tasks and return canceled count.""" - count = 0 - while self._active_inputs: - task = self._active_inputs.pop() - task.cancel() - with suppress(asyncio.CancelledError): - await task - count += 1 - return count - - def reset_session_context(self, session_id: str) -> None: - """Reset volatile context for an already-created session.""" - session = self._sessions.get(session_id) - if session is None: - return - session.reset_context() - - @contextlib.asynccontextmanager - async def graceful_shutdown(self) -> AsyncGenerator[asyncio.Event, None]: - """Run the runtime indefinitely with graceful shutdown.""" - stop_event = asyncio.Event() - loop = asyncio.get_running_loop() - handled_signals: list[signal.Signals] = [] - for sig in (signal.SIGINT, signal.SIGTERM): - try: - loop.add_signal_handler(sig, stop_event.set) - handled_signals.append(sig) - except (NotImplementedError, RuntimeError): - continue - current_task = asyncio.current_task() - future = asyncio.ensure_future(stop_event.wait()) - future.add_done_callback(lambda _, task=current_task: task and task.cancel()) # type: ignore[misc] - try: - yield stop_event - finally: - future.cancel() - cancelled = await self._cancel_active_inputs() - if cancelled: - logger.info("runtime.cancel_inflight count={}", cancelled) - for sig in handled_signals: - with suppress(NotImplementedError, RuntimeError): - loop.remove_signal_handler(sig) - - def install_hooks(self, channel_manager: ChannelManager) -> None: - """Install hooks for cross-cutting concerns like channel integration.""" - - hooks_module_str = os.getenv("BUB_HOOKS_MODULE") - if not hooks_module_str: - return - try: - module = importlib.import_module(hooks_module_str) - except ImportError as e: - raise ImportError(f"Failed to import hooks module '{hooks_module_str}'") from e - if not hasattr(module, "install"): - raise AttributeError(f"Hooks module '{hooks_module_str}' does not have an 'install' function") - hooks_context = SimpleNamespace( - runtime=self, - register_channel=channel_manager.register, - default_channels=channel_manager.default_channels, - ) - module.install(hooks_context) - - -def _normalize_name_set(raw: set[str] | None) -> set[str] | None: - if raw is None: - return None - - normalized = {name.strip().casefold() for name in raw if name.strip()} - return normalized or None diff --git a/src/bub/bus.py b/src/bub/bus.py new file mode 100644 index 00000000..deee6b7d --- /dev/null +++ b/src/bub/bus.py @@ -0,0 +1,50 @@ +"""Minimal async message bus used by Bub framework.""" + +from __future__ import annotations + +import asyncio +from typing import Protocol + +from bub.types import Envelope + + +class BusProtocol(Protocol): + """Minimal async contract for Bub bus providers.""" + + async def publish_inbound(self, message: Envelope) -> None: ... + + async def publish_outbound(self, message: Envelope) -> None: ... + + async def next_inbound(self, timeout_seconds: float | None = None) -> Envelope | None: ... + + async def next_outbound(self, timeout_seconds: float | None = None) -> Envelope | None: ... + + +class MessageBus: + """In-memory async bus for inbound/outbound envelopes.""" + + def __init__(self) -> None: + self._inbound: asyncio.Queue[Envelope] = asyncio.Queue() + self._outbound: asyncio.Queue[Envelope] = asyncio.Queue() + + async def publish_inbound(self, message: Envelope) -> None: + await self._inbound.put(message) + + async def publish_outbound(self, message: Envelope) -> None: + await self._outbound.put(message) + + async def next_inbound(self, timeout_seconds: float | None = None) -> Envelope | None: + if timeout_seconds is None: + return await self._inbound.get() + try: + return await asyncio.wait_for(self._inbound.get(), timeout=timeout_seconds) + except TimeoutError: + return None + + async def next_outbound(self, timeout_seconds: float | None = None) -> Envelope | None: + if timeout_seconds is None: + return await self._outbound.get() + try: + return await asyncio.wait_for(self._outbound.get(), timeout=timeout_seconds) + except TimeoutError: + return None diff --git a/src/bub/channels/__init__.py b/src/bub/channels/__init__.py deleted file mode 100644 index d45f044b..00000000 --- a/src/bub/channels/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Channel adapters and bus exports.""" - -from bub.channels.base import BaseChannel -from bub.channels.cli import CliChannel -from bub.channels.discord import DiscordChannel, DiscordConfig -from bub.channels.manager import ChannelManager -from bub.channels.telegram import TelegramChannel, TelegramConfig - -__all__ = [ - "BaseChannel", - "ChannelManager", - "CliChannel", - "DiscordChannel", - "DiscordConfig", - "TelegramChannel", - "TelegramConfig", -] diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py deleted file mode 100644 index 01b398fd..00000000 --- a/src/bub/channels/base.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Base channel interface.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any - -from bub.app.runtime import AppRuntime - -if TYPE_CHECKING: - from bub.core import LoopResult - - -def exclude_none(d: dict[str, Any]) -> dict[str, Any]: - return {k: v for k, v in d.items() if v is not None} - - -class BaseChannel[T](ABC): - """Abstract base class for channel adapters.""" - - name: str = "base" - - def __init__(self, runtime: AppRuntime) -> None: - self.runtime = runtime - - @abstractmethod - async def start(self, on_receive: Callable[[T], Awaitable[None]]) -> None: - """Start the channel and set up the receive callback.""" - - @property - def output_channel(self) -> str: - """The name of the channel to send outputs to. Defaults to the same channel.""" - return self.name - - @property - def debounce_enabled(self) -> bool: - """Whether inbound messages should be debounced before model execution.""" - return True - - @abstractmethod - def is_mentioned(self, message: T) -> bool: - """Determine if the message is relevant to this channel.""" - - @abstractmethod - async def get_session_prompt(self, message: T) -> tuple[str, str]: - """Get the session id and prompt text for the given message.""" - pass - - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - """Run the given prompt through the runtime and return the result.""" - return await self.runtime.handle_input(session_id, prompt) - - def format_prompt(self, prompt: str) -> str: - """Format accumulated prompt text before sending it to the runtime.""" - return f"channel: ${self.output_channel}\n{prompt}" - - @abstractmethod - async def process_output(self, session_id: str, output: LoopResult) -> None: - """Process the output returned by the LLM.""" - pass diff --git a/src/bub/channels/cli.py b/src/bub/channels/cli.py deleted file mode 100644 index 962f4be1..00000000 --- a/src/bub/channels/cli.py +++ /dev/null @@ -1,148 +0,0 @@ -"""CLI channel adapter.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from datetime import datetime -from hashlib import md5 -from pathlib import Path - -from loguru import logger -from prompt_toolkit import PromptSession -from prompt_toolkit.completion import WordCompleter -from prompt_toolkit.formatted_text import FormattedText -from prompt_toolkit.history import FileHistory -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.patch_stdout import patch_stdout -from rich import get_console - -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel -from bub.cli.render import CliRenderer -from bub.core.agent_loop import LoopResult - - -class CliChannel(BaseChannel[str]): - """Interactive terminal channel.""" - - name = "cli" - - def __init__(self, runtime: AppRuntime, *, session_id: str = "cli") -> None: - super().__init__(runtime) - self._session_id = session_id - self._session = runtime.get_session(session_id) - self._renderer = CliRenderer(get_console()) - self._mode = "agent" - self._last_tape_info: object | None = None - self._prompt = self._build_prompt() - self._stop_requested = False - - @property - def debounce_enabled(self) -> bool: - return False - - async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: - self._renderer.welcome(model=self.runtime.settings.model, workspace=str(self.runtime.workspace)) - await self._refresh_tape_info() - - while not self._stop_requested: - try: - with patch_stdout(raw=True): - raw = (await self._prompt.prompt_async(self._prompt_message())).strip() - except KeyboardInterrupt: - self._renderer.info("Interrupted. Use ',quit' to exit.") - continue - except EOFError: - break - - if not raw: - continue - - request = self._normalize_input(raw) - with self._renderer.console.status("[cyan]Processing...[/cyan]", spinner="dots"): - await on_receive(request) - - self._renderer.info("Bye.") - - def is_mentioned(self, message: str) -> bool: - _ = message - return True - - async def get_session_prompt(self, message: str) -> tuple[str, str]: - return self._session_id, message - - def format_prompt(self, prompt: str) -> str: - return prompt - - async def process_output(self, session_id: str, output: LoopResult) -> None: - _ = session_id - await self._refresh_tape_info() - if output.immediate_output: - self._renderer.command_output(output.immediate_output) - if output.error: - self._renderer.error(output.error) - if output.assistant_output: - self._renderer.assistant_output(output.assistant_output) - if output.exit_requested: - self._stop_requested = True - - async def _refresh_tape_info(self) -> None: - try: - self._last_tape_info = await self._session.tape.info() - except Exception as exc: - self._last_tape_info = None - logger.debug("cli.tape_info.unavailable session_id={} error={}", self._session_id, exc) - - def _build_prompt(self) -> PromptSession[str]: - kb = KeyBindings() - - @kb.add("c-x", eager=True) - def _toggle_mode(event) -> None: - self._mode = "shell" if self._mode == "agent" else "agent" - event.app.invalidate() - - def _tool_sort_key(tool_name: str) -> tuple[str, str]: - section, _, name = tool_name.rpartition(".") - return (section, name) - - history_file = self._history_file(self.runtime.settings.resolve_home(), self.runtime.workspace) - history_file.parent.mkdir(parents=True, exist_ok=True) - history = FileHistory(str(history_file)) - tool_names = sorted((f",{tool}" for tool in self._session.tool_view.all_tools()), key=_tool_sort_key) - completer = WordCompleter(tool_names, ignore_case=True) - return PromptSession( - completer=completer, - complete_while_typing=True, - key_bindings=kb, - history=history, - bottom_toolbar=self._render_bottom_toolbar, - ) - - def _prompt_message(self) -> FormattedText: - cwd = Path.cwd().name - symbol = ">" if self._mode == "agent" else "," - return FormattedText([("bold", f"{cwd} {symbol} ")]) - - def _render_bottom_toolbar(self) -> FormattedText: - info = self._last_tape_info - now = datetime.now().strftime("%H:%M") - left = f"{now} mode:{self._mode}" - right = ( - f"model:{self.runtime.settings.model} " - f"entries:{getattr(info, 'entries', '-')} " - f"anchors:{getattr(info, 'anchors', '-')} " - f"last:{getattr(info, 'last_anchor', None) or '-'}" - ) - return FormattedText([("", f"{left} {right}")]) - - def _normalize_input(self, raw: str) -> str: - if self._mode != "shell": - return raw - if raw.startswith(","): - return raw - return f", {raw}" - - @staticmethod - def _history_file(home: Path, workspace: Path) -> Path: - workspace_hash = md5(str(workspace).encode("utf-8")).hexdigest() # noqa: S324 - return home / "history" / f"{workspace_hash}.history" diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py deleted file mode 100644 index 5f53a758..00000000 --- a/src/bub/channels/manager.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Channel manager.""" - -from __future__ import annotations - -import asyncio -import contextlib -import functools - -from loguru import logger - -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel -from bub.channels.runner import SessionRunner - - -class ChannelManager: - """Coordinate inbound routing and outbound dispatch for channels.""" - - def __init__(self, runtime: AppRuntime, *, include_defaults: bool = True) -> None: - self.runtime = runtime - self._channels: dict[str, BaseChannel] = {} - self._channel_tasks: list[asyncio.Task[None]] = [] - self._session_runners: dict[str, SessionRunner] = {} - if include_defaults: - for channel_cls in self.default_channels(): - self.register(channel_cls) - runtime.install_hooks(self) - - def register[T: type[BaseChannel]](self, channel: T) -> T: - self.register_instance(channel(self.runtime)) - return channel - - def register_instance[T: BaseChannel](self, channel: T) -> T: - if channel.name in self._channels: - raise ValueError(f"channel '{channel.name}' already registered") - self._channels[channel.name] = channel - return channel - - @property - def channels(self) -> dict[str, BaseChannel]: - return dict(self._channels) - - async def run(self) -> None: - logger.info("channel.manager.start channels={}", self.enabled_channels()) - for channel in self._channels.values(): - task = asyncio.create_task(channel.start(functools.partial(self._process_input, channel))) - self._channel_tasks.append(task) - try: - await asyncio.gather(*self._channel_tasks) - finally: - for task in self._channel_tasks: - task.cancel() - with contextlib.suppress(asyncio.CancelledError, Exception): - await asyncio.gather(*self._channel_tasks) - self._channel_tasks.clear() - logger.info("channel.manager.stop") - - def enabled_channels(self) -> list[str]: - return sorted(self._channels) - - def default_channels(self) -> list[type[BaseChannel]]: - """Return the built-in channels.""" - result: list[type[BaseChannel]] = [] - - if self.runtime.settings.telegram_enabled: - from bub.channels.telegram import TelegramChannel - - result.append(TelegramChannel) - if self.runtime.settings.discord_enabled: - from bub.channels.discord import DiscordChannel - - result.append(DiscordChannel) - return result - - async def _process_input[T](self, channel: BaseChannel[T], message: T) -> None: - session_id, _ = await channel.get_session_prompt(message) - if session_id not in self._session_runners: - self._session_runners[session_id] = SessionRunner( - session_id, - self.runtime.settings.message_debounce_seconds, - self.runtime.settings.message_delay_seconds, - self.runtime.settings.active_time_window_seconds, - ) - await self._session_runners[session_id].process_message(channel, message) diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py deleted file mode 100644 index 757e6eef..00000000 --- a/src/bub/channels/telegram.py +++ /dev/null @@ -1,410 +0,0 @@ -"""Telegram channel adapter.""" - -from __future__ import annotations - -import asyncio -import contextlib -import json -from collections.abc import AsyncGenerator, Awaitable, Callable -from dataclasses import dataclass -from typing import Any, ClassVar - -from loguru import logger -from telegram import Message, Update -from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters - -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel, exclude_none -from bub.channels.utils import resolve_proxy -from bub.core.agent_loop import LoopResult - -NO_ACCESS_MESSAGE = "You are not allowed to chat with me. Please deploy your own instance of Bub." - - -def _message_type(message: Message) -> str: - if getattr(message, "text", None): - return "text" - if getattr(message, "photo", None): - return "photo" - if getattr(message, "audio", None): - return "audio" - if getattr(message, "sticker", None): - return "sticker" - if getattr(message, "video", None): - return "video" - if getattr(message, "voice", None): - return "voice" - if getattr(message, "document", None): - return "document" - if getattr(message, "video_note", None): - return "video_note" - return "unknown" - - -class BubMessageFilter(filters.MessageFilter): - GROUP_CHAT_TYPES: ClassVar[set[str]] = {"group", "supergroup"} - - def _content(self, message: Message) -> str: - return (getattr(message, "text", None) or getattr(message, "caption", None) or "").strip() - - def filter(self, message: Message) -> bool | dict[str, list[Any]] | None: - msg_type = _message_type(message) - if msg_type == "unknown": - return False - - # Private chat: process all non-command messages and bot commands. - if message.chat.type == "private": - return True - - # Group chat: only process when explicitly addressed to the bot. - if message.chat.type in self.GROUP_CHAT_TYPES: - bot = message.get_bot() - bot_id = bot.id - bot_username = (bot.username or "").lower() - - mentions_bot = self._mentions_bot(message, bot_id, bot_username) - reply_to_bot = self._is_reply_to_bot(message, bot_id) - - if msg_type != "text" and not getattr(message, "caption", None): - return reply_to_bot - - return mentions_bot or reply_to_bot - - return False - - def _mentions_bot(self, message: Message, bot_id: int, bot_username: str) -> bool: - content = self._content(message).lower() - mentions_by_keyword = "bub" in content or bool(bot_username and f"@{bot_username}" in content) - - entities = [*(getattr(message, "entities", None) or ()), *(getattr(message, "caption_entities", None) or ())] - for entity in entities: - if entity.type == "mention" and bot_username: - mention_text = content[entity.offset : entity.offset + entity.length] - if mention_text.lower() == f"@{bot_username}": - return True - continue - if entity.type == "text_mention" and entity.user and entity.user.id == bot_id: - return True - return mentions_by_keyword - - @staticmethod - def _is_reply_to_bot(message: Message, bot_id: int) -> bool: - reply_to_message = message.reply_to_message - if reply_to_message is None or reply_to_message.from_user is None: - return False - return reply_to_message.from_user.id == bot_id - - -MESSAGE_FILTER = BubMessageFilter() - - -@dataclass(frozen=True) -class TelegramConfig: - """Telegram adapter config.""" - - token: str - allow_from: set[str] - allow_chats: set[str] - proxy: str | None = None - - -class TelegramChannel(BaseChannel[Message]): - """Telegram adapter using long polling mode.""" - - name = "telegram" - - def __init__(self, runtime: AppRuntime) -> None: - super().__init__(runtime) - settings = runtime.settings - assert settings.telegram_token is not None # noqa: S101 - self._config = TelegramConfig( - token=settings.telegram_token, - allow_from=set(settings.telegram_allow_from), - allow_chats=set(settings.telegram_allow_chats), - proxy=settings.telegram_proxy, - ) - self._app: Application | None = None - self._on_receive: Callable[[Message], Awaitable[None]] | None = None - - def is_mentioned(self, message: Message) -> bool: - return bool(MESSAGE_FILTER.filter(message)) - - async def start(self, on_receive: Callable[[Message], Awaitable[None]]) -> None: - self._on_receive = on_receive - proxy, _ = resolve_proxy(self._config.proxy) - logger.info( - "telegram.start allow_from_count={} allow_chats_count={} proxy_enabled={}", - len(self._config.allow_from), - len(self._config.allow_chats), - bool(proxy), - ) - builder = Application.builder().token(self._config.token) - if proxy: - builder = builder.proxy(proxy).get_updates_proxy(proxy) - self._app = builder.build() - self._app.add_handler(CommandHandler("start", self._on_start)) - self._app.add_handler(CommandHandler("bub", self._on_text, has_args=True, block=False)) - self._app.add_handler(MessageHandler(~filters.COMMAND, self._on_text, block=False)) - await self._app.initialize() - await self._app.start() - updater = self._app.updater - if updater is None: - return - await updater.start_polling(drop_pending_updates=True, allowed_updates=["message"]) - logger.info("telegram.polling") - try: - await asyncio.Event().wait() # Keep running until stopped - finally: - updater = self._app.updater - with contextlib.suppress(Exception): - if updater is not None and updater.running: - await updater.stop() - await self._app.stop() - await self._app.shutdown() - self._app = None - logger.info("telegram.stopped") - - async def get_session_prompt(self, message: Message) -> tuple[str, str]: - chat_id = str(message.chat_id) - session_id = f"{self.name}:{chat_id}" - content, media = self._parse_message(message) - if content.startswith("/bub "): - content = content[5:] - - # Pass comma commands directly to the input handler - if content.strip().startswith(","): - return session_id, content - - metadata: dict[str, Any] = { - "message_id": message.message_id, - "type": _message_type(message), - "username": message.from_user.username if message.from_user else "", - "full_name": message.from_user.full_name if message.from_user else "", - "sender_id": str(message.from_user.id) if message.from_user else "", - "sender_is_bot": message.from_user.is_bot if message.from_user else None, - "date": message.date.timestamp() if message.date else None, - } - - if media: - metadata["media"] = media - caption = getattr(message, "caption", None) - if caption: - metadata["caption"] = caption - - reply_meta = self._extract_reply_metadata(message) - if reply_meta: - metadata["reply_to_message"] = reply_meta - - metadata_json = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) - return session_id, metadata_json - - async def process_output(self, session_id: str, output: LoopResult) -> None: - parts = [part for part in (output.immediate_output, output.assistant_output) if part] - if output.error: - parts.append(f"error: {output.error}") - content = "\n\n".join(parts).strip() - if not content: - return - logger.info("telegram.outbound session_id={} content={}", session_id, content) - send_back_text = [output.immediate_output] if output.immediate_output else [] - if not self.runtime.settings.proactive_response: - send_back_text.extend([output.assistant_output] if output.assistant_output else []) - # NOTE: assistant output is ignored intentionally to rely on the telegram skill to send messages proactively. - # Feel free to override this method to ensure response for every message received. - if output.error: - send_back_text.append(f"Error: {output.error}") - if send_back_text and self._app is not None: - await self._app.bot.send_message(chat_id=session_id.split(":", 1)[1], text="\n\n".join(send_back_text)) - - async def _on_start(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: - if update.message is None: - return - if self._config.allow_chats and str(update.message.chat_id) not in self._config.allow_chats: - await update.message.reply_text(NO_ACCESS_MESSAGE) - return - await update.message.reply_text("Bub is online. Send text to start.") - - async def _on_text(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: - if update.message is None or update.effective_user is None: - return - chat_id = str(update.message.chat_id) - if self._config.allow_chats and chat_id not in self._config.allow_chats: - return - user = update.effective_user - sender_tokens = {str(user.id)} - if user.username: - sender_tokens.add(user.username) - if self._config.allow_from and sender_tokens.isdisjoint(self._config.allow_from): - await update.message.reply_text("Access denied.") - return - - text, _ = self._parse_message(update.message) - if text.startswith("/bot ") or text.startswith("/bub "): - text = text[5:] - - if self._on_receive is None: - logger.warning("telegram.inbound no handler for received messages") - return - async with self._start_typing(chat_id): - await self._on_receive(update.message) - - @contextlib.asynccontextmanager - async def _start_typing(self, chat_id: str) -> AsyncGenerator[None, None]: - typing_task = asyncio.create_task(self._typing_loop(chat_id)) - try: - yield - finally: - typing_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await typing_task - - async def _typing_loop(self, chat_id: str) -> None: - try: - while self._app is not None: - await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing") - await asyncio.sleep(4) - except Exception: - logger.exception("telegram.typing_loop.error chat_id={}", chat_id) - return - - @classmethod - def _parse_message(cls, message: Message) -> tuple[str, dict[str, Any] | None]: - msg_type = _message_type(message) - if msg_type == "text": - return getattr(message, "text", None) or "", None - parser = cls._MEDIA_MESSAGE_PARSERS.get(msg_type) - if parser is not None: - return parser(message) - return "[Unknown message type]", None - - @staticmethod - def _parse_photo(message: Message) -> tuple[str, dict[str, Any] | None]: - caption = getattr(message, "caption", None) or "" - formatted = f"[Photo message] Caption: {caption}" if caption else "[Photo message]" - photos = getattr(message, "photo", None) or [] - if not photos: - return formatted, None - largest = photos[-1] - metadata = exclude_none({ - "file_id": largest.file_id, - "file_size": largest.file_size, - "width": largest.width, - "height": largest.height, - }) - return formatted, metadata - - @staticmethod - def _parse_audio(message: Message) -> tuple[str, dict[str, Any] | None]: - audio = getattr(message, "audio", None) - if audio is None: - return "[Audio]", None - title = audio.title or "Unknown" - performer = audio.performer or "" - duration = audio.duration or 0 - metadata = exclude_none({ - "file_id": audio.file_id, - "file_size": audio.file_size, - "duration": audio.duration, - "title": audio.title, - "performer": audio.performer, - }) - if performer: - return f"[Audio: {performer} - {title} ({duration}s)]", metadata - return f"[Audio: {title} ({duration}s)]", metadata - - @staticmethod - def _parse_sticker(message: Message) -> tuple[str, dict[str, Any] | None]: - sticker = getattr(message, "sticker", None) - if sticker is None: - return "[Sticker]", None - emoji = sticker.emoji or "" - set_name = sticker.set_name or "" - metadata = exclude_none({ - "file_id": sticker.file_id, - "width": sticker.width, - "height": sticker.height, - "emoji": sticker.emoji, - "set_name": sticker.set_name, - "is_animated": sticker.is_animated, - "is_video": sticker.is_video, - }) - if emoji: - return f"[Sticker: {emoji} from {set_name}]", metadata - return f"[Sticker from {set_name}]", metadata - - @staticmethod - def _parse_video(message: Message) -> tuple[str, dict[str, Any] | None]: - video = getattr(message, "video", None) - duration = video.duration if video else 0 - caption = getattr(message, "caption", None) or "" - formatted = f"[Video: {duration}s]" - formatted = f"{formatted} Caption: {caption}" if caption else formatted - if video is None: - return formatted, None - metadata = exclude_none({ - "file_id": video.file_id, - "file_size": video.file_size, - "width": video.width, - "height": video.height, - "duration": video.duration, - }) - return formatted, metadata - - @staticmethod - def _parse_voice(message: Message) -> tuple[str, dict[str, Any] | None]: - voice = getattr(message, "voice", None) - duration = voice.duration if voice else 0 - if voice is None: - return f"[Voice message: {duration}s]", None - metadata = exclude_none({"file_id": voice.file_id, "duration": voice.duration}) - return f"[Voice message: {duration}s]", metadata - - @staticmethod - def _parse_document(message: Message) -> tuple[str, dict[str, Any] | None]: - document = getattr(message, "document", None) - if document is None: - return "[Document]", None - file_name = document.file_name or "unknown" - mime_type = document.mime_type or "unknown" - caption = getattr(message, "caption", None) or "" - formatted = f"[Document: {file_name} ({mime_type})]" - formatted = f"{formatted} Caption: {caption}" if caption else formatted - metadata = exclude_none({ - "file_id": document.file_id, - "file_name": document.file_name, - "file_size": document.file_size, - "mime_type": document.mime_type, - }) - return formatted, metadata - - @staticmethod - def _parse_video_note(message: Message) -> tuple[str, dict[str, Any] | None]: - video_note = getattr(message, "video_note", None) - duration = video_note.duration if video_note else 0 - if video_note is None: - return f"[Video note: {duration}s]", None - metadata = exclude_none({"file_id": video_note.file_id, "duration": video_note.duration}) - return f"[Video note: {duration}s]", metadata - - @staticmethod - def _extract_reply_metadata(message: Message) -> dict[str, Any] | None: - reply_to = message.reply_to_message - if reply_to is None or reply_to.from_user is None: - return None - return exclude_none({ - "message_id": reply_to.message_id, - "from_user_id": reply_to.from_user.id, - "from_username": reply_to.from_user.username, - "from_is_bot": reply_to.from_user.is_bot, - "text": (reply_to.text or "")[:100] if reply_to.text else "", - }) - - _MEDIA_MESSAGE_PARSERS: ClassVar[dict[str, Callable[[Message], tuple[str, dict[str, Any] | None]]]] = { - "photo": _parse_photo, - "audio": _parse_audio, - "sticker": _parse_sticker, - "video": _parse_video, - "voice": _parse_voice, - "document": _parse_document, - "video_note": _parse_video_note, - } diff --git a/src/bub/cli.py b/src/bub/cli.py new file mode 100644 index 00000000..a572bc58 --- /dev/null +++ b/src/bub/cli.py @@ -0,0 +1,25 @@ +"""Bub framework CLI bootstrap.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from bub.framework import BubFramework + +app = typer.Typer(name="bub", help="Batteries-included, hook-first AI framework", add_completion=False) + + +def _load_cli_commands() -> None: + framework = BubFramework(Path.cwd()) + framework.load_skills() + framework.register_cli_commands(app) + + if not app.registered_commands: + @app.command("help") + def _help() -> None: + typer.echo("No CLI command skills loaded. Install a command skill in .agent/skills.") + + +_load_cli_commands() diff --git a/src/bub/cli/__init__.py b/src/bub/cli/__init__.py deleted file mode 100644 index abd2e698..00000000 --- a/src/bub/cli/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""CLI package export.""" - -from bub.cli.app import app - -__all__ = ["app"] diff --git a/src/bub/cli/app.py b/src/bub/cli/app.py deleted file mode 100644 index 31281585..00000000 --- a/src/bub/cli/app.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Typer CLI entrypoints.""" - -from __future__ import annotations - -import asyncio -import contextlib -import sys -from pathlib import Path -from typing import Annotated - -import typer -from loguru import logger - -from bub.app import build_runtime -from bub.app.runtime import AppRuntime -from bub.channels import ChannelManager, CliChannel -from bub.logging_utils import configure_logging - -app = typer.Typer(name="bub", help="Tape-first coding agent CLI", add_completion=False) - - -def _parse_subset(values: list[str] | None) -> set[str] | None: - if values is None: - return None - - names: set[str] = set() - for raw in values: - for part in raw.split(","): - name = part.strip() - if name: - names.add(name) - return names or None - - -@app.callback(invoke_without_command=True) -def _default(ctx: typer.Context) -> None: - if ctx.invoked_subcommand is None: - chat() - - -@app.command() -def chat( - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, - session_id: Annotated[str, typer.Option("--session-id", envvar="BUB_SESSION_ID")] = "cli", - disable_scheduler: Annotated[bool, typer.Option("--disable-scheduler", envvar="BUB_DISABLE_SCHEDULER")] = False, -) -> None: - """Run interactive CLI.""" - - configure_logging(profile="chat") - resolved_workspace = (workspace.expanduser() if workspace else Path.cwd()).resolve() - logger.info( - "chat.start workspace={} model={} max_tokens={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - ) - with build_runtime( - resolved_workspace, model=model, max_tokens=max_tokens, enable_scheduler=not disable_scheduler - ) as runtime: - manager = ChannelManager(runtime, include_defaults=False) - manager.register_instance(CliChannel(runtime, session_id=session_id)) - asyncio.run(_serve_channels(manager)) - - -@app.command() -def idle( - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, -) -> None: - """Start the scheduler only, this is a good option for running a completely autonomous agent.""" - from apscheduler.schedulers.blocking import BlockingScheduler - - from bub.app.jobstore import JSONJobStore - from bub.config.settings import load_settings - - configure_logging(profile="chat") - resolved_workspace = (workspace or Path.cwd()).resolve() - logger.info( - "idle.start workspace={} model={} max_tokens={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - ) - settings = load_settings(resolved_workspace) - job_store = JSONJobStore(settings.resolve_home() / "jobs.json") - scheduler = BlockingScheduler(jobstores={"default": job_store}) - try: - scheduler.start() - finally: - logger.info("idle.stop workspace={}", str(resolved_workspace)) - - -@app.command() -def run( - message: Annotated[str, typer.Argument()], - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, - session_id: Annotated[str, typer.Option("--session-id", envvar="BUB_SESSION_ID")] = "cli", - tools: Annotated[ - list[str] | None, - typer.Option( - "--tools", - help="Allowed tool names (repeatable or comma-separated, supports command and model names).", - ), - ] = None, - skills: Annotated[ - list[str] | None, - typer.Option( - "--skills", - help="Allowed skill names (repeatable or comma-separated).", - ), - ] = None, - disable_scheduler: Annotated[bool, typer.Option("--disable-scheduler", envvar="BUB_DISABLE_SCHEDULER")] = False, -) -> None: - """Run a single message and exit, useful for quick testing or one-off commands.""" - - configure_logging() - resolved_workspace = (workspace.expanduser() if workspace else Path.cwd()).resolve() - allowed_tools = _parse_subset(tools) - allowed_skills = _parse_subset(skills) - logger.info( - "run.start workspace={} model={} max_tokens={} allowed_tools={} allowed_skills={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - ",".join(sorted(allowed_tools)) if allowed_tools else "", - ",".join(sorted(allowed_skills)) if allowed_skills else "", - ) - with build_runtime( - resolved_workspace, - model=model, - max_tokens=max_tokens, - allowed_tools=allowed_tools, - allowed_skills=allowed_skills, - enable_scheduler=not disable_scheduler, - ) as runtime: - asyncio.run(_run_once(runtime, session_id, message)) - - -async def _run_once(runtime: AppRuntime, session_id: str, message: str) -> None: - import rich - - async with runtime.graceful_shutdown(): - try: - result = await runtime.handle_input(session_id, message) - if result.error: - rich.print(f"[red]Error:[/red] {result.error}", file=sys.stderr) - else: - rich.print(result.assistant_output or result.immediate_output or "") - except asyncio.CancelledError: - rich.print("[yellow]Operation interrupted.[/yellow]", file=sys.stderr) - - -@app.command() -def message( - workspace: Annotated[Path | None, typer.Option("--workspace", "-w")] = None, - model: Annotated[str | None, typer.Option("--model")] = None, - max_tokens: Annotated[int | None, typer.Option("--max-tokens")] = None, - proactive_response: Annotated[bool, typer.Option("--proactive-response", envvar="BUB_PROACTIVE_RESPONSE")] = False, -) -> None: - """Run message channels with the same agent loop runtime.""" - - configure_logging() - resolved_workspace = (workspace.expanduser() if workspace else Path.cwd()).resolve() - logger.info( - "message.start workspace={} model={} max_tokens={}, proactive_response={}", - str(resolved_workspace), - model or "", - max_tokens if max_tokens is not None else "", - proactive_response, - ) - - with build_runtime(resolved_workspace, model=model, max_tokens=max_tokens) as runtime: - runtime.settings.proactive_response = proactive_response - manager = ChannelManager(runtime) - asyncio.run(_serve_channels(manager)) - - -async def _serve_channels(manager: ChannelManager) -> None: - task = asyncio.create_task(manager.run()) - try: - async with manager.runtime.graceful_shutdown() as stop_event: - task.add_done_callback(lambda t: stop_event.set()) - await stop_event.wait() - except asyncio.CancelledError: - pass - finally: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - logger.info("channels.stop") - - -if __name__ == "__main__": - app() diff --git a/src/bub/cli/render.py b/src/bub/cli/render.py deleted file mode 100644 index c89981bc..00000000 --- a/src/bub/cli/render.py +++ /dev/null @@ -1,46 +0,0 @@ -"""CLI rendering helpers.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from rich.console import Console -from rich.panel import Panel -from rich.text import Text - - -@dataclass -class CliRenderer: - """Rich-based renderer for interactive CLI.""" - - console: Console - - def welcome(self, *, model: str, workspace: str) -> None: - body = ( - f"workspace: {workspace}\n" - f"model: {model}\n" - "internal command prefix: ','\n" - "shell command prefix: ',' at line start (Ctrl-X for shell mode)\n" - "type ',help' for command list" - ) - self.console.print(Panel(body, title="Bub", border_style="cyan")) - - def info(self, text: str) -> None: - if not text.strip(): - return - self.console.print(Text(text, style="bright_black")) - - def command_output(self, text: str) -> None: - if not text.strip(): - return - self.console.print(Panel(text, title="Command", border_style="green")) - - def assistant_output(self, text: str) -> None: - if not text.strip(): - return - self.console.print(Panel(text, title="Assistant", border_style="blue")) - - def error(self, text: str) -> None: - if not text.strip(): - return - self.console.print(Panel(text, title="Error", border_style="red")) diff --git a/src/bub/config/__init__.py b/src/bub/config/__init__.py deleted file mode 100644 index 88d4bd00..00000000 --- a/src/bub/config/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Configuration package.""" - -from bub.config.settings import Settings, load_settings - -__all__ = ["Settings", "load_settings"] diff --git a/src/bub/config/settings.py b/src/bub/config/settings.py deleted file mode 100644 index 3627aa36..00000000 --- a/src/bub/config/settings.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Application settings.""" - -from __future__ import annotations - -import os -from pathlib import Path - -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """Runtime settings loaded from environment and .env files.""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - env_prefix="BUB_", - case_sensitive=False, - extra="ignore", - env_parse_none_str="null", - ) - - model: str = "openrouter:qwen/qwen3-coder-next" - api_key: str | None = None - api_base: str | None = None - ollama_api_key: str | None = None - ollama_api_base: str | None = None - llm_api_key: str | None = Field(default=None, validation_alias="LLM_API_KEY") - openrouter_api_key: str | None = Field(default=None, validation_alias="OPENROUTER_API_KEY") - max_tokens: int = Field(default=1024, ge=1) - model_timeout_seconds: int | None = 90 - system_prompt: str = "" - - home: str | None = None - workspace_path: str | None = None - tape_name: str = "bub" - max_steps: int = Field(default=20, ge=1) - - proactive_response: bool = False - message_delay_seconds: int = 10 - message_debounce_seconds: int = 1 - active_time_window_seconds: int = 60 - - telegram_enabled: bool = False - telegram_token: str | None = None - telegram_allow_from: list[str] = Field(default_factory=list) - telegram_allow_chats: list[str] = Field(default_factory=list) - telegram_proxy: str | None = Field(default=None) - - discord_enabled: bool = False - discord_token: str | None = None - discord_allow_from: list[str] = Field(default_factory=list) - discord_allow_channels: list[str] = Field(default_factory=list) - discord_command_prefix: str = "!" - discord_proxy: str | None = None - - @property - def resolved_api_key(self) -> str | None: - if self.api_key: - return self.api_key - if self.llm_api_key: - return self.llm_api_key - if self.openrouter_api_key: - return self.openrouter_api_key - return os.getenv("LLM_API_KEY") or os.getenv("OPENROUTER_API_KEY") - - def resolve_home(self) -> Path: - if self.home: - return Path(self.home).expanduser().resolve() - return (Path.home() / ".bub").resolve() - - -def load_settings(workspace_path: Path | None = None) -> Settings: - """Load settings with optional workspace override.""" - - if workspace_path is None: - return Settings() - - return Settings(workspace_path=str(workspace_path.resolve())) diff --git a/src/bub/core/__init__.py b/src/bub/core/__init__.py deleted file mode 100644 index 587da740..00000000 --- a/src/bub/core/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Core runtime components.""" - -from bub.core.agent_loop import AgentLoop, LoopResult -from bub.core.model_runner import ModelRunner -from bub.core.router import CommandExecutionResult, InputRouter, UserRouteResult -from bub.core.types import HookContext - -__all__ = [ - "AgentLoop", - "CommandExecutionResult", - "HookContext", - "InputRouter", - "LoopResult", - "ModelRunner", - "UserRouteResult", -] diff --git a/src/bub/core/agent_loop.py b/src/bub/core/agent_loop.py deleted file mode 100644 index 5a686ee3..00000000 --- a/src/bub/core/agent_loop.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Forward-only agent loop.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from bub.core.model_runner import ModelRunner, ModelTurnResult -from bub.core.router import InputRouter -from bub.tape.service import TapeService - - -@dataclass(frozen=True) -class LoopResult: - """Loop output for one input turn.""" - - immediate_output: str - assistant_output: str - exit_requested: bool - steps: int - error: str | None = None - - -class AgentLoop: - """Deterministic single-session loop built on an endless tape.""" - - def __init__(self, *, router: InputRouter, model_runner: ModelRunner, tape: TapeService) -> None: - self._router = router - self._model_runner = model_runner - self._tape = tape - - async def handle_input(self, raw: str) -> LoopResult: - route = await self._router.route_user(raw) - if route.exit_requested: - return LoopResult( - immediate_output=route.immediate_output, - assistant_output="", - exit_requested=True, - steps=0, - error=None, - ) - - if not route.enter_model: - return LoopResult( - immediate_output=route.immediate_output, - assistant_output="", - exit_requested=False, - steps=0, - error=None, - ) - - model_result = await self._model_runner.run(route.model_prompt) - await self._record_result(model_result) - return LoopResult( - immediate_output=route.immediate_output, - assistant_output=model_result.visible_text, - exit_requested=model_result.exit_requested, - steps=model_result.steps, - error=model_result.error, - ) - - async def _record_result(self, result: ModelTurnResult) -> None: - await self._tape.append_event( - "loop.result", - { - "steps": result.steps, - "followups": result.command_followups, - "exit_requested": result.exit_requested, - "error": result.error, - }, - ) diff --git a/src/bub/core/command_detector.py b/src/bub/core/command_detector.py deleted file mode 100644 index 665a26cc..00000000 --- a/src/bub/core/command_detector.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Input command detection.""" - -from __future__ import annotations - -import re -import shutil - -from bub.core.commands import parse_command_words, parse_internal_command -from bub.core.types import DetectedCommand - -INTERNAL_PREFIX = "," -ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") -SAFE_PATH_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~/-]+$") -MAX_PATH_TOKEN_LENGTH = 240 - - -def detect_line_command(line: str) -> DetectedCommand | None: - """Detect whether one line should be treated as command.""" - - stripped = line.strip() - if not stripped: - return None - - if stripped.startswith(INTERNAL_PREFIX): - name, args_tokens = parse_internal_command(stripped) - if not name: - return None - return DetectedCommand(kind="internal", raw=stripped, name=name, args_tokens=args_tokens) - - if _is_shell_command(stripped): - words = parse_command_words(stripped) - if not words: - return None - command_name, args_tokens = _shell_command_parts(words) - return DetectedCommand(kind="shell", raw=stripped, name=command_name, args_tokens=args_tokens) - - return None - - -def _is_shell_command(line: str) -> bool: - words = parse_command_words(line) - if not words: - return False - - env_prefixed_command = _command_word_from_env_prefix(words) - if env_prefixed_command is not None: - return _is_path_like(env_prefixed_command) or shutil.which(env_prefixed_command) is not None - - first_word = words[0] - if _is_path_like(first_word): - return True - return shutil.which(first_word) is not None - - -def _is_path_like(token: str) -> bool: - if len(token) > MAX_PATH_TOKEN_LENGTH: - return False - if "://" in token: - return False - if any(ch in token for ch in ("\n", "\r", "\t", " ", '"', "'", "`", "=")): - return False - if SAFE_PATH_TOKEN_RE.fullmatch(token) is None: - return False - return token.startswith(("./", "../", "/", "~/")) or "/" in token - - -def _command_word_from_env_prefix(words: list[str]) -> str | None: - index = 0 - while index < len(words) and _is_env_assignment(words[index]): - index += 1 - if index == 0 or index >= len(words): - return None - return words[index] - - -def _is_env_assignment(token: str) -> bool: - if ENV_ASSIGN_RE.match(token) is None: - return False - _, value = token.split("=", 1) - if not value: - return False - return "\n" not in value and "\r" not in value and "\t" not in value - - -def _shell_command_parts(words: list[str]) -> tuple[str, list[str]]: - index = 0 - while index < len(words) and _is_env_assignment(words[index]): - index += 1 - if index < len(words): - return words[index], words[index + 1 :] - return words[0], words[1:] diff --git a/src/bub/core/commands.py b/src/bub/core/commands.py deleted file mode 100644 index ee8a8e09..00000000 --- a/src/bub/core/commands.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Command parsing helpers.""" - -from __future__ import annotations - -import shlex -from dataclasses import dataclass - - -@dataclass(frozen=True) -class ParsedArgs: - """Parsed command arguments.""" - - kwargs: dict[str, object] - positional: list[str] - - -def parse_command_words(text: str) -> list[str]: - """Split command text into words using shell rules.""" - - try: - return shlex.split(text) - except ValueError: - return [] - - -def parse_internal_command(line: str) -> tuple[str, list[str]]: - """Parse ',name ...' command line into name and args tokens.""" - - body = line.strip()[1:].strip() - words = parse_command_words(body) - if not words: - return "", [] - - return words[0], words[1:] - - -def parse_kv_arguments(tokens: list[str]) -> ParsedArgs: - """Parse tool arguments from tokens.""" - - kwargs: dict[str, object] = {} - positional: list[str] = [] - idx = 0 - while idx < len(tokens): - token = tokens[idx] - - if token.startswith("--"): - key = token[2:] - if "=" in key: - name, value = key.split("=", 1) - kwargs[name] = value - idx += 1 - continue - - if idx + 1 < len(tokens) and not tokens[idx + 1].startswith("--"): - kwargs[key] = tokens[idx + 1] - idx += 2 - continue - - kwargs[key] = True - idx += 1 - continue - - if "=" in token: - key, value = token.split("=", 1) - kwargs[key] = value - idx += 1 - continue - - positional.append(token) - idx += 1 - - return ParsedArgs(kwargs=kwargs, positional=positional) diff --git a/src/bub/core/model_runner.py b/src/bub/core/model_runner.py deleted file mode 100644 index cb2dc832..00000000 --- a/src/bub/core/model_runner.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Model turn runner.""" - -from __future__ import annotations - -import asyncio -import re -import textwrap -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import ClassVar - -from loguru import logger -from republic import Tool, ToolAutoResult - -from bub.core.router import AssistantRouteResult, InputRouter -from bub.skills.loader import SkillMetadata -from bub.skills.view import render_compact_skills -from bub.tape.service import TapeService -from bub.tools.progressive import ProgressiveToolView -from bub.tools.view import render_tool_prompt_block - -HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") -TOOL_CONTINUE_PROMPT = "Continue the task." - - -@dataclass(frozen=True) -class ModelTurnResult: - """Result of one model turn loop.""" - - visible_text: str - exit_requested: bool - steps: int - error: str | None = None - command_followups: int = 0 - - -@dataclass -class _PromptState: - prompt: str - step: int = 0 - followups: int = 0 - visible_parts: list[str] = field(default_factory=list) - error: str | None = None - exit_requested: bool = False - - -class ModelRunner: - """Runs assistant loop over tape with command-aware follow-up handling.""" - - DEFAULT_HEADERS: ClassVar[dict[str, str]] = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"} - - def __init__( - self, - *, - tape: TapeService, - router: InputRouter, - tool_view: ProgressiveToolView, - tools: list[Tool], - list_skills: Callable[[], list[SkillMetadata]], - model: str, - max_steps: int, - max_tokens: int, - model_timeout_seconds: int | None, - base_system_prompt: str, - get_workspace_system_prompt: Callable[[], str], - ) -> None: - self._tape = tape - self._router = router - self._tool_view = tool_view - self._tools = tools - self._list_skills = list_skills - self._model = model - self._max_steps = max_steps - self._max_tokens = max_tokens - self._model_timeout_seconds = model_timeout_seconds - self._base_system_prompt = base_system_prompt.strip() - self._get_workspace_system_prompt = get_workspace_system_prompt - self._expanded_skills: set[str] = set() - - def reset_context(self) -> None: - """Clear volatile model-side context caches within one session.""" - self._expanded_skills.clear() - - async def run(self, prompt: str) -> ModelTurnResult: - state = _PromptState(prompt=prompt) - self._activate_hints(prompt) - - while state.step < self._max_steps and not state.exit_requested: - state.step += 1 - logger.info("model.runner.step step={} model={}", state.step, self._model) - await self._tape.append_event( - "loop.step.start", - { - "step": state.step, - "model": self._model, - }, - ) - response = await self._chat(state.prompt) - if response.error is not None: - state.error = response.error - await self._tape.append_event( - "loop.step.error", - { - "step": state.step, - "error": response.error, - }, - ) - break - - if response.followup_prompt: - await self._tape.append_event( - "loop.step.finish", - { - "step": state.step, - "visible_text": False, - "followup": True, - "exit_requested": False, - }, - ) - state.prompt = response.followup_prompt - state.followups += 1 - continue - - assistant_text = response.text - if not assistant_text.strip(): - await self._tape.append_event("loop.step.empty", {"step": state.step}) - break - - self._activate_hints(assistant_text) - route = await self._router.route_assistant(assistant_text) - await self._consume_route(state, route) - if not route.next_prompt: - break - state.prompt = route.next_prompt - state.followups += 1 - - if state.step >= self._max_steps and not state.error: - state.error = f"max_steps_reached={self._max_steps}" - await self._tape.append_event("loop.max_steps", {"max_steps": self._max_steps}) - - return ModelTurnResult( - visible_text="\n\n".join(part for part in state.visible_parts if part).strip(), - exit_requested=state.exit_requested, - steps=state.step, - error=state.error, - command_followups=state.followups, - ) - - async def _consume_route(self, state: _PromptState, route: AssistantRouteResult) -> None: - if route.visible_text: - state.visible_parts.append(route.visible_text) - if route.exit_requested: - state.exit_requested = True - await self._tape.append_event( - "loop.step.finish", - { - "step": state.step, - "visible_text": bool(route.visible_text), - "followup": bool(route.next_prompt), - "exit_requested": route.exit_requested, - }, - ) - - async def _chat(self, prompt: str) -> _ChatResult: - system_prompt = self._render_system_prompt() - try: - async with asyncio.timeout(self._model_timeout_seconds): - provider, _, _ = self._model.partition(":") - if provider.casefold() == "vertexai": - output = await self._tape.tape.run_tools_async( - prompt=prompt, - system_prompt=system_prompt, - max_tokens=self._max_tokens, - tools=self._tools, - http_options={"headers": self.DEFAULT_HEADERS}, - ) - else: - output = await self._tape.tape.run_tools_async( - prompt=prompt, - system_prompt=system_prompt, - max_tokens=self._max_tokens, - tools=self._tools, - extra_headers=self.DEFAULT_HEADERS, - ) - return _ChatResult.from_tool_auto(output) - except TimeoutError: - return _ChatResult( - text="", - error=f"model_timeout: no response within {self._model_timeout_seconds}s", - ) - except Exception as exc: - logger.exception("model.call.error") - return _ChatResult(text="", error=f"model_call_error: {exc!s}") - - def _render_system_prompt(self) -> str: - blocks: list[str] = [] - if self._base_system_prompt: - blocks.append(self._base_system_prompt) - if workspace_system_prompt := self._get_workspace_system_prompt(): - blocks.append(workspace_system_prompt) - blocks.append(render_tool_prompt_block(self._tool_view)) - compact_skills = render_compact_skills(self._list_skills(), self._expanded_skills) - if compact_skills: - blocks.append(compact_skills) - blocks.append(_runtime_contract()) - return "\n\n".join(block for block in blocks if block.strip()) - - def _activate_hints(self, text: str) -> None: - skill_index = self._build_skill_index() - for match in HINT_RE.finditer(text): - hint = match.group(1) - self._tool_view.note_hint(hint) - - skill = skill_index.get(hint.casefold()) - if skill is None: - continue - self._expanded_skills.add(skill.name) - - def _build_skill_index(self) -> dict[str, SkillMetadata]: - return {skill.name.casefold(): skill for skill in self._list_skills()} - - -@dataclass(frozen=True) -class _ChatResult: - text: str - error: str | None = None - followup_prompt: str | None = None - - @classmethod - def from_tool_auto(cls, output: ToolAutoResult) -> _ChatResult: - if output.kind == "text": - return cls(text=output.text or "") - if output.kind == "tools": - return cls(text="", followup_prompt=TOOL_CONTINUE_PROMPT) - - if output.tool_calls or output.tool_results: - return cls(text="", followup_prompt=TOOL_CONTINUE_PROMPT) - - if output.error is None: - return cls(text="", error="tool_auto_error: unknown") - return cls(text="", error=f"{output.error.kind.value}: {output.error.message}") - - -def _runtime_contract() -> str: - return textwrap.dedent("""\ - - 1. Use tool calls for all actions (file ops, shell, web, tape, skills). - 2. Do not emit comma-prefixed commands in normal flow; use tool calls instead. - 3. If a compatibility fallback is required, runtime can still parse comma commands. - 4. Never emit '' blocks yourself; those are runtime-generated. - 5. When enough evidence is collected, return plain natural language answer. - 6. Use '$name' hints to request detail expansion for tools/skills when needed. - - - Excessively long context may cause model call failures. In this case, you SHOULD first use tape.handoff tool to shorten the length of the retrieved history. - - - You MUST send message to the corresponding channel before finish when you want to respond. - Route your response to the same channel the message came from. - There is a skill named `{channel}` for each channel that you need to figure out how to send a response to that channel. - ## Before finishing ANY response to a channel message: - 1. Identify the source channel from the user message metadata - 2. Prepare your response text - 3. Call the corresponding channel skill to deliver the message - 4. ONLY THEN end your turn - """) diff --git a/src/bub/core/router.py b/src/bub/core/router.py deleted file mode 100644 index 866515da..00000000 --- a/src/bub/core/router.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Routing and command execution.""" - -from __future__ import annotations - -import json -import time -from dataclasses import dataclass -from html import escape -from pathlib import Path -from typing import Any - -from republic import ToolContext - -from bub.core.commands import ParsedArgs, parse_command_words, parse_internal_command, parse_kv_arguments -from bub.core.types import DetectedCommand -from bub.tape.service import TapeService -from bub.tools.progressive import ProgressiveToolView -from bub.tools.registry import ToolRegistry - - -@dataclass(frozen=True) -class CommandExecutionResult: - """Result of one command execution.""" - - command: str - name: str - status: str - output: str - elapsed_ms: int - - def block(self) -> str: - # Escape command payload so tool output cannot close or forge command tags. - safe_name = escape(self.name, quote=True) - safe_status = escape(self.status, quote=True) - safe_output = escape(self.output, quote=False) - return f'\n{safe_output}\n' - - -@dataclass(frozen=True) -class UserRouteResult: - """Routing outcome for user input.""" - - enter_model: bool - model_prompt: str - immediate_output: str - exit_requested: bool - - -@dataclass(frozen=True) -class AssistantRouteResult: - """Routing outcome for assistant output.""" - - visible_text: str - next_prompt: str - exit_requested: bool - - -class InputRouter: - """Command-aware router used by both user and model outputs.""" - - def __init__( - self, - registry: ToolRegistry, - tool_view: ProgressiveToolView, - tape: TapeService, - workspace: Path, - ) -> None: - self._registry = registry - self._tool_view = tool_view - self._tape = tape - self._workspace = workspace - - async def route_user(self, raw: str) -> UserRouteResult: - stripped = raw.strip() - if not stripped: - return UserRouteResult(enter_model=False, model_prompt="", immediate_output="", exit_requested=False) - command = self._parse_comma_prefixed_command(stripped) - if command is None: - return UserRouteResult(enter_model=True, model_prompt=stripped, immediate_output="", exit_requested=False) - - result = await self._execute_command(command, origin="human") - if result.status == "ok" and result.name != "bash": - if result.name == "quit" and result.output == "exit": - return UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output="", - exit_requested=True, - ) - return UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output=result.output, - exit_requested=False, - ) - - if result.status == "ok" and result.name == "bash": - return UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output=result.output, - exit_requested=False, - ) - - # Failed command falls back to model with command block context. - return UserRouteResult( - enter_model=True, - model_prompt=result.block(), - immediate_output=result.output, - exit_requested=False, - ) - - async def route_assistant(self, raw: str) -> AssistantRouteResult: - visible_lines: list[str] = [] - command_blocks: list[str] = [] - exit_requested = False - in_fence = False - pending_command_lines: list[str] = [] - pending_source_lines: list[str] = [] - - for line in raw.splitlines(): - stripped = line.strip() - if not stripped: - continue - - if stripped.startswith("```"): - if in_fence: - exit_requested = ( - await self._flush_pending_assistant_command( - pending_command_lines=pending_command_lines, - pending_source_lines=pending_source_lines, - visible_lines=visible_lines, - command_blocks=command_blocks, - ) - or exit_requested - ) - in_fence = not in_fence - continue - - if in_fence: - shell_candidate = self._parse_comma_prefixed_command(stripped) - if shell_candidate is not None and shell_candidate.kind == "shell": - exit_requested = ( - await self._flush_pending_assistant_command( - pending_command_lines=pending_command_lines, - pending_source_lines=pending_source_lines, - visible_lines=visible_lines, - command_blocks=command_blocks, - ) - or exit_requested - ) - pending_command_lines.append(shell_candidate.raw) - pending_source_lines.append(line) - continue - if pending_command_lines: - pending_command_lines.append(line) - pending_source_lines.append(line) - continue - visible_lines.append(line) - continue - - command = self._parse_comma_prefixed_command(stripped) - if command is None: - visible_lines.append(line) - continue - - exit_requested = await self._execute_assistant_command(command, command_blocks) or exit_requested - - exit_requested = ( - await self._flush_pending_assistant_command( - pending_command_lines=pending_command_lines, - pending_source_lines=pending_source_lines, - visible_lines=visible_lines, - command_blocks=command_blocks, - ) - or exit_requested - ) - visible_text = "\n".join(visible_lines).strip() - if command_blocks: - # Hide execution-phase chatter and keep only post-execution assistant answers. - visible_text = "" - next_prompt = "\n".join(command_blocks).strip() - return AssistantRouteResult( - visible_text=visible_text, - next_prompt=next_prompt, - exit_requested=exit_requested, - ) - - async def _execute_assistant_command(self, command: DetectedCommand, command_blocks: list[str]) -> bool: - result = await self._execute_command(command, origin="assistant") - command_blocks.append(result.block()) - return result.name == "quit" and result.status == "ok" and result.output == "exit" - - async def _flush_pending_assistant_command( - self, - *, - pending_command_lines: list[str], - pending_source_lines: list[str], - visible_lines: list[str], - command_blocks: list[str], - ) -> bool: - if not pending_command_lines: - return False - - command_text = "\n".join(pending_command_lines).strip() - words = parse_command_words(command_text) - command = ( - DetectedCommand(kind="shell", raw=command_text, name=words[0], args_tokens=words[1:]) if words else None - ) - pending_command_lines.clear() - source_lines = list(pending_source_lines) - pending_source_lines.clear() - - if command is None: - visible_lines.extend(source_lines) - return False - return await self._execute_assistant_command(command, command_blocks) - - def _parse_comma_prefixed_command(self, stripped: str) -> DetectedCommand | None: - if not stripped.startswith(","): - return None - body = stripped[1:].lstrip() - if not body: - return None - name, args_tokens = parse_internal_command(stripped) - if name: - resolved = self._resolve_internal_name(name) - if self._registry.has(resolved): - return DetectedCommand(kind="internal", raw=stripped, name=name, args_tokens=args_tokens) - - words = parse_command_words(body) - if not words: - return None - return DetectedCommand(kind="shell", raw=body, name=words[0], args_tokens=words[1:]) - - async def _execute_command(self, command: DetectedCommand, *, origin: str) -> CommandExecutionResult: - start = time.time() - - if command.kind == "shell": - return await self._execute_shell(command, origin=origin, start=start) - return await self._execute_internal(command, origin=origin, start=start) - - async def _execute_shell(self, command: DetectedCommand, *, origin: str, start: float) -> CommandExecutionResult: - elapsed_ms: int - state = self._tape.tape.context.state - context = ToolContext(self._tape.tape.name, "execute_internal", state=state) - try: - output = await self._registry.execute( - "bash", - kwargs={ - "cmd": command.raw, - "cwd": str(self._workspace), - }, - context=context, - ) - status = "ok" - text = str(output) - except Exception as exc: - status = "error" - text = f"{exc!s}" - - elapsed_ms = int((time.time() - start) * 1000) - await self._record_command(command=command, status=status, output=text, elapsed_ms=elapsed_ms, origin=origin) - return CommandExecutionResult( - command=command.raw, - name="bash", - status=status, - output=text, - elapsed_ms=elapsed_ms, - ) - - async def _execute_internal(self, command: DetectedCommand, *, origin: str, start: float) -> CommandExecutionResult: - name = self._resolve_internal_name(command.name) - parsed_args = parse_kv_arguments(command.args_tokens) - - if name == "tool.describe" and parsed_args.positional and "name" not in parsed_args.kwargs: - parsed_args.kwargs["name"] = parsed_args.positional[0] - - if name == "handoff": - self._inject_default_handoff_name(parsed_args) - - if self._registry.has(name) is False: - elapsed_ms = int((time.time() - start) * 1000) - text = f"unknown internal command: {command.name}" - await self._record_command( - command=command, - status="error", - output=text, - elapsed_ms=elapsed_ms, - origin=origin, - ) - return CommandExecutionResult( - command=command.raw, - name=name, - status="error", - output=text, - elapsed_ms=elapsed_ms, - ) - - state = self._tape.tape.context.state - context = ToolContext(self._tape.tape.name, "execute_internal", state=state) - try: - output = await self._registry.execute(name, kwargs=dict(parsed_args.kwargs), context=context) - status = "ok" - text = str(output) - if name == "tool.describe": - described = parsed_args.kwargs.get("name") - if isinstance(described, str): - self._tool_view.note_selected(described) - elif name not in {"help", "tools"}: - self._tool_view.note_selected(name) - except Exception as exc: - status = "error" - text = f"{exc!s}" - - elapsed_ms = int((time.time() - start) * 1000) - await self._record_command(command=command, status=status, output=text, elapsed_ms=elapsed_ms, origin=origin) - return CommandExecutionResult( - command=command.raw, - name=name, - status=status, - output=text, - elapsed_ms=elapsed_ms, - ) - - @staticmethod - def _resolve_internal_name(name: str) -> str: - aliases = { - "tool": "tool.describe", - "tape": "tape.info", - } - return aliases.get(name, name) - - @staticmethod - def _inject_default_handoff_name(parsed_args: ParsedArgs) -> None: - if "name" in parsed_args.kwargs: - return - if parsed_args.positional: - parsed_args.kwargs["name"] = parsed_args.positional[0] - else: - parsed_args.kwargs["name"] = "handoff" - - async def _record_command( - self, - *, - command: DetectedCommand, - status: str, - output: str, - elapsed_ms: int, - origin: str, - ) -> None: - await self._tape.append_event( - "command", - { - "origin": origin, - "kind": command.kind, - "raw": command.raw, - "name": command.name, - "status": status, - "elapsed_ms": elapsed_ms, - "output": output, - }, - ) - - def render_failure_context(self, result: CommandExecutionResult) -> str: - return result.block() - - @staticmethod - def to_json(data: Any) -> str: - return json.dumps(data, ensure_ascii=False) diff --git a/src/bub/core/types.py b/src/bub/core/types.py deleted file mode 100644 index 0f39440c..00000000 --- a/src/bub/core/types.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Shared core dataclasses.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: - from bub.app.runtime import AppRuntime - from bub.channels.base import BaseChannel - - -@dataclass(frozen=True) -class DetectedCommand: - """Detected command parsed from a line.""" - - kind: str # internal|shell - raw: str - name: str - args_tokens: list[str] = field(default_factory=list) - - -@dataclass(frozen=True) -class ParsedAssistantMessage: - """Assistant output split between text and command lines.""" - - visible_lines: list[str] - commands: list[DetectedCommand] - - -class HookContext(Protocol): - """Context object passed to hooks.""" - - runtime: AppRuntime - - def register_channel(self, channel: type[BaseChannel]) -> None: - """Register a custom channel.""" - - def default_channels(self) -> list[type[BaseChannel]]: - """Return the default channels to be registered.""" - ... diff --git a/src/bub/envelope.py b/src/bub/envelope.py new file mode 100644 index 00000000..59122e6e --- /dev/null +++ b/src/bub/envelope.py @@ -0,0 +1,42 @@ +"""Utilities for reading and normalizing user-defined envelopes.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from bub.types import Envelope + + +def field_of(message: Envelope, key: str, default: Any = None) -> Any: + """Read a field from mapping-like or attribute-based messages.""" + + if isinstance(message, Mapping): + return message.get(key, default) + return getattr(message, key, default) + + +def content_of(message: Envelope) -> str: + """Get textual content from any envelope shape.""" + + return str(field_of(message, "content", "")) + + +def normalize_envelope(message: Envelope) -> dict[str, Any]: + """Convert arbitrary message objects to a mutable envelope mapping.""" + + if isinstance(message, Mapping): + return dict(message) + if hasattr(message, "__dict__"): + return dict(vars(message)) + return {"content": str(message)} + + +def unpack_batch(batch: Any) -> list[Envelope]: + """Normalize one render_outbound return value to a list of envelopes.""" + + if batch is None: + return [] + if isinstance(batch, (list, tuple)): + return list(batch) + return [batch] diff --git a/src/bub/framework.py b/src/bub/framework.py new file mode 100644 index 00000000..1d05a772 --- /dev/null +++ b/src/bub/framework.py @@ -0,0 +1,187 @@ +"""Hook-first Bub framework runtime.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast + +import pluggy +from loguru import logger + +from bub.bus import BusProtocol, MessageBus +from bub.envelope import content_of, field_of, unpack_batch +from bub.hook_runtime import HookRuntime +from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs +from bub.skills.loader import SkillMetadata, discover_hook_skills, load_skill_plugin +from bub.types import Envelope, TurnResult + +SOURCE_PRIORITY = {"builtin": 0, "global": 1, "project": 2} + + +@dataclass(frozen=True) +class LoadedSkill: + """Runtime registration result for one skill.""" + + skill: SkillMetadata + plugin_name: str + + +class BubFramework: + """Minimal framework core. Everything grows from hook skills.""" + + def __init__(self, workspace: Path) -> None: + self.workspace = workspace.resolve() + self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) + self._plugin_manager.add_hookspecs(BubHookSpecs) + self._hook_runtime = HookRuntime(self._plugin_manager) + self._loaded_skills: list[LoadedSkill] = [] + self._failed_skills: dict[str, str] = {} + + @property + def loaded_skills(self) -> list[LoadedSkill]: + return list(self._loaded_skills) + + @property + def failed_skills(self) -> dict[str, str]: + return dict(self._failed_skills) + + def load_skills(self) -> None: + """Discover and register all hook skills.""" + + self._loaded_skills = [] + self._failed_skills = {} + + skills = sorted(discover_hook_skills(self.workspace), key=self._registration_order_key) + for skill in skills: + plugin_name = f"{skill.source}:{skill.name}" + try: + plugin = load_skill_plugin(skill) + self._plugin_manager.register(plugin, name=plugin_name) + self._loaded_skills.append(LoadedSkill(skill=skill, plugin_name=plugin_name)) + except Exception as exc: # pragma: no cover - exercised via behavior tests + self._failed_skills[skill.name] = str(exc) + logger.opt(exception=True).warning("skill.load_failed skill={} source={}", skill.name, skill.source) + + def create_bus(self) -> BusProtocol: + """Create bus instance from hooks; fallback to default in-memory bus.""" + + provided = self._hook_runtime.call_first_sync("provide_bus") + if self._is_bus_like(provided): + return cast(BusProtocol, provided) + return MessageBus() + + def register_cli_commands(self, app: Any) -> None: + """Ask skills to register CLI commands.""" + + self._hook_runtime.call_many_sync("register_cli_commands", app=app) + + async def process_inbound(self, inbound: Envelope) -> TurnResult: + """Run one inbound message through hooks and return turn result.""" + + try: + normalized = await self._hook_runtime.call_first("normalize_inbound", message=inbound) + message = normalized if normalized is not None else inbound + session_id = await self._hook_runtime.call_first("resolve_session", message=message) or self._default_session_id( + message + ) + state = await self._hook_runtime.call_first("load_state", session_id=session_id) or {} + if not isinstance(state, dict): + state = {} + prompt = await self._hook_runtime.call_first("build_prompt", message=message, session_id=session_id, state=state) + if not prompt: + prompt = content_of(message) + model_output = await self._hook_runtime.call_first("run_model", prompt=prompt, session_id=session_id, state=state) + if model_output is None: + await self._hook_runtime.notify_error( + stage="run_model:fallback", + error=RuntimeError("no model skill returned output"), + message=message, + ) + model_output = prompt + else: + model_output = str(model_output) + + await self._hook_runtime.call_many( + "save_state", + session_id=session_id, + state=state, + message=message, + model_output=model_output, + ) + outbounds = await self._collect_outbounds(message, session_id, state, model_output) + for outbound in outbounds: + await self._hook_runtime.call_many("dispatch_outbound", message=outbound) + return TurnResult(session_id=session_id, prompt=prompt, model_output=model_output, outbounds=outbounds) + except Exception as exc: + await self._hook_runtime.notify_error(stage="turn", error=exc, message=inbound) + raise + + async def handle_bus_once(self, bus: BusProtocol | None = None, *, timeout_seconds: float | None = None) -> TurnResult | None: + """Consume one inbound message from bus and publish generated outbounds.""" + + active_bus = bus or self.create_bus() + inbound = await active_bus.next_inbound(timeout_seconds=timeout_seconds) + if inbound is None: + return None + result = await self.process_inbound(inbound) + for outbound in result.outbounds: + await active_bus.publish_outbound(outbound) + return result + + def hook_report(self) -> dict[str, list[str]]: + """Return hook implementation summary for diagnostics.""" + + return self._hook_runtime.hook_report() + + @staticmethod + def _default_session_id(message: Envelope) -> str: + session_id = field_of(message, "session_id") + if session_id is not None: + return str(session_id) + channel = str(field_of(message, "channel", "default")) + chat_id = str(field_of(message, "chat_id", "default")) + return f"{channel}:{chat_id}" + + async def _collect_outbounds( + self, + message: Envelope, + session_id: str, + state: dict[str, Any], + model_output: str, + ) -> list[Envelope]: + batches = await self._hook_runtime.call_many( + "render_outbound", + message=message, + session_id=session_id, + state=state, + model_output=model_output, + ) + outbounds: list[Envelope] = [] + for batch in batches: + outbounds.extend(unpack_batch(batch)) + if outbounds: + return outbounds + + fallback: dict[str, Any] = { + "content": model_output, + "session_id": session_id, + } + channel = field_of(message, "channel") + chat_id = field_of(message, "chat_id") + if channel is not None: + fallback["channel"] = channel + if chat_id is not None: + fallback["chat_id"] = chat_id + return [fallback] + + @staticmethod + def _registration_order_key(skill: SkillMetadata) -> tuple[int, str]: + return (SOURCE_PRIORITY.get(skill.source, 3), skill.name.casefold()) + + @staticmethod + def _is_bus_like(candidate: Any) -> bool: + if candidate is None: + return False + required = ("publish_inbound", "publish_outbound", "next_inbound", "next_outbound") + return all(callable(getattr(candidate, name, None)) for name in required) diff --git a/src/bub/hook_runtime.py b/src/bub/hook_runtime.py new file mode 100644 index 00000000..d33a3695 --- /dev/null +++ b/src/bub/hook_runtime.py @@ -0,0 +1,179 @@ +"""Hook execution runtime with per-plugin fault isolation.""" + +from __future__ import annotations + +import inspect +from typing import Any + +import pluggy +from loguru import logger + +from bub.types import Envelope + + +class HookRuntime: + """Safe wrapper around pluggy hook execution.""" + + def __init__(self, plugin_manager: pluggy.PluginManager) -> None: + self._plugin_manager = plugin_manager + + async def call_first(self, hook_name: str, **kwargs: Any) -> Any: + """Run hook implementations in precedence order and return first non-None value.""" + + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = await self._invoke_impl_async(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + if value is not None: + return value + return None + + async def call_many(self, hook_name: str, **kwargs: Any) -> list[Any]: + """Run all implementations and collect successful return values.""" + + results: list[Any] = [] + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = await self._invoke_impl_async(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + if value is _SKIP_VALUE: + continue + results.append(value) + return results + + def call_first_sync(self, hook_name: str, **kwargs: Any) -> Any: + """Synchronous variant of call_first for bootstrap hooks.""" + + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = self._invoke_impl_sync(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + if value is _SKIP_VALUE: + continue + if value is not None: + return value + return None + + def call_many_sync(self, hook_name: str, **kwargs: Any) -> list[Any]: + """Synchronous variant of call_many for bootstrap hooks.""" + + results: list[Any] = [] + for impl in self._iter_hookimpls(hook_name): + call_kwargs = self._kwargs_for_impl(impl, kwargs) + value = self._invoke_impl_sync(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + if value is _SKIP_VALUE: + continue + results.append(value) + return results + + async def notify_error(self, *, stage: str, error: Exception, message: Envelope | None) -> None: + """Call on_error hooks, swallowing observer failures.""" + + for impl in self._iter_hookimpls("on_error"): + call_kwargs = self._kwargs_for_impl(impl, {"stage": stage, "error": error, "message": message}) + try: + value = impl.function(**call_kwargs) + if inspect.isawaitable(value): + await value + except Exception: + logger.opt(exception=True).warning( + "hook.on_error_failed stage={} plugin={}", + stage, + impl.plugin_name or "", + ) + + def notify_error_sync(self, *, stage: str, error: Exception, message: Envelope | None) -> None: + """Synchronous on_error dispatch for bootstrap paths.""" + + for impl in self._iter_hookimpls("on_error"): + call_kwargs = self._kwargs_for_impl(impl, {"stage": stage, "error": error, "message": message}) + try: + value = impl.function(**call_kwargs) + except Exception: + logger.opt(exception=True).warning( + "hook.on_error_failed stage={} plugin={}", + stage, + impl.plugin_name or "", + ) + continue + if inspect.isawaitable(value): + logger.warning( + "hook.async_not_supported hook=on_error plugin={}", + impl.plugin_name or "", + ) + + def hook_report(self) -> dict[str, list[str]]: + """Build a hook->plugins mapping for diagnostics.""" + + report: dict[str, list[str]] = {} + for hook_name, hook_caller in sorted(self._plugin_manager.hook.__dict__.items()): + if hook_name.startswith("_") or not hasattr(hook_caller, "get_hookimpls"): + continue + plugin_names = [impl.plugin_name for impl in hook_caller.get_hookimpls()] + if plugin_names: + report[hook_name] = plugin_names + return report + + async def _invoke_impl_async( + self, + *, + hook_name: str, + impl: Any, + call_kwargs: dict[str, Any], + kwargs: dict[str, Any], + ) -> Any: + try: + value = impl.function(**call_kwargs) + if inspect.isawaitable(value): + value = await value + except Exception as error: + await self.notify_error( + stage=f"{hook_name}:{impl.plugin_name or ''}", + error=error, + message=_message_from_kwargs(kwargs), + ) + return _SKIP_VALUE + return value + + def _invoke_impl_sync( + self, + *, + hook_name: str, + impl: Any, + call_kwargs: dict[str, Any], + kwargs: dict[str, Any], + ) -> Any: + try: + value = impl.function(**call_kwargs) + except Exception as error: + self.notify_error_sync( + stage=f"{hook_name}:{impl.plugin_name or ''}", + error=error, + message=_message_from_kwargs(kwargs), + ) + return _SKIP_VALUE + if inspect.isawaitable(value): + logger.warning( + "hook.async_not_supported hook={} plugin={}", + hook_name, + impl.plugin_name or "", + ) + return _SKIP_VALUE + return value + + def _iter_hookimpls(self, hook_name: str) -> list[Any]: + hook = getattr(self._plugin_manager.hook, hook_name, None) + if hook is None or not hasattr(hook, "get_hookimpls"): + return [] + return list(reversed(hook.get_hookimpls())) + + @staticmethod + def _kwargs_for_impl(impl: Any, kwargs: dict[str, Any]) -> dict[str, Any]: + return {name: kwargs[name] for name in impl.argnames if name in kwargs} + + +def _message_from_kwargs(kwargs: dict[str, Any]) -> Envelope | None: + message = kwargs.get("message") + if message is None: + return None + return message + + +_SKIP_VALUE = object() diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py new file mode 100644 index 00000000..dc9aaa5f --- /dev/null +++ b/src/bub/hookspecs.py @@ -0,0 +1,74 @@ +"""Pluggy hook namespace and framework hook specifications.""" + +from __future__ import annotations + +from typing import Any + +import pluggy + +from bub.bus import BusProtocol +from bub.types import Envelope, State + +BUB_HOOK_NAMESPACE = "bub" +hookspec = pluggy.HookspecMarker(BUB_HOOK_NAMESPACE) +hookimpl = pluggy.HookimplMarker(BUB_HOOK_NAMESPACE) + + +class BubHookSpecs: + """Hook contract for Bub framework extensions.""" + + @hookspec(firstresult=True) + def provide_bus(self) -> BusProtocol | None: + """Provide a bus instance for inbound/outbound envelopes.""" + + @hookspec(firstresult=True) + def normalize_inbound(self, message: Envelope) -> Envelope | None: + """Normalize or rewrite one inbound message.""" + + @hookspec(firstresult=True) + def resolve_session(self, message: Envelope) -> str | None: + """Resolve session id for one inbound message.""" + + @hookspec(firstresult=True) + def load_state(self, session_id: str) -> State | None: + """Load state snapshot for one session.""" + + @hookspec(firstresult=True) + def build_prompt(self, message: Envelope, session_id: str, state: State) -> str | None: + """Build model prompt for this turn.""" + + @hookspec(firstresult=True) + def run_model(self, prompt: str, session_id: str, state: State) -> str | None: + """Run model for one turn and return plain text output.""" + + @hookspec + def save_state( + self, + session_id: str, + state: State, + message: Envelope, + model_output: str, + ) -> None: + """Persist state updates after one model turn.""" + + @hookspec + def render_outbound( + self, + message: Envelope, + session_id: str, + state: State, + model_output: str, + ) -> list[Envelope] | None: + """Render outbound messages from model output.""" + + @hookspec + def dispatch_outbound(self, message: Envelope) -> bool | None: + """Dispatch one outbound message to external channel(s).""" + + @hookspec + def register_cli_commands(self, app: Any) -> None: + """Register CLI commands onto the root Typer application.""" + + @hookspec + def on_error(self, stage: str, error: Exception, message: Envelope | None) -> None: + """Observe framework errors from any stage.""" diff --git a/src/bub/integrations/republic_client.py b/src/bub/integrations/republic_client.py deleted file mode 100644 index 7d4efc9d..00000000 --- a/src/bub/integrations/republic_client.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Republic integration helpers.""" - -from __future__ import annotations - -from pathlib import Path - -from republic import LLM - -from bub.config.settings import Settings -from bub.tape.context import default_tape_context -from bub.tape.store import FileTapeStore - -AGENTS_FILE = "AGENTS.md" - - -def build_tape_store(settings: Settings, workspace: Path) -> FileTapeStore: - """Build persistent tape store for one workspace.""" - - return FileTapeStore(settings.resolve_home(), workspace) - - -def build_llm(settings: Settings, store: FileTapeStore) -> LLM: - """Build Republic LLM client configured for Bub runtime.""" - - client_args = None - if "azure" in settings.model: - client_args = {"api_version": "2025-01-01-preview"} - - return LLM( - settings.model, - api_key=settings.resolved_api_key, - api_base=settings.api_base, - tape_store=store, - context=default_tape_context(), - client_args=client_args, - ) - - -def read_workspace_agents_prompt(workspace: Path) -> str: - """Read workspace AGENTS.md if present.""" - - prompt_file = workspace / AGENTS_FILE - if not prompt_file.is_file(): - return "" - try: - return prompt_file.read_text(encoding="utf-8").strip() - except OSError: - return "" diff --git a/src/bub/logging_utils.py b/src/bub/logging_utils.py deleted file mode 100644 index 89d8b49a..00000000 --- a/src/bub/logging_utils.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Runtime logging helpers.""" - -from __future__ import annotations - -import os -import sys -from logging import Handler -from typing import Literal - -import loguru -from loguru import logger -from rich import get_console -from rich.logging import RichHandler - -LogProfile = Literal["default", "chat"] - -_PROFILE_FORMATS: dict[LogProfile, str] = { - "chat": "{level} | {extra[tape]} |{message}", - "default": "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<6} | {name}:{function}:{line} | {extra[tape]} | {message}", -} -_CONFIGURED_PROFILE: LogProfile | None = None - - -def _build_chat_handler() -> Handler: - return RichHandler( - console=get_console(), - show_level=True, - show_time=False, - show_path=False, - markup=False, - rich_tracebacks=False, - ) - - -def configure_logging(*, profile: LogProfile = "default") -> None: - """Configure process-level logging once.""" - from bub.tape.service import current_tape - - def inject_context(record: loguru.Record) -> None: - record["extra"]["tape"] = current_tape() - - global _CONFIGURED_PROFILE - if profile == _CONFIGURED_PROFILE: - return - - level = os.getenv("BUB_LOG_LEVEL", "INFO").upper() - logger.remove() - if profile == "chat": - logger.add( - _build_chat_handler(), - level=level, - format="{message}", - backtrace=False, - diagnose=False, - ) - else: - logger.add( - sys.stderr, - level=level, - format=_PROFILE_FORMATS[profile], - backtrace=False, - diagnose=False, - ) - logger.configure(patcher=inject_context) - _CONFIGURED_PROFILE = profile diff --git a/src/bub/skills/__init__.py b/src/bub/skills/__init__.py index 00e20225..4f5f5c14 100644 --- a/src/bub/skills/__init__.py +++ b/src/bub/skills/__init__.py @@ -1,6 +1,5 @@ -"""Skill discovery package.""" +"""Skill discovery and loading exports.""" -from bub.skills.loader import SkillMetadata, discover_skills -from bub.skills.view import render_compact_skills +from bub.skills.loader import SkillMetadata, discover_hook_skills -__all__ = ["SkillMetadata", "discover_skills", "render_compact_skills"] +__all__ = ["SkillMetadata", "discover_hook_skills"] diff --git a/src/bub/skills/builtin/__init__.py b/src/bub/skills/builtin/__init__.py new file mode 100644 index 00000000..e4554feb --- /dev/null +++ b/src/bub/skills/builtin/__init__.py @@ -0,0 +1 @@ +"""Builtin skill bundle for Bub framework.""" diff --git a/src/bub/skills/builtin/cli_core/SKILL.md b/src/bub/skills/builtin/cli_core/SKILL.md new file mode 100644 index 00000000..030fa5a8 --- /dev/null +++ b/src/bub/skills/builtin/cli_core/SKILL.md @@ -0,0 +1,10 @@ +--- +name: cli-core +description: Register base framework CLI commands via hooks. +kind: command +entrypoint: bub.skills.builtin.cli_core.plugin:plugin +--- + +# CLI Core Skill + +Provides the default `run`, `skills`, and `hooks` CLI commands. diff --git a/src/bub/skills/builtin/cli_core/__init__.py b/src/bub/skills/builtin/cli_core/__init__.py new file mode 100644 index 00000000..9f73885a --- /dev/null +++ b/src/bub/skills/builtin/cli_core/__init__.py @@ -0,0 +1 @@ +"""CLI core command skill.""" diff --git a/src/bub/skills/builtin/cli_core/plugin.py b/src/bub/skills/builtin/cli_core/plugin.py new file mode 100644 index 00000000..c78b4ed9 --- /dev/null +++ b/src/bub/skills/builtin/cli_core/plugin.py @@ -0,0 +1,83 @@ +"""Builtin CLI command hooks.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import typer + +from bub.envelope import field_of +from bub.framework import BubFramework +from bub.hookspecs import hookimpl + + +class CliCoreSkill: + @hookimpl + def register_cli_commands(self, app: typer.Typer) -> None: + @app.command("run") + def run( + message: str = typer.Argument(..., help="Inbound message content"), + workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace root"), # noqa: B008 + channel: str = typer.Option("stdout", "--channel", help="Message channel"), + chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), + sender_id: str = typer.Option("human", "--sender-id", help="Sender id"), + session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), + ) -> None: + """Run one inbound message through the framework pipeline.""" + + framework = _load_framework(workspace) + inbound: dict[str, Any] = { + "channel": channel, + "chat_id": chat_id, + "sender_id": sender_id, + "content": message, + } + if session_id is not None and session_id.strip(): + inbound["session_id"] = session_id.strip() + + result = asyncio.run(framework.process_inbound(inbound)) + for outbound in result.outbounds: + rendered = str(field_of(outbound, "content", "")) + target_channel = str(field_of(outbound, "channel", "stdout")) + target_chat = str(field_of(outbound, "chat_id", "local")) + typer.echo(f"[{target_channel}:{target_chat}] {rendered}") + + @app.command("skills") + def list_skills( + workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 + ) -> None: + """Show loaded and failed skills.""" + + framework = _load_framework(workspace) + for record in framework.loaded_skills: + typer.echo( + f"loaded {record.skill.name} ({record.skill.source}) -> {record.skill.metadata.get('entrypoint')}" + ) + for skill_name, error in framework.failed_skills.items(): + typer.echo(f"failed {skill_name}: {error}") + + @app.command("hooks") + def list_hooks( + workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 + ) -> None: + """Show hook implementation mapping.""" + + framework = _load_framework(workspace) + report = framework.hook_report() + if not report: + typer.echo("(no hook implementations)") + return + for hook_name, plugins in report.items(): + typer.echo(f"{hook_name}: {', '.join(plugins)}") + + +plugin = CliCoreSkill() + + +def _load_framework(workspace: Path | None) -> BubFramework: + resolved_workspace = (workspace or Path.cwd()).resolve() + framework = BubFramework(resolved_workspace) + framework.load_skills() + return framework diff --git a/src/bub/skills/builtin/common.py b/src/bub/skills/builtin/common.py new file mode 100644 index 00000000..3fc77064 --- /dev/null +++ b/src/bub/skills/builtin/common.py @@ -0,0 +1,16 @@ +"""Shared helpers for builtin hook skills.""" + +from __future__ import annotations + +from bub.types import State + + +def read_turn(state: State) -> int: + """Read turn counter from state with tolerant conversion.""" + + value = state.get("turn") + if isinstance(value, int): + return value + if isinstance(value, str) and value.isdigit(): + return int(value) + return 0 diff --git a/src/bub/skills/builtin/input_bus/SKILL.md b/src/bub/skills/builtin/input_bus/SKILL.md new file mode 100644 index 00000000..18fe4da8 --- /dev/null +++ b/src/bub/skills/builtin/input_bus/SKILL.md @@ -0,0 +1,10 @@ +--- +name: input-bus +description: Resolve inbound session ids and normalize inbound messages. +kind: bus +entrypoint: bub.skills.builtin.input_bus.plugin:plugin +--- + +# Input Bus Skill + +Normalizes inbound envelopes and maps them into stable session ids. diff --git a/src/bub/skills/builtin/input_bus/__init__.py b/src/bub/skills/builtin/input_bus/__init__.py new file mode 100644 index 00000000..c72072f2 --- /dev/null +++ b/src/bub/skills/builtin/input_bus/__init__.py @@ -0,0 +1 @@ +"""Input/bus skill.""" diff --git a/src/bub/skills/builtin/input_bus/plugin.py b/src/bub/skills/builtin/input_bus/plugin.py new file mode 100644 index 00000000..1ecb2fcc --- /dev/null +++ b/src/bub/skills/builtin/input_bus/plugin.py @@ -0,0 +1,38 @@ +"""Builtin input bus hooks.""" + +from __future__ import annotations + +from bub.bus import MessageBus +from bub.envelope import normalize_envelope +from bub.hookspecs import hookimpl +from bub.types import Envelope + + +class InputBusSkill: + @hookimpl + def provide_bus(self) -> MessageBus: + return MessageBus() + + @hookimpl + def normalize_inbound(self, message: Envelope) -> Envelope: + envelope = normalize_envelope(message) + content = str(envelope.get("content", "")).strip() + metadata = envelope.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + metadata.setdefault("normalized", True) + envelope["content"] = content + envelope["metadata"] = metadata + return envelope + + @hookimpl + def resolve_session(self, message: Envelope) -> str | None: + envelope = normalize_envelope(message) + channel = envelope.get("channel") + chat_id = envelope.get("chat_id") + if channel is None or chat_id is None: + return None + return f"{channel}:{chat_id}" + + +plugin = InputBusSkill() diff --git a/src/bub/skills/builtin/memory_tape/SKILL.md b/src/bub/skills/builtin/memory_tape/SKILL.md new file mode 100644 index 00000000..32670cee --- /dev/null +++ b/src/bub/skills/builtin/memory_tape/SKILL.md @@ -0,0 +1,10 @@ +--- +name: memory-tape +description: In-memory session state skill that mimics tape-style evolution. +kind: memory +entrypoint: bub.skills.builtin.memory_tape.plugin:plugin +--- + +# Memory Tape Skill + +Keeps minimal per-session state in-process and updates counters each turn. diff --git a/src/bub/skills/builtin/memory_tape/__init__.py b/src/bub/skills/builtin/memory_tape/__init__.py new file mode 100644 index 00000000..1b24ca10 --- /dev/null +++ b/src/bub/skills/builtin/memory_tape/__init__.py @@ -0,0 +1 @@ +"""In-memory tape skill.""" diff --git a/src/bub/skills/builtin/memory_tape/plugin.py b/src/bub/skills/builtin/memory_tape/plugin.py new file mode 100644 index 00000000..42b80c32 --- /dev/null +++ b/src/bub/skills/builtin/memory_tape/plugin.py @@ -0,0 +1,28 @@ +"""Builtin memory hook implementation.""" + +from __future__ import annotations + +from bub.envelope import content_of +from bub.hookspecs import hookimpl +from bub.skills.builtin.common import read_turn +from bub.types import Envelope, State + + +class MemoryTapeSkill: + def __init__(self) -> None: + self._state_by_session: dict[str, State] = {} + + @hookimpl + def load_state(self, session_id: str) -> State: + state = self._state_by_session.get(session_id, {"turn": 0}) + return dict(state) + + @hookimpl + def save_state(self, session_id: str, state: State, message: Envelope, model_output: str) -> None: + state["turn"] = read_turn(state) + 1 + state["last_user"] = content_of(message) + state["last_assistant"] = model_output + self._state_by_session[session_id] = dict(state) + + +plugin = MemoryTapeSkill() diff --git a/src/bub/skills/builtin/model_echo/SKILL.md b/src/bub/skills/builtin/model_echo/SKILL.md new file mode 100644 index 00000000..2a25d2ac --- /dev/null +++ b/src/bub/skills/builtin/model_echo/SKILL.md @@ -0,0 +1,10 @@ +--- +name: model-echo +description: Minimal model hook that echoes prompt text. +kind: model +entrypoint: bub.skills.builtin.model_echo.plugin:plugin +--- + +# Echo Model Skill + +Provides the smallest possible model hook implementation for framework bootstrap. diff --git a/src/bub/skills/builtin/model_echo/__init__.py b/src/bub/skills/builtin/model_echo/__init__.py new file mode 100644 index 00000000..810833b4 --- /dev/null +++ b/src/bub/skills/builtin/model_echo/__init__.py @@ -0,0 +1 @@ +"""Echo model skill.""" diff --git a/src/bub/skills/builtin/model_echo/plugin.py b/src/bub/skills/builtin/model_echo/plugin.py new file mode 100644 index 00000000..05e988eb --- /dev/null +++ b/src/bub/skills/builtin/model_echo/plugin.py @@ -0,0 +1,24 @@ +"""Builtin model hook implementation.""" + +from __future__ import annotations + +from bub.envelope import content_of +from bub.hookspecs import hookimpl +from bub.skills.builtin.common import read_turn +from bub.types import Envelope, State + + +class EchoModelSkill: + @hookimpl + def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: + _ = session_id + prefix = str(state.get("prompt_prefix", "")) + return f"{prefix}{content_of(message)}" + + @hookimpl + def run_model(self, prompt: str, session_id: str, state: State) -> str: + turn = read_turn(state) + 1 + return f"[{session_id}] turn={turn} {prompt}" + + +plugin = EchoModelSkill() diff --git a/src/bub/skills/builtin/output_stdout/SKILL.md b/src/bub/skills/builtin/output_stdout/SKILL.md new file mode 100644 index 00000000..5a7da398 --- /dev/null +++ b/src/bub/skills/builtin/output_stdout/SKILL.md @@ -0,0 +1,10 @@ +--- +name: output-stdout +description: Render outbound envelopes and dispatch stdout channel messages. +kind: output +entrypoint: bub.skills.builtin.output_stdout.plugin:plugin +--- + +# Output Stdout Skill + +Renders outbound envelopes and prints messages when channel is stdout. diff --git a/src/bub/skills/builtin/output_stdout/__init__.py b/src/bub/skills/builtin/output_stdout/__init__.py new file mode 100644 index 00000000..a5504d05 --- /dev/null +++ b/src/bub/skills/builtin/output_stdout/__init__.py @@ -0,0 +1 @@ +"""Stdout output skill.""" diff --git a/src/bub/skills/builtin/output_stdout/plugin.py b/src/bub/skills/builtin/output_stdout/plugin.py new file mode 100644 index 00000000..c6f82037 --- /dev/null +++ b/src/bub/skills/builtin/output_stdout/plugin.py @@ -0,0 +1,33 @@ +"""Builtin output hooks.""" + +from __future__ import annotations + +from bub.envelope import field_of +from bub.hookspecs import hookimpl +from bub.types import Envelope, State + + +class OutputStdoutSkill: + @hookimpl + def render_outbound(self, message: Envelope, session_id: str, state: State, model_output: str) -> list[Envelope]: + _ = state + channel = field_of(message, "channel", "stdout") + chat_id = field_of(message, "chat_id", "local") + return [ + { + "channel": channel, + "chat_id": chat_id, + "content": model_output, + "metadata": {"session_id": session_id}, + } + ] + + @hookimpl + def dispatch_outbound(self, message: Envelope) -> bool: + if field_of(message, "channel", "stdout") != "stdout": + return False + print(field_of(message, "content", "")) + return True + + +plugin = OutputStdoutSkill() diff --git a/src/bub/skills/loader.py b/src/bub/skills/loader.py index 913b913e..0d0292a2 100644 --- a/src/bub/skills/loader.py +++ b/src/bub/skills/loader.py @@ -1,40 +1,51 @@ -"""Skill discovery and loading.""" +"""Skill discovery and hook plugin loading.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from importlib import import_module from pathlib import Path -from typing import Any, cast +from typing import Any import yaml PROJECT_SKILLS_DIR = ".agent/skills" SKILL_FILE_NAME = "SKILL.md" +HOOK_SKILL_KINDS = frozenset({"hook", "model", "memory", "output", "bus", "tool", "channel", "command"}) +SKILL_SOURCES = ("project", "global", "builtin") @dataclass(frozen=True) class SkillMetadata: - """Skill metadata used in compact prompt view.""" + """Discovered skill metadata.""" name: str description: str location: Path - body: str - metadata: dict[str, Any] | None = None - source: str = "unknown" + source: str + metadata: dict[str, Any] = field(default_factory=dict) -def discover_skills(workspace_path: Path) -> list[SkillMetadata]: - """Discover skills from project, global, and built-in roots.""" +def discover_hook_skills(workspace_path: Path) -> list[SkillMetadata]: + """Discover skills with hook entrypoints.""" + + results: list[SkillMetadata] = [] + for skill in discover_skills(workspace_path): + entrypoint = skill.metadata.get("entrypoint") + kind = str(skill.metadata.get("kind") or "").strip().lower() + if not isinstance(entrypoint, str) or not entrypoint.strip(): + continue + if kind not in HOOK_SKILL_KINDS: + continue + results.append(skill) + return results + - ordered_roots = [ - (workspace_path / PROJECT_SKILLS_DIR, "project"), - (Path.home() / PROJECT_SKILLS_DIR, "global"), - (_builtin_skills_root(), "builtin"), - ] +def discover_skills(workspace_path: Path) -> list[SkillMetadata]: + """Discover skills from project, global, and builtin roots with override precedence.""" - by_name: dict[str, SkillMetadata] = {} - for root, source in ordered_roots: + skills_by_name: dict[str, SkillMetadata] = {} + for root, source in _iter_skill_roots(workspace_path): if not root.is_dir(): continue for skill_dir in sorted(root.iterdir()): @@ -44,10 +55,26 @@ def discover_skills(workspace_path: Path) -> list[SkillMetadata]: if metadata is None: continue key = metadata.name.casefold() - if key not in by_name: - by_name[key] = metadata + if key not in skills_by_name: + skills_by_name[key] = metadata - return sorted(by_name.values(), key=lambda item: item.name.casefold()) + return sorted(skills_by_name.values(), key=lambda item: item.name.casefold()) + + +def load_skill_plugin(skill: SkillMetadata) -> object: + """Load plugin object from one skill entrypoint.""" + + entrypoint = skill.metadata.get("entrypoint") + if not isinstance(entrypoint, str): + raise TypeError(f"{skill.name}: entrypoint must be string") + + module_name, sep, attr_name = entrypoint.partition(":") + if not sep or not module_name or not attr_name: + raise ValueError(f"{skill.name}: invalid entrypoint format '{entrypoint}'") + + module = import_module(module_name) + plugin = getattr(module, attr_name) + return plugin def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: @@ -63,13 +90,15 @@ def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: metadata, body = _parse_frontmatter(content) name = str(metadata.get("name") or skill_dir.name).strip() description = str(metadata.get("description") or "No description provided.").strip() - meta = cast(dict[str, Any], metadata.get("metadata")) - if not name: return None return SkillMetadata( - name=name, description=description, location=skill_file.resolve(), source=source, metadata=meta, body=body + name=name, + description=description, + location=skill_file.resolve(), + source=source, + metadata={str(key).casefold(): value for key, value in metadata.items() if key is not None}, ) @@ -93,4 +122,16 @@ def _parse_frontmatter(content: str) -> tuple[dict[str, object], str]: def _builtin_skills_root() -> Path: - return Path(__file__).resolve().parent + return Path(__file__).resolve().parent / "builtin" + + +def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: + roots: list[tuple[Path, str]] = [] + for source in SKILL_SOURCES: + if source == "project": + roots.append((workspace_path / PROJECT_SKILLS_DIR, source)) + elif source == "global": + roots.append((Path.home() / PROJECT_SKILLS_DIR, source)) + elif source == "builtin": + roots.append((_builtin_skills_root(), source)) + return roots diff --git a/src/bub/skills/skill-creator/SKILL.md b/src/bub/skills/skill-creator/SKILL.md deleted file mode 100644 index 0d8d2c39..00000000 --- a/src/bub/skills/skill-creator/SKILL.md +++ /dev/null @@ -1,366 +0,0 @@ ---- -name: skill-creator -description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Bub's capabilities with specialized knowledge, workflows, or tool integrations. -metadata: - short-description: Create or update a skill ---- - -# Skill Creator - -This skill provides guidance for creating effective skills. - -## About Skills - -Skills are modular, self-contained folders that extend Bub's capabilities by providing -specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific -domains or tasks—they transform Bub from a general-purpose agent into a specialized agent -equipped with procedural knowledge that no model can fully possess. - -### Skill Location Policy - -When creating a skill, place it in one of these two roots: - -1. Project-local: `$workspace/.agent/skills/` -2. Global: `~/.agent/skills/` (shared across workspaces) - -Prefer project-local by default. Use global only when the user explicitly wants the skill available across multiple workspaces. - -### What Skills Provide - -1. Specialized workflows - Multi-step procedures for specific domains -2. Tool integrations - Instructions for working with specific file formats or APIs -3. Domain expertise - Company-specific knowledge, schemas, business logic -4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks - -## Core Principles - -### Concise is Key - -The context window is a public good. Skills share the context window with everything else Bub needs: system prompt, conversation history, other Skills' metadata, and the actual user request. - -**Default assumption: Bub is already very smart.** Only add context Bub doesn't already have. Challenge each piece of information: "Does Bub really need this explanation?" and "Does this paragraph justify its token cost?" - -Prefer concise examples over verbose explanations. - -### Set Appropriate Degrees of Freedom - -Match the level of specificity to the task's fragility and variability: - -**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach. - -**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior. - -**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. - -Think of Bub as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). - -### Anatomy of a Skill - -Every skill consists of a required SKILL.md file and optional bundled resources: - -``` -skill-name/ -├── SKILL.md (required) -│ ├── YAML frontmatter metadata (required) -│ │ ├── name: (required) -│ │ └── description: (required) -│ └── Markdown instructions (required) -└── Bundled Resources (optional) - ├── scripts/ - Executable code (Python/Bash/etc.) - ├── references/ - Documentation intended to be loaded into context as needed - └── assets/ - Files used in output (templates, icons, fonts, etc.) -``` - -#### SKILL.md (required) - -Every SKILL.md consists of: - -- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Bub reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. -- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). - -#### Bundled Resources (optional) - -##### Scripts (`scripts/`) - -Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. - -- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed -- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks -- **Benefits**: Token efficient, deterministic, may be executed without loading into context -- **Note**: Scripts may still need to be read by Bub for patching or environment-specific adjustments - -##### References (`references/`) - -Documentation and reference material intended to be loaded as needed into context to inform Bub's process and thinking. - -- **When to include**: For documentation that Bub should reference while working -- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications -- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides -- **Benefits**: Keeps SKILL.md lean, loaded only when Bub determines it's needed -- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md -- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. - -##### Assets (`assets/`) - -Files not intended to be loaded into context, but rather used within the output Bub produces. - -- **When to include**: When the skill needs files that will be used in the final output -- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography -- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified -- **Benefits**: Separates output resources from documentation, enables Bub to use files without loading them into context - -#### What to Not Include in a Skill - -A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including: - -- README.md -- INSTALLATION_GUIDE.md -- QUICK_REFERENCE.md -- CHANGELOG.md -- etc. - -The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion. - -### Progressive Disclosure Design Principle - -Skills use a three-level loading system to manage context efficiently: - -1. **Metadata (name + description)** - Always in context (~100 words) -2. **SKILL.md body** - When skill triggers (<5k words) -3. **Bundled resources** - As needed by Bub (Unlimited because scripts can be executed without reading into context window) - -#### Progressive Disclosure Patterns - -Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them. - -**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files. - -**Pattern 1: High-level guide with references** - -```markdown -# PDF Processing - -## Quick start - -Extract text with pdfplumber: -[code example] - -## Advanced features - -- **Form filling**: See [FORMS.md](FORMS.md) for complete guide -- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods -- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns -``` - -Bub loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. - -**Pattern 2: Domain-specific organization** - -For Skills with multiple domains, organize content by domain to avoid loading irrelevant context: - -``` -bigquery-skill/ -├── SKILL.md (overview and navigation) -└── reference/ - ├── finance.md (revenue, billing metrics) - ├── sales.md (opportunities, pipeline) - ├── product.md (API usage, features) - └── marketing.md (campaigns, attribution) -``` - -When a user asks about sales metrics, Bub only reads sales.md. - -Similarly, for skills supporting multiple frameworks or variants, organize by variant: - -``` -cloud-deploy/ -├── SKILL.md (workflow + provider selection) -└── references/ - ├── aws.md (AWS deployment patterns) - ├── gcp.md (GCP deployment patterns) - └── azure.md (Azure deployment patterns) -``` - -When the user chooses AWS, Bub only reads aws.md. - -**Pattern 3: Conditional details** - -Show basic content, link to advanced content: - -```markdown -# DOCX Processing - -## Creating documents - -Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md). - -## Editing documents - -For simple edits, modify the XML directly. - -**For tracked changes**: See [REDLINING.md](REDLINING.md) -**For OOXML details**: See [OOXML.md](OOXML.md) -``` - -Bub reads REDLINING.md or OOXML.md only when the user needs those features. - -**Important guidelines:** - -- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. -- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Bub can see the full scope when previewing. - -## Skill Creation Process - -Skill creation involves these steps: - -1. Understand the skill with concrete examples -2. Plan reusable skill contents (scripts, references, assets) -3. Initialize the skill (run `uv run scripts/init_skill.py`) -4. Edit the skill (implement resources and write SKILL.md) -5. Validate the skill (run `uv run scripts/quick_validate.py`) -6. Iterate based on real usage - -Follow these steps in order, skipping only if there is a clear reason why they are not applicable. - -### Skill Naming - -- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`). -- When generating names, generate a name under 64 characters (letters, digits, hyphens). -- Prefer short, verb-led phrases that describe the action. -- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`). -- Name the skill folder exactly after the skill name. - -### Step 1: Understanding the Skill with Concrete Examples - -Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. - -To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. - -For example, when building an image-editor skill, relevant questions include: - -- "What functionality should the image-editor skill support? Editing, rotating, anything else?" -- "Can you give some examples of how this skill would be used?" -- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" -- "What would a user say that should trigger this skill?" - -To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. - -Conclude this step when there is a clear sense of the functionality the skill should support. - -### Step 2: Planning the Reusable Skill Contents - -To turn concrete examples into an effective skill, analyze each example by: - -1. Considering how to execute on the example from scratch -2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly - -Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: - -1. Rotating a PDF requires re-writing the same code each time -2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill - -Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: - -1. Writing a frontend webapp requires the same boilerplate HTML/React each time -2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill - -Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: - -1. Querying BigQuery requires re-discovering the table schemas and relationships each time -2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill - -To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. - -### Step 3: Initializing the Skill - -At this point, it is time to actually create the skill. - -Skip this step only if the skill being developed already exists. In this case, continue to the next step. - -When creating a new skill from scratch, always run `uv run scripts/init_skill.py`. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. - -Usage: - -```bash -uv run scripts/init_skill.py --path [--resources scripts,references,assets] [--examples] -``` - -Examples: - -```bash -uv run scripts/init_skill.py my-skill --path "$workspace/.agent/skills" -uv run scripts/init_skill.py my-skill --path "$workspace/.agent/skills" --resources scripts,references -uv run scripts/init_skill.py my-skill --path "$workspace/.agent/skills" --resources scripts --examples -uv run scripts/init_skill.py my-skill --path "~/.agent/skills" -``` - -The script: - -- Creates the skill directory at the specified path -- Generates a SKILL.md template with proper frontmatter and TODO placeholders -- Optionally creates resource directories based on `--resources` -- Optionally adds example files when `--examples` is set - -After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files. - -### Step 4: Edit the Skill - -When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Bub to use. Include information that would be beneficial and non-obvious to Bub. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Bub instance execute these tasks more effectively. - -#### Learn Proven Design Patterns - -Consult these helpful guides based on your skill's needs: - -- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic -- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns - -These files contain established best practices for effective skill design. - -#### Start with Reusable Skill Contents - -To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. - -Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion. - -If you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required. - -#### Update SKILL.md - -**Writing Guidelines:** Always use imperative/infinitive form. - -##### Frontmatter - -Write the YAML frontmatter with `name` and `description`: - -- `name`: The skill name -- `description`: This is the primary triggering mechanism for your skill, and helps Bub understand when to use the skill. - - Include both what the Skill does and specific triggers/contexts for when to use it. - - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Bub. - - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Bub needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" - -Do not include any other fields in YAML frontmatter. - -##### Body - -Write instructions for using the skill and its bundled resources. - -### Step 5: Validate the Skill - -Once development of the skill is complete, validate the skill folder to catch basic issues early: - -```bash -uv run scripts/quick_validate.py -``` - -The validation script checks YAML frontmatter format, required fields, and naming rules. If validation fails, fix the reported issues and run the command again. - -### Step 6: Iterate - -After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. - -**Iteration workflow:** - -1. Use the skill on real tasks -2. Notice struggles or inefficiencies -3. Identify how SKILL.md or bundled resources should be updated -4. Implement changes and test again diff --git a/src/bub/skills/skill-creator/license.txt b/src/bub/skills/skill-creator/license.txt deleted file mode 100644 index d6456956..00000000 --- a/src/bub/skills/skill-creator/license.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/src/bub/skills/skill-creator/scripts/init_skill.py b/src/bub/skills/skill-creator/scripts/init_skill.py deleted file mode 100644 index e2ed5f5e..00000000 --- a/src/bub/skills/skill-creator/scripts/init_skill.py +++ /dev/null @@ -1,386 +0,0 @@ -#!/usr/bin/env python3 -""" -Skill Initializer - Creates a new skill from template - -Usage: - uv run scripts/init_skill.py --path [--resources scripts,references,assets] [--examples] [--interface key=value] - -Examples: - uv run scripts/init_skill.py my-new-skill --path skills/public - uv run scripts/init_skill.py my-new-skill --path skills/public --resources scripts,references - uv run scripts/init_skill.py my-api-helper --path skills/private --resources scripts --examples - uv run scripts/init_skill.py custom-skill --path /custom/location - uv run scripts/init_skill.py my-skill --path skills/public --interface short_description="Short UI label" -""" - -import argparse -import re -import sys -from pathlib import Path - -MAX_SKILL_NAME_LENGTH = 64 -ALLOWED_RESOURCES = {"scripts", "references", "assets"} - -SKILL_TEMPLATE = """--- -name: {skill_name} -description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] ---- - -# {skill_title} - -## Overview - -[TODO: 1-2 sentences explaining what this skill enables] - -## Structuring This Skill - -[TODO: Choose the structure that best fits this skill's purpose. Common patterns: - -**1. Workflow-Based** (best for sequential processes) -- Works well when there are clear step-by-step procedures -- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing" -- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2... - -**2. Task-Based** (best for tool collections) -- Works well when the skill offers different operations/capabilities -- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text" -- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2... - -**3. Reference/Guidelines** (best for standards or specifications) -- Works well for brand guidelines, coding standards, or requirements -- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features" -- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage... - -**4. Capabilities-Based** (best for integrated systems) -- Works well when the skill provides multiple interrelated features -- Example: Product Management with "Core Capabilities" -> numbered capability list -- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature... - -Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). - -Delete this entire "Structuring This Skill" section when done - it's just guidance.] - -## [TODO: Replace with the first main section based on chosen structure] - -[TODO: Add content here. See examples in existing skills: -- Code samples for technical skills -- Decision trees for complex workflows -- Concrete examples with realistic user requests -- References to scripts/templates/references as needed] - -## Resources (optional) - -Create only the resource directories this skill actually needs. Delete this section if no resources are required. - -### scripts/ -Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. - -**Examples from other skills:** -- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation -- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing - -**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. - -**Note:** Scripts may be executed without loading into context, but can still be read by Bub for patching or environment adjustments. - -### references/ -Documentation and reference material intended to be loaded into context to inform Bub's process and thinking. - -**Examples from other skills:** -- Product management: `communication.md`, `context_building.md` - detailed workflow guides -- BigQuery: API reference documentation and query examples -- Finance: Schema documentation, company policies - -**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Bub should reference while working. - -### assets/ -Files not intended to be loaded into context, but rather used within the output Bub produces. - -**Examples from other skills:** -- Brand styling: PowerPoint template files (.pptx), logo files -- Frontend builder: HTML/React boilerplate project directories -- Typography: Font files (.ttf, .woff2) - -**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. - ---- - -**Not every skill requires all three types of resources.** -""" - -EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 -""" -Example helper script for {skill_name} - -This is a placeholder script that can be executed directly. -Replace with actual implementation or delete if not needed. - -Example real scripts from other skills: -- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields -- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images -""" - -def main(): - print("This is an example script for {skill_name}") - # TODO: Add actual script logic here - # This could be data processing, file conversion, API calls, etc. - -if __name__ == "__main__": - main() -''' - -EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} - -This is a placeholder for detailed reference documentation. -Replace with actual reference content or delete if not needed. - -Example real reference docs from other skills: -- product-management/references/communication.md - Comprehensive guide for status updates -- product-management/references/context_building.md - Deep-dive on gathering context -- bigquery/references/ - API references and query examples - -## When Reference Docs Are Useful - -Reference docs are ideal for: -- Comprehensive API documentation -- Detailed workflow guides -- Complex multi-step processes -- Information too lengthy for main SKILL.md -- Content that's only needed for specific use cases - -## Structure Suggestions - -### API Reference Example -- Overview -- Authentication -- Endpoints with examples -- Error codes -- Rate limits - -### Workflow Guide Example -- Prerequisites -- Step-by-step instructions -- Common patterns -- Troubleshooting -- Best practices -""" - -EXAMPLE_ASSET = """# Example Asset File - -This placeholder represents where asset files would be stored. -Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. - -Asset files are NOT intended to be loaded into context, but rather used within -the output Bub produces. - -Example asset files from other skills: -- Brand guidelines: logo.png, slides_template.pptx -- Frontend builder: hello-world/ directory with HTML/React boilerplate -- Typography: custom-font.ttf, font-family.woff2 -- Data: sample_data.csv, test_dataset.json - -## Common Asset Types - -- Templates: .pptx, .docx, boilerplate directories -- Images: .png, .jpg, .svg, .gif -- Fonts: .ttf, .otf, .woff, .woff2 -- Boilerplate code: Project directories, starter files -- Icons: .ico, .svg -- Data files: .csv, .json, .xml, .yaml - -Note: This is a text placeholder. Actual assets can be any file type. -""" - - -def normalize_skill_name(skill_name): - """Normalize a skill name to lowercase hyphen-case.""" - normalized = skill_name.strip().lower() - normalized = re.sub(r"[^a-z0-9]+", "-", normalized) - normalized = normalized.strip("-") - normalized = re.sub(r"-{2,}", "-", normalized) - return normalized - - -def title_case_skill_name(skill_name): - """Convert hyphenated skill name to Title Case for display.""" - return " ".join(word.capitalize() for word in skill_name.split("-")) - - -def parse_resources(raw_resources): - if not raw_resources: - return [] - resources = [item.strip() for item in raw_resources.split(",") if item.strip()] - invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES}) - if invalid: - allowed = ", ".join(sorted(ALLOWED_RESOURCES)) - print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}") - print(f" Allowed: {allowed}") - sys.exit(1) - deduped = [] - seen = set() - for resource in resources: - if resource not in seen: - deduped.append(resource) - seen.add(resource) - return deduped - - -def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples): - for resource in resources: - resource_dir = skill_dir / resource - resource_dir.mkdir(exist_ok=True) - if resource == "scripts": - if include_examples: - example_script = resource_dir / "example.py" - example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) - example_script.chmod(0o755) - print("[OK] Created scripts/example.py") - else: - print("[OK] Created scripts/") - elif resource == "references": - if include_examples: - example_reference = resource_dir / "api_reference.md" - example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) - print("[OK] Created references/api_reference.md") - else: - print("[OK] Created references/") - elif resource == "assets": - if include_examples: - example_asset = resource_dir / "example_asset.txt" - example_asset.write_text(EXAMPLE_ASSET) - print("[OK] Created assets/example_asset.txt") - else: - print("[OK] Created assets/") - - -def init_skill(skill_name, path, resources, include_examples, interface_overrides): - """ - Initialize a new skill directory with template SKILL.md. - - Args: - skill_name: Name of the skill - path: Path where the skill directory should be created - resources: Resource directories to create - include_examples: Whether to create example files in resource directories - - Returns: - Path to created skill directory, or None if error - """ - # Determine skill directory path - skill_dir = Path(path).expanduser().resolve() / skill_name - - # Check if directory already exists - if skill_dir.exists(): - print(f"[ERROR] Skill directory already exists: {skill_dir}") - return None - - # Create skill directory - try: - skill_dir.mkdir(parents=True, exist_ok=False) - print(f"[OK] Created skill directory: {skill_dir}") - except Exception as e: - print(f"[ERROR] Error creating directory: {e}") - return None - - # Create SKILL.md from template - skill_title = title_case_skill_name(skill_name) - skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title) - - skill_md_path = skill_dir / "SKILL.md" - try: - skill_md_path.write_text(skill_content) - print("[OK] Created SKILL.md") - except Exception as e: - print(f"[ERROR] Error creating SKILL.md: {e}") - return None - - # Create resource directories if requested - if resources: - try: - create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples) - except Exception as e: - print(f"[ERROR] Error creating resource directories: {e}") - return None - - # Print next steps - print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}") - print("\nNext steps:") - print("1. Edit SKILL.md to complete the TODO items and update the description") - if resources: - if include_examples: - print("2. Customize or delete the example files in scripts/, references/, and assets/") - else: - print("2. Add resources to scripts/, references/, and assets/ as needed") - else: - print("2. Create resource directories only if needed (scripts/, references/, assets/)") - print("3. Update agents/openai.yaml if the UI metadata should differ") - print("4. Run the validator when ready to check the skill structure") - - return skill_dir - - -def main(): - parser = argparse.ArgumentParser( - description="Create a new skill directory with a SKILL.md template.", - ) - parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)") - parser.add_argument("--path", required=True, help="Output directory for the skill") - parser.add_argument( - "--resources", - default="", - help="Comma-separated list: scripts,references,assets", - ) - parser.add_argument( - "--examples", - action="store_true", - help="Create example files inside the selected resource directories", - ) - parser.add_argument( - "--interface", - action="append", - default=[], - help="Interface override in key=value format (repeatable)", - ) - args = parser.parse_args() - - raw_skill_name = args.skill_name - skill_name = normalize_skill_name(raw_skill_name) - if not skill_name: - print("[ERROR] Skill name must include at least one letter or digit.") - sys.exit(1) - if len(skill_name) > MAX_SKILL_NAME_LENGTH: - print( - f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). " - f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." - ) - sys.exit(1) - if skill_name != raw_skill_name: - print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.") - - resources = parse_resources(args.resources) - if args.examples and not resources: - print("[ERROR] --examples requires --resources to be set.") - sys.exit(1) - - path = args.path - - print(f"Initializing skill: {skill_name}") - print(f" Location: {path}") - if resources: - print(f" Resources: {', '.join(resources)}") - if args.examples: - print(" Examples: enabled") - else: - print(" Resources: none (create as needed)") - print() - - result = init_skill(skill_name, path, resources, args.examples, args.interface) - - if result: - sys.exit(0) - else: - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/src/bub/skills/skill-creator/scripts/quick_validate.py b/src/bub/skills/skill-creator/scripts/quick_validate.py deleted file mode 100644 index 6db98ab4..00000000 --- a/src/bub/skills/skill-creator/scripts/quick_validate.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.12" -# dependencies = [ -# "pyyaml>=6.0", -# ] -# /// -""" -Quick validation script for skills - minimal version -""" - -import re -import sys -from pathlib import Path - -import yaml - -MAX_SKILL_NAME_LENGTH = 64 - - -def validate_skill(skill_path): - """Basic validation of a skill""" - skill_path = Path(skill_path) - - skill_md = skill_path / "SKILL.md" - if not skill_md.exists(): - return False, "SKILL.md not found" - - content = skill_md.read_text() - if not content.startswith("---"): - return False, "No YAML frontmatter found" - - match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) - if not match: - return False, "Invalid frontmatter format" - - frontmatter_text = match.group(1) - - try: - frontmatter = yaml.safe_load(frontmatter_text) - if not isinstance(frontmatter, dict): - return False, "Frontmatter must be a YAML dictionary" - except yaml.YAMLError as e: - return False, f"Invalid YAML in frontmatter: {e}" - - allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} - - unexpected_keys = set(frontmatter.keys()) - allowed_properties - if unexpected_keys: - allowed = ", ".join(sorted(allowed_properties)) - unexpected = ", ".join(sorted(unexpected_keys)) - return ( - False, - f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", - ) - - if "name" not in frontmatter: - return False, "Missing 'name' in frontmatter" - if "description" not in frontmatter: - return False, "Missing 'description' in frontmatter" - - name = frontmatter.get("name", "") - if not isinstance(name, str): - return False, f"Name must be a string, got {type(name).__name__}" - name = name.strip() - if name: - if not re.match(r"^[a-z0-9-]+$", name): - return ( - False, - f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", - ) - if name.startswith("-") or name.endswith("-") or "--" in name: - return ( - False, - f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", - ) - if len(name) > MAX_SKILL_NAME_LENGTH: - return ( - False, - f"Name is too long ({len(name)} characters). Maximum is {MAX_SKILL_NAME_LENGTH} characters.", - ) - - description = frontmatter.get("description", "") - if not isinstance(description, str): - return False, f"Description must be a string, got {type(description).__name__}" - description = description.strip() - if description: - if "<" in description or ">" in description: - return False, "Description cannot contain angle brackets (< or >)" - if len(description) > 1024: - return ( - False, - f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", - ) - - return True, "Skill is valid!" - - -if __name__ == "__main__": - if len(sys.argv) != 2: - print("Usage: uv run scripts/quick_validate.py ") - sys.exit(1) - - valid, message = validate_skill(sys.argv[1]) - print(message) - sys.exit(0 if valid else 1) diff --git a/src/bub/skills/skill-installer/LICENSE.txt b/src/bub/skills/skill-installer/LICENSE.txt deleted file mode 100644 index d6456956..00000000 --- a/src/bub/skills/skill-installer/LICENSE.txt +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/src/bub/skills/skill-installer/SKILL.md b/src/bub/skills/skill-installer/SKILL.md deleted file mode 100644 index 1d66b403..00000000 --- a/src/bub/skills/skill-installer/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: skill-installer -description: Install Bub skills into the shared skills directory from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). -metadata: - short-description: Install curated skills from openai/skills or other repos ---- - -# Skill Installer - -Helps install skills. By default these are from https://github.com/openai/skills/tree/main/skills/.curated, but users can also provide other locations. Experimental skills live in https://github.com/openai/skills/tree/main/skills/.experimental and can be installed the same way. - -Use `npx skills` based on the task: -- List skills when the user asks what is available, or if the user uses this skill without specifying what to do. Default listing is `.curated`, but you can pass `--path skills/.experimental` when they ask about experimental skills. -- Install from the curated list when the user provides a skill name. -- Install from another repo when the user provides a GitHub repo/path (including private repos). - -## Install Location Policy - -Use one of these roots for installed skills: - -1. Project-local: `$workspace/.agent/skills/` -2. Global: `~/.agent/skills/` (shared across workspaces) - -Prefer project-local for repo-specific workflows. Use global only when the user asks for cross-workspace availability. - -## Communication - -When listing skills, output approximately as follows, depending on the context of the user's request. If they ask about experimental skills, list from `.experimental` instead of `.curated` and label the source accordingly: -""" -Skills from {repo}: -1. skill-1 -2. skill-2 (already installed) -3. ... -Which ones would you like installed? -""" - -After installing a skill, tell the user: "Restart Bub to pick up new skills." - -## Commands - -All of these commands use network. - -- List curated skills: `npx skills add openai/skills --list --skill '*' --agent antigravity --yes` -- List experimental skills: `npx skills add https://github.com/openai/skills/tree/main/skills/.experimental --list --skill '*' --agent antigravity --yes` -- Install curated skill (project-local): `npx skills add https://github.com/openai/skills/tree/main/skills/.curated/ --agent antigravity --yes` -- Install curated skill (global): `npx skills add https://github.com/openai/skills/tree/main/skills/.curated/ --agent antigravity --yes --global` -- Install from URL: `npx skills add https://github.com///tree// --agent antigravity --yes` -- Install from shorthand: `npx skills add /@ --agent antigravity --yes` -- Show installed skills in current scope: `npx skills list` -- Show installed global skills: `npx skills list --global` - -## Behavior and Options - -- `npx skills add` handles discovery and installation directly. -- Use project scope by default; use `--global` for cross-workspace availability. -- Always pass `--agent antigravity` so install location matches Bub discovery (`.agent/skills`). -- Prefer non-interactive usage with `--yes` when you already know the target skill. - -## Notes - -- Curated/experimental listings are resolved by `npx skills add ... --list`. -- Private GitHub repos depend on `npx skills` auth support and your local git/npm credentials. -- The skills at https://github.com/openai/skills/tree/main/skills/.system are preinstalled, so no need to help users install those. If they ask, just explain this. If they insist, you can download and overwrite. -- Installed annotations come from `npx skills list` in the active scope. diff --git a/src/bub/skills/view.py b/src/bub/skills/view.py deleted file mode 100644 index f222760e..00000000 --- a/src/bub/skills/view.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Skill prompt rendering.""" - -from __future__ import annotations - -from bub.skills.loader import SkillMetadata - - -def render_compact_skills(skills: list[SkillMetadata], expanded_skills: set[str]) -> str: - """Render compact skill metadata for system prompt.""" - - if not skills: - return "" - channel_skills: list[SkillMetadata] = [ - skill for skill in skills if skill.metadata and skill.metadata.get("channel") - ] - lines = [""] - for skill in skills: - if skill.metadata and skill.metadata.get("channel"): - continue - lines.append(f"=== [{skill.name}]({skill.location}): {skill.description} ===") - if skill.name in expanded_skills: - lines.append(f"{skill.body.rstrip()}\n") - lines.append("") - if channel_skills: - lines.append("") - for skill in channel_skills: - lines.append(f"=== [{skill.name}]({skill.location}): {skill.description} ===") - if skill.name in expanded_skills: - lines.append(f"{skill.body.rstrip()}\n") - lines.append("") - return "\n".join(lines) diff --git a/src/bub/tape/__init__.py b/src/bub/tape/__init__.py deleted file mode 100644 index be0f2bd2..00000000 --- a/src/bub/tape/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Tape helpers.""" - -from bub.tape.anchors import AnchorSummary -from bub.tape.context import default_tape_context -from bub.tape.service import TapeService -from bub.tape.store import FileTapeStore - -__all__ = ["AnchorSummary", "FileTapeStore", "TapeService", "default_tape_context"] diff --git a/src/bub/tape/anchors.py b/src/bub/tape/anchors.py deleted file mode 100644 index 01f651cf..00000000 --- a/src/bub/tape/anchors.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Anchor models.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class AnchorSummary: - """Rendered anchor summary.""" - - name: str - state: dict[str, object] diff --git a/src/bub/tape/context.py b/src/bub/tape/context.py deleted file mode 100644 index 63e936e3..00000000 --- a/src/bub/tape/context.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Tape context helpers.""" - -from __future__ import annotations - -import json -from collections.abc import Iterable -from typing import Any - -from republic import TapeContext, TapeEntry - - -def default_tape_context(state: dict[str, Any] | None = None) -> TapeContext: - """Return the default context selection for Bub.""" - - return TapeContext(select=_select_messages, state=state or {}) - - -def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[dict[str, Any]]: - messages: list[dict[str, Any]] = [] - pending_calls: list[dict[str, Any]] = [] - - for entry in entries: - if entry.kind == "message": - _append_message_entry(messages, entry) - continue - - if entry.kind == "tool_call": - pending_calls = _append_tool_call_entry(messages, entry) - continue - - if entry.kind == "tool_result": - _append_tool_result_entry(messages, pending_calls, entry) - pending_calls = [] - - return messages - - -def _append_message_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None: - payload = entry.payload - if isinstance(payload, dict): - messages.append(dict(payload)) - - -def _append_tool_call_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> list[dict[str, Any]]: - calls = _normalize_tool_calls(entry.payload.get("calls")) - if calls: - messages.append({"role": "assistant", "content": "", "tool_calls": calls}) - return calls - - -def _append_tool_result_entry( - messages: list[dict[str, Any]], - pending_calls: list[dict[str, Any]], - entry: TapeEntry, -) -> None: - results = entry.payload.get("results") - if not isinstance(results, list): - return - for index, result in enumerate(results): - messages.append(_build_tool_result_message(result, pending_calls, index)) - - -def _build_tool_result_message( - result: object, - pending_calls: list[dict[str, Any]], - index: int, -) -> dict[str, Any]: - message: dict[str, Any] = {"role": "tool", "content": _render_tool_result(result)} - if index >= len(pending_calls): - return message - - call = pending_calls[index] - call_id = call.get("id") - if isinstance(call_id, str) and call_id: - message["tool_call_id"] = call_id - - function = call.get("function") - if isinstance(function, dict): - name = function.get("name") - if isinstance(name, str) and name: - message["name"] = name - return message - - -def _normalize_tool_calls(value: object) -> list[dict[str, Any]]: - if not isinstance(value, list): - return [] - calls: list[dict[str, Any]] = [] - for item in value: - if isinstance(item, dict): - calls.append(dict(item)) - return calls - - -def _render_tool_result(result: object) -> str: - if isinstance(result, str): - return result - try: - return json.dumps(result, ensure_ascii=False) - except TypeError: - return str(result) diff --git a/src/bub/tape/service.py b/src/bub/tape/service.py deleted file mode 100644 index 01a30d57..00000000 --- a/src/bub/tape/service.py +++ /dev/null @@ -1,216 +0,0 @@ -"""High-level tape service.""" - -from __future__ import annotations - -import contextlib -import json -import re -from collections.abc import Generator -from contextvars import ContextVar -from dataclasses import dataclass -from pathlib import Path -from typing import Any, cast - -from loguru import logger -from rapidfuzz import fuzz, process -from republic import LLM, TapeEntry -from republic.tape import Tape - -from bub.tape.anchors import AnchorSummary -from bub.tape.store import FileTapeStore - - -@dataclass(frozen=True) -class TapeInfo: - """Runtime tape info summary.""" - - name: str - entries: int - anchors: int - last_anchor: str | None - entries_since_last_anchor: int - last_token_usage: int | None - - -_tape_context: ContextVar[Tape] = ContextVar("tape") -WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") -MIN_FUZZY_QUERY_LENGTH = 3 -MIN_FUZZY_SCORE = 80 -MAX_FUZZY_CANDIDATES = 128 - - -def current_tape() -> str: - """Get the name of the current tape in context.""" - tape = _tape_context.get(None) - if tape is None: - return "-" - return tape.name # type: ignore[no-any-return] - - -class TapeService: - """Tape helper with app-specific operations.""" - - def __init__(self, llm: LLM, tape_name: str, *, store: FileTapeStore) -> None: - self._llm = llm - self._store = store - self._tape = llm.tape(tape_name) - self._bootstrapped = False - - @property - def tape(self) -> Tape: - return _tape_context.get(self._tape) - - @contextlib.contextmanager - def fork_tape(self) -> Generator[Tape, None, None]: - fork_name = self._store.fork(self._tape.name) - reset_token = _tape_context.set(self._llm.tape(fork_name)) - try: - yield _tape_context.get() - finally: - self._store.merge(fork_name, self._tape.name) - _tape_context.reset(reset_token) - logger.info("Merged forked tape '{}' back into '{}'", fork_name, self._tape.name) - - async def ensure_bootstrap_anchor(self) -> None: - if self._bootstrapped: - return - self._bootstrapped = True - anchors = list(await self._tape.query_async.kinds("anchor").all()) - if anchors: - return - await self.handoff("session/start", state={"owner": "human"}) - - async def handoff(self, name: str, *, state: dict[str, Any] | None = None) -> list[TapeEntry]: - return cast(list[TapeEntry], await self.tape.handoff_async(name, state=state)) - - async def append_event(self, name: str, data: dict[str, Any]) -> None: - await self.tape.append_async(TapeEntry.event(name, data=data)) - - async def append_system(self, content: str) -> None: - await self.tape.append_async(TapeEntry.system(content)) - - async def info(self) -> TapeInfo: - entries = list(await self._tape.query_async.all()) - anchors = [entry for entry in entries if entry.kind == "anchor"] - last_anchor = anchors[-1].payload.get("name") if anchors else None - if last_anchor is not None: - entries_since_last_anchor = [entry for entry in entries if entry.id > anchors[-1].id] - else: - entries_since_last_anchor = entries - last_token_usage: int | None = None - for entry in reversed(entries): - if entry.kind == "event" and entry.payload.get("name") == "run": - with contextlib.suppress(AttributeError): - token_usage = entry.payload.get("data", {}).get("usage", {}).get("total_tokens") - if token_usage and isinstance(token_usage, int): - last_token_usage = token_usage - break - - return TapeInfo( - name=self._tape.name, - entries=len(entries), - anchors=len(anchors), - last_anchor=str(last_anchor) if last_anchor else None, - entries_since_last_anchor=len(entries_since_last_anchor), - last_token_usage=last_token_usage, - ) - - async def reset(self, *, archive: bool = False) -> str: - archive_path: Path | None = None - if archive and self._store is not None: - archive_path = self._store.archive(self._tape.name) - await self._tape.reset_async() - state = {"owner": "human"} - if archive_path is not None: - state["archived"] = str(archive_path) - await self._tape.handoff_async("session/start", state=state) - return f"Archived: {archive_path}" if archive_path else "ok" - - async def anchors(self, *, limit: int = 20) -> list[AnchorSummary]: - entries = list(await self._tape.query_async.kinds("anchor").all()) - results: list[AnchorSummary] = [] - for entry in entries[-limit:]: - name = str(entry.payload.get("name", "-")) - state = entry.payload.get("state") - state_dict: dict[str, object] = dict(state) if isinstance(state, dict) else {} - results.append(AnchorSummary(name=name, state=state_dict)) - return results - - async def between_anchors(self, start: str, end: str, *, kinds: tuple[str, ...] = ()) -> list[TapeEntry]: - query = self.tape.query_async.between_anchors(start, end) - if kinds: - query = query.kinds(*kinds) - return list(await query.all()) - - async def after_anchor(self, anchor: str, *, kinds: tuple[str, ...] = ()) -> list[TapeEntry]: - query = self.tape.query_async.after_anchor(anchor) - if kinds: - query = query.kinds(*kinds) - return list(await query.all()) - - async def from_last_anchor(self, *, kinds: tuple[str, ...] = ()) -> list[TapeEntry]: - query = self.tape.query_async.last_anchor() - if kinds: - query = query.kinds(*kinds) - return list(await query.all()) - - async def search(self, query: str, *, limit: int = 20, all_tapes: bool = False) -> list[TapeEntry]: - normalized_query = query.strip().lower() - if not normalized_query: - return [] - results: list[TapeEntry] = [] - tapes = [self.tape] - if all_tapes: - tapes = [self._llm.tape(name) for name in self._store.list_tapes()] - - for tape in tapes: - count = 0 - for entry in reversed(list(await tape.query_async.kinds("message").all())): - payload_text = json.dumps(entry.payload, ensure_ascii=False) - entry_meta = getattr(entry, "meta", {}) - meta_text = json.dumps(entry_meta, ensure_ascii=False) - - if ( - normalized_query in payload_text.lower() or normalized_query in meta_text.lower() - ) or self._is_fuzzy_match(normalized_query, payload_text, meta_text): - results.append(entry) - count += 1 - if count >= limit: - break - return results - - @staticmethod - def _is_fuzzy_match(normalized_query: str, payload_text: str, meta_text: str) -> bool: - if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH: - return False - - query_tokens = WORD_PATTERN.findall(normalized_query) - if not query_tokens: - return False - query_phrase = " ".join(query_tokens) - window_size = len(query_tokens) - - source_tokens = WORD_PATTERN.findall(payload_text.lower()) + WORD_PATTERN.findall(meta_text.lower()) - if not source_tokens: - return False - - candidates: list[str] = [] - for token in source_tokens: - candidates.append(token) - if len(candidates) >= MAX_FUZZY_CANDIDATES: - break - - if window_size > 1: - max_window_start = len(source_tokens) - window_size + 1 - for idx in range(max(0, max_window_start)): - candidates.append(" ".join(source_tokens[idx : idx + window_size])) - if len(candidates) >= MAX_FUZZY_CANDIDATES: - break - - best_match = process.extractOne( - query_phrase, - candidates, - scorer=fuzz.WRatio, - score_cutoff=MIN_FUZZY_SCORE, - ) - return best_match is not None diff --git a/src/bub/tape/store.py b/src/bub/tape/store.py deleted file mode 100644 index 8c9a9ec7..00000000 --- a/src/bub/tape/store.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Persistent tape store implementation.""" - -from __future__ import annotations - -import json -import shutil -import threading -import uuid -from dataclasses import dataclass -from datetime import UTC, datetime -from hashlib import md5 -from pathlib import Path -from typing import cast -from urllib.parse import quote, unquote - -from republic.tape import InMemoryQueryMixin, TapeEntry - -TAPE_FILE_SUFFIX = ".jsonl" - - -@dataclass(frozen=True) -class TapePaths: - """Resolved tape paths for one workspace.""" - - home: Path - tape_root: Path - workspace_hash: str - - -class TapeFile: - """Helper for one tape file.""" - - def __init__(self, path: Path) -> None: - self.path = path - self.fork_start_id: int | None = None - self._lock = threading.Lock() - self._read_entries: list[TapeEntry] = [] - self._read_offset = 0 - - def copy_to(self, target: TapeFile) -> None: - if self.path.exists(): - shutil.copy2(self.path, target.path) - target._read_entries = self.read() - target.fork_start_id = self._next_id() - target._read_offset = self._read_offset - - def copy_from(self, source: TapeFile) -> None: - entries = [entry for entry in source.read() if entry.id >= (source.fork_start_id or 0)] - self._append_many(entries) - # Refresh to update intenal state - self.read() - - def _next_id(self) -> int: - if self._read_entries: - return cast(int, self._read_entries[-1].id + 1) - return 1 - - def _reset(self) -> None: - self._read_entries = [] - self._read_offset = 0 - - def reset(self) -> None: - with self._lock: - if self.path.exists(): - self.path.unlink() - self._reset() - - def read(self) -> list[TapeEntry]: - with self._lock: - return self._read_locked() - - def _read_locked(self) -> list[TapeEntry]: - if not self.path.exists(): - self._reset() - return [] - - file_size = self.path.stat().st_size - if file_size < self._read_offset: - # The file was truncated or replaced, so cached entries are stale. - self._reset() - - with self.path.open("r", encoding="utf-8") as handle: - handle.seek(self._read_offset) - for raw_line in handle: - line = raw_line.strip() - if not line: - continue - try: - payload = json.loads(line) - except json.JSONDecodeError: - continue - entry = self.entry_from_payload(payload) - if entry is not None: - self._read_entries.append(entry) - self._read_offset = handle.tell() - - return list(self._read_entries) - - @staticmethod - def entry_to_payload(entry: TapeEntry) -> dict[str, object]: - return { - "id": entry.id, - "kind": entry.kind, - "payload": dict(entry.payload), - "meta": dict(entry.meta), - "timestamp": entry.timestamp, - } - - @staticmethod - def entry_from_payload(payload: object) -> TapeEntry | None: - if not isinstance(payload, dict): - return None - entry_id = payload.get("id") - kind = payload.get("kind") - entry_payload = payload.get("payload") - meta = payload.get("meta") - if not isinstance(entry_id, int): - return None - if not isinstance(kind, str): - return None - if not isinstance(entry_payload, dict): - return None - if not isinstance(meta, dict): - meta = {} - timestamp = payload.get("timestamp", 0.0) - return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), timestamp) - - def append(self, entry: TapeEntry) -> None: - return self._append_many([entry]) - - def _append_many(self, entries: list[TapeEntry]) -> None: - if not entries: - return - - with self._lock: - # Keep cache and offset in sync before allocating new IDs. - self._read_locked() - with self.path.open("a", encoding="utf-8") as handle: - next_id = self._next_id() - for entry in entries: - stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta)) - handle.write(json.dumps(self.entry_to_payload(stored), ensure_ascii=False) + "\n") - self._read_entries.append(stored) - next_id += 1 - self._read_offset = handle.tell() - - def archive(self) -> Path | None: - if not self.path.exists(): - return None - stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") - archive_file = self.path.with_suffix(f"{TAPE_FILE_SUFFIX}.{stamp}.bak") - self.path.replace(archive_file) - return archive_file - - -class FileTapeStore(InMemoryQueryMixin): - """Append-only JSONL tape store compatible with Republic TapeStore protocol.""" - - def __init__(self, home: Path, workspace_path: Path) -> None: - self._paths = self._resolve_paths(home, workspace_path) - self._tape_files: dict[str, TapeFile] = {} - self._fork_start_ids: dict[str, int] = {} - self._lock = threading.Lock() - - def list_tapes(self) -> list[str]: - with self._lock: - tapes: list[str] = [] - prefix = f"{self._paths.workspace_hash}__" - for path in self._paths.tape_root.glob(f"{prefix}*{TAPE_FILE_SUFFIX}"): - encoded = path.name.removeprefix(prefix).removesuffix(TAPE_FILE_SUFFIX) - if not encoded or "__" in encoded: - continue - tapes.append(unquote(encoded)) - return sorted(set(tapes)) - - def fork(self, source: str) -> str: - fork_suffix = uuid.uuid4().hex[:8] - new_name = f"{source}__{fork_suffix}" - source_file = self._tape_file(source) - target_file = self._tape_file(new_name) - source_file.copy_to(target_file) - return new_name - - def merge(self, source: str, target: str) -> None: - source_file = self._tape_file(source) - target_file = self._tape_file(target) - target_file.copy_from(source_file) - source_file.path.unlink(missing_ok=True) - self._tape_files.pop(source, None) - - def reset(self, tape: str) -> None: - return self._tape_file(tape).reset() - - def read(self, tape: str) -> list[TapeEntry] | None: - tape_file = self._tape_file(tape) - if not tape_file.path.exists(): - return None - return tape_file.read() - - def append(self, tape: str, entry: TapeEntry) -> None: - return self._tape_file(tape).append(entry) - - def archive(self, tape: str) -> Path | None: - tape_file = self._tape_file(tape) - self._tape_files.pop(tape, None) - return tape_file.archive() - - def _tape_file(self, tape: str) -> TapeFile: - if tape not in self._tape_files: - encoded_name = quote(tape, safe="") - file_name = f"{self._paths.workspace_hash}__{encoded_name}{TAPE_FILE_SUFFIX}" - self._tape_files[tape] = TapeFile(self._paths.tape_root / file_name) - return self._tape_files[tape] - - @staticmethod - def _resolve_paths(home: Path, workspace_path: Path) -> TapePaths: - tape_root = (home / "tapes").resolve() - tape_root.mkdir(parents=True, exist_ok=True) - workspace_hash = md5(str(workspace_path.resolve()).encode("utf-8")).hexdigest() # noqa: S324 - return TapePaths(home=home, tape_root=tape_root, workspace_hash=workspace_hash) diff --git a/src/bub/tools/__init__.py b/src/bub/tools/__init__.py deleted file mode 100644 index 9e5a8c0c..00000000 --- a/src/bub/tools/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Tooling package.""" - -from bub.tools.progressive import ProgressiveToolView -from bub.tools.registry import ToolDescriptor, ToolRegistry - -__all__ = ["ProgressiveToolView", "ToolDescriptor", "ToolRegistry"] diff --git a/src/bub/tools/builtin.py b/src/bub/tools/builtin.py deleted file mode 100644 index 9fd6bc66..00000000 --- a/src/bub/tools/builtin.py +++ /dev/null @@ -1,478 +0,0 @@ -"""Built-in tool definitions.""" - -from __future__ import annotations - -import asyncio -import json -import os -import shutil -import uuid -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import TYPE_CHECKING -from urllib import parse as urllib_parse - -from apscheduler.jobstores.base import ConflictingIdError, JobLookupError -from apscheduler.triggers.cron import CronTrigger -from apscheduler.triggers.date import DateTrigger -from apscheduler.triggers.interval import IntervalTrigger -from pydantic import BaseModel, Field -from republic import ToolContext - -from bub.tape.service import TapeService -from bub.tools.registry import ToolRegistry - -if TYPE_CHECKING: - from bub.app.runtime import AppRuntime - -DEFAULT_OLLAMA_WEB_API_BASE = "https://ollama.com/api" -WEB_REQUEST_TIMEOUT_SECONDS = 20 -SUBPROCESS_TIMEOUT_SECONDS = 30 -MAX_FETCH_BYTES = 1_000_000 -WEB_USER_AGENT = "bub-web-tools/1.0" -SESSION_ID_ENV_VAR = "BUB_SESSION_ID" - - -class BashInput(BaseModel): - cmd: str = Field(..., description="Shell command") - cwd: str | None = Field(default=None, description="Working directory") - timeout_seconds: int = Field( - default=SUBPROCESS_TIMEOUT_SECONDS, ge=1, description="Maximum seconds to allow command to run" - ) - - -class ReadInput(BaseModel): - path: str = Field(..., description="File path") - offset: int = Field(default=0, ge=0) - limit: int | None = Field(default=None, ge=1) - - -class WriteInput(BaseModel): - path: str = Field(..., description="File path") - content: str = Field(..., description="File content") - - -class EditInput(BaseModel): - path: str = Field(..., description="File path") - old: str = Field(..., description="Search text") - new: str = Field(..., description="Replacement text") - start_line: int = Field(default=0, ge=0, description="Start line number to search from") - - -class FetchInput(BaseModel): - url: str = Field(..., description="URL") - - -class SearchInput(BaseModel): - query: str = Field(..., description="Search query") - max_results: int = Field(default=5, ge=1, le=10) - - -class HandoffInput(BaseModel): - name: str | None = Field(default=None, description="Anchor name") - summary: str | None = Field(default=None, description="Summary") - next_steps: str | None = Field(default=None, description="Next steps") - - -class ToolNameInput(BaseModel): - name: str = Field(..., description="Tool name") - - -class TapeSearchInput(BaseModel): - query: str = Field(..., description="Query") - limit: int = Field(default=20, ge=1) - - -class TapeResetInput(BaseModel): - archive: bool = Field(default=False) - - -class EmptyInput(BaseModel): - pass - - -class ScheduleAddInput(BaseModel): - after_seconds: int | None = Field(None, description="If set, schedule to run after this many seconds from now") - interval_seconds: int | None = Field(None, description="If set, repeat at this interval") - cron: str | None = Field( - None, description="If set, run with cron expression in crontab format: minute hour day month day_of_week" - ) - message: str = Field(..., description="Reminder message to send") - - -class ScheduleRemoveInput(BaseModel): - job_id: str = Field(..., description="Job id to remove") - - -def _resolve_path(workspace: Path, raw: str) -> Path: - path = Path(raw).expanduser() - if path.is_absolute(): - return path - return workspace / path - - -def _normalize_url(raw_url: str) -> str | None: - normalized = raw_url.strip() - if not normalized: - return None - - parsed = urllib_parse.urlparse(normalized) - if parsed.scheme and parsed.netloc: - if parsed.scheme not in {"http", "https"}: - return None - return normalized - - if parsed.scheme == "" and parsed.netloc == "" and parsed.path: - with_scheme = f"https://{normalized}" - parsed = urllib_parse.urlparse(with_scheme) - if parsed.netloc: - return with_scheme - - return None - - -def _normalize_api_base(raw_api_base: str) -> str | None: - normalized = raw_api_base.strip().rstrip("/") - if not normalized: - return None - - parsed = urllib_parse.urlparse(normalized) - if parsed.scheme in {"http", "https"} and parsed.netloc: - return normalized - return None - - -def _format_search_results(results: list[object]) -> str: - lines: list[str] = [] - for idx, item in enumerate(results, start=1): - if not isinstance(item, dict): - continue - title = str(item.get("title") or "(untitled)") - url = str(item.get("url") or "") - content = str(item.get("content") or "") - lines.append(f"{idx}. {title}") - if url: - lines.append(f" {url}") - if content: - lines.append(f" {content}") - return "\n".join(lines) if lines else "none" - - -def register_builtin_tools( - registry: ToolRegistry, - *, - workspace: Path, - tape: TapeService, - runtime: AppRuntime, -) -> None: - """Register built-in tools and internal commands.""" - from bub.tools.schedule import run_scheduled_reminder - - register = registry.register - - @register(name="bash", short_description="Run shell command", model=BashInput, context=True) - async def run_bash(params: BashInput, context: ToolContext) -> str: - """Execute bash in workspace. Non-zero exit raises an error. - IMPORTANT: please DO NOT use sleep to delay execution, use schedule.add tool instead. - """ - import dotenv - - cwd = params.cwd or str(workspace) - executable = shutil.which("bash") or "bash" - env = dict(os.environ) - workspace_env = workspace / ".env" - if workspace_env.is_file(): - env.update((k, v) for k, v in dotenv.dotenv_values(workspace_env).items() if v is not None) - env[SESSION_ID_ENV_VAR] = context.state.get("session_id", "") - completed = await asyncio.create_subprocess_exec( - executable, - "-lc", - params.cmd, - cwd=cwd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - ) - async with asyncio.timeout(params.timeout_seconds): - stdout_bytes, stderr_bytes = await completed.communicate() - stdout_text = (stdout_bytes or b"").decode("utf-8", errors="replace").strip() - stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace").strip() - if completed.returncode != 0: - message = stderr_text or stdout_text or f"exit={completed.returncode}" - raise RuntimeError(f"exit={completed.returncode}: {message}") - return stdout_text or "(no output)" - - @register(name="fs.read", short_description="Read file content", model=ReadInput) - def fs_read(params: ReadInput) -> str: - """Read UTF-8 text with optional offset and limit.""" - file_path = _resolve_path(workspace, params.path) - text = file_path.read_text(encoding="utf-8") - lines = text.splitlines() - start = min(params.offset, len(lines)) - end = len(lines) if params.limit is None else min(len(lines), start + params.limit) - return "\n".join(lines[start:end]) - - @register(name="fs.write", short_description="Write file content", model=WriteInput) - def fs_write(params: WriteInput) -> str: - """Write UTF-8 text to path, creating parent directory if needed.""" - file_path = _resolve_path(workspace, params.path) - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(params.content, encoding="utf-8") - return f"wrote: {file_path}" - - @register(name="fs.edit", short_description="Edit file content", model=EditInput) - def fs_edit(params: EditInput) -> str: - """Replace all occurrences of old text in file.""" - file_path = _resolve_path(workspace, params.path) - if not file_path.is_file(): - raise RuntimeError(f"file not found: {file_path}") - text = file_path.read_text(encoding="utf-8") - lines = text.splitlines() - start_line = min(params.start_line, len(lines)) - prev, to_replace = "\n".join(lines[:start_line]), "\n".join(lines[start_line:]) - if params.old not in to_replace: - raise RuntimeError(f"'{params.old}' not found in {file_path} from line {start_line}") - new_text = to_replace.replace(params.old, params.new) - file_path.write_text(f"{prev}\n{new_text}", encoding="utf-8") - return f"edited: {file_path}" - - @register(name="web.fetch", short_description="Fetch URL as markdown", model=FetchInput) - async def web_fetch_default(params: FetchInput) -> str: - """Fetch URL and convert HTML to markdown-like text.""" - import aiohttp - - url = _normalize_url(params.url) - if not url: - return "error: invalid url" - - try: - async with ( - aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=WEB_REQUEST_TIMEOUT_SECONDS)) as session, - session.get(url, headers={"User-Agent": WEB_USER_AGENT, "Accept": "text/markdown"}) as response, - ): - content_bytes = await response.content.read(MAX_FETCH_BYTES + 1) - truncated = len(content_bytes) > MAX_FETCH_BYTES - content = content_bytes[:MAX_FETCH_BYTES].decode("utf-8", errors="replace") - except aiohttp.ClientError as exc: - return f"HTTP error: {exc!s}" - if not content: - return "error: empty response body" - if truncated: - return f"{content}\n\n[truncated: response exceeded byte limit]" - return content - - @register(name="schedule.add", short_description="Add a cron schedule", model=ScheduleAddInput, context=True) - def schedule_add(params: ScheduleAddInput, context: ToolContext) -> str: - """Schedule a reminder message to be sent to current session in the future. You can specify either of the following scheduling options: - - after_seconds: run once after this many seconds from now - - interval_seconds: run repeatedly at this interval - - cron: run with cron expression in crontab format: minute hour day month day_of_week - """ - job_id = str(uuid.uuid4())[:8] - if params.after_seconds is not None: - trigger = DateTrigger(run_date=datetime.now(UTC) + timedelta(seconds=params.after_seconds)) - elif params.interval_seconds is not None: - trigger = IntervalTrigger(seconds=params.interval_seconds) - else: - try: - trigger = CronTrigger.from_crontab(params.cron) - except ValueError as exc: - raise RuntimeError(f"invalid cron expression: {params.cron}") from exc - - try: - job = runtime.scheduler.add_job( - run_scheduled_reminder, - trigger=trigger, - id=job_id, - kwargs={ - "message": params.message, - "session_id": context.state.get("session_id", ""), - "workspace": str(runtime.workspace), - }, - coalesce=True, - max_instances=1, - ) - except ConflictingIdError as exc: - raise RuntimeError(f"job id already exists: {job_id}") from exc - - next_run = "-" - if isinstance(job.next_run_time, datetime): - next_run = job.next_run_time.isoformat() - return f"scheduled: {job.id} next={next_run}" - - @register(name="schedule.remove", short_description="Remove a scheduled job", model=ScheduleRemoveInput) - def schedule_remove(params: ScheduleRemoveInput) -> str: - """Remove one scheduled job by id.""" - try: - runtime.scheduler.remove_job(params.job_id) - except JobLookupError as exc: - raise RuntimeError(f"job not found: {params.job_id}") from exc - return f"removed: {params.job_id}" - - @register(name="schedule.list", short_description="List scheduled jobs", model=EmptyInput, context=True) - def schedule_list(_params: EmptyInput, context: ToolContext) -> str: - """List scheduled jobs for current workspace.""" - jobs = runtime.scheduler.get_jobs() - rows: list[str] = [] - for job in jobs: - next_run = "-" - if isinstance(job.next_run_time, datetime): - next_run = job.next_run_time.isoformat() - message = str(job.kwargs.get("message", "")) - job_session = job.kwargs.get("session_id") - if job_session and job_session != context.state.get("session_id", ""): - continue - rows.append(f"{job.id} next={next_run} msg={message}") - - if not rows: - return "(no scheduled jobs)" - - return "\n".join(rows) - - if runtime.settings.ollama_api_key: - - @register(name="web.search", short_description="Search the web", model=SearchInput) - async def web_search_ollama(params: SearchInput) -> str: - import aiohttp - - api_key = runtime.settings.ollama_api_key - if not api_key: - return "error: ollama api key is not configured" - - api_base = _normalize_api_base(runtime.settings.ollama_api_base or DEFAULT_OLLAMA_WEB_API_BASE) - if not api_base: - return "error: invalid ollama api base url" - - endpoint = f"{api_base}/web_search" - payload = { - "query": params.query, - "max_results": params.max_results, - } - try: - async with ( - aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=WEB_REQUEST_TIMEOUT_SECONDS)) as session, - session.post( - endpoint, - json=payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - "User-Agent": WEB_USER_AGENT, - }, - ) as response, - ): - response_body = await response.text() - except aiohttp.ClientError as exc: - return f"HTTP error: {exc!s}" - - try: - data = json.loads(response_body) - except json.JSONDecodeError as exc: - return f"error: invalid json response: {exc!s}" - - results = data.get("results") - if not isinstance(results, list) or not results: - return "none" - return _format_search_results(results) - - else: - - @register(name="web.search", short_description="Search the web", model=SearchInput) - def web_search_default(params: SearchInput) -> str: - """Return a DuckDuckGo search URL for the query.""" - query = urllib_parse.quote_plus(params.query) - return f"https://duckduckgo.com/?q={query}" - - @register(name="help", short_description="Show command help", model=EmptyInput) - def command_help(_params: EmptyInput) -> str: - """Show Bub internal command usage and examples.""" - return ( - "Commands use ',' at line start.\n" - "Known names map to internal tools; other commands run through bash.\n" - "Examples:\n" - " ,help\n" - " ,git status\n" - " , ls -la\n" - " ,tools\n" - " ,tool.describe name=fs.read\n" - " ,tape.handoff name=phase-1 summary='Bootstrap complete'\n" - " ,tape.anchors\n" - " ,tape.info\n" - " ,tape.search query=error\n" - " ,schedule.add cron='*/5 * * * *' message='echo hello'\n" - " ,schedule.list\n" - " ,schedule.remove job_id=my-job\n" - " ,skills.list\n" - " ,quit\n" - ) - - @register(name="tools", short_description="List available tools", model=EmptyInput) - def list_tools(_params: EmptyInput) -> str: - """List all tools in compact mode.""" - return "\n".join(registry.compact_rows()) - - @register(name="tool.describe", short_description="Show tool detail", model=ToolNameInput) - def tool_describe(params: ToolNameInput) -> str: - """Expand one tool description and schema.""" - return registry.detail(params.name) - - @register(name="tape.handoff", short_description="Create anchor handoff", model=HandoffInput) - async def handoff(params: HandoffInput) -> str: - """Create tape anchor with optional summary and next_steps state.""" - anchor_name = params.name or "handoff" - state: dict[str, object] = {} - if params.summary: - state["summary"] = params.summary - if params.next_steps: - state["next_steps"] = params.next_steps - await tape.handoff(anchor_name, state=state or None) - return f"handoff created: {anchor_name}" - - @register(name="tape.anchors", short_description="List tape anchors", model=EmptyInput) - async def anchors(_params: EmptyInput) -> str: - """List recent tape anchors.""" - rows = [] - for anchor in await tape.anchors(limit=50): - rows.append(f"{anchor.name} state={json.dumps(anchor.state, ensure_ascii=False)}") - return "\n".join(rows) if rows else "(no anchors)" - - @register(name="tape.info", short_description="Show tape summary", model=EmptyInput) - async def tape_info(_params: EmptyInput) -> str: - """Show tape summary with entry and anchor counts.""" - info = await tape.info() - return "\n".join(( - f"tape={info.name}", - f"entries={info.entries}", - f"anchors={info.anchors}", - f"last_anchor={info.last_anchor or '-'}", - f"entries_since_last_anchor={info.entries_since_last_anchor}", - f"last_token_usage={info.last_token_usage or 'unknown'}", - )) - - @register(name="tape.search", short_description="Search tape entries", model=TapeSearchInput) - async def tape_search(params: TapeSearchInput) -> str: - """Search entries in tape by query. In reverse order.""" - entries = await tape.search(params.query, limit=params.limit) - if not entries: - return "(no matches)" - return "\n".join(f"#{entry.id} {entry.kind} {entry.payload}" for entry in entries) - - @register(name="tape.reset", short_description="Reset tape", model=TapeResetInput, context=True) - async def tape_reset(params: TapeResetInput, context: ToolContext) -> str: - """Reset current tape; can archive before clearing.""" - result = await tape.reset(archive=params.archive) - runtime.reset_session_context(context.state.get("session_id", "")) - return result - - @register(name="skills.list", short_description="List skills", model=EmptyInput) - def list_skills(_params: EmptyInput) -> str: - """List all discovered skills in compact form.""" - skills = runtime.discover_skills() - if not skills: - return "(no skills)" - return "\n".join(f"{skill.name}: {skill.description}" for skill in skills) - - @register(name="quit", short_description="Exit program", model=EmptyInput) - def quit_command(_params: EmptyInput) -> str: - """Request exit from interactive CLI.""" - return "exit" diff --git a/src/bub/tools/progressive.py b/src/bub/tools/progressive.py deleted file mode 100644 index b0fba931..00000000 --- a/src/bub/tools/progressive.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Progressive tool prompt rendering.""" - -from __future__ import annotations - -from dataclasses import dataclass, field - -from bub.tools.registry import ToolRegistry - - -@dataclass -class ProgressiveToolView: - """Renders compact tool view and expands schema on demand.""" - - registry: ToolRegistry - expanded: set[str] = field(default_factory=set) - - def note_selected(self, name: str) -> None: - if self.registry.has(name): - self.expanded.add(name) - - def all_tools(self) -> list[str]: - return [descriptor.name for descriptor in self.registry.descriptors()] - - def reset(self) -> None: - """Clear expanded tool details for a fresh prompt context.""" - self.expanded.clear() - - def note_hint(self, hint: str) -> bool: - """Expand one tool when hint matches tool name (case-insensitive).""" - - normalized = hint.casefold() - for descriptor in self.registry.descriptors(): - model_name = self.registry.to_model_name(descriptor.name) - if descriptor.name.casefold() != normalized and model_name.casefold() != normalized: - continue - self.expanded.add(descriptor.name) - return True - return False - - def compact_block(self) -> str: - lines = [""] - for row in self.registry.compact_rows(for_model=True): - lines.append(f" - {row}") - lines.append("") - return "\n".join(lines) - - def expanded_block(self) -> str: - if not self.expanded: - return "" - - lines = [""] - for name in sorted(self.expanded): - model_name = self.registry.to_model_name(name) - try: - detail = self.registry.detail(name, for_model=True) - except KeyError: - continue - lines.append(f' ') - for line in detail.splitlines(): - lines.append(f" {line}") - lines.append(" ") - lines.append("") - return "\n".join(lines) diff --git a/src/bub/tools/registry.py b/src/bub/tools/registry.py deleted file mode 100644 index 9a9fa1fa..00000000 --- a/src/bub/tools/registry.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Unified tool registry.""" - -from __future__ import annotations - -import builtins -import inspect -import json -import time -from collections.abc import Awaitable, Callable -from copy import deepcopy -from dataclasses import dataclass -from functools import wraps -from typing import Any, cast - -from loguru import logger -from pydantic import BaseModel -from republic import Tool, ToolContext, tool_from_model - - -def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str: - """Shorten text to width characters, cutting in the middle of words if needed. - - Unlike textwrap.shorten, this function can cut in the middle of a word, - ensuring long strings without spaces are still truncated properly. - """ - if len(text) <= width: - return text - - # Reserve space for placeholder - available = width - len(placeholder) - if available <= 0: - return placeholder - - return text[:available] + placeholder - - -@dataclass(frozen=True) -class ToolDescriptor: - """Tool metadata and runtime handle.""" - - name: str - short_description: str - detail: str - tool: Tool - source: str = "builtin" - - -class ToolRegistry: - """Registry for built-in tools, internal commands, and skill-backed tools.""" - - def __init__(self, allowed_tools: set[str] | None = None) -> None: - self._tools: dict[str, ToolDescriptor] = {} - self._allowed_tools = allowed_tools - - def register( - self, - *, - name: str, - short_description: str, - detail: str | None = None, - model: type[BaseModel] | None = None, - context: bool = False, - source: str = "builtin", - ) -> Callable[[Callable], ToolDescriptor | None]: - def decorator[**P, T](func: Callable[P, T | Awaitable[T]]) -> ToolDescriptor | None: - tool_detail = detail or func.__doc__ or "" - if ( - self._allowed_tools is not None - and name.casefold() not in self._allowed_tools - and self.to_model_name(name).casefold() not in self._allowed_tools - ): - return None - - @wraps(func) - async def handler(*args: P.args, **kwargs: P.kwargs) -> T: - context_arg = kwargs.get("context") if context else None - call_kwargs = {key: value for key, value in kwargs.items() if key != "context"} - if args and isinstance(args[0], BaseModel): - call_kwargs.update(args[0].model_dump()) - self._log_tool_call(name, call_kwargs, cast("ToolContext | None", context_arg)) - - start = time.monotonic() - try: - result = func(*args, **kwargs) - if inspect.isawaitable(result): - result = await result - except Exception: - logger.exception("tool.call.error name={}", name) - raise - else: - return result - finally: - duration = time.monotonic() - start - logger.info("tool.call.end name={} duration={:.3f}ms", name, duration * 1000) - - if model is not None: - tool = tool_from_model(model, handler, name=name, description=short_description, context=context) - else: - tool = Tool.from_callable(handler, name=name, description=short_description, context=context) - tool_desc = ToolDescriptor( - name=name, short_description=short_description, detail=tool_detail, tool=tool, source=source - ) - self._tools[name] = tool_desc - return tool_desc - - return decorator - - def has(self, name: str) -> bool: - return name in self._tools - - def get(self, name: str) -> ToolDescriptor | None: - return self._tools.get(name) - - def descriptors(self) -> builtins.list[ToolDescriptor]: - return sorted(self._tools.values(), key=lambda item: item.name) - - @staticmethod - def to_model_name(name: str) -> str: - return name.replace(".", "_") - - def compact_rows(self, *, for_model: bool = False) -> builtins.list[str]: - rows: builtins.list[str] = [] - for descriptor in self.descriptors(): - display_name = self.to_model_name(descriptor.name) if for_model else descriptor.name - if for_model and display_name != descriptor.name: - rows.append(f"{display_name} (command: {descriptor.name}): {descriptor.short_description}") - else: - rows.append(f"{display_name}: {descriptor.short_description}") - return rows - - def detail(self, name: str, *, for_model: bool = False) -> str: - descriptor = self.get(name) - if descriptor is None: - raise KeyError(name) - - schema = descriptor.tool.schema() - display_name = descriptor.name - command_name_line = "" - if for_model: - schema = deepcopy(schema) - display_name = self.to_model_name(descriptor.name) - function = schema.get("function") - if isinstance(function, dict): - function["name"] = display_name - if display_name != descriptor.name: - command_name_line = f"command_name: {descriptor.name}\n" - - return ( - f"name: {display_name}\n" - f"{command_name_line}" - f"source: {descriptor.source}\n" - f"description: {descriptor.short_description}\n" - f"detail: {descriptor.detail}\n" - f"schema: {schema}" - ) - - def model_tools(self) -> builtins.list[Tool]: - tools: builtins.list[Tool] = [] - seen_names: set[str] = set() - for descriptor in self.descriptors(): - model_name = self.to_model_name(descriptor.name) - if model_name in seen_names: - raise ValueError(f"Duplicate model tool name after conversion: {model_name}") - seen_names.add(model_name) - - base = descriptor.tool - tools.append( - Tool( - name=model_name, - description=base.description, - parameters=base.parameters, - handler=base.handler, - context=base.context, - ) - ) - return tools - - def _log_tool_call(self, name: str, kwargs: dict[str, Any], context: ToolContext | None) -> None: - params: list[str] = [] - for key, value in kwargs.items(): - try: - rendered = json.dumps(value, ensure_ascii=False) - except TypeError: - rendered = repr(value) - value = _shorten_text(rendered, width=60, placeholder="...") - if value.startswith('"') and not value.endswith('"'): - value = value + '"' - if value.startswith("{") and not value.endswith("}"): - value = value + "}" - if value.startswith("[") and not value.endswith("]"): - value = value + "]" - params.append(f"{key}={value}") - params_str = ", ".join(params) - logger.info("tool.call.start name={} {{ {} }}", name, params_str) - - async def execute( - self, - name: str, - *, - kwargs: dict[str, Any], - context: ToolContext | None = None, - ) -> Any: - descriptor = self.get(name) - if descriptor is None: - raise KeyError(name) - - if descriptor.tool.context: - kwargs["context"] = context - result = descriptor.tool.run(**kwargs) - if inspect.isawaitable(result): - result = await result - return result diff --git a/src/bub/tools/schedule.py b/src/bub/tools/schedule.py deleted file mode 100644 index 5f402e59..00000000 --- a/src/bub/tools/schedule.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -import subprocess -import sys - -from loguru import logger - -SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS = 300 - - -def run_scheduled_reminder(message: str, session_id: str, workspace: str | None = None) -> None: - if session_id.startswith("telegram:"): - chat_id = session_id.split(":", 1)[1] - message = ( - f"[Reminder for Telegram chat {chat_id}, after done, send a notice to this chat if necessary]\n{message}" - ) - command = [sys.executable, "-m", "bub.cli.app", "run", "--session-id", session_id, message] - - logger.info("running scheduled reminder via bub run session_id={} message={}", session_id, message) - try: - completed = subprocess.run( - command, - check=True, - cwd=workspace, - timeout=SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS, - ) - except subprocess.TimeoutExpired: - logger.error( - "scheduled reminder timed out after {}s session_id={}", - SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS, - session_id, - ) - except subprocess.CalledProcessError as exc: - logger.error("scheduled reminder failed with exit={}", exc.returncode) - else: - logger.info("scheduled reminder succeeded with exit={}", completed.returncode) diff --git a/src/bub/tools/view.py b/src/bub/tools/view.py deleted file mode 100644 index 41a0379c..00000000 --- a/src/bub/tools/view.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Tool view helpers.""" - -from __future__ import annotations - -from bub.tools.progressive import ProgressiveToolView - - -def render_tool_prompt_block(view: ProgressiveToolView) -> str: - """Render the combined tool prompt section.""" - - compact = view.compact_block() - expanded = view.expanded_block() - if not expanded: - return compact - return f"{compact}\n\n{expanded}" diff --git a/src/bub/types.py b/src/bub/types.py new file mode 100644 index 00000000..cd57eb9a --- /dev/null +++ b/src/bub/types.py @@ -0,0 +1,19 @@ +"""Framework-neutral data aliases.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +Envelope = Any +State = dict[str, Any] + + +@dataclass(frozen=True) +class TurnResult: + """Result of one complete message turn.""" + + session_id: str + prompt: str + model_output: str + outbounds: list[Envelope] = field(default_factory=list) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 79c4f5af..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -import inspect -from typing import Any - -import pytest - -from bub.tools.registry import ToolRegistry - - -@pytest.fixture(autouse=True) -def _patch_tool_registry_execute(monkeypatch: pytest.MonkeyPatch) -> None: - async def patched_execute( - self: ToolRegistry, - name: str, - *, - kwargs: dict[str, Any], - context: Any = None, - ) -> Any: - descriptor = self.get(name) - if descriptor is None: - raise KeyError(name) - - if descriptor.tool.context: - result = descriptor.tool.run(context=context, **kwargs) - else: - result = descriptor.tool.run(**kwargs) - - if inspect.isawaitable(result): - result = await result - return result - - monkeypatch.setattr(ToolRegistry, "execute", patched_execute) diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py deleted file mode 100644 index cf727417..00000000 --- a/tests/test_agent_loop.py +++ /dev/null @@ -1,76 +0,0 @@ -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass - -import pytest - -from bub.core.agent_loop import AgentLoop -from bub.core.model_runner import ModelTurnResult -from bub.core.router import UserRouteResult - - -@dataclass -class FakeRouter: - route: UserRouteResult - - async def route_user(self, _raw: str) -> UserRouteResult: - return self.route - - -@dataclass -class FakeRunner: - result: ModelTurnResult - - async def run(self, _prompt: str) -> ModelTurnResult: - return self.result - - -class FakeTape: - def __init__(self) -> None: - self.events: list[tuple[str, dict[str, object]]] = [] - - @contextmanager - def fork_tape(self) -> Generator["FakeTape", None, None]: - yield self - - async def append_event(self, name: str, data: dict[str, object]) -> None: - self.events.append((name, data)) - - -@pytest.mark.asyncio -async def test_loop_short_circuit_without_model() -> None: - loop = AgentLoop( - router=FakeRouter( - UserRouteResult( - enter_model=False, - model_prompt="", - immediate_output="ok", - exit_requested=False, - ) - ), # type: ignore[arg-type] - model_runner=FakeRunner(ModelTurnResult("", False, 0)), # type: ignore[arg-type] - tape=FakeTape(), # type: ignore[arg-type] - ) - result = await loop.handle_input(",help") - assert result.immediate_output == "ok" - assert result.assistant_output == "" - - -@pytest.mark.asyncio -async def test_loop_runs_model_when_router_requests() -> None: - loop = AgentLoop( - router=FakeRouter( - UserRouteResult( - enter_model=True, - model_prompt="context", - immediate_output="cmd error", - exit_requested=False, - ) - ), # type: ignore[arg-type] - model_runner=FakeRunner(ModelTurnResult("answer", False, 2)), # type: ignore[arg-type] - tape=FakeTape(), # type: ignore[arg-type] - ) - result = await loop.handle_input("bad cmd") - assert result.immediate_output == "cmd error" - assert result.assistant_output == "answer" - assert result.steps == 2 diff --git a/tests/test_bus.py b/tests/test_bus.py new file mode 100644 index 00000000..62246caf --- /dev/null +++ b/tests/test_bus.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bub.bus import MessageBus +from bub.framework import BubFramework + + +@pytest.mark.asyncio +async def test_handle_bus_once_publishes_outbound(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + bus = framework.create_bus() + assert isinstance(bus, MessageBus) + + await bus.publish_inbound({"channel": "stdout", "chat_id": "bus", "sender_id": "u1", "content": "from bus"}) + + result = await framework.handle_bus_once(bus, timeout_seconds=0.1) + outbound = await bus.next_outbound(timeout_seconds=0.1) + + assert result is not None + assert outbound is not None + assert "from bus" in str(outbound["content"]) diff --git a/tests/test_channels.py b/tests/test_channels.py deleted file mode 100644 index 10d46e06..00000000 --- a/tests/test_channels.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import asyncio - -import pytest - -from bub.channels.base import BaseChannel -from bub.channels.manager import ChannelManager - - -class _Settings: - telegram_enabled = False - discord_enabled = False - - -class _Runtime: - settings = _Settings() - - def install_hooks(self, manager: ChannelManager) -> None: - pass - - -class _FakeChannel(BaseChannel[object]): - name = "fake" - - def __init__(self, runtime) -> None: - super().__init__(runtime) - self.started = asyncio.Event() - self.stopped = False - - async def start(self, on_receive): # type: ignore[override] - _ = on_receive - self.started.set() - try: - await asyncio.Event().wait() - finally: - self.stopped = True - - async def get_session_prompt(self, message: object) -> tuple[str, str]: - _ = message - return "session", "prompt" - - def is_mentioned(self, message: object) -> bool: - _ = message - return True - - async def process_output(self, session_id: str, output) -> None: - _ = (session_id, output) - - -def test_channel_manager_rejects_duplicate_channel_name() -> None: - manager = ChannelManager(_Runtime()) # type: ignore[arg-type] - manager.register(_FakeChannel) - - with pytest.raises(ValueError, match="already registered"): - manager.register(_FakeChannel) - - -@pytest.mark.asyncio -async def test_channel_manager_starts_and_stops_registered_channels() -> None: - manager = ChannelManager(_Runtime()) # type: ignore[arg-type] - manager.register(_FakeChannel) - - task = asyncio.create_task(manager.run()) - channel = manager.channels["fake"] - await asyncio.wait_for(channel.started.wait(), timeout=1.0) - assert manager.enabled_channels() == ["fake"] - - task.cancel() - with pytest.raises(asyncio.CancelledError): - await asyncio.wait_for(task, timeout=1.0) - assert channel.stopped is True diff --git a/tests/test_cli_app.py b/tests/test_cli_app.py deleted file mode 100644 index cf17498a..00000000 --- a/tests/test_cli_app.py +++ /dev/null @@ -1,275 +0,0 @@ -import asyncio -import contextlib -import importlib -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from bub.core.agent_loop import LoopResult - -cli_app_module = importlib.import_module("bub.cli.app") - - -class DummyRuntime: - def __init__(self, workspace: Path) -> None: - self.workspace = workspace - - class _Settings: - model = "openrouter:test" - telegram_enabled = False - discord_enabled = False - telegram_token = None - telegram_allow_from = () - telegram_allow_chats = () - - @staticmethod - def resolve_home() -> Path: - return Path.cwd() - - self.settings = _Settings() - self.registry = type("_Registry", (), {"descriptors": staticmethod(lambda: [])})() - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb) -> None: - _ = (exc_type, exc, tb) - return None - - def set_bus(self, _bus) -> None: - return None - - def get_session(self, _session_id: str): - class _Tape: - @staticmethod - def info(): - class _Info: - entries = 0 - anchors = 0 - last_anchor = None - - return _Info() - - class _Session: - tape = _Tape() - tool_view = type("_ToolView", (), {"all_tools": staticmethod(lambda: [])})() - - return _Session() - - def install_hooks(self, _manager) -> None: - return None - - def handle_input(self, _session_id: str, _text: str): - raise AssertionError - - @contextlib.asynccontextmanager - async def graceful_shutdown(self): - stop_event = asyncio.Event() - yield stop_event - - -def test_chat_command_registers_cli_channel(monkeypatch, tmp_path: Path) -> None: - called: dict[str, object] = {} - - def _fake_build_runtime(workspace: Path, *, model=None, max_tokens=None, enable_scheduler=True): - assert workspace == tmp_path - assert enable_scheduler is True - return DummyRuntime(workspace) - - async def _fake_serve_channels(manager) -> None: - called["channels"] = manager.enabled_channels() - called["channel_type"] = type(manager.channels["cli"]).__name__ - - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - monkeypatch.setattr(cli_app_module, "_serve_channels", _fake_serve_channels) - - runner = CliRunner() - result = runner.invoke( - cli_app_module.app, - ["chat", "--workspace", str(tmp_path), "--session-id", "cli:test"], - ) - assert result.exit_code == 0 - assert called["channels"] == ["cli"] - assert called["channel_type"] == "CliChannel" - - -def test_run_command_expands_home_in_workspace(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, Path] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, _session_id: str, _text: str): - class _Result: - error = None - assistant_output = "ok" - immediate_output = "" - - return _Result() - - def _fake_build_runtime(workspace: Path, **_kwargs): - captured["workspace"] = workspace - return _RunRuntime(workspace) - - fake_home = tmp_path / "home" - fake_home.mkdir() - expected_workspace = (fake_home / "workspace").resolve() - - monkeypatch.setenv("HOME", str(fake_home)) - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - runner = CliRunner() - result = runner.invoke(cli_app_module.app, ["run", "ping", "--workspace", "~/workspace"]) - - assert result.exit_code == 0 - assert captured["workspace"] == expected_workspace - - -def test_message_command_requires_valid_subcommand_name(monkeypatch, tmp_path: Path) -> None: - def _fake_build_runtime(workspace: Path, *, model=None, max_tokens=None): - return DummyRuntime(workspace) - - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - runner = CliRunner() - result = runner.invoke(cli_app_module.app, ["telegram", "--workspace", str(tmp_path)]) - assert result.exit_code != 0 - assert "No such command 'telegram'" in result.output - - -def test_run_command_forwards_allowed_tools_and_skills(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, object] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, _session_id: str, _text: str): - return LoopResult( - immediate_output="", - assistant_output="ok", - exit_requested=False, - steps=1, - error=None, - ) - - def _fake_build_runtime( - workspace: Path, - *, - model=None, - max_tokens=None, - allowed_tools=None, - allowed_skills=None, - enable_scheduler=True, - ): - captured["workspace"] = workspace - captured["model"] = model - captured["max_tokens"] = max_tokens - captured["allowed_tools"] = allowed_tools - captured["allowed_skills"] = allowed_skills - captured["enable_scheduler"] = enable_scheduler - return _RunRuntime(workspace) - - monkeypatch.setattr(cli_app_module, "build_runtime", _fake_build_runtime) - runner = CliRunner() - result = runner.invoke( - cli_app_module.app, - [ - "run", - "ping", - "--workspace", - str(tmp_path), - "--tools", - "fs.read, web.search", - "--tools", - "bash", - "--skills", - "skill-a, skill-b", - ], - ) - - assert result.exit_code == 0 - assert "ok" in result.output - assert captured["workspace"] == tmp_path - assert captured["allowed_tools"] == {"fs.read", "web.search", "bash"} - assert captured["allowed_skills"] == {"skill-a", "skill-b"} - assert captured["enable_scheduler"] is True - - -def test_run_command_uses_env_session_id_by_default(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, object] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, session_id: str, _text: str): - captured["session_id"] = session_id - return LoopResult( - immediate_output="", - assistant_output="ok", - exit_requested=False, - steps=1, - error=None, - ) - - monkeypatch.setenv("BUB_SESSION_ID", "parent-session") - monkeypatch.setattr(cli_app_module, "build_runtime", lambda workspace, **_: _RunRuntime(workspace)) - runner = CliRunner() - result = runner.invoke(cli_app_module.app, ["run", "ping", "--workspace", str(tmp_path)]) - - assert result.exit_code == 0 - assert captured["session_id"] == "parent-session" - - -def test_run_command_session_id_option_overrides_env(monkeypatch, tmp_path: Path) -> None: - captured: dict[str, object] = {} - - class _RunRuntime(DummyRuntime): - async def handle_input(self, session_id: str, _text: str): - captured["session_id"] = session_id - return LoopResult( - immediate_output="", - assistant_output="ok", - exit_requested=False, - steps=1, - error=None, - ) - - monkeypatch.setenv("BUB_SESSION_ID", "parent-session") - monkeypatch.setattr(cli_app_module, "build_runtime", lambda workspace, **_: _RunRuntime(workspace)) - runner = CliRunner() - result = runner.invoke( - cli_app_module.app, - ["run", "ping", "--workspace", str(tmp_path), "--session-id", "explicit-session"], - ) - - assert result.exit_code == 0 - assert captured["session_id"] == "explicit-session" - - -@pytest.mark.asyncio -async def test_serve_channels_stops_manager_on_sigterm(monkeypatch) -> None: - class _DummyRuntime: - def __init__(self) -> None: - self.stop_event: asyncio.Event | None = None - - @contextlib.asynccontextmanager - async def graceful_shutdown(self): - stop_event = asyncio.Event() - self.stop_event = stop_event - yield stop_event - - class _DummyManager: - def __init__(self) -> None: - self.calls: list[str] = [] - self.runtime = _DummyRuntime() - - async def run(self) -> None: - self.calls.append("start") - try: - await asyncio.Event().wait() - finally: - self.calls.append("stop") - - manager = _DummyManager() - - task = asyncio.create_task(cli_app_module._serve_channels(manager)) - await asyncio.sleep(0.05) - assert manager.calls == ["start"] - assert manager.runtime.stop_event is not None - manager.runtime.stop_event.set() - await asyncio.wait_for(task, timeout=1.0) - - assert manager.calls == ["start", "stop"] diff --git a/tests/test_cli_channel.py b/tests/test_cli_channel.py deleted file mode 100644 index b8b32017..00000000 --- a/tests/test_cli_channel.py +++ /dev/null @@ -1,64 +0,0 @@ -from pathlib import Path - -from bub.channels.cli import CliChannel - - -class _DummyRuntime: - def __init__(self) -> None: - self.workspace = Path.cwd() - - class _Settings: - model = "openrouter:test" - - @staticmethod - def resolve_home() -> Path: - return Path.cwd() - - self.settings = _Settings() - - def get_session(self, _session_id: str): - class _Tape: - @staticmethod - def info(): - class _Info: - entries = 0 - anchors = 0 - last_anchor = None - - return _Info() - - class _Session: - tape = _Tape() - tool_view = type("_ToolView", (), {"all_tools": staticmethod(lambda: [])})() - - return _Session() - - -def test_normalize_input_keeps_agent_mode_text() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - cli._mode = "agent" - assert cli._normalize_input("echo hi") == "echo hi" - - -def test_normalize_input_adds_shell_prefix_in_shell_mode() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - cli._mode = "shell" - assert cli._normalize_input("echo hi") == ", echo hi" - - -def test_normalize_input_keeps_explicit_prefixes_in_shell_mode() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - cli._mode = "shell" - assert cli._normalize_input(",help") == ",help" - assert cli._normalize_input(",ls -la") == ",ls -la" - assert cli._normalize_input(", ls -la") == ", ls -la" - - -def test_cli_channel_disables_debounce() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - assert cli.debounce_enabled is False - - -def test_cli_channel_does_not_wrap_prompt() -> None: - cli = CliChannel(_DummyRuntime()) # type: ignore[arg-type] - assert cli.format_prompt("plain prompt") == "plain prompt" diff --git a/tests/test_command_detector.py b/tests/test_command_detector.py deleted file mode 100644 index 14ad3063..00000000 --- a/tests/test_command_detector.py +++ /dev/null @@ -1,36 +0,0 @@ -from bub.core.command_detector import detect_line_command - - -def test_detect_internal_command() -> None: - command = detect_line_command(",help") - assert command is not None - assert command.kind == "internal" - assert command.name == "help" - - -def test_detect_shell_command() -> None: - command = detect_line_command("echo hello") - assert command is not None - assert command.kind == "shell" - assert command.name == "echo" - - -def test_non_command_text_returns_none() -> None: - assert detect_line_command("请帮我总结今天的改动") is None - - -def test_patch_assignment_text_is_not_detected_as_shell_command() -> None: - line = 'new_text="def _is_shell_command(line: str) -> bool:\\n return False"' - assert detect_line_command(line) is None - - -def test_very_long_assignment_text_does_not_crash_or_detect_as_command() -> None: - long_payload = f'new_text="{"a/" * 400}end"' - assert detect_line_command(long_payload) is None - - -def test_env_prefixed_shell_command_is_detected() -> None: - command = detect_line_command("FOO=bar echo hello") - assert command is not None - assert command.kind == "shell" - assert command.name == "echo" diff --git a/tests/test_fault_tolerance.py b/tests/test_fault_tolerance.py new file mode 100644 index 00000000..a381b981 --- /dev/null +++ b/tests/test_fault_tolerance.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bub.framework import BubFramework + + +def _write_broken_skill(workspace: Path) -> None: + broken = workspace / ".agent" / "skills" / "broken" + broken.mkdir(parents=True) + (broken / "SKILL.md").write_text( + "\n".join( + [ + "---", + "name: broken", + "description: broken skill", + "kind: model", + "entrypoint: missing.module:plugin", + "---", + ] + ), + encoding="utf-8", + ) + + +@pytest.mark.asyncio +async def test_broken_skill_does_not_break_framework(tmp_path: Path) -> None: + _write_broken_skill(tmp_path) + + framework = BubFramework(tmp_path) + framework.load_skills() + + assert "broken" in framework.failed_skills + result = await framework.process_inbound({"channel": "stdout", "chat_id": "c1", "sender_id": "u1", "content": "still works"}) + assert "still works" in result.model_output + + +def _write_runtime_error_skill(workspace: Path) -> None: + package = workspace / "runtime_plugins" + package.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "broken_output.py").write_text( + "\n".join( + [ + "from bub.hookspecs import hookimpl", + "", + "class BrokenOutputSkill:", + " @hookimpl", + " def render_outbound(self, message, session_id, state, model_output):", + " raise RuntimeError('output broke on purpose')", + "", + "plugin = BrokenOutputSkill()", + ] + ), + encoding="utf-8", + ) + + skill_dir = workspace / ".agent" / "skills" / "broken-output" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "\n".join( + [ + "---", + "name: broken-output", + "description: runtime broken output skill", + "kind: output", + "entrypoint: runtime_plugins.broken_output:plugin", + "---", + ] + ), + encoding="utf-8", + ) + + +@pytest.mark.asyncio +async def test_runtime_broken_skill_isolated_from_main_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _write_runtime_error_skill(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + framework = BubFramework(tmp_path) + framework.load_skills() + + result = await framework.process_inbound({"channel": "stdout", "chat_id": "c2", "sender_id": "u1", "content": "safe"}) + + assert "broken-output" not in framework.failed_skills + assert result.outbounds + assert "safe" in result.model_output diff --git a/tests/test_framework_flow.py b/tests/test_framework_flow.py new file mode 100644 index 00000000..1d48d33a --- /dev/null +++ b/tests/test_framework_flow.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import typer + +from bub.framework import BubFramework + + +@pytest.mark.asyncio +async def test_framework_processes_message_with_builtin_skills(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + + result = await framework.process_inbound( + {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": "hello framework"} + ) + + assert result.session_id == "stdout:local" + assert "hello framework" in result.prompt + assert result.model_output.startswith("[stdout:local] turn=1") + assert result.outbounds + assert result.outbounds[0]["content"] == result.model_output + + +@pytest.mark.asyncio +async def test_framework_increments_state_across_turns(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + + first = await framework.process_inbound({"channel": "stdout", "chat_id": "same", "sender_id": "u1", "content": "first"}) + second = await framework.process_inbound( + {"channel": "stdout", "chat_id": "same", "sender_id": "u1", "content": "second"} + ) + + assert "turn=1" in first.model_output + assert "turn=2" in second.model_output + + +@pytest.mark.asyncio +async def test_framework_accepts_user_defined_message_object(tmp_path: Path) -> None: + class CustomMessage: + def __init__(self, *, channel: str, chat_id: str, sender_id: str, content: str) -> None: + self.channel = channel + self.chat_id = chat_id + self.sender_id = sender_id + self.content = content + + framework = BubFramework(tmp_path) + framework.load_skills() + + result = await framework.process_inbound( + CustomMessage(channel="stdout", chat_id="obj", sender_id="u1", content="object message") + ) + + assert result.session_id == "stdout:obj" + assert "object message" in result.model_output + + +def test_framework_registers_cli_commands_from_skills(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + app = typer.Typer() + + framework.register_cli_commands(app) + + command_names = {command.name for command in app.registered_commands} + assert {"run", "skills", "hooks"}.issubset(command_names) diff --git a/tests/test_model_runner.py b/tests/test_model_runner.py deleted file mode 100644 index 4a9ed0a4..00000000 --- a/tests/test_model_runner.py +++ /dev/null @@ -1,454 +0,0 @@ -from dataclasses import dataclass, field - -import pytest -from republic import ToolAutoResult - -from bub.core.model_runner import TOOL_CONTINUE_PROMPT, ModelRunner -from bub.core.router import AssistantRouteResult -from bub.skills.loader import SkillMetadata - - -class FakeRouter: - def __init__(self) -> None: - self._calls = 0 - - async def route_assistant(self, raw: str) -> AssistantRouteResult: - self._calls += 1 - if self._calls == 1: - assert raw == "assistant-first" - return AssistantRouteResult(visible_text="v1", next_prompt="one", exit_requested=False) - assert raw == "assistant-second" - return AssistantRouteResult(visible_text="v2", next_prompt="", exit_requested=False) - - -class SingleStepRouter: - async def route_assistant(self, raw: str) -> AssistantRouteResult: - assert raw == "assistant-only" - return AssistantRouteResult(visible_text="done", next_prompt="", exit_requested=False) - - -class AnySingleStepRouter: - async def route_assistant(self, raw: str) -> AssistantRouteResult: - assert raw - return AssistantRouteResult(visible_text="done", next_prompt="", exit_requested=False) - - -class FollowupRouter: - def __init__(self, *, first: str, second: str) -> None: - self._calls = 0 - self._first = first - self._second = second - - async def route_assistant(self, raw: str) -> AssistantRouteResult: - self._calls += 1 - if self._calls == 1: - assert raw == self._first - return AssistantRouteResult( - visible_text="", next_prompt="followup", exit_requested=False - ) - assert raw == self._second - return AssistantRouteResult(visible_text="done", next_prompt="", exit_requested=False) - - -class ToolFollowupRouter: - async def route_assistant(self, raw: str) -> AssistantRouteResult: - assert raw == "assistant-after-tool" - return AssistantRouteResult(visible_text="tool done", next_prompt="", exit_requested=False) - - -class FakeToolView: - def __init__(self) -> None: - self.expanded: set[str] = set() - - def compact_block(self) -> str: - return "" - - def expanded_block(self) -> str: - if not self.expanded: - return "" - lines = [""] - for name in sorted(self.expanded): - lines.append(f' ') - lines.append("") - return "\n".join(lines) - - def note_hint(self, hint: str) -> bool: - normalized = hint.casefold() - if normalized == "fs.read": - self.expanded.add("fs.read") - return True - return False - - -@dataclass -class FakeTapeImpl: - class _Query: - def kinds(self, *_kinds: str) -> "FakeTapeImpl._Query": - return self - - def last_anchor(self) -> "FakeTapeImpl._Query": - return self - - def all(self) -> list[object]: - return [] - - outputs: list[ToolAutoResult] - calls: list[tuple[str, str, int]] = field(default_factory=list) - call_kwargs: list[dict[str, object]] = field(default_factory=list) - query: _Query = field(default_factory=_Query) - - async def run_tools_async( - self, - *, - prompt: str, - system_prompt: str, - max_tokens: int, - tools: list[object], - **kwargs: object, - ) -> ToolAutoResult: - self.calls.append((prompt, system_prompt, max_tokens)) - self.call_kwargs.append(kwargs) - return self.outputs.pop(0) - - -@dataclass -class FakeTapeService: - tape: FakeTapeImpl - events: list[tuple[str, dict[str, object]]] = field(default_factory=list) - - async def append_event(self, name: str, data: dict[str, object]) -> None: - self.events.append((name, data)) - - -@pytest.mark.asyncio -async def test_model_runner_follows_command_context_until_stop() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.text_result("assistant-first"), - ToolAutoResult.text_result("assistant-second"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=FakeRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=5, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "workspace", - ) - - result = await runner.run("start") - assert result.visible_text == "v1\n\nv2" - assert result.steps == 2 - assert result.command_followups == 1 - assert result.error is None - - -@pytest.mark.asyncio -async def test_model_runner_continues_after_tool_execution() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.tools_result( - tool_calls=[{"function": {"name": "fs.write", "arguments": '{"path":"tmp.txt","content":"hi"}'}}], - tool_results=["ok"], - ), - ToolAutoResult.text_result("assistant-after-tool"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=ToolFollowupRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=3, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "workspace", - ) - - result = await runner.run("create file") - assert result.visible_text == "tool done" - assert result.steps == 2 - assert result.command_followups == 1 - assert tape.tape.calls[1][0] == TOOL_CONTINUE_PROMPT - - -@pytest.mark.asyncio -async def test_model_runner_tool_followup_does_not_inline_tool_payload() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.tools_result( - tool_calls=[ - { - "function": { - "name": 'fs.write"unsafe', - "arguments": '{"path":"tmp/.txt","content":"x & y"}', - } - } - ], - tool_results=['ok & "quoted"'], - ), - ToolAutoResult.text_result("assistant-after-tool"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=ToolFollowupRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=3, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "workspace", - ) - - await runner.run("create file") - followup_prompt = tape.tape.calls[1][0] - assert followup_prompt == TOOL_CONTINUE_PROMPT - - -@pytest.mark.asyncio -async def test_model_runner_expands_skill_from_hint() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - skill = SkillMetadata( - name="friendly-python", - description="style", - location=__file__, # type: ignore[arg-type] - body="content", - source="project", - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=AnySingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [skill], - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("please follow $friendly-python") - _, system_prompt, _ = tape.tape.calls[0] - assert "" in system_prompt - assert "friendly-python" in system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_expands_skill_from_assistant_hint() -> None: - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.text_result("assistant mentions $friendly-python"), - ToolAutoResult.text_result("assistant-second"), - ] - ) - ) - skill = SkillMetadata( - name="friendly-python", - description="style", - location=__file__, # type: ignore[arg-type] - body="content", - source="project", - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=FollowupRouter(first="assistant mentions $friendly-python", second="assistant-second"), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [skill], - model="openrouter:test", - max_steps=2, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("no skill hint here") - _, second_system_prompt, _ = tape.tape.calls[1] - assert "" in second_system_prompt - assert "friendly-python" in second_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_expands_tool_from_user_hint() -> None: - tool_view = FakeToolView() - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=AnySingleStepRouter(), # type: ignore[arg-type] - tool_view=tool_view, # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("use $fs.read") - _, first_system_prompt, _ = tape.tape.calls[0] - assert "" in first_system_prompt - assert '' in first_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_expands_tool_from_assistant_hint() -> None: - tool_view = FakeToolView() - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ - ToolAutoResult.text_result("assistant mentions $fs.read"), - ToolAutoResult.text_result("assistant-second"), - ] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=FollowupRouter(first="assistant mentions $fs.read", second="assistant-second"), # type: ignore[arg-type] - tool_view=tool_view, # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=2, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("no tool hint here") - _, second_system_prompt, _ = tape.tape.calls[1] - assert "" in second_system_prompt - assert '' in second_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_refreshes_skills_from_provider_between_runs() -> None: - skill = SkillMetadata( - name="friendly-python", - description="style", - location=__file__, # type: ignore[arg-type] - body="content", - source="project", - ) - all_skills: list[SkillMetadata] = [] - - tape = FakeTapeService( - FakeTapeImpl( - outputs=[ToolAutoResult.text_result("assistant-only"), ToolAutoResult.text_result("assistant-only")] - ) - ) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=AnySingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: list(all_skills), - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - await runner.run("first run") - _, first_system_prompt, _ = tape.tape.calls[0] - assert "friendly-python" not in first_system_prompt - - all_skills.append(skill) - await runner.run("second run") - _, second_system_prompt, _ = tape.tape.calls[1] - assert "" in second_system_prompt - assert "friendly-python" in second_system_prompt - - -@pytest.mark.asyncio -async def test_model_runner_passes_extra_headers_for_openrouter() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=SingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="openrouter:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("hi") - kwargs = tape.tape.call_kwargs[0] - assert kwargs.get("extra_headers") == ModelRunner.DEFAULT_HEADERS - - -@pytest.mark.asyncio -async def test_model_runner_maps_headers_for_vertexai() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=SingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="vertexai:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("hi") - kwargs = tape.tape.call_kwargs[0] - assert "extra_headers" not in kwargs - assert kwargs["http_options"] == {"headers": ModelRunner.DEFAULT_HEADERS} - - -@pytest.mark.asyncio -async def test_model_runner_uses_extra_headers_for_unknown_provider() -> None: - tape = FakeTapeService(FakeTapeImpl(outputs=[ToolAutoResult.text_result("assistant-only")])) - runner = ModelRunner( - tape=tape, # type: ignore[arg-type] - router=SingleStepRouter(), # type: ignore[arg-type] - tool_view=FakeToolView(), # type: ignore[arg-type] - tools=[], - list_skills=lambda: [], - model="custom:test", - max_steps=1, - max_tokens=512, - model_timeout_seconds=90, - base_system_prompt="base", - get_workspace_system_prompt=lambda: "", - ) - - await runner.run("hi") - kwargs = tape.tape.call_kwargs[0] - assert kwargs.get("extra_headers") == ModelRunner.DEFAULT_HEADERS - assert "http_options" not in kwargs diff --git a/tests/test_router.py b/tests/test_router.py deleted file mode 100644 index a740d616..00000000 --- a/tests/test_router.py +++ /dev/null @@ -1,238 +0,0 @@ -from dataclasses import dataclass, field -from pathlib import Path - -import pytest -from pydantic import BaseModel, Field - -from bub.core.router import InputRouter -from bub.tools.progressive import ProgressiveToolView -from bub.tools.registry import ToolRegistry - - -class BashInput(BaseModel): - cmd: str = Field(...) - cwd: str | None = Field(default=None) - - -class EmptyInput(BaseModel): - pass - - -@dataclass -class _FakeContext: - state: dict[str, object] = field(default_factory=lambda: {"session_id": "cli:test"}) - - -@dataclass -class _FakeTapeHandle: - name: str = "test-tape" - context: _FakeContext = field(default_factory=_FakeContext) - - -@dataclass -class FakeTape: - events: list[tuple[str, dict[str, object]]] = field(default_factory=list) - tape: _FakeTapeHandle = field(default_factory=_FakeTapeHandle) - - async def append_event(self, name: str, data: dict[str, object]) -> None: - self.events.append((name, data)) - - -def _build_router( - *, - bash_error: bool = False, - bash_output: str = "ok from bash", - bash_error_message: str = "", -) -> InputRouter: - registry = ToolRegistry() - - def run_bash(params: BashInput) -> str: - if bash_error: - raise RuntimeError(bash_error_message or "bash failed") - return bash_output - - def command_help(_params: EmptyInput) -> str: - return "help text" - - def quit_command(_params: EmptyInput) -> str: - return "exit" - - registry.register( - name="bash", - short_description="Run shell command", - detail="bash detail", - model=BashInput, - )(run_bash) - registry.register( - name="help", - short_description="help", - detail="help detail", - model=EmptyInput, - )(command_help) - registry.register( - name="quit", - short_description="quit", - detail="quit detail", - model=EmptyInput, - )(quit_command) - - view = ProgressiveToolView(registry) - return InputRouter(registry, view, FakeTape(), Path.cwd()) - - -@pytest.mark.asyncio -async def test_user_internal_command_short_circuits_model() -> None: - router = _build_router() - result = await router.route_user(",help") - assert result.enter_model is False - assert result.immediate_output == "help text" - - -@pytest.mark.asyncio -async def test_user_shell_success_short_circuits_model() -> None: - router = _build_router() - for text in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_user(text) - assert result.enter_model is False - assert result.immediate_output == "ok from bash" - - -@pytest.mark.asyncio -async def test_user_shell_failure_falls_back_to_model() -> None: - router = _build_router(bash_error=True) - for text in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_user(text) - assert result.enter_model is True - assert '' in result.model_prompt - - -@pytest.mark.asyncio -async def test_user_natural_language_starting_with_command_word_goes_to_model() -> None: - router = _build_router() - result = await router.route_user("write another python file buggy , just run , then wait ten second then fix") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt.startswith("write another") - - -@pytest.mark.asyncio -async def test_user_plain_shell_like_text_without_prefix_goes_to_model() -> None: - router = _build_router() - result = await router.route_user("echo hi") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt == "echo hi" - - -@pytest.mark.asyncio -async def test_user_non_line_start_comma_text_goes_to_model() -> None: - router = _build_router() - result = await router.route_user("please run ,echo hi") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt == "please run ,echo hi" - - -@pytest.mark.asyncio -async def test_user_dollar_prefix_goes_to_model_as_plain_text() -> None: - router = _build_router() - result = await router.route_user("$echo hi") - assert result.enter_model is True - assert result.immediate_output == "" - assert result.model_prompt == "$echo hi" - - -@pytest.mark.asyncio -async def test_assistant_plain_shell_text_is_not_executed() -> None: - router = _build_router() - result = await router.route_assistant("will run command\necho hi") - assert result.visible_text == "will run command\necho hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_assistant_non_line_start_comma_text_is_not_executed() -> None: - router = _build_router() - result = await router.route_assistant("please run ,echo hi") - assert result.visible_text == "please run ,echo hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_assistant_legacy_dollar_prefix_is_visible_text() -> None: - router = _build_router() - result = await router.route_assistant("$ echo hi") - assert result.visible_text == "$ echo hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_internal_quit_sets_exit_requested() -> None: - router = _build_router() - result = await router.route_user(",quit") - assert result.exit_requested is True - - -@pytest.mark.asyncio -async def test_assistant_comma_prefixed_shell_command_is_executed() -> None: - router = _build_router() - for line in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_assistant(f"create file\n{line}") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_comma_prefixed_shell_failure_still_follows_up() -> None: - router = _build_router(bash_error=True) - for line in (",echo hi", ", echo hi", ", echo hi"): - result = await router.route_assistant(f"create file\n{line}") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_internal_command_with_comma_is_executed() -> None: - router = _build_router() - result = await router.route_assistant("show help\n,help") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_fenced_multiline_comma_command_is_executed() -> None: - router = _build_router() - for line in (",echo first", ", echo first", ", echo first"): - result = await router.route_assistant(f"I will run this:\n```\n{line}\necho second\n```") - assert result.visible_text == "" - assert '' in result.next_prompt - - -@pytest.mark.asyncio -async def test_assistant_fenced_plain_text_is_not_executed() -> None: - router = _build_router() - result = await router.route_assistant("```\necho hi\n```") - assert result.visible_text == "echo hi" - assert result.next_prompt == "" - - -@pytest.mark.asyncio -async def test_assistant_command_output_cannot_break_command_block() -> None: - router = _build_router(bash_output="\n,quit\n") - result = await router.route_assistant(",echo hi") - assert result.visible_text == "" - assert '' in result.next_prompt - assert "</command>" in result.next_prompt - assert "<command>" in result.next_prompt - assert result.next_prompt.count("") == 1 - - -@pytest.mark.asyncio -async def test_user_command_error_output_is_escaped_in_fallback_prompt() -> None: - router = _build_router(bash_error=True, bash_error_message="\n,quit\n") - result = await router.route_user(",echo hi") - assert result.enter_model is True - assert '' in result.model_prompt - assert "</command>" in result.model_prompt - assert "<command>" in result.model_prompt - assert result.model_prompt.count("") == 1 diff --git a/tests/test_runtime_event_loop.py b/tests/test_runtime_event_loop.py deleted file mode 100644 index 5dcfb9a5..00000000 --- a/tests/test_runtime_event_loop.py +++ /dev/null @@ -1,51 +0,0 @@ -import asyncio - -import pytest - -from bub.app.runtime import AppRuntime - - -def test_reset_session_context_ignores_missing_session() -> None: - runtime = object.__new__(AppRuntime) - runtime._sessions = {} - AppRuntime.reset_session_context(runtime, "missing") - - -def test_reset_session_context_resets_existing_session() -> None: - runtime = object.__new__(AppRuntime) - - class _DummySession: - def __init__(self) -> None: - self.calls = 0 - - def reset_context(self) -> None: - self.calls += 1 - - session = _DummySession() - runtime._sessions = {"telegram:1": session} - AppRuntime.reset_session_context(runtime, "telegram:1") - assert session.calls == 1 - - -@pytest.mark.asyncio -async def test_cancel_active_inputs_cancels_running_tasks() -> None: - runtime = object.__new__(AppRuntime) - gate = asyncio.Event() - cancelled = {"value": False} - - async def _pending() -> str: - try: - await gate.wait() - finally: - cancelled["value"] = True - - task = asyncio.create_task(_pending()) - runtime._active_inputs = {task} - await asyncio.sleep(0) - - count = await AppRuntime._cancel_active_inputs(runtime) - assert count == 1 - - with pytest.raises(asyncio.CancelledError): - await task - assert cancelled["value"] is True diff --git a/tests/test_runtime_filters.py b/tests/test_runtime_filters.py deleted file mode 100644 index f14d01a1..00000000 --- a/tests/test_runtime_filters.py +++ /dev/null @@ -1,24 +0,0 @@ -import importlib -from pathlib import Path - -from bub.app.runtime import AppRuntime -from bub.skills.loader import SkillMetadata - -runtime_module = importlib.import_module("bub.app.runtime") - - -def _build_runtime_stub(workspace: Path, *, allowed_skills: set[str] | None) -> AppRuntime: - runtime = object.__new__(AppRuntime) - runtime.workspace = workspace - runtime._allowed_skills = allowed_skills # type: ignore[attr-defined] - return runtime - - -def test_discover_skills_filters_by_allowlist(monkeypatch, tmp_path: Path) -> None: - alpha = SkillMetadata(name="alpha", description="a", location=tmp_path / "alpha.md", body="", source="project") - beta = SkillMetadata(name="beta", description="b", location=tmp_path / "beta.md", body="", source="project") - monkeypatch.setattr(runtime_module, "discover_skills", lambda _workspace: [alpha, beta]) - - runtime = _build_runtime_stub(tmp_path, allowed_skills={"alpha"}) - names = [skill.name for skill in runtime.discover_skills()] - assert names == ["alpha"] diff --git a/tests/test_skill_loader.py b/tests/test_skill_loader.py new file mode 100644 index 00000000..4ecd63c7 --- /dev/null +++ b/tests/test_skill_loader.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from pathlib import Path + +from bub.skills.loader import discover_hook_skills + + +def _write_skill(root: Path, *, name: str, kind: str, entrypoint: str) -> None: + root.mkdir(parents=True) + (root / "SKILL.md").write_text( + "\n".join( + [ + "---", + f"name: {name}", + f"kind: {kind}", + f"entrypoint: {entrypoint}", + "---", + ] + ), + encoding="utf-8", + ) + + +def test_discover_hook_skills_respects_project_over_global(monkeypatch, tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + fake_home = tmp_path / "home" + + _write_skill( + workspace / ".agent" / "skills" / "demo", + name="demo", + kind="model", + entrypoint="project.demo:plugin", + ) + _write_skill( + fake_home / ".agent" / "skills" / "demo", + name="demo", + kind="model", + entrypoint="global.demo:plugin", + ) + + monkeypatch.setenv("HOME", str(fake_home)) + + skills = discover_hook_skills(workspace) + demo = next(skill for skill in skills if skill.name == "demo") + assert demo.source == "project" + assert demo.metadata["entrypoint"] == "project.demo:plugin" + + +def test_discover_hook_skills_filters_non_hook_skills(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + _write_skill( + workspace / ".agent" / "skills" / "no-entrypoint", + name="no-entrypoint", + kind="model", + entrypoint="", + ) + _write_skill( + workspace / ".agent" / "skills" / "valid", + name="valid", + kind="output", + entrypoint="pkg.valid:plugin", + ) + + names = [skill.name for skill in discover_hook_skills(workspace)] + assert "valid" in names + assert "no-entrypoint" not in names diff --git a/tests/test_skill_override.py b/tests/test_skill_override.py new file mode 100644 index 00000000..74906a8c --- /dev/null +++ b/tests/test_skill_override.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import typer + +from bub.framework import BubFramework + + +def _write_project_override_skill(workspace: Path) -> None: + package = workspace / "project_plugins" + package.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "override.py").write_text( + "\n".join( + [ + "import typer", + "", + "from bub.bus import MessageBus", + "from bub.hookspecs import hookimpl", + "", + "class ProjectBus(MessageBus):", + " pass", + "", + "class ProjectOverrideSkill:", + " @hookimpl", + " def provide_bus(self):", + " return ProjectBus()", + "", + " @hookimpl", + " def register_cli_commands(self, app):", + " @app.command('project-ping')", + " def project_ping():", + " typer.echo('pong')", + "", + "plugin = ProjectOverrideSkill()", + ] + ), + encoding="utf-8", + ) + + skill_dir = workspace / ".agent" / "skills" / "project-override" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "\n".join( + [ + "---", + "name: project-override", + "description: project overrides bus and cli hooks", + "kind: hook", + "entrypoint: project_plugins.override:plugin", + "---", + ] + ), + encoding="utf-8", + ) + + +def test_project_skill_can_override_builtin_bus(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _write_project_override_skill(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + framework = BubFramework(tmp_path) + framework.load_skills() + bus = framework.create_bus() + + assert bus.__class__.__name__ == "ProjectBus" + + +def test_project_skill_can_extend_cli_commands(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _write_project_override_skill(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + framework = BubFramework(tmp_path) + framework.load_skills() + app = typer.Typer() + + framework.register_cli_commands(app) + + names = {command.name for command in app.registered_commands} + assert "project-ping" in names diff --git a/tests/test_skills_loader.py b/tests/test_skills_loader.py deleted file mode 100644 index 5f4690cd..00000000 --- a/tests/test_skills_loader.py +++ /dev/null @@ -1,17 +0,0 @@ -from pathlib import Path - -from bub.skills.loader import discover_skills - - -def test_discover_and_load_project_skill(tmp_path: Path) -> None: - skill_dir = tmp_path / ".agent" / "skills" / "demo-skill" - skill_dir.mkdir(parents=True) - skill_file = skill_dir / "SKILL.md" - skill_file.write_text( - "---\nname: demo-skill\ndescription: demo skill\n---\n\n# Demo\n", - encoding="utf-8", - ) - - skills = discover_skills(tmp_path) - names = [skill.name for skill in skills] - assert "demo-skill" in names diff --git a/tests/test_tape_context.py b/tests/test_tape_context.py deleted file mode 100644 index ec4bbd85..00000000 --- a/tests/test_tape_context.py +++ /dev/null @@ -1,49 +0,0 @@ -from republic import TapeEntry - -from bub.tape.context import default_tape_context - - -def test_default_tape_context_includes_tool_messages() -> None: - context = default_tape_context() - assert context.select is not None - - entries = [ - TapeEntry.message({"role": "user", "content": "create a file"}), - TapeEntry.tool_call([ - { - "id": "call-1", - "type": "function", - "function": {"name": "fs.write", "arguments": '{"path":"a.txt","content":"hi"}'}, - }, - { - "id": "call-2", - "type": "function", - "function": {"name": "fs.read", "arguments": '{"path":"a.txt"}'}, - }, - ]), - TapeEntry.tool_result(["ok", {"content": "hi"}]), - TapeEntry.message({"role": "assistant", "content": "done"}), - ] - - messages = context.select(entries, context) - assert messages[0] == {"role": "user", "content": "create a file"} - assert messages[1]["role"] == "assistant" - assert messages[1]["tool_calls"][0]["id"] == "call-1" - assert messages[2] == {"role": "tool", "content": "ok", "tool_call_id": "call-1", "name": "fs.write"} - assert messages[3] == { - "role": "tool", - "content": '{"content": "hi"}', - "tool_call_id": "call-2", - "name": "fs.read", - } - assert messages[4] == {"role": "assistant", "content": "done"} - - -def test_default_tape_context_handles_result_without_calls() -> None: - context = default_tape_context() - assert context.select is not None - - entries = [TapeEntry.tool_result([{"status": "ok"}])] - messages = context.select(entries, context) - - assert messages == [{"role": "tool", "content": '{"status": "ok"}'}] diff --git a/tests/test_tape_service.py b/tests/test_tape_service.py deleted file mode 100644 index ca2a5b6c..00000000 --- a/tests/test_tape_service.py +++ /dev/null @@ -1,120 +0,0 @@ -from dataclasses import dataclass - -import pytest - -from bub.tape.service import TapeService - - -@dataclass -class FakeEntry: - id: int - kind: str - payload: dict[str, object] - meta: dict[str, object] - - -class FakeTape: - class _Query: - def __init__(self, tape: "FakeTape") -> None: - self._tape = tape - - async def all(self) -> list[FakeEntry]: - return list(self._tape.entries) - - def kinds(self, *kinds: str) -> "FakeTape._Query": - return self - - def __init__(self) -> None: - self.name = "fake" - self.entries: list[FakeEntry] = [ - FakeEntry( - id=1, - kind="anchor", - payload={"name": "session/start", "state": {"owner": "human"}}, - meta={}, - ) - ] - self.reset_calls = 0 - self.query_async = self._Query(self) - - async def handoff_async(self, name: str, state: dict[str, object] | None = None) -> list[FakeEntry]: - entry = FakeEntry( - id=len(self.entries) + 1, - kind="anchor", - payload={"name": name, "state": state or {}}, - meta={}, - ) - self.entries.append(entry) - return [entry] - - async def reset_async(self) -> None: - self.reset_calls += 1 - self.entries = [] - - -@pytest.mark.asyncio -async def test_reset_rebuilds_bootstrap_anchor() -> None: - service = TapeService.__new__(TapeService) - fake_tape = FakeTape() - service._tape = fake_tape # type: ignore[attr-defined] - service._store = None # type: ignore[attr-defined] - - result = await service.reset() - - assert result == "ok" - assert fake_tape.reset_calls == 1 - anchors = [entry for entry in fake_tape.entries if entry.kind == "anchor"] - assert len(anchors) == 1 - assert anchors[0].payload["name"] == "session/start" - - -@pytest.mark.asyncio -async def test_search_supports_fuzzy_typo_matching() -> None: - service = TapeService.__new__(TapeService) - fake_tape = FakeTape() - fake_tape.entries.extend(( - FakeEntry( - id=2, - kind="message", - payload={"role": "assistant", "content": "Please review the database migration plan."}, - meta={"source": "assistant"}, - ), - FakeEntry( - id=3, - kind="message", - payload={"role": "assistant", "content": "Unrelated note"}, - meta={}, - ), - )) - service._tape = fake_tape # type: ignore[attr-defined] - - matches = await service.search("databse migrtion", limit=5) - - assert len(matches) == 1 - assert matches[0].id == 2 - - -@pytest.mark.asyncio -async def test_search_respects_limit_for_exact_match() -> None: - service = TapeService.__new__(TapeService) - fake_tape = FakeTape() - fake_tape.entries.extend(( - FakeEntry( - id=2, - kind="message", - payload={"role": "assistant", "content": "Alpha report generated"}, - meta={}, - ), - FakeEntry( - id=3, - kind="message", - payload={"role": "assistant", "content": "Alpha follow-up details"}, - meta={}, - ), - )) - service._tape = fake_tape # type: ignore[attr-defined] - - matches = await service.search("alpha", limit=1) - - assert len(matches) == 1 - assert matches[0].id == 3 diff --git a/tests/test_tape_store.py b/tests/test_tape_store.py deleted file mode 100644 index 85f052c2..00000000 --- a/tests/test_tape_store.py +++ /dev/null @@ -1,133 +0,0 @@ -from pathlib import Path - -from republic import TapeEntry - -from bub.tape.store import FileTapeStore, TapeFile - - -def test_store_isolated_by_tape_name(tmp_path: Path) -> None: - home = tmp_path / "home" - workspace = tmp_path / "workspace" - workspace.mkdir() - store = FileTapeStore(home, workspace) - - store.append("a", TapeEntry.message({"role": "user", "content": "one"})) - store.append("b", TapeEntry.message({"role": "user", "content": "two"})) - - a_entries = store.read("a") - b_entries = store.read("b") - assert a_entries is not None - assert b_entries is not None - assert a_entries[0].payload["content"] == "one" - assert b_entries[0].payload["content"] == "two" - assert sorted(store.list_tapes()) == ["a", "b"] - - -def test_archive_then_reset(tmp_path: Path) -> None: - home = tmp_path / "home" - workspace = tmp_path / "workspace" - workspace.mkdir() - store = FileTapeStore(home, workspace) - - store.append("session", TapeEntry.event("command", {"raw": "echo hi"})) - archive = store.archive("session") - assert archive is not None - assert archive.exists() - assert store.read("session") is None - - -def test_tape_file_read_is_incremental(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_file = TapeFile(tape_path) - - tape_path.write_text( - '{"id":1,"kind":"message","payload":{"content":"one"},"meta":{}}\n', - encoding="utf-8", - ) - first = tape_file.read() - assert [entry.id for entry in first] == [1] - - with tape_path.open("a", encoding="utf-8") as handle: - handle.write('{"id":2,"kind":"message","payload":{"content":"two"},"meta":{}}\n') - second = tape_file.read() - assert [entry.id for entry in second] == [1, 2] - - -def test_tape_file_read_handles_truncated_file(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_file = TapeFile(tape_path) - - tape_path.write_text( - '{"id":1,"kind":"message","payload":{"content":"one"},"meta":{}}\n', - encoding="utf-8", - ) - assert [entry.id for entry in tape_file.read()] == [1] - - tape_path.write_text("", encoding="utf-8") - assert tape_file.read() == [] - - with tape_path.open("a", encoding="utf-8") as handle: - handle.write('{"id":1,"kind":"message","payload":{"content":"reset"},"meta":{}}\n') - after_truncate = tape_file.read() - assert [entry.payload["content"] for entry in after_truncate] == ["reset"] - - -def test_tape_file_append_increments_ids_without_intermediate_read(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_file = TapeFile(tape_path) - - tape_file.append(TapeEntry.message({"role": "user", "content": "one"})) - tape_file.append(TapeEntry.message({"role": "assistant", "content": "two"})) - tape_file.append(TapeEntry.message({"role": "assistant", "content": "three"})) - - entries = tape_file.read() - assert [entry.id for entry in entries] == [1, 2, 3] - - -def test_tape_file_append_uses_existing_tail_id(tmp_path: Path) -> None: - tape_path = tmp_path / "tape.jsonl" - tape_path.write_text( - '{"id":3,"kind":"message","payload":{"role":"user","content":"existing"},"meta":{}}\n', - encoding="utf-8", - ) - tape_file = TapeFile(tape_path) - - tape_file.append(TapeEntry.message({"role": "assistant", "content": "new"})) - - entries = tape_file.read() - assert [entry.id for entry in entries] == [3, 4] - - -def test_multi_forks_merge_keeps_entries_ordered(tmp_path: Path) -> None: - home = tmp_path / "home" - workspace = tmp_path / "workspace" - workspace.mkdir() - store = FileTapeStore(home, workspace) - - root_tape = "session" - store.append(root_tape, TapeEntry.message({"role": "user", "content": "root-1"})) - - fork_a = store.fork(root_tape) - fork_b = store.fork(root_tape) - - store.append(fork_a, TapeEntry.message({"role": "assistant", "content": "fork-a-1"})) - store.append(fork_a, TapeEntry.message({"role": "assistant", "content": "fork-a-2"})) - store.append(fork_b, TapeEntry.message({"role": "assistant", "content": "fork-b-1"})) - store.append(fork_b, TapeEntry.message({"role": "assistant", "content": "fork-b-2"})) - - store.merge(fork_b, root_tape) - store.merge(fork_a, root_tape) - - merged = store.read(root_tape) - assert merged is not None - - assert [entry.payload["content"] for entry in merged] == [ - "root-1", - "fork-b-1", - "fork-b-2", - "fork-a-1", - "fork-a-2", - ] - assert [entry.id for entry in merged] == [1, 2, 3, 4, 5] - assert store.read(fork_a) is None - assert store.read(fork_b) is None diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py deleted file mode 100644 index 81376da9..00000000 --- a/tests/test_telegram_channel.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import contextlib -from types import SimpleNamespace - -import pytest - -from bub.channels.telegram import TelegramChannel - - -class _Settings: - def __init__(self) -> None: - self.telegram_token = "test-token" # noqa: S105 - self.telegram_allow_from: list[str] = [] - self.telegram_allow_chats: list[str] = [] - self.telegram_proxy: str | None = None - - -class _Runtime: - def __init__(self) -> None: - self.settings = _Settings() - - -class DummyMessage: - def __init__(self, *, chat_id: int, text: str, message_id: int = 1) -> None: - self.chat_id = chat_id - self.text = text - self.message_id = message_id - self.replies: list[str] = [] - - async def reply_text(self, text: str) -> None: - self.replies.append(text) - - -@pytest.mark.asyncio -async def test_on_text_denies_chat_not_in_allowlist() -> None: - runtime = _Runtime() - runtime.settings.telegram_allow_chats = ["123"] - channel = TelegramChannel(runtime) # type: ignore[arg-type] - - message = DummyMessage(chat_id=999, text="hello") - update = SimpleNamespace( - message=message, - effective_user=SimpleNamespace(id=1, username="tester", full_name="Test User"), - ) - - await channel._on_text(update, None) # type: ignore[arg-type] - assert message.replies == [] - - -@pytest.mark.asyncio -async def test_on_text_invokes_receive_handler_for_allowed_message() -> None: - runtime = _Runtime() - runtime.settings.telegram_allow_chats = ["999"] - channel = TelegramChannel(runtime) # type: ignore[arg-type] - - message = DummyMessage(chat_id=999, text="hello") - update = SimpleNamespace( - message=message, - effective_user=SimpleNamespace(id=1, username="tester", full_name="Test User"), - ) - - received: list[object] = [] - - async def _on_receive(msg: object) -> None: - received.append(msg) - - channel._on_receive = _on_receive - - @contextlib.asynccontextmanager - async def _start_typing(_chat_id: str): - yield - - channel._start_typing = _start_typing # type: ignore[method-assign] - - await channel._on_text(update, None) # type: ignore[arg-type] - - assert message.replies == [] - assert received == [message] - - -@pytest.mark.asyncio -async def test_on_text_always_stops_typing() -> None: - runtime = _Runtime() - runtime.settings.telegram_allow_chats = ["999"] - channel = TelegramChannel(runtime) # type: ignore[arg-type] - - message = DummyMessage(chat_id=999, text="hello") - update = SimpleNamespace( - message=message, - effective_user=SimpleNamespace(id=1, username="tester", full_name="Test User"), - ) - - calls = {"start": 0, "stop": 0} - - @contextlib.asynccontextmanager - async def _start_typing(_chat_id: str): - calls["start"] += 1 - try: - yield - finally: - calls["stop"] += 1 - - async def _on_receive(_msg: object) -> None: - raise RuntimeError("receive failed") - - channel._on_receive = _on_receive - channel._start_typing = _start_typing # type: ignore[method-assign] - - with pytest.raises(RuntimeError, match="receive failed"): - await channel._on_text(update, None) # type: ignore[arg-type] - - assert calls == {"start": 1, "stop": 1} diff --git a/tests/test_telegram_filter.py b/tests/test_telegram_filter.py deleted file mode 100644 index 2781b1f5..00000000 --- a/tests/test_telegram_filter.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from types import SimpleNamespace - -from bub.channels.telegram import BubMessageFilter - - -@dataclass -class DummyUser: - id: int - - -@dataclass -class DummyEntity: - type: str - offset: int = 0 - length: int = 0 - user: DummyUser | None = None - - -class DummyMessage: - def __init__( - self, - *, - text: str, - chat_type: str, - bot_id: int = 1000, - bot_username: str = "BubBot", - entities: list[DummyEntity] | None = None, - reply_to_message: object | None = None, - caption: str | None = None, - photo: list[object] | None = None, - ) -> None: - self.text = text - self.caption = caption - self.photo = photo - self.chat = SimpleNamespace(type=chat_type) - self.entities = entities or [] - self.caption_entities = [] - self.reply_to_message = reply_to_message - self._bot_id = bot_id - self._bot_username = bot_username - - def get_bot(self) -> object: - return SimpleNamespace(id=self._bot_id, username=self._bot_username) - - -def test_group_allows_bot_prefix() -> None: - message = DummyMessage(text="/bot hello", chat_type="group") - assert BubMessageFilter().filter(message) is False - - -def test_group_allows_at_mention_by_username_entity() -> None: - message = DummyMessage( - text="@BubBot ping", - chat_type="supergroup", - entities=[DummyEntity(type="mention", offset=0, length=7)], - ) - assert BubMessageFilter().filter(message) is True - - -def test_group_allows_at_mention_by_text_mention_entity() -> None: - message = DummyMessage( - text="ping bot", - chat_type="group", - entities=[DummyEntity(type="text_mention", user=DummyUser(id=1000))], - ) - assert BubMessageFilter().filter(message) is True - - -def test_group_allows_reply_to_bot_message() -> None: - reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=1000)) - message = DummyMessage(text="reply", chat_type="group", reply_to_message=reply_to_message) - assert BubMessageFilter().filter(message) is True - - -def test_group_rejects_unrelated_text() -> None: - message = DummyMessage(text="hello world", chat_type="group") - assert BubMessageFilter().filter(message) is False - - -def test_private_allows_media_without_text() -> None: - message = DummyMessage(text="", chat_type="private", photo=[object()]) - assert BubMessageFilter().filter(message) is True - - -def test_private_rejects_non_bot_command() -> None: - message = DummyMessage(text="/start", chat_type="private") - assert BubMessageFilter().filter(message) is True - - -def test_private_allows_bub_command() -> None: - message = DummyMessage(text="/bub summarize", chat_type="private") - assert BubMessageFilter().filter(message) is True - - -def test_group_rejects_media_without_reply_or_mention() -> None: - message = DummyMessage(text="", chat_type="group", photo=[object()]) - assert BubMessageFilter().filter(message) is False diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py deleted file mode 100644 index 104a3ebf..00000000 --- a/tests/test_tool_registry.py +++ /dev/null @@ -1,100 +0,0 @@ -import pytest -from republic import ToolContext - -from bub.tools.registry import ToolRegistry - - -@pytest.mark.asyncio -async def test_registry_logs_once_for_execute(monkeypatch) -> None: - logs: list[str] = [] - - def _capture(message: str, *args: object) -> None: - logs.append(message) - - monkeypatch.setattr("bub.tools.registry.logger.info", _capture) - monkeypatch.setattr("bub.tools.registry.logger.exception", _capture) - - registry = ToolRegistry() - - @registry.register(name="math.add", short_description="add", detail="add") - def add(*, a: int, b: int) -> int: - return a + b - - result = await registry.execute("math.add", kwargs={"a": 1, "b": 2}) - assert result == 3 - assert logs.count("tool.call.start name={} {{ {} }}") == 1 - assert logs.count("tool.call.end name={} duration={:.3f}ms") == 1 - - -@pytest.mark.asyncio -async def test_registry_logs_for_direct_tool_run_with_context(monkeypatch) -> None: - logs: list[str] = [] - - def _capture(message: str, *args: object) -> None: - logs.append(message) - - monkeypatch.setattr("bub.tools.registry.logger.info", _capture) - monkeypatch.setattr("bub.tools.registry.logger.exception", _capture) - - registry = ToolRegistry() - - @registry.register(name="fs.ctx", short_description="ctx", detail="ctx", context=True) - def handle(*, context: ToolContext, path: str) -> str: - return f"{context.run_id}:{path}" - - descriptor = registry.get("fs.ctx") - assert descriptor is not None - - output = await descriptor.tool.run(context=ToolContext(tape="t1", run_id="r1"), path="README.md") - assert output == "r1:README.md" - assert logs.count("tool.call.start name={} {{ {} }}") == 1 - assert logs.count("tool.call.end name={} duration={:.3f}ms") == 1 - - -@pytest.mark.asyncio -async def test_registry_execute_context_tool_should_work() -> None: - registry = ToolRegistry() - - @registry.register(name="ctx.echo", short_description="echo", detail="echo", context=True) - def echo(*, context: ToolContext, value: str) -> str: - return f"{context.run_id}:{value}" - - ctx = ToolContext(tape="t1", run_id="r1") - out = await registry.execute("ctx.echo", kwargs={"value": "hi"}, context=ctx) - assert out == "r1:hi" - - -@pytest.mark.asyncio -async def test_registry_model_tools_use_underscore_names_and_keep_handlers() -> None: - registry = ToolRegistry() - - @registry.register(name="fs.read", short_description="read", detail="read") - def read(*, path: str) -> str: - return f"read:{path}" - - rows = registry.compact_rows(for_model=True) - assert rows == ["fs_read (command: fs.read): read"] - - model_tools = registry.model_tools() - assert [tool.name for tool in model_tools] == ["fs_read"] - assert await model_tools[0].run(path="README.md") == "read:README.md" - - -def test_registry_model_tool_name_conflict_raises_error() -> None: - registry = ToolRegistry() - - registry.register(name="fs.read", short_description="dot", detail="dot")(lambda: "dot") - registry.register(name="fs_read", short_description="underscore", detail="underscore")(lambda: "underscore") - - with pytest.raises(ValueError, match="Duplicate model tool name"): - registry.model_tools() - - -def test_registry_restrict_to_matches_command_and_model_names() -> None: - registry = ToolRegistry({"fs_read"}) - - registry.register(name="fs.read", short_description="read", detail="read")(lambda: "read") - registry.register(name="web.search", short_description="search", detail="search")(lambda: "search") - - assert registry.get("fs.read") is not None - assert registry.get("web.search") is None diff --git a/tests/test_tools_builtin.py b/tests/test_tools_builtin.py deleted file mode 100644 index 048c151b..00000000 --- a/tests/test_tools_builtin.py +++ /dev/null @@ -1,442 +0,0 @@ -import asyncio -import inspect -import json -import re -from collections.abc import Iterator -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import pytest -from apscheduler.schedulers.background import BackgroundScheduler -from republic import ToolContext - -from bub.config.settings import Settings -from bub.tools.builtin import register_builtin_tools -from bub.tools.registry import ToolRegistry - - -@dataclass -class _TapeInfo: - name: str = "bub" - entries: int = 0 - anchors: int = 0 - last_anchor: str | None = None - entries_since_last_anchor: int = 0 - - -class _DummyTape: - async def handoff(self, _name: str, *, state: dict[str, object] | None = None) -> list[object]: - _ = state - return [] - - async def anchors(self, *, limit: int = 20) -> list[object]: - _ = limit - return [] - - async def info(self) -> _TapeInfo: - return _TapeInfo() - - async def search(self, _query: str, *, limit: int = 20) -> list[object]: - _ = limit - return [] - - async def reset(self, *, archive: bool = False) -> str: - _ = archive - return "reset" - - -class _DummyRuntime: - def __init__(self, settings: Settings, scheduler: BackgroundScheduler) -> None: - self.settings = settings - self.scheduler = scheduler - self._discovered_skills: list[object] = [] - self.reset_calls: list[str] = [] - self.workspace = Path.cwd() - - def discover_skills(self) -> list[object]: - return list(self._discovered_skills) - - def reset_session_context(self, session_id: str) -> None: - self.reset_calls.append(session_id) - - -def _build_registry(workspace: Path, settings: Settings, scheduler: BackgroundScheduler) -> ToolRegistry: - registry = ToolRegistry() - runtime = _DummyRuntime(settings, scheduler) - register_builtin_tools( - registry, - workspace=workspace, - tape=_DummyTape(), # type: ignore[arg-type] - runtime=runtime, # type: ignore[arg-type] - ) - return registry - - -def _execute_tool( - registry: ToolRegistry, - name: str, - *, - kwargs: dict[str, Any], - session_id: str = "cli:test", -) -> Any: - descriptor = registry.get(name) - context = ToolContext(tape="test", run_id="test-run", state={"session_id": session_id}) - if descriptor is not None and descriptor.tool.context: - result = descriptor.tool.run(context=context, **kwargs) - else: - result = registry.execute(name, kwargs=kwargs) - if inspect.isawaitable(result): - return asyncio.run(result) - return result - - -@pytest.fixture -def scheduler() -> Iterator[BackgroundScheduler]: - scheduler = BackgroundScheduler(daemon=True) - scheduler.start() - yield scheduler - scheduler.shutdown(wait=False) - - -def test_web_search_default_returns_duckduckgo_url(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.search", kwargs={"query": "psiace bub"}) - assert result == "https://duckduckgo.com/?q=psiace+bub" - - -def test_web_fetch_default_normalizes_url_and_extracts_text( - tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler -) -> None: - observed_urls: list[str] = [] - - class _Response: - class _Content: - @staticmethod - async def read(_size: int | None = None) -> bytes: - return b"

    Title

    Hello world.

    " - - content = _Content() - - class _RequestCtx: - def __init__(self, response: _Response) -> None: - self._response = response - - async def __aenter__(self) -> _Response: - return self._response - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - class _Session: - async def __aenter__(self) -> "_Session": - return self - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - def get(self, url: str, *, headers: dict[str, str]) -> _RequestCtx: - _ = headers - observed_urls.append(url) - return _RequestCtx(_Response()) - - monkeypatch.setattr("aiohttp.ClientSession", lambda *args, **kwargs: _Session()) - - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.fetch", kwargs={"url": "example.com"}) - - assert observed_urls == ["https://example.com"] - assert "Title" in result - assert "Hello world." in result - - -def test_web_search_ollama_mode_calls_api(tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler) -> None: - observed_request: dict[str, str] = {} - - class _Response: - @staticmethod - async def text() -> str: - payload = { - "results": [ - { - "title": "Example", - "url": "https://example.com", - "content": "Example snippet", - } - ] - } - return json.dumps(payload) - - class _RequestCtx: - def __init__(self, response: _Response) -> None: - self._response = response - - async def __aenter__(self) -> _Response: - return self._response - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - class _Session: - async def __aenter__(self) -> "_Session": - return self - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - def post(self, url: str, *, json: dict[str, object], headers: dict[str, str]) -> _RequestCtx: - import json as json_lib - - observed_request["url"] = url - observed_request["auth"] = headers.get("Authorization", "") - observed_request["payload"] = json_lib.dumps(json) - return _RequestCtx(_Response()) - - monkeypatch.setattr("aiohttp.ClientSession", lambda *args, **kwargs: _Session()) - - settings = Settings( - _env_file=None, - model="openrouter:test", - ollama_api_key="ollama-test-key", - ollama_api_base="https://search.ollama.test/api", - ) - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.search", kwargs={"query": "test query", "max_results": 3}) - - assert observed_request["url"] == "https://search.ollama.test/api/web_search" - assert observed_request["auth"] == "Bearer ollama-test-key" - assert json.loads(observed_request["payload"]) == {"query": "test query", "max_results": 3} - assert "Example" in result - assert "https://example.com" in result - assert "Example snippet" in result - - -def test_web_fetch_ollama_mode_normalizes_url_and_extracts_text( - tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler -) -> None: - observed_urls: list[str] = [] - - class _Response: - class _Content: - @staticmethod - async def read(_size: int | None = None) -> bytes: - return b"

    Title

    Hello world.

    " - - content = _Content() - - class _RequestCtx: - def __init__(self, response: _Response) -> None: - self._response = response - - async def __aenter__(self) -> _Response: - return self._response - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - class _Session: - async def __aenter__(self) -> "_Session": - return self - - async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool: - _ = (exc_type, exc, tb) - return False - - def get(self, url: str, *, headers: dict[str, str]) -> _RequestCtx: - _ = headers - observed_urls.append(url) - return _RequestCtx(_Response()) - - monkeypatch.setattr("aiohttp.ClientSession", lambda *args, **kwargs: _Session()) - - settings = Settings( - _env_file=None, - model="openrouter:test", - ollama_api_key="ollama-test-key", - ) - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "web.fetch", kwargs={"url": "example.com"}) - - assert observed_urls == ["https://example.com"] - assert "Title" in result - assert "Hello world." in result - - -def test_schedule_add_list_remove_roundtrip(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - - add_result = _execute_tool( - registry, - "schedule.add", - kwargs={ - "cron": "*/5 * * * *", - "message": "hello", - }, - ) - assert add_result.startswith("scheduled: ") - matched = re.match(r"^scheduled: (?P[a-z0-9-]+) next=.*$", add_result) - assert matched is not None - job_id = matched.group("job_id") - - list_result = _execute_tool(registry, "schedule.list", kwargs={}) - assert job_id in list_result - assert "msg=hello" in list_result - - remove_result = _execute_tool(registry, "schedule.remove", kwargs={"job_id": job_id}) - assert remove_result == f"removed: {job_id}" - - assert _execute_tool(registry, "schedule.list", kwargs={}) == "(no scheduled jobs)" - - -def test_schedule_add_rejects_invalid_cron(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - - try: - _execute_tool( - registry, - "schedule.add", - kwargs={"cron": "* * *", "message": "bad"}, - ) - raise AssertionError("expected RuntimeError") - except RuntimeError as exc: - assert "invalid cron expression" in str(exc) - - -def test_schedule_remove_missing_job_returns_error(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - - try: - _execute_tool(registry, "schedule.remove", kwargs={"job_id": "missing"}) - raise AssertionError("expected RuntimeError") - except RuntimeError as exc: - assert "job not found: missing" in str(exc) - - -def test_schedule_shared_scheduler_across_registries(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - - settings = Settings(_env_file=None, model="openrouter:test") - registry_a = _build_registry(workspace, settings, scheduler) - registry_b = _build_registry(workspace, settings, scheduler) - - add_result = _execute_tool( - registry_a, - "schedule.add", - kwargs={"cron": "*/5 * * * *", "message": "from-a"}, - ) - matched = re.match(r"^scheduled: (?P[a-z0-9-]+) next=.*$", add_result) - assert matched is not None - - assert matched.group("job_id") in _execute_tool(registry_b, "schedule.list", kwargs={}) - - -def test_skills_list_uses_latest_runtime_skills(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - @dataclass(frozen=True) - class _Skill: - name: str - description: str - - class _Runtime: - def __init__(self, settings: Settings, scheduler: BackgroundScheduler) -> None: - self.settings = settings - self.scheduler = scheduler - self._discovered_skills: list[_Skill] = [_Skill(name="alpha", description="first")] - - def discover_skills(self) -> list[_Skill]: - return list(self._discovered_skills) - - settings = Settings(_env_file=None, model="openrouter:test") - runtime = _Runtime(settings, scheduler) - registry = ToolRegistry() - register_builtin_tools( - registry, - workspace=tmp_path, - tape=_DummyTape(), # type: ignore[arg-type] - runtime=runtime, # type: ignore[arg-type] - ) - - assert _execute_tool(registry, "skills.list", kwargs={}) == "alpha: first" - - runtime._discovered_skills.append(_Skill(name="beta", description="second")) - second = _execute_tool(registry, "skills.list", kwargs={}) - assert "alpha: first" in second - assert "beta: second" in second - - -def test_bash_tool_inherits_runtime_session_id( - tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler -) -> None: - observed: dict[str, object] = {} - - class _Completed: - returncode = 0 - - @staticmethod - async def communicate() -> tuple[bytes, bytes]: - return b"ok", b"" - - async def _fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> _Completed: - observed["args"] = args - observed["kwargs"] = kwargs - return _Completed() - - monkeypatch.setattr("bub.tools.builtin.asyncio.create_subprocess_exec", _fake_create_subprocess_exec) - - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "bash", kwargs={"cmd": "echo hi"}) - - assert result == "ok" - kwargs = observed["kwargs"] - assert isinstance(kwargs, dict) - assert kwargs["env"]["BUB_SESSION_ID"] == "cli:test" - - -def test_bash_handles_non_utf8_output(tmp_path: Path, monkeypatch: Any, scheduler: BackgroundScheduler) -> None: - class _Completed: - returncode = 0 - - @staticmethod - async def communicate() -> tuple[bytes, bytes]: - # GBK-encoded bytes that cannot be decoded as UTF-8 - return "微软".encode("gbk"), b"" - - async def _fake_create_subprocess_exec(*args: Any, **kwargs: Any) -> _Completed: - _ = (args, kwargs) - return _Completed() - - monkeypatch.setattr("bub.tools.builtin.asyncio.create_subprocess_exec", _fake_create_subprocess_exec) - - settings = Settings(_env_file=None, model="openrouter:test") - registry = _build_registry(tmp_path, settings, scheduler) - result = _execute_tool(registry, "bash", kwargs={"cmd": "echo hello"}) - - # Should contain replacement character instead of raising UnicodeDecodeError - assert "�" in result - - -def test_tape_reset_also_clears_session_runtime_context(tmp_path: Path, scheduler: BackgroundScheduler) -> None: - settings = Settings(_env_file=None, model="openrouter:test") - runtime = _DummyRuntime(settings, scheduler) - registry = ToolRegistry() - register_builtin_tools( - registry, - workspace=tmp_path, - tape=_DummyTape(), # type: ignore[arg-type] - runtime=runtime, # type: ignore[arg-type] - ) - - result = _execute_tool(registry, "tape.reset", kwargs={"archive": True}, session_id="telegram:123") - assert result == "reset" - assert runtime.reset_calls == ["telegram:123"] diff --git a/tests/test_tools_schedule.py b/tests/test_tools_schedule.py deleted file mode 100644 index c0f3cf59..00000000 --- a/tests/test_tools_schedule.py +++ /dev/null @@ -1,41 +0,0 @@ -import subprocess -import sys -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -from bub.tools.schedule import SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS, run_scheduled_reminder - - -def test_run_scheduled_reminder_invokes_bub_run(monkeypatch: Any, tmp_path: Path) -> None: - observed: dict[str, object] = {} - - def _fake_run(command: list[str], **kwargs: Any) -> Any: - observed["command"] = command - observed["kwargs"] = kwargs - return SimpleNamespace(returncode=0, stderr="", stdout="") - - monkeypatch.setattr("bub.tools.schedule.subprocess.run", _fake_run) - - run_scheduled_reminder("remind me", "telegram:42") - - assert observed["command"] == [ - sys.executable, - "-m", - "bub.cli.app", - "run", - "--session-id", - "telegram:42", - "[Reminder for Telegram chat 42, after done, send a notice to this chat if necessary]\nremind me", - ] - assert observed["kwargs"] == {"check": True, "cwd": None, "timeout": SCHEDULE_SUBPROCESS_TIMEOUT_SECONDS} - - -def test_run_scheduled_reminder_handles_timeout(monkeypatch: Any) -> None: - def _fake_run(command: list[str], **kwargs: Any) -> Any: - _ = kwargs - raise subprocess.TimeoutExpired(cmd=command, timeout=1) - - monkeypatch.setattr("bub.tools.schedule.subprocess.run", _fake_run) - - run_scheduled_reminder("remind me", "telegram:42") diff --git a/uv.lock b/uv.lock index 211f9f5e..ee96f611 100644 --- a/uv.lock +++ b/uv.lock @@ -268,6 +268,7 @@ dependencies = [ { name = "discord-py" }, { name = "httpx", extra = ["socks"] }, { name = "loguru" }, + { name = "pluggy" }, { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -305,6 +306,7 @@ requires-dist = [ { name = "discord-py", specifier = ">=2.6.4" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "loguru", specifier = ">=0.7.2" }, + { name = "pluggy", specifier = ">=1.6.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, From 95bdda1eaaf3775eae79e59063bfc6cb49d5695e Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sat, 14 Feb 2026 04:46:57 +0000 Subject: [PATCH 02/39] feat: migrate builtin capabilities into runtime and cli skills --- README.md | 18 +- docs/architecture.md | 7 +- docs/cli.md | 17 + docs/features.md | 6 +- docs/index.md | 5 + pyproject.toml | 7 - src/bub/framework.py | 2 + src/bub/hook_runtime.py | 2 + src/bub/skills/__init__.py | 4 +- .../skills/builtin/{cli_core => cli}/SKILL.md | 4 +- .../builtin/{cli_core => cli}/__init__.py | 0 .../builtin/{cli_core => cli}/plugin.py | 0 src/bub/skills/builtin/common.py | 16 - src/bub/skills/builtin/input_bus/SKILL.md | 10 - src/bub/skills/builtin/input_bus/__init__.py | 1 - src/bub/skills/builtin/input_bus/plugin.py | 38 - src/bub/skills/builtin/memory_tape/SKILL.md | 10 - .../skills/builtin/memory_tape/__init__.py | 1 - src/bub/skills/builtin/memory_tape/plugin.py | 28 - src/bub/skills/builtin/model_echo/SKILL.md | 10 - src/bub/skills/builtin/model_echo/__init__.py | 1 - src/bub/skills/builtin/model_echo/plugin.py | 24 - src/bub/skills/builtin/output_stdout/SKILL.md | 10 - .../skills/builtin/output_stdout/__init__.py | 1 - .../skills/builtin/output_stdout/plugin.py | 33 - src/bub/skills/builtin/runtime/SKILL.md | 10 + src/bub/skills/builtin/runtime/__init__.py | 1 + src/bub/skills/builtin/runtime/engine.py | 746 ++++++++++++++++++ src/bub/skills/builtin/runtime/plugin.py | 68 ++ src/bub/skills/loader.py | 14 + tests/conftest.py | 8 + tests/fixtures_plugins/__init__.py | 2 + tests/fixtures_plugins/stateful_hooks.py | 39 + tests/test_framework_flow.py | 79 +- uv.lock | 590 +------------- 35 files changed, 1029 insertions(+), 783 deletions(-) rename src/bub/skills/builtin/{cli_core => cli}/SKILL.md (70%) rename src/bub/skills/builtin/{cli_core => cli}/__init__.py (100%) rename src/bub/skills/builtin/{cli_core => cli}/plugin.py (100%) delete mode 100644 src/bub/skills/builtin/common.py delete mode 100644 src/bub/skills/builtin/input_bus/SKILL.md delete mode 100644 src/bub/skills/builtin/input_bus/__init__.py delete mode 100644 src/bub/skills/builtin/input_bus/plugin.py delete mode 100644 src/bub/skills/builtin/memory_tape/SKILL.md delete mode 100644 src/bub/skills/builtin/memory_tape/__init__.py delete mode 100644 src/bub/skills/builtin/memory_tape/plugin.py delete mode 100644 src/bub/skills/builtin/model_echo/SKILL.md delete mode 100644 src/bub/skills/builtin/model_echo/__init__.py delete mode 100644 src/bub/skills/builtin/model_echo/plugin.py delete mode 100644 src/bub/skills/builtin/output_stdout/SKILL.md delete mode 100644 src/bub/skills/builtin/output_stdout/__init__.py delete mode 100644 src/bub/skills/builtin/output_stdout/plugin.py create mode 100644 src/bub/skills/builtin/runtime/SKILL.md create mode 100644 src/bub/skills/builtin/runtime/__init__.py create mode 100644 src/bub/skills/builtin/runtime/engine.py create mode 100644 src/bub/skills/builtin/runtime/plugin.py create mode 100644 tests/conftest.py create mode 100644 tests/fixtures_plugins/__init__.py create mode 100644 tests/fixtures_plugins/stateful_hooks.py diff --git a/README.md b/README.md index be452ba7..d6e597f3 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,25 @@ Bub is a **batteries-included, hook-first AI framework**. Bub is a collaborative agent for shared delivery workflows, evolving into a framework that helps other agents operate with the same collaboration model. It is not a personal-assistant shell: it is designed for shared environments where work must be inspectable, handoff-friendly, and operationally reliable. -> Documentation: +- model execution and tool loop +- command routing and runtime tape behavior +- input listener hooks (normalize + session resolution) in the same runtime process +- CLI command registration +- channel/bus behaviors provided by project skills Built on [Republic](https://github.com/bubbuild/republic), Bub treats context as explicit assembly from verifiable interaction history, rather than opaque inherited state. This aligns with [Socialized Evaluation](https://psiace.me/posts/im-and-socialized-evaluation/): systems are judged by how well teams can inspect, review, and continue work together. -## What Bub Provides +- `cli` +- `runtime` (Republic-driven runtime battery with routing, tools, and tape-backed sessions) + +## Runtime Defaults + +- Without usable Republic model credentials, the framework still runs and returns prompt text as output. +- `runtime` can be controlled with environment variables: + - `BUB_RUNTIME_ENABLED=1|0|auto` + - `BUB_MODEL`, `BUB_API_KEY`, `BUB_API_BASE` + - `BUB_RUNTIME_MAX_STEPS`, `BUB_RUNTIME_MAX_TOKENS`, `BUB_RUNTIME_MODEL_TIMEOUT_SECONDS` - Multi-operator collaboration in shared delivery environments. - Explicit command boundaries for predictable execution. @@ -32,6 +45,7 @@ uv sync uv run bub run "hello" uv run bub hooks uv run bub skills +BUB_RUNTIME_ENABLED=1 uv run bub run ",help" ``` ## Skill Layout diff --git a/docs/architecture.md b/docs/architecture.md index b6003637..bd91ae64 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,11 @@ Those concerns are provided by skills through hooks. The framework is batteries-included: default skills provide a runnable baseline, while every battery can be overridden by project or global skills. +Current builtin baseline: + +- `cli` skill: registers `run`, `skills`, and `hooks` commands +- `runtime` skill: input listener hooks, model runtime, tool loop, command-compatible routing + ## Hook Pipeline 1. `normalize_inbound` @@ -33,7 +38,7 @@ while every battery can be overridden by project or global skills. - Skill load failures are isolated and tracked in `failed_skills`. - Hook runtime failures are isolated per plugin and reported via `on_error`. -- If no model skill returns output, the framework falls back to prompt echo to keep the process alive. +- If no model skill returns output, the framework falls back to the prompt text to keep the process alive. ## Skill Resolution diff --git a/docs/cli.md b/docs/cli.md index 8286a33c..cde0f566 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,18 @@ uv run bub run "hello" --channel stdout --chat-id local ``` +## Force Republic model runtime + +```bash +BUB_RUNTIME_ENABLED=1 uv run bub run "summarize current repo status" +``` + +## Command-compatible mode through runtime + +```bash +BUB_RUNTIME_ENABLED=1 uv run bub run ",help" +``` + ## List skills ```bash @@ -17,3 +29,8 @@ uv run bub skills ```bash uv run bub hooks ``` + +## Notes + +- If Republic model runtime is unavailable (for example no API key), `bub run` still works and returns prompt text. +- Session identity defaults to `channel:chat_id` unless `--session-id` is provided. diff --git a/docs/features.md b/docs/features.md index 257a43ca..3889d16c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,7 +1,9 @@ # Key Features -- Batteries-included baseline skills for input, memory, model, output, and CLI +- Batteries-included baseline skills for Republic runtime and CLI bootstrap - Hook-based extension model using Pluggy - Skill discovery with project/global/builtin override order -- Envelope-and-bus oriented runtime model +- Envelope-oriented runtime model with pluggable bus providers +- Builtin `runtime` skill with Republic-driven model runtime and command routing +- Tape-backed session runtime with persistent JSONL storage - Fault isolation when skill loading fails diff --git a/docs/index.md b/docs/index.md index d077dd6f..6fc340ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,3 +12,8 @@ Kernel responsibilities: 4. keep failure boundaries small Everything else ships as skills and can be replaced by user-provided skills. + +Builtin baseline in this repository: + +- `cli` +- `runtime` diff --git a/pyproject.toml b/pyproject.toml index 51d161ac..65d4e4a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,13 +33,6 @@ dependencies = [ "loguru>=0.7.2", "telegramify-markdown>=0.5.4", "apscheduler>=3.11.2", - "aiohttp>=3.13.3", - "rapidfuzz>=3.14.1", - "discord-py>=2.6.4", - "requests>=2.32.5", - "python-dotenv>=1.2.1", - "any-llm-sdk[anthropic,vertexai]>=1.8.0", - "httpx[socks]>=0.28.1", ] [project.urls] diff --git a/src/bub/framework.py b/src/bub/framework.py index 1d05a772..943329c0 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -82,6 +82,8 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: try: normalized = await self._hook_runtime.call_first("normalize_inbound", message=inbound) message = normalized if normalized is not None else inbound + if isinstance(message, dict): + message.setdefault("workspace", str(self.workspace)) session_id = await self._hook_runtime.call_first("resolve_session", message=message) or self._default_session_id( message ) diff --git a/src/bub/hook_runtime.py b/src/bub/hook_runtime.py index d33a3695..fb928fd9 100644 --- a/src/bub/hook_runtime.py +++ b/src/bub/hook_runtime.py @@ -23,6 +23,8 @@ async def call_first(self, hook_name: str, **kwargs: Any) -> Any: for impl in self._iter_hookimpls(hook_name): call_kwargs = self._kwargs_for_impl(impl, kwargs) value = await self._invoke_impl_async(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + if value is _SKIP_VALUE: + continue if value is not None: return value return None diff --git a/src/bub/skills/__init__.py b/src/bub/skills/__init__.py index 4f5f5c14..1d37e516 100644 --- a/src/bub/skills/__init__.py +++ b/src/bub/skills/__init__.py @@ -1,5 +1,5 @@ """Skill discovery and loading exports.""" -from bub.skills.loader import SkillMetadata, discover_hook_skills +from bub.skills.loader import SkillMetadata, discover_hook_skills, discover_skills, load_skill_body -__all__ = ["SkillMetadata", "discover_hook_skills"] +__all__ = ["SkillMetadata", "discover_hook_skills", "discover_skills", "load_skill_body"] diff --git a/src/bub/skills/builtin/cli_core/SKILL.md b/src/bub/skills/builtin/cli/SKILL.md similarity index 70% rename from src/bub/skills/builtin/cli_core/SKILL.md rename to src/bub/skills/builtin/cli/SKILL.md index 030fa5a8..004964ca 100644 --- a/src/bub/skills/builtin/cli_core/SKILL.md +++ b/src/bub/skills/builtin/cli/SKILL.md @@ -1,8 +1,8 @@ --- -name: cli-core +name: cli description: Register base framework CLI commands via hooks. kind: command -entrypoint: bub.skills.builtin.cli_core.plugin:plugin +entrypoint: bub.skills.builtin.cli.plugin:plugin --- # CLI Core Skill diff --git a/src/bub/skills/builtin/cli_core/__init__.py b/src/bub/skills/builtin/cli/__init__.py similarity index 100% rename from src/bub/skills/builtin/cli_core/__init__.py rename to src/bub/skills/builtin/cli/__init__.py diff --git a/src/bub/skills/builtin/cli_core/plugin.py b/src/bub/skills/builtin/cli/plugin.py similarity index 100% rename from src/bub/skills/builtin/cli_core/plugin.py rename to src/bub/skills/builtin/cli/plugin.py diff --git a/src/bub/skills/builtin/common.py b/src/bub/skills/builtin/common.py deleted file mode 100644 index 3fc77064..00000000 --- a/src/bub/skills/builtin/common.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Shared helpers for builtin hook skills.""" - -from __future__ import annotations - -from bub.types import State - - -def read_turn(state: State) -> int: - """Read turn counter from state with tolerant conversion.""" - - value = state.get("turn") - if isinstance(value, int): - return value - if isinstance(value, str) and value.isdigit(): - return int(value) - return 0 diff --git a/src/bub/skills/builtin/input_bus/SKILL.md b/src/bub/skills/builtin/input_bus/SKILL.md deleted file mode 100644 index 18fe4da8..00000000 --- a/src/bub/skills/builtin/input_bus/SKILL.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: input-bus -description: Resolve inbound session ids and normalize inbound messages. -kind: bus -entrypoint: bub.skills.builtin.input_bus.plugin:plugin ---- - -# Input Bus Skill - -Normalizes inbound envelopes and maps them into stable session ids. diff --git a/src/bub/skills/builtin/input_bus/__init__.py b/src/bub/skills/builtin/input_bus/__init__.py deleted file mode 100644 index c72072f2..00000000 --- a/src/bub/skills/builtin/input_bus/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Input/bus skill.""" diff --git a/src/bub/skills/builtin/input_bus/plugin.py b/src/bub/skills/builtin/input_bus/plugin.py deleted file mode 100644 index 1ecb2fcc..00000000 --- a/src/bub/skills/builtin/input_bus/plugin.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Builtin input bus hooks.""" - -from __future__ import annotations - -from bub.bus import MessageBus -from bub.envelope import normalize_envelope -from bub.hookspecs import hookimpl -from bub.types import Envelope - - -class InputBusSkill: - @hookimpl - def provide_bus(self) -> MessageBus: - return MessageBus() - - @hookimpl - def normalize_inbound(self, message: Envelope) -> Envelope: - envelope = normalize_envelope(message) - content = str(envelope.get("content", "")).strip() - metadata = envelope.get("metadata") - if not isinstance(metadata, dict): - metadata = {} - metadata.setdefault("normalized", True) - envelope["content"] = content - envelope["metadata"] = metadata - return envelope - - @hookimpl - def resolve_session(self, message: Envelope) -> str | None: - envelope = normalize_envelope(message) - channel = envelope.get("channel") - chat_id = envelope.get("chat_id") - if channel is None or chat_id is None: - return None - return f"{channel}:{chat_id}" - - -plugin = InputBusSkill() diff --git a/src/bub/skills/builtin/memory_tape/SKILL.md b/src/bub/skills/builtin/memory_tape/SKILL.md deleted file mode 100644 index 32670cee..00000000 --- a/src/bub/skills/builtin/memory_tape/SKILL.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: memory-tape -description: In-memory session state skill that mimics tape-style evolution. -kind: memory -entrypoint: bub.skills.builtin.memory_tape.plugin:plugin ---- - -# Memory Tape Skill - -Keeps minimal per-session state in-process and updates counters each turn. diff --git a/src/bub/skills/builtin/memory_tape/__init__.py b/src/bub/skills/builtin/memory_tape/__init__.py deleted file mode 100644 index 1b24ca10..00000000 --- a/src/bub/skills/builtin/memory_tape/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""In-memory tape skill.""" diff --git a/src/bub/skills/builtin/memory_tape/plugin.py b/src/bub/skills/builtin/memory_tape/plugin.py deleted file mode 100644 index 42b80c32..00000000 --- a/src/bub/skills/builtin/memory_tape/plugin.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Builtin memory hook implementation.""" - -from __future__ import annotations - -from bub.envelope import content_of -from bub.hookspecs import hookimpl -from bub.skills.builtin.common import read_turn -from bub.types import Envelope, State - - -class MemoryTapeSkill: - def __init__(self) -> None: - self._state_by_session: dict[str, State] = {} - - @hookimpl - def load_state(self, session_id: str) -> State: - state = self._state_by_session.get(session_id, {"turn": 0}) - return dict(state) - - @hookimpl - def save_state(self, session_id: str, state: State, message: Envelope, model_output: str) -> None: - state["turn"] = read_turn(state) + 1 - state["last_user"] = content_of(message) - state["last_assistant"] = model_output - self._state_by_session[session_id] = dict(state) - - -plugin = MemoryTapeSkill() diff --git a/src/bub/skills/builtin/model_echo/SKILL.md b/src/bub/skills/builtin/model_echo/SKILL.md deleted file mode 100644 index 2a25d2ac..00000000 --- a/src/bub/skills/builtin/model_echo/SKILL.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: model-echo -description: Minimal model hook that echoes prompt text. -kind: model -entrypoint: bub.skills.builtin.model_echo.plugin:plugin ---- - -# Echo Model Skill - -Provides the smallest possible model hook implementation for framework bootstrap. diff --git a/src/bub/skills/builtin/model_echo/__init__.py b/src/bub/skills/builtin/model_echo/__init__.py deleted file mode 100644 index 810833b4..00000000 --- a/src/bub/skills/builtin/model_echo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Echo model skill.""" diff --git a/src/bub/skills/builtin/model_echo/plugin.py b/src/bub/skills/builtin/model_echo/plugin.py deleted file mode 100644 index 05e988eb..00000000 --- a/src/bub/skills/builtin/model_echo/plugin.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Builtin model hook implementation.""" - -from __future__ import annotations - -from bub.envelope import content_of -from bub.hookspecs import hookimpl -from bub.skills.builtin.common import read_turn -from bub.types import Envelope, State - - -class EchoModelSkill: - @hookimpl - def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: - _ = session_id - prefix = str(state.get("prompt_prefix", "")) - return f"{prefix}{content_of(message)}" - - @hookimpl - def run_model(self, prompt: str, session_id: str, state: State) -> str: - turn = read_turn(state) + 1 - return f"[{session_id}] turn={turn} {prompt}" - - -plugin = EchoModelSkill() diff --git a/src/bub/skills/builtin/output_stdout/SKILL.md b/src/bub/skills/builtin/output_stdout/SKILL.md deleted file mode 100644 index 5a7da398..00000000 --- a/src/bub/skills/builtin/output_stdout/SKILL.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: output-stdout -description: Render outbound envelopes and dispatch stdout channel messages. -kind: output -entrypoint: bub.skills.builtin.output_stdout.plugin:plugin ---- - -# Output Stdout Skill - -Renders outbound envelopes and prints messages when channel is stdout. diff --git a/src/bub/skills/builtin/output_stdout/__init__.py b/src/bub/skills/builtin/output_stdout/__init__.py deleted file mode 100644 index a5504d05..00000000 --- a/src/bub/skills/builtin/output_stdout/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Stdout output skill.""" diff --git a/src/bub/skills/builtin/output_stdout/plugin.py b/src/bub/skills/builtin/output_stdout/plugin.py deleted file mode 100644 index c6f82037..00000000 --- a/src/bub/skills/builtin/output_stdout/plugin.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Builtin output hooks.""" - -from __future__ import annotations - -from bub.envelope import field_of -from bub.hookspecs import hookimpl -from bub.types import Envelope, State - - -class OutputStdoutSkill: - @hookimpl - def render_outbound(self, message: Envelope, session_id: str, state: State, model_output: str) -> list[Envelope]: - _ = state - channel = field_of(message, "channel", "stdout") - chat_id = field_of(message, "chat_id", "local") - return [ - { - "channel": channel, - "chat_id": chat_id, - "content": model_output, - "metadata": {"session_id": session_id}, - } - ] - - @hookimpl - def dispatch_outbound(self, message: Envelope) -> bool: - if field_of(message, "channel", "stdout") != "stdout": - return False - print(field_of(message, "content", "")) - return True - - -plugin = OutputStdoutSkill() diff --git a/src/bub/skills/builtin/runtime/SKILL.md b/src/bub/skills/builtin/runtime/SKILL.md new file mode 100644 index 00000000..ae1b32d8 --- /dev/null +++ b/src/bub/skills/builtin/runtime/SKILL.md @@ -0,0 +1,10 @@ +--- +name: runtime +description: Republic-driven model skill with command routing, tools, and tape-backed sessions. +kind: model +entrypoint: bub.skills.builtin.runtime.plugin:plugin +--- + +# Runtime Skill + +Provides a Republic-driven model runtime and command battery through hooks. diff --git a/src/bub/skills/builtin/runtime/__init__.py b/src/bub/skills/builtin/runtime/__init__.py new file mode 100644 index 00000000..8bfc1d61 --- /dev/null +++ b/src/bub/skills/builtin/runtime/__init__.py @@ -0,0 +1 @@ +"""Builtin runtime skill package.""" diff --git a/src/bub/skills/builtin/runtime/engine.py b/src/bub/skills/builtin/runtime/engine.py new file mode 100644 index 00000000..f82ec7d1 --- /dev/null +++ b/src/bub/skills/builtin/runtime/engine.py @@ -0,0 +1,746 @@ +"""Republic-driven runtime battery used by runtime skill.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import shlex +import time +from dataclasses import dataclass +from datetime import UTC, datetime +from hashlib import md5 +from pathlib import Path +from typing import Any + +from republic import LLM, TapeEntry, Tool, ToolAutoResult +from republic.tape import InMemoryTapeStore, Tape + +from bub.skills.loader import discover_skills, load_skill_body + +DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" +DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 +DEFAULT_MODEL_TIMEOUT_SECONDS = 90 +DEFAULT_MAX_STEPS = 8 +DEFAULT_MAX_TOKENS = 1024 +CONTINUE_PROMPT = "Continue the task." +AGENTS_FILE_NAME = "AGENTS.md" + + +@dataclass(frozen=True) +class ParsedArgs: + kwargs: dict[str, object] + positional: list[str] + + +@dataclass(frozen=True) +class RuntimeSettings: + model: str + api_key: str | None + api_base: str | None + max_steps: int + max_tokens: int + timeout_seconds: int | None + enabled: bool + + +class RuntimeEngine: + """Runtime engine with command compatibility and Republic model driving.""" + + def __init__(self, workspace: Path) -> None: + self.workspace = workspace.resolve() + self._event_root = self.workspace / ".bub" / "runtime" + self._event_root.mkdir(parents=True, exist_ok=True) + self._settings = _load_runtime_settings() + self._llm = _build_llm(self._settings) + self._workspace_prompt = _read_workspace_agents_prompt(self.workspace) + + async def run(self, *, session_id: str, prompt: str) -> str | None: + stripped = prompt.strip() + if not stripped: + return None + if stripped.startswith(","): + return await self._run_command(session_id=session_id, line=stripped) + return await self._run_runtime(session_id=session_id, prompt=stripped) + + async def _run_runtime(self, *, session_id: str, prompt: str) -> str | None: + if self._llm is None: + return None + + tape = self._llm.tape(_session_tape_name(session_id)) + self._ensure_bootstrap_anchor(tape) + tools = self._build_model_tools(session_id=session_id, tape=tape) + next_prompt = prompt + + for step in range(1, self._settings.max_steps + 1): + start = time.monotonic() + try: + output = await self._run_tools_once(tape=tape, prompt=next_prompt, tools=tools) + except Exception as exc: + elapsed_ms = int((time.monotonic() - start) * 1000) + self._append_event( + session_id, + { + "type": "model", + "step": step, + "status": "error", + "elapsed_ms": elapsed_ms, + "error": f"{exc!s}", + "ts": datetime.now(UTC).isoformat(), + }, + ) + return None + + outcome = _resolve_tool_auto_result(output) + elapsed_ms = int((time.monotonic() - start) * 1000) + if outcome.kind == "text": + self._append_event( + session_id, + { + "type": "model", + "step": step, + "status": "ok", + "elapsed_ms": elapsed_ms, + "ts": datetime.now(UTC).isoformat(), + }, + ) + return outcome.text + if outcome.kind == "continue": + self._append_event( + session_id, + { + "type": "model", + "step": step, + "status": "continue", + "elapsed_ms": elapsed_ms, + "ts": datetime.now(UTC).isoformat(), + }, + ) + next_prompt = CONTINUE_PROMPT + continue + self._append_event( + session_id, + { + "type": "model", + "step": step, + "status": "error", + "elapsed_ms": elapsed_ms, + "error": outcome.error, + "ts": datetime.now(UTC).isoformat(), + }, + ) + return f"error: {outcome.error}" + + return f"error: max_steps_reached={self._settings.max_steps}" + + async def _run_tools_once(self, *, tape: Tape, prompt: str, tools: list[Tool]) -> ToolAutoResult: + if self._settings.timeout_seconds is None: + return await tape.run_tools_async( + prompt=prompt, + system_prompt=self._system_prompt(), + max_tokens=self._settings.max_tokens, + tools=tools, + extra_headers={"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"}, + ) + async with asyncio.timeout(self._settings.timeout_seconds): + return await tape.run_tools_async( + prompt=prompt, + system_prompt=self._system_prompt(), + max_tokens=self._settings.max_tokens, + tools=tools, + extra_headers={"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"}, + ) + + async def _run_command(self, *, session_id: str, line: str) -> str: + tape = self._llm.tape(_session_tape_name(session_id)) if self._llm is not None else None + if tape is not None: + self._ensure_bootstrap_anchor(tape) + raw_body = line[1:].strip() + if not raw_body: + return "error: empty command" + + name, args_tokens = _parse_internal_command(line) + resolved_name = _resolve_internal_name(name) + command_name = resolved_name if resolved_name in _internal_tool_names() else "bash" + start = time.monotonic() + try: + if command_name == "bash": + output = await self._run_shell(raw_body) + else: + output = await self._run_internal( + command_name=command_name, + args_tokens=args_tokens, + session_id=session_id, + tape=tape, + ) + status = "ok" + except Exception as exc: + status = "error" + output = f"{exc!s}" + elapsed_ms = int((time.monotonic() - start) * 1000) + + event_payload = { + "type": "command", + "raw": line, + "name": command_name, + "status": status, + "elapsed_ms": elapsed_ms, + "output": output, + "ts": datetime.now(UTC).isoformat(), + } + self._append_event(session_id, event_payload) + if tape is not None: + tape.append(TapeEntry.event("command", data=event_payload)) + if status == "error": + return f"error: {output}" + return output + + async def _run_internal( + self, + *, + command_name: str, + args_tokens: list[str], + session_id: str, + tape: Tape | None, + ) -> str: + args = _parse_kv_arguments(args_tokens) + handler = self._internal_handlers().get(command_name) + if handler is None: + raise RuntimeError(f"unknown internal command: {command_name}") + result = handler(args=args, session_id=session_id, tape=tape) + if inspect.isawaitable(result): + result = await result + return str(result) + + def _internal_handlers(self) -> dict[str, Any]: + return { + "help": self._command_help, + "tools": self._command_tools, + "tool.describe": self._command_tool_describe, + "skills.list": self._command_skills_list, + "skills.describe": self._command_skills_describe, + "tape.info": self._command_tape_info, + "tape.search": self._command_tape_search, + "tape.handoff": self._command_tape_handoff, + "tape.anchors": self._command_tape_anchors, + "fs.read": self._command_fs_read, + "fs.write": self._command_fs_write, + "fs.edit": self._command_fs_edit, + "quit": self._command_quit, + } + + def _command_help(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = args, session_id, tape + return _help_text() + + def _command_tools(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = args, session_id, tape + return "\n".join(sorted(_internal_tool_names())) + + def _command_tool_describe(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = session_id, tape + name = _arg_as_str(args, "name") or (args.positional[0] if args.positional else "") + if not name: + raise RuntimeError("missing tool name") + return _tool_describe(name) + + def _command_skills_list(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = args, session_id, tape + skills = discover_skills(self.workspace) + if not skills: + return "(no skills)" + return "\n".join(f"{skill.name} ({skill.source}): {skill.description}" for skill in skills) + + def _command_skills_describe(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = session_id, tape + name = _arg_as_str(args, "name") or (args.positional[0] if args.positional else "") + if not name: + raise RuntimeError("missing skill name") + body = load_skill_body(name, self.workspace) + if body is None: + raise RuntimeError(f"skill not found: {name}") + return body + + def _command_tape_info(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = args + entries = self._read_entries(session_id=session_id, tape=tape) + anchors = [entry for entry in entries if entry.get("kind") == "anchor"] + last_anchor = str(anchors[-1].get("name") or "-") if anchors else "-" + return f"name: {session_id}\nentries: {len(entries)}\nanchors: {len(anchors)}\nlast_anchor: {last_anchor}" + + def _command_tape_search(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + query = _arg_as_str(args, "query") or (args.positional[0] if args.positional else "") + if not query: + raise RuntimeError("missing query") + limit = _arg_as_int(args, "limit", default=20) or 20 + lowered = query.casefold() + matches: list[str] = [] + for entry in self._read_entries(session_id=session_id, tape=tape): + serialized = json.dumps(entry, ensure_ascii=False) + if lowered in serialized.casefold(): + matches.append(serialized) + if len(matches) >= limit: + break + if not matches: + return "(no matches)" + return "\n".join(matches) + + def _command_tape_handoff(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + name = _arg_as_str(args, "name") or (args.positional[0] if args.positional else "handoff") + summary = _arg_as_str(args, "summary") or "" + if tape is not None: + state = {"summary": summary} if summary else None + tape.handoff(name, state=state) + return f"anchor added: {name}" + self._append_event( + session_id, + { + "type": "anchor", + "name": name, + "summary": summary, + "ts": datetime.now(UTC).isoformat(), + }, + ) + return f"anchor added: {name}" + + def _command_tape_anchors(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = args + anchors = [entry for entry in self._read_entries(session_id=session_id, tape=tape) if entry.get("kind") == "anchor"] + if not anchors: + return "(no anchors)" + return "\n".join(str(entry.get("name") or "-") for entry in anchors) + + def _command_fs_read(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = session_id, tape + path = _arg_as_str(args, "path") or (args.positional[0] if args.positional else "") + if not path: + raise RuntimeError("missing path") + offset = _arg_as_int(args, "offset", default=0) or 0 + limit = _arg_as_int(args, "limit") + return self._fs_read(path, offset=offset, limit=limit) + + def _command_fs_write(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = session_id, tape + path = _arg_as_str(args, "path") or (args.positional[0] if args.positional else "") + content = _arg_as_str(args, "content") + if not path or content is None: + raise RuntimeError("missing path/content") + return self._fs_write(path, content) + + def _command_fs_edit(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = session_id, tape + path = _arg_as_str(args, "path") or (args.positional[0] if args.positional else "") + old = _arg_as_str(args, "old") + new = _arg_as_str(args, "new") + replace_all = bool(args.kwargs.get("replace_all", False)) + if not path or old is None or new is None: + raise RuntimeError("missing path/old/new") + return self._fs_edit(path, old, new, replace_all=replace_all) + + def _command_quit(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: + _ = args, session_id, tape + return "exit" + + async def _run_shell(self, command: str, *, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS) -> str: + completed = await asyncio.create_subprocess_exec( + "bash", + "-lc", + command, + cwd=cwd or str(self.workspace), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + async with asyncio.timeout(timeout_seconds): + stdout_bytes, stderr_bytes = await completed.communicate() + stdout_text = (stdout_bytes or b"").decode("utf-8", errors="replace").strip() + stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace").strip() + if completed.returncode != 0: + message = stderr_text or stdout_text or f"exit={completed.returncode}" + raise RuntimeError(f"exit={completed.returncode}: {message}") + return stdout_text or "(no output)" + + def _build_model_tools(self, *, session_id: str, tape: Tape) -> list[Tool]: # noqa: C901 + async def bash(cmd: str, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS) -> str: + return await self._run_shell(cmd, cwd=cwd, timeout_seconds=timeout_seconds) + + def fs_read(path: str, offset: int = 0, limit: int | None = None) -> str: + return self._fs_read(path, offset=offset, limit=limit) + + def fs_write(path: str, content: str) -> str: + return self._fs_write(path, content) + + def fs_edit(path: str, old: str, new: str, replace_all: bool = False) -> str: + return self._fs_edit(path, old, new, replace_all=replace_all) + + def skills_list() -> str: + return self._command_skills_list(args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape) + + def skills_describe(name: str) -> str: + args = ParsedArgs(kwargs={"name": name}, positional=[]) + return self._command_skills_describe(args=args, session_id=session_id, tape=tape) + + def tape_info() -> str: + return self._command_tape_info(args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape) + + def tape_search(query: str, limit: int = 20) -> str: + args = ParsedArgs(kwargs={"query": query, "limit": limit}, positional=[]) + return self._command_tape_search(args=args, session_id=session_id, tape=tape) + + def tape_handoff(name: str = "handoff", summary: str = "") -> str: + args = ParsedArgs(kwargs={"name": name, "summary": summary}, positional=[]) + return self._command_tape_handoff(args=args, session_id=session_id, tape=tape) + + def tape_anchors() -> str: + return self._command_tape_anchors(args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape) + + tools = [ + ("bash", "Run shell command in workspace with timeout.", bash), + ("fs.read", "Read a UTF-8 file with optional offset and limit.", fs_read), + ("fs.write", "Write UTF-8 content to a file path.", fs_write), + ("fs.edit", "Replace text once or all in a file.", fs_edit), + ("skills.list", "List discovered skills with source and description.", skills_list), + ("skills.describe", "Read SKILL.md body by skill name.", skills_describe), + ("tape.info", "Show session tape summary.", tape_info), + ("tape.search", "Search tape entries by query.", tape_search), + ("tape.handoff", "Create one anchor event.", tape_handoff), + ("tape.anchors", "List anchor names.", tape_anchors), + ] + return [Tool.from_callable(func, name=name, description=description) for name, description, func in tools] + + def _system_prompt(self) -> str: + blocks = [ + ( + "You are Bub runtime skill. Use tools for operations such as shell, file edits, " + "skills lookup, and tape operations. Return concise natural language when done." + ), + ] + if self._workspace_prompt: + blocks.append(self._workspace_prompt) + return "\n\n".join(blocks) + + def _read_entries(self, *, session_id: str, tape: Tape | None) -> list[dict[str, object]]: + if tape is not None: + entries: list[dict[str, object]] = [] + for entry in tape.read_entries(): + entries.append( + { + "id": entry.id, + "kind": entry.kind, + "name": entry.payload.get("name") if isinstance(entry.payload, dict) else None, + "payload": entry.payload, + "meta": entry.meta, + } + ) + return entries + return self._read_events_file(session_id) + + @staticmethod + def _ensure_bootstrap_anchor(tape: Tape) -> None: + for entry in tape.read_entries(): + if entry.kind == "anchor": + return + tape.handoff("session/start", state={"owner": "human"}) + + def _fs_read(self, raw_path: str, *, offset: int = 0, limit: int | None = None) -> str: + path = self._resolve_path(raw_path) + text = path.read_text(encoding="utf-8") + lines = text.splitlines() + start = max(0, min(offset, len(lines))) + end = len(lines) if limit is None else min(len(lines), start + max(0, limit)) + return "\n".join(lines[start:end]) + + def _fs_write(self, raw_path: str, content: str) -> str: + path = self._resolve_path(raw_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return f"wrote: {path}" + + def _fs_edit(self, raw_path: str, old: str, new: str, *, replace_all: bool) -> str: + path = self._resolve_path(raw_path) + text = path.read_text(encoding="utf-8") + if old not in text: + raise RuntimeError("old text not found") + if replace_all: + count = text.count(old) + path.write_text(text.replace(old, new), encoding="utf-8") + return f"updated: {path} occurrences={count}" + path.write_text(text.replace(old, new, 1), encoding="utf-8") + return f"updated: {path} occurrences=1" + + def _resolve_path(self, raw_path: str) -> Path: + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (self.workspace / path).resolve() + + def _append_event(self, session_id: str, payload: dict[str, object]) -> None: + file_path = self._event_file(session_id) + with file_path.open("a", encoding="utf-8") as file: + file.write(json.dumps(payload, ensure_ascii=False) + "\n") + + def _read_events_file(self, session_id: str) -> list[dict[str, object]]: + file_path = self._event_file(session_id) + if not file_path.exists(): + return [] + events: list[dict[str, object]] = [] + for raw in file_path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line: + continue + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + events.append(parsed) + return events + + def _event_file(self, session_id: str) -> Path: + slug = md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 + return self._event_root / f"{slug}.jsonl" + + +@dataclass(frozen=True) +class _ToolAutoOutcome: + kind: str + text: str = "" + error: str = "" + + +def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome: + if output.kind == "text": + return _ToolAutoOutcome(kind="text", text=output.text or "") + if output.kind == "tools" or output.tool_calls or output.tool_results: + return _ToolAutoOutcome(kind="continue") + if output.error is None: + return _ToolAutoOutcome(kind="error", error="tool_auto_error: unknown") + error_kind = getattr(output.error.kind, "value", str(output.error.kind)) + return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}") + + +def _build_llm(settings: RuntimeSettings) -> LLM | None: + if not settings.enabled: + return None + return LLM( + settings.model, + api_key=settings.api_key, + api_base=settings.api_base, + tape_store=InMemoryTapeStore(), + ) + + +def _load_runtime_settings() -> RuntimeSettings: + model = _first_non_empty([os.getenv("BUB_MODEL"), DEFAULT_MODEL]) or DEFAULT_MODEL + api_key = _first_non_empty( + [ + os.getenv("BUB_API_KEY"), + os.getenv("BUB_LLM_API_KEY"), + os.getenv("BUB_OPENROUTER_API_KEY"), + os.getenv("LLM_API_KEY"), + os.getenv("OPENROUTER_API_KEY"), + ] + ) + api_base = _first_non_empty([os.getenv("BUB_API_BASE")]) + max_steps = _int_env("BUB_RUNTIME_MAX_STEPS", default=DEFAULT_MAX_STEPS) + max_tokens = _int_env("BUB_RUNTIME_MAX_TOKENS", default=DEFAULT_MAX_TOKENS) + timeout_seconds = _int_env("BUB_RUNTIME_MODEL_TIMEOUT_SECONDS", default=DEFAULT_MODEL_TIMEOUT_SECONDS) + mode = (_first_non_empty([os.getenv("BUB_RUNTIME_ENABLED"), "auto"]) or "auto").casefold() + + requires_key = _model_requires_api_key(model) + if mode in {"1", "true", "yes", "on"}: + enabled = True + elif mode in {"0", "false", "no", "off"}: + enabled = False + else: + enabled = bool(api_key) or not requires_key + + return RuntimeSettings( + model=model, + api_key=api_key, + api_base=api_base, + max_steps=max_steps, + max_tokens=max_tokens, + timeout_seconds=timeout_seconds, + enabled=enabled, + ) + + +def _model_requires_api_key(model: str) -> bool: + prefixes = ("openrouter:", "openai:", "anthropic:", "gemini:", "xai:", "groq:", "mistral:", "deepseek:") + lowered = model.casefold() + return lowered.startswith(prefixes) + + +def _first_non_empty(values: list[str | None]) -> str | None: + for value in values: + if value is None: + continue + stripped = value.strip() + if stripped: + return stripped + return None + + +def _int_env(name: str, *, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + parsed = int(raw) + except ValueError: + return default + if parsed <= 0: + return default + return parsed + + + + +def _read_workspace_agents_prompt(workspace: Path) -> str: + prompt_path = workspace / AGENTS_FILE_NAME + if not prompt_path.is_file(): + return "" + try: + return prompt_path.read_text(encoding="utf-8").strip() + except OSError: + return "" + + +def _session_tape_name(session_id: str) -> str: + slug = md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 + return f"runtime:{slug}" + + +def _parse_internal_command(line: str) -> tuple[str, list[str]]: + body = line.strip()[1:].strip() + words = _parse_command_words(body) + if not words: + return "", [] + return words[0], words[1:] + + +def _parse_command_words(text: str) -> list[str]: + try: + return shlex.split(text) + except ValueError: + return [] + + +def _parse_kv_arguments(tokens: list[str]) -> ParsedArgs: + kwargs: dict[str, object] = {} + positional: list[str] = [] + index = 0 + while index < len(tokens): + token = tokens[index] + if token.startswith("--"): + key = token[2:] + if "=" in key: + name, value = key.split("=", 1) + kwargs[name] = value + index += 1 + continue + if index + 1 < len(tokens) and not tokens[index + 1].startswith("--"): + kwargs[key] = tokens[index + 1] + index += 2 + continue + kwargs[key] = True + index += 1 + continue + if "=" in token: + key, value = token.split("=", 1) + kwargs[key] = value + index += 1 + continue + positional.append(token) + index += 1 + return ParsedArgs(kwargs=kwargs, positional=positional) + + +def _resolve_internal_name(name: str) -> str: + aliases = { + "tool": "tool.describe", + "tape": "tape.info", + "skill": "skills.describe", + } + return aliases.get(name, name) + + +def _internal_tool_names() -> set[str]: + return { + "bash", + "help", + "tools", + "tool.describe", + "skills.list", + "skills.describe", + "tape.info", + "tape.search", + "tape.handoff", + "tape.anchors", + "fs.read", + "fs.write", + "fs.edit", + "quit", + } + + +def _tool_describe(name: str) -> str: + descriptions = { + "bash": "Run shell command in workspace with timeout.", + "help": "Show internal command usage.", + "tools": "List available internal tools.", + "tool.describe": "Show one tool description.", + "skills.list": "List discovered skills with source and description.", + "skills.describe": "Show one skill body by name.", + "tape.info": "Show session tape summary.", + "tape.search": "Search session tape entries by query.", + "tape.handoff": "Create an anchor event.", + "tape.anchors": "List anchor names.", + "fs.read": "Read a UTF-8 file with optional offset and limit.", + "fs.write": "Write UTF-8 file content.", + "fs.edit": "Replace text in file once or all.", + "quit": "Return exit marker.", + } + description = descriptions.get(name) + if description is None: + raise RuntimeError(f"unknown tool: {name}") + return f"{name}: {description}" + + +def _help_text() -> str: + return ( + "Commands use ',' at line start.\n" + "Known internal commands:\n" + " ,help\n" + " ,tools\n" + " ,tool.describe name=fs.read\n" + " ,skills.list\n" + " ,skills.describe name=friendly-python\n" + " ,tape.info\n" + " ,tape.search query=error\n" + " ,tape.handoff name=phase-1 summary='done'\n" + " ,tape.anchors\n" + " ,fs.read path=README.md\n" + " ,fs.write path=tmp.txt content='hello'\n" + " ,fs.edit path=tmp.txt old=hello new=world\n" + " ,quit\n" + "Any unknown command after ',' is executed as shell via bash." + ) + + +def _arg_as_str(args: ParsedArgs, key: str) -> str | None: + value = args.kwargs.get(key) + if value is None: + return None + return str(value) + + +def _arg_as_int(args: ParsedArgs, key: str, default: int | None = None) -> int | None: + value = args.kwargs.get(key) + if value is None: + return default + try: + return int(str(value)) + except (TypeError, ValueError): + return default diff --git a/src/bub/skills/builtin/runtime/plugin.py b/src/bub/skills/builtin/runtime/plugin.py new file mode 100644 index 00000000..8676c8cf --- /dev/null +++ b/src/bub/skills/builtin/runtime/plugin.py @@ -0,0 +1,68 @@ +"""Builtin runtime hook implementation.""" + +from __future__ import annotations + +from pathlib import Path + +from bub.envelope import content_of, field_of, normalize_envelope +from bub.hookspecs import hookimpl +from bub.skills.builtin.runtime.engine import RuntimeEngine +from bub.types import Envelope, State + + +class RuntimeSkill: + @hookimpl + def normalize_inbound(self, message: Envelope) -> Envelope: + envelope = normalize_envelope(message) + envelope["content"] = str(envelope.get("content", "")).strip() + metadata = envelope.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + metadata.setdefault("listener", "runtime") + envelope["metadata"] = metadata + return envelope + + @hookimpl + def resolve_session(self, message: Envelope) -> str: + session_id = field_of(message, "session_id") + if session_id is not None and str(session_id).strip(): + return str(session_id) + channel = str(field_of(message, "channel", "default")) + chat_id = str(field_of(message, "chat_id", "default")) + return f"{channel}:{chat_id}" + + @hookimpl + def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: + _ = session_id + workspace = field_of(message, "workspace") + if isinstance(workspace, str) and workspace.strip(): + state["_runtime_workspace"] = workspace.strip() + elif "_runtime_workspace" not in state: + state["_runtime_workspace"] = str(Path.cwd()) + return content_of(message) + + @hookimpl + async def run_model(self, prompt: str, session_id: str, state: State) -> str | None: + workspace = _workspace_from_state(state) + engine = _engine_for_workspace(workspace) + return await engine.run(session_id=session_id, prompt=prompt) + + +def _workspace_from_state(state: State) -> Path: + raw = state.get("_runtime_workspace") + if isinstance(raw, str) and raw.strip(): + return Path(raw).expanduser().resolve() + return Path.cwd().resolve() + + +def _engine_for_workspace(workspace: Path) -> RuntimeEngine: + cached = _ENGINE_CACHE.get(workspace) + if cached is not None: + return cached + engine = RuntimeEngine(workspace) + _ENGINE_CACHE[workspace] = engine + return engine + + +_ENGINE_CACHE: dict[Path, RuntimeEngine] = {} +plugin = RuntimeSkill() diff --git a/src/bub/skills/loader.py b/src/bub/skills/loader.py index 0d0292a2..19ea4950 100644 --- a/src/bub/skills/loader.py +++ b/src/bub/skills/loader.py @@ -61,6 +61,20 @@ def discover_skills(workspace_path: Path) -> list[SkillMetadata]: return sorted(skills_by_name.values(), key=lambda item: item.name.casefold()) +def load_skill_body(name: str, workspace_path: Path) -> str | None: + """Load full SKILL.md content by skill name.""" + + lowered = name.casefold() + for skill in discover_skills(workspace_path): + if skill.name.casefold() != lowered: + continue + try: + return skill.location.read_text(encoding="utf-8") + except OSError: + return None + return None + + def load_skill_plugin(skill: SkillMetadata) -> object: """Load plugin object from one skill entrypoint.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..4d76d88b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture(autouse=True) +def _disable_runtime_model_driver(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BUB_RUNTIME_ENABLED", "0") diff --git a/tests/fixtures_plugins/__init__.py b/tests/fixtures_plugins/__init__.py new file mode 100644 index 00000000..bcfba0f8 --- /dev/null +++ b/tests/fixtures_plugins/__init__.py @@ -0,0 +1,2 @@ +"""Test-only skill plugins.""" + diff --git a/tests/fixtures_plugins/stateful_hooks.py b/tests/fixtures_plugins/stateful_hooks.py new file mode 100644 index 00000000..ee2dcbb0 --- /dev/null +++ b/tests/fixtures_plugins/stateful_hooks.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from bub.envelope import content_of +from bub.hookspecs import hookimpl + + +class StatefulHooksSkill: + def __init__(self) -> None: + self._states: dict[str, dict[str, object]] = {} + + @hookimpl + def load_state(self, session_id: str) -> dict[str, object]: + return dict(self._states.get(session_id, {"turn": 0})) + + @hookimpl + def build_prompt(self, message: object, session_id: str, state: dict[str, object]) -> str: + _ = session_id + _ = state + return content_of(message) + + @hookimpl + def run_model(self, prompt: str, session_id: str, state: dict[str, object]) -> str: + turn = int(state.get("turn", 0)) + 1 + return f"[{session_id}] turn={turn} {prompt}" + + @hookimpl + def save_state( + self, + session_id: str, + state: dict[str, object], + message: object, + model_output: str, + ) -> None: + _ = message, model_output + state["turn"] = int(state.get("turn", 0)) + 1 + self._states[session_id] = dict(state) + + +plugin = StatefulHooksSkill() diff --git a/tests/test_framework_flow.py b/tests/test_framework_flow.py index 1d48d33a..9bdfaa5e 100644 --- a/tests/test_framework_flow.py +++ b/tests/test_framework_flow.py @@ -8,6 +8,24 @@ from bub.framework import BubFramework +def _write_stateful_test_skill(workspace: Path) -> None: + skill_dir = workspace / ".agent" / "skills" / "stateful-hooks" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "\n".join( + [ + "---", + "name: stateful-hooks", + "description: test-only stateful hooks skill", + "kind: model", + "entrypoint: fixtures_plugins.stateful_hooks:plugin", + "---", + ] + ), + encoding="utf-8", + ) + + @pytest.mark.asyncio async def test_framework_processes_message_with_builtin_skills(tmp_path: Path) -> None: framework = BubFramework(tmp_path) @@ -19,13 +37,16 @@ async def test_framework_processes_message_with_builtin_skills(tmp_path: Path) - assert result.session_id == "stdout:local" assert "hello framework" in result.prompt - assert result.model_output.startswith("[stdout:local] turn=1") + assert result.model_output == "hello framework" assert result.outbounds assert result.outbounds[0]["content"] == result.model_output @pytest.mark.asyncio -async def test_framework_increments_state_across_turns(tmp_path: Path) -> None: +async def test_framework_increments_state_across_turns(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + _write_stateful_test_skill(tmp_path) + monkeypatch.syspath_prepend(str(Path(__file__).parent)) + framework = BubFramework(tmp_path) framework.load_skills() @@ -67,3 +88,57 @@ def test_framework_registers_cli_commands_from_skills(tmp_path: Path) -> None: command_names = {command.name for command in app.registered_commands} assert {"run", "skills", "hooks"}.issubset(command_names) + + +@pytest.mark.asyncio +async def test_framework_routes_internal_command_with_runtime(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + + result = await framework.process_inbound( + {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": ",help"} + ) + + assert "Commands use ',' at line start." in result.model_output + + +@pytest.mark.asyncio +async def test_framework_routes_shell_command_with_runtime(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + + result = await framework.process_inbound( + {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": ",echo runtime-ok"} + ) + + assert "runtime-ok" in result.model_output + + +@pytest.mark.asyncio +async def test_runtime_normalizes_inbound_content(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + + result = await framework.process_inbound( + {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": " padded message "} + ) + + assert result.prompt == "padded message" + + +@pytest.mark.asyncio +async def test_runtime_resolve_session_ignores_blank_session_id(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + + result = await framework.process_inbound( + { + "channel": "stdout", + "chat_id": "trim", + "sender_id": "u1", + "session_id": " ", + "content": "hello", + } + ) + + assert result.session_id == "stdout:trim" diff --git a/uv.lock b/uv.lock index ee96f611..1ea8ec79 100644 --- a/uv.lock +++ b/uv.lock @@ -7,113 +7,6 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -193,68 +86,12 @@ wheels = [ ] [[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, -] - -[[package]] -name = "audioop-lts" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" }, - { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" }, - { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" }, - { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" }, - { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" }, - { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" }, - { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" }, - { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" }, - { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" }, - { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" }, - { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" }, - { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" }, - { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" }, - { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" }, - { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" }, - { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" }, - { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" }, - { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" }, - { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" }, - { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" }, - { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" }, - { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" }, - { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" }, - { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" }, - { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" }, - { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" }, - { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" }, - { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" }, - { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" }, - { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" }, - { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" }, - { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" }, +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] [[package]] @@ -262,11 +99,8 @@ name = "bub" version = "0.2.3" source = { editable = "." } dependencies = [ - { name = "aiohttp" }, - { name = "any-llm-sdk", extra = ["anthropic", "vertexai"] }, { name = "apscheduler" }, - { name = "discord-py" }, - { name = "httpx", extra = ["socks"] }, + { name = "blinker" }, { name = "loguru" }, { name = "pluggy" }, { name = "prompt-toolkit" }, @@ -300,11 +134,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "aiohttp", specifier = ">=3.13.3" }, - { name = "any-llm-sdk", extras = ["anthropic", "vertexai"], specifier = ">=1.8.0" }, { name = "apscheduler", specifier = ">=3.11.2" }, - { name = "discord-py", specifier = ">=2.6.4" }, - { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, + { name = "blinker", specifier = ">=1.8.2" }, { name = "loguru", specifier = ">=0.7.2" }, { name = "pluggy", specifier = ">=1.6.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, @@ -600,95 +431,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - [[package]] name = "ghp-import" version = "2.1.0" @@ -1173,105 +915,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, ] -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - [[package]] name = "mypy" version = "1.19.1" @@ -1417,120 +1060,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.12.5" @@ -1976,15 +1505,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "socksio" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, -] - [[package]] name = "telegramify-markdown" version = "0.5.4" @@ -2252,97 +1772,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b66 wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] - -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, -] From 36246fe8fd797ace82add8bc30022a4b724220be Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sun, 22 Feb 2026 17:38:56 +0000 Subject: [PATCH 03/39] feat: bub the framework v2 --- README.md | 90 ++----- docs/architecture.md | 70 +++--- docs/cli.md | 11 +- docs/features.md | 24 +- docs/index.md | 24 +- docs/skills.md | 69 +++++ mkdocs.yml | 7 +- src/bub/framework.py | 37 ++- src/bub/skills/__init__.py | 24 +- src/bub/skills/builtin/__init__.py | 1 - src/bub/skills/builtin/cli/SKILL.md | 42 +++- src/bub/skills/builtin/cli/__init__.py | 1 - .../skills/builtin/cli/agents/bub/agent.yaml | 4 + .../builtin/cli/{ => agents/bub}/plugin.py | 31 ++- .../skills/builtin/cli/references/usage.md | 29 +++ .../builtin/cli/scripts/command_index.py | 42 ++++ src/bub/skills/builtin/runtime/SKILL.md | 43 +++- src/bub/skills/builtin/runtime/__init__.py | 1 - .../builtin/runtime/agents/bub/agent.yaml | 6 + .../runtime/{ => agents/bub}/engine.py | 86 +++++-- .../runtime/{ => agents/bub}/plugin.py | 2 +- .../builtin/runtime/references/usage.md | 26 ++ .../runtime/scripts/prepare_context.py | 49 ++++ src/bub/skills/loader.py | 176 +++++++++++-- tests/test_fault_tolerance.py | 21 +- tests/test_framework_flow.py | 25 +- tests/test_skill_loader.py | 236 ++++++++++++++++-- tests/test_skill_override.py | 19 +- 28 files changed, 940 insertions(+), 256 deletions(-) create mode 100644 docs/skills.md delete mode 100644 src/bub/skills/builtin/__init__.py delete mode 100644 src/bub/skills/builtin/cli/__init__.py create mode 100644 src/bub/skills/builtin/cli/agents/bub/agent.yaml rename src/bub/skills/builtin/cli/{ => agents/bub}/plugin.py (73%) create mode 100644 src/bub/skills/builtin/cli/references/usage.md create mode 100644 src/bub/skills/builtin/cli/scripts/command_index.py delete mode 100644 src/bub/skills/builtin/runtime/__init__.py create mode 100644 src/bub/skills/builtin/runtime/agents/bub/agent.yaml rename src/bub/skills/builtin/runtime/{ => agents/bub}/engine.py (91%) rename src/bub/skills/builtin/runtime/{ => agents/bub}/plugin.py (97%) create mode 100644 src/bub/skills/builtin/runtime/references/usage.md create mode 100644 src/bub/skills/builtin/runtime/scripts/prepare_context.py diff --git a/README.md b/README.md index d6e597f3..787689a4 100644 --- a/README.md +++ b/README.md @@ -5,36 +5,24 @@ [![Commit activity](https://img.shields.io/github/commit-activity/m/bubbuild/bub)](https://github.com/bubbuild/bub/graphs/commit-activity) [![License](https://img.shields.io/github/license/bubbuild/bub)](LICENSE) -Bub is a **batteries-included, hook-first AI framework**. +Bub is a batteries-included, hook-first AI framework with a minimal core and skill-owned behavior. -Bub is a collaborative agent for shared delivery workflows, evolving into a framework that helps other agents operate with the same collaboration model. -It is not a personal-assistant shell: it is designed for shared environments where work must be inspectable, handoff-friendly, and operationally reliable. +## Why Bub -- model execution and tool loop -- command routing and runtime tape behavior -- input listener hooks (normalize + session resolution) in the same runtime process -- CLI command registration -- channel/bus behaviors provided by project skills +Bub keeps the framework kernel small and stable, and moves runtime capabilities into skills. +This makes behavior easy to evolve without forking the core. -Built on [Republic](https://github.com/bubbuild/republic), Bub treats context as explicit assembly from verifiable interaction history, rather than opaque inherited state. -This aligns with [Socialized Evaluation](https://psiace.me/posts/im-and-socialized-evaluation/): systems are judged by how well teams can inspect, review, and continue work together. +## Design Principles -- `cli` -- `runtime` (Republic-driven runtime battery with routing, tools, and tape-backed sessions) +- Minimal kernel for orchestration and safety boundaries +- Skill-first extension model for runtime behavior +- Standards-based skill metadata (`SKILL.md`) +- Predictable override order across project, user, and builtin scopes -## Runtime Defaults +## Builtin Batteries -- Without usable Republic model credentials, the framework still runs and returns prompt text as output. -- `runtime` can be controlled with environment variables: - - `BUB_RUNTIME_ENABLED=1|0|auto` - - `BUB_MODEL`, `BUB_API_KEY`, `BUB_API_BASE` - - `BUB_RUNTIME_MAX_STEPS`, `BUB_RUNTIME_MAX_TOKENS`, `BUB_RUNTIME_MODEL_TIMEOUT_SECONDS` - -- Multi-operator collaboration in shared delivery environments. -- Explicit command boundaries for predictable execution. -- Verifiable history (`tape`, `anchor`, `handoff`) for audit and continuity. -- Channel-neutral behavior across CLI and message channels. -- Extensible tools and skills with a unified operator-facing workflow. +- `cli`: command entrypoints and diagnostics +- `runtime`: message handling, model/tool execution, and outbound rendering ## Quick Start @@ -48,55 +36,15 @@ uv run bub skills BUB_RUNTIME_ENABLED=1 uv run bub run ",help" ``` -## Skill Layout - -```bash -BUB_MODEL=openrouter:qwen/qwen3-coder-next -BUB_API_KEY=your_key_here -``` - -1. `/.agents/skills` -2. `~/.agents/skills` -3. `src/bub_skills/` - -```bash -uv run bub -``` - -## Interaction Model +## Documentation -- `hello`: natural language routed to model. -- `,help`: internal command. -- `,git status`: shell command. -- `, ls -la`: shell command (space after comma is optional). - -Common commands: - -```text -,help -,tools -,tool.describe name=fs.read -,skills.list -,skills.describe name=friendly-python -,handoff name=phase-1 summary="bootstrap done" -,anchors -,tape.info -,tape.search query=error -,tape.reset archive=true -,quit -``` - -## Channel Runtime - -Telegram: - -```bash -BUB_TELEGRAM_TOKEN=123456:token -BUB_TELEGRAM_ALLOW_USERS=123456789,your_username -uv run bub message -``` +- `docs/index.md`: overview +- `docs/features.md`: capability summary +- `docs/architecture.md`: architecture principles and guarantees +- `docs/skills.md`: skill authoring and extension model +- `docs/cli.md`: command usage -## Development +## Development Checks ```bash uv run ruff check . diff --git a/docs/architecture.md b/docs/architecture.md index bd91ae64..69d956d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,50 +1,52 @@ # Architecture -## Framework Kernel +Bub uses a minimal-kernel architecture: the core orchestrates a turn, while skills provide behavior. -- `BubFramework` -- `BubHookSpecs` -- `Skill Loader` +## Principles -The kernel only coordinates a turn. It does not own channel, model, or tool behavior. -Those concerns are provided by skills through hooks. +- Keep core responsibilities small and stable +- Put runtime behavior behind explicit extension points +- Preserve predictable override semantics +- Prefer graceful degradation over global failure -The framework is batteries-included: default skills provide a runnable baseline, -while every battery can be overridden by project or global skills. +## Guarantees -Current builtin baseline: +### Deterministic Turn Lifecycle -- `cli` skill: registers `run`, `skills`, and `hooks` commands -- `runtime` skill: input listener hooks, model runtime, tool loop, command-compatible routing +Each inbound message follows a stable lifecycle: -## Hook Pipeline +1. normalize input +2. resolve session +3. load context/state +4. build model input +5. run model/tools +6. persist state +7. render outbound messages +8. dispatch output -1. `normalize_inbound` -2. `resolve_session` -3. `load_state` -4. `build_prompt` -5. `run_model` -6. `save_state` -7. `render_outbound` -8. `dispatch_outbound` +### Deterministic Skill Resolution -## Extension Ownership +Skills are resolved by scope priority: -- `cli` commands are registered by `register_cli_commands`. -- `bus` instances are provided by `provide_bus`. -- `message` shape is defined by users and adapted by skills. +1. project scope +2. user scope +3. builtin scope -## Runtime Safety +If names collide, higher-priority scope wins. -- Skill load failures are isolated and tracked in `failed_skills`. -- Hook runtime failures are isolated per plugin and reported via `on_error`. -- If no model skill returns output, the framework falls back to the prompt text to keep the process alive. +### Failure Isolation -## Skill Resolution +- Skill load failures are isolated +- Hook execution failures are isolated per extension +- The framework keeps the turn loop operational with safe fallbacks -1. workspace `.agent/skills` -2. user `~/.agent/skills` -3. builtin skills +## Non-goals -If two skills share the same name, higher precedence source wins. -At runtime, project skills execute before global and builtin implementations. +- Enforcing one global business schema for all messages +- Hardcoding domain behavior into the kernel +- Merging duplicate skill names across scopes + +## See Also + +- `docs/skills.md` for skill contract and layout +- `docs/cli.md` for command behavior diff --git a/docs/cli.md b/docs/cli.md index cde0f566..3976dbb3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,13 +6,13 @@ uv run bub run "hello" --channel stdout --chat-id local ``` -## Force Republic model runtime +## Run with runtime mode ```bash BUB_RUNTIME_ENABLED=1 uv run bub run "summarize current repo status" ``` -## Command-compatible mode through runtime +## Command-style runtime input ```bash BUB_RUNTIME_ENABLED=1 uv run bub run ",help" @@ -24,6 +24,8 @@ BUB_RUNTIME_ENABLED=1 uv run bub run ",help" uv run bub skills ``` +This command shows discovered skills and their current runtime health. + ## List hook bindings ```bash @@ -32,5 +34,6 @@ uv run bub hooks ## Notes -- If Republic model runtime is unavailable (for example no API key), `bub run` still works and returns prompt text. -- Session identity defaults to `channel:chat_id` unless `--session-id` is provided. +- `--workspace` is supported on `run`, `skills`, and `hooks` +- If runtime model is unavailable, `bub run` still returns a safe textual result +- Session identity falls back to `channel:chat_id` when not provided explicitly diff --git a/docs/features.md b/docs/features.md index 3889d16c..b2cfb89c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,9 +1,19 @@ # Key Features -- Batteries-included baseline skills for Republic runtime and CLI bootstrap -- Hook-based extension model using Pluggy -- Skill discovery with project/global/builtin override order -- Envelope-oriented runtime model with pluggable bus providers -- Builtin `runtime` skill with Republic-driven model runtime and command routing -- Tape-backed session runtime with persistent JSONL storage -- Fault isolation when skill loading fails +## Framework + +- Hook-first extension model +- Minimal kernel with clear failure boundaries +- Deterministic turn orchestration + +## Skills + +- Standards-based `SKILL.md` metadata +- Scope-based discovery and override behavior +- Optional Bub adapter extension layer + +## Runtime + +- Builtin `runtime` and `cli` batteries +- Graceful degradation when extensions fail +- Safe fallback behavior when model runtime is unavailable diff --git a/docs/index.md b/docs/index.md index 6fc340ba..33944151 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,19 +1,17 @@ -# Bub Framework +# Bub -Bub it. Build it. +Bub is a hook-first framework that keeps kernel responsibilities minimal and pushes behavior into skills. -Bub is a batteries-included, hooks-first AI framework. +## Builtin Baseline -Kernel responsibilities: - -1. load skills -2. run hook pipeline -3. orchestrate envelopes over the bus -4. keep failure boundaries small +- `cli` +- `runtime` -Everything else ships as skills and can be replaced by user-provided skills. +Both can be replaced or extended by your own skills. -Builtin baseline in this repository: +## Read Next -- `cli` -- `runtime` +- `architecture.md`: principles and architectural guarantees +- `skills.md`: skill authoring and extension model +- `cli.md`: command usage +- `features.md`: capability summary diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 00000000..2d4f2044 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,69 @@ +# Skills + +Bub builds on the standard Agent Skills format, then adds a Bub-specific adapter layer. + +## Skill Model + +A Bub skill has two layers: + +1. Standard layer: `SKILL.md` and optional resources +2. Bub layer: optional adapter code for Bub runtime integration + +Skills remain valid even without a Bub adapter. + +## Minimum Skill + +```text +my-skill/ +└── SKILL.md +``` + +## Skill With Bub Adapter + +```text +my-skill/ +├── SKILL.md +└── agents/ + └── bub/ + ├── plugin.py + └── agent.yaml +``` + +## Recommended Layout + +```text +my-skill/ +├── SKILL.md +├── agents/ +│ └── bub/ +│ ├── plugin.py +│ └── agent.yaml +├── scripts/ +│ └── *.py +└── references/ + └── *.md +``` + +## Where Bub Discovers Skills + +Bub checks skills from: + +- project scope +- user scope +- builtin scope + +Higher-priority scope overrides lower-priority scope on name collision. + +## SKILL.md Frontmatter + +Supported fields: + +- required: `name`, `description` +- optional: `license`, `compatibility`, `metadata`, `allowed-tools` + +## Authoring Guidance + +- Keep `SKILL.md` focused and task-oriented +- Move large details to `references/` +- Put deterministic repeatable logic in `scripts/` +- Use clear examples and edge cases for activation quality diff --git a/mkdocs.yml b/mkdocs.yml index 2a46f343..5759bbda 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,13 +9,10 @@ copyright: Copyright (c) 2026 Bub Build contributors. nav: - Home: index.md - - Key Features: features.md - - Posts: - - "2026-03-01 · Socialized Evaluation and Agent Partnership": posts/2026-03-01-bub-socialized-evaluation-and-agent-partnership.md - - "2025-07-16 · Baby Bub Bootstrap Milestone": posts/2025-07-16-baby-bub-bootstrap-milestone.md - - Deployment: deployment.md - Architecture: architecture.md + - Skills: skills.md - CLI: cli.md + - Key Features: features.md plugins: - search diff --git a/src/bub/framework.py b/src/bub/framework.py index 943329c0..fc12912f 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -13,7 +13,7 @@ from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs -from bub.skills.loader import SkillMetadata, discover_hook_skills, load_skill_plugin +from bub.skills.loader import SkillMetadata, discover_skills, has_bub_adapter, load_skill_plugin, skill_bub_plugin_path from bub.types import Envelope, TurnResult SOURCE_PRIORITY = {"builtin": 0, "global": 1, "project": 2} @@ -27,6 +27,16 @@ class LoadedSkill: plugin_name: str +@dataclass(frozen=True) +class SkillStatus: + """Observed runtime state for one discovered skill.""" + + skill: SkillMetadata + state: str + plugin_path: Path | None + detail: str | None = None + + class BubFramework: """Minimal framework core. Everything grows from hook skills.""" @@ -37,6 +47,7 @@ def __init__(self, workspace: Path) -> None: self._hook_runtime = HookRuntime(self._plugin_manager) self._loaded_skills: list[LoadedSkill] = [] self._failed_skills: dict[str, str] = {} + self._skill_statuses: dict[str, SkillStatus] = {} @property def loaded_skills(self) -> list[LoadedSkill]: @@ -46,21 +57,43 @@ def loaded_skills(self) -> list[LoadedSkill]: def failed_skills(self) -> dict[str, str]: return dict(self._failed_skills) + @property + def skill_statuses(self) -> list[SkillStatus]: + return sorted(self._skill_statuses.values(), key=lambda item: item.skill.name.casefold()) + def load_skills(self) -> None: """Discover and register all hook skills.""" self._loaded_skills = [] self._failed_skills = {} + self._skill_statuses = {} + + discovered = discover_skills(self.workspace) + for skill in discovered: + if has_bub_adapter(skill): + continue + self._skill_statuses[skill.name.casefold()] = SkillStatus(skill=skill, state="adapter_absent", plugin_path=None) - skills = sorted(discover_hook_skills(self.workspace), key=self._registration_order_key) + skills = sorted((skill for skill in discovered if has_bub_adapter(skill)), key=self._registration_order_key) for skill in skills: plugin_name = f"{skill.source}:{skill.name}" try: plugin = load_skill_plugin(skill) self._plugin_manager.register(plugin, name=plugin_name) self._loaded_skills.append(LoadedSkill(skill=skill, plugin_name=plugin_name)) + self._skill_statuses[skill.name.casefold()] = SkillStatus( + skill=skill, + state="hook_active", + plugin_path=skill_bub_plugin_path(skill), + ) except Exception as exc: # pragma: no cover - exercised via behavior tests self._failed_skills[skill.name] = str(exc) + self._skill_statuses[skill.name.casefold()] = SkillStatus( + skill=skill, + state="degraded", + plugin_path=skill_bub_plugin_path(skill), + detail=str(exc), + ) logger.opt(exception=True).warning("skill.load_failed skill={} source={}", skill.name, skill.source) def create_bus(self) -> BusProtocol: diff --git a/src/bub/skills/__init__.py b/src/bub/skills/__init__.py index 1d37e516..c6485f92 100644 --- a/src/bub/skills/__init__.py +++ b/src/bub/skills/__init__.py @@ -1,5 +1,25 @@ """Skill discovery and loading exports.""" -from bub.skills.loader import SkillMetadata, discover_hook_skills, discover_skills, load_skill_body +from bub.skills.loader import ( + SkillMetadata, + discover_hook_skills, + discover_skills, + has_bub_adapter, + load_bub_agent_profile, + load_bub_agent_profile_file, + load_skill_body, + skill_bub_agent_profile_path, + skill_bub_plugin_path, +) -__all__ = ["SkillMetadata", "discover_hook_skills", "discover_skills", "load_skill_body"] +__all__ = [ + "SkillMetadata", + "discover_hook_skills", + "discover_skills", + "has_bub_adapter", + "load_bub_agent_profile", + "load_bub_agent_profile_file", + "load_skill_body", + "skill_bub_agent_profile_path", + "skill_bub_plugin_path", +] diff --git a/src/bub/skills/builtin/__init__.py b/src/bub/skills/builtin/__init__.py deleted file mode 100644 index e4554feb..00000000 --- a/src/bub/skills/builtin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Builtin skill bundle for Bub framework.""" diff --git a/src/bub/skills/builtin/cli/SKILL.md b/src/bub/skills/builtin/cli/SKILL.md index 004964ca..60c3f0e2 100644 --- a/src/bub/skills/builtin/cli/SKILL.md +++ b/src/bub/skills/builtin/cli/SKILL.md @@ -1,10 +1,44 @@ --- name: cli -description: Register base framework CLI commands via hooks. -kind: command -entrypoint: bub.skills.builtin.cli.plugin:plugin +description: Command-surface design and diagnostics skill. Use when defining CLI command contracts, arguments, output formats, observability commands, or reviewing and extending existing CLI behavior. --- # CLI Core Skill -Provides the default `run`, `skills`, and `hooks` CLI commands. +## Steps + +1. Define a clear command contract: inputs, defaults, output shape, and failure semantics. +2. Prioritize diagnostics commands first (status, config, hooks, failure summaries), then business commands. +3. Use `scripts/command_index.py` to generate deterministic command listings. +4. Add minimal tests for each new command: one success path and at least one failure path. + +## Examples + +Input example: + +```text +Please add a new `bub doctor` command that validates runtime dependencies. +``` + +Expected output characteristics: + +```text +- command contract is explicit +- diagnostics output is machine-parsable +- failures provide actionable hints +``` + +## Edge Cases + +- Keep output both human-readable and script-parseable; avoid mixed ambiguous formats. +- If command names conflict, identify the conflict source before proposing alternatives. +- When default values affect compatibility, document the impact explicitly. + +## Bub Adapter + +- Bub adapter entrypoint: `agents/bub/plugin.py`. +- Bub adapter profile: `agents/bub/agent.yaml`. + +## References + +- See `references/usage.md` for detailed constraints and templates. diff --git a/src/bub/skills/builtin/cli/__init__.py b/src/bub/skills/builtin/cli/__init__.py deleted file mode 100644 index 9f73885a..00000000 --- a/src/bub/skills/builtin/cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CLI core command skill.""" diff --git a/src/bub/skills/builtin/cli/agents/bub/agent.yaml b/src/bub/skills/builtin/cli/agents/bub/agent.yaml new file mode 100644 index 00000000..d5f40989 --- /dev/null +++ b/src/bub/skills/builtin/cli/agents/bub/agent.yaml @@ -0,0 +1,4 @@ +version: 1 +system_prompt: >- + You are Bub CLI adapter. Prioritize explicit command contracts, clear help + text, deterministic output, and diagnostics-first command design. diff --git a/src/bub/skills/builtin/cli/plugin.py b/src/bub/skills/builtin/cli/agents/bub/plugin.py similarity index 73% rename from src/bub/skills/builtin/cli/plugin.py rename to src/bub/skills/builtin/cli/agents/bub/plugin.py index c78b4ed9..05f038b0 100644 --- a/src/bub/skills/builtin/cli/plugin.py +++ b/src/bub/skills/builtin/cli/agents/bub/plugin.py @@ -11,11 +11,18 @@ from bub.envelope import field_of from bub.framework import BubFramework from bub.hookspecs import hookimpl +from bub.skills.loader import skill_bub_agent_profile_path class CliCoreSkill: @hookimpl def register_cli_commands(self, app: typer.Typer) -> None: + self._register_run(app) + self._register_skills(app) + self._register_hooks(app) + + @staticmethod + def _register_run(app: typer.Typer) -> None: @app.command("run") def run( message: str = typer.Argument(..., help="Inbound message content"), @@ -44,6 +51,8 @@ def run( target_chat = str(field_of(outbound, "chat_id", "local")) typer.echo(f"[{target_channel}:{target_chat}] {rendered}") + @staticmethod + def _register_skills(app: typer.Typer) -> None: @app.command("skills") def list_skills( workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 @@ -51,13 +60,21 @@ def list_skills( """Show loaded and failed skills.""" framework = _load_framework(workspace) - for record in framework.loaded_skills: - typer.echo( - f"loaded {record.skill.name} ({record.skill.source}) -> {record.skill.metadata.get('entrypoint')}" - ) - for skill_name, error in framework.failed_skills.items(): - typer.echo(f"failed {skill_name}: {error}") - + for status in framework.skill_statuses: + rendered = f"{status.skill.name} ({status.skill.source}) state={status.state}" + if status.plugin_path is not None: + rendered += f" adapter={status.plugin_path}" + profile_path = skill_bub_agent_profile_path(status.skill) + if profile_path.is_file(): + rendered += f" profile={profile_path}" + else: + rendered += f" profile=missing:{profile_path}" + if status.detail: + rendered += f" detail={status.detail}" + typer.echo(rendered) + + @staticmethod + def _register_hooks(app: typer.Typer) -> None: @app.command("hooks") def list_hooks( workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 diff --git a/src/bub/skills/builtin/cli/references/usage.md b/src/bub/skills/builtin/cli/references/usage.md new file mode 100644 index 00000000..8fab829e --- /dev/null +++ b/src/bub/skills/builtin/cli/references/usage.md @@ -0,0 +1,29 @@ +# CLI Usage Reference + +## Contract Template + +Define every command with: + +1. Input parameters and defaults +2. Success output schema +3. Error output schema +4. Exit behavior + +## Output Guidance + +- Prefer line-oriented output for terminal users. +- If output is consumed by scripts, keep fields stable. +- Avoid mixing diagnostic text with machine-parsable sections. + +## Suggested Checks + +1. Command registration is deterministic. +2. Help text is explicit for required/optional flags. +3. Failure paths include next-step hints. + +## Minimal Test Matrix + +1. Successful invocation with default options +2. Successful invocation with explicit options +3. Invalid arguments +4. Runtime dependency unavailable diff --git a/src/bub/skills/builtin/cli/scripts/command_index.py b/src/bub/skills/builtin/cli/scripts/command_index.py new file mode 100644 index 00000000..8dc37f1d --- /dev/null +++ b/src/bub/skills/builtin/cli/scripts/command_index.py @@ -0,0 +1,42 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// +"""Render a deterministic command index from a JSON command list.""" + +from __future__ import annotations + +import json +import sys + + +def main() -> int: + raw = sys.stdin.read().strip() + if not raw: + sys.stderr.write("expected JSON list on stdin\n") + return 1 + + payload = json.loads(raw) + if not isinstance(payload, list): + sys.stderr.write("payload must be a JSON list\n") + return 1 + + commands: list[tuple[str, str]] = [] + for item in payload: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip() + desc = str(item.get("description", "")).strip() + if not name: + continue + commands.append((name, desc)) + + for name, desc in sorted(commands, key=lambda item: item[0]): + line = f"{name}: {desc}" if desc else name + sys.stdout.write(line + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bub/skills/builtin/runtime/SKILL.md b/src/bub/skills/builtin/runtime/SKILL.md index ae1b32d8..d1922dc6 100644 --- a/src/bub/skills/builtin/runtime/SKILL.md +++ b/src/bub/skills/builtin/runtime/SKILL.md @@ -1,10 +1,45 @@ --- name: runtime -description: Republic-driven model skill with command routing, tools, and tape-backed sessions. -kind: model -entrypoint: bub.skills.builtin.runtime.plugin:plugin +description: Stateful runtime orchestration skill. Use when implementing or debugging inbound normalization, session identity, tool routing, state transitions, and failure isolation across multi-turn runtime flows. --- # Runtime Skill -Provides a Republic-driven model runtime and command battery through hooks. +## Steps + +1. Normalize inbound payloads so `content`, `session_id`, and `metadata` are always usable. +2. Resolve session identity deterministically: explicit `session_id` first, then `channel:chat_id`. +3. Set explicit loop boundaries for model and tools: max steps, timeout, and fallback rules. +4. Preserve actionable diagnostics on failures; do not swallow errors. +5. Use `scripts/prepare_context.py` to build stable context payloads. + +## Bub Adapter + +- Bub adapter entrypoint: `agents/bub/plugin.py`. +- Bub adapter profile: `agents/bub/agent.yaml`. + +## Examples + +Input example: + +```text +Normalize this inbound message and preserve stable session identity. +``` + +Expected output characteristics: + +```text +- deterministic session id +- trimmed content +- metadata includes runtime listener mark +``` + +## Edge Cases + +- If `session_id` is absent but `channel/chat_id` exists, fallback must remain deterministic. +- Empty inbound content should still return a structured result, not an exception. +- Tool-call failures and model failures should be logged distinctly to avoid root-cause confusion. + +## References + +- See `references/usage.md` for detailed flow and failure-handling guidance. diff --git a/src/bub/skills/builtin/runtime/__init__.py b/src/bub/skills/builtin/runtime/__init__.py deleted file mode 100644 index 8bfc1d61..00000000 --- a/src/bub/skills/builtin/runtime/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Builtin runtime skill package.""" diff --git a/src/bub/skills/builtin/runtime/agents/bub/agent.yaml b/src/bub/skills/builtin/runtime/agents/bub/agent.yaml new file mode 100644 index 00000000..87f05027 --- /dev/null +++ b/src/bub/skills/builtin/runtime/agents/bub/agent.yaml @@ -0,0 +1,6 @@ +version: 1 +system_prompt: >- + You are Bub runtime adapter. Use tools for shell, file operations, skill + lookup, and tape operations. Keep responses concise, deterministic, and + operationally actionable. +continue_prompt: Continue the task. diff --git a/src/bub/skills/builtin/runtime/engine.py b/src/bub/skills/builtin/runtime/agents/bub/engine.py similarity index 91% rename from src/bub/skills/builtin/runtime/engine.py rename to src/bub/skills/builtin/runtime/agents/bub/engine.py index f82ec7d1..dd2b5c9b 100644 --- a/src/bub/skills/builtin/runtime/engine.py +++ b/src/bub/skills/builtin/runtime/agents/bub/engine.py @@ -17,7 +17,7 @@ from republic import LLM, TapeEntry, Tool, ToolAutoResult from republic.tape import InMemoryTapeStore, Tape -from bub.skills.loader import discover_skills, load_skill_body +from bub.skills.loader import discover_skills, load_bub_agent_profile_file, load_skill_body DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 @@ -26,6 +26,12 @@ DEFAULT_MAX_TOKENS = 1024 CONTINUE_PROMPT = "Continue the task." AGENTS_FILE_NAME = "AGENTS.md" +AGENT_PROFILE_FILE_NAME = "agent.yaml" +RUNTIME_ENABLED_ENV = "BUB_RUNTIME_ENABLED" +PRIMARY_API_KEY_ENV = "BUB_API_KEY" +RUNTIME_ENABLED_ON_VALUE = "1" +RUNTIME_ENABLED_OFF_VALUE = "0" +RUNTIME_ENABLED_AUTO_VALUE = "auto" @dataclass(frozen=True) @@ -45,6 +51,12 @@ class RuntimeSettings: enabled: bool +@dataclass(frozen=True) +class RuntimeAgentProfile: + system_prompt: str | None = None + continue_prompt: str = CONTINUE_PROMPT + + class RuntimeEngine: """Runtime engine with command compatibility and Republic model driving.""" @@ -55,6 +67,7 @@ def __init__(self, workspace: Path) -> None: self._settings = _load_runtime_settings() self._llm = _build_llm(self._settings) self._workspace_prompt = _read_workspace_agents_prompt(self.workspace) + self._agent_profile = _load_agent_profile(Path(__file__).with_name(AGENT_PROFILE_FILE_NAME)) async def run(self, *, session_id: str, prompt: str) -> str | None: stripped = prompt.strip() @@ -117,7 +130,7 @@ async def _run_runtime(self, *, session_id: str, prompt: str) -> str | None: "ts": datetime.now(UTC).isoformat(), }, ) - next_prompt = CONTINUE_PROMPT + next_prompt = self._agent_profile.continue_prompt continue self._append_event( session_id, @@ -409,11 +422,12 @@ def tape_anchors() -> str: return [Tool.from_callable(func, name=name, description=description) for name, description, func in tools] def _system_prompt(self) -> str: + default_prompt = ( + "You are Bub runtime skill. Use tools for operations such as shell, file edits, " + "skills lookup, and tape operations. Return concise natural language when done." + ) blocks = [ - ( - "You are Bub runtime skill. Use tools for operations such as shell, file edits, " - "skills lookup, and tape operations. Return concise natural language when done." - ), + self._agent_profile.system_prompt or default_prompt, ] if self._workspace_prompt: blocks.append(self._workspace_prompt) @@ -532,28 +546,13 @@ def _build_llm(settings: RuntimeSettings) -> LLM | None: def _load_runtime_settings() -> RuntimeSettings: model = _first_non_empty([os.getenv("BUB_MODEL"), DEFAULT_MODEL]) or DEFAULT_MODEL - api_key = _first_non_empty( - [ - os.getenv("BUB_API_KEY"), - os.getenv("BUB_LLM_API_KEY"), - os.getenv("BUB_OPENROUTER_API_KEY"), - os.getenv("LLM_API_KEY"), - os.getenv("OPENROUTER_API_KEY"), - ] - ) + api_key = _resolve_runtime_api_key() api_base = _first_non_empty([os.getenv("BUB_API_BASE")]) max_steps = _int_env("BUB_RUNTIME_MAX_STEPS", default=DEFAULT_MAX_STEPS) max_tokens = _int_env("BUB_RUNTIME_MAX_TOKENS", default=DEFAULT_MAX_TOKENS) timeout_seconds = _int_env("BUB_RUNTIME_MODEL_TIMEOUT_SECONDS", default=DEFAULT_MODEL_TIMEOUT_SECONDS) - mode = (_first_non_empty([os.getenv("BUB_RUNTIME_ENABLED"), "auto"]) or "auto").casefold() - - requires_key = _model_requires_api_key(model) - if mode in {"1", "true", "yes", "on"}: - enabled = True - elif mode in {"0", "false", "no", "off"}: - enabled = False - else: - enabled = bool(api_key) or not requires_key + mode = _resolve_runtime_enabled_mode() + enabled = _resolve_runtime_enabled(mode=mode, model=model, api_key=api_key) return RuntimeSettings( model=model, @@ -572,6 +571,27 @@ def _model_requires_api_key(model: str) -> bool: return lowered.startswith(prefixes) +def _resolve_runtime_api_key() -> str | None: + return _first_non_empty([os.getenv(PRIMARY_API_KEY_ENV)]) + + +def _resolve_runtime_enabled(*, mode: str, model: str, api_key: str | None) -> bool: + if mode == RUNTIME_ENABLED_ON_VALUE: + return True + if mode == RUNTIME_ENABLED_OFF_VALUE: + return False + requires_key = _model_requires_api_key(model) + return bool(api_key) or not requires_key + + +def _resolve_runtime_enabled_mode() -> str: + mode = (_first_non_empty([os.getenv(RUNTIME_ENABLED_ENV), RUNTIME_ENABLED_AUTO_VALUE]) or RUNTIME_ENABLED_AUTO_VALUE) + lowered = mode.casefold() + if lowered in {RUNTIME_ENABLED_ON_VALUE, RUNTIME_ENABLED_OFF_VALUE, RUNTIME_ENABLED_AUTO_VALUE}: + return lowered + return RUNTIME_ENABLED_AUTO_VALUE + + def _first_non_empty(values: list[str | None]) -> str | None: for value in values: if value is None: @@ -594,9 +614,6 @@ def _int_env(name: str, *, default: int) -> int: return default return parsed - - - def _read_workspace_agents_prompt(workspace: Path) -> str: prompt_path = workspace / AGENTS_FILE_NAME if not prompt_path.is_file(): @@ -607,6 +624,21 @@ def _read_workspace_agents_prompt(workspace: Path) -> str: return "" +def _load_agent_profile(path: Path) -> RuntimeAgentProfile: + raw = load_bub_agent_profile_file(path) + if not raw: + return RuntimeAgentProfile() + + system_prompt = raw.get("system_prompt") + continue_prompt = raw.get("continue_prompt") + + resolved_system_prompt = system_prompt.strip() if isinstance(system_prompt, str) and system_prompt.strip() else None + resolved_continue_prompt = ( + continue_prompt.strip() if isinstance(continue_prompt, str) and continue_prompt.strip() else CONTINUE_PROMPT + ) + return RuntimeAgentProfile(system_prompt=resolved_system_prompt, continue_prompt=resolved_continue_prompt) + + def _session_tape_name(session_id: str) -> str: slug = md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 return f"runtime:{slug}" diff --git a/src/bub/skills/builtin/runtime/plugin.py b/src/bub/skills/builtin/runtime/agents/bub/plugin.py similarity index 97% rename from src/bub/skills/builtin/runtime/plugin.py rename to src/bub/skills/builtin/runtime/agents/bub/plugin.py index 8676c8cf..331b77c6 100644 --- a/src/bub/skills/builtin/runtime/plugin.py +++ b/src/bub/skills/builtin/runtime/agents/bub/plugin.py @@ -6,7 +6,7 @@ from bub.envelope import content_of, field_of, normalize_envelope from bub.hookspecs import hookimpl -from bub.skills.builtin.runtime.engine import RuntimeEngine +from bub.skills.builtin.runtime.agents.bub.engine import RuntimeEngine from bub.types import Envelope, State diff --git a/src/bub/skills/builtin/runtime/references/usage.md b/src/bub/skills/builtin/runtime/references/usage.md new file mode 100644 index 00000000..6504b1c6 --- /dev/null +++ b/src/bub/skills/builtin/runtime/references/usage.md @@ -0,0 +1,26 @@ +# Runtime Usage Reference + +## Normalization Rules + +1. Convert inbound content to trimmed string. +2. Ensure metadata is a dictionary. +3. Add a runtime listener marker when missing. + +## Session Rules + +1. Use explicit `session_id` when provided. +2. Fallback to `channel:chat_id` when absent. +3. Keep session resolution deterministic across turns. + +## Failure Isolation + +1. Isolate plugin failures from framework core flow. +2. Emit actionable error details for diagnostics. +3. Keep fallback response behavior deterministic. + +## Minimal Test Matrix + +1. Message normalization with extra whitespace +2. Session fallback without explicit `session_id` +3. Runtime plugin failure does not break core processing +4. Outbound fallback when no renderer returns output diff --git a/src/bub/skills/builtin/runtime/scripts/prepare_context.py b/src/bub/skills/builtin/runtime/scripts/prepare_context.py new file mode 100644 index 00000000..8d9415f9 --- /dev/null +++ b/src/bub/skills/builtin/runtime/scripts/prepare_context.py @@ -0,0 +1,49 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// +"""Prepare a normalized runtime context payload from JSON input.""" + +from __future__ import annotations + +import json +import sys +from typing import Any + + +def _session_id(payload: dict[str, Any]) -> str: + raw = payload.get("session_id") + if isinstance(raw, str) and raw.strip(): + return raw.strip() + channel = str(payload.get("channel", "default")) + chat_id = str(payload.get("chat_id", "default")) + return f"{channel}:{chat_id}" + + +def main() -> int: + raw = sys.stdin.read().strip() + if not raw: + sys.stderr.write("expected JSON payload on stdin\n") + return 1 + + payload = json.loads(raw) + if not isinstance(payload, dict): + sys.stderr.write("payload must be a JSON object\n") + return 1 + + metadata = payload.get("metadata") + normalized_metadata: dict[str, Any] = metadata if isinstance(metadata, dict) else {} + normalized = { + "session_id": _session_id(payload), + "content": str(payload.get("content", "")).strip(), + "metadata": normalized_metadata, + } + normalized_metadata.setdefault("listener", "runtime") + + sys.stdout.write(json.dumps(normalized, ensure_ascii=False) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bub/skills/loader.py b/src/bub/skills/loader.py index 19ea4950..2c268864 100644 --- a/src/bub/skills/loader.py +++ b/src/bub/skills/loader.py @@ -2,17 +2,26 @@ from __future__ import annotations +import hashlib +import re +import sys from dataclasses import dataclass, field -from importlib import import_module +from importlib import util as importlib_util from pathlib import Path +from types import ModuleType from typing import Any import yaml PROJECT_SKILLS_DIR = ".agent/skills" SKILL_FILE_NAME = "SKILL.md" -HOOK_SKILL_KINDS = frozenset({"hook", "model", "memory", "output", "bus", "tool", "channel", "command"}) +AGENTS_DIR_NAME = "agents" +BUB_AGENT_DIR_NAME = "bub" +BUB_PLUGIN_FILE_NAME = "plugin.py" +BUB_AGENT_PROFILE_FILE_NAME = "agent.yaml" SKILL_SOURCES = ("project", "global", "builtin") +SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +ALLOWED_FRONTMATTER_FIELDS = frozenset({"name", "description", "license", "compatibility", "metadata", "allowed-tools"}) @dataclass(frozen=True) @@ -27,18 +36,9 @@ class SkillMetadata: def discover_hook_skills(workspace_path: Path) -> list[SkillMetadata]: - """Discover skills with hook entrypoints.""" + """Discover skills that provide a Bub hook plugin module.""" - results: list[SkillMetadata] = [] - for skill in discover_skills(workspace_path): - entrypoint = skill.metadata.get("entrypoint") - kind = str(skill.metadata.get("kind") or "").strip().lower() - if not isinstance(entrypoint, str) or not entrypoint.strip(): - continue - if kind not in HOOK_SKILL_KINDS: - continue - results.append(skill) - return results + return [skill for skill in discover_skills(workspace_path) if has_bub_adapter(skill)] def discover_skills(workspace_path: Path) -> list[SkillMetadata]: @@ -76,19 +76,42 @@ def load_skill_body(name: str, workspace_path: Path) -> str | None: def load_skill_plugin(skill: SkillMetadata) -> object: - """Load plugin object from one skill entrypoint.""" + """Load Bub adapter plugin object from `/agents/bub/plugin.py`.""" + + plugin_file = skill_bub_plugin_path(skill) + if not plugin_file.is_file(): + raise FileNotFoundError(f"{skill.name}: missing {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_PLUGIN_FILE_NAME}") + + module_name = _module_name_for_skill(skill=skill, plugin_file=plugin_file) + module = _load_module_from_file(module_name=module_name, plugin_file=plugin_file) + if not hasattr(module, "plugin"): + raise AttributeError( + f"{skill.name}: {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_PLUGIN_FILE_NAME} must export attribute `plugin`" + ) + plugin = module.plugin + if plugin is None: + raise TypeError(f"{skill.name}: exported `plugin` must not be None") + return plugin - entrypoint = skill.metadata.get("entrypoint") - if not isinstance(entrypoint, str): - raise TypeError(f"{skill.name}: entrypoint must be string") - module_name, sep, attr_name = entrypoint.partition(":") - if not sep or not module_name or not attr_name: - raise ValueError(f"{skill.name}: invalid entrypoint format '{entrypoint}'") +def load_bub_agent_profile(skill: SkillMetadata) -> dict[str, object]: + """Load Bub adapter profile from `/agents/bub/agent.yaml`.""" - module = import_module(module_name) - plugin = getattr(module, attr_name) - return plugin + return load_bub_agent_profile_file(skill_bub_agent_profile_path(skill)) + + +def load_bub_agent_profile_file(path: Path) -> dict[str, object]: + """Load one Bub adapter profile file as a normalized mapping.""" + + if not path.is_file(): + return {} + try: + payload = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return {} + if not isinstance(payload, dict): + return {} + return {str(key): value for key, value in payload.items() if isinstance(key, str)} def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: @@ -101,11 +124,11 @@ def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: except OSError: return None - metadata, body = _parse_frontmatter(content) - name = str(metadata.get("name") or skill_dir.name).strip() - description = str(metadata.get("description") or "No description provided.").strip() - if not name: + metadata = _parse_frontmatter(content) + if not _is_valid_frontmatter(skill_dir=skill_dir, metadata=metadata): return None + name = str(metadata["name"]).strip() + description = str(metadata["description"]).strip() return SkillMetadata( name=name, @@ -135,10 +158,109 @@ def _parse_frontmatter(content: str) -> tuple[dict[str, object], str]: return {}, content +def _is_valid_frontmatter(*, skill_dir: Path, metadata: dict[str, object]) -> bool: + name = metadata.get("name") + description = metadata.get("description") + return ( + _has_only_supported_fields(metadata) + and _is_valid_name(name=name, skill_dir=skill_dir) + and _is_valid_description(description) + and _is_valid_license(metadata.get("license")) + and _is_valid_compatibility(metadata.get("compatibility")) + and _is_valid_metadata_field(metadata.get("metadata")) + and _is_valid_allowed_tools(metadata.get("allowed-tools")) + ) + + +def _has_only_supported_fields(metadata: dict[str, object]) -> bool: + return all(key in ALLOWED_FRONTMATTER_FIELDS for key in metadata) + + +def _is_valid_name(*, name: object, skill_dir: Path) -> bool: + if not isinstance(name, str): + return False + normalized_name = name.strip() + if not normalized_name or len(normalized_name) > 64: + return False + if normalized_name != skill_dir.name: + return False + return SKILL_NAME_PATTERN.fullmatch(normalized_name) is not None + + +def _is_valid_description(description: object) -> bool: + if not isinstance(description, str): + return False + normalized = description.strip() + return bool(normalized) and len(normalized) <= 1024 + + +def _is_valid_license(license_value: object) -> bool: + if license_value is None: + return True + return isinstance(license_value, str) and bool(license_value.strip()) + + +def _is_valid_compatibility(compatibility: object) -> bool: + if compatibility is None: + return True + if not isinstance(compatibility, str): + return False + normalized = compatibility.strip() + return bool(normalized) and len(normalized) <= 500 + + +def _is_valid_metadata_field(metadata_field: object) -> bool: + if metadata_field is None: + return True + if not isinstance(metadata_field, dict): + return False + return all(isinstance(key, str) and isinstance(value, str) for key, value in metadata_field.items()) + + +def _is_valid_allowed_tools(allowed_tools: object) -> bool: + if allowed_tools is None: + return True + return isinstance(allowed_tools, str) and bool(allowed_tools.strip()) + + def _builtin_skills_root() -> Path: return Path(__file__).resolve().parent / "builtin" +def skill_bub_plugin_path(skill: SkillMetadata) -> Path: + return skill.location.parent / AGENTS_DIR_NAME / BUB_AGENT_DIR_NAME / BUB_PLUGIN_FILE_NAME + + +def skill_bub_agent_profile_path(skill: SkillMetadata) -> Path: + return skill.location.parent / AGENTS_DIR_NAME / BUB_AGENT_DIR_NAME / BUB_AGENT_PROFILE_FILE_NAME + + +def has_bub_adapter(skill: SkillMetadata) -> bool: + return skill_bub_plugin_path(skill).is_file() + + +def _module_name_for_skill(*, skill: SkillMetadata, plugin_file: Path) -> str: + digest = hashlib.sha256(str(plugin_file).encode("utf-8")).hexdigest()[:12] + normalized_name = "".join(ch if ch.isalnum() else "_" for ch in f"{skill.source}_{skill.name}".lower()) + return f"bub_skill_{normalized_name}_{digest}" + + +def _load_module_from_file(*, module_name: str, plugin_file: Path) -> ModuleType: + spec = importlib_util.spec_from_file_location(module_name, plugin_file) + if spec is None or spec.loader is None: + raise ImportError(f"failed to build module spec for {plugin_file}") + + module = importlib_util.module_from_spec(spec) + sys.modules.pop(module_name, None) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) + raise + return module + + def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: roots: list[tuple[Path, str]] = [] for source in SKILL_SOURCES: diff --git a/tests/test_fault_tolerance.py b/tests/test_fault_tolerance.py index a381b981..450baf49 100644 --- a/tests/test_fault_tolerance.py +++ b/tests/test_fault_tolerance.py @@ -9,15 +9,15 @@ def _write_broken_skill(workspace: Path) -> None: broken = workspace / ".agent" / "skills" / "broken" - broken.mkdir(parents=True) + plugin_file = broken / "agents" / "bub" / "plugin.py" + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text("import missing_module\n", encoding="utf-8") (broken / "SKILL.md").write_text( "\n".join( [ "---", "name: broken", "description: broken skill", - "kind: model", - "entrypoint: missing.module:plugin", "---", ] ), @@ -38,10 +38,10 @@ async def test_broken_skill_does_not_break_framework(tmp_path: Path) -> None: def _write_runtime_error_skill(workspace: Path) -> None: - package = workspace / "runtime_plugins" - package.mkdir(parents=True) - (package / "__init__.py").write_text("", encoding="utf-8") - (package / "broken_output.py").write_text( + skill_dir = workspace / ".agent" / "skills" / "broken-output" + plugin_file = skill_dir / "agents" / "bub" / "plugin.py" + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text( "\n".join( [ "from bub.hookspecs import hookimpl", @@ -57,16 +57,12 @@ def _write_runtime_error_skill(workspace: Path) -> None: encoding="utf-8", ) - skill_dir = workspace / ".agent" / "skills" / "broken-output" - skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "\n".join( [ "---", "name: broken-output", "description: runtime broken output skill", - "kind: output", - "entrypoint: runtime_plugins.broken_output:plugin", "---", ] ), @@ -75,9 +71,8 @@ def _write_runtime_error_skill(workspace: Path) -> None: @pytest.mark.asyncio -async def test_runtime_broken_skill_isolated_from_main_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +async def test_runtime_broken_skill_isolated_from_main_flow(tmp_path: Path) -> None: _write_runtime_error_skill(tmp_path) - monkeypatch.syspath_prepend(str(tmp_path)) framework = BubFramework(tmp_path) framework.load_skills() diff --git a/tests/test_framework_flow.py b/tests/test_framework_flow.py index 9bdfaa5e..0224adbb 100644 --- a/tests/test_framework_flow.py +++ b/tests/test_framework_flow.py @@ -4,21 +4,23 @@ import pytest import typer +from typer.testing import CliRunner +from bub.cli import app from bub.framework import BubFramework def _write_stateful_test_skill(workspace: Path) -> None: skill_dir = workspace / ".agent" / "skills" / "stateful-hooks" - skill_dir.mkdir(parents=True) + plugin_file = skill_dir / "agents" / "bub" / "plugin.py" + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text("from fixtures_plugins.stateful_hooks import plugin\n", encoding="utf-8") (skill_dir / "SKILL.md").write_text( "\n".join( [ "---", "name: stateful-hooks", "description: test-only stateful hooks skill", - "kind: model", - "entrypoint: fixtures_plugins.stateful_hooks:plugin", "---", ] ), @@ -90,6 +92,23 @@ def test_framework_registers_cli_commands_from_skills(tmp_path: Path) -> None: assert {"run", "skills", "hooks"}.issubset(command_names) +def test_framework_reports_skill_statuses(tmp_path: Path) -> None: + framework = BubFramework(tmp_path) + framework.load_skills() + + states = {item.skill.name: item.state for item in framework.skill_statuses} + assert states["cli"] == "hook_active" + assert states["runtime"] == "hook_active" + + +def test_skills_command_shows_profile_column(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(app, ["skills", "--workspace", str(tmp_path)]) + + assert result.exit_code == 0 + assert "profile=" in result.stdout + + @pytest.mark.asyncio async def test_framework_routes_internal_command_with_runtime(tmp_path: Path) -> None: framework = BubFramework(tmp_path) diff --git a/tests/test_skill_loader.py b/tests/test_skill_loader.py index 4ecd63c7..0ffe9bc2 100644 --- a/tests/test_skill_loader.py +++ b/tests/test_skill_loader.py @@ -2,23 +2,70 @@ from pathlib import Path -from bub.skills.loader import discover_hook_skills +from bub.skills.loader import ( + SkillMetadata, + discover_hook_skills, + discover_skills, + load_bub_agent_profile, + load_bub_agent_profile_file, + skill_bub_agent_profile_path, +) -def _write_skill(root: Path, *, name: str, kind: str, entrypoint: str) -> None: +def _write_skill(root: Path, *, name: str, with_plugin: bool) -> None: root.mkdir(parents=True) (root / "SKILL.md").write_text( "\n".join( [ "---", f"name: {name}", - f"kind: {kind}", - f"entrypoint: {entrypoint}", + f"description: {name} skill", "---", ] ), encoding="utf-8", ) + if with_plugin: + plugin_file = root / "agents" / "bub" / "plugin.py" + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text( + "\n".join( + [ + "from bub.hookspecs import hookimpl", + "", + "class DemoSkill:", + " @hookimpl", + " def resolve_session(self, message):", + " return None", + "", + "plugin = DemoSkill()", + ] + ), + encoding="utf-8", + ) + + +def _write_skill_with_frontmatter(root: Path, *, frontmatter_lines: list[str], with_plugin: bool) -> None: + root.mkdir(parents=True) + (root / "SKILL.md").write_text("\n".join(frontmatter_lines), encoding="utf-8") + if with_plugin: + plugin_file = root / "agents" / "bub" / "plugin.py" + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text( + "\n".join( + [ + "from bub.hookspecs import hookimpl", + "", + "class DemoSkill:", + " @hookimpl", + " def resolve_session(self, message):", + " return None", + "", + "plugin = DemoSkill()", + ] + ), + encoding="utf-8", + ) def test_discover_hook_skills_respects_project_over_global(monkeypatch, tmp_path: Path) -> None: @@ -29,14 +76,12 @@ def test_discover_hook_skills_respects_project_over_global(monkeypatch, tmp_path _write_skill( workspace / ".agent" / "skills" / "demo", name="demo", - kind="model", - entrypoint="project.demo:plugin", + with_plugin=True, ) _write_skill( fake_home / ".agent" / "skills" / "demo", name="demo", - kind="model", - entrypoint="global.demo:plugin", + with_plugin=True, ) monkeypatch.setenv("HOME", str(fake_home)) @@ -44,7 +89,7 @@ def test_discover_hook_skills_respects_project_over_global(monkeypatch, tmp_path skills = discover_hook_skills(workspace) demo = next(skill for skill in skills if skill.name == "demo") assert demo.source == "project" - assert demo.metadata["entrypoint"] == "project.demo:plugin" + assert demo.location.parent == workspace / ".agent" / "skills" / "demo" def test_discover_hook_skills_filters_non_hook_skills(tmp_path: Path) -> None: @@ -52,18 +97,177 @@ def test_discover_hook_skills_filters_non_hook_skills(tmp_path: Path) -> None: workspace.mkdir() _write_skill( - workspace / ".agent" / "skills" / "no-entrypoint", - name="no-entrypoint", - kind="model", - entrypoint="", + workspace / ".agent" / "skills" / "no-plugin", + name="no-plugin", + with_plugin=False, ) _write_skill( workspace / ".agent" / "skills" / "valid", name="valid", - kind="output", - entrypoint="pkg.valid:plugin", + with_plugin=True, ) names = [skill.name for skill in discover_hook_skills(workspace)] assert "valid" in names - assert "no-entrypoint" not in names + assert "no-plugin" not in names + + +def test_discover_skills_rejects_name_mismatch_with_directory(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _write_skill_with_frontmatter( + workspace / ".agent" / "skills" / "actual-dir", + frontmatter_lines=[ + "---", + "name: other-name", + "description: mismatch", + "---", + ], + with_plugin=True, + ) + + names = {skill.name for skill in discover_skills(workspace)} + assert "other-name" not in names + assert "actual-dir" not in names + + +def test_discover_skills_rejects_invalid_name_pattern(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _write_skill_with_frontmatter( + workspace / ".agent" / "skills" / "bad-name", + frontmatter_lines=[ + "---", + "name: bad--name", + "description: invalid pattern", + "---", + ], + with_plugin=True, + ) + + names = {skill.name for skill in discover_skills(workspace)} + assert "bad--name" not in names + assert "bad-name" not in names + + +def test_discover_skills_rejects_missing_required_description(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _write_skill_with_frontmatter( + workspace / ".agent" / "skills" / "no-description", + frontmatter_lines=[ + "---", + "name: no-description", + "---", + ], + with_plugin=True, + ) + + names = {skill.name for skill in discover_skills(workspace)} + assert "no-description" not in names + + +def test_discover_skills_rejects_invalid_metadata_type(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _write_skill_with_frontmatter( + workspace / ".agent" / "skills" / "bad-metadata", + frontmatter_lines=[ + "---", + "name: bad-metadata", + "description: metadata must be string map", + "metadata:", + " author: test", + " version: 1", + "---", + ], + with_plugin=True, + ) + + names = {skill.name for skill in discover_skills(workspace)} + assert "bad-metadata" not in names + + +def test_discover_skills_accepts_spec_optional_fields(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _write_skill_with_frontmatter( + workspace / ".agent" / "skills" / "spec-compliant", + frontmatter_lines=[ + "---", + "name: spec-compliant", + "description: Valid skill metadata with optional fields included.", + "license: Apache-2.0", + "compatibility: Requires internet access and git.", + "allowed-tools: Bash(git:*) Read", + "metadata:", + " author: test", + " version: '1.0'", + "---", + ], + with_plugin=True, + ) + + names = {skill.name for skill in discover_skills(workspace)} + assert "spec-compliant" in names + + +def test_discover_skills_rejects_unknown_frontmatter_fields(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + _write_skill_with_frontmatter( + workspace / ".agent" / "skills" / "unknown-field", + frontmatter_lines=[ + "---", + "name: unknown-field", + "description: contains unsupported top-level field.", + "entrypoint: should-not-be-here", + "---", + ], + with_plugin=True, + ) + + names = {skill.name for skill in discover_skills(workspace)} + assert "unknown-field" not in names + + +def test_load_bub_agent_profile_by_skill_metadata(tmp_path: Path) -> None: + skill_dir = tmp_path / "demo-skill" + skill_dir.mkdir(parents=True) + profile_path = skill_dir / "agents" / "bub" / "agent.yaml" + profile_path.parent.mkdir(parents=True) + profile_path.write_text("version: 1\nsystem_prompt: demo\n", encoding="utf-8") + + metadata = SkillMetadata( + name="demo-skill", + description="demo", + location=skill_dir / "SKILL.md", + source="project", + ) + + assert skill_bub_agent_profile_path(metadata) == profile_path + profile = load_bub_agent_profile(metadata) + assert profile["system_prompt"] == "demo" + + +def test_load_agent_profile_falls_back_when_missing(tmp_path: Path) -> None: + profile = load_bub_agent_profile_file(tmp_path / "missing.yaml") + assert profile == {} + + +def test_load_agent_profile_reads_prompt_fields(tmp_path: Path) -> None: + path = tmp_path / "agent.yaml" + path.write_text( + "\n".join( + [ + "version: 1", + "system_prompt: Runtime custom prompt", + "continue_prompt: Continue from profile", + ] + ), + encoding="utf-8", + ) + + profile = load_bub_agent_profile_file(path) + assert profile["system_prompt"] == "Runtime custom prompt" + assert profile["continue_prompt"] == "Continue from profile" diff --git a/tests/test_skill_override.py b/tests/test_skill_override.py index 74906a8c..899275ad 100644 --- a/tests/test_skill_override.py +++ b/tests/test_skill_override.py @@ -2,17 +2,16 @@ from pathlib import Path -import pytest import typer from bub.framework import BubFramework def _write_project_override_skill(workspace: Path) -> None: - package = workspace / "project_plugins" - package.mkdir(parents=True) - (package / "__init__.py").write_text("", encoding="utf-8") - (package / "override.py").write_text( + skill_dir = workspace / ".agent" / "skills" / "project-override" + plugin_file = skill_dir / "agents" / "bub" / "plugin.py" + plugin_file.parent.mkdir(parents=True) + plugin_file.write_text( "\n".join( [ "import typer", @@ -40,16 +39,12 @@ def _write_project_override_skill(workspace: Path) -> None: encoding="utf-8", ) - skill_dir = workspace / ".agent" / "skills" / "project-override" - skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text( "\n".join( [ "---", "name: project-override", "description: project overrides bus and cli hooks", - "kind: hook", - "entrypoint: project_plugins.override:plugin", "---", ] ), @@ -57,9 +52,8 @@ def _write_project_override_skill(workspace: Path) -> None: ) -def test_project_skill_can_override_builtin_bus(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_project_skill_can_override_builtin_bus(tmp_path: Path) -> None: _write_project_override_skill(tmp_path) - monkeypatch.syspath_prepend(str(tmp_path)) framework = BubFramework(tmp_path) framework.load_skills() @@ -68,9 +62,8 @@ def test_project_skill_can_override_builtin_bus(monkeypatch: pytest.MonkeyPatch, assert bus.__class__.__name__ == "ProjectBus" -def test_project_skill_can_extend_cli_commands(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_project_skill_can_extend_cli_commands(tmp_path: Path) -> None: _write_project_override_skill(tmp_path) - monkeypatch.syspath_prepend(str(tmp_path)) framework = BubFramework(tmp_path) framework.load_skills() From ba2284fd936b46af0a2201da646ef8b24ab16fcc Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Sun, 22 Feb 2026 17:54:29 +0000 Subject: [PATCH 04/39] refactor: not plugin, just skill adapter --- README.md | 3 +- docs/architecture.md | 6 +- docs/cli.md | 3 +- docs/features.md | 2 +- docs/index.md | 2 +- docs/skills.md | 67 ++++++++++++------- src/bub/framework.py | 32 +++++---- src/bub/hook_runtime.py | 18 ++--- src/bub/skills/__init__.py | 14 ++-- src/bub/skills/builtin/cli/SKILL.md | 2 +- .../cli/agents/bub/{plugin.py => adapter.py} | 14 ++-- src/bub/skills/builtin/runtime/SKILL.md | 2 +- .../agents/bub/{plugin.py => adapter.py} | 6 +- .../builtin/runtime/references/usage.md | 4 +- src/bub/skills/loader.py | 54 +++++++-------- tests/fixtures_plugins/__init__.py | 3 +- tests/fixtures_plugins/stateful_hooks.py | 2 +- tests/test_fault_tolerance.py | 14 ++-- tests/test_framework_flow.py | 6 +- tests/test_skill_loader.py | 60 ++++++++--------- tests/test_skill_override.py | 8 +-- 21 files changed, 174 insertions(+), 148 deletions(-) rename src/bub/skills/builtin/cli/agents/bub/{plugin.py => adapter.py} (91%) rename src/bub/skills/builtin/runtime/agents/bub/{plugin.py => adapter.py} (96%) diff --git a/README.md b/README.md index 787689a4..0d196ea1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Commit activity](https://img.shields.io/github/commit-activity/m/bubbuild/bub)](https://github.com/bubbuild/bub/graphs/commit-activity) [![License](https://img.shields.io/github/license/bubbuild/bub)](LICENSE) -Bub is a batteries-included, hook-first AI framework with a minimal core and skill-owned behavior. +Bub is a batteries-included, skill-first AI framework with a minimal core and skill-owned behavior. ## Why Bub @@ -16,6 +16,7 @@ This makes behavior easy to evolve without forking the core. - Minimal kernel for orchestration and safety boundaries - Skill-first extension model for runtime behavior +- Standard Agent Skills contract first, Bub runtime adapter second - Standards-based skill metadata (`SKILL.md`) - Predictable override order across project, user, and builtin scopes diff --git a/docs/architecture.md b/docs/architecture.md index 69d956d5..8e7aea0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,7 +37,7 @@ If names collide, higher-priority scope wins. ### Failure Isolation - Skill load failures are isolated -- Hook execution failures are isolated per extension +- Adapter execution failures are isolated per extension - The framework keeps the turn loop operational with safe fallbacks ## Non-goals @@ -48,5 +48,5 @@ If names collide, higher-priority scope wins. ## See Also -- `docs/skills.md` for skill contract and layout -- `docs/cli.md` for command behavior +- `skills.md` for skill contract and layout +- `cli.md` for command behavior diff --git a/docs/cli.md b/docs/cli.md index 3976dbb3..90767b13 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,7 +6,7 @@ uv run bub run "hello" --channel stdout --chat-id local ``` -## Run with runtime mode +## Run with runtime enabled (optional) ```bash BUB_RUNTIME_ENABLED=1 uv run bub run "summarize current repo status" @@ -35,5 +35,6 @@ uv run bub hooks ## Notes - `--workspace` is supported on `run`, `skills`, and `hooks` +- `BUB_RUNTIME_ENABLED` supports `0`, `1`, and `auto` (default) - If runtime model is unavailable, `bub run` still returns a safe textual result - Session identity falls back to `channel:chat_id` when not provided explicitly diff --git a/docs/features.md b/docs/features.md index b2cfb89c..bedfdee6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,7 +2,7 @@ ## Framework -- Hook-first extension model +- Adapter-based extension model - Minimal kernel with clear failure boundaries - Deterministic turn orchestration diff --git a/docs/index.md b/docs/index.md index 33944151..2ab3756d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Bub -Bub is a hook-first framework that keeps kernel responsibilities minimal and pushes behavior into skills. +Bub is a skill-first framework that keeps kernel responsibilities minimal and pushes behavior into skills. ## Builtin Baseline diff --git a/docs/skills.md b/docs/skills.md index 2d4f2044..7601bf1d 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -1,34 +1,37 @@ # Skills -Bub builds on the standard Agent Skills format, then adds a Bub-specific adapter layer. +Bub follows the Agent Skills specification and adds one optional Bub runtime adapter layer. -## Skill Model +## Core Contract -A Bub skill has two layers: - -1. Standard layer: `SKILL.md` and optional resources -2. Bub layer: optional adapter code for Bub runtime integration - -Skills remain valid even without a Bub adapter. - -## Minimum Skill +Every skill must remain valid as a standard Agent Skill: ```text my-skill/ └── SKILL.md ``` -## Skill With Bub Adapter +`SKILL.md` must contain valid YAML frontmatter and body instructions. +The skill directory name must match frontmatter `name`. + +## Bub Runtime Extension + +If a skill needs Bub runtime hooks, add: ```text my-skill/ ├── SKILL.md └── agents/ └── bub/ - ├── plugin.py + ├── adapter.py └── agent.yaml ``` +- `agents/bub/adapter.py`: optional Bub hook adapter module, exporting `adapter` +- `agents/bub/agent.yaml`: optional prompt/profile data consumed by Bub runtime + +This extension is Bub-specific. It does not change standard Agent Skills compatibility. + ## Recommended Layout ```text @@ -36,7 +39,7 @@ my-skill/ ├── SKILL.md ├── agents/ │ └── bub/ -│ ├── plugin.py +│ ├── adapter.py │ └── agent.yaml ├── scripts/ │ └── *.py @@ -44,26 +47,40 @@ my-skill/ └── *.md ``` -## Where Bub Discovers Skills +## Discovery And Override -Bub checks skills from: +Bub discovers skills from three scopes in priority order: -- project scope -- user scope -- builtin scope +1. project: `.agent/skills` +2. user: `~/.agent/skills` +3. builtin: `src/bub/skills/builtin` -Higher-priority scope overrides lower-priority scope on name collision. +If names collide, higher-priority scope overrides lower-priority scope. -## SKILL.md Frontmatter +## Frontmatter Fields -Supported fields: +Supported `SKILL.md` frontmatter fields: - required: `name`, `description` - optional: `license`, `compatibility`, `metadata`, `allowed-tools` ## Authoring Guidance -- Keep `SKILL.md` focused and task-oriented -- Move large details to `references/` -- Put deterministic repeatable logic in `scripts/` -- Use clear examples and edge cases for activation quality +- Keep `SKILL.md` concise and activation-oriented +- Move detailed reference material into `references/` +- Put deterministic executable logic into `scripts/` +- Keep Bub-only runtime details inside `agents/bub/`, not in the generic skill contract + +## Script Convention + +For `scripts/*.py`, prefer standalone `uv` scripts with PEP 723 metadata: + +```python +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// +``` + +This keeps execution deterministic and avoids hidden environment assumptions. diff --git a/src/bub/framework.py b/src/bub/framework.py index fc12912f..74ecb877 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -13,7 +13,13 @@ from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs -from bub.skills.loader import SkillMetadata, discover_skills, has_bub_adapter, load_skill_plugin, skill_bub_plugin_path +from bub.skills.loader import ( + SkillMetadata, + discover_skills, + has_bub_runtime_adapter, + load_skill_adapter, + skill_bub_adapter_path, +) from bub.types import Envelope, TurnResult SOURCE_PRIORITY = {"builtin": 0, "global": 1, "project": 2} @@ -21,10 +27,10 @@ @dataclass(frozen=True) class LoadedSkill: - """Runtime registration result for one skill.""" + """Runtime adapter registration result for one skill.""" skill: SkillMetadata - plugin_name: str + adapter_name: str @dataclass(frozen=True) @@ -33,7 +39,7 @@ class SkillStatus: skill: SkillMetadata state: str - plugin_path: Path | None + adapter_path: Path | None detail: str | None = None @@ -70,28 +76,28 @@ def load_skills(self) -> None: discovered = discover_skills(self.workspace) for skill in discovered: - if has_bub_adapter(skill): + if has_bub_runtime_adapter(skill): continue - self._skill_statuses[skill.name.casefold()] = SkillStatus(skill=skill, state="adapter_absent", plugin_path=None) + self._skill_statuses[skill.name.casefold()] = SkillStatus(skill=skill, state="adapter_absent", adapter_path=None) - skills = sorted((skill for skill in discovered if has_bub_adapter(skill)), key=self._registration_order_key) + skills = sorted((skill for skill in discovered if has_bub_runtime_adapter(skill)), key=self._registration_order_key) for skill in skills: - plugin_name = f"{skill.source}:{skill.name}" + adapter_name = f"{skill.source}:{skill.name}" try: - plugin = load_skill_plugin(skill) - self._plugin_manager.register(plugin, name=plugin_name) - self._loaded_skills.append(LoadedSkill(skill=skill, plugin_name=plugin_name)) + adapter = load_skill_adapter(skill) + self._plugin_manager.register(adapter, name=adapter_name) + self._loaded_skills.append(LoadedSkill(skill=skill, adapter_name=adapter_name)) self._skill_statuses[skill.name.casefold()] = SkillStatus( skill=skill, state="hook_active", - plugin_path=skill_bub_plugin_path(skill), + adapter_path=skill_bub_adapter_path(skill), ) except Exception as exc: # pragma: no cover - exercised via behavior tests self._failed_skills[skill.name] = str(exc) self._skill_statuses[skill.name.casefold()] = SkillStatus( skill=skill, state="degraded", - plugin_path=skill_bub_plugin_path(skill), + adapter_path=skill_bub_adapter_path(skill), detail=str(exc), ) logger.opt(exception=True).warning("skill.load_failed skill={} source={}", skill.name, skill.source) diff --git a/src/bub/hook_runtime.py b/src/bub/hook_runtime.py index fb928fd9..24afd2f7 100644 --- a/src/bub/hook_runtime.py +++ b/src/bub/hook_runtime.py @@ -1,4 +1,4 @@ -"""Hook execution runtime with per-plugin fault isolation.""" +"""Hook execution runtime with per-adapter fault isolation.""" from __future__ import annotations @@ -76,7 +76,7 @@ async def notify_error(self, *, stage: str, error: Exception, message: Envelope await value except Exception: logger.opt(exception=True).warning( - "hook.on_error_failed stage={} plugin={}", + "hook.on_error_failed stage={} adapter={}", stage, impl.plugin_name or "", ) @@ -90,27 +90,27 @@ def notify_error_sync(self, *, stage: str, error: Exception, message: Envelope | value = impl.function(**call_kwargs) except Exception: logger.opt(exception=True).warning( - "hook.on_error_failed stage={} plugin={}", + "hook.on_error_failed stage={} adapter={}", stage, impl.plugin_name or "", ) continue if inspect.isawaitable(value): logger.warning( - "hook.async_not_supported hook=on_error plugin={}", + "hook.async_not_supported hook=on_error adapter={}", impl.plugin_name or "", ) def hook_report(self) -> dict[str, list[str]]: - """Build a hook->plugins mapping for diagnostics.""" + """Build a hook->adapters mapping for diagnostics.""" report: dict[str, list[str]] = {} for hook_name, hook_caller in sorted(self._plugin_manager.hook.__dict__.items()): if hook_name.startswith("_") or not hasattr(hook_caller, "get_hookimpls"): continue - plugin_names = [impl.plugin_name for impl in hook_caller.get_hookimpls()] - if plugin_names: - report[hook_name] = plugin_names + adapter_names = [impl.plugin_name for impl in hook_caller.get_hookimpls()] + if adapter_names: + report[hook_name] = adapter_names return report async def _invoke_impl_async( @@ -153,7 +153,7 @@ def _invoke_impl_sync( return _SKIP_VALUE if inspect.isawaitable(value): logger.warning( - "hook.async_not_supported hook={} plugin={}", + "hook.async_not_supported hook={} adapter={}", hook_name, impl.plugin_name or "", ) diff --git a/src/bub/skills/__init__.py b/src/bub/skills/__init__.py index c6485f92..734cf21b 100644 --- a/src/bub/skills/__init__.py +++ b/src/bub/skills/__init__.py @@ -2,24 +2,26 @@ from bub.skills.loader import ( SkillMetadata, - discover_hook_skills, + discover_adapter_skills, discover_skills, - has_bub_adapter, + has_bub_runtime_adapter, load_bub_agent_profile, load_bub_agent_profile_file, + load_skill_adapter, load_skill_body, + skill_bub_adapter_path, skill_bub_agent_profile_path, - skill_bub_plugin_path, ) __all__ = [ "SkillMetadata", - "discover_hook_skills", + "discover_adapter_skills", "discover_skills", - "has_bub_adapter", + "has_bub_runtime_adapter", "load_bub_agent_profile", "load_bub_agent_profile_file", + "load_skill_adapter", "load_skill_body", + "skill_bub_adapter_path", "skill_bub_agent_profile_path", - "skill_bub_plugin_path", ] diff --git a/src/bub/skills/builtin/cli/SKILL.md b/src/bub/skills/builtin/cli/SKILL.md index 60c3f0e2..361317b3 100644 --- a/src/bub/skills/builtin/cli/SKILL.md +++ b/src/bub/skills/builtin/cli/SKILL.md @@ -36,7 +36,7 @@ Expected output characteristics: ## Bub Adapter -- Bub adapter entrypoint: `agents/bub/plugin.py`. +- Bub adapter entrypoint: `agents/bub/adapter.py`. - Bub adapter profile: `agents/bub/agent.yaml`. ## References diff --git a/src/bub/skills/builtin/cli/agents/bub/plugin.py b/src/bub/skills/builtin/cli/agents/bub/adapter.py similarity index 91% rename from src/bub/skills/builtin/cli/agents/bub/plugin.py rename to src/bub/skills/builtin/cli/agents/bub/adapter.py index 05f038b0..31a793e0 100644 --- a/src/bub/skills/builtin/cli/agents/bub/plugin.py +++ b/src/bub/skills/builtin/cli/agents/bub/adapter.py @@ -1,4 +1,4 @@ -"""Builtin CLI command hooks.""" +"""Builtin CLI command adapter.""" from __future__ import annotations @@ -14,7 +14,7 @@ from bub.skills.loader import skill_bub_agent_profile_path -class CliCoreSkill: +class CliCoreAdapter: @hookimpl def register_cli_commands(self, app: typer.Typer) -> None: self._register_run(app) @@ -62,8 +62,8 @@ def list_skills( framework = _load_framework(workspace) for status in framework.skill_statuses: rendered = f"{status.skill.name} ({status.skill.source}) state={status.state}" - if status.plugin_path is not None: - rendered += f" adapter={status.plugin_path}" + if status.adapter_path is not None: + rendered += f" adapter={status.adapter_path}" profile_path = skill_bub_agent_profile_path(status.skill) if profile_path.is_file(): rendered += f" profile={profile_path}" @@ -86,11 +86,11 @@ def list_hooks( if not report: typer.echo("(no hook implementations)") return - for hook_name, plugins in report.items(): - typer.echo(f"{hook_name}: {', '.join(plugins)}") + for hook_name, adapter_names in report.items(): + typer.echo(f"{hook_name}: {', '.join(adapter_names)}") -plugin = CliCoreSkill() +adapter = CliCoreAdapter() def _load_framework(workspace: Path | None) -> BubFramework: diff --git a/src/bub/skills/builtin/runtime/SKILL.md b/src/bub/skills/builtin/runtime/SKILL.md index d1922dc6..01e035ca 100644 --- a/src/bub/skills/builtin/runtime/SKILL.md +++ b/src/bub/skills/builtin/runtime/SKILL.md @@ -15,7 +15,7 @@ description: Stateful runtime orchestration skill. Use when implementing or debu ## Bub Adapter -- Bub adapter entrypoint: `agents/bub/plugin.py`. +- Bub adapter entrypoint: `agents/bub/adapter.py`. - Bub adapter profile: `agents/bub/agent.yaml`. ## Examples diff --git a/src/bub/skills/builtin/runtime/agents/bub/plugin.py b/src/bub/skills/builtin/runtime/agents/bub/adapter.py similarity index 96% rename from src/bub/skills/builtin/runtime/agents/bub/plugin.py rename to src/bub/skills/builtin/runtime/agents/bub/adapter.py index 331b77c6..2c60e3e7 100644 --- a/src/bub/skills/builtin/runtime/agents/bub/plugin.py +++ b/src/bub/skills/builtin/runtime/agents/bub/adapter.py @@ -1,4 +1,4 @@ -"""Builtin runtime hook implementation.""" +"""Builtin runtime adapter.""" from __future__ import annotations @@ -10,7 +10,7 @@ from bub.types import Envelope, State -class RuntimeSkill: +class RuntimeAdapter: @hookimpl def normalize_inbound(self, message: Envelope) -> Envelope: envelope = normalize_envelope(message) @@ -65,4 +65,4 @@ def _engine_for_workspace(workspace: Path) -> RuntimeEngine: _ENGINE_CACHE: dict[Path, RuntimeEngine] = {} -plugin = RuntimeSkill() +adapter = RuntimeAdapter() diff --git a/src/bub/skills/builtin/runtime/references/usage.md b/src/bub/skills/builtin/runtime/references/usage.md index 6504b1c6..c4ad2eda 100644 --- a/src/bub/skills/builtin/runtime/references/usage.md +++ b/src/bub/skills/builtin/runtime/references/usage.md @@ -14,7 +14,7 @@ ## Failure Isolation -1. Isolate plugin failures from framework core flow. +1. Isolate adapter failures from framework core flow. 2. Emit actionable error details for diagnostics. 3. Keep fallback response behavior deterministic. @@ -22,5 +22,5 @@ 1. Message normalization with extra whitespace 2. Session fallback without explicit `session_id` -3. Runtime plugin failure does not break core processing +3. Runtime adapter failure does not break core processing 4. Outbound fallback when no renderer returns output diff --git a/src/bub/skills/loader.py b/src/bub/skills/loader.py index 2c268864..2f6f940d 100644 --- a/src/bub/skills/loader.py +++ b/src/bub/skills/loader.py @@ -1,4 +1,4 @@ -"""Skill discovery and hook plugin loading.""" +"""Skill discovery and Bub runtime adapter loading.""" from __future__ import annotations @@ -17,7 +17,7 @@ SKILL_FILE_NAME = "SKILL.md" AGENTS_DIR_NAME = "agents" BUB_AGENT_DIR_NAME = "bub" -BUB_PLUGIN_FILE_NAME = "plugin.py" +BUB_ADAPTER_FILE_NAME = "adapter.py" BUB_AGENT_PROFILE_FILE_NAME = "agent.yaml" SKILL_SOURCES = ("project", "global", "builtin") SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -35,10 +35,10 @@ class SkillMetadata: metadata: dict[str, Any] = field(default_factory=dict) -def discover_hook_skills(workspace_path: Path) -> list[SkillMetadata]: - """Discover skills that provide a Bub hook plugin module.""" +def discover_adapter_skills(workspace_path: Path) -> list[SkillMetadata]: + """Discover skills that provide a Bub runtime adapter module.""" - return [skill for skill in discover_skills(workspace_path) if has_bub_adapter(skill)] + return [skill for skill in discover_skills(workspace_path) if has_bub_runtime_adapter(skill)] def discover_skills(workspace_path: Path) -> list[SkillMetadata]: @@ -75,23 +75,23 @@ def load_skill_body(name: str, workspace_path: Path) -> str | None: return None -def load_skill_plugin(skill: SkillMetadata) -> object: - """Load Bub adapter plugin object from `/agents/bub/plugin.py`.""" +def load_skill_adapter(skill: SkillMetadata) -> object: + """Load Bub adapter object from `/agents/bub/adapter.py`.""" - plugin_file = skill_bub_plugin_path(skill) - if not plugin_file.is_file(): - raise FileNotFoundError(f"{skill.name}: missing {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_PLUGIN_FILE_NAME}") + adapter_file = skill_bub_adapter_path(skill) + if not adapter_file.is_file(): + raise FileNotFoundError(f"{skill.name}: missing {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_ADAPTER_FILE_NAME}") - module_name = _module_name_for_skill(skill=skill, plugin_file=plugin_file) - module = _load_module_from_file(module_name=module_name, plugin_file=plugin_file) - if not hasattr(module, "plugin"): + module_name = _module_name_for_skill(skill=skill, adapter_file=adapter_file) + module = _load_module_from_file(module_name=module_name, adapter_file=adapter_file) + if not hasattr(module, "adapter"): raise AttributeError( - f"{skill.name}: {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_PLUGIN_FILE_NAME} must export attribute `plugin`" + f"{skill.name}: {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_ADAPTER_FILE_NAME} must export attribute `adapter`" ) - plugin = module.plugin - if plugin is None: - raise TypeError(f"{skill.name}: exported `plugin` must not be None") - return plugin + adapter = module.adapter + if adapter is None: + raise TypeError(f"{skill.name}: exported `adapter` must not be None") + return adapter def load_bub_agent_profile(skill: SkillMetadata) -> dict[str, object]: @@ -227,28 +227,28 @@ def _builtin_skills_root() -> Path: return Path(__file__).resolve().parent / "builtin" -def skill_bub_plugin_path(skill: SkillMetadata) -> Path: - return skill.location.parent / AGENTS_DIR_NAME / BUB_AGENT_DIR_NAME / BUB_PLUGIN_FILE_NAME +def skill_bub_adapter_path(skill: SkillMetadata) -> Path: + return skill.location.parent / AGENTS_DIR_NAME / BUB_AGENT_DIR_NAME / BUB_ADAPTER_FILE_NAME def skill_bub_agent_profile_path(skill: SkillMetadata) -> Path: return skill.location.parent / AGENTS_DIR_NAME / BUB_AGENT_DIR_NAME / BUB_AGENT_PROFILE_FILE_NAME -def has_bub_adapter(skill: SkillMetadata) -> bool: - return skill_bub_plugin_path(skill).is_file() +def has_bub_runtime_adapter(skill: SkillMetadata) -> bool: + return skill_bub_adapter_path(skill).is_file() -def _module_name_for_skill(*, skill: SkillMetadata, plugin_file: Path) -> str: - digest = hashlib.sha256(str(plugin_file).encode("utf-8")).hexdigest()[:12] +def _module_name_for_skill(*, skill: SkillMetadata, adapter_file: Path) -> str: + digest = hashlib.sha256(str(adapter_file).encode("utf-8")).hexdigest()[:12] normalized_name = "".join(ch if ch.isalnum() else "_" for ch in f"{skill.source}_{skill.name}".lower()) return f"bub_skill_{normalized_name}_{digest}" -def _load_module_from_file(*, module_name: str, plugin_file: Path) -> ModuleType: - spec = importlib_util.spec_from_file_location(module_name, plugin_file) +def _load_module_from_file(*, module_name: str, adapter_file: Path) -> ModuleType: + spec = importlib_util.spec_from_file_location(module_name, adapter_file) if spec is None or spec.loader is None: - raise ImportError(f"failed to build module spec for {plugin_file}") + raise ImportError(f"failed to build module spec for {adapter_file}") module = importlib_util.module_from_spec(spec) sys.modules.pop(module_name, None) diff --git a/tests/fixtures_plugins/__init__.py b/tests/fixtures_plugins/__init__.py index bcfba0f8..8dce71e0 100644 --- a/tests/fixtures_plugins/__init__.py +++ b/tests/fixtures_plugins/__init__.py @@ -1,2 +1 @@ -"""Test-only skill plugins.""" - +"""Test-only skill adapters.""" diff --git a/tests/fixtures_plugins/stateful_hooks.py b/tests/fixtures_plugins/stateful_hooks.py index ee2dcbb0..4d350d3f 100644 --- a/tests/fixtures_plugins/stateful_hooks.py +++ b/tests/fixtures_plugins/stateful_hooks.py @@ -36,4 +36,4 @@ def save_state( self._states[session_id] = dict(state) -plugin = StatefulHooksSkill() +adapter = StatefulHooksSkill() diff --git a/tests/test_fault_tolerance.py b/tests/test_fault_tolerance.py index 450baf49..432d9e70 100644 --- a/tests/test_fault_tolerance.py +++ b/tests/test_fault_tolerance.py @@ -9,9 +9,9 @@ def _write_broken_skill(workspace: Path) -> None: broken = workspace / ".agent" / "skills" / "broken" - plugin_file = broken / "agents" / "bub" / "plugin.py" - plugin_file.parent.mkdir(parents=True) - plugin_file.write_text("import missing_module\n", encoding="utf-8") + adapter_file = broken / "agents" / "bub" / "adapter.py" + adapter_file.parent.mkdir(parents=True) + adapter_file.write_text("import missing_module\n", encoding="utf-8") (broken / "SKILL.md").write_text( "\n".join( [ @@ -39,9 +39,9 @@ async def test_broken_skill_does_not_break_framework(tmp_path: Path) -> None: def _write_runtime_error_skill(workspace: Path) -> None: skill_dir = workspace / ".agent" / "skills" / "broken-output" - plugin_file = skill_dir / "agents" / "bub" / "plugin.py" - plugin_file.parent.mkdir(parents=True) - plugin_file.write_text( + adapter_file = skill_dir / "agents" / "bub" / "adapter.py" + adapter_file.parent.mkdir(parents=True) + adapter_file.write_text( "\n".join( [ "from bub.hookspecs import hookimpl", @@ -51,7 +51,7 @@ def _write_runtime_error_skill(workspace: Path) -> None: " def render_outbound(self, message, session_id, state, model_output):", " raise RuntimeError('output broke on purpose')", "", - "plugin = BrokenOutputSkill()", + "adapter = BrokenOutputSkill()", ] ), encoding="utf-8", diff --git a/tests/test_framework_flow.py b/tests/test_framework_flow.py index 0224adbb..3adf3cf5 100644 --- a/tests/test_framework_flow.py +++ b/tests/test_framework_flow.py @@ -12,9 +12,9 @@ def _write_stateful_test_skill(workspace: Path) -> None: skill_dir = workspace / ".agent" / "skills" / "stateful-hooks" - plugin_file = skill_dir / "agents" / "bub" / "plugin.py" - plugin_file.parent.mkdir(parents=True) - plugin_file.write_text("from fixtures_plugins.stateful_hooks import plugin\n", encoding="utf-8") + adapter_file = skill_dir / "agents" / "bub" / "adapter.py" + adapter_file.parent.mkdir(parents=True) + adapter_file.write_text("from fixtures_plugins.stateful_hooks import adapter\n", encoding="utf-8") (skill_dir / "SKILL.md").write_text( "\n".join( [ diff --git a/tests/test_skill_loader.py b/tests/test_skill_loader.py index 0ffe9bc2..a8fe7803 100644 --- a/tests/test_skill_loader.py +++ b/tests/test_skill_loader.py @@ -4,7 +4,7 @@ from bub.skills.loader import ( SkillMetadata, - discover_hook_skills, + discover_adapter_skills, discover_skills, load_bub_agent_profile, load_bub_agent_profile_file, @@ -12,7 +12,7 @@ ) -def _write_skill(root: Path, *, name: str, with_plugin: bool) -> None: +def _write_skill(root: Path, *, name: str, with_adapter: bool) -> None: root.mkdir(parents=True) (root / "SKILL.md").write_text( "\n".join( @@ -25,10 +25,10 @@ def _write_skill(root: Path, *, name: str, with_plugin: bool) -> None: ), encoding="utf-8", ) - if with_plugin: - plugin_file = root / "agents" / "bub" / "plugin.py" - plugin_file.parent.mkdir(parents=True) - plugin_file.write_text( + if with_adapter: + adapter_file = root / "agents" / "bub" / "adapter.py" + adapter_file.parent.mkdir(parents=True) + adapter_file.write_text( "\n".join( [ "from bub.hookspecs import hookimpl", @@ -38,20 +38,20 @@ def _write_skill(root: Path, *, name: str, with_plugin: bool) -> None: " def resolve_session(self, message):", " return None", "", - "plugin = DemoSkill()", + "adapter = DemoSkill()", ] ), encoding="utf-8", ) -def _write_skill_with_frontmatter(root: Path, *, frontmatter_lines: list[str], with_plugin: bool) -> None: +def _write_skill_with_frontmatter(root: Path, *, frontmatter_lines: list[str], with_adapter: bool) -> None: root.mkdir(parents=True) (root / "SKILL.md").write_text("\n".join(frontmatter_lines), encoding="utf-8") - if with_plugin: - plugin_file = root / "agents" / "bub" / "plugin.py" - plugin_file.parent.mkdir(parents=True) - plugin_file.write_text( + if with_adapter: + adapter_file = root / "agents" / "bub" / "adapter.py" + adapter_file.parent.mkdir(parents=True) + adapter_file.write_text( "\n".join( [ "from bub.hookspecs import hookimpl", @@ -61,14 +61,14 @@ def _write_skill_with_frontmatter(root: Path, *, frontmatter_lines: list[str], w " def resolve_session(self, message):", " return None", "", - "plugin = DemoSkill()", + "adapter = DemoSkill()", ] ), encoding="utf-8", ) -def test_discover_hook_skills_respects_project_over_global(monkeypatch, tmp_path: Path) -> None: +def test_discover_adapter_skills_respects_project_over_global(monkeypatch, tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() fake_home = tmp_path / "home" @@ -76,40 +76,40 @@ def test_discover_hook_skills_respects_project_over_global(monkeypatch, tmp_path _write_skill( workspace / ".agent" / "skills" / "demo", name="demo", - with_plugin=True, + with_adapter=True, ) _write_skill( fake_home / ".agent" / "skills" / "demo", name="demo", - with_plugin=True, + with_adapter=True, ) monkeypatch.setenv("HOME", str(fake_home)) - skills = discover_hook_skills(workspace) + skills = discover_adapter_skills(workspace) demo = next(skill for skill in skills if skill.name == "demo") assert demo.source == "project" assert demo.location.parent == workspace / ".agent" / "skills" / "demo" -def test_discover_hook_skills_filters_non_hook_skills(tmp_path: Path) -> None: +def test_discover_adapter_skills_filters_non_adapter_skills(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() _write_skill( - workspace / ".agent" / "skills" / "no-plugin", - name="no-plugin", - with_plugin=False, + workspace / ".agent" / "skills" / "no-adapter", + name="no-adapter", + with_adapter=False, ) _write_skill( workspace / ".agent" / "skills" / "valid", name="valid", - with_plugin=True, + with_adapter=True, ) - names = [skill.name for skill in discover_hook_skills(workspace)] + names = [skill.name for skill in discover_adapter_skills(workspace)] assert "valid" in names - assert "no-plugin" not in names + assert "no-adapter" not in names def test_discover_skills_rejects_name_mismatch_with_directory(tmp_path: Path) -> None: @@ -123,7 +123,7 @@ def test_discover_skills_rejects_name_mismatch_with_directory(tmp_path: Path) -> "description: mismatch", "---", ], - with_plugin=True, + with_adapter=True, ) names = {skill.name for skill in discover_skills(workspace)} @@ -142,7 +142,7 @@ def test_discover_skills_rejects_invalid_name_pattern(tmp_path: Path) -> None: "description: invalid pattern", "---", ], - with_plugin=True, + with_adapter=True, ) names = {skill.name for skill in discover_skills(workspace)} @@ -160,7 +160,7 @@ def test_discover_skills_rejects_missing_required_description(tmp_path: Path) -> "name: no-description", "---", ], - with_plugin=True, + with_adapter=True, ) names = {skill.name for skill in discover_skills(workspace)} @@ -181,7 +181,7 @@ def test_discover_skills_rejects_invalid_metadata_type(tmp_path: Path) -> None: " version: 1", "---", ], - with_plugin=True, + with_adapter=True, ) names = {skill.name for skill in discover_skills(workspace)} @@ -205,7 +205,7 @@ def test_discover_skills_accepts_spec_optional_fields(tmp_path: Path) -> None: " version: '1.0'", "---", ], - with_plugin=True, + with_adapter=True, ) names = {skill.name for skill in discover_skills(workspace)} @@ -224,7 +224,7 @@ def test_discover_skills_rejects_unknown_frontmatter_fields(tmp_path: Path) -> N "entrypoint: should-not-be-here", "---", ], - with_plugin=True, + with_adapter=True, ) names = {skill.name for skill in discover_skills(workspace)} diff --git a/tests/test_skill_override.py b/tests/test_skill_override.py index 899275ad..3be8929f 100644 --- a/tests/test_skill_override.py +++ b/tests/test_skill_override.py @@ -9,9 +9,9 @@ def _write_project_override_skill(workspace: Path) -> None: skill_dir = workspace / ".agent" / "skills" / "project-override" - plugin_file = skill_dir / "agents" / "bub" / "plugin.py" - plugin_file.parent.mkdir(parents=True) - plugin_file.write_text( + adapter_file = skill_dir / "agents" / "bub" / "adapter.py" + adapter_file.parent.mkdir(parents=True) + adapter_file.write_text( "\n".join( [ "import typer", @@ -33,7 +33,7 @@ def _write_project_override_skill(workspace: Path) -> None: " def project_ping():", " typer.echo('pong')", "", - "plugin = ProjectOverrideSkill()", + "adapter = ProjectOverrideSkill()", ] ), encoding="utf-8", From 378b0306a1b4c1ea0dabf1d1af2c9ab85583d53c Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 25 Feb 2026 16:38:53 +0800 Subject: [PATCH 05/39] refactor: redesign the interfaces of bub framework Signed-off-by: Frost Ming --- README.md | 105 +++++-- docs/architecture.md | 81 +++--- docs/cli.md | 51 +++- docs/features.md | 36 ++- docs/index.md | 24 +- docs/skills.md | 81 +++--- pyproject.toml | 20 +- src/bub/{cli.py => __main__.py} | 15 +- src/bub/builtin/__init__.py | 0 src/bub/builtin/cli.py | 95 ++++++ .../runtime/agents/bub => builtin}/engine.py | 72 ++--- src/bub/builtin/hook_impl.py | 76 +++++ src/bub/envelope.py | 2 +- src/bub/framework.py | 120 +++----- src/bub/hook_runtime.py | 8 +- src/bub/{skills/loader.py => skills.py} | 89 +----- src/bub/skills/__init__.py | 27 -- src/bub/skills/builtin/cli/SKILL.md | 44 --- .../skills/builtin/cli/agents/bub/adapter.py | 100 ------- .../skills/builtin/cli/agents/bub/agent.yaml | 4 - .../skills/builtin/cli/references/usage.md | 29 -- .../builtin/cli/scripts/command_index.py | 42 --- src/bub/skills/builtin/runtime/SKILL.md | 45 --- .../builtin/runtime/agents/bub/adapter.py | 68 ----- .../builtin/runtime/agents/bub/agent.yaml | 6 - .../builtin/runtime/references/usage.md | 26 -- .../runtime/scripts/prepare_context.py | 49 ---- src/bub/types.py | 4 +- src/bub_skills/README.md | 3 + tests/conftest.py | 8 - tests/fixtures_plugins/__init__.py | 1 - tests/fixtures_plugins/stateful_hooks.py | 39 --- tests/test_bus.py | 25 -- tests/test_fault_tolerance.py | 84 ------ tests/test_framework_flow.py | 163 ----------- tests/test_skill_loader.py | 273 ------------------ tests/test_skill_override.py | 75 ----- uv.lock | 2 +- 38 files changed, 503 insertions(+), 1489 deletions(-) rename src/bub/{cli.py => __main__.py} (63%) create mode 100644 src/bub/builtin/__init__.py create mode 100644 src/bub/builtin/cli.py rename src/bub/{skills/builtin/runtime/agents/bub => builtin}/engine.py (92%) create mode 100644 src/bub/builtin/hook_impl.py rename src/bub/{skills/loader.py => skills.py} (64%) delete mode 100644 src/bub/skills/__init__.py delete mode 100644 src/bub/skills/builtin/cli/SKILL.md delete mode 100644 src/bub/skills/builtin/cli/agents/bub/adapter.py delete mode 100644 src/bub/skills/builtin/cli/agents/bub/agent.yaml delete mode 100644 src/bub/skills/builtin/cli/references/usage.md delete mode 100644 src/bub/skills/builtin/cli/scripts/command_index.py delete mode 100644 src/bub/skills/builtin/runtime/SKILL.md delete mode 100644 src/bub/skills/builtin/runtime/agents/bub/adapter.py delete mode 100644 src/bub/skills/builtin/runtime/agents/bub/agent.yaml delete mode 100644 src/bub/skills/builtin/runtime/references/usage.md delete mode 100644 src/bub/skills/builtin/runtime/scripts/prepare_context.py create mode 100644 src/bub_skills/README.md delete mode 100644 tests/conftest.py delete mode 100644 tests/fixtures_plugins/__init__.py delete mode 100644 tests/fixtures_plugins/stateful_hooks.py delete mode 100644 tests/test_bus.py delete mode 100644 tests/test_fault_tolerance.py delete mode 100644 tests/test_framework_flow.py delete mode 100644 tests/test_skill_loader.py delete mode 100644 tests/test_skill_override.py diff --git a/README.md b/README.md index 0d196ea1..b30b2d12 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,14 @@ # Bub -[![Release](https://img.shields.io/github/v/release/bubbuild/bub)](https://github.com/bubbuild/bub/releases) -[![Build status](https://img.shields.io/github/actions/workflow/status/bubbuild/bub/main.yml?branch=main)](https://github.com/bubbuild/bub/actions/workflows/main.yml?query=branch%3Amain) -[![Commit activity](https://img.shields.io/github/commit-activity/m/bubbuild/bub)](https://github.com/bubbuild/bub/graphs/commit-activity) -[![License](https://img.shields.io/github/license/bubbuild/bub)](LICENSE) +Bub is a hook-first AI framework built on `pluggy`: the core stays small and orchestrates turns, while builtins and plugins provide behavior. -Bub is a batteries-included, skill-first AI framework with a minimal core and skill-owned behavior. +## Current Implementation -## Why Bub - -Bub keeps the framework kernel small and stable, and moves runtime capabilities into skills. -This makes behavior easy to evolve without forking the core. - -## Design Principles - -- Minimal kernel for orchestration and safety boundaries -- Skill-first extension model for runtime behavior -- Standard Agent Skills contract first, Bub runtime adapter second -- Standards-based skill metadata (`SKILL.md`) -- Predictable override order across project, user, and builtin scopes - -## Builtin Batteries - -- `cli`: command entrypoints and diagnostics -- `runtime`: message handling, model/tool execution, and outbound rendering +- CLI bootstrap: `src/bub/__main__.py` (Typer app) +- Turn orchestrator: `src/bub/framework.py` +- Hook contract: `src/bub/hookspecs.py` +- Builtin hooks/runtime: `src/bub/builtin/hook_impl.py` + `src/bub/builtin/engine.py` +- Skill discovery and validation: `src/bub/skills.py` ## Quick Start @@ -31,24 +16,82 @@ This makes behavior easy to evolve without forking the core. git clone https://github.com/bubbuild/bub.git cd bub uv sync -uv run bub run "hello" -uv run bub hooks -uv run bub skills -BUB_RUNTIME_ENABLED=1 uv run bub run ",help" +uv run bub --help +``` + +```bash +# Runtime off: falls back to model_output=prompt +BUB_RUNTIME_ENABLED=0 uv run bub run "hello" +``` + +```bash +# Internal command mode (line starts with ',') +BUB_RUNTIME_ENABLED=0 uv run bub run ",help" +``` + +```bash +# Model runtime (hosted providers usually require a key) +BUB_API_KEY=your_key uv run bub run "Summarize this repository" ``` +## CLI Commands + +- `bub run MESSAGE`: execute one inbound turn and print outbound messages +- `bub hooks`: print hook-to-plugin bindings +- `bub install PLUGIN_SPEC`: install plugin from PyPI or `owner/repo` (GitHub shorthand) + +## Runtime Behavior + +- Regular text input: uses `run_model`; if runtime is unavailable, output falls back to the prompt text +- Comma commands: `,help`, `,tools`, `,fs.read ...`, etc. +- Unknown comma commands: executed as `bash -lc` in workspace +- Session event log: `.bub/runtime/.jsonl` +- `AGENTS.md`: if present in workspace, appended to runtime system prompt + +## Skills + +- Discovery roots with deterministic override: + 1. `/.agent/skills` + 2. `~/.agent/skills` + 3. `src/bub_skills` +- Each skill directory must include `SKILL.md` +- Supported frontmatter fields: + - required: `name`, `description` + - optional: `license`, `compatibility`, `metadata`, `allowed-tools` + +## Plugin Development + +Plugins are loaded from Python entry points in `group="bub"`: + +```toml +[project.entry-points."bub"] +my_plugin = "my_package.my_plugin" +``` + +Implement hooks with `@hookimpl` following `BubHookSpecs`. + +## Runtime Environment Variables + +- `BUB_RUNTIME_ENABLED`: `auto` (default), `1`, `0` +- `BUB_MODEL`: default `openrouter:qwen/qwen3-coder-next` +- `BUB_API_KEY`: runtime provider key +- `BUB_API_BASE`: optional provider base URL +- `BUB_RUNTIME_MAX_STEPS`: default `8` +- `BUB_RUNTIME_MAX_TOKENS`: default `1024` +- `BUB_RUNTIME_MODEL_TIMEOUT_SECONDS`: default `90` + ## Documentation - `docs/index.md`: overview -- `docs/features.md`: capability summary -- `docs/architecture.md`: architecture principles and guarantees -- `docs/skills.md`: skill authoring and extension model -- `docs/cli.md`: command usage +- `docs/architecture.md`: lifecycle, precedence, and failure isolation +- `docs/skills.md`: skill discovery and frontmatter constraints +- `docs/cli.md`: CLI usage and comma command mode +- `docs/features.md`: implemented capabilities and limits ## Development Checks ```bash uv run ruff check . -uv run mypy +uv run mypy src uv run pytest -q ``` diff --git a/docs/architecture.md b/docs/architecture.md index 8e7aea0c..a0700c85 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,52 +1,63 @@ # Architecture -Bub uses a minimal-kernel architecture: the core orchestrates a turn, while skills provide behavior. +## Core Components -## Principles +- `BubFramework`: creates the plugin manager, loads hooks, runs turns +- `BubHookSpecs`: defines hook contracts (`firstresult` and broadcast hooks) +- `HookRuntime`: executes hook implementations with per-impl fault isolation +- `MessageBus`: default in-memory bus (replaceable via hook) -- Keep core responsibilities small and stable -- Put runtime behavior behind explicit extension points -- Preserve predictable override semantics -- Prefer graceful degradation over global failure +## Turn Lifecycle -## Guarantees +`process_inbound()` executes hooks in this order: -### Deterministic Turn Lifecycle +1. `normalize_inbound(message)` +2. `resolve_session(message)` +3. `load_state(session_id)` (defaults to `{}`) +4. `build_prompt(message, session_id, state)` (defaults to message `content`) +5. `run_model(prompt, session_id, state)` +6. `save_state(...)` (broadcast) +7. `render_outbound(...)` (broadcast) +8. `dispatch_outbound(message)` (broadcast per outbound) -Each inbound message follows a stable lifecycle: +If `render_outbound` yields nothing, the framework emits one fallback outbound: -1. normalize input -2. resolve session -3. load context/state -4. build model input -5. run model/tools -6. persist state -7. render outbound messages -8. dispatch output +```text +{ + "content": model_output, + "session_id": session_id, + "channel": ...?, # if exists in inbound + "chat_id": ...? # if exists in inbound +} +``` -### Deterministic Skill Resolution +## Precedence And Override Semantics -Skills are resolved by scope priority: +- Hook registration order: + 1. Builtin plugin `bub.builtin.hook_impl` + 2. External entry points (`group="bub"`) +- Execution order: `HookRuntime` reverses pluggy impl order, so later-registered plugins run first +- For `firstresult` hooks: first non-`None` value wins +- For broadcast hooks (for example `save_state`): all implementations are attempted -1. project scope -2. user scope -3. builtin scope +## Fault Isolation And Fallbacks -If names collide, higher-priority scope wins. +- A failing hook implementation does not crash the whole turn; `on_error` is notified +- If `run_model` returns no value, fallback is `model_output = prompt` +- `create_bus()` falls back to `MessageBus` when no plugin provides a bus +- `handle_bus_once()` consumes one inbound from bus and publishes produced outbounds -### Failure Isolation +## Builtin Runtime -- Skill load failures are isolated -- Adapter execution failures are isolated per extension -- The framework keeps the turn loop operational with safe fallbacks +Builtin `run_model` is implemented by `RuntimeEngine`: -## Non-goals +- Regular prompts run through Republic `run_tools_async` +- Comma-prefixed input goes through internal command dispatch (`help/tools/fs.*/...`) +- Unknown comma commands are executed as shell commands +- Runtime events are persisted at `.bub/runtime/.jsonl` -- Enforcing one global business schema for all messages -- Hardcoding domain behavior into the kernel -- Merging duplicate skill names across scopes +## Boundaries -## See Also - -- `skills.md` for skill contract and layout -- `cli.md` for command behavior +- `Envelope` is intentionally weakly typed (`Any`) and read via helper accessors +- There is no global enforced business schema for messages or cross-plugin state +- Skill discovery/validation is a separate subsystem (see `skills.md`) diff --git a/docs/cli.md b/docs/cli.md index 90767b13..ad345fff 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,40 +1,67 @@ # CLI -## Run one message +`bub` currently exposes three commands: `run`, `hooks`, and `install`. + +## `bub run` + +Run one inbound message through the full framework lifecycle. ```bash uv run bub run "hello" --channel stdout --chat-id local ``` -## Run with runtime enabled (optional) +When runtime is disabled or unavailable, output safely falls back to the input prompt text: ```bash -BUB_RUNTIME_ENABLED=1 uv run bub run "summarize current repo status" +BUB_RUNTIME_ENABLED=0 uv run bub run "hello" ``` -## Command-style runtime input +Run with runtime enabled: ```bash -BUB_RUNTIME_ENABLED=1 uv run bub run ",help" +BUB_RUNTIME_ENABLED=1 BUB_API_KEY=your_key uv run bub run "summarize current repo status" ``` -## List skills +Comma-prefixed inputs invoke internal command mode: ```bash -uv run bub skills +BUB_RUNTIME_ENABLED=0 uv run bub run ",help" +BUB_RUNTIME_ENABLED=0 uv run bub run ",tools" +BUB_RUNTIME_ENABLED=0 uv run bub run ",fs.read path=README.md" ``` -This command shows discovered skills and their current runtime health. +Unknown comma commands are executed as shell commands: -## List hook bindings +```bash +BUB_RUNTIME_ENABLED=0 uv run bub run ",echo hello-from-shell" +``` + +## `bub hooks` + +Print hook-to-plugin bindings discovered at startup. ```bash uv run bub hooks ``` +## `bub install` + +Install plugins from PyPI requirement spec or GitHub shorthand. + +```bash +uv run bub install my-plugin-package +uv run bub install owner/repo +``` + +`owner/repo` is converted to: + +```text +git+https://github.com/owner/repo.git +``` + ## Notes -- `--workspace` is supported on `run`, `skills`, and `hooks` +- `--workspace` is supported by `run` and `hooks` - `BUB_RUNTIME_ENABLED` supports `0`, `1`, and `auto` (default) -- If runtime model is unavailable, `bub run` still returns a safe textual result -- Session identity falls back to `channel:chat_id` when not provided explicitly +- Session id defaults to `channel:chat_id` when `--session-id` is not provided +- `run` prints each outbound as `[channel:chat_id] content` diff --git a/docs/features.md b/docs/features.md index bedfdee6..f82211a8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,19 +1,35 @@ # Key Features -## Framework +## Framework Core -- Adapter-based extension model -- Minimal kernel with clear failure boundaries -- Deterministic turn orchestration +- Hook-first architecture with `pluggy` +- Deterministic turn lifecycle in `BubFramework.process_inbound()` +- Safe fallbacks for missing bus, missing model output, and missing outbound renderers +- Per-hook-implementation fault isolation via `HookRuntime` ## Skills -- Standards-based `SKILL.md` metadata -- Scope-based discovery and override behavior -- Optional Bub adapter extension layer +- `SKILL.md` frontmatter validation (`name`, `description`, optional fields) +- Deterministic discovery/override order: project -> global -> builtin +- Skill body loading for runtime commands like `,skills.describe` ## Runtime -- Builtin `runtime` and `cli` batteries -- Graceful degradation when extensions fail -- Safe fallback behavior when model runtime is unavailable +- Builtin CLI commands: `run`, `hooks`, `install` +- Builtin runtime engine with: + - LLM turn execution through Republic tools + - Internal comma command mode (`help`, `tools`, `fs.*`, `tape.*`, `skills.*`) + - Shell fallback for unknown comma commands +- Runtime event logging to `.bub/runtime/*.jsonl` + +## Plugin Extensibility + +- External plugins loaded from Python entry points (`group="bub"`) +- First-result hooks for override-style behavior +- Broadcast hooks for multi-observer side effects (`save_state`, `dispatch_outbound`, `on_error`) + +## Current Boundaries + +- No strict envelope schema: `Envelope` is `Any` +- No enforced global persistence/state format across plugins +- Repository currently ships the `src/bub_skills` root, but no mandatory builtin skill pack behavior in core diff --git a/docs/index.md b/docs/index.md index 2ab3756d..0687b114 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,17 +1,23 @@ # Bub -Bub is a skill-first framework that keeps kernel responsibilities minimal and pushes behavior into skills. +Bub currently implements a minimal hook-first framework: -## Builtin Baseline +- The core orchestrates one message turn end-to-end +- Builtin hooks provide default CLI and runtime behavior +- External plugins join the same lifecycle via entry points (`group="bub"`) -- `cli` -- `runtime` +## Where To Look -Both can be replaced or extended by your own skills. +- CLI bootstrap: `src/bub/__main__.py` +- Core runtime orchestration: `src/bub/framework.py` +- Hook specifications: `src/bub/hookspecs.py` +- Hook execution isolation: `src/bub/hook_runtime.py` +- Builtin implementations: `src/bub/builtin/*` +- Skill discovery/validation: `src/bub/skills.py` ## Read Next -- `architecture.md`: principles and architectural guarantees -- `skills.md`: skill authoring and extension model -- `cli.md`: command usage -- `features.md`: capability summary +- `architecture.md`: lifecycle, hook precedence, and fault isolation +- `cli.md`: `bub run`, `bub hooks`, `bub install`, and comma commands +- `skills.md`: `SKILL.md` frontmatter rules and override behavior +- `features.md`: current capabilities and known boundaries diff --git a/docs/skills.md b/docs/skills.md index 7601bf1d..f1790b5a 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -1,51 +1,33 @@ # Skills -Bub follows the Agent Skills specification and adds one optional Bub runtime adapter layer. +Bub currently treats skills as discoverable `SKILL.md` documents with validated frontmatter. -## Core Contract +## Minimal Contract -Every skill must remain valid as a standard Agent Skill: +Each skill directory must contain a `SKILL.md` file: ```text my-skill/ -└── SKILL.md +`-- SKILL.md ``` -`SKILL.md` must contain valid YAML frontmatter and body instructions. -The skill directory name must match frontmatter `name`. +Rules enforced by `src/bub/skills.py`: -## Bub Runtime Extension +- `SKILL.md` must start with YAML frontmatter (`--- ... ---`) +- Frontmatter must include non-empty `name` and `description` +- Directory name must exactly match frontmatter `name` +- `name` must match regex: `^[a-z0-9]+(?:-[a-z0-9]+)*$` -If a skill needs Bub runtime hooks, add: +## Supported Frontmatter Fields -```text -my-skill/ -├── SKILL.md -└── agents/ - └── bub/ - ├── adapter.py - └── agent.yaml -``` - -- `agents/bub/adapter.py`: optional Bub hook adapter module, exporting `adapter` -- `agents/bub/agent.yaml`: optional prompt/profile data consumed by Bub runtime - -This extension is Bub-specific. It does not change standard Agent Skills compatibility. - -## Recommended Layout - -```text -my-skill/ -├── SKILL.md -├── agents/ -│ └── bub/ -│ ├── adapter.py -│ └── agent.yaml -├── scripts/ -│ └── *.py -└── references/ - └── *.md -``` +- Required: + - `name` (string) + - `description` (string) +- Optional: + - `license` (string) + - `compatibility` (string) + - `metadata` (map of `string -> string`) + - `allowed-tools` (string) ## Discovery And Override @@ -53,27 +35,30 @@ Bub discovers skills from three scopes in priority order: 1. project: `.agent/skills` 2. user: `~/.agent/skills` -3. builtin: `src/bub/skills/builtin` +3. builtin: `src/bub_skills` If names collide, higher-priority scope overrides lower-priority scope. -## Frontmatter Fields +## Runtime Access To Skills + +Builtin runtime command mode can inspect discovered skills: -Supported `SKILL.md` frontmatter fields: +```bash +BUB_RUNTIME_ENABLED=0 uv run bub run ",skills.list" +BUB_RUNTIME_ENABLED=0 uv run bub run ",skills.describe name=my-skill" +``` -- required: `name`, `description` -- optional: `license`, `compatibility`, `metadata`, `allowed-tools` +If no valid skills are discovered, `,skills.list` returns `(no skills)`. ## Authoring Guidance -- Keep `SKILL.md` concise and activation-oriented -- Move detailed reference material into `references/` -- Put deterministic executable logic into `scripts/` -- Keep Bub-only runtime details inside `agents/bub/`, not in the generic skill contract +- Keep `SKILL.md` concise and action-oriented +- Keep metadata strict and minimal to avoid discovery failures +- Use lowercase kebab-case names to satisfy validation -## Script Convention +## Optional Script Convention -For `scripts/*.py`, prefer standalone `uv` scripts with PEP 723 metadata: +For `scripts/*.py`, a practical standalone convention is PEP 723 with `uv`: ```python #!/usr/bin/env -S uv run --script @@ -83,4 +68,4 @@ For `scripts/*.py`, prefer standalone `uv` scripts with PEP 723 metadata: # /// ``` -This keeps execution deterministic and avoids hidden environment assumptions. +This keeps execution deterministic and reduces hidden environment assumptions. diff --git a/pyproject.toml b/pyproject.toml index 65d4e4a9..30f0e0c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,8 @@ [project] name = "bub" -version = "0.2.3" -description = "Bub it. Build it. A collaborative agent for shared delivery workflows." -authors = [ - { name = "Chojan Shang", email = "psiace@apache.org" }, - { name = "Frost Ming", email = "me@frostming.com" }, - { name = "Hong Yi", email = "zouzou0208@gmail.com" }, -] +version = "0.3.0" +description = "Bub it. Build it. Batteries-included, hook-first AI framework." +authors = [{ name = "Chojan Shang", email = "psiace@apache.org" }] readme = "README.md" keywords = ['python'] requires-python = ">=3.12,<4.0" @@ -41,7 +37,7 @@ Repository = "https://github.com/bubbuild/bub" Documentation = "https://bub.build" [project.scripts] -bub = "bub.cli:app" +bub = "bub.__main__:app" [dependency-groups] dev = [ @@ -59,11 +55,11 @@ dev = [ ] [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["pdm-backend"] +build-backend = "pdm.backend" -[tool.hatch.build.targets.wheel] -packages = ["src/bub"] +[tool.pdm.build] +includes = ["src/bub", "src/bub_skills"] [tool.vulture] ignore_names = ["test_*", "Test*"] diff --git a/src/bub/cli.py b/src/bub/__main__.py similarity index 63% rename from src/bub/cli.py rename to src/bub/__main__.py index a572bc58..6865f593 100644 --- a/src/bub/cli.py +++ b/src/bub/__main__.py @@ -8,18 +8,23 @@ from bub.framework import BubFramework -app = typer.Typer(name="bub", help="Batteries-included, hook-first AI framework", add_completion=False) - -def _load_cli_commands() -> None: +def create_cli_app() -> typer.Typer: + app = typer.Typer(name="bub", help="Batteries-included, hook-first AI framework", add_completion=False) framework = BubFramework(Path.cwd()) - framework.load_skills() + framework.load_hooks() framework.register_cli_commands(app) if not app.registered_commands: + @app.command("help") def _help() -> None: typer.echo("No CLI command skills loaded. Install a command skill in .agent/skills.") + return app + + +app = create_cli_app() -_load_cli_commands() +if __name__ == "__main__": + app() diff --git a/src/bub/builtin/__init__.py b/src/bub/builtin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py new file mode 100644 index 00000000..b8952d37 --- /dev/null +++ b/src/bub/builtin/cli.py @@ -0,0 +1,95 @@ +"""Builtin CLI command adapter.""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path +from typing import Any + +import typer + +from bub.envelope import field_of +from bub.framework import BubFramework + +app = typer.Typer() + + +def _load_framework(workspace: Path | None) -> BubFramework: + if workspace is None: + workspace = Path.cwd() + framework = BubFramework(workspace) + framework.load_hooks() + return framework + + +def run( + message: str = typer.Argument(..., help="Inbound message content"), + workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace root"), # noqa: B008 + channel: str = typer.Option("stdout", "--channel", help="Message channel"), + chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), + sender_id: str = typer.Option("human", "--sender-id", help="Sender id"), + session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), +) -> None: + """Run one inbound message through the framework pipeline.""" + + framework = _load_framework(workspace) + inbound: dict[str, Any] = { + "channel": channel, + "chat_id": chat_id, + "sender_id": sender_id, + "content": message, + } + if session_id is not None and session_id.strip(): + inbound["session_id"] = session_id.strip() + + result = asyncio.run(framework.process_inbound(inbound)) + for outbound in result.outbounds: + rendered = str(field_of(outbound, "content", "")) + target_channel = str(field_of(outbound, "channel", "stdout")) + target_chat = str(field_of(outbound, "chat_id", "local")) + typer.echo(f"[{target_channel}:{target_chat}] {rendered}") + + +def list_hooks( + workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 +) -> None: + """Show hook implementation mapping.""" + + framework = _load_framework(workspace) + report = framework.hook_report() + if not report: + typer.echo("(no hook implementations)") + return + for hook_name, adapter_names in report.items(): + typer.echo(f"{hook_name}: {', '.join(adapter_names)}") + + +def install_plugin( + plugin_spec: str = typer.Argument(..., help="Python requirement string or github owner/repo"), +) -> None: + """Install a plugin from PyPI or GitHub repository.""" + if "/" in plugin_spec and not plugin_spec.startswith("git+") and "github.com" not in plugin_spec: + plugin_spec = f"git+https://github.com/{plugin_spec}.git" + if uv_bin := _find_uv(): + typer.echo(f"Installing plugin '{plugin_spec}' with uv...") + subprocess.run([uv_bin, "pip", "install", plugin_spec], check=True) # noqa: S603 + return + typer.echo(f"Installing plugin '{plugin_spec}' with pip...") + subprocess.run([sys.executable, "-m", "pip", "install", "-p", sys.executable, plugin_spec], check=True) # noqa: S603 + + +def _find_uv() -> Path | None: + """Find uv executable in the system.""" + + this_path = sysconfig.get_path("scripts") + path_str = os.getenv("PATH", "") + + uv_path = shutil.which("uv", path=f"{this_path}{os.pathsep}{path_str}") + if uv_path is not None: + return Path(uv_path) + return None diff --git a/src/bub/skills/builtin/runtime/agents/bub/engine.py b/src/bub/builtin/engine.py similarity index 92% rename from src/bub/skills/builtin/runtime/agents/bub/engine.py rename to src/bub/builtin/engine.py index dd2b5c9b..e8e580df 100644 --- a/src/bub/skills/builtin/runtime/agents/bub/engine.py +++ b/src/bub/builtin/engine.py @@ -17,7 +17,7 @@ from republic import LLM, TapeEntry, Tool, ToolAutoResult from republic.tape import InMemoryTapeStore, Tape -from bub.skills.loader import discover_skills, load_bub_agent_profile_file, load_skill_body +from bub.skills import discover_skills, load_skill_body DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 @@ -32,6 +32,10 @@ RUNTIME_ENABLED_ON_VALUE = "1" RUNTIME_ENABLED_OFF_VALUE = "0" RUNTIME_ENABLED_AUTO_VALUE = "auto" +DEFAULT_SYSTEM_PROMPT = ( + "You are Bub runtime skill. Use tools for operations such as shell, file edits, " + "skills lookup, and tape operations. Return concise natural language when done." +) @dataclass(frozen=True) @@ -66,8 +70,6 @@ def __init__(self, workspace: Path) -> None: self._event_root.mkdir(parents=True, exist_ok=True) self._settings = _load_runtime_settings() self._llm = _build_llm(self._settings) - self._workspace_prompt = _read_workspace_agents_prompt(self.workspace) - self._agent_profile = _load_agent_profile(Path(__file__).with_name(AGENT_PROFILE_FILE_NAME)) async def run(self, *, session_id: str, prompt: str) -> str | None: stripped = prompt.strip() @@ -130,7 +132,7 @@ async def _run_runtime(self, *, session_id: str, prompt: str) -> str | None: "ts": datetime.now(UTC).isoformat(), }, ) - next_prompt = self._agent_profile.continue_prompt + next_prompt = CONTINUE_PROMPT continue self._append_event( session_id, @@ -319,7 +321,9 @@ def _command_tape_handoff(self, *, args: ParsedArgs, session_id: str, tape: Tape def _command_tape_anchors(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: _ = args - anchors = [entry for entry in self._read_entries(session_id=session_id, tape=tape) if entry.get("kind") == "anchor"] + anchors = [ + entry for entry in self._read_entries(session_id=session_id, tape=tape) if entry.get("kind") == "anchor" + ] if not anchors: return "(no anchors)" return "\n".join(str(entry.get("name") or "-") for entry in anchors) @@ -355,7 +359,9 @@ def _command_quit(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) _ = args, session_id, tape return "exit" - async def _run_shell(self, command: str, *, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS) -> str: + async def _run_shell( + self, command: str, *, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS + ) -> str: completed = await asyncio.create_subprocess_exec( "bash", "-lc", @@ -387,7 +393,9 @@ def fs_edit(path: str, old: str, new: str, replace_all: bool = False) -> str: return self._fs_edit(path, old, new, replace_all=replace_all) def skills_list() -> str: - return self._command_skills_list(args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape) + return self._command_skills_list( + args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape + ) def skills_describe(name: str) -> str: args = ParsedArgs(kwargs={"name": name}, positional=[]) @@ -405,7 +413,9 @@ def tape_handoff(name: str = "handoff", summary: str = "") -> str: return self._command_tape_handoff(args=args, session_id=session_id, tape=tape) def tape_anchors() -> str: - return self._command_tape_anchors(args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape) + return self._command_tape_anchors( + args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape + ) tools = [ ("bash", "Run shell command in workspace with timeout.", bash), @@ -422,30 +432,22 @@ def tape_anchors() -> str: return [Tool.from_callable(func, name=name, description=description) for name, description, func in tools] def _system_prompt(self) -> str: - default_prompt = ( - "You are Bub runtime skill. Use tools for operations such as shell, file edits, " - "skills lookup, and tape operations. Return concise natural language when done." - ) - blocks = [ - self._agent_profile.system_prompt or default_prompt, - ] - if self._workspace_prompt: - blocks.append(self._workspace_prompt) + blocks = [DEFAULT_SYSTEM_PROMPT] + if workspace_prompt := _read_workspace_agents_prompt(self.workspace): + blocks.append(workspace_prompt) return "\n\n".join(blocks) def _read_entries(self, *, session_id: str, tape: Tape | None) -> list[dict[str, object]]: if tape is not None: entries: list[dict[str, object]] = [] for entry in tape.read_entries(): - entries.append( - { - "id": entry.id, - "kind": entry.kind, - "name": entry.payload.get("name") if isinstance(entry.payload, dict) else None, - "payload": entry.payload, - "meta": entry.meta, - } - ) + entries.append({ + "id": entry.id, + "kind": entry.kind, + "name": entry.payload.get("name") if isinstance(entry.payload, dict) else None, + "payload": entry.payload, + "meta": entry.meta, + }) return entries return self._read_events_file(session_id) @@ -585,7 +587,7 @@ def _resolve_runtime_enabled(*, mode: str, model: str, api_key: str | None) -> b def _resolve_runtime_enabled_mode() -> str: - mode = (_first_non_empty([os.getenv(RUNTIME_ENABLED_ENV), RUNTIME_ENABLED_AUTO_VALUE]) or RUNTIME_ENABLED_AUTO_VALUE) + mode = _first_non_empty([os.getenv(RUNTIME_ENABLED_ENV), RUNTIME_ENABLED_AUTO_VALUE]) or RUNTIME_ENABLED_AUTO_VALUE lowered = mode.casefold() if lowered in {RUNTIME_ENABLED_ON_VALUE, RUNTIME_ENABLED_OFF_VALUE, RUNTIME_ENABLED_AUTO_VALUE}: return lowered @@ -614,6 +616,7 @@ def _int_env(name: str, *, default: int) -> int: return default return parsed + def _read_workspace_agents_prompt(workspace: Path) -> str: prompt_path = workspace / AGENTS_FILE_NAME if not prompt_path.is_file(): @@ -624,21 +627,6 @@ def _read_workspace_agents_prompt(workspace: Path) -> str: return "" -def _load_agent_profile(path: Path) -> RuntimeAgentProfile: - raw = load_bub_agent_profile_file(path) - if not raw: - return RuntimeAgentProfile() - - system_prompt = raw.get("system_prompt") - continue_prompt = raw.get("continue_prompt") - - resolved_system_prompt = system_prompt.strip() if isinstance(system_prompt, str) and system_prompt.strip() else None - resolved_continue_prompt = ( - continue_prompt.strip() if isinstance(continue_prompt, str) and continue_prompt.strip() else CONTINUE_PROMPT - ) - return RuntimeAgentProfile(system_prompt=resolved_system_prompt, continue_prompt=resolved_continue_prompt) - - def _session_tape_name(session_id: str) -> str: slug = md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 return f"runtime:{slug}" diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py new file mode 100644 index 00000000..2fe7b574 --- /dev/null +++ b/src/bub/builtin/hook_impl.py @@ -0,0 +1,76 @@ +from pathlib import Path + +import typer + +from bub.builtin.engine import RuntimeEngine +from bub.envelope import content_of, field_of, normalize_envelope +from bub.hookspecs import hookimpl +from bub.types import Envelope, State + + +@hookimpl +def normalize_inbound(message: Envelope) -> Envelope: + envelope = normalize_envelope(message) + envelope["content"] = str(envelope.get("content", "")).strip() + metadata = envelope.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + metadata.setdefault("listener", "runtime") + envelope["metadata"] = metadata + return envelope + + +@hookimpl +def resolve_session(message: Envelope) -> str: + session_id = field_of(message, "session_id") + if session_id is not None and str(session_id).strip(): + return str(session_id) + channel = str(field_of(message, "channel", "default")) + chat_id = str(field_of(message, "chat_id", "default")) + return f"{channel}:{chat_id}" + + +@hookimpl +def build_prompt(message: Envelope, session_id: str, state: State) -> str: + _ = session_id + workspace = field_of(message, "workspace") + if isinstance(workspace, str) and workspace.strip(): + state["_runtime_workspace"] = workspace.strip() + elif "_runtime_workspace" not in state: + state["_runtime_workspace"] = str(Path.cwd()) + return content_of(message) + + +@hookimpl +async def run_model(prompt: str, session_id: str, state: State) -> str | None: + workspace = _workspace_from_state(state) + engine = _engine_for_workspace(workspace) + return await engine.run(session_id=session_id, prompt=prompt) + + +@hookimpl +def register_cli_commands(app: typer.Typer) -> None: + from bub.builtin import cli + + app.command("run")(cli.run) + app.command("hooks")(cli.list_hooks) + app.command("install")(cli.install_plugin) + + +def _workspace_from_state(state: State) -> Path: + raw = state.get("_runtime_workspace") + if isinstance(raw, str) and raw.strip(): + return Path(raw).expanduser().resolve() + return Path.cwd().resolve() + + +def _engine_for_workspace(workspace: Path) -> RuntimeEngine: + cached = _ENGINE_CACHE.get(workspace) + if cached is not None: + return cached + engine = RuntimeEngine(workspace) + _ENGINE_CACHE[workspace] = engine + return engine + + +_ENGINE_CACHE: dict[Path, RuntimeEngine] = {} diff --git a/src/bub/envelope.py b/src/bub/envelope.py index 59122e6e..362d4bc5 100644 --- a/src/bub/envelope.py +++ b/src/bub/envelope.py @@ -37,6 +37,6 @@ def unpack_batch(batch: Any) -> list[Envelope]: if batch is None: return [] - if isinstance(batch, (list, tuple)): + if isinstance(batch, list | tuple): return list(batch) return [batch] diff --git a/src/bub/framework.py b/src/bub/framework.py index 74ecb877..4ebb3970 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -7,39 +7,17 @@ from typing import Any, cast import pluggy -from loguru import logger from bub.bus import BusProtocol, MessageBus from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs -from bub.skills.loader import ( - SkillMetadata, - discover_skills, - has_bub_runtime_adapter, - load_skill_adapter, - skill_bub_adapter_path, -) from bub.types import Envelope, TurnResult -SOURCE_PRIORITY = {"builtin": 0, "global": 1, "project": 2} - - -@dataclass(frozen=True) -class LoadedSkill: - """Runtime adapter registration result for one skill.""" - - skill: SkillMetadata - adapter_name: str - @dataclass(frozen=True) -class SkillStatus: - """Observed runtime state for one discovered skill.""" - - skill: SkillMetadata - state: str - adapter_path: Path | None +class PluginStatus: + is_success: bool detail: str | None = None @@ -51,56 +29,30 @@ def __init__(self, workspace: Path) -> None: self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) self._plugin_manager.add_hookspecs(BubHookSpecs) self._hook_runtime = HookRuntime(self._plugin_manager) - self._loaded_skills: list[LoadedSkill] = [] - self._failed_skills: dict[str, str] = {} - self._skill_statuses: dict[str, SkillStatus] = {} - - @property - def loaded_skills(self) -> list[LoadedSkill]: - return list(self._loaded_skills) - - @property - def failed_skills(self) -> dict[str, str]: - return dict(self._failed_skills) - - @property - def skill_statuses(self) -> list[SkillStatus]: - return sorted(self._skill_statuses.values(), key=lambda item: item.skill.name.casefold()) - - def load_skills(self) -> None: - """Discover and register all hook skills.""" - - self._loaded_skills = [] - self._failed_skills = {} - self._skill_statuses = {} - - discovered = discover_skills(self.workspace) - for skill in discovered: - if has_bub_runtime_adapter(skill): - continue - self._skill_statuses[skill.name.casefold()] = SkillStatus(skill=skill, state="adapter_absent", adapter_path=None) - - skills = sorted((skill for skill in discovered if has_bub_runtime_adapter(skill)), key=self._registration_order_key) - for skill in skills: - adapter_name = f"{skill.source}:{skill.name}" + self._plugin_status: dict[str, PluginStatus] = {} + + def _load_builtin_hooks(self) -> None: + from bub.builtin import hook_impl + + try: + self._plugin_manager.register(hook_impl, name="builtin") + except Exception as exc: + self._plugin_status["builtin"] = PluginStatus(is_success=False, detail=str(exc)) + else: + self._plugin_status["builtin"] = PluginStatus(is_success=True) + + def load_hooks(self) -> None: + import importlib.metadata + + self._load_builtin_hooks() + for entry_point in importlib.metadata.entry_points(group="bub"): try: - adapter = load_skill_adapter(skill) - self._plugin_manager.register(adapter, name=adapter_name) - self._loaded_skills.append(LoadedSkill(skill=skill, adapter_name=adapter_name)) - self._skill_statuses[skill.name.casefold()] = SkillStatus( - skill=skill, - state="hook_active", - adapter_path=skill_bub_adapter_path(skill), - ) - except Exception as exc: # pragma: no cover - exercised via behavior tests - self._failed_skills[skill.name] = str(exc) - self._skill_statuses[skill.name.casefold()] = SkillStatus( - skill=skill, - state="degraded", - adapter_path=skill_bub_adapter_path(skill), - detail=str(exc), - ) - logger.opt(exception=True).warning("skill.load_failed skill={} source={}", skill.name, skill.source) + plugin = entry_point.load() + self._plugin_manager.register(plugin, name=entry_point.name) + except Exception as exc: + self._plugin_status[entry_point.name] = PluginStatus(is_success=False, detail=str(exc)) + else: + self._plugin_status[entry_point.name] = PluginStatus(is_success=True) def create_bus(self) -> BusProtocol: """Create bus instance from hooks; fallback to default in-memory bus.""" @@ -123,16 +75,20 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: message = normalized if normalized is not None else inbound if isinstance(message, dict): message.setdefault("workspace", str(self.workspace)) - session_id = await self._hook_runtime.call_first("resolve_session", message=message) or self._default_session_id( - message - ) + session_id = await self._hook_runtime.call_first( + "resolve_session", message=message + ) or self._default_session_id(message) state = await self._hook_runtime.call_first("load_state", session_id=session_id) or {} if not isinstance(state, dict): state = {} - prompt = await self._hook_runtime.call_first("build_prompt", message=message, session_id=session_id, state=state) + prompt = await self._hook_runtime.call_first( + "build_prompt", message=message, session_id=session_id, state=state + ) if not prompt: prompt = content_of(message) - model_output = await self._hook_runtime.call_first("run_model", prompt=prompt, session_id=session_id, state=state) + model_output = await self._hook_runtime.call_first( + "run_model", prompt=prompt, session_id=session_id, state=state + ) if model_output is None: await self._hook_runtime.notify_error( stage="run_model:fallback", @@ -158,7 +114,9 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: await self._hook_runtime.notify_error(stage="turn", error=exc, message=inbound) raise - async def handle_bus_once(self, bus: BusProtocol | None = None, *, timeout_seconds: float | None = None) -> TurnResult | None: + async def handle_bus_once( + self, bus: BusProtocol | None = None, *, timeout_seconds: float | None = None + ) -> TurnResult | None: """Consume one inbound message from bus and publish generated outbounds.""" active_bus = bus or self.create_bus() @@ -216,10 +174,6 @@ async def _collect_outbounds( fallback["chat_id"] = chat_id return [fallback] - @staticmethod - def _registration_order_key(skill: SkillMetadata) -> tuple[int, str]: - return (SOURCE_PRIORITY.get(skill.source, 3), skill.name.casefold()) - @staticmethod def _is_bus_like(candidate: Any) -> bool: if candidate is None: diff --git a/src/bub/hook_runtime.py b/src/bub/hook_runtime.py index 24afd2f7..432ec7b5 100644 --- a/src/bub/hook_runtime.py +++ b/src/bub/hook_runtime.py @@ -22,7 +22,9 @@ async def call_first(self, hook_name: str, **kwargs: Any) -> Any: for impl in self._iter_hookimpls(hook_name): call_kwargs = self._kwargs_for_impl(impl, kwargs) - value = await self._invoke_impl_async(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + value = await self._invoke_impl_async( + hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs + ) if value is _SKIP_VALUE: continue if value is not None: @@ -35,7 +37,9 @@ async def call_many(self, hook_name: str, **kwargs: Any) -> list[Any]: results: list[Any] = [] for impl in self._iter_hookimpls(hook_name): call_kwargs = self._kwargs_for_impl(impl, kwargs) - value = await self._invoke_impl_async(hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs) + value = await self._invoke_impl_async( + hook_name=hook_name, impl=impl, call_kwargs=call_kwargs, kwargs=kwargs + ) if value is _SKIP_VALUE: continue results.append(value) diff --git a/src/bub/skills/loader.py b/src/bub/skills.py similarity index 64% rename from src/bub/skills/loader.py rename to src/bub/skills.py index 2f6f940d..b0b94879 100644 --- a/src/bub/skills/loader.py +++ b/src/bub/skills.py @@ -2,23 +2,15 @@ from __future__ import annotations -import hashlib import re -import sys from dataclasses import dataclass, field -from importlib import util as importlib_util from pathlib import Path -from types import ModuleType from typing import Any import yaml PROJECT_SKILLS_DIR = ".agent/skills" SKILL_FILE_NAME = "SKILL.md" -AGENTS_DIR_NAME = "agents" -BUB_AGENT_DIR_NAME = "bub" -BUB_ADAPTER_FILE_NAME = "adapter.py" -BUB_AGENT_PROFILE_FILE_NAME = "agent.yaml" SKILL_SOURCES = ("project", "global", "builtin") SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") ALLOWED_FRONTMATTER_FIELDS = frozenset({"name", "description", "license", "compatibility", "metadata", "allowed-tools"}) @@ -35,12 +27,6 @@ class SkillMetadata: metadata: dict[str, Any] = field(default_factory=dict) -def discover_adapter_skills(workspace_path: Path) -> list[SkillMetadata]: - """Discover skills that provide a Bub runtime adapter module.""" - - return [skill for skill in discover_skills(workspace_path) if has_bub_runtime_adapter(skill)] - - def discover_skills(workspace_path: Path) -> list[SkillMetadata]: """Discover skills from project, global, and builtin roots with override precedence.""" @@ -75,45 +61,6 @@ def load_skill_body(name: str, workspace_path: Path) -> str | None: return None -def load_skill_adapter(skill: SkillMetadata) -> object: - """Load Bub adapter object from `/agents/bub/adapter.py`.""" - - adapter_file = skill_bub_adapter_path(skill) - if not adapter_file.is_file(): - raise FileNotFoundError(f"{skill.name}: missing {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_ADAPTER_FILE_NAME}") - - module_name = _module_name_for_skill(skill=skill, adapter_file=adapter_file) - module = _load_module_from_file(module_name=module_name, adapter_file=adapter_file) - if not hasattr(module, "adapter"): - raise AttributeError( - f"{skill.name}: {AGENTS_DIR_NAME}/{BUB_AGENT_DIR_NAME}/{BUB_ADAPTER_FILE_NAME} must export attribute `adapter`" - ) - adapter = module.adapter - if adapter is None: - raise TypeError(f"{skill.name}: exported `adapter` must not be None") - return adapter - - -def load_bub_agent_profile(skill: SkillMetadata) -> dict[str, object]: - """Load Bub adapter profile from `/agents/bub/agent.yaml`.""" - - return load_bub_agent_profile_file(skill_bub_agent_profile_path(skill)) - - -def load_bub_agent_profile_file(path: Path) -> dict[str, object]: - """Load one Bub adapter profile file as a normalized mapping.""" - - if not path.is_file(): - return {} - try: - payload = yaml.safe_load(path.read_text(encoding="utf-8")) - except (OSError, yaml.YAMLError): - return {} - if not isinstance(payload, dict): - return {} - return {str(key): value for key, value in payload.items() if isinstance(key, str)} - - def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: skill_file = skill_dir / SKILL_FILE_NAME if not skill_file.is_file(): @@ -224,41 +171,7 @@ def _is_valid_allowed_tools(allowed_tools: object) -> bool: def _builtin_skills_root() -> Path: - return Path(__file__).resolve().parent / "builtin" - - -def skill_bub_adapter_path(skill: SkillMetadata) -> Path: - return skill.location.parent / AGENTS_DIR_NAME / BUB_AGENT_DIR_NAME / BUB_ADAPTER_FILE_NAME - - -def skill_bub_agent_profile_path(skill: SkillMetadata) -> Path: - return skill.location.parent / AGENTS_DIR_NAME / BUB_AGENT_DIR_NAME / BUB_AGENT_PROFILE_FILE_NAME - - -def has_bub_runtime_adapter(skill: SkillMetadata) -> bool: - return skill_bub_adapter_path(skill).is_file() - - -def _module_name_for_skill(*, skill: SkillMetadata, adapter_file: Path) -> str: - digest = hashlib.sha256(str(adapter_file).encode("utf-8")).hexdigest()[:12] - normalized_name = "".join(ch if ch.isalnum() else "_" for ch in f"{skill.source}_{skill.name}".lower()) - return f"bub_skill_{normalized_name}_{digest}" - - -def _load_module_from_file(*, module_name: str, adapter_file: Path) -> ModuleType: - spec = importlib_util.spec_from_file_location(module_name, adapter_file) - if spec is None or spec.loader is None: - raise ImportError(f"failed to build module spec for {adapter_file}") - - module = importlib_util.module_from_spec(spec) - sys.modules.pop(module_name, None) - sys.modules[module_name] = module - try: - spec.loader.exec_module(module) - except Exception: - sys.modules.pop(module_name, None) - raise - return module + return Path(__file__).resolve().parent.parent / "bub_skills" def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: diff --git a/src/bub/skills/__init__.py b/src/bub/skills/__init__.py deleted file mode 100644 index 734cf21b..00000000 --- a/src/bub/skills/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Skill discovery and loading exports.""" - -from bub.skills.loader import ( - SkillMetadata, - discover_adapter_skills, - discover_skills, - has_bub_runtime_adapter, - load_bub_agent_profile, - load_bub_agent_profile_file, - load_skill_adapter, - load_skill_body, - skill_bub_adapter_path, - skill_bub_agent_profile_path, -) - -__all__ = [ - "SkillMetadata", - "discover_adapter_skills", - "discover_skills", - "has_bub_runtime_adapter", - "load_bub_agent_profile", - "load_bub_agent_profile_file", - "load_skill_adapter", - "load_skill_body", - "skill_bub_adapter_path", - "skill_bub_agent_profile_path", -] diff --git a/src/bub/skills/builtin/cli/SKILL.md b/src/bub/skills/builtin/cli/SKILL.md deleted file mode 100644 index 361317b3..00000000 --- a/src/bub/skills/builtin/cli/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: cli -description: Command-surface design and diagnostics skill. Use when defining CLI command contracts, arguments, output formats, observability commands, or reviewing and extending existing CLI behavior. ---- - -# CLI Core Skill - -## Steps - -1. Define a clear command contract: inputs, defaults, output shape, and failure semantics. -2. Prioritize diagnostics commands first (status, config, hooks, failure summaries), then business commands. -3. Use `scripts/command_index.py` to generate deterministic command listings. -4. Add minimal tests for each new command: one success path and at least one failure path. - -## Examples - -Input example: - -```text -Please add a new `bub doctor` command that validates runtime dependencies. -``` - -Expected output characteristics: - -```text -- command contract is explicit -- diagnostics output is machine-parsable -- failures provide actionable hints -``` - -## Edge Cases - -- Keep output both human-readable and script-parseable; avoid mixed ambiguous formats. -- If command names conflict, identify the conflict source before proposing alternatives. -- When default values affect compatibility, document the impact explicitly. - -## Bub Adapter - -- Bub adapter entrypoint: `agents/bub/adapter.py`. -- Bub adapter profile: `agents/bub/agent.yaml`. - -## References - -- See `references/usage.md` for detailed constraints and templates. diff --git a/src/bub/skills/builtin/cli/agents/bub/adapter.py b/src/bub/skills/builtin/cli/agents/bub/adapter.py deleted file mode 100644 index 31a793e0..00000000 --- a/src/bub/skills/builtin/cli/agents/bub/adapter.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Builtin CLI command adapter.""" - -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any - -import typer - -from bub.envelope import field_of -from bub.framework import BubFramework -from bub.hookspecs import hookimpl -from bub.skills.loader import skill_bub_agent_profile_path - - -class CliCoreAdapter: - @hookimpl - def register_cli_commands(self, app: typer.Typer) -> None: - self._register_run(app) - self._register_skills(app) - self._register_hooks(app) - - @staticmethod - def _register_run(app: typer.Typer) -> None: - @app.command("run") - def run( - message: str = typer.Argument(..., help="Inbound message content"), - workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace root"), # noqa: B008 - channel: str = typer.Option("stdout", "--channel", help="Message channel"), - chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), - sender_id: str = typer.Option("human", "--sender-id", help="Sender id"), - session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), - ) -> None: - """Run one inbound message through the framework pipeline.""" - - framework = _load_framework(workspace) - inbound: dict[str, Any] = { - "channel": channel, - "chat_id": chat_id, - "sender_id": sender_id, - "content": message, - } - if session_id is not None and session_id.strip(): - inbound["session_id"] = session_id.strip() - - result = asyncio.run(framework.process_inbound(inbound)) - for outbound in result.outbounds: - rendered = str(field_of(outbound, "content", "")) - target_channel = str(field_of(outbound, "channel", "stdout")) - target_chat = str(field_of(outbound, "chat_id", "local")) - typer.echo(f"[{target_channel}:{target_chat}] {rendered}") - - @staticmethod - def _register_skills(app: typer.Typer) -> None: - @app.command("skills") - def list_skills( - workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 - ) -> None: - """Show loaded and failed skills.""" - - framework = _load_framework(workspace) - for status in framework.skill_statuses: - rendered = f"{status.skill.name} ({status.skill.source}) state={status.state}" - if status.adapter_path is not None: - rendered += f" adapter={status.adapter_path}" - profile_path = skill_bub_agent_profile_path(status.skill) - if profile_path.is_file(): - rendered += f" profile={profile_path}" - else: - rendered += f" profile=missing:{profile_path}" - if status.detail: - rendered += f" detail={status.detail}" - typer.echo(rendered) - - @staticmethod - def _register_hooks(app: typer.Typer) -> None: - @app.command("hooks") - def list_hooks( - workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 - ) -> None: - """Show hook implementation mapping.""" - - framework = _load_framework(workspace) - report = framework.hook_report() - if not report: - typer.echo("(no hook implementations)") - return - for hook_name, adapter_names in report.items(): - typer.echo(f"{hook_name}: {', '.join(adapter_names)}") - - -adapter = CliCoreAdapter() - - -def _load_framework(workspace: Path | None) -> BubFramework: - resolved_workspace = (workspace or Path.cwd()).resolve() - framework = BubFramework(resolved_workspace) - framework.load_skills() - return framework diff --git a/src/bub/skills/builtin/cli/agents/bub/agent.yaml b/src/bub/skills/builtin/cli/agents/bub/agent.yaml deleted file mode 100644 index d5f40989..00000000 --- a/src/bub/skills/builtin/cli/agents/bub/agent.yaml +++ /dev/null @@ -1,4 +0,0 @@ -version: 1 -system_prompt: >- - You are Bub CLI adapter. Prioritize explicit command contracts, clear help - text, deterministic output, and diagnostics-first command design. diff --git a/src/bub/skills/builtin/cli/references/usage.md b/src/bub/skills/builtin/cli/references/usage.md deleted file mode 100644 index 8fab829e..00000000 --- a/src/bub/skills/builtin/cli/references/usage.md +++ /dev/null @@ -1,29 +0,0 @@ -# CLI Usage Reference - -## Contract Template - -Define every command with: - -1. Input parameters and defaults -2. Success output schema -3. Error output schema -4. Exit behavior - -## Output Guidance - -- Prefer line-oriented output for terminal users. -- If output is consumed by scripts, keep fields stable. -- Avoid mixing diagnostic text with machine-parsable sections. - -## Suggested Checks - -1. Command registration is deterministic. -2. Help text is explicit for required/optional flags. -3. Failure paths include next-step hints. - -## Minimal Test Matrix - -1. Successful invocation with default options -2. Successful invocation with explicit options -3. Invalid arguments -4. Runtime dependency unavailable diff --git a/src/bub/skills/builtin/cli/scripts/command_index.py b/src/bub/skills/builtin/cli/scripts/command_index.py deleted file mode 100644 index 8dc37f1d..00000000 --- a/src/bub/skills/builtin/cli/scripts/command_index.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = [] -# /// -"""Render a deterministic command index from a JSON command list.""" - -from __future__ import annotations - -import json -import sys - - -def main() -> int: - raw = sys.stdin.read().strip() - if not raw: - sys.stderr.write("expected JSON list on stdin\n") - return 1 - - payload = json.loads(raw) - if not isinstance(payload, list): - sys.stderr.write("payload must be a JSON list\n") - return 1 - - commands: list[tuple[str, str]] = [] - for item in payload: - if not isinstance(item, dict): - continue - name = str(item.get("name", "")).strip() - desc = str(item.get("description", "")).strip() - if not name: - continue - commands.append((name, desc)) - - for name, desc in sorted(commands, key=lambda item: item[0]): - line = f"{name}: {desc}" if desc else name - sys.stdout.write(line + "\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/bub/skills/builtin/runtime/SKILL.md b/src/bub/skills/builtin/runtime/SKILL.md deleted file mode 100644 index 01e035ca..00000000 --- a/src/bub/skills/builtin/runtime/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: runtime -description: Stateful runtime orchestration skill. Use when implementing or debugging inbound normalization, session identity, tool routing, state transitions, and failure isolation across multi-turn runtime flows. ---- - -# Runtime Skill - -## Steps - -1. Normalize inbound payloads so `content`, `session_id`, and `metadata` are always usable. -2. Resolve session identity deterministically: explicit `session_id` first, then `channel:chat_id`. -3. Set explicit loop boundaries for model and tools: max steps, timeout, and fallback rules. -4. Preserve actionable diagnostics on failures; do not swallow errors. -5. Use `scripts/prepare_context.py` to build stable context payloads. - -## Bub Adapter - -- Bub adapter entrypoint: `agents/bub/adapter.py`. -- Bub adapter profile: `agents/bub/agent.yaml`. - -## Examples - -Input example: - -```text -Normalize this inbound message and preserve stable session identity. -``` - -Expected output characteristics: - -```text -- deterministic session id -- trimmed content -- metadata includes runtime listener mark -``` - -## Edge Cases - -- If `session_id` is absent but `channel/chat_id` exists, fallback must remain deterministic. -- Empty inbound content should still return a structured result, not an exception. -- Tool-call failures and model failures should be logged distinctly to avoid root-cause confusion. - -## References - -- See `references/usage.md` for detailed flow and failure-handling guidance. diff --git a/src/bub/skills/builtin/runtime/agents/bub/adapter.py b/src/bub/skills/builtin/runtime/agents/bub/adapter.py deleted file mode 100644 index 2c60e3e7..00000000 --- a/src/bub/skills/builtin/runtime/agents/bub/adapter.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Builtin runtime adapter.""" - -from __future__ import annotations - -from pathlib import Path - -from bub.envelope import content_of, field_of, normalize_envelope -from bub.hookspecs import hookimpl -from bub.skills.builtin.runtime.agents.bub.engine import RuntimeEngine -from bub.types import Envelope, State - - -class RuntimeAdapter: - @hookimpl - def normalize_inbound(self, message: Envelope) -> Envelope: - envelope = normalize_envelope(message) - envelope["content"] = str(envelope.get("content", "")).strip() - metadata = envelope.get("metadata") - if not isinstance(metadata, dict): - metadata = {} - metadata.setdefault("listener", "runtime") - envelope["metadata"] = metadata - return envelope - - @hookimpl - def resolve_session(self, message: Envelope) -> str: - session_id = field_of(message, "session_id") - if session_id is not None and str(session_id).strip(): - return str(session_id) - channel = str(field_of(message, "channel", "default")) - chat_id = str(field_of(message, "chat_id", "default")) - return f"{channel}:{chat_id}" - - @hookimpl - def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: - _ = session_id - workspace = field_of(message, "workspace") - if isinstance(workspace, str) and workspace.strip(): - state["_runtime_workspace"] = workspace.strip() - elif "_runtime_workspace" not in state: - state["_runtime_workspace"] = str(Path.cwd()) - return content_of(message) - - @hookimpl - async def run_model(self, prompt: str, session_id: str, state: State) -> str | None: - workspace = _workspace_from_state(state) - engine = _engine_for_workspace(workspace) - return await engine.run(session_id=session_id, prompt=prompt) - - -def _workspace_from_state(state: State) -> Path: - raw = state.get("_runtime_workspace") - if isinstance(raw, str) and raw.strip(): - return Path(raw).expanduser().resolve() - return Path.cwd().resolve() - - -def _engine_for_workspace(workspace: Path) -> RuntimeEngine: - cached = _ENGINE_CACHE.get(workspace) - if cached is not None: - return cached - engine = RuntimeEngine(workspace) - _ENGINE_CACHE[workspace] = engine - return engine - - -_ENGINE_CACHE: dict[Path, RuntimeEngine] = {} -adapter = RuntimeAdapter() diff --git a/src/bub/skills/builtin/runtime/agents/bub/agent.yaml b/src/bub/skills/builtin/runtime/agents/bub/agent.yaml deleted file mode 100644 index 87f05027..00000000 --- a/src/bub/skills/builtin/runtime/agents/bub/agent.yaml +++ /dev/null @@ -1,6 +0,0 @@ -version: 1 -system_prompt: >- - You are Bub runtime adapter. Use tools for shell, file operations, skill - lookup, and tape operations. Keep responses concise, deterministic, and - operationally actionable. -continue_prompt: Continue the task. diff --git a/src/bub/skills/builtin/runtime/references/usage.md b/src/bub/skills/builtin/runtime/references/usage.md deleted file mode 100644 index c4ad2eda..00000000 --- a/src/bub/skills/builtin/runtime/references/usage.md +++ /dev/null @@ -1,26 +0,0 @@ -# Runtime Usage Reference - -## Normalization Rules - -1. Convert inbound content to trimmed string. -2. Ensure metadata is a dictionary. -3. Add a runtime listener marker when missing. - -## Session Rules - -1. Use explicit `session_id` when provided. -2. Fallback to `channel:chat_id` when absent. -3. Keep session resolution deterministic across turns. - -## Failure Isolation - -1. Isolate adapter failures from framework core flow. -2. Emit actionable error details for diagnostics. -3. Keep fallback response behavior deterministic. - -## Minimal Test Matrix - -1. Message normalization with extra whitespace -2. Session fallback without explicit `session_id` -3. Runtime adapter failure does not break core processing -4. Outbound fallback when no renderer returns output diff --git a/src/bub/skills/builtin/runtime/scripts/prepare_context.py b/src/bub/skills/builtin/runtime/scripts/prepare_context.py deleted file mode 100644 index 8d9415f9..00000000 --- a/src/bub/skills/builtin/runtime/scripts/prepare_context.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env -S uv run --script -# /// script -# requires-python = ">=3.12" -# dependencies = [] -# /// -"""Prepare a normalized runtime context payload from JSON input.""" - -from __future__ import annotations - -import json -import sys -from typing import Any - - -def _session_id(payload: dict[str, Any]) -> str: - raw = payload.get("session_id") - if isinstance(raw, str) and raw.strip(): - return raw.strip() - channel = str(payload.get("channel", "default")) - chat_id = str(payload.get("chat_id", "default")) - return f"{channel}:{chat_id}" - - -def main() -> int: - raw = sys.stdin.read().strip() - if not raw: - sys.stderr.write("expected JSON payload on stdin\n") - return 1 - - payload = json.loads(raw) - if not isinstance(payload, dict): - sys.stderr.write("payload must be a JSON object\n") - return 1 - - metadata = payload.get("metadata") - normalized_metadata: dict[str, Any] = metadata if isinstance(metadata, dict) else {} - normalized = { - "session_id": _session_id(payload), - "content": str(payload.get("content", "")).strip(), - "metadata": normalized_metadata, - } - normalized_metadata.setdefault("listener", "runtime") - - sys.stdout.write(json.dumps(normalized, ensure_ascii=False) + "\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/bub/types.py b/src/bub/types.py index cd57eb9a..d4755378 100644 --- a/src/bub/types.py +++ b/src/bub/types.py @@ -5,8 +5,8 @@ from dataclasses import dataclass, field from typing import Any -Envelope = Any -State = dict[str, Any] +type Envelope = Any +type State = dict[str, Any] @dataclass(frozen=True) diff --git a/src/bub_skills/README.md b/src/bub_skills/README.md new file mode 100644 index 00000000..2ae03540 --- /dev/null +++ b/src/bub_skills/README.md @@ -0,0 +1,3 @@ +# Bub Skills Directory + +This directory contains built-in skills for the Bub framework. These skills are designed to provide essential functionalities and can be easily integrated into your Bub workspace. Each skill is implemented as a plugin, allowing for modularity and ease of maintenance. diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 4d76d88b..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -import pytest - - -@pytest.fixture(autouse=True) -def _disable_runtime_model_driver(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("BUB_RUNTIME_ENABLED", "0") diff --git a/tests/fixtures_plugins/__init__.py b/tests/fixtures_plugins/__init__.py deleted file mode 100644 index 8dce71e0..00000000 --- a/tests/fixtures_plugins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test-only skill adapters.""" diff --git a/tests/fixtures_plugins/stateful_hooks.py b/tests/fixtures_plugins/stateful_hooks.py deleted file mode 100644 index 4d350d3f..00000000 --- a/tests/fixtures_plugins/stateful_hooks.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from bub.envelope import content_of -from bub.hookspecs import hookimpl - - -class StatefulHooksSkill: - def __init__(self) -> None: - self._states: dict[str, dict[str, object]] = {} - - @hookimpl - def load_state(self, session_id: str) -> dict[str, object]: - return dict(self._states.get(session_id, {"turn": 0})) - - @hookimpl - def build_prompt(self, message: object, session_id: str, state: dict[str, object]) -> str: - _ = session_id - _ = state - return content_of(message) - - @hookimpl - def run_model(self, prompt: str, session_id: str, state: dict[str, object]) -> str: - turn = int(state.get("turn", 0)) + 1 - return f"[{session_id}] turn={turn} {prompt}" - - @hookimpl - def save_state( - self, - session_id: str, - state: dict[str, object], - message: object, - model_output: str, - ) -> None: - _ = message, model_output - state["turn"] = int(state.get("turn", 0)) + 1 - self._states[session_id] = dict(state) - - -adapter = StatefulHooksSkill() diff --git a/tests/test_bus.py b/tests/test_bus.py deleted file mode 100644 index 62246caf..00000000 --- a/tests/test_bus.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from bub.bus import MessageBus -from bub.framework import BubFramework - - -@pytest.mark.asyncio -async def test_handle_bus_once_publishes_outbound(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - bus = framework.create_bus() - assert isinstance(bus, MessageBus) - - await bus.publish_inbound({"channel": "stdout", "chat_id": "bus", "sender_id": "u1", "content": "from bus"}) - - result = await framework.handle_bus_once(bus, timeout_seconds=0.1) - outbound = await bus.next_outbound(timeout_seconds=0.1) - - assert result is not None - assert outbound is not None - assert "from bus" in str(outbound["content"]) diff --git a/tests/test_fault_tolerance.py b/tests/test_fault_tolerance.py deleted file mode 100644 index 432d9e70..00000000 --- a/tests/test_fault_tolerance.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from bub.framework import BubFramework - - -def _write_broken_skill(workspace: Path) -> None: - broken = workspace / ".agent" / "skills" / "broken" - adapter_file = broken / "agents" / "bub" / "adapter.py" - adapter_file.parent.mkdir(parents=True) - adapter_file.write_text("import missing_module\n", encoding="utf-8") - (broken / "SKILL.md").write_text( - "\n".join( - [ - "---", - "name: broken", - "description: broken skill", - "---", - ] - ), - encoding="utf-8", - ) - - -@pytest.mark.asyncio -async def test_broken_skill_does_not_break_framework(tmp_path: Path) -> None: - _write_broken_skill(tmp_path) - - framework = BubFramework(tmp_path) - framework.load_skills() - - assert "broken" in framework.failed_skills - result = await framework.process_inbound({"channel": "stdout", "chat_id": "c1", "sender_id": "u1", "content": "still works"}) - assert "still works" in result.model_output - - -def _write_runtime_error_skill(workspace: Path) -> None: - skill_dir = workspace / ".agent" / "skills" / "broken-output" - adapter_file = skill_dir / "agents" / "bub" / "adapter.py" - adapter_file.parent.mkdir(parents=True) - adapter_file.write_text( - "\n".join( - [ - "from bub.hookspecs import hookimpl", - "", - "class BrokenOutputSkill:", - " @hookimpl", - " def render_outbound(self, message, session_id, state, model_output):", - " raise RuntimeError('output broke on purpose')", - "", - "adapter = BrokenOutputSkill()", - ] - ), - encoding="utf-8", - ) - - (skill_dir / "SKILL.md").write_text( - "\n".join( - [ - "---", - "name: broken-output", - "description: runtime broken output skill", - "---", - ] - ), - encoding="utf-8", - ) - - -@pytest.mark.asyncio -async def test_runtime_broken_skill_isolated_from_main_flow(tmp_path: Path) -> None: - _write_runtime_error_skill(tmp_path) - - framework = BubFramework(tmp_path) - framework.load_skills() - - result = await framework.process_inbound({"channel": "stdout", "chat_id": "c2", "sender_id": "u1", "content": "safe"}) - - assert "broken-output" not in framework.failed_skills - assert result.outbounds - assert "safe" in result.model_output diff --git a/tests/test_framework_flow.py b/tests/test_framework_flow.py deleted file mode 100644 index 3adf3cf5..00000000 --- a/tests/test_framework_flow.py +++ /dev/null @@ -1,163 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest -import typer -from typer.testing import CliRunner - -from bub.cli import app -from bub.framework import BubFramework - - -def _write_stateful_test_skill(workspace: Path) -> None: - skill_dir = workspace / ".agent" / "skills" / "stateful-hooks" - adapter_file = skill_dir / "agents" / "bub" / "adapter.py" - adapter_file.parent.mkdir(parents=True) - adapter_file.write_text("from fixtures_plugins.stateful_hooks import adapter\n", encoding="utf-8") - (skill_dir / "SKILL.md").write_text( - "\n".join( - [ - "---", - "name: stateful-hooks", - "description: test-only stateful hooks skill", - "---", - ] - ), - encoding="utf-8", - ) - - -@pytest.mark.asyncio -async def test_framework_processes_message_with_builtin_skills(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - - result = await framework.process_inbound( - {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": "hello framework"} - ) - - assert result.session_id == "stdout:local" - assert "hello framework" in result.prompt - assert result.model_output == "hello framework" - assert result.outbounds - assert result.outbounds[0]["content"] == result.model_output - - -@pytest.mark.asyncio -async def test_framework_increments_state_across_turns(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - _write_stateful_test_skill(tmp_path) - monkeypatch.syspath_prepend(str(Path(__file__).parent)) - - framework = BubFramework(tmp_path) - framework.load_skills() - - first = await framework.process_inbound({"channel": "stdout", "chat_id": "same", "sender_id": "u1", "content": "first"}) - second = await framework.process_inbound( - {"channel": "stdout", "chat_id": "same", "sender_id": "u1", "content": "second"} - ) - - assert "turn=1" in first.model_output - assert "turn=2" in second.model_output - - -@pytest.mark.asyncio -async def test_framework_accepts_user_defined_message_object(tmp_path: Path) -> None: - class CustomMessage: - def __init__(self, *, channel: str, chat_id: str, sender_id: str, content: str) -> None: - self.channel = channel - self.chat_id = chat_id - self.sender_id = sender_id - self.content = content - - framework = BubFramework(tmp_path) - framework.load_skills() - - result = await framework.process_inbound( - CustomMessage(channel="stdout", chat_id="obj", sender_id="u1", content="object message") - ) - - assert result.session_id == "stdout:obj" - assert "object message" in result.model_output - - -def test_framework_registers_cli_commands_from_skills(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - app = typer.Typer() - - framework.register_cli_commands(app) - - command_names = {command.name for command in app.registered_commands} - assert {"run", "skills", "hooks"}.issubset(command_names) - - -def test_framework_reports_skill_statuses(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - - states = {item.skill.name: item.state for item in framework.skill_statuses} - assert states["cli"] == "hook_active" - assert states["runtime"] == "hook_active" - - -def test_skills_command_shows_profile_column(tmp_path: Path) -> None: - runner = CliRunner() - result = runner.invoke(app, ["skills", "--workspace", str(tmp_path)]) - - assert result.exit_code == 0 - assert "profile=" in result.stdout - - -@pytest.mark.asyncio -async def test_framework_routes_internal_command_with_runtime(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - - result = await framework.process_inbound( - {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": ",help"} - ) - - assert "Commands use ',' at line start." in result.model_output - - -@pytest.mark.asyncio -async def test_framework_routes_shell_command_with_runtime(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - - result = await framework.process_inbound( - {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": ",echo runtime-ok"} - ) - - assert "runtime-ok" in result.model_output - - -@pytest.mark.asyncio -async def test_runtime_normalizes_inbound_content(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - - result = await framework.process_inbound( - {"channel": "stdout", "chat_id": "local", "sender_id": "u1", "content": " padded message "} - ) - - assert result.prompt == "padded message" - - -@pytest.mark.asyncio -async def test_runtime_resolve_session_ignores_blank_session_id(tmp_path: Path) -> None: - framework = BubFramework(tmp_path) - framework.load_skills() - - result = await framework.process_inbound( - { - "channel": "stdout", - "chat_id": "trim", - "sender_id": "u1", - "session_id": " ", - "content": "hello", - } - ) - - assert result.session_id == "stdout:trim" diff --git a/tests/test_skill_loader.py b/tests/test_skill_loader.py deleted file mode 100644 index a8fe7803..00000000 --- a/tests/test_skill_loader.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from bub.skills.loader import ( - SkillMetadata, - discover_adapter_skills, - discover_skills, - load_bub_agent_profile, - load_bub_agent_profile_file, - skill_bub_agent_profile_path, -) - - -def _write_skill(root: Path, *, name: str, with_adapter: bool) -> None: - root.mkdir(parents=True) - (root / "SKILL.md").write_text( - "\n".join( - [ - "---", - f"name: {name}", - f"description: {name} skill", - "---", - ] - ), - encoding="utf-8", - ) - if with_adapter: - adapter_file = root / "agents" / "bub" / "adapter.py" - adapter_file.parent.mkdir(parents=True) - adapter_file.write_text( - "\n".join( - [ - "from bub.hookspecs import hookimpl", - "", - "class DemoSkill:", - " @hookimpl", - " def resolve_session(self, message):", - " return None", - "", - "adapter = DemoSkill()", - ] - ), - encoding="utf-8", - ) - - -def _write_skill_with_frontmatter(root: Path, *, frontmatter_lines: list[str], with_adapter: bool) -> None: - root.mkdir(parents=True) - (root / "SKILL.md").write_text("\n".join(frontmatter_lines), encoding="utf-8") - if with_adapter: - adapter_file = root / "agents" / "bub" / "adapter.py" - adapter_file.parent.mkdir(parents=True) - adapter_file.write_text( - "\n".join( - [ - "from bub.hookspecs import hookimpl", - "", - "class DemoSkill:", - " @hookimpl", - " def resolve_session(self, message):", - " return None", - "", - "adapter = DemoSkill()", - ] - ), - encoding="utf-8", - ) - - -def test_discover_adapter_skills_respects_project_over_global(monkeypatch, tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - fake_home = tmp_path / "home" - - _write_skill( - workspace / ".agent" / "skills" / "demo", - name="demo", - with_adapter=True, - ) - _write_skill( - fake_home / ".agent" / "skills" / "demo", - name="demo", - with_adapter=True, - ) - - monkeypatch.setenv("HOME", str(fake_home)) - - skills = discover_adapter_skills(workspace) - demo = next(skill for skill in skills if skill.name == "demo") - assert demo.source == "project" - assert demo.location.parent == workspace / ".agent" / "skills" / "demo" - - -def test_discover_adapter_skills_filters_non_adapter_skills(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - - _write_skill( - workspace / ".agent" / "skills" / "no-adapter", - name="no-adapter", - with_adapter=False, - ) - _write_skill( - workspace / ".agent" / "skills" / "valid", - name="valid", - with_adapter=True, - ) - - names = [skill.name for skill in discover_adapter_skills(workspace)] - assert "valid" in names - assert "no-adapter" not in names - - -def test_discover_skills_rejects_name_mismatch_with_directory(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - _write_skill_with_frontmatter( - workspace / ".agent" / "skills" / "actual-dir", - frontmatter_lines=[ - "---", - "name: other-name", - "description: mismatch", - "---", - ], - with_adapter=True, - ) - - names = {skill.name for skill in discover_skills(workspace)} - assert "other-name" not in names - assert "actual-dir" not in names - - -def test_discover_skills_rejects_invalid_name_pattern(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - _write_skill_with_frontmatter( - workspace / ".agent" / "skills" / "bad-name", - frontmatter_lines=[ - "---", - "name: bad--name", - "description: invalid pattern", - "---", - ], - with_adapter=True, - ) - - names = {skill.name for skill in discover_skills(workspace)} - assert "bad--name" not in names - assert "bad-name" not in names - - -def test_discover_skills_rejects_missing_required_description(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - _write_skill_with_frontmatter( - workspace / ".agent" / "skills" / "no-description", - frontmatter_lines=[ - "---", - "name: no-description", - "---", - ], - with_adapter=True, - ) - - names = {skill.name for skill in discover_skills(workspace)} - assert "no-description" not in names - - -def test_discover_skills_rejects_invalid_metadata_type(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - _write_skill_with_frontmatter( - workspace / ".agent" / "skills" / "bad-metadata", - frontmatter_lines=[ - "---", - "name: bad-metadata", - "description: metadata must be string map", - "metadata:", - " author: test", - " version: 1", - "---", - ], - with_adapter=True, - ) - - names = {skill.name for skill in discover_skills(workspace)} - assert "bad-metadata" not in names - - -def test_discover_skills_accepts_spec_optional_fields(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - _write_skill_with_frontmatter( - workspace / ".agent" / "skills" / "spec-compliant", - frontmatter_lines=[ - "---", - "name: spec-compliant", - "description: Valid skill metadata with optional fields included.", - "license: Apache-2.0", - "compatibility: Requires internet access and git.", - "allowed-tools: Bash(git:*) Read", - "metadata:", - " author: test", - " version: '1.0'", - "---", - ], - with_adapter=True, - ) - - names = {skill.name for skill in discover_skills(workspace)} - assert "spec-compliant" in names - - -def test_discover_skills_rejects_unknown_frontmatter_fields(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - _write_skill_with_frontmatter( - workspace / ".agent" / "skills" / "unknown-field", - frontmatter_lines=[ - "---", - "name: unknown-field", - "description: contains unsupported top-level field.", - "entrypoint: should-not-be-here", - "---", - ], - with_adapter=True, - ) - - names = {skill.name for skill in discover_skills(workspace)} - assert "unknown-field" not in names - - -def test_load_bub_agent_profile_by_skill_metadata(tmp_path: Path) -> None: - skill_dir = tmp_path / "demo-skill" - skill_dir.mkdir(parents=True) - profile_path = skill_dir / "agents" / "bub" / "agent.yaml" - profile_path.parent.mkdir(parents=True) - profile_path.write_text("version: 1\nsystem_prompt: demo\n", encoding="utf-8") - - metadata = SkillMetadata( - name="demo-skill", - description="demo", - location=skill_dir / "SKILL.md", - source="project", - ) - - assert skill_bub_agent_profile_path(metadata) == profile_path - profile = load_bub_agent_profile(metadata) - assert profile["system_prompt"] == "demo" - - -def test_load_agent_profile_falls_back_when_missing(tmp_path: Path) -> None: - profile = load_bub_agent_profile_file(tmp_path / "missing.yaml") - assert profile == {} - - -def test_load_agent_profile_reads_prompt_fields(tmp_path: Path) -> None: - path = tmp_path / "agent.yaml" - path.write_text( - "\n".join( - [ - "version: 1", - "system_prompt: Runtime custom prompt", - "continue_prompt: Continue from profile", - ] - ), - encoding="utf-8", - ) - - profile = load_bub_agent_profile_file(path) - assert profile["system_prompt"] == "Runtime custom prompt" - assert profile["continue_prompt"] == "Continue from profile" diff --git a/tests/test_skill_override.py b/tests/test_skill_override.py deleted file mode 100644 index 3be8929f..00000000 --- a/tests/test_skill_override.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import typer - -from bub.framework import BubFramework - - -def _write_project_override_skill(workspace: Path) -> None: - skill_dir = workspace / ".agent" / "skills" / "project-override" - adapter_file = skill_dir / "agents" / "bub" / "adapter.py" - adapter_file.parent.mkdir(parents=True) - adapter_file.write_text( - "\n".join( - [ - "import typer", - "", - "from bub.bus import MessageBus", - "from bub.hookspecs import hookimpl", - "", - "class ProjectBus(MessageBus):", - " pass", - "", - "class ProjectOverrideSkill:", - " @hookimpl", - " def provide_bus(self):", - " return ProjectBus()", - "", - " @hookimpl", - " def register_cli_commands(self, app):", - " @app.command('project-ping')", - " def project_ping():", - " typer.echo('pong')", - "", - "adapter = ProjectOverrideSkill()", - ] - ), - encoding="utf-8", - ) - - (skill_dir / "SKILL.md").write_text( - "\n".join( - [ - "---", - "name: project-override", - "description: project overrides bus and cli hooks", - "---", - ] - ), - encoding="utf-8", - ) - - -def test_project_skill_can_override_builtin_bus(tmp_path: Path) -> None: - _write_project_override_skill(tmp_path) - - framework = BubFramework(tmp_path) - framework.load_skills() - bus = framework.create_bus() - - assert bus.__class__.__name__ == "ProjectBus" - - -def test_project_skill_can_extend_cli_commands(tmp_path: Path) -> None: - _write_project_override_skill(tmp_path) - - framework = BubFramework(tmp_path) - framework.load_skills() - app = typer.Typer() - - framework.register_cli_commands(app) - - names = {command.name for command in app.registered_commands} - assert "project-ping" in names diff --git a/uv.lock b/uv.lock index 1ea8ec79..98d7ade1 100644 --- a/uv.lock +++ b/uv.lock @@ -96,7 +96,7 @@ wheels = [ [[package]] name = "bub" -version = "0.2.3" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "apscheduler" }, From 1be54adfd98289f99f147eea07b11e847e73166a Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 26 Feb 2026 09:40:11 +0800 Subject: [PATCH 06/39] refactor: remove redundant timeout check in _run_tools_once method Signed-off-by: Frost Ming --- src/bub/builtin/engine.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/engine.py index e8e580df..3374de33 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/engine.py @@ -150,14 +150,6 @@ async def _run_runtime(self, *, session_id: str, prompt: str) -> str | None: return f"error: max_steps_reached={self._settings.max_steps}" async def _run_tools_once(self, *, tape: Tape, prompt: str, tools: list[Tool]) -> ToolAutoResult: - if self._settings.timeout_seconds is None: - return await tape.run_tools_async( - prompt=prompt, - system_prompt=self._system_prompt(), - max_tokens=self._settings.max_tokens, - tools=tools, - extra_headers={"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"}, - ) async with asyncio.timeout(self._settings.timeout_seconds): return await tape.run_tools_async( prompt=prompt, From 2557c9383239a959c4d5a75d36088ba06c35919b Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Tue, 3 Mar 2026 09:10:00 +0800 Subject: [PATCH 07/39] Refactor code structure and remove redundant sections for improved readability and maintainability Signed-off-by: Frost Ming --- pyproject.toml | 3 +- src/bub/builtin/engine.py | 777 ++++++----------------------------- src/bub/builtin/hook_impl.py | 127 +++--- src/bub/builtin/settings.py | 15 + src/bub/builtin/tape.py | 168 ++++++++ src/bub/builtin/tools.py | 144 +++++++ src/bub/framework.py | 13 +- src/bub/hookspecs.py | 38 +- src/bub/skills.py | 24 +- uv.lock | 72 +--- 10 files changed, 579 insertions(+), 802 deletions(-) create mode 100644 src/bub/builtin/settings.py create mode 100644 src/bub/builtin/tape.py create mode 100644 src/bub/builtin/tools.py diff --git a/pyproject.toml b/pyproject.toml index 30f0e0c2..ee10f4dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,12 +23,13 @@ dependencies = [ "pluggy>=1.6.0", "typer>=0.9.0", "republic>=0.5.2", + "any-llm-sdk[anthropic]", "rich>=13.0.0", "prompt-toolkit>=3.0.0", "python-telegram-bot>=21.0", "loguru>=0.7.2", - "telegramify-markdown>=0.5.4", "apscheduler>=3.11.2", + "rapidfuzz>=3.14.3", ] [project.urls] diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/engine.py index 3374de33..5c948cee 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/engine.py @@ -4,510 +4,179 @@ import asyncio import inspect -import json -import os import shlex import time from dataclasses import dataclass from datetime import UTC, datetime -from hashlib import md5 +from functools import cached_property from pathlib import Path -from typing import Any -from republic import LLM, TapeEntry, Tool, ToolAutoResult -from republic.tape import InMemoryTapeStore, Tape +from pluggy import PluginManager +from republic import LLM, AsyncTapeStore, Tool, ToolAutoResult, ToolContext +from republic.tape import InMemoryTapeStore, Tape, TapeStore -from bub.skills import discover_skills, load_skill_body +from bub.builtin.settings import RuntimeSettings +from bub.builtin.tape import TapeService +from bub.types import State -DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" -DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 -DEFAULT_MODEL_TIMEOUT_SECONDS = 90 -DEFAULT_MAX_STEPS = 8 -DEFAULT_MAX_TOKENS = 1024 CONTINUE_PROMPT = "Continue the task." -AGENTS_FILE_NAME = "AGENTS.md" -AGENT_PROFILE_FILE_NAME = "agent.yaml" -RUNTIME_ENABLED_ENV = "BUB_RUNTIME_ENABLED" -PRIMARY_API_KEY_ENV = "BUB_API_KEY" -RUNTIME_ENABLED_ON_VALUE = "1" -RUNTIME_ENABLED_OFF_VALUE = "0" -RUNTIME_ENABLED_AUTO_VALUE = "auto" -DEFAULT_SYSTEM_PROMPT = ( - "You are Bub runtime skill. Use tools for operations such as shell, file edits, " - "skills lookup, and tape operations. Return concise natural language when done." -) - - -@dataclass(frozen=True) -class ParsedArgs: - kwargs: dict[str, object] - positional: list[str] - - -@dataclass(frozen=True) -class RuntimeSettings: - model: str - api_key: str | None - api_base: str | None - max_steps: int - max_tokens: int - timeout_seconds: int | None - enabled: bool - - -@dataclass(frozen=True) -class RuntimeAgentProfile: - system_prompt: str | None = None - continue_prompt: str = CONTINUE_PROMPT +DEFAULT_BUB_HEADERS = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"} class RuntimeEngine: """Runtime engine with command compatibility and Republic model driving.""" - def __init__(self, workspace: Path) -> None: - self.workspace = workspace.resolve() - self._event_root = self.workspace / ".bub" / "runtime" - self._event_root.mkdir(parents=True, exist_ok=True) - self._settings = _load_runtime_settings() - self._llm = _build_llm(self._settings) - - async def run(self, *, session_id: str, prompt: str) -> str | None: + def __init__(self, plugins_manager: PluginManager) -> None: + self.settings = _load_runtime_settings() + tape_store = plugins_manager.hook.provide_tape_store() + if tape_store is None: + tape_store = InMemoryTapeStore() + self._llm = _build_llm(self.settings, tape_store) + self._pm = plugins_manager + self._tools: list[Tool] | None = None + self.tapes = TapeService(self._llm, Path.home() / ".bub" / "tapes") + + def _load_tools(self) -> list[Tool]: + tools: dict[str, Tool] = {} + for provided in reversed(self._pm.hook.provide_tools()): + if isinstance(provided, dict): + tools.update(provided) + return list(tools.values()) + + @cached_property + def tools(self) -> list[Tool]: + if self._tools is None: + self._tools = self._load_tools() + return self._tools + + async def run(self, *, session_id: str, prompt: str, state: State) -> str: stripped = prompt.strip() if not stripped: - return None + return "error: empty prompt" + tape = self.tapes.session_tape(session_id) + await self.tapes.ensure_bootstrap_anchor(tape.name) + tape.context.state.update(state) if stripped.startswith(","): - return await self._run_command(session_id=session_id, line=stripped) - return await self._run_runtime(session_id=session_id, prompt=stripped) + return await self._run_command(tape=tape, line=stripped) + return await self._run_model(tape=tape, prompt=stripped) - async def _run_runtime(self, *, session_id: str, prompt: str) -> str | None: - if self._llm is None: - return None + async def _run_command(self, tape: Tape, *, line: str) -> str: + raw_body = line[1:].strip() + if not raw_body: + return "error: empty command" + + name, arg_tokens = _parse_internal_command(line) + start = time.monotonic() + context = ToolContext(tape=tape.name, run_id="run_command", state=tape.context.state) + tools = {tool.name: tool for tool in self.tools} + try: + if name not in tools: + output = await tools["bash"].run(context=context, cmd=line) + else: + args = _parse_args(arg_tokens) + output = tools[name].run(*args.positional, context=context, **args.kwargs) + if inspect.isawaitable(output): + output = await output + status = "ok" + except Exception as exc: + status = "error" + output = f"{exc!s}" + elapsed_ms = int((time.monotonic() - start) * 1000) + + event_payload = { + "raw": line, + "name": name, + "status": status, + "elapsed_ms": elapsed_ms, + "output": output, + "date": datetime.now(UTC).isoformat(), + } + await self.tapes.append_event(tape.name, "command", event_payload) + if status == "error": + return f"error: {output}" + return output - tape = self._llm.tape(_session_tape_name(session_id)) - self._ensure_bootstrap_anchor(tape) - tools = self._build_model_tools(session_id=session_id, tape=tape) + async def _run_model(self, *, tape: Tape, prompt: str) -> str: next_prompt = prompt - for step in range(1, self._settings.max_steps + 1): + for step in range(1, self.settings.max_steps + 1): start = time.monotonic() + await self.tapes.append_event(tape.name, "loop.step.start", {"step": step, "prompt": next_prompt}) try: - output = await self._run_tools_once(tape=tape, prompt=next_prompt, tools=tools) + output = await self._run_tools_once(tape=tape, prompt=next_prompt) except Exception as exc: elapsed_ms = int((time.monotonic() - start) * 1000) - self._append_event( - session_id, + await self.tapes.append_event( + tape.name, + "loop.step", { - "type": "model", "step": step, - "status": "error", "elapsed_ms": elapsed_ms, + "status": "error", "error": f"{exc!s}", - "ts": datetime.now(UTC).isoformat(), + "date": datetime.now(UTC).isoformat(), }, ) - return None + return f"error: {exc!s}" outcome = _resolve_tool_auto_result(output) elapsed_ms = int((time.monotonic() - start) * 1000) if outcome.kind == "text": - self._append_event( - session_id, + await self.tapes.append_event( + tape.name, + "loop.step", { - "type": "model", "step": step, - "status": "ok", "elapsed_ms": elapsed_ms, - "ts": datetime.now(UTC).isoformat(), + "status": "ok", + "date": datetime.now(UTC).isoformat(), }, ) return outcome.text if outcome.kind == "continue": - self._append_event( - session_id, + next_prompt = CONTINUE_PROMPT + await self.tapes.append_event( + tape.name, + "loop.step", { - "type": "model", "step": step, - "status": "continue", "elapsed_ms": elapsed_ms, - "ts": datetime.now(UTC).isoformat(), + "status": "continue", + "date": datetime.now(UTC).isoformat(), }, ) - next_prompt = CONTINUE_PROMPT continue - self._append_event( - session_id, + await self.tapes.append_event( + tape.name, + "loop.step", { - "type": "model", "step": step, - "status": "error", "elapsed_ms": elapsed_ms, + "status": "error", "error": outcome.error, - "ts": datetime.now(UTC).isoformat(), + "date": datetime.now(UTC).isoformat(), }, ) return f"error: {outcome.error}" - return f"error: max_steps_reached={self._settings.max_steps}" + return f"error: max_steps_reached={self.settings.max_steps}" - async def _run_tools_once(self, *, tape: Tape, prompt: str, tools: list[Tool]) -> ToolAutoResult: - async with asyncio.timeout(self._settings.timeout_seconds): + async def _run_tools_once(self, *, tape: Tape, prompt: str) -> ToolAutoResult: + async with asyncio.timeout(self.settings.timeout_seconds): return await tape.run_tools_async( prompt=prompt, - system_prompt=self._system_prompt(), - max_tokens=self._settings.max_tokens, - tools=tools, - extra_headers={"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"}, - ) - - async def _run_command(self, *, session_id: str, line: str) -> str: - tape = self._llm.tape(_session_tape_name(session_id)) if self._llm is not None else None - if tape is not None: - self._ensure_bootstrap_anchor(tape) - raw_body = line[1:].strip() - if not raw_body: - return "error: empty command" - - name, args_tokens = _parse_internal_command(line) - resolved_name = _resolve_internal_name(name) - command_name = resolved_name if resolved_name in _internal_tool_names() else "bash" - start = time.monotonic() - try: - if command_name == "bash": - output = await self._run_shell(raw_body) - else: - output = await self._run_internal( - command_name=command_name, - args_tokens=args_tokens, - session_id=session_id, - tape=tape, - ) - status = "ok" - except Exception as exc: - status = "error" - output = f"{exc!s}" - elapsed_ms = int((time.monotonic() - start) * 1000) - - event_payload = { - "type": "command", - "raw": line, - "name": command_name, - "status": status, - "elapsed_ms": elapsed_ms, - "output": output, - "ts": datetime.now(UTC).isoformat(), - } - self._append_event(session_id, event_payload) - if tape is not None: - tape.append(TapeEntry.event("command", data=event_payload)) - if status == "error": - return f"error: {output}" - return output - - async def _run_internal( - self, - *, - command_name: str, - args_tokens: list[str], - session_id: str, - tape: Tape | None, - ) -> str: - args = _parse_kv_arguments(args_tokens) - handler = self._internal_handlers().get(command_name) - if handler is None: - raise RuntimeError(f"unknown internal command: {command_name}") - result = handler(args=args, session_id=session_id, tape=tape) - if inspect.isawaitable(result): - result = await result - return str(result) - - def _internal_handlers(self) -> dict[str, Any]: - return { - "help": self._command_help, - "tools": self._command_tools, - "tool.describe": self._command_tool_describe, - "skills.list": self._command_skills_list, - "skills.describe": self._command_skills_describe, - "tape.info": self._command_tape_info, - "tape.search": self._command_tape_search, - "tape.handoff": self._command_tape_handoff, - "tape.anchors": self._command_tape_anchors, - "fs.read": self._command_fs_read, - "fs.write": self._command_fs_write, - "fs.edit": self._command_fs_edit, - "quit": self._command_quit, - } - - def _command_help(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = args, session_id, tape - return _help_text() - - def _command_tools(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = args, session_id, tape - return "\n".join(sorted(_internal_tool_names())) - - def _command_tool_describe(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = session_id, tape - name = _arg_as_str(args, "name") or (args.positional[0] if args.positional else "") - if not name: - raise RuntimeError("missing tool name") - return _tool_describe(name) - - def _command_skills_list(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = args, session_id, tape - skills = discover_skills(self.workspace) - if not skills: - return "(no skills)" - return "\n".join(f"{skill.name} ({skill.source}): {skill.description}" for skill in skills) - - def _command_skills_describe(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = session_id, tape - name = _arg_as_str(args, "name") or (args.positional[0] if args.positional else "") - if not name: - raise RuntimeError("missing skill name") - body = load_skill_body(name, self.workspace) - if body is None: - raise RuntimeError(f"skill not found: {name}") - return body - - def _command_tape_info(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = args - entries = self._read_entries(session_id=session_id, tape=tape) - anchors = [entry for entry in entries if entry.get("kind") == "anchor"] - last_anchor = str(anchors[-1].get("name") or "-") if anchors else "-" - return f"name: {session_id}\nentries: {len(entries)}\nanchors: {len(anchors)}\nlast_anchor: {last_anchor}" - - def _command_tape_search(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - query = _arg_as_str(args, "query") or (args.positional[0] if args.positional else "") - if not query: - raise RuntimeError("missing query") - limit = _arg_as_int(args, "limit", default=20) or 20 - lowered = query.casefold() - matches: list[str] = [] - for entry in self._read_entries(session_id=session_id, tape=tape): - serialized = json.dumps(entry, ensure_ascii=False) - if lowered in serialized.casefold(): - matches.append(serialized) - if len(matches) >= limit: - break - if not matches: - return "(no matches)" - return "\n".join(matches) - - def _command_tape_handoff(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - name = _arg_as_str(args, "name") or (args.positional[0] if args.positional else "handoff") - summary = _arg_as_str(args, "summary") or "" - if tape is not None: - state = {"summary": summary} if summary else None - tape.handoff(name, state=state) - return f"anchor added: {name}" - self._append_event( - session_id, - { - "type": "anchor", - "name": name, - "summary": summary, - "ts": datetime.now(UTC).isoformat(), - }, - ) - return f"anchor added: {name}" - - def _command_tape_anchors(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = args - anchors = [ - entry for entry in self._read_entries(session_id=session_id, tape=tape) if entry.get("kind") == "anchor" - ] - if not anchors: - return "(no anchors)" - return "\n".join(str(entry.get("name") or "-") for entry in anchors) - - def _command_fs_read(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = session_id, tape - path = _arg_as_str(args, "path") or (args.positional[0] if args.positional else "") - if not path: - raise RuntimeError("missing path") - offset = _arg_as_int(args, "offset", default=0) or 0 - limit = _arg_as_int(args, "limit") - return self._fs_read(path, offset=offset, limit=limit) - - def _command_fs_write(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = session_id, tape - path = _arg_as_str(args, "path") or (args.positional[0] if args.positional else "") - content = _arg_as_str(args, "content") - if not path or content is None: - raise RuntimeError("missing path/content") - return self._fs_write(path, content) - - def _command_fs_edit(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = session_id, tape - path = _arg_as_str(args, "path") or (args.positional[0] if args.positional else "") - old = _arg_as_str(args, "old") - new = _arg_as_str(args, "new") - replace_all = bool(args.kwargs.get("replace_all", False)) - if not path or old is None or new is None: - raise RuntimeError("missing path/old/new") - return self._fs_edit(path, old, new, replace_all=replace_all) - - def _command_quit(self, *, args: ParsedArgs, session_id: str, tape: Tape | None) -> str: - _ = args, session_id, tape - return "exit" - - async def _run_shell( - self, command: str, *, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS - ) -> str: - completed = await asyncio.create_subprocess_exec( - "bash", - "-lc", - command, - cwd=cwd or str(self.workspace), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - async with asyncio.timeout(timeout_seconds): - stdout_bytes, stderr_bytes = await completed.communicate() - stdout_text = (stdout_bytes or b"").decode("utf-8", errors="replace").strip() - stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace").strip() - if completed.returncode != 0: - message = stderr_text or stdout_text or f"exit={completed.returncode}" - raise RuntimeError(f"exit={completed.returncode}: {message}") - return stdout_text or "(no output)" - - def _build_model_tools(self, *, session_id: str, tape: Tape) -> list[Tool]: # noqa: C901 - async def bash(cmd: str, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS) -> str: - return await self._run_shell(cmd, cwd=cwd, timeout_seconds=timeout_seconds) - - def fs_read(path: str, offset: int = 0, limit: int | None = None) -> str: - return self._fs_read(path, offset=offset, limit=limit) - - def fs_write(path: str, content: str) -> str: - return self._fs_write(path, content) - - def fs_edit(path: str, old: str, new: str, replace_all: bool = False) -> str: - return self._fs_edit(path, old, new, replace_all=replace_all) - - def skills_list() -> str: - return self._command_skills_list( - args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape + system_prompt=self._system_prompt(state=tape.context.state), + max_tokens=self.settings.max_tokens, + tools=self.tools, + extra_headers=DEFAULT_BUB_HEADERS, ) - def skills_describe(name: str) -> str: - args = ParsedArgs(kwargs={"name": name}, positional=[]) - return self._command_skills_describe(args=args, session_id=session_id, tape=tape) - - def tape_info() -> str: - return self._command_tape_info(args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape) - - def tape_search(query: str, limit: int = 20) -> str: - args = ParsedArgs(kwargs={"query": query, "limit": limit}, positional=[]) - return self._command_tape_search(args=args, session_id=session_id, tape=tape) - - def tape_handoff(name: str = "handoff", summary: str = "") -> str: - args = ParsedArgs(kwargs={"name": name, "summary": summary}, positional=[]) - return self._command_tape_handoff(args=args, session_id=session_id, tape=tape) - - def tape_anchors() -> str: - return self._command_tape_anchors( - args=ParsedArgs(kwargs={}, positional=[]), session_id=session_id, tape=tape - ) - - tools = [ - ("bash", "Run shell command in workspace with timeout.", bash), - ("fs.read", "Read a UTF-8 file with optional offset and limit.", fs_read), - ("fs.write", "Write UTF-8 content to a file path.", fs_write), - ("fs.edit", "Replace text once or all in a file.", fs_edit), - ("skills.list", "List discovered skills with source and description.", skills_list), - ("skills.describe", "Read SKILL.md body by skill name.", skills_describe), - ("tape.info", "Show session tape summary.", tape_info), - ("tape.search", "Search tape entries by query.", tape_search), - ("tape.handoff", "Create one anchor event.", tape_handoff), - ("tape.anchors", "List anchor names.", tape_anchors), - ] - return [Tool.from_callable(func, name=name, description=description) for name, description, func in tools] - - def _system_prompt(self) -> str: - blocks = [DEFAULT_SYSTEM_PROMPT] - if workspace_prompt := _read_workspace_agents_prompt(self.workspace): - blocks.append(workspace_prompt) + def _system_prompt(self, state: State) -> str: + blocks = [] + for prompt in reversed(self._pm.hook.system_prompt(state=state)): + blocks.append(prompt) return "\n\n".join(blocks) - def _read_entries(self, *, session_id: str, tape: Tape | None) -> list[dict[str, object]]: - if tape is not None: - entries: list[dict[str, object]] = [] - for entry in tape.read_entries(): - entries.append({ - "id": entry.id, - "kind": entry.kind, - "name": entry.payload.get("name") if isinstance(entry.payload, dict) else None, - "payload": entry.payload, - "meta": entry.meta, - }) - return entries - return self._read_events_file(session_id) - - @staticmethod - def _ensure_bootstrap_anchor(tape: Tape) -> None: - for entry in tape.read_entries(): - if entry.kind == "anchor": - return - tape.handoff("session/start", state={"owner": "human"}) - - def _fs_read(self, raw_path: str, *, offset: int = 0, limit: int | None = None) -> str: - path = self._resolve_path(raw_path) - text = path.read_text(encoding="utf-8") - lines = text.splitlines() - start = max(0, min(offset, len(lines))) - end = len(lines) if limit is None else min(len(lines), start + max(0, limit)) - return "\n".join(lines[start:end]) - - def _fs_write(self, raw_path: str, content: str) -> str: - path = self._resolve_path(raw_path) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - return f"wrote: {path}" - - def _fs_edit(self, raw_path: str, old: str, new: str, *, replace_all: bool) -> str: - path = self._resolve_path(raw_path) - text = path.read_text(encoding="utf-8") - if old not in text: - raise RuntimeError("old text not found") - if replace_all: - count = text.count(old) - path.write_text(text.replace(old, new), encoding="utf-8") - return f"updated: {path} occurrences={count}" - path.write_text(text.replace(old, new, 1), encoding="utf-8") - return f"updated: {path} occurrences=1" - - def _resolve_path(self, raw_path: str) -> Path: - path = Path(raw_path).expanduser() - if path.is_absolute(): - return path - return (self.workspace / path).resolve() - - def _append_event(self, session_id: str, payload: dict[str, object]) -> None: - file_path = self._event_file(session_id) - with file_path.open("a", encoding="utf-8") as file: - file.write(json.dumps(payload, ensure_ascii=False) + "\n") - - def _read_events_file(self, session_id: str) -> list[dict[str, object]]: - file_path = self._event_file(session_id) - if not file_path.exists(): - return [] - events: list[dict[str, object]] = [] - for raw in file_path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line: - continue - try: - parsed = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(parsed, dict): - events.append(parsed) - return events - - def _event_file(self, session_id: str) -> Path: - slug = md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 - return self._event_root / f"{slug}.jsonl" - @dataclass(frozen=True) class _ToolAutoOutcome: @@ -527,232 +196,44 @@ def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome: return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}") -def _build_llm(settings: RuntimeSettings) -> LLM | None: - if not settings.enabled: - return None +def _build_llm(settings: RuntimeSettings, tape_store: TapeStore | AsyncTapeStore) -> LLM: return LLM( settings.model, api_key=settings.api_key, api_base=settings.api_base, - tape_store=InMemoryTapeStore(), + tape_store=tape_store, ) def _load_runtime_settings() -> RuntimeSettings: - model = _first_non_empty([os.getenv("BUB_MODEL"), DEFAULT_MODEL]) or DEFAULT_MODEL - api_key = _resolve_runtime_api_key() - api_base = _first_non_empty([os.getenv("BUB_API_BASE")]) - max_steps = _int_env("BUB_RUNTIME_MAX_STEPS", default=DEFAULT_MAX_STEPS) - max_tokens = _int_env("BUB_RUNTIME_MAX_TOKENS", default=DEFAULT_MAX_TOKENS) - timeout_seconds = _int_env("BUB_RUNTIME_MODEL_TIMEOUT_SECONDS", default=DEFAULT_MODEL_TIMEOUT_SECONDS) - mode = _resolve_runtime_enabled_mode() - enabled = _resolve_runtime_enabled(mode=mode, model=model, api_key=api_key) - - return RuntimeSettings( - model=model, - api_key=api_key, - api_base=api_base, - max_steps=max_steps, - max_tokens=max_tokens, - timeout_seconds=timeout_seconds, - enabled=enabled, - ) - - -def _model_requires_api_key(model: str) -> bool: - prefixes = ("openrouter:", "openai:", "anthropic:", "gemini:", "xai:", "groq:", "mistral:", "deepseek:") - lowered = model.casefold() - return lowered.startswith(prefixes) - - -def _resolve_runtime_api_key() -> str | None: - return _first_non_empty([os.getenv(PRIMARY_API_KEY_ENV)]) - - -def _resolve_runtime_enabled(*, mode: str, model: str, api_key: str | None) -> bool: - if mode == RUNTIME_ENABLED_ON_VALUE: - return True - if mode == RUNTIME_ENABLED_OFF_VALUE: - return False - requires_key = _model_requires_api_key(model) - return bool(api_key) or not requires_key - - -def _resolve_runtime_enabled_mode() -> str: - mode = _first_non_empty([os.getenv(RUNTIME_ENABLED_ENV), RUNTIME_ENABLED_AUTO_VALUE]) or RUNTIME_ENABLED_AUTO_VALUE - lowered = mode.casefold() - if lowered in {RUNTIME_ENABLED_ON_VALUE, RUNTIME_ENABLED_OFF_VALUE, RUNTIME_ENABLED_AUTO_VALUE}: - return lowered - return RUNTIME_ENABLED_AUTO_VALUE - - -def _first_non_empty(values: list[str | None]) -> str | None: - for value in values: - if value is None: - continue - stripped = value.strip() - if stripped: - return stripped - return None - - -def _int_env(name: str, *, default: int) -> int: - raw = os.getenv(name) - if raw is None: - return default - try: - parsed = int(raw) - except ValueError: - return default - if parsed <= 0: - return default - return parsed + return RuntimeSettings() -def _read_workspace_agents_prompt(workspace: Path) -> str: - prompt_path = workspace / AGENTS_FILE_NAME - if not prompt_path.is_file(): - return "" - try: - return prompt_path.read_text(encoding="utf-8").strip() - except OSError: - return "" - - -def _session_tape_name(session_id: str) -> str: - slug = md5(session_id.encode("utf-8")).hexdigest()[:16] # noqa: S324 - return f"runtime:{slug}" +@dataclass(frozen=True) +class Args: + positional: list[str] + kwargs: dict[str, str] def _parse_internal_command(line: str) -> tuple[str, list[str]]: - body = line.strip()[1:].strip() - words = _parse_command_words(body) + body = line.strip() + words = shlex.split(body) if not words: return "", [] return words[0], words[1:] -def _parse_command_words(text: str) -> list[str]: - try: - return shlex.split(text) - except ValueError: - return [] - - -def _parse_kv_arguments(tokens: list[str]) -> ParsedArgs: - kwargs: dict[str, object] = {} +def _parse_args(args_tokens: list[str]) -> Args: positional: list[str] = [] - index = 0 - while index < len(tokens): - token = tokens[index] - if token.startswith("--"): - key = token[2:] - if "=" in key: - name, value = key.split("=", 1) - kwargs[name] = value - index += 1 - continue - if index + 1 < len(tokens) and not tokens[index + 1].startswith("--"): - kwargs[key] = tokens[index + 1] - index += 2 - continue - kwargs[key] = True - index += 1 - continue + kwargs: dict[str, str] = {} + first_kwarg = False + for token in args_tokens: if "=" in token: key, value = token.split("=", 1) kwargs[key] = value - index += 1 - continue - positional.append(token) - index += 1 - return ParsedArgs(kwargs=kwargs, positional=positional) - - -def _resolve_internal_name(name: str) -> str: - aliases = { - "tool": "tool.describe", - "tape": "tape.info", - "skill": "skills.describe", - } - return aliases.get(name, name) - - -def _internal_tool_names() -> set[str]: - return { - "bash", - "help", - "tools", - "tool.describe", - "skills.list", - "skills.describe", - "tape.info", - "tape.search", - "tape.handoff", - "tape.anchors", - "fs.read", - "fs.write", - "fs.edit", - "quit", - } - - -def _tool_describe(name: str) -> str: - descriptions = { - "bash": "Run shell command in workspace with timeout.", - "help": "Show internal command usage.", - "tools": "List available internal tools.", - "tool.describe": "Show one tool description.", - "skills.list": "List discovered skills with source and description.", - "skills.describe": "Show one skill body by name.", - "tape.info": "Show session tape summary.", - "tape.search": "Search session tape entries by query.", - "tape.handoff": "Create an anchor event.", - "tape.anchors": "List anchor names.", - "fs.read": "Read a UTF-8 file with optional offset and limit.", - "fs.write": "Write UTF-8 file content.", - "fs.edit": "Replace text in file once or all.", - "quit": "Return exit marker.", - } - description = descriptions.get(name) - if description is None: - raise RuntimeError(f"unknown tool: {name}") - return f"{name}: {description}" - - -def _help_text() -> str: - return ( - "Commands use ',' at line start.\n" - "Known internal commands:\n" - " ,help\n" - " ,tools\n" - " ,tool.describe name=fs.read\n" - " ,skills.list\n" - " ,skills.describe name=friendly-python\n" - " ,tape.info\n" - " ,tape.search query=error\n" - " ,tape.handoff name=phase-1 summary='done'\n" - " ,tape.anchors\n" - " ,fs.read path=README.md\n" - " ,fs.write path=tmp.txt content='hello'\n" - " ,fs.edit path=tmp.txt old=hello new=world\n" - " ,quit\n" - "Any unknown command after ',' is executed as shell via bash." - ) - - -def _arg_as_str(args: ParsedArgs, key: str) -> str | None: - value = args.kwargs.get(key) - if value is None: - return None - return str(value) - - -def _arg_as_int(args: ParsedArgs, key: str, default: int | None = None) -> int | None: - value = args.kwargs.get(key) - if value is None: - return default - try: - return int(str(value)) - except (TypeError, ValueError): - return default + first_kwarg = True + elif first_kwarg: + raise ValueError(f"positional argument '{token}' cannot appear after keyword arguments") + else: + positional.append(token) + return Args(positional=positional, kwargs=kwargs) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 2fe7b574..2cc328c3 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -1,60 +1,79 @@ from pathlib import Path import typer +from pluggy import PluginManager from bub.builtin.engine import RuntimeEngine from bub.envelope import content_of, field_of, normalize_envelope from bub.hookspecs import hookimpl from bub.types import Envelope, State - -@hookimpl -def normalize_inbound(message: Envelope) -> Envelope: - envelope = normalize_envelope(message) - envelope["content"] = str(envelope.get("content", "")).strip() - metadata = envelope.get("metadata") - if not isinstance(metadata, dict): - metadata = {} - metadata.setdefault("listener", "runtime") - envelope["metadata"] = metadata - return envelope - - -@hookimpl -def resolve_session(message: Envelope) -> str: - session_id = field_of(message, "session_id") - if session_id is not None and str(session_id).strip(): - return str(session_id) - channel = str(field_of(message, "channel", "default")) - chat_id = str(field_of(message, "chat_id", "default")) - return f"{channel}:{chat_id}" - - -@hookimpl -def build_prompt(message: Envelope, session_id: str, state: State) -> str: - _ = session_id - workspace = field_of(message, "workspace") - if isinstance(workspace, str) and workspace.strip(): - state["_runtime_workspace"] = workspace.strip() - elif "_runtime_workspace" not in state: - state["_runtime_workspace"] = str(Path.cwd()) - return content_of(message) - - -@hookimpl -async def run_model(prompt: str, session_id: str, state: State) -> str | None: - workspace = _workspace_from_state(state) - engine = _engine_for_workspace(workspace) - return await engine.run(session_id=session_id, prompt=prompt) - - -@hookimpl -def register_cli_commands(app: typer.Typer) -> None: - from bub.builtin import cli - - app.command("run")(cli.run) - app.command("hooks")(cli.list_hooks) - app.command("install")(cli.install_plugin) +AGENTS_FILE_NAME = "AGENTS.md" + + +class BuiltinImpl: + """Default hook implementations for basic runtime operations.""" + + def __init__(self, plugin_manager: PluginManager) -> None: + self.plugin_manager = plugin_manager + self.engine = RuntimeEngine(plugin_manager) + + @hookimpl + def normalize_inbound(self, message: Envelope) -> Envelope: + envelope = normalize_envelope(message) + envelope["content"] = str(envelope.get("content", "")).strip() + metadata = envelope.get("metadata") + if not isinstance(metadata, dict): + metadata = {} + metadata.setdefault("listener", "runtime") + envelope["metadata"] = metadata + return envelope + + @hookimpl + def resolve_session(self, message: Envelope) -> str: + session_id = field_of(message, "session_id") + if session_id is not None and str(session_id).strip(): + return str(session_id) + channel = str(field_of(message, "channel", "default")) + chat_id = str(field_of(message, "chat_id", "default")) + return f"{channel}:{chat_id}" + + @hookimpl + def load_state(self, session_id: str) -> State: + return {"session_id": session_id, "_runtime_engine": self.engine} + + @hookimpl + def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: + _ = session_id + workspace = field_of(message, "workspace") + if isinstance(workspace, str) and workspace.strip(): + state["_runtime_workspace"] = workspace.strip() + elif "_runtime_workspace" not in state: + state["_runtime_workspace"] = str(Path.cwd()) + return content_of(message) + + @hookimpl + async def run_model(self, prompt: str, session_id: str, state: State) -> str: + return await self.engine.run(session_id=session_id, prompt=prompt, state=state) + + @hookimpl + def register_cli_commands(self, app: typer.Typer) -> None: + from bub.builtin import cli + + app.command("run")(cli.run) + app.command("hooks")(cli.list_hooks) + app.command("install")(cli.install_plugin) + + @hookimpl + def system_prompt(self, state: State) -> str: + # Read the content of AGENTS.md under workspace + prompt_path = _workspace_from_state(state) / AGENTS_FILE_NAME + if not prompt_path.is_file(): + return "" + try: + return prompt_path.read_text(encoding="utf-8").strip() + except OSError: + return "" def _workspace_from_state(state: State) -> Path: @@ -62,15 +81,3 @@ def _workspace_from_state(state: State) -> Path: if isinstance(raw, str) and raw.strip(): return Path(raw).expanduser().resolve() return Path.cwd().resolve() - - -def _engine_for_workspace(workspace: Path) -> RuntimeEngine: - cached = _ENGINE_CACHE.get(workspace) - if cached is not None: - return cached - engine = RuntimeEngine(workspace) - _ENGINE_CACHE[workspace] = engine - return engine - - -_ENGINE_CACHE: dict[Path, RuntimeEngine] = {} diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py new file mode 100644 index 00000000..7ee18291 --- /dev/null +++ b/src/bub/builtin/settings.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + +DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" +DEFAULT_MAX_TOKENS = 1024 + + +class RuntimeSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore") + + model: str = DEFAULT_MODEL + api_key: str | None = None + api_base: str | None = None + max_steps: int = 50 + max_tokens: int = DEFAULT_MAX_TOKENS + timeout_seconds: int | None = None diff --git a/src/bub/builtin/tape.py b/src/bub/builtin/tape.py new file mode 100644 index 00000000..020c4320 --- /dev/null +++ b/src/bub/builtin/tape.py @@ -0,0 +1,168 @@ +import hashlib +import re +from dataclasses import asdict +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from pydantic import json +from pydantic.dataclasses import dataclass +from rapidfuzz import fuzz, process +from republic import LLM, Tape, TapeEntry + +WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") +MIN_FUZZY_QUERY_LENGTH = 3 +MIN_FUZZY_SCORE = 80 +MAX_FUZZY_CANDIDATES = 128 + + +@dataclass(frozen=True) +class TapeInfo: + """Runtime tape info summary.""" + + name: str + entries: int + anchors: int + last_anchor: str | None + entries_since_last_anchor: int + + +@dataclass(frozen=True) +class AnchorSummary: + """Rendered anchor summary.""" + + name: str + state: dict[str, object] + + +class TapeService: + def __init__(self, llm: LLM, archive_path: Path) -> None: + self._llm = llm + self._archive_path = archive_path + + async def info(self, tape_name: str) -> TapeInfo: + tape = self._llm.tape(tape_name) + entries = list(await tape.query_async.all()) + anchors = [entry for entry in entries if entry.kind == "anchor"] + last_anchor = anchors[-1].payload.get("name") if anchors else None + if last_anchor is not None: + entries_since_last_anchor = sum(1 for entry in entries if entry.id > anchors[-1].id) + else: + entries_since_last_anchor = len(entries) + return TapeInfo( + name=tape.name, + entries=len(entries), + anchors=len(anchors), + last_anchor=str(last_anchor) if last_anchor else None, + entries_since_last_anchor=entries_since_last_anchor, + ) + + async def ensure_bootstrap_anchor(self, tape_name: str) -> None: + tape = self._llm.tape(tape_name) + anchors = list(await tape.query_async.kinds("anchor").all()) + if not anchors: + await tape.handoff_async("session/start", state={"owner": "human"}) + + async def anchors(self, tape_name: str, limit: int = 20) -> list[AnchorSummary]: + tape = self._llm.tape(tape_name) + entries = list(await tape.query_async.kinds("anchor").all()) + results: list[AnchorSummary] = [] + for entry in entries[-limit:]: + name = str(entry.payload.get("name", "-")) + state = entry.payload.get("state") + state_dict: dict[str, object] = dict(state) if isinstance(state, dict) else {} + results.append(AnchorSummary(name=name, state=state_dict)) + return results + + async def _archive(self, tape_name: str) -> Path: + tape = self._llm.tape(tape_name) + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + self._archive_path.mkdir(parents=True, exist_ok=True) + archive_path = self._archive_path / f"{tape.name}.jsonl.{stamp}.bak" + with archive_path.open("w", encoding="utf-8") as f: + for entry in await tape.query_async.all(): + f.write(json.dumps(asdict(entry)) + "\n") + return archive_path + + async def reset(self, tape_name: str, *, archive: bool = False) -> str: + tape = self._llm.tape(tape_name) + archive_path: Path | None = None + if archive: + archive_path = await self._archive(tape_name) + await tape.reset_async() + state = {"owner": "human"} + if archive_path is not None: + state["archived"] = str(archive_path) + await tape.handoff_async("session/start", state=state) + return f"Archived: {archive_path}" if archive_path else "ok" + + async def handoff(self, tape_name: str, *, name: str, state: dict[str, Any] | None = None) -> list[TapeEntry]: + tape = self._llm.tape(tape_name) + return await tape.handoff_async(name, state=state) + + async def search(self, tape_name: str, query: str, *, limit: int = 20) -> list[TapeEntry]: + normalized_query = query.strip().lower() + if not normalized_query: + return [] + results: list[TapeEntry] = [] + tapes = [self._llm.tape(tape_name)] + + for tape in tapes: + count = 0 + for entry in reversed(list(await tape.query_async.kinds("message").all())): + payload_text = json.dumps(entry.payload, ensure_ascii=False) + entry_meta = getattr(entry, "meta", {}) + meta_text = json.dumps(entry_meta, ensure_ascii=False) + + if ( + normalized_query in payload_text.lower() or normalized_query in meta_text.lower() + ) or self._is_fuzzy_match(normalized_query, payload_text, meta_text): + results.append(entry) + count += 1 + if count >= limit: + break + return results + + @staticmethod + def _is_fuzzy_match(normalized_query: str, payload_text: str, meta_text: str) -> bool: + if len(normalized_query) < MIN_FUZZY_QUERY_LENGTH: + return False + + query_tokens = WORD_PATTERN.findall(normalized_query) + if not query_tokens: + return False + query_phrase = " ".join(query_tokens) + window_size = len(query_tokens) + + source_tokens = WORD_PATTERN.findall(payload_text.lower()) + WORD_PATTERN.findall(meta_text.lower()) + if not source_tokens: + return False + + candidates: list[str] = [] + for token in source_tokens: + candidates.append(token) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + if window_size > 1: + max_window_start = len(source_tokens) - window_size + 1 + for idx in range(max(0, max_window_start)): + candidates.append(" ".join(source_tokens[idx : idx + window_size])) + if len(candidates) >= MAX_FUZZY_CANDIDATES: + break + + best_match = process.extractOne( + query_phrase, + candidates, + scorer=fuzz.WRatio, + score_cutoff=MIN_FUZZY_SCORE, + ) + return best_match is not None + + async def append_event(self, tape_name: str, name: str, payload: dict[str, Any], **meta: Any) -> None: + tape = self._llm.tape(tape_name) + return await tape.append_async(TapeEntry.event(name=name, payload=payload, **meta)) + + def session_tape(self, session_id: str) -> Tape: + tape_name = hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + return self._llm.tape(tape_name) diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py new file mode 100644 index 00000000..37b1a6ed --- /dev/null +++ b/src/bub/builtin/tools.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from republic import ToolContext, tool + +if TYPE_CHECKING: + from bub.builtin.engine import RuntimeEngine + +DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 + + +def _get_runtime(context: ToolContext) -> RuntimeEngine: + if "_runtime_engine" not in context.state: + raise RuntimeError("no runtime engine found in tool context") + return cast("RuntimeEngine", context.state["_runtime_engine"]) + + +@tool(context=True) +async def bash( + cmd: str, cwd: str | None = None, timeout_seconds: int = DEFAULT_COMMAND_TIMEOUT_SECONDS, *, context: ToolContext +) -> str: + """Run a shell command and return its output within a time limit. Raises if the command fails or times out.""" + workspace = context.state.get("_runtime_workspace") + completed = await asyncio.create_subprocess_exec( + "bash", + "-lc", + cmd, + cwd=cwd or workspace, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + async with asyncio.timeout(timeout_seconds): + stdout_bytes, stderr_bytes = await completed.communicate() + stdout_text = (stdout_bytes or b"").decode("utf-8", errors="replace").strip() + stderr_text = (stderr_bytes or b"").decode("utf-8", errors="replace").strip() + if completed.returncode != 0: + message = stderr_text or stdout_text or f"exit={completed.returncode}" + raise RuntimeError(f"exit={completed.returncode}: {message}") + return stdout_text or "(no output)" + + +@tool(context=True) +def fs_read(path: str, offset: int = 0, limit: int | None = None, *, context: ToolContext) -> str: + """Read a text file and return its content. Supports optional pagination with offset and limit.""" + resolved_path = _resolve_path(context, path) + text = resolved_path.read_text(encoding="utf-8") + lines = text.splitlines() + start = max(0, min(offset, len(lines))) + end = len(lines) if limit is None else min(len(lines), start + max(0, limit)) + return "\n".join(lines[start:end]) + + +@tool(context=True) +def fs_write(path: str, content: str, *, context: ToolContext) -> str: + """Write content to a text file.""" + resolved_path = _resolve_path(context, path) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + resolved_path.write_text(content, encoding="utf-8") + return f"wrote: {resolved_path}" + + +@tool(context=True) +def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolContext) -> str: + """Edit a text file by replacing old text with new text. You can specify the line number to start searching for the old text.""" + resolved_path = _resolve_path(context, path) + text = resolved_path.read_text(encoding="utf-8") + lines = text.splitlines() + prev, to_replace = "\n".join(lines[:start]), "\n".join(lines[start:]) + if old not in to_replace: + raise ValueError(f"'{old}' not found in {resolved_path} from line {start}") + replaced = to_replace.replace(old, new) + resolved_path.write_text(prev + "\n" + replaced, encoding="utf-8") + return f"edited: {resolved_path}" + + +@tool(context=True) +async def tape_info(context: ToolContext) -> str: + runtime = _get_runtime(context) + info = await runtime.tapes.info(context.tape or "") + return f"name: {info.name}\nentries: {info.entries}\nanchors: {info.anchors}\nlast_anchor: {info.last_anchor}" + + +@tool(context=True) +async def tape_search(query: str, limit: int = 20, *, context: ToolContext) -> str: + runtime = _get_runtime(context) + entries = await runtime.tapes.search(context.tape or "", query=query, limit=limit) + if not entries: + return "(no matches)" + return "\n".join(f"- {json.dumps(entry.payload)}" for entry in entries) + + +@tool(context=True) +async def tape_reset(archive: bool = False, *, context: ToolContext) -> str: + runtime = _get_runtime(context) + result = await runtime.tapes.reset(context.tape or "", archive=archive) + return result + + +@tool(context=True) +async def tape_handoff(name: str = "handoff", summary: str = "", *, context: ToolContext) -> str: + runtime = _get_runtime(context) + await runtime.tapes.handoff(context.tape or "", name=name, state={"summary": summary}) + return f"anchor added: {name}" + + +@tool(context=True) +async def tape_anchors(*, context: ToolContext) -> str: + runtime = _get_runtime(context) + anchors = await runtime.tapes.anchors(context.tape or "") + if not anchors: + return "(no anchors)" + return "\n".join(f"- {anchor.name}" for anchor in anchors) + + +@tool(name="help") +def show_help() -> str: + """List available tools.""" + return ( + "Commands use ',' at line start.\n" + "Known internal commands:\n" + " ,help\n" + " ,tape.info\n" + " ,tape.search query=error\n" + " ,tape.handoff name=phase-1 summary='done'\n" + " ,tape.anchors\n" + " ,fs.read path=README.md\n" + " ,fs.write path=tmp.txt content='hello'\n" + " ,fs.edit path=tmp.txt old=hello new=world\n" + "Any unknown command after ',' is executed as shell via bash." + ) + + +def _resolve_path(context: ToolContext, raw_path: str) -> Path: + workspace = context.state.get("_runtime_workspace") + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + if workspace is None: + raise ValueError(f"relative path '{raw_path}' is not allowed without a workspace") + return (workspace / path).resolve() diff --git a/src/bub/framework.py b/src/bub/framework.py index 4ebb3970..3e921c2f 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -32,10 +32,12 @@ def __init__(self, workspace: Path) -> None: self._plugin_status: dict[str, PluginStatus] = {} def _load_builtin_hooks(self) -> None: - from bub.builtin import hook_impl + from bub.builtin.hook_impl import BuiltinImpl + + impl = BuiltinImpl(self._plugin_manager) try: - self._plugin_manager.register(hook_impl, name="builtin") + self._plugin_manager.register(impl, name="builtin") except Exception as exc: self._plugin_status["builtin"] = PluginStatus(is_success=False, detail=str(exc)) else: @@ -78,9 +80,10 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: session_id = await self._hook_runtime.call_first( "resolve_session", message=message ) or self._default_session_id(message) - state = await self._hook_runtime.call_first("load_state", session_id=session_id) or {} - if not isinstance(state, dict): - state = {} + state = {} + for hook_state in reversed(self._hook_runtime.call_many_sync("load_state", session_id=session_id)): + if isinstance(hook_state, dict): + state.update(hook_state) prompt = await self._hook_runtime.call_first( "build_prompt", message=message, session_id=session_id, state=state ) diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py index dc9aaa5f..be5004f2 100644 --- a/src/bub/hookspecs.py +++ b/src/bub/hookspecs.py @@ -5,6 +5,8 @@ from typing import Any import pluggy +from republic import AsyncTapeStore, Tool +from republic.tape import TapeStore from bub.bus import BusProtocol from bub.types import Envelope, State @@ -22,24 +24,29 @@ def provide_bus(self) -> BusProtocol | None: """Provide a bus instance for inbound/outbound envelopes.""" @hookspec(firstresult=True) - def normalize_inbound(self, message: Envelope) -> Envelope | None: + def normalize_inbound(self, message: Envelope) -> Envelope: """Normalize or rewrite one inbound message.""" + ... @hookspec(firstresult=True) - def resolve_session(self, message: Envelope) -> str | None: + def resolve_session(self, message: Envelope) -> str: """Resolve session id for one inbound message.""" + ... @hookspec(firstresult=True) - def load_state(self, session_id: str) -> State | None: + def load_state(self, session_id: str) -> State: """Load state snapshot for one session.""" + ... @hookspec(firstresult=True) - def build_prompt(self, message: Envelope, session_id: str, state: State) -> str | None: + def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: """Build model prompt for this turn.""" + ... @hookspec(firstresult=True) - def run_model(self, prompt: str, session_id: str, state: State) -> str | None: + def run_model(self, prompt: str, session_id: str, state: State) -> str: """Run model for one turn and return plain text output.""" + ... @hookspec def save_state( @@ -58,12 +65,14 @@ def render_outbound( session_id: str, state: State, model_output: str, - ) -> list[Envelope] | None: + ) -> list[Envelope]: """Render outbound messages from model output.""" + ... @hookspec - def dispatch_outbound(self, message: Envelope) -> bool | None: + def dispatch_outbound(self, message: Envelope) -> bool: """Dispatch one outbound message to external channel(s).""" + ... @hookspec def register_cli_commands(self, app: Any) -> None: @@ -72,3 +81,18 @@ def register_cli_commands(self, app: Any) -> None: @hookspec def on_error(self, stage: str, error: Exception, message: Envelope | None) -> None: """Observe framework errors from any stage.""" + + @hookspec + def system_prompt(self, state: State) -> str: + """Provide a system prompt to be prepended to all model prompts.""" + ... + + @hookspec + def provide_tools(self) -> dict[str, Tool]: + """Return a dict of tools to be registered in the framework's tool registry.""" + ... + + @hookspec(firstresult=True) + def provide_tape_store(self) -> TapeStore | AsyncTapeStore: + """Provide a tape store instance for Bub's conversation recording feature.""" + ... diff --git a/src/bub/skills.py b/src/bub/skills.py index b0b94879..266777ad 100644 --- a/src/bub/skills.py +++ b/src/bub/skills.py @@ -9,7 +9,7 @@ import yaml -PROJECT_SKILLS_DIR = ".agent/skills" +PROJECT_SKILLS_DIR = ".agents/skills" SKILL_FILE_NAME = "SKILL.md" SKILL_SOURCES = ("project", "global", "builtin") SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -26,6 +26,14 @@ class SkillMetadata: source: str metadata: dict[str, Any] = field(default_factory=dict) + def body(self) -> str: + front_matter_pattern = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) + try: + content = self.location.read_text(encoding="utf-8") + except OSError: + return "" + return front_matter_pattern.sub("", content, count=1).strip() + def discover_skills(workspace_path: Path) -> list[SkillMetadata]: """Discover skills from project, global, and builtin roots with override precedence.""" @@ -47,20 +55,6 @@ def discover_skills(workspace_path: Path) -> list[SkillMetadata]: return sorted(skills_by_name.values(), key=lambda item: item.name.casefold()) -def load_skill_body(name: str, workspace_path: Path) -> str | None: - """Load full SKILL.md content by skill name.""" - - lowered = name.casefold() - for skill in discover_skills(workspace_path): - if skill.name.casefold() != lowered: - continue - try: - return skill.location.read_text(encoding="utf-8") - except OSError: - return None - return None - - def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: skill_file = skill_dir / SKILL_FILE_NAME if not skill_file.is_file(): diff --git a/uv.lock b/uv.lock index 98d7ade1..3d7401b0 100644 --- a/uv.lock +++ b/uv.lock @@ -18,7 +18,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.79.0" +version = "0.84.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -30,9 +30,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b1/91aea3f8fd180d01d133d931a167a78a3737b3fd39ccef2ae8d6619c24fd/anthropic-0.79.0.tar.gz", hash = "sha256:8707aafb3b1176ed6c13e2b1c9fb3efddce90d17aee5d8b83a86c70dcdcca871", size = 509825, upload-time = "2026-02-07T18:06:18.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/b2/cc0b8e874a18d7da50b0fda8c99e4ac123f23bf47b471827c5f6f3e4a767/anthropic-0.79.0-py3-none-any.whl", hash = "sha256:04cbd473b6bbda4ca2e41dd670fe2f829a911530f01697d0a1e37321eb75f3cf", size = 405918, upload-time = "2026-02-07T18:06:20.246Z" }, + { url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" }, ] [[package]] @@ -56,9 +56,6 @@ wheels = [ anthropic = [ { name = "anthropic" }, ] -vertexai = [ - { name = "google-genai" }, -] [[package]] name = "anyio" @@ -85,22 +82,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] -[[package]] -name = "blinker" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, -] - [[package]] name = "bub" version = "0.3.0" source = { editable = "." } dependencies = [ + { name = "any-llm-sdk", extra = ["anthropic"] }, { name = "apscheduler" }, - { name = "blinker" }, { name = "loguru" }, { name = "pluggy" }, { name = "prompt-toolkit" }, @@ -113,7 +101,6 @@ dependencies = [ { name = "republic" }, { name = "requests" }, { name = "rich" }, - { name = "telegramify-markdown" }, { name = "typer" }, ] @@ -134,8 +121,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "any-llm-sdk", extras = ["anthropic"] }, { name = "apscheduler", specifier = ">=3.11.2" }, - { name = "blinker", specifier = ">=1.8.2" }, { name = "loguru", specifier = ">=0.7.2" }, { name = "pluggy", specifier = ">=1.6.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, @@ -144,11 +131,9 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, - { name = "rapidfuzz", specifier = ">=3.14.1" }, + { name = "rapidfuzz", specifier = ">=3.14.3" }, { name = "republic", specifier = ">=0.5.2" }, - { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=13.0.0" }, - { name = "telegramify-markdown", specifier = ">=0.5.4" }, { name = "typer", specifier = ">=0.9.0" }, ] @@ -802,15 +787,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] -[[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 = "mkdocs" version = "1.6.1" @@ -1391,21 +1367,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/49/897203df2bf680091de74b88692283f421584fa1de7e7b5069efce578020/republic-0.5.2-py3-none-any.whl", hash = "sha256:bfa540e35a2f1cec22ff68fa23703eeb1ee9ccdad8dc946cc8533bfae0989530", size = 39807, upload-time = "2026-03-01T06:33:17.43Z" }, ] -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - [[package]] name = "rich" version = "14.3.2" @@ -1505,27 +1466,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[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 = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - [[package]] name = "tox" version = "4.34.1" From 59889d95e850d78929717265b1b5c30472c39ae8 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Tue, 3 Mar 2026 10:38:25 +0800 Subject: [PATCH 08/39] refactor: remove plugin installation logic and enhance tool handling in the framework Signed-off-by: Frost Ming --- src/bub/builtin/cli.py | 16 ---- src/bub/builtin/engine.py | 63 ++++++++++----- src/bub/builtin/hook_impl.py | 17 ++-- src/bub/builtin/settings.py | 4 +- src/bub/builtin/tools.py | 42 +++++++--- src/bub/hookspecs.py | 6 +- src/bub/skills.py | 48 ++++-------- src/bub/tools.py | 147 +++++++++++++++++++++++++++++++++++ 8 files changed, 252 insertions(+), 91 deletions(-) create mode 100644 src/bub/tools.py diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index b8952d37..ce11f4e3 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -5,8 +5,6 @@ import asyncio import os import shutil -import subprocess -import sys import sysconfig from pathlib import Path from typing import Any @@ -69,20 +67,6 @@ def list_hooks( typer.echo(f"{hook_name}: {', '.join(adapter_names)}") -def install_plugin( - plugin_spec: str = typer.Argument(..., help="Python requirement string or github owner/repo"), -) -> None: - """Install a plugin from PyPI or GitHub repository.""" - if "/" in plugin_spec and not plugin_spec.startswith("git+") and "github.com" not in plugin_spec: - plugin_spec = f"git+https://github.com/{plugin_spec}.git" - if uv_bin := _find_uv(): - typer.echo(f"Installing plugin '{plugin_spec}' with uv...") - subprocess.run([uv_bin, "pip", "install", plugin_spec], check=True) # noqa: S603 - return - typer.echo(f"Installing plugin '{plugin_spec}' with pip...") - subprocess.run([sys.executable, "-m", "pip", "install", "-p", sys.executable, plugin_spec], check=True) # noqa: S603 - - def _find_uv() -> Path | None: """Find uv executable in the system.""" diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/engine.py index 5c948cee..d6fca73a 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/engine.py @@ -1,15 +1,17 @@ -"""Republic-driven runtime battery used by runtime skill.""" +"""Republic-driven runtime engine to process prompts.""" from __future__ import annotations import asyncio import inspect +import re import shlex import time from dataclasses import dataclass from datetime import UTC, datetime from functools import cached_property from pathlib import Path +from typing import Any from pluggy import PluginManager from republic import LLM, AsyncTapeStore, Tool, ToolAutoResult, ToolContext @@ -17,10 +19,13 @@ from bub.builtin.settings import RuntimeSettings from bub.builtin.tape import TapeService +from bub.skills import discover_skills, render_skills_prompt +from bub.tools import model_tools, render_tools_prompt from bub.types import State CONTINUE_PROMPT = "Continue the task." DEFAULT_BUB_HEADERS = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"} +HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") class RuntimeEngine: @@ -33,21 +38,18 @@ def __init__(self, plugins_manager: PluginManager) -> None: tape_store = InMemoryTapeStore() self._llm = _build_llm(self.settings, tape_store) self._pm = plugins_manager - self._tools: list[Tool] | None = None self.tapes = TapeService(self._llm, Path.home() / ".bub" / "tapes") - def _load_tools(self) -> list[Tool]: + @cached_property + def tools(self) -> list[Tool]: tools: dict[str, Tool] = {} for provided in reversed(self._pm.hook.provide_tools()): - if isinstance(provided, dict): - tools.update(provided) + tools.update((tool.name, tool) for tool in provided) return list(tools.values()) @cached_property - def tools(self) -> list[Tool]: - if self._tools is None: - self._tools = self._load_tools() - return self._tools + def model_tools(self) -> list[Tool]: + return model_tools(self.tools) async def run(self, *, session_id: str, prompt: str, state: State) -> str: stripped = prompt.strip() @@ -61,8 +63,8 @@ async def run(self, *, session_id: str, prompt: str, state: State) -> str: return await self._run_model(tape=tape, prompt=stripped) async def _run_command(self, tape: Tape, *, line: str) -> str: - raw_body = line[1:].strip() - if not raw_body: + line = line[1:].strip() + if not line: return "error: empty command" name, arg_tokens = _parse_internal_command(line) @@ -74,7 +76,9 @@ async def _run_command(self, tape: Tape, *, line: str) -> str: output = await tools["bash"].run(context=context, cmd=line) else: args = _parse_args(arg_tokens) - output = tools[name].run(*args.positional, context=context, **args.kwargs) + if tools[name].context: + args.kwargs["context"] = context + output = tools[name].run(*args.positional, **args.kwargs) if inspect.isawaitable(output): output = await output status = "ok" @@ -161,20 +165,32 @@ async def _run_model(self, *, tape: Tape, prompt: str) -> str: return f"error: max_steps_reached={self.settings.max_steps}" + def _load_skills_prompt(self, prompt: str, workspace: Path) -> str: + skill_index = {skill.name: skill for skill in discover_skills(workspace)} + expanded_skills = set(HINT_RE.findall(prompt)) & set(skill_index.keys()) + return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills) + async def _run_tools_once(self, *, tape: Tape, prompt: str) -> ToolAutoResult: - async with asyncio.timeout(self.settings.timeout_seconds): + async with asyncio.timeout(self.settings.model_timeout_seconds): return await tape.run_tools_async( prompt=prompt, - system_prompt=self._system_prompt(state=tape.context.state), + system_prompt=self._system_prompt(prompt, state=tape.context.state), max_tokens=self.settings.max_tokens, - tools=self.tools, + tools=self.model_tools, extra_headers=DEFAULT_BUB_HEADERS, ) - def _system_prompt(self, state: State) -> str: - blocks = [] - for prompt in reversed(self._pm.hook.system_prompt(state=state)): - blocks.append(prompt) + def _system_prompt(self, prompt: str, state: State) -> str: + blocks: list[str] = [] + for result in reversed(self._pm.hook.system_prompt(prompt=prompt, state=state)): + if result: + blocks.append(result) + tools_prompt = render_tools_prompt(self.tools) + if tools_prompt: + blocks.append(tools_prompt) + workspace = workspace_from_state(state) + if skills_prompt := self._load_skills_prompt(prompt, workspace): + blocks.append(skills_prompt) return "\n\n".join(blocks) @@ -212,7 +228,7 @@ def _load_runtime_settings() -> RuntimeSettings: @dataclass(frozen=True) class Args: positional: list[str] - kwargs: dict[str, str] + kwargs: dict[str, Any] def _parse_internal_command(line: str) -> tuple[str, list[str]]: @@ -237,3 +253,10 @@ def _parse_args(args_tokens: list[str]) -> Args: else: positional.append(token) return Args(positional=positional, kwargs=kwargs) + + +def workspace_from_state(state: State) -> Path: + raw = state.get("_runtime_workspace") + if isinstance(raw, str) and raw.strip(): + return Path(raw).expanduser().resolve() + return Path.cwd().resolve() diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 2cc328c3..a79d79e8 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -2,8 +2,9 @@ import typer from pluggy import PluginManager +from republic import Tool -from bub.builtin.engine import RuntimeEngine +from bub.builtin.engine import RuntimeEngine, workspace_from_state from bub.envelope import content_of, field_of, normalize_envelope from bub.hookspecs import hookimpl from bub.types import Envelope, State @@ -62,12 +63,11 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("hooks")(cli.list_hooks) - app.command("install")(cli.install_plugin) @hookimpl - def system_prompt(self, state: State) -> str: + def system_prompt(self, prompt: str, state: State) -> str: # Read the content of AGENTS.md under workspace - prompt_path = _workspace_from_state(state) / AGENTS_FILE_NAME + prompt_path = workspace_from_state(state) / AGENTS_FILE_NAME if not prompt_path.is_file(): return "" try: @@ -75,9 +75,8 @@ def system_prompt(self, state: State) -> str: except OSError: return "" + @hookimpl + def provide_tools(self) -> list[Tool]: + from bub.builtin.tools import get_builtin_tools -def _workspace_from_state(state: State) -> Path: - raw = state.get("_runtime_workspace") - if isinstance(raw, str) and raw.strip(): - return Path(raw).expanduser().resolve() - return Path.cwd().resolve() + return get_builtin_tools() diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 7ee18291..137f74a3 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -5,11 +5,11 @@ class RuntimeSettings(BaseSettings): - model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore") + model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore", env_file=".env") model: str = DEFAULT_MODEL api_key: str | None = None api_base: str | None = None max_steps: int = 50 max_tokens: int = DEFAULT_MAX_TOKENS - timeout_seconds: int | None = None + model_timeout_seconds: int | None = None diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 37b1a6ed..efd4fb20 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -5,7 +5,9 @@ from pathlib import Path from typing import TYPE_CHECKING, cast -from republic import ToolContext, tool +from republic import Tool, ToolContext + +from bub.tools import tool if TYPE_CHECKING: from bub.builtin.engine import RuntimeEngine @@ -43,7 +45,7 @@ async def bash( return stdout_text or "(no output)" -@tool(context=True) +@tool(context=True, name="fs.read") def fs_read(path: str, offset: int = 0, limit: int | None = None, *, context: ToolContext) -> str: """Read a text file and return its content. Supports optional pagination with offset and limit.""" resolved_path = _resolve_path(context, path) @@ -54,7 +56,7 @@ def fs_read(path: str, offset: int = 0, limit: int | None = None, *, context: To return "\n".join(lines[start:end]) -@tool(context=True) +@tool(context=True, name="fs.write") def fs_write(path: str, content: str, *, context: ToolContext) -> str: """Write content to a text file.""" resolved_path = _resolve_path(context, path) @@ -63,7 +65,7 @@ def fs_write(path: str, content: str, *, context: ToolContext) -> str: return f"wrote: {resolved_path}" -@tool(context=True) +@tool(context=True, name="fs.edit") def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolContext) -> str: """Edit a text file by replacing old text with new text. You can specify the line number to start searching for the old text.""" resolved_path = _resolve_path(context, path) @@ -77,15 +79,17 @@ def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolConte return f"edited: {resolved_path}" -@tool(context=True) +@tool(context=True, name="tape.info") async def tape_info(context: ToolContext) -> str: + """Get information about the current tape, such as number of entries and anchors.""" runtime = _get_runtime(context) info = await runtime.tapes.info(context.tape or "") return f"name: {info.name}\nentries: {info.entries}\nanchors: {info.anchors}\nlast_anchor: {info.last_anchor}" -@tool(context=True) +@tool(context=True, name="tape.search") async def tape_search(query: str, limit: int = 20, *, context: ToolContext) -> str: + """Search for entries in the current tape that match the query. Returns a list of matching entries.""" runtime = _get_runtime(context) entries = await runtime.tapes.search(context.tape or "", query=query, limit=limit) if not entries: @@ -93,22 +97,25 @@ async def tape_search(query: str, limit: int = 20, *, context: ToolContext) -> s return "\n".join(f"- {json.dumps(entry.payload)}" for entry in entries) -@tool(context=True) +@tool(context=True, name="tape.reset") async def tape_reset(archive: bool = False, *, context: ToolContext) -> str: + """Reset the current tape, optionally archiving it.""" runtime = _get_runtime(context) result = await runtime.tapes.reset(context.tape or "", archive=archive) return result -@tool(context=True) +@tool(context=True, name="tape.handoff") async def tape_handoff(name: str = "handoff", summary: str = "", *, context: ToolContext) -> str: + """Add a handoff anchor to the current tape.""" runtime = _get_runtime(context) await runtime.tapes.handoff(context.tape or "", name=name, state={"summary": summary}) return f"anchor added: {name}" -@tool(context=True) +@tool(context=True, name="tape.anchors") async def tape_anchors(*, context: ToolContext) -> str: + """List anchors in the current tape.""" runtime = _get_runtime(context) anchors = await runtime.tapes.anchors(context.tape or "") if not anchors: @@ -118,7 +125,7 @@ async def tape_anchors(*, context: ToolContext) -> str: @tool(name="help") def show_help() -> str: - """List available tools.""" + """Show a help message.""" return ( "Commands use ',' at line start.\n" "Known internal commands:\n" @@ -142,3 +149,18 @@ def _resolve_path(context: ToolContext, raw_path: str) -> Path: if workspace is None: raise ValueError(f"relative path '{raw_path}' is not allowed without a workspace") return (workspace / path).resolve() + + +def get_builtin_tools() -> list[Tool]: + return [ + show_help, + bash, + fs_read, + fs_write, + fs_edit, + tape_info, + tape_search, + tape_reset, + tape_handoff, + tape_anchors, + ] diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py index be5004f2..d20b85fe 100644 --- a/src/bub/hookspecs.py +++ b/src/bub/hookspecs.py @@ -83,13 +83,13 @@ def on_error(self, stage: str, error: Exception, message: Envelope | None) -> No """Observe framework errors from any stage.""" @hookspec - def system_prompt(self, state: State) -> str: + def system_prompt(self, prompt: str, state: State) -> str: """Provide a system prompt to be prepended to all model prompts.""" ... @hookspec - def provide_tools(self) -> dict[str, Tool]: - """Return a dict of tools to be registered in the framework's tool registry.""" + def provide_tools(self) -> list[Tool]: + """Return a list of tools to be registered in the framework's tool registry.""" ... @hookspec(firstresult=True) diff --git a/src/bub/skills.py b/src/bub/skills.py index 266777ad..5b108704 100644 --- a/src/bub/skills.py +++ b/src/bub/skills.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from collections.abc import Collection from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -13,7 +14,6 @@ SKILL_FILE_NAME = "SKILL.md" SKILL_SOURCES = ("project", "global", "builtin") SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") -ALLOWED_FRONTMATTER_FIELDS = frozenset({"name", "description", "license", "compatibility", "metadata", "allowed-tools"}) @dataclass(frozen=True) @@ -103,20 +103,12 @@ def _is_valid_frontmatter(*, skill_dir: Path, metadata: dict[str, object]) -> bo name = metadata.get("name") description = metadata.get("description") return ( - _has_only_supported_fields(metadata) - and _is_valid_name(name=name, skill_dir=skill_dir) + _is_valid_name(name=name, skill_dir=skill_dir) and _is_valid_description(description) - and _is_valid_license(metadata.get("license")) - and _is_valid_compatibility(metadata.get("compatibility")) and _is_valid_metadata_field(metadata.get("metadata")) - and _is_valid_allowed_tools(metadata.get("allowed-tools")) ) -def _has_only_supported_fields(metadata: dict[str, object]) -> bool: - return all(key in ALLOWED_FRONTMATTER_FIELDS for key in metadata) - - def _is_valid_name(*, name: object, skill_dir: Path) -> bool: if not isinstance(name, str): return False @@ -135,21 +127,6 @@ def _is_valid_description(description: object) -> bool: return bool(normalized) and len(normalized) <= 1024 -def _is_valid_license(license_value: object) -> bool: - if license_value is None: - return True - return isinstance(license_value, str) and bool(license_value.strip()) - - -def _is_valid_compatibility(compatibility: object) -> bool: - if compatibility is None: - return True - if not isinstance(compatibility, str): - return False - normalized = compatibility.strip() - return bool(normalized) and len(normalized) <= 500 - - def _is_valid_metadata_field(metadata_field: object) -> bool: if metadata_field is None: return True @@ -158,12 +135,6 @@ def _is_valid_metadata_field(metadata_field: object) -> bool: return all(isinstance(key, str) and isinstance(value, str) for key, value in metadata_field.items()) -def _is_valid_allowed_tools(allowed_tools: object) -> bool: - if allowed_tools is None: - return True - return isinstance(allowed_tools, str) and bool(allowed_tools.strip()) - - def _builtin_skills_root() -> Path: return Path(__file__).resolve().parent.parent / "bub_skills" @@ -178,3 +149,18 @@ def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: elif source == "builtin": roots.append((_builtin_skills_root(), source)) return roots + + +def render_skills_prompt(skills: list[SkillMetadata], expanded_skills: Collection[str] = ()) -> str: + if not skills: + return "" + lines = [""] + for skill in skills: + line = f"- [{skill.name}]({skill.location}): {skill.description}" + if skill.name in expanded_skills: + body = skill.body() + if body: + line += f"\n{body}" + lines.append(line) + lines.append("") + return "\n".join(lines) diff --git a/src/bub/tools.py b/src/bub/tools.py new file mode 100644 index 00000000..dd99bfde --- /dev/null +++ b/src/bub/tools.py @@ -0,0 +1,147 @@ +import inspect +import json +import time +from collections.abc import Callable +from dataclasses import replace +from typing import Any, overload + +from anthropic import BaseModel +from loguru import logger +from republic import Tool +from republic import tool as republic_tool + + +def _add_logging(tool: Tool) -> Tool: + if tool.handler is None: + return tool + + async def wrapped(*args, **kwargs): + call_kwargs = kwargs.copy() + if tool.context: + call_kwargs.pop("context", None) + _log_tool_call(tool.name, args, call_kwargs) + start = time.monotonic() + + try: + result = tool.handler(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + except Exception: + elapsed_time = (time.monotonic() - start) * 1000 + logger.exception("tool.call.error name={} elapsed_time={:.2f}ms", tool.name, elapsed_time) + raise + else: + elapsed_time = (time.monotonic() - start) * 1000 + logger.info("tool.call.success name={} elapsed_time={:.2f}ms", tool.name, elapsed_time) + return result + + return replace(tool, handler=wrapped) + + +def _shorten_text(text: str, width: int = 30, placeholder: str = "...") -> str: + if len(text) <= width: + return text + + # Reserve space for placeholder + available = width - len(placeholder) + if available <= 0: + return placeholder + + return text[:available] + placeholder + + +def _render_value(value: Any) -> str: + try: + rendered = json.dumps(value, ensure_ascii=False) + except TypeError: + rendered = repr(value) + rendered = _shorten_text(rendered, width=100, placeholder="...") + if rendered.startswith('"') and not rendered.endswith('"'): + rendered = rendered + '"' + if rendered.startswith("{") and not rendered.endswith("}"): + rendered = rendered + "}" + if rendered.startswith("[") and not rendered.endswith("]"): + rendered = rendered + "]" + return rendered + + +def _log_tool_call(name: str, args: Any, kwargs: dict[str, Any]) -> None: + params: list[str] = [] + + for value in args: + params.append(_render_value(value)) + for key, value in kwargs.items(): + rendered = _render_value(value) + params.append(f"{key}={rendered}") + params_str = ", ".join(params) + logger.info("tool.call.start name={} {{ {} }}", name, params_str) + + +@overload +def tool( + func: Callable, + *, + name: str | None = ..., + model: type[BaseModel] | None = ..., + description: str | None = ..., + context: bool = ..., +) -> Tool: ... + + +@overload +def tool( + func: None = ..., + *, + name: str | None = ..., + model: type[BaseModel] | None = ..., + description: str | None = ..., + context: bool = ..., +) -> Callable[[Callable], Tool]: ... + + +def tool( + func: Callable | None = None, + *, + name: str | None = None, + model: type[BaseModel] | None = None, + description: str | None = None, + context: bool = False, +) -> Tool | Callable[[Callable], Tool]: + """Decorator to convert a function into a Tool instance.""" + + result = republic_tool( + func=func, + name=name, + model=model, + description=description, + context=context, + ) + if isinstance(result, Tool): + return _add_logging(result) + + def decorator(func: Callable) -> Tool: + return _add_logging(result(func)) + + return decorator + + +def _to_model_name(name: str) -> str: + return name.replace(".", "_") + + +def model_tools(tools: list[Tool]) -> list[Tool]: + """Helper to convert a list of Tool instances into a format accepted by LLMs.""" + return [replace(tool, name=_to_model_name(tool.name)) for tool in tools] + + +def render_tools_prompt(tools: list[Tool]) -> str: + """Render a human-readable description of tools for model prompts.""" + if not tools: + return "" + lines = [] + for tool in tools: + line = f"- {_to_model_name(tool.name)}" + if tool.description: + line += f": {tool.description}" + lines.append(line) + return f"\n{'\n'.join(lines)}\n" From 2ea65fbd61c54e33b67851c1549331d1fe72217d Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Tue, 3 Mar 2026 10:45:37 +0800 Subject: [PATCH 09/39] refactor: add default tape context to engine for improved message handling Signed-off-by: Frost Ming --- src/bub/builtin/context.py | 101 +++++++++++++++++++++++++++++++++++++ src/bub/builtin/engine.py | 2 + 2 files changed, 103 insertions(+) create mode 100644 src/bub/builtin/context.py diff --git a/src/bub/builtin/context.py b/src/bub/builtin/context.py new file mode 100644 index 00000000..63e936e3 --- /dev/null +++ b/src/bub/builtin/context.py @@ -0,0 +1,101 @@ +"""Tape context helpers.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable +from typing import Any + +from republic import TapeContext, TapeEntry + + +def default_tape_context(state: dict[str, Any] | None = None) -> TapeContext: + """Return the default context selection for Bub.""" + + return TapeContext(select=_select_messages, state=state or {}) + + +def _select_messages(entries: Iterable[TapeEntry], _context: TapeContext) -> list[dict[str, Any]]: + messages: list[dict[str, Any]] = [] + pending_calls: list[dict[str, Any]] = [] + + for entry in entries: + if entry.kind == "message": + _append_message_entry(messages, entry) + continue + + if entry.kind == "tool_call": + pending_calls = _append_tool_call_entry(messages, entry) + continue + + if entry.kind == "tool_result": + _append_tool_result_entry(messages, pending_calls, entry) + pending_calls = [] + + return messages + + +def _append_message_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> None: + payload = entry.payload + if isinstance(payload, dict): + messages.append(dict(payload)) + + +def _append_tool_call_entry(messages: list[dict[str, Any]], entry: TapeEntry) -> list[dict[str, Any]]: + calls = _normalize_tool_calls(entry.payload.get("calls")) + if calls: + messages.append({"role": "assistant", "content": "", "tool_calls": calls}) + return calls + + +def _append_tool_result_entry( + messages: list[dict[str, Any]], + pending_calls: list[dict[str, Any]], + entry: TapeEntry, +) -> None: + results = entry.payload.get("results") + if not isinstance(results, list): + return + for index, result in enumerate(results): + messages.append(_build_tool_result_message(result, pending_calls, index)) + + +def _build_tool_result_message( + result: object, + pending_calls: list[dict[str, Any]], + index: int, +) -> dict[str, Any]: + message: dict[str, Any] = {"role": "tool", "content": _render_tool_result(result)} + if index >= len(pending_calls): + return message + + call = pending_calls[index] + call_id = call.get("id") + if isinstance(call_id, str) and call_id: + message["tool_call_id"] = call_id + + function = call.get("function") + if isinstance(function, dict): + name = function.get("name") + if isinstance(name, str) and name: + message["name"] = name + return message + + +def _normalize_tool_calls(value: object) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + calls: list[dict[str, Any]] = [] + for item in value: + if isinstance(item, dict): + calls.append(dict(item)) + return calls + + +def _render_tool_result(result: object) -> str: + if isinstance(result, str): + return result + try: + return json.dumps(result, ensure_ascii=False) + except TypeError: + return str(result) diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/engine.py index d6fca73a..e02916e3 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/engine.py @@ -17,6 +17,7 @@ from republic import LLM, AsyncTapeStore, Tool, ToolAutoResult, ToolContext from republic.tape import InMemoryTapeStore, Tape, TapeStore +from bub.builtin.context import default_tape_context from bub.builtin.settings import RuntimeSettings from bub.builtin.tape import TapeService from bub.skills import discover_skills, render_skills_prompt @@ -218,6 +219,7 @@ def _build_llm(settings: RuntimeSettings, tape_store: TapeStore | AsyncTapeStore api_key=settings.api_key, api_base=settings.api_base, tape_store=tape_store, + context=default_tape_context(), ) From d6d2e543f672b14aa283d0da3407810b103e5a02 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Tue, 3 Mar 2026 11:02:46 +0800 Subject: [PATCH 10/39] refactor: enhance output formatting in CLI and improve logging in tool calls Signed-off-by: Frost Ming --- src/bub/__init__.py | 4 +++- src/bub/builtin/cli.py | 2 +- src/bub/tools.py | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/bub/__init__.py b/src/bub/__init__.py index 41b0ad98..de728f0f 100644 --- a/src/bub/__init__.py +++ b/src/bub/__init__.py @@ -1,6 +1,8 @@ """Bub framework package.""" from bub.framework import BubFramework +from bub.hookspecs import hookimpl +from bub.tools import tool -__all__ = ["BubFramework"] +__all__ = ["BubFramework", "hookimpl", "tool"] __version__ = "0.3.0" diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index ce11f4e3..0c0b80a4 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -50,7 +50,7 @@ def run( rendered = str(field_of(outbound, "content", "")) target_channel = str(field_of(outbound, "channel", "stdout")) target_chat = str(field_of(outbound, "chat_id", "local")) - typer.echo(f"[{target_channel}:{target_chat}] {rendered}") + typer.echo(f"[{target_channel}:{target_chat}]\n{rendered}") def list_hooks( diff --git a/src/bub/tools.py b/src/bub/tools.py index dd99bfde..956ae823 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -73,8 +73,8 @@ def _log_tool_call(name: str, args: Any, kwargs: dict[str, Any]) -> None: for key, value in kwargs.items(): rendered = _render_value(value) params.append(f"{key}={rendered}") - params_str = ", ".join(params) - logger.info("tool.call.start name={} {{ {} }}", name, params_str) + params_str = f" {{ {', '.join(params)} }}" if params else "" + logger.info("tool.call.start name={}{}", name, params_str) @overload From 80144506edccbdc5fc4954d997e9e375c5443a1a Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Tue, 3 Mar 2026 17:32:08 +0800 Subject: [PATCH 11/39] refactor: enhance tape info output with additional details and improve context handling Signed-off-by: Frost Ming --- pyproject.toml | 9 ++++----- src/bub/builtin/engine.py | 12 ++++++++---- src/bub/builtin/tape.py | 24 ++++++++++++++++++------ src/bub/builtin/tools.py | 14 ++++++++++++-- src/bub/hookspecs.py | 16 ++++++++-------- 5 files changed, 50 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee10f4dd..f0d4ea84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,9 +2,11 @@ name = "bub" version = "0.3.0" description = "Bub it. Build it. Batteries-included, hook-first AI framework." -authors = [{ name = "Chojan Shang", email = "psiace@apache.org" }] +authors = [ + { name = "Chojan Shang", email = "psiace@apache.org" }, + { name = "Frost Ming", email = "me@frostming.com" }, +] readme = "README.md" -keywords = ['python'] requires-python = ">=3.12,<4.0" classifiers = [ "Intended Audience :: Developers", @@ -68,7 +70,6 @@ paths = ["src"] [tool.mypy] files = ["src"] -exclude = ['^src/bub/skills/.*$'] disallow_untyped_defs = false disallow_any_unimported = false no_implicit_optional = true @@ -85,7 +86,6 @@ testpaths = ["tests"] target-version = "py312" line-length = 120 fix = true -extend-exclude = ["src/bub/skills/**/scripts/*.py"] [tool.ruff.lint] select = [ @@ -133,7 +133,6 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] -"src/bub/tools/builtin.py" = ["C901"] [tool.ruff.format] preview = true diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/engine.py index e02916e3..51b59d4e 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/engine.py @@ -87,19 +87,20 @@ async def _run_command(self, tape: Tape, *, line: str) -> str: status = "error" output = f"{exc!s}" elapsed_ms = int((time.monotonic() - start) * 1000) + output_text = output if isinstance(output, str) else str(output) event_payload = { "raw": line, "name": name, "status": status, "elapsed_ms": elapsed_ms, - "output": output, + "output": output_text, "date": datetime.now(UTC).isoformat(), } await self.tapes.append_event(tape.name, "command", event_payload) if status == "error": - return f"error: {output}" - return output + return f"error: {output_text}" + return output_text async def _run_model(self, *, tape: Tape, prompt: str) -> str: next_prompt = prompt @@ -139,7 +140,10 @@ async def _run_model(self, *, tape: Tape, prompt: str) -> str: ) return outcome.text if outcome.kind == "continue": - next_prompt = CONTINUE_PROMPT + if "context" in tape.context.state: + next_prompt = f"{CONTINUE_PROMPT} [context: {tape.context.state['context']}]" + else: + next_prompt = CONTINUE_PROMPT await self.tapes.append_event( tape.name, "loop.step", diff --git a/src/bub/builtin/tape.py b/src/bub/builtin/tape.py index 020c4320..7d428ecd 100644 --- a/src/bub/builtin/tape.py +++ b/src/bub/builtin/tape.py @@ -1,9 +1,10 @@ +import contextlib import hashlib import re from dataclasses import asdict from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, cast from pydantic import json from pydantic.dataclasses import dataclass @@ -25,6 +26,7 @@ class TapeInfo: anchors: int last_anchor: str | None entries_since_last_anchor: int + last_token_usage: int | None @dataclass(frozen=True) @@ -46,15 +48,24 @@ async def info(self, tape_name: str) -> TapeInfo: anchors = [entry for entry in entries if entry.kind == "anchor"] last_anchor = anchors[-1].payload.get("name") if anchors else None if last_anchor is not None: - entries_since_last_anchor = sum(1 for entry in entries if entry.id > anchors[-1].id) + entries_since_last_anchor = [entry for entry in entries if entry.id > anchors[-1].id] else: - entries_since_last_anchor = len(entries) + entries_since_last_anchor = entries + last_token_usage: int | None = None + for entry in reversed(entries_since_last_anchor): + if entry.kind == "event" and entry.payload.get("name") == "run": + with contextlib.suppress(AttributeError): + token_usage = entry.payload.get("data", {}).get("usage", {}).get("total_tokens") + if token_usage and isinstance(token_usage, int): + last_token_usage = token_usage + break return TapeInfo( name=tape.name, entries=len(entries), anchors=len(anchors), last_anchor=str(last_anchor) if last_anchor else None, - entries_since_last_anchor=entries_since_last_anchor, + entries_since_last_anchor=len(entries_since_last_anchor), + last_token_usage=last_token_usage, ) async def ensure_bootstrap_anchor(self, tape_name: str) -> None: @@ -98,7 +109,8 @@ async def reset(self, tape_name: str, *, archive: bool = False) -> str: async def handoff(self, tape_name: str, *, name: str, state: dict[str, Any] | None = None) -> list[TapeEntry]: tape = self._llm.tape(tape_name) - return await tape.handoff_async(name, state=state) + entries = await tape.handoff_async(name, state=state) + return cast(list[TapeEntry], entries) async def search(self, tape_name: str, query: str, *, limit: int = 20) -> list[TapeEntry]: normalized_query = query.strip().lower() @@ -161,7 +173,7 @@ def _is_fuzzy_match(normalized_query: str, payload_text: str, meta_text: str) -> async def append_event(self, tape_name: str, name: str, payload: dict[str, Any], **meta: Any) -> None: tape = self._llm.tape(tape_name) - return await tape.append_async(TapeEntry.event(name=name, payload=payload, **meta)) + await tape.append_async(TapeEntry.event(name=name, payload=payload, **meta)) def session_tape(self, session_id: str) -> Tape: tape_name = hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index efd4fb20..b1217a97 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -84,7 +84,14 @@ async def tape_info(context: ToolContext) -> str: """Get information about the current tape, such as number of entries and anchors.""" runtime = _get_runtime(context) info = await runtime.tapes.info(context.tape or "") - return f"name: {info.name}\nentries: {info.entries}\nanchors: {info.anchors}\nlast_anchor: {info.last_anchor}" + return ( + f"name: {info.name}\n" + f"entries: {info.entries}\n" + f"anchors: {info.anchors}\n" + f"last_anchor: {info.last_anchor}\n" + f"entries_since_last_anchor: {info.entries_since_last_anchor}\n" + f"last_token_usage: {info.last_token_usage}" + ) @tool(context=True, name="tape.search") @@ -148,7 +155,10 @@ def _resolve_path(context: ToolContext, raw_path: str) -> Path: return path if workspace is None: raise ValueError(f"relative path '{raw_path}' is not allowed without a workspace") - return (workspace / path).resolve() + if not isinstance(workspace, str | Path): + raise TypeError("runtime workspace must be a filesystem path") + workspace_path = Path(workspace) + return (workspace_path / path).resolve() def get_builtin_tools() -> list[Tool]: diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py index d20b85fe..4b21fb97 100644 --- a/src/bub/hookspecs.py +++ b/src/bub/hookspecs.py @@ -31,22 +31,22 @@ def normalize_inbound(self, message: Envelope) -> Envelope: @hookspec(firstresult=True) def resolve_session(self, message: Envelope) -> str: """Resolve session id for one inbound message.""" - ... + raise NotImplementedError @hookspec(firstresult=True) def load_state(self, session_id: str) -> State: """Load state snapshot for one session.""" - ... + raise NotImplementedError @hookspec(firstresult=True) def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: """Build model prompt for this turn.""" - ... + raise NotImplementedError @hookspec(firstresult=True) def run_model(self, prompt: str, session_id: str, state: State) -> str: """Run model for one turn and return plain text output.""" - ... + raise NotImplementedError @hookspec def save_state( @@ -67,12 +67,12 @@ def render_outbound( model_output: str, ) -> list[Envelope]: """Render outbound messages from model output.""" - ... + raise NotImplementedError @hookspec def dispatch_outbound(self, message: Envelope) -> bool: """Dispatch one outbound message to external channel(s).""" - ... + raise NotImplementedError @hookspec def register_cli_commands(self, app: Any) -> None: @@ -85,12 +85,12 @@ def on_error(self, stage: str, error: Exception, message: Envelope | None) -> No @hookspec def system_prompt(self, prompt: str, state: State) -> str: """Provide a system prompt to be prepended to all model prompts.""" - ... + raise NotImplementedError @hookspec def provide_tools(self) -> list[Tool]: """Return a list of tools to be registered in the framework's tool registry.""" - ... + raise NotImplementedError @hookspec(firstresult=True) def provide_tape_store(self) -> TapeStore | AsyncTapeStore: From 8e486e4e0e2674c9f8be2a5dab287135e32e1464 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 10:37:47 +0800 Subject: [PATCH 12/39] refactor: implement channel architecture with message handling and Telegram integration Signed-off-by: Frost Ming --- src/bub/builtin/hook_impl.py | 79 +++++--- src/bub/bus.py | 50 ----- src/bub/channels/__init__.py | 4 + src/bub/channels/base.py | 28 +++ src/bub/channels/handler.py | 74 +++++++ src/bub/channels/manager.py | 134 +++++++++++++ src/bub/channels/message.py | 26 +++ src/bub/channels/telegram.py | 373 +++++++++++++++++++++++++++++++++++ src/bub/channels/utils.py | 13 +- src/bub/framework.py | 54 ++--- src/bub/hookspecs.py | 24 ++- src/bub/types.py | 9 +- 12 files changed, 737 insertions(+), 131 deletions(-) delete mode 100644 src/bub/bus.py create mode 100644 src/bub/channels/__init__.py create mode 100644 src/bub/channels/base.py create mode 100644 src/bub/channels/handler.py create mode 100644 src/bub/channels/manager.py create mode 100644 src/bub/channels/message.py create mode 100644 src/bub/channels/telegram.py diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index a79d79e8..4aa2bbec 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -1,13 +1,17 @@ +from dataclasses import replace from pathlib import Path import typer -from pluggy import PluginManager +from loguru import logger from republic import Tool from bub.builtin.engine import RuntimeEngine, workspace_from_state -from bub.envelope import content_of, field_of, normalize_envelope +from bub.channels.base import Channel +from bub.channels.message import ChannelMessage +from bub.envelope import content_of, field_of +from bub.hook_runtime import HookRuntime from bub.hookspecs import hookimpl -from bub.types import Envelope, State +from bub.types import Envelope, MessageHandler, OutboundDispatcher, State AGENTS_FILE_NAME = "AGENTS.md" @@ -15,23 +19,18 @@ class BuiltinImpl: """Default hook implementations for basic runtime operations.""" - def __init__(self, plugin_manager: PluginManager) -> None: - self.plugin_manager = plugin_manager - self.engine = RuntimeEngine(plugin_manager) + def __init__( + self, + hooks: HookRuntime, + *, + outbound_dispatcher: OutboundDispatcher | None = None, + ) -> None: + self.hooks = hooks + self.engine = RuntimeEngine(hooks._plugin_manager) + self._outbound_dispatcher = outbound_dispatcher @hookimpl - def normalize_inbound(self, message: Envelope) -> Envelope: - envelope = normalize_envelope(message) - envelope["content"] = str(envelope.get("content", "")).strip() - metadata = envelope.get("metadata") - if not isinstance(metadata, dict): - metadata = {} - metadata.setdefault("listener", "runtime") - envelope["metadata"] = metadata - return envelope - - @hookimpl - def resolve_session(self, message: Envelope) -> str: + def resolve_session(self, message: ChannelMessage) -> str: session_id = field_of(message, "session_id") if session_id is not None and str(session_id).strip(): return str(session_id) @@ -40,18 +39,32 @@ def resolve_session(self, message: Envelope) -> str: return f"{channel}:{chat_id}" @hookimpl - def load_state(self, session_id: str) -> State: - return {"session_id": session_id, "_runtime_engine": self.engine} + async def load_state(self, message: ChannelMessage, session_id: str) -> State: + on_start = field_of(message, "on_start") + if on_start is not None: + await on_start(message) + state = {"session_id": session_id, "_runtime_engine": self.engine} + if context := field_of(message, "context_str"): + state["context"] = context + return state + + @hookimpl + async def save_state(self, session_id: str, state: State, message: ChannelMessage, model_output: str) -> None: + on_finish = field_of(message, "on_finish") + if on_finish is not None: + await on_finish(message) @hookimpl - def build_prompt(self, message: Envelope, session_id: str, state: State) -> str: + def build_prompt(self, message: ChannelMessage, session_id: str, state: State) -> str: _ = session_id workspace = field_of(message, "workspace") if isinstance(workspace, str) and workspace.strip(): state["_runtime_workspace"] = workspace.strip() elif "_runtime_workspace" not in state: state["_runtime_workspace"] = str(Path.cwd()) - return content_of(message) + context = field_of(message, "context_str") + context_prefix = f"{context}\n---\n" if context else "" + return f"{context_prefix}{content_of(message)}" @hookimpl async def run_model(self, prompt: str, session_id: str, state: State) -> str: @@ -80,3 +93,25 @@ def provide_tools(self) -> list[Tool]: from bub.builtin.tools import get_builtin_tools return get_builtin_tools() + + @hookimpl + def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: + from bub.channels.telegram import TelegramChannel + + return [TelegramChannel(on_receive=message_handler)] + + @hookimpl + async def on_error(self, stage: str, error: Exception, message: ChannelMessage | None) -> None: + logger.exception(f"Error at stage '{stage}' with message '{message}': {error}") + if message is not None: + message = replace(message, content=str(error)) + await self.hooks.call_many("dispatch_outbound", message=message) + + @hookimpl + async def dispatch_outbound(self, message: Envelope) -> bool: + content = content_of(message) + session_id = field_of(message, "session_id") + logger.info("session.run.outbound session_id={} content={}", session_id, content) + if self._outbound_dispatcher is None: + return False + return await self._outbound_dispatcher(message) diff --git a/src/bub/bus.py b/src/bub/bus.py deleted file mode 100644 index deee6b7d..00000000 --- a/src/bub/bus.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Minimal async message bus used by Bub framework.""" - -from __future__ import annotations - -import asyncio -from typing import Protocol - -from bub.types import Envelope - - -class BusProtocol(Protocol): - """Minimal async contract for Bub bus providers.""" - - async def publish_inbound(self, message: Envelope) -> None: ... - - async def publish_outbound(self, message: Envelope) -> None: ... - - async def next_inbound(self, timeout_seconds: float | None = None) -> Envelope | None: ... - - async def next_outbound(self, timeout_seconds: float | None = None) -> Envelope | None: ... - - -class MessageBus: - """In-memory async bus for inbound/outbound envelopes.""" - - def __init__(self) -> None: - self._inbound: asyncio.Queue[Envelope] = asyncio.Queue() - self._outbound: asyncio.Queue[Envelope] = asyncio.Queue() - - async def publish_inbound(self, message: Envelope) -> None: - await self._inbound.put(message) - - async def publish_outbound(self, message: Envelope) -> None: - await self._outbound.put(message) - - async def next_inbound(self, timeout_seconds: float | None = None) -> Envelope | None: - if timeout_seconds is None: - return await self._inbound.get() - try: - return await asyncio.wait_for(self._inbound.get(), timeout=timeout_seconds) - except TimeoutError: - return None - - async def next_outbound(self, timeout_seconds: float | None = None) -> Envelope | None: - if timeout_seconds is None: - return await self._outbound.get() - try: - return await asyncio.wait_for(self._outbound.get(), timeout=timeout_seconds) - except TimeoutError: - return None diff --git a/src/bub/channels/__init__.py b/src/bub/channels/__init__.py new file mode 100644 index 00000000..3625cf1d --- /dev/null +++ b/src/bub/channels/__init__.py @@ -0,0 +1,4 @@ +from .base import Channel +from .manager import ChannelManager + +__all__ = ["Channel", "ChannelManager"] diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py new file mode 100644 index 00000000..2217f781 --- /dev/null +++ b/src/bub/channels/base.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod +from typing import ClassVar + +from bub.channels.message import ChannelMessage + + +class Channel(ABC): + """Base class for all channels""" + + name: ClassVar[str] = "base" + + @abstractmethod + async def start(self) -> None: + """Start listening for events and dispatching to handlers.""" + + @abstractmethod + async def stop(self) -> None: + """Stop the channel and clean up resources.""" + + @property + def needs_debounce(self) -> bool: + """Whether this channel needs debounce to prevent overload. Default to False.""" + return False + + async def send(self, message: ChannelMessage) -> None: + """Send a message to the channel. Optional to implement.""" + # Do nothing by default + return diff --git a/src/bub/channels/handler.py b/src/bub/channels/handler.py new file mode 100644 index 00000000..e444aa1d --- /dev/null +++ b/src/bub/channels/handler.py @@ -0,0 +1,74 @@ +import asyncio +from dataclasses import replace + +from loguru import logger + +from bub.channels.message import ChannelMessage +from bub.types import MessageHandler + + +class BufferedMessageHandler: + """A message handler that buffers incoming messages and processes them in batch with debounce and active time window.""" + + def __init__( + self, handler: MessageHandler, *, active_time_window: float, max_wait_seconds: float, debounce_seconds: float + ) -> None: + self._handler = handler + self._pending_prompts: list[str] = [] + self._last_active_time: float | None = None + self._event = asyncio.Event() + self._timer: asyncio.TimerHandle | None = None + self._in_processing: asyncio.Task | None = None + self._message_template: ChannelMessage | None = None + self._loop = asyncio.get_running_loop() + + self.active_time_window = active_time_window + self.max_wait_seconds = max_wait_seconds + self.debounce_seconds = debounce_seconds + + def _reset_timer(self, timeout: float) -> None: + self._event.clear() + if self._timer: + self._timer.cancel() + self._timer = self._loop.call_later(timeout, self._event.set) + + async def _process(self) -> None: + await self._event.wait() + content = "\n".join(self._pending_prompts) + self._pending_prompts.clear() + self._in_processing = None + assert self._message_template is not None # noqa: S101 + message = replace(self._message_template, content=content) + await self._handler(message) + + async def __call__(self, message: ChannelMessage) -> None: + if self._message_template is None: + self._message_template = message + now = self._loop.time() + if not message.is_active and ( + self._last_active_time is None or now - self._last_active_time > self.active_time_window + ): + self._last_active_time = None + logger.info( + "session.message received ignored session_id={}, content={}", message.session_id, message.content + ) + return + if message.content.startswith(","): + logger.info( + "session.message received command session_id={}, content={}", message.session_id, message.content + ) + await self._handler(message) + return + self._pending_prompts.append(message.content) + if message.is_active: + self._last_active_time = now + logger.info( + "session.message received active session_id={}, content={}", message.session_id, message.content + ) + self._reset_timer(self.debounce_seconds) + if self._in_processing is None: + self._in_processing = asyncio.create_task(self._process()) + elif self._last_active_time is not None and self._in_processing is None: + logger.info("session.receive followup session_id={} message={}", message.session_id, message.content) + self._reset_timer(self.max_wait_seconds) + self._in_processing = asyncio.create_task(self._process()) diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py new file mode 100644 index 00000000..16420aa7 --- /dev/null +++ b/src/bub/channels/manager.py @@ -0,0 +1,134 @@ +import asyncio +import contextlib +from collections.abc import Collection + +from loguru import logger +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from bub.channels.base import Channel +from bub.channels.handler import BufferedMessageHandler +from bub.channels.message import ChannelMessage +from bub.envelope import content_of, field_of +from bub.framework import BubFramework +from bub.types import Envelope, MessageHandler + + +class ChannelSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="BUB_", extra="ignore", env_file=".env") + + enabled_channels: str = Field( + default="all", description="Comma-separated list of enabled channels, or 'all' for all channels." + ) + debounce_seconds: float = Field( + default=1.0, + description="Minimum seconds between processing two messages from the same channel to prevent overload.", + ) + max_wait_seconds: float = Field( + default=10.0, + description="Maximum seconds to wait for processing before new messages reach the channel.", + ) + active_time_window: float = Field( + default=60.0, + description="Time window in seconds to consider a channel active for processing messages.", + ) + + +class ChannelManager: + def __init__(self, framework: BubFramework, enabled_channels: Collection[str] | None = None) -> None: + self.framework = framework + self._channels: dict[str, Channel] = {} + self._settings = ChannelSettings() + if enabled_channels is not None: + self._enabled_channels = list(enabled_channels) + else: + self._enabled_channels = self._settings.enabled_channels.split(",") + self._messages = asyncio.Queue[ChannelMessage]() + self._ongoing_tasks: set[asyncio.Task] = set() + self._session_handlers: dict[str, MessageHandler] = {} + + async def on_receive(self, message: ChannelMessage) -> None: + channel = message.channel + session_id = message.session_id + if channel not in self._channels: + logger.warning(f"Received message from unknown channel '{channel}', ignoring.") + return + if session_id not in self._session_handlers: + handler: MessageHandler + if self._channels[channel].needs_debounce: + handler = BufferedMessageHandler( + self._messages.put, + active_time_window=self._settings.active_time_window, + max_wait_seconds=self._settings.max_wait_seconds, + debounce_seconds=self._settings.debounce_seconds, + ) + else: + handler = self._messages.put + self._session_handlers[session_id] = handler + await self._session_handlers[session_id](message) + + async def dispatch(self, message: Envelope) -> bool: + channel_name = field_of(message, "channel") + if channel_name is None: + return False + + channel_key = str(channel_name) + channel = self._channels.get(channel_key) + if channel is None: + logger.warning(f"channel.manager outbound ignored unknown channel '{channel_key}'.") + return False + + outbound = ChannelMessage( + session_id=str(field_of(message, "session_id", f"{channel_key}:default")), + channel=channel_key, + chat_id=str(field_of(message, "chat_id", "default")), + content=content_of(message), + ) + await channel.send(outbound) + return True + + def enabled_channels(self) -> list[Channel]: + if "all" in self._enabled_channels: + return list(self._channels.values()) + return [channel for name, channel in self._channels.items() if name in self._enabled_channels] + + def _load_channels(self) -> None: + for result in reversed( + self.framework._hook_runtime.call_many_sync("provide_channels", message_handler=self.on_receive) + ): + for channel in result: + self._channels[channel.name] = channel + + async def listen_and_run(self) -> None: + self._load_channels() + self.framework.bind_outbound_router(self) + for channel in self.enabled_channels(): + await channel.start() + logger.info("channel.manager started listening") + try: + while True: + message = await self._messages.get() + task = asyncio.create_task(self.framework.process_inbound(message)) + task.add_done_callback(lambda t: self._ongoing_tasks.discard(t)) + self._ongoing_tasks.add(task) + except asyncio.CancelledError: + logger.info("channel.manager received shutdown signal") + except Exception: + logger.exception("channel.manager error") + raise + finally: + self.framework.bind_outbound_router(None) + await self.shutdown() + logger.info("channel.manager stopped") + + async def shutdown(self) -> None: + count = 0 + while self._ongoing_tasks: + task = self._ongoing_tasks.pop() + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + count += 1 + logger.info(f"channel.manager cancelled {count} in-flight tasks") + for channel in self.enabled_channels(): + await channel.stop() diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py new file mode 100644 index 00000000..8dd34e0a --- /dev/null +++ b/src/bub/channels/message.py @@ -0,0 +1,26 @@ +import json +from collections.abc import Callable, Coroutine +from dataclasses import dataclass, field +from typing import Any, Self + + +@dataclass(frozen=True) +class ChannelMessage: + """Structured message data from channels to framework.""" + + session_id: str + channel: str + content: str + chat_id: str = "default" + is_active: bool = False + context: dict[str, Any] = field(default_factory=dict) + on_start: Callable[[Self], Coroutine[None, None, None]] | None = None + on_finish: Callable[[Self], Coroutine[None, None, None]] | None = None + + def __post_init__(self) -> None: + self.context.update({"channel": "$" + self.channel, "chat_id": self.chat_id}) + + @property + def context_str(self) -> str: + """String representation of the context for prompt building.""" + return json.dumps(self.context, ensure_ascii=False)[1:-1] diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py new file mode 100644 index 00000000..1576eb6a --- /dev/null +++ b/src/bub/channels/telegram.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import contextlib +from collections.abc import Callable +from typing import Any, ClassVar + +from loguru import logger +from pydantic import Field, json +from pydantic_settings import BaseSettings, SettingsConfigDict +from telegram import Message, Update +from telegram.ext import Application, CommandHandler, ContextTypes, filters +from telegram.ext import MessageHandler as TelegramMessageHandler + +from bub.channels.base import Channel +from bub.channels.message import ChannelMessage +from bub.channels.utils import exclude_none +from bub.types import MessageHandler + + +class TelegramSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="BUB_TELEGRAM_", extra="ignore", env_file=".env") + + token: str = Field(default="", description="Telegram bot token.") + allow_users: str | None = Field( + default=None, description="Comma-separated list of allowed Telegram user IDs, or empty for no restriction." + ) + allow_chats: str | None = Field( + default=None, description="Comma-separated list of allowed Telegram chat IDs, or empty for no restriction." + ) + proxy: str | None = Field( + default=None, + description="Optional proxy URL for connecting to Telegram API, e.g. 'http://user:pass@host:port' or 'socks5://host:port'.", + ) + + +NO_ACCESS_MESSAGE = "You are not allowed to chat with me. Please deploy your own instance of Bub." + + +def _message_type(message: Message) -> str: + if getattr(message, "text", None): + return "text" + if getattr(message, "photo", None): + return "photo" + if getattr(message, "audio", None): + return "audio" + if getattr(message, "sticker", None): + return "sticker" + if getattr(message, "video", None): + return "video" + if getattr(message, "voice", None): + return "voice" + if getattr(message, "document", None): + return "document" + if getattr(message, "video_note", None): + return "video_note" + return "unknown" + + +class BubMessageFilter(filters.MessageFilter): + GROUP_CHAT_TYPES: ClassVar[set[str]] = {"group", "supergroup"} + + def _content(self, message: Message) -> str: + return (getattr(message, "text", None) or getattr(message, "caption", None) or "").strip() + + def filter(self, message: Message) -> bool | dict[str, list[Any]] | None: + msg_type = _message_type(message) + if msg_type == "unknown": + return False + + # Private chat: process all non-command messages and bot commands. + if message.chat.type == "private": + return True + + # Group chat: only process when explicitly addressed to the bot. + if message.chat.type in self.GROUP_CHAT_TYPES: + bot = message.get_bot() + bot_id = bot.id + bot_username = (bot.username or "").lower() + + mentions_bot = self._mentions_bot(message, bot_id, bot_username) + reply_to_bot = self._is_reply_to_bot(message, bot_id) + + if msg_type != "text" and not getattr(message, "caption", None): + return reply_to_bot + + return mentions_bot or reply_to_bot + + return False + + def _mentions_bot(self, message: Message, bot_id: int, bot_username: str) -> bool: + content = self._content(message).lower() + mentions_by_keyword = "bub" in content or bool(bot_username and f"@{bot_username}" in content) + + entities = [*(getattr(message, "entities", None) or ()), *(getattr(message, "caption_entities", None) or ())] + for entity in entities: + if entity.type == "mention" and bot_username: + mention_text = content[entity.offset : entity.offset + entity.length] + if mention_text.lower() == f"@{bot_username}": + return True + continue + if entity.type == "text_mention" and entity.user and entity.user.id == bot_id: + return True + return mentions_by_keyword + + @staticmethod + def _is_reply_to_bot(message: Message, bot_id: int) -> bool: + reply_to_message = message.reply_to_message + if reply_to_message is None or reply_to_message.from_user is None: + return False + return reply_to_message.from_user.id == bot_id + + +MESSAGE_FILTER = BubMessageFilter() + + +class TelegramChannel(Channel): + name = "telegram" + _app: Application + + def __init__(self, on_receive: MessageHandler) -> None: + self._on_receive = on_receive + self._settings = TelegramSettings() + self._allow_users = {uid.strip() for uid in (self._settings.allow_users or "").split(",") if uid.strip()} + self._allow_chats = {cid.strip() for cid in (self._settings.allow_chats or "").split(",") if cid.strip()} + self._parser = TelegramMessageParser() + + @property + def needs_debounce(self) -> bool: + return True + + async def start(self) -> None: + proxy = self._settings.proxy + logger.info( + "telegram.start allow_users_count={} allow_chats_count={} proxy_enabled={}", + len(self._allow_users), + len(self._allow_chats), + bool(proxy), + ) + builder = Application.builder().token(self._settings.token) + if proxy: + builder = builder.proxy(proxy).get_updates_proxy(proxy) + self._app = builder.build() + self._app.add_handler(CommandHandler("start", self._on_start)) + self._app.add_handler(CommandHandler("bub", self._on_message, has_args=True, block=False)) + self._app.add_handler(TelegramMessageHandler(~filters.COMMAND, self._on_message, block=False)) + await self._app.initialize() + await self._app.start() + updater = self._app.updater + if updater is None: + return + await updater.start_polling(drop_pending_updates=True, allowed_updates=["message"]) + logger.info("telegram.start polling") + + async def stop(self) -> None: + updater = self._app.updater + with contextlib.suppress(Exception): + if updater is not None and updater.running: + await updater.stop() + await self._app.stop() + await self._app.shutdown() + logger.info("telegram.stopped") + + async def send(self, message: ChannelMessage) -> None: + chat_id = message.chat_id + content = message.content + try: + data = json.loads(content) + text = data.get("message", "") + except json.JSONDecodeError: + text = content + if not text.strip(): + return + await self._app.bot.send_message(chat_id=chat_id, text=text) + + async def _on_start(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: + if update.message is None: + return + if self._allow_chats and str(update.message.chat_id) not in self._allow_chats: + await update.message.reply_text(NO_ACCESS_MESSAGE) + return + await update.message.reply_text("Bub is online. Send text to start.") + + async def _on_message(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) -> None: + if update.message is None or update.effective_user is None: + return + chat_id = str(update.message.chat_id) + if self._allow_chats and chat_id not in self._allow_chats: + return + user = update.effective_user + sender_tokens = {str(user.id)} + if user.username: + sender_tokens.add(user.username) + if self._allow_users and sender_tokens.isdisjoint(self._allow_users): + await update.message.reply_text("Access denied.") + return + await self._on_receive(self._build_message(update.message)) + + def _build_message(self, message: Message) -> ChannelMessage: + chat_id = str(message.chat_id) + session_id = f"{self.name}:{chat_id}" + content, metadata = self._parser.parse(message) + if content.startswith("/bub "): + content = content[5:] + + # Pass comma commands directly to the input handler + if content.strip().startswith(","): + return ChannelMessage(session_id=session_id, content=content.strip(), channel=self.name, chat_id=chat_id) + + reply_meta = self._parser.get_reply(message) + if reply_meta: + metadata["reply_to_message"] = reply_meta + + content = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) + is_active = MESSAGE_FILTER.filter(message) is not False + return ChannelMessage( + session_id=session_id, + channel=self.name, + chat_id=chat_id, + content=content, + is_active=is_active, + ) + + +class TelegramMessageParser: + @classmethod + def parse(cls, message: Message) -> tuple[str, dict[str, Any]]: + msg_type = _message_type(message) + content, media = f"[Unsupported message type: {msg_type}]", None + if msg_type == "text": + content, media = getattr(message, "text", None) or "", None + else: + parser = cls._MEDIA_MESSAGE_PARSERS.get(msg_type) + if parser is not None: + content, media = parser(message) + metadata = exclude_none({ + "message_id": message.message_id, + "type": _message_type(message), + "username": message.from_user.username if message.from_user else "", + "full_name": message.from_user.full_name if message.from_user else "", + "sender_id": str(message.from_user.id) if message.from_user else "", + "sender_is_bot": message.from_user.is_bot if message.from_user else None, + "date": message.date.timestamp() if message.date else None, + "media": media, + "caption": getattr(message, "caption", None), + }) + return content, metadata + + @classmethod + def get_reply(cls, message: Message) -> dict[str, Any] | None: + reply_to = message.reply_to_message + if reply_to is None or reply_to.from_user is None: + return None + content, metadata = cls.parse(reply_to) + return {"message": content, **metadata} + + @staticmethod + def _parse_photo(message: Message) -> tuple[str, dict[str, Any] | None]: + caption = getattr(message, "caption", None) or "" + formatted = f"[Photo message] Caption: {caption}" if caption else "[Photo message]" + photos = getattr(message, "photo", None) or [] + if not photos: + return formatted, None + largest = photos[-1] + metadata = exclude_none({ + "file_id": largest.file_id, + "file_size": largest.file_size, + "width": largest.width, + "height": largest.height, + }) + return formatted, metadata + + @staticmethod + def _parse_audio(message: Message) -> tuple[str, dict[str, Any] | None]: + audio = getattr(message, "audio", None) + if audio is None: + return "[Audio]", None + title = audio.title or "Unknown" + performer = audio.performer or "" + duration = audio.duration or 0 + metadata = exclude_none({ + "file_id": audio.file_id, + "file_size": audio.file_size, + "duration": audio.duration, + "title": audio.title, + "performer": audio.performer, + }) + if performer: + return f"[Audio: {performer} - {title} ({duration}s)]", metadata + return f"[Audio: {title} ({duration}s)]", metadata + + @staticmethod + def _parse_sticker(message: Message) -> tuple[str, dict[str, Any] | None]: + sticker = getattr(message, "sticker", None) + if sticker is None: + return "[Sticker]", None + emoji = sticker.emoji or "" + set_name = sticker.set_name or "" + metadata = exclude_none({ + "file_id": sticker.file_id, + "width": sticker.width, + "height": sticker.height, + "emoji": sticker.emoji, + "set_name": sticker.set_name, + "is_animated": sticker.is_animated, + "is_video": sticker.is_video, + }) + if emoji: + return f"[Sticker: {emoji} from {set_name}]", metadata + return f"[Sticker from {set_name}]", metadata + + @staticmethod + def _parse_video(message: Message) -> tuple[str, dict[str, Any] | None]: + video = getattr(message, "video", None) + duration = video.duration if video else 0 + caption = getattr(message, "caption", None) or "" + formatted = f"[Video: {duration}s]" + formatted = f"{formatted} Caption: {caption}" if caption else formatted + if video is None: + return formatted, None + metadata = exclude_none({ + "file_id": video.file_id, + "file_size": video.file_size, + "width": video.width, + "height": video.height, + "duration": video.duration, + }) + return formatted, metadata + + @staticmethod + def _parse_voice(message: Message) -> tuple[str, dict[str, Any] | None]: + voice = getattr(message, "voice", None) + duration = voice.duration if voice else 0 + if voice is None: + return f"[Voice message: {duration}s]", None + metadata = exclude_none({"file_id": voice.file_id, "duration": voice.duration}) + return f"[Voice message: {duration}s]", metadata + + @staticmethod + def _parse_document(message: Message) -> tuple[str, dict[str, Any] | None]: + document = getattr(message, "document", None) + if document is None: + return "[Document]", None + file_name = document.file_name or "unknown" + mime_type = document.mime_type or "unknown" + caption = getattr(message, "caption", None) or "" + formatted = f"[Document: {file_name} ({mime_type})]" + formatted = f"{formatted} Caption: {caption}" if caption else formatted + metadata = exclude_none({ + "file_id": document.file_id, + "file_name": document.file_name, + "file_size": document.file_size, + "mime_type": document.mime_type, + }) + return formatted, metadata + + @staticmethod + def _parse_video_note(message: Message) -> tuple[str, dict[str, Any] | None]: + video_note = getattr(message, "video_note", None) + duration = video_note.duration if video_note else 0 + if video_note is None: + return f"[Video note: {duration}s]", None + metadata = exclude_none({"file_id": video_note.file_id, "duration": video_note.duration}) + return f"[Video note: {duration}s]", metadata + + _MEDIA_MESSAGE_PARSERS: ClassVar[dict[str, Callable[[Message], tuple[str, dict[str, Any] | None]]]] = { + "photo": _parse_photo, + "audio": _parse_audio, + "sticker": _parse_sticker, + "video": _parse_video, + "voice": _parse_voice, + "document": _parse_document, + "video_note": _parse_video_note, + } diff --git a/src/bub/channels/utils.py b/src/bub/channels/utils.py index f92e132f..2285911c 100644 --- a/src/bub/channels/utils.py +++ b/src/bub/channels/utils.py @@ -1,11 +1,6 @@ -"""Channel utility helpers.""" +from typing import Any -from __future__ import annotations - -def resolve_proxy(explicit_proxy: str | None) -> tuple[str | None, str]: - if explicit_proxy: - return explicit_proxy, "explicit" - - # Proxy usage must be opt-in; ignore ambient env vars and OS proxy settings. - return None, "none" +def exclude_none(d: dict[str, Any]) -> dict[str, Any]: + """Exclude None values from a dictionary.""" + return {k: v for k, v in d.items() if v is not None} diff --git a/src/bub/framework.py b/src/bub/framework.py index 3e921c2f..3a2785de 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -4,15 +4,14 @@ from dataclasses import dataclass from pathlib import Path -from typing import Any, cast +from typing import Any import pluggy -from bub.bus import BusProtocol, MessageBus from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs -from bub.types import Envelope, TurnResult +from bub.types import Envelope, OutboundChannelRouter, TurnResult @dataclass(frozen=True) @@ -30,11 +29,12 @@ def __init__(self, workspace: Path) -> None: self._plugin_manager.add_hookspecs(BubHookSpecs) self._hook_runtime = HookRuntime(self._plugin_manager) self._plugin_status: dict[str, PluginStatus] = {} + self._outbound_router: OutboundChannelRouter | None = None def _load_builtin_hooks(self) -> None: from bub.builtin.hook_impl import BuiltinImpl - impl = BuiltinImpl(self._plugin_manager) + impl = BuiltinImpl(self._hook_runtime, outbound_dispatcher=self.dispatch_via_router) try: self._plugin_manager.register(impl, name="builtin") @@ -56,14 +56,6 @@ def load_hooks(self) -> None: else: self._plugin_status[entry_point.name] = PluginStatus(is_success=True) - def create_bus(self) -> BusProtocol: - """Create bus instance from hooks; fallback to default in-memory bus.""" - - provided = self._hook_runtime.call_first_sync("provide_bus") - if self._is_bus_like(provided): - return cast(BusProtocol, provided) - return MessageBus() - def register_cli_commands(self, app: Any) -> None: """Ask skills to register CLI commands.""" @@ -73,15 +65,18 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: """Run one inbound message through hooks and return turn result.""" try: - normalized = await self._hook_runtime.call_first("normalize_inbound", message=inbound) - message = normalized if normalized is not None else inbound + message = inbound if isinstance(message, dict): message.setdefault("workspace", str(self.workspace)) session_id = await self._hook_runtime.call_first( "resolve_session", message=message ) or self._default_session_id(message) + if isinstance(message, dict): + message.setdefault("session_id", session_id) state = {} - for hook_state in reversed(self._hook_runtime.call_many_sync("load_state", session_id=session_id)): + for hook_state in reversed( + await self._hook_runtime.call_many("load_state", message=message, session_id=session_id) + ): if isinstance(hook_state, dict): state.update(hook_state) prompt = await self._hook_runtime.call_first( @@ -117,25 +112,19 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: await self._hook_runtime.notify_error(stage="turn", error=exc, message=inbound) raise - async def handle_bus_once( - self, bus: BusProtocol | None = None, *, timeout_seconds: float | None = None - ) -> TurnResult | None: - """Consume one inbound message from bus and publish generated outbounds.""" - - active_bus = bus or self.create_bus() - inbound = await active_bus.next_inbound(timeout_seconds=timeout_seconds) - if inbound is None: - return None - result = await self.process_inbound(inbound) - for outbound in result.outbounds: - await active_bus.publish_outbound(outbound) - return result - def hook_report(self) -> dict[str, list[str]]: """Return hook implementation summary for diagnostics.""" return self._hook_runtime.hook_report() + def bind_outbound_router(self, router: OutboundChannelRouter | None) -> None: + self._outbound_router = router + + async def dispatch_via_router(self, message: Envelope) -> bool: + if self._outbound_router is None: + return False + return await self._outbound_router.dispatch(message) + @staticmethod def _default_session_id(message: Envelope) -> str: session_id = field_of(message, "session_id") @@ -176,10 +165,3 @@ async def _collect_outbounds( if chat_id is not None: fallback["chat_id"] = chat_id return [fallback] - - @staticmethod - def _is_bus_like(candidate: Any) -> bool: - if candidate is None: - return False - required = ("publish_inbound", "publish_outbound", "next_inbound", "next_outbound") - return all(callable(getattr(candidate, name, None)) for name in required) diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py index 4b21fb97..a40f7913 100644 --- a/src/bub/hookspecs.py +++ b/src/bub/hookspecs.py @@ -2,14 +2,16 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import pluggy from republic import AsyncTapeStore, Tool from republic.tape import TapeStore -from bub.bus import BusProtocol -from bub.types import Envelope, State +from bub.types import Envelope, MessageHandler, State + +if TYPE_CHECKING: + from bub.channels.base import Channel BUB_HOOK_NAMESPACE = "bub" hookspec = pluggy.HookspecMarker(BUB_HOOK_NAMESPACE) @@ -19,22 +21,13 @@ class BubHookSpecs: """Hook contract for Bub framework extensions.""" - @hookspec(firstresult=True) - def provide_bus(self) -> BusProtocol | None: - """Provide a bus instance for inbound/outbound envelopes.""" - - @hookspec(firstresult=True) - def normalize_inbound(self, message: Envelope) -> Envelope: - """Normalize or rewrite one inbound message.""" - ... - @hookspec(firstresult=True) def resolve_session(self, message: Envelope) -> str: """Resolve session id for one inbound message.""" raise NotImplementedError @hookspec(firstresult=True) - def load_state(self, session_id: str) -> State: + def load_state(self, message: Envelope, session_id: str) -> State: """Load state snapshot for one session.""" raise NotImplementedError @@ -96,3 +89,8 @@ def provide_tools(self) -> list[Tool]: def provide_tape_store(self) -> TapeStore | AsyncTapeStore: """Provide a tape store instance for Bub's conversation recording feature.""" ... + + @hookspec + def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: + """Provide a list of channels for receiving messages.""" + raise NotImplementedError diff --git a/src/bub/types.py b/src/bub/types.py index d4755378..917f2132 100644 --- a/src/bub/types.py +++ b/src/bub/types.py @@ -2,11 +2,18 @@ from __future__ import annotations +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any +from typing import Any, Protocol type Envelope = Any type State = dict[str, Any] +type MessageHandler = Callable[[Envelope], Coroutine[Any, Any, None]] +type OutboundDispatcher = Callable[[Envelope], Coroutine[Any, Any, bool]] + + +class OutboundChannelRouter(Protocol): + async def dispatch(self, message: Envelope) -> bool: ... @dataclass(frozen=True) From 61a125f9d89ea1ce11c5f15874ef4e62baacf3dc Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 11:16:01 +0800 Subject: [PATCH 13/39] feat: add Telegram skill for message handling and editing via Bot API Signed-off-by: Frost Ming --- src/bub/builtin/cli.py | 25 ++- src/bub/builtin/hook_impl.py | 30 ++- src/bub/builtin/tools.py | 15 ++ src/bub/channels/manager.py | 4 +- src/bub/channels/message.py | 5 +- src/bub/channels/telegram.py | 1 + src/bub/framework.py | 25 ++- src/bub/skills.py | 2 +- src/bub_skills/telegram/SKILL.md | 126 ++++++++++++ .../telegram/scripts/telegram_edit.py | 105 ++++++++++ .../telegram/scripts/telegram_send.py | 179 ++++++++++++++++++ 11 files changed, 483 insertions(+), 34 deletions(-) create mode 100644 src/bub_skills/telegram/SKILL.md create mode 100644 src/bub_skills/telegram/scripts/telegram_edit.py create mode 100755 src/bub_skills/telegram/scripts/telegram_send.py diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 0c0b80a4..67aee602 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -1,11 +1,9 @@ """Builtin CLI command adapter.""" +# ruff: noqa: B008 from __future__ import annotations import asyncio -import os -import shutil -import sysconfig from pathlib import Path from typing import Any @@ -27,7 +25,7 @@ def _load_framework(workspace: Path | None) -> BubFramework: def run( message: str = typer.Argument(..., help="Inbound message content"), - workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace root"), # noqa: B008 + workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace root"), channel: str = typer.Option("stdout", "--channel", help="Message channel"), chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), sender_id: str = typer.Option("human", "--sender-id", help="Sender id"), @@ -54,7 +52,7 @@ def run( def list_hooks( - workspace: Path | None = typer.Option(None, "--workspace", "-w"), # noqa: B008 + workspace: Path | None = typer.Option(None, "--workspace", "-w"), ) -> None: """Show hook implementation mapping.""" @@ -67,13 +65,14 @@ def list_hooks( typer.echo(f"{hook_name}: {', '.join(adapter_names)}") -def _find_uv() -> Path | None: - """Find uv executable in the system.""" +def message( + workspace: Path | None = typer.Option(None, "--workspace", "-w"), + enable_channels: list[str] = typer.Option([], "--enable-channel", help="Channels to enable for CLI (default: all)"), +) -> None: + """Start message listener(like telegram).""" + from bub.channels.manager import ChannelManager - this_path = sysconfig.get_path("scripts") - path_str = os.getenv("PATH", "") + framework = _load_framework(workspace) - uv_path = shutil.which("uv", path=f"{this_path}{os.pathsep}{path_str}") - if uv_path is not None: - return Path(uv_path) - return None + manager = ChannelManager(framework, enabled_channels=enable_channels or None) + asyncio.run(manager.listen_and_run()) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 4aa2bbec..307df901 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -1,4 +1,3 @@ -from dataclasses import replace from pathlib import Path import typer @@ -76,6 +75,7 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("hooks")(cli.list_hooks) + app.command("message")(cli.message) @hookimpl def system_prompt(self, prompt: str, state: State) -> str: @@ -101,11 +101,16 @@ def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: return [TelegramChannel(on_receive=message_handler)] @hookimpl - async def on_error(self, stage: str, error: Exception, message: ChannelMessage | None) -> None: + async def on_error(self, stage: str, error: Exception, message: Envelope | None) -> None: logger.exception(f"Error at stage '{stage}' with message '{message}': {error}") if message is not None: - message = replace(message, content=str(error)) - await self.hooks.call_many("dispatch_outbound", message=message) + outbound = ChannelMessage( + session_id=field_of(message, "session_id", "unknown"), + channel=field_of(message, "channel", "default"), + chat_id=field_of(message, "chat_id", "default"), + content=f"An error occurred at stage '{stage}': {error}", + ) + await self.hooks.call_many("dispatch_outbound", message=outbound) @hookimpl async def dispatch_outbound(self, message: Envelope) -> bool: @@ -115,3 +120,20 @@ async def dispatch_outbound(self, message: Envelope) -> bool: if self._outbound_dispatcher is None: return False return await self._outbound_dispatcher(message) + + @hookimpl + def render_outbound( + self, + message: Envelope, + session_id: str, + state: State, + model_output: str, + ) -> list[ChannelMessage]: + outbound = ChannelMessage( + session_id=session_id, + channel=field_of(message, "channel", "default"), + chat_id=field_of(message, "chat_id", "default"), + content=model_output, + output_channel=field_of(message, "output_channel", "default"), + ) + return [outbound] diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index b1217a97..bca73aef 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -7,6 +7,7 @@ from republic import Tool, ToolContext +from bub.skills import discover_skills from bub.tools import tool if TYPE_CHECKING: @@ -79,6 +80,19 @@ def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolConte return f"edited: {resolved_path}" +@tool(context=True, name="skill.load") +def skill_load(name: str, *, context: ToolContext) -> str: + from bub.builtin.engine import workspace_from_state + + """Load a skill by name. The skill must be located in the 'skills' directory under the workspace and have a valid frontmatter.""" + workspace = workspace_from_state(context.state) + skill_index = {skill.name: skill for skill in discover_skills(workspace)} + if name.casefold() not in skill_index: + return "(no such skill)" + skill = skill_index[name.casefold()] + return skill.body() or "(skill has no body)" + + @tool(context=True, name="tape.info") async def tape_info(context: ToolContext) -> str: """Get information about the current tape, such as number of entries and anchors.""" @@ -137,6 +151,7 @@ def show_help() -> str: "Commands use ',' at line start.\n" "Known internal commands:\n" " ,help\n" + " ,skill.load name=foo\n" " ,tape.info\n" " ,tape.search query=error\n" " ,tape.handoff name=phase-1 summary='done'\n" diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 16420aa7..1222b732 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -75,7 +75,6 @@ async def dispatch(self, message: Envelope) -> bool: channel_key = str(channel_name) channel = self._channels.get(channel_key) if channel is None: - logger.warning(f"channel.manager outbound ignored unknown channel '{channel_key}'.") return False outbound = ChannelMessage( @@ -89,7 +88,8 @@ async def dispatch(self, message: Envelope) -> bool: def enabled_channels(self) -> list[Channel]: if "all" in self._enabled_channels: - return list(self._channels.values()) + # Exclude 'cli' channel from 'all' to prevent interference with other channels + return [channel for name, channel in self._channels.items() if name != "cli"] return [channel for name, channel in self._channels.items() if name in self._enabled_channels] def _load_channels(self) -> None: diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py index 8dd34e0a..eb26cf39 100644 --- a/src/bub/channels/message.py +++ b/src/bub/channels/message.py @@ -4,7 +4,7 @@ from typing import Any, Self -@dataclass(frozen=True) +@dataclass class ChannelMessage: """Structured message data from channels to framework.""" @@ -16,9 +16,12 @@ class ChannelMessage: context: dict[str, Any] = field(default_factory=dict) on_start: Callable[[Self], Coroutine[None, None, None]] | None = None on_finish: Callable[[Self], Coroutine[None, None, None]] | None = None + output_channel: str = "" def __post_init__(self) -> None: self.context.update({"channel": "$" + self.channel, "chat_id": self.chat_id}) + if not self.output_channel: # output to the same channel by default + self.output_channel = self.channel @property def context_str(self) -> str: diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 1576eb6a..4a94b226 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -218,6 +218,7 @@ def _build_message(self, message: Message) -> ChannelMessage: chat_id=chat_id, content=content, is_active=is_active, + output_channel="null", # disable outbound for telegram messages ) diff --git a/src/bub/framework.py b/src/bub/framework.py index 3a2785de..1b852695 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -65,25 +65,24 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: """Run one inbound message through hooks and return turn result.""" try: - message = inbound - if isinstance(message, dict): - message.setdefault("workspace", str(self.workspace)) + if isinstance(inbound, dict): + inbound.setdefault("workspace", str(self.workspace)) session_id = await self._hook_runtime.call_first( - "resolve_session", message=message - ) or self._default_session_id(message) - if isinstance(message, dict): - message.setdefault("session_id", session_id) + "resolve_session", message=inbound + ) or self._default_session_id(inbound) + if isinstance(inbound, dict): + inbound.setdefault("session_id", session_id) state = {} for hook_state in reversed( - await self._hook_runtime.call_many("load_state", message=message, session_id=session_id) + await self._hook_runtime.call_many("load_state", message=inbound, session_id=session_id) ): if isinstance(hook_state, dict): state.update(hook_state) prompt = await self._hook_runtime.call_first( - "build_prompt", message=message, session_id=session_id, state=state + "build_prompt", message=inbound, session_id=session_id, state=state ) if not prompt: - prompt = content_of(message) + prompt = content_of(inbound) model_output = await self._hook_runtime.call_first( "run_model", prompt=prompt, session_id=session_id, state=state ) @@ -91,7 +90,7 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: await self._hook_runtime.notify_error( stage="run_model:fallback", error=RuntimeError("no model skill returned output"), - message=message, + message=inbound, ) model_output = prompt else: @@ -101,10 +100,10 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: "save_state", session_id=session_id, state=state, - message=message, + message=inbound, model_output=model_output, ) - outbounds = await self._collect_outbounds(message, session_id, state, model_output) + outbounds = await self._collect_outbounds(inbound, session_id, state, model_output) for outbound in outbounds: await self._hook_runtime.call_many("dispatch_outbound", message=outbound) return TurnResult(session_id=session_id, prompt=prompt, model_output=model_output, outbounds=outbounds) diff --git a/src/bub/skills.py b/src/bub/skills.py index 5b108704..eff1cda8 100644 --- a/src/bub/skills.py +++ b/src/bub/skills.py @@ -156,7 +156,7 @@ def render_skills_prompt(skills: list[SkillMetadata], expanded_skills: Collectio return "" lines = [""] for skill in skills: - line = f"- [{skill.name}]({skill.location}): {skill.description}" + line = f"- {skill.name}: {skill.description}" if skill.name in expanded_skills: body = skill.body() if body: diff --git a/src/bub_skills/telegram/SKILL.md b/src/bub_skills/telegram/SKILL.md new file mode 100644 index 00000000..2101cd1f --- /dev/null +++ b/src/bub_skills/telegram/SKILL.md @@ -0,0 +1,126 @@ +--- +name: telegram +description: | + Telegram Bot skill for sending and editing Telegram messages via Bot API. + Use when Bub needs to: (1) Send a message to a Telegram user/group/channel, + (2) Reply to a specific Telegram message with reply_to_message_id, + (3) Edit an existing Telegram message, or (4) Push proactive Telegram notifications + when working outside an active Telegram session. +metadata: + channel: telegram +--- + +# Telegram Skill + +Agent-facing execution guide for Telegram outbound communication. + +Assumption: `BUB_TELEGRAM_TOKEN` is already available. + +## Required Inputs + +Collect these before execution: + +- `chat_id` (required) +- message content (required for send/edit) +- `reply_to_message_id` (required for threaded reply behavior) +- `message_id` (required for edit) + +## Execution Policy + +1. If handling a direct user message in Telegram and `message_id` is known, prefer reply mode (`--reply-to`). +2. If source metadata says sender is a bot (`sender_is_bot=true`), do not use reply mode. +3. In the bot-source case, send a normal message and prefix content with `@` (or the provided source username). +4. For long-running tasks, optionally send one progress message, then edit that same message for final status. +5. Use literal newlines in message text when line breaks are needed. +6. Avoid emitting HTML tags in message content; use Markdown for formatting instead. + +## Active Response Policy + +When this skill is in scope, prefer proactive and timely Telegram updates: + +- Send an immediate acknowledgment for newly assigned tasks +- Send progress updates for long-running operations using message edits +- Send completion notifications when work finishes +- Send important status or failure notifications without waiting for follow-up prompts +- If execution is blocked or fails, send a problem report immediately with cause, impact, and next action + +Recommended pattern: + +1. Send a short acknowledgment reply +2. Continue processing +3. If blocked, edit or send an issue update immediately +4. Edit the acknowledgment message with final result when possible + +## Voice Message Policy + +When the inbound Telegram message is voice: + +1. Transcribe the voice input first (use STT skill if available) +2. Prepare response content based on transcription +3. Prefer voice response output (use TTS skill if available) +4. If voice output is unavailable, send a concise text fallback and state limitation + +## Reaction Policy + +When an inbound Telegram message warrants acknowledgment but does not merit a full reply, use a Telegram reaction as the response. +But when any explanation or details are needed, use a normal reply instead. + +## Command Templates + +Paths are relative to this skill directory. + +```bash +# Send message +uv run ./scripts/telegram_send.py \ + --chat-id \ + --message "" + +# Send reply to a specific message +uv run ./scripts/telegram_send.py \ + --chat-id \ + --message "" \ + --reply-to + +# Source message sender is bot: no direct reply, use @user_id style +uv run ./scripts/telegram_send.py \ + --chat-id \ + --message "" \ + --source-is-bot \ + --source-username + +# Edit existing message +uv run ./scripts/telegram_edit.py \ + --chat-id \ + --message-id \ + --text "" +``` + +For other actions that not covered by these scripts, use `curl` to call Telegram Bot API directly with the provided token. + +## Script Interface Reference + +### `telegram_send.py` + +- `--chat-id`, `-c`: required, supports comma-separated ids +- `--message`, `-m`: required +- `--reply-to`, `-r`: optional +- `--token`, `-t`: optional (normally not needed) +- `--source-is-bot`: optional flag, disables reply mode and switches to `@user_id` style +- `--source-user-id`: optional, required when `--source-is-bot` is set + +### `telegram_edit.py` + +- `--chat-id`, `-c`: required +- `--message-id`, `-m`: required +- `--text`, `-t`: required +- `--token`: optional (normally not needed) + +## Failure Handling + +- On HTTP errors, inspect API response text and adjust identifiers/permissions. +- If edit fails because message is not editable, fall back to a new send. +- If reply target is invalid, resend without `--reply-to` only when context threading is non-critical. +- For task-level failures (not only API failures), notify the Telegram user with: + - what failed + - what was already completed + - what will happen next (retry/manual action/escalation) diff --git a/src/bub_skills/telegram/scripts/telegram_edit.py b/src/bub_skills/telegram/scripts/telegram_edit.py new file mode 100644 index 00000000..1daf3893 --- /dev/null +++ b/src/bub_skills/telegram/scripts/telegram_edit.py @@ -0,0 +1,105 @@ +#!/usr/bin/env uv run +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "requests>=2.31.0", +# "telegramify-markdown>=0.5.0", +# ] +# /// + +""" +Telegram Bot Message Editor + +Edit an existing message via Telegram Bot API. +Uses telegramify_markdown to convert markdown to Telegram MarkdownV2 format. + +""" + +import argparse +import os +import sys + +import requests + +try: + from telegramify_markdown import markdownify +except ImportError: + print("❌ Error: telegramify_markdown not installed. Run: pip install telegramify-markdown") + sys.exit(1) + + +def unescape_newlines(text: str) -> str: + """ + Convert escaped newline sequences to real newlines. + Handles \\n -> \n, \\r\\n -> \r\n, etc. + """ + # First unescape \\n to real newline + result = text.replace("\\n", "\n") + result = result.replace("\\r\\n", "\r\n") + result = result.replace("\\r", "\r") + return result + + +def edit_message(bot_token: str, chat_id: str, message_id: int, text: str) -> dict: + """ + Edit an existing message via Telegram Bot API. + + Args: + bot_token: Telegram bot token + chat_id: Target chat ID + message_id: ID of the message to edit + text: New message text (will be converted to MarkdownV2) + + Returns: + API response as dict + """ + url = f"https://api.telegram.org/bot{bot_token}/editMessageText" + + # Unescape \\n sequences to real newlines (bash/argparse converts real newlines to \\n) + text = unescape_newlines(text) + + # Convert markdown to Telegram MarkdownV2 format + converted_text = markdownify(text).rstrip("\n") + + payload = { + "chat_id": chat_id, + "message_id": message_id, + "text": converted_text, + "parse_mode": "MarkdownV2", + } + + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + + return response.json() + + +def main(): + parser = argparse.ArgumentParser(description="Edit an existing message via Telegram Bot API") + parser.add_argument("--chat-id", "-c", required=True, help="Target chat ID") + parser.add_argument("--message-id", "-m", type=int, required=True, help="ID of the message to edit") + parser.add_argument("--text", "-t", required=True, help="New message text (markdown supported)") + parser.add_argument("--token", help="Bot token (defaults to BUB_TELEGRAM_TOKEN env var)") + + args = parser.parse_args() + + # Get bot token + bot_token = args.token or os.environ.get("BUB_TELEGRAM_TOKEN") + if not bot_token: + print("❌ Error: Bot token required. Set BUB_TELEGRAM_TOKEN env var or use --token") + sys.exit(1) + + try: + edit_message(bot_token, args.chat_id, args.message_id, args.text) + print(f"✅ Message {args.message_id} edited successfully") + except requests.HTTPError as e: + print(f"❌ HTTP Error: {e}") + print(f" Response: {e.response.text}") + sys.exit(1) + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/bub_skills/telegram/scripts/telegram_send.py b/src/bub_skills/telegram/scripts/telegram_send.py new file mode 100755 index 00000000..95a2aee2 --- /dev/null +++ b/src/bub_skills/telegram/scripts/telegram_send.py @@ -0,0 +1,179 @@ +#!/usr/bin/env uv run +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "requests>=2.31.0", +# "telegramify-markdown>=0.5.0", +# ] +# /// + +""" +Telegram Bot Message Sender + +A simple script to send messages via Telegram Bot API. +Uses telegramify_markdown to convert markdown to Telegram MarkdownV2 format. +""" + +import argparse +import os +import sys + +import requests + +try: + from telegramify_markdown import markdownify +except ImportError: + print("❌ Error: telegramify_markdown not installed. Run: pip install telegramify-markdown") + sys.exit(1) + + +def unescape_newlines(text: str) -> str: + """ + Convert escaped newline sequences to real newlines. + Handles \\n -> \n, \\r\\n -> \r\n, etc. + """ + # First unescape \\n to real newline + result = text.replace("\\n", "\n") + result = result.replace("\\r\\n", "\r\n") + result = result.replace("\\r", "\r") + return result + + +def edit_message(bot_token: str, chat_id: str, message_id: int, text: str) -> dict: + """ + Edit an existing message via Telegram Bot API. + + Uses telegramify_markdown to convert text to MarkdownV2 format. + + Args: + bot_token: Telegram bot token + chat_id: Target chat ID + message_id: ID of the message to edit + text: New message text (will be converted to MarkdownV2) + + Returns: + API response as dict + """ + url = f"https://api.telegram.org/bot{bot_token}/editMessageText" + + # Convert markdown to Telegram MarkdownV2 format + converted_text = markdownify(text) + + payload = { + "chat_id": chat_id, + "message_id": message_id, + "text": converted_text, + "parse_mode": "MarkdownV2", + } + + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + + return response.json() + + +def send_message( + bot_token: str, + chat_id: str, + text: str, + reply_to_message_id: int | None = None, + mention_username: str | None = None, +) -> dict: + """ + Send a message via Telegram Bot API. + + Uses telegramify_markdown to convert text to MarkdownV2 format. + + Args: + bot_token: Telegram bot token + chat_id: Target chat ID + text: Message text (will be converted to MarkdownV2) + reply_to_message_id: Optional message ID to reply to + mention_username: Optional username to prefix with @ mention style + + Returns: + API response as dict + """ + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + + # Unescape \\n sequences to real newlines (bash/argparse converts real newlines to \\n) + text = unescape_newlines(text) + if mention_username: + text = f"@{mention_username} {text}" + + # Convert markdown to Telegram MarkdownV2 format + converted_text = markdownify(text).rstrip("\n") + + payload = { + "chat_id": chat_id, + "text": converted_text, + "parse_mode": "MarkdownV2", + } + + if reply_to_message_id: + payload["reply_to_message_id"] = reply_to_message_id + + response = requests.post(url, json=payload, timeout=30) + if response.status_code == 400 and reply_to_message_id: + payload.pop("reply_to_message_id", None) + response = requests.post(url, json=payload, timeout=30) + response.raise_for_status() + + return response.json() + + +def main(): + parser = argparse.ArgumentParser(description="Send messages via Telegram Bot API (auto-converts to MarkdownV2)") + parser.add_argument("--chat-id", "-c", required=True, help="Target chat ID") + parser.add_argument( + "--message", + "-m", + required=True, + help="Message text to send (markdown supported, will be converted to MarkdownV2)", + ) + parser.add_argument("--token", "-t", help="Bot token (defaults to BUB_TELEGRAM_TOKEN env var)") + parser.add_argument("--reply-to", "-r", type=int, help="Message ID to reply to (creates threaded conversation)") + parser.add_argument( + "--source-is-bot", + action="store_true", + help="Set when source message sender is a bot; disables reply mode and switches to @username style send", + ) + parser.add_argument( + "--source-username", + help="Source username for @username prefix when --source-is-bot is enabled", + ) + + args = parser.parse_args() + + # Get bot token + bot_token = args.token or os.environ.get("BUB_TELEGRAM_TOKEN") + if not bot_token: + print("❌ Error: Bot token required. Set BUB_TELEGRAM_TOKEN env var or use --token") + sys.exit(1) + + # Parse chat IDs + chat_id = args.chat_id.strip() + reply_to = args.reply_to + mention_username = None + if args.source_is_bot: + if not args.source_username: + print("❌ Error: --source-username is required when --source-is-bot is enabled") + sys.exit(1) + reply_to = None + mention_username = args.source_username + + # Send messages + try: + send_message(bot_token, chat_id, args.message, reply_to, mention_username) + print(f"✅ Message sent successfully to {chat_id} (MarkdownV2)") + except requests.HTTPError as e: + print(f"❌ HTTP Error: {e}") + print(f" Response: {e.response.text}") + sys.exit(1) + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() From a1870a751161917a4dbd74e7bebbedacc3ef043a Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 13:22:57 +0800 Subject: [PATCH 14/39] feat: add CLI channel for interactive chat sessions and enhance message handling Signed-off-by: Frost Ming --- src/bub/builtin/cli.py | 19 ++++ src/bub/builtin/engine.py | 44 +++++---- src/bub/builtin/hook_impl.py | 28 ++++-- src/bub/builtin/settings.py | 6 ++ src/bub/channels/base.py | 3 +- src/bub/channels/cli/__init__.py | 162 +++++++++++++++++++++++++++++++ src/bub/channels/cli/renderer.py | 46 +++++++++ src/bub/channels/manager.py | 15 ++- src/bub/channels/message.py | 11 ++- src/bub/channels/telegram.py | 3 +- src/bub/channels/utils.py | 16 +++ src/bub/framework.py | 39 ++++---- src/bub/hook_runtime.py | 24 +---- 13 files changed, 341 insertions(+), 75 deletions(-) create mode 100644 src/bub/channels/cli/__init__.py create mode 100644 src/bub/channels/cli/renderer.py diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 67aee602..73889aa8 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -76,3 +76,22 @@ def message( manager = ChannelManager(framework, enabled_channels=enable_channels or None) asyncio.run(manager.listen_and_run()) + + +def chat( + workspace: Path | None = typer.Option(None, "--workspace", "-w"), + chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), + session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), +) -> None: + """Start a REPL chat session.""" + from bub.channels.manager import ChannelManager + + framework = _load_framework(workspace) + + manager = ChannelManager(framework, enabled_channels=["cli"]) + channel = manager.get_channel("cli") + if channel is None: + typer.echo("CLI channel not found. Please check your hook implementations.") + raise typer.Exit(1) + channel.set_metadata(chat_id=chat_id, session_id=session_id) # type: ignore[attr-defined] + asyncio.run(manager.listen_and_run()) diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/engine.py index 51b59d4e..f193e693 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/engine.py @@ -39,7 +39,7 @@ def __init__(self, plugins_manager: PluginManager) -> None: tape_store = InMemoryTapeStore() self._llm = _build_llm(self.settings, tape_store) self._pm = plugins_manager - self.tapes = TapeService(self._llm, Path.home() / ".bub" / "tapes") + self.tapes = TapeService(self._llm, self.settings.home / "tapes") @cached_property def tools(self) -> list[Tool]: @@ -66,12 +66,14 @@ async def run(self, *, session_id: str, prompt: str, state: State) -> str: async def _run_command(self, tape: Tape, *, line: str) -> str: line = line[1:].strip() if not line: - return "error: empty command" + raise ValueError("empty command") name, arg_tokens = _parse_internal_command(line) start = time.monotonic() context = ToolContext(tape=tape.name, run_id="run_command", state=tape.context.state) tools = {tool.name: tool for tool in self.tools} + output = "" + status = "ok" try: if name not in tools: output = await tools["bash"].run(context=context, cmd=line) @@ -82,25 +84,25 @@ async def _run_command(self, tape: Tape, *, line: str) -> str: output = tools[name].run(*args.positional, **args.kwargs) if inspect.isawaitable(output): output = await output - status = "ok" except Exception as exc: status = "error" output = f"{exc!s}" - elapsed_ms = int((time.monotonic() - start) * 1000) - output_text = output if isinstance(output, str) else str(output) - - event_payload = { - "raw": line, - "name": name, - "status": status, - "elapsed_ms": elapsed_ms, - "output": output_text, - "date": datetime.now(UTC).isoformat(), - } - await self.tapes.append_event(tape.name, "command", event_payload) - if status == "error": - return f"error: {output_text}" - return output_text + raise + else: + return output if isinstance(output, str) else str(output) + finally: + elapsed_ms = int((time.monotonic() - start) * 1000) + output_text = output if isinstance(output, str) else str(output) + + event_payload = { + "raw": line, + "name": name, + "status": status, + "elapsed_ms": elapsed_ms, + "output": output_text, + "date": datetime.now(UTC).isoformat(), + } + await self.tapes.append_event(tape.name, "command", event_payload) async def _run_model(self, *, tape: Tape, prompt: str) -> str: next_prompt = prompt @@ -123,7 +125,7 @@ async def _run_model(self, *, tape: Tape, prompt: str) -> str: "date": datetime.now(UTC).isoformat(), }, ) - return f"error: {exc!s}" + raise outcome = _resolve_tool_auto_result(output) elapsed_ms = int((time.monotonic() - start) * 1000) @@ -166,9 +168,9 @@ async def _run_model(self, *, tape: Tape, prompt: str) -> str: "date": datetime.now(UTC).isoformat(), }, ) - return f"error: {outcome.error}" + raise RuntimeError(outcome.error) - return f"error: max_steps_reached={self.settings.max_steps}" + raise RuntimeError(f"max_steps_reached={self.settings.max_steps}") def _load_skills_prompt(self, prompt: str, workspace: Path) -> str: skill_index = {skill.name: skill for skill in discover_skills(workspace)} diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 307df901..70bcc7ce 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -1,3 +1,4 @@ +import inspect from pathlib import Path import typer @@ -41,7 +42,9 @@ def resolve_session(self, message: ChannelMessage) -> str: async def load_state(self, message: ChannelMessage, session_id: str) -> State: on_start = field_of(message, "on_start") if on_start is not None: - await on_start(message) + result = on_start(message) + if inspect.isawaitable(result): + await result state = {"session_id": session_id, "_runtime_engine": self.engine} if context := field_of(message, "context_str"): state["context"] = context @@ -51,7 +54,9 @@ async def load_state(self, message: ChannelMessage, session_id: str) -> State: async def save_state(self, session_id: str, state: State, message: ChannelMessage, model_output: str) -> None: on_finish = field_of(message, "on_finish") if on_finish is not None: - await on_finish(message) + result = on_finish(message) + if inspect.isawaitable(result): + await result @hookimpl def build_prompt(self, message: ChannelMessage, session_id: str, state: State) -> str: @@ -61,9 +66,13 @@ def build_prompt(self, message: ChannelMessage, session_id: str, state: State) - state["_runtime_workspace"] = workspace.strip() elif "_runtime_workspace" not in state: state["_runtime_workspace"] = str(Path.cwd()) + content = content_of(message) + if content.startswith(","): + message.kind = "command" + return content context = field_of(message, "context_str") context_prefix = f"{context}\n---\n" if context else "" - return f"{context_prefix}{content_of(message)}" + return f"{context_prefix}{content}" @hookimpl async def run_model(self, prompt: str, session_id: str, state: State) -> str: @@ -76,6 +85,7 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("hooks")(cli.list_hooks) app.command("message")(cli.message) + app.command("chat")(cli.chat) @hookimpl def system_prompt(self, prompt: str, state: State) -> str: @@ -96,19 +106,23 @@ def provide_tools(self) -> list[Tool]: @hookimpl def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: + from bub.channels.cli import CliChannel from bub.channels.telegram import TelegramChannel - return [TelegramChannel(on_receive=message_handler)] + return [ + TelegramChannel(on_receive=message_handler), + CliChannel(on_receive=message_handler, engine=self.engine), + ] @hookimpl async def on_error(self, stage: str, error: Exception, message: Envelope | None) -> None: - logger.exception(f"Error at stage '{stage}' with message '{message}': {error}") if message is not None: outbound = ChannelMessage( session_id=field_of(message, "session_id", "unknown"), channel=field_of(message, "channel", "default"), chat_id=field_of(message, "chat_id", "default"), content=f"An error occurred at stage '{stage}': {error}", + kind="error", ) await self.hooks.call_many("dispatch_outbound", message=outbound) @@ -116,7 +130,8 @@ async def on_error(self, stage: str, error: Exception, message: Envelope | None) async def dispatch_outbound(self, message: Envelope) -> bool: content = content_of(message) session_id = field_of(message, "session_id") - logger.info("session.run.outbound session_id={} content={}", session_id, content) + if field_of(message, "output_channel") != "cli": + logger.info("session.run.outbound session_id={} content={}", session_id, content) if self._outbound_dispatcher is None: return False return await self._outbound_dispatcher(message) @@ -135,5 +150,6 @@ def render_outbound( chat_id=field_of(message, "chat_id", "default"), content=model_output, output_channel=field_of(message, "output_channel", "default"), + kind=field_of(message, "kind", "normal"), ) return [outbound] diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 137f74a3..46e2b54e 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -1,12 +1,18 @@ +import pathlib + +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next" DEFAULT_MAX_TOKENS = 1024 +DEFAULT_HOME = pathlib.Path.home() / ".bub" class RuntimeSettings(BaseSettings): model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore", env_file=".env") + home: pathlib.Path = Field(default=DEFAULT_HOME) + model: str = DEFAULT_MODEL api_key: str | None = None api_base: str | None = None diff --git a/src/bub/channels/base.py b/src/bub/channels/base.py index 2217f781..e00ce436 100644 --- a/src/bub/channels/base.py +++ b/src/bub/channels/base.py @@ -1,3 +1,4 @@ +import asyncio from abc import ABC, abstractmethod from typing import ClassVar @@ -10,7 +11,7 @@ class Channel(ABC): name: ClassVar[str] = "base" @abstractmethod - async def start(self) -> None: + async def start(self, stop_event: asyncio.Event) -> None: """Start listening for events and dispatching to handlers.""" @abstractmethod diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py new file mode 100644 index 00000000..e6cbb4f1 --- /dev/null +++ b/src/bub/channels/cli/__init__.py @@ -0,0 +1,162 @@ +import asyncio +import contextlib +from datetime import datetime +from hashlib import md5 +from pathlib import Path + +from prompt_toolkit import PromptSession +from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.history import FileHistory +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.patch_stdout import patch_stdout +from rich import get_console + +from bub.builtin.engine import RuntimeEngine +from bub.builtin.tape import TapeInfo +from bub.channels.base import Channel +from bub.channels.cli.renderer import CliRenderer +from bub.channels.message import ChannelMessage +from bub.envelope import content_of, field_of +from bub.types import MessageHandler + + +class CliChannel(Channel): + """A simple CLI channel for testing and debugging.""" + + name = "cli" + _stop_event: asyncio.Event + + def __init__(self, on_receive: MessageHandler, engine: RuntimeEngine) -> None: + self._on_receive = on_receive + self._engine = engine + self._message_template = { + "chat_id": "cli_chat", + "channel": self.name, + "session_id": "cli_session", + } + self._mode = "agent" # or "shell" + self._main_task: asyncio.Task | None = None + self._renderer = CliRenderer(get_console()) + self._prompt = self._build_prompt(Path.cwd()) + self._last_tape_info: TapeInfo | None = None + + async def _refresh_tape_info(self) -> None: + tape = self._engine.tapes.session_tape(self._message_template["session_id"]) + info = await self._engine.tapes.info(tape.name) + self._last_tape_info = info + + def set_metadata(self, session_id: str | None = None, chat_id: str | None = None) -> None: + if session_id is not None: + self._message_template["session_id"] = session_id + if chat_id is not None: + self._message_template["chat_id"] = chat_id + + async def start(self, stop_event: asyncio.Event) -> None: + self._stop_event = stop_event + self._main_task = asyncio.create_task(self._main_loop()) + + async def stop(self) -> None: + if self._main_task is not None: + self._main_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._main_task + + async def send(self, message: ChannelMessage) -> None: + match message.kind: + case "error": + self._renderer.error(content_of(message)) + case "command": + self._renderer.command_output(content_of(message)) + case _: + self._renderer.assistant_output(content_of(message)) + + async def _main_loop(self) -> None: + workspace = Path.cwd() + self._renderer.welcome(model=self._engine.settings.model, workspace=str(workspace)) + await self._refresh_tape_info() + request_completed = asyncio.Event() + + while not self._stop_event.is_set(): + try: + with patch_stdout(raw=True): + raw = (await self._prompt.prompt_async(self._prompt_message())).strip() + except KeyboardInterrupt: + self._renderer.info("Interrupted. Use ',quit' to exit.") + continue + except EOFError: + break + + if not raw: + continue + if raw in {",quit", ",exit"}: + break + + request = self._normalize_input(raw) + + async def on_request_complete(_) -> None: + await self._refresh_tape_info() + request_completed.set() + + with self._renderer.console.status("[cyan]Processing...[/cyan]", spinner="dots"): + message = ChannelMessage(content=request, **self._message_template, on_finish=on_request_complete) + await self._on_receive(message) + await request_completed.wait() + request_completed.clear() + + self._renderer.info("Bye.") + self._stop_event.set() + + def _normalize_input(self, raw: str) -> str: + if self._mode != "shell": + return raw + if raw.startswith(","): + return raw + return f",{raw}" + + def _prompt_message(self) -> FormattedText: + cwd = Path.cwd().name + symbol = ">" if self._mode == "agent" else "," + return FormattedText([("bold", f"{cwd} {symbol} ")]) + + def _build_prompt(self, workspace: Path) -> PromptSession[str]: + kb = KeyBindings() + + @kb.add("c-x", eager=True) + def _toggle_mode(event) -> None: + self._mode = "shell" if self._mode == "agent" else "agent" + event.app.invalidate() + + def _tool_sort_key(tool_name: str) -> tuple[str, str]: + section, _, name = tool_name.rpartition(".") + return (section, name) + + history_file = self._history_file(self._engine.settings.home, workspace) + history_file.parent.mkdir(parents=True, exist_ok=True) + history = FileHistory(str(history_file)) + tool_names = sorted((f",{tool.name}" for tool in self._engine.tools), key=_tool_sort_key) + completer = WordCompleter(tool_names, ignore_case=True) + return PromptSession( + completer=completer, + complete_while_typing=True, + key_bindings=kb, + history=history, + bottom_toolbar=self._render_bottom_toolbar, + ) + + def _render_bottom_toolbar(self) -> FormattedText: + info = self._last_tape_info + now = datetime.now().strftime("%H:%M") + left = f"{now} mode:{self._mode}" + right = ( + f"model:{self._engine.settings.model} " + f"entries:{field_of(info, 'entries', '-')} " + f"anchors:{field_of(info, 'anchors', '-')} " + f"last:{field_of(info, 'last_anchor', None) or '-'}" + ) + return FormattedText([("", f"{left} {right}")]) + + @staticmethod + def _history_file(home: Path, workspace: Path) -> Path: + workspace_hash = md5(str(workspace).encode("utf-8"), usedforsecurity=False).hexdigest() + return home / "history" / f"{workspace_hash}.history" diff --git a/src/bub/channels/cli/renderer.py b/src/bub/channels/cli/renderer.py new file mode 100644 index 00000000..c89981bc --- /dev/null +++ b/src/bub/channels/cli/renderer.py @@ -0,0 +1,46 @@ +"""CLI rendering helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + + +@dataclass +class CliRenderer: + """Rich-based renderer for interactive CLI.""" + + console: Console + + def welcome(self, *, model: str, workspace: str) -> None: + body = ( + f"workspace: {workspace}\n" + f"model: {model}\n" + "internal command prefix: ','\n" + "shell command prefix: ',' at line start (Ctrl-X for shell mode)\n" + "type ',help' for command list" + ) + self.console.print(Panel(body, title="Bub", border_style="cyan")) + + def info(self, text: str) -> None: + if not text.strip(): + return + self.console.print(Text(text, style="bright_black")) + + def command_output(self, text: str) -> None: + if not text.strip(): + return + self.console.print(Panel(text, title="Command", border_style="green")) + + def assistant_output(self, text: str) -> None: + if not text.strip(): + return + self.console.print(Panel(text, title="Assistant", border_style="blue")) + + def error(self, text: str) -> None: + if not text.strip(): + return + self.console.print(Panel(text, title="Error", border_style="red")) diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 1222b732..20e779d7 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -9,6 +9,7 @@ from bub.channels.base import Channel from bub.channels.handler import BufferedMessageHandler from bub.channels.message import ChannelMessage +from bub.channels.utils import wait_until_stopped from bub.envelope import content_of, field_of from bub.framework import BubFramework from bub.types import Envelope, MessageHandler @@ -46,6 +47,7 @@ def __init__(self, framework: BubFramework, enabled_channels: Collection[str] | self._messages = asyncio.Queue[ChannelMessage]() self._ongoing_tasks: set[asyncio.Task] = set() self._session_handlers: dict[str, MessageHandler] = {} + self._load_channels() async def on_receive(self, message: ChannelMessage) -> None: channel = message.channel @@ -67,13 +69,16 @@ async def on_receive(self, message: ChannelMessage) -> None: self._session_handlers[session_id] = handler await self._session_handlers[session_id](message) + def get_channel(self, name: str) -> Channel | None: + return self._channels.get(name) + async def dispatch(self, message: Envelope) -> bool: channel_name = field_of(message, "channel") if channel_name is None: return False channel_key = str(channel_name) - channel = self._channels.get(channel_key) + channel = self.get_channel(channel_key) if channel is None: return False @@ -82,6 +87,8 @@ async def dispatch(self, message: Envelope) -> bool: channel=channel_key, chat_id=str(field_of(message, "chat_id", "default")), content=content_of(message), + context=field_of(message, "context", {}), + kind=field_of(message, "kind", "normal"), ) await channel.send(outbound) return True @@ -100,14 +107,14 @@ def _load_channels(self) -> None: self._channels[channel.name] = channel async def listen_and_run(self) -> None: - self._load_channels() + stop_event = asyncio.Event() self.framework.bind_outbound_router(self) for channel in self.enabled_channels(): - await channel.start() + await channel.start(stop_event) logger.info("channel.manager started listening") try: while True: - message = await self._messages.get() + message = await wait_until_stopped(self._messages.get(), stop_event) task = asyncio.create_task(self.framework.process_inbound(message)) task.add_done_callback(lambda t: self._ongoing_tasks.discard(t)) self._ongoing_tasks.add(task) diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py index eb26cf39..ba7f7230 100644 --- a/src/bub/channels/message.py +++ b/src/bub/channels/message.py @@ -1,7 +1,9 @@ import json -from collections.abc import Callable, Coroutine +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Self +from typing import Any, Literal, Self + +type MessageKind = Literal["error", "normal", "command"] @dataclass @@ -13,9 +15,10 @@ class ChannelMessage: content: str chat_id: str = "default" is_active: bool = False + kind: MessageKind = "normal" context: dict[str, Any] = field(default_factory=dict) - on_start: Callable[[Self], Coroutine[None, None, None]] | None = None - on_finish: Callable[[Self], Coroutine[None, None, None]] | None = None + on_start: Callable[[Self], Any] | None = None + on_finish: Callable[[Self], Any] | None = None output_channel: str = "" def __post_init__(self) -> None: diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 4a94b226..a33be27f 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import contextlib from collections.abc import Callable from typing import Any, ClassVar @@ -128,7 +129,7 @@ def __init__(self, on_receive: MessageHandler) -> None: def needs_debounce(self) -> bool: return True - async def start(self) -> None: + async def start(self, stop_event: asyncio.Event) -> None: proxy = self._settings.proxy logger.info( "telegram.start allow_users_count={} allow_chats_count={} proxy_enabled={}", diff --git a/src/bub/channels/utils.py b/src/bub/channels/utils.py index 2285911c..ec3108c7 100644 --- a/src/bub/channels/utils.py +++ b/src/bub/channels/utils.py @@ -1,6 +1,22 @@ +import asyncio +from collections.abc import Coroutine from typing import Any def exclude_none(d: dict[str, Any]) -> dict[str, Any]: """Exclude None values from a dictionary.""" return {k: v for k, v in d.items() if v is not None} + + +async def wait_until_stopped[T](coro: Coroutine[None, None, T], stop_event: asyncio.Event) -> T: + """Run a coroutine until a stop event is set.""" + task = asyncio.create_task(coro) + waiter = asyncio.create_task(stop_event.wait()) + _ = await asyncio.wait({task, waiter}, return_when=asyncio.FIRST_COMPLETED) + if stop_event.is_set(): + task.cancel() + await task + raise asyncio.CancelledError("Operation cancelled due to stop event") + else: + waiter.cancel() + return task.result() diff --git a/src/bub/framework.py b/src/bub/framework.py index 1b852695..36802142 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -83,26 +83,29 @@ async def process_inbound(self, inbound: Envelope) -> TurnResult: ) if not prompt: prompt = content_of(inbound) - model_output = await self._hook_runtime.call_first( - "run_model", prompt=prompt, session_id=session_id, state=state - ) - if model_output is None: - await self._hook_runtime.notify_error( - stage="run_model:fallback", - error=RuntimeError("no model skill returned output"), + model_output = "" + try: + model_output = await self._hook_runtime.call_first( + "run_model", prompt=prompt, session_id=session_id, state=state + ) + if model_output is None: + await self._hook_runtime.notify_error( + stage="run_model:fallback", + error=RuntimeError("no model skill returned output"), + message=inbound, + ) + model_output = prompt + else: + model_output = str(model_output) + finally: + await self._hook_runtime.call_many( + "save_state", + session_id=session_id, + state=state, message=inbound, + model_output=model_output, ) - model_output = prompt - else: - model_output = str(model_output) - - await self._hook_runtime.call_many( - "save_state", - session_id=session_id, - state=state, - message=inbound, - model_output=model_output, - ) + outbounds = await self._collect_outbounds(inbound, session_id, state, model_output) for outbound in outbounds: await self._hook_runtime.call_many("dispatch_outbound", message=outbound) diff --git a/src/bub/hook_runtime.py b/src/bub/hook_runtime.py index 432ec7b5..22ab3abe 100644 --- a/src/bub/hook_runtime.py +++ b/src/bub/hook_runtime.py @@ -125,17 +125,9 @@ async def _invoke_impl_async( call_kwargs: dict[str, Any], kwargs: dict[str, Any], ) -> Any: - try: - value = impl.function(**call_kwargs) - if inspect.isawaitable(value): - value = await value - except Exception as error: - await self.notify_error( - stage=f"{hook_name}:{impl.plugin_name or ''}", - error=error, - message=_message_from_kwargs(kwargs), - ) - return _SKIP_VALUE + value = impl.function(**call_kwargs) + if inspect.isawaitable(value): + value = await value return value def _invoke_impl_sync( @@ -146,15 +138,7 @@ def _invoke_impl_sync( call_kwargs: dict[str, Any], kwargs: dict[str, Any], ) -> Any: - try: - value = impl.function(**call_kwargs) - except Exception as error: - self.notify_error_sync( - stage=f"{hook_name}:{impl.plugin_name or ''}", - error=error, - message=_message_from_kwargs(kwargs), - ) - return _SKIP_VALUE + value = impl.function(**call_kwargs) if inspect.isawaitable(value): logger.warning( "hook.async_not_supported hook={} adapter={}", From c89e00a0d8fc9887d58544149d6eac41fba65465 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 13:28:29 +0800 Subject: [PATCH 15/39] fix: add JSON import and enhance logging in TelegramChannel message building Signed-off-by: Frost Ming --- src/bub/channels/telegram.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index a33be27f..9d0600bd 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -2,11 +2,12 @@ import asyncio import contextlib +import json from collections.abc import Callable from typing import Any, ClassVar from loguru import logger -from pydantic import Field, json +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict from telegram import Message, Update from telegram.ext import Application, CommandHandler, ContextTypes, filters @@ -210,7 +211,7 @@ def _build_message(self, message: Message) -> ChannelMessage: reply_meta = self._parser.get_reply(message) if reply_meta: metadata["reply_to_message"] = reply_meta - + print(f"TelegramChannel._build_message content={content} metadata={metadata}") content = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) is_active = MESSAGE_FILTER.filter(message) is not False return ChannelMessage( From 8802998894c8694187b0d013e1f31f8505131f0c Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 15:38:08 +0800 Subject: [PATCH 16/39] feat: implement ForkTapeStore for enhanced tape management and persistence Signed-off-by: Frost Ming --- src/bub/builtin/cli.py | 19 ++- src/bub/builtin/engine.py | 28 +++-- src/bub/builtin/hook_impl.py | 7 ++ src/bub/builtin/store.py | 206 +++++++++++++++++++++++++++++++ src/bub/builtin/tape.py | 18 ++- src/bub/channels/cli/__init__.py | 8 +- 6 files changed, 258 insertions(+), 28 deletions(-) create mode 100644 src/bub/builtin/store.py diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 73889aa8..2f3573d6 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -5,10 +5,10 @@ import asyncio from pathlib import Path -from typing import Any import typer +from bub.channels.message import ChannelMessage from bub.envelope import field_of from bub.framework import BubFramework @@ -26,7 +26,7 @@ def _load_framework(workspace: Path | None) -> BubFramework: def run( message: str = typer.Argument(..., help="Inbound message content"), workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace root"), - channel: str = typer.Option("stdout", "--channel", help="Message channel"), + channel: str = typer.Option("cli", "--channel", help="Message channel"), chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), sender_id: str = typer.Option("human", "--sender-id", help="Sender id"), session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), @@ -34,14 +34,13 @@ def run( """Run one inbound message through the framework pipeline.""" framework = _load_framework(workspace) - inbound: dict[str, Any] = { - "channel": channel, - "chat_id": chat_id, - "sender_id": sender_id, - "content": message, - } - if session_id is not None and session_id.strip(): - inbound["session_id"] = session_id.strip() + inbound = ChannelMessage( + session_id=f"{channel}:{chat_id}" if session_id is None else session_id, + content=message, + channel=channel, + chat_id=chat_id, + context={"sender_id": sender_id}, + ) result = asyncio.run(framework.process_inbound(inbound)) for outbound in result.outbounds: diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/engine.py index f193e693..af9e1edd 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/engine.py @@ -15,10 +15,11 @@ from pluggy import PluginManager from republic import LLM, AsyncTapeStore, Tool, ToolAutoResult, ToolContext -from republic.tape import InMemoryTapeStore, Tape, TapeStore +from republic.tape import InMemoryTapeStore, Tape from bub.builtin.context import default_tape_context from bub.builtin.settings import RuntimeSettings +from bub.builtin.store import ForkTapeStore from bub.builtin.tape import TapeService from bub.skills import discover_skills, render_skills_prompt from bub.tools import model_tools, render_tools_prompt @@ -34,12 +35,16 @@ class RuntimeEngine: def __init__(self, plugins_manager: PluginManager) -> None: self.settings = _load_runtime_settings() - tape_store = plugins_manager.hook.provide_tape_store() + self._pm = plugins_manager + + @cached_property + def tapes(self) -> TapeService: + tape_store = self._pm.hook.provide_tape_store() if tape_store is None: tape_store = InMemoryTapeStore() - self._llm = _build_llm(self.settings, tape_store) - self._pm = plugins_manager - self.tapes = TapeService(self._llm, self.settings.home / "tapes") + tape_store = ForkTapeStore(tape_store) + llm = _build_llm(self.settings, tape_store) + return TapeService(llm, self.settings.home / "tapes", tape_store) @cached_property def tools(self) -> list[Tool]: @@ -56,12 +61,13 @@ async def run(self, *, session_id: str, prompt: str, state: State) -> str: stripped = prompt.strip() if not stripped: return "error: empty prompt" - tape = self.tapes.session_tape(session_id) - await self.tapes.ensure_bootstrap_anchor(tape.name) + tape = self.tapes.session_tape(session_id, workspace_from_state(state)) tape.context.state.update(state) - if stripped.startswith(","): - return await self._run_command(tape=tape, line=stripped) - return await self._run_model(tape=tape, prompt=stripped) + async with self.tapes.fork_tape(tape.name): + await self.tapes.ensure_bootstrap_anchor(tape.name) + if stripped.startswith(","): + return await self._run_command(tape=tape, line=stripped) + return await self._run_model(tape=tape, prompt=stripped) async def _run_command(self, tape: Tape, *, line: str) -> str: line = line[1:].strip() @@ -219,7 +225,7 @@ def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome: return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}") -def _build_llm(settings: RuntimeSettings, tape_store: TapeStore | AsyncTapeStore) -> LLM: +def _build_llm(settings: RuntimeSettings, tape_store: AsyncTapeStore) -> LLM: return LLM( settings.model, api_key=settings.api_key, diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 70bcc7ce..1cdeee70 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -4,6 +4,7 @@ import typer from loguru import logger from republic import Tool +from republic.tape import TapeStore from bub.builtin.engine import RuntimeEngine, workspace_from_state from bub.channels.base import Channel @@ -153,3 +154,9 @@ def render_outbound( kind=field_of(message, "kind", "normal"), ) return [outbound] + + @hookimpl + def provide_tape_store(self) -> TapeStore: + from bub.builtin.store import FileTapeStore + + return FileTapeStore(directory=self.engine.settings.home / "tapes") diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py new file mode 100644 index 00000000..50b6bf44 --- /dev/null +++ b/src/bub/builtin/store.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import contextlib +import contextvars +import itertools +import json +import threading +from collections.abc import AsyncGenerator, Iterable +from dataclasses import asdict +from pathlib import Path +from typing import cast + +from loguru import logger +from republic import AsyncTapeStore, TapeEntry, TapeQuery +from republic.tape import AsyncTapeStoreAdapter, InMemoryQueryMixin, InMemoryTapeStore, TapeStore +from republic.tape.store import is_async_tape_store + +current_store: contextvars.ContextVar[TapeStore] = contextvars.ContextVar("current_store") + + +class ForkTapeStore: + def __init__(self, parent: AsyncTapeStore | TapeStore) -> None: + if is_async_tape_store(parent): + self._parent = parent + else: + self._parent = AsyncTapeStoreAdapter(parent) # type: ignore[arg-type] + + @property + def _current(self) -> TapeStore: + return current_store.get(_emtpy_store) + + async def list_tapes(self) -> list[str]: + return await self._parent.list_tapes() + + async def reset(self, tape: str) -> None: + self._current.reset(tape) + await self._parent.reset(tape) + + async def fetch_all(self, query: TapeQuery[AsyncTapeStore]) -> Iterable[TapeEntry]: + try: + parent_entries = await self._parent.fetch_all(query) + except Exception: + parent_entries = [] + this_entries: list[TapeEntry] = [] + if hasattr(self._current, "read"): + for entry in cast(list[TapeEntry], self._current.read(query.tape) or []): + if query._kinds and entry.kind not in query._kinds: + continue + if entry.kind == "anchor": # noqa: SIM102 + if query._after_last or (query._after_anchor and entry.payload.get("name") == query._after_anchor): + this_entries.clear() + parent_entries = [] + this_entries.append(entry) + return itertools.chain(parent_entries, this_entries) + + async def append(self, tape: str, entry: TapeEntry) -> None: + self._current.append(tape, entry) + + @contextlib.asynccontextmanager + async def fork(self, tape: str) -> AsyncGenerator[None, None]: + store = InMemoryTapeStore() + token = current_store.set(store) + try: + yield + finally: + current_store.reset(token) + entries = store.read(tape) + if entries: + count = len(entries) + for entry in entries: + await self._parent.append(tape, entry) + logger.info(f'Merged {count} entries into tape "{tape}"') + + +class EmptyTapeStore: + """Sync TapeStore sentinel that always returns empty results.""" + + def list_tapes(self) -> list[str]: + return [] + + def reset(self, tape: str) -> None: + pass + + def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: + return [] + + def append(self, tape: str, entry: TapeEntry) -> None: + pass + + +_emtpy_store = EmptyTapeStore() + + +class FileTapeStore(InMemoryQueryMixin): + """TapeStore implementation that persists tapes as JSONL files under a directory.""" + + def __init__(self, directory: Path) -> None: + self._directory = directory + self._directory.mkdir(parents=True, exist_ok=True) + + def _tape_file(self, tape: str) -> TapeFile: + return TapeFile(self._directory / f"{tape}.jsonl") + + def list_tapes(self) -> list[str]: + result: list[str] = [] + for file in self._directory.glob("*.jsonl"): + filename = file.stem + if filename.count("__") != 1: + continue + result.append(filename) + return result + + def reset(self, tape: str) -> None: + self._tape_file(tape).reset() + + def append(self, tape: str, entry: TapeEntry) -> None: + self._tape_file(tape).append(entry) + + def read(self, tape: str) -> list[TapeEntry] | None: + return self._tape_file(tape).read() + + +class TapeFile: + """Helper for one tape file.""" + + def __init__(self, path: Path) -> None: + self.path = path + self._lock = threading.Lock() + self._read_entries: list[TapeEntry] = [] + self._read_offset = 0 + + def _next_id(self) -> int: + if self._read_entries: + return cast(int, self._read_entries[-1].id + 1) + return 1 + + def _reset(self) -> None: + self._read_entries = [] + self._read_offset = 0 + + def reset(self) -> None: + with self._lock: + if self.path.exists(): + self.path.unlink() + self._reset() + + def read(self) -> list[TapeEntry]: + with self._lock: + return self._read_locked() + + def _read_locked(self) -> list[TapeEntry]: + if not self.path.exists(): + self._reset() + return [] + + file_size = self.path.stat().st_size + if file_size < self._read_offset: + # The file was truncated or replaced, so cached entries are stale. + self._reset() + + with self.path.open("r", encoding="utf-8") as handle: + handle.seek(self._read_offset) + for raw_line in handle: + line = raw_line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + entry = self.entry_from_payload(payload) + if entry is not None: + self._read_entries.append(entry) + self._read_offset = handle.tell() + + return list(self._read_entries) + + @staticmethod + def entry_from_payload(payload: object) -> TapeEntry | None: + if not isinstance(payload, dict): + return None + entry_id = payload.get("id") + kind = payload.get("kind") + entry_payload = payload.get("payload") + meta = payload.get("meta") + if not isinstance(entry_id, int): + return None + if not isinstance(kind, str): + return None + if not isinstance(entry_payload, dict): + return None + if not isinstance(meta, dict): + meta = {} + timestamp = payload.get("timestamp", 0.0) + return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), timestamp) + + def append(self, entry: TapeEntry) -> None: + with self._lock: + # Keep cache and offset in sync before allocating new IDs. + self._read_locked() + with self.path.open("a", encoding="utf-8") as handle: + next_id = self._next_id() + stored = TapeEntry(next_id, entry.kind, dict(entry.payload), dict(entry.meta)) + handle.write(json.dumps(asdict(stored), ensure_ascii=False) + "\n") + self._read_entries.append(stored) + self._read_offset = handle.tell() diff --git a/src/bub/builtin/tape.py b/src/bub/builtin/tape.py index 7d428ecd..66d45ae8 100644 --- a/src/bub/builtin/tape.py +++ b/src/bub/builtin/tape.py @@ -1,6 +1,7 @@ import contextlib import hashlib import re +from collections.abc import AsyncGenerator from dataclasses import asdict from datetime import UTC, datetime from pathlib import Path @@ -11,6 +12,8 @@ from rapidfuzz import fuzz, process from republic import LLM, Tape, TapeEntry +from bub.builtin.store import ForkTapeStore + WORD_PATTERN = re.compile(r"[a-z0-9_/-]+") MIN_FUZZY_QUERY_LENGTH = 3 MIN_FUZZY_SCORE = 80 @@ -38,9 +41,10 @@ class AnchorSummary: class TapeService: - def __init__(self, llm: LLM, archive_path: Path) -> None: + def __init__(self, llm: LLM, archive_path: Path, store: ForkTapeStore) -> None: self._llm = llm self._archive_path = archive_path + self._store = store async def info(self, tape_name: str) -> TapeInfo: tape = self._llm.tape(tape_name) @@ -175,6 +179,14 @@ async def append_event(self, tape_name: str, name: str, payload: dict[str, Any], tape = self._llm.tape(tape_name) await tape.append_async(TapeEntry.event(name=name, payload=payload, **meta)) - def session_tape(self, session_id: str) -> Tape: - tape_name = hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + def session_tape(self, session_id: str, workspace: Path) -> Tape: + workspace_hash = hashlib.md5(str(workspace.resolve()).encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + tape_name = ( + workspace_hash + "__" + hashlib.md5(session_id.encode("utf-8"), usedforsecurity=False).hexdigest()[:16] + ) return self._llm.tape(tape_name) + + @contextlib.asynccontextmanager + async def fork_tape(self, tape_name: str) -> AsyncGenerator[None, None]: + async with self._store.fork(tape_name): + yield diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index e6cbb4f1..c006a05d 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -40,9 +40,10 @@ def __init__(self, on_receive: MessageHandler, engine: RuntimeEngine) -> None: self._renderer = CliRenderer(get_console()) self._prompt = self._build_prompt(Path.cwd()) self._last_tape_info: TapeInfo | None = None + self._workspace = Path.cwd() async def _refresh_tape_info(self) -> None: - tape = self._engine.tapes.session_tape(self._message_template["session_id"]) + tape = self._engine.tapes.session_tape(self._message_template["session_id"], self._workspace) info = await self._engine.tapes.info(tape.name) self._last_tape_info = info @@ -72,8 +73,7 @@ async def send(self, message: ChannelMessage) -> None: self._renderer.assistant_output(content_of(message)) async def _main_loop(self) -> None: - workspace = Path.cwd() - self._renderer.welcome(model=self._engine.settings.model, workspace=str(workspace)) + self._renderer.welcome(model=self._engine.settings.model, workspace=str(self._workspace)) await self._refresh_tape_info() request_completed = asyncio.Event() @@ -98,8 +98,8 @@ async def on_request_complete(_) -> None: await self._refresh_tape_info() request_completed.set() + message = ChannelMessage(content=request, **self._message_template, on_finish=on_request_complete) with self._renderer.console.status("[cyan]Processing...[/cyan]", spinner="dots"): - message = ChannelMessage(content=request, **self._message_template, on_finish=on_request_complete) await self._on_receive(message) await request_completed.wait() request_completed.clear() From aa5d39ba993a70181ac6bcd3cb6331c872947f69 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 15:43:27 +0800 Subject: [PATCH 17/39] Add skill-creator and skill-installer components Signed-off-by: Frost Ming --- pyproject.toml | 3 + src/bub_skills/skill-creator/SKILL.md | 366 +++++++++++++++++ src/bub_skills/skill-creator/license.txt | 202 +++++++++ .../skill-creator/scripts/init_skill.py | 386 ++++++++++++++++++ .../skill-creator/scripts/quick_validate.py | 106 +++++ src/bub_skills/skill-installer/LICENSE.txt | 202 +++++++++ src/bub_skills/skill-installer/SKILL.md | 64 +++ 7 files changed, 1329 insertions(+) create mode 100644 src/bub_skills/skill-creator/SKILL.md create mode 100644 src/bub_skills/skill-creator/license.txt create mode 100644 src/bub_skills/skill-creator/scripts/init_skill.py create mode 100644 src/bub_skills/skill-creator/scripts/quick_validate.py create mode 100644 src/bub_skills/skill-installer/LICENSE.txt create mode 100644 src/bub_skills/skill-installer/SKILL.md diff --git a/pyproject.toml b/pyproject.toml index f0d4ea84..c030bcf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,9 @@ testpaths = ["tests"] target-version = "py312" line-length = 120 fix = true +extend-exclude = [ + "src/bub_skills/**/scripts/*" +] [tool.ruff.lint] select = [ diff --git a/src/bub_skills/skill-creator/SKILL.md b/src/bub_skills/skill-creator/SKILL.md new file mode 100644 index 00000000..0d8d2c39 --- /dev/null +++ b/src/bub_skills/skill-creator/SKILL.md @@ -0,0 +1,366 @@ +--- +name: skill-creator +description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Bub's capabilities with specialized knowledge, workflows, or tool integrations. +metadata: + short-description: Create or update a skill +--- + +# Skill Creator + +This skill provides guidance for creating effective skills. + +## About Skills + +Skills are modular, self-contained folders that extend Bub's capabilities by providing +specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific +domains or tasks—they transform Bub from a general-purpose agent into a specialized agent +equipped with procedural knowledge that no model can fully possess. + +### Skill Location Policy + +When creating a skill, place it in one of these two roots: + +1. Project-local: `$workspace/.agent/skills/` +2. Global: `~/.agent/skills/` (shared across workspaces) + +Prefer project-local by default. Use global only when the user explicitly wants the skill available across multiple workspaces. + +### What Skills Provide + +1. Specialized workflows - Multi-step procedures for specific domains +2. Tool integrations - Instructions for working with specific file formats or APIs +3. Domain expertise - Company-specific knowledge, schemas, business logic +4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks + +## Core Principles + +### Concise is Key + +The context window is a public good. Skills share the context window with everything else Bub needs: system prompt, conversation history, other Skills' metadata, and the actual user request. + +**Default assumption: Bub is already very smart.** Only add context Bub doesn't already have. Challenge each piece of information: "Does Bub really need this explanation?" and "Does this paragraph justify its token cost?" + +Prefer concise examples over verbose explanations. + +### Set Appropriate Degrees of Freedom + +Match the level of specificity to the task's fragility and variability: + +**High freedom (text-based instructions)**: Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach. + +**Medium freedom (pseudocode or scripts with parameters)**: Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior. + +**Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. + +Think of Bub as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). + +### Anatomy of a Skill + +Every skill consists of a required SKILL.md file and optional bundled resources: + +``` +skill-name/ +├── SKILL.md (required) +│ ├── YAML frontmatter metadata (required) +│ │ ├── name: (required) +│ │ └── description: (required) +│ └── Markdown instructions (required) +└── Bundled Resources (optional) + ├── scripts/ - Executable code (Python/Bash/etc.) + ├── references/ - Documentation intended to be loaded into context as needed + └── assets/ - Files used in output (templates, icons, fonts, etc.) +``` + +#### SKILL.md (required) + +Every SKILL.md consists of: + +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Bub reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). + +#### Bundled Resources (optional) + +##### Scripts (`scripts/`) + +Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. + +- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed +- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks +- **Benefits**: Token efficient, deterministic, may be executed without loading into context +- **Note**: Scripts may still need to be read by Bub for patching or environment-specific adjustments + +##### References (`references/`) + +Documentation and reference material intended to be loaded as needed into context to inform Bub's process and thinking. + +- **When to include**: For documentation that Bub should reference while working +- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications +- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides +- **Benefits**: Keeps SKILL.md lean, loaded only when Bub determines it's needed +- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md +- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. + +##### Assets (`assets/`) + +Files not intended to be loaded into context, but rather used within the output Bub produces. + +- **When to include**: When the skill needs files that will be used in the final output +- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography +- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified +- **Benefits**: Separates output resources from documentation, enables Bub to use files without loading them into context + +#### What to Not Include in a Skill + +A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including: + +- README.md +- INSTALLATION_GUIDE.md +- QUICK_REFERENCE.md +- CHANGELOG.md +- etc. + +The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxiliary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion. + +### Progressive Disclosure Design Principle + +Skills use a three-level loading system to manage context efficiently: + +1. **Metadata (name + description)** - Always in context (~100 words) +2. **SKILL.md body** - When skill triggers (<5k words) +3. **Bundled resources** - As needed by Bub (Unlimited because scripts can be executed without reading into context window) + +#### Progressive Disclosure Patterns + +Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them. + +**Key principle:** When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files. + +**Pattern 1: High-level guide with references** + +```markdown +# PDF Processing + +## Quick start + +Extract text with pdfplumber: +[code example] + +## Advanced features + +- **Form filling**: See [FORMS.md](FORMS.md) for complete guide +- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods +- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns +``` + +Bub loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. + +**Pattern 2: Domain-specific organization** + +For Skills with multiple domains, organize content by domain to avoid loading irrelevant context: + +``` +bigquery-skill/ +├── SKILL.md (overview and navigation) +└── reference/ + ├── finance.md (revenue, billing metrics) + ├── sales.md (opportunities, pipeline) + ├── product.md (API usage, features) + └── marketing.md (campaigns, attribution) +``` + +When a user asks about sales metrics, Bub only reads sales.md. + +Similarly, for skills supporting multiple frameworks or variants, organize by variant: + +``` +cloud-deploy/ +├── SKILL.md (workflow + provider selection) +└── references/ + ├── aws.md (AWS deployment patterns) + ├── gcp.md (GCP deployment patterns) + └── azure.md (Azure deployment patterns) +``` + +When the user chooses AWS, Bub only reads aws.md. + +**Pattern 3: Conditional details** + +Show basic content, link to advanced content: + +```markdown +# DOCX Processing + +## Creating documents + +Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md). + +## Editing documents + +For simple edits, modify the XML directly. + +**For tracked changes**: See [REDLINING.md](REDLINING.md) +**For OOXML details**: See [OOXML.md](OOXML.md) +``` + +Bub reads REDLINING.md or OOXML.md only when the user needs those features. + +**Important guidelines:** + +- **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Bub can see the full scope when previewing. + +## Skill Creation Process + +Skill creation involves these steps: + +1. Understand the skill with concrete examples +2. Plan reusable skill contents (scripts, references, assets) +3. Initialize the skill (run `uv run scripts/init_skill.py`) +4. Edit the skill (implement resources and write SKILL.md) +5. Validate the skill (run `uv run scripts/quick_validate.py`) +6. Iterate based on real usage + +Follow these steps in order, skipping only if there is a clear reason why they are not applicable. + +### Skill Naming + +- Use lowercase letters, digits, and hyphens only; normalize user-provided titles to hyphen-case (e.g., "Plan Mode" -> `plan-mode`). +- When generating names, generate a name under 64 characters (letters, digits, hyphens). +- Prefer short, verb-led phrases that describe the action. +- Namespace by tool when it improves clarity or triggering (e.g., `gh-address-comments`, `linear-address-issue`). +- Name the skill folder exactly after the skill name. + +### Step 1: Understanding the Skill with Concrete Examples + +Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. + +To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. + +For example, when building an image-editor skill, relevant questions include: + +- "What functionality should the image-editor skill support? Editing, rotating, anything else?" +- "Can you give some examples of how this skill would be used?" +- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" +- "What would a user say that should trigger this skill?" + +To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. + +Conclude this step when there is a clear sense of the functionality the skill should support. + +### Step 2: Planning the Reusable Skill Contents + +To turn concrete examples into an effective skill, analyze each example by: + +1. Considering how to execute on the example from scratch +2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly + +Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: + +1. Rotating a PDF requires re-writing the same code each time +2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill + +Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: + +1. Writing a frontend webapp requires the same boilerplate HTML/React each time +2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill + +Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: + +1. Querying BigQuery requires re-discovering the table schemas and relationships each time +2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill + +To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. + +### Step 3: Initializing the Skill + +At this point, it is time to actually create the skill. + +Skip this step only if the skill being developed already exists. In this case, continue to the next step. + +When creating a new skill from scratch, always run `uv run scripts/init_skill.py`. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. + +Usage: + +```bash +uv run scripts/init_skill.py --path [--resources scripts,references,assets] [--examples] +``` + +Examples: + +```bash +uv run scripts/init_skill.py my-skill --path "$workspace/.agent/skills" +uv run scripts/init_skill.py my-skill --path "$workspace/.agent/skills" --resources scripts,references +uv run scripts/init_skill.py my-skill --path "$workspace/.agent/skills" --resources scripts --examples +uv run scripts/init_skill.py my-skill --path "~/.agent/skills" +``` + +The script: + +- Creates the skill directory at the specified path +- Generates a SKILL.md template with proper frontmatter and TODO placeholders +- Optionally creates resource directories based on `--resources` +- Optionally adds example files when `--examples` is set + +After initialization, customize the SKILL.md and add resources as needed. If you used `--examples`, replace or delete placeholder files. + +### Step 4: Edit the Skill + +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Bub to use. Include information that would be beneficial and non-obvious to Bub. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Bub instance execute these tasks more effectively. + +#### Learn Proven Design Patterns + +Consult these helpful guides based on your skill's needs: + +- **Multi-step processes**: See references/workflows.md for sequential workflows and conditional logic +- **Specific output formats or quality standards**: See references/output-patterns.md for template and example patterns + +These files contain established best practices for effective skill design. + +#### Start with Reusable Skill Contents + +To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. + +Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion. + +If you used `--examples`, delete any placeholder files that are not needed for the skill. Only create resource directories that are actually required. + +#### Update SKILL.md + +**Writing Guidelines:** Always use imperative/infinitive form. + +##### Frontmatter + +Write the YAML frontmatter with `name` and `description`: + +- `name`: The skill name +- `description`: This is the primary triggering mechanism for your skill, and helps Bub understand when to use the skill. + - Include both what the Skill does and specific triggers/contexts for when to use it. + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Bub. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Bub needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + +Do not include any other fields in YAML frontmatter. + +##### Body + +Write instructions for using the skill and its bundled resources. + +### Step 5: Validate the Skill + +Once development of the skill is complete, validate the skill folder to catch basic issues early: + +```bash +uv run scripts/quick_validate.py +``` + +The validation script checks YAML frontmatter format, required fields, and naming rules. If validation fails, fix the reported issues and run the command again. + +### Step 6: Iterate + +After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. + +**Iteration workflow:** + +1. Use the skill on real tasks +2. Notice struggles or inefficiencies +3. Identify how SKILL.md or bundled resources should be updated +4. Implement changes and test again diff --git a/src/bub_skills/skill-creator/license.txt b/src/bub_skills/skill-creator/license.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/src/bub_skills/skill-creator/license.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/bub_skills/skill-creator/scripts/init_skill.py b/src/bub_skills/skill-creator/scripts/init_skill.py new file mode 100644 index 00000000..e2ed5f5e --- /dev/null +++ b/src/bub_skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + uv run scripts/init_skill.py --path [--resources scripts,references,assets] [--examples] [--interface key=value] + +Examples: + uv run scripts/init_skill.py my-new-skill --path skills/public + uv run scripts/init_skill.py my-new-skill --path skills/public --resources scripts,references + uv run scripts/init_skill.py my-api-helper --path skills/private --resources scripts --examples + uv run scripts/init_skill.py custom-skill --path /custom/location + uv run scripts/init_skill.py my-skill --path skills/public --interface short_description="Short UI label" +""" + +import argparse +import re +import sys +from pathlib import Path + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_RESOURCES = {"scripts", "references", "assets"} + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing" +- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text" +- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features" +- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" -> numbered capability list +- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources (optional) + +Create only the resource directories this skill actually needs. Delete this section if no resources are required. + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Bub for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Bub's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Bub should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Bub produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Not every skill requires all three types of resources.** +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Bub produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def normalize_skill_name(skill_name): + """Normalize a skill name to lowercase hyphen-case.""" + normalized = skill_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return " ".join(word.capitalize() for word in skill_name.split("-")) + + +def parse_resources(raw_resources): + if not raw_resources: + return [] + resources = [item.strip() for item in raw_resources.split(",") if item.strip()] + invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES}) + if invalid: + allowed = ", ".join(sorted(ALLOWED_RESOURCES)) + print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}") + print(f" Allowed: {allowed}") + sys.exit(1) + deduped = [] + seen = set() + for resource in resources: + if resource not in seen: + deduped.append(resource) + seen.add(resource) + return deduped + + +def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples): + for resource in resources: + resource_dir = skill_dir / resource + resource_dir.mkdir(exist_ok=True) + if resource == "scripts": + if include_examples: + example_script = resource_dir / "example.py" + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("[OK] Created scripts/example.py") + else: + print("[OK] Created scripts/") + elif resource == "references": + if include_examples: + example_reference = resource_dir / "api_reference.md" + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("[OK] Created references/api_reference.md") + else: + print("[OK] Created references/") + elif resource == "assets": + if include_examples: + example_asset = resource_dir / "example_asset.txt" + example_asset.write_text(EXAMPLE_ASSET) + print("[OK] Created assets/example_asset.txt") + else: + print("[OK] Created assets/") + + +def init_skill(skill_name, path, resources, include_examples, interface_overrides): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + resources: Resource directories to create + include_examples: Whether to create example files in resource directories + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).expanduser().resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"[ERROR] Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"[OK] Created skill directory: {skill_dir}") + except Exception as e: + print(f"[ERROR] Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title) + + skill_md_path = skill_dir / "SKILL.md" + try: + skill_md_path.write_text(skill_content) + print("[OK] Created SKILL.md") + except Exception as e: + print(f"[ERROR] Error creating SKILL.md: {e}") + return None + + # Create resource directories if requested + if resources: + try: + create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples) + except Exception as e: + print(f"[ERROR] Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + if resources: + if include_examples: + print("2. Customize or delete the example files in scripts/, references/, and assets/") + else: + print("2. Add resources to scripts/, references/, and assets/ as needed") + else: + print("2. Create resource directories only if needed (scripts/, references/, assets/)") + print("3. Update agents/openai.yaml if the UI metadata should differ") + print("4. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + parser = argparse.ArgumentParser( + description="Create a new skill directory with a SKILL.md template.", + ) + parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)") + parser.add_argument("--path", required=True, help="Output directory for the skill") + parser.add_argument( + "--resources", + default="", + help="Comma-separated list: scripts,references,assets", + ) + parser.add_argument( + "--examples", + action="store_true", + help="Create example files inside the selected resource directories", + ) + parser.add_argument( + "--interface", + action="append", + default=[], + help="Interface override in key=value format (repeatable)", + ) + args = parser.parse_args() + + raw_skill_name = args.skill_name + skill_name = normalize_skill_name(raw_skill_name) + if not skill_name: + print("[ERROR] Skill name must include at least one letter or digit.") + sys.exit(1) + if len(skill_name) > MAX_SKILL_NAME_LENGTH: + print( + f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + sys.exit(1) + if skill_name != raw_skill_name: + print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.") + + resources = parse_resources(args.resources) + if args.examples and not resources: + print("[ERROR] --examples requires --resources to be set.") + sys.exit(1) + + path = args.path + + print(f"Initializing skill: {skill_name}") + print(f" Location: {path}") + if resources: + print(f" Resources: {', '.join(resources)}") + if args.examples: + print(" Examples: enabled") + else: + print(" Resources: none (create as needed)") + print() + + result = init_skill(skill_name, path, resources, args.examples, args.interface) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/bub_skills/skill-creator/scripts/quick_validate.py b/src/bub_skills/skill-creator/scripts/quick_validate.py new file mode 100644 index 00000000..6db98ab4 --- /dev/null +++ b/src/bub_skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "pyyaml>=6.0", +# ] +# /// +""" +Quick validation script for skills - minimal version +""" + +import re +import sys +from pathlib import Path + +import yaml + +MAX_SKILL_NAME_LENGTH = 64 + + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + content = skill_md.read_text() + if not content.startswith("---"): + return False, "No YAML frontmatter found" + + match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + allowed_properties = {"name", "description", "license", "allowed-tools", "metadata"} + + unexpected_keys = set(frontmatter.keys()) - allowed_properties + if unexpected_keys: + allowed = ", ".join(sorted(allowed_properties)) + unexpected = ", ".join(sorted(unexpected_keys)) + return ( + False, + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", + ) + + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter" + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter" + + name = frontmatter.get("name", "") + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + if not re.match(r"^[a-z0-9-]+$", name): + return ( + False, + f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)", + ) + if name.startswith("-") or name.endswith("-") or "--" in name: + return ( + False, + f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens", + ) + if len(name) > MAX_SKILL_NAME_LENGTH: + return ( + False, + f"Name is too long ({len(name)} characters). Maximum is {MAX_SKILL_NAME_LENGTH} characters.", + ) + + description = frontmatter.get("description", "") + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + if "<" in description or ">" in description: + return False, "Description cannot contain angle brackets (< or >)" + if len(description) > 1024: + return ( + False, + f"Description is too long ({len(description)} characters). Maximum is 1024 characters.", + ) + + return True, "Skill is valid!" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: uv run scripts/quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) diff --git a/src/bub_skills/skill-installer/LICENSE.txt b/src/bub_skills/skill-installer/LICENSE.txt new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/src/bub_skills/skill-installer/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/src/bub_skills/skill-installer/SKILL.md b/src/bub_skills/skill-installer/SKILL.md new file mode 100644 index 00000000..1d66b403 --- /dev/null +++ b/src/bub_skills/skill-installer/SKILL.md @@ -0,0 +1,64 @@ +--- +name: skill-installer +description: Install Bub skills into the shared skills directory from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). +metadata: + short-description: Install curated skills from openai/skills or other repos +--- + +# Skill Installer + +Helps install skills. By default these are from https://github.com/openai/skills/tree/main/skills/.curated, but users can also provide other locations. Experimental skills live in https://github.com/openai/skills/tree/main/skills/.experimental and can be installed the same way. + +Use `npx skills` based on the task: +- List skills when the user asks what is available, or if the user uses this skill without specifying what to do. Default listing is `.curated`, but you can pass `--path skills/.experimental` when they ask about experimental skills. +- Install from the curated list when the user provides a skill name. +- Install from another repo when the user provides a GitHub repo/path (including private repos). + +## Install Location Policy + +Use one of these roots for installed skills: + +1. Project-local: `$workspace/.agent/skills/` +2. Global: `~/.agent/skills/` (shared across workspaces) + +Prefer project-local for repo-specific workflows. Use global only when the user asks for cross-workspace availability. + +## Communication + +When listing skills, output approximately as follows, depending on the context of the user's request. If they ask about experimental skills, list from `.experimental` instead of `.curated` and label the source accordingly: +""" +Skills from {repo}: +1. skill-1 +2. skill-2 (already installed) +3. ... +Which ones would you like installed? +""" + +After installing a skill, tell the user: "Restart Bub to pick up new skills." + +## Commands + +All of these commands use network. + +- List curated skills: `npx skills add openai/skills --list --skill '*' --agent antigravity --yes` +- List experimental skills: `npx skills add https://github.com/openai/skills/tree/main/skills/.experimental --list --skill '*' --agent antigravity --yes` +- Install curated skill (project-local): `npx skills add https://github.com/openai/skills/tree/main/skills/.curated/ --agent antigravity --yes` +- Install curated skill (global): `npx skills add https://github.com/openai/skills/tree/main/skills/.curated/ --agent antigravity --yes --global` +- Install from URL: `npx skills add https://github.com///tree// --agent antigravity --yes` +- Install from shorthand: `npx skills add /@ --agent antigravity --yes` +- Show installed skills in current scope: `npx skills list` +- Show installed global skills: `npx skills list --global` + +## Behavior and Options + +- `npx skills add` handles discovery and installation directly. +- Use project scope by default; use `--global` for cross-workspace availability. +- Always pass `--agent antigravity` so install location matches Bub discovery (`.agent/skills`). +- Prefer non-interactive usage with `--yes` when you already know the target skill. + +## Notes + +- Curated/experimental listings are resolved by `npx skills add ... --list`. +- Private GitHub repos depend on `npx skills` auth support and your local git/npm credentials. +- The skills at https://github.com/openai/skills/tree/main/skills/.system are preinstalled, so no need to help users install those. If they ask, just explain this. If they insist, you can download and overwrite. +- Installed annotations come from `npx skills list` in the active scope. From 37ce5b0cdf875d29692045b954cf055a0cd5f985 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 15:58:14 +0800 Subject: [PATCH 18/39] feat: update system prompt to include detailed response instructions and refactor agent file reading Signed-off-by: Frost Ming --- src/bub/builtin/hook_impl.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 1cdeee70..a51838f9 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -6,7 +6,7 @@ from republic import Tool from republic.tape import TapeStore -from bub.builtin.engine import RuntimeEngine, workspace_from_state +from bub.builtin.engine import RuntimeEngine from bub.channels.base import Channel from bub.channels.message import ChannelMessage from bub.envelope import content_of, field_of @@ -15,6 +15,21 @@ from bub.types import Envelope, MessageHandler, OutboundDispatcher, State AGENTS_FILE_NAME = "AGENTS.md" +DEFAULT_SYSTEM_PROMPT = """\ + +Excessively long context may cause model call failures. In this case, you MAY use tape.info to the token usage and you SHOULD use tape.handoff tool to shorten the length of the retrieved history. + + +You MUST send message to the corresponding channel before finish when you want to respond. +Route your response to the same channel the message came from. +There is a skill named `{channel}` for each channel that you need to figure out how to send a response to that channel. +## Before finishing ANY response to a channel message: +1. Identify the source channel from the user message metadata +2. Prepare your response text +3. Call the corresponding channel skill to deliver the message +4. ONLY THEN end your turn + +""" class BuiltinImpl: @@ -88,10 +103,9 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("message")(cli.message) app.command("chat")(cli.chat) - @hookimpl - def system_prompt(self, prompt: str, state: State) -> str: - # Read the content of AGENTS.md under workspace - prompt_path = workspace_from_state(state) / AGENTS_FILE_NAME + def _read_agents_file(self, state: State) -> str: + workspace = state.get("_runtime_workspace", str(Path.cwd())) + prompt_path = Path(workspace) / AGENTS_FILE_NAME if not prompt_path.is_file(): return "" try: @@ -99,6 +113,11 @@ def system_prompt(self, prompt: str, state: State) -> str: except OSError: return "" + @hookimpl + def system_prompt(self, prompt: str, state: State) -> str: + # Read the content of AGENTS.md under workspace + return DEFAULT_SYSTEM_PROMPT + "\n\n" + self._read_agents_file(state) + @hookimpl def provide_tools(self) -> list[Tool]: from bub.builtin.tools import get_builtin_tools From 0413becd3b40be8239a53c39b473cd66c6cdc2e5 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 17:20:08 +0800 Subject: [PATCH 19/39] feat: refactor hook implementation to use lifespan context management and add tests for skill functionality Signed-off-by: Frost Ming --- pyproject.toml | 3 + src/bub/builtin/hook_impl.py | 19 +++--- src/bub/builtin/store.py | 4 +- src/bub/channels/cli/__init__.py | 21 ++++-- src/bub/channels/message.py | 7 +- src/bub/channels/telegram.py | 33 +++++++++- tests/test_channels_utils.py | 25 ++++++++ tests/test_envelope.py | 43 +++++++++++++ tests/test_hook_runtime.py | 106 +++++++++++++++++++++++++++++++ tests/test_skills.py | 102 +++++++++++++++++++++++++++++ 10 files changed, 340 insertions(+), 23 deletions(-) create mode 100644 tests/test_channels_utils.py create mode 100644 tests/test_envelope.py create mode 100644 tests/test_hook_runtime.py create mode 100644 tests/test_skills.py diff --git a/pyproject.toml b/pyproject.toml index c030bcf3..f90bb252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,9 @@ warn_return_any = true warn_unused_ignores = true show_error_codes = true ignore_missing_imports = true +exclude = [ + "src/bub_skills/.*/scripts/.*", +] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index a51838f9..40d4d617 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -1,4 +1,4 @@ -import inspect +import sys from pathlib import Path import typer @@ -56,11 +56,9 @@ def resolve_session(self, message: ChannelMessage) -> str: @hookimpl async def load_state(self, message: ChannelMessage, session_id: str) -> State: - on_start = field_of(message, "on_start") - if on_start is not None: - result = on_start(message) - if inspect.isawaitable(result): - await result + lifespan = field_of(message, "lifespan") + if lifespan is not None: + await lifespan.__aenter__() state = {"session_id": session_id, "_runtime_engine": self.engine} if context := field_of(message, "context_str"): state["context"] = context @@ -68,11 +66,10 @@ async def load_state(self, message: ChannelMessage, session_id: str) -> State: @hookimpl async def save_state(self, session_id: str, state: State, message: ChannelMessage, model_output: str) -> None: - on_finish = field_of(message, "on_finish") - if on_finish is not None: - result = on_finish(message) - if inspect.isawaitable(result): - await result + tp, value, traceback = sys.exc_info() + lifespan = field_of(message, "lifespan") + if lifespan is not None: + await lifespan.__aexit__(tp, value, traceback) @hookimpl def build_prompt(self, message: ChannelMessage, session_id: str, state: State) -> str: diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index 50b6bf44..d62fda60 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -23,14 +23,14 @@ def __init__(self, parent: AsyncTapeStore | TapeStore) -> None: if is_async_tape_store(parent): self._parent = parent else: - self._parent = AsyncTapeStoreAdapter(parent) # type: ignore[arg-type] + self._parent = AsyncTapeStoreAdapter(parent) @property def _current(self) -> TapeStore: return current_store.get(_emtpy_store) async def list_tapes(self) -> list[str]: - return await self._parent.list_tapes() + return cast(list[str], await self._parent.list_tapes()) async def reset(self, tape: str) -> None: self._current.reset(tape) diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index c006a05d..8edf8cb0 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -1,5 +1,6 @@ import asyncio import contextlib +from collections.abc import AsyncGenerator from datetime import datetime from hashlib import md5 from pathlib import Path @@ -94,11 +95,13 @@ async def _main_loop(self) -> None: request = self._normalize_input(raw) - async def on_request_complete(_) -> None: - await self._refresh_tape_info() - request_completed.set() - - message = ChannelMessage(content=request, **self._message_template, on_finish=on_request_complete) + message = ChannelMessage( + session_id=self._message_template["session_id"], + channel=self._message_template["channel"], + chat_id=self._message_template["chat_id"], + content=request, + lifespan=self.message_lifespan(request_completed), + ) with self._renderer.console.status("[cyan]Processing...[/cyan]", spinner="dots"): await self._on_receive(message) await request_completed.wait() @@ -107,6 +110,14 @@ async def on_request_complete(_) -> None: self._renderer.info("Bye.") self._stop_event.set() + @contextlib.asynccontextmanager + async def message_lifespan(self, request_completed: asyncio.Event) -> AsyncGenerator[None, None]: + try: + yield + finally: + await self._refresh_tape_info() + request_completed.set() + def _normalize_input(self, raw: str) -> str: if self._mode != "shell": return raw diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py index ba7f7230..9a93f920 100644 --- a/src/bub/channels/message.py +++ b/src/bub/channels/message.py @@ -1,7 +1,7 @@ +import contextlib import json -from collections.abc import Callable from dataclasses import dataclass, field -from typing import Any, Literal, Self +from typing import Any, Literal type MessageKind = Literal["error", "normal", "command"] @@ -17,8 +17,7 @@ class ChannelMessage: is_active: bool = False kind: MessageKind = "normal" context: dict[str, Any] = field(default_factory=dict) - on_start: Callable[[Self], Any] | None = None - on_finish: Callable[[Self], Any] | None = None + lifespan: contextlib.AbstractAsyncContextManager | None = None output_channel: str = "" def __post_init__(self) -> None: diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 9d0600bd..132460ee 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -3,7 +3,7 @@ import asyncio import contextlib import json -from collections.abc import Callable +from collections.abc import AsyncGenerator, Callable from typing import Any, ClassVar from loguru import logger @@ -125,6 +125,7 @@ def __init__(self, on_receive: MessageHandler) -> None: self._allow_users = {uid.strip() for uid in (self._settings.allow_users or "").split(",") if uid.strip()} self._allow_chats = {cid.strip() for cid in (self._settings.allow_chats or "").split(",") if cid.strip()} self._parser = TelegramMessageParser() + self._typing_tasks: dict[str, asyncio.Task] = {} @property def needs_debounce(self) -> bool: @@ -160,6 +161,11 @@ async def stop(self) -> None: await updater.stop() await self._app.stop() await self._app.shutdown() + for task in self._typing_tasks.values(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + self._typing_tasks.clear() logger.info("telegram.stopped") async def send(self, message: ChannelMessage) -> None: @@ -220,9 +226,34 @@ def _build_message(self, message: Message) -> ChannelMessage: chat_id=chat_id, content=content, is_active=is_active, + lifespan=self.start_typing(chat_id), output_channel="null", # disable outbound for telegram messages ) + @contextlib.asynccontextmanager + async def start_typing(self, chat_id: str) -> AsyncGenerator[None, None]: + if chat_id in self._typing_tasks: + yield + return + task = asyncio.create_task(self._typing_loop(chat_id)) + self._typing_tasks[chat_id] = task + try: + yield + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + del self._typing_tasks[chat_id] + + async def _typing_loop(self, chat_id: str) -> None: + while True: + try: + await self._app.bot.send_chat_action(chat_id=chat_id, action="typing") + await asyncio.sleep(4) # Telegram typing status lasts for 5 seconds, so we refresh it every 4 seconds + except Exception as e: + logger.error(f"Error in typing loop for chat_id={chat_id}: {e}") + break + class TelegramMessageParser: @classmethod diff --git a/tests/test_channels_utils.py b/tests/test_channels_utils.py new file mode 100644 index 00000000..393a5383 --- /dev/null +++ b/tests/test_channels_utils.py @@ -0,0 +1,25 @@ +import asyncio + +import pytest + +from bub.channels.utils import exclude_none, wait_until_stopped + + +def test_exclude_none_keeps_non_none_values() -> None: + payload = {"a": 1, "b": None, "c": "x", "d": False} + assert exclude_none(payload) == {"a": 1, "c": "x", "d": False} + + +@pytest.mark.asyncio +async def test_wait_until_stopped_returns_result_when_coroutine_finishes_first() -> None: + stop_event = asyncio.Event() + result = await wait_until_stopped(asyncio.sleep(0.01, result="done"), stop_event) + assert result == "done" + + +@pytest.mark.asyncio +async def test_wait_until_stopped_cancels_when_stop_event_set() -> None: + stop_event = asyncio.Event() + stop_event.set() + with pytest.raises(asyncio.CancelledError): + await wait_until_stopped(asyncio.sleep(0.2, result="done"), stop_event) diff --git a/tests/test_envelope.py b/tests/test_envelope.py new file mode 100644 index 00000000..8b0d121e --- /dev/null +++ b/tests/test_envelope.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass + +from bub.envelope import content_of, field_of, normalize_envelope, unpack_batch + + +@dataclass +class _Message: + content: str + channel: str = "cli" + + +def test_field_of_supports_mapping_and_object() -> None: + mapping = {"content": "hello", "count": 3} + assert field_of(mapping, "content") == "hello" + assert field_of(mapping, "missing", "fallback") == "fallback" + + obj = _Message(content="world") + assert field_of(obj, "content") == "world" + assert field_of(obj, "missing", "fallback") == "fallback" + + +def test_content_of_stringifies_value() -> None: + assert content_of({"content": 123}) == "123" + assert content_of({"other": "x"}) == "" + + +def test_normalize_envelope_for_mapping_object_and_raw_value() -> None: + mapping = {"content": "hello"} + normalized_mapping = normalize_envelope(mapping) + assert normalized_mapping == mapping + assert normalized_mapping is not mapping + + obj = _Message(content="world", channel="telegram") + assert normalize_envelope(obj) == {"content": "world", "channel": "telegram"} + + assert normalize_envelope(42) == {"content": "42"} + + +def test_unpack_batch_handles_none_sequence_and_single_item() -> None: + assert unpack_batch(None) == [] + assert unpack_batch([{"content": "a"}]) == [{"content": "a"}] + assert unpack_batch(({"content": "a"}, {"content": "b"})) == [{"content": "a"}, {"content": "b"}] + assert unpack_batch({"content": "single"}) == [{"content": "single"}] diff --git a/tests/test_hook_runtime.py b/tests/test_hook_runtime.py new file mode 100644 index 00000000..337a2bda --- /dev/null +++ b/tests/test_hook_runtime.py @@ -0,0 +1,106 @@ +import pluggy +import pytest + +from bub.hook_runtime import HookRuntime +from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs, hookimpl + + +def _runtime_with_plugins(*plugins: tuple[str, object]) -> HookRuntime: + manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) + manager.add_hookspecs(BubHookSpecs) + for name, plugin in plugins: + manager.register(plugin, name=name) + return HookRuntime(manager) + + +@pytest.mark.asyncio +async def test_call_first_respects_priority_and_returns_first_non_none() -> None: + called: list[str] = [] + + class LowPriority: + @hookimpl + def resolve_session(self, message): + called.append("low") + return "low" + + class MidPriority: + @hookimpl + def resolve_session(self, message): + called.append("mid") + return "mid" + + class HighPriorityReturnsNone: + @hookimpl + def resolve_session(self, message): + called.append("high") + return None + + runtime = _runtime_with_plugins( + ("low", LowPriority()), + ("mid", MidPriority()), + ("high", HighPriorityReturnsNone()), + ) + + result = await runtime.call_first("resolve_session", message={"session_id": "x"}, ignored="value") + assert result == "mid" + assert called == ["high", "mid"] + + +def test_call_many_sync_skips_async_impl() -> None: + class _AwaitableValue: + def __await__(self): + yield from () + return "async" + + class AsyncPrompt: + @hookimpl + def system_prompt(self, prompt, state): + return _AwaitableValue() + + class SyncPrompt: + @hookimpl + def system_prompt(self, prompt, state): + return "sync" + + runtime = _runtime_with_plugins( + ("sync", SyncPrompt()), + ("async", AsyncPrompt()), + ) + + assert runtime.call_many_sync("system_prompt", prompt="hello", state={}) == ["sync"] + + +@pytest.mark.asyncio +async def test_notify_error_swallows_observer_failures() -> None: + observed: list[str] = [] + + class RaisingObserver: + @hookimpl + async def on_error(self, stage, error, message): + raise RuntimeError("boom") + + class RecordingObserver: + @hookimpl + async def on_error(self, stage, error, message): + observed.append(stage) + + runtime = _runtime_with_plugins( + ("raise", RaisingObserver()), + ("record", RecordingObserver()), + ) + + await runtime.notify_error(stage="turn", error=ValueError("bad"), message={"content": "x"}) + assert observed == ["turn"] + + +def test_hook_report_lists_registered_implementations() -> None: + class SessionPlugin: + @hookimpl + def resolve_session(self, message): + return "session" + + runtime = _runtime_with_plugins(("session", SessionPlugin())) + report = runtime.hook_report() + + assert "resolve_session" in report + assert report["resolve_session"] == ["session"] diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 00000000..12bcb563 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,102 @@ +from pathlib import Path + +from bub.skills import ( + SKILL_FILE_NAME, + SkillMetadata, + _parse_frontmatter, + _read_skill, + discover_skills, + render_skills_prompt, +) + + +def _write_skill( + root: Path, + name: str, + *, + description: str = "A skill", + body: str = "Skill body", + metadata: dict[str, str] | None = None, +) -> Path: + skill_dir = root / name + skill_dir.mkdir(parents=True, exist_ok=True) + lines = [ + "---", + f"name: {name}", + f"description: {description}", + ] + if metadata is not None: + lines.append("metadata:") + for key, value in metadata.items(): + lines.append(f" {key}: {value}") + lines.extend(["---", body]) + skill_file = skill_dir / SKILL_FILE_NAME + skill_file.write_text("\n".join(lines), encoding="utf-8") + return skill_file + + +def test_skill_metadata_body_strips_frontmatter(tmp_path: Path) -> None: + skill_file = _write_skill(tmp_path, "demo-skill", body="Line 1\nLine 2") + metadata = SkillMetadata( + name="demo-skill", + description="Demo", + location=skill_file, + source="project", + ) + assert metadata.body() == "Line 1\nLine 2" + + +def test_read_skill_rejects_invalid_metadata_field_type(tmp_path: Path) -> None: + skill_dir = tmp_path / "bad-skill" + skill_dir.mkdir() + content = "---\nname: bad-skill\ndescription: bad\nmetadata:\n retries: 3\n---\nBody\n" + (skill_dir / SKILL_FILE_NAME).write_text(content, encoding="utf-8") + + assert _read_skill(skill_dir, source="project") is None + + +def test_parse_frontmatter_returns_empty_on_invalid_yaml() -> None: + content = "---\nname: [broken\n---\nbody\n" + assert _parse_frontmatter(content) == {} + + +def test_discover_skills_prefers_project_over_global_and_builtin(tmp_path: Path, monkeypatch) -> None: + project_root = tmp_path / "project" + global_root = tmp_path / "global" + builtin_root = tmp_path / "builtin" + for root in (project_root, global_root, builtin_root): + root.mkdir(parents=True) + + _write_skill(project_root, "shared", description="project version") + _write_skill(global_root, "shared", description="global version") + _write_skill(builtin_root, "shared", description="builtin version") + _write_skill(global_root, "global-only", description="global only") + + monkeypatch.setattr( + "bub.skills._iter_skill_roots", + lambda _workspace: [ + (project_root, "project"), + (global_root, "global"), + (builtin_root, "builtin"), + ], + ) + + discovered = discover_skills(tmp_path) + index = {item.name: item for item in discovered} + assert index["shared"].description == "project version" + assert index["shared"].source == "project" + assert index["global-only"].source == "global" + + +def test_render_skills_prompt_includes_expanded_body(tmp_path: Path) -> None: + skill_file = _write_skill(tmp_path, "skill-a", description="desc", body="expanded body") + skills = [ + SkillMetadata(name="skill-a", description="desc", location=skill_file, source="project"), + SkillMetadata(name="skill-b", description="desc-b", location=skill_file, source="project"), + ] + + rendered = render_skills_prompt(skills, expanded_skills={"skill-a"}) + assert "" in rendered + assert "- skill-a: desc" in rendered + assert "expanded body" in rendered + assert "- skill-b: desc-b" in rendered From f4226324815d7e8d2c9097a5b00ccaf94aef5be0 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 17:26:41 +0800 Subject: [PATCH 20/39] feat: add requirement installation step in entrypoint script Signed-off-by: Frost Ming --- entrypoint.sh | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index fda64ef3..bf6b2590 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,10 +2,9 @@ set -eo pipefail -if [ -f "/workspace/bub_hooks.py" ]; then - cp /workspace/bub_hooks.py /app/.venv/lib/python3.12/site-packages/ - echo "Hooks module bub_hooks.py copied to site-packages." - export BUB_HOOKS_MODULE="bub_hooks" +if [ -f "/workspace/bub-reqs.txt" ]; then + echo "Installing additional requirements from /workspace/bub-reqs.txt" + uv pip install -r /workspace/bub-reqs.txt -p /app/.venv/bin/python fi if [ -f "/workspace/startup.sh" ]; then From 5797a9dc76899c7c09a1230f7e3694879098c220 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 17:53:39 +0800 Subject: [PATCH 21/39] fix: simplify message template assignment in BufferedMessageHandler Signed-off-by: Frost Ming --- src/bub/channels/handler.py | 3 +-- src/bub/channels/telegram.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/bub/channels/handler.py b/src/bub/channels/handler.py index e444aa1d..0b36e9bd 100644 --- a/src/bub/channels/handler.py +++ b/src/bub/channels/handler.py @@ -42,8 +42,7 @@ async def _process(self) -> None: await self._handler(message) async def __call__(self, message: ChannelMessage) -> None: - if self._message_template is None: - self._message_template = message + self._message_template = message now = self._loop.time() if not message.is_active and ( self._last_active_time is None or now - self._last_active_time > self.active_time_window diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 132460ee..29b1cc7e 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -217,7 +217,6 @@ def _build_message(self, message: Message) -> ChannelMessage: reply_meta = self._parser.get_reply(message) if reply_meta: metadata["reply_to_message"] = reply_meta - print(f"TelegramChannel._build_message content={content} metadata={metadata}") content = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) is_active = MESSAGE_FILTER.filter(message) is not False return ChannelMessage( From 7940e8d84cf6f96bf6e7a8eee0e2f0aec86b31e9 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 18:02:45 +0800 Subject: [PATCH 22/39] fix: remove unused import of json from pydantic in tape.py Signed-off-by: Frost Ming --- src/bub/builtin/tape.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bub/builtin/tape.py b/src/bub/builtin/tape.py index 66d45ae8..df85e788 100644 --- a/src/bub/builtin/tape.py +++ b/src/bub/builtin/tape.py @@ -1,5 +1,6 @@ import contextlib import hashlib +import json import re from collections.abc import AsyncGenerator from dataclasses import asdict @@ -7,7 +8,6 @@ from pathlib import Path from typing import Any, cast -from pydantic import json from pydantic.dataclasses import dataclass from rapidfuzz import fuzz, process from republic import LLM, Tape, TapeEntry From d2c55fb97e6645d9701c2715aa3ee27d35d437bf Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 18:50:02 +0800 Subject: [PATCH 23/39] fix: replace import of BaseModel from anthropic with pydantic in tools.py Signed-off-by: Frost Ming --- src/bub/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bub/tools.py b/src/bub/tools.py index 956ae823..b08ef193 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -5,8 +5,8 @@ from dataclasses import replace from typing import Any, overload -from anthropic import BaseModel from loguru import logger +from pydantic import BaseModel from republic import Tool from republic import tool as republic_tool From 0396eb8bcff52447ce2819e7b39915e299d67799 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 20:07:11 +0800 Subject: [PATCH 24/39] fix: remove apscheduler dependency from project files Signed-off-by: Frost Ming --- pyproject.toml | 1 - src/bub/builtin/cli.py | 1 - src/bub/channels/handler.py | 12 +++++----- src/bub/framework.py | 2 ++ uv.lock | 44 ------------------------------------- 5 files changed, 8 insertions(+), 52 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f90bb252..efa46444 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ "prompt-toolkit>=3.0.0", "python-telegram-bot>=21.0", "loguru>=0.7.2", - "apscheduler>=3.11.2", "rapidfuzz>=3.14.3", ] diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 2f3573d6..7c20aba6 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -54,7 +54,6 @@ def list_hooks( workspace: Path | None = typer.Option(None, "--workspace", "-w"), ) -> None: """Show hook implementation mapping.""" - framework = _load_framework(workspace) report = framework.hook_report() if not report: diff --git a/src/bub/channels/handler.py b/src/bub/channels/handler.py index 0b36e9bd..4b5810ba 100644 --- a/src/bub/channels/handler.py +++ b/src/bub/channels/handler.py @@ -44,6 +44,12 @@ async def _process(self) -> None: async def __call__(self, message: ChannelMessage) -> None: self._message_template = message now = self._loop.time() + if message.content.startswith(","): + logger.info( + "session.message received command session_id={}, content={}", message.session_id, message.content + ) + await self._handler(message) + return if not message.is_active and ( self._last_active_time is None or now - self._last_active_time > self.active_time_window ): @@ -52,12 +58,6 @@ async def __call__(self, message: ChannelMessage) -> None: "session.message received ignored session_id={}, content={}", message.session_id, message.content ) return - if message.content.startswith(","): - logger.info( - "session.message received command session_id={}, content={}", message.session_id, message.content - ) - await self._handler(message) - return self._pending_prompts.append(message.content) if message.is_active: self._last_active_time = now diff --git a/src/bub/framework.py b/src/bub/framework.py index 36802142..7c529a1f 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -7,6 +7,7 @@ from typing import Any import pluggy +from loguru import logger from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import HookRuntime @@ -52,6 +53,7 @@ def load_hooks(self) -> None: plugin = entry_point.load() self._plugin_manager.register(plugin, name=entry_point.name) except Exception as exc: + logger.warning(f"Failed to load plugin '{entry_point.name}': {exc}") self._plugin_status[entry_point.name] = PluginStatus(is_success=False, detail=str(exc)) else: self._plugin_status[entry_point.name] = PluginStatus(is_success=True) diff --git a/uv.lock b/uv.lock index 3d7401b0..a5c4b57f 100644 --- a/uv.lock +++ b/uv.lock @@ -70,25 +70,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] -[[package]] -name = "apscheduler" -version = "3.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzlocal" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, -] - [[package]] name = "bub" version = "0.3.0" source = { editable = "." } dependencies = [ { name = "any-llm-sdk", extra = ["anthropic"] }, - { name = "apscheduler" }, { name = "loguru" }, { name = "pluggy" }, { name = "prompt-toolkit" }, @@ -122,7 +109,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "any-llm-sdk", extras = ["anthropic"] }, - { name = "apscheduler", specifier = ">=3.11.2" }, { name = "loguru", specifier = ">=0.7.2" }, { name = "pluggy", specifier = ">=1.6.0" }, { name = "prompt-toolkit", specifier = ">=3.0.0" }, @@ -1557,36 +1543,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "tzdata" -version = "2025.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, -] - -[[package]] -name = "tzlocal" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "tzdata", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - [[package]] name = "uv" version = "0.10.0" From 9f6a8f1bf95bd7dd0c918ff43fe00001adb71201 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 20:16:43 +0800 Subject: [PATCH 25/39] fix: trim whitespace from skill content read operations Signed-off-by: Frost Ming --- src/bub/skills.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bub/skills.py b/src/bub/skills.py index eff1cda8..92811132 100644 --- a/src/bub/skills.py +++ b/src/bub/skills.py @@ -29,7 +29,7 @@ class SkillMetadata: def body(self) -> str: front_matter_pattern = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) try: - content = self.location.read_text(encoding="utf-8") + content = self.location.read_text(encoding="utf-8").strip() except OSError: return "" return front_matter_pattern.sub("", content, count=1).strip() @@ -61,7 +61,7 @@ def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: return None try: - content = skill_file.read_text(encoding="utf-8") + content = skill_file.read_text(encoding="utf-8").strip() except OSError: return None From 683f3022a3c77e3d505998285304468bdc7c792d Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 20:20:52 +0800 Subject: [PATCH 26/39] feat: add GitHub CLI skill documentation for repository, issue, pull request, release, workflow, and gist operations Signed-off-by: Frost Ming --- src/bub_skills/gh/SKILL.md | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/bub_skills/gh/SKILL.md diff --git a/src/bub_skills/gh/SKILL.md b/src/bub_skills/gh/SKILL.md new file mode 100644 index 00000000..0fd4d147 --- /dev/null +++ b/src/bub_skills/gh/SKILL.md @@ -0,0 +1,86 @@ +--- +name: gh +description: GitHub CLI skill for interacting with GitHub via the gh command line tool. Use when Bub needs to (1) Create, view, or manage GitHub repositories, (2) Work with issues and pull requests, (3) Create and manage releases, (4) Run and monitor GitHub Actions workflows, (5) Create and manage gists, or (6) Perform any GitHub operations via command line. +--- + +# GitHub CLI (gh) Skill + +Interact with GitHub using the gh command line tool. + +## Prerequisites + +The GitHub PAT is available via `GITHUB_TOKEN` environment variable or `gh` CLI authentication. + +Check authentication: +```bash +gh auth status +``` + +If not authenticated: +```bash +gh auth login +``` + +## Repository Operations + +```bash +gh repo create [--public|--private] +gh repo clone +gh repo fork +gh repo view [owner/repo] +gh repo list [owner] +``` + +## Issue Operations + +```bash +gh issue create --title "Title" --body "Body" +gh issue list [--state open|closed] +gh issue view +gh issue close +gh issue comment --body "Comment" +``` + +## Pull Request Operations + +```bash +gh pr create --title "Title" --body "Body" +gh pr list [--state open|closed] +gh pr view +gh pr checkout +gh pr merge +gh pr review --approve +``` + +## Release Operations + +```bash +gh release create --generate-notes +gh release list +gh release download +gh release upload +``` + +## Workflow Operations + +```bash +gh workflow list +gh workflow run +gh run list +gh run view +gh run watch +``` + +## Gist Operations + +```bash +gh gist create +gh gist list +gh gist view +``` + +## Tips + +- Use --web to open in browser +- Use -R owner/repo to specify repository +- Use --json with --jq for scripting From 0ebaac1bca81ba9e936c88d529513d58c8f51af3 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 20:42:54 +0800 Subject: [PATCH 27/39] fix: update channel name retrieval in dispatch method to use output_channel as fallback Signed-off-by: Frost Ming --- src/bub/channels/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 20e779d7..7e00d0e8 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -73,7 +73,7 @@ def get_channel(self, name: str) -> Channel | None: return self._channels.get(name) async def dispatch(self, message: Envelope) -> bool: - channel_name = field_of(message, "channel") + channel_name = field_of(message, "output_channel", field_of(message, "channel")) if channel_name is None: return False From 989351234141b6921d20530c4169f65346d9a755 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 20:55:37 +0800 Subject: [PATCH 28/39] feat: add skill_load to the list of built-in tools Signed-off-by: Frost Ming --- src/bub/builtin/tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index bca73aef..0a7445ab 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -180,6 +180,7 @@ def get_builtin_tools() -> list[Tool]: return [ show_help, bash, + skill_load, fs_read, fs_write, fs_edit, From 6fd7fda48eec395cee580aa674a607a1770a46b4 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 20:57:19 +0800 Subject: [PATCH 29/39] fix: update docstring for skill_load function to clarify skill loading requirements Signed-off-by: Frost Ming --- src/bub/builtin/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 0a7445ab..b59e76fb 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -82,9 +82,9 @@ def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolConte @tool(context=True, name="skill.load") def skill_load(name: str, *, context: ToolContext) -> str: + """Load the skill content by name. The skill must be located in the 'skills' directory under the workspace and have a valid frontmatter.""" from bub.builtin.engine import workspace_from_state - """Load a skill by name. The skill must be located in the 'skills' directory under the workspace and have a valid frontmatter.""" workspace = workspace_from_state(context.state) skill_index = {skill.name: skill for skill in discover_skills(workspace)} if name.casefold() not in skill_index: From a4e21abaaf7b4569c1944004f522b3444fab3cb9 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Wed, 4 Mar 2026 20:57:49 +0800 Subject: [PATCH 30/39] fix: update docstring for skill_load function to clarify skill location requirements Signed-off-by: Frost Ming --- pyproject.toml | 1 + src/bub/builtin/tools.py | 2 +- uv.lock | 674 ++++++++++++--------------------------- 3 files changed, 210 insertions(+), 467 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index efa46444..b92fce76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Bub it. Build it. Batteries-included, hook-first AI framework." authors = [ { name = "Chojan Shang", email = "psiace@apache.org" }, { name = "Frost Ming", email = "me@frostming.com" }, + { name = "Yihong", email = "zouzou0208@gmail.com" }, ] readme = "README.md" requires-python = ">=3.12,<4.0" diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index b59e76fb..5530f1e7 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -82,7 +82,7 @@ def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolConte @tool(context=True, name="skill.load") def skill_load(name: str, *, context: ToolContext) -> str: - """Load the skill content by name. The skill must be located in the 'skills' directory under the workspace and have a valid frontmatter.""" + """Load the skill content by name. The skill must be located in predefined locations and have a valid frontmatter.""" from bub.builtin.engine import workspace_from_state workspace = workspace_from_state(context.state) diff --git a/uv.lock b/uv.lock index a5c4b57f..78f7aa63 100644 --- a/uv.lock +++ b/uv.lock @@ -3,8 +3,16 @@ revision = 3 requires-python = ">=3.12, <4.0" resolution-markers = [ "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] @@ -37,7 +45,7 @@ wheels = [ [[package]] name = "any-llm-sdk" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -47,9 +55,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/4a/9aaf246e535d4d9d59992ec9fb4c4008aeb450e5f47c87c891c7c31b2514/any_llm_sdk-1.8.0.tar.gz", hash = "sha256:4c4fc65459fa749d10cb051e116eaf4b36918e85d0ae8cd0c619862c3a442fda", size = 128310, upload-time = "2026-02-04T11:13:31.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/eb/34717209925bf9beac79eb729ac322a751bb3fce63a13741558a333bd97a/any_llm_sdk-1.9.0.tar.gz", hash = "sha256:ebce4aedb22fa944110fa58dcb98f3b788779a7862797622a17bc113d8743ead", size = 136181, upload-time = "2026-03-03T16:32:36.332Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/af/84ec19d7e5af2fa2ddf9762652ff68ed5d80bd0c74db3c291376dd10ecb0/any_llm_sdk-1.8.0-py3-none-any.whl", hash = "sha256:059b8216ef695f05dfc3cc0eee2f29c35160474c4ded4c8251d6b32dcd1928b1", size = 181447, upload-time = "2026-02-04T11:13:29.783Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ba/df83b28ab9f1195cb3da7a332f02d44b9c4c6249ac96e6a5a48ab2d97aa4/any_llm_sdk-1.9.0-py3-none-any.whl", hash = "sha256:4683261c407b15cd6eb7ee8f6f6d9fe9e844f37b58747334db21e9f0afbe4542", size = 191821, upload-time = "2026-03-03T16:32:35.004Z" }, ] [package.optional-dependencies] @@ -81,12 +89,10 @@ dependencies = [ { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "python-dotenv" }, { name = "python-telegram-bot" }, { name = "pyyaml" }, { name = "rapidfuzz" }, { name = "republic" }, - { name = "requests" }, { name = "rich" }, { name = "typer" }, ] @@ -114,7 +120,6 @@ requires-dist = [ { name = "prompt-toolkit", specifier = ">=3.0.0" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, - { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "rapidfuzz", specifier = ">=3.14.3" }, @@ -140,143 +145,20 @@ dev = [ [[package]] name = "cachetools" -version = "7.0.0" +version = "7.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/af/df70e9b65bc77a1cbe0768c0aa4617147f30f8306ded98c1744bcdc0ae1e/cachetools-7.0.0.tar.gz", hash = "sha256:a9abf18ff3b86c7d05b27ead412e235e16ae045925e531fae38d5fada5ed5b08", size = 35796, upload-time = "2026-02-01T18:59:47.411Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/c7/342b33cc6877eebc6c9bb45cb9f78e170e575839699f6f3cc96050176431/cachetools-7.0.2.tar.gz", hash = "sha256:7e7f09a4ca8b791d8bb4864afc71e9c17e607a28e6839ca1a644253c97dbeae0", size = 36983, upload-time = "2026-03-02T19:45:16.926Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/df/2dd32cce20cbcf6f2ec456b58d44368161ad28320729f64e5e1d5d7bd0ae/cachetools-7.0.0-py3-none-any.whl", hash = "sha256:d52fef60e6e964a1969cfb61ccf6242a801b432790fe520d78720d757c81cbd2", size = 13487, upload-time = "2026-02-01T18:59:45.981Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/4b6968e77c110f12da96fdbfcb39c6557c2e5e81bd7afcf8ed893d5bc588/cachetools-7.0.2-py3-none-any.whl", hash = "sha256:938dcad184827c5e94928c4fd5526e2b46692b7fb1ae94472da9131d0299343c", size = 13793, upload-time = "2026-03-02T19:45:15.495Z" }, ] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "chardet" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -300,72 +182,6 @@ 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 = "cryptography" -version = "46.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, -] - -[[package]] -name = "discord-py" -version = "2.6.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/e7/9b1dbb9b2fc07616132a526c05af23cfd420381793968a189ee08e12e35f/discord_py-2.6.4.tar.gz", hash = "sha256:44384920bae9b7a073df64ae9b14c8cf85f9274b5ad5d1d07bd5a67539de2da9", size = 1092623, upload-time = "2025-10-08T21:45:43.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/ae/3d3a89b06f005dc5fa8618528dde519b3ba7775c365750f7932b9831ef05/discord_py-2.6.4-py3-none-any.whl", hash = "sha256:2783b7fb7f8affa26847bfc025144652c294e8fe6e0f8877c67ed895749eb227", size = 1209284, upload-time = "2025-10-08T21:45:41.679Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -395,11 +211,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.3" +version = "3.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] [[package]] @@ -415,55 +231,11 @@ wheels = [ ] [[package]] -name = "google-auth" -version = "2.48.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, - { name = "rsa" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, -] - -[package.optional-dependencies] -requests = [ - { name = "requests" }, -] - -[[package]] -name = "google-genai" -version = "1.65.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "google-auth", extra = ["requests"] }, - { name = "httpx" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "sniffio" }, - { name = "tenacity" }, - { name = "typing-extensions" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/f9/cc1191c2540d6a4e24609a586c4ed45d2db57cfef47931c139ee70e5874a/google_genai-1.65.0.tar.gz", hash = "sha256:d470eb600af802d58a79c7f13342d9ea0d05d965007cae8f76c7adff3d7a4750", size = 497206, upload-time = "2026-02-26T00:20:33.824Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/3c/3fea4e7c91357c71782d7dcaad7a2577d636c90317e003386893c25bc62c/google_genai-1.65.0-py3-none-any.whl", hash = "sha256:68c025205856919bc03edb0155c11b4b833810b7ce17ad4b7a9eeba5158f6c44", size = 724429, upload-time = "2026-02-26T00:20:32.186Z" }, -] - -[[package]] -name = "griffe" -version = "1.15.0" +name = "griffelib" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, + { url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" }, ] [[package]] @@ -503,11 +275,6 @@ 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.optional-dependencies] -socks = [ - { name = "socksio" }, -] - [[package]] name = "idna" version = "3.11" @@ -608,54 +375,62 @@ wheels = [ [[package]] name = "librt" -version = "0.7.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, - { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, - { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, - { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, - { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, - { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, - { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, - { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, - { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, - { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, - { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, - { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, - { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, - { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, - { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, - { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, - { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, - { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, - { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, - { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, - { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, - { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, - { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, - { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, - { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, - { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, - { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] [[package]] @@ -673,11 +448,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.1" +version = "3.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] @@ -799,16 +574,16 @@ wheels = [ [[package]] name = "mkdocs-autorefs" -version = "1.4.3" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] [[package]] @@ -865,16 +640,16 @@ python = [ [[package]] name = "mkdocstrings-python" -version = "2.0.1" +version = "2.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "griffe" }, + { name = "griffelib" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, ] [[package]] @@ -921,7 +696,7 @@ wheels = [ [[package]] name = "openai" -version = "2.17.0" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -933,9 +708,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/a2/677f22c4b487effb8a09439fb6134034b5f0a39ca27df8b95fac23a93720/openai-2.17.0.tar.gz", hash = "sha256:47224b74bd20f30c6b0a6a329505243cb2f26d5cf84d9f8d0825ff8b35e9c999", size = 631445, upload-time = "2026-02-05T16:27:40.953Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/97/284535aa75e6e84ab388248b5a323fc296b1f70530130dee37f7f4fbe856/openai-2.17.0-py3-none-any.whl", hash = "sha256:4f393fd886ca35e113aac7ff239bcd578b81d8f104f5aedc7d3693eb2af1d338", size = 1069524, upload-time = "2026-02-05T16:27:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" }, ] [[package]] @@ -970,11 +745,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.1" +version = "4.9.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] [[package]] @@ -988,26 +763,26 @@ wheels = [ [[package]] name = "prek" -version = "0.3.2" +version = "0.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/f5/ee52def928dd1355c20bcfcf765e1e61434635c33f3075e848e7b83a157b/prek-0.3.2.tar.gz", hash = "sha256:dce0074ff1a21290748ca567b4bda7553ee305a8c7b14d737e6c58364a499364", size = 334229, upload-time = "2026-02-06T13:49:47.539Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/51/2324eaad93a4b144853ca1c56da76f357d3a70c7b4fd6659e972d7bb8660/prek-0.3.4.tar.gz", hash = "sha256:56a74d02d8b7dfe3c774ecfcd8c1b4e5f1e1b84369043a8003e8e3a779fce72d", size = 356633, upload-time = "2026-02-28T03:47:13.452Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/69/70a5fc881290a63910494df2677c0fb241d27cfaa435bbcd0de5cd2e2443/prek-0.3.2-py3-none-linux_armv6l.whl", hash = "sha256:4f352f9c3fc98aeed4c8b2ec4dbf16fc386e45eea163c44d67e5571489bd8e6f", size = 4614960, upload-time = "2026-02-06T13:50:05.818Z" }, - { url = "https://files.pythonhosted.org/packages/c0/15/a82d5d32a2207ccae5d86ea9e44f2b93531ed000faf83a253e8d1108e026/prek-0.3.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4a000cfbc3a6ec7d424f8be3c3e69ccd595448197f92daac8652382d0acc2593", size = 4622889, upload-time = "2026-02-06T13:49:53.662Z" }, - { url = "https://files.pythonhosted.org/packages/89/75/ea833b58a12741397017baef9b66a6e443bfa8286ecbd645d14111446280/prek-0.3.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5436bdc2702cbd7bcf9e355564ae66f8131211e65fefae54665a94a07c3d450a", size = 4239653, upload-time = "2026-02-06T13:50:02.88Z" }, - { url = "https://files.pythonhosted.org/packages/10/b4/d9c3885987afac6e20df4cb7db14e3b0d5a08a77ae4916488254ebac4d0b/prek-0.3.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0161b5f584f9e7f416d6cf40a17b98f17953050ff8d8350ec60f20fe966b86b6", size = 4595101, upload-time = "2026-02-06T13:49:49.813Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/1a06473ed83dbc898de22838abdb13954e2583ce229f857f61828384634c/prek-0.3.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e641e8533bca38797eebb49aa89ed0e8db0e61225943b27008c257e3af4d631", size = 4521978, upload-time = "2026-02-06T13:49:41.266Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5e/c38390d5612e6d86b32151c1d2fdab74a57913473193591f0eb00c894c21/prek-0.3.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfca1810d49d3f9ef37599c958c4e716bc19a1d78a7e88cbdcb332e0b008994f", size = 4829108, upload-time = "2026-02-06T13:49:44.598Z" }, - { url = "https://files.pythonhosted.org/packages/80/a6/cecce2ab623747ff65ed990bb0d95fa38449ee19b348234862acf9392fff/prek-0.3.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d69d754299a95a85dc20196f633232f306bee7e7c8cba61791f49ce70404ec", size = 5357520, upload-time = "2026-02-06T13:49:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/a5/18/d6bcb29501514023c76d55d5cd03bdbc037737c8de8b6bc41cdebfb1682c/prek-0.3.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:539dcb90ad9b20837968539855df6a29493b328a1ae87641560768eed4f313b0", size = 4852635, upload-time = "2026-02-06T13:49:58.347Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0a/ae46f34ba27ba87aea5c9ad4ac9cd3e07e014fd5079ae079c84198f62118/prek-0.3.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1998db3d0cbe243984736c82232be51318f9192e2433919a6b1c5790f600b5fd", size = 4599484, upload-time = "2026-02-06T13:49:43.296Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a9/73bfb5b3f7c3583f9b0d431924873928705cdef6abb3d0461c37254a681b/prek-0.3.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:07ab237a5415a3e8c0db54de9d63899bcd947624bdd8820d26f12e65f8d19eb7", size = 4657694, upload-time = "2026-02-06T13:50:01.074Z" }, - { url = "https://files.pythonhosted.org/packages/a7/bc/0994bc176e1a80110fad3babce2c98b0ac4007630774c9e18fc200a34781/prek-0.3.2-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:0ced19701d69c14a08125f14a5dd03945982edf59e793c73a95caf4697a7ac30", size = 4509337, upload-time = "2026-02-06T13:49:54.891Z" }, - { url = "https://files.pythonhosted.org/packages/f9/13/e73f85f65ba8f626468e5d1694ab3763111513da08e0074517f40238c061/prek-0.3.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ffb28189f976fa111e770ee94e4f298add307714568fb7d610c8a7095cb1ce59", size = 4697350, upload-time = "2026-02-06T13:50:04.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/47/98c46dcd580305b9960252a4eb966f1a7b1035c55c363f378d85662ba400/prek-0.3.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:f63134b3eea14421789a7335d86f99aee277cb520427196f2923b9260c60e5c5", size = 4955860, upload-time = "2026-02-06T13:49:56.581Z" }, - { url = "https://files.pythonhosted.org/packages/73/42/1bb4bba3ff47897df11e9dfd774027cdfa135482c961a54e079af0faf45a/prek-0.3.2-py3-none-win32.whl", hash = "sha256:58c806bd1344becd480ef5a5ba348846cc000af0e1fbe854fef91181a2e06461", size = 4267619, upload-time = "2026-02-06T13:49:39.503Z" }, - { url = "https://files.pythonhosted.org/packages/97/11/6665f47a7c350d83de17403c90bbf7a762ef50876ece456a86f64f46fbfb/prek-0.3.2-py3-none-win_amd64.whl", hash = "sha256:70114b48e9eb8048b2c11b4c7715ce618529c6af71acc84dd8877871a2ef71a6", size = 4624324, upload-time = "2026-02-06T13:49:45.922Z" }, - { url = "https://files.pythonhosted.org/packages/22/e7/740997ca82574d03426f897fd88afe3fc8a7306b8c7ea342a8bc1c538488/prek-0.3.2-py3-none-win_arm64.whl", hash = "sha256:9144d176d0daa2469a25c303ef6f6fa95a8df015eb275232f5cb53551ecefef0", size = 4336008, upload-time = "2026-02-06T13:49:52.27Z" }, + { url = "https://files.pythonhosted.org/packages/09/20/1a964cb72582307c2f1dc7f583caab90f42810ad41551e5220592406a4c3/prek-0.3.4-py3-none-linux_armv6l.whl", hash = "sha256:c35192d6e23fe7406bd2f333d1c7dab1a4b34ab9289789f453170f33550aa74d", size = 4641915, upload-time = "2026-02-28T03:47:03.772Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cb/4a21f37102bac37e415b61818344aa85de8d29a581253afa7db8c08d5a33/prek-0.3.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6f784d78de72a8bbe58a5fe7bde787c364ae88f0aff5222c5c5c7287876c510a", size = 4649166, upload-time = "2026-02-28T03:47:06.164Z" }, + { url = "https://files.pythonhosted.org/packages/85/9c/a7c0d117a098d57931428bdb60fcb796e0ebc0478c59288017a2e22eca96/prek-0.3.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50a43f522625e8c968e8c9992accf9e29017abad6c782d6d176b73145ad680b7", size = 4274422, upload-time = "2026-02-28T03:46:59.356Z" }, + { url = "https://files.pythonhosted.org/packages/59/84/81d06df1724d09266df97599a02543d82fde7dfaefd192f09d9b2ccb092f/prek-0.3.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:4bbb1d3912a88935f35c6ba4466b4242732e3e3a8c608623c708e83cea85de00", size = 4629873, upload-time = "2026-02-28T03:46:56.419Z" }, + { url = "https://files.pythonhosted.org/packages/09/cd/bb0aefa25cfacd8dbced75b9a9d9945707707867fa5635fb69ae1bbc2d88/prek-0.3.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca4d4134db8f6e8de3c418317becdf428957e3cab271807f475318105fd46d04", size = 4552507, upload-time = "2026-02-28T03:47:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/578a7af4861afb64ec81c03bfdcc1bb3341bb61f2fff8a094ecf13987a56/prek-0.3.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fb6395f6eb76133bb1e11fc718db8144522466cdc2e541d05e7813d1bbcae7d", size = 4865929, upload-time = "2026-02-28T03:47:09.231Z" }, + { url = "https://files.pythonhosted.org/packages/fc/48/f169406590028f7698ef2e1ff5bffd92ca05e017636c1163a2f5ef0f8275/prek-0.3.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aae17813239ddcb4ae7b38418de4d49afff740f48f8e0556029c96f58e350412", size = 5390286, upload-time = "2026-02-28T03:47:10.796Z" }, + { url = "https://files.pythonhosted.org/packages/05/c5/98a73fec052059c3ae06ce105bef67caca42334c56d84e9ef75df72ba152/prek-0.3.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10a621a690d9c127afc3d21c275030d364d1fbef3296c095068d3ae80a59546e", size = 4891028, upload-time = "2026-02-28T03:47:07.916Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b4/029966e35e59b59c142be7e1d2208ad261709ac1a66aa4a3ce33c5b9f91f/prek-0.3.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d978c31bc3b1f0b3d58895b7c6ac26f077e0ea846da54f46aeee4c7088b1b105", size = 4633986, upload-time = "2026-02-28T03:47:14.351Z" }, + { url = "https://files.pythonhosted.org/packages/1d/27/d122802555745b6940c99fcb41496001c192ddcdf56ec947ec10a0298e05/prek-0.3.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8e089a030f0a023c22a4bb2ec4ff3fcc153585d701cff67acbfca2f37e173ae", size = 4680722, upload-time = "2026-02-28T03:47:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/92318c96b3a67b4e62ed82741016ede34d97ea9579d3cc1332b167632222/prek-0.3.4-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8060c72b764f0b88112616763da9dd3a7c293e010f8520b74079893096160a2f", size = 4535623, upload-time = "2026-02-28T03:46:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/df/f5/6b383d94e722637da4926b4f609d36fe432827bb6f035ad46ee02bde66b6/prek-0.3.4-py3-none-musllinux_1_1_i686.whl", hash = "sha256:65b23268456b5a763278d4e1ec532f2df33918f13ded85869a1ddff761eb9697", size = 4729879, upload-time = "2026-02-28T03:46:57.886Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/fdc705b807d813fd713ffa4f67f96741542ed1dafbb221206078c06f3df4/prek-0.3.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3975c61139c7b3200e38dc3955e050b0f2615701d3deb9715696a902e850509e", size = 5001569, upload-time = "2026-02-28T03:47:00.892Z" }, + { url = "https://files.pythonhosted.org/packages/84/92/b007a41f58e8192a1e611a21b396ad870d51d7873b7af12068ebae7fc15f/prek-0.3.4-py3-none-win32.whl", hash = "sha256:37449ae82f4dc08b72e542401e3d7318f05d1163e87c31ab260a40f425d6516e", size = 4297057, upload-time = "2026-02-28T03:47:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dc/bcb02de9b11461e8e0c7d3c8fdf8cfa15ac6efe73472a4375549ba5defd2/prek-0.3.4-py3-none-win_amd64.whl", hash = "sha256:60e9aa86ca65de963510ae28c5d94b9d7a97bcbaa6e4cdb5bf5083ed4c45dc71", size = 4655174, upload-time = "2026-02-28T03:46:53.749Z" }, + { url = "https://files.pythonhosted.org/packages/0b/86/98f5598569f4cd3de7161e266fab6a8981e65555f79d4704810c1502ad0a/prek-0.3.4-py3-none-win_arm64.whl", hash = "sha256:486bdae8f4512d3b4f6eb61b83e5b7595da2adca385af4b2b7823c0ab38d1827", size = 4367817, upload-time = "2026-02-28T03:46:55.264Z" }, ] [[package]] @@ -1110,16 +885,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -1133,15 +908,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.20.1" +version = "10.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/63/06673d1eb6d8f83c0ea1f677d770e12565fb516928b4109c9e2055656a9e/pymdown_extensions-10.21.tar.gz", hash = "sha256:39f4a020f40773f6b2ff31d2cd2546c2c04d0a6498c31d9c688d2be07e1767d5", size = 853363, upload-time = "2026-02-15T20:44:06.748Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/5b079febdc65e1c3fb2729bf958d18b45be7113828528e8a0b5850dd819a/pymdown_extensions-10.21-py3-none-any.whl", hash = "sha256:91b879f9f864d49794c2d9534372b10150e6141096c3908a455e45ca72ad9d3f", size = 268877, upload-time = "2026-02-15T20:44:05.464Z" }, ] [[package]] @@ -1197,13 +972,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-discovery" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/93a3e83bdf9322c7e21cafd092e56a4a17c4d8ef4277b6eb01af1a540a6f/python_discovery-1.1.0.tar.gz", hash = "sha256:447941ba1aed8cc2ab7ee3cb91be5fc137c5bdbb05b7e6ea62fbdcb66e50b268", size = 55674, upload-time = "2026-02-26T09:42:49.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" }, +] + [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +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/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { 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]] @@ -1355,52 +1143,40 @@ wheels = [ [[package]] name = "rich" -version = "14.3.2" +version = "14.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, -] - -[[package]] -name = "rsa" -version = "4.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, ] [[package]] name = "ruff" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] [[package]] @@ -1454,11 +1230,10 @@ wheels = [ [[package]] name = "tox" -version = "4.34.1" +version = "4.47.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cachetools" }, - { name = "chardet" }, { name = "colorama" }, { name = "filelock" }, { name = "packaging" }, @@ -1467,23 +1242,34 @@ dependencies = [ { name = "pyproject-api" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/9b/5909f40b281ebd37c2f83de5087b9cb8a9a64c33745f334be0aeaedadbbc/tox-4.34.1.tar.gz", hash = "sha256:ef1e82974c2f5ea02954d590ee0b967fad500c3879b264ea19efb9a554f3cc60", size = 205306, upload-time = "2026-01-09T17:42:59.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/62/182ae37b7472072086184e2246e01c6fb8399b87d76e0c79636774ee7117/tox-4.47.3.tar.gz", hash = "sha256:57643508d4c218ad312457a3b0ce3135c50fa1f9f1e4d40867683d880cad1c37", size = 254468, upload-time = "2026-03-04T02:39:01.842Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/0f/fe6629e277ce615e53d0a0b65dc23c88b15a402bb7dbf771f17bbd18f1c4/tox-4.34.1-py3-none-any.whl", hash = "sha256:5610d69708bab578d618959b023f8d7d5d3386ed14a2392aeebf9c583615af60", size = 176812, upload-time = "2026-01-09T17:42:58.629Z" }, + { url = "https://files.pythonhosted.org/packages/01/3b/66b7b2cc1478b066e55eba27e2903db2f20bf363c14ac0e33c08df3364f8/tox-4.47.3-py3-none-any.whl", hash = "sha256:e447862a6821b421bbbfb8cbac071818c0a6884907a4c964d8322516d0b19b34", size = 202177, upload-time = "2026-03-04T02:39:00.482Z" }, ] [[package]] name = "tox-uv" -version = "1.29.0" +version = "1.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tox-uv-bare" }, + { name = "uv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/51/9a6dd32e34a3ee200c7890497093875e2c0a0b08737bb897e5916c6575bc/tox_uv-1.33.1-py3-none-any.whl", hash = "sha256:0617caa6444097434cdef24477307ff3242021a44088df673ae08771d3657f79", size = 5364, upload-time = "2026-03-02T17:06:18.32Z" }, +] + +[[package]] +name = "tox-uv-bare" +version = "1.33.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, { name = "tox" }, - { name = "uv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/90/06752775b8cfadba8856190f5beae9f552547e0f287e0246677972107375/tox_uv-1.29.0.tar.gz", hash = "sha256:30fa9e6ad507df49d3c6a2f88894256bcf90f18e240a00764da6ecab1db24895", size = 23427, upload-time = "2025-10-09T20:40:27.384Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/7b/5ce3aa477400c7791968037b3bf27a50a4e19160a111d9956d20e5ce6b06/tox_uv_bare-1.33.1.tar.gz", hash = "sha256:169185feb3cc8f321eb2a33c575c61dc6efd9bf6044b97636a7381261d29e85c", size = 27203, upload-time = "2026-03-02T17:06:21.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/17/221d62937c4130b044bb437caac4181e7e13d5536bbede65264db1f0ac9f/tox_uv-1.29.0-py3-none-any.whl", hash = "sha256:b1d251286edeeb4bc4af1e24c8acfdd9404700143c2199ccdbb4ea195f7de6cc", size = 17254, upload-time = "2025-10-09T20:40:25.885Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8e/ae95104165f4e2da5d9d25d8c71c7c935227c3eeb88e0376dab48b787a1c/tox_uv_bare-1.33.1-py3-none-any.whl", hash = "sha256:e64fdcd607a0f66212ef9edb36a5a672f10b461fce2a8216dda3e93c45d4a3f9", size = 19718, upload-time = "2026-03-02T17:06:19.657Z" }, ] [[package]] @@ -1500,17 +1286,17 @@ wheels = [ [[package]] name = "typer" -version = "0.21.1" +version = "0.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc" }, { name = "click" }, { name = "rich" }, { name = "shellingham" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, ] [[package]] @@ -1545,41 +1331,42 @@ wheels = [ [[package]] name = "uv" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" }, - { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" }, - { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" }, - { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" }, - { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" }, - { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" }, +version = "0.10.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/e7/600a90d4662dbd8414c1f6b709c8c79075d37d2044f72b94acbfaf29baad/uv-0.10.8.tar.gz", hash = "sha256:4b23242b5224c7eaea481ce6c6dbc210f0eafb447cf60211633980947cd23de4", size = 3936600, upload-time = "2026-03-03T21:35:22.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/6c/8ef256575242d5f3869c5a445ffd4363b91a89acb34a3e043bec2ad5a1be/uv-0.10.8-py3-none-linux_armv6l.whl", hash = "sha256:d214c82c7c14dd23f9aeb609d03070b8ea2b2f0cf249c9321cbbb5375a17e5df", size = 22461003, upload-time = "2026-03-03T21:35:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fb/fd0656a92e6b9c4f92ddba7dcd76bd87469be500755125e06fea853dc212/uv-0.10.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1315c3901c5859aec2c5b4a17da4c5410d17f6890890f9f1a31f25aa0fa9ace", size = 21549446, upload-time = "2026-03-03T21:35:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/64/b9/1a4105df3afe7af99791f5b00fb037d85b2e3aaa1227e95878538d51ecf3/uv-0.10.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a253e5d2cae9e02654de31918b610dfc8f1f16a33f34046603757820bc45ee1b", size = 20222180, upload-time = "2026-03-03T21:35:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/c5/72/6e98e0f8b3fe80cb881c36492dca6d932fbb05f956dfdccbdb8ebe4ceff4/uv-0.10.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:57a24e15fd9dd4a36bcec2ccbe4b26d2a172c109e954a8940f5e8a8b965dae74", size = 22064813, upload-time = "2026-03-03T21:35:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/737da8577f4b1799f7024f6cd98fffcac77076a1b078b277cffc84946e96/uv-0.10.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:675dc659195f9b9811ef5534eb3f16459fc88e109aefacbc91c07751b5b9715a", size = 22064861, upload-time = "2026-03-03T21:35:25.067Z" }, + { url = "https://files.pythonhosted.org/packages/7e/21/464ee3cd81f44345953cb26dd49870811f7647f3074f7651775cadb2158b/uv-0.10.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:18d2968b0a50111c2fc6b782f7c63ded4f461c44efab537f552cf565f9aaae25", size = 22054515, upload-time = "2026-03-03T21:35:44.572Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/1c592d7b843ffa999502116b0dc573732b40cb37061a4acc741dcdb181da/uv-0.10.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ed3c7ebb6f757cddedb56dec3d7c745e5ea7310b11e12ae1c28f1e8172e7bbf", size = 23433992, upload-time = "2026-03-03T21:35:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e2/2b716f0613746138294598668bbe65295a8da3d8fa104a756dec6284bf3c/uv-0.10.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffaf115501e33be0d4f13cb5b7c2b46b031d4c679a6109e24a7edfb719c44c6c", size = 24257250, upload-time = "2026-03-03T21:35:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4d/0165e82cd1117cd6f8a7d9a2122c23cc091f7cf738aa4a2a54579420a08f/uv-0.10.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0209ee8cb573e113ff4a760360f28448f9ebcdcf9c91ca49e872821de5d2d054", size = 23338918, upload-time = "2026-03-03T21:35:33.795Z" }, + { url = "https://files.pythonhosted.org/packages/20/74/652129a25145732482bb0020602507f52d9a5ca0e1a40ddd6deb27402333/uv-0.10.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11dc790f732dc5fee61f0f6bd998fc2e9c200df1082245604ac091c32c23a523", size = 23259370, upload-time = "2026-03-03T21:35:39.478Z" }, + { url = "https://files.pythonhosted.org/packages/19/c5/6e5923d6c9e3b50dc8542647bea692b7c227a9489f59ddff4fdfb20d8459/uv-0.10.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e26f8c35684face38db814d452dd1a2181152dbf7f7b2de1f547e6ba0c378d67", size = 22174747, upload-time = "2026-03-03T21:35:42.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/eee9e1883888327d07f51e7595ed5952e0bca2dc79d1c03b8a6e4309553e/uv-0.10.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:385add107d40c43dc00ca8c1a21ecf43101f846f8339eb7026bf6c9f6df7760d", size = 22893359, upload-time = "2026-03-03T21:35:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/bf/36/407a22917e55ce5cc2e7af956e3b9d91648a96558858acef84e3c50d5ca8/uv-0.10.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:24e8eb28c4f05acb38e60fefe2a2b15f4283a3849ce580bf2a62aca0a13123b3", size = 22637451, upload-time = "2026-03-03T21:35:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/21/d5/dabef9914e1ff27ad95e4b1daf59cd97c80e26a44c04c2870bcca7c83fc0/uv-0.10.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:73a8c1a1fceac73cd983dcc0a64f4f94f5fd1e5428681a5a76132574264504fb", size = 23480991, upload-time = "2026-03-03T21:35:52.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c0/1a4a45a9246f087e9446d0d804a436f6ee0befeaef731b04d1b2802d9d8f/uv-0.10.8-py3-none-win32.whl", hash = "sha256:9f344fdb34938ce35e9211a1b866adfa0c7f043967652ed1431917514aeec062", size = 21579030, upload-time = "2026-03-03T21:35:28.176Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2b/b29510efa1e6f409db105dbdafbd942ca3a2b638bef682ff2e5b9f6e4021/uv-0.10.8-py3-none-win_amd64.whl", hash = "sha256:1e63015284ed28c2112717256c328513215fb966a57c5870788eac2e8f949f28", size = 23944828, upload-time = "2026-03-03T21:36:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/3f/9e/b5a11b0523171c0103c4fed54da76685a765ad4d3215e8220facfd24aed9/uv-0.10.8-py3-none-win_arm64.whl", hash = "sha256:a80284f46b6f2e0b3d03eb7c2d43e17139a4ec313e8b9f56a71efafc996804cb", size = 22322224, upload-time = "2026-03-03T21:35:14.148Z" }, ] [[package]] name = "virtualenv" -version = "20.36.1" +version = "21.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, + { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/c9/18d4b36606d6091844daa3bd93cf7dc78e6f5da21d9f21d06c221104b684/virtualenv-21.1.0.tar.gz", hash = "sha256:1990a0188c8f16b6b9cf65c9183049007375b26aad415514d377ccacf1e4fb44", size = 5840471, upload-time = "2026-02-27T08:49:29.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/78/55/896b06bf93a49bec0f4ae2a6f1ed12bd05c8860744ac3a70eda041064e4d/virtualenv-21.1.0-py3-none-any.whl", hash = "sha256:164f5e14c5587d170cf98e60378eb91ea35bf037be313811905d3a24ea33cc07", size = 5825072, upload-time = "2026-02-27T08:49:27.516Z" }, ] [[package]] @@ -1615,51 +1402,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] - [[package]] name = "win32-setctime" version = "1.2.0" From 1cd158020efecf28eff82cd7d76124a2a04f3bac Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 5 Mar 2026 11:39:28 +0800 Subject: [PATCH 31/39] refactor: remove Discord and Telegram skill implementations and related scripts Signed-off-by: Frost Ming --- src/bub/channels/discord.py | 298 ------------------ src/bub/skills/discord/SKILL.md | 145 --------- src/bub/skills/discord/scripts/discord_bot.py | 140 -------- .../skills/discord/scripts/discord_send.py | 71 ----- src/bub/skills/gh/SKILL.md | 86 ----- src/bub/skills/telegram/SKILL.md | 126 -------- .../skills/telegram/scripts/telegram_edit.py | 105 ------ .../skills/telegram/scripts/telegram_send.py | 179 ----------- 8 files changed, 1150 deletions(-) delete mode 100644 src/bub/channels/discord.py delete mode 100644 src/bub/skills/discord/SKILL.md delete mode 100644 src/bub/skills/discord/scripts/discord_bot.py delete mode 100644 src/bub/skills/discord/scripts/discord_send.py delete mode 100644 src/bub/skills/gh/SKILL.md delete mode 100644 src/bub/skills/telegram/SKILL.md delete mode 100644 src/bub/skills/telegram/scripts/telegram_edit.py delete mode 100755 src/bub/skills/telegram/scripts/telegram_send.py diff --git a/src/bub/channels/discord.py b/src/bub/channels/discord.py deleted file mode 100644 index 0ac233d7..00000000 --- a/src/bub/channels/discord.py +++ /dev/null @@ -1,298 +0,0 @@ -"""Discord channel adapter.""" - -from __future__ import annotations - -import contextlib -import json -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any, cast - -import discord -from discord.ext import commands -from loguru import logger - -from bub.app.runtime import AppRuntime -from bub.channels.base import BaseChannel, exclude_none -from bub.channels.utils import resolve_proxy -from bub.core.agent_loop import LoopResult - - -def _message_type(message: discord.Message) -> str: - if message.content: - return "text" - if message.attachments: - return "attachment" - if message.stickers: - return "sticker" - return "unknown" - - -@dataclass(frozen=True) -class DiscordConfig: - """Discord adapter config.""" - - token: str - allow_from: set[str] - allow_channels: set[str] - command_prefix: str = "!" - proxy: str | None = None - - -class DiscordChannel(BaseChannel[discord.Message]): - """Discord adapter based on discord.py.""" - - name = "discord" - - def __init__(self, runtime: AppRuntime) -> None: - super().__init__(runtime) - settings = runtime.settings - self._config = DiscordConfig( - token=settings.discord_token or "", - allow_from=set(settings.discord_allow_from), - allow_channels=set(settings.discord_allow_channels), - command_prefix=settings.discord_command_prefix, - proxy=settings.discord_proxy, - ) - self._bot: commands.Bot | None = None - self._on_receive: Callable[[discord.Message], Awaitable[None]] | None = None - self._latest_message_by_session: dict[str, discord.Message] = {} - - async def start(self, on_receive: Callable[[discord.Message], Awaitable[None]]) -> None: - if not self._config.token: - raise RuntimeError("discord token is empty") - - self._on_receive = on_receive - intents = discord.Intents.default() - intents.messages = True - intents.message_content = True - - proxy, _ = resolve_proxy(self._config.proxy) - bot = commands.Bot(command_prefix=self._config.command_prefix, intents=intents, help_command=None, proxy=proxy) - self._bot = bot - - @bot.event - async def on_ready() -> None: - logger.info("discord.ready user={} id={}", str(bot.user), bot.user.id if bot.user else "") - - @bot.event - async def on_message(message: discord.Message) -> None: - await bot.process_commands(message) - await self._on_message(message) - - logger.info( - "discord.start allow_from_count={} allow_channels_count={} proxy_enabled={}", - len(self._config.allow_from), - len(self._config.allow_channels), - bool(proxy), - ) - try: - async with bot: - await bot.start(self._config.token) - finally: - self._bot = None - logger.info("discord.stopped") - - async def get_session_prompt(self, message: discord.Message) -> tuple[str, str]: - channel_id = str(message.channel.id) - session_id = f"{self.name}:{channel_id}" - content, media = self._parse_message(message) - - prefix = f"{self._config.command_prefix}bub " - if content.startswith(prefix): - content = content[len(prefix) :] - - if content.strip().startswith(","): - self._latest_message_by_session[session_id] = message - return session_id, content - - metadata: dict[str, Any] = { - "message_id": message.id, - "type": _message_type(message), - "username": message.author.name, - "full_name": getattr(message.author, "display_name", message.author.name), - "sender_id": str(message.author.id), - "date": message.created_at.timestamp() if message.created_at else None, - "channel_id": str(message.channel.id), - "guild_id": str(message.guild.id) if message.guild else None, - } - - if media: - metadata["media"] = media - - reply_meta = self._extract_reply_metadata(message) - if reply_meta: - metadata["reply_to_message"] = reply_meta - - metadata_json = json.dumps( - {"message": content, "channel_id": channel_id, **exclude_none(metadata)}, ensure_ascii=False - ) - self._latest_message_by_session[session_id] = message - return session_id, metadata_json - - async def process_output(self, session_id: str, output: LoopResult) -> None: - parts = [part for part in (output.immediate_output, output.assistant_output) if part] - if output.error: - parts.append(f"Error: {output.error}") - content = "\n\n".join(parts).strip() - if content: - print(content, flush=True) - - send_content = output.immediate_output.strip() - if not send_content: - return - - channel = await self._resolve_channel(session_id) - if channel is None: - logger.warning("discord.outbound unresolved channel session_id={}", session_id) - return - - source = self._latest_message_by_session.get(session_id) - reference = source.to_reference(fail_if_not_exists=False) if source is not None else None - for chunk in self._chunk_message(send_content): - kwargs: dict[str, Any] = {"content": chunk} - if reference is not None: - kwargs["reference"] = reference - kwargs["mention_author"] = False - await channel.send(**kwargs) - - async def _on_message(self, message: discord.Message) -> None: - if message.author.bot: - return - if self._on_receive is None: - logger.warning("discord.inbound no handler for received messages") - return - - content, _ = self._parse_message(message) - logger.info( - "discord.inbound channel_id={} sender_id={} username={} content={}", - message.channel.id, - message.author.id, - message.author.name, - content[:100], - ) - - async with message.channel.typing(): - await self._on_receive(message) - - async def _resolve_channel(self, session_id: str) -> discord.abc.Messageable | None: - if self._bot is None: - return None - channel_id = int(session_id.split(":", 1)[1]) - channel = self._bot.get_channel(channel_id) - if channel is not None: - return channel # type: ignore[return-value] - with contextlib.suppress(Exception): - fetched = await self._bot.fetch_channel(channel_id) - if isinstance(fetched, discord.abc.Messageable): - return fetched - return None - - def is_mentioned(self, message: discord.Message) -> bool: - channel_id = str(message.channel.id) - if self._config.allow_channels and channel_id not in self._config.allow_channels: - return False - - if not message.content.strip(): - return False - - sender_tokens = {str(message.author.id), message.author.name} - if getattr(message.author, "global_name", None): - sender_tokens.add(cast(str, message.author.global_name)) - if self._config.allow_from and sender_tokens.isdisjoint(self._config.allow_from): - logger.warning( - "discord.inbound.denied channel_id={} sender_id={} reason=allow_from", - message.channel.id, - message.author.id, - ) - return False - - if ( - isinstance(message.channel, discord.DMChannel) - or "bub" in message.content.lower() - or self._is_bub_scoped_thread(message) - or message.content.startswith(f"{self._config.command_prefix}bub") - ): - return True - - bot_user = self._bot.user if self._bot is not None else None - if bot_user is None: - return False - if bot_user in message.mentions: - return True - - ref = message.reference - if ref is None: - return False - resolved = ref.resolved - return bool(isinstance(resolved, discord.Message) and resolved.author and resolved.author.id == bot_user.id) - - @staticmethod - def _is_bub_scoped_thread(message: discord.Message) -> bool: - channel = message.channel - thread_name = getattr(channel, "name", None) - if not isinstance(thread_name, str): - return False - is_thread = isinstance(channel, discord.Thread) or getattr(channel, "parent", None) is not None - return is_thread and thread_name.lower().startswith("bub") - - @staticmethod - def _parse_message(message: discord.Message) -> tuple[str, dict[str, Any] | None]: - if message.content: - return message.content, None - - if message.attachments: - attachment_lines: list[str] = [] - attachment_meta: list[dict[str, Any]] = [] - for att in message.attachments: - attachment_lines.append(f"[Attachment: {att.filename}]") - attachment_meta.append( - exclude_none({ - "id": str(att.id), - "filename": att.filename, - "content_type": att.content_type, - "size": att.size, - "url": att.url, - }) - ) - return "\n".join(attachment_lines), {"attachments": attachment_meta} - - if message.stickers: - lines = [f"[Sticker: {sticker.name}]" for sticker in message.stickers] - meta = [{"id": str(sticker.id), "name": sticker.name} for sticker in message.stickers] - return "\n".join(lines), {"stickers": meta} - - return "[Unknown message type]", None - - @staticmethod - def _extract_reply_metadata(message: discord.Message) -> dict[str, Any] | None: - ref = message.reference - if ref is None: - return None - resolved = ref.resolved - if not isinstance(resolved, discord.Message): - return None - return exclude_none({ - "message_id": str(resolved.id), - "from_user_id": str(resolved.author.id), - "from_username": resolved.author.name, - "from_is_bot": resolved.author.bot, - "text": (resolved.content or "")[:100], - }) - - @staticmethod - def _chunk_message(text: str, *, limit: int = 2000) -> list[str]: - if len(text) <= limit: - return [text] - chunks: list[str] = [] - remaining = text - while remaining: - if len(remaining) <= limit: - chunks.append(remaining) - break - split_at = remaining.rfind("\n", 0, limit) - if split_at <= 0: - split_at = limit - chunks.append(remaining[:split_at].rstrip()) - remaining = remaining[split_at:].lstrip("\n") - return [chunk for chunk in chunks if chunk] diff --git a/src/bub/skills/discord/SKILL.md b/src/bub/skills/discord/SKILL.md deleted file mode 100644 index 3c2a866d..00000000 --- a/src/bub/skills/discord/SKILL.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -name: discord -description: | - Discord Bot integration for sending messages, managing channels, and responding to events. - Use when Bub needs to: (1) Send messages to Discord channels, (2) Create Discord bot with discord.py, - (3) Handle Discord events (on_message, on_member_join, etc.), (4) Work with Discord webhooks, - or (5) Any Discord-related functionality. -metadata: - channel: discord ---- - -# Discord Bot Skill - -Send messages and interact with Discord using discord.py. - -## Response Contract (Important) - -When the user asks to send or draft a Discord message: - -- Return only the final message content intended for Discord. -- Do not include action narration or meta text such as: - - "I already prepared..." - - "I can switch to another version..." - - "If you want, I can..." -- Do not prepend or append explanatory wrappers around the message body. -- If a style is requested (short, technical, casual), apply it directly in the final message. -- Keep the message concise unless the user explicitly requests detail. - -## Quick Start - -```bash -# Dependencies are declared in each script via PEP 723. -# Paths are relative to this skill directory. -# Run scripts directly with uv; it will resolve dependencies from the script header. -uv run ./scripts/discord_send.py --help -uv run ./scripts/discord_bot.py -``` - -## Sending Messages - -### Basic Message - -```python -import discord -from discord.ext import commands - -intents = discord.Intents.default() -intents.message_content = True - -bot = commands.Bot(command_prefix='!', intents=intents) - -@bot.event -async def on_ready(): - channel = bot.get_channel(CHANNEL_ID) - await channel.send("Hello from Bub!") -``` - -### Send to Channel by ID - -```python -channel = bot.get_channel(123456789) -await channel.send("Message") -``` - -### Send to Thread - -```python -thread = bot.get_channel(THREAD_ID) -await thread.send("Message in thread") -``` - -### Embed Message - -```python -embed = discord.Embed( - title="Title", - description="Description", - color=discord.Color.blue() -) -embed.add_field(name="Field", value="Value") -await channel.send(embed=embed) -``` - -## Using the Bot - -### Configuration - -Set environment variable: -```bash -export BUB_DISCORD_TOKEN="your_token_here" -``` - -### Running the Bot - -```python -import asyncio -import os -from discord_bot import run_bot - -async def main(): - token = os.environ.get("BUB_DISCORD_TOKEN") - await run_bot(token) - -asyncio.run(main()) -``` - -## Common Patterns - -### Respond to Messages - -```python -@bot.event -async def on_message(message): - if message.author.bot: - return - if "hello" in message.content.lower(): - await message.reply("Hello!") -``` - -### Command with Arguments - -```python -@bot.command(name="echo") -async def echo(ctx, *, text: str): - await ctx.send(text) -``` - -### Button Interaction - -```python -from discord.ui import Button, View - -button = Button(label="Click me", style=discord.ButtonStyle.primary) -async def callback(interaction): - await interaction.response.send_message("Clicked!") - -button.callback = callback -view = View() -view.add_item(button) -await ctx.send("Click:", view=view) -``` - -## Environment - -- `BUB_DISCORD_TOKEN`: Bot token from Discord Developer Portal diff --git a/src/bub/skills/discord/scripts/discord_bot.py b/src/bub/skills/discord/scripts/discord_bot.py deleted file mode 100644 index 05927ba3..00000000 --- a/src/bub/skills/discord/scripts/discord_bot.py +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env uv run -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "discord.py>=2.3.0", -# ] -# /// - -""" -Discord Bot basic scaffold. -Written to keep logic readable and testable. -""" - -import asyncio -import os -from dataclasses import dataclass - -import discord -from discord.ext import commands - - -@dataclass -class Config: - """Bot configuration.""" - - token: str - command_prefix: str = "!" - intents_messages: bool = True - intents_message_content: bool = True - - -def create_bot(config: Config) -> commands.Bot: - """ - Create a bot instance. - - Args: - config: Bot configuration. - - Returns: - A configured bot instance. - """ - intents = discord.Intents.default() - intents.messages = config.intents_messages - intents.message_content = config.intents_message_content - - bot = commands.Bot( - command_prefix=config.command_prefix, - intents=intents, - help_command=None, - ) - - return bot - - -def register_events(bot: commands.Bot) -> None: - """Register event handlers.""" - - @bot.event - async def on_ready() -> None: - """Handle bot startup completion.""" - print(f"🤖 Logged in as {bot.user}") - print(f" ID: {bot.user.id}") - - @bot.event - async def on_message(message: discord.Message) -> None: - """Handle incoming messages.""" - # Ignore messages from bots. - if message.author.bot: - return - - # Reply within an existing thread when available. - if message.thread is not None: - await message.thread.send(f"Received: {message.content}") - - # Continue command processing. - await bot.process_commands(message) - - @bot.event - async def on_command_error(ctx: commands.Context, error: commands.CommandError) -> None: - """Handle command errors.""" - if isinstance(error, commands.CommandNotFound): - await ctx.send(f"Command not found: {ctx.invoked_with}") - elif isinstance(error, commands.MissingRequiredArgument): - await ctx.send(f"Missing argument: {error.param.name}") - else: - await ctx.send(f"Error: {error}") - raise error - - -def register_commands(bot: commands.Bot) -> None: - """Register bot commands.""" - - @bot.command(name="ping") - async def ping(ctx: commands.Context) -> None: - """Ping command.""" - await ctx.send("pong 🏓") - - @bot.command(name="hello") - async def hello(ctx: commands.Context) -> None: - """Hello command.""" - await ctx.send(f"Hello, {ctx.author.mention}! 👋") - - @bot.command(name="echo") - async def echo(ctx: commands.Context, *, text: str) -> None: - """Echo command.""" - await ctx.send(text) - - @bot.command(name="info") - async def info(ctx: commands.Context) -> None: - """Bot info command.""" - embed = discord.Embed(title="🤖 Bot Info", description="Bub's Discord Bot", color=discord.Color.blue()) - embed.add_field( - name="Commands", value="!ping - ping\n!hello - hello\n!echo - echo\n!info - this", inline=False - ) - await ctx.send(embed=embed) - - -async def run_bot(token: str) -> None: - """Run the bot.""" - config = Config(token=token) - bot = create_bot(config) - - register_events(bot) - register_commands(bot) - - await bot.start(token) - - -def main() -> None: - """Entry point.""" - token = os.environ.get("BUB_DISCORD_TOKEN") - if token is None: - print("Error: BUB_DISCORD_TOKEN not set") - return - - asyncio.run(run_bot(token)) - - -if __name__ == "__main__": - main() diff --git a/src/bub/skills/discord/scripts/discord_send.py b/src/bub/skills/discord/scripts/discord_send.py deleted file mode 100644 index e2f8a4d2..00000000 --- a/src/bub/skills/discord/scripts/discord_send.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env uv run -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "discord.py>=2.3.0", -# ] -# /// - -""" -Discord message sender script. -""" - -import argparse -import asyncio -import os -import sys - -import discord - - -async def send_message( - token: str, - channel_id: int, - message: str, - embed: bool = False, -) -> None: - """Send a message to a Discord channel.""" - intents = discord.Intents.default() - intents.message_content = True - - client = discord.Client(intents=intents) - - @client.event - async def on_ready(): - print(f"Logged in as {client.user}") - channel = client.get_channel(channel_id) - if channel is None: - print(f"Channel {channel_id} not found") - await client.close() - sys.exit(1) - - if embed: - emb = discord.Embed(description=message) - await channel.send(embed=emb) - else: - await channel.send(message) - - print(f"Message sent to channel {channel_id}") - await client.close() - - await client.start(token) - - -def main(): - parser = argparse.ArgumentParser(description="Send message to Discord") - parser.add_argument("--token", "-t", default=os.environ.get("BUB_DISCORD_TOKEN")) - parser.add_argument("--channel", "-c", type=int, required=True, help="Channel ID") - parser.add_argument("--message", "-m", required=True, help="Message to send") - parser.add_argument("--embed", "-e", action="store_true", help="Send as embed") - - args = parser.parse_args() - - if not args.token: - print("Error: BUB_DISCORD_TOKEN not set") - sys.exit(1) - - asyncio.run(send_message(args.token, args.channel, args.message, args.embed)) - - -if __name__ == "__main__": - main() diff --git a/src/bub/skills/gh/SKILL.md b/src/bub/skills/gh/SKILL.md deleted file mode 100644 index 0fd4d147..00000000 --- a/src/bub/skills/gh/SKILL.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -name: gh -description: GitHub CLI skill for interacting with GitHub via the gh command line tool. Use when Bub needs to (1) Create, view, or manage GitHub repositories, (2) Work with issues and pull requests, (3) Create and manage releases, (4) Run and monitor GitHub Actions workflows, (5) Create and manage gists, or (6) Perform any GitHub operations via command line. ---- - -# GitHub CLI (gh) Skill - -Interact with GitHub using the gh command line tool. - -## Prerequisites - -The GitHub PAT is available via `GITHUB_TOKEN` environment variable or `gh` CLI authentication. - -Check authentication: -```bash -gh auth status -``` - -If not authenticated: -```bash -gh auth login -``` - -## Repository Operations - -```bash -gh repo create [--public|--private] -gh repo clone -gh repo fork -gh repo view [owner/repo] -gh repo list [owner] -``` - -## Issue Operations - -```bash -gh issue create --title "Title" --body "Body" -gh issue list [--state open|closed] -gh issue view -gh issue close -gh issue comment --body "Comment" -``` - -## Pull Request Operations - -```bash -gh pr create --title "Title" --body "Body" -gh pr list [--state open|closed] -gh pr view -gh pr checkout -gh pr merge -gh pr review --approve -``` - -## Release Operations - -```bash -gh release create --generate-notes -gh release list -gh release download -gh release upload -``` - -## Workflow Operations - -```bash -gh workflow list -gh workflow run -gh run list -gh run view -gh run watch -``` - -## Gist Operations - -```bash -gh gist create -gh gist list -gh gist view -``` - -## Tips - -- Use --web to open in browser -- Use -R owner/repo to specify repository -- Use --json with --jq for scripting diff --git a/src/bub/skills/telegram/SKILL.md b/src/bub/skills/telegram/SKILL.md deleted file mode 100644 index 2101cd1f..00000000 --- a/src/bub/skills/telegram/SKILL.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: telegram -description: | - Telegram Bot skill for sending and editing Telegram messages via Bot API. - Use when Bub needs to: (1) Send a message to a Telegram user/group/channel, - (2) Reply to a specific Telegram message with reply_to_message_id, - (3) Edit an existing Telegram message, or (4) Push proactive Telegram notifications - when working outside an active Telegram session. -metadata: - channel: telegram ---- - -# Telegram Skill - -Agent-facing execution guide for Telegram outbound communication. - -Assumption: `BUB_TELEGRAM_TOKEN` is already available. - -## Required Inputs - -Collect these before execution: - -- `chat_id` (required) -- message content (required for send/edit) -- `reply_to_message_id` (required for threaded reply behavior) -- `message_id` (required for edit) - -## Execution Policy - -1. If handling a direct user message in Telegram and `message_id` is known, prefer reply mode (`--reply-to`). -2. If source metadata says sender is a bot (`sender_is_bot=true`), do not use reply mode. -3. In the bot-source case, send a normal message and prefix content with `@` (or the provided source username). -4. For long-running tasks, optionally send one progress message, then edit that same message for final status. -5. Use literal newlines in message text when line breaks are needed. -6. Avoid emitting HTML tags in message content; use Markdown for formatting instead. - -## Active Response Policy - -When this skill is in scope, prefer proactive and timely Telegram updates: - -- Send an immediate acknowledgment for newly assigned tasks -- Send progress updates for long-running operations using message edits -- Send completion notifications when work finishes -- Send important status or failure notifications without waiting for follow-up prompts -- If execution is blocked or fails, send a problem report immediately with cause, impact, and next action - -Recommended pattern: - -1. Send a short acknowledgment reply -2. Continue processing -3. If blocked, edit or send an issue update immediately -4. Edit the acknowledgment message with final result when possible - -## Voice Message Policy - -When the inbound Telegram message is voice: - -1. Transcribe the voice input first (use STT skill if available) -2. Prepare response content based on transcription -3. Prefer voice response output (use TTS skill if available) -4. If voice output is unavailable, send a concise text fallback and state limitation - -## Reaction Policy - -When an inbound Telegram message warrants acknowledgment but does not merit a full reply, use a Telegram reaction as the response. -But when any explanation or details are needed, use a normal reply instead. - -## Command Templates - -Paths are relative to this skill directory. - -```bash -# Send message -uv run ./scripts/telegram_send.py \ - --chat-id \ - --message "" - -# Send reply to a specific message -uv run ./scripts/telegram_send.py \ - --chat-id \ - --message "" \ - --reply-to - -# Source message sender is bot: no direct reply, use @user_id style -uv run ./scripts/telegram_send.py \ - --chat-id \ - --message "" \ - --source-is-bot \ - --source-username - -# Edit existing message -uv run ./scripts/telegram_edit.py \ - --chat-id \ - --message-id \ - --text "" -``` - -For other actions that not covered by these scripts, use `curl` to call Telegram Bot API directly with the provided token. - -## Script Interface Reference - -### `telegram_send.py` - -- `--chat-id`, `-c`: required, supports comma-separated ids -- `--message`, `-m`: required -- `--reply-to`, `-r`: optional -- `--token`, `-t`: optional (normally not needed) -- `--source-is-bot`: optional flag, disables reply mode and switches to `@user_id` style -- `--source-user-id`: optional, required when `--source-is-bot` is set - -### `telegram_edit.py` - -- `--chat-id`, `-c`: required -- `--message-id`, `-m`: required -- `--text`, `-t`: required -- `--token`: optional (normally not needed) - -## Failure Handling - -- On HTTP errors, inspect API response text and adjust identifiers/permissions. -- If edit fails because message is not editable, fall back to a new send. -- If reply target is invalid, resend without `--reply-to` only when context threading is non-critical. -- For task-level failures (not only API failures), notify the Telegram user with: - - what failed - - what was already completed - - what will happen next (retry/manual action/escalation) diff --git a/src/bub/skills/telegram/scripts/telegram_edit.py b/src/bub/skills/telegram/scripts/telegram_edit.py deleted file mode 100644 index 1daf3893..00000000 --- a/src/bub/skills/telegram/scripts/telegram_edit.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env uv run -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "requests>=2.31.0", -# "telegramify-markdown>=0.5.0", -# ] -# /// - -""" -Telegram Bot Message Editor - -Edit an existing message via Telegram Bot API. -Uses telegramify_markdown to convert markdown to Telegram MarkdownV2 format. - -""" - -import argparse -import os -import sys - -import requests - -try: - from telegramify_markdown import markdownify -except ImportError: - print("❌ Error: telegramify_markdown not installed. Run: pip install telegramify-markdown") - sys.exit(1) - - -def unescape_newlines(text: str) -> str: - """ - Convert escaped newline sequences to real newlines. - Handles \\n -> \n, \\r\\n -> \r\n, etc. - """ - # First unescape \\n to real newline - result = text.replace("\\n", "\n") - result = result.replace("\\r\\n", "\r\n") - result = result.replace("\\r", "\r") - return result - - -def edit_message(bot_token: str, chat_id: str, message_id: int, text: str) -> dict: - """ - Edit an existing message via Telegram Bot API. - - Args: - bot_token: Telegram bot token - chat_id: Target chat ID - message_id: ID of the message to edit - text: New message text (will be converted to MarkdownV2) - - Returns: - API response as dict - """ - url = f"https://api.telegram.org/bot{bot_token}/editMessageText" - - # Unescape \\n sequences to real newlines (bash/argparse converts real newlines to \\n) - text = unescape_newlines(text) - - # Convert markdown to Telegram MarkdownV2 format - converted_text = markdownify(text).rstrip("\n") - - payload = { - "chat_id": chat_id, - "message_id": message_id, - "text": converted_text, - "parse_mode": "MarkdownV2", - } - - response = requests.post(url, json=payload, timeout=30) - response.raise_for_status() - - return response.json() - - -def main(): - parser = argparse.ArgumentParser(description="Edit an existing message via Telegram Bot API") - parser.add_argument("--chat-id", "-c", required=True, help="Target chat ID") - parser.add_argument("--message-id", "-m", type=int, required=True, help="ID of the message to edit") - parser.add_argument("--text", "-t", required=True, help="New message text (markdown supported)") - parser.add_argument("--token", help="Bot token (defaults to BUB_TELEGRAM_TOKEN env var)") - - args = parser.parse_args() - - # Get bot token - bot_token = args.token or os.environ.get("BUB_TELEGRAM_TOKEN") - if not bot_token: - print("❌ Error: Bot token required. Set BUB_TELEGRAM_TOKEN env var or use --token") - sys.exit(1) - - try: - edit_message(bot_token, args.chat_id, args.message_id, args.text) - print(f"✅ Message {args.message_id} edited successfully") - except requests.HTTPError as e: - print(f"❌ HTTP Error: {e}") - print(f" Response: {e.response.text}") - sys.exit(1) - except Exception as e: - print(f"❌ Error: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/src/bub/skills/telegram/scripts/telegram_send.py b/src/bub/skills/telegram/scripts/telegram_send.py deleted file mode 100755 index 95a2aee2..00000000 --- a/src/bub/skills/telegram/scripts/telegram_send.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env uv run -# /// script -# requires-python = ">=3.10" -# dependencies = [ -# "requests>=2.31.0", -# "telegramify-markdown>=0.5.0", -# ] -# /// - -""" -Telegram Bot Message Sender - -A simple script to send messages via Telegram Bot API. -Uses telegramify_markdown to convert markdown to Telegram MarkdownV2 format. -""" - -import argparse -import os -import sys - -import requests - -try: - from telegramify_markdown import markdownify -except ImportError: - print("❌ Error: telegramify_markdown not installed. Run: pip install telegramify-markdown") - sys.exit(1) - - -def unescape_newlines(text: str) -> str: - """ - Convert escaped newline sequences to real newlines. - Handles \\n -> \n, \\r\\n -> \r\n, etc. - """ - # First unescape \\n to real newline - result = text.replace("\\n", "\n") - result = result.replace("\\r\\n", "\r\n") - result = result.replace("\\r", "\r") - return result - - -def edit_message(bot_token: str, chat_id: str, message_id: int, text: str) -> dict: - """ - Edit an existing message via Telegram Bot API. - - Uses telegramify_markdown to convert text to MarkdownV2 format. - - Args: - bot_token: Telegram bot token - chat_id: Target chat ID - message_id: ID of the message to edit - text: New message text (will be converted to MarkdownV2) - - Returns: - API response as dict - """ - url = f"https://api.telegram.org/bot{bot_token}/editMessageText" - - # Convert markdown to Telegram MarkdownV2 format - converted_text = markdownify(text) - - payload = { - "chat_id": chat_id, - "message_id": message_id, - "text": converted_text, - "parse_mode": "MarkdownV2", - } - - response = requests.post(url, json=payload, timeout=30) - response.raise_for_status() - - return response.json() - - -def send_message( - bot_token: str, - chat_id: str, - text: str, - reply_to_message_id: int | None = None, - mention_username: str | None = None, -) -> dict: - """ - Send a message via Telegram Bot API. - - Uses telegramify_markdown to convert text to MarkdownV2 format. - - Args: - bot_token: Telegram bot token - chat_id: Target chat ID - text: Message text (will be converted to MarkdownV2) - reply_to_message_id: Optional message ID to reply to - mention_username: Optional username to prefix with @ mention style - - Returns: - API response as dict - """ - url = f"https://api.telegram.org/bot{bot_token}/sendMessage" - - # Unescape \\n sequences to real newlines (bash/argparse converts real newlines to \\n) - text = unescape_newlines(text) - if mention_username: - text = f"@{mention_username} {text}" - - # Convert markdown to Telegram MarkdownV2 format - converted_text = markdownify(text).rstrip("\n") - - payload = { - "chat_id": chat_id, - "text": converted_text, - "parse_mode": "MarkdownV2", - } - - if reply_to_message_id: - payload["reply_to_message_id"] = reply_to_message_id - - response = requests.post(url, json=payload, timeout=30) - if response.status_code == 400 and reply_to_message_id: - payload.pop("reply_to_message_id", None) - response = requests.post(url, json=payload, timeout=30) - response.raise_for_status() - - return response.json() - - -def main(): - parser = argparse.ArgumentParser(description="Send messages via Telegram Bot API (auto-converts to MarkdownV2)") - parser.add_argument("--chat-id", "-c", required=True, help="Target chat ID") - parser.add_argument( - "--message", - "-m", - required=True, - help="Message text to send (markdown supported, will be converted to MarkdownV2)", - ) - parser.add_argument("--token", "-t", help="Bot token (defaults to BUB_TELEGRAM_TOKEN env var)") - parser.add_argument("--reply-to", "-r", type=int, help="Message ID to reply to (creates threaded conversation)") - parser.add_argument( - "--source-is-bot", - action="store_true", - help="Set when source message sender is a bot; disables reply mode and switches to @username style send", - ) - parser.add_argument( - "--source-username", - help="Source username for @username prefix when --source-is-bot is enabled", - ) - - args = parser.parse_args() - - # Get bot token - bot_token = args.token or os.environ.get("BUB_TELEGRAM_TOKEN") - if not bot_token: - print("❌ Error: Bot token required. Set BUB_TELEGRAM_TOKEN env var or use --token") - sys.exit(1) - - # Parse chat IDs - chat_id = args.chat_id.strip() - reply_to = args.reply_to - mention_username = None - if args.source_is_bot: - if not args.source_username: - print("❌ Error: --source-username is required when --source-is-bot is enabled") - sys.exit(1) - reply_to = None - mention_username = args.source_username - - # Send messages - try: - send_message(bot_token, chat_id, args.message, reply_to, mention_username) - print(f"✅ Message sent successfully to {chat_id} (MarkdownV2)") - except requests.HTTPError as e: - print(f"❌ HTTP Error: {e}") - print(f" Response: {e.response.text}") - sys.exit(1) - except Exception as e: - print(f"❌ Error: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() From d43cbe23e773efd8d754567d67d0bd9a882d0572 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 5 Mar 2026 11:44:15 +0800 Subject: [PATCH 32/39] refactor: update __all__ to include ChannelMessage and remove unused test files Signed-off-by: Frost Ming --- src/bub/channels/__init__.py | 3 +- src/bub/channels/runner.py | 83 -------------- src/bub/skills.py | 10 +- tests/test_channels_proxy.py | 16 --- tests/test_discord_filter.py | 73 ------------ tests/test_discord_output.py | 91 --------------- tests/test_graceful_shutdown.py | 90 --------------- tests/test_session_runner.py | 154 -------------------------- tests/test_skill_path_expansion.py | 39 ------- tests/test_telegram_session_prompt.py | 89 --------------- 10 files changed, 6 insertions(+), 642 deletions(-) delete mode 100644 src/bub/channels/runner.py delete mode 100644 tests/test_channels_proxy.py delete mode 100644 tests/test_discord_filter.py delete mode 100644 tests/test_discord_output.py delete mode 100644 tests/test_graceful_shutdown.py delete mode 100644 tests/test_session_runner.py delete mode 100644 tests/test_skill_path_expansion.py delete mode 100644 tests/test_telegram_session_prompt.py diff --git a/src/bub/channels/__init__.py b/src/bub/channels/__init__.py index 3625cf1d..12bcf9b5 100644 --- a/src/bub/channels/__init__.py +++ b/src/bub/channels/__init__.py @@ -1,4 +1,5 @@ from .base import Channel from .manager import ChannelManager +from .message import ChannelMessage -__all__ = ["Channel", "ChannelManager"] +__all__ = ["Channel", "ChannelManager", "ChannelMessage"] diff --git a/src/bub/channels/runner.py b/src/bub/channels/runner.py deleted file mode 100644 index b307672a..00000000 --- a/src/bub/channels/runner.py +++ /dev/null @@ -1,83 +0,0 @@ -import asyncio -from typing import Any - -from loguru import logger - -from bub.channels.base import BaseChannel - - -class SessionRunner: - def __init__( - self, session_id: str, debounce_seconds: int, message_delay_seconds: int, active_time_window_seconds: int - ) -> None: - self.session_id = session_id - self.debounce_seconds = debounce_seconds - self.message_delay_seconds = message_delay_seconds - self.active_time_window_seconds = active_time_window_seconds - self._prompts: list[str] = [] - self._event = asyncio.Event() - self._timer: asyncio.TimerHandle | None = None - self._last_mentioned_at: float | None = None - self._running_task: asyncio.Task[None] | None = None - self._loop = asyncio.get_running_loop() - - async def _run(self, channel: BaseChannel) -> None: - await self._event.wait() - prompt = channel.format_prompt("\n".join(self._prompts)) - self._prompts.clear() - self._running_task = None - try: - result = await channel.run_prompt(self.session_id, prompt) - await channel.process_output(self.session_id, result) - except Exception: - if not channel.debounce_enabled: - raise - logger.exception("session.run.error session_id={}", self.session_id) - - def reset_timer(self, timeout: int) -> None: - self._event.clear() - if self._timer: - self._timer.cancel() - self._timer = self._loop.call_later(timeout, self._event.set) - - async def process_message(self, channel: BaseChannel, message: Any) -> None: - is_mentioned = channel.is_mentioned(message) - _, prompt = await channel.get_session_prompt(message) - now = self._loop.time() - if not is_mentioned and ( - self._last_mentioned_at is None or now - self._last_mentioned_at > self.active_time_window_seconds - ): - self._last_mentioned_at = None - logger.info("session.receive ignored session_id={} message={}", self.session_id, prompt) - return - if prompt.startswith(","): - logger.info("session.receive.command session_id={} message={}", self.session_id, prompt) - try: - result = await channel.run_prompt(self.session_id, prompt) - await channel.process_output(self.session_id, result) - except Exception: - if not channel.debounce_enabled: - raise - logger.exception("session.run.error session_id={}", self.session_id) - return - elif not channel.debounce_enabled: - logger.info("session.receive.immediate session_id={} message={}", self.session_id, prompt) - result = await channel.run_prompt(self.session_id, prompt) - await channel.process_output(self.session_id, result) - return - - self._prompts.append(prompt) - if is_mentioned: - # Debounce mentioned messages before responding. - self._last_mentioned_at = now - logger.info("session.receive.mentioned session_id={} message={}", self.session_id, prompt) - self.reset_timer(self.debounce_seconds) - if self._running_task is None: - self._running_task = asyncio.create_task(self._run(channel)) - return await self._running_task - elif self._last_mentioned_at is not None and self._running_task is None: - # Otherwise if bot is mentioned before, we will keep reading messages for at most 60s. - logger.info("session.receive followup session_id={} message={}", self.session_id, prompt) - self.reset_timer(self.message_delay_seconds) - self._running_task = asyncio.create_task(self._run(channel)) - return await self._running_task diff --git a/src/bub/skills.py b/src/bub/skills.py index 92811132..ceb2e4e1 100644 --- a/src/bub/skills.py +++ b/src/bub/skills.py @@ -80,10 +80,10 @@ def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None: ) -def _parse_frontmatter(content: str) -> tuple[dict[str, object], str]: +def _parse_frontmatter(content: str) -> dict[str, Any]: lines = content.splitlines() if not lines or lines[0].strip() != "---": - return {}, content + return {} for idx, line in enumerate(lines[1:], start=1): if line.strip() == "---": @@ -92,11 +92,9 @@ def _parse_frontmatter(content: str) -> tuple[dict[str, object], str]: parsed = yaml.safe_load(payload) except yaml.YAMLError: parsed = {} - body = "\n".join(lines[idx + 1 :]) if isinstance(parsed, dict): - return {str(key).lower(): value for key, value in parsed.items()}, body - return {}, body - return {}, content + return {str(key).lower(): value for key, value in parsed.items()} + return {} def _is_valid_frontmatter(*, skill_dir: Path, metadata: dict[str, object]) -> bool: diff --git a/tests/test_channels_proxy.py b/tests/test_channels_proxy.py deleted file mode 100644 index 3a5a0cda..00000000 --- a/tests/test_channels_proxy.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from bub.channels.utils import resolve_proxy - - -def test_resolve_proxy_prefers_explicit_over_ambient(monkeypatch) -> None: - monkeypatch.setenv("HTTPS_PROXY", "http://env.proxy:8080") - proxy, source = resolve_proxy("http://explicit.proxy:9000") - assert proxy == "http://explicit.proxy:9000" - assert source == "explicit" - - -def test_resolve_proxy_is_opt_in(monkeypatch) -> None: - proxy, source = resolve_proxy(None) - assert proxy is None - assert source == "none" diff --git a/tests/test_discord_filter.py b/tests/test_discord_filter.py deleted file mode 100644 index 7262dbd4..00000000 --- a/tests/test_discord_filter.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from types import SimpleNamespace - -from bub.channels.discord import DiscordChannel - - -@dataclass -class DummyAuthor: - id: int = 1 - name: str = "frost" - global_name: str | None = None - - -class DummyMessage: - def __init__( - self, - *, - content: str, - channel: object, - author: DummyAuthor | None = None, - ) -> None: - self.content = content - self.channel = channel - self.author = author or DummyAuthor() - self.mentions: list[object] = [] - self.reference = None - - -def _build_channel() -> DiscordChannel: - settings = SimpleNamespace( - discord_token="token", # noqa: S106 - discord_allow_from=[], - discord_allow_channels=[], - discord_command_prefix="!", - discord_proxy=None, - ) - runtime = SimpleNamespace(settings=settings) - return DiscordChannel(runtime) # type: ignore[arg-type] - - -def test_allow_message_when_content_contains_bub() -> None: - channel = _build_channel() - message = DummyMessage(content="please ask Bub to check this", channel=SimpleNamespace(id=100, name="general")) - assert channel.is_mentioned(message) is True # type: ignore[arg-type] - - -def test_allow_message_when_thread_name_starts_with_bub() -> None: - channel = _build_channel() - thread = SimpleNamespace(id=101, name="bub-help", parent=SimpleNamespace(name="forum")) - message = DummyMessage(content="hello", channel=thread) - assert channel.is_mentioned(message) is True # type: ignore[arg-type] - - -def test_reject_message_when_only_parent_name_starts_with_bub() -> None: - channel = _build_channel() - thread = SimpleNamespace(id=102, name="question-1", parent=SimpleNamespace(name="bub-forum")) - message = DummyMessage(content="hello", channel=thread) - assert channel.is_mentioned(message) is False # type: ignore[arg-type] - - -def test_reject_unrelated_message_without_bot_context() -> None: - channel = _build_channel() - message = DummyMessage(content="hello world", channel=SimpleNamespace(id=103, name="general")) - assert channel.is_mentioned(message) is False # type: ignore[arg-type] - - -def test_reject_empty_content_even_in_bub_thread() -> None: - channel = _build_channel() - thread = SimpleNamespace(id=104, name="bub-help", parent=SimpleNamespace(name="forum")) - message = DummyMessage(content=" ", channel=thread) - assert channel.is_mentioned(message) is False # type: ignore[arg-type] diff --git a/tests/test_discord_output.py b/tests/test_discord_output.py deleted file mode 100644 index b541e834..00000000 --- a/tests/test_discord_output.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -import builtins -from types import SimpleNamespace - -import pytest - -from bub.channels.discord import DiscordChannel -from bub.core.agent_loop import LoopResult - - -class DummyMessageable: - def __init__(self) -> None: - self.sent: list[dict[str, object]] = [] - - async def send(self, **kwargs: object) -> None: - self.sent.append(kwargs) - - -def _build_channel() -> DiscordChannel: - settings = SimpleNamespace( - discord_token="token", # noqa: S106 - discord_allow_from=[], - discord_allow_channels=[], - discord_command_prefix="!", - discord_proxy=None, - ) - runtime = SimpleNamespace(settings=settings) - return DiscordChannel(runtime) # type: ignore[arg-type] - - -@pytest.mark.asyncio -async def test_process_output_sends_only_immediate_and_prints_full(monkeypatch: pytest.MonkeyPatch) -> None: - channel = _build_channel() - sink = DummyMessageable() - printed: list[str] = [] - - def _capture_print(*args: object, **kwargs: object) -> None: - printed.append(" ".join(str(arg) for arg in args)) - - async def _resolve_channel(_session_id: str) -> DummyMessageable: - return sink - - monkeypatch.setattr(builtins, "print", _capture_print) - channel._bot = object() # type: ignore[assignment] - channel._resolve_channel = _resolve_channel # type: ignore[method-assign] - - output = LoopResult( - immediate_output="immediate reply", - assistant_output="assistant details", - exit_requested=False, - steps=1, - error="boom", - ) - await channel.process_output("discord:1", output) - - joined = "\n".join(printed) - assert "immediate reply" in joined - assert "assistant details" in joined - assert "Error: boom" in joined - assert sink.sent == [{"content": "immediate reply"}] - - -@pytest.mark.asyncio -async def test_process_output_no_immediate_does_not_send_but_prints(monkeypatch: pytest.MonkeyPatch) -> None: - channel = _build_channel() - sink = DummyMessageable() - printed: list[str] = [] - - def _capture_print(*args: object, **kwargs: object) -> None: - printed.append(" ".join(str(arg) for arg in args)) - - async def _resolve_channel(_session_id: str) -> DummyMessageable: - return sink - - monkeypatch.setattr(builtins, "print", _capture_print) - channel._bot = object() # type: ignore[assignment] - channel._resolve_channel = _resolve_channel # type: ignore[method-assign] - - output = LoopResult( - immediate_output="", - assistant_output="assistant only", - exit_requested=False, - steps=1, - error=None, - ) - await channel.process_output("discord:1", output) - - joined = "\n".join(printed) - assert "assistant only" in joined - assert sink.sent == [] diff --git a/tests/test_graceful_shutdown.py b/tests/test_graceful_shutdown.py deleted file mode 100644 index 33058345..00000000 --- a/tests/test_graceful_shutdown.py +++ /dev/null @@ -1,90 +0,0 @@ -import asyncio -import contextlib - -import pytest - -from bub.channels.base import BaseChannel -from bub.channels.manager import ChannelManager -from bub.cli.app import _serve_channels - - -class _Settings: - telegram_enabled = False - discord_enabled = False - - -class _Runtime: - settings = _Settings() - - def install_hooks(self, manager: ChannelManager) -> None: - pass - - -class _ChannelRaisesOnStop(BaseChannel[object]): - name = "bad" - - async def start(self, on_receive): # type: ignore[override] - _ = on_receive - try: - await asyncio.Event().wait() - finally: - raise RuntimeError("stop failure") - - async def get_session_prompt(self, message: object) -> tuple[str, str]: - _ = message - return "s", "p" - - def is_mentioned(self, message: object) -> bool: - _ = message - return True - - async def process_output(self, session_id: str, output): - _ = (session_id, output) - - -@pytest.mark.asyncio -async def test_channel_manager_shutdown_propagates_channel_stop_error() -> None: - manager = ChannelManager(_Runtime()) # type: ignore[arg-type] - manager.register(_ChannelRaisesOnStop) - - task = asyncio.create_task(manager.run()) - await asyncio.sleep(0.05) - task.cancel() - with pytest.raises(RuntimeError, match="stop failure"): - await asyncio.wait_for(task, timeout=1.0) - - -@pytest.mark.asyncio -async def test_serve_channels_handles_cancelled_error_from_graceful_shutdown() -> None: - class _DummyRuntime: - @contextlib.asynccontextmanager - async def graceful_shutdown(self): - stop_event = asyncio.Event() - current_task = asyncio.current_task() - waiter = asyncio.create_task(stop_event.wait()) - waiter.add_done_callback(lambda _: current_task.cancel() if current_task else None) - try: - self.stop_event = stop_event - yield stop_event - finally: - waiter.cancel() - - class _DummyManager: - def __init__(self) -> None: - self.runtime = _DummyRuntime() - self.calls: list[str] = [] - - async def run(self) -> None: - self.calls.append("start") - try: - await asyncio.Event().wait() - finally: - self.calls.append("stop") - - manager = _DummyManager() - task = asyncio.create_task(_serve_channels(manager)) - await asyncio.sleep(0.05) - assert manager.calls == ["start"] - manager.runtime.stop_event.set() - await asyncio.wait_for(task, timeout=1.0) - assert manager.calls == ["start", "stop"] diff --git a/tests/test_session_runner.py b/tests/test_session_runner.py deleted file mode 100644 index a4df5aee..00000000 --- a/tests/test_session_runner.py +++ /dev/null @@ -1,154 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable - -import pytest - -from bub.channels.base import BaseChannel -from bub.channels.runner import SessionRunner -from bub.core.agent_loop import LoopResult - - -class _Runtime: - pass - - -class _ImmediateChannel(BaseChannel[str]): - name = "cli" - - def __init__(self) -> None: - super().__init__(_Runtime()) # type: ignore[arg-type] - self.run_prompts: list[str] = [] - self.processed = 0 - - @property - def debounce_enabled(self) -> bool: - return False - - async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: - _ = on_receive - - def is_mentioned(self, message: str) -> bool: - _ = message - return True - - async def get_session_prompt(self, message: str) -> tuple[str, str]: - return "cli:test", message - - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - _ = session_id - self.run_prompts.append(prompt) - return LoopResult( - immediate_output="", - assistant_output="", - exit_requested=False, - steps=0, - error=None, - ) - - async def process_output(self, session_id: str, output: LoopResult) -> None: - _ = (session_id, output) - self.processed += 1 - - -class _DebouncedChannel(BaseChannel[str]): - name = "telegram" - - def __init__(self) -> None: - super().__init__(_Runtime()) # type: ignore[arg-type] - self.run_prompts: list[str] = [] - - async def start(self, on_receive: Callable[[str], Awaitable[None]]) -> None: - _ = on_receive - - def is_mentioned(self, message: str) -> bool: - _ = message - return True - - async def get_session_prompt(self, message: str) -> tuple[str, str]: - return "telegram:1", message - - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - _ = session_id - self.run_prompts.append(prompt) - return LoopResult( - immediate_output="", - assistant_output="", - exit_requested=False, - steps=0, - error=None, - ) - - async def process_output(self, session_id: str, output: LoopResult) -> None: - _ = (session_id, output) - - -class _ImmediateFailingChannel(_ImmediateChannel): - async def run_prompt(self, session_id: str, prompt: str) -> LoopResult: - _ = (session_id, prompt) - raise RuntimeError("cli failure") - - -@pytest.mark.asyncio -async def test_session_runner_runs_non_debounced_channel_immediately() -> None: - runner = SessionRunner( - session_id="cli:test", - debounce_seconds=10, - message_delay_seconds=10, - active_time_window_seconds=60, - ) - channel = _ImmediateChannel() - - await runner.process_message(channel, "first") - await runner.process_message(channel, "second") - - assert channel.run_prompts == ["first", "second"] - assert channel.processed == 2 - - -@pytest.mark.asyncio -async def test_command_prompt_is_not_buffered() -> None: - runner = SessionRunner( - session_id="telegram:1", - debounce_seconds=1, - message_delay_seconds=1, - active_time_window_seconds=60, - ) - channel = _DebouncedChannel() - - await runner.process_message(channel, ",help") - - assert channel.run_prompts == [",help"] - assert runner._prompts == [] - assert runner._running_task is None - - -@pytest.mark.asyncio -async def test_session_runner_does_not_leak_command_into_batched_prompt() -> None: - runner = SessionRunner( - session_id="telegram:1", - debounce_seconds=0, - message_delay_seconds=0, - active_time_window_seconds=60, - ) - channel = _DebouncedChannel() - - await runner.process_message(channel, ",help") - await runner.process_message(channel, "hello") - - assert channel.run_prompts[0] == ",help" - assert channel.run_prompts[1] == "channel: $telegram\nhello" - - -@pytest.mark.asyncio -async def test_session_runner_raises_for_non_debounced_channel_errors() -> None: - runner = SessionRunner( - session_id="cli:test", - debounce_seconds=10, - message_delay_seconds=10, - active_time_window_seconds=60, - ) - channel = _ImmediateFailingChannel() - - with pytest.raises(RuntimeError, match="cli failure"): - await runner.process_message(channel, "hello") diff --git a/tests/test_skill_path_expansion.py b/tests/test_skill_path_expansion.py deleted file mode 100644 index 971b1477..00000000 --- a/tests/test_skill_path_expansion.py +++ /dev/null @@ -1,39 +0,0 @@ -import importlib.util -import sys -from pathlib import Path -from types import ModuleType - - -def _load_script_module(name: str, relative_path: str) -> ModuleType: - repo_root = Path(__file__).resolve().parents[1] - module_path = repo_root / relative_path - module_dir = str(module_path.parent) - spec = importlib.util.spec_from_file_location(name, module_path) - assert spec is not None - assert spec.loader is not None - module = importlib.util.module_from_spec(spec) - sys.path.insert(0, module_dir) - sys.modules[name] = module - try: - spec.loader.exec_module(module) - finally: - if sys.path and sys.path[0] == module_dir: - sys.path.pop(0) - return module - - -def test_init_skill_expands_home_in_path(monkeypatch, tmp_path: Path) -> None: - module = _load_script_module( - "skill_creator_init_script", - "src/bub/skills/skill-creator/scripts/init_skill.py", - ) - - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setenv("HOME", str(fake_home)) - - created = module.init_skill("demo-skill", "~/skills", [], False, []) - - expected = (fake_home / "skills" / "demo-skill").resolve() - assert created == expected - assert (expected / "SKILL.md").exists() diff --git a/tests/test_telegram_session_prompt.py b/tests/test_telegram_session_prompt.py deleted file mode 100644 index d7803e90..00000000 --- a/tests/test_telegram_session_prompt.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import json -from datetime import UTC, datetime -from types import SimpleNamespace - -import pytest - -from bub.channels.telegram import TelegramChannel - - -def _build_channel() -> TelegramChannel: - runtime = SimpleNamespace( - settings=SimpleNamespace( - telegram_token="token", # noqa: S106 - telegram_allow_from=[], - telegram_allow_chats=[], - telegram_proxy=None, - ) - ) - return TelegramChannel(runtime) - - -def _build_message(*, text: str = "hello", chat_id: int = 123, message_id: int = 10) -> SimpleNamespace: - user = SimpleNamespace(id=42, username="tester", full_name="Test User", is_bot=False) - return SimpleNamespace( - chat_id=chat_id, - chat=SimpleNamespace(type="private"), - message_id=message_id, - text=text, - caption=None, - date=datetime(2026, 1, 1, tzinfo=UTC), - from_user=user, - reply_to_message=None, - photo=None, - audio=None, - sticker=None, - video=None, - voice=None, - document=None, - video_note=None, - ) - - -@pytest.mark.asyncio -async def test_get_session_prompt_wraps_text_with_notice_and_metadata() -> None: - channel = _build_channel() - message = _build_message(text="hello world") - - session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] - - assert session_id == "telegram:123" - data = json.loads(prompt) - assert data["message"] == "hello world" - assert data["chat_id"] == "123" - assert data["message_id"] == 10 - assert data["type"] == "text" - assert data["sender_id"] == "42" - assert data["sender_is_bot"] is False - - -@pytest.mark.asyncio -async def test_get_session_prompt_returns_raw_for_comma_command() -> None: - channel = _build_channel() - message = _build_message(text=",status") - - session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] - - assert session_id == "telegram:123" - assert prompt == ",status" - - -@pytest.mark.asyncio -async def test_get_session_prompt_includes_reply_metadata() -> None: - channel = _build_channel() - message = _build_message(text="replying") - message.reply_to_message = SimpleNamespace( - message_id=99, - text="original", - from_user=SimpleNamespace(id=1000, username="bot", is_bot=True), - ) - - _session_id, prompt = await channel.get_session_prompt(message) # type: ignore[arg-type] - data = json.loads(prompt) - reply = data["reply_to_message"] - assert reply["message_id"] == 99 - assert reply["from_user_id"] == 1000 - assert reply["from_username"] == "bot" - assert reply["from_is_bot"] is True From 112b04f06534de3bd34312dc8908e8fed7430fe9 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 5 Mar 2026 12:39:14 +0800 Subject: [PATCH 33/39] refactor: update documentation for architecture, channels, CLI, deployment, features, skills, and add extension guide; remove Discord integration Signed-off-by: Frost Ming --- docs/architecture.md | 103 +++++++++++++-------------- docs/channels.md | 89 ++++++++++++++++++----- docs/cli.md | 67 +++++++++-------- docs/deployment.md | 88 +++++++++++------------ docs/discord.md | 52 -------------- docs/extension-guide.md | 154 ++++++++++++++++++++++++++++++++++++++++ docs/features.md | 42 +++++------ docs/index.md | 28 ++++---- docs/skills.md | 51 +++++++------ mkdocs.yml | 5 +- src/bub/skills.py | 10 +++ 11 files changed, 428 insertions(+), 261 deletions(-) delete mode 100644 docs/discord.md create mode 100644 docs/extension-guide.md diff --git a/docs/architecture.md b/docs/architecture.md index a0700c85..6324a174 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,62 +2,59 @@ ## Core Components -- `BubFramework`: creates the plugin manager, loads hooks, runs turns -- `BubHookSpecs`: defines hook contracts (`firstresult` and broadcast hooks) -- `HookRuntime`: executes hook implementations with per-impl fault isolation -- `MessageBus`: default in-memory bus (replaceable via hook) +- `BubFramework`: creates the plugin manager, loads plugins, and runs `process_inbound()`. +- `BubHookSpecs`: defines all hook contracts (`src/bub/hookspecs.py`). +- `HookRuntime`: executes hooks with sync/async compatibility helpers (`src/bub/hook_runtime.py`). +- `RuntimeEngine`: builtin model-and-tools runtime (`src/bub/builtin/engine.py`). +- `ChannelManager`: starts channels, buffers inbound messages, and routes outbound messages (`src/bub/channels/manager.py`). ## Turn Lifecycle -`process_inbound()` executes hooks in this order: - -1. `normalize_inbound(message)` -2. `resolve_session(message)` -3. `load_state(session_id)` (defaults to `{}`) -4. `build_prompt(message, session_id, state)` (defaults to message `content`) -5. `run_model(prompt, session_id, state)` -6. `save_state(...)` (broadcast) -7. `render_outbound(...)` (broadcast) -8. `dispatch_outbound(message)` (broadcast per outbound) - -If `render_outbound` yields nothing, the framework emits one fallback outbound: - -```text -{ - "content": model_output, - "session_id": session_id, - "channel": ...?, # if exists in inbound - "chat_id": ...? # if exists in inbound -} -``` - -## Precedence And Override Semantics - -- Hook registration order: - 1. Builtin plugin `bub.builtin.hook_impl` - 2. External entry points (`group="bub"`) -- Execution order: `HookRuntime` reverses pluggy impl order, so later-registered plugins run first -- For `firstresult` hooks: first non-`None` value wins -- For broadcast hooks (for example `save_state`): all implementations are attempted - -## Fault Isolation And Fallbacks - -- A failing hook implementation does not crash the whole turn; `on_error` is notified -- If `run_model` returns no value, fallback is `model_output = prompt` -- `create_bus()` falls back to `MessageBus` when no plugin provides a bus -- `handle_bus_once()` consumes one inbound from bus and publishes produced outbounds - -## Builtin Runtime - -Builtin `run_model` is implemented by `RuntimeEngine`: - -- Regular prompts run through Republic `run_tools_async` -- Comma-prefixed input goes through internal command dispatch (`help/tools/fs.*/...`) -- Unknown comma commands are executed as shell commands -- Runtime events are persisted at `.bub/runtime/.jsonl` +`BubFramework.process_inbound()` currently executes in this order: + +1. Populate inbound `workspace` when inbound is a `dict`. +2. `resolve_session(message)` via `call_first` (fallback to `channel:chat_id` if empty). +3. `load_state(message, session_id)` via `call_many`, then merge returned state dicts. +4. `build_prompt(message, session_id, state)` via `call_first` (fallback to inbound `content` if empty). +5. `run_model(prompt, session_id, state)` via `call_first`. +6. `save_state(...)` via `call_many` in a `finally` block. +7. `render_outbound(...)` via `call_many`, then flatten all batches. +8. If no outbound exists, emit one fallback outbound. +9. For each outbound, execute `dispatch_outbound(message)` via `call_many`. + +## Hook Priority Semantics + +- Registration order: +1. Builtin plugin `builtin` +2. External entry points (`group="bub"`) +- Execution order: +1. `HookRuntime` reverses pluggy implementation order, so later-registered plugins run first. +2. `call_first` returns the first non-`None` value. +3. `call_many` collects every implementation return value (including `None`). +- Merge/override details: +1. `load_state` is reversed again before merge so high-priority plugins win on key collisions. +2. `provide_channels` is reversed in `ChannelManager`, so high-priority plugins can override channel names. + +## Error Behavior + +- For normal hooks, `HookRuntime` does not swallow implementation errors. +- `process_inbound()` catches top-level exceptions, notifies `on_error(stage="turn", ...)`, then re-raises. +- `on_error` itself is observer-safe: one failing observer does not block the others. +- In sync calls (`call_first_sync`/`call_many_sync`), awaitable return values are skipped with a warning. + +## Builtin Runtime Notes + +Builtin `BuiltinImpl` behavior includes: + +- `build_prompt`: supports comma command mode; non-command text may include `context_str`. +- `run_model`: delegates to `RuntimeEngine.run()`. +- `system_prompt`: combines a default prompt with workspace `AGENTS.md`. +- `provide_tools`: returns builtin tools. +- `provide_channels`: returns `telegram` and `cli` channel adapters. +- `provide_tape_store`: returns a file-backed tape store under `~/.bub/tapes`. ## Boundaries -- `Envelope` is intentionally weakly typed (`Any`) and read via helper accessors -- There is no global enforced business schema for messages or cross-plugin state -- Skill discovery/validation is a separate subsystem (see `skills.md`) +- `Envelope` stays intentionally weakly typed (`Any` + accessor helpers). +- There is no globally enforced schema for cross-plugin `state`. +- Runtime behavior in this document is aligned with current source code. diff --git a/docs/channels.md b/docs/channels.md index e235202e..bfc80bd7 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -1,34 +1,87 @@ # Channels -Bub supports running the same agent loop through channel adapters. -Use channels when you want either local interactive operation or remote operation from mobile/shared team environments. +Bub uses channel adapters to run the same agent pipeline across different I/O endpoints. -## Supported Channels +## Builtin Channels -- `cli` (local): interactive terminal channel used by `uv run bub chat`. -- [Telegram](telegram.md): direct messages and group chats. -- [Discord](discord.md): servers, channels, and threads. +- `cli`: local interactive terminal channel (`uv run bub chat`) +- `telegram`: Telegram bot channel (`uv run bub message`) -## Run Entry +## Run Modes -Start channel mode with: +Local interactive mode: + +```bash +uv run bub chat +``` + +Channel listener mode (all non-`cli` channels by default): ```bash uv run bub message ``` -If the process exits immediately, check that at least one channel is enabled in `.env`. +Enable only Telegram: + +```bash +uv run bub message --enable-channel telegram +``` + +## Session Semantics + +- `run` command default session id: `:` +- Telegram channel session id: `telegram:` +- `chat` command default session id: `cli_session` (override with `--session-id`) + +## Debounce Behavior + +- `cli` does not debounce; each input is processed immediately. +- Other channels can debounce and batch inbound messages per session. +- Comma commands (`,` prefix) always bypass debounce and execute immediately. + +## About Discord + +Core Bub does not currently include a builtin Discord adapter. +If you need Discord, implement it in an external plugin via `provide_channels`. + +## Telegram Configuration + +Environment variables are read by `TelegramSettings` (`src/bub/channels/telegram.py`). + +Required: + +```bash +BUB_TELEGRAM_TOKEN=123456:token +``` + +Optional allowlists (comma-separated): + +```bash +BUB_TELEGRAM_ALLOW_USERS=123456789,your_username +BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 +``` + +Optional proxy: + +```bash +BUB_TELEGRAM_PROXY=http://127.0.0.1:7890 +``` + +## Telegram Message Behavior -## Session Isolation +- Session id is `telegram:`. +- `/start` is handled by builtin channel logic. +- `/bub ...` is accepted and normalized to plain prompt content. +- Non-command messages are ingested; active/follow-up behavior is decided by channel filter metadata plus debounce handling. -- CLI session key: `cli` or `cli:` (from `--session-id`). -- Telegram session key: `telegram:` -- Discord session key: `discord:` +## Telegram Outbound Behavior -This keeps message history isolated per conversation endpoint. +- Outbound is sent back to Telegram chat via bot API. +- Empty outbound text is ignored. +- If outbound content is JSON, the `"message"` field is used when present. -## Runtime Semantics +## Telegram Access Control -- `uv run bub chat` runs `CliChannel` via `ChannelManager`, sharing the same channel pipeline as Telegram/Discord. -- CLI sets `debounce_enabled = False`, so each input is processed immediately. -- Message channels keep debounce enabled to batch short bursts before model execution. +- If `BUB_TELEGRAM_ALLOW_CHATS` is set, non-listed chats are ignored. +- If `BUB_TELEGRAM_ALLOW_USERS` is set, non-listed users are denied. +- In group chats, keep allowlists strict for production bots. diff --git a/docs/cli.md b/docs/cli.md index ad345fff..535ea0d3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,39 +1,35 @@ # CLI -`bub` currently exposes three commands: `run`, `hooks`, and `install`. +`bub` currently exposes four builtin commands: `run`, `hooks`, `message`, and `chat`. ## `bub run` -Run one inbound message through the full framework lifecycle. +Run one inbound message through the full framework pipeline and print outbounds. ```bash -uv run bub run "hello" --channel stdout --chat-id local +uv run bub run "hello" --channel cli --chat-id local ``` -When runtime is disabled or unavailable, output safely falls back to the input prompt text: +Common options: -```bash -BUB_RUNTIME_ENABLED=0 uv run bub run "hello" -``` - -Run with runtime enabled: - -```bash -BUB_RUNTIME_ENABLED=1 BUB_API_KEY=your_key uv run bub run "summarize current repo status" -``` +- `--workspace/-w`: workspace root +- `--channel`: source channel (default `cli`) +- `--chat-id`: source endpoint id (default `local`) +- `--sender-id`: sender identity (default `human`) +- `--session-id`: explicit session id (default is `:`) -Comma-prefixed inputs invoke internal command mode: +Comma-prefixed input enters internal command mode: ```bash -BUB_RUNTIME_ENABLED=0 uv run bub run ",help" -BUB_RUNTIME_ENABLED=0 uv run bub run ",tools" -BUB_RUNTIME_ENABLED=0 uv run bub run ",fs.read path=README.md" +uv run bub run ",help" +uv run bub run ",tools" +uv run bub run ",fs.read path=README.md" ``` -Unknown comma commands are executed as shell commands: +Unknown comma commands fall back to shell execution: ```bash -BUB_RUNTIME_ENABLED=0 uv run bub run ",echo hello-from-shell" +uv run bub run ",echo hello-from-shell" ``` ## `bub hooks` @@ -44,24 +40,35 @@ Print hook-to-plugin bindings discovered at startup. uv run bub hooks ``` -## `bub install` +## `bub message` -Install plugins from PyPI requirement spec or GitHub shorthand. +Start channel listener mode (defaults to all non-`cli` channels). ```bash -uv run bub install my-plugin-package -uv run bub install owner/repo +uv run bub message ``` -`owner/repo` is converted to: +Enable only selected channels: -```text -git+https://github.com/owner/repo.git +```bash +uv run bub message --enable-channel telegram +``` + +## `bub chat` + +Start an interactive REPL session via the `cli` channel. + +```bash +uv run bub chat +uv run bub chat --chat-id local --session-id cli:local ``` ## Notes -- `--workspace` is supported by `run` and `hooks` -- `BUB_RUNTIME_ENABLED` supports `0`, `1`, and `auto` (default) -- Session id defaults to `channel:chat_id` when `--session-id` is not provided -- `run` prints each outbound as `[channel:chat_id] content` +- `--workspace` is supported by `run`, `hooks`, `message`, and `chat`. +- `run` prints each outbound as: + +```text +[channel:chat_id] +content +``` diff --git a/docs/deployment.md b/docs/deployment.md index cfc79bce..a91bbe89 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,14 +1,14 @@ # Deployment Guide -This page covers production-oriented setups for Bub, including local process management and Docker Compose. +This page covers practical Bub deployment paths based on the current repository behavior. ## 1) Prerequisites - Python 3.12+ - `uv` installed -- A valid model provider key (for example `OPENROUTER_API_KEY` or `LLM_API_KEY`) +- a valid model provider key (for example `OPENROUTER_API_KEY`) -Quick bootstrap: +Bootstrap: ```bash git clone https://github.com/bubbuild/bub.git @@ -17,61 +17,57 @@ uv sync cp env.example .env ``` -Minimum `.env`: +Minimum `.env` example: ```bash BUB_MODEL=openrouter:qwen/qwen3-coder-next OPENROUTER_API_KEY=sk-or-... ``` -## 2) Deployment Modes +## 2) Runtime Modes -Choose one mode based on your operation target: +Choose one command based on your operation target: -1. Interactive local operator: - `uv run bub chat` -2. Channel service (Telegram/Discord): - `uv run bub message` -3. Scheduler-only autonomous mode: - `uv run bub idle` +1. Interactive local operator: `uv run bub chat` +2. Channel listener service: `uv run bub message` +3. One-shot task execution: `uv run bub run "summarize this repo"` -One-shot operation: +## 3) Telegram Channel Setup -```bash -uv run bub run "summarize changes in this repo" -``` +Current core channel integration is Telegram. -## 3) Message Channel Deployment +Required: -Enable channels in `.env` first. +```bash +BUB_TELEGRAM_TOKEN=123456:token +``` -Telegram: +Optional allowlists (comma-separated values): ```bash -BUB_TELEGRAM_ENABLED=true -BUB_TELEGRAM_TOKEN=123456:token -BUB_TELEGRAM_ALLOW_FROM='["123456789","your_username"]' -BUB_TELEGRAM_ALLOW_CHATS='["123456789","-1001234567890"]' +BUB_TELEGRAM_ALLOW_USERS=123456789,your_username +BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 ``` -Discord: +Optional proxy: ```bash -BUB_DISCORD_ENABLED=true -BUB_DISCORD_TOKEN=discord_bot_token -BUB_DISCORD_ALLOW_FROM='["123456789012345678","your_discord_name"]' -BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' +BUB_TELEGRAM_PROXY=http://127.0.0.1:7890 ``` -Start channel service: +Run listener: ```bash -uv run bub message +uv run bub message --enable-channel telegram ``` -## 4) Docker Compose Deployment +## 4) Docker Compose + +Repository assets: -The repository already provides `Dockerfile`, `docker-compose.yml`, and `entrypoint.sh`. +- `Dockerfile` +- `docker-compose.yml` +- `entrypoint.sh` Build and run: @@ -80,10 +76,12 @@ docker compose up -d --build docker compose logs -f app ``` -Behavior in container: +Current entrypoint behavior: -- If `/workspace/startup.sh` exists, container starts `bub idle` in background, then executes `startup.sh`. -- Otherwise, container starts `bub message`. +- if `/workspace/startup.sh` exists, entrypoint tries to start `bub idle` in background, then runs `startup.sh` +- otherwise it starts `bub message` + +Important: core CLI currently does not expose a builtin `idle` command. If you rely on `startup.sh`, verify your image/plugin setup provides it, or adjust `entrypoint.sh`. Default mounts in `docker-compose.yml`: @@ -93,18 +91,16 @@ Default mounts in `docker-compose.yml`: ## 5) Operational Checks -Health checklist: - -1. Process is running: - `ps aux | rg "bub (chat|message|idle)"` -2. Model key is loaded: +1. Verify process: + `ps aux | rg "bub (chat|message|run)"` +2. Verify model config: `rg -n "BUB_MODEL|OPENROUTER_API_KEY|LLM_API_KEY" .env` -3. Channel flags are correct: - `rg -n "BUB_TELEGRAM_ENABLED|BUB_DISCORD_ENABLED" .env` -4. Logs show channel startup: - `uv run bub message` and confirm `channel.manager.start` output. +3. Verify Telegram settings: + `rg -n "BUB_TELEGRAM_TOKEN|BUB_TELEGRAM_ALLOW_USERS|BUB_TELEGRAM_ALLOW_CHATS" .env` +4. Verify startup logs: + `uv run bub message --enable-channel telegram` -## 6) Safe Upgrade Procedure +## 6) Safe Upgrade ```bash git fetch --all --tags @@ -115,4 +111,4 @@ uv run mypy uv run pytest -q ``` -Then restart your service mode (`chat`, `message`, or container service). +Then restart your service command. diff --git a/docs/discord.md b/docs/discord.md deleted file mode 100644 index 5138b8ef..00000000 --- a/docs/discord.md +++ /dev/null @@ -1,52 +0,0 @@ -# Discord Integration - -Discord allows Bub to run as a remote collaboration endpoint for team channels, threads, and DMs. - -## Configure - -```bash -BUB_DISCORD_ENABLED=true -BUB_DISCORD_TOKEN=discord_bot_token -BUB_DISCORD_ALLOW_FROM='["123456789012345678","your_discord_name"]' -BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' -``` - -Optional: - -```bash -BUB_DISCORD_COMMAND_PREFIX=! -BUB_DISCORD_PROXY=http://127.0.0.1:7890 -``` - -Notes: - -- If `BUB_DISCORD_ALLOW_FROM` is empty, all senders are accepted. -- If `BUB_DISCORD_ALLOW_CHANNELS` is empty, all channels are accepted. -- In production, use strict allowlists. - -## Run - -```bash -uv run bub message -``` - -## Run Behavior - -- Uses `discord.py` bot service loop. -- Each Discord channel maps to `discord:` session key. -- Inbound text enters the same `AgentLoop` used by CLI. -- Outbound immediate output is sent back in-channel (split into chunks when too long). -- Bub processes messages in these cases: - - DM channel - - message includes `bub` - - message starts with `!bub` (or your configured prefix) - - message mentions the bot - - message replies to a bot message - - thread name starts with `bub` - -## Security and Operations - -1. Keep bot token only in `.env` or a secret manager. -2. Restrict `BUB_DISCORD_ALLOW_CHANNELS` and `BUB_DISCORD_ALLOW_FROM`. -3. Confirm the bot has message-content intent enabled in Discord Developer Portal. -4. If no response is observed, verify token, allowlists, intents, and service logs. diff --git a/docs/extension-guide.md b/docs/extension-guide.md new file mode 100644 index 00000000..17547e48 --- /dev/null +++ b/docs/extension-guide.md @@ -0,0 +1,154 @@ +# Extension Guide + +This guide explains how to implement Bub hooks with `@hookimpl`, and how those implementations are executed in the current runtime. + +## 1) Import And Basic Shape + +Use the marker exported by Bub: + +```python +from bub import hookimpl +``` + +Implement hooks on a plugin object: + +```python +from __future__ import annotations + +from bub import hookimpl + + +class MyPlugin: + @hookimpl + def build_prompt(self, message, session_id, state): + return "custom prompt" + +my_plugin = MyPlugin() +``` + +## 2) Register Plugin Via Entry Points + +Expose your plugin in `pyproject.toml`: + +```toml +[project.entry-points."bub"] +my_plugin = "my_package.plugin:my_plugin" +``` + +`BubFramework.load_hooks()` loads builtin first, then entry points in `group="bub"`. + +## 3) Ship Skills In Extension Packages + +Extension packages can also ship skills by including a top-level `bub_skills/` directory in the distribution. + +Example layout: + +```text +my-extension/ +├─ src/ +│ ├─ my_extension/ +│ │ └─ plugin.py +│ └─ bub_skills/ +│ └─ my-skill/ +│ └─ SKILL.md +└─ pyproject.toml +``` + +Configure your build backend to include the `bub_skills/` directory in the package data. For example, with `pdm-backend`: + +```toml +[tool.pdm.build] +includes = ["src/"] +``` + +At runtime, Bub discovers builtin skills from `/bub_skills`, so packaged skills in that location are loaded automatically. +These skills use normal precedence rules and can still be overridden by workspace (`.agents/skills`) or user (`~/.agents/skills`) skills. + +## 4) Hook Execution Semantics + +`HookRuntime` drives most framework hooks: + +- `call_first(...)`: execute by priority, return first non-`None` +- `call_many(...)`: execute all, collect all return values (including `None`) +- `call_first_sync(...)` / `call_many_sync(...)`: sync-only bootstrap paths + +Current `process_inbound()` hook usage: + +1. `resolve_session` (`call_first`) +2. `load_state` (`call_many`, then merged by framework) +3. `build_prompt` (`call_first`) +4. `run_model` (`call_first`) +5. `save_state` (`call_many`, always executed in `finally`) +6. `render_outbound` (`call_many`) +7. `dispatch_outbound` (`call_many`, per outbound) + +Other hook consumers: + +- `register_cli_commands`: called by `call_many_sync` +- `provide_channels`: called by `call_many_sync` in `ChannelManager` +- `system_prompt`, `provide_tools`, `provide_tape_store`: consumed by `RuntimeEngine` + +## 5) Priority And Override Rules + +- Builtin plugin is registered first. +- Later plugins have higher runtime precedence. +- `HookRuntime` reverses pluggy implementation order so later registration runs first. +- For `load_state`, framework re-reverses before merge so high-priority values overwrite low-priority values. + +## 6) Sync vs Async Rules + +- Async hook calls can run both sync and async implementations. +- Sync hook calls skip awaitable return values and log a warning. +- Therefore, keep bootstrap hooks synchronous: + - `register_cli_commands` + - `provide_channels` + +## 7) Signature Matching + +`HookRuntime` passes only parameters declared in your function signature. +You can safely omit unused hook arguments. + +Example: + +```python +from bub import hookimpl + + +class SessionPlugin: + @hookimpl + def resolve_session(self, message): + return "my-session" +``` + +## 8) Minimal End-To-End Example + +```python +from __future__ import annotations + +from bub import hookimpl + + +class EchoPlugin: + @hookimpl + def build_prompt(self, message, session_id, state): + return f"[echo] {message['content']}" + + @hookimpl + async def run_model(self, prompt, session_id, state): + return prompt +``` + +Run and verify: + +```bash +uv run bub hooks +uv run bub run "hello" +``` + +Check that your plugin is listed for `build_prompt` / `run_model`, and output reflects your override. + +## 9) Common Pitfalls + +- Returning awaitables from hooks invoked via sync paths (`call_many_sync` / `call_first_sync`) causes skip. +- Assuming hook failures are isolated: non-`on_error` hook exceptions propagate and can fail the turn. +- Using stale hook names: always confirm against `src/bub/hookspecs.py`. diff --git a/docs/features.md b/docs/features.md index f82211a8..96afd4e2 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,34 +2,34 @@ ## Framework Core -- Hook-first architecture with `pluggy` -- Deterministic turn lifecycle in `BubFramework.process_inbound()` -- Safe fallbacks for missing bus, missing model output, and missing outbound renderers -- Per-hook-implementation fault isolation via `HookRuntime` +- Hook-first architecture powered by `pluggy`. +- Deterministic turn pipeline in `BubFramework.process_inbound()`. +- Safe fallback to prompt text when `run_model` returns no value (with `on_error` notification). +- Automatic fallback outbound when `render_outbound` produces nothing. -## Skills +## Runtime And Commands -- `SKILL.md` frontmatter validation (`name`, `description`, optional fields) -- Deterministic discovery/override order: project -> global -> builtin -- Skill body loading for runtime commands like `,skills.describe` +- Builtin CLI commands: `run`, `hooks`, `message`, `chat`. +- Builtin `RuntimeEngine`: + - normal input goes through model + tool loop (Republic) + - comma-prefixed input enters internal command mode (`,help`, `,tools`, `,fs.read`, etc.) + - unknown internal commands fall back to shell execution via the `bash` tool +- Runtime events are persisted to tapes (default under `~/.bub/tapes`). -## Runtime +## Channel Capability -- Builtin CLI commands: `run`, `hooks`, `install` -- Builtin runtime engine with: - - LLM turn execution through Republic tools - - Internal comma command mode (`help`, `tools`, `fs.*`, `tape.*`, `skills.*`) - - Shell fallback for unknown comma commands -- Runtime event logging to `.bub/runtime/*.jsonl` +- Builtin channels: `cli` and `telegram`. +- `message` mode runs the same framework pipeline for channel-driven traffic. +- Outbound delivery is routed by `ChannelManager`, keeping business hooks channel-agnostic. ## Plugin Extensibility -- External plugins loaded from Python entry points (`group="bub"`) -- First-result hooks for override-style behavior -- Broadcast hooks for multi-observer side effects (`save_state`, `dispatch_outbound`, `on_error`) +- External plugins are loaded via Python entry points (`group="bub"`). +- Later-registered plugins run first and can override builtin behavior. +- Supports both first-result hooks (override style) and broadcast hooks (observer style). ## Current Boundaries -- No strict envelope schema: `Envelope` is `Any` -- No enforced global persistence/state format across plugins -- Repository currently ships the `src/bub_skills` root, but no mandatory builtin skill pack behavior in core +- No strict envelope schema: `Envelope` is intentionally flexible. +- No centralized key contract for shared plugin `state`. +- Core repository does not currently ship a builtin Discord channel adapter. diff --git a/docs/index.md b/docs/index.md index 0687b114..cf0b57ce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,23 +1,25 @@ # Bub -Bub currently implements a minimal hook-first framework: +Bub is a hook-first AI framework built on top of `pluggy`. -- The core orchestrates one message turn end-to-end -- Builtin hooks provide default CLI and runtime behavior -- External plugins join the same lifecycle via entry points (`group="bub"`) +- `BubFramework` runs one inbound message through a deterministic turn pipeline. +- Builtin plugin `bub.builtin.hook_impl` provides default CLI, runtime, and channel behavior. +- External plugins join the same lifecycle via Python entry points (`group="bub"`). -## Where To Look +## Code Entry Points - CLI bootstrap: `src/bub/__main__.py` -- Core runtime orchestration: `src/bub/framework.py` -- Hook specifications: `src/bub/hookspecs.py` -- Hook execution isolation: `src/bub/hook_runtime.py` +- Runtime orchestration: `src/bub/framework.py` +- Hook contracts: `src/bub/hookspecs.py` +- Hook dispatcher runtime: `src/bub/hook_runtime.py` - Builtin implementations: `src/bub/builtin/*` -- Skill discovery/validation: `src/bub/skills.py` +- Skill discovery: `src/bub/skills.py` ## Read Next -- `architecture.md`: lifecycle, hook precedence, and fault isolation -- `cli.md`: `bub run`, `bub hooks`, `bub install`, and comma commands -- `skills.md`: `SKILL.md` frontmatter rules and override behavior -- `features.md`: current capabilities and known boundaries +- `architecture.md`: real execution flow, precedence, and error semantics +- `extension-guide.md`: how to build and publish hook-based extensions +- `cli.md`: `bub run/hooks/message/chat` usage +- `channels.md`: builtin channels and session behavior +- `skills.md`: `SKILL.md` discovery and override rules +- `features.md`: capabilities and current boundaries diff --git a/docs/skills.md b/docs/skills.md index f1790b5a..4d2d5a9a 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -1,6 +1,6 @@ # Skills -Bub currently treats skills as discoverable `SKILL.md` documents with validated frontmatter. +Bub treats skills as discoverable `SKILL.md` documents with validated frontmatter. ## Minimal Contract @@ -11,50 +11,49 @@ my-skill/ `-- SKILL.md ``` -Rules enforced by `src/bub/skills.py`: +Validation rules from `src/bub/skills.py`: - `SKILL.md` must start with YAML frontmatter (`--- ... ---`) -- Frontmatter must include non-empty `name` and `description` -- Directory name must exactly match frontmatter `name` -- `name` must match regex: `^[a-z0-9]+(?:-[a-z0-9]+)*$` +- frontmatter must include non-empty `name` and `description` +- directory name must exactly match frontmatter `name` +- `name` must match regex `^[a-z0-9]+(?:-[a-z0-9]+)*$` +- if provided, `metadata` must be a map of `string -> string` -## Supported Frontmatter Fields +## Frontmatter Fields -- Required: - - `name` (string) - - `description` (string) -- Optional: - - `license` (string) - - `compatibility` (string) - - `metadata` (map of `string -> string`) - - `allowed-tools` (string) +Currently enforced fields: + +- required: `name`, `description` +- optional with type check: `metadata` + +Other extra keys are allowed but not validated by core. ## Discovery And Override -Bub discovers skills from three scopes in priority order: +Skills are discovered from three roots in this precedence order: -1. project: `.agent/skills` -2. user: `~/.agent/skills` +1. project: `.agents/skills` +2. user: `~/.agents/skills` 3. builtin: `src/bub_skills` -If names collide, higher-priority scope overrides lower-priority scope. +If names collide, earlier roots in this list win. -## Runtime Access To Skills +## Runtime Access -Builtin runtime command mode can inspect discovered skills: +Builtin command mode can inspect discovered skills: ```bash -BUB_RUNTIME_ENABLED=0 uv run bub run ",skills.list" -BUB_RUNTIME_ENABLED=0 uv run bub run ",skills.describe name=my-skill" +uv run bub run ",skills.list" +uv run bub run ",skills.describe name=my-skill" ``` If no valid skills are discovered, `,skills.list` returns `(no skills)`. ## Authoring Guidance -- Keep `SKILL.md` concise and action-oriented -- Keep metadata strict and minimal to avoid discovery failures -- Use lowercase kebab-case names to satisfy validation +- keep `SKILL.md` concise and action-oriented +- keep metadata small and deterministic +- use lowercase kebab-case names for compatibility ## Optional Script Convention @@ -67,5 +66,3 @@ For `scripts/*.py`, a practical standalone convention is PEP 723 with `uv`: # dependencies = [] # /// ``` - -This keeps execution deterministic and reduces hidden environment assumptions. diff --git a/mkdocs.yml b/mkdocs.yml index 5759bbda..f9c97cab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,8 +10,11 @@ copyright: Copyright (c) 2026 Bub Build contributors. nav: - Home: index.md - Architecture: architecture.md - - Skills: skills.md + - Extension Guide: extension-guide.md - CLI: cli.md + - Channels: channels.md + - Skills: skills.md + - Deployment: deployment.md - Key Features: features.md plugins: diff --git a/src/bub/skills.py b/src/bub/skills.py index ceb2e4e1..097dc9dd 100644 --- a/src/bub/skills.py +++ b/src/bub/skills.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +import warnings from collections.abc import Collection from dataclasses import dataclass, field from pathlib import Path @@ -11,6 +12,7 @@ import yaml PROJECT_SKILLS_DIR = ".agents/skills" +LEGACY_SKILLS_DIR = ".agent/skills" SKILL_FILE_NAME = "SKILL.md" SKILL_SOURCES = ("project", "global", "builtin") SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") @@ -142,6 +144,14 @@ def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: for source in SKILL_SOURCES: if source == "project": roots.append((workspace_path / PROJECT_SKILLS_DIR, source)) + legacy_path = workspace_path / LEGACY_SKILLS_DIR + if legacy_path.is_dir(): + warnings.warn( + f"Found legacy skills directory at '{legacy_path}'. Please move it to '{PROJECT_SKILLS_DIR}' to avoid this warning in the future.", + category=UserWarning, + stacklevel=2, + ) + roots.append((legacy_path, source)) elif source == "global": roots.append((Path.home() / PROJECT_SKILLS_DIR, source)) elif source == "builtin": From 92fccc056b0bbba832642d9f731290b5912bd437 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 5 Mar 2026 18:59:09 +0800 Subject: [PATCH 34/39] refactor: reorganize documentation structure and add new channel and workflow guides; update Telegram configuration details Signed-off-by: Frost Ming --- docs/channels.md | 87 -------------- docs/channels/index.md | 47 ++++++++ docs/channels/telegram.md | 45 +++++++ docs/core/index.md | 13 ++ docs/deployment.md | 25 +--- docs/index.md | 56 ++++++--- ...2025-07-16-baby-bub-bootstrap-milestone.md | 112 ++++++++++++++++++ docs/posts/index.md | 8 ++ docs/workflows/index.md | 13 ++ mkdocs.yml | 22 +++- pyproject.toml | 2 +- src/bub/builtin/store.py | 8 +- uv.lock | 8 +- 13 files changed, 308 insertions(+), 138 deletions(-) delete mode 100644 docs/channels.md create mode 100644 docs/channels/index.md create mode 100644 docs/channels/telegram.md create mode 100644 docs/core/index.md create mode 100644 docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md create mode 100644 docs/posts/index.md create mode 100644 docs/workflows/index.md diff --git a/docs/channels.md b/docs/channels.md deleted file mode 100644 index bfc80bd7..00000000 --- a/docs/channels.md +++ /dev/null @@ -1,87 +0,0 @@ -# Channels - -Bub uses channel adapters to run the same agent pipeline across different I/O endpoints. - -## Builtin Channels - -- `cli`: local interactive terminal channel (`uv run bub chat`) -- `telegram`: Telegram bot channel (`uv run bub message`) - -## Run Modes - -Local interactive mode: - -```bash -uv run bub chat -``` - -Channel listener mode (all non-`cli` channels by default): - -```bash -uv run bub message -``` - -Enable only Telegram: - -```bash -uv run bub message --enable-channel telegram -``` - -## Session Semantics - -- `run` command default session id: `:` -- Telegram channel session id: `telegram:` -- `chat` command default session id: `cli_session` (override with `--session-id`) - -## Debounce Behavior - -- `cli` does not debounce; each input is processed immediately. -- Other channels can debounce and batch inbound messages per session. -- Comma commands (`,` prefix) always bypass debounce and execute immediately. - -## About Discord - -Core Bub does not currently include a builtin Discord adapter. -If you need Discord, implement it in an external plugin via `provide_channels`. - -## Telegram Configuration - -Environment variables are read by `TelegramSettings` (`src/bub/channels/telegram.py`). - -Required: - -```bash -BUB_TELEGRAM_TOKEN=123456:token -``` - -Optional allowlists (comma-separated): - -```bash -BUB_TELEGRAM_ALLOW_USERS=123456789,your_username -BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 -``` - -Optional proxy: - -```bash -BUB_TELEGRAM_PROXY=http://127.0.0.1:7890 -``` - -## Telegram Message Behavior - -- Session id is `telegram:`. -- `/start` is handled by builtin channel logic. -- `/bub ...` is accepted and normalized to plain prompt content. -- Non-command messages are ingested; active/follow-up behavior is decided by channel filter metadata plus debounce handling. - -## Telegram Outbound Behavior - -- Outbound is sent back to Telegram chat via bot API. -- Empty outbound text is ignored. -- If outbound content is JSON, the `"message"` field is used when present. - -## Telegram Access Control - -- If `BUB_TELEGRAM_ALLOW_CHATS` is set, non-listed chats are ignored. -- If `BUB_TELEGRAM_ALLOW_USERS` is set, non-listed users are denied. -- In group chats, keep allowlists strict for production bots. diff --git a/docs/channels/index.md b/docs/channels/index.md new file mode 100644 index 00000000..b0176a79 --- /dev/null +++ b/docs/channels/index.md @@ -0,0 +1,47 @@ +# Channels + +Bub uses channel adapters to run the same agent pipeline across different I/O endpoints. + +## Builtin Channels + +- `cli`: local interactive terminal channel (`uv run bub chat`) +- `telegram`: Telegram bot channel (`uv run bub message`) + +See [Telegram](telegram.md) for channel-specific configuration and runtime behavior. + +## Run Modes + +Local interactive mode: + +```bash +uv run bub chat +``` + +Channel listener mode (all non-`cli` channels by default): + +```bash +uv run bub message +``` + +Enable only Telegram: + +```bash +uv run bub message --enable-channel telegram +``` + +## Session Semantics + +- `run` command default session id: `:` +- Telegram channel session id: `telegram:` +- `chat` command default session id: `cli_session` (override with `--session-id`) + +## Debounce Behavior + +- `cli` does not debounce; each input is processed immediately. +- Other channels can debounce and batch inbound messages per session. +- Comma commands (`,` prefix) always bypass debounce and execute immediately. + +## About Discord + +Core Bub does not currently include a builtin Discord adapter. +If you need Discord, implement it in an external plugin via `provide_channels`. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md new file mode 100644 index 00000000..87cd2536 --- /dev/null +++ b/docs/channels/telegram.md @@ -0,0 +1,45 @@ +# Telegram + +Telegram is the builtin remote channel adapter in current core Bub. + +## Configuration + +Environment variables are read by `TelegramSettings` (`src/bub/channels/telegram.py`). + +Required: + +```bash +BUB_TELEGRAM_TOKEN=123456:token +``` + +Optional allowlists (comma-separated): + +```bash +BUB_TELEGRAM_ALLOW_USERS=123456789,your_username +BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 +``` + +Optional proxy: + +```bash +BUB_TELEGRAM_PROXY=http://127.0.0.1:7890 +``` + +## Message Behavior + +- Session id is `telegram:`. +- `/start` is handled by builtin channel logic. +- `/bub ...` is accepted and normalized to plain prompt content. +- Non-command messages are ingested; active/follow-up behavior is decided by channel filter metadata plus debounce handling. + +## Outbound Behavior + +- Outbound is sent back to Telegram chat via bot API. +- Empty outbound text is ignored. +- If outbound content is JSON, the `"message"` field is used when present. + +## Access Control + +- If `BUB_TELEGRAM_ALLOW_CHATS` is set, non-listed chats are ignored. +- If `BUB_TELEGRAM_ALLOW_USERS` is set, non-listed users are denied. +- In group chats, keep allowlists strict for production bots. diff --git a/docs/core/index.md b/docs/core/index.md new file mode 100644 index 00000000..bfb1064f --- /dev/null +++ b/docs/core/index.md @@ -0,0 +1,13 @@ +# Core Overview + +This section groups core runtime design and behavior. + +## Includes + +- [Architecture](../architecture.md): execution lifecycle, hook precedence, and error semantics. +- [Key Features](../features.md): high-level capability summary and current boundaries. + +## Suggested Reading Order + +1. [Architecture](../architecture.md) +2. [Key Features](../features.md) diff --git a/docs/deployment.md b/docs/deployment.md index a91bbe89..249684c1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -34,31 +34,14 @@ Choose one command based on your operation target: ## 3) Telegram Channel Setup -Current core channel integration is Telegram. +Telegram configuration and runtime behavior are documented in: -Required: +- `docs/channels/telegram.md` -```bash -BUB_TELEGRAM_TOKEN=123456:token -``` - -Optional allowlists (comma-separated values): - -```bash -BUB_TELEGRAM_ALLOW_USERS=123456789,your_username -BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 -``` - -Optional proxy: - -```bash -BUB_TELEGRAM_PROXY=http://127.0.0.1:7890 -``` - -Run listener: +Quick start: ```bash -uv run bub message --enable-channel telegram +BUB_TELEGRAM_TOKEN=123456:token uv run bub message --enable-channel telegram ``` ## 4) Docker Compose diff --git a/docs/index.md b/docs/index.md index cf0b57ce..6d4370cb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,25 +1,47 @@ # Bub -Bub is a hook-first AI framework built on top of `pluggy`. +Bub is a hook-first AI framework for running agent workflows through CLI and message channels. -- `BubFramework` runs one inbound message through a deterministic turn pipeline. -- Builtin plugin `bub.builtin.hook_impl` provides default CLI, runtime, and channel behavior. -- External plugins join the same lifecycle via Python entry points (`group="bub"`). +## Quick Start -## Code Entry Points +Install dependencies and create local config: -- CLI bootstrap: `src/bub/__main__.py` -- Runtime orchestration: `src/bub/framework.py` -- Hook contracts: `src/bub/hookspecs.py` -- Hook dispatcher runtime: `src/bub/hook_runtime.py` -- Builtin implementations: `src/bub/builtin/*` -- Skill discovery: `src/bub/skills.py` +```bash +git clone https://github.com/bubbuild/bub.git +cd bub +uv sync +cp env.example .env +``` + +Run interactive local chat: + +```bash +uv run bub chat +``` + +Run a one-shot task: + +```bash +uv run bub run "summarize this repository" +``` + +Start channel listener mode: + +```bash +uv run bub message +``` + +## Deployment + +For production setup and operations, read: + +- [Deployment Guide](deployment.md) +- [Channels Overview](channels/index.md) +- [Telegram Channel](channels/telegram.md) ## Read Next -- `architecture.md`: real execution flow, precedence, and error semantics -- `extension-guide.md`: how to build and publish hook-based extensions -- `cli.md`: `bub run/hooks/message/chat` usage -- `channels.md`: builtin channels and session behavior -- `skills.md`: `SKILL.md` discovery and override rules -- `features.md`: capabilities and current boundaries +- [Core Overview](core/index.md): architecture and capability summary in one place +- [Workflows Overview](workflows/index.md): CLI and skills usage in one place +- [Extension Guide](extension-guide.md): build and publish hook-based extensions +- [Posts](posts/index.md): project notes and updates diff --git a/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md b/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md new file mode 100644 index 00000000..6af60ff8 --- /dev/null +++ b/docs/posts/2025-07-16-baby-bub-bootstrap-milestone.md @@ -0,0 +1,112 @@ +--- +title: "Baby Bub: From Inspiration to Bootstrap Milestone" +date: 2025-07-16 +description: "How Bub draws from modern agent design, and why fixing a single mypy issue is a meaningful step toward self-improving AI." +--- + +# Baby Bub: From Inspiration to Bootstrap Milestone + +## Genesis: Inspiration from Modern Agents + +Bub is a CLI-first AI agent, built to "Bub it. Build it." The project draws direct inspiration from [How to Build an Agent](https://ampcode.com/how-to-build-an-agent) and [Tiny Agents: Building LLM-Powered Agents from Scratch](https://huggingface.co/blog/tiny-agents). Both resources distill the essence of tool-using, loop-based, composable, and extensible agents. + +But Bub is also a response to the new wave of self-improving, self-hosting agents: think Claude Code, SWE-agent, and the broader "self-bootstrapping" movement. The goal: an agent that can not only help you build, but can help build (and fix) itself. + +## Architecture: ReAct Loop, Tools, and CLI + +### The ReAct Loop + +At the heart of Bub is a classic ReAct loop, implemented in [`src/bub/agent/core.py`](https://github.com/PsiACE/bub/blob/19c015/src/bub/agent/core.py): + +```python +class Agent: + ... + def chat(self, message: str, on_step: Optional[Callable[[str, str], None]] = None) -> str: + self.conversation_history.append(Message(role="user", content=message)) + while True: + ... + response = litellm.completion(...) + assistant_message = str(response.choices[0].message.content) + self.conversation_history.append(Message(role="assistant", content=assistant_message)) + ... + tool_calls = self.tool_executor.extract_tool_calls(assistant_message) + if tool_calls: + for tool_call in tool_calls: + ... + result = self.tool_executor.execute_tool(tool_name, **parameters) + observation = f"Observation: {result.format_result()}" + self.conversation_history.append(Message(role="user", content=observation)) + ... + continue + else: + return assistant_message +``` + +This loop enables the agent to: + +- Parse LLM output for tool calls (ReAct pattern: Thought, Action, Action Input, Observation). +- Execute tools (file read/write/edit, shell commands) and feed results back into the conversation. +- Iterate until a "Final Answer" is produced. + +### Tool System: Extensible and Safe + +Tools are registered via a `ToolRegistry` ([`src/bub/agent/tools.py`](https://github.com/psiace/bub/blob/19c015/src/bub/agent/tools.py)), and each tool is a Pydantic model with validation and metadata. For example, the `RunCommandTool` blocks dangerous commands and validates input: + +```python +class RunCommandTool(Tool): + ... + DANGEROUS_COMMANDS: ClassVar[set[str]] = {"rm", "del", ...} + def _validate_command(self) -> Optional[str]: + ... + if base_cmd in self.DANGEROUS_COMMANDS: + return f"Dangerous command blocked: {base_cmd}" +``` + +This design makes it possible for the agent to safely self-modify, run tests, or even edit its own codebase—crucial for self-improvement. + +### CLI: User Experience and Debuggability + +The CLI ([`src/bub/cli/app.py`](https://github.com/psiace/bub/blob/19c015/src/bub/cli/app.py)) is built with Typer and Rich, providing a modern, user-friendly interface. The renderer ([`src/bub/cli/render.py`](https://github.com/psiace/bub/blob/19c015/src/bub/cli/render.py)) supports debug toggling, minimal/verbose TAAO (Thought/Action/Action Input/Observation) output, and clear error reporting. + +```python +class Renderer: + def __init__(self) -> None: + self.console: Console = Console() + self._show_debug: bool = False + ... +``` + +## Milestone: The First mypy Fix (and Why It Matters) + +Bub aspires to self-improvement. The first tangible milestone? Fixing the very first mypy error: adding a missing return type annotation to `Renderer.__init__`, check out the [commit](https://github.com/PsiACE/bub/commit/87cdcc). + +```diff +- def __init__(self): +- self.console = Console() +- self._show_debug = False ++ def __init__(self) -> None: ++ self.console: Console = Console() ++ self._show_debug: bool = False +``` + +This change reduced the mypy error count from 24 to 23. Trivial? Maybe. But it's a proof of concept: the agent can reason about, locate, and fix type errors in its own codebase. This is the first step toward a self-hosting, self-healing agent loop—one that can eventually: + +- Run static analysis on itself +- Propose and apply code fixes +- Test and validate improvements + +## Looking Forward: Bub as a Bootstrap Agent + +Bub is still early. But the architecture is in place for: + +- LLM-driven code editing and refactoring +- Automated type and lint fixes +- CLI-driven, user-friendly agent workflows + +The journey from "fixing a mypy annotation" to "full agent self-improvement" is long, but every bootstrap starts with a single, type-safe step. + +--- + +- [Project on GitHub](https://github.com/psiace/bub) +- Inspired by [ampcode.com/how-to-build-an-agent](https://ampcode.com/how-to-build-an-agent) and [huggingface.co/blog/tiny-agents](https://huggingface.co/blog/tiny-agents) +- See also: Claude Code, SWE-agent, and the broader self-bootstrapping movement diff --git a/docs/posts/index.md b/docs/posts/index.md new file mode 100644 index 00000000..6ce63c5d --- /dev/null +++ b/docs/posts/index.md @@ -0,0 +1,8 @@ +# Posts + +Project posts and long-form notes. + +## Entries + +- [Baby Bub: From Inspiration to Bootstrap Milestone](2025-07-16-baby-bub-bootstrap-milestone.md) +- [Bub: Socialized Evaluation and Agent Partnership](2026-03-01-bub-socialized-evaluation-and-agent-partnership.md) diff --git a/docs/workflows/index.md b/docs/workflows/index.md new file mode 100644 index 00000000..e3ad1413 --- /dev/null +++ b/docs/workflows/index.md @@ -0,0 +1,13 @@ +# Workflows Overview + +This section groups day-to-day operator workflows. + +## Includes + +- [CLI](../cli.md): interactive chat, one-shot runs, hook inspection, and channel listener commands. +- [Skills](../skills.md): skill discovery rules, frontmatter validation, and runtime usage. + +## Common Flow + +1. Use [CLI](../cli.md) to run and verify behavior quickly. +2. Add or tune skills following [Skills](../skills.md). diff --git a/mkdocs.yml b/mkdocs.yml index f9c97cab..f35424b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,13 +9,23 @@ copyright: Copyright (c) 2026 Bub Build contributors. nav: - Home: index.md - - Architecture: architecture.md - - Extension Guide: extension-guide.md - - CLI: cli.md - - Channels: channels.md - - Skills: skills.md + - Core: + - Overview: core/index.md + - Architecture: architecture.md + - Key Features: features.md + - Workflows: + - Overview: workflows/index.md + - CLI: cli.md + - Skills: skills.md + - Channels: + - Overview: channels/index.md + - Telegram: channels/telegram.md - Deployment: deployment.md - - Key Features: features.md + - Extension Guide: extension-guide.md + - Posts: + - Overview: posts/index.md + - 2025-07-16 Bootstrap Milestone: posts/2025-07-16-baby-bub-bootstrap-milestone.md + - 2026-03-01 Socialized Evaluation: posts/2026-03-01-bub-socialized-evaluation-and-agent-partnership.md plugins: - search diff --git a/pyproject.toml b/pyproject.toml index b92fce76..16242901 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "pyyaml>=6.0.0", "pluggy>=1.6.0", "typer>=0.9.0", - "republic>=0.5.2", + "republic>=0.5.3", "any-llm-sdk[anthropic]", "rich>=13.0.0", "prompt-toolkit>=3.0.0", diff --git a/src/bub/builtin/store.py b/src/bub/builtin/store.py index d62fda60..f8827b52 100644 --- a/src/bub/builtin/store.py +++ b/src/bub/builtin/store.py @@ -7,6 +7,7 @@ import threading from collections.abc import AsyncGenerator, Iterable from dataclasses import asdict +from datetime import UTC, datetime from pathlib import Path from typing import cast @@ -191,8 +192,11 @@ def entry_from_payload(payload: object) -> TapeEntry | None: return None if not isinstance(meta, dict): meta = {} - timestamp = payload.get("timestamp", 0.0) - return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), timestamp) + if "date" in payload: + date = payload["date"] + else: + date = datetime.fromtimestamp(payload.get("timestamp", 0.0), tz=UTC).isoformat() + return TapeEntry(entry_id, kind, dict(entry_payload), dict(meta), date) def append(self, entry: TapeEntry) -> None: with self._lock: diff --git a/uv.lock b/uv.lock index 78f7aa63..0b288713 100644 --- a/uv.lock +++ b/uv.lock @@ -123,7 +123,7 @@ requires-dist = [ { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "rapidfuzz", specifier = ">=3.14.3" }, - { name = "republic", specifier = ">=0.5.2" }, + { name = "republic", specifier = ">=0.5.3" }, { name = "rich", specifier = ">=13.0.0" }, { name = "typer", specifier = ">=0.9.0" }, ] @@ -1130,15 +1130,15 @@ wheels = [ [[package]] name = "republic" -version = "0.5.2" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "any-llm-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/d0/a509e91a47c77ef02d66693f8b7564cee913445ba0946e0ce7081e641381/republic-0.5.2.tar.gz", hash = "sha256:c3e3f3a4735a2f460ffb9872c27268975a0686882c4189917b384b35bfdfad5a", size = 100151, upload-time = "2026-03-01T06:33:18.957Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/45/21c3cd167dad5e3d2434c9454a298d85b49fe0f4740f21f35ee50f4da89d/republic-0.5.3.tar.gz", hash = "sha256:31b43d41026a3d877c894c8bf06e3295929401a991f606d3d17ce1a63562c688", size = 100213, upload-time = "2026-03-05T10:52:22.704Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/49/897203df2bf680091de74b88692283f421584fa1de7e7b5069efce578020/republic-0.5.2-py3-none-any.whl", hash = "sha256:bfa540e35a2f1cec22ff68fa23703eeb1ee9ccdad8dc946cc8533bfae0989530", size = 39807, upload-time = "2026-03-01T06:33:17.43Z" }, + { url = "https://files.pythonhosted.org/packages/06/eb/77769fbc7877b38ed98d17ec5ae174e3f0b0bdafa2f5be4ef054d49efe11/republic-0.5.3-py3-none-any.whl", hash = "sha256:82bb6654fcfbb22ebd9267b23bc22a7d5fe7dc46fe9fe1a56e46e26f5ce8bcf9", size = 39881, upload-time = "2026-03-05T10:52:23.606Z" }, ] [[package]] From abf9f2b80f30a7678f97e1079f466ce365642d00 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Thu, 5 Mar 2026 19:13:27 +0800 Subject: [PATCH 35/39] refactor: update execution policy for multi-line messages and add heredoc example Signed-off-by: Frost Ming --- src/bub_skills/telegram/SKILL.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/bub_skills/telegram/SKILL.md b/src/bub_skills/telegram/SKILL.md index 2101cd1f..790edef3 100644 --- a/src/bub_skills/telegram/SKILL.md +++ b/src/bub_skills/telegram/SKILL.md @@ -31,7 +31,7 @@ Collect these before execution: 2. If source metadata says sender is a bot (`sender_is_bot=true`), do not use reply mode. 3. In the bot-source case, send a normal message and prefix content with `@` (or the provided source username). 4. For long-running tasks, optionally send one progress message, then edit that same message for final status. -5. Use literal newlines in message text when line breaks are needed. +5. For multi-line text, pass the content via heredoc command substitution instead of embedding raw line breaks in quoted strings. 6. Avoid emitting HTML tags in message content; use Markdown for formatting instead. ## Active Response Policy @@ -75,6 +75,17 @@ uv run ./scripts/telegram_send.py \ --chat-id \ --message "" +# Send multi-line message (heredoc) +uv run ./scripts/telegram_send.py \ + --chat-id \ + --message "$(cat <<'EOF' +Build finished successfully. +Summary: +- 12 tests passed +- 0 failures +EOF +)" + # Send reply to a specific message uv run ./scripts/telegram_send.py \ --chat-id \ From 1f760672cc2bc578ee83b10052584ac355132a92 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 6 Mar 2026 10:29:49 +0800 Subject: [PATCH 36/39] feat: Introduce Agent class for prompt processing and refactor CLI framework - Added a new Agent class to handle prompt processing using hooks and tools. - Refactored BubFramework to create a CLI app and manage workspace context. - Updated CLI commands to utilize the new Agent for message handling. - Removed legacy runtime engine references and replaced them with the Agent. - Enhanced tape management with ForkTapeStore for better session handling. - Improved system prompt retrieval and tool registration through hooks. - Added utility functions for workspace management and event handling. - Introduced tests for CLI app creation, channel management, and system prompt resolution. Signed-off-by: Frost Ming --- docker-compose.yml | 2 +- docs/CNAME | 1 + docs/architecture.md | 24 ++--- docs/channels/index.md | 6 +- docs/cli.md | 16 ++-- docs/deployment.md | 14 ++- docs/extension-guide.md | 43 +++++++-- docs/index.md | 2 +- entrypoint.sh | 4 +- env.example | 67 +++++--------- src/bub/__main__.py | 7 +- src/bub/builtin/{engine.py => agent.py} | 68 ++++++--------- src/bub/builtin/cli.py | 31 +++---- src/bub/builtin/hook_impl.py | 54 ++++-------- src/bub/builtin/settings.py | 4 +- src/bub/builtin/tools.py | 50 ++++------- src/bub/channels/cli/__init__.py | 19 ++-- src/bub/channels/handler.py | 13 +-- src/bub/channels/manager.py | 12 +-- src/bub/channels/message.py | 13 ++- src/bub/channels/telegram.py | 2 +- src/bub/framework.py | 55 +++++++++--- src/bub/hookspecs.py | 7 +- src/bub/tools.py | 14 ++- src/bub/{channels => }/utils.py | 10 +++ tests/test_channels_utils.py | 2 +- tests/test_framework.py | 111 ++++++++++++++++++++++++ 27 files changed, 372 insertions(+), 279 deletions(-) create mode 100644 docs/CNAME rename src/bub/builtin/{engine.py => agent.py} (81%) rename src/bub/{channels => }/utils.py (73%) create mode 100644 tests/test_framework.py diff --git a/docker-compose.yml b/docker-compose.yml index 68e4edf4..a261c7d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - ${BUB_WORKSPACE_PATH:-.}:/workspace - ${BUB_HOME:-${HOME}/.bub}:/data - - ${BUB_AGENT_HOME:-${HOME}/.agent}:/root/.agent + - ${BUB_AGENT_HOME:-${HOME}/.agents}:/root/.agents stdin_open: true tty: true restart: unless-stopped diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 00000000..b1d389ec --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +bub.build diff --git a/docs/architecture.md b/docs/architecture.md index 6324a174..b15fd7d8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,22 +5,22 @@ - `BubFramework`: creates the plugin manager, loads plugins, and runs `process_inbound()`. - `BubHookSpecs`: defines all hook contracts (`src/bub/hookspecs.py`). - `HookRuntime`: executes hooks with sync/async compatibility helpers (`src/bub/hook_runtime.py`). -- `RuntimeEngine`: builtin model-and-tools runtime (`src/bub/builtin/engine.py`). +- `Agent`: builtin model-and-tools runtime (`src/bub/builtin/agent.py`). - `ChannelManager`: starts channels, buffers inbound messages, and routes outbound messages (`src/bub/channels/manager.py`). ## Turn Lifecycle `BubFramework.process_inbound()` currently executes in this order: -1. Populate inbound `workspace` when inbound is a `dict`. -2. `resolve_session(message)` via `call_first` (fallback to `channel:chat_id` if empty). -3. `load_state(message, session_id)` via `call_many`, then merge returned state dicts. -4. `build_prompt(message, session_id, state)` via `call_first` (fallback to inbound `content` if empty). -5. `run_model(prompt, session_id, state)` via `call_first`. -6. `save_state(...)` via `call_many` in a `finally` block. -7. `render_outbound(...)` via `call_many`, then flatten all batches. +1. Resolve session via `resolve_session(message)` (fallback to `channel:chat_id` if empty). +2. Initialize state with `_runtime_workspace` from `BubFramework.workspace`. +3. Merge all `load_state(message, session_id)` dicts. +4. Build prompt via `build_prompt(message, session_id, state)` (fallback to inbound `content` if empty). +5. Execute `run_model(prompt, session_id, state)`. +6. Always execute `save_state(...)` in a `finally` block. +7. Render outbound batches via `render_outbound(...)`, then flatten them. 8. If no outbound exists, emit one fallback outbound. -9. For each outbound, execute `dispatch_outbound(message)` via `call_many`. +9. Dispatch each outbound via `dispatch_outbound(message)`. ## Hook Priority Semantics @@ -33,7 +33,7 @@ 3. `call_many` collects every implementation return value (including `None`). - Merge/override details: 1. `load_state` is reversed again before merge so high-priority plugins win on key collisions. -2. `provide_channels` is reversed in `ChannelManager`, so high-priority plugins can override channel names. +2. `provide_channels` is collected by `BubFramework.get_channels()`, and the first channel name wins, so high-priority plugins can override builtin channel names. ## Error Behavior @@ -47,9 +47,9 @@ Builtin `BuiltinImpl` behavior includes: - `build_prompt`: supports comma command mode; non-command text may include `context_str`. -- `run_model`: delegates to `RuntimeEngine.run()`. +- `run_model`: delegates to `Agent.run()`. - `system_prompt`: combines a default prompt with workspace `AGENTS.md`. -- `provide_tools`: returns builtin tools. +- `register_cli_commands`: installs `run`, `gateway`, `chat`, plus hidden compatibility/diagnostic commands. - `provide_channels`: returns `telegram` and `cli` channel adapters. - `provide_tape_store`: returns a file-backed tape store under `~/.bub/tapes`. diff --git a/docs/channels/index.md b/docs/channels/index.md index b0176a79..0206a580 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -5,7 +5,7 @@ Bub uses channel adapters to run the same agent pipeline across different I/O en ## Builtin Channels - `cli`: local interactive terminal channel (`uv run bub chat`) -- `telegram`: Telegram bot channel (`uv run bub message`) +- `telegram`: Telegram bot channel (`uv run bub gateway`) See [Telegram](telegram.md) for channel-specific configuration and runtime behavior. @@ -20,13 +20,13 @@ uv run bub chat Channel listener mode (all non-`cli` channels by default): ```bash -uv run bub message +uv run bub gateway ``` Enable only Telegram: ```bash -uv run bub message --enable-channel telegram +uv run bub gateway --enable-channel telegram ``` ## Session Semantics diff --git a/docs/cli.md b/docs/cli.md index 535ea0d3..31ed6a3b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI -`bub` currently exposes four builtin commands: `run`, `hooks`, `message`, and `chat`. +`bub` currently exposes four builtin commands: `run`, `gateway`, `chat`, and the hidden compatibility command `message`. ## `bub run` @@ -12,7 +12,7 @@ uv run bub run "hello" --channel cli --chat-id local Common options: -- `--workspace/-w`: workspace root +- `--workspace/-w`: workspace root, declared once on the top-level CLI and shared by all subcommands - `--channel`: source channel (default `cli`) - `--chat-id`: source endpoint id (default `local`) - `--sender-id`: sender identity (default `human`) @@ -40,20 +40,24 @@ Print hook-to-plugin bindings discovered at startup. uv run bub hooks ``` -## `bub message` +`hooks` remains available for diagnostics, but it is hidden from the top-level help. + +## `bub gateway` Start channel listener mode (defaults to all non-`cli` channels). ```bash -uv run bub message +uv run bub gateway ``` Enable only selected channels: ```bash -uv run bub message --enable-channel telegram +uv run bub gateway --enable-channel telegram ``` +`bub message` is kept as a hidden compatibility alias and forwards to the same command implementation. + ## `bub chat` Start an interactive REPL session via the `cli` channel. @@ -65,7 +69,7 @@ uv run bub chat --chat-id local --session-id cli:local ## Notes -- `--workspace` is supported by `run`, `hooks`, `message`, and `chat`. +- `--workspace` is parsed before the subcommand, for example `uv run bub --workspace /repo chat`. - `run` prints each outbound as: ```text diff --git a/docs/deployment.md b/docs/deployment.md index 249684c1..75543756 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -29,7 +29,7 @@ OPENROUTER_API_KEY=sk-or-... Choose one command based on your operation target: 1. Interactive local operator: `uv run bub chat` -2. Channel listener service: `uv run bub message` +2. Channel listener service: `uv run bub gateway` 3. One-shot task execution: `uv run bub run "summarize this repo"` ## 3) Telegram Channel Setup @@ -41,7 +41,7 @@ Telegram configuration and runtime behavior are documented in: Quick start: ```bash -BUB_TELEGRAM_TOKEN=123456:token uv run bub message --enable-channel telegram +BUB_TELEGRAM_TOKEN=123456:token uv run bub gateway --enable-channel telegram ``` ## 4) Docker Compose @@ -61,10 +61,8 @@ docker compose logs -f app Current entrypoint behavior: -- if `/workspace/startup.sh` exists, entrypoint tries to start `bub idle` in background, then runs `startup.sh` -- otherwise it starts `bub message` - -Important: core CLI currently does not expose a builtin `idle` command. If you rely on `startup.sh`, verify your image/plugin setup provides it, or adjust `entrypoint.sh`. +- if `/workspace/startup.sh` exists, entrypoint tries to run `startup.sh` +- otherwise it starts `bub gateway` Default mounts in `docker-compose.yml`: @@ -75,13 +73,13 @@ Default mounts in `docker-compose.yml`: ## 5) Operational Checks 1. Verify process: - `ps aux | rg "bub (chat|message|run)"` + `ps aux | rg "bub (chat|gateway|run)"` 2. Verify model config: `rg -n "BUB_MODEL|OPENROUTER_API_KEY|LLM_API_KEY" .env` 3. Verify Telegram settings: `rg -n "BUB_TELEGRAM_TOKEN|BUB_TELEGRAM_ALLOW_USERS|BUB_TELEGRAM_ALLOW_CHATS" .env` 4. Verify startup logs: - `uv run bub message --enable-channel telegram` + `uv run bub gateway --enable-channel telegram` ## 6) Safe Upgrade diff --git a/docs/extension-guide.md b/docs/extension-guide.md index 17547e48..b6e95712 100644 --- a/docs/extension-guide.md +++ b/docs/extension-guide.md @@ -37,7 +37,30 @@ my_plugin = "my_package.plugin:my_plugin" `BubFramework.load_hooks()` loads builtin first, then entry points in `group="bub"`. -## 3) Ship Skills In Extension Packages +## 3) Expose Tools By Importing The Module + +Tools are registered through the `@tool` decorator's import-time side effect. +Your plugin must import the module that contains the `@tool` definitions before the agent starts using them. + +Example: + +```python +from __future__ import annotations + +from bub import hookimpl + +from . import tools # noqa: F401 + + +class MyPlugin: + @hookimpl + def system_prompt(self, prompt, state): + return "extension prompt" +``` + +If that import is missing, the tool module never runs, nothing is inserted into `bub.tools.REGISTRY`, and the tool will not be available to the agent or CLI completion. + +## 4) Ship Skills In Extension Packages Extension packages can also ship skills by including a top-level `bub_skills/` directory in the distribution. @@ -64,7 +87,7 @@ includes = ["src/"] At runtime, Bub discovers builtin skills from `/bub_skills`, so packaged skills in that location are loaded automatically. These skills use normal precedence rules and can still be overridden by workspace (`.agents/skills`) or user (`~/.agents/skills`) skills. -## 4) Hook Execution Semantics +## 5) Hook Execution Semantics `HookRuntime` drives most framework hooks: @@ -85,25 +108,26 @@ Current `process_inbound()` hook usage: Other hook consumers: - `register_cli_commands`: called by `call_many_sync` -- `provide_channels`: called by `call_many_sync` in `ChannelManager` -- `system_prompt`, `provide_tools`, `provide_tape_store`: consumed by `RuntimeEngine` +- `provide_channels`: called by `call_many_sync` in `BubFramework.get_channels()` +- `system_prompt`, `provide_tape_store`: consumed by `BubFramework` and the builtin `Agent` -## 5) Priority And Override Rules +## 6) Priority And Override Rules - Builtin plugin is registered first. - Later plugins have higher runtime precedence. - `HookRuntime` reverses pluggy implementation order so later registration runs first. - For `load_state`, framework re-reverses before merge so high-priority values overwrite low-priority values. -## 6) Sync vs Async Rules +## 7) Sync vs Async Rules - Async hook calls can run both sync and async implementations. - Sync hook calls skip awaitable return values and log a warning. - Therefore, keep bootstrap hooks synchronous: - `register_cli_commands` - `provide_channels` + - `provide_tape_store` -## 7) Signature Matching +## 8) Signature Matching `HookRuntime` passes only parameters declared in your function signature. You can safely omit unused hook arguments. @@ -120,7 +144,7 @@ class SessionPlugin: return "my-session" ``` -## 8) Minimal End-To-End Example +## 9) Minimal End-To-End Example ```python from __future__ import annotations @@ -147,8 +171,9 @@ uv run bub run "hello" Check that your plugin is listed for `build_prompt` / `run_model`, and output reflects your override. -## 9) Common Pitfalls +## 10) Common Pitfalls +- Defining `@tool` functions without importing the module from your plugin means the tools never register. - Returning awaitables from hooks invoked via sync paths (`call_many_sync` / `call_first_sync`) causes skip. - Assuming hook failures are isolated: non-`on_error` hook exceptions propagate and can fail the turn. - Using stale hook names: always confirm against `src/bub/hookspecs.py`. diff --git a/docs/index.md b/docs/index.md index 6d4370cb..d39c6e23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,7 +28,7 @@ uv run bub run "summarize this repository" Start channel listener mode: ```bash -uv run bub message +uv run bub gateway ``` ## Deployment diff --git a/entrypoint.sh b/entrypoint.sh index bf6b2590..b24e9ecf 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -8,9 +8,7 @@ if [ -f "/workspace/bub-reqs.txt" ]; then fi if [ -f "/workspace/startup.sh" ]; then - # Start the idle process in the background - nohup /app/.venv/bin/bub idle >/proc/1/fd/1 2>>/proc/1/fd/2 & exec bash /workspace/startup.sh else - exec /app/.venv/bin/bub message + exec /app/.venv/bin/bub gateway fi diff --git a/env.example b/env.example index a45b158e..a5d5c709 100644 --- a/env.example +++ b/env.example @@ -2,78 +2,51 @@ # Copy to `.env` and fill values. # --------------------------------------------------------------------------- -# Model / provider +# Agent runtime # --------------------------------------------------------------------------- # Republic model format: provider:model_id # Default in code is `openrouter:qwen/qwen3-coder-next`. -BUB_MODEL=openrouter:qwen/qwen3-coder-next +# BUB_MODEL=openrouter:qwen/qwen3-coder-next +# BUB_MAX_STEPS=50 +# BUB_MAX_TOKENS=1024 +# BUB_MODEL_TIMEOUT_SECONDS=300 +# BUB_HOME=~/.bub # --------------------------------------------------------------------------- # API keys (choose one) # --------------------------------------------------------------------------- -# Bub reads keys in this order: -# 1) BUB_API_KEY -# 2) LLM_API_KEY -# 3) OPENROUTER_API_KEY +# `AgentSettings` reads `BUB_API_KEY` and `BUB_API_BASE` directly. +# Provider-specific keys such as `OPENROUTER_API_KEY` may still be used by +# the underlying SDK/provider setup, but are not read by Bub settings itself. # Preferred explicit key # BUB_API_KEY=sk-... -# Generic key (commonly used in this repo) -LLM_API_KEY=sk-... - -# OpenRouter-specific key -# OPENROUTER_API_KEY=sk-or-... # Optional custom API base # BUB_API_BASE=https://openrouter.ai/api/v1 # --------------------------------------------------------------------------- -# Optional Ollama web tools override -# --------------------------------------------------------------------------- -# If BUB_OLLAMA_API_KEY is set, Bub reloads: -# - web.search -> Ollama /web_search API -# - web.fetch -> remains markdown-style fetcher (same output contract) -# -# BUB_OLLAMA_API_KEY=ollama_... -# Optional (default runtime fallback: https://ollama.com/api) -# BUB_OLLAMA_API_BASE=https://ollama.com/api - -# --------------------------------------------------------------------------- -# Runtime behavior +# Channel manager # --------------------------------------------------------------------------- -# BUB_MAX_TOKENS=4096 -# BUB_MAX_STEPS=100 -# BUB_MODEL_TIMEOUT_SECONDS=300 -# BUB_SYSTEM_PROMPT=You are a coding agent. -# BUB_HOME=~/.bub -# BUB_WORKSPACE_PATH=/absolute/path/to/workspace -# BUB_TAPE_NAME=bub +# Comma-separated channel names, or `all`. +# `all` excludes the builtin `cli` channel. +# BUB_ENABLED_CHANNELS=all +# BUB_DEBOUNCE_SECONDS=1.0 +# BUB_MAX_WAIT_SECONDS=10.0 +# BUB_ACTIVE_TIME_WINDOW=60.0 # --------------------------------------------------------------------------- # Telegram channel (optional) # --------------------------------------------------------------------------- -# BUB_TELEGRAM_ENABLED=true # BUB_TELEGRAM_TOKEN=123456:telegram-bot-token -# JSON array recommended: -# BUB_TELEGRAM_ALLOW_FROM='["123456789","my_username"]' -# BUB_TELEGRAM_ALLOW_CHATS='["123456789","-1001234567890"]' +# Comma-separated values: +# BUB_TELEGRAM_ALLOW_USERS=123456789,my_username +# BUB_TELEGRAM_ALLOW_CHATS=123456789,-1001234567890 # BUB_TELEGRAM_PROXY=http://127.0.0.1:1080 -# --------------------------------------------------------------------------- -# Discord channel (optional) -# --------------------------------------------------------------------------- -# BUB_DISCORD_ENABLED=true -# BUB_DISCORD_TOKEN=discord_bot_token -# JSON array recommended: -# BUB_DISCORD_ALLOW_FROM='["123456789012345678","my_discord_name"]' -# BUB_DISCORD_ALLOW_CHANNELS='["123456789012345678"]' -# Optional: -# BUB_DISCORD_COMMAND_PREFIX=! -# BUB_DISCORD_PROXY=http://127.0.0.1:7890 - # --------------------------------------------------------------------------- # Example minimal OpenRouter setup # --------------------------------------------------------------------------- # BUB_MODEL=openrouter:qwen/qwen3-coder-next -# OPENROUTER_API_KEY=sk-or-... +# BUB_API_KEY=sk-or-... diff --git a/src/bub/__main__.py b/src/bub/__main__.py index 6865f593..0a505fd7 100644 --- a/src/bub/__main__.py +++ b/src/bub/__main__.py @@ -2,18 +2,15 @@ from __future__ import annotations -from pathlib import Path - import typer from bub.framework import BubFramework def create_cli_app() -> typer.Typer: - app = typer.Typer(name="bub", help="Batteries-included, hook-first AI framework", add_completion=False) - framework = BubFramework(Path.cwd()) + framework = BubFramework() framework.load_hooks() - framework.register_cli_commands(app) + app = framework.create_cli_app() if not app.registered_commands: diff --git a/src/bub/builtin/engine.py b/src/bub/builtin/agent.py similarity index 81% rename from src/bub/builtin/engine.py rename to src/bub/builtin/agent.py index af9e1edd..c7b888a2 100644 --- a/src/bub/builtin/engine.py +++ b/src/bub/builtin/agent.py @@ -13,50 +13,40 @@ from pathlib import Path from typing import Any -from pluggy import PluginManager -from republic import LLM, AsyncTapeStore, Tool, ToolAutoResult, ToolContext +from republic import LLM, AsyncTapeStore, ToolAutoResult, ToolContext from republic.tape import InMemoryTapeStore, Tape from bub.builtin.context import default_tape_context -from bub.builtin.settings import RuntimeSettings +from bub.builtin.settings import AgentSettings from bub.builtin.store import ForkTapeStore from bub.builtin.tape import TapeService +from bub.framework import BubFramework from bub.skills import discover_skills, render_skills_prompt -from bub.tools import model_tools, render_tools_prompt +from bub.tools import REGISTRY, model_tools, render_tools_prompt from bub.types import State +from bub.utils import workspace_from_state CONTINUE_PROMPT = "Continue the task." DEFAULT_BUB_HEADERS = {"HTTP-Referer": "https://bub.build/", "X-Title": "Bub"} HINT_RE = re.compile(r"\$([A-Za-z0-9_.-]+)") -class RuntimeEngine: - """Runtime engine with command compatibility and Republic model driving.""" +class Agent: + """Agent that processes prompts using hooks and tools. Backed by republic.""" - def __init__(self, plugins_manager: PluginManager) -> None: + def __init__(self, framework: BubFramework) -> None: self.settings = _load_runtime_settings() - self._pm = plugins_manager + self.framework = framework @cached_property def tapes(self) -> TapeService: - tape_store = self._pm.hook.provide_tape_store() + tape_store = self.framework.get_tape_store() if tape_store is None: tape_store = InMemoryTapeStore() tape_store = ForkTapeStore(tape_store) llm = _build_llm(self.settings, tape_store) return TapeService(llm, self.settings.home / "tapes", tape_store) - @cached_property - def tools(self) -> list[Tool]: - tools: dict[str, Tool] = {} - for provided in reversed(self._pm.hook.provide_tools()): - tools.update((tool.name, tool) for tool in provided) - return list(tools.values()) - - @cached_property - def model_tools(self) -> list[Tool]: - return model_tools(self.tools) - async def run(self, *, session_id: str, prompt: str, state: State) -> str: stripped = prompt.strip() if not stripped: @@ -67,7 +57,7 @@ async def run(self, *, session_id: str, prompt: str, state: State) -> str: await self.tapes.ensure_bootstrap_anchor(tape.name) if stripped.startswith(","): return await self._run_command(tape=tape, line=stripped) - return await self._run_model(tape=tape, prompt=stripped) + return await self._agent_loop(tape=tape, prompt=stripped) async def _run_command(self, tape: Tape, *, line: str) -> str: line = line[1:].strip() @@ -77,17 +67,16 @@ async def _run_command(self, tape: Tape, *, line: str) -> str: name, arg_tokens = _parse_internal_command(line) start = time.monotonic() context = ToolContext(tape=tape.name, run_id="run_command", state=tape.context.state) - tools = {tool.name: tool for tool in self.tools} output = "" status = "ok" try: - if name not in tools: - output = await tools["bash"].run(context=context, cmd=line) + if name not in REGISTRY: + output = await REGISTRY["bash"].run(context=context, cmd=line) else: args = _parse_args(arg_tokens) - if tools[name].context: + if REGISTRY[name].context: args.kwargs["context"] = context - output = tools[name].run(*args.positional, **args.kwargs) + output = REGISTRY[name].run(*args.positional, **args.kwargs) if inspect.isawaitable(output): output = await output except Exception as exc: @@ -110,7 +99,7 @@ async def _run_command(self, tape: Tape, *, line: str) -> str: } await self.tapes.append_event(tape.name, "command", event_payload) - async def _run_model(self, *, tape: Tape, prompt: str) -> str: + async def _agent_loop(self, *, tape: Tape, prompt: str) -> str: next_prompt = prompt for step in range(1, self.settings.max_steps + 1): @@ -184,21 +173,21 @@ def _load_skills_prompt(self, prompt: str, workspace: Path) -> str: return render_skills_prompt(list(skill_index.values()), expanded_skills=expanded_skills) async def _run_tools_once(self, *, tape: Tape, prompt: str) -> ToolAutoResult: + extra_options = {"extra_headers": DEFAULT_BUB_HEADERS} if self.settings.model.startswith("openrouter:") else {} async with asyncio.timeout(self.settings.model_timeout_seconds): return await tape.run_tools_async( prompt=prompt, system_prompt=self._system_prompt(prompt, state=tape.context.state), max_tokens=self.settings.max_tokens, - tools=self.model_tools, - extra_headers=DEFAULT_BUB_HEADERS, + tools=model_tools(REGISTRY.values()), + **extra_options, ) def _system_prompt(self, prompt: str, state: State) -> str: blocks: list[str] = [] - for result in reversed(self._pm.hook.system_prompt(prompt=prompt, state=state)): - if result: - blocks.append(result) - tools_prompt = render_tools_prompt(self.tools) + if result := self.framework.get_system_prompt(prompt=prompt, state=state): + blocks.append(result) + tools_prompt = render_tools_prompt(REGISTRY.values()) if tools_prompt: blocks.append(tools_prompt) workspace = workspace_from_state(state) @@ -225,7 +214,7 @@ def _resolve_tool_auto_result(output: ToolAutoResult) -> _ToolAutoOutcome: return _ToolAutoOutcome(kind="error", error=f"{error_kind}: {output.error.message}") -def _build_llm(settings: RuntimeSettings, tape_store: AsyncTapeStore) -> LLM: +def _build_llm(settings: AgentSettings, tape_store: AsyncTapeStore) -> LLM: return LLM( settings.model, api_key=settings.api_key, @@ -235,8 +224,8 @@ def _build_llm(settings: RuntimeSettings, tape_store: AsyncTapeStore) -> LLM: ) -def _load_runtime_settings() -> RuntimeSettings: - return RuntimeSettings() +def _load_runtime_settings() -> AgentSettings: + return AgentSettings() @dataclass(frozen=True) @@ -267,10 +256,3 @@ def _parse_args(args_tokens: list[str]) -> Args: else: positional.append(token) return Args(positional=positional, kwargs=kwargs) - - -def workspace_from_state(state: State) -> Path: - raw = state.get("_runtime_workspace") - if isinstance(raw, str) and raw.strip(): - return Path(raw).expanduser().resolve() - return Path.cwd().resolve() diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 7c20aba6..2eab2ed5 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio -from pathlib import Path import typer @@ -15,17 +14,9 @@ app = typer.Typer() -def _load_framework(workspace: Path | None) -> BubFramework: - if workspace is None: - workspace = Path.cwd() - framework = BubFramework(workspace) - framework.load_hooks() - return framework - - def run( + ctx: typer.Context, message: str = typer.Argument(..., help="Inbound message content"), - workspace: Path | None = typer.Option(None, "--workspace", "-w", help="Workspace root"), channel: str = typer.Option("cli", "--channel", help="Message channel"), chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), sender_id: str = typer.Option("human", "--sender-id", help="Sender id"), @@ -33,7 +24,7 @@ def run( ) -> None: """Run one inbound message through the framework pipeline.""" - framework = _load_framework(workspace) + framework = ctx.ensure_object(BubFramework) inbound = ChannelMessage( session_id=f"{channel}:{chat_id}" if session_id is None else session_id, content=message, @@ -50,11 +41,9 @@ def run( typer.echo(f"[{target_channel}:{target_chat}]\n{rendered}") -def list_hooks( - workspace: Path | None = typer.Option(None, "--workspace", "-w"), -) -> None: +def list_hooks(ctx: typer.Context) -> None: """Show hook implementation mapping.""" - framework = _load_framework(workspace) + framework = ctx.ensure_object(BubFramework) report = framework.hook_report() if not report: typer.echo("(no hook implementations)") @@ -63,28 +52,28 @@ def list_hooks( typer.echo(f"{hook_name}: {', '.join(adapter_names)}") -def message( - workspace: Path | None = typer.Option(None, "--workspace", "-w"), +def gateway( + ctx: typer.Context, enable_channels: list[str] = typer.Option([], "--enable-channel", help="Channels to enable for CLI (default: all)"), ) -> None: - """Start message listener(like telegram).""" + """Start message listeners(like telegram).""" from bub.channels.manager import ChannelManager - framework = _load_framework(workspace) + framework = ctx.ensure_object(BubFramework) manager = ChannelManager(framework, enabled_channels=enable_channels or None) asyncio.run(manager.listen_and_run()) def chat( - workspace: Path | None = typer.Option(None, "--workspace", "-w"), + ctx: typer.Context, chat_id: str = typer.Option("local", "--chat-id", help="Chat id"), session_id: str | None = typer.Option(None, "--session-id", help="Optional session id"), ) -> None: """Start a REPL chat session.""" from bub.channels.manager import ChannelManager - framework = _load_framework(workspace) + framework = ctx.ensure_object(BubFramework) manager = ChannelManager(framework, enabled_channels=["cli"]) channel = manager.get_channel("cli") diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 40d4d617..e6c92e5d 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -3,19 +3,21 @@ import typer from loguru import logger -from republic import Tool from republic.tape import TapeStore -from bub.builtin.engine import RuntimeEngine +from bub.builtin.agent import Agent from bub.channels.base import Channel from bub.channels.message import ChannelMessage from bub.envelope import content_of, field_of -from bub.hook_runtime import HookRuntime +from bub.framework import BubFramework from bub.hookspecs import hookimpl -from bub.types import Envelope, MessageHandler, OutboundDispatcher, State +from bub.types import Envelope, MessageHandler, State AGENTS_FILE_NAME = "AGENTS.md" DEFAULT_SYSTEM_PROMPT = """\ + +Call tools or use skills to finish the task users assigned. When enough evidence is collected, return plain natural language answer. + Excessively long context may cause model call failures. In this case, you MAY use tape.info to the token usage and you SHOULD use tape.handoff tool to shorten the length of the retrieved history. @@ -35,15 +37,11 @@ class BuiltinImpl: """Default hook implementations for basic runtime operations.""" - def __init__( - self, - hooks: HookRuntime, - *, - outbound_dispatcher: OutboundDispatcher | None = None, - ) -> None: - self.hooks = hooks - self.engine = RuntimeEngine(hooks._plugin_manager) - self._outbound_dispatcher = outbound_dispatcher + def __init__(self, framework: BubFramework) -> None: + from bub.builtin import tools # noqa: F401 + + self.framework = framework + self.agent = Agent(framework) @hookimpl def resolve_session(self, message: ChannelMessage) -> str: @@ -59,7 +57,7 @@ async def load_state(self, message: ChannelMessage, session_id: str) -> State: lifespan = field_of(message, "lifespan") if lifespan is not None: await lifespan.__aenter__() - state = {"session_id": session_id, "_runtime_engine": self.engine} + state = {"session_id": session_id, "_runtime_agent": self.agent} if context := field_of(message, "context_str"): state["context"] = context return state @@ -73,12 +71,6 @@ async def save_state(self, session_id: str, state: State, message: ChannelMessag @hookimpl def build_prompt(self, message: ChannelMessage, session_id: str, state: State) -> str: - _ = session_id - workspace = field_of(message, "workspace") - if isinstance(workspace, str) and workspace.strip(): - state["_runtime_workspace"] = workspace.strip() - elif "_runtime_workspace" not in state: - state["_runtime_workspace"] = str(Path.cwd()) content = content_of(message) if content.startswith(","): message.kind = "command" @@ -89,16 +81,16 @@ def build_prompt(self, message: ChannelMessage, session_id: str, state: State) - @hookimpl async def run_model(self, prompt: str, session_id: str, state: State) -> str: - return await self.engine.run(session_id=session_id, prompt=prompt, state=state) + return await self.agent.run(session_id=session_id, prompt=prompt, state=state) @hookimpl def register_cli_commands(self, app: typer.Typer) -> None: from bub.builtin import cli app.command("run")(cli.run) - app.command("hooks")(cli.list_hooks) - app.command("message")(cli.message) app.command("chat")(cli.chat) + app.command("hooks", hidden=True)(cli.list_hooks) + app.command("message", hidden=True)(app.command("gateway")(cli.gateway)) def _read_agents_file(self, state: State) -> str: workspace = state.get("_runtime_workspace", str(Path.cwd())) @@ -115,12 +107,6 @@ def system_prompt(self, prompt: str, state: State) -> str: # Read the content of AGENTS.md under workspace return DEFAULT_SYSTEM_PROMPT + "\n\n" + self._read_agents_file(state) - @hookimpl - def provide_tools(self) -> list[Tool]: - from bub.builtin.tools import get_builtin_tools - - return get_builtin_tools() - @hookimpl def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: from bub.channels.cli import CliChannel @@ -128,7 +114,7 @@ def provide_channels(self, message_handler: MessageHandler) -> list[Channel]: return [ TelegramChannel(on_receive=message_handler), - CliChannel(on_receive=message_handler, engine=self.engine), + CliChannel(on_receive=message_handler, agent=self.agent), ] @hookimpl @@ -141,7 +127,7 @@ async def on_error(self, stage: str, error: Exception, message: Envelope | None) content=f"An error occurred at stage '{stage}': {error}", kind="error", ) - await self.hooks.call_many("dispatch_outbound", message=outbound) + await self.framework._hook_runtime.call_many("dispatch_outbound", message=outbound) @hookimpl async def dispatch_outbound(self, message: Envelope) -> bool: @@ -149,9 +135,7 @@ async def dispatch_outbound(self, message: Envelope) -> bool: session_id = field_of(message, "session_id") if field_of(message, "output_channel") != "cli": logger.info("session.run.outbound session_id={} content={}", session_id, content) - if self._outbound_dispatcher is None: - return False - return await self._outbound_dispatcher(message) + return await self.framework.dispatch_via_router(message) @hookimpl def render_outbound( @@ -175,4 +159,4 @@ def render_outbound( def provide_tape_store(self) -> TapeStore: from bub.builtin.store import FileTapeStore - return FileTapeStore(directory=self.engine.settings.home / "tapes") + return FileTapeStore(directory=self.agent.settings.home / "tapes") diff --git a/src/bub/builtin/settings.py b/src/bub/builtin/settings.py index 46e2b54e..7accc943 100644 --- a/src/bub/builtin/settings.py +++ b/src/bub/builtin/settings.py @@ -8,7 +8,9 @@ DEFAULT_HOME = pathlib.Path.home() / ".bub" -class RuntimeSettings(BaseSettings): +class AgentSettings(BaseSettings): + """Configuration settings for the Agent, loaded from environment variables with prefix BUB_ or from a .env file.""" + model_config = SettingsConfigDict(env_prefix="BUB_", env_parse_none_str="null", extra="ignore", env_file=".env") home: pathlib.Path = Field(default=DEFAULT_HOME) diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 5530f1e7..5dcf8ea9 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -5,21 +5,21 @@ from pathlib import Path from typing import TYPE_CHECKING, cast -from republic import Tool, ToolContext +from republic import ToolContext from bub.skills import discover_skills from bub.tools import tool if TYPE_CHECKING: - from bub.builtin.engine import RuntimeEngine + from bub.builtin.agent import Agent DEFAULT_COMMAND_TIMEOUT_SECONDS = 30 -def _get_runtime(context: ToolContext) -> RuntimeEngine: - if "_runtime_engine" not in context.state: - raise RuntimeError("no runtime engine found in tool context") - return cast("RuntimeEngine", context.state["_runtime_engine"]) +def _get_agent(context: ToolContext) -> Agent: + if "_runtime_agent" not in context.state: + raise RuntimeError("no runtime agent found in tool context") + return cast("Agent", context.state["_runtime_agent"]) @tool(context=True) @@ -83,7 +83,7 @@ def fs_edit(path: str, old: str, new: str, start: int = 0, *, context: ToolConte @tool(context=True, name="skill.load") def skill_load(name: str, *, context: ToolContext) -> str: """Load the skill content by name. The skill must be located in predefined locations and have a valid frontmatter.""" - from bub.builtin.engine import workspace_from_state + from bub.utils import workspace_from_state workspace = workspace_from_state(context.state) skill_index = {skill.name: skill for skill in discover_skills(workspace)} @@ -96,8 +96,8 @@ def skill_load(name: str, *, context: ToolContext) -> str: @tool(context=True, name="tape.info") async def tape_info(context: ToolContext) -> str: """Get information about the current tape, such as number of entries and anchors.""" - runtime = _get_runtime(context) - info = await runtime.tapes.info(context.tape or "") + agent = _get_agent(context) + info = await agent.tapes.info(context.tape or "") return ( f"name: {info.name}\n" f"entries: {info.entries}\n" @@ -111,8 +111,8 @@ async def tape_info(context: ToolContext) -> str: @tool(context=True, name="tape.search") async def tape_search(query: str, limit: int = 20, *, context: ToolContext) -> str: """Search for entries in the current tape that match the query. Returns a list of matching entries.""" - runtime = _get_runtime(context) - entries = await runtime.tapes.search(context.tape or "", query=query, limit=limit) + agent = _get_agent(context) + entries = await agent.tapes.search(context.tape or "", query=query, limit=limit) if not entries: return "(no matches)" return "\n".join(f"- {json.dumps(entry.payload)}" for entry in entries) @@ -121,24 +121,24 @@ async def tape_search(query: str, limit: int = 20, *, context: ToolContext) -> s @tool(context=True, name="tape.reset") async def tape_reset(archive: bool = False, *, context: ToolContext) -> str: """Reset the current tape, optionally archiving it.""" - runtime = _get_runtime(context) - result = await runtime.tapes.reset(context.tape or "", archive=archive) + agent = _get_agent(context) + result = await agent.tapes.reset(context.tape or "", archive=archive) return result @tool(context=True, name="tape.handoff") async def tape_handoff(name: str = "handoff", summary: str = "", *, context: ToolContext) -> str: """Add a handoff anchor to the current tape.""" - runtime = _get_runtime(context) - await runtime.tapes.handoff(context.tape or "", name=name, state={"summary": summary}) + agent = _get_agent(context) + await agent.tapes.handoff(context.tape or "", name=name, state={"summary": summary}) return f"anchor added: {name}" @tool(context=True, name="tape.anchors") async def tape_anchors(*, context: ToolContext) -> str: """List anchors in the current tape.""" - runtime = _get_runtime(context) - anchors = await runtime.tapes.anchors(context.tape or "") + agent = _get_agent(context) + anchors = await agent.tapes.anchors(context.tape or "") if not anchors: return "(no anchors)" return "\n".join(f"- {anchor.name}" for anchor in anchors) @@ -174,19 +174,3 @@ def _resolve_path(context: ToolContext, raw_path: str) -> Path: raise TypeError("runtime workspace must be a filesystem path") workspace_path = Path(workspace) return (workspace_path / path).resolve() - - -def get_builtin_tools() -> list[Tool]: - return [ - show_help, - bash, - skill_load, - fs_read, - fs_write, - fs_edit, - tape_info, - tape_search, - tape_reset, - tape_handoff, - tape_anchors, - ] diff --git a/src/bub/channels/cli/__init__.py b/src/bub/channels/cli/__init__.py index 8edf8cb0..99acedb6 100644 --- a/src/bub/channels/cli/__init__.py +++ b/src/bub/channels/cli/__init__.py @@ -13,12 +13,13 @@ from prompt_toolkit.patch_stdout import patch_stdout from rich import get_console -from bub.builtin.engine import RuntimeEngine +from bub.builtin.agent import Agent from bub.builtin.tape import TapeInfo from bub.channels.base import Channel from bub.channels.cli.renderer import CliRenderer from bub.channels.message import ChannelMessage from bub.envelope import content_of, field_of +from bub.tools import REGISTRY from bub.types import MessageHandler @@ -28,9 +29,9 @@ class CliChannel(Channel): name = "cli" _stop_event: asyncio.Event - def __init__(self, on_receive: MessageHandler, engine: RuntimeEngine) -> None: + def __init__(self, on_receive: MessageHandler, agent: Agent) -> None: self._on_receive = on_receive - self._engine = engine + self._agent = agent self._message_template = { "chat_id": "cli_chat", "channel": self.name, @@ -44,8 +45,8 @@ def __init__(self, on_receive: MessageHandler, engine: RuntimeEngine) -> None: self._workspace = Path.cwd() async def _refresh_tape_info(self) -> None: - tape = self._engine.tapes.session_tape(self._message_template["session_id"], self._workspace) - info = await self._engine.tapes.info(tape.name) + tape = self._agent.tapes.session_tape(self._message_template["session_id"], self._workspace) + info = await self._agent.tapes.info(tape.name) self._last_tape_info = info def set_metadata(self, session_id: str | None = None, chat_id: str | None = None) -> None: @@ -74,7 +75,7 @@ async def send(self, message: ChannelMessage) -> None: self._renderer.assistant_output(content_of(message)) async def _main_loop(self) -> None: - self._renderer.welcome(model=self._engine.settings.model, workspace=str(self._workspace)) + self._renderer.welcome(model=self._agent.settings.model, workspace=str(self._workspace)) await self._refresh_tape_info() request_completed = asyncio.Event() @@ -142,10 +143,10 @@ def _tool_sort_key(tool_name: str) -> tuple[str, str]: section, _, name = tool_name.rpartition(".") return (section, name) - history_file = self._history_file(self._engine.settings.home, workspace) + history_file = self._history_file(self._agent.settings.home, workspace) history_file.parent.mkdir(parents=True, exist_ok=True) history = FileHistory(str(history_file)) - tool_names = sorted((f",{tool.name}" for tool in self._engine.tools), key=_tool_sort_key) + tool_names = sorted((f",{name}" for name in REGISTRY), key=_tool_sort_key) completer = WordCompleter(tool_names, ignore_case=True) return PromptSession( completer=completer, @@ -160,7 +161,7 @@ def _render_bottom_toolbar(self) -> FormattedText: now = datetime.now().strftime("%H:%M") left = f"{now} mode:{self._mode}" right = ( - f"model:{self._engine.settings.model} " + f"model:{self._agent.settings.model} " f"entries:{field_of(info, 'entries', '-')} " f"anchors:{field_of(info, 'anchors', '-')} " f"last:{field_of(info, 'last_anchor', None) or '-'}" diff --git a/src/bub/channels/handler.py b/src/bub/channels/handler.py index 4b5810ba..24e664da 100644 --- a/src/bub/channels/handler.py +++ b/src/bub/channels/handler.py @@ -1,5 +1,4 @@ import asyncio -from dataclasses import replace from loguru import logger @@ -14,12 +13,11 @@ def __init__( self, handler: MessageHandler, *, active_time_window: float, max_wait_seconds: float, debounce_seconds: float ) -> None: self._handler = handler - self._pending_prompts: list[str] = [] + self._pending_messages: list[ChannelMessage] = [] self._last_active_time: float | None = None self._event = asyncio.Event() self._timer: asyncio.TimerHandle | None = None self._in_processing: asyncio.Task | None = None - self._message_template: ChannelMessage | None = None self._loop = asyncio.get_running_loop() self.active_time_window = active_time_window @@ -34,15 +32,12 @@ def _reset_timer(self, timeout: float) -> None: async def _process(self) -> None: await self._event.wait() - content = "\n".join(self._pending_prompts) - self._pending_prompts.clear() + message = ChannelMessage.from_batch(self._pending_messages) + self._pending_messages.clear() self._in_processing = None - assert self._message_template is not None # noqa: S101 - message = replace(self._message_template, content=content) await self._handler(message) async def __call__(self, message: ChannelMessage) -> None: - self._message_template = message now = self._loop.time() if message.content.startswith(","): logger.info( @@ -58,7 +53,7 @@ async def __call__(self, message: ChannelMessage) -> None: "session.message received ignored session_id={}, content={}", message.session_id, message.content ) return - self._pending_prompts.append(message.content) + self._pending_messages.append(message) if message.is_active: self._last_active_time = now logger.info( diff --git a/src/bub/channels/manager.py b/src/bub/channels/manager.py index 7e00d0e8..8823ecf1 100644 --- a/src/bub/channels/manager.py +++ b/src/bub/channels/manager.py @@ -9,10 +9,10 @@ from bub.channels.base import Channel from bub.channels.handler import BufferedMessageHandler from bub.channels.message import ChannelMessage -from bub.channels.utils import wait_until_stopped from bub.envelope import content_of, field_of from bub.framework import BubFramework from bub.types import Envelope, MessageHandler +from bub.utils import wait_until_stopped class ChannelSettings(BaseSettings): @@ -38,7 +38,7 @@ class ChannelSettings(BaseSettings): class ChannelManager: def __init__(self, framework: BubFramework, enabled_channels: Collection[str] | None = None) -> None: self.framework = framework - self._channels: dict[str, Channel] = {} + self._channels: dict[str, Channel] = self.framework.get_channels(self.on_receive) self._settings = ChannelSettings() if enabled_channels is not None: self._enabled_channels = list(enabled_channels) @@ -47,7 +47,6 @@ def __init__(self, framework: BubFramework, enabled_channels: Collection[str] | self._messages = asyncio.Queue[ChannelMessage]() self._ongoing_tasks: set[asyncio.Task] = set() self._session_handlers: dict[str, MessageHandler] = {} - self._load_channels() async def on_receive(self, message: ChannelMessage) -> None: channel = message.channel @@ -99,13 +98,6 @@ def enabled_channels(self) -> list[Channel]: return [channel for name, channel in self._channels.items() if name != "cli"] return [channel for name, channel in self._channels.items() if name in self._enabled_channels] - def _load_channels(self) -> None: - for result in reversed( - self.framework._hook_runtime.call_many_sync("provide_channels", message_handler=self.on_receive) - ): - for channel in result: - self._channels[channel.name] = channel - async def listen_and_run(self) -> None: stop_event = asyncio.Event() self.framework.bind_outbound_router(self) diff --git a/src/bub/channels/message.py b/src/bub/channels/message.py index 9a93f920..c7bfba0b 100644 --- a/src/bub/channels/message.py +++ b/src/bub/channels/message.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import contextlib import json -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Any, Literal type MessageKind = Literal["error", "normal", "command"] @@ -29,3 +31,12 @@ def __post_init__(self) -> None: def context_str(self) -> str: """String representation of the context for prompt building.""" return json.dumps(self.context, ensure_ascii=False)[1:-1] + + @classmethod + def from_batch(cls, batch: list[ChannelMessage]) -> ChannelMessage: + """Create a single message by combining a batch of messages.""" + if not batch: + raise ValueError("Batch cannot be empty") + template = batch[-1] + content = "\n".join(message.content for message in batch) + return replace(template, content=content) diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 29b1cc7e..510544ba 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -15,8 +15,8 @@ from bub.channels.base import Channel from bub.channels.message import ChannelMessage -from bub.channels.utils import exclude_none from bub.types import MessageHandler +from bub.utils import exclude_none class TelegramSettings(BaseSettings): diff --git a/src/bub/framework.py b/src/bub/framework.py index 7c529a1f..4dc9dd49 100644 --- a/src/bub/framework.py +++ b/src/bub/framework.py @@ -4,15 +4,21 @@ from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pluggy +import typer from loguru import logger +from republic import AsyncTapeStore +from republic.tape import TapeStore from bub.envelope import content_of, field_of, unpack_batch from bub.hook_runtime import HookRuntime from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs -from bub.types import Envelope, OutboundChannelRouter, TurnResult +from bub.types import Envelope, MessageHandler, OutboundChannelRouter, TurnResult + +if TYPE_CHECKING: + from bub.channels.base import Channel @dataclass(frozen=True) @@ -24,8 +30,8 @@ class PluginStatus: class BubFramework: """Minimal framework core. Everything grows from hook skills.""" - def __init__(self, workspace: Path) -> None: - self.workspace = workspace.resolve() + def __init__(self) -> None: + self.workspace = Path.cwd().resolve() self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE) self._plugin_manager.add_hookspecs(BubHookSpecs) self._hook_runtime = HookRuntime(self._plugin_manager) @@ -35,7 +41,7 @@ def __init__(self, workspace: Path) -> None: def _load_builtin_hooks(self) -> None: from bub.builtin.hook_impl import BuiltinImpl - impl = BuiltinImpl(self._hook_runtime, outbound_dispatcher=self.dispatch_via_router) + impl = BuiltinImpl(self) try: self._plugin_manager.register(impl, name="builtin") @@ -51,6 +57,8 @@ def load_hooks(self) -> None: for entry_point in importlib.metadata.entry_points(group="bub"): try: plugin = entry_point.load() + if callable(plugin): # Support entry points that are classes + plugin = plugin(self) self._plugin_manager.register(plugin, name=entry_point.name) except Exception as exc: logger.warning(f"Failed to load plugin '{entry_point.name}': {exc}") @@ -58,23 +66,32 @@ def load_hooks(self) -> None: else: self._plugin_status[entry_point.name] = PluginStatus(is_success=True) - def register_cli_commands(self, app: Any) -> None: - """Ask skills to register CLI commands.""" + def create_cli_app(self) -> typer.Typer: + """Create CLI app by collecting commands from hooks. Can be used for custom CLI entry point.""" + app = typer.Typer(name="bub", help="Batteries-included, hook-first AI framework", add_completion=False) + + @app.callback(invoke_without_command=True) + def _main( + ctx: typer.Context, + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Path to the workspace"), + ) -> None: + if workspace: + self.workspace = Path(workspace).resolve() + ctx.obj = self self._hook_runtime.call_many_sync("register_cli_commands", app=app) + return app async def process_inbound(self, inbound: Envelope) -> TurnResult: """Run one inbound message through hooks and return turn result.""" try: - if isinstance(inbound, dict): - inbound.setdefault("workspace", str(self.workspace)) session_id = await self._hook_runtime.call_first( "resolve_session", message=inbound ) or self._default_session_id(inbound) if isinstance(inbound, dict): inbound.setdefault("session_id", session_id) - state = {} + state = {"_runtime_workspace": str(self.workspace)} for hook_state in reversed( await self._hook_runtime.call_many("load_state", message=inbound, session_id=session_id) ): @@ -169,3 +186,21 @@ async def _collect_outbounds( if chat_id is not None: fallback["chat_id"] = chat_id return [fallback] + + def get_channels(self, message_handler: MessageHandler) -> dict[str, Channel]: + channels: dict[str, Channel] = {} + for result in self._hook_runtime.call_many_sync("provide_channels", message_handler=message_handler): + for channel in result: + if channel.name not in channels: + channels[channel.name] = channel + return channels + + def get_tape_store(self) -> TapeStore | AsyncTapeStore | None: + return self._hook_runtime.call_first_sync("provide_tape_store") + + def get_system_prompt(self, prompt: str, state: dict[str, Any]) -> str: + return "\n\n".join( + result + for result in reversed(self._hook_runtime.call_many_sync("system_prompt", prompt=prompt, state=state)) + if result + ) diff --git a/src/bub/hookspecs.py b/src/bub/hookspecs.py index a40f7913..0e5ae423 100644 --- a/src/bub/hookspecs.py +++ b/src/bub/hookspecs.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any import pluggy -from republic import AsyncTapeStore, Tool +from republic import AsyncTapeStore from republic.tape import TapeStore from bub.types import Envelope, MessageHandler, State @@ -80,11 +80,6 @@ def system_prompt(self, prompt: str, state: State) -> str: """Provide a system prompt to be prepended to all model prompts.""" raise NotImplementedError - @hookspec - def provide_tools(self) -> list[Tool]: - """Return a list of tools to be registered in the framework's tool registry.""" - raise NotImplementedError - @hookspec(firstresult=True) def provide_tape_store(self) -> TapeStore | AsyncTapeStore: """Provide a tape store instance for Bub's conversation recording feature.""" diff --git a/src/bub/tools.py b/src/bub/tools.py index b08ef193..d723ec63 100644 --- a/src/bub/tools.py +++ b/src/bub/tools.py @@ -1,7 +1,7 @@ import inspect import json import time -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import replace from typing import Any, overload @@ -10,6 +10,9 @@ from republic import Tool from republic import tool as republic_tool +# Central registry for tools. Tools defined with the @tool decorator are automatically added here. +REGISTRY: dict[str, Tool] = {} + def _add_logging(tool: Tool) -> Tool: if tool.handler is None: @@ -117,10 +120,13 @@ def tool( context=context, ) if isinstance(result, Tool): + REGISTRY[result.name] = result return _add_logging(result) def decorator(func: Callable) -> Tool: - return _add_logging(result(func)) + tool_instance = _add_logging(result(func)) + REGISTRY[tool_instance.name] = tool_instance + return tool_instance return decorator @@ -129,12 +135,12 @@ def _to_model_name(name: str) -> str: return name.replace(".", "_") -def model_tools(tools: list[Tool]) -> list[Tool]: +def model_tools(tools: Iterable[Tool]) -> list[Tool]: """Helper to convert a list of Tool instances into a format accepted by LLMs.""" return [replace(tool, name=_to_model_name(tool.name)) for tool in tools] -def render_tools_prompt(tools: list[Tool]) -> str: +def render_tools_prompt(tools: Iterable[Tool]) -> str: """Render a human-readable description of tools for model prompts.""" if not tools: return "" diff --git a/src/bub/channels/utils.py b/src/bub/utils.py similarity index 73% rename from src/bub/channels/utils.py rename to src/bub/utils.py index ec3108c7..2162363a 100644 --- a/src/bub/channels/utils.py +++ b/src/bub/utils.py @@ -1,7 +1,10 @@ import asyncio from collections.abc import Coroutine +from pathlib import Path from typing import Any +from bub.types import State + def exclude_none(d: dict[str, Any]) -> dict[str, Any]: """Exclude None values from a dictionary.""" @@ -20,3 +23,10 @@ async def wait_until_stopped[T](coro: Coroutine[None, None, T], stop_event: asyn else: waiter.cancel() return task.result() + + +def workspace_from_state(state: State) -> Path: + raw = state.get("_runtime_workspace") + if isinstance(raw, str) and raw.strip(): + return Path(raw).expanduser().resolve() + return Path.cwd().resolve() diff --git a/tests/test_channels_utils.py b/tests/test_channels_utils.py index 393a5383..9237dbc0 100644 --- a/tests/test_channels_utils.py +++ b/tests/test_channels_utils.py @@ -2,7 +2,7 @@ import pytest -from bub.channels.utils import exclude_none, wait_until_stopped +from bub.utils import exclude_none, wait_until_stopped def test_exclude_none_keeps_non_none_values() -> None: diff --git a/tests/test_framework.py b/tests/test_framework.py new file mode 100644 index 00000000..51118cef --- /dev/null +++ b/tests/test_framework.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +import typer +from typer.testing import CliRunner + +from bub.channels.base import Channel +from bub.framework import BubFramework +from bub.hookspecs import hookimpl + + +class NamedChannel(Channel): + def __init__(self, name: str, label: str) -> None: + self.name = name + self.label = label + + async def start(self, stop_event) -> None: + return None + + async def stop(self) -> None: + return None + + +def test_create_cli_app_sets_workspace_and_context(tmp_path: Path) -> None: + framework = BubFramework() + + class CliPlugin: + @hookimpl + def register_cli_commands(self, app: typer.Typer) -> None: + @app.command("workspace") + def workspace_command(ctx: typer.Context) -> None: + current = ctx.ensure_object(BubFramework) + typer.echo(str(current.workspace)) + + framework._plugin_manager.register(CliPlugin(), name="cli-plugin") + app = framework.create_cli_app() + + result = CliRunner().invoke(app, ["--workspace", str(tmp_path), "workspace"]) + + assert result.exit_code == 0 + assert result.stdout.strip() == str(tmp_path.resolve()) + assert framework.workspace == tmp_path.resolve() + + +def test_get_channels_prefers_high_priority_plugin_for_duplicate_names() -> None: + framework = BubFramework() + + class LowPriorityPlugin: + @hookimpl + def provide_channels(self, message_handler): + return [NamedChannel("shared", "low"), NamedChannel("low-only", "low")] + + class HighPriorityPlugin: + @hookimpl + def provide_channels(self, message_handler): + return [NamedChannel("shared", "high"), NamedChannel("high-only", "high")] + + framework._plugin_manager.register(LowPriorityPlugin(), name="low") + framework._plugin_manager.register(HighPriorityPlugin(), name="high") + + channels = framework.get_channels(lambda message: None) + + assert set(channels) == {"shared", "low-only", "high-only"} + assert channels["shared"].label == "high" + assert channels["low-only"].label == "low" + assert channels["high-only"].label == "high" + + +def test_get_system_prompt_uses_priority_order_and_skips_empty_results() -> None: + framework = BubFramework() + + class LowPriorityPlugin: + @hookimpl + def system_prompt(self, prompt: str, state: dict[str, str]) -> str: + return "low" + + class HighPriorityPlugin: + @hookimpl + def system_prompt(self, prompt: str, state: dict[str, str]) -> str | None: + return "high" + + class EmptyPlugin: + @hookimpl + def system_prompt(self, prompt: str, state: dict[str, str]) -> str | None: + return None + + framework._plugin_manager.register(LowPriorityPlugin(), name="low") + framework._plugin_manager.register(HighPriorityPlugin(), name="high") + framework._plugin_manager.register(EmptyPlugin(), name="empty") + + prompt = framework.get_system_prompt(prompt="hello", state={}) + + assert prompt == "low\n\nhigh" + + +def test_builtin_cli_exposes_gateway_and_keeps_message_hidden_alias() -> None: + framework = BubFramework() + framework.load_hooks() + app = framework.create_cli_app() + runner = CliRunner() + + help_result = runner.invoke(app, ["--help"]) + alias_result = runner.invoke(app, ["message", "--help"]) + + assert help_result.exit_code == 0 + assert "gateway" in help_result.stdout + assert "│ message" not in help_result.stdout + assert alias_result.exit_code == 0 + assert "bub message" in alias_result.stdout + assert "Start message listeners" in alias_result.stdout From f0d51bdbf4576977cfb63b3a7c282714173a0d5c Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 6 Mar 2026 11:50:39 +0800 Subject: [PATCH 37/39] refactor: change subprocess execution method in bash tool to use create_subprocess_shell Fix #88 Signed-off-by: Frost Ming --- src/bub/builtin/tools.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/bub/builtin/tools.py b/src/bub/builtin/tools.py index 5dcf8ea9..c8c9605a 100644 --- a/src/bub/builtin/tools.py +++ b/src/bub/builtin/tools.py @@ -28,9 +28,7 @@ async def bash( ) -> str: """Run a shell command and return its output within a time limit. Raises if the command fails or times out.""" workspace = context.state.get("_runtime_workspace") - completed = await asyncio.create_subprocess_exec( - "bash", - "-lc", + completed = await asyncio.create_subprocess_shell( cmd, cwd=cwd or workspace, stdout=asyncio.subprocess.PIPE, From f62883574723c49186e0f4181605f1fdb2594ec5 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 6 Mar 2026 12:59:26 +0800 Subject: [PATCH 38/39] refactor: update _builtin_skills_root to return a list of paths from the bub_skills module Signed-off-by: Frost Ming --- src/bub/skills.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/bub/skills.py b/src/bub/skills.py index 097dc9dd..cbc8887e 100644 --- a/src/bub/skills.py +++ b/src/bub/skills.py @@ -135,8 +135,10 @@ def _is_valid_metadata_field(metadata_field: object) -> bool: return all(isinstance(key, str) and isinstance(value, str) for key, value in metadata_field.items()) -def _builtin_skills_root() -> Path: - return Path(__file__).resolve().parent.parent / "bub_skills" +def _builtin_skills_root() -> list[Path]: + import importlib + + return [Path(p) for p in importlib.import_module("bub_skills").__path__] def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: @@ -155,7 +157,8 @@ def _iter_skill_roots(workspace_path: Path) -> list[tuple[Path, str]]: elif source == "global": roots.append((Path.home() / PROJECT_SKILLS_DIR, source)) elif source == "builtin": - roots.append((_builtin_skills_root(), source)) + for path in _builtin_skills_root(): + roots.append((path, source)) return roots From 36fb7522e6c962f6842acb6f5f7f35185a660de2 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Fri, 6 Mar 2026 15:52:33 +0800 Subject: [PATCH 39/39] refactor: enhance message handling by adding prettify method for content sanitization and updating message processing to handle media data Signed-off-by: Frost Ming --- src/bub/channels/handler.py | 21 +++++-- src/bub/channels/telegram.py | 113 ++++++++++++++++++++++------------- 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/src/bub/channels/handler.py b/src/bub/channels/handler.py index 24e664da..f955a5f2 100644 --- a/src/bub/channels/handler.py +++ b/src/bub/channels/handler.py @@ -1,4 +1,5 @@ import asyncio +import re from loguru import logger @@ -37,11 +38,17 @@ async def _process(self) -> None: self._in_processing = None await self._handler(message) + @staticmethod + def prettify(content: str) -> str: + return re.sub(r'data:[^;]+;base64,[^"]+', "[media]", content) + async def __call__(self, message: ChannelMessage) -> None: now = self._loop.time() if message.content.startswith(","): logger.info( - "session.message received command session_id={}, content={}", message.session_id, message.content + "session.message received command session_id={}, content={}", + message.session_id, + self.prettify(message.content), ) await self._handler(message) return @@ -50,19 +57,25 @@ async def __call__(self, message: ChannelMessage) -> None: ): self._last_active_time = None logger.info( - "session.message received ignored session_id={}, content={}", message.session_id, message.content + "session.message received ignored session_id={}, content={}", + message.session_id, + self.prettify(message.content), ) return self._pending_messages.append(message) if message.is_active: self._last_active_time = now logger.info( - "session.message received active session_id={}, content={}", message.session_id, message.content + "session.message received active session_id={}, content={}", + message.session_id, + self.prettify(message.content), ) self._reset_timer(self.debounce_seconds) if self._in_processing is None: self._in_processing = asyncio.create_task(self._process()) elif self._last_active_time is not None and self._in_processing is None: - logger.info("session.receive followup session_id={} message={}", message.session_id, message.content) + logger.info( + "session.receive followup session_id={} message={}", message.session_id, self.prettify(message.content) + ) self._reset_timer(self.max_wait_seconds) self._in_processing = asyncio.create_task(self._process()) diff --git a/src/bub/channels/telegram.py b/src/bub/channels/telegram.py index 510544ba..580d3de7 100644 --- a/src/bub/channels/telegram.py +++ b/src/bub/channels/telegram.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import base64 import contextlib import json from collections.abc import AsyncGenerator, Callable @@ -9,7 +10,7 @@ from loguru import logger from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict -from telegram import Message, Update +from telegram import Bot, Message, Update from telegram.ext import Application, CommandHandler, ContextTypes, filters from telegram.ext import MessageHandler as TelegramMessageHandler @@ -124,7 +125,7 @@ def __init__(self, on_receive: MessageHandler) -> None: self._settings = TelegramSettings() self._allow_users = {uid.strip() for uid in (self._settings.allow_users or "").split(",") if uid.strip()} self._allow_chats = {cid.strip() for cid in (self._settings.allow_chats or "").split(",") if cid.strip()} - self._parser = TelegramMessageParser() + self._parser = TelegramMessageParser(bot_getter=lambda: self._app.bot) self._typing_tasks: dict[str, asyncio.Task] = {} @property @@ -201,12 +202,12 @@ async def _on_message(self, update: Update, _context: ContextTypes.DEFAULT_TYPE) if self._allow_users and sender_tokens.isdisjoint(self._allow_users): await update.message.reply_text("Access denied.") return - await self._on_receive(self._build_message(update.message)) + await self._on_receive(await self._build_message(update.message)) - def _build_message(self, message: Message) -> ChannelMessage: + async def _build_message(self, message: Message) -> ChannelMessage: chat_id = str(message.chat_id) session_id = f"{self.name}:{chat_id}" - content, metadata = self._parser.parse(message) + content, metadata = await self._parser.parse(message) if content.startswith("/bub "): content = content[5:] @@ -214,7 +215,7 @@ def _build_message(self, message: Message) -> ChannelMessage: if content.strip().startswith(","): return ChannelMessage(session_id=session_id, content=content.strip(), channel=self.name, chat_id=chat_id) - reply_meta = self._parser.get_reply(message) + reply_meta = await self._parser.get_reply(message) if reply_meta: metadata["reply_to_message"] = reply_meta content = json.dumps({"message": content, "chat_id": chat_id, **metadata}, ensure_ascii=False) @@ -255,16 +256,18 @@ async def _typing_loop(self, chat_id: str) -> None: class TelegramMessageParser: - @classmethod - def parse(cls, message: Message) -> tuple[str, dict[str, Any]]: + def __init__(self, bot_getter: Callable[[], Bot] | None = None) -> None: + self._bot_getter = bot_getter + + async def parse(self, message: Message) -> tuple[str, dict[str, Any]]: msg_type = _message_type(message) content, media = f"[Unsupported message type: {msg_type}]", None if msg_type == "text": content, media = getattr(message, "text", None) or "", None else: - parser = cls._MEDIA_MESSAGE_PARSERS.get(msg_type) + parser = getattr(self, f"_parse_{msg_type}", None) if parser is not None: - content, media = parser(message) + content, media = await parser(message) metadata = exclude_none({ "message_id": message.message_id, "type": _message_type(message), @@ -274,36 +277,34 @@ def parse(cls, message: Message) -> tuple[str, dict[str, Any]]: "sender_is_bot": message.from_user.is_bot if message.from_user else None, "date": message.date.timestamp() if message.date else None, "media": media, - "caption": getattr(message, "caption", None), }) return content, metadata - @classmethod - def get_reply(cls, message: Message) -> dict[str, Any] | None: + async def get_reply(self, message: Message) -> dict[str, Any] | None: reply_to = message.reply_to_message if reply_to is None or reply_to.from_user is None: return None - content, metadata = cls.parse(reply_to) + content, metadata = await self.parse(reply_to) return {"message": content, **metadata} - @staticmethod - def _parse_photo(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_photo(self, message: Message) -> tuple[str, dict[str, Any] | None]: caption = getattr(message, "caption", None) or "" formatted = f"[Photo message] Caption: {caption}" if caption else "[Photo message]" photos = getattr(message, "photo", None) or [] if not photos: return formatted, None largest = photos[-1] - metadata = exclude_none({ + mime_type = "image/jpeg" + media = exclude_none({ "file_id": largest.file_id, "file_size": largest.file_size, "width": largest.width, "height": largest.height, + "data_url": await self._download_media(mime_type, largest.file_id, largest.file_size), }) - return formatted, metadata + return formatted, media - @staticmethod - def _parse_audio(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_audio(self, message: Message) -> tuple[str, dict[str, Any] | None]: audio = getattr(message, "audio", None) if audio is None: return "[Audio]", None @@ -312,37 +313,59 @@ def _parse_audio(message: Message) -> tuple[str, dict[str, Any] | None]: duration = audio.duration or 0 metadata = exclude_none({ "file_id": audio.file_id, + "mime_type": audio.mime_type, "file_size": audio.file_size, "duration": audio.duration, "title": audio.title, "performer": audio.performer, + "data_url": await self._download_media( + audio.mime_type or "application/octet-stream", audio.file_id, audio.file_size + ), }) if performer: return f"[Audio: {performer} - {title} ({duration}s)]", metadata return f"[Audio: {title} ({duration}s)]", metadata - @staticmethod - def _parse_sticker(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _download_media(self, mime_type: str, file_id: str, file_size: int) -> str | None: + if not file_id: + raise ValueError("file_id must not be empty") + if self._bot_getter is None: + raise RuntimeError("Telegram bot is not configured for media downloads.") + if file_size > 2 * 1024 * 1024: # limit to 2MB + return None + bot = self._bot_getter() + if bot is None: + raise RuntimeError("Telegram bot is not available for media downloads.") + + telegram_file = await bot.get_file(file_id) + if telegram_file is None: + raise RuntimeError(f"Telegram file lookup returned no result for file_id={file_id}.") + data = await telegram_file.download_as_bytearray() + print("File size:", len(data)) + return f"data:{mime_type};base64,{base64.b64encode(data).decode('utf-8')}" + + async def _parse_sticker(self, message: Message) -> tuple[str, dict[str, Any] | None]: sticker = getattr(message, "sticker", None) if sticker is None: return "[Sticker]", None emoji = sticker.emoji or "" set_name = sticker.set_name or "" + mime_type = "image/webp" if not sticker.is_animated else "video/webm" metadata = exclude_none({ "file_id": sticker.file_id, "width": sticker.width, "height": sticker.height, + "mime_type": mime_type, "emoji": sticker.emoji, "set_name": sticker.set_name, "is_animated": sticker.is_animated, - "is_video": sticker.is_video, + "data_url": await self._download_media(mime_type, sticker.file_id, sticker.file_size), }) if emoji: return f"[Sticker: {emoji} from {set_name}]", metadata return f"[Sticker from {set_name}]", metadata - @staticmethod - def _parse_video(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_video(self, message: Message) -> tuple[str, dict[str, Any] | None]: video = getattr(message, "video", None) duration = video.duration if video else 0 caption = getattr(message, "caption", None) or "" @@ -356,20 +379,25 @@ def _parse_video(message: Message) -> tuple[str, dict[str, Any] | None]: "width": video.width, "height": video.height, "duration": video.duration, + "mime_type": video.mime_type, + "data_url": await self._download_media(video.mime_type or "video/mp4", video.file_id, video.file_size), }) return formatted, metadata - @staticmethod - def _parse_voice(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_voice(self, message: Message) -> tuple[str, dict[str, Any] | None]: voice = getattr(message, "voice", None) duration = voice.duration if voice else 0 if voice is None: return f"[Voice message: {duration}s]", None - metadata = exclude_none({"file_id": voice.file_id, "duration": voice.duration}) + metadata = exclude_none({ + "file_id": voice.file_id, + "duration": voice.duration, + "mime_type": voice.mime_type, + "data_url": await self._download_media(voice.mime_type or "audio/ogg", voice.file_id, voice.file_size), + }) return f"[Voice message: {duration}s]", metadata - @staticmethod - def _parse_document(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_document(self, message: Message) -> tuple[str, dict[str, Any] | None]: document = getattr(message, "document", None) if document is None: return "[Document]", None @@ -383,24 +411,23 @@ def _parse_document(message: Message) -> tuple[str, dict[str, Any] | None]: "file_name": document.file_name, "file_size": document.file_size, "mime_type": document.mime_type, + "data_url": await self._download_media( + document.mime_type or "application/octet-stream", document.file_id, document.file_size + ), }) return formatted, metadata - @staticmethod - def _parse_video_note(message: Message) -> tuple[str, dict[str, Any] | None]: + async def _parse_video_note(self, message: Message) -> tuple[str, dict[str, Any] | None]: video_note = getattr(message, "video_note", None) duration = video_note.duration if video_note else 0 if video_note is None: return f"[Video note: {duration}s]", None - metadata = exclude_none({"file_id": video_note.file_id, "duration": video_note.duration}) + metadata = exclude_none({ + "file_id": video_note.file_id, + "duration": video_note.duration, + "mime_type": video_note.mime_type, + "data_url": await self._download_media( + video_note.mime_type or "video/mp4", video_note.file_id, video_note.file_size + ), + }) return f"[Video note: {duration}s]", metadata - - _MEDIA_MESSAGE_PARSERS: ClassVar[dict[str, Callable[[Message], tuple[str, dict[str, Any] | None]]]] = { - "photo": _parse_photo, - "audio": _parse_audio, - "sticker": _parse_sticker, - "video": _parse_video, - "voice": _parse_voice, - "document": _parse_document, - "video_note": _parse_video_note, - }