From 3c60282270f21084c103965ef93272dbac639260 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Thu, 12 Mar 2026 08:25:21 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20ClawHub=20skill=20install=20?= =?UTF-8?q?=E2=80=94=20use=20/download=20ZIP=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ClawHub API v1 version endpoint only returns file metadata (path, size, sha256, contentType) without inline content or download URLs. Our code was looking for inline content in the metadata, which never existed, causing all ClawHub installs to fail with: 'no inline/raw file content was available' Fix: Use the /api/v1/download endpoint (same as the official clawhub CLI) to download skills as ZIP bundles and extract files in-memory. Changes: - Add _download_zip() method that downloads and extracts ZIP bundles - Retry on 429 rate limiting with Retry-After header support - Path sanitization and binary file filtering for security - Keep _extract_files() as a fallback for inline/raw content - Also fix nested file lookup (version_data.version.files) --- tools/skills_hub.py | 78 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/tools/skills_hub.py b/tools/skills_hub.py index b4e66746ea318..eab88002385e1 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -572,14 +572,23 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]: logger.warning("ClawHub fetch failed for %s: could not resolve latest version", slug) return None - version_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions/{latest_version}") - if not isinstance(version_data, dict): - return None + # Primary method: download the skill as a ZIP bundle from /download + files = self._download_zip(slug, latest_version) + + # Fallback: try the version metadata endpoint for inline/raw content + if "SKILL.md" not in files: + version_data = self._get_json(f"{self.BASE_URL}/skills/{slug}/versions/{latest_version}") + if isinstance(version_data, dict): + # Files may be nested under version_data["version"]["files"] + files = self._extract_files(version_data) or files + if "SKILL.md" not in files: + nested = version_data.get("version", {}) + if isinstance(nested, dict): + files = self._extract_files(nested) or files - files = self._extract_files(version_data) if "SKILL.md" not in files: logger.warning( - "ClawHub fetch for %s resolved version %s but no inline/raw file content was available", + "ClawHub fetch for %s resolved version %s but could not retrieve file content", slug, latest_version, ) @@ -674,6 +683,65 @@ def _extract_files(self, version_data: Dict[str, Any]) -> Dict[str, str]: return files + def _download_zip(self, slug: str, version: str) -> Dict[str, str]: + """Download skill as a ZIP bundle from the /download endpoint and extract text files.""" + import io + import zipfile + + files: Dict[str, str] = {} + max_retries = 3 + for attempt in range(max_retries): + try: + resp = httpx.get( + f"{self.BASE_URL}/download", + params={"slug": slug, "version": version}, + timeout=30, + follow_redirects=True, + ) + if resp.status_code == 429: + retry_after = int(resp.headers.get("retry-after", "5")) + retry_after = min(retry_after, 15) # Cap wait time + logger.debug( + "ClawHub download rate-limited for %s, retrying in %ds (attempt %d/%d)", + slug, retry_after, attempt + 1, max_retries, + ) + time.sleep(retry_after) + continue + if resp.status_code != 200: + logger.debug("ClawHub ZIP download for %s v%s returned %s", slug, version, resp.status_code) + return files + + with zipfile.ZipFile(io.BytesIO(resp.content)) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + # Sanitize path — strip leading slashes and .. + name = info.filename.lstrip("/") + if ".." in name or name.startswith("/"): + continue + # Only extract text-sized files (skip large binaries) + if info.file_size > 500_000: + logger.debug("Skipping large file in ZIP: %s (%d bytes)", name, info.file_size) + continue + try: + raw = zf.read(info.filename) + files[name] = raw.decode("utf-8") + except (UnicodeDecodeError, KeyError): + logger.debug("Skipping non-text file in ZIP: %s", name) + continue + + return files + + except zipfile.BadZipFile: + logger.warning("ClawHub returned invalid ZIP for %s v%s", slug, version) + return files + except httpx.HTTPError as exc: + logger.debug("ClawHub ZIP download failed for %s v%s: %s", slug, version, exc) + return files + + logger.debug("ClawHub ZIP download exhausted retries for %s v%s", slug, version) + return files + def _fetch_text(self, url: str) -> Optional[str]: try: resp = httpx.get(url, timeout=20) From a91a8fd767d298ec6b85761cc1a91893fea25e17 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Thu, 12 Mar 2026 15:42:55 -0700 Subject: [PATCH 2/2] chore: lower default compression threshold from 85% to 50% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triggers context compression earlier — at 50% of the model's context window instead of 85%. Updated in all four places where the default is defined: context_compressor.py, cli.py, run_agent.py, config.py, and gateway/run.py. --- agent/context_compressor.py | 2 +- cli.py | 2 +- gateway/run.py | 2 +- hermes_cli/config.py | 4 ++-- run_agent.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index a0ca0c991588a..b2dff9c85a78f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -28,7 +28,7 @@ class ContextCompressor: def __init__( self, model: str, - threshold_percent: float = 0.85, + threshold_percent: float = 0.50, protect_first_n: int = 3, protect_last_n: int = 4, summary_target_tokens: int = 2500, diff --git a/cli.py b/cli.py index 7f2b2394a05fc..4443bad5fa946 100755 --- a/cli.py +++ b/cli.py @@ -175,7 +175,7 @@ def load_cli_config() -> Dict[str, Any]: }, "compression": { "enabled": True, # Auto-compress when approaching context limit - "threshold": 0.85, # Compress at 85% of model's context limit + "threshold": 0.50, # Compress at 50% of model's context limit "summary_model": "google/gemini-3-flash-preview", # Fast/cheap model for summaries }, "agent": { diff --git a/gateway/run.py b/gateway/run.py index dfd1e4c200d68..0ea715d1d7edb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1035,7 +1035,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Read model + compression config from config.yaml — same # source of truth the agent itself uses. _hyg_model = "anthropic/claude-sonnet-4.6" - _hyg_threshold_pct = 0.85 + _hyg_threshold_pct = 0.50 _hyg_compression_enabled = True try: _hyg_cfg_path = _hermes_home / "config.yaml" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 39501e93dc11d..c05ebd5a45730 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -121,7 +121,7 @@ def ensure_hermes_home(): "compression": { "enabled": True, - "threshold": 0.85, + "threshold": 0.50, "summary_model": "google/gemini-3-flash-preview", "summary_provider": "auto", }, @@ -1119,7 +1119,7 @@ def show_config(): enabled = compression.get('enabled', True) print(f" Enabled: {'yes' if enabled else 'no'}") if enabled: - print(f" Threshold: {compression.get('threshold', 0.85) * 100:.0f}%") + print(f" Threshold: {compression.get('threshold', 0.50) * 100:.0f}%") print(f" Model: {compression.get('summary_model', 'google/gemini-3-flash-preview')}") comp_provider = compression.get('summary_provider', 'auto') if comp_provider != 'auto': diff --git a/run_agent.py b/run_agent.py index cce83f6b6bb70..84312335b1486 100644 --- a/run_agent.py +++ b/run_agent.py @@ -662,7 +662,7 @@ def __init__( # Initialize context compressor for automatic context management # Compresses conversation when approaching model's context limit # Configuration via config.yaml (compression section) or environment variables - compression_threshold = float(os.getenv("CONTEXT_COMPRESSION_THRESHOLD", "0.85")) + compression_threshold = float(os.getenv("CONTEXT_COMPRESSION_THRESHOLD", "0.50")) compression_enabled = os.getenv("CONTEXT_COMPRESSION_ENABLED", "true").lower() in ("true", "1", "yes") compression_summary_model = os.getenv("CONTEXT_COMPRESSION_MODEL") or None