hubble-cli 4.0.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.
- hubble/__init__.py +3 -0
- hubble/__main__.py +4 -0
- hubble/agent.py +546 -0
- hubble/banner.py +484 -0
- hubble/board.py +118 -0
- hubble/main.py +299 -0
- hubble/models.py +92 -0
- hubble/onboarding.py +94 -0
- hubble/permissions.py +139 -0
- hubble/prompts.py +157 -0
- hubble/provider.py +283 -0
- hubble/providers.py +186 -0
- hubble/repl.py +976 -0
- hubble/scanner.py +134 -0
- hubble/session.py +128 -0
- hubble/settings.py +184 -0
- hubble/skills.py +173 -0
- hubble/spinner.py +77 -0
- hubble/tools.py +763 -0
- hubble/ui.py +469 -0
- hubble/web.py +279 -0
- hubble_cli-4.0.0.dist-info/METADATA +275 -0
- hubble_cli-4.0.0.dist-info/RECORD +27 -0
- hubble_cli-4.0.0.dist-info/WHEEL +5 -0
- hubble_cli-4.0.0.dist-info/entry_points.txt +3 -0
- hubble_cli-4.0.0.dist-info/licenses/LICENSE +21 -0
- hubble_cli-4.0.0.dist-info/top_level.txt +1 -0
hubble/scanner.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Background model health scan: fetch /models, send a tiny completion to each, save working ones.
|
|
2
|
+
|
|
3
|
+
Writes available_models.json in the same format as test_models.py, so both stay compatible.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from hubble.models import SCAN_FILE
|
|
16
|
+
from hubble.provider import normalize_base_url
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def scan_age_hours(path: Path = SCAN_FILE) -> Optional[float]:
|
|
20
|
+
try:
|
|
21
|
+
return (time.time() - path.stat().st_mtime) / 3600
|
|
22
|
+
except OSError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _probe(client: httpx.Client, base_url: str, headers: Dict[str, str], model: str, timeout: float) -> Dict[str, Any]:
|
|
27
|
+
payload = {"model": model, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 10, "temperature": 0.1}
|
|
28
|
+
start = time.time()
|
|
29
|
+
result = {"model": model, "available": False, "status_code": None, "latency_ms": 0, "sample": "", "reason": ""}
|
|
30
|
+
try:
|
|
31
|
+
resp = client.post(f"{base_url}/chat/completions", headers=headers, json=payload, timeout=timeout)
|
|
32
|
+
result["latency_ms"] = round((time.time() - start) * 1000)
|
|
33
|
+
result["status_code"] = resp.status_code
|
|
34
|
+
text = resp.text
|
|
35
|
+
if resp.status_code != 200:
|
|
36
|
+
result["reason"] = f"HTTP {resp.status_code}"
|
|
37
|
+
elif "upstream returned 403" in text or '"upstream_error"' in text or "unhandled err" in text:
|
|
38
|
+
result["reason"] = "upstream error inside 200"
|
|
39
|
+
else:
|
|
40
|
+
choices = resp.json().get("choices") or []
|
|
41
|
+
content = ((choices[0].get("message") or {}).get("content") or "").strip() if choices else ""
|
|
42
|
+
if content:
|
|
43
|
+
result.update(available=True, reason="OK", sample=content.replace("\n", " ")[:60])
|
|
44
|
+
else:
|
|
45
|
+
result["reason"] = "empty content"
|
|
46
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
47
|
+
result["latency_ms"] = round((time.time() - start) * 1000)
|
|
48
|
+
result["reason"] = f"{type(e).__name__}"
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ModelScanner:
|
|
53
|
+
"""Runs one scan at a time in a daemon thread; `status` is safe to read from the UI."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, base_url: str, api_key: str, concurrency: int = 12, timeout: float = 12.0,
|
|
56
|
+
output: Path = SCAN_FILE):
|
|
57
|
+
self.base_url = normalize_base_url(base_url)
|
|
58
|
+
self.api_key = api_key
|
|
59
|
+
self.concurrency = concurrency
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.output = output
|
|
62
|
+
self.status = "idle" # idle | running | done | failed
|
|
63
|
+
self.done = 0
|
|
64
|
+
self.total = 0
|
|
65
|
+
self.working = 0
|
|
66
|
+
self.error = ""
|
|
67
|
+
self._thread: Optional[threading.Thread] = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def running(self) -> bool:
|
|
71
|
+
return self._thread is not None and self._thread.is_alive()
|
|
72
|
+
|
|
73
|
+
def start(self) -> bool:
|
|
74
|
+
if self.running:
|
|
75
|
+
return False
|
|
76
|
+
self.status, self.done, self.total, self.working, self.error = "running", 0, 0, 0, ""
|
|
77
|
+
self._thread = threading.Thread(target=self._run, name="hubble-model-scan", daemon=True)
|
|
78
|
+
self._thread.start()
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
def summary(self) -> str:
|
|
82
|
+
if self.status == "running":
|
|
83
|
+
return f"scanning models {self.done}/{self.total or '?'}"
|
|
84
|
+
if self.status == "done":
|
|
85
|
+
return f"{self.working} models available"
|
|
86
|
+
if self.status == "failed":
|
|
87
|
+
return "model scan failed"
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
def _run(self):
|
|
91
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
92
|
+
try:
|
|
93
|
+
with httpx.Client(timeout=self.timeout + 5) as client:
|
|
94
|
+
resp = client.get(f"{self.base_url}/models", headers=headers)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
data = resp.json()
|
|
97
|
+
entries = data.get("data", data) if isinstance(data, dict) else data
|
|
98
|
+
owners = {m["id"]: m.get("owned_by", "") for m in entries if isinstance(m, dict) and m.get("id")}
|
|
99
|
+
self.total = len(owners)
|
|
100
|
+
results: List[Dict[str, Any]] = []
|
|
101
|
+
|
|
102
|
+
def task(name):
|
|
103
|
+
r = _probe(client, self.base_url, headers, name, self.timeout)
|
|
104
|
+
self.done += 1
|
|
105
|
+
if r["available"]:
|
|
106
|
+
self.working += 1
|
|
107
|
+
return r
|
|
108
|
+
|
|
109
|
+
with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
|
|
110
|
+
results = list(pool.map(task, owners))
|
|
111
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
|
|
112
|
+
self.status, self.error = "failed", f"{type(e).__name__}: {e}"[:200]
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
working = sorted((r for r in results if r["available"]), key=lambda r: r["latency_ms"])
|
|
116
|
+
if not working:
|
|
117
|
+
# A gateway outage or bad key would otherwise wipe the model list.
|
|
118
|
+
self.status, self.error = "failed", "no model responded; kept the previous list"
|
|
119
|
+
return
|
|
120
|
+
save = {
|
|
121
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
122
|
+
"base_url": self.base_url,
|
|
123
|
+
"total_tested": len(results),
|
|
124
|
+
"all_ids": list(owners),
|
|
125
|
+
"working_count": len(working),
|
|
126
|
+
"working_models": [{"model": r["model"], "latency_ms": r["latency_ms"],
|
|
127
|
+
"owner": owners.get(r["model"], ""), "sample": r["sample"]} for r in working],
|
|
128
|
+
"all_results": results,
|
|
129
|
+
}
|
|
130
|
+
self.output.parent.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
tmp = self.output.with_suffix(".json.tmp")
|
|
132
|
+
tmp.write_text(json.dumps(save, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
133
|
+
tmp.replace(self.output)
|
|
134
|
+
self.status = "done"
|
hubble/session.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Append-only JSONL session transcripts under ~/.hubble/projects/<project>/<id>.jsonl.
|
|
2
|
+
|
|
3
|
+
Record types: meta (first line), msg (one chat message), reset (history replaced by compaction).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from hubble.settings import HOME_DIR
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def project_dir(root: Path) -> Path:
|
|
18
|
+
slug = re.sub(r"[^A-Za-z0-9]+", "-", root.name).strip("-")[:40] or "root"
|
|
19
|
+
digest = hashlib.sha1(str(root).lower().encode()).hexdigest()[:8]
|
|
20
|
+
return HOME_DIR / "projects" / f"{slug}-{digest}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Session:
|
|
24
|
+
def __init__(self, path: Path, session_id: str):
|
|
25
|
+
self.path = path
|
|
26
|
+
self.id = session_id
|
|
27
|
+
|
|
28
|
+
def _write(self, record: Dict[str, Any]):
|
|
29
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
record.setdefault("ts", time.time())
|
|
31
|
+
line = json.dumps(record, ensure_ascii=False) + "\n"
|
|
32
|
+
if self.path.exists() and self.path.stat().st_size:
|
|
33
|
+
with open(self.path, "rb") as f:
|
|
34
|
+
f.seek(-1, 2)
|
|
35
|
+
if f.read(1) != b"\n":
|
|
36
|
+
line = "\n" + line # previous write was cut off; start a fresh line
|
|
37
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
38
|
+
f.write(line)
|
|
39
|
+
|
|
40
|
+
def append(self, message: Dict[str, Any]):
|
|
41
|
+
self._write({"type": "msg", "message": message})
|
|
42
|
+
|
|
43
|
+
def reset(self, messages: List[Dict[str, Any]]):
|
|
44
|
+
self._write({"type": "reset", "messages": messages})
|
|
45
|
+
|
|
46
|
+
def meta(self, **fields):
|
|
47
|
+
self._write({"type": "meta", **fields})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionStore:
|
|
51
|
+
def __init__(self, root: Path):
|
|
52
|
+
self.root = root
|
|
53
|
+
self.dir = project_dir(root)
|
|
54
|
+
|
|
55
|
+
def new(self, model: str) -> Session:
|
|
56
|
+
sid = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
|
|
57
|
+
session = Session(self.dir / f"{sid}.jsonl", sid)
|
|
58
|
+
session.meta(cwd=str(self.root), model=model)
|
|
59
|
+
return session
|
|
60
|
+
|
|
61
|
+
def list(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
62
|
+
if not self.dir.is_dir():
|
|
63
|
+
return []
|
|
64
|
+
files = sorted(self.dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
65
|
+
out = []
|
|
66
|
+
for p in files[:limit]:
|
|
67
|
+
messages, meta = self._replay(p)
|
|
68
|
+
first = next((m.get("content") for m in messages
|
|
69
|
+
if m.get("role") == "user" and isinstance(m.get("content"), str)), "")
|
|
70
|
+
if not messages:
|
|
71
|
+
continue
|
|
72
|
+
out.append({"id": p.stem, "updated": p.stat().st_mtime, "model": meta.get("model"),
|
|
73
|
+
"messages": len(messages), "title": (first or "").strip().splitlines()[0][:70] if first else ""})
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
def latest(self) -> Optional[str]:
|
|
77
|
+
items = self.list(limit=1)
|
|
78
|
+
return items[0]["id"] if items else None
|
|
79
|
+
|
|
80
|
+
def load(self, session_id: str) -> Tuple[Session, List[Dict[str, Any]], Dict[str, Any]]:
|
|
81
|
+
matches = [p for p in self.dir.glob("*.jsonl") if p.stem == session_id] or \
|
|
82
|
+
[p for p in self.dir.glob("*.jsonl") if p.stem.startswith(session_id) or session_id in p.stem]
|
|
83
|
+
if not matches:
|
|
84
|
+
raise FileNotFoundError(f"No session '{session_id}' for this project")
|
|
85
|
+
p = matches[0]
|
|
86
|
+
messages, meta = self._replay(p)
|
|
87
|
+
return Session(p, p.stem), messages, meta
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _replay(path: Path) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
|
91
|
+
messages: List[Dict[str, Any]] = []
|
|
92
|
+
meta: Dict[str, Any] = {}
|
|
93
|
+
try:
|
|
94
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
95
|
+
for line in f:
|
|
96
|
+
try:
|
|
97
|
+
rec = json.loads(line)
|
|
98
|
+
except ValueError:
|
|
99
|
+
continue # a crash can leave a partial last line
|
|
100
|
+
kind = rec.get("type")
|
|
101
|
+
if kind == "msg":
|
|
102
|
+
messages.append(rec["message"])
|
|
103
|
+
elif kind == "reset":
|
|
104
|
+
messages = list(rec.get("messages", []))
|
|
105
|
+
elif kind == "meta":
|
|
106
|
+
meta.update({k: v for k, v in rec.items() if k not in ("type", "ts")})
|
|
107
|
+
except OSError:
|
|
108
|
+
pass
|
|
109
|
+
return repair_history(messages), meta
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def repair_history(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
113
|
+
"""Give every assistant tool call a result, so a transcript cut mid-turn stays sendable."""
|
|
114
|
+
out: List[Dict[str, Any]] = []
|
|
115
|
+
pending: List[str] = []
|
|
116
|
+
for m in messages:
|
|
117
|
+
if pending and m.get("role") != "tool":
|
|
118
|
+
out.extend({"role": "tool", "tool_call_id": cid, "content": "Interrupted: no result recorded."}
|
|
119
|
+
for cid in pending)
|
|
120
|
+
pending = []
|
|
121
|
+
out.append(m)
|
|
122
|
+
if m.get("role") == "assistant" and m.get("tool_calls"):
|
|
123
|
+
pending = [tc["id"] for tc in m["tool_calls"]]
|
|
124
|
+
elif m.get("role") == "tool" and m.get("tool_call_id") in pending:
|
|
125
|
+
pending.remove(m["tool_call_id"])
|
|
126
|
+
out.extend({"role": "tool", "tool_call_id": cid, "content": "Interrupted: no result recorded."}
|
|
127
|
+
for cid in pending)
|
|
128
|
+
return out
|
hubble/settings.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Layered settings.
|
|
2
|
+
|
|
3
|
+
Precedence (later wins): built-in defaults, ~/.hubble/settings.json,
|
|
4
|
+
<project>/.hubble/settings.json, <project>/.hubble/settings.local.json, CLI flags.
|
|
5
|
+
Credentials come from HUBBLE_* environment variables or .env files (the older AIHUB_* names,
|
|
6
|
+
from before this CLI was renamed, are still read as a fallback).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
from hubble.models import DEFAULT_MODEL, PROJECT_DIR
|
|
16
|
+
|
|
17
|
+
HOME_DIR = Path(os.environ.get("HUBBLE_HOME", Path.home() / ".hubble"))
|
|
18
|
+
|
|
19
|
+
DEFAULTS: Dict[str, Any] = {
|
|
20
|
+
"model": DEFAULT_MODEL,
|
|
21
|
+
"base_url": "https://aihub.071129.xyz/v1",
|
|
22
|
+
"temperature": 0.3,
|
|
23
|
+
"max_tokens": 8192,
|
|
24
|
+
"context_window": 128000,
|
|
25
|
+
"auto_compact_ratio": 0.8,
|
|
26
|
+
"max_turns": 40,
|
|
27
|
+
"permission_mode": "default",
|
|
28
|
+
"persona": "code",
|
|
29
|
+
"shell": "auto",
|
|
30
|
+
"shell_timeout": 120,
|
|
31
|
+
"additional_dirs": [],
|
|
32
|
+
"shell_sandbox": "off", # "docker" runs shell commands in an isolated, resource-capped container
|
|
33
|
+
"sandbox_image": "python:3.12-slim",
|
|
34
|
+
"sandbox_memory": "1g",
|
|
35
|
+
"sandbox_cpus": "2",
|
|
36
|
+
"sandbox_network": True,
|
|
37
|
+
"allow_secret_files": False,
|
|
38
|
+
"model_refresh_hours": 0, # 0 = always check on startup; set hours to only recheck when stale
|
|
39
|
+
"fallback_model": "codestral-latest",
|
|
40
|
+
"show_reasoning": False,
|
|
41
|
+
"web_tools": True,
|
|
42
|
+
"web_search": {"engine": "auto"},
|
|
43
|
+
"home_animation": True,
|
|
44
|
+
"permissions": {"allow": [], "deny": []},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConfigError(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_ENV_PREFIXES = ("HUBBLE_", "AIHUB_") # AIHUB_ is the pre-rename name, kept as a fallback
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_env_file(path: Path) -> Dict[str, str]:
|
|
56
|
+
"""Only HUBBLE_*/AIHUB_* keys are read, so other secrets in a .env never leak into os.environ."""
|
|
57
|
+
out: Dict[str, str] = {}
|
|
58
|
+
try:
|
|
59
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
60
|
+
for line in f:
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
63
|
+
continue
|
|
64
|
+
key, val = line.split("=", 1)
|
|
65
|
+
key = key.strip().removeprefix("export ").strip()
|
|
66
|
+
if key.startswith(_ENV_PREFIXES):
|
|
67
|
+
out[key] = val.strip().strip("'\"")
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_json(path: Path) -> Dict[str, Any]:
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
return {}
|
|
76
|
+
try:
|
|
77
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
78
|
+
data = json.load(f)
|
|
79
|
+
except (OSError, ValueError) as e:
|
|
80
|
+
raise ConfigError(f"Invalid settings file {path}: {e}")
|
|
81
|
+
if not isinstance(data, dict):
|
|
82
|
+
raise ConfigError(f"Settings file {path} must contain a JSON object")
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
|
+
for key, val in override.items():
|
|
88
|
+
if key == "permissions" and isinstance(val, dict):
|
|
89
|
+
perms = base.setdefault("permissions", {"allow": [], "deny": []})
|
|
90
|
+
for kind in ("allow", "deny"):
|
|
91
|
+
perms[kind] = list(perms.get(kind, [])) + list(val.get(kind, []))
|
|
92
|
+
elif isinstance(val, dict) and isinstance(base.get(key), dict):
|
|
93
|
+
base[key] = _merge(dict(base[key]), val)
|
|
94
|
+
else:
|
|
95
|
+
base[key] = val
|
|
96
|
+
return base
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# A cloned repository must not be able to loosen security or redirect the API key.
|
|
100
|
+
NEVER_FROM_PROJECT = {"base_url", "api_key"}
|
|
101
|
+
TRUSTED_ONLY = {"permission_mode", "allow_secret_files", "additional_dirs", "shell",
|
|
102
|
+
"shell_sandbox", "sandbox_network"}
|
|
103
|
+
TRUST_FILE = HOME_DIR / "trusted_folders.json"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _trusted_folders() -> list:
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(TRUST_FILE.read_text(encoding="utf-8"))
|
|
109
|
+
return data if isinstance(data, list) else []
|
|
110
|
+
except (OSError, ValueError):
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_trusted(root: Path) -> bool:
|
|
115
|
+
key = str(root.resolve()).lower()
|
|
116
|
+
return any(key == str(t).lower() or key.startswith(str(t).lower().rstrip("\\/") + os.sep)
|
|
117
|
+
for t in _trusted_folders())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def trust_folder(root: Path):
|
|
121
|
+
folders = _trusted_folders()
|
|
122
|
+
if str(root.resolve()) not in folders:
|
|
123
|
+
folders.append(str(root.resolve()))
|
|
124
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
TRUST_FILE.write_text(json.dumps(folders, indent=2), encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _filter_project(data: Dict[str, Any], trusted: bool, ignored: list) -> Dict[str, Any]:
|
|
129
|
+
out = {}
|
|
130
|
+
for key, val in data.items():
|
|
131
|
+
if key in NEVER_FROM_PROJECT or (key in TRUSTED_ONLY and not trusted):
|
|
132
|
+
ignored.append(key)
|
|
133
|
+
continue
|
|
134
|
+
if key == "permissions" and isinstance(val, dict) and not trusted and val.get("allow"):
|
|
135
|
+
ignored.append("permissions.allow")
|
|
136
|
+
val = {"deny": val.get("deny", [])} # deny rules only tighten, so they always apply
|
|
137
|
+
out[key] = val
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_settings(root: Path, overrides: Optional[Dict[str, Any]] = None,
|
|
142
|
+
trusted: Optional[bool] = None) -> Dict[str, Any]:
|
|
143
|
+
if trusted is None:
|
|
144
|
+
trusted = is_trusted(root)
|
|
145
|
+
settings = copy.deepcopy(DEFAULTS)
|
|
146
|
+
settings = _merge(settings, _read_json(HOME_DIR / "settings.json"))
|
|
147
|
+
ignored: list = []
|
|
148
|
+
for path in (root / ".hubble" / "settings.json", root / ".hubble" / "settings.local.json"):
|
|
149
|
+
settings = _merge(settings, _filter_project(_read_json(path), trusted, ignored))
|
|
150
|
+
settings["_ignored_project_keys"] = sorted(set(ignored))
|
|
151
|
+
settings = _merge(settings, {k: v for k, v in (overrides or {}).items() if v is not None})
|
|
152
|
+
|
|
153
|
+
env: Dict[str, str] = {}
|
|
154
|
+
for env_file in (HOME_DIR / ".env", PROJECT_DIR / ".env"):
|
|
155
|
+
for k, v in _read_env_file(env_file).items():
|
|
156
|
+
env.setdefault(k, v)
|
|
157
|
+
env.update({k: v for k, v in os.environ.items() if k.startswith(_ENV_PREFIXES)})
|
|
158
|
+
|
|
159
|
+
if not settings.get("api_key"):
|
|
160
|
+
settings["api_key"] = env.get("HUBBLE_API_KEY") or env.get("AIHUB_API_KEY", "")
|
|
161
|
+
if overrides is None or overrides.get("base_url") is None:
|
|
162
|
+
settings["base_url"] = env.get("HUBBLE_BASE_URL") or env.get("AIHUB_BASE_URL") or settings["base_url"]
|
|
163
|
+
return settings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def require_api_key(settings: Dict[str, Any]) -> str:
|
|
167
|
+
key = settings.get("api_key") or ""
|
|
168
|
+
if not key:
|
|
169
|
+
raise ConfigError(
|
|
170
|
+
"Missing HUBBLE_API_KEY. Set it in one of:\n"
|
|
171
|
+
f" {PROJECT_DIR / '.env'}\n"
|
|
172
|
+
f" {HOME_DIR / '.env'}\n"
|
|
173
|
+
" or the environment: $env:HUBBLE_API_KEY=\"...\" (PowerShell) / export HUBBLE_API_KEY=... (bash)"
|
|
174
|
+
)
|
|
175
|
+
return key
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def save_user_setting(key: str, value: Any):
|
|
179
|
+
"""Persist one value in ~/.hubble/settings.json, keeping everything else in the file."""
|
|
180
|
+
path = HOME_DIR / "settings.json"
|
|
181
|
+
data = _read_json(path)
|
|
182
|
+
data[key] = value
|
|
183
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
hubble/skills.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Skills: reusable instructions the model can call itself, or the user can run with /<name>.
|
|
2
|
+
|
|
3
|
+
A skill is `<name>/SKILL.md` (with resources alongside it) or a plain `<name>.md`, under
|
|
4
|
+
`~/.hubble/skills/` (user, every project) or `<project>/.hubble/skills/` (project only).
|
|
5
|
+
It needs `name` and `description` frontmatter; the description is what tells the model,
|
|
6
|
+
and the user, when to reach for it — only the description is loaded into the system prompt,
|
|
7
|
+
and the full body loads on demand, so adding skills does not bloat every request.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from hubble.settings import HOME_DIR
|
|
16
|
+
from hubble.tools import Tool, ToolContext, ToolError, truncate
|
|
17
|
+
|
|
18
|
+
NAME_RX = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
|
|
19
|
+
FRONTMATTER_RX = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
|
|
20
|
+
|
|
21
|
+
TEMPLATE = """---
|
|
22
|
+
name: {name}
|
|
23
|
+
description: One sentence saying what this does and, importantly, WHEN to use it (the model \
|
|
24
|
+
and /help match on this). Example: "Run the project's test suite and summarize failures. Use \
|
|
25
|
+
when asked to test, check or verify the code works."
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# {title}
|
|
29
|
+
|
|
30
|
+
Step-by-step instructions for the model to follow when this skill is invoked. Be concrete:
|
|
31
|
+
name the exact commands, files or conventions this project uses.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Skill:
|
|
37
|
+
name: str
|
|
38
|
+
description: str
|
|
39
|
+
path: Path
|
|
40
|
+
scope: str # "user" | "project"
|
|
41
|
+
args_hint: str = ""
|
|
42
|
+
_body: Optional[str] = field(default=None, repr=False)
|
|
43
|
+
|
|
44
|
+
def body(self) -> str:
|
|
45
|
+
if self._body is None:
|
|
46
|
+
try:
|
|
47
|
+
self._body = _parse(self.path)[1]
|
|
48
|
+
except OSError as e:
|
|
49
|
+
self._body = f"(could not read {self.path}: {e})"
|
|
50
|
+
return self._body
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse(path: Path) -> tuple:
|
|
54
|
+
"""Returns (frontmatter dict, body). A file with no frontmatter is treated as pure body."""
|
|
55
|
+
text = path.read_text(encoding="utf-8")
|
|
56
|
+
m = FRONTMATTER_RX.match(text)
|
|
57
|
+
if not m:
|
|
58
|
+
return {}, text
|
|
59
|
+
meta: Dict[str, str] = {}
|
|
60
|
+
for line in m.group(1).splitlines():
|
|
61
|
+
if ":" in line and not line.startswith((" ", "\t", "-")):
|
|
62
|
+
key, _, val = line.partition(":")
|
|
63
|
+
meta[key.strip().lower()] = val.strip().strip("'\"")
|
|
64
|
+
return meta, m.group(2)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _skill_dirs(root: Path) -> List[tuple]:
|
|
68
|
+
return [(HOME_DIR / "skills", "user"), (root / ".hubble" / "skills", "project")]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def discover_skills(root: Path) -> List[Skill]:
|
|
72
|
+
"""Project skills come after user skills and win on a name clash (more specific wins)."""
|
|
73
|
+
found: Dict[str, Skill] = {}
|
|
74
|
+
for base, scope in _skill_dirs(root):
|
|
75
|
+
if not base.is_dir():
|
|
76
|
+
continue
|
|
77
|
+
candidates = sorted(base.glob("*/SKILL.md")) + sorted(base.glob("*.md"))
|
|
78
|
+
for path in candidates:
|
|
79
|
+
name = (path.parent.name if path.name == "SKILL.md" else path.stem).lower()
|
|
80
|
+
if not NAME_RX.match(name):
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
meta, body = _parse(path)
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
description = meta.get("description") or next(
|
|
87
|
+
(l.strip("# ").strip() for l in body.splitlines() if l.strip()), "") or "(no description)"
|
|
88
|
+
found[name] = Skill(name=meta.get("name", name).lower() or name, description=description,
|
|
89
|
+
path=path, scope=scope, args_hint=meta.get("argument-hint", ""))
|
|
90
|
+
return sorted(found.values(), key=lambda s: s.name)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def skills_prompt_block(skills: List[Skill]) -> str:
|
|
94
|
+
"""Name + description only (progressive disclosure); call the skill tool for the full body."""
|
|
95
|
+
if not skills:
|
|
96
|
+
return ""
|
|
97
|
+
lines = [f'- {s.name}: {s.description}' for s in skills]
|
|
98
|
+
return ("# Skills\nReusable instructions for recurring tasks in this project. If one clearly matches "
|
|
99
|
+
"what the user asked, call the skill tool to load its full instructions before proceeding. "
|
|
100
|
+
"Do not call it speculatively; only when its description matches.\n" + "\n".join(lines))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SkillTool(Tool):
|
|
104
|
+
name = "skill"
|
|
105
|
+
description = ("Load the full instructions for a named skill (see the Skills section of your system "
|
|
106
|
+
"prompt for names and descriptions). Call this before following a skill's steps.")
|
|
107
|
+
parameters = {"type": "object", "properties": {
|
|
108
|
+
"name": {"type": "string", "description": "Skill name, exactly as listed"},
|
|
109
|
+
}, "required": ["name"]}
|
|
110
|
+
kind = "read"
|
|
111
|
+
|
|
112
|
+
def __init__(self, skills: List[Skill]):
|
|
113
|
+
self.by_name = {s.name: s for s in skills}
|
|
114
|
+
|
|
115
|
+
def target(self, args):
|
|
116
|
+
return args.get("name", "")
|
|
117
|
+
|
|
118
|
+
def run(self, args, ctx):
|
|
119
|
+
skill = self.by_name.get(str(args["name"]).lower())
|
|
120
|
+
if not skill:
|
|
121
|
+
available = ", ".join(sorted(self.by_name)) or "(none)"
|
|
122
|
+
raise ToolError(f"no skill named '{args['name']}'. Available: {available}")
|
|
123
|
+
return truncate(skill.body(), 40000)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class WriteSkillTool(Tool):
|
|
127
|
+
name = "write_skill"
|
|
128
|
+
description = ("Save a reusable skill so future sessions in this project can call it by name. Use this "
|
|
129
|
+
"when you notice a multi-step procedure the user is likely to repeat (a release "
|
|
130
|
+
"checklist, a debugging recipe, project-specific conventions) — not for one-off tasks. "
|
|
131
|
+
"Ask the user before writing one unless they asked you to save it.")
|
|
132
|
+
parameters = {"type": "object", "properties": {
|
|
133
|
+
"name": {"type": "string", "description": "lowercase-with-hyphens, becomes the /name command"},
|
|
134
|
+
"description": {"type": "string",
|
|
135
|
+
"description": "One sentence: what it does and when to use it (for matching)"},
|
|
136
|
+
"body": {"type": "string", "description": "Full instructions in Markdown"},
|
|
137
|
+
"scope": {"type": "string", "enum": ["project", "user"],
|
|
138
|
+
"description": "project (default; this repo only) or user (every project)"},
|
|
139
|
+
}, "required": ["name", "description", "body"]}
|
|
140
|
+
kind = "edit"
|
|
141
|
+
|
|
142
|
+
def _path(self, args, ctx: ToolContext) -> Path:
|
|
143
|
+
name = str(args["name"]).lower().strip()
|
|
144
|
+
if not NAME_RX.match(name):
|
|
145
|
+
raise ToolError("name must be lowercase letters, digits, - or _ (max 41 chars)")
|
|
146
|
+
base = HOME_DIR / "skills" if args.get("scope") == "user" else ctx.root / ".hubble" / "skills"
|
|
147
|
+
return base / name / "SKILL.md"
|
|
148
|
+
|
|
149
|
+
def _content(self, args) -> str:
|
|
150
|
+
return f"---\nname: {args['name']}\ndescription: {args['description']}\n---\n\n{args['body'].strip()}\n"
|
|
151
|
+
|
|
152
|
+
def target(self, args):
|
|
153
|
+
return str(args.get("name", ""))
|
|
154
|
+
|
|
155
|
+
def preview(self, args, ctx):
|
|
156
|
+
try:
|
|
157
|
+
path = self._path(args, ctx)
|
|
158
|
+
except ToolError as e:
|
|
159
|
+
return f"(will fail: {e})"
|
|
160
|
+
old = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
161
|
+
new = self._content(args)
|
|
162
|
+
from hubble.tools import unified_diff
|
|
163
|
+
return unified_diff(old, new, str(path)) or "(no changes)"
|
|
164
|
+
|
|
165
|
+
def run(self, args, ctx: ToolContext):
|
|
166
|
+
path = self._path(args, ctx)
|
|
167
|
+
existed = path.is_file()
|
|
168
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
ctx.snapshot(path)
|
|
170
|
+
path.write_text(self._content(args), encoding="utf-8")
|
|
171
|
+
where = "every project" if args.get("scope") == "user" else "this project"
|
|
172
|
+
return f"{'Updated' if existed else 'Saved'} skill '{args['name']}' for {where} at {path}. " \
|
|
173
|
+
f"Available immediately as /{args['name']} or the skill tool."
|