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.
- pythonclaw/__init__.py +17 -0
- pythonclaw/__main__.py +6 -0
- pythonclaw/channels/discord_bot.py +231 -0
- pythonclaw/channels/telegram_bot.py +236 -0
- pythonclaw/config.py +190 -0
- pythonclaw/core/__init__.py +25 -0
- pythonclaw/core/agent.py +773 -0
- pythonclaw/core/compaction.py +220 -0
- pythonclaw/core/knowledge/rag.py +93 -0
- pythonclaw/core/llm/anthropic_client.py +107 -0
- pythonclaw/core/llm/base.py +26 -0
- pythonclaw/core/llm/gemini_client.py +139 -0
- pythonclaw/core/llm/openai_compatible.py +39 -0
- pythonclaw/core/llm/response.py +57 -0
- pythonclaw/core/memory/manager.py +120 -0
- pythonclaw/core/memory/storage.py +164 -0
- pythonclaw/core/persistent_agent.py +103 -0
- pythonclaw/core/retrieval/__init__.py +6 -0
- pythonclaw/core/retrieval/chunker.py +78 -0
- pythonclaw/core/retrieval/dense.py +152 -0
- pythonclaw/core/retrieval/fusion.py +51 -0
- pythonclaw/core/retrieval/reranker.py +112 -0
- pythonclaw/core/retrieval/retriever.py +166 -0
- pythonclaw/core/retrieval/sparse.py +69 -0
- pythonclaw/core/session_store.py +269 -0
- pythonclaw/core/skill_loader.py +322 -0
- pythonclaw/core/skillhub.py +290 -0
- pythonclaw/core/tools.py +622 -0
- pythonclaw/core/utils.py +64 -0
- pythonclaw/daemon.py +221 -0
- pythonclaw/init.py +61 -0
- pythonclaw/main.py +489 -0
- pythonclaw/onboard.py +290 -0
- pythonclaw/scheduler/cron.py +310 -0
- pythonclaw/scheduler/heartbeat.py +178 -0
- pythonclaw/server.py +145 -0
- pythonclaw/session_manager.py +104 -0
- pythonclaw/templates/persona/demo_persona.md +2 -0
- pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
- pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
- pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/communication/email/send_email.py +88 -0
- pythonclaw/templates/skills/data/CATEGORY.md +4 -0
- pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
- pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
- pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
- pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
- pythonclaw/templates/skills/data/news/SKILL.md +39 -0
- pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/news/search_news.py +57 -0
- pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
- pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
- pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
- pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
- pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
- pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/weather/weather.py +142 -0
- pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
- pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
- pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
- pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
- pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
- pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
- pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/github/gh.py +165 -0
- pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
- pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/http_request/request.py +90 -0
- pythonclaw/templates/skills/google/CATEGORY.md +4 -0
- pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
- pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
- pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
- pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
- pythonclaw/templates/skills/system/CATEGORY.md +4 -0
- pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
- pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
- pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
- pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
- pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
- pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
- pythonclaw/templates/skills/system/random/SKILL.md +33 -0
- pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/random/random_util.py +45 -0
- pythonclaw/templates/skills/system/time/SKILL.md +33 -0
- pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/time/time_util.py +81 -0
- pythonclaw/templates/skills/text/CATEGORY.md +4 -0
- pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
- pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/text/translator/translate.py +66 -0
- pythonclaw/templates/skills/web/CATEGORY.md +4 -0
- pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
- pythonclaw/templates/soul/SOUL.md +54 -0
- pythonclaw/web/__init__.py +1 -0
- pythonclaw/web/app.py +585 -0
- pythonclaw/web/static/favicon.png +0 -0
- pythonclaw/web/static/index.html +1318 -0
- pythonclaw/web/static/logo.png +0 -0
- pythonclaw-0.2.0.dist-info/METADATA +410 -0
- pythonclaw-0.2.0.dist-info/RECORD +112 -0
- pythonclaw-0.2.0.dist-info/WHEEL +5 -0
- pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
- pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
- pythonclaw-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heartbeat monitor for pythonclaw.
|
|
3
|
+
|
|
4
|
+
Periodically sends a minimal probe to the configured LLM provider to verify
|
|
5
|
+
that it is reachable and responding. Results are:
|
|
6
|
+
|
|
7
|
+
* Logged to context/logs/heartbeat.log (rotating, max 1 MB x 3 files)
|
|
8
|
+
* Printed to stdout at DEBUG level
|
|
9
|
+
* Optionally sent as a Telegram alert when the provider becomes unreachable
|
|
10
|
+
(one alert per outage, not once per failed ping)
|
|
11
|
+
|
|
12
|
+
Configuration (pythonclaw.json or env vars)
|
|
13
|
+
-------------------------------------------
|
|
14
|
+
heartbeat.intervalSec / HEARTBEAT_INTERVAL_SEC — seconds between probes (default: 60)
|
|
15
|
+
heartbeat.alertChatId / HEARTBEAT_ALERT_CHAT_ID — Telegram chat_id to receive failure alerts
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import logging.handlers
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
from .. import config
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from ..channels.telegram_bot import TelegramBot
|
|
31
|
+
from ..core.llm.base import LLMProvider
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEFAULT_INTERVAL = 60
|
|
36
|
+
LOG_DIR = os.path.join("context", "logs")
|
|
37
|
+
LOG_FILE = os.path.join(LOG_DIR, "heartbeat.log")
|
|
38
|
+
|
|
39
|
+
# Minimal probe message sent to the LLM
|
|
40
|
+
_PROBE_MESSAGES = [{"role": "user", "content": "ping"}]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HeartbeatMonitor:
|
|
44
|
+
"""Async heartbeat that pings the LLM provider on a fixed interval."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
provider: "LLMProvider",
|
|
49
|
+
interval_sec: int = DEFAULT_INTERVAL,
|
|
50
|
+
telegram_bot: "TelegramBot | None" = None,
|
|
51
|
+
alert_chat_id: int | None = None,
|
|
52
|
+
log_path: str = LOG_FILE,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._provider = provider
|
|
55
|
+
self._interval = interval_sec
|
|
56
|
+
self._telegram_bot = telegram_bot
|
|
57
|
+
self._alert_chat_id = alert_chat_id
|
|
58
|
+
self._log_path = log_path
|
|
59
|
+
self._running = False
|
|
60
|
+
self._task: asyncio.Task | None = None
|
|
61
|
+
self._last_ok: bool | None = None # track state to avoid alert storms
|
|
62
|
+
self._file_logger = _build_file_logger(log_path)
|
|
63
|
+
|
|
64
|
+
# ── Public API ───────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
async def start(self) -> None:
|
|
67
|
+
"""Start the heartbeat loop as a background asyncio task."""
|
|
68
|
+
self._running = True
|
|
69
|
+
self._task = asyncio.create_task(self._loop(), name="heartbeat")
|
|
70
|
+
logger.info("[Heartbeat] Monitor started (interval=%ds)", self._interval)
|
|
71
|
+
|
|
72
|
+
async def stop(self) -> None:
|
|
73
|
+
self._running = False
|
|
74
|
+
if self._task and not self._task.done():
|
|
75
|
+
self._task.cancel()
|
|
76
|
+
try:
|
|
77
|
+
await self._task
|
|
78
|
+
except asyncio.CancelledError:
|
|
79
|
+
pass
|
|
80
|
+
logger.info("[Heartbeat] Monitor stopped.")
|
|
81
|
+
|
|
82
|
+
# ── Internal loop ────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async def _loop(self) -> None:
|
|
85
|
+
while self._running:
|
|
86
|
+
await self._probe()
|
|
87
|
+
await asyncio.sleep(self._interval)
|
|
88
|
+
|
|
89
|
+
async def _probe(self) -> None:
|
|
90
|
+
start = time.monotonic()
|
|
91
|
+
ok = False
|
|
92
|
+
error_msg = ""
|
|
93
|
+
try:
|
|
94
|
+
# Run the blocking provider call in a thread-pool so we don't block the event loop
|
|
95
|
+
response = await asyncio.get_event_loop().run_in_executor(
|
|
96
|
+
None,
|
|
97
|
+
lambda: self._provider.chat(messages=_PROBE_MESSAGES, tools=[], tool_choice="none"),
|
|
98
|
+
)
|
|
99
|
+
_ = response.choices[0].message.content # verify structure
|
|
100
|
+
ok = True
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
error_msg = str(exc)
|
|
103
|
+
|
|
104
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
105
|
+
self._log(ok, latency_ms, error_msg)
|
|
106
|
+
await self._maybe_alert(ok, error_msg)
|
|
107
|
+
|
|
108
|
+
def _log(self, ok: bool, latency_ms: int, error_msg: str) -> None:
|
|
109
|
+
status = "OK" if ok else "FAIL"
|
|
110
|
+
entry = f"[Heartbeat] {status} | latency={latency_ms}ms"
|
|
111
|
+
if not ok:
|
|
112
|
+
entry += f" | error={error_msg}"
|
|
113
|
+
if ok:
|
|
114
|
+
logger.debug(entry)
|
|
115
|
+
else:
|
|
116
|
+
logger.warning(entry)
|
|
117
|
+
self._file_logger.info(entry)
|
|
118
|
+
|
|
119
|
+
async def _maybe_alert(self, ok: bool, error_msg: str) -> None:
|
|
120
|
+
if self._telegram_bot is None or self._alert_chat_id is None:
|
|
121
|
+
self._last_ok = ok
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if not ok and self._last_ok is not False:
|
|
125
|
+
# Transition to failure → send alert
|
|
126
|
+
msg = f"🚨 *Heartbeat FAILED*\n\nThe LLM provider is not responding.\n\nError: `{error_msg}`"
|
|
127
|
+
try:
|
|
128
|
+
await self._telegram_bot.send_message(self._alert_chat_id, msg)
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
logger.error("[Heartbeat] Failed to send Telegram alert: %s", exc)
|
|
131
|
+
elif ok and self._last_ok is False:
|
|
132
|
+
# Recovered → send recovery notice
|
|
133
|
+
msg = "✅ *Heartbeat RECOVERED*\n\nThe LLM provider is responding again."
|
|
134
|
+
try:
|
|
135
|
+
await self._telegram_bot.send_message(self._alert_chat_id, msg)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.error("[Heartbeat] Failed to send Telegram recovery: %s", exc)
|
|
138
|
+
|
|
139
|
+
self._last_ok = ok
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
def _build_file_logger(log_path: str) -> logging.Logger:
|
|
145
|
+
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
146
|
+
file_logger = logging.getLogger("pythonclaw.heartbeat.file")
|
|
147
|
+
file_logger.setLevel(logging.INFO)
|
|
148
|
+
if not file_logger.handlers:
|
|
149
|
+
handler = logging.handlers.RotatingFileHandler(
|
|
150
|
+
log_path, maxBytes=1_000_000, backupCount=3, encoding="utf-8"
|
|
151
|
+
)
|
|
152
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
153
|
+
file_logger.addHandler(handler)
|
|
154
|
+
return file_logger
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def create_heartbeat(
|
|
158
|
+
provider: "LLMProvider",
|
|
159
|
+
telegram_bot: "TelegramBot | None" = None,
|
|
160
|
+
) -> HeartbeatMonitor:
|
|
161
|
+
"""Create a HeartbeatMonitor from pythonclaw.json / env vars."""
|
|
162
|
+
interval = config.get_int(
|
|
163
|
+
"heartbeat", "intervalSec", env="HEARTBEAT_INTERVAL_SEC", default=DEFAULT_INTERVAL,
|
|
164
|
+
)
|
|
165
|
+
raw_chat_id = config.get_str(
|
|
166
|
+
"heartbeat", "alertChatId", env="HEARTBEAT_ALERT_CHAT_ID",
|
|
167
|
+
)
|
|
168
|
+
alert_chat_id = int(raw_chat_id) if raw_chat_id else None
|
|
169
|
+
return HeartbeatMonitor(
|
|
170
|
+
provider=provider,
|
|
171
|
+
interval_sec=interval,
|
|
172
|
+
telegram_bot=telegram_bot,
|
|
173
|
+
alert_chat_id=alert_chat_id,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# Backward-compatible alias
|
|
178
|
+
create_heartbeat_from_env = create_heartbeat
|
pythonclaw/server.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Daemon server for PythonClaw — multi-channel mode.
|
|
3
|
+
|
|
4
|
+
Supports Telegram and Discord channels, individually or combined.
|
|
5
|
+
|
|
6
|
+
Architecture
|
|
7
|
+
------------
|
|
8
|
+
+----------------------------------------+
|
|
9
|
+
| SessionManager |
|
|
10
|
+
| "{channel}:{id}" → Agent |
|
|
11
|
+
| "cron:{job_id}" → Agent |
|
|
12
|
+
| (Markdown-backed via SessionStore) |
|
|
13
|
+
+----------------------------------------+
|
|
14
|
+
|
|
|
15
|
+
+--------------------+--------------------+
|
|
16
|
+
| | |
|
|
17
|
+
TelegramBot CronScheduler HeartbeatMonitor
|
|
18
|
+
DiscordBot static + dynamic
|
|
19
|
+
jobs
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import signal
|
|
28
|
+
|
|
29
|
+
from .core.llm.base import LLMProvider
|
|
30
|
+
from .core.persistent_agent import PersistentAgent
|
|
31
|
+
from .core.session_store import SessionStore
|
|
32
|
+
from .scheduler.cron import CronScheduler
|
|
33
|
+
from .scheduler.heartbeat import create_heartbeat
|
|
34
|
+
from .session_manager import SessionManager
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run_server(
|
|
40
|
+
provider: LLMProvider,
|
|
41
|
+
channels: list[str] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Main entry point for daemon mode.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
provider : the LLM provider to use
|
|
49
|
+
channels : list of channels to start, e.g. ["telegram", "discord"].
|
|
50
|
+
Defaults to ["telegram"] for backward compatibility.
|
|
51
|
+
"""
|
|
52
|
+
if channels is None:
|
|
53
|
+
channels = ["telegram"]
|
|
54
|
+
|
|
55
|
+
# ── 1. Session store (Markdown persistence) ───────────────────────────────
|
|
56
|
+
store = SessionStore()
|
|
57
|
+
logger.info("[Server] SessionStore initialised at '%s'", store.base_dir)
|
|
58
|
+
|
|
59
|
+
# ── 2. SessionManager (placeholder factory, updated below) ────────────────
|
|
60
|
+
session_manager = SessionManager(agent_factory=lambda sid: None, store=store)
|
|
61
|
+
|
|
62
|
+
# ── 3. CronScheduler ─────────────────────────────────────────────────────
|
|
63
|
+
jobs_path = os.path.join("context", "cron", "jobs.yaml")
|
|
64
|
+
scheduler = CronScheduler(
|
|
65
|
+
session_manager=session_manager,
|
|
66
|
+
jobs_path=jobs_path,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# ── 4. Real agent factory ─────────────────────────────────────────────────
|
|
70
|
+
def agent_factory(session_id: str) -> PersistentAgent:
|
|
71
|
+
return PersistentAgent(
|
|
72
|
+
provider=provider,
|
|
73
|
+
store=store,
|
|
74
|
+
session_id=session_id,
|
|
75
|
+
cron_manager=scheduler,
|
|
76
|
+
verbose=False,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
session_manager.set_factory(agent_factory)
|
|
80
|
+
|
|
81
|
+
# ── 5. Start channels ─────────────────────────────────────────────────────
|
|
82
|
+
active_bots: list = []
|
|
83
|
+
|
|
84
|
+
if "telegram" in channels:
|
|
85
|
+
try:
|
|
86
|
+
from .channels.telegram_bot import create_bot_from_env
|
|
87
|
+
bot = create_bot_from_env(session_manager)
|
|
88
|
+
scheduler._telegram_bot = bot
|
|
89
|
+
await bot.start_async()
|
|
90
|
+
active_bots.append(bot)
|
|
91
|
+
logger.info("[Server] Telegram bot started.")
|
|
92
|
+
except (ValueError, ImportError) as exc:
|
|
93
|
+
logger.warning("[Server] Telegram skipped: %s", exc)
|
|
94
|
+
|
|
95
|
+
if "discord" in channels:
|
|
96
|
+
try:
|
|
97
|
+
from .channels.discord_bot import create_bot_from_env as create_discord
|
|
98
|
+
discord_bot = create_discord(session_manager)
|
|
99
|
+
asyncio.create_task(discord_bot.start_async())
|
|
100
|
+
active_bots.append(discord_bot)
|
|
101
|
+
logger.info("[Server] Discord bot started.")
|
|
102
|
+
except (ValueError, ImportError) as exc:
|
|
103
|
+
logger.warning("[Server] Discord skipped: %s", exc)
|
|
104
|
+
|
|
105
|
+
if not active_bots:
|
|
106
|
+
logger.error("[Server] No channels started. Check your pythonclaw.json configuration.")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# ── 6. Start scheduler ────────────────────────────────────────────────────
|
|
110
|
+
scheduler.start()
|
|
111
|
+
|
|
112
|
+
# ── 7. Heartbeat monitor ──────────────────────────────────────────────────
|
|
113
|
+
telegram_bot = next((b for b in active_bots if hasattr(b, '_app')), None)
|
|
114
|
+
heartbeat = create_heartbeat(provider=provider, telegram_bot=telegram_bot)
|
|
115
|
+
await heartbeat.start()
|
|
116
|
+
|
|
117
|
+
logger.info("[Server] All subsystems running (%s). Press Ctrl-C to stop.",
|
|
118
|
+
", ".join(channels))
|
|
119
|
+
|
|
120
|
+
# ── Graceful shutdown ─────────────────────────────────────────────────────
|
|
121
|
+
stop_event = asyncio.Event()
|
|
122
|
+
|
|
123
|
+
def _signal_handler() -> None:
|
|
124
|
+
logger.info("[Server] Shutdown signal received.")
|
|
125
|
+
stop_event.set()
|
|
126
|
+
|
|
127
|
+
loop = asyncio.get_running_loop()
|
|
128
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
129
|
+
try:
|
|
130
|
+
loop.add_signal_handler(sig, _signal_handler)
|
|
131
|
+
except (NotImplementedError, OSError):
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
await stop_event.wait()
|
|
136
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
137
|
+
pass
|
|
138
|
+
finally:
|
|
139
|
+
logger.info("[Server] Shutting down subsystems...")
|
|
140
|
+
await heartbeat.stop()
|
|
141
|
+
scheduler.stop()
|
|
142
|
+
for bot in active_bots:
|
|
143
|
+
if hasattr(bot, 'stop_async'):
|
|
144
|
+
await bot.stop_async()
|
|
145
|
+
logger.info("[Server] Shutdown complete.")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SessionManager — central registry of session_id → Agent instances.
|
|
3
|
+
|
|
4
|
+
All channels (Telegram, CLI, Web, etc.) and the cron scheduler go through a
|
|
5
|
+
single SessionManager so that session lifecycle is managed in one place.
|
|
6
|
+
|
|
7
|
+
Session ID conventions
|
|
8
|
+
----------------------
|
|
9
|
+
telegram:{chat_id} — one per Telegram chat
|
|
10
|
+
cron:{job_id} — one per scheduled job (persistent across runs)
|
|
11
|
+
cli — the interactive REPL session
|
|
12
|
+
web:{connection_id} — future web channel
|
|
13
|
+
|
|
14
|
+
Factory signature
|
|
15
|
+
-----------------
|
|
16
|
+
The factory callable must accept the session_id as its first positional arg:
|
|
17
|
+
|
|
18
|
+
def factory(session_id: str) -> Agent: ...
|
|
19
|
+
|
|
20
|
+
This lets PersistentAgent know which JSONL file to load/save.
|
|
21
|
+
|
|
22
|
+
Usage
|
|
23
|
+
-----
|
|
24
|
+
sm = SessionManager(agent_factory, store=session_store)
|
|
25
|
+
agent = sm.get_or_create("telegram:123456")
|
|
26
|
+
sm.reset("telegram:123456") # deletes JSONL, creates fresh agent
|
|
27
|
+
sm.list_sessions()
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from typing import Callable, TYPE_CHECKING
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .core.agent import Agent
|
|
37
|
+
from .core.session_store import SessionStore
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Factory receives the session_id so PersistentAgent can locate its JSONL file.
|
|
42
|
+
AgentFactory = Callable[[str], "Agent"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionManager:
|
|
46
|
+
"""
|
|
47
|
+
Central registry that maps session_id strings to Agent instances.
|
|
48
|
+
|
|
49
|
+
Channels and schedulers call get_or_create(session_id) to obtain the
|
|
50
|
+
Agent for a given session. This decouples session lifecycle from
|
|
51
|
+
channel-specific code.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
agent_factory: AgentFactory,
|
|
57
|
+
store: "SessionStore | None" = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._factory = agent_factory
|
|
60
|
+
self._store = store
|
|
61
|
+
self._sessions: dict[str, "Agent"] = {}
|
|
62
|
+
|
|
63
|
+
# ── Factory ──────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def set_factory(self, factory: AgentFactory) -> None:
|
|
66
|
+
"""Late-bind the factory (used to resolve circular dependencies in server.py)."""
|
|
67
|
+
self._factory = factory
|
|
68
|
+
|
|
69
|
+
# ── Core API ─────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def get_or_create(self, session_id: str) -> "Agent":
|
|
72
|
+
"""Return the existing Agent for session_id, creating one if needed."""
|
|
73
|
+
if session_id not in self._sessions:
|
|
74
|
+
logger.info("[SessionManager] Creating session '%s'", session_id)
|
|
75
|
+
self._sessions[session_id] = self._factory(session_id)
|
|
76
|
+
return self._sessions[session_id]
|
|
77
|
+
|
|
78
|
+
def reset(self, session_id: str) -> "Agent":
|
|
79
|
+
"""Discard the current Agent, erase persisted history, and start fresh."""
|
|
80
|
+
logger.info("[SessionManager] Resetting session '%s'", session_id)
|
|
81
|
+
if self._store is not None:
|
|
82
|
+
self._store.delete(session_id)
|
|
83
|
+
self._sessions[session_id] = self._factory(session_id)
|
|
84
|
+
return self._sessions[session_id]
|
|
85
|
+
|
|
86
|
+
def remove(self, session_id: str) -> None:
|
|
87
|
+
"""Remove a session from memory (JSONL file is kept unless store.delete is called)."""
|
|
88
|
+
if session_id in self._sessions:
|
|
89
|
+
del self._sessions[session_id]
|
|
90
|
+
logger.info("[SessionManager] Removed session '%s'", session_id)
|
|
91
|
+
|
|
92
|
+
def list_sessions(self) -> list[str]:
|
|
93
|
+
"""Return all active (in-memory) session IDs."""
|
|
94
|
+
return list(self._sessions.keys())
|
|
95
|
+
|
|
96
|
+
def get(self, session_id: str) -> "Agent | None":
|
|
97
|
+
"""Return the Agent for session_id, or None if it doesn't exist."""
|
|
98
|
+
return self._sessions.get(session_id)
|
|
99
|
+
|
|
100
|
+
def __len__(self) -> int:
|
|
101
|
+
return len(self._sessions)
|
|
102
|
+
|
|
103
|
+
def __contains__(self, session_id: str) -> bool:
|
|
104
|
+
return session_id in self._sessions
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: email
|
|
3
|
+
description: >
|
|
4
|
+
Send emails via SMTP. Supports plain text and HTML, attachments, CC/BCC.
|
|
5
|
+
Use when the user asks to send any kind of email — notifications,
|
|
6
|
+
messages, reports, etc. Credentials are read from pythonclaw.json.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Instructions
|
|
10
|
+
|
|
11
|
+
Send emails through an SMTP server. Credentials are stored in the
|
|
12
|
+
`skills.email` section of `pythonclaw.json`.
|
|
13
|
+
|
|
14
|
+
### Prerequisites
|
|
15
|
+
|
|
16
|
+
The user must configure these fields in `pythonclaw.json` (or the web dashboard Config page):
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
"skills": {
|
|
20
|
+
"email": {
|
|
21
|
+
"smtpServer": "smtp.gmail.com",
|
|
22
|
+
"smtpPort": 587,
|
|
23
|
+
"senderEmail": "you@gmail.com",
|
|
24
|
+
"senderPassword": "your-app-password"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For Gmail, use an [App Password](https://myaccount.google.com/apppasswords).
|
|
30
|
+
|
|
31
|
+
### Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
python {skill_path}/send_email.py \
|
|
35
|
+
--to "recipient@example.com" \
|
|
36
|
+
--subject "Hello" \
|
|
37
|
+
--body "Message body here"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Optional flags:
|
|
41
|
+
- `--cc "a@b.com,c@d.com"` — CC recipients
|
|
42
|
+
- `--bcc "x@y.com"` — BCC recipients
|
|
43
|
+
- `--html` — treat body as HTML
|
|
44
|
+
|
|
45
|
+
### Examples
|
|
46
|
+
|
|
47
|
+
- "Send an email to alice@example.com saying the report is ready"
|
|
48
|
+
- "Email bob@company.com with subject 'Meeting Update' and body 'Rescheduled to 3pm'"
|
|
49
|
+
|
|
50
|
+
## Resources
|
|
51
|
+
|
|
52
|
+
| File | Description |
|
|
53
|
+
|------|-------------|
|
|
54
|
+
| `send_email.py` | Generic SMTP email sender |
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generic SMTP email sender. Reads credentials from pythonclaw.json."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import smtplib
|
|
9
|
+
from email.mime.multipart import MIMEMultipart
|
|
10
|
+
from email.mime.text import MIMEText
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load_email_config() -> dict:
|
|
14
|
+
"""Load skills.email from pythonclaw.json."""
|
|
15
|
+
for path in ["pythonclaw.json", os.path.expanduser("~/.pythonclaw/pythonclaw.json")]:
|
|
16
|
+
if not os.path.isfile(path):
|
|
17
|
+
continue
|
|
18
|
+
with open(path, encoding="utf-8") as f:
|
|
19
|
+
text = f.read()
|
|
20
|
+
text = re.sub(r'//.*$', '', text, flags=re.MULTILINE)
|
|
21
|
+
text = re.sub(r',\s*([}\]])', r'\1', text)
|
|
22
|
+
data = json.loads(text)
|
|
23
|
+
return data.get("skills", {}).get("email", {})
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def send_email(
|
|
28
|
+
to: list[str],
|
|
29
|
+
subject: str,
|
|
30
|
+
body: str,
|
|
31
|
+
*,
|
|
32
|
+
cc: list[str] | None = None,
|
|
33
|
+
bcc: list[str] | None = None,
|
|
34
|
+
html: bool = False,
|
|
35
|
+
) -> str:
|
|
36
|
+
cfg = _load_email_config()
|
|
37
|
+
server = cfg.get("smtpServer", "smtp.gmail.com")
|
|
38
|
+
port = int(cfg.get("smtpPort", 587))
|
|
39
|
+
sender = cfg.get("senderEmail", "")
|
|
40
|
+
password = cfg.get("senderPassword", "")
|
|
41
|
+
|
|
42
|
+
if not sender or not password:
|
|
43
|
+
return "Error: Email credentials not configured. Set skills.email in pythonclaw.json."
|
|
44
|
+
|
|
45
|
+
msg = MIMEMultipart("alternative")
|
|
46
|
+
msg["From"] = sender
|
|
47
|
+
msg["To"] = ", ".join(to)
|
|
48
|
+
msg["Subject"] = subject
|
|
49
|
+
if cc:
|
|
50
|
+
msg["Cc"] = ", ".join(cc)
|
|
51
|
+
|
|
52
|
+
content_type = "html" if html else "plain"
|
|
53
|
+
msg.attach(MIMEText(body, content_type, "utf-8"))
|
|
54
|
+
|
|
55
|
+
all_recipients = list(to) + (cc or []) + (bcc or [])
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with smtplib.SMTP(server, port, timeout=30) as smtp:
|
|
59
|
+
smtp.ehlo()
|
|
60
|
+
smtp.starttls()
|
|
61
|
+
smtp.ehlo()
|
|
62
|
+
smtp.login(sender, password)
|
|
63
|
+
smtp.sendmail(sender, all_recipients, msg.as_string())
|
|
64
|
+
return f"Email sent to {', '.join(to)} (subject: {subject})"
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
return f"Send failed: {exc}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main():
|
|
70
|
+
parser = argparse.ArgumentParser(description="Send an email via SMTP.")
|
|
71
|
+
parser.add_argument("--to", required=True, help="Recipient(s), comma-separated")
|
|
72
|
+
parser.add_argument("--subject", required=True, help="Email subject line")
|
|
73
|
+
parser.add_argument("--body", required=True, help="Email body text")
|
|
74
|
+
parser.add_argument("--cc", default="", help="CC recipients, comma-separated")
|
|
75
|
+
parser.add_argument("--bcc", default="", help="BCC recipients, comma-separated")
|
|
76
|
+
parser.add_argument("--html", action="store_true", help="Treat body as HTML")
|
|
77
|
+
args = parser.parse_args()
|
|
78
|
+
|
|
79
|
+
to = [a.strip() for a in args.to.split(",") if a.strip()]
|
|
80
|
+
cc = [a.strip() for a in args.cc.split(",") if a.strip()] or None
|
|
81
|
+
bcc = [a.strip() for a in args.bcc.split(",") if a.strip()] or None
|
|
82
|
+
|
|
83
|
+
result = send_email(to, args.subject, args.body, cc=cc, bcc=bcc, html=args.html)
|
|
84
|
+
print(result)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: csv_analyzer
|
|
3
|
+
description: >
|
|
4
|
+
Analyze CSV and Excel files — statistics, filtering, grouping, and
|
|
5
|
+
data previews. Use when the user asks to read, analyze, query, or
|
|
6
|
+
summarize tabular data files (CSV, TSV, Excel).
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Instructions
|
|
10
|
+
|
|
11
|
+
Analyze tabular data files using pandas.
|
|
12
|
+
|
|
13
|
+
### Prerequisites
|
|
14
|
+
|
|
15
|
+
Install dependency: `pip install pandas openpyxl`
|
|
16
|
+
|
|
17
|
+
### Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python {skill_path}/analyze.py PATH [command] [options]
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
- `info` (default) — column types, shape, missing values
|
|
25
|
+
- `head` — first N rows (default 10)
|
|
26
|
+
- `stats` — descriptive statistics for numeric columns
|
|
27
|
+
- `query` — filter rows with a pandas query expression
|
|
28
|
+
- `groupby` — group-by aggregation
|
|
29
|
+
- `columns` — list column names and types
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
- `--rows N` — number of rows for head (default 10)
|
|
33
|
+
- `--query "col > 100"` — pandas query expression
|
|
34
|
+
- `--groupby COL` — column to group by
|
|
35
|
+
- `--agg mean|sum|count|min|max` — aggregation function (default: mean)
|
|
36
|
+
- `--format json` — output as JSON
|
|
37
|
+
- `--columns "col1,col2"` — select specific columns
|
|
38
|
+
|
|
39
|
+
### Examples
|
|
40
|
+
|
|
41
|
+
- "Show me what's in data.csv" → `analyze.py data.csv info`
|
|
42
|
+
- "First 20 rows of sales.xlsx" → `analyze.py sales.xlsx head --rows 20`
|
|
43
|
+
- "Statistics for revenue column" → `analyze.py data.csv stats --columns revenue`
|
|
44
|
+
- "Filter rows where age > 30" → `analyze.py data.csv query --query "age > 30"`
|
|
45
|
+
- "Average sales by region" → `analyze.py data.csv groupby --groupby region --columns sales --agg mean`
|
|
46
|
+
|
|
47
|
+
## Resources
|
|
48
|
+
|
|
49
|
+
| File | Description |
|
|
50
|
+
|------|-------------|
|
|
51
|
+
| `analyze.py` | Tabular data analyzer |
|