pythonclaw 0.2.0__py3-none-any.whl

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 (112) hide show
  1. pythonclaw/__init__.py +17 -0
  2. pythonclaw/__main__.py +6 -0
  3. pythonclaw/channels/discord_bot.py +231 -0
  4. pythonclaw/channels/telegram_bot.py +236 -0
  5. pythonclaw/config.py +190 -0
  6. pythonclaw/core/__init__.py +25 -0
  7. pythonclaw/core/agent.py +773 -0
  8. pythonclaw/core/compaction.py +220 -0
  9. pythonclaw/core/knowledge/rag.py +93 -0
  10. pythonclaw/core/llm/anthropic_client.py +107 -0
  11. pythonclaw/core/llm/base.py +26 -0
  12. pythonclaw/core/llm/gemini_client.py +139 -0
  13. pythonclaw/core/llm/openai_compatible.py +39 -0
  14. pythonclaw/core/llm/response.py +57 -0
  15. pythonclaw/core/memory/manager.py +120 -0
  16. pythonclaw/core/memory/storage.py +164 -0
  17. pythonclaw/core/persistent_agent.py +103 -0
  18. pythonclaw/core/retrieval/__init__.py +6 -0
  19. pythonclaw/core/retrieval/chunker.py +78 -0
  20. pythonclaw/core/retrieval/dense.py +152 -0
  21. pythonclaw/core/retrieval/fusion.py +51 -0
  22. pythonclaw/core/retrieval/reranker.py +112 -0
  23. pythonclaw/core/retrieval/retriever.py +166 -0
  24. pythonclaw/core/retrieval/sparse.py +69 -0
  25. pythonclaw/core/session_store.py +269 -0
  26. pythonclaw/core/skill_loader.py +322 -0
  27. pythonclaw/core/skillhub.py +290 -0
  28. pythonclaw/core/tools.py +622 -0
  29. pythonclaw/core/utils.py +64 -0
  30. pythonclaw/daemon.py +221 -0
  31. pythonclaw/init.py +61 -0
  32. pythonclaw/main.py +489 -0
  33. pythonclaw/onboard.py +290 -0
  34. pythonclaw/scheduler/cron.py +310 -0
  35. pythonclaw/scheduler/heartbeat.py +178 -0
  36. pythonclaw/server.py +145 -0
  37. pythonclaw/session_manager.py +104 -0
  38. pythonclaw/templates/persona/demo_persona.md +2 -0
  39. pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
  40. pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
  41. pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
  42. pythonclaw/templates/skills/communication/email/send_email.py +88 -0
  43. pythonclaw/templates/skills/data/CATEGORY.md +4 -0
  44. pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
  45. pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
  46. pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
  47. pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
  48. pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
  49. pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
  50. pythonclaw/templates/skills/data/news/SKILL.md +39 -0
  51. pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
  52. pythonclaw/templates/skills/data/news/search_news.py +57 -0
  53. pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
  54. pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
  55. pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
  56. pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
  57. pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
  58. pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
  59. pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
  60. pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
  61. pythonclaw/templates/skills/data/weather/weather.py +142 -0
  62. pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
  63. pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
  64. pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
  65. pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
  66. pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
  67. pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
  68. pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
  69. pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
  70. pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
  71. pythonclaw/templates/skills/dev/github/gh.py +165 -0
  72. pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
  73. pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
  74. pythonclaw/templates/skills/dev/http_request/request.py +90 -0
  75. pythonclaw/templates/skills/google/CATEGORY.md +4 -0
  76. pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
  77. pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
  78. pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
  79. pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
  80. pythonclaw/templates/skills/system/CATEGORY.md +4 -0
  81. pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
  82. pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
  83. pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
  84. pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
  85. pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
  86. pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
  87. pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
  88. pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
  89. pythonclaw/templates/skills/system/random/SKILL.md +33 -0
  90. pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
  91. pythonclaw/templates/skills/system/random/random_util.py +45 -0
  92. pythonclaw/templates/skills/system/time/SKILL.md +33 -0
  93. pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
  94. pythonclaw/templates/skills/system/time/time_util.py +81 -0
  95. pythonclaw/templates/skills/text/CATEGORY.md +4 -0
  96. pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
  97. pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
  98. pythonclaw/templates/skills/text/translator/translate.py +66 -0
  99. pythonclaw/templates/skills/web/CATEGORY.md +4 -0
  100. pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
  101. pythonclaw/templates/soul/SOUL.md +54 -0
  102. pythonclaw/web/__init__.py +1 -0
  103. pythonclaw/web/app.py +585 -0
  104. pythonclaw/web/static/favicon.png +0 -0
  105. pythonclaw/web/static/index.html +1318 -0
  106. pythonclaw/web/static/logo.png +0 -0
  107. pythonclaw-0.2.0.dist-info/METADATA +410 -0
  108. pythonclaw-0.2.0.dist-info/RECORD +112 -0
  109. pythonclaw-0.2.0.dist-info/WHEEL +5 -0
  110. pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
  111. pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
  112. pythonclaw-0.2.0.dist-info/top_level.txt +1 -0
pythonclaw/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """PythonClaw — Open-source autonomous AI agent framework."""
2
+
3
+ from . import config
4
+ from .core.agent import Agent
5
+ from .core.llm.base import LLMProvider
6
+ from .core.llm.openai_compatible import OpenAICompatibleProvider
7
+ from .init import init
8
+
9
+ __version__ = "0.2.0"
10
+ __all__ = [
11
+ "Agent",
12
+ "LLMProvider",
13
+ "OpenAICompatibleProvider",
14
+ "config",
15
+ "init",
16
+ "__version__",
17
+ ]
pythonclaw/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running PythonClaw as ``python -m pythonclaw``."""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,231 @@
1
+ """
2
+ Discord channel for PythonClaw.
3
+
4
+ Session IDs: "discord:{user_id}" (DMs) or "discord:{channel_id}" (guilds)
5
+
6
+ Commands
7
+ --------
8
+ !reset — discard and recreate the current session
9
+ !status — show session info
10
+ !compact [hint] — compact conversation history
11
+ <text> — forwarded to Agent.chat(), reply sent back
12
+
13
+ The bot responds to:
14
+ - Direct messages (always)
15
+ - Channel mentions (@bot message) in guilds
16
+ - Optionally all messages in whitelisted channels
17
+
18
+ Access control
19
+ --------------
20
+ Set DISCORD_ALLOWED_USERS to a comma-separated list of Discord user IDs.
21
+ Set DISCORD_ALLOWED_CHANNELS to restrict which guild channels the bot listens in.
22
+ Leave empty to allow all.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from typing import TYPE_CHECKING
29
+
30
+ import discord
31
+
32
+ from .. import config
33
+
34
+ if TYPE_CHECKING:
35
+ from ..session_manager import SessionManager
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ MAX_MSG_LEN = 2000 # Discord message character limit
40
+
41
+
42
+ class DiscordBot:
43
+ """
44
+ Discord channel — pure I/O layer.
45
+
46
+ Routes messages to the appropriate Agent via the shared SessionManager.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ session_manager: "SessionManager",
52
+ token: str,
53
+ allowed_users: list[int] | None = None,
54
+ allowed_channels: list[int] | None = None,
55
+ ) -> None:
56
+ self._sm = session_manager
57
+ self._token = token
58
+ self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
59
+ self._allowed_channels: set[int] = set(allowed_channels) if allowed_channels else set()
60
+
61
+ intents = discord.Intents.default()
62
+ intents.message_content = True
63
+ self._client = discord.Client(intents=intents)
64
+ self._setup_handlers()
65
+
66
+ # ── Session ID convention ─────────────────────────────────────────────────
67
+
68
+ @staticmethod
69
+ def _session_id(source_id: int, is_dm: bool = False) -> str:
70
+ prefix = "discord:dm" if is_dm else "discord"
71
+ return f"{prefix}:{source_id}"
72
+
73
+ # ── Access control ────────────────────────────────────────────────────────
74
+
75
+ def _is_allowed_user(self, user_id: int) -> bool:
76
+ if not self._allowed_users:
77
+ return True
78
+ return user_id in self._allowed_users
79
+
80
+ def _is_allowed_channel(self, channel_id: int) -> bool:
81
+ if not self._allowed_channels:
82
+ return True
83
+ return channel_id in self._allowed_channels
84
+
85
+ # ── Message splitting ─────────────────────────────────────────────────────
86
+
87
+ @staticmethod
88
+ def _split_message(text: str, limit: int = MAX_MSG_LEN) -> list[str]:
89
+ if len(text) <= limit:
90
+ return [text]
91
+ chunks = []
92
+ while text:
93
+ chunks.append(text[:limit])
94
+ text = text[limit:]
95
+ return chunks
96
+
97
+ # ── Handlers ──────────────────────────────────────────────────────────────
98
+
99
+ def _setup_handlers(self) -> None:
100
+ client = self._client
101
+
102
+ @client.event
103
+ async def on_ready():
104
+ logger.info("[Discord] Logged in as %s (id=%s)", client.user.name, client.user.id)
105
+
106
+ @client.event
107
+ async def on_message(message: discord.Message):
108
+ if message.author == client.user:
109
+ return
110
+ if message.author.bot:
111
+ return
112
+
113
+ is_dm = isinstance(message.channel, discord.DMChannel)
114
+ is_mentioned = client.user in message.mentions if not is_dm else False
115
+
116
+ # In guilds, only respond to mentions or whitelisted channels
117
+ if not is_dm and not is_mentioned and not self._is_allowed_channel(message.channel.id):
118
+ return
119
+
120
+ if not self._is_allowed_user(message.author.id):
121
+ await message.reply("Sorry, you are not authorised to use this bot.")
122
+ return
123
+
124
+ content = message.content.strip()
125
+ # Remove bot mention from the beginning
126
+ if is_mentioned and client.user:
127
+ content = content.replace(f"<@{client.user.id}>", "").strip()
128
+
129
+ if not content:
130
+ return
131
+
132
+ # Command dispatch
133
+ if content.startswith("!reset"):
134
+ await self._cmd_reset(message, is_dm)
135
+ return
136
+ if content.startswith("!status"):
137
+ await self._cmd_status(message, is_dm)
138
+ return
139
+ if content.startswith("!compact"):
140
+ hint = content[len("!compact"):].strip() or None
141
+ await self._cmd_compact(message, is_dm, hint)
142
+ return
143
+
144
+ await self._handle_chat(message, content, is_dm)
145
+
146
+ # ── Command implementations ───────────────────────────────────────────────
147
+
148
+ async def _cmd_reset(self, message: discord.Message, is_dm: bool) -> None:
149
+ sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
150
+ self._sm.reset(sid)
151
+ await message.reply("Session reset. Starting fresh!")
152
+
153
+ async def _cmd_status(self, message: discord.Message, is_dm: bool) -> None:
154
+ sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
155
+ agent = self._sm.get_or_create(sid)
156
+ from ..core.compaction import estimate_tokens
157
+ status = (
158
+ f"**Session Status**\n"
159
+ f"```\n"
160
+ f"Session ID : {sid}\n"
161
+ f"Provider : {type(agent.provider).__name__}\n"
162
+ f"Skills : {len(agent.loaded_skill_names)} loaded\n"
163
+ f"Memories : {len(agent.memory.list_all())} entries\n"
164
+ f"History : {len(agent.messages)} messages\n"
165
+ f"Est. tokens : ~{estimate_tokens(agent.messages)}\n"
166
+ f"Compactions : {agent.compaction_count}\n"
167
+ f"```"
168
+ )
169
+ await message.reply(status)
170
+
171
+ async def _cmd_compact(self, message: discord.Message, is_dm: bool, hint: str | None) -> None:
172
+ sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
173
+ agent = self._sm.get_or_create(sid)
174
+ await message.reply("Compacting conversation history...")
175
+ try:
176
+ result = agent.compact(instruction=hint)
177
+ except Exception as exc:
178
+ logger.exception("[Discord] compact() raised an exception")
179
+ result = f"Compaction failed: {exc}"
180
+ for chunk in self._split_message(result or "(no result)"):
181
+ await message.reply(chunk)
182
+
183
+ async def _handle_chat(self, message: discord.Message, content: str, is_dm: bool) -> None:
184
+ sid = self._session_id(message.author.id if is_dm else message.channel.id, is_dm)
185
+ agent = self._sm.get_or_create(sid)
186
+ async with message.channel.typing():
187
+ try:
188
+ response = agent.chat(content)
189
+ except Exception as exc:
190
+ logger.exception("[Discord] Agent.chat() raised an exception")
191
+ response = f"Sorry, something went wrong: {exc}"
192
+ for chunk in self._split_message(response or "(no response)"):
193
+ await message.reply(chunk)
194
+
195
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
196
+
197
+ async def start_async(self) -> None:
198
+ """Non-blocking start — for use inside an existing asyncio event loop."""
199
+ logger.info("[Discord] Starting bot (async mode)...")
200
+ await self._client.start(self._token)
201
+
202
+ async def stop_async(self) -> None:
203
+ logger.info("[Discord] Stopping bot...")
204
+ await self._client.close()
205
+
206
+
207
+ # ── Utility ───────────────────────────────────────────────────────────────────
208
+
209
+ def create_bot(session_manager: "SessionManager") -> "DiscordBot":
210
+ """Create a DiscordBot from pythonclaw.json / env vars."""
211
+ token = config.get_str(
212
+ "channels", "discord", "token", env="DISCORD_BOT_TOKEN",
213
+ )
214
+ if not token:
215
+ raise ValueError("Discord token not set (env DISCORD_BOT_TOKEN or channels.discord.token)")
216
+ allowed_users = config.get_int_list(
217
+ "channels", "discord", "allowedUsers", env="DISCORD_ALLOWED_USERS",
218
+ )
219
+ allowed_channels = config.get_int_list(
220
+ "channels", "discord", "allowedChannels", env="DISCORD_ALLOWED_CHANNELS",
221
+ )
222
+ return DiscordBot(
223
+ session_manager=session_manager,
224
+ token=token,
225
+ allowed_users=allowed_users or None,
226
+ allowed_channels=allowed_channels or None,
227
+ )
228
+
229
+
230
+ # Backward-compatible alias
231
+ create_bot_from_env = create_bot
@@ -0,0 +1,236 @@
1
+ """
2
+ Telegram channel for pythonclaw.
3
+
4
+ Telegram is purely a *channel* — it handles sending and receiving messages.
5
+ Session lifecycle (which Agent handles which chat) is delegated to the
6
+ SessionManager, which is shared across all channels and the cron scheduler.
7
+
8
+ Session IDs used by this channel: "telegram:{chat_id}"
9
+
10
+ Commands
11
+ --------
12
+ /start — greeting + usage hint
13
+ /reset — discard and recreate the current session
14
+ /status — show session info (provider, skills, memory, tokens, compactions)
15
+ /compact [hint] — compact conversation history
16
+ <text> — forwarded to Agent.chat(), reply sent back
17
+
18
+ Access control
19
+ --------------
20
+ Set TELEGRAM_ALLOWED_USERS to a comma-separated list of integer Telegram user
21
+ IDs to restrict access. Leave empty (or unset) to allow all users.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ from typing import TYPE_CHECKING
28
+
29
+ from telegram import Update
30
+ from telegram.ext import (
31
+ Application,
32
+ CommandHandler,
33
+ ContextTypes,
34
+ MessageHandler,
35
+ filters,
36
+ )
37
+
38
+ from .. import config
39
+
40
+ if TYPE_CHECKING:
41
+ from ..session_manager import SessionManager
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class TelegramBot:
47
+ """
48
+ Telegram channel — pure I/O layer.
49
+
50
+ Receives messages from Telegram and routes them to the appropriate Agent
51
+ via the shared SessionManager. Does not own or manage Agent instances.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ session_manager: "SessionManager",
57
+ token: str,
58
+ allowed_users: list[int] | None = None,
59
+ ) -> None:
60
+ self._sm = session_manager
61
+ self._token = token
62
+ self._allowed_users: set[int] = set(allowed_users) if allowed_users else set()
63
+ self._app: Application | None = None
64
+
65
+ # ── Session ID convention ─────────────────────────────────────────────────
66
+
67
+ @staticmethod
68
+ def _session_id(chat_id: int) -> str:
69
+ return f"telegram:{chat_id}"
70
+
71
+ # ── Push message (called by cron / heartbeat) ─────────────────────────────
72
+
73
+ async def send_message(self, chat_id: int, text: str) -> None:
74
+ """Send a message to a specific chat (used by cron/heartbeat)."""
75
+ if self._app is None:
76
+ logger.warning("[Telegram] send_message called before bot is running")
77
+ return
78
+ await self._app.bot.send_message(chat_id=chat_id, text=text)
79
+
80
+ # ── Access control ────────────────────────────────────────────────────────
81
+
82
+ def _is_allowed(self, user_id: int) -> bool:
83
+ if not self._allowed_users:
84
+ return True
85
+ return user_id in self._allowed_users
86
+
87
+ async def _check_access(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
88
+ user = update.effective_user
89
+ if user is None or not self._is_allowed(user.id):
90
+ logger.warning("[Telegram] Rejected user_id=%s", user.id if user else "unknown")
91
+ await update.message.reply_text("Sorry, you are not authorised to use this bot.")
92
+ return False
93
+ return True
94
+
95
+ # ── Command handlers ──────────────────────────────────────────────────────
96
+
97
+ async def _cmd_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
98
+ if not await self._check_access(update, context):
99
+ return
100
+ sid = self._session_id(update.effective_chat.id)
101
+ self._sm.get_or_create(sid)
102
+ await update.message.reply_text(
103
+ "👋 Hi! I'm your PythonClaw agent.\n\n"
104
+ "Just send me a message and I'll do my best to help.\n\n"
105
+ "Commands:\n"
106
+ " /start — show this message\n"
107
+ " /reset — start a fresh session\n"
108
+ " /status — show session info\n"
109
+ " /compact [hint] — compact conversation history"
110
+ )
111
+
112
+ async def _cmd_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
113
+ if not await self._check_access(update, context):
114
+ return
115
+ sid = self._session_id(update.effective_chat.id)
116
+ self._sm.reset(sid)
117
+ await update.message.reply_text("Session reset. Starting fresh! Send me a message.")
118
+
119
+ async def _cmd_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
120
+ if not await self._check_access(update, context):
121
+ return
122
+ sid = self._session_id(update.effective_chat.id)
123
+ agent = self._sm.get_or_create(sid)
124
+ from ..core.compaction import estimate_tokens
125
+ await update.message.reply_text(
126
+ f"📊 Session Status\n"
127
+ f" Session ID : {sid}\n"
128
+ f" Provider : {type(agent.provider).__name__}\n"
129
+ f" Skills : {len(agent.loaded_skill_names)} loaded\n"
130
+ f" Memories : {len(agent.memory.list_all())} entries\n"
131
+ f" History : {len(agent.messages)} messages\n"
132
+ f" Est. tokens : ~{estimate_tokens(agent.messages):,}\n"
133
+ f" Compactions : {agent.compaction_count}\n"
134
+ f" Total sessions: {len(self._sm)}"
135
+ )
136
+
137
+ async def _cmd_compact(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
138
+ if not await self._check_access(update, context):
139
+ return
140
+ sid = self._session_id(update.effective_chat.id)
141
+ agent = self._sm.get_or_create(sid)
142
+ hint: str | None = " ".join(context.args).strip() or None if context.args else None
143
+ await update.message.reply_text("⏳ Compacting conversation history...")
144
+ try:
145
+ result = agent.compact(instruction=hint)
146
+ except Exception as exc:
147
+ result = f"Compaction failed: {exc}"
148
+ for chunk in _split_message(result):
149
+ await update.message.reply_text(chunk)
150
+
151
+ # ── Message handler ───────────────────────────────────────────────────────
152
+
153
+ async def _handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
154
+ if not await self._check_access(update, context):
155
+ return
156
+ user_text = (update.message.text or "").strip()
157
+ if not user_text:
158
+ return
159
+ sid = self._session_id(update.effective_chat.id)
160
+ agent = self._sm.get_or_create(sid)
161
+ await update.message.chat.send_action("typing")
162
+ try:
163
+ response = agent.chat(user_text)
164
+ except Exception as exc:
165
+ logger.exception("[Telegram] Agent.chat() raised an exception")
166
+ response = f"Sorry, something went wrong: {exc}"
167
+ for chunk in _split_message(response or "(no response)"):
168
+ await update.message.reply_text(chunk)
169
+
170
+ # ── Lifecycle ─────────────────────────────────────────────────────────────
171
+
172
+ def build_application(self) -> Application:
173
+ app = Application.builder().token(self._token).build()
174
+ app.add_handler(CommandHandler("start", self._cmd_start))
175
+ app.add_handler(CommandHandler("reset", self._cmd_reset))
176
+ app.add_handler(CommandHandler("status", self._cmd_status))
177
+ app.add_handler(CommandHandler("compact", self._cmd_compact))
178
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self._handle_message))
179
+ self._app = app
180
+ return app
181
+
182
+ def run_polling(self) -> None:
183
+ """Blocking call — starts the bot using long polling (for standalone use)."""
184
+ app = self.build_application()
185
+ logger.info("[Telegram] Starting bot (polling mode)...")
186
+ app.run_polling(drop_pending_updates=True)
187
+
188
+ async def start_async(self) -> None:
189
+ """Non-blocking start — for use inside an existing asyncio event loop."""
190
+ app = self.build_application()
191
+ logger.info("[Telegram] Initialising bot (async mode)...")
192
+ await app.initialize()
193
+ await app.start()
194
+ await app.updater.start_polling(drop_pending_updates=True)
195
+
196
+ async def stop_async(self) -> None:
197
+ if self._app is None:
198
+ return
199
+ logger.info("[Telegram] Stopping bot...")
200
+ await self._app.updater.stop()
201
+ await self._app.stop()
202
+ await self._app.shutdown()
203
+
204
+
205
+ # ── Utility ───────────────────────────────────────────────────────────────────
206
+
207
+ def _split_message(text: str, limit: int = 4096) -> list[str]:
208
+ """Split a long string into chunks that fit within Telegram's message limit."""
209
+ if len(text) <= limit:
210
+ return [text]
211
+ chunks = []
212
+ while text:
213
+ chunks.append(text[:limit])
214
+ text = text[limit:]
215
+ return chunks
216
+
217
+
218
+ def create_bot(session_manager: "SessionManager") -> TelegramBot:
219
+ """Create a TelegramBot from pythonclaw.json / env vars."""
220
+ token = config.get_str(
221
+ "channels", "telegram", "token", env="TELEGRAM_BOT_TOKEN",
222
+ )
223
+ if not token:
224
+ raise ValueError("Telegram token not set (env TELEGRAM_BOT_TOKEN or channels.telegram.token)")
225
+ allowed_users = config.get_int_list(
226
+ "channels", "telegram", "allowedUsers", env="TELEGRAM_ALLOWED_USERS",
227
+ )
228
+ return TelegramBot(
229
+ session_manager=session_manager,
230
+ token=token,
231
+ allowed_users=allowed_users or None,
232
+ )
233
+
234
+
235
+ # Backward-compatible alias
236
+ create_bot_from_env = create_bot
pythonclaw/config.py ADDED
@@ -0,0 +1,190 @@
1
+ """
2
+ Centralised configuration for PythonClaw.
3
+
4
+ Load order (later sources override earlier ones):
5
+ 1. pythonclaw.json in current working directory
6
+ 2. ~/.pythonclaw/pythonclaw.json
7
+ 3. Environment variables (highest priority)
8
+
9
+ The JSON file supports // line comments and trailing commas for convenience
10
+ (a subset of JSON5 that covers the most common needs).
11
+
12
+ Usage
13
+ -----
14
+ from pythonclaw import config
15
+
16
+ config.load() # call once at startup
17
+ provider = config.get("llm", "provider", env="LLM_PROVIDER", default="deepseek")
18
+ token = config.get("channels", "telegram", "token", env="TELEGRAM_BOT_TOKEN")
19
+ users = config.get_int_list("channels", "telegram", "allowedUsers",
20
+ env="TELEGRAM_ALLOWED_USERS")
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ _TRAILING_COMMA_RE = re.compile(r",\s*([}\]])")
32
+
33
+ _config: dict | None = None
34
+ _config_path: Path | None = None
35
+
36
+
37
+ def _strip_json5(text: str) -> str:
38
+ """Strip // comments and trailing commas so standard json.loads works.
39
+
40
+ Handles // inside quoted strings correctly (they are preserved).
41
+ """
42
+ result: list[str] = []
43
+ i = 0
44
+ n = len(text)
45
+ while i < n:
46
+ ch = text[i]
47
+ if ch == '"':
48
+ # Consume the entire string literal (including escaped chars)
49
+ j = i + 1
50
+ while j < n:
51
+ if text[j] == '\\':
52
+ j += 2
53
+ elif text[j] == '"':
54
+ j += 1
55
+ break
56
+ else:
57
+ j += 1
58
+ result.append(text[i:j])
59
+ i = j
60
+ elif ch == '/' and i + 1 < n and text[i + 1] == '/':
61
+ # Line comment — skip until end of line
62
+ while i < n and text[i] != '\n':
63
+ i += 1
64
+ else:
65
+ result.append(ch)
66
+ i += 1
67
+ text = "".join(result)
68
+ text = _TRAILING_COMMA_RE.sub(r"\1", text)
69
+ return text
70
+
71
+
72
+ def _find_config_file() -> Path | None:
73
+ candidates = [
74
+ Path.cwd() / "pythonclaw.json",
75
+ Path.home() / ".pythonclaw" / "pythonclaw.json",
76
+ ]
77
+ for p in candidates:
78
+ if p.is_file():
79
+ return p
80
+ return None
81
+
82
+
83
+ def _deep_get(data: dict, *keys: str, default: Any = None) -> Any:
84
+ current = data
85
+ for key in keys:
86
+ if not isinstance(current, dict):
87
+ return default
88
+ current = current.get(key)
89
+ if current is None:
90
+ return default
91
+ return current
92
+
93
+
94
+ def load(path: str | Path | None = None, *, force: bool = False) -> dict:
95
+ """Load and cache configuration. Safe to call multiple times.
96
+
97
+ Parameters
98
+ ----------
99
+ path : explicit path to a JSON config file (overrides auto-discovery)
100
+ force : if True, reload even if already cached
101
+ """
102
+ global _config, _config_path
103
+
104
+ if _config is not None and not force:
105
+ return _config
106
+
107
+ config_path = Path(path) if path else _find_config_file()
108
+ _config_path = config_path
109
+ raw: dict = {}
110
+
111
+ if config_path and config_path.is_file():
112
+ text = config_path.read_text(encoding="utf-8")
113
+ text = _strip_json5(text)
114
+ raw = json.loads(text)
115
+
116
+ _config = raw
117
+ return _config
118
+
119
+
120
+ def get(*keys: str, env: str | None = None, default: Any = None) -> Any:
121
+ """Get a config value. Env var takes priority over JSON.
122
+
123
+ Examples
124
+ --------
125
+ config.get("llm", "provider", env="LLM_PROVIDER", default="deepseek")
126
+ config.get("channels", "telegram", "token", env="TELEGRAM_BOT_TOKEN")
127
+ """
128
+ if _config is None:
129
+ load()
130
+
131
+ if env:
132
+ env_val = os.environ.get(env)
133
+ if env_val is not None:
134
+ return env_val
135
+
136
+ val = _deep_get(_config, *keys, default=default)
137
+ return val
138
+
139
+
140
+ def get_int(*keys: str, env: str | None = None, default: int = 0) -> int:
141
+ """Get an integer config value."""
142
+ val = get(*keys, env=env, default=default)
143
+ return int(val) if val is not None else default
144
+
145
+
146
+ def get_str(*keys: str, env: str | None = None, default: str = "") -> str:
147
+ """Get a string config value."""
148
+ val = get(*keys, env=env, default=default)
149
+ return str(val) if val is not None else default
150
+
151
+
152
+ def get_list(*keys: str, env: str | None = None, default: list | None = None) -> list:
153
+ """Get a list value. Env var is parsed as comma-separated."""
154
+ if _config is None:
155
+ load()
156
+
157
+ if env:
158
+ env_val = os.environ.get(env)
159
+ if env_val is not None and env_val.strip():
160
+ return [v.strip() for v in env_val.split(",") if v.strip()]
161
+
162
+ val = _deep_get(_config, *keys)
163
+ if isinstance(val, list):
164
+ return val
165
+ return default or []
166
+
167
+
168
+ def get_int_list(*keys: str, env: str | None = None) -> list[int]:
169
+ """Get a list of integers. Env var is parsed as comma-separated ints."""
170
+ raw = get_list(*keys, env=env)
171
+ return [int(v) for v in raw] if raw else []
172
+
173
+
174
+ def config_path() -> Path | None:
175
+ """Return the path to the loaded config file, or None."""
176
+ return _config_path
177
+
178
+
179
+ def as_dict() -> dict:
180
+ """Return a copy of the full loaded config dict."""
181
+ if _config is None:
182
+ load()
183
+ return dict(_config)
184
+
185
+
186
+ def reset() -> None:
187
+ """Clear the cached config (mainly for testing)."""
188
+ global _config, _config_path
189
+ _config = None
190
+ _config_path = None