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,622 @@
1
+ """
2
+ Built-in tool implementations and OpenAI-compatible schemas.
3
+
4
+ Structure
5
+ ---------
6
+ PRIMITIVE_TOOLS — run_command / read_file / write_file / list_files (always available)
7
+ SKILL_TOOLS — use_skill / list_skill_resources (always available)
8
+ META_SKILL_TOOLS — create_skill (always available — "god mode" skill creation)
9
+ MEMORY_TOOLS — remember / recall (always available)
10
+ WEB_SEARCH_TOOL — web_search (only when Tavily API key is configured)
11
+ KNOWLEDGE_TOOL — consult_knowledge_base (only when a RAG index is loaded)
12
+ CRON_TOOLS — cron_add / cron_remove / cron_list (only when CronScheduler is injected)
13
+
14
+ Agent._build_tools() assembles the right subset per session.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import os
21
+ import subprocess
22
+ import sys
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ # ── Virtual-environment detection ─────────────────────────────────────────────
28
+
29
+ _venv_dir: str | None = None
30
+
31
+
32
+ def _detect_venv() -> str | None:
33
+ """Find the project's virtual environment directory.
34
+
35
+ Priority:
36
+ 1. Already running inside a venv (sys.prefix != sys.base_prefix)
37
+ 2. .venv/ in CWD
38
+ 3. venv/ in CWD
39
+ """
40
+ if sys.prefix != sys.base_prefix:
41
+ return sys.prefix
42
+
43
+ for name in (".venv", "venv"):
44
+ candidate = os.path.join(os.getcwd(), name)
45
+ python = os.path.join(candidate, "bin", "python")
46
+ if os.path.isfile(python):
47
+ return candidate
48
+
49
+ return None
50
+
51
+
52
+ def _venv_python() -> str:
53
+ """Return the Python executable inside the detected venv, or sys.executable."""
54
+ venv = _venv_dir or _detect_venv()
55
+ if venv:
56
+ candidate = os.path.join(venv, "bin", "python")
57
+ if os.path.isfile(candidate):
58
+ return candidate
59
+ return sys.executable
60
+
61
+
62
+ def _venv_env() -> dict[str, str]:
63
+ """Build an env dict that activates the project venv for subprocesses."""
64
+ env = os.environ.copy()
65
+ venv = _venv_dir or _detect_venv()
66
+ if venv:
67
+ venv_bin = os.path.join(venv, "bin")
68
+ env["VIRTUAL_ENV"] = venv
69
+ env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
70
+ env.pop("PYTHONHOME", None)
71
+ else:
72
+ python_dir = os.path.dirname(sys.executable)
73
+ env["PATH"] = f"{python_dir}{os.pathsep}{env.get('PATH', '')}"
74
+ return env
75
+
76
+
77
+ def configure_venv(venv_dir: str | None = None) -> str | None:
78
+ """Explicitly set or auto-detect the venv. Called by Agent.__init__."""
79
+ global _venv_dir
80
+ if venv_dir:
81
+ _venv_dir = os.path.realpath(venv_dir)
82
+ else:
83
+ _venv_dir = _detect_venv()
84
+ if _venv_dir:
85
+ logger.info("[tools] Using venv: %s", _venv_dir)
86
+ return _venv_dir
87
+
88
+
89
+ # ── Sandbox (path restriction) ───────────────────────────────────────────────
90
+
91
+ _sandbox_roots: list[str] = []
92
+
93
+
94
+ def set_sandbox(roots: list[str]) -> None:
95
+ """Configure the allowed root directories for file-write operations.
96
+
97
+ Called by Agent.__init__ to restrict write_file / create_skill to the
98
+ project's working tree. An empty list disables sandboxing (not recommended).
99
+ """
100
+ _sandbox_roots.clear()
101
+ for r in roots:
102
+ _sandbox_roots.append(os.path.realpath(r))
103
+
104
+
105
+ def _resolve_in_sandbox(path: str) -> str:
106
+ """Resolve *path* to an absolute real path and verify it lives inside the sandbox.
107
+
108
+ Returns the resolved path on success.
109
+ Raises ``PermissionError`` if the path escapes every sandbox root.
110
+ """
111
+ resolved = os.path.realpath(os.path.abspath(path))
112
+
113
+ if not _sandbox_roots:
114
+ return resolved
115
+
116
+ for root in _sandbox_roots:
117
+ if resolved == root or resolved.startswith(root + os.sep):
118
+ return resolved
119
+
120
+ raise PermissionError(
121
+ f"Path '{path}' (resolved to '{resolved}') is outside the allowed directories: "
122
+ + ", ".join(_sandbox_roots)
123
+ )
124
+
125
+
126
+ def _sanitize_filename(name: str) -> str:
127
+ """Strip path separators and '..' segments from a filename."""
128
+ name = name.replace("..", "").replace("/", "").replace("\\", "")
129
+ if not name:
130
+ raise ValueError("Empty or invalid filename after sanitization.")
131
+ return name
132
+
133
+
134
+ # ── Primitive tool implementations ────────────────────────────────────────────
135
+
136
+ def run_command(command: str) -> str:
137
+ """Execute a shell command and return combined stdout/stderr.
138
+
139
+ The command inherits the project's virtual environment so that
140
+ ``python``, ``pip``, and any installed CLI tools resolve correctly.
141
+ """
142
+ try:
143
+ result = subprocess.run(
144
+ command, shell=True, capture_output=True, text=True,
145
+ timeout=60, env=_venv_env(),
146
+ )
147
+ return result.stdout if result.returncode == 0 else f"Error (exit {result.returncode}):\n{result.stderr}"
148
+ except Exception as exc:
149
+ return f"Execution error: {exc}"
150
+
151
+
152
+ def read_file(path: str) -> str:
153
+ """Read and return the contents of a file."""
154
+ try:
155
+ if not os.path.exists(path):
156
+ return f"Error: '{path}' not found."
157
+ with open(path, "r", encoding="utf-8") as f:
158
+ return f.read()
159
+ except Exception as exc:
160
+ return f"Read error: {exc}"
161
+
162
+
163
+ def write_file(path: str, content: str) -> str:
164
+ """Write content to a file, creating parent directories as needed.
165
+
166
+ Writes are restricted to sandbox directories (configured via set_sandbox).
167
+ """
168
+ try:
169
+ resolved = _resolve_in_sandbox(path)
170
+ parent = os.path.dirname(resolved)
171
+ if parent:
172
+ os.makedirs(parent, exist_ok=True)
173
+ with open(resolved, "w", encoding="utf-8") as f:
174
+ f.write(content)
175
+ return f"Written {len(content)} chars to {path}"
176
+ except PermissionError as exc:
177
+ return f"Blocked: {exc}"
178
+ except Exception as exc:
179
+ return f"Write error: {exc}"
180
+
181
+
182
+ def list_files(path: str = ".") -> str:
183
+ """List files in a directory, one per line."""
184
+ try:
185
+ return "\n".join(sorted(os.listdir(path)))
186
+ except Exception as exc:
187
+ return f"List error: {exc}"
188
+
189
+
190
+ AVAILABLE_TOOLS: dict[str, callable] = {
191
+ "run_command": run_command,
192
+ "read_file": read_file,
193
+ "write_file": write_file,
194
+ "list_files": list_files,
195
+ }
196
+
197
+
198
+
199
+ # ── Schema helpers ────────────────────────────────────────────────────────────
200
+
201
+ def _fn(name: str, description: str, properties: dict, required: list[str]) -> dict:
202
+ return {
203
+ "type": "function",
204
+ "function": {
205
+ "name": name,
206
+ "description": description,
207
+ "parameters": {
208
+ "type": "object",
209
+ "properties": properties,
210
+ "required": required,
211
+ },
212
+ },
213
+ }
214
+
215
+
216
+ # ── Primitive tool schemas ────────────────────────────────────────────────────
217
+
218
+
219
+
220
+ PRIMITIVE_TOOLS: list[dict] = [
221
+ _fn(
222
+ "run_command",
223
+ "Execute a shell command. Use to run scripts, install packages, or perform system operations.",
224
+ {"command": {"type": "string", "description": "The shell command to execute."}},
225
+ ["command"],
226
+ ),
227
+ _fn(
228
+ "read_file",
229
+ "Read the contents of a file. Use to inspect code, logs, or data.",
230
+ {"path": {"type": "string", "description": "Path to the file."}},
231
+ ["path"],
232
+ ),
233
+ _fn(
234
+ "write_file",
235
+ "Write content to a file (must be within the project directory). Creates parent directories automatically.",
236
+ {
237
+ "path": {"type": "string", "description": "Path to the file to write (must be within project root)."},
238
+ "content": {"type": "string", "description": "The content to write."},
239
+ },
240
+ ["path", "content"],
241
+ ),
242
+ _fn(
243
+ "list_files",
244
+ "List files in a directory. Use to discover available scripts or files.",
245
+ {"path": {"type": "string", "description": "Directory path (defaults to '.').", "default": "."}},
246
+ [],
247
+ ),
248
+ ]
249
+
250
+
251
+ # ── Skill tool schemas ───────────────────────────────────────────────────────
252
+ # Level 2: Agent triggers a skill to load its full instructions into context.
253
+ # Level 3: Agent reads/runs bundled resources via read_file / run_command.
254
+
255
+ SKILL_TOOLS: list[dict] = [
256
+ _fn(
257
+ "use_skill",
258
+ (
259
+ "Activate a skill by name. "
260
+ "This loads the skill's detailed instructions and workflow into context. "
261
+ "Only call this when you've identified the right skill from the catalog "
262
+ "in the system prompt."
263
+ ),
264
+ {"skill_name": {"type": "string", "description": "Exact skill name from the catalog."}},
265
+ ["skill_name"],
266
+ ),
267
+ _fn(
268
+ "list_skill_resources",
269
+ (
270
+ "List resource files bundled with a skill (scripts, schemas, reference docs). "
271
+ "Use after activating a skill to discover what files are available."
272
+ ),
273
+ {"skill_name": {"type": "string", "description": "Name of the activated skill."}},
274
+ ["skill_name"],
275
+ ),
276
+ ]
277
+
278
+
279
+ # ── Memory tool schemas ──────────────────────────────────────────────────────
280
+
281
+ MEMORY_TOOLS: list[dict] = [
282
+ _fn(
283
+ "remember",
284
+ "Store a piece of information in long-term memory.",
285
+ {
286
+ "key": {"type": "string", "description": "Topic or category to store under."},
287
+ "content": {"type": "string", "description": "The information to remember."},
288
+ },
289
+ ["key", "content"],
290
+ ),
291
+ _fn(
292
+ "recall",
293
+ (
294
+ "Search long-term memory using semantic + keyword retrieval. "
295
+ "Pass a descriptive query to get the most relevant memories. "
296
+ "Use query='*' to retrieve ALL memories."
297
+ ),
298
+ {"query": {"type": "string", "description": "Topic or question to search memory for. Use '*' for all memories."}},
299
+ ["query"],
300
+ ),
301
+ ]
302
+
303
+
304
+ # ── Web search tool (Tavily) ──────────────────────────────────────────────────
305
+
306
+ _tavily_client = None
307
+ _tavily_api_key = None
308
+
309
+
310
+ def _get_tavily_client():
311
+ """Return a cached TavilyClient, rebuilding only when the API key changes."""
312
+ global _tavily_client, _tavily_api_key
313
+ from .. import config
314
+ api_key = config.get_str("tavily", "apiKey", env="TAVILY_API_KEY")
315
+ if not api_key:
316
+ return None
317
+ if _tavily_client is None or _tavily_api_key != api_key:
318
+ from tavily import TavilyClient
319
+ _tavily_client = TavilyClient(api_key)
320
+ _tavily_api_key = api_key
321
+ return _tavily_client
322
+
323
+
324
+ def web_search(
325
+ query: str,
326
+ *,
327
+ search_depth: str = "basic",
328
+ topic: str = "general",
329
+ max_results: int = 3,
330
+ time_range: str | None = None,
331
+ include_domains: list[str] | None = None,
332
+ exclude_domains: list[str] | None = None,
333
+ ) -> str:
334
+ """Search the web using the Tavily API and return formatted results."""
335
+ try:
336
+ from tavily import TavilyClient # noqa: F401
337
+ except ImportError:
338
+ return (
339
+ "Error: tavily-python is not installed. "
340
+ "Install it with: pip install tavily-python"
341
+ )
342
+
343
+ client = _get_tavily_client()
344
+ if client is None:
345
+ return "Error: Tavily API key not configured (set TAVILY_API_KEY or tavily.apiKey in pythonclaw.json)"
346
+
347
+ try:
348
+ kwargs: dict = {
349
+ "query": query,
350
+ "search_depth": search_depth,
351
+ "topic": topic,
352
+ "max_results": max_results,
353
+ "include_answer": True,
354
+ }
355
+ if time_range:
356
+ kwargs["time_range"] = time_range
357
+ if include_domains:
358
+ kwargs["include_domains"] = include_domains
359
+ if exclude_domains:
360
+ kwargs["exclude_domains"] = exclude_domains
361
+
362
+ response = client.search(**kwargs)
363
+ except Exception as exc:
364
+ logger.warning("[web_search] Tavily API error: %s", exc)
365
+ return f"Web search error: {exc}"
366
+
367
+ parts: list[str] = []
368
+
369
+ answer = response.get("answer")
370
+ if answer:
371
+ parts.append(f"**Summary:** {answer}\n")
372
+
373
+ results = response.get("results", [])
374
+ if results:
375
+ parts.append("**Sources:**")
376
+ for i, r in enumerate(results, 1):
377
+ title = r.get("title", "Untitled")
378
+ url = r.get("url", "")
379
+ content = r.get("content", "")
380
+ if len(content) > 300:
381
+ content = content[:300] + "..."
382
+ parts.append(f"\n{i}. [{title}]({url})")
383
+ if content:
384
+ parts.append(f" {content}")
385
+
386
+ if not parts:
387
+ return "No results found."
388
+
389
+ return "\n".join(parts)
390
+
391
+
392
+ AVAILABLE_TOOLS["web_search"] = web_search
393
+
394
+
395
+ WEB_SEARCH_TOOL: dict = _fn(
396
+ "web_search",
397
+ (
398
+ "Search the web for real-time information using the Tavily API. "
399
+ "Use this when you need up-to-date information, current events, "
400
+ "facts you're unsure about, or anything that benefits from live web data."
401
+ ),
402
+ {
403
+ "query": {
404
+ "type": "string",
405
+ "description": "The search query. Be specific for better results.",
406
+ },
407
+ "search_depth": {
408
+ "type": "string",
409
+ "enum": ["basic", "advanced"],
410
+ "description": "Search depth: 'basic' (fast) or 'advanced' (more thorough).",
411
+ "default": "basic",
412
+ },
413
+ "topic": {
414
+ "type": "string",
415
+ "enum": ["general", "news", "finance"],
416
+ "description": "Search category: 'general', 'news', or 'finance'.",
417
+ "default": "general",
418
+ },
419
+ "max_results": {
420
+ "type": "integer",
421
+ "description": "Number of results to return (1-10). Use 2-3 for most queries.",
422
+ "default": 3,
423
+ },
424
+ "time_range": {
425
+ "type": "string",
426
+ "enum": ["day", "week", "month", "year"],
427
+ "description": "Filter results by recency. Omit for no time filter.",
428
+ },
429
+ },
430
+ ["query"],
431
+ )
432
+
433
+
434
+ # ── Knowledge base tool schema (conditional) ─────────────────────────────────
435
+
436
+ KNOWLEDGE_TOOL: dict = _fn(
437
+ "consult_knowledge_base",
438
+ "Search the knowledge base for relevant information using hybrid retrieval.",
439
+ {"query": {"type": "string", "description": "Specific question or topic to look up."}},
440
+ ["query"],
441
+ )
442
+
443
+
444
+ # ── Meta-skill: create_skill ("God Mode") ────────────────────────────────────
445
+
446
+ def create_skill(
447
+ name: str,
448
+ description: str,
449
+ instructions: str,
450
+ category: str = "",
451
+ resources: dict[str, str] | None = None,
452
+ dependencies: list[str] | None = None,
453
+ ) -> str:
454
+ """Create a new skill on disk and install its dependencies.
455
+
456
+ This is the "god mode" tool — the agent uses it to extend its own
457
+ capabilities at runtime. After creation, the caller must invalidate
458
+ the SkillRegistry cache so the new skill appears in the catalog.
459
+
460
+ All paths are validated against the sandbox. Resource filenames are
461
+ sanitized to prevent directory traversal.
462
+ """
463
+ skills_dir = os.path.join("context", "skills")
464
+ _resolve_in_sandbox(skills_dir)
465
+ os.makedirs(skills_dir, exist_ok=True)
466
+
467
+ # Build target directory (sanitize name and category)
468
+ safe_name = _sanitize_filename(name.replace(" ", "_").lower())
469
+ if category:
470
+ safe_category = _sanitize_filename(category.replace(" ", "_").lower())
471
+ skill_dir = os.path.join(skills_dir, safe_category, safe_name)
472
+ cat_dir = os.path.join(skills_dir, safe_category)
473
+ cat_md = os.path.join(cat_dir, "CATEGORY.md")
474
+ if not os.path.isfile(cat_md):
475
+ os.makedirs(cat_dir, exist_ok=True)
476
+ with open(cat_md, "w", encoding="utf-8") as f:
477
+ f.write(f"---\nname: {safe_category}\ndescription: Auto-created category for {category} skills.\n---\n")
478
+ else:
479
+ skill_dir = os.path.join(skills_dir, safe_name)
480
+
481
+ _resolve_in_sandbox(skill_dir)
482
+ os.makedirs(skill_dir, exist_ok=True)
483
+
484
+ # Write SKILL.md
485
+ skill_md_content = (
486
+ f"---\nname: {safe_name}\n"
487
+ f"description: >\n {description}\n"
488
+ f"---\n\n{instructions}\n"
489
+ )
490
+ skill_md_path = os.path.join(skill_dir, "SKILL.md")
491
+ with open(skill_md_path, "w", encoding="utf-8") as f:
492
+ f.write(skill_md_content)
493
+
494
+ # Write resource files (filenames are sanitized to prevent traversal)
495
+ written_files = ["SKILL.md"]
496
+ if resources:
497
+ for filename, content in resources.items():
498
+ safe_fn = _sanitize_filename(filename)
499
+ fpath = os.path.join(skill_dir, safe_fn)
500
+ _resolve_in_sandbox(fpath)
501
+ with open(fpath, "w", encoding="utf-8") as f:
502
+ f.write(content)
503
+ if safe_fn.endswith((".sh", ".py")):
504
+ os.chmod(fpath, 0o755)
505
+ written_files.append(safe_fn)
506
+
507
+ # Install dependencies (into the project venv)
508
+ dep_results: list[str] = []
509
+ if dependencies:
510
+ pip_python = _venv_python()
511
+ for dep in dependencies:
512
+ try:
513
+ proc = subprocess.run(
514
+ [pip_python, "-m", "pip", "install", dep],
515
+ capture_output=True, text=True, timeout=120,
516
+ env=_venv_env(),
517
+ )
518
+ if proc.returncode == 0:
519
+ dep_results.append(f" ✓ {dep}")
520
+ else:
521
+ dep_results.append(f" ✗ {dep}: {proc.stderr.strip()}")
522
+ except Exception as exc:
523
+ dep_results.append(f" ✗ {dep}: {exc}")
524
+
525
+ # Build result summary
526
+ parts = [
527
+ f"Skill '{safe_name}' created at {skill_dir}/",
528
+ f"Files: {', '.join(written_files)}",
529
+ ]
530
+ if dep_results:
531
+ parts.append("Dependencies:\n" + "\n".join(dep_results))
532
+ parts.append("Registry will be refreshed — the skill is now available via use_skill().")
533
+
534
+ return "\n".join(parts)
535
+
536
+
537
+ AVAILABLE_TOOLS["create_skill"] = create_skill
538
+
539
+
540
+ META_SKILL_TOOLS: list[dict] = [
541
+ _fn(
542
+ "create_skill",
543
+ (
544
+ "Create a brand-new skill on the fly when no existing skill can handle the user's request. "
545
+ "This writes a SKILL.md and optional resource scripts to the skills directory, "
546
+ "installs pip dependencies, and makes the skill immediately available. "
547
+ "Use this when you need a capability that doesn't exist yet."
548
+ ),
549
+ {
550
+ "name": {
551
+ "type": "string",
552
+ "description": "Skill name (lowercase, underscores). E.g. 'weather_forecast'.",
553
+ },
554
+ "description": {
555
+ "type": "string",
556
+ "description": "One-line description of what the skill does and when to use it.",
557
+ },
558
+ "instructions": {
559
+ "type": "string",
560
+ "description": (
561
+ "Full Markdown instructions for the skill body (the content after the YAML frontmatter). "
562
+ "Include ## Instructions, usage examples, and ## Resources sections."
563
+ ),
564
+ },
565
+ "category": {
566
+ "type": "string",
567
+ "description": "Optional category folder (e.g. 'data', 'dev', 'web'). Empty for flat layout.",
568
+ "default": "",
569
+ },
570
+ "resources": {
571
+ "type": "object",
572
+ "description": (
573
+ "Map of filename → file content for bundled scripts. "
574
+ "E.g. {\"fetch.py\": \"import requests\\n...\", \"config.yaml\": \"...\"}."
575
+ ),
576
+ "additionalProperties": {"type": "string"},
577
+ },
578
+ "dependencies": {
579
+ "type": "array",
580
+ "items": {"type": "string"},
581
+ "description": "List of pip packages to install. E.g. [\"requests\", \"beautifulsoup4\"].",
582
+ },
583
+ },
584
+ ["name", "description", "instructions"],
585
+ ),
586
+ ]
587
+
588
+
589
+ # ── Cron tool schemas (conditional) ──────────────────────────────────────────
590
+
591
+ CRON_TOOLS: list[dict] = [
592
+ _fn(
593
+ "cron_add",
594
+ (
595
+ "Schedule a recurring LLM task. "
596
+ "Use standard 5-field cron syntax: 'min hour day month weekday'. "
597
+ "Example: '0 9 * * *' = 9 am daily."
598
+ ),
599
+ {
600
+ "job_id": {"type": "string", "description": "Unique job identifier (no spaces)."},
601
+ "cron": {"type": "string", "description": "5-field cron expression, e.g. '0 9 * * *'."},
602
+ "prompt": {"type": "string", "description": "The prompt the agent will run on each trigger."},
603
+ "deliver_to_chat_id": {
604
+ "type": "integer",
605
+ "description": "Optional Telegram chat_id to deliver the result to.",
606
+ },
607
+ },
608
+ ["job_id", "cron", "prompt"],
609
+ ),
610
+ _fn(
611
+ "cron_remove",
612
+ "Remove a previously scheduled cron job by its ID.",
613
+ {"job_id": {"type": "string", "description": "The job ID to remove."}},
614
+ ["job_id"],
615
+ ),
616
+ _fn(
617
+ "cron_list",
618
+ "List all currently scheduled cron jobs (both static and dynamic).",
619
+ {},
620
+ [],
621
+ ),
622
+ ]
@@ -0,0 +1,64 @@
1
+ """
2
+ Shared utilities for pythonclaw.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ def parse_frontmatter(content: str) -> tuple[dict, str]:
9
+ """
10
+ Parse YAML-style frontmatter delimited by '---' from *content*.
11
+
12
+ Returns (metadata_dict, body_string).
13
+ If no frontmatter is found, returns ({}, content).
14
+
15
+ Supports:
16
+ - Simple ``key: value`` pairs
17
+ - YAML block scalars (``>``, ``|``) with indented continuation lines
18
+ - Bare multi-line values (indented continuation lines without ``>`` / ``|``)
19
+ """
20
+ if not content.startswith("---"):
21
+ return {}, content
22
+
23
+ parts = content.split("---", 2)
24
+ if len(parts) < 3:
25
+ return {}, content
26
+
27
+ metadata: dict[str, str] = {}
28
+ current_key: str | None = None
29
+ current_lines: list[str] = []
30
+ block_mode: str | None = None # ">" (folded) or "|" (literal)
31
+
32
+ def _flush() -> None:
33
+ if current_key is not None and current_lines:
34
+ text = " ".join(current_lines) if block_mode == ">" else "\n".join(current_lines)
35
+ metadata[current_key] = text.strip()
36
+
37
+ for line in parts[1].strip().splitlines():
38
+ stripped = line.strip()
39
+
40
+ # Continuation line (starts with whitespace and we have a current key)
41
+ if line and line[0] in (" ", "\t") and current_key is not None:
42
+ current_lines.append(stripped)
43
+ continue
44
+
45
+ # New key: value pair
46
+ if ":" in stripped:
47
+ _flush()
48
+ key, _, value = stripped.partition(":")
49
+ current_key = key.strip()
50
+ value = value.strip()
51
+
52
+ if value in (">", "|"):
53
+ block_mode = value
54
+ current_lines = []
55
+ elif value:
56
+ block_mode = None
57
+ current_lines = [value]
58
+ else:
59
+ block_mode = None
60
+ current_lines = []
61
+
62
+ _flush()
63
+
64
+ return metadata, parts[2].strip()