copaw-worker 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
+ Metadata-Version: 2.4
2
+ Name: copaw-worker
3
+ Version: 0.1.0
4
+ Summary: Lightweight HiClaw Worker runtime based on CoPaw
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/higress-group/hiclaw
7
+ Project-URL: Repository, https://github.com/higress-group/hiclaw
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: copaw>=0.0.5
11
+ Requires-Dist: matrix-nio>=0.24.0
12
+
13
+ # copaw-worker
14
+
15
+ Lightweight [HiClaw](https://github.com/higress-group/hiclaw) Worker runtime based on [CoPaw](https://github.com/agentscope-ai/CoPaw).
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install copaw-worker
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```bash
26
+ copaw-worker --name <worker-name> --fs <minio-endpoint> --fs-key <access-key> --fs-secret <secret-key>
27
+ ```
28
+
29
+ See [HiClaw worker guide](https://github.com/higress-group/hiclaw/blob/main/docs/worker-guide.md) for full setup instructions.
@@ -0,0 +1,17 @@
1
+ # copaw-worker
2
+
3
+ Lightweight [HiClaw](https://github.com/higress-group/hiclaw) Worker runtime based on [CoPaw](https://github.com/agentscope-ai/CoPaw).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install copaw-worker
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ copaw-worker --name <worker-name> --fs <minio-endpoint> --fs-key <access-key> --fs-secret <secret-key>
15
+ ```
16
+
17
+ See [HiClaw worker guide](https://github.com/higress-group/hiclaw/blob/main/docs/worker-guide.md) for full setup instructions.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "copaw-worker"
7
+ version = "0.1.0"
8
+ description = "Lightweight HiClaw Worker runtime based on CoPaw"
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "copaw>=0.0.5",
14
+ "matrix-nio>=0.24.0",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/higress-group/hiclaw"
19
+ Repository = "https://github.com/higress-group/hiclaw"
20
+
21
+ [project.scripts]
22
+ copaw-worker = "copaw_worker.cli:main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ # copaw-worker: HiClaw Worker runtime based on CoPaw
@@ -0,0 +1,226 @@
1
+ """
2
+ Bridge: translate openclaw.json (HiClaw Worker config) into CoPaw's
3
+ config.json + providers.json, then set COPAW_WORKING_DIR so CoPaw
4
+ picks up the right workspace.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ def _port_remap(url: str, is_container: bool) -> str:
16
+ """Remap container-internal :8080 to host-exposed :18080 when needed."""
17
+ if not is_container and url and ":8080" in url:
18
+ return url.replace(":8080", ":18080")
19
+ return url
20
+
21
+
22
+ def _is_in_container() -> bool:
23
+ return Path("/.dockerenv").exists()
24
+
25
+
26
+ def _secret_dir(working_dir: Path) -> Path:
27
+ """Return the secret dir path that copaw uses alongside working_dir."""
28
+ return Path(str(working_dir) + ".secret")
29
+
30
+
31
+ def _patch_copaw_paths(working_dir: Path) -> None:
32
+ """Patch copaw's module-level path constants to point at working_dir.
33
+
34
+ copaw.constant captures WORKING_DIR / SECRET_DIR at import time from
35
+ env vars, so setting COPAW_WORKING_DIR after import has no effect.
36
+ We must update the live module objects directly.
37
+ """
38
+ secret_dir = _secret_dir(working_dir)
39
+ secret_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ try:
42
+ import copaw.constant as _const
43
+ _const.WORKING_DIR = working_dir
44
+ _const.SECRET_DIR = secret_dir
45
+ _const.ACTIVE_SKILLS_DIR = working_dir / "active_skills"
46
+ _const.CUSTOMIZED_SKILLS_DIR = working_dir / "customized_skills"
47
+ _const.MEMORY_DIR = working_dir / "memory"
48
+ _const.CUSTOM_CHANNELS_DIR = working_dir / "custom_channels"
49
+ _const.MODELS_DIR = working_dir / "models"
50
+ except ImportError:
51
+ pass
52
+
53
+ try:
54
+ import copaw.providers.store as _store
55
+ _store._PROVIDERS_JSON = secret_dir / "providers.json"
56
+ _store._LEGACY_PROVIDERS_JSON_CANDIDATES = (
57
+ Path(__file__).resolve().parent / "providers.json",
58
+ working_dir / "providers.json",
59
+ )
60
+ except ImportError:
61
+ pass
62
+
63
+ try:
64
+ import copaw.envs.store as _envs
65
+ _envs._BOOTSTRAP_WORKING_DIR = working_dir
66
+ _envs._BOOTSTRAP_SECRET_DIR = secret_dir
67
+ _envs._ENVS_JSON = secret_dir / "envs.json"
68
+ _envs._LEGACY_ENVS_JSON_CANDIDATES = (working_dir / "envs.json",)
69
+ except (ImportError, AttributeError):
70
+ pass
71
+
72
+
73
+ def bridge_openclaw_to_copaw(
74
+ openclaw_cfg: dict[str, Any],
75
+ working_dir: Path,
76
+ ) -> None:
77
+ """
78
+ Read openclaw_cfg (parsed openclaw.json) and write:
79
+ - <working_dir>/config.json (channels + agents)
80
+ - <working_dir>/providers.json (LLM credentials, for reference)
81
+ - <working_dir>.secret/providers.json (where copaw actually reads from)
82
+
83
+ Also sets COPAW_WORKING_DIR env var and patches copaw's module-level
84
+ path constants so the running process uses the correct directory.
85
+ """
86
+ working_dir.mkdir(parents=True, exist_ok=True)
87
+ in_container = _is_in_container()
88
+
89
+ _write_config_json(openclaw_cfg, working_dir, in_container)
90
+ _write_providers_json(openclaw_cfg, working_dir, in_container)
91
+
92
+ os.environ["COPAW_WORKING_DIR"] = str(working_dir)
93
+
94
+ # Patch module-level constants (import-time values won't reflect env change)
95
+ _patch_copaw_paths(working_dir)
96
+
97
+ # Copy providers.json into secret_dir — that's where copaw actually reads it
98
+ secret_dir = _secret_dir(working_dir)
99
+ providers_src = working_dir / "providers.json"
100
+ if providers_src.exists():
101
+ shutil.copy2(providers_src, secret_dir / "providers.json")
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # config.json
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def _write_config_json(
109
+ cfg: dict[str, Any],
110
+ working_dir: Path,
111
+ in_container: bool,
112
+ ) -> None:
113
+ matrix_raw = cfg.get("channels", {}).get("matrix", {})
114
+ homeserver = _port_remap(
115
+ matrix_raw.get("homeserver", ""), in_container
116
+ )
117
+ access_token = matrix_raw.get("accessToken", "")
118
+
119
+ # DM allowlist
120
+ dm_cfg = matrix_raw.get("dm", {})
121
+ dm_policy = dm_cfg.get("policy", "allowlist")
122
+ dm_allow_from: list[str] = dm_cfg.get("allowFrom", [])
123
+
124
+ # Group allowlist
125
+ group_policy = matrix_raw.get("groupPolicy", "allowlist")
126
+ group_allow_from: list[str] = matrix_raw.get("groupAllowFrom", [])
127
+
128
+ # Per-room/group config (pass through as-is for MatrixChannel to use)
129
+ groups = matrix_raw.get("groups", {})
130
+
131
+ matrix_channel_cfg = {
132
+ "enabled": matrix_raw.get("enabled", True),
133
+ "homeserver": homeserver,
134
+ "access_token": access_token,
135
+ "dm_policy": dm_policy,
136
+ "allow_from": dm_allow_from,
137
+ "group_policy": group_policy,
138
+ "group_allow_from": group_allow_from,
139
+ "groups": groups,
140
+ }
141
+
142
+ config_path = working_dir / "config.json"
143
+ # Merge with existing config to avoid clobbering other settings
144
+ existing: dict[str, Any] = {}
145
+ if config_path.exists():
146
+ with open(config_path) as f:
147
+ existing = json.load(f)
148
+
149
+ existing.setdefault("channels", {})["matrix"] = matrix_channel_cfg
150
+ # Disable console channel (we use Matrix)
151
+ existing["channels"].setdefault("console", {})["enabled"] = False
152
+
153
+ with open(config_path, "w") as f:
154
+ json.dump(existing, f, indent=2, ensure_ascii=False)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # providers.json
159
+ # ---------------------------------------------------------------------------
160
+
161
+ def _write_providers_json(
162
+ cfg: dict[str, Any],
163
+ working_dir: Path,
164
+ in_container: bool,
165
+ ) -> None:
166
+ providers_raw = cfg.get("models", {}).get("providers", {})
167
+
168
+ custom_providers: dict[str, Any] = {}
169
+ active_provider_id = ""
170
+ active_model = ""
171
+
172
+ for provider_id, provider_cfg in providers_raw.items():
173
+ base_url = _port_remap(
174
+ provider_cfg.get("baseUrl", ""), in_container
175
+ )
176
+ api_key = provider_cfg.get("apiKey", "")
177
+
178
+ models_raw = provider_cfg.get("models", [])
179
+ models = [
180
+ {"id": m["id"], "name": m.get("name", m["id"])}
181
+ for m in models_raw
182
+ if m.get("id")
183
+ ]
184
+
185
+ custom_providers[provider_id] = {
186
+ "id": provider_id,
187
+ "name": provider_id,
188
+ "default_base_url": base_url,
189
+ "api_key_prefix": "",
190
+ "models": models,
191
+ "base_url": base_url,
192
+ "api_key": api_key,
193
+ "chat_model": "OpenAIChatModel",
194
+ }
195
+
196
+ # Use first provider + first model as active LLM
197
+ if not active_provider_id and models:
198
+ active_provider_id = provider_id
199
+ active_model = models[0]["id"]
200
+
201
+ # Resolve active model from agents.defaults.model.primary
202
+ # Format: "provider_id/model_id"
203
+ primary = (
204
+ cfg.get("agents", {})
205
+ .get("defaults", {})
206
+ .get("model", {})
207
+ .get("primary", "")
208
+ )
209
+ if primary and "/" in primary:
210
+ pid, mid = primary.split("/", 1)
211
+ if pid in custom_providers:
212
+ active_provider_id = pid
213
+ active_model = mid
214
+
215
+ providers_data: dict[str, Any] = {
216
+ "providers": {},
217
+ "custom_providers": custom_providers,
218
+ "active_llm": {
219
+ "provider_id": active_provider_id,
220
+ "model": active_model,
221
+ },
222
+ }
223
+
224
+ providers_path = working_dir / "providers.json"
225
+ with open(providers_path, "w") as f:
226
+ json.dump(providers_data, f, indent=2, ensure_ascii=False)
@@ -0,0 +1,63 @@
1
+ """CLI entry point: copaw-worker"""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ import signal
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from copaw_worker.config import WorkerConfig
13
+ from copaw_worker.worker import Worker
14
+
15
+ logging.basicConfig(
16
+ level=logging.INFO,
17
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
18
+ )
19
+
20
+
21
+ def main() -> None:
22
+ """Entry point registered in pyproject.toml."""
23
+
24
+ def _run(
25
+ name: str = typer.Option(..., "--name", help="Worker name"),
26
+ fs: str = typer.Option(..., "--fs", help="MinIO endpoint"),
27
+ fs_key: str = typer.Option(..., "--fs-key", help="MinIO access key"),
28
+ fs_secret: str = typer.Option(..., "--fs-secret", help="MinIO secret key"),
29
+ fs_bucket: str = typer.Option("hiclaw-storage", "--fs-bucket", help="MinIO bucket"),
30
+ sync_interval: int = typer.Option(300, "--sync-interval", help="Sync interval (seconds)"),
31
+ install_dir: Optional[str] = typer.Option(None, "--install-dir", help="Base install dir"),
32
+ console_port: Optional[int] = typer.Option(None, "--console-port", help="Enable web console on this port (e.g. 8088, costs ~500MB extra RAM)"),
33
+ ) -> None:
34
+ """Start the CoPaw Worker and connect to Matrix."""
35
+ config = WorkerConfig(
36
+ worker_name=name,
37
+ minio_endpoint=fs,
38
+ minio_access_key=fs_key,
39
+ minio_secret_key=fs_secret,
40
+ minio_bucket=fs_bucket,
41
+ sync_interval=sync_interval,
42
+ install_dir=Path(install_dir) if install_dir else None,
43
+ console_port=console_port,
44
+ )
45
+ worker = Worker(config)
46
+
47
+ async def _async_run() -> None:
48
+ loop = asyncio.get_running_loop()
49
+
50
+ def _shutdown() -> None:
51
+ asyncio.create_task(worker.stop())
52
+
53
+ for sig in (signal.SIGINT, signal.SIGTERM):
54
+ loop.add_signal_handler(sig, _shutdown)
55
+
56
+ await worker.run()
57
+
58
+ try:
59
+ asyncio.run(_async_run())
60
+ except KeyboardInterrupt:
61
+ pass
62
+
63
+ typer.run(_run)
@@ -0,0 +1,28 @@
1
+ """WorkerConfig: parsed from CLI args / env vars."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ class WorkerConfig:
8
+ def __init__(
9
+ self,
10
+ worker_name: str,
11
+ minio_endpoint: str,
12
+ minio_access_key: str,
13
+ minio_secret_key: str,
14
+ minio_bucket: str = "hiclaw-storage",
15
+ minio_secure: bool = False,
16
+ sync_interval: int = 300,
17
+ install_dir: Path | None = None,
18
+ console_port: int | None = None,
19
+ ) -> None:
20
+ self.worker_name = worker_name
21
+ self.minio_endpoint = minio_endpoint
22
+ self.minio_access_key = minio_access_key
23
+ self.minio_secret_key = minio_secret_key
24
+ self.minio_bucket = minio_bucket
25
+ self.minio_secure = minio_secure
26
+ self.sync_interval = sync_interval
27
+ self.install_dir = install_dir or Path.home() / ".copaw-worker"
28
+ self.console_port = console_port