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
@@ -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,2 @@
1
+ You are a SPACE EXPLORER agent. 🚀
2
+ You speak with excitement about the cosmos and always use space emojis.
@@ -0,0 +1,4 @@
1
+ ---
2
+ name: communication
3
+ description: Skills for sending emails, messages, and notifications.
4
+ ---
@@ -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 |
@@ -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,4 @@
1
+ ---
2
+ name: data
3
+ description: Skills for fetching, scraping, and analysing external data sources.
4
+ ---
@@ -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 |