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,773 @@
1
+ """
2
+ Agent — the core reasoning loop for PythonClaw.
3
+
4
+ Responsibilities
5
+ ----------------
6
+ - Maintain conversation history (messages list)
7
+ - Build the per-session tool set and dispatch tool calls
8
+ - Three-tier progressive skill loading (catalog → instructions → resources)
9
+ - Trigger context compaction (auto or manual)
10
+ - Interface with memory (MemoryManager) and knowledge (KnowledgeRAG)
11
+
12
+ What this class is NOT responsible for
13
+ ---------------------------------------
14
+ - Session lifecycle (→ SessionManager)
15
+ - Persistence across restarts (→ PersistentAgent subclass)
16
+ - I/O channels like Telegram (→ channels/)
17
+ - Scheduling (→ scheduler/)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import time
26
+ from concurrent.futures import ThreadPoolExecutor, as_completed
27
+ from datetime import datetime
28
+
29
+ from .. import config
30
+ from .compaction import (
31
+ DEFAULT_AUTO_THRESHOLD_TOKENS,
32
+ DEFAULT_RECENT_KEEP,
33
+ compact as _do_compact,
34
+ estimate_tokens,
35
+ )
36
+ from .knowledge.rag import KnowledgeRAG
37
+ from .llm.base import LLMProvider
38
+ from .memory.manager import MemoryManager
39
+ from .skill_loader import SkillRegistry
40
+ from .tools import (
41
+ AVAILABLE_TOOLS,
42
+ CRON_TOOLS,
43
+ KNOWLEDGE_TOOL,
44
+ MEMORY_TOOLS,
45
+ META_SKILL_TOOLS,
46
+ PRIMITIVE_TOOLS,
47
+ SKILL_TOOLS,
48
+ WEB_SEARCH_TOOL,
49
+ configure_venv,
50
+ set_sandbox,
51
+ )
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ def _load_text_dir_or_file(path: str | None, label: str = "File") -> str:
57
+ """
58
+ Load text from a single file or from all .md/.txt files in a directory.
59
+ Returns an empty string if *path* is None or does not exist.
60
+ """
61
+ if not path or not os.path.exists(path):
62
+ return ""
63
+ if os.path.isfile(path):
64
+ with open(path, "r", encoding="utf-8") as f:
65
+ return f.read()
66
+ if os.path.isdir(path):
67
+ parts = []
68
+ for filename in sorted(os.listdir(path)):
69
+ if filename.lower().endswith((".md", ".txt")):
70
+ with open(os.path.join(path, filename), "r", encoding="utf-8") as f:
71
+ parts.append(f"\n\n--- {label}: {filename} ---\n" + f.read())
72
+ return "".join(parts)
73
+ return ""
74
+
75
+
76
+ _DETAIL_LOG_DIR = os.path.join("context", "logs")
77
+ _DETAIL_LOG_FILE = os.path.join(_DETAIL_LOG_DIR, "history_detail.jsonl")
78
+
79
+
80
+ def _log_detail(entry: dict) -> None:
81
+ """Append a JSON line to the detailed interaction log."""
82
+ try:
83
+ os.makedirs(_DETAIL_LOG_DIR, exist_ok=True)
84
+ entry["ts"] = datetime.now().isoformat(timespec="milliseconds")
85
+ with open(_DETAIL_LOG_FILE, "a", encoding="utf-8") as f:
86
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
87
+ except OSError:
88
+ pass
89
+
90
+
91
+ class Agent:
92
+ """
93
+ Stateful LLM agent with tool use, three-tier skill loading, memory,
94
+ and compaction.
95
+
96
+ Parameters
97
+ ----------
98
+ provider : LLM backend (DeepSeek, Grok, Claude, Gemini, …)
99
+ memory_dir : path to memory directory (auto-detected if None)
100
+ skills_dirs : list of skill directory paths
101
+ knowledge_path : path to knowledge directory for RAG
102
+ persona_path : path to persona .md file or directory
103
+ soul_path : path to SOUL.md file or directory
104
+ verbose : print debug info to stdout
105
+ show_full_context : print the full context window before each LLM call
106
+ max_chat_history : max non-system messages kept in the sliding window
107
+ auto_compaction : trigger compaction when token estimate exceeds threshold
108
+ compaction_threshold : token threshold for auto-compaction
109
+ compaction_recent_keep : number of recent messages kept verbatim after compaction
110
+ cron_manager : CronScheduler instance (enables cron_add/remove/list tools)
111
+ """
112
+
113
+ MAX_TOOL_ROUNDS = 15
114
+
115
+ def __init__(
116
+ self,
117
+ provider: LLMProvider,
118
+ memory_dir: str | None = None,
119
+ skills_dirs: list[str] | None = None,
120
+ knowledge_path: str | None = None,
121
+ persona_path: str | None = None,
122
+ soul_path: str | None = None,
123
+ verbose: bool = False,
124
+ show_full_context: bool = False,
125
+ max_chat_history: int = 10,
126
+ auto_compaction: bool = True,
127
+ compaction_threshold: int = DEFAULT_AUTO_THRESHOLD_TOKENS,
128
+ compaction_recent_keep: int = DEFAULT_RECENT_KEEP,
129
+ cron_manager=None,
130
+ ) -> None:
131
+ if memory_dir is None and skills_dirs is None and knowledge_path is None and persona_path is None:
132
+ cwd = os.getcwd()
133
+ context_dir = os.path.join(cwd, "context")
134
+ if not os.path.exists(context_dir):
135
+ if verbose:
136
+ print(f"[Agent] Context not found. Initialising default context in {context_dir}...")
137
+ try:
138
+ from ...init import init
139
+ init(cwd)
140
+ except ImportError:
141
+ try:
142
+ from pythonclaw.init import init
143
+ init(cwd)
144
+ except ImportError:
145
+ print("[Agent] Warning: Could not auto-initialise context.")
146
+ if verbose:
147
+ print(f"[Agent] Using default context at {context_dir}")
148
+ memory_dir = os.path.join(context_dir, "memory")
149
+ knowledge_path = os.path.join(context_dir, "knowledge")
150
+ skills_dirs = [os.path.join(context_dir, "skills")]
151
+ persona_path = os.path.join(context_dir, "persona")
152
+ if soul_path is None:
153
+ soul_path = os.path.join(context_dir, "soul")
154
+
155
+ # Sandbox: restrict file-write tools to the project working tree
156
+ sandbox_root = os.getcwd()
157
+ set_sandbox([sandbox_root])
158
+ if verbose:
159
+ print(f"[Agent] Sandbox root: {sandbox_root}")
160
+
161
+ # Venv: ensure all subprocesses use the project's virtual environment
162
+ venv_path = configure_venv()
163
+ if verbose and venv_path:
164
+ print(f"[Agent] Virtual env: {venv_path}")
165
+
166
+ self.provider = provider
167
+ self.messages: list[dict] = []
168
+ self.verbose = verbose
169
+ self.show_full_context = show_full_context
170
+ self.max_chat_history = max_chat_history
171
+ self.auto_compaction = auto_compaction
172
+ self.compaction_threshold = compaction_threshold
173
+ self.compaction_recent_keep = compaction_recent_keep
174
+ self.compaction_count: int = 0
175
+ self._cron_manager = cron_manager
176
+
177
+ self.loaded_skill_names: set[str] = set()
178
+ self.pending_injections: list[str] = []
179
+
180
+ # Memory
181
+ mem_dir = memory_dir or config.get("memory", "dir", env="PYTHONCLAW_MEMORY_DIR")
182
+ self.memory = MemoryManager(mem_dir)
183
+
184
+ # Knowledge RAG (hybrid retrieval)
185
+ self.rag: KnowledgeRAG | None = None
186
+ if knowledge_path and os.path.exists(knowledge_path):
187
+ self.rag = KnowledgeRAG(
188
+ knowledge_dir=knowledge_path,
189
+ provider=provider,
190
+ use_reranker=True,
191
+ )
192
+ if verbose:
193
+ print(f"[Agent] KnowledgeRAG: '{knowledge_path}' ({len(self.rag)} chunks)")
194
+
195
+ # Web search (Tavily)
196
+ self._web_search_enabled = bool(
197
+ config.get("tavily", "apiKey", env="TAVILY_API_KEY")
198
+ )
199
+ if verbose and self._web_search_enabled:
200
+ print("[Agent] Web search enabled (Tavily)")
201
+
202
+ # Skills — always include the built-in templates + user context/skills
203
+ self.skills_dirs: list[str] = []
204
+ pkg_templates = os.path.join(
205
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
206
+ "templates", "skills",
207
+ )
208
+ if os.path.isdir(pkg_templates):
209
+ self.skills_dirs.append(pkg_templates)
210
+ if skills_dirs:
211
+ for d in ([skills_dirs] if isinstance(skills_dirs, str) else skills_dirs):
212
+ if d not in self.skills_dirs:
213
+ self.skills_dirs.append(d)
214
+
215
+ # Identity layers
216
+ self.soul_instruction = _load_text_dir_or_file(soul_path, label="Soul")
217
+ self.persona_instruction = _load_text_dir_or_file(persona_path, label="Persona")
218
+
219
+ # Detect if the user has set up their own soul/persona (not template defaults)
220
+ self._needs_onboarding = not self._has_user_identity(soul_path, persona_path)
221
+
222
+ if verbose and self.soul_instruction:
223
+ print(f"[Agent] Soul loaded ({len(self.soul_instruction)} chars)")
224
+ if verbose and self.persona_instruction:
225
+ print(f"[Agent] Persona loaded ({len(self.persona_instruction)} chars)")
226
+ if verbose and self._needs_onboarding:
227
+ print("[Agent] No user identity found — onboarding will be triggered")
228
+
229
+ self._init_system_prompt()
230
+
231
+ @staticmethod
232
+ def _has_user_identity(soul_path: str | None, persona_path: str | None) -> bool:
233
+ """Return True if the user has customized soul or persona files."""
234
+ for p in (soul_path, persona_path):
235
+ if p is None:
236
+ continue
237
+ if os.path.isdir(p):
238
+ for fname in os.listdir(p):
239
+ fpath = os.path.join(p, fname)
240
+ if os.path.isfile(fpath) and os.path.getsize(fpath) > 0:
241
+ return True
242
+ elif os.path.isfile(p) and os.path.getsize(p) > 0:
243
+ return True
244
+ return False
245
+
246
+ # ── Initialisation ────────────────────────────────────────────────────────
247
+
248
+ def _init_system_prompt(self) -> None:
249
+ """
250
+ Build the initial system message with three-tier skill loading.
251
+
252
+ Level 1 (Metadata) is injected here — the full skill catalog
253
+ (name + description for every installed skill). This lets the
254
+ LLM decide when to activate a skill without any discovery calls.
255
+ """
256
+ self._registry = SkillRegistry(skills_dirs=self.skills_dirs)
257
+ skill_catalog = self._registry.build_catalog()
258
+
259
+ soul_section = f"\n\n## Core Identity (Soul)\n{self.soul_instruction}" if self.soul_instruction else ""
260
+ persona_section = f"\n\n## Role & Persona\n{self.persona_instruction}" if self.persona_instruction else ""
261
+
262
+ web_search_section = ""
263
+ if self._web_search_enabled:
264
+ web_search_section = """
265
+ 3. **Web Search**: `web_search` (powered by Tavily)
266
+ Search the web for real-time information when you need up-to-date data,
267
+ current events, facts you're unsure about, or technical documentation.
268
+ Supports topic filters (general/news/finance) and time range filters."""
269
+
270
+ system_msg = f"""You are a PythonClaw agent — an autonomous AI assistant.{soul_section}{persona_section}
271
+
272
+ You operate in a potentially sandboxed environment where you can execute code.
273
+
274
+ ### Tool Capabilities
275
+ 1. **Primitive Tools**: `run_command`, `read_file`, `write_file`, `list_files`
276
+ Note: `write_file` can only write within the project directory.
277
+ 2. **Skills** (three-tier progressive loading):
278
+ You have access to the following skills. Each skill's description
279
+ tells you WHAT it does and WHEN to use it.
280
+
281
+ **Installed Skills:**
282
+ {skill_catalog}
283
+
284
+ To activate a skill, call `use_skill(skill_name="<name>")`.
285
+ This loads detailed instructions into context. After activation
286
+ you can use `list_skill_resources` to discover bundled scripts
287
+ and reference files, then `read_file` / `run_command` to use them.
288
+ {web_search_section}
289
+
290
+ ### Skill Creation ("God Mode")
291
+ If NO existing skill can fulfill the user's request, you can **create a new skill
292
+ on the fly** using `create_skill`. This lets you:
293
+ - Write a SKILL.md with instructions and bundled Python/shell scripts
294
+ - Automatically install pip dependencies
295
+ - The new skill becomes immediately available via `use_skill`
296
+
297
+ Use this when the user needs a capability that doesn't exist yet. Think carefully
298
+ about the skill design: write clean, reusable code and a clear SKILL.md.
299
+
300
+ **CRITICAL**: Always create GENERIC, reusable skills — NEVER task-specific ones.
301
+ - BAD: `us_iran_news_fetcher` (only one topic), `send_bob_email` (one recipient)
302
+ - GOOD: `news` (any topic as parameter), `email` (any recipient as parameter)
303
+ All specifics (topics, recipients, URLs) must be command-line arguments.
304
+
305
+ ### Workflow
306
+ 1. User asks a question.
307
+ 2. Match the request against the skill catalog above.
308
+ 3. If a skill fits, call `use_skill` to load its instructions (Level 2).
309
+ 4. Follow the injected instructions. Use `read_file` / `run_command`
310
+ to access bundled resources as needed (Level 3).
311
+ 5. If NO skill fits, consider creating one with `create_skill` — write
312
+ the script, install dependencies, then immediately `use_skill` to
313
+ activate and run it.
314
+
315
+ ### Performance — Be Efficient
316
+ **CRITICAL RULES for speed:**
317
+ 1. **Batch tool calls**: When you need multiple independent searches or
318
+ tool calls, issue them ALL in a single response. They run in parallel
319
+ and are MUCH faster. NEVER do one search per round.
320
+ 2. **Minimize search rounds**: For most topics, 1-3 web searches total
321
+ is enough. Combine queries (e.g. "NVDA stock price P/E ratio analyst
322
+ ratings 2025" instead of 3 separate searches). Use `max_results=2-3`.
323
+ 3. **Don't repeat**: If a previous search already covered a topic, use
324
+ that data. Never search for the same information twice.
325
+ 4. **Answer quickly**: Gather just enough data to answer well, then
326
+ respond. Don't exhaustively search every sub-topic.
327
+
328
+ ### Memory
329
+ You have a long-term memory.
330
+ - **Proactively save** ALL user profile details, preferences, and key facts using `remember`.
331
+ - Use `recall(query="<topic>")` to search memory semantically. Use `recall(query="*")` to retrieve ALL memories (full dump).
332
+ - ALWAYS check memory (`recall`) if the user asks something that might be stored from a previous session.
333
+
334
+ Always verify the output of your commands.
335
+
336
+ ### Response Guidelines
337
+ - Answer the user's question directly and concisely.
338
+ - Do NOT mention what skills or tools you have available, unless explicitly asked.
339
+ - Do NOT list other things you can do at the end of your response.
340
+ """
341
+ if getattr(self, "_needs_onboarding", False):
342
+ system_msg += """
343
+ ### First-Time Onboarding
344
+ **IMPORTANT**: No user identity (soul/persona) has been configured yet.
345
+ On the VERY FIRST user message, start a friendly onboarding conversation.
346
+
347
+ **Language rule**: Always conduct onboarding in **English** by default.
348
+ If the user replies in another language, switch to that language for
349
+ the rest of the onboarding (and set that as their language preference).
350
+
351
+ 1. Greet the user warmly and introduce yourself as PythonClaw
352
+ 2. Ask: "What should I call you?" (wait for response)
353
+ 3. Ask: "What kind of personality would you like me to have? (e.g. professional, friendly, humorous, encouraging)"
354
+ 4. Ask: "What area would you like me to focus on? (e.g. software development, finance, research, daily assistant)"
355
+
356
+ After collecting ALL answers, use the `onboarding` skill to write the
357
+ soul.md and persona.md files. Detect the user's language from their
358
+ replies (default to English if they replied in English) and pass it as
359
+ the `--language` argument. Then use `remember` to save the user's name
360
+ and preferences to long-term memory.
361
+
362
+ Ask the questions ONE AT A TIME, waiting for each answer before asking the next.
363
+ If the user's first message already contains task content (not just "hi"),
364
+ still start onboarding but keep it brief — you can help with their task after.
365
+ """
366
+
367
+ self.messages.append({"role": "system", "content": system_msg})
368
+ if self.verbose:
369
+ logger.debug("System prompt built. Skill catalog: %d skills.", len(self._registry.discover()))
370
+
371
+ # ── Tool management ───────────────────────────────────────────────────────
372
+
373
+ def _build_tools(self) -> list[dict]:
374
+ """Assemble the full tool schema list for the current session."""
375
+ tools = PRIMITIVE_TOOLS + SKILL_TOOLS + META_SKILL_TOOLS + MEMORY_TOOLS
376
+ if self._web_search_enabled:
377
+ tools = tools + [WEB_SEARCH_TOOL]
378
+ if self.rag:
379
+ tools = tools + [KNOWLEDGE_TOOL]
380
+ if self._cron_manager:
381
+ tools = tools + CRON_TOOLS
382
+ return tools
383
+
384
+ def _execute_tool_call(self, tool_call) -> str:
385
+ """Dispatch a single tool call and return the string result."""
386
+ func_name: str = tool_call.function.name
387
+ try:
388
+ args: dict = json.loads(tool_call.function.arguments)
389
+ except json.JSONDecodeError as exc:
390
+ return f"Error: could not parse tool arguments: {exc}"
391
+
392
+ if self.verbose:
393
+ logger.debug("Tool: %s args=%s", func_name, args)
394
+
395
+ try:
396
+ if func_name == "use_skill":
397
+ result = self._use_skill(args.get("skill_name"))
398
+ elif func_name == "list_skill_resources":
399
+ resources = self._registry.list_resources(args.get("skill_name", ""))
400
+ if resources:
401
+ result = "Resources:\n" + "\n".join(f" - {r}" for r in resources)
402
+ else:
403
+ result = "No bundled resources found (or skill not found)."
404
+ elif func_name == "remember":
405
+ result = self.memory.remember(args.get("content"), args.get("key"))
406
+ elif func_name == "recall":
407
+ result = self.memory.recall(args.get("query", "*"))
408
+ elif func_name == "consult_knowledge_base" and self.rag:
409
+ hits = self.rag.retrieve(args.get("query"), top_k=5)
410
+ if hits:
411
+ result = "Found relevant info:\n" + "\n".join(
412
+ f"- [{h['source']}]: {h['content']}" for h in hits
413
+ )
414
+ else:
415
+ result = "No relevant information found in the knowledge base."
416
+ elif func_name == "cron_add" and self._cron_manager:
417
+ result = self._cron_manager.add_dynamic_job(
418
+ job_id=args.get("job_id"),
419
+ cron_expr=args.get("cron"),
420
+ prompt=args.get("prompt"),
421
+ deliver_to="telegram" if args.get("deliver_to_chat_id") else None,
422
+ chat_id=args.get("deliver_to_chat_id"),
423
+ )
424
+ elif func_name == "cron_remove" and self._cron_manager:
425
+ result = self._cron_manager.remove_dynamic_job(args.get("job_id"))
426
+ elif func_name == "cron_list" and self._cron_manager:
427
+ result = self._cron_manager.list_jobs()
428
+ elif func_name == "create_skill":
429
+ result = AVAILABLE_TOOLS["create_skill"](**args)
430
+ self._refresh_skill_registry()
431
+ elif func_name in AVAILABLE_TOOLS:
432
+ result = AVAILABLE_TOOLS[func_name](**args)
433
+ else:
434
+ result = f"Error: unknown tool '{func_name}'."
435
+ except Exception as exc:
436
+ result = f"Error executing '{func_name}': {exc}"
437
+
438
+ if self.verbose:
439
+ preview = str(result)[:200] + ("..." if len(str(result)) > 200 else "")
440
+ logger.debug("Result: %s", preview)
441
+
442
+ return str(result)
443
+
444
+ # ── Skill registry refresh (after create_skill) ────────────────────────
445
+
446
+ def _refresh_skill_registry(self) -> None:
447
+ """Invalidate the registry cache so newly created skills are discovered."""
448
+ self._registry.invalidate()
449
+ new_catalog = self._registry.build_catalog()
450
+ self.messages.append({
451
+ "role": "system",
452
+ "content": (
453
+ "[Skill Registry Updated]\n"
454
+ "A new skill has been created. Updated skill catalog:\n\n"
455
+ f"{new_catalog}"
456
+ ),
457
+ })
458
+ if self.verbose:
459
+ count = len(self._registry.discover())
460
+ logger.debug("Skill registry refreshed — %d skills now available.", count)
461
+
462
+ # ── Skill loading (Level 2) ───────────────────────────────────────────────
463
+
464
+ def _use_skill(self, skill_name: str) -> str:
465
+ """
466
+ Level 2: Load a skill's full instructions into context.
467
+
468
+ Called when the LLM triggers ``use_skill``. The SKILL.md body
469
+ is injected as a system message so subsequent turns can follow
470
+ the instructions.
471
+
472
+ If the skill directory contains a ``check_setup.sh`` script, it
473
+ is executed automatically before activation. When the check fails
474
+ (non-zero exit), the skill is still loaded but a prominent warning
475
+ with the script output is included so the LLM can guide the user
476
+ through the fix.
477
+ """
478
+ if skill_name in self.loaded_skill_names:
479
+ return f"Skill '{skill_name}' is already active."
480
+
481
+ skill = self._registry.load_skill(skill_name)
482
+ if not skill:
483
+ return f"Error: skill '{skill_name}' not found in catalog."
484
+
485
+ # ── Pre-activation environment check ─────────────────────────────────
486
+ setup_warning = ""
487
+ check_script = os.path.join(skill.metadata.path, "check_setup.sh")
488
+ if os.path.isfile(check_script):
489
+ import subprocess
490
+ from .tools import _venv_env
491
+ try:
492
+ proc = subprocess.run(
493
+ ["bash", check_script],
494
+ capture_output=True, text=True, timeout=15,
495
+ env=_venv_env(),
496
+ )
497
+ if proc.returncode != 0:
498
+ output = (proc.stdout + proc.stderr).strip()
499
+ setup_warning = (
500
+ f"\n\n⚠️ **SETUP CHECK FAILED** (exit code {proc.returncode}):\n"
501
+ f"```\n{output}\n```\n"
502
+ f"Please tell the user what went wrong and how to fix it "
503
+ f"before attempting to use this skill's commands.\n"
504
+ )
505
+ if self.verbose:
506
+ logger.debug("Skill '%s' setup check FAILED: %s", skill_name, output)
507
+ else:
508
+ setup_info = proc.stdout.strip()
509
+ setup_warning = f"\n\n✅ Setup check passed:\n```\n{setup_info}\n```\n"
510
+ if self.verbose:
511
+ logger.debug("Skill '%s' setup check passed.", skill_name)
512
+ except Exception as exc:
513
+ setup_warning = f"\n\n⚠️ Setup check could not run: {exc}\n"
514
+
515
+ resources = self._registry.list_resources(skill_name)
516
+ resource_hint = ""
517
+ if resources:
518
+ resource_hint = (
519
+ "\n\n**Bundled resources** (use `read_file` / `run_command` to access):\n"
520
+ + "\n".join(f" - `{skill.metadata.path}/{r}`" for r in resources)
521
+ )
522
+
523
+ injection = (
524
+ f"\n[SKILL ACTIVATED: {skill.name}]\n"
525
+ f"Path: {skill.metadata.path}\n\n"
526
+ f"{skill.instructions}{resource_hint}{setup_warning}\n"
527
+ )
528
+ self.pending_injections.append(injection)
529
+ self.loaded_skill_names.add(skill_name)
530
+ if self.verbose:
531
+ logger.debug("Skill activated: %s (Level 2 loaded)", skill_name)
532
+
533
+ status = "activated"
534
+ if "FAILED" in setup_warning:
535
+ status = "activated with setup warnings — tell the user how to fix"
536
+ return (
537
+ f"Skill '{skill_name}' {status}. "
538
+ f"Instructions loaded into context. "
539
+ f"Bundled resources: {resources or 'none'}."
540
+ )
541
+
542
+ # ── History management ────────────────────────────────────────────────────
543
+
544
+ def _get_pruned_messages(self) -> list[dict]:
545
+ """
546
+ Build a context window for the API call:
547
+ - All system messages (system prompt + skill injections + compaction summaries)
548
+ - The most recent `max_chat_history` non-system messages
549
+
550
+ Ensures the window never starts with an orphaned tool result.
551
+ """
552
+ system_msgs = [m for m in self.messages if m.get("role") == "system"]
553
+ chat_msgs = [m for m in self.messages if m.get("role") != "system"]
554
+
555
+ if len(chat_msgs) > self.max_chat_history:
556
+ chat_msgs = chat_msgs[-self.max_chat_history:]
557
+ while chat_msgs and chat_msgs[0].get("role") == "tool":
558
+ chat_msgs.pop(0)
559
+
560
+ return system_msgs + chat_msgs
561
+
562
+ # ── Compaction ────────────────────────────────────────────────────────────
563
+
564
+ def compact(self, instruction: str | None = None) -> str:
565
+ """
566
+ Manually compact conversation history.
567
+
568
+ Summarises older messages into a single [Compaction Summary] system
569
+ entry, flushes important facts to long-term memory, and persists the
570
+ summary to context/compaction/history.jsonl.
571
+
572
+ Parameters
573
+ ----------
574
+ instruction : optional focus hint, e.g. "focus on open tasks"
575
+ """
576
+ chat_msgs = [m for m in self.messages if m.get("role") != "system"]
577
+ if len(chat_msgs) <= self.compaction_recent_keep:
578
+ return (
579
+ f"Nothing to compact yet — only {len(chat_msgs)} message(s) in history "
580
+ f"(threshold: {self.compaction_recent_keep})."
581
+ )
582
+ try:
583
+ new_messages, summary = _do_compact(
584
+ messages=self.messages,
585
+ provider=self.provider,
586
+ memory=self.memory,
587
+ recent_keep=self.compaction_recent_keep,
588
+ instruction=instruction,
589
+ )
590
+ except Exception as exc:
591
+ return f"Compaction failed: {exc}"
592
+
593
+ self.messages = new_messages
594
+ self.compaction_count += 1
595
+
596
+ lines = summary.splitlines()
597
+ preview = "\n".join(lines[:5])
598
+ if len(lines) > 5:
599
+ preview += f"\n... ({len(lines) - 5} more lines)"
600
+ return f"Compaction #{self.compaction_count} complete.\n\nSummary:\n{preview}"
601
+
602
+ def _maybe_auto_compact(self) -> bool:
603
+ """Auto-compact if the estimated token count exceeds the threshold."""
604
+ if not self.auto_compaction:
605
+ return False
606
+ if estimate_tokens(self.messages) < self.compaction_threshold:
607
+ return False
608
+ if self.verbose:
609
+ logger.debug("Auto-compaction triggered.")
610
+ try:
611
+ new_messages, _ = _do_compact(
612
+ messages=self.messages,
613
+ provider=self.provider,
614
+ memory=self.memory,
615
+ recent_keep=self.compaction_recent_keep,
616
+ )
617
+ self.messages = new_messages
618
+ self.compaction_count += 1
619
+ return True
620
+ except Exception as exc:
621
+ if self.verbose:
622
+ logger.debug("Auto-compaction failed (non-fatal): %s", exc)
623
+ return False
624
+
625
+ # ── Session management ─────────────────────────────────────────────────
626
+
627
+ def clear_history(self) -> None:
628
+ """Clear conversation history but keep the agent intact.
629
+
630
+ Preserves loaded skills, memory, RAG, provider, and all config.
631
+ Only resets messages to a fresh system prompt and clears
632
+ conversation-specific state.
633
+ """
634
+ self.messages.clear()
635
+ self.loaded_skill_names.clear()
636
+ self.compaction_count = 0
637
+ self._init_system_prompt()
638
+
639
+ # ── Main chat loop ────────────────────────────────────────────────────────
640
+
641
+ def chat(self, user_input: str) -> str:
642
+ """
643
+ Send *user_input* to the LLM and return the final text response.
644
+
645
+ Runs the standard tool-use loop:
646
+ 1. Build context window (auto-compact if needed)
647
+ 2. Call LLM
648
+ 3. If the model requests tool calls → execute → repeat
649
+ 4. When the model replies with text → return it
650
+ """
651
+ self.messages.append({"role": "user", "content": user_input})
652
+
653
+ _log_detail({"event": "user_input", "content": user_input})
654
+
655
+ current_tools = self._build_tools()
656
+ tool_rounds = 0
657
+ chat_start = time.monotonic()
658
+
659
+ while True:
660
+ try:
661
+ self._maybe_auto_compact()
662
+ messages_to_send = self._get_pruned_messages()
663
+
664
+ if self.show_full_context:
665
+ logger.debug(
666
+ "Context window (%d messages):\n%s",
667
+ len(messages_to_send),
668
+ json.dumps(messages_to_send, indent=2, ensure_ascii=False),
669
+ )
670
+
671
+ response = self.provider.chat(
672
+ messages=messages_to_send,
673
+ tools=current_tools,
674
+ tool_choice="auto",
675
+ )
676
+ message = response.choices[0].message
677
+
678
+ if not message.tool_calls:
679
+ self.messages.append(message.model_dump())
680
+ _log_detail({
681
+ "event": "response",
682
+ "tool_rounds": tool_rounds,
683
+ "elapsed_ms": int((time.monotonic() - chat_start) * 1000),
684
+ "response_len": len(message.content or ""),
685
+ })
686
+ return message.content
687
+
688
+ tool_rounds += 1
689
+ if tool_rounds > self.MAX_TOOL_ROUNDS:
690
+ self.messages.append(message.model_dump())
691
+ limit_msg = (
692
+ f"Reached the maximum of {self.MAX_TOOL_ROUNDS} tool-call rounds. "
693
+ "Please provide a final answer with the information gathered so far."
694
+ )
695
+ self.messages.append({"role": "system", "content": limit_msg})
696
+ if self.verbose:
697
+ logger.debug("Tool round limit (%d) reached, forcing text reply.", self.MAX_TOOL_ROUNDS)
698
+ # One more LLM call with tool_choice="none" to force a text reply
699
+ try:
700
+ final = self.provider.chat(
701
+ messages=self._get_pruned_messages(),
702
+ tools=current_tools,
703
+ tool_choice="none",
704
+ )
705
+ final_msg = final.choices[0].message
706
+ self.messages.append(final_msg.model_dump())
707
+ return final_msg.content
708
+ except Exception as exc:
709
+ return f"Error (after hitting tool limit): {exc}"
710
+
711
+ self.messages.append(message.model_dump())
712
+ self.pending_injections = []
713
+
714
+ tool_calls = message.tool_calls
715
+ _log_detail({
716
+ "event": "tool_calls",
717
+ "round": tool_rounds,
718
+ "calls": [
719
+ {"name": tc.function.name, "args": tc.function.arguments}
720
+ for tc in tool_calls
721
+ ],
722
+ })
723
+
724
+ if len(tool_calls) == 1:
725
+ t0 = time.monotonic()
726
+ result = self._execute_tool_call(tool_calls[0])
727
+ _log_detail({
728
+ "event": "tool_result",
729
+ "round": tool_rounds,
730
+ "name": tool_calls[0].function.name,
731
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
732
+ "result_len": len(result),
733
+ })
734
+ self.messages.append({
735
+ "role": "tool",
736
+ "tool_call_id": tool_calls[0].id,
737
+ "content": result,
738
+ })
739
+ else:
740
+ t0 = time.monotonic()
741
+ results: dict[str, str] = {}
742
+ with ThreadPoolExecutor(max_workers=min(len(tool_calls), 8)) as pool:
743
+ futures = {
744
+ pool.submit(self._execute_tool_call, tc): tc
745
+ for tc in tool_calls
746
+ }
747
+ for future in as_completed(futures):
748
+ tc = futures[future]
749
+ try:
750
+ results[tc.id] = future.result()
751
+ except Exception as exc:
752
+ results[tc.id] = f"Error: {exc}"
753
+ _log_detail({
754
+ "event": "tool_results_parallel",
755
+ "round": tool_rounds,
756
+ "count": len(tool_calls),
757
+ "elapsed_ms": int((time.monotonic() - t0) * 1000),
758
+ "tools": [tc.function.name for tc in tool_calls],
759
+ })
760
+ for tc in tool_calls:
761
+ self.messages.append({
762
+ "role": "tool",
763
+ "tool_call_id": tc.id,
764
+ "content": results[tc.id],
765
+ })
766
+
767
+ for injection in self.pending_injections:
768
+ self.messages.append({"role": "system", "content": injection})
769
+ self.pending_injections = []
770
+
771
+ except Exception as exc:
772
+ logger.exception("Critical error in Agent.chat()")
773
+ return f"Error: {exc}"