#!/usr/bin/env python3 """ Sprint 11 — Collective Knowledge Network client. Uploads structural operation knowledge (not user data) to cloud API. Pulls verified execution paths from cloud before local discovery. """ from __future__ import annotations import json import os import time import urllib.request import urllib.error from pathlib import Path CLOUD_API = os.environ.get("HERMES_CLOUD_API", "https://api.hermes-agent.ai/v1") CLOUD_KEY = os.environ.get("HERMES_CLOUD_KEY", "") LOCAL_CACHE = Path(os.environ.get( "CLOUD_CACHE_DIR", str(Path(os.environ.get("HOME", "/data/data/com.termux/files/home")) / ".hermes" / "cloud_cache") )) UPLOAD_LOG = Path(os.environ.get( "UPLOAD_LOG", str(Path(os.environ.get("HOME", "/data/data/com.termux/files/home")) / ".hermes" / "cloud_uploads.jsonl") )) # Schema version for knowledge entries SCHEMA_VERSION = "1.0" # ──── Knowledge Entry Format (S11-01) ───────────────────────────────────────── def make_knowledge_entry( intent: str, app: str, app_version: str, execution_path: list[dict], success_rate: float = 1.0, notes: str = "", ) -> dict: """ Create a knowledge entry for upload. Only structural knowledge — no user data (content, contacts, messages). """ return { "schema_version": SCHEMA_VERSION, "intent": intent, "app": app, "app_version": app_version, "execution_path": execution_path, "success_rate": success_rate, "notes": notes, "validated_by": 1, "ts": int(time.time()), "device_type": "pixel_8_pro", "android_version": "14", } # ──── Upload client (S11-02) ────────────────────────────────────────────────── def upload_knowledge(entry: dict) -> dict: """Upload a knowledge entry to cloud API.""" if not CLOUD_KEY: return {"error": "HERMES_CLOUD_KEY not set", "skipped": True} LOCAL_CACHE.mkdir(parents=True, exist_ok=True) # Log locally regardless of upload success _log_entry = {**entry, "upload_ts": int(time.time())} with open(UPLOAD_LOG, "a", encoding="utf-8") as f: f.write(json.dumps(_log_entry, ensure_ascii=False) + "\n") # Upload to cloud try: payload = json.dumps(entry).encode() req = urllib.request.Request( f"{CLOUD_API}/knowledge", data=payload, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {CLOUD_KEY}", "X-Schema-Version": SCHEMA_VERSION, }, method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: return {"error": f"HTTP {e.code}", "body": e.read().decode()[:200]} except Exception as e: # Store locally for retry pending_path = LOCAL_CACHE / f"pending_{int(time.time())}.json" pending_path.write_text(json.dumps(entry, ensure_ascii=False), encoding="utf-8") return {"error": str(e), "pending": str(pending_path)} # ──── Pull client (S11-03) ──────────────────────────────────────────────────── def pull_knowledge(app: str, intent: str, app_version: str = "") -> list[dict]: """ Pull verified knowledge from cloud for a given app/intent. Returns list of execution_path entries sorted by success_rate descending. Falls back to local cache if cloud is unavailable. """ LOCAL_CACHE.mkdir(parents=True, exist_ok=True) cache_key = f"{app}_{intent}_{app_version}".replace("/", "_") cache_file = LOCAL_CACHE / f"{cache_key}.json" # Try cloud first if CLOUD_KEY: try: params = f"app={app}&intent={intent}" if app_version: params += f"&app_version={app_version}" req = urllib.request.Request( f"{CLOUD_API}/knowledge?{params}", headers={"Authorization": f"Bearer {CLOUD_KEY}"}, ) with urllib.request.urlopen(req, timeout=5) as resp: data = json.loads(resp.read().decode()) entries = data.get("entries", []) # Cache locally cache_file.write_text( json.dumps(entries, ensure_ascii=False), encoding="utf-8") return entries except Exception: pass # Fallback to local cache if cache_file.exists(): try: return json.loads(cache_file.read_text(encoding="utf-8")) except Exception: pass return [] def get_best_execution_path(app: str, intent: str, app_version: str = "") -> list[dict] | None: """Return the highest success_rate execution path from cloud/cache, or None.""" entries = pull_knowledge(app, intent, app_version) if not entries: return None entries.sort(key=lambda e: e.get("success_rate", 0), reverse=True) return entries[0].get("execution_path") # ──── Auto-upload after successful operations ────────────────────────────────── def record_success(app: str, intent: str, method: str, app_version: str = "") -> dict: """Record a successful operation execution to cloud (async-safe).""" entry = make_knowledge_entry( intent=intent, app=app, app_version=app_version, execution_path=[{"method": method, "step": 1, "result": "success"}], success_rate=1.0, ) return upload_knowledge(entry) def retry_pending_uploads() -> dict: """Retry any locally-cached pending uploads.""" if not CLOUD_KEY: return {"skipped": True} LOCAL_CACHE.mkdir(parents=True, exist_ok=True) pending = list(LOCAL_CACHE.glob("pending_*.json")) uploaded = 0 failed = 0 for p in pending: try: entry = json.loads(p.read_text(encoding="utf-8")) result = upload_knowledge(entry) if "error" not in result or result.get("skipped"): p.unlink() uploaded += 1 else: failed += 1 except Exception: failed += 1 return {"uploaded": uploaded, "failed": failed, "pending_remaining": failed} if __name__ == "__main__": import sys if len(sys.argv) > 1 and sys.argv[1] == "test": # Test with sample knowledge entry (no real upload without API key) entry = make_knowledge_entry( intent="send_message", app="wechat", app_version="8.0.47", execution_path=[ {"method": "accessibility", "step": 1, "action": "am_start", "target": "LauncherUI"}, {"method": "accessibility", "step": 2, "action": "input_text", "content": ""}, {"method": "accessibility", "step": 3, "action": "keyevent", "key": "ENTER"}, ], success_rate=0.95, ) print("Knowledge entry:") print(json.dumps(entry, ensure_ascii=False, indent=2)) # Test upload (will fail without API key) result = upload_knowledge(entry) print("\nUpload result:", json.dumps(result, ensure_ascii=False)) # Test pull best = get_best_execution_path("wechat", "send_message") print("\nBest execution path from cloud:", best) else: print("Usage: python3 cloud_knowledge.py test") print(f"CLOUD_API: {CLOUD_API}") print(f"CLOUD_KEY: {'set' if CLOUD_KEY else 'NOT SET'}") pending = list(LOCAL_CACHE.glob("pending_*.json")) if LOCAL_CACHE.exists() else [] print(f"Pending uploads: {len(pending)}")