dulus 0.2.12__tar.gz → 0.2.14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. {dulus-0.2.12/dulus.egg-info → dulus-0.2.14}/PKG-INFO +17 -6
  2. {dulus-0.2.12 → dulus-0.2.14}/README.md +16 -5
  3. {dulus-0.2.12 → dulus-0.2.14}/docs/news.md +4 -0
  4. {dulus-0.2.12 → dulus-0.2.14/dulus.egg-info}/PKG-INFO +17 -6
  5. {dulus-0.2.12 → dulus-0.2.14}/dulus.py +91 -31
  6. {dulus-0.2.12 → dulus-0.2.14}/providers.py +141 -4
  7. {dulus-0.2.12 → dulus-0.2.14}/pyproject.toml +1 -1
  8. {dulus-0.2.12 → dulus-0.2.14}/tools.py +3 -2
  9. {dulus-0.2.12 → dulus-0.2.14}/LICENSE +0 -0
  10. {dulus-0.2.12 → dulus-0.2.14}/MANIFEST.in +0 -0
  11. {dulus-0.2.12 → dulus-0.2.14}/agent.py +0 -0
  12. {dulus-0.2.12 → dulus-0.2.14}/backend/__init__.py +0 -0
  13. {dulus-0.2.12 → dulus-0.2.14}/backend/compressor.py +0 -0
  14. {dulus-0.2.12 → dulus-0.2.14}/backend/context.py +0 -0
  15. {dulus-0.2.12 → dulus-0.2.14}/backend/githook.py +0 -0
  16. {dulus-0.2.12 → dulus-0.2.14}/backend/marketplace.py +0 -0
  17. {dulus-0.2.12 → dulus-0.2.14}/backend/mempalace_bridge.py +0 -0
  18. {dulus-0.2.12 → dulus-0.2.14}/backend/personas.py +0 -0
  19. {dulus-0.2.12 → dulus-0.2.14}/backend/plugins.py +0 -0
  20. {dulus-0.2.12 → dulus-0.2.14}/backend/server.py +0 -0
  21. {dulus-0.2.12 → dulus-0.2.14}/backend/tasks.py +0 -0
  22. {dulus-0.2.12 → dulus-0.2.14}/batch_api.py +0 -0
  23. {dulus-0.2.12 → dulus-0.2.14}/checkpoint/__init__.py +0 -0
  24. {dulus-0.2.12 → dulus-0.2.14}/checkpoint/hooks.py +0 -0
  25. {dulus-0.2.12 → dulus-0.2.14}/checkpoint/store.py +0 -0
  26. {dulus-0.2.12 → dulus-0.2.14}/checkpoint/types.py +0 -0
  27. {dulus-0.2.12 → dulus-0.2.14}/claude_code_watcher.py +0 -0
  28. {dulus-0.2.12 → dulus-0.2.14}/clipboard_utils.py +0 -0
  29. {dulus-0.2.12 → dulus-0.2.14}/cloudsave.py +0 -0
  30. {dulus-0.2.12 → dulus-0.2.14}/common.py +0 -0
  31. {dulus-0.2.12 → dulus-0.2.14}/compaction.py +0 -0
  32. {dulus-0.2.12 → dulus-0.2.14}/config.py +0 -0
  33. {dulus-0.2.12 → dulus-0.2.14}/context.py +0 -0
  34. {dulus-0.2.12 → dulus-0.2.14}/data/__init__.py +0 -0
  35. {dulus-0.2.12 → dulus-0.2.14}/data/active_persona.json +0 -0
  36. {dulus-0.2.12 → dulus-0.2.14}/data/context.json +0 -0
  37. {dulus-0.2.12 → dulus-0.2.14}/data/marketplace.json +0 -0
  38. {dulus-0.2.12 → dulus-0.2.14}/data/personas.json +0 -0
  39. {dulus-0.2.12 → dulus-0.2.14}/data/tasks.json +0 -0
  40. {dulus-0.2.12 → dulus-0.2.14}/docs/README.md +0 -0
  41. {dulus-0.2.12 → dulus-0.2.14}/docs/__init__.py +0 -0
  42. {dulus-0.2.12 → dulus-0.2.14}/docs/api.html +0 -0
  43. {dulus-0.2.12 → dulus-0.2.14}/docs/architecture.md +0 -0
  44. {dulus-0.2.12 → dulus-0.2.14}/docs/azure-speech-template.json +0 -0
  45. {dulus-0.2.12 → dulus-0.2.14}/docs/dashboard/index.html +0 -0
  46. {dulus-0.2.12 → dulus-0.2.14}/docs/divider.svg +0 -0
  47. {dulus-0.2.12 → dulus-0.2.14}/docs/generate.py +0 -0
  48. {dulus-0.2.12 → dulus-0.2.14}/docs/hero.svg +0 -0
  49. {dulus-0.2.12 → dulus-0.2.14}/docs/index.html +0 -0
  50. {dulus-0.2.12 → dulus-0.2.14}/docs/nvidia-models.svg +0 -0
  51. {dulus-0.2.12 → dulus-0.2.14}/docs/particle-playground.html +0 -0
  52. {dulus-0.2.12 → dulus-0.2.14}/docs/personas/index.html +0 -0
  53. {dulus-0.2.12 → dulus-0.2.14}/docs/preview.html +0 -0
  54. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-agents.svg +0 -0
  55. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-brainstorm.svg +0 -0
  56. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-bridges.svg +0 -0
  57. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-features.svg +0 -0
  58. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-freetier.svg +0 -0
  59. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-memory.svg +0 -0
  60. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-models.svg +0 -0
  61. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-perms.svg +0 -0
  62. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-plugins.svg +0 -0
  63. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-quickstart.svg +0 -0
  64. {dulus-0.2.12 → dulus-0.2.14}/docs/sec-ssj.svg +0 -0
  65. {dulus-0.2.12 → dulus-0.2.14}/docs/spinners.svg +0 -0
  66. {dulus-0.2.12 → dulus-0.2.14}/docs/split-pane.svg +0 -0
  67. {dulus-0.2.12 → dulus-0.2.14}/docs/terminal-boot.svg +0 -0
  68. {dulus-0.2.12 → dulus-0.2.14}/docs/uploads/particle-playground.html +0 -0
  69. {dulus-0.2.12 → dulus-0.2.14}/dulus.egg-info/SOURCES.txt +0 -0
  70. {dulus-0.2.12 → dulus-0.2.14}/dulus.egg-info/dependency_links.txt +0 -0
  71. {dulus-0.2.12 → dulus-0.2.14}/dulus.egg-info/entry_points.txt +0 -0
  72. {dulus-0.2.12 → dulus-0.2.14}/dulus.egg-info/requires.txt +0 -0
  73. {dulus-0.2.12 → dulus-0.2.14}/dulus.egg-info/top_level.txt +0 -0
  74. {dulus-0.2.12 → dulus-0.2.14}/dulus_gui.py +0 -0
  75. {dulus-0.2.12 → dulus-0.2.14}/dulus_mcp/__init__.py +0 -0
  76. {dulus-0.2.12 → dulus-0.2.14}/dulus_mcp/client.py +0 -0
  77. {dulus-0.2.12 → dulus-0.2.14}/dulus_mcp/config.py +0 -0
  78. {dulus-0.2.12 → dulus-0.2.14}/dulus_mcp/tools.py +0 -0
  79. {dulus-0.2.12 → dulus-0.2.14}/dulus_mcp/types.py +0 -0
  80. {dulus-0.2.12 → dulus-0.2.14}/gui/__init__.py +0 -0
  81. {dulus-0.2.12 → dulus-0.2.14}/gui/agent_bridge.py +0 -0
  82. {dulus-0.2.12 → dulus-0.2.14}/gui/chat_widget.py +0 -0
  83. {dulus-0.2.12 → dulus-0.2.14}/gui/main_window.py +0 -0
  84. {dulus-0.2.12 → dulus-0.2.14}/gui/personas.py +0 -0
  85. {dulus-0.2.12 → dulus-0.2.14}/gui/session_utils.py +0 -0
  86. {dulus-0.2.12 → dulus-0.2.14}/gui/settings_dialog.py +0 -0
  87. {dulus-0.2.12 → dulus-0.2.14}/gui/sidebar.py +0 -0
  88. {dulus-0.2.12 → dulus-0.2.14}/gui/tasks_view.py +0 -0
  89. {dulus-0.2.12 → dulus-0.2.14}/gui/themes.py +0 -0
  90. {dulus-0.2.12 → dulus-0.2.14}/gui/tool_panel.py +0 -0
  91. {dulus-0.2.12 → dulus-0.2.14}/input.py +0 -0
  92. {dulus-0.2.12 → dulus-0.2.14}/license_manager.py +0 -0
  93. {dulus-0.2.12 → dulus-0.2.14}/memory/__init__.py +0 -0
  94. {dulus-0.2.12 → dulus-0.2.14}/memory/audit.py +0 -0
  95. {dulus-0.2.12 → dulus-0.2.14}/memory/consolidator.py +0 -0
  96. {dulus-0.2.12 → dulus-0.2.14}/memory/context.py +0 -0
  97. {dulus-0.2.12 → dulus-0.2.14}/memory/offload.py +0 -0
  98. {dulus-0.2.12 → dulus-0.2.14}/memory/palace.py +0 -0
  99. {dulus-0.2.12 → dulus-0.2.14}/memory/scan.py +0 -0
  100. {dulus-0.2.12 → dulus-0.2.14}/memory/sessions.py +0 -0
  101. {dulus-0.2.12 → dulus-0.2.14}/memory/store.py +0 -0
  102. {dulus-0.2.12 → dulus-0.2.14}/memory/tools.py +0 -0
  103. {dulus-0.2.12 → dulus-0.2.14}/memory/types.py +0 -0
  104. {dulus-0.2.12 → dulus-0.2.14}/memory/vector_search.py +0 -0
  105. {dulus-0.2.12 → dulus-0.2.14}/multi_agent/__init__.py +0 -0
  106. {dulus-0.2.12 → dulus-0.2.14}/multi_agent/subagent.py +0 -0
  107. {dulus-0.2.12 → dulus-0.2.14}/multi_agent/tools.py +0 -0
  108. {dulus-0.2.12 → dulus-0.2.14}/offload_helper.py +0 -0
  109. {dulus-0.2.12 → dulus-0.2.14}/plugin/__init__.py +0 -0
  110. {dulus-0.2.12 → dulus-0.2.14}/plugin/autoadapter.py +0 -0
  111. {dulus-0.2.12 → dulus-0.2.14}/plugin/loader.py +0 -0
  112. {dulus-0.2.12 → dulus-0.2.14}/plugin/recommend.py +0 -0
  113. {dulus-0.2.12 → dulus-0.2.14}/plugin/store.py +0 -0
  114. {dulus-0.2.12 → dulus-0.2.14}/plugin/types.py +0 -0
  115. {dulus-0.2.12 → dulus-0.2.14}/setup.cfg +0 -0
  116. {dulus-0.2.12 → dulus-0.2.14}/skill/__init__.py +0 -0
  117. {dulus-0.2.12 → dulus-0.2.14}/skill/builtin.py +0 -0
  118. {dulus-0.2.12 → dulus-0.2.14}/skill/clawhub.py +0 -0
  119. {dulus-0.2.12 → dulus-0.2.14}/skill/executor.py +0 -0
  120. {dulus-0.2.12 → dulus-0.2.14}/skill/loader.py +0 -0
  121. {dulus-0.2.12 → dulus-0.2.14}/skill/tools.py +0 -0
  122. {dulus-0.2.12 → dulus-0.2.14}/skills.py +0 -0
  123. {dulus-0.2.12 → dulus-0.2.14}/spinner.py +0 -0
  124. {dulus-0.2.12 → dulus-0.2.14}/string_utils.py +0 -0
  125. {dulus-0.2.12 → dulus-0.2.14}/subagent.py +0 -0
  126. {dulus-0.2.12 → dulus-0.2.14}/task/__init__.py +0 -0
  127. {dulus-0.2.12 → dulus-0.2.14}/task/store.py +0 -0
  128. {dulus-0.2.12 → dulus-0.2.14}/task/tools.py +0 -0
  129. {dulus-0.2.12 → dulus-0.2.14}/task/types.py +0 -0
  130. {dulus-0.2.12 → dulus-0.2.14}/tests/test_checkpoint.py +0 -0
  131. {dulus-0.2.12 → dulus-0.2.14}/tests/test_compaction.py +0 -0
  132. {dulus-0.2.12 → dulus-0.2.14}/tests/test_diff_view.py +0 -0
  133. {dulus-0.2.12 → dulus-0.2.14}/tests/test_injection_fix.py +0 -0
  134. {dulus-0.2.12 → dulus-0.2.14}/tests/test_license.py +0 -0
  135. {dulus-0.2.12 → dulus-0.2.14}/tests/test_mcp.py +0 -0
  136. {dulus-0.2.12 → dulus-0.2.14}/tests/test_memory.py +0 -0
  137. {dulus-0.2.12 → dulus-0.2.14}/tests/test_plugin.py +0 -0
  138. {dulus-0.2.12 → dulus-0.2.14}/tests/test_skills.py +0 -0
  139. {dulus-0.2.12 → dulus-0.2.14}/tests/test_subagent.py +0 -0
  140. {dulus-0.2.12 → dulus-0.2.14}/tests/test_task.py +0 -0
  141. {dulus-0.2.12 → dulus-0.2.14}/tests/test_telegram_buffer.py +0 -0
  142. {dulus-0.2.12 → dulus-0.2.14}/tests/test_tool_registry.py +0 -0
  143. {dulus-0.2.12 → dulus-0.2.14}/tests/test_voice.py +0 -0
  144. {dulus-0.2.12 → dulus-0.2.14}/tmux_offloader.py +0 -0
  145. {dulus-0.2.12 → dulus-0.2.14}/tmux_tools.py +0 -0
  146. {dulus-0.2.12 → dulus-0.2.14}/tool_registry.py +0 -0
  147. {dulus-0.2.12 → dulus-0.2.14}/ui/__init__.py +0 -0
  148. {dulus-0.2.12 → dulus-0.2.14}/ui/input.py +0 -0
  149. {dulus-0.2.12 → dulus-0.2.14}/ui/render.py +0 -0
  150. {dulus-0.2.12 → dulus-0.2.14}/voice/__init__.py +0 -0
  151. {dulus-0.2.12 → dulus-0.2.14}/voice/keyterms.py +0 -0
  152. {dulus-0.2.12 → dulus-0.2.14}/voice/recorder.py +0 -0
  153. {dulus-0.2.12 → dulus-0.2.14}/voice/stt.py +0 -0
  154. {dulus-0.2.12 → dulus-0.2.14}/voice/tts.py +0 -0
  155. {dulus-0.2.12 → dulus-0.2.14}/webchat.py +0 -0
  156. {dulus-0.2.12 → dulus-0.2.14}/webchat_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dulus
3
- Version: 0.2.12
3
+ Version: 0.2.14
4
4
  Summary: Spanish-first multi-provider AI CLI — 14 NVIDIA models free, Mesa Redonda, voice, TTS, RTK token reducer, MemPalace
5
5
  Author: KevRojo
6
6
  License: GPL-3.0
@@ -67,7 +67,7 @@ SET /sticky_input ON since the first run for the best experience!
67
67
  <a href="https://pypi.org/project/dulus/"><img src="https://static.pepy.tech/badge/dulus?style=flat-square" alt="downloads"/></a>
68
68
  <img src="https://img.shields.io/badge/python-3.11+-ff6b1f?style=flat-square&labelColor=07070a" alt="python"/>
69
69
  <img src="https://img.shields.io/badge/license-GPLv3-ff6b1f?style=flat-square&labelColor=07070a" alt="license"/>
70
- <img src="https://img.shields.io/badge/version-v0.2.6-ff6b1f?style=flat-square&labelColor=07070a" alt="version"/>
70
+ <img src="https://img.shields.io/badge/version-v0.2.14-ff6b1f?style=flat-square&labelColor=07070a" alt="version"/>
71
71
  <img src="https://img.shields.io/badge/providers-11-ff6b1f?style=flat-square&labelColor=07070a" alt="providers"/>
72
72
  <img src="https://img.shields.io/badge/tools-27-ff6b1f?style=flat-square&labelColor=07070a" alt="tools"/>
73
73
  <img src="https://img.shields.io/badge/tests-263+-ff6b1f?style=flat-square&labelColor=07070a" alt="tests"/>
@@ -87,10 +87,16 @@ SET /sticky_input ON since the first run for the best experience!
87
87
  <p align="center"><img src="https://raw.githubusercontent.com/KevRojo/Dulus/main/docs/divider.svg" alt="" width="100%"></p>
88
88
 
89
89
  ## What is this
90
+ Talent cant be copied.
91
+
92
+ Dulus Reduce your IA costs by 60% parsing webchats and claude-code directly. Write poetry while Anthropic only see text.
93
+
94
+ <img width="3189" height="1489" alt="image" src="https://github.com/user-attachments/assets/8fe96b65-6ae9-4ef7-9d85-0086abc64d23" />
95
+
90
96
 
91
97
  Dulus is a **lightweight Python reimplementation of Claude Code** that isn't locked to Claude. It ships the whole loop — REPL, tool dispatch, streaming, context compaction, checkpoints, sub-agents, voice, Telegram bridge, MCP, plugins — in roughly **12K lines you can actually read**. Fork it. Bend it. Run it offline against Qwen on your M2.
92
98
 
93
- > **v0.2.12 — May 8, 2026** — `sounddevice` also moved to `[voice]` extra (it pulls `cffi`, which needs `libffi-dev` to compile and breaks termux installs). Core `pip install dulus` is now zero-compile. Opt in to extras: `[memory]` for MemPalace, `[voice]` for mic input, `[full]` for both.
99
+ > **v0.2.13 — May 8, 2026** — Internal robustness fixes for Ollama streaming. No user-facing API changes.
94
100
  > Type `/news` to see what changed.
95
101
 
96
102
  ---
@@ -198,7 +204,7 @@ echo "explain this diff" | git diff | dulus -p --accept-all
198
204
  | **Voice input** | Offline STT via Whisper. No API key. No cloud. |
199
205
  | **Brainstorm** | Multi-persona AI debate. Auto-generated expert roles. |
200
206
  | **SSJ Developer Mode** | Power menu: 10 workflow shortcuts behind one keystroke |
201
- | **Telegram bridge** | Run Dulus from your phone. Slash commands. Vision. Voice. |
207
+ | **Telegram bridge** | Run Dulus from your phone. Slash commands. Vision. Voice. Multi-user authorized list. |
202
208
  | **Checkpoints** | Auto-snapshot conversation + files. Rewind to any turn. |
203
209
  | **Plan mode** | Read-only analysis phase before touching anything |
204
210
  | **Context compression** | Auto-compact long sessions. Keep the signal, drop the slop. |
@@ -385,10 +391,15 @@ Then `/voice` in the REPL. Offline. Supports `/voice lang zh` and `/voice device
385
391
  ## Telegram bridge
386
392
 
387
393
  ```
388
- /telegram <bot_token> <chat_id>
394
+ /telegram <bot_token> <chat_id> # single user
395
+ /telegram <bot_token> <id1>,<id2>,<id3> # multi-user — same Dulus, multiple authorized chats
389
396
  ```
390
397
 
391
- Auto-starts next launch. Supports slash commands, vision, and voice from your phone. Useful when you want to poke a long-running agent from the bus.
398
+ Auto-starts next launch. Supports slash commands, vision, and voice from your phone.
399
+ Multi-user mode (v0.2.14+): each authorized chat gets its own replies — Dulus tracks who
400
+ sent each message and routes the response back. Trailing commas are ignored, so
401
+ `717151713,787615162,,` works fine. Useful when you want to poke a long-running agent
402
+ from the bus, or share one Dulus instance with your team.
392
403
 
393
404
  ---
394
405
 
@@ -22,7 +22,7 @@ SET /sticky_input ON since the first run for the best experience!
22
22
  <a href="https://pypi.org/project/dulus/"><img src="https://static.pepy.tech/badge/dulus?style=flat-square" alt="downloads"/></a>
23
23
  <img src="https://img.shields.io/badge/python-3.11+-ff6b1f?style=flat-square&labelColor=07070a" alt="python"/>
24
24
  <img src="https://img.shields.io/badge/license-GPLv3-ff6b1f?style=flat-square&labelColor=07070a" alt="license"/>
25
- <img src="https://img.shields.io/badge/version-v0.2.6-ff6b1f?style=flat-square&labelColor=07070a" alt="version"/>
25
+ <img src="https://img.shields.io/badge/version-v0.2.14-ff6b1f?style=flat-square&labelColor=07070a" alt="version"/>
26
26
  <img src="https://img.shields.io/badge/providers-11-ff6b1f?style=flat-square&labelColor=07070a" alt="providers"/>
27
27
  <img src="https://img.shields.io/badge/tools-27-ff6b1f?style=flat-square&labelColor=07070a" alt="tools"/>
28
28
  <img src="https://img.shields.io/badge/tests-263+-ff6b1f?style=flat-square&labelColor=07070a" alt="tests"/>
@@ -42,10 +42,16 @@ SET /sticky_input ON since the first run for the best experience!
42
42
  <p align="center"><img src="https://raw.githubusercontent.com/KevRojo/Dulus/main/docs/divider.svg" alt="" width="100%"></p>
43
43
 
44
44
  ## What is this
45
+ Talent cant be copied.
46
+
47
+ Dulus Reduce your IA costs by 60% parsing webchats and claude-code directly. Write poetry while Anthropic only see text.
48
+
49
+ <img width="3189" height="1489" alt="image" src="https://github.com/user-attachments/assets/8fe96b65-6ae9-4ef7-9d85-0086abc64d23" />
50
+
45
51
 
46
52
  Dulus is a **lightweight Python reimplementation of Claude Code** that isn't locked to Claude. It ships the whole loop — REPL, tool dispatch, streaming, context compaction, checkpoints, sub-agents, voice, Telegram bridge, MCP, plugins — in roughly **12K lines you can actually read**. Fork it. Bend it. Run it offline against Qwen on your M2.
47
53
 
48
- > **v0.2.12 — May 8, 2026** — `sounddevice` also moved to `[voice]` extra (it pulls `cffi`, which needs `libffi-dev` to compile and breaks termux installs). Core `pip install dulus` is now zero-compile. Opt in to extras: `[memory]` for MemPalace, `[voice]` for mic input, `[full]` for both.
54
+ > **v0.2.13 — May 8, 2026** — Internal robustness fixes for Ollama streaming. No user-facing API changes.
49
55
  > Type `/news` to see what changed.
50
56
 
51
57
  ---
@@ -153,7 +159,7 @@ echo "explain this diff" | git diff | dulus -p --accept-all
153
159
  | **Voice input** | Offline STT via Whisper. No API key. No cloud. |
154
160
  | **Brainstorm** | Multi-persona AI debate. Auto-generated expert roles. |
155
161
  | **SSJ Developer Mode** | Power menu: 10 workflow shortcuts behind one keystroke |
156
- | **Telegram bridge** | Run Dulus from your phone. Slash commands. Vision. Voice. |
162
+ | **Telegram bridge** | Run Dulus from your phone. Slash commands. Vision. Voice. Multi-user authorized list. |
157
163
  | **Checkpoints** | Auto-snapshot conversation + files. Rewind to any turn. |
158
164
  | **Plan mode** | Read-only analysis phase before touching anything |
159
165
  | **Context compression** | Auto-compact long sessions. Keep the signal, drop the slop. |
@@ -340,10 +346,15 @@ Then `/voice` in the REPL. Offline. Supports `/voice lang zh` and `/voice device
340
346
  ## Telegram bridge
341
347
 
342
348
  ```
343
- /telegram <bot_token> <chat_id>
349
+ /telegram <bot_token> <chat_id> # single user
350
+ /telegram <bot_token> <id1>,<id2>,<id3> # multi-user — same Dulus, multiple authorized chats
344
351
  ```
345
352
 
346
- Auto-starts next launch. Supports slash commands, vision, and voice from your phone. Useful when you want to poke a long-running agent from the bus.
353
+ Auto-starts next launch. Supports slash commands, vision, and voice from your phone.
354
+ Multi-user mode (v0.2.14+): each authorized chat gets its own replies — Dulus tracks who
355
+ sent each message and routes the response back. Trailing commas are ignored, so
356
+ `717151713,787615162,,` works fine. Useful when you want to poke a long-running agent
357
+ from the bus, or share one Dulus instance with your team.
347
358
 
348
359
  ---
349
360
 
@@ -3,6 +3,10 @@
3
3
  ## 🔥🔥🔥 News (Pacific Time)
4
4
 
5
5
 
6
+ - May 09, 2026 (**v0.2.14**): **Multi-user Telegram bridge**
7
+ - **Telegram bridge now supports multiple authorized chat_ids.** Configure with `/telegram <token> <id1>,<id2>,<id3>` or set `telegram_chat_ids` in `config.json` as a comma-separated string (trailing commas like `717151713,787615162,,` are ignored). Each authorized chat gets its own replies — Dulus tracks who sent each message via `_active_tg_chat_id` and routes the response back to the right user. Welcome message is broadcast to all configured users on bridge start. The legacy single-int `telegram_chat_id` still works for backwards-compat.
8
+ - **Why this matters.** One Dulus instance, multiple humans poking it from their phones — useful for teams sharing a long-running agent, or for paired-up users running the same MemPalace from different devices.
9
+
6
10
  - Apr 09, 2026 (**v1.01.20**): **Automated Plugin Adapter System, Premium UI, and Hot-Reloading**
7
11
  - **Automated Plugin Adapter (`plugin/autoadapter.py`)** — Dulus can now intelligently onboard any Python repository without a manual manifest. Using AST-based static analysis and AI-driven generation, it creates `plugin.json` and `plugin_tool.py` on the fly, handling complex dependencies and constructor arguments.
8
12
  - **Intelligent Library Handling** — The AI generation pipeline now includes specialized instructions for terminal-based libraries (e.g., `asciimatics`), ensuring correct usage of patterns like `Screen.wrapper` to prevent runtime errors.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dulus
3
- Version: 0.2.12
3
+ Version: 0.2.14
4
4
  Summary: Spanish-first multi-provider AI CLI — 14 NVIDIA models free, Mesa Redonda, voice, TTS, RTK token reducer, MemPalace
5
5
  Author: KevRojo
6
6
  License: GPL-3.0
@@ -67,7 +67,7 @@ SET /sticky_input ON since the first run for the best experience!
67
67
  <a href="https://pypi.org/project/dulus/"><img src="https://static.pepy.tech/badge/dulus?style=flat-square" alt="downloads"/></a>
68
68
  <img src="https://img.shields.io/badge/python-3.11+-ff6b1f?style=flat-square&labelColor=07070a" alt="python"/>
69
69
  <img src="https://img.shields.io/badge/license-GPLv3-ff6b1f?style=flat-square&labelColor=07070a" alt="license"/>
70
- <img src="https://img.shields.io/badge/version-v0.2.6-ff6b1f?style=flat-square&labelColor=07070a" alt="version"/>
70
+ <img src="https://img.shields.io/badge/version-v0.2.14-ff6b1f?style=flat-square&labelColor=07070a" alt="version"/>
71
71
  <img src="https://img.shields.io/badge/providers-11-ff6b1f?style=flat-square&labelColor=07070a" alt="providers"/>
72
72
  <img src="https://img.shields.io/badge/tools-27-ff6b1f?style=flat-square&labelColor=07070a" alt="tools"/>
73
73
  <img src="https://img.shields.io/badge/tests-263+-ff6b1f?style=flat-square&labelColor=07070a" alt="tests"/>
@@ -87,10 +87,16 @@ SET /sticky_input ON since the first run for the best experience!
87
87
  <p align="center"><img src="https://raw.githubusercontent.com/KevRojo/Dulus/main/docs/divider.svg" alt="" width="100%"></p>
88
88
 
89
89
  ## What is this
90
+ Talent cant be copied.
91
+
92
+ Dulus Reduce your IA costs by 60% parsing webchats and claude-code directly. Write poetry while Anthropic only see text.
93
+
94
+ <img width="3189" height="1489" alt="image" src="https://github.com/user-attachments/assets/8fe96b65-6ae9-4ef7-9d85-0086abc64d23" />
95
+
90
96
 
91
97
  Dulus is a **lightweight Python reimplementation of Claude Code** that isn't locked to Claude. It ships the whole loop — REPL, tool dispatch, streaming, context compaction, checkpoints, sub-agents, voice, Telegram bridge, MCP, plugins — in roughly **12K lines you can actually read**. Fork it. Bend it. Run it offline against Qwen on your M2.
92
98
 
93
- > **v0.2.12 — May 8, 2026** — `sounddevice` also moved to `[voice]` extra (it pulls `cffi`, which needs `libffi-dev` to compile and breaks termux installs). Core `pip install dulus` is now zero-compile. Opt in to extras: `[memory]` for MemPalace, `[voice]` for mic input, `[full]` for both.
99
+ > **v0.2.13 — May 8, 2026** — Internal robustness fixes for Ollama streaming. No user-facing API changes.
94
100
  > Type `/news` to see what changed.
95
101
 
96
102
  ---
@@ -198,7 +204,7 @@ echo "explain this diff" | git diff | dulus -p --accept-all
198
204
  | **Voice input** | Offline STT via Whisper. No API key. No cloud. |
199
205
  | **Brainstorm** | Multi-persona AI debate. Auto-generated expert roles. |
200
206
  | **SSJ Developer Mode** | Power menu: 10 workflow shortcuts behind one keystroke |
201
- | **Telegram bridge** | Run Dulus from your phone. Slash commands. Vision. Voice. |
207
+ | **Telegram bridge** | Run Dulus from your phone. Slash commands. Vision. Voice. Multi-user authorized list. |
202
208
  | **Checkpoints** | Auto-snapshot conversation + files. Rewind to any turn. |
203
209
  | **Plan mode** | Read-only analysis phase before touching anything |
204
210
  | **Context compression** | Auto-compact long sessions. Keep the signal, drop the slop. |
@@ -385,10 +391,15 @@ Then `/voice` in the REPL. Offline. Supports `/voice lang zh` and `/voice device
385
391
  ## Telegram bridge
386
392
 
387
393
  ```
388
- /telegram <bot_token> <chat_id>
394
+ /telegram <bot_token> <chat_id> # single user
395
+ /telegram <bot_token> <id1>,<id2>,<id3> # multi-user — same Dulus, multiple authorized chats
389
396
  ```
390
397
 
391
- Auto-starts next launch. Supports slash commands, vision, and voice from your phone. Useful when you want to poke a long-running agent from the bus.
398
+ Auto-starts next launch. Supports slash commands, vision, and voice from your phone.
399
+ Multi-user mode (v0.2.14+): each authorized chat gets its own replies — Dulus tracks who
400
+ sent each message and routes the response back. Trailing commas are ignored, so
401
+ `717151713,787615162,,` works fine. Useful when you want to poke a long-running agent
402
+ from the bus, or share one Dulus instance with your team.
392
403
 
393
404
  ---
394
405
 
@@ -631,8 +631,11 @@ def ask_permission_interactive(desc: str, config: dict) -> bool:
631
631
  config["permission_mode"] = "accept-all"
632
632
  if _is_in_tg_turn(config):
633
633
  token = config.get("telegram_token")
634
- chat_id = config.get("telegram_chat_id")
635
- _tg_send(token, chat_id, "✅ Permission mode set to accept-all for this session.")
634
+ # Reply to the user who actually triggered this prompt; fall back
635
+ # to the first configured chat_id if the active one is unknown.
636
+ cid = config.get("_active_tg_chat_id") or (_tg_get_chat_ids(config) or [None])[0]
637
+ if cid:
638
+ _tg_send(token, cid, "✅ Permission mode set to accept-all for this session.")
636
639
  else:
637
640
  ok(" Permission mode set to accept-all for this session.")
638
641
  return True
@@ -4743,8 +4746,50 @@ def _tg_typing_loop(token: str, chat_id: int, stop_event: threading.Event, confi
4743
4746
  _tg_api(token, "sendChatAction", {"chat_id": chat_id, "action": "typing"})
4744
4747
  stop_event.wait(4)
4745
4748
 
4746
- def _tg_poll_loop(token: str, chat_id: int, config: dict):
4747
- """Long-polling loop that reads Telegram messages and feeds them to run_query."""
4749
+ def _parse_chat_ids(value) -> list[int]:
4750
+ """Accept int, list, or comma-separated string ('123,456,,') list[int].
4751
+ Empty parts (from trailing commas) are dropped.
4752
+ """
4753
+ if not value:
4754
+ return []
4755
+ if isinstance(value, int):
4756
+ return [value]
4757
+ if isinstance(value, list):
4758
+ out = []
4759
+ for x in value:
4760
+ try:
4761
+ out.append(int(x))
4762
+ except (TypeError, ValueError):
4763
+ continue
4764
+ return out
4765
+ if isinstance(value, str):
4766
+ out = []
4767
+ for p in value.split(","):
4768
+ p = p.strip()
4769
+ if not p:
4770
+ continue
4771
+ try:
4772
+ out.append(int(p))
4773
+ except ValueError:
4774
+ continue
4775
+ return out
4776
+ return []
4777
+
4778
+ def _tg_get_chat_ids(config: dict) -> list[int]:
4779
+ """Read configured chat ids from config. Supports legacy single int and
4780
+ new comma-separated string / list."""
4781
+ ids = _parse_chat_ids(config.get("telegram_chat_ids")) or _parse_chat_ids(config.get("telegram_chat_id"))
4782
+ return ids
4783
+
4784
+ def _tg_poll_loop(token: str, chat_ids, config: dict):
4785
+ """Long-polling loop. chat_ids: int (legacy) or list[int].
4786
+ All listed users are authorized; replies go back to whoever sent the msg.
4787
+ """
4788
+ if isinstance(chat_ids, int):
4789
+ chat_ids = [chat_ids]
4790
+ chat_ids = list(chat_ids or [])
4791
+ authorized = set(chat_ids)
4792
+
4748
4793
  run_query_cb = config.get("_run_query_callback")
4749
4794
  # Flush old messages so we don't process stale commands on startup
4750
4795
  flush = _tg_api(token, "getUpdates", {"offset": -1, "timeout": 0})
@@ -4757,8 +4802,9 @@ def _tg_poll_loop(token: str, chat_id: int, config: dict):
4757
4802
  _tg_register_commands(token)
4758
4803
  except Exception:
4759
4804
  pass
4760
- # Notify user bot is online
4761
- _tg_send(token, chat_id, "🟢 Dulus\nSend me a message and I'll process it.")
4805
+ # Notify all configured users that the bot is online
4806
+ for cid in chat_ids:
4807
+ _tg_send(token, cid, "🟢 Dulus\nSend me a message and I'll process it.")
4762
4808
 
4763
4809
  while not _telegram_stop.is_set():
4764
4810
  try:
@@ -4779,13 +4825,21 @@ def _tg_poll_loop(token: str, chat_id: int, config: dict):
4779
4825
  msg_chat_id = msg.get("chat", {}).get("id")
4780
4826
  text = sanitize_text(msg.get("text", ""))
4781
4827
 
4782
- if msg_chat_id != chat_id:
4828
+ if msg_chat_id not in authorized:
4783
4829
  _tg_api(token, "sendMessage", {
4784
4830
  "chat_id": msg_chat_id,
4785
4831
  "text": "⛔ Unauthorized."
4786
4832
  })
4787
4833
  continue
4788
4834
 
4835
+ # Track who is currently active so other code (permission
4836
+ # prompts, etc.) can reply to the right user.
4837
+ config["_active_tg_chat_id"] = msg_chat_id
4838
+ # Bind chat_id to the originating user so all downstream
4839
+ # references in this iteration (and closures spawned below)
4840
+ # send replies back to whoever messaged.
4841
+ chat_id = msg_chat_id
4842
+
4789
4843
  # ── Handle photo messages from Telegram ──
4790
4844
  photo_list = msg.get("photo")
4791
4845
  if photo_list:
@@ -5040,18 +5094,18 @@ def _run_daemon(config: dict) -> None:
5040
5094
 
5041
5095
  # Start Telegram bridge if previously configured
5042
5096
  token = config.get("telegram_token", "")
5043
- chat_id = config.get("telegram_chat_id", 0)
5044
- if token and chat_id:
5097
+ chat_ids = _tg_get_chat_ids(config)
5098
+ if token and chat_ids:
5045
5099
  global _telegram_stop, _telegram_thread
5046
5100
  _telegram_stop = threading.Event()
5047
5101
  _telegram_thread = threading.Thread(
5048
- target=_tg_poll_loop, args=(token, int(chat_id), config), daemon=True
5102
+ target=_tg_poll_loop, args=(token, chat_ids, config), daemon=True
5049
5103
  )
5050
5104
  _telegram_thread.start()
5051
- ok(f"Telegram bridge started → chat {chat_id}")
5105
+ ok(f"Telegram bridge started → chats: {', '.join(str(c) for c in chat_ids)}")
5052
5106
  else:
5053
5107
  warn("No Telegram config found. Bridge not started.")
5054
- info("Set it later with: /telegram <token> <chat_id>")
5108
+ info("Set it later with: /telegram <token> <chat_id>[,<chat_id>...]")
5055
5109
 
5056
5110
  info("Press Ctrl+C to stop.\n")
5057
5111
 
@@ -5113,34 +5167,37 @@ def cmd_telegram(args: str, _state, config) -> bool:
5113
5167
  if parts and parts[0].lower() == "status":
5114
5168
  running = _telegram_thread and _telegram_thread.is_alive()
5115
5169
  token = config.get("telegram_token", "")
5116
- chat_id = config.get("telegram_chat_id", 0)
5170
+ chat_ids = _tg_get_chat_ids(config)
5171
+ ids_str = ",".join(str(c) for c in chat_ids) if chat_ids else "(none)"
5117
5172
  if running:
5118
- ok(f"Telegram bridge is running. Chat ID: {chat_id}")
5173
+ ok(f"Telegram bridge is running. Chat IDs: {ids_str}")
5119
5174
  elif token:
5120
5175
  info(f"Configured but not running. Use /telegram to start.")
5121
5176
  else:
5122
- info("Not configured. Use /telegram <bot_token> <chat_id>")
5177
+ info("Not configured. Use /telegram <bot_token> <chat_id>[,<chat_id>...]")
5123
5178
  return True
5124
5179
 
5125
- # /telegram <token> <chat_id> — configure and start
5180
+ # /telegram <token> <chat_id>[,<chat_id>...] — configure and start
5126
5181
  if len(parts) >= 2:
5127
5182
  token = parts[0]
5128
- try:
5129
- chat_id = int(parts[1])
5130
- except ValueError:
5131
- err("Chat ID must be a number. Send a message to your bot, then check getUpdates.")
5183
+ chat_ids = _parse_chat_ids(parts[1])
5184
+ if not chat_ids:
5185
+ err("Chat ID must be a number (or comma-separated list, e.g. 12345,67890).")
5132
5186
  return True
5133
5187
  config["telegram_token"] = token
5134
- config["telegram_chat_id"] = chat_id
5188
+ # Persist as comma-separated string in the new key; clear the legacy
5189
+ # single-id key so the file stays clean.
5190
+ config["telegram_chat_ids"] = ",".join(str(c) for c in chat_ids)
5191
+ config.pop("telegram_chat_id", None)
5135
5192
  save_config(config)
5136
- ok("Telegram config saved.")
5193
+ ok(f"Telegram config saved. Authorized chats: {', '.join(str(c) for c in chat_ids)}")
5137
5194
  else:
5138
5195
  # Try to use saved config
5139
5196
  token = config.get("telegram_token", "")
5140
- chat_id = config.get("telegram_chat_id", 0)
5197
+ chat_ids = _tg_get_chat_ids(config)
5141
5198
 
5142
- if not token or not chat_id:
5143
- err("No config found. Usage: /telegram <bot_token> <chat_id>")
5199
+ if not token or not chat_ids:
5200
+ err("No config found. Usage: /telegram <bot_token> <chat_id>[,<chat_id>...]")
5144
5201
  return True
5145
5202
 
5146
5203
  # Already running?
@@ -5162,10 +5219,10 @@ def cmd_telegram(args: str, _state, config) -> bool:
5162
5219
 
5163
5220
  _telegram_stop = threading.Event()
5164
5221
  _telegram_thread = threading.Thread(
5165
- target=_tg_poll_loop, args=(token, chat_id, config), daemon=True
5222
+ target=_tg_poll_loop, args=(token, chat_ids, config), daemon=True
5166
5223
  )
5167
5224
  _telegram_thread.start()
5168
- ok(f"Telegram bridge active. Chat ID: {chat_id}")
5225
+ ok(f"Telegram bridge active. Chat IDs: {', '.join(str(c) for c in chat_ids)}")
5169
5226
  info("Send messages to your bot — they'll be processed here.")
5170
5227
  info("Stop with /telegram stop or send /stop in Telegram.")
5171
5228
  return True
@@ -6907,7 +6964,7 @@ def repl(config: dict, initial_prompt: str = None):
6907
6964
  active_flags.append("proactive")
6908
6965
  if config.get("lite_mode"):
6909
6966
  active_flags.append("lite")
6910
- if config.get("telegram_token") and config.get("telegram_chat_id"):
6967
+ if config.get("telegram_token") and _tg_get_chat_ids(config):
6911
6968
  active_flags.append("telegram")
6912
6969
  if active_flags:
6913
6970
  flags_str = " · ".join(clr(f, "green") for f in active_flags)
@@ -7330,7 +7387,10 @@ def repl(config: dict, initial_prompt: str = None):
7330
7387
  if is_background:
7331
7388
  is_tg_turn = config.get("_in_telegram_turn", False)
7332
7389
  ttok = config.get("telegram_token")
7333
- tchat = config.get("telegram_chat_id")
7390
+ # Background broadcasts go to whoever was last active in TG
7391
+ # (or the first configured chat as fallback).
7392
+ _tids = _tg_get_chat_ids(config)
7393
+ tchat = config.get("_active_tg_chat_id") or (_tids[0] if _tids else 0)
7334
7394
  # Check that Telegram is still active (_telegram_stop not set)
7335
7395
  if not is_tg_turn and ttok and tchat and _telegram_stop and not _telegram_stop.is_set():
7336
7396
  if state.messages and state.messages[-1].get("role") == "assistant":
@@ -7431,14 +7491,14 @@ def repl(config: dict, initial_prompt: str = None):
7431
7491
  config["_handle_slash_callback"] = _handle_slash_from_telegram
7432
7492
 
7433
7493
  # ── Auto-start Telegram bridge if configured ──────────────────────
7434
- if config.get("telegram_token") and config.get("telegram_chat_id"):
7494
+ if config.get("telegram_token") and _tg_get_chat_ids(config):
7435
7495
  global _telegram_thread, _telegram_stop
7436
7496
  if not (_telegram_thread and _telegram_thread.is_alive()):
7437
7497
  config["_state"] = state
7438
7498
  _telegram_stop = threading.Event()
7439
7499
  _telegram_thread = threading.Thread(
7440
7500
  target=_tg_poll_loop,
7441
- args=(config["telegram_token"], config["telegram_chat_id"], config),
7501
+ args=(config["telegram_token"], _tg_get_chat_ids(config), config),
7442
7502
  daemon=True
7443
7503
  )
7444
7504
  _telegram_thread.start()
@@ -2347,6 +2347,109 @@ def calc_cost(model: str, in_tok: int, out_tok: int) -> float:
2347
2347
  return (in_tok * ic + out_tok * oc) / 1_000_000
2348
2348
 
2349
2349
 
2350
+ # ── Native tool-call format interceptors ──────────────────────────────────
2351
+ # Some models (Gemma 3/4, Mistral, ...) emit their NATIVE tool-call format
2352
+ # inside `delta.content` even when the API has been told to use OpenAI-style
2353
+ # tool schemas. Without interception the user sees raw markers like
2354
+ # `<|tool_call>call:Foo{"x":1}<tool_call|>` streamed as text, and the
2355
+ # intended tool call never fires — and on Ollama Cloud / vLLM the broken
2356
+ # format can also trip a 502 from the upstream proxy. The helpers below let
2357
+ # stream_ollama / stream_openai_compat detect these markers, switch into
2358
+ # buffer mode, and parse the buffered tail into proper tool_calls.
2359
+ _NATIVE_TOOL_OPENERS = (
2360
+ "<|tool_call|>", # Gemma official
2361
+ "<|tool_call>", # Gemma 4 asymmetric variant seen in the wild
2362
+ "<tool_call>", # Hermes / Qwen
2363
+ "[TOOL_CALLS]", # Mistral
2364
+ )
2365
+
2366
+ _GEMMA_QUOTE_TOKEN_FIXES = (
2367
+ ("<|\"|>", '"'),
2368
+ ("<|'|>", "'"),
2369
+ )
2370
+
2371
+ _NATIVE_FMT_V2 = re.compile(
2372
+ r"<\|?tool_call\|?>\s*call:\s*(\w+)\s*(\{.*?\})\s*<\|?(?:end_)?(?:/)?tool_call\|?>",
2373
+ re.DOTALL,
2374
+ )
2375
+ _NATIVE_FMT_V1 = re.compile(
2376
+ r"<\|?tool_call\|?>\s*(\{.*?\})\s*<\|?(?:end_)?(?:/)?tool_call\|?>",
2377
+ re.DOTALL,
2378
+ )
2379
+ _NATIVE_FMT_MISTRAL = re.compile(r"\[TOOL_CALLS\]\s*(\[.*?\])", re.DOTALL)
2380
+
2381
+
2382
+ def _find_native_tool_marker(text: str) -> "int | None":
2383
+ earliest = None
2384
+ for opener in _NATIVE_TOOL_OPENERS:
2385
+ idx = text.find(opener)
2386
+ if idx != -1 and (earliest is None or idx < earliest):
2387
+ earliest = idx
2388
+ return earliest
2389
+
2390
+
2391
+ def _extract_native_tool_calls(buf: str) -> list:
2392
+ """Parse buffered native-format tool calls. Returns [] on any failure."""
2393
+ if not buf:
2394
+ return []
2395
+ for tok, repl in _GEMMA_QUOTE_TOKEN_FIXES:
2396
+ buf = buf.replace(tok, repl)
2397
+
2398
+ out: list = []
2399
+
2400
+ # Format 2 first (more specific — explicit `call:NAME` outside the JSON)
2401
+ for m in _NATIVE_FMT_V2.finditer(buf):
2402
+ name, body = m.group(1), m.group(2)
2403
+ try:
2404
+ args = json.loads(body)
2405
+ if not isinstance(args, dict):
2406
+ args = {"_raw": body}
2407
+ except json.JSONDecodeError:
2408
+ args = {"_raw": body}
2409
+ out.append({"id": f"native_call_{len(out)}", "name": name, "input": args})
2410
+
2411
+ # Format 1: JSON envelope with `name` + `arguments`
2412
+ if not out:
2413
+ for m in _NATIVE_FMT_V1.finditer(buf):
2414
+ try:
2415
+ parsed = json.loads(m.group(1))
2416
+ if isinstance(parsed, dict):
2417
+ name = parsed.get("name") or parsed.get("function") or ""
2418
+ args = parsed.get("arguments") or parsed.get("args") or {}
2419
+ if name:
2420
+ if not isinstance(args, dict):
2421
+ args = {"_raw": str(args)}
2422
+ out.append({
2423
+ "id": f"native_call_{len(out)}",
2424
+ "name": name, "input": args,
2425
+ })
2426
+ except json.JSONDecodeError:
2427
+ continue
2428
+
2429
+ # Mistral [TOOL_CALLS] [{...}, {...}]
2430
+ if not out:
2431
+ for m in _NATIVE_FMT_MISTRAL.finditer(buf):
2432
+ try:
2433
+ arr = json.loads(m.group(1))
2434
+ if isinstance(arr, list):
2435
+ for item in arr:
2436
+ if not isinstance(item, dict):
2437
+ continue
2438
+ name = item.get("name") or (item.get("function") or {}).get("name") or ""
2439
+ args = item.get("arguments") or (item.get("function") or {}).get("arguments") or {}
2440
+ if name:
2441
+ if not isinstance(args, dict):
2442
+ args = {"_raw": str(args)}
2443
+ out.append({
2444
+ "id": f"native_call_{len(out)}",
2445
+ "name": name, "input": args,
2446
+ })
2447
+ except json.JSONDecodeError:
2448
+ continue
2449
+
2450
+ return out
2451
+
2452
+
2350
2453
  def estimate_tokens_kimi(api_key: str, model: str, messages: list) -> int | None:
2351
2454
  """Estimate token count using Kimi's native API endpoint.
2352
2455
 
@@ -3548,7 +3651,17 @@ def stream_ollama(
3548
3651
  text = ""
3549
3652
  thinking = ""
3550
3653
  tool_buf: dict = {}
3551
-
3654
+
3655
+ # Native tool-call interceptor state. When the model emits its native
3656
+ # `<|tool_call|>...` envelope inside `content` (Gemma 3/4 in particular
3657
+ # do this even when given OpenAI-style tool schemas), we stop yielding
3658
+ # text and accumulate everything from the marker onward. At end-of-stream
3659
+ # we parse the buffer into proper tool_calls. Without this the user sees
3660
+ # `<|tool_call>call:Foo{...}<tool_call|>` as raw text, the tool never
3661
+ # fires, and on Ollama Cloud the malformed exchange can trip a 502.
3662
+ _native_buf = "" # text accumulated after a native marker
3663
+ _native_intercept = False # True once we've seen any native marker
3664
+
3552
3665
  # State for prompt-based tool call parsing across streamed chunks
3553
3666
  use_deep_tools = config.get("deep_tools", False) if config else False
3554
3667
  _auto_wrap_json = is_deepseek_r1 and use_deep_tools
@@ -3603,9 +3716,22 @@ def stream_ollama(
3603
3716
  if display:
3604
3717
  text += display
3605
3718
  yield TextChunk(display)
3719
+ elif _native_intercept:
3720
+ # Already inside a native tool-call envelope — buffer silently.
3721
+ _native_buf += content
3606
3722
  else:
3607
- text += content
3608
- yield TextChunk(content)
3723
+ marker = _find_native_tool_marker(content)
3724
+ if marker is not None:
3725
+ # Yield clean prefix, then start buffering from the marker.
3726
+ prefix = content[:marker]
3727
+ if prefix:
3728
+ text += prefix
3729
+ yield TextChunk(prefix)
3730
+ _native_buf += content[marker:]
3731
+ _native_intercept = True
3732
+ else:
3733
+ text += content
3734
+ yield TextChunk(content)
3609
3735
 
3610
3736
  # Handle native ollama tools format
3611
3737
  for tc in msg.get("tool_calls", []):
@@ -3632,7 +3758,18 @@ def stream_ollama(
3632
3758
  for idx in sorted(tool_buf):
3633
3759
  v = tool_buf[idx]
3634
3760
  tool_calls.append({"id": v["id"], "name": v["name"], "input": v["input"]})
3635
-
3761
+
3762
+ # Merge native-format tool calls intercepted from `content` (Gemma 3/4 etc.)
3763
+ if _native_intercept:
3764
+ intercepted = _extract_native_tool_calls(_native_buf)
3765
+ if intercepted:
3766
+ tool_calls.extend(intercepted)
3767
+ else:
3768
+ # Parser couldn't make sense of it — surface the raw buffer so the
3769
+ # user sees something instead of a silent stall.
3770
+ text += _native_buf
3771
+ yield TextChunk(_native_buf)
3772
+
3636
3773
  # Merge prompt-based tools from parser
3637
3774
  if _prompt_tool_mode:
3638
3775
  for tc in parser.tool_calls:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dulus"
7
- version = "0.2.12"
7
+ version = "0.2.14"
8
8
  description = "Spanish-first multi-provider AI CLI — 14 NVIDIA models free, Mesa Redonda, voice, TTS, RTK token reducer, MemPalace"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1746,7 +1746,8 @@ def ask_input_interactive(prompt: str, config: dict, menu_text: str = None) -> s
1746
1746
  is_tg = _is_in_tg_turn(config)
1747
1747
  if is_tg and "_tg_send_callback" in config:
1748
1748
  token = config.get("telegram_token")
1749
- chat_id = config.get("telegram_chat_id")
1749
+ # Reply to the user who triggered the current TG turn (multi-user support).
1750
+ chat_id = config.get("_active_tg_chat_id") or config.get("telegram_chat_id")
1750
1751
  import re, threading
1751
1752
  clean_prompt = re.sub(r'\x1b\[[0-9;]*m', '', prompt).strip()
1752
1753
 
@@ -1981,7 +1982,7 @@ def _print_to_console(content: str = "", style: str = "normal", prefix: str = ""
1981
1982
  # If in Telegram turn, also send to Telegram
1982
1983
  if config and _is_in_tg_turn(config):
1983
1984
  token = config.get("telegram_token")
1984
- chat_id = config.get("telegram_chat_id")
1985
+ chat_id = config.get("_active_tg_chat_id") or config.get("telegram_chat_id")
1985
1986
  if token and chat_id and "_tg_send_callback" in config:
1986
1987
  import re
1987
1988
  # Clean ANSI codes and send
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes