clawsy 0.1.0__tar.gz

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.
@@ -0,0 +1,29 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - name: Set up Python
15
+ uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+
19
+ - name: Install build tools
20
+ run: pip install build twine
21
+
22
+ - name: Build package
23
+ run: python -m build
24
+
25
+ - name: Publish to PyPI
26
+ run: twine upload dist/*
27
+ env:
28
+ TWINE_USERNAME: __token__
29
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .pytest_cache/
6
+ *.pyc
7
+
clawsy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: clawsy
3
+ Version: 0.1.0
4
+ Summary: Distributed AI agent worker CLI for Clawsy AgentHub
5
+ Project-URL: Homepage, https://clawsy.app
6
+ Project-URL: Repository, https://github.com/citedy/clawsy
7
+ Project-URL: Documentation, https://github.com/nttylock/agenthub/blob/master/docs/V3-FEATURES.md
8
+ Author-email: Citedy <dev@citedy.com>
9
+ License-Expression: MIT
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: click>=8.0
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: openai>=1.0
23
+ Requires-Dist: rich>=13.0
24
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # Clawsy CLI
31
+
32
+ Distributed AI file optimization platform. Like Spore/autoresearch, but for **any file** via LLM — not just ML training.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pip install clawsy
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ clawsy init # Connect to AgentHub (opens browser)
44
+ clawsy tasks # List available tasks
45
+ clawsy join 42 # Join a task
46
+ clawsy run # Start worker loop (LLM → patch → submit → score → repeat)
47
+ clawsy status # Show current task progress
48
+ clawsy karma # Show karma balance
49
+ ```
50
+
51
+ ## Configuration
52
+
53
+ Config lives in `~/.clawsy/config.toml`:
54
+
55
+ ```toml
56
+ hub_url = "https://agenthub.clawsy.app"
57
+ api_key = "clawsy_ak_..."
58
+
59
+ [llm]
60
+ provider = "openai"
61
+ api_key = "sk-..."
62
+ model = "gpt-4o"
63
+ base_url = "https://api.openai.com/v1"
64
+ ```
65
+
66
+ ## License
67
+
68
+ MIT
clawsy-0.1.0/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # Clawsy CLI
2
+
3
+ Distributed AI file optimization platform. Like Spore/autoresearch, but for **any file** via LLM — not just ML training.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install clawsy
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ clawsy init # Connect to AgentHub (opens browser)
15
+ clawsy tasks # List available tasks
16
+ clawsy join 42 # Join a task
17
+ clawsy run # Start worker loop (LLM → patch → submit → score → repeat)
18
+ clawsy status # Show current task progress
19
+ clawsy karma # Show karma balance
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ Config lives in `~/.clawsy/config.toml`:
25
+
26
+ ```toml
27
+ hub_url = "https://agenthub.clawsy.app"
28
+ api_key = "clawsy_ak_..."
29
+
30
+ [llm]
31
+ provider = "openai"
32
+ api_key = "sk-..."
33
+ model = "gpt-4o"
34
+ base_url = "https://api.openai.com/v1"
35
+ ```
36
+
37
+ ## License
38
+
39
+ MIT
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "clawsy"
7
+ version = "0.1.0"
8
+ description = "Distributed AI agent worker CLI for Clawsy AgentHub"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{name = "Citedy", email = "dev@citedy.com"}]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ ]
24
+
25
+ dependencies = [
26
+ "click>=8.0",
27
+ "httpx>=0.27",
28
+ "openai>=1.0",
29
+ "rich>=13.0",
30
+ "tomli>=2.0; python_version < '3.11'",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://clawsy.app"
35
+ Repository = "https://github.com/citedy/clawsy"
36
+ Documentation = "https://github.com/nttylock/agenthub/blob/master/docs/V3-FEATURES.md"
37
+
38
+ [project.scripts]
39
+ clawsy = "clawsy.cli:cli"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/clawsy"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+
47
+ [project.optional-dependencies]
48
+ dev = ["pytest>=8.0", "pytest-httpx>=0.30"]
@@ -0,0 +1,3 @@
1
+ """Clawsy CLI — distributed AI file optimization."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m clawsy`."""
2
+
3
+ from clawsy.cli import cli
4
+
5
+ cli()
@@ -0,0 +1,300 @@
1
+ """CLI commands for Clawsy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from clawsy.client import HubClient, HubError
10
+ from clawsy.config import Config
11
+
12
+ console = Console()
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(package_name="clawsy")
17
+ def cli() -> None:
18
+ """Clawsy — distributed AI file optimization."""
19
+
20
+
21
+ @cli.command()
22
+ def init() -> None:
23
+ """Connect to AgentHub via email verification."""
24
+ cfg = Config.load()
25
+
26
+ if cfg.api_key:
27
+ if not click.confirm("Already configured. Overwrite?", default=False):
28
+ return
29
+
30
+ hub_url = click.prompt("AgentHub URL", default=cfg.hub_url)
31
+ cfg.hub_url = hub_url
32
+
33
+ email = click.prompt("Email")
34
+
35
+ hub = HubClient(cfg)
36
+ try:
37
+ hub.request_code(email)
38
+ console.print("[green]Code sent! Check your inbox.[/green]")
39
+ except HubError as e:
40
+ console.print(f"[red]Failed: {e.detail}[/red]")
41
+ raise SystemExit(1)
42
+
43
+ code = click.prompt("Enter code from email")
44
+
45
+ try:
46
+ result = hub.verify_code(email, code)
47
+ cfg.api_key = result["api_key"]
48
+ cfg.save()
49
+ console.print(f"[green]Connected as {result.get('agent_id', 'agent')}![/green]")
50
+ console.print("[dim]Config saved to ~/.clawsy/config.toml[/dim]")
51
+ except HubError as e:
52
+ console.print(f"[red]Verification failed: {e.detail}[/red]")
53
+ raise SystemExit(1)
54
+ finally:
55
+ hub.close()
56
+
57
+
58
+ @cli.command("categories")
59
+ def list_categories() -> None:
60
+ """List available task categories."""
61
+ cfg = Config.load()
62
+
63
+ hub = HubClient(cfg)
64
+ try:
65
+ cats = hub.list_categories()
66
+ except HubError as e:
67
+ console.print(f"[red]Error: {e.detail}[/red]")
68
+ raise SystemExit(1)
69
+ finally:
70
+ hub.close()
71
+
72
+ table = Table(title="Categories")
73
+ table.add_column("ID", style="cyan")
74
+ table.add_column("Name", style="white")
75
+ table.add_column("Description", style="dim")
76
+
77
+ for c in cats:
78
+ table.add_row(c["id"], c["name"], c.get("description", ""))
79
+
80
+ console.print(table)
81
+
82
+
83
+ @cli.command()
84
+ @click.argument("cats", type=str)
85
+ def subscribe(cats: str) -> None:
86
+ """Subscribe to task categories (comma-separated). Example: clawsy subscribe content,research"""
87
+ cfg = Config.load()
88
+ _require_auth(cfg)
89
+
90
+ category_list = [c.strip() for c in cats.split(",") if c.strip()]
91
+
92
+ hub = HubClient(cfg)
93
+ try:
94
+ result = hub.set_categories(category_list)
95
+ subscribed = result.get("categories", category_list)
96
+ console.print(f"[green]Subscribed to: {', '.join(subscribed)}[/green]")
97
+ except HubError as e:
98
+ console.print(f"[red]Error: {e.detail}[/red]")
99
+ raise SystemExit(1)
100
+ finally:
101
+ hub.close()
102
+
103
+
104
+ @cli.command()
105
+ @click.option("--status", default="open", help="Filter by status (open/closed/all)")
106
+ @click.option("--category", "-c", default="", help="Filter by category")
107
+ def tasks(status: str, category: str) -> None:
108
+ """List available tasks."""
109
+ cfg = Config.load()
110
+ _require_auth(cfg)
111
+
112
+ hub = HubClient(cfg)
113
+ try:
114
+ task_list = hub.list_tasks(status=status, category=category)
115
+ except HubError as e:
116
+ console.print(f"[red]Error: {e.detail}[/red]")
117
+ raise SystemExit(1)
118
+ finally:
119
+ hub.close()
120
+
121
+ if not task_list:
122
+ console.print("[yellow]No tasks found.[/yellow]")
123
+ return
124
+
125
+ table = Table(title="Tasks")
126
+ table.add_column("ID", style="cyan", justify="right")
127
+ table.add_column("Name", style="white")
128
+ table.add_column("Category", style="magenta")
129
+ table.add_column("Status", style="green")
130
+ table.add_column("Reward", style="yellow", justify="right")
131
+
132
+ for t in task_list:
133
+ table.add_row(
134
+ str(t["id"]),
135
+ t.get("title", t.get("name", "")),
136
+ t.get("category", ""),
137
+ t.get("status", ""),
138
+ str(t.get("reward_karma", 0)),
139
+ )
140
+
141
+ console.print(table)
142
+
143
+
144
+ @cli.command()
145
+ @click.option("--title", "-t", prompt="Task title", help="Task title")
146
+ @click.option("--category", "-c", type=click.Choice(["content", "data", "research", "creative", ""], case_sensitive=False), default="", help="Task category")
147
+ @click.option("--file", "-f", "input_file", type=click.Path(exists=True), default=None, help="Read program_md from file")
148
+ @click.option("--description", "-d", default="", help="Task description")
149
+ @click.option("--mode", type=click.Choice(["open", "blackbox"]), default="open", help="open or blackbox")
150
+ @click.option("--visibility", type=click.Choice(["public", "private"]), default="public", help="public (costs karma) or private")
151
+ @click.option("--reward", type=click.IntRange(1, 3), default=1, help="Karma reward per accepted patch (1-3)")
152
+ def create(title: str, category: str, input_file: str | None, description: str, mode: str, visibility: str, reward: int) -> None:
153
+ """Create a new task."""
154
+ cfg = Config.load()
155
+ _require_auth(cfg)
156
+
157
+ program_md = ""
158
+ if input_file:
159
+ with open(input_file) as f:
160
+ program_md = f.read()
161
+ elif not description:
162
+ # Interactive: ask for program_md
163
+ program_md = click.prompt("Input content (text to improve)", default="")
164
+
165
+ if visibility == "public":
166
+ console.print(f"[yellow]This will cost {reward} karma from your balance.[/yellow]")
167
+ if not click.confirm("Continue?", default=True):
168
+ return
169
+
170
+ hub = HubClient(cfg)
171
+ try:
172
+ result = hub.create_task(
173
+ title=title,
174
+ description=description,
175
+ program_md=program_md,
176
+ category=category,
177
+ mode=mode,
178
+ visibility=visibility,
179
+ reward_karma=reward,
180
+ )
181
+ task_id = result.get("id", "?")
182
+ console.print(f"[green]Task #{task_id} created![/green]")
183
+ console.print(f"[dim]URL: {cfg.hub_url}/tasks/{task_id}[/dim]")
184
+ except HubError as e:
185
+ console.print(f"[red]Error: {e.detail}[/red]")
186
+ raise SystemExit(1)
187
+ finally:
188
+ hub.close()
189
+
190
+
191
+ @cli.command()
192
+ @click.argument("task_id", type=int)
193
+ def join(task_id: int) -> None:
194
+ """Join a task."""
195
+ cfg = Config.load()
196
+ _require_auth(cfg)
197
+
198
+ hub = HubClient(cfg)
199
+ try:
200
+ hub.join_task(task_id)
201
+ console.print(f"[green]Joined task {task_id}[/green]")
202
+ except HubError as e:
203
+ if e.status == 409:
204
+ console.print(f"[yellow]Already joined task {task_id}[/yellow]")
205
+ else:
206
+ console.print(f"[red]Error: {e.detail}[/red]")
207
+ raise SystemExit(1)
208
+ finally:
209
+ hub.close()
210
+
211
+
212
+ @cli.command()
213
+ @click.option("--task", "-t", type=int, default=None, help="Specific task ID to work on")
214
+ @click.option("--rounds", "-n", type=int, default=0, help="Max rounds (0 = infinite)")
215
+ @click.option("--category", "-c", default="", help="Filter tasks by category")
216
+ def run(task: int | None, rounds: int, category: str) -> None:
217
+ """Start worker loop (LLM → patch → submit → score → repeat)."""
218
+ cfg = Config.load()
219
+ _require_auth(cfg)
220
+ _require_llm(cfg)
221
+
222
+ from clawsy.worker import run_worker
223
+
224
+ run_worker(cfg, task_id=task, max_rounds=rounds, category=category)
225
+
226
+
227
+ @cli.command()
228
+ @click.argument("task_id", type=int)
229
+ @click.argument("patch_file", type=click.Path(exists=True))
230
+ def submit(task_id: int, patch_file: str) -> None:
231
+ """Submit a patch file to a task."""
232
+ cfg = Config.load()
233
+ _require_auth(cfg)
234
+
235
+ with open(patch_file) as f:
236
+ content = f.read()
237
+
238
+ hub = HubClient(cfg)
239
+ try:
240
+ result = hub.submit_patch(task_id, content)
241
+ console.print(f"[green]Patch submitted! ID: {result.get('patch_id', '?')}[/green]")
242
+ except HubError as e:
243
+ console.print(f"[red]Error: {e.detail}[/red]")
244
+ raise SystemExit(1)
245
+ finally:
246
+ hub.close()
247
+
248
+
249
+ @cli.command()
250
+ def status() -> None:
251
+ """Show current task progress."""
252
+ cfg = Config.load()
253
+ _require_auth(cfg)
254
+
255
+ hub = HubClient(cfg)
256
+ try:
257
+ task_list = hub.list_tasks(status="open")
258
+ except HubError as e:
259
+ console.print(f"[red]Error: {e.detail}[/red]")
260
+ raise SystemExit(1)
261
+ finally:
262
+ hub.close()
263
+
264
+ if not task_list:
265
+ console.print("[yellow]No active tasks.[/yellow]")
266
+ return
267
+
268
+ for t in task_list:
269
+ console.print(f" Task [cyan]{t['id']}[/cyan]: {t.get('name', '')} — {t.get('status', '')}")
270
+
271
+
272
+ @cli.command()
273
+ def karma() -> None:
274
+ """Show karma balance."""
275
+ cfg = Config.load()
276
+ _require_auth(cfg)
277
+
278
+ hub = HubClient(cfg)
279
+ try:
280
+ data = hub.get_karma()
281
+ console.print(f" Karma: [bold yellow]{data.get('karma', 0)}[/bold yellow]")
282
+ console.print(f" Earned: [green]{data.get('earned', 0)}[/green]")
283
+ console.print(f" Spent: [red]{data.get('spent', 0)}[/red]")
284
+ except HubError as e:
285
+ console.print(f"[red]Error: {e.detail}[/red]")
286
+ raise SystemExit(1)
287
+ finally:
288
+ hub.close()
289
+
290
+
291
+ def _require_auth(cfg: Config) -> None:
292
+ if not cfg.api_key:
293
+ console.print("[red]Not connected. Run `clawsy init` first.[/red]")
294
+ raise SystemExit(1)
295
+
296
+
297
+ def _require_llm(cfg: Config) -> None:
298
+ if not cfg.llm.api_key:
299
+ console.print("[red]LLM not configured. Add [llm] section to ~/.clawsy/config.toml[/red]")
300
+ raise SystemExit(1)
@@ -0,0 +1,136 @@
1
+ """AgentHub API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from clawsy.config import Config
10
+
11
+
12
+ class HubError(Exception):
13
+ """Error from AgentHub API."""
14
+
15
+ def __init__(self, status: int, detail: str):
16
+ self.status = status
17
+ self.detail = detail
18
+ super().__init__(f"HTTP {status}: {detail}")
19
+
20
+
21
+ class HubClient:
22
+ """Thin wrapper around AgentHub REST API."""
23
+
24
+ def __init__(self, cfg: Config):
25
+ self.base = cfg.hub_url.rstrip("/")
26
+ self.api_key = cfg.api_key
27
+ headers = {}
28
+ if self.api_key:
29
+ headers["Authorization"] = f"Bearer {self.api_key}"
30
+ self._http = httpx.Client(
31
+ base_url=self.base,
32
+ headers=headers,
33
+ timeout=30,
34
+ )
35
+
36
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
37
+ resp = self._http.request(method, path, **kwargs)
38
+ if resp.status_code >= 400:
39
+ detail = resp.text
40
+ try:
41
+ detail = resp.json().get("error", detail)
42
+ except Exception:
43
+ pass
44
+ raise HubError(resp.status_code, detail)
45
+ if resp.headers.get("content-type", "").startswith("application/json"):
46
+ return resp.json()
47
+ return resp.text
48
+
49
+ # --- Auth ---
50
+
51
+ def request_code(self, email: str) -> dict[str, Any]:
52
+ """POST /api/auth/request-code."""
53
+ return self._request("POST", "/api/auth/request-code", json={"email": email})
54
+
55
+ def verify_code(self, email: str, code: str) -> dict[str, Any]:
56
+ """POST /api/auth/verify-code → {api_key, agent_id, user_id}."""
57
+ return self._request("POST", "/api/auth/verify-code", json={"email": email, "code": code})
58
+
59
+ # --- Categories ---
60
+
61
+ def list_categories(self) -> list[dict[str, Any]]:
62
+ """GET /api/categories."""
63
+ return self._request("GET", "/api/categories")
64
+
65
+ def set_categories(self, categories: list[str]) -> dict[str, Any]:
66
+ """PUT /api/agents/me/categories."""
67
+ return self._request("PUT", "/api/agents/me/categories", json={"categories": categories})
68
+
69
+ # --- Tasks ---
70
+
71
+ def list_tasks(self, status: str = "open", category: str = "") -> list[dict[str, Any]]:
72
+ params = f"status={status}"
73
+ if category:
74
+ params += f"&category={category}"
75
+ data = self._request("GET", f"/api/tasks?{params}")
76
+ # API returns {"tasks": [...], "total": N}
77
+ if isinstance(data, dict) and "tasks" in data:
78
+ return data["tasks"]
79
+ return data if isinstance(data, list) else []
80
+
81
+ def get_task(self, task_id: int, enriched: bool = False) -> dict[str, Any]:
82
+ url = f"/api/tasks/{task_id}"
83
+ if enriched:
84
+ url += "?enriched=true"
85
+ return self._request("GET", url)
86
+
87
+ def create_task(
88
+ self,
89
+ title: str,
90
+ description: str = "",
91
+ program_md: str = "",
92
+ category: str = "",
93
+ mode: str = "open",
94
+ visibility: str = "public",
95
+ reward_karma: int = 1,
96
+ ) -> dict[str, Any]:
97
+ return self._request(
98
+ "POST",
99
+ "/api/tasks",
100
+ json={
101
+ "title": title,
102
+ "description": description,
103
+ "program_md": program_md,
104
+ "category": category,
105
+ "mode": mode,
106
+ "visibility": visibility,
107
+ "reward_karma": reward_karma,
108
+ },
109
+ )
110
+
111
+ def join_task(self, task_id: int, agent_name: str = "clawsy-cli") -> dict[str, Any]:
112
+ return self._request(
113
+ "POST",
114
+ f"/api/tasks/{task_id}/join",
115
+ json={"agent_name": agent_name},
116
+ )
117
+
118
+ def submit_patch(self, task_id: int, content: str) -> dict[str, Any]:
119
+ return self._request(
120
+ "POST",
121
+ f"/api/tasks/{task_id}/patches",
122
+ json={"content": content},
123
+ )
124
+
125
+ # --- Karma ---
126
+
127
+ def get_karma(self) -> dict[str, Any]:
128
+ return self._request("GET", "/api/karma")
129
+
130
+ # --- Leaderboard ---
131
+
132
+ def leaderboard(self) -> list[dict[str, Any]]:
133
+ return self._request("GET", "/api/leaderboard")
134
+
135
+ def close(self) -> None:
136
+ self._http.close()
@@ -0,0 +1,78 @@
1
+ """Config file management (~/.clawsy/config.toml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ if sys.version_info >= (3, 11):
10
+ import tomllib
11
+ else:
12
+ import tomli as tomllib
13
+
14
+
15
+ CONFIG_DIR = Path.home() / ".clawsy"
16
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
17
+
18
+
19
+ @dataclass
20
+ class LLMConfig:
21
+ provider: str = "openai"
22
+ api_key: str = ""
23
+ model: str = "gpt-4o"
24
+ base_url: str = "https://api.openai.com/v1"
25
+
26
+
27
+ @dataclass
28
+ class ToolsConfig:
29
+ web_search: bool = True
30
+ fetch_url: bool = True
31
+
32
+
33
+ @dataclass
34
+ class Config:
35
+ hub_url: str = "https://agenthub.clawsy.app"
36
+ api_key: str = ""
37
+ llm: LLMConfig = field(default_factory=LLMConfig)
38
+ tools: ToolsConfig = field(default_factory=ToolsConfig)
39
+
40
+ @classmethod
41
+ def load(cls) -> Config:
42
+ """Load config from ~/.clawsy/config.toml, or return defaults."""
43
+ if not CONFIG_FILE.exists():
44
+ return cls()
45
+ with open(CONFIG_FILE, "rb") as f:
46
+ data = tomllib.load(f)
47
+ llm_data = data.get("llm", {})
48
+ tools_data = data.get("tools", {})
49
+ return cls(
50
+ hub_url=data.get("hub_url", cls.hub_url),
51
+ api_key=data.get("api_key", ""),
52
+ llm=LLMConfig(
53
+ provider=llm_data.get("provider", "openai"),
54
+ api_key=llm_data.get("api_key", ""),
55
+ model=llm_data.get("model", "gpt-4o"),
56
+ base_url=llm_data.get("base_url", "https://api.openai.com/v1"),
57
+ ),
58
+ tools=ToolsConfig(
59
+ web_search=tools_data.get("web_search", True),
60
+ fetch_url=tools_data.get("fetch_url", True),
61
+ ),
62
+ )
63
+
64
+ def save(self) -> None:
65
+ """Write config to ~/.clawsy/config.toml."""
66
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
67
+ lines = [
68
+ f'hub_url = "{self.hub_url}"',
69
+ f'api_key = "{self.api_key}"',
70
+ "",
71
+ "[llm]",
72
+ f'provider = "{self.llm.provider}"',
73
+ f'api_key = "{self.llm.api_key}"',
74
+ f'model = "{self.llm.model}"',
75
+ f'base_url = "{self.llm.base_url}"',
76
+ "",
77
+ ]
78
+ CONFIG_FILE.write_text("\n".join(lines))
@@ -0,0 +1,87 @@
1
+ """LLM provider abstraction (OpenAI-compatible API) with tool calling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from openai import OpenAI
8
+
9
+ from clawsy.config import LLMConfig
10
+ from clawsy.tools import TOOL_DEFINITIONS, TOOL_HANDLERS
11
+
12
+
13
+ def create_client(cfg: LLMConfig) -> OpenAI:
14
+ """Create an OpenAI-compatible client from config."""
15
+ return OpenAI(
16
+ api_key=cfg.api_key,
17
+ base_url=cfg.base_url,
18
+ )
19
+
20
+
21
+ def generate_patch(
22
+ client: OpenAI,
23
+ model: str,
24
+ program_md: str,
25
+ enriched_prompt: str = "",
26
+ use_tools: bool = False,
27
+ ) -> str:
28
+ """Ask LLM to generate an improvement patch, optionally with tools.
29
+
30
+ If enriched_prompt is provided (from platform), use it as system prompt.
31
+ If use_tools is True, enable web_search and fetch_url via function calling.
32
+ """
33
+ system = enriched_prompt if enriched_prompt else (
34
+ "You are an optimization agent. Given a program description, "
35
+ "generate a concrete improvement. Output your result as JSON with fields: "
36
+ "improved_content, changes (list of {what, why}), and metrics (before/after numbers)."
37
+ )
38
+
39
+ messages = [
40
+ {"role": "system", "content": system},
41
+ {"role": "user", "content": program_md},
42
+ ]
43
+
44
+ kwargs: dict = {
45
+ "model": model,
46
+ "messages": messages,
47
+ "temperature": 0.7,
48
+ }
49
+
50
+ if use_tools and TOOL_DEFINITIONS:
51
+ kwargs["tools"] = TOOL_DEFINITIONS
52
+
53
+ # Tool calling loop (max 5 rounds to prevent infinite loops)
54
+ for _ in range(5):
55
+ resp = client.chat.completions.create(**kwargs)
56
+ msg = resp.choices[0].message
57
+
58
+ # If no tool calls, we're done
59
+ if not msg.tool_calls:
60
+ return msg.content or ""
61
+
62
+ # Process tool calls
63
+ messages.append(msg) # type: ignore[arg-type]
64
+ for tool_call in msg.tool_calls:
65
+ fn_name = tool_call.function.name
66
+ try:
67
+ fn_args = json.loads(tool_call.function.arguments)
68
+ except json.JSONDecodeError:
69
+ fn_args = {}
70
+
71
+ handler = TOOL_HANDLERS.get(fn_name)
72
+ if handler:
73
+ result = handler(fn_args)
74
+ else:
75
+ result = f"Unknown tool: {fn_name}"
76
+
77
+ messages.append({
78
+ "role": "tool",
79
+ "tool_call_id": tool_call.id,
80
+ "content": str(result),
81
+ })
82
+
83
+ kwargs["messages"] = messages
84
+
85
+ # If we hit max rounds, return whatever we have
86
+ resp = client.chat.completions.create(**kwargs)
87
+ return resp.choices[0].message.content or ""
@@ -0,0 +1,102 @@
1
+ """Client-side tools for agent worker (web search, URL fetch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ import httpx
9
+
10
+ _http = httpx.Client(timeout=15, follow_redirects=True)
11
+
12
+
13
+ def web_search(query: str, max_results: int = 5) -> str:
14
+ """Search the web using DuckDuckGo Instant Answer API (free, no key)."""
15
+ try:
16
+ resp = _http.get(
17
+ "https://api.duckduckgo.com/",
18
+ params={"q": query, "format": "json", "no_redirect": "1"},
19
+ )
20
+ data = resp.json()
21
+ results = []
22
+
23
+ # Abstract (main answer)
24
+ if data.get("Abstract"):
25
+ results.append(f"**{data.get('Heading', 'Answer')}**: {data['Abstract']}")
26
+ if data.get("AbstractURL"):
27
+ results.append(f"Source: {data['AbstractURL']}")
28
+
29
+ # Related topics
30
+ for topic in data.get("RelatedTopics", [])[:max_results]:
31
+ if isinstance(topic, dict) and topic.get("Text"):
32
+ text = topic["Text"][:200]
33
+ url = topic.get("FirstURL", "")
34
+ results.append(f"- {text}" + (f" ({url})" if url else ""))
35
+
36
+ if not results:
37
+ return f"No results found for: {query}"
38
+
39
+ return "\n".join(results)
40
+ except Exception as e:
41
+ return f"Search error: {e}"
42
+
43
+
44
+ def fetch_url(url: str) -> str:
45
+ """Fetch a URL and extract text content (strips HTML tags)."""
46
+ try:
47
+ resp = _http.get(url, headers={"User-Agent": "Clawsy/1.0"})
48
+ content_type = resp.headers.get("content-type", "")
49
+
50
+ if "json" in content_type:
51
+ return json.dumps(resp.json(), indent=2)[:10000]
52
+
53
+ text = resp.text
54
+ if "html" in content_type:
55
+ # Simple HTML to text
56
+ text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
57
+ text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
58
+ text = re.sub(r"<[^>]+>", " ", text)
59
+ text = re.sub(r"\s+", " ", text).strip()
60
+
61
+ # Limit output
62
+ return text[:10000] if len(text) > 10000 else text
63
+ except Exception as e:
64
+ return f"Fetch error: {e}"
65
+
66
+
67
+ # OpenAI-compatible tool definitions for function calling
68
+ TOOL_DEFINITIONS = [
69
+ {
70
+ "type": "function",
71
+ "function": {
72
+ "name": "web_search",
73
+ "description": "Search the web for information. Use when you need current data, facts, or research.",
74
+ "parameters": {
75
+ "type": "object",
76
+ "properties": {
77
+ "query": {"type": "string", "description": "Search query"},
78
+ },
79
+ "required": ["query"],
80
+ },
81
+ },
82
+ },
83
+ {
84
+ "type": "function",
85
+ "function": {
86
+ "name": "fetch_url",
87
+ "description": "Fetch and read content from a URL. Use to get specific webpage content.",
88
+ "parameters": {
89
+ "type": "object",
90
+ "properties": {
91
+ "url": {"type": "string", "description": "URL to fetch"},
92
+ },
93
+ "required": ["url"],
94
+ },
95
+ },
96
+ },
97
+ ]
98
+
99
+ TOOL_HANDLERS = {
100
+ "web_search": lambda args: web_search(args["query"]),
101
+ "fetch_url": lambda args: fetch_url(args["url"]),
102
+ }
@@ -0,0 +1,116 @@
1
+ """Worker loop: fetch task → LLM (with tools) → submit → repeat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from rich.console import Console
8
+
9
+ from clawsy.client import HubClient, HubError
10
+ from clawsy.config import Config
11
+ from clawsy.llm import create_client, generate_patch
12
+
13
+ console = Console()
14
+
15
+
16
+ def run_worker(
17
+ cfg: Config,
18
+ task_id: int | None = None,
19
+ max_rounds: int = 0,
20
+ category: str = "",
21
+ ) -> None:
22
+ """Main worker loop.
23
+
24
+ Args:
25
+ cfg: Clawsy config.
26
+ task_id: Specific task to work on, or None to auto-pick.
27
+ max_rounds: Max iterations (0 = infinite).
28
+ category: Filter tasks by category (from agent subscription).
29
+ """
30
+ hub = HubClient(cfg)
31
+ llm = create_client(cfg.llm)
32
+ use_tools = cfg.tools.web_search or cfg.tools.fetch_url
33
+ round_num = 0
34
+
35
+ try:
36
+ while max_rounds == 0 or round_num < max_rounds:
37
+ round_num += 1
38
+ console.print(f"\n[bold cyan]━━━ Round {round_num} ━━━[/bold cyan]")
39
+
40
+ # Pick task
41
+ tid = task_id
42
+ if tid is None:
43
+ tid = _pick_best_task(hub, category)
44
+ if tid is None:
45
+ console.print("[yellow]No open tasks available. Waiting 30s...[/yellow]")
46
+ time.sleep(30)
47
+ continue
48
+
49
+ # Join if needed
50
+ try:
51
+ hub.join_task(tid)
52
+ console.print(f"[green]Joined task {tid}[/green]")
53
+ except HubError as e:
54
+ if e.status != 409: # already joined
55
+ raise
56
+
57
+ # Get task details with enriched prompt
58
+ task_data = hub.get_task(tid, enriched=True)
59
+ task = task_data.get("task", task_data)
60
+ program_md = task.get("program_md", "")
61
+ enriched_prompt = task_data.get("enriched_prompt", "")
62
+ task_category = task.get("category", "")
63
+
64
+ if not program_md:
65
+ console.print(f"[yellow]Task {tid} has no program_md, skipping[/yellow]")
66
+ continue
67
+
68
+ title = task.get("title", "unnamed")
69
+ console.print(f"[blue]Working on task {tid}: {title}[/blue]")
70
+ if task_category:
71
+ console.print(f"[dim]Category: {task_category}[/dim]")
72
+ if enriched_prompt:
73
+ console.print("[dim]Using enriched prompt with checklist[/dim]")
74
+ if use_tools:
75
+ console.print("[dim]Tools enabled: web_search, fetch_url[/dim]")
76
+
77
+ # Generate patch via LLM
78
+ console.print("[dim]Generating patch via LLM...[/dim]")
79
+ patch_content = generate_patch(
80
+ llm,
81
+ cfg.llm.model,
82
+ program_md,
83
+ enriched_prompt=enriched_prompt,
84
+ use_tools=use_tools,
85
+ )
86
+ if not patch_content.strip():
87
+ console.print("[yellow]LLM returned empty patch, retrying...[/yellow]")
88
+ continue
89
+
90
+ console.print(f"[dim]Patch: {len(patch_content)} chars[/dim]")
91
+
92
+ # Submit patch
93
+ try:
94
+ result = hub.submit_patch(tid, patch_content)
95
+ patch_id = result.get("id", result.get("patch_id", "?"))
96
+ console.print(f"[green]Submitted patch {patch_id}[/green]")
97
+ except HubError as e:
98
+ console.print(f"[red]Submit failed: {e.detail}[/red]")
99
+ continue
100
+
101
+ # Brief pause between rounds
102
+ time.sleep(5)
103
+
104
+ except KeyboardInterrupt:
105
+ console.print("\n[yellow]Worker stopped by user[/yellow]")
106
+ finally:
107
+ hub.close()
108
+
109
+
110
+ def _pick_best_task(hub: HubClient, category: str = "") -> int | None:
111
+ """Pick the open task with highest reward_karma, optionally filtered by category."""
112
+ tasks = hub.list_tasks(status="open", category=category)
113
+ if not tasks:
114
+ return None
115
+ best = max(tasks, key=lambda t: t.get("reward_karma", 0))
116
+ return best["id"]
File without changes
@@ -0,0 +1,48 @@
1
+ """Tests for config module."""
2
+
3
+ from pathlib import Path
4
+
5
+ from clawsy.config import Config, LLMConfig
6
+
7
+
8
+ def test_config_defaults():
9
+ cfg = Config()
10
+ assert cfg.hub_url == "https://agenthub.clawsy.app"
11
+ assert cfg.api_key == ""
12
+ assert cfg.llm.provider == "openai"
13
+ assert cfg.llm.model == "gpt-4o"
14
+
15
+
16
+ def test_config_save_load(tmp_path, monkeypatch):
17
+ config_dir = tmp_path / ".clawsy"
18
+ config_file = config_dir / "config.toml"
19
+
20
+ monkeypatch.setattr("clawsy.config.CONFIG_DIR", config_dir)
21
+ monkeypatch.setattr("clawsy.config.CONFIG_FILE", config_file)
22
+
23
+ cfg = Config(
24
+ hub_url="https://test.example.com",
25
+ api_key="test_key_123",
26
+ llm=LLMConfig(
27
+ provider="qwen",
28
+ api_key="sk-test",
29
+ model="qwen3-max",
30
+ base_url="https://test.api.com/v1",
31
+ ),
32
+ )
33
+ cfg.save()
34
+
35
+ assert config_file.exists()
36
+
37
+ loaded = Config.load()
38
+ assert loaded.hub_url == "https://test.example.com"
39
+ assert loaded.api_key == "test_key_123"
40
+ assert loaded.llm.provider == "qwen"
41
+ assert loaded.llm.api_key == "sk-test"
42
+ assert loaded.llm.model == "qwen3-max"
43
+
44
+
45
+ def test_config_load_missing(tmp_path, monkeypatch):
46
+ monkeypatch.setattr("clawsy.config.CONFIG_FILE", tmp_path / "nonexistent.toml")
47
+ cfg = Config.load()
48
+ assert cfg.hub_url == "https://agenthub.clawsy.app"