hexcli 2.8.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.
- hexcli/__init__.py +6 -0
- hexcli/agent.py +1931 -0
- hexcli/assets/hexcli.ico +0 -0
- hexcli/assets/hexcli.png +0 -0
- hexcli/cancel.py +76 -0
- hexcli/chatlog.py +232 -0
- hexcli/commands.py +73 -0
- hexcli/compaction.py +309 -0
- hexcli/config.py +217 -0
- hexcli/diffview.py +106 -0
- hexcli/distribution.py +237 -0
- hexcli/doctor.py +265 -0
- hexcli/escalate.py +192 -0
- hexcli/http_client.py +156 -0
- hexcli/launcher.py +481 -0
- hexcli/lineedit.py +1110 -0
- hexcli/llm.py +599 -0
- hexcli/local_escalation.py +191 -0
- hexcli/lockfile.py +71 -0
- hexcli/loop_v2.py +393 -0
- hexcli/markdown_stream.py +241 -0
- hexcli/memory.py +416 -0
- hexcli/network.py +154 -0
- hexcli/parsing.py +215 -0
- hexcli/paths.py +127 -0
- hexcli/prompts.py +321 -0
- hexcli/protocol_v2.py +505 -0
- hexcli/repl.py +807 -0
- hexcli/safety.py +127 -0
- hexcli/sessions.py +226 -0
- hexcli/setup_wizard.py +144 -0
- hexcli/shell_session.py +186 -0
- hexcli/statusbar.py +894 -0
- hexcli/stream_render.py +250 -0
- hexcli/telemetry.py +131 -0
- hexcli/tools.py +775 -0
- hexcli/ui.py +1106 -0
- hexcli-2.8.0.dist-info/METADATA +394 -0
- hexcli-2.8.0.dist-info/RECORD +42 -0
- hexcli-2.8.0.dist-info/WHEEL +4 -0
- hexcli-2.8.0.dist-info/entry_points.txt +3 -0
- hexcli-2.8.0.dist-info/licenses/LICENSE +21 -0
hexcli/distribution.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""hexcli.distribution — Self-update and uninstall helpers for Hex CLI.
|
|
2
|
+
|
|
3
|
+
Called from hexcli.agent via --update and --uninstall flags.
|
|
4
|
+
All logic is stdlib only.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
# The npurun binary comes from the fork's own releases, not Hex CLI's.
|
|
16
|
+
_GITHUB_API = "https://api.github.com/repos/NathanL15/npurun/releases/latest"
|
|
17
|
+
_NPURUN_ASSET = "npurun-arm64.exe"
|
|
18
|
+
_SHORTCUT_NAME = "Hex CLI.lnk"
|
|
19
|
+
_START_MENU = Path.home() / "AppData" / "Roaming" / "Microsoft" / "Windows" / "Start Menu" / "Programs"
|
|
20
|
+
# The Windows Terminal profile install.ps1 registers (a fragment, so it never
|
|
21
|
+
# edits the user's settings.json). Removed on uninstall.
|
|
22
|
+
_WT_FRAGMENT = Path.home() / "AppData" / "Local" / "Microsoft" / "Windows Terminal" / "Fragments" / "Hex CLI" / "hexcli.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Helpers
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def _print(msg: str) -> None:
|
|
30
|
+
print(f" {msg}", flush=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _fetch_latest_release() -> dict[str, Any]:
|
|
34
|
+
req = urllib.request.Request(
|
|
35
|
+
_GITHUB_API,
|
|
36
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": "hexcli"},
|
|
37
|
+
)
|
|
38
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
39
|
+
return json.loads(resp.read().decode())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _find_asset_url(release: dict[str, Any], asset_name: str) -> str | None:
|
|
43
|
+
for asset in release.get("assets", []):
|
|
44
|
+
if asset.get("name") == asset_name:
|
|
45
|
+
return str(asset["browser_download_url"])
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _download(url: str, dest: Path) -> None:
|
|
50
|
+
req = urllib.request.Request(url, headers={"User-Agent": "hexcli"})
|
|
51
|
+
with urllib.request.urlopen(req, timeout=120) as resp, dest.open("wb") as fh:
|
|
52
|
+
shutil.copyfileobj(resp, fh)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _launcher():
|
|
56
|
+
try:
|
|
57
|
+
from . import launcher
|
|
58
|
+
return launcher
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_version(tag: str) -> tuple[int, ...]:
|
|
64
|
+
import re
|
|
65
|
+
m = re.search(r"(\d+)\.(\d+)\.(\d+)", tag or "")
|
|
66
|
+
return tuple(int(x) for x in m.groups()) if m else ()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _git_pull(install_dir: Path) -> bool:
|
|
70
|
+
"""Return True if git pull succeeds, False on any failure."""
|
|
71
|
+
git = shutil.which("git")
|
|
72
|
+
if not git:
|
|
73
|
+
_print("git not found; source update skipped.")
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
result = subprocess.run(
|
|
77
|
+
[git, "pull", "--ff-only"],
|
|
78
|
+
cwd=str(install_dir),
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=120,
|
|
82
|
+
)
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
_print("git pull timed out; source update skipped.")
|
|
85
|
+
return False
|
|
86
|
+
if result.returncode == 0:
|
|
87
|
+
_print(result.stdout.strip() or "Already up to date.")
|
|
88
|
+
return True
|
|
89
|
+
_print(f"git pull failed: {result.stderr.strip()}")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Public entry points
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def update(install_dir: Path | None = None) -> int:
|
|
98
|
+
"""Pull the latest source and refresh the npurun binary.
|
|
99
|
+
|
|
100
|
+
Returns an exit code (0 = success, 1 = partial failure, 2 = hard failure).
|
|
101
|
+
"""
|
|
102
|
+
from . import paths
|
|
103
|
+
print("\n Hex CLI update\n")
|
|
104
|
+
|
|
105
|
+
# 1. Update the Python source: git in a checkout, pip for an installed package.
|
|
106
|
+
install_dir = install_dir or paths.CHECKOUT_DIR
|
|
107
|
+
if install_dir is not None and (install_dir / ".git").exists():
|
|
108
|
+
_print("Pulling source...")
|
|
109
|
+
_git_pull(install_dir)
|
|
110
|
+
else:
|
|
111
|
+
_print("Installed as a package; update the code with:\n"
|
|
112
|
+
" pip install --upgrade git+https://github.com/NathanL15/Hex-CLI")
|
|
113
|
+
|
|
114
|
+
# 2. Fetch the fork's latest release metadata from GitHub.
|
|
115
|
+
_print("Checking the latest npurun release...")
|
|
116
|
+
try:
|
|
117
|
+
release = _fetch_latest_release()
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
_print(f"GitHub API error: {exc}")
|
|
120
|
+
_print("Source updated. npurun not checked: no network.")
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
tag = release.get("tag_name", "unknown")
|
|
124
|
+
_print(f"Latest npurun: {tag}")
|
|
125
|
+
|
|
126
|
+
# Nothing to do when the binary the launcher will run is already there.
|
|
127
|
+
ln = _launcher()
|
|
128
|
+
if ln is not None:
|
|
129
|
+
have = ln.find_npurun_exe()
|
|
130
|
+
have_version = ln._npurun_version(have) if have else ()
|
|
131
|
+
want = _parse_version(tag)
|
|
132
|
+
if have_version and want and tuple(have_version) >= want:
|
|
133
|
+
_print(f"npurun {ln.version_str(have_version)} at {have} is current.")
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
# 3. Download the npurun binary if a matching asset exists.
|
|
137
|
+
url = _find_asset_url(release, _NPURUN_ASSET)
|
|
138
|
+
if not url:
|
|
139
|
+
_print(f"{tag} has no {_NPURUN_ASSET} asset; binary update skipped.")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
bin_dir = paths.npurun_bin_dir()
|
|
143
|
+
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
existing = bin_dir / _NPURUN_ASSET
|
|
145
|
+
dest_tmp = bin_dir / f"{_NPURUN_ASSET}.tmp"
|
|
146
|
+
_print(f"Downloading {_NPURUN_ASSET}...")
|
|
147
|
+
try:
|
|
148
|
+
_download(url, dest_tmp)
|
|
149
|
+
dest_tmp.replace(existing)
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
dest_tmp.unlink(missing_ok=True)
|
|
152
|
+
_print(f"Download failed: {exc}")
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
_print(f"npurun updated: {existing}")
|
|
156
|
+
_print("Update complete.")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def uninstall(install_dir: Path | None = None) -> int:
|
|
161
|
+
"""Remove the Start Menu shortcut and optionally purge user data."""
|
|
162
|
+
from . import paths
|
|
163
|
+
print("\n Hex CLI uninstall\n")
|
|
164
|
+
|
|
165
|
+
# 1. Remove Start Menu shortcut.
|
|
166
|
+
shortcut = _START_MENU / _SHORTCUT_NAME
|
|
167
|
+
if shortcut.exists():
|
|
168
|
+
try:
|
|
169
|
+
shortcut.unlink()
|
|
170
|
+
_print(f"Removed shortcut: {shortcut}")
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
_print(f"Could not remove shortcut: {exc}")
|
|
173
|
+
else:
|
|
174
|
+
_print("Start Menu shortcut not found.")
|
|
175
|
+
if _WT_FRAGMENT.exists():
|
|
176
|
+
try:
|
|
177
|
+
_WT_FRAGMENT.unlink()
|
|
178
|
+
try:
|
|
179
|
+
_WT_FRAGMENT.parent.rmdir() # the fragment folder, if nothing else is in it
|
|
180
|
+
except OSError:
|
|
181
|
+
pass
|
|
182
|
+
_print("Removed Windows Terminal profile: Hex CLI")
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
_print(f"Could not remove the Windows Terminal profile: {exc}")
|
|
185
|
+
|
|
186
|
+
# 2. Ask whether to purge per-user data (~/.shellai: sessions, memory,
|
|
187
|
+
# chat logs, the runtime config, the embedding model).
|
|
188
|
+
shellai_dir = paths.data_dir(create=False)
|
|
189
|
+
if shellai_dir.exists():
|
|
190
|
+
try:
|
|
191
|
+
answer = input(
|
|
192
|
+
f"\n Remove {shellai_dir} with its sessions and memory? [y/N] "
|
|
193
|
+
).strip().lower()
|
|
194
|
+
except (EOFError, KeyboardInterrupt):
|
|
195
|
+
answer = "n"
|
|
196
|
+
if answer in ("y", "yes"):
|
|
197
|
+
try:
|
|
198
|
+
shutil.rmtree(shellai_dir)
|
|
199
|
+
_print(f"Removed: {shellai_dir}")
|
|
200
|
+
except OSError as exc:
|
|
201
|
+
_print(f"Could not remove {shellai_dir}: {exc}")
|
|
202
|
+
else:
|
|
203
|
+
_print(f"{shellai_dir} kept.")
|
|
204
|
+
|
|
205
|
+
# 3. The code itself.
|
|
206
|
+
checkout = install_dir or paths.CHECKOUT_DIR
|
|
207
|
+
_print("\nTo complete uninstall: pip uninstall hexcli")
|
|
208
|
+
if checkout is not None:
|
|
209
|
+
_print(f"and delete the checkout: Remove-Item -Recurse \"{checkout}\"")
|
|
210
|
+
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def first_run_check(install_dir: Path | None = None) -> None:
|
|
215
|
+
"""Print first-run setup hints when critical dependencies are missing.
|
|
216
|
+
|
|
217
|
+
Runs once per process on every `hexcli` invocation, but prints nothing
|
|
218
|
+
when everything looks healthy — zero noise for existing installs.
|
|
219
|
+
"""
|
|
220
|
+
from . import paths
|
|
221
|
+
hints: list[str] = []
|
|
222
|
+
|
|
223
|
+
# npurun: the launcher's own search (source build, downloaded binary, PATH).
|
|
224
|
+
ln = _launcher()
|
|
225
|
+
found = ln.find_npurun_exe() if ln is not None else None
|
|
226
|
+
if found is None and not (shutil.which("npurun") or shutil.which("npurun.exe")):
|
|
227
|
+
hints.append(" npurun not found. Run: hexcli --update")
|
|
228
|
+
|
|
229
|
+
# ONNX embedding model for memory.
|
|
230
|
+
if not paths.embedding_model_path().exists():
|
|
231
|
+
hints.append(" Embedding model missing; memory is off. hexcli --doctor prints the download commands.")
|
|
232
|
+
|
|
233
|
+
if hints:
|
|
234
|
+
print("\n First-run setup", flush=True)
|
|
235
|
+
for h in hints:
|
|
236
|
+
print(h, flush=True)
|
|
237
|
+
print(flush=True)
|
hexcli/doctor.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.doctor — diagnose an installation and say exactly how to fix it.
|
|
3
|
+
|
|
4
|
+
The QAIRT SDK cannot be redistributed and the NPU bundles are multi-GB, so
|
|
5
|
+
this project can never be a one-click install. The honest response is to
|
|
6
|
+
diagnose perfectly: every check prints PASS/WARN/FAIL plus the exact command
|
|
7
|
+
that fixes it.
|
|
8
|
+
|
|
9
|
+
This exists because of a real failure: the ONNX embedding model was never
|
|
10
|
+
installed on the development machine, so semantic memory silently no-opped
|
|
11
|
+
for months (docs/V2_PLAN.md §14.1). `first_run_check` printed a hint that
|
|
12
|
+
scrolled past. Checks that only whisper are checks that don't work.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .ui import C, cprint
|
|
27
|
+
|
|
28
|
+
PASS, WARN, FAIL = "PASS", "WARN", "FAIL"
|
|
29
|
+
_NPURUN_RELEASES = "https://github.com/NathanL15/npurun/releases"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Check:
|
|
34
|
+
name: str
|
|
35
|
+
status: str
|
|
36
|
+
detail: str
|
|
37
|
+
fix: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _mark(status: str) -> str:
|
|
41
|
+
return {
|
|
42
|
+
PASS: f"{C.BGREEN} ok {C.RESET}",
|
|
43
|
+
WARN: f"{C.BYELLOW} warn {C.RESET}",
|
|
44
|
+
FAIL: f"{C.BRED} fail {C.RESET}",
|
|
45
|
+
}[status]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_python() -> Check:
|
|
49
|
+
v = sys.version_info
|
|
50
|
+
if v < (3, 10):
|
|
51
|
+
return Check("Python", FAIL, f"{v.major}.{v.minor}; 3.10 or newer required",
|
|
52
|
+
"Install the ARM64 build of Python 3.11 or newer from python.org.")
|
|
53
|
+
return Check("Python", PASS, f"{v.major}.{v.minor}.{v.micro} ({sys.executable})")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def check_packages() -> list[Check]:
|
|
57
|
+
out: list[Check] = []
|
|
58
|
+
for mod, why, fix in (
|
|
59
|
+
("numpy", "vector math for memory", "pip install numpy"),
|
|
60
|
+
("onnxruntime", "embedding model runtime", "pip install onnxruntime"),
|
|
61
|
+
):
|
|
62
|
+
try:
|
|
63
|
+
__import__(mod)
|
|
64
|
+
out.append(Check(mod, PASS, "importable"))
|
|
65
|
+
except ImportError:
|
|
66
|
+
out.append(Check(mod, WARN, f"missing; {why} off", fix))
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_embedding_model(app_dir: Path | None = None) -> list[Check]:
|
|
71
|
+
"""The silent-failure case that motivated this whole command."""
|
|
72
|
+
from . import paths
|
|
73
|
+
if app_dir is not None: # an explicit location (tests, a custom layout)
|
|
74
|
+
model = app_dir / "onnx" / paths.EMBEDDING_MODEL_FILE
|
|
75
|
+
tok = app_dir / "onnx" / paths.EMBEDDING_TOKENIZER_FILE
|
|
76
|
+
dest = app_dir / "onnx"
|
|
77
|
+
else:
|
|
78
|
+
model = paths.embedding_model_path()
|
|
79
|
+
tok = paths.embedding_tokenizer_path()
|
|
80
|
+
dest = paths.embedding_dir(for_download=True)
|
|
81
|
+
fix = (
|
|
82
|
+
f"Download both into {dest} :\n"
|
|
83
|
+
f" curl -L -o \"{dest / paths.EMBEDDING_MODEL_FILE}\" "
|
|
84
|
+
f"{paths.EMBEDDING_BASE_URL}/onnx/{paths.EMBEDDING_MODEL_FILE}\n"
|
|
85
|
+
f" curl -L -o \"{dest / paths.EMBEDDING_TOKENIZER_FILE}\" "
|
|
86
|
+
f"{paths.EMBEDDING_BASE_URL}/{paths.EMBEDDING_TOKENIZER_FILE}"
|
|
87
|
+
)
|
|
88
|
+
checks = []
|
|
89
|
+
if model.exists() and model.stat().st_size > 1_000_000:
|
|
90
|
+
checks.append(Check("embedding model", PASS, f"{model.stat().st_size // 1_000_000} MB"))
|
|
91
|
+
else:
|
|
92
|
+
checks.append(Check("embedding model", WARN, "missing; memory is off", fix))
|
|
93
|
+
if tok.exists():
|
|
94
|
+
checks.append(Check("embedding tokenizer", PASS, "present"))
|
|
95
|
+
else:
|
|
96
|
+
checks.append(Check("embedding tokenizer", WARN, "missing; memory is off", fix))
|
|
97
|
+
return checks
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _launcher():
|
|
101
|
+
try:
|
|
102
|
+
from . import launcher
|
|
103
|
+
return launcher
|
|
104
|
+
except Exception:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_qairt() -> list[Check]:
|
|
109
|
+
sdk = Path(os.environ.get("QNN_SDK_ROOT", r"C:\Qualcomm\AIStack\QAIRT_2.47.0"))
|
|
110
|
+
ln = _launcher()
|
|
111
|
+
if ln is not None:
|
|
112
|
+
sdk = ln.QNN_SDK_ROOT # what the launcher actually exports (newest valid; Rewind SDK first)
|
|
113
|
+
checks: list[Check] = []
|
|
114
|
+
if not sdk.exists():
|
|
115
|
+
checks.append(Check("QAIRT SDK", FAIL, f"not found at {sdk}",
|
|
116
|
+
"Download it from the Qualcomm developer portal and set QNN_SDK_ROOT."))
|
|
117
|
+
return checks
|
|
118
|
+
checks.append(Check("QAIRT SDK", PASS, str(sdk)))
|
|
119
|
+
lib = sdk / "lib" / "aarch64-windows-msvc"
|
|
120
|
+
checks.append(Check("QAIRT libs", PASS if lib.exists() else FAIL,
|
|
121
|
+
str(lib) if lib.exists() else "missing aarch64-windows-msvc libs",
|
|
122
|
+
"" if lib.exists() else "Re-run the QAIRT installer."))
|
|
123
|
+
adsp = Path(os.environ.get("ADSP_LIBRARY_PATH", ""))
|
|
124
|
+
wanted = sdk / "lib" / "hexagon-v73" / "unsigned"
|
|
125
|
+
if not adsp.exists():
|
|
126
|
+
checks.append(Check("ADSP_LIBRARY_PATH", WARN,
|
|
127
|
+
"unset; npurun crashes without it",
|
|
128
|
+
rf'setx ADSP_LIBRARY_PATH "{wanted}"'))
|
|
129
|
+
elif adsp.resolve() != wanted.resolve() and wanted.exists():
|
|
130
|
+
# Pinned to another SDK's libs (a setx from before 2.50 was installed).
|
|
131
|
+
# The launcher sets the right value for the server it starts; a
|
|
132
|
+
# server started by hand would use this one.
|
|
133
|
+
checks.append(Check("ADSP_LIBRARY_PATH", WARN,
|
|
134
|
+
f"{adsp} is not the {sdk.name} SDK's; the launcher overrides it",
|
|
135
|
+
rf'setx ADSP_LIBRARY_PATH "{wanted}"'))
|
|
136
|
+
else:
|
|
137
|
+
checks.append(Check("ADSP_LIBRARY_PATH", PASS, str(adsp)))
|
|
138
|
+
return checks
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def check_npurun() -> list[Check]:
|
|
142
|
+
ln = _launcher()
|
|
143
|
+
# The launcher's candidate order, so the doctor diagnoses the binary
|
|
144
|
+
# that will actually run.
|
|
145
|
+
path = ln.find_npurun_exe() if ln is not None else None
|
|
146
|
+
if path is None:
|
|
147
|
+
exe = shutil.which("npurun") or shutil.which("npurun.exe")
|
|
148
|
+
local = Path.home() / ".cargo" / "bin" / "npurun.exe"
|
|
149
|
+
path = Path(exe) if exe else (local if local.exists() else None)
|
|
150
|
+
if path is None:
|
|
151
|
+
return [Check("npurun", FAIL, "binary not found",
|
|
152
|
+
f"hexcli --update\n{_NPURUN_RELEASES}")]
|
|
153
|
+
checks = [Check("npurun", PASS, str(path))]
|
|
154
|
+
if ln is not None:
|
|
155
|
+
version = ln._npurun_version(path)
|
|
156
|
+
ver = ".".join(str(n) for n in version) or "unknown"
|
|
157
|
+
need = ".".join(str(n) for n in ln.REQUIRED_NPURUN)
|
|
158
|
+
if ln.npurun_outdated(version=version) is not None:
|
|
159
|
+
checks.append(Check("npurun version", FAIL,
|
|
160
|
+
f"{ver}; {need} required",
|
|
161
|
+
"hexcli --update"))
|
|
162
|
+
else:
|
|
163
|
+
checks.append(Check("npurun version", PASS, ver))
|
|
164
|
+
if ln.REWIND_ROOT is not None:
|
|
165
|
+
checks.append(Check("KV prefix reuse", PASS,
|
|
166
|
+
f"on: npurun {ver}, {ln.REWIND_ROOT.name}"))
|
|
167
|
+
else:
|
|
168
|
+
checks.append(Check("KV prefix reuse", WARN,
|
|
169
|
+
f"off: npurun {ver}, {ln.QNN_SDK_ROOT.name}",
|
|
170
|
+
r"Install QAIRT 2.50 or newer under C:\Qualcomm\AIStack."))
|
|
171
|
+
try:
|
|
172
|
+
r = subprocess.run([str(path), "list"], capture_output=True, text=True, timeout=20)
|
|
173
|
+
models = [ln.split()[0] for ln in r.stdout.splitlines() if ln.strip()]
|
|
174
|
+
if models:
|
|
175
|
+
checks.append(Check("model bundles", PASS, ", ".join(models)))
|
|
176
|
+
else:
|
|
177
|
+
checks.append(Check("model bundles", FAIL, "none downloaded",
|
|
178
|
+
"npurun pull qwen3-4b-instruct-2507"))
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
checks.append(Check("model bundles", WARN, f"could not list: {exc.__class__.__name__}"))
|
|
181
|
+
return checks
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check_server(config: dict[str, Any]) -> Check:
|
|
185
|
+
base = str(config.get("openai_compatible", {}).get("base_url", ""))
|
|
186
|
+
ln = _launcher()
|
|
187
|
+
if ln is not None and "_npurun_model" not in config:
|
|
188
|
+
# Run outside the launcher (the installer, a bare --doctor): the server
|
|
189
|
+
# that matters is the one the launcher starts, on its port, not the
|
|
190
|
+
# example config's.
|
|
191
|
+
base = f"http://127.0.0.1:{ln.NPURUN_PORT}"
|
|
192
|
+
if not base:
|
|
193
|
+
return Check("model server", WARN, "no openai_compatible.base_url configured")
|
|
194
|
+
host = base.split("//")[-1].split("/")[0]
|
|
195
|
+
try:
|
|
196
|
+
with urllib.request.urlopen(f"http://{host}/healthz", timeout=3) as r:
|
|
197
|
+
if r.status == 200:
|
|
198
|
+
detail = f"healthy at {host}"
|
|
199
|
+
try:
|
|
200
|
+
with urllib.request.urlopen(f"http://{host}/v1/models", timeout=3) as m:
|
|
201
|
+
models = json.loads(m.read().decode("utf-8")).get("data") or []
|
|
202
|
+
first = models[0] if models else {}
|
|
203
|
+
if first.get("input_token_budget"):
|
|
204
|
+
detail += (f"; input budget {first['input_token_budget']} of "
|
|
205
|
+
f"{first.get('context_size', '?')} tokens")
|
|
206
|
+
except Exception:
|
|
207
|
+
pass
|
|
208
|
+
return Check("model server", PASS, detail)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
return Check("model server", WARN, f"not responding at {host}",
|
|
212
|
+
"Launch Hex CLI, or run python launcher.py from the repo.")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_ruff() -> Check:
|
|
216
|
+
if shutil.which("ruff"):
|
|
217
|
+
return Check("ruff (optional)", PASS, "on PATH")
|
|
218
|
+
return Check("ruff (optional)", WARN, "not found; lint_code is off",
|
|
219
|
+
"pip install ruff")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_workspace() -> list[Check]:
|
|
223
|
+
cwd = Path.cwd()
|
|
224
|
+
checks = [Check("working directory", PASS, str(cwd))]
|
|
225
|
+
for name in ("AGENTS.md", ".shellai/AGENTS.md", "HEXCLI.md"):
|
|
226
|
+
if (cwd / name).is_file():
|
|
227
|
+
checks.append(Check("project instructions", PASS, f"{name} found"))
|
|
228
|
+
break
|
|
229
|
+
else:
|
|
230
|
+
checks.append(Check("project instructions", WARN,
|
|
231
|
+
"no AGENTS.md",
|
|
232
|
+
"Create AGENTS.md with a few lines about this project."))
|
|
233
|
+
return checks
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def run_doctor(config: dict[str, Any], app_dir: Path | None = None) -> int:
|
|
237
|
+
"""Print the full report. Returns 1 if any check FAILed."""
|
|
238
|
+
checks: list[Check] = [check_python()]
|
|
239
|
+
checks += check_packages()
|
|
240
|
+
checks += check_embedding_model()
|
|
241
|
+
checks += check_qairt()
|
|
242
|
+
checks += check_npurun()
|
|
243
|
+
checks.append(check_server(config))
|
|
244
|
+
checks.append(check_ruff())
|
|
245
|
+
checks += check_workspace()
|
|
246
|
+
|
|
247
|
+
print()
|
|
248
|
+
cprint(" Install check", C.BOLD)
|
|
249
|
+
print()
|
|
250
|
+
for c in checks:
|
|
251
|
+
print(f" {_mark(c.status)} {c.name:<22} {c.detail}")
|
|
252
|
+
if c.fix and c.status != PASS:
|
|
253
|
+
for line in c.fix.splitlines():
|
|
254
|
+
cprint(f" {line}", C.DIM)
|
|
255
|
+
fails = sum(1 for c in checks if c.status == FAIL)
|
|
256
|
+
warns = sum(1 for c in checks if c.status == WARN)
|
|
257
|
+
print()
|
|
258
|
+
if fails:
|
|
259
|
+
cprint(f" {fails} failed, {warns} warnings.", C.BRED)
|
|
260
|
+
elif warns:
|
|
261
|
+
cprint(f" {warns} warnings.", C.BYELLOW)
|
|
262
|
+
else:
|
|
263
|
+
cprint(" All checks passed.", C.BGREEN)
|
|
264
|
+
print()
|
|
265
|
+
return 1 if fails else 0
|
hexcli/escalate.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.escalate — Cloud escalation for stuck agent turns.
|
|
3
|
+
|
|
4
|
+
Triggered by error-loop detection. Sends the last 6 turns and the
|
|
5
|
+
failing tool sequence to Anthropic's API after a redaction pass.
|
|
6
|
+
|
|
7
|
+
Transport: stdlib urllib.request — no SDK dependency.
|
|
8
|
+
Key source: ANTHROPIC_API_KEY env var, then config["anthropic_api_key"].
|
|
9
|
+
Default model: claude-haiku-4-5-20251001 (override via config["escalation_model"]).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
_API_URL = "https://api.anthropic.com/v1/messages"
|
|
23
|
+
_ANTHROPIC_VERSION = "2023-06-01"
|
|
24
|
+
DEFAULT_ESCALATION_MODEL = "claude-haiku-4-5-20251001"
|
|
25
|
+
_MAX_FILE_CONTENT = 200 # chars — truncate long strings before sending
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Sensitive path prefixes — content following these in payload text is redacted.
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
_SENSITIVE_PATH_PREFIXES: list[str] = [
|
|
32
|
+
str(Path.home() / ".ssh"),
|
|
33
|
+
str(Path.home() / ".aws"),
|
|
34
|
+
str(Path.home() / ".gnupg"),
|
|
35
|
+
str(Path.home() / ".gpg"),
|
|
36
|
+
# Generic tilde forms so tests can use them directly.
|
|
37
|
+
"~/.ssh",
|
|
38
|
+
"~/.aws",
|
|
39
|
+
"~/.gnupg",
|
|
40
|
+
"~/.gpg",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Redaction patterns: (compiled_pattern, replacement) applied in order.
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
_REDACT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
48
|
+
# Anthropic / OpenAI API keys
|
|
49
|
+
(re.compile(r"sk-[A-Za-z0-9\-_]{10,}"), "sk-***"),
|
|
50
|
+
# Generic Bearer tokens
|
|
51
|
+
(re.compile(r"Bearer\s+[A-Za-z0-9._\-]{8,}"), "Bearer ***"),
|
|
52
|
+
# password= in query strings / config
|
|
53
|
+
(re.compile(r"password=[^\s&'\"\r\n<>]{1,200}", re.IGNORECASE), "password=***"),
|
|
54
|
+
# api_key= and api-key=
|
|
55
|
+
(re.compile(r"api[_\-]key=[^\s&'\"\r\n<>]{1,200}", re.IGNORECASE), "api_key=***"),
|
|
56
|
+
# token= (word-boundary to avoid "multipart" style false positives)
|
|
57
|
+
(re.compile(r"\btoken=[^\s&'\"\r\n<>]{1,200}", re.IGNORECASE), "token=***"),
|
|
58
|
+
# Connection strings
|
|
59
|
+
(
|
|
60
|
+
re.compile(
|
|
61
|
+
r"(postgresql|postgres|mongodb|mysql|redis|amqp|rabbitmq)://[^\s'\"\r\n<>{}[\]]{1,300}",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
),
|
|
64
|
+
r"\1://***",
|
|
65
|
+
),
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def redact_text(text: str) -> str:
|
|
70
|
+
"""Apply all redaction rules to a string, in-place (returns new string)."""
|
|
71
|
+
# Sensitive path content: if a sensitive path prefix appears, redact the
|
|
72
|
+
# following _MAX_FILE_CONTENT chars (which is likely file content).
|
|
73
|
+
for prefix in _SENSITIVE_PATH_PREFIXES:
|
|
74
|
+
idx = text.lower().find(prefix.lower())
|
|
75
|
+
while idx != -1:
|
|
76
|
+
end = min(idx + len(prefix) + _MAX_FILE_CONTENT, len(text))
|
|
77
|
+
text = text[:idx] + "[REDACTED:sensitive-path]" + text[end:]
|
|
78
|
+
idx = text.lower().find(prefix.lower())
|
|
79
|
+
|
|
80
|
+
for pattern, replacement in _REDACT_PATTERNS:
|
|
81
|
+
text = pattern.sub(replacement, text)
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _redact_value(v: Any) -> Any:
|
|
86
|
+
"""Recursively redact strings in a JSON-like structure."""
|
|
87
|
+
if isinstance(v, str):
|
|
88
|
+
if len(v) > _MAX_FILE_CONTENT * 2:
|
|
89
|
+
v = v[: _MAX_FILE_CONTENT] + "...[truncated]"
|
|
90
|
+
return redact_text(v)
|
|
91
|
+
if isinstance(v, dict):
|
|
92
|
+
return {k: _redact_value(val) for k, val in v.items()}
|
|
93
|
+
if isinstance(v, list):
|
|
94
|
+
return [_redact_value(item) for item in v]
|
|
95
|
+
return v
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def redact_payload(turns: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
99
|
+
"""Deep-copy and redact all strings in a list of message dicts."""
|
|
100
|
+
return [_redact_value(copy.deepcopy(t)) for t in turns]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# API key resolution
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def get_api_key(config: dict[str, Any]) -> str | None:
|
|
108
|
+
"""Return the Anthropic API key from env or config; None if absent."""
|
|
109
|
+
key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
|
110
|
+
if not key:
|
|
111
|
+
key = str(config.get("anthropic_api_key", "") or "").strip()
|
|
112
|
+
return key or None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Transport
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def _call_api(api_key: str, model: str, messages: list[dict[str, Any]]) -> str:
|
|
120
|
+
payload = json.dumps({
|
|
121
|
+
"model": model,
|
|
122
|
+
"max_tokens": 1024,
|
|
123
|
+
"messages": messages,
|
|
124
|
+
}).encode()
|
|
125
|
+
req = urllib.request.Request(
|
|
126
|
+
_API_URL,
|
|
127
|
+
data=payload,
|
|
128
|
+
headers={
|
|
129
|
+
"Content-Type": "application/json",
|
|
130
|
+
"x-api-key": api_key,
|
|
131
|
+
"anthropic-version": _ANTHROPIC_VERSION,
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
135
|
+
data = json.loads(resp.read().decode())
|
|
136
|
+
content = data.get("content", [])
|
|
137
|
+
if content and isinstance(content[0], dict):
|
|
138
|
+
return str(content[0].get("text", "")).strip()
|
|
139
|
+
return str(data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Main escalation entry point
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def escalate(
|
|
147
|
+
config: dict[str, Any],
|
|
148
|
+
turns: list[dict[str, Any]],
|
|
149
|
+
tool_seq: list[str],
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Send redacted context to Claude cloud and return the suggestion.
|
|
152
|
+
|
|
153
|
+
Returns a user-visible message — either the LLM's suggestion or an
|
|
154
|
+
explanation of why escalation is unavailable.
|
|
155
|
+
"""
|
|
156
|
+
api_key = get_api_key(config)
|
|
157
|
+
if not api_key:
|
|
158
|
+
return "(set ANTHROPIC_API_KEY to enable cloud escalation)"
|
|
159
|
+
|
|
160
|
+
model = str(config.get("escalation_model", DEFAULT_ESCALATION_MODEL)).strip()
|
|
161
|
+
|
|
162
|
+
# Build the escalation prompt.
|
|
163
|
+
parts: list[str] = [
|
|
164
|
+
"The local agent is stuck in a repeated error loop.\n",
|
|
165
|
+
]
|
|
166
|
+
if tool_seq:
|
|
167
|
+
parts.append(f"Failing tool sequence: {', '.join(tool_seq)}\n")
|
|
168
|
+
if turns:
|
|
169
|
+
parts.append("\nLast session turns:\n")
|
|
170
|
+
for t in turns[-6:]:
|
|
171
|
+
role = t.get("role", "?")
|
|
172
|
+
content = str(t.get("content", ""))[:500]
|
|
173
|
+
parts.append(f"[{role}]: {content}\n")
|
|
174
|
+
parts.append(
|
|
175
|
+
"\nPlease provide a concise suggestion on how to resolve this loop "
|
|
176
|
+
"and unblock the agent."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
prompt = redact_text("".join(parts))
|
|
180
|
+
messages = [{"role": "user", "content": prompt}]
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
return _call_api(api_key, model, messages)
|
|
184
|
+
except urllib.error.HTTPError as exc:
|
|
185
|
+
body = ""
|
|
186
|
+
try:
|
|
187
|
+
body = exc.read().decode()[:200]
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
return f"Cloud escalation failed (HTTP {exc.code}): {body}"
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
return f"Cloud escalation failed: {exc}"
|