pythonclaw 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. pythonclaw/__init__.py +17 -0
  2. pythonclaw/__main__.py +6 -0
  3. pythonclaw/channels/discord_bot.py +231 -0
  4. pythonclaw/channels/telegram_bot.py +236 -0
  5. pythonclaw/config.py +190 -0
  6. pythonclaw/core/__init__.py +25 -0
  7. pythonclaw/core/agent.py +773 -0
  8. pythonclaw/core/compaction.py +220 -0
  9. pythonclaw/core/knowledge/rag.py +93 -0
  10. pythonclaw/core/llm/anthropic_client.py +107 -0
  11. pythonclaw/core/llm/base.py +26 -0
  12. pythonclaw/core/llm/gemini_client.py +139 -0
  13. pythonclaw/core/llm/openai_compatible.py +39 -0
  14. pythonclaw/core/llm/response.py +57 -0
  15. pythonclaw/core/memory/manager.py +120 -0
  16. pythonclaw/core/memory/storage.py +164 -0
  17. pythonclaw/core/persistent_agent.py +103 -0
  18. pythonclaw/core/retrieval/__init__.py +6 -0
  19. pythonclaw/core/retrieval/chunker.py +78 -0
  20. pythonclaw/core/retrieval/dense.py +152 -0
  21. pythonclaw/core/retrieval/fusion.py +51 -0
  22. pythonclaw/core/retrieval/reranker.py +112 -0
  23. pythonclaw/core/retrieval/retriever.py +166 -0
  24. pythonclaw/core/retrieval/sparse.py +69 -0
  25. pythonclaw/core/session_store.py +269 -0
  26. pythonclaw/core/skill_loader.py +322 -0
  27. pythonclaw/core/skillhub.py +290 -0
  28. pythonclaw/core/tools.py +622 -0
  29. pythonclaw/core/utils.py +64 -0
  30. pythonclaw/daemon.py +221 -0
  31. pythonclaw/init.py +61 -0
  32. pythonclaw/main.py +489 -0
  33. pythonclaw/onboard.py +290 -0
  34. pythonclaw/scheduler/cron.py +310 -0
  35. pythonclaw/scheduler/heartbeat.py +178 -0
  36. pythonclaw/server.py +145 -0
  37. pythonclaw/session_manager.py +104 -0
  38. pythonclaw/templates/persona/demo_persona.md +2 -0
  39. pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
  40. pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
  41. pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
  42. pythonclaw/templates/skills/communication/email/send_email.py +88 -0
  43. pythonclaw/templates/skills/data/CATEGORY.md +4 -0
  44. pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
  45. pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
  46. pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
  47. pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
  48. pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
  49. pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
  50. pythonclaw/templates/skills/data/news/SKILL.md +39 -0
  51. pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
  52. pythonclaw/templates/skills/data/news/search_news.py +57 -0
  53. pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
  54. pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
  55. pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
  56. pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
  57. pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
  58. pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
  59. pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
  60. pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
  61. pythonclaw/templates/skills/data/weather/weather.py +142 -0
  62. pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
  63. pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
  64. pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
  65. pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
  66. pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
  67. pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
  68. pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
  69. pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
  70. pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
  71. pythonclaw/templates/skills/dev/github/gh.py +165 -0
  72. pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
  73. pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
  74. pythonclaw/templates/skills/dev/http_request/request.py +90 -0
  75. pythonclaw/templates/skills/google/CATEGORY.md +4 -0
  76. pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
  77. pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
  78. pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
  79. pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
  80. pythonclaw/templates/skills/system/CATEGORY.md +4 -0
  81. pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
  82. pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
  83. pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
  84. pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
  85. pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
  86. pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
  87. pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
  88. pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
  89. pythonclaw/templates/skills/system/random/SKILL.md +33 -0
  90. pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
  91. pythonclaw/templates/skills/system/random/random_util.py +45 -0
  92. pythonclaw/templates/skills/system/time/SKILL.md +33 -0
  93. pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
  94. pythonclaw/templates/skills/system/time/time_util.py +81 -0
  95. pythonclaw/templates/skills/text/CATEGORY.md +4 -0
  96. pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
  97. pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
  98. pythonclaw/templates/skills/text/translator/translate.py +66 -0
  99. pythonclaw/templates/skills/web/CATEGORY.md +4 -0
  100. pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
  101. pythonclaw/templates/soul/SOUL.md +54 -0
  102. pythonclaw/web/__init__.py +1 -0
  103. pythonclaw/web/app.py +585 -0
  104. pythonclaw/web/static/favicon.png +0 -0
  105. pythonclaw/web/static/index.html +1318 -0
  106. pythonclaw/web/static/logo.png +0 -0
  107. pythonclaw-0.2.0.dist-info/METADATA +410 -0
  108. pythonclaw-0.2.0.dist-info/RECORD +112 -0
  109. pythonclaw-0.2.0.dist-info/WHEEL +5 -0
  110. pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
  111. pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
  112. pythonclaw-0.2.0.dist-info/top_level.txt +1 -0
pythonclaw/web/app.py ADDED
@@ -0,0 +1,585 @@
1
+ """
2
+ FastAPI application for the PythonClaw Web Dashboard.
3
+
4
+ Provides REST endpoints for config/skills/status inspection, a config
5
+ save endpoint for editing settings from the browser, and a WebSocket
6
+ endpoint for real-time chat with the agent.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import copy
13
+ import json
14
+ import logging
15
+ import os
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
21
+ from fastapi.responses import HTMLResponse, JSONResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+
24
+ from .. import config
25
+ from ..core.agent import Agent
26
+ from ..core.persistent_agent import PersistentAgent
27
+ from ..core.session_store import SessionStore
28
+ from ..core.llm.base import LLMProvider
29
+ from ..core.skill_loader import SkillRegistry
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ STATIC_DIR = Path(__file__).parent / "static"
34
+
35
+ _agent: Agent | None = None
36
+ _provider: LLMProvider | None = None
37
+ _store: SessionStore | None = None
38
+ _start_time: float = 0.0
39
+ _build_provider_fn = None
40
+
41
+ WEB_SESSION_ID = "web:dashboard"
42
+
43
+
44
+ def create_app(provider: LLMProvider | None, *, build_provider_fn=None) -> FastAPI:
45
+ """Build and return the FastAPI app.
46
+
47
+ Parameters
48
+ ----------
49
+ provider : LLM provider (may be None if not yet configured)
50
+ build_provider_fn : callable that rebuilds the provider from config
51
+ (used after config save to hot-reload the provider)
52
+ """
53
+ global _provider, _store, _start_time, _build_provider_fn
54
+ _provider = provider
55
+ _store = SessionStore()
56
+ _start_time = time.time()
57
+ _build_provider_fn = build_provider_fn
58
+
59
+ app = FastAPI(title="PythonClaw Dashboard", docs_url=None, redoc_url=None)
60
+
61
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
62
+
63
+ app.add_api_route("/", _serve_index, methods=["GET"], response_class=HTMLResponse)
64
+ app.add_api_route("/api/config", _api_config_get, methods=["GET"])
65
+ app.add_api_route("/api/config", _api_config_save, methods=["POST"])
66
+ app.add_api_route("/api/skills", _api_skills, methods=["GET"])
67
+ app.add_api_route("/api/status", _api_status, methods=["GET"])
68
+ app.add_api_route("/api/memories", _api_memories, methods=["GET"])
69
+ app.add_api_route("/api/identity", _api_identity, methods=["GET"])
70
+ app.add_api_route("/api/identity/soul", _api_save_soul, methods=["POST"])
71
+ app.add_api_route("/api/identity/persona", _api_save_persona, methods=["POST"])
72
+ app.add_api_route("/api/transcribe", _api_transcribe, methods=["POST"])
73
+ app.add_api_route("/api/skillhub/search", _api_skillhub_search, methods=["POST"])
74
+ app.add_api_route("/api/skillhub/browse", _api_skillhub_browse, methods=["GET"])
75
+ app.add_api_route("/api/skillhub/install", _api_skillhub_install, methods=["POST"])
76
+ app.add_websocket_route("/ws/chat", _ws_chat)
77
+
78
+ return app
79
+
80
+
81
+ def _get_agent() -> Agent | None:
82
+ """Lazy-init the shared web agent with persistent sessions."""
83
+ global _agent
84
+ if _agent is not None:
85
+ return _agent
86
+ if _provider is None:
87
+ return None
88
+ try:
89
+ verbose = config.get("agent", "verbose", default=False)
90
+ _agent = PersistentAgent(
91
+ provider=_provider,
92
+ verbose=bool(verbose),
93
+ store=_store,
94
+ session_id=WEB_SESSION_ID,
95
+ )
96
+ except Exception as exc:
97
+ logger.warning("[Web] Agent init failed: %s", exc)
98
+ return None
99
+ return _agent
100
+
101
+
102
+ def _reset_agent() -> None:
103
+ """Discard the current agent so the next call rebuilds it."""
104
+ global _agent
105
+ _agent = None
106
+
107
+
108
+ # ── HTML ──────────────────────────────────────────────────────────────────────
109
+
110
+ async def _serve_index():
111
+ index_path = STATIC_DIR / "index.html"
112
+ return HTMLResponse(index_path.read_text(encoding="utf-8"))
113
+
114
+
115
+ # ── REST API ──────────────────────────────────────────────────────────────────
116
+
117
+ def _mask_secrets(obj: Any, _parent_key: str = "") -> Any:
118
+ """Recursively mask values whose key contains 'apikey' or 'token'."""
119
+ if isinstance(obj, dict):
120
+ return {k: _mask_secrets(v, k) for k, v in obj.items()}
121
+ if isinstance(obj, list):
122
+ return [_mask_secrets(v) for v in obj]
123
+ if isinstance(obj, str) and obj:
124
+ key_lower = _parent_key.lower()
125
+ if any(s in key_lower for s in ("apikey", "token", "secret", "password")):
126
+ if len(obj) > 8:
127
+ return obj[:4] + "*" * (len(obj) - 8) + obj[-4:]
128
+ return "****"
129
+ return obj
130
+
131
+
132
+ def _secret_keys_present(obj: Any, _parent_key: str = "") -> dict[str, str]:
133
+ """Walk config and return a flat map of dotted-key → value for secret fields."""
134
+ result: dict[str, str] = {}
135
+ if isinstance(obj, dict):
136
+ for k, v in obj.items():
137
+ full = f"{_parent_key}.{k}" if _parent_key else k
138
+ if isinstance(v, (dict, list)):
139
+ result.update(_secret_keys_present(v, full))
140
+ elif isinstance(v, str) and v:
141
+ if any(s in k.lower() for s in ("apikey", "token", "secret", "password")):
142
+ result[full] = v
143
+ return result
144
+
145
+
146
+ _MASKED_PLACEHOLDER = "••••••••"
147
+
148
+
149
+ async def _api_config_get():
150
+ raw = config.as_dict()
151
+ masked = _mask_secrets(copy.deepcopy(raw))
152
+ cfg_path = config.config_path()
153
+
154
+ # Build a list of which secret fields have a value set (without revealing them)
155
+ secrets_set = {k: True for k in _secret_keys_present(raw)}
156
+
157
+ return {
158
+ "config": masked,
159
+ "configPath": str(cfg_path) if cfg_path else None,
160
+ "providerReady": _provider is not None,
161
+ "secretsSet": secrets_set,
162
+ }
163
+
164
+
165
+ def _deep_set(d: dict, keys: list[str], value: Any) -> None:
166
+ """Set a value in a nested dict using a list of keys."""
167
+ for k in keys[:-1]:
168
+ d = d.setdefault(k, {})
169
+ d[keys[-1]] = value
170
+
171
+
172
+ def _deep_get_raw(d: dict, keys: list[str]) -> Any:
173
+ """Get a value from a nested dict using a list of keys."""
174
+ for k in keys:
175
+ if not isinstance(d, dict):
176
+ return None
177
+ d = d.get(k)
178
+ return d
179
+
180
+
181
+ async def _api_config_save(request: Request):
182
+ """Save new configuration to pythonclaw.json and hot-reload the provider.
183
+
184
+ Secret fields that arrive as the masked placeholder or empty string
185
+ are preserved from the existing config (not overwritten).
186
+ """
187
+ global _provider
188
+
189
+ try:
190
+ body = await request.json()
191
+ new_config = body.get("config")
192
+ if not isinstance(new_config, dict):
193
+ return JSONResponse({"ok": False, "error": "Invalid config object."}, status_code=400)
194
+ except Exception as exc:
195
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
196
+
197
+ # Merge: for any secret field that is still the placeholder or empty,
198
+ # keep the original value from the current config.
199
+ existing = config.as_dict()
200
+ existing_secrets = _secret_keys_present(existing)
201
+ for dotted_key, original_value in existing_secrets.items():
202
+ keys = dotted_key.split(".")
203
+ incoming = _deep_get_raw(new_config, keys)
204
+ if incoming is None or incoming == "" or incoming == _MASKED_PLACEHOLDER or "****" in str(incoming):
205
+ _deep_set(new_config, keys, original_value)
206
+
207
+ cfg_path = config.config_path()
208
+ if cfg_path is None:
209
+ cfg_path = Path.cwd() / "pythonclaw.json"
210
+
211
+ try:
212
+ json_text = json.dumps(new_config, indent=2, ensure_ascii=False)
213
+ cfg_path.write_text(json_text + "\n", encoding="utf-8")
214
+ except Exception as exc:
215
+ return JSONResponse({"ok": False, "error": f"Write failed: {exc}"}, status_code=500)
216
+
217
+ config.load(str(cfg_path), force=True)
218
+ logger.info("[Web] Config saved to %s", cfg_path)
219
+
220
+ _reset_agent()
221
+ if _build_provider_fn:
222
+ try:
223
+ _provider = _build_provider_fn()
224
+ logger.info("[Web] Provider rebuilt successfully.")
225
+ except Exception as exc:
226
+ logger.warning("[Web] Provider rebuild failed: %s", exc)
227
+ _provider = None
228
+
229
+ return {"ok": True, "configPath": str(cfg_path), "providerReady": _provider is not None}
230
+
231
+
232
+ async def _api_skills():
233
+ agent = _get_agent()
234
+ if agent is None:
235
+ try:
236
+ pkg_templates = os.path.join(
237
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
238
+ "templates", "skills",
239
+ )
240
+ skills_dirs = [pkg_templates, os.path.join("context", "skills")]
241
+ skills_dirs = [d for d in skills_dirs if os.path.isdir(d)]
242
+ registry = SkillRegistry(skills_dirs=skills_dirs)
243
+ skills_meta = registry.discover()
244
+ except Exception:
245
+ return {"total": 0, "categories": {}}
246
+ else:
247
+ registry = agent._registry
248
+ skills_meta = registry.discover()
249
+
250
+ categories: dict[str, list] = {}
251
+ for sm in skills_meta:
252
+ cat = sm.category or "uncategorised"
253
+ categories.setdefault(cat, []).append({
254
+ "name": sm.name,
255
+ "description": sm.description,
256
+ "category": cat,
257
+ "path": sm.path,
258
+ })
259
+
260
+ return {"total": len(skills_meta), "categories": categories}
261
+
262
+
263
+ async def _api_status():
264
+ uptime = int(time.time() - _start_time)
265
+ provider_name = config.get_str("llm", "provider", env="LLM_PROVIDER", default="deepseek")
266
+
267
+ agent = _get_agent()
268
+ if agent is None:
269
+ return {
270
+ "provider": "Not configured",
271
+ "providerName": provider_name,
272
+ "providerReady": False,
273
+ "skillsLoaded": 0,
274
+ "skillsTotal": 0,
275
+ "memoryCount": 0,
276
+ "historyLength": 0,
277
+ "compactionCount": 0,
278
+ "uptimeSeconds": uptime,
279
+ "webSearchEnabled": False,
280
+ }
281
+
282
+ session_file = _store._path(WEB_SESSION_ID) if _store else None
283
+ return {
284
+ "provider": type(agent.provider).__name__,
285
+ "providerName": provider_name,
286
+ "providerReady": True,
287
+ "skillsLoaded": len(agent.loaded_skill_names),
288
+ "skillsTotal": len(agent._registry.discover()),
289
+ "memoryCount": len(agent.memory.list_all()),
290
+ "historyLength": len(agent.messages),
291
+ "compactionCount": agent.compaction_count,
292
+ "uptimeSeconds": uptime,
293
+ "webSearchEnabled": agent._web_search_enabled,
294
+ "sessionFile": session_file,
295
+ "sessionPersistent": True,
296
+ }
297
+
298
+
299
+ async def _api_memories():
300
+ agent = _get_agent()
301
+ if agent is None:
302
+ return {"total": 0, "memories": []}
303
+ memories = agent.memory.list_all()
304
+ return {"total": len(memories), "memories": memories}
305
+
306
+
307
+ async def _api_identity():
308
+ """Return soul, persona content, and the full tool list."""
309
+ from ..core.tools import (
310
+ PRIMITIVE_TOOLS, SKILL_TOOLS, META_SKILL_TOOLS,
311
+ MEMORY_TOOLS, WEB_SEARCH_TOOL, KNOWLEDGE_TOOL, CRON_TOOLS,
312
+ )
313
+
314
+ def _read_md(directory: str) -> str | None:
315
+ p = Path(directory)
316
+ if p.is_file():
317
+ return p.read_text(encoding="utf-8").strip()
318
+ if p.is_dir():
319
+ for f in sorted(p.iterdir()):
320
+ if f.suffix in (".md", ".txt") and f.is_file():
321
+ return f.read_text(encoding="utf-8").strip()
322
+ return None
323
+
324
+ cwd = Path.cwd()
325
+ soul = _read_md(str(cwd / "context" / "soul"))
326
+ persona = _read_md(str(cwd / "context" / "persona"))
327
+
328
+ def _tool_info(schema: dict) -> dict:
329
+ fn = schema.get("function", {})
330
+ return {"name": fn.get("name", ""), "description": fn.get("description", "")}
331
+
332
+ tools = []
333
+ tool_groups = [
334
+ ("Primitive", PRIMITIVE_TOOLS),
335
+ ("Skills", SKILL_TOOLS),
336
+ ("Meta", META_SKILL_TOOLS),
337
+ ("Memory", MEMORY_TOOLS),
338
+ ("Cron", CRON_TOOLS),
339
+ ]
340
+ for group, schemas in tool_groups:
341
+ for s in schemas:
342
+ info = _tool_info(s)
343
+ info["group"] = group
344
+ tools.append(info)
345
+
346
+ tools.append({**_tool_info(WEB_SEARCH_TOOL), "group": "Search"})
347
+ tools.append({**_tool_info(KNOWLEDGE_TOOL), "group": "Knowledge"})
348
+
349
+ return {
350
+ "soul": soul,
351
+ "persona": persona,
352
+ "soulConfigured": soul is not None,
353
+ "personaConfigured": persona is not None,
354
+ "tools": tools,
355
+ }
356
+
357
+
358
+ async def _api_save_soul(request: Request):
359
+ """Save soul content to context/soul/SOUL.md and reload agent identity."""
360
+ try:
361
+ body = await request.json()
362
+ content = body.get("content", "").strip()
363
+ if not content:
364
+ return JSONResponse({"ok": False, "error": "Content cannot be empty."}, status_code=400)
365
+
366
+ soul_dir = Path.cwd() / "context" / "soul"
367
+ soul_dir.mkdir(parents=True, exist_ok=True)
368
+ soul_file = soul_dir / "SOUL.md"
369
+ soul_file.write_text(content + "\n", encoding="utf-8")
370
+ logger.info("[Web] Soul saved to %s", soul_file)
371
+
372
+ _reload_agent_identity()
373
+ return {"ok": True, "path": str(soul_file)}
374
+ except Exception as exc:
375
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
376
+
377
+
378
+ async def _api_save_persona(request: Request):
379
+ """Save persona content to context/persona/persona.md and reload agent identity."""
380
+ try:
381
+ body = await request.json()
382
+ content = body.get("content", "").strip()
383
+ if not content:
384
+ return JSONResponse({"ok": False, "error": "Content cannot be empty."}, status_code=400)
385
+
386
+ persona_dir = Path.cwd() / "context" / "persona"
387
+ persona_dir.mkdir(parents=True, exist_ok=True)
388
+ persona_file = persona_dir / "persona.md"
389
+ persona_file.write_text(content + "\n", encoding="utf-8")
390
+ logger.info("[Web] Persona saved to %s", persona_file)
391
+
392
+ _reload_agent_identity()
393
+ return {"ok": True, "path": str(persona_file)}
394
+ except Exception as exc:
395
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
396
+
397
+
398
+ async def _api_transcribe(request: Request):
399
+ """Proxy audio to Deepgram STT and return transcript."""
400
+ import urllib.request
401
+ import urllib.error
402
+
403
+ dg_key = config.get("deepgram", "apiKey", env="DEEPGRAM_API_KEY")
404
+ if not dg_key:
405
+ return JSONResponse(
406
+ {"ok": False, "error": "Deepgram API key not configured. Set it in Config."},
407
+ status_code=400,
408
+ )
409
+
410
+ content_type = request.headers.get("content-type", "audio/webm")
411
+ body = await request.body()
412
+ if not body:
413
+ return JSONResponse({"ok": False, "error": "No audio data received."}, status_code=400)
414
+
415
+ url = "https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true&detect_language=true"
416
+ req = urllib.request.Request(
417
+ url,
418
+ data=body,
419
+ headers={
420
+ "Authorization": f"Token {dg_key}",
421
+ "Content-Type": content_type,
422
+ },
423
+ method="POST",
424
+ )
425
+
426
+ try:
427
+ with urllib.request.urlopen(req, timeout=30) as resp:
428
+ result = json.loads(resp.read().decode("utf-8"))
429
+ except urllib.error.HTTPError as exc:
430
+ err_body = exc.read().decode("utf-8", errors="replace")
431
+ logger.warning("[Web] Deepgram error %s: %s", exc.code, err_body)
432
+ return JSONResponse(
433
+ {"ok": False, "error": f"Deepgram API error ({exc.code})"},
434
+ status_code=502,
435
+ )
436
+ except Exception as exc:
437
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
438
+
439
+ try:
440
+ transcript = (
441
+ result.get("results", {})
442
+ .get("channels", [{}])[0]
443
+ .get("alternatives", [{}])[0]
444
+ .get("transcript", "")
445
+ )
446
+ except (IndexError, KeyError):
447
+ transcript = ""
448
+
449
+ return {"ok": True, "transcript": transcript}
450
+
451
+
452
+ async def _api_skillhub_search(request: Request):
453
+ """Search SkillHub marketplace."""
454
+ from ..core import skillhub
455
+
456
+ try:
457
+ body = await request.json()
458
+ query = body.get("query", "").strip()
459
+ if not query:
460
+ return JSONResponse({"ok": False, "error": "Query is required."}, status_code=400)
461
+ limit = int(body.get("limit", 10))
462
+ category = body.get("category")
463
+ results = skillhub.search(query, limit=limit, category=category)
464
+ return {"ok": True, "results": results}
465
+ except RuntimeError as exc:
466
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
467
+ except Exception as exc:
468
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
469
+
470
+
471
+ async def _api_skillhub_browse(request: Request):
472
+ """Browse SkillHub catalog."""
473
+ from ..core import skillhub
474
+
475
+ try:
476
+ limit = int(request.query_params.get("limit", 20))
477
+ sort = request.query_params.get("sort", "score")
478
+ category = request.query_params.get("category")
479
+ results = skillhub.browse(limit=limit, sort=sort, category=category or None)
480
+ return {"ok": True, "results": results}
481
+ except RuntimeError as exc:
482
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
483
+ except Exception as exc:
484
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
485
+
486
+
487
+ async def _api_skillhub_install(request: Request):
488
+ """Install a skill from SkillHub."""
489
+ from ..core import skillhub
490
+
491
+ try:
492
+ body = await request.json()
493
+ skill_id = body.get("skill_id", "").strip()
494
+ if not skill_id:
495
+ return JSONResponse({"ok": False, "error": "skill_id is required."}, status_code=400)
496
+
497
+ path = skillhub.install_skill(skill_id)
498
+
499
+ agent = _get_agent()
500
+ if agent is not None:
501
+ agent._refresh_skill_registry()
502
+
503
+ return {"ok": True, "path": path, "message": f"Skill installed to {path}"}
504
+ except RuntimeError as exc:
505
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
506
+ except Exception as exc:
507
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=500)
508
+
509
+
510
+ def _reload_agent_identity() -> None:
511
+ """Reload the agent's soul/persona from disk without full reset."""
512
+ global _agent
513
+ if _agent is None:
514
+ return
515
+ from ..core.agent import _load_text_dir_or_file
516
+ cwd = Path.cwd()
517
+ _agent.soul_instruction = _load_text_dir_or_file(
518
+ str(cwd / "context" / "soul"), label="Soul"
519
+ )
520
+ _agent.persona_instruction = _load_text_dir_or_file(
521
+ str(cwd / "context" / "persona"), label="Persona"
522
+ )
523
+ _agent._needs_onboarding = False
524
+ _agent._init_system_prompt()
525
+
526
+
527
+ # ── WebSocket Chat ────────────────────────────────────────────────────────────
528
+
529
+ async def _ws_chat(websocket: WebSocket):
530
+ await websocket.accept()
531
+ logger.info("[Web] WebSocket client connected")
532
+
533
+ try:
534
+ while True:
535
+ data = await websocket.receive_text()
536
+ try:
537
+ payload = json.loads(data)
538
+ message = payload.get("message", "").strip()
539
+ except (json.JSONDecodeError, AttributeError):
540
+ message = data.strip()
541
+
542
+ if not message:
543
+ continue
544
+
545
+ agent = _get_agent()
546
+ if agent is None:
547
+ await websocket.send_json({
548
+ "type": "error",
549
+ "content": "LLM provider is not configured yet. Go to the Config tab and set your API key, then save.",
550
+ })
551
+ continue
552
+
553
+ if message.startswith("/compact"):
554
+ hint = message[len("/compact"):].strip() or None
555
+ result = agent.compact(instruction=hint)
556
+ await websocket.send_json({"type": "response", "content": result})
557
+ continue
558
+
559
+ if message == "/status":
560
+ status = await _api_status()
561
+ await websocket.send_json({"type": "response", "content": json.dumps(status, indent=2)})
562
+ continue
563
+
564
+ if message == "/clear":
565
+ if _store:
566
+ _store.delete(WEB_SESSION_ID)
567
+ if agent is not None:
568
+ agent.clear_history()
569
+ await websocket.send_json({"type": "response", "content": "Chat history cleared. Agent is still active with all skills and memory intact."})
570
+ continue
571
+
572
+ await websocket.send_json({"type": "thinking", "content": ""})
573
+
574
+ loop = asyncio.get_event_loop()
575
+ try:
576
+ response = await loop.run_in_executor(None, agent.chat, message)
577
+ await websocket.send_json({"type": "response", "content": response})
578
+ except Exception as exc:
579
+ logger.exception("[Web] Chat error")
580
+ await websocket.send_json({"type": "error", "content": str(exc)})
581
+
582
+ except WebSocketDisconnect:
583
+ logger.info("[Web] WebSocket client disconnected")
584
+ except Exception:
585
+ logger.exception("[Web] WebSocket error")
Binary file