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/http_client.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.http_client — keep-alive HTTP transport, lifted out of agent.py.
|
|
3
|
+
|
|
4
|
+
A single keep-alive connection per backend host:port is cached for the
|
|
5
|
+
life of the process and reused across every agent-loop step, instead of
|
|
6
|
+
opening/closing a fresh TCP connection on every call (the previous
|
|
7
|
+
urllib.request.urlopen()-per-call behaviour). The agent loop only ever has
|
|
8
|
+
one LLM call in flight at a time, so a single cached connection per host
|
|
9
|
+
is safe without locking around request/response pairs. Stays stdlib-only
|
|
10
|
+
(http.client), matching the project's no-heavy-deps design.
|
|
11
|
+
|
|
12
|
+
Split stage 2 (docs/V2X_ROADMAP.md, "The Split"). Function bodies are moved
|
|
13
|
+
verbatim; agent.py re-binds every name.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import http.client
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
_HTTP_CONNECTIONS: dict[tuple[str, str, int], http.client.HTTPConnection] = {}
|
|
27
|
+
_HTTP_CONN_LOCK = threading.Lock()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _connection_key(url: str) -> tuple[str, str, int]:
|
|
31
|
+
parsed = urllib.parse.urlsplit(url)
|
|
32
|
+
scheme = parsed.scheme or "http"
|
|
33
|
+
host = parsed.hostname or "127.0.0.1"
|
|
34
|
+
port = parsed.port or (443 if scheme == "https" else 80)
|
|
35
|
+
return (scheme, host, port)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_connection(url: str, timeout_s: float) -> tuple[http.client.HTTPConnection, str]:
|
|
39
|
+
parsed = urllib.parse.urlsplit(url)
|
|
40
|
+
scheme, host, port = _connection_key(url)
|
|
41
|
+
path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
|
|
42
|
+
with _HTTP_CONN_LOCK:
|
|
43
|
+
conn = _HTTP_CONNECTIONS.get((scheme, host, port))
|
|
44
|
+
if conn is None:
|
|
45
|
+
conn_cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
|
|
46
|
+
conn = conn_cls(host, port, timeout=timeout_s)
|
|
47
|
+
_HTTP_CONNECTIONS[(scheme, host, port)] = conn
|
|
48
|
+
else:
|
|
49
|
+
conn.timeout = timeout_s
|
|
50
|
+
return conn, path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _http_request(
|
|
54
|
+
method: str,
|
|
55
|
+
url: str,
|
|
56
|
+
headers: dict[str, str],
|
|
57
|
+
body: bytes | None,
|
|
58
|
+
timeout_s: float,
|
|
59
|
+
) -> http.client.HTTPResponse:
|
|
60
|
+
"""POST/GET over a cached keep-alive connection, with one transparent
|
|
61
|
+
reconnect if the server dropped an idle connection (RemoteDisconnected /
|
|
62
|
+
broken pipe) before we noticed.
|
|
63
|
+
|
|
64
|
+
Raises urllib.error.URLError / urllib.error.HTTPError on connection
|
|
65
|
+
failure / non-2xx status, matching what urllib.request.urlopen() used to
|
|
66
|
+
raise, so the existing top-level error handling keeps working unchanged.
|
|
67
|
+
"""
|
|
68
|
+
# The server holds one inference slot and answers 429 + Retry-After while
|
|
69
|
+
# it is busy — including the few seconds of an end-of-turn prewarm
|
|
70
|
+
# (_prewarm_backend). Wait it out instead of surfacing an error.
|
|
71
|
+
deadline = time.monotonic() + _BUSY_WAIT_MAX_S
|
|
72
|
+
while True:
|
|
73
|
+
resp = _http_request_once(method, url, headers, body, timeout_s)
|
|
74
|
+
if resp.status != 429 or time.monotonic() >= deadline:
|
|
75
|
+
break
|
|
76
|
+
try:
|
|
77
|
+
delay = float(resp.getheader("Retry-After") or 1.0)
|
|
78
|
+
except ValueError:
|
|
79
|
+
delay = 1.0
|
|
80
|
+
resp.read()
|
|
81
|
+
time.sleep(min(max(delay, 0.2), 3.0))
|
|
82
|
+
if resp.status >= 400:
|
|
83
|
+
body_bytes = resp.read()
|
|
84
|
+
raise urllib.error.HTTPError(
|
|
85
|
+
url, resp.status, resp.reason, dict(resp.getheaders()), io.BytesIO(body_bytes)
|
|
86
|
+
)
|
|
87
|
+
return resp
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_BUSY_WAIT_MAX_S = 25.0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _http_request_once(
|
|
94
|
+
method: str,
|
|
95
|
+
url: str,
|
|
96
|
+
headers: dict[str, str],
|
|
97
|
+
body: bytes | None,
|
|
98
|
+
timeout_s: float,
|
|
99
|
+
) -> http.client.HTTPResponse:
|
|
100
|
+
conn, path = _get_connection(url, timeout_s)
|
|
101
|
+
try:
|
|
102
|
+
conn.request(method, path, body=body, headers=headers)
|
|
103
|
+
resp = conn.getresponse()
|
|
104
|
+
except (
|
|
105
|
+
http.client.RemoteDisconnected, http.client.ImproperConnectionState,
|
|
106
|
+
http.client.BadStatusLine,
|
|
107
|
+
BrokenPipeError, ConnectionResetError, ConnectionAbortedError,
|
|
108
|
+
):
|
|
109
|
+
# ImproperConnectionState covers CannotSendRequest AND ResponseNotReady:
|
|
110
|
+
# a request that died mid-cycle (server killed for a restart) leaves the
|
|
111
|
+
# cached connection stuck in Request-sent, and without the reconnect the
|
|
112
|
+
# first call after a successful restart failed with ResponseNotReady.
|
|
113
|
+
conn.close()
|
|
114
|
+
try:
|
|
115
|
+
conn.request(method, path, body=body, headers=headers)
|
|
116
|
+
resp = conn.getresponse()
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
conn.close()
|
|
119
|
+
raise urllib.error.URLError(exc) from exc
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
# Close before raising, or the poisoned connection stays cached and
|
|
122
|
+
# every later call inherits its half-sent state.
|
|
123
|
+
conn.close()
|
|
124
|
+
raise urllib.error.URLError(exc) from exc
|
|
125
|
+
return resp
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def http_json_request(
|
|
129
|
+
url: str, payload: dict[str, Any], headers: dict[str, str], timeout_s: int
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
body = json.dumps(payload).encode("utf-8")
|
|
132
|
+
req_headers = {"Content-Type": "application/json"}
|
|
133
|
+
req_headers.update(headers)
|
|
134
|
+
resp = _http_request("POST", url, req_headers, body, timeout_s)
|
|
135
|
+
data = resp.read()
|
|
136
|
+
return json.loads(data.decode("utf-8"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def http_json_get(url: str, timeout_s: int = 10) -> Any:
|
|
140
|
+
resp = _http_request("GET", url, {}, None, timeout_s)
|
|
141
|
+
data = resp.read()
|
|
142
|
+
return json.loads(data.decode("utf-8"))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def ping_backend(config: dict[str, Any]) -> bool:
|
|
146
|
+
"""Return True if the configured backend responds to a quick health probe."""
|
|
147
|
+
try:
|
|
148
|
+
if config["backend"] == "ollama":
|
|
149
|
+
host = config["ollama"]["host"].rstrip("/")
|
|
150
|
+
http_json_get(f"{host}/api/tags", timeout_s=3)
|
|
151
|
+
elif config["backend"] == "openai":
|
|
152
|
+
base_url = config["openai_compatible"]["base_url"].rstrip("/")
|
|
153
|
+
_http_request("GET", f"{base_url}/models", {}, None, 3.0)
|
|
154
|
+
return True
|
|
155
|
+
except Exception:
|
|
156
|
+
return False
|
hexcli/launcher.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.launcher — start the NPU server, then the REPL.
|
|
3
|
+
|
|
4
|
+
The `hex` command (and `Hex CLI.cmd` / `launcher.py` in a checkout) runs
|
|
5
|
+
this. It finds the QAIRT SDK and the npurun build, pulls the model bundle
|
|
6
|
+
on first use, starts `npurun serve` if it is not up, writes the runtime
|
|
7
|
+
config under ~/.shellai, and runs `python -m hexcli.agent` with the
|
|
8
|
+
server's environment. Qwen3-4B on the Hexagon NPU is the only path: a
|
|
9
|
+
missing prerequisite is reported with the fix, never substituted.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Windows consoles often default to cp1252, which can't encode the arrows/
|
|
24
|
+
# checkmarks this script prints. Force UTF-8 so it works regardless of caller.
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
27
|
+
if hasattr(_stream, "reconfigure"):
|
|
28
|
+
_stream.reconfigure(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Paths
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
from . import paths
|
|
35
|
+
|
|
36
|
+
APP_DIR = paths.CHECKOUT_DIR or paths.PACKAGE_DIR # kept for callers; see hexcli.paths
|
|
37
|
+
|
|
38
|
+
# npurun — Qwen3-4B on Hexagon NPU via Qualcomm Genie SDK.
|
|
39
|
+
# Discovery mirrors install.ps1: a source build wins, then the prebuilt
|
|
40
|
+
# binary the installer downloads next to this script, then PATH.
|
|
41
|
+
|
|
42
|
+
# The fork build this version of Hex CLI is written for. An older build runs,
|
|
43
|
+
# but without whatever the newer fork added (2.6.x: host polling off, the
|
|
44
|
+
# start-up prime, the request-ending watchdog, the async-init override), and
|
|
45
|
+
# nothing used to say so. Now --doctor fails on it, the launcher warns, and
|
|
46
|
+
# install.ps1 / hexcli --update replace it. install.ps1 reads this line by
|
|
47
|
+
# regex, so keep the shape `REQUIRED_NPURUN = (a, b, c)`.
|
|
48
|
+
REQUIRED_NPURUN = (0, 2, 3)
|
|
49
|
+
NPURUN_RELEASES = "https://github.com/NathanL15/npurun/releases"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def version_str(version: tuple[int, ...]) -> str:
|
|
53
|
+
return ".".join(str(n) for n in version)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _npurun_version(exe: Path) -> tuple[int, ...]:
|
|
57
|
+
"""(major, minor, patch) from `npurun --version`; () if unknown."""
|
|
58
|
+
try:
|
|
59
|
+
out = subprocess.run([str(exe), "--version"], capture_output=True, text=True, timeout=10).stdout
|
|
60
|
+
except Exception:
|
|
61
|
+
return ()
|
|
62
|
+
m = re.search(r"(\d+)\.(\d+)\.(\d+)", out or "")
|
|
63
|
+
return tuple(int(x) for x in m.groups()) if m else ()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_npurun_exe(home: Path | None = None, app_dir: Path | None = None,
|
|
67
|
+
required: tuple[int, ...] | None = None) -> Path | None:
|
|
68
|
+
"""A source build wins, then the downloaded binary, then PATH — except
|
|
69
|
+
that a candidate older than `required` yields to a later one that is not,
|
|
70
|
+
so a stale cargo build cannot shadow the binary --update just fetched."""
|
|
71
|
+
home = home or Path.home()
|
|
72
|
+
required = REQUIRED_NPURUN if required is None else required
|
|
73
|
+
downloaded = ([app_dir / paths.NPURUN_ASSET] if app_dir is not None
|
|
74
|
+
else paths.npurun_download_candidates())
|
|
75
|
+
candidates = [c for c in (home / ".cargo" / "bin" / "npurun.exe", *downloaded) if c.exists()]
|
|
76
|
+
if not candidates:
|
|
77
|
+
import shutil
|
|
78
|
+
found = shutil.which("npurun")
|
|
79
|
+
return Path(found) if found else None
|
|
80
|
+
for candidate in candidates:
|
|
81
|
+
version = _npurun_version(candidate)
|
|
82
|
+
if version and tuple(version) >= tuple(required):
|
|
83
|
+
return candidate
|
|
84
|
+
return candidates[0]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def npurun_outdated(exe: Path | None = None,
|
|
88
|
+
version: tuple[int, ...] | None = None) -> tuple[int, ...] | None:
|
|
89
|
+
"""The installed build's version when it is known and older than
|
|
90
|
+
REQUIRED_NPURUN; None when it is current or unknown."""
|
|
91
|
+
if version is None:
|
|
92
|
+
version = _npurun_version(exe or NPURUN_EXE)
|
|
93
|
+
if version and tuple(version) < tuple(REQUIRED_NPURUN):
|
|
94
|
+
return tuple(version)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _qairt_valid(root: Path) -> bool:
|
|
99
|
+
return (
|
|
100
|
+
(root / "lib" / "aarch64-windows-msvc").exists()
|
|
101
|
+
and (root / "bin" / "aarch64-windows-msvc").exists()
|
|
102
|
+
and (root / "lib" / "hexagon-v73" / "unsigned").exists()
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _qairt_version_key(path: Path) -> tuple[int, ...]:
|
|
107
|
+
"""Numeric sort key for QAIRT_<a>.<b>.<c> directory names.
|
|
108
|
+
|
|
109
|
+
String sort is wrong and quietly so: 'QAIRT_2.9.0' > 'QAIRT_2.47.0'
|
|
110
|
+
lexicographically, which would export a stale SDK and produce exactly the
|
|
111
|
+
DLL/stack-overrun failures this discovery code exists to prevent.
|
|
112
|
+
"""
|
|
113
|
+
parts = path.name[len("QAIRT_"):].split(".")
|
|
114
|
+
key: list[int] = []
|
|
115
|
+
for part in parts:
|
|
116
|
+
digits = "".join(c for c in part if c.isdigit())
|
|
117
|
+
key.append(int(digits) if digits else 0)
|
|
118
|
+
return tuple(key)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def find_qairt_root(env_value: str | None = None, stack_dir: Path | None = None) -> Path | None:
|
|
122
|
+
"""QNN_SDK_ROOT env wins if valid; otherwise the newest valid
|
|
123
|
+
C:\\Qualcomm\\AIStack\\QAIRT_* install (compared numerically)."""
|
|
124
|
+
env_value = env_value if env_value is not None else os.environ.get("QNN_SDK_ROOT", "")
|
|
125
|
+
if env_value:
|
|
126
|
+
root = Path(env_value)
|
|
127
|
+
if _qairt_valid(root):
|
|
128
|
+
return root
|
|
129
|
+
# An explicitly-set root that fails validation is a user intention we
|
|
130
|
+
# are about to ignore; say so, or the resulting failure gets blamed on
|
|
131
|
+
# the SDK we silently substituted.
|
|
132
|
+
warn(f"QNN_SDK_ROOT={env_value} is not a valid QAIRT install; ignored.")
|
|
133
|
+
stack = stack_dir or Path("C:/Qualcomm/AIStack")
|
|
134
|
+
if stack.exists():
|
|
135
|
+
for candidate in sorted(stack.glob("QAIRT_*"), key=_qairt_version_key, reverse=True):
|
|
136
|
+
if _qairt_valid(candidate):
|
|
137
|
+
return candidate
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
NPURUN_MODEL = "qwen3-4b-instruct-2507"
|
|
142
|
+
NPURUN_MODEL_DIR = Path.home() / "AppData" / "Local" / "npurun" / "models" / NPURUN_MODEL
|
|
143
|
+
NPURUN_PORT = 11435
|
|
144
|
+
NPURUN_LOG = paths.npurun_log_path()
|
|
145
|
+
NPURUN_CONFIG = paths.runtime_config_path()
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Console dressing (classic conhost only)
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# The Start Menu shortcut launches via conhost.exe on purpose: Windows
|
|
151
|
+
# Terminal has no per-profile taskbar icon, so under WT the running app
|
|
152
|
+
# always groups under the generic terminal icon. Classic conhost windows
|
|
153
|
+
# accept WM_SETICON, which puts the Hex logo on the taskbar. Conhost does
|
|
154
|
+
# not enable ANSI processing by itself the way WT does, so switch that on
|
|
155
|
+
# too. Both calls are harmless no-ops under WT/ConPTY.
|
|
156
|
+
|
|
157
|
+
def _dress_console_window() -> None:
|
|
158
|
+
if sys.platform != "win32":
|
|
159
|
+
return
|
|
160
|
+
import ctypes
|
|
161
|
+
k32 = ctypes.windll.kernel32
|
|
162
|
+
for std in (-11, -12): # stdout, stderr
|
|
163
|
+
handle = k32.GetStdHandle(std)
|
|
164
|
+
mode = ctypes.c_uint32()
|
|
165
|
+
if k32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
166
|
+
k32.SetConsoleMode(handle, mode.value | 0x0004) # VT processing
|
|
167
|
+
# QuickEdit off. With it on (the conhost default), a click inside the
|
|
168
|
+
# window starts a selection and every console write blocks until a key
|
|
169
|
+
# is pressed — the answer streams into a frozen screen and Ctrl+C, which
|
|
170
|
+
# is "copy" while text is selected, is what releases it. Measured
|
|
171
|
+
# 2026-09-04: a click-drag froze the next write for as long as the
|
|
172
|
+
# selection lived, and the process never received an interrupt.
|
|
173
|
+
stdin = k32.GetStdHandle(-10)
|
|
174
|
+
mode = ctypes.c_uint32()
|
|
175
|
+
# Mouse input off too: Windows Terminal gives the mouse to the app when
|
|
176
|
+
# that flag is on and QuickEdit is off, and text selection stops working.
|
|
177
|
+
if k32.GetConsoleMode(stdin, ctypes.byref(mode)):
|
|
178
|
+
ENABLE_MOUSE_INPUT, ENABLE_QUICK_EDIT, ENABLE_EXTENDED_FLAGS = 0x0010, 0x0040, 0x0080
|
|
179
|
+
k32.SetConsoleMode(stdin, (mode.value & ~ENABLE_QUICK_EDIT & ~ENABLE_MOUSE_INPUT) | ENABLE_EXTENDED_FLAGS)
|
|
180
|
+
hwnd = k32.GetConsoleWindow()
|
|
181
|
+
ico = paths.icon_path()
|
|
182
|
+
if not (hwnd and ico.exists()):
|
|
183
|
+
return
|
|
184
|
+
u32 = ctypes.windll.user32
|
|
185
|
+
WM_SETICON, IMAGE_ICON, LR_LOADFROMFILE = 0x80, 1, 0x10
|
|
186
|
+
for which, size in ((0, 16), (1, 32)): # ICON_SMALL, ICON_BIG
|
|
187
|
+
h_icon = u32.LoadImageW(None, str(ico), IMAGE_ICON, size, size,
|
|
188
|
+
LR_LOADFROMFILE)
|
|
189
|
+
if h_icon:
|
|
190
|
+
u32.SendMessageW(hwnd, WM_SETICON, which, h_icon)
|
|
191
|
+
|
|
192
|
+
# Called from main(), NOT at import time: the evals import this module, and
|
|
193
|
+
# an import-time WM_SETICON re-badges whatever console the importing process
|
|
194
|
+
# happens to be running in (a test run turned the developer's own terminal
|
|
195
|
+
# tab into a Hex window).
|
|
196
|
+
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
# ANSI helpers
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
_TTY = sys.stdout.isatty()
|
|
202
|
+
|
|
203
|
+
def _c(text: str, code: str) -> str:
|
|
204
|
+
return f"\033[{code}m{text}\033[0m" if _TTY else text
|
|
205
|
+
|
|
206
|
+
def bold(t: str) -> str: return _c(t, "1")
|
|
207
|
+
def dim(t: str) -> str: return _c(t, "2")
|
|
208
|
+
def green(t: str) -> str: return _c(t, "92")
|
|
209
|
+
def cyan(t: str) -> str: return _c(t, "96")
|
|
210
|
+
def yellow(t: str) -> str: return _c(t, "93")
|
|
211
|
+
def red(t: str) -> str: return _c(t, "91")
|
|
212
|
+
|
|
213
|
+
def step(n: int, total: int, msg: str) -> None:
|
|
214
|
+
print(f" {bold(f'[{n}/{total}]')} {msg}", flush=True)
|
|
215
|
+
|
|
216
|
+
def ok(msg: str = "done") -> None:
|
|
217
|
+
print(f" {green('✓')} {msg}", flush=True)
|
|
218
|
+
|
|
219
|
+
def warn(msg: str) -> None:
|
|
220
|
+
print(f" {yellow('⚠')} {msg}", flush=True)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# Resolved after the printing helpers exist: find_qairt_root() warns when it
|
|
224
|
+
# rejects an explicitly-set QNN_SDK_ROOT, and a NameError there would crash
|
|
225
|
+
# the launcher at import for exactly the users that warning is meant for.
|
|
226
|
+
NPURUN_EXE = find_npurun_exe() or (Path.home() / ".cargo" / "bin" / "npurun.exe")
|
|
227
|
+
QNN_SDK_ROOT = find_qairt_root() or Path("C:/Qualcomm/AIStack/QAIRT_2.47.0")
|
|
228
|
+
|
|
229
|
+
# KV prefix reuse ("Rewind runtime"), measured 2026-09-02: on QAIRT >= 2.50
|
|
230
|
+
# the fork's NPURUN_REWIND=2 mode keeps the system prompt's KV cache across
|
|
231
|
+
# steps AND turns (first-token latency 2-7 s vs 6-10 s). It needs BOTH a
|
|
232
|
+
# >= 2.50 SDK and a fork build that knows the mode (>= 0.2.0); older builds
|
|
233
|
+
# reset the dialog per request, which 2.50 cannot tolerate after a large
|
|
234
|
+
# prefill. When both are present the newest SDK wins over QNN_SDK_ROOT.
|
|
235
|
+
MIN_REWIND_QAIRT = (2, 50)
|
|
236
|
+
MIN_REWIND_NPURUN = (0, 2, 0)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def rewind_runtime_root(stack_dir: Path | None = None,
|
|
240
|
+
npurun_version: tuple[int, ...] | None = None) -> Path | None:
|
|
241
|
+
"""The QAIRT root to use for the Rewind runtime, or None when the
|
|
242
|
+
machine lacks a new-enough SDK or npurun build."""
|
|
243
|
+
version = npurun_version if npurun_version is not None else _npurun_version(NPURUN_EXE)
|
|
244
|
+
if tuple(version) < MIN_REWIND_NPURUN:
|
|
245
|
+
return None
|
|
246
|
+
stack = stack_dir or Path("C:/Qualcomm/AIStack")
|
|
247
|
+
if not stack.exists():
|
|
248
|
+
return None
|
|
249
|
+
for candidate in sorted(stack.glob("QAIRT_*"), key=_qairt_version_key, reverse=True):
|
|
250
|
+
if _qairt_version_key(candidate)[:2] >= MIN_REWIND_QAIRT and _qairt_valid(candidate):
|
|
251
|
+
return candidate
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
REWIND_ROOT = rewind_runtime_root()
|
|
256
|
+
if REWIND_ROOT is not None:
|
|
257
|
+
QNN_SDK_ROOT = REWIND_ROOT
|
|
258
|
+
|
|
259
|
+
def err(msg: str) -> None:
|
|
260
|
+
print(f" {red('✗')} {msg}", flush=True)
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# npurun path — Qwen3-4B on Hexagon NPU (Genie SDK)
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def _npurun_ready() -> bool:
|
|
267
|
+
"""npurun.exe built/installed and QAIRT SDK present."""
|
|
268
|
+
return NPURUN_EXE.exists() and (QNN_SDK_ROOT / "lib" / "aarch64-windows-msvc").exists()
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _npurun_model_ok() -> bool:
|
|
272
|
+
return (NPURUN_MODEL_DIR / "manifest.json").exists() or NPURUN_MODEL_DIR.exists()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _npurun_env() -> dict:
|
|
276
|
+
env = os.environ.copy()
|
|
277
|
+
bin_dir = str(QNN_SDK_ROOT / "bin" / "aarch64-windows-msvc")
|
|
278
|
+
lib_dir = str(QNN_SDK_ROOT / "lib" / "aarch64-windows-msvc")
|
|
279
|
+
env["QNN_SDK_ROOT"] = str(QNN_SDK_ROOT)
|
|
280
|
+
env["ADSP_LIBRARY_PATH"] = str(QNN_SDK_ROOT / "lib" / "hexagon-v73" / "unsigned")
|
|
281
|
+
env["PATH"] = f"{bin_dir};{lib_dir};{NPURUN_EXE.parent};{env.get('PATH', '')}"
|
|
282
|
+
if REWIND_ROOT is not None:
|
|
283
|
+
env["NPURUN_REWIND"] = "2" # never reset; prefix-match every warm query
|
|
284
|
+
else:
|
|
285
|
+
env.pop("NPURUN_REWIND", None)
|
|
286
|
+
# Host polling of the NPU (hexcli-fork >= 0.2.2, NPURUN_HTP_POLL). The
|
|
287
|
+
# bundle ships poll=true, which spins ~3 cores even while idle (+11 W,
|
|
288
|
+
# SoC ~70 C) and costs decode speed; measured 2026-09-05 with it off:
|
|
289
|
+
# idle at the machine floor, 19 tok/s instead of 15.5 at half the power.
|
|
290
|
+
# 0.2.2 defaults to off; set it explicitly so the log records intent and
|
|
291
|
+
# a user can flip it back with NPURUN_HTP_POLL=1 in their environment.
|
|
292
|
+
env.setdefault("NPURUN_HTP_POLL", "0")
|
|
293
|
+
# Async dialog init (hexcli-fork >= 0.2.2, NPURUN_HTP_ASYNC_INIT). The
|
|
294
|
+
# fork turns Genie's `allow-async-init` on for a 1.4 s faster dialog
|
|
295
|
+
# rebuild. Measured 2026-09-06 (docs/backend_study/PROMPT_LEVER.md §5):
|
|
296
|
+
# at ~3K tokens of context it multiplies the NPU hang rate about tenfold
|
|
297
|
+
# (7 of 14 requests hung with it on, 1 of 25 with it off, AC power, host
|
|
298
|
+
# polling irrelevant). Off by default; NPURUN_HTP_ASYNC_INIT=1 restores it.
|
|
299
|
+
env.setdefault("NPURUN_HTP_ASYNC_INIT", "0")
|
|
300
|
+
return env
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _is_npurun_up() -> bool:
|
|
304
|
+
try:
|
|
305
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{NPURUN_PORT}/healthz", timeout=2) as r:
|
|
306
|
+
return r.status == 200
|
|
307
|
+
except Exception:
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" # the REPL's frames, so the two spinners match
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _wait_npurun(timeout: int = 60) -> bool:
|
|
315
|
+
deadline = time.time() + timeout
|
|
316
|
+
i = 0
|
|
317
|
+
while time.time() < deadline:
|
|
318
|
+
if i % 10 == 0 and _is_npurun_up():
|
|
319
|
+
print("\r" + " " * 40 + "\r", end="", flush=True)
|
|
320
|
+
return True
|
|
321
|
+
print(f"\r {cyan(_SPINNER[i % len(_SPINNER)])} {dim('starting the model server')}", end="", flush=True)
|
|
322
|
+
time.sleep(0.1)
|
|
323
|
+
i += 1
|
|
324
|
+
print()
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _pull_npurun_model() -> None:
|
|
329
|
+
"""Download the Qwen3-4B Genie bundle via `npurun pull` (~2.5 GB)."""
|
|
330
|
+
r = subprocess.run(
|
|
331
|
+
[str(NPURUN_EXE), "pull", NPURUN_MODEL],
|
|
332
|
+
env=_npurun_env(), text=True, capture_output=True,
|
|
333
|
+
)
|
|
334
|
+
if r.returncode != 0:
|
|
335
|
+
raise RuntimeError(r.stderr.strip() or "npurun pull failed")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _write_npurun_config() -> None:
|
|
339
|
+
"""Write the backend wiring, preserving anything the user set themselves.
|
|
340
|
+
|
|
341
|
+
This file is what `hexcli --config` loads, so /setup writes its answers
|
|
342
|
+
here too. Regenerating it wholesale (which happens after any model
|
|
343
|
+
re-pull) silently reverted those answers, making /setup's "applies on
|
|
344
|
+
every launch" promise false. Only the connection keys are ours to own.
|
|
345
|
+
"""
|
|
346
|
+
cfg = {
|
|
347
|
+
"backend": "openai",
|
|
348
|
+
"model": "qwen3-4b",
|
|
349
|
+
"temperature": 0.1,
|
|
350
|
+
"timeout_seconds": 300,
|
|
351
|
+
"max_output_tokens": 1024,
|
|
352
|
+
"autopilot_max_output_tokens": 4096,
|
|
353
|
+
"max_agent_steps": 15,
|
|
354
|
+
"tool_output_limit": 12000,
|
|
355
|
+
"use_streaming": True,
|
|
356
|
+
"openai_compatible": {
|
|
357
|
+
"base_url": f"http://127.0.0.1:{NPURUN_PORT}/v1",
|
|
358
|
+
"api_key": "local",
|
|
359
|
+
},
|
|
360
|
+
"_npurun_model": NPURUN_MODEL,
|
|
361
|
+
}
|
|
362
|
+
if NPURUN_CONFIG.exists():
|
|
363
|
+
try:
|
|
364
|
+
existing = json.loads(NPURUN_CONFIG.read_text(encoding="utf-8"))
|
|
365
|
+
if isinstance(existing, dict):
|
|
366
|
+
# User keys win over our defaults; our connection block is
|
|
367
|
+
# rewritten because the port/model may legitimately change.
|
|
368
|
+
connection = {"openai_compatible", "_npurun_model", "backend"}
|
|
369
|
+
for key, value in existing.items():
|
|
370
|
+
if key not in connection:
|
|
371
|
+
cfg[key] = value
|
|
372
|
+
except (json.JSONDecodeError, OSError):
|
|
373
|
+
pass # unreadable: fall back to a clean write
|
|
374
|
+
# Runtime-coupled prompt keys are ours when the Rewind runtime is on:
|
|
375
|
+
# prefix reuse needs a byte-stable system prompt, and the no-tools direct
|
|
376
|
+
# stage becomes a cost (every knowledge query diverges -> dialog rebuild).
|
|
377
|
+
if REWIND_ROOT is not None:
|
|
378
|
+
cfg["prompt_stable_prefix"] = True
|
|
379
|
+
cfg["prompt_split"] = False
|
|
380
|
+
NPURUN_CONFIG.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
381
|
+
ok(str(NPURUN_CONFIG))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _start_npurun_server() -> None:
|
|
385
|
+
cmd = [str(NPURUN_EXE), "serve", "--model", NPURUN_MODEL,
|
|
386
|
+
"--bind", f"127.0.0.1:{NPURUN_PORT}"]
|
|
387
|
+
log = open(str(NPURUN_LOG), "w", encoding="utf-8")
|
|
388
|
+
subprocess.Popen(cmd, stdout=log, stderr=log, env=_npurun_env(),
|
|
389
|
+
creationflags=0x00000008)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _hold_window() -> None:
|
|
393
|
+
"""In a classic console the window closes with the process, so a failure
|
|
394
|
+
message would vanish before it could be read. Windows Terminal keeps the
|
|
395
|
+
pane open on a non-zero exit; nothing to do there."""
|
|
396
|
+
if os.environ.get("WT_SESSION") or not sys.stdin.isatty():
|
|
397
|
+
return
|
|
398
|
+
try:
|
|
399
|
+
input(" Press Enter to close.")
|
|
400
|
+
except (EOFError, KeyboardInterrupt):
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _fail(*lines: str) -> int:
|
|
405
|
+
print()
|
|
406
|
+
for i, line in enumerate(lines):
|
|
407
|
+
err(line) if i == 0 else print(dim(f" {line}"))
|
|
408
|
+
_hold_window()
|
|
409
|
+
return 1
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def run_npurun_path() -> int:
|
|
413
|
+
"""npurun setup and launch. Returns the agent's exit code.
|
|
414
|
+
|
|
415
|
+
Quiet on the happy path: when the server is already up nothing is
|
|
416
|
+
printed and the REPL's banner is the first thing on screen. Progress
|
|
417
|
+
lines appear only for work that takes time (a model download, a server
|
|
418
|
+
start), and a failure ends here with the log path rather than a silent
|
|
419
|
+
fall-through to a tier that is not maintained.
|
|
420
|
+
"""
|
|
421
|
+
outdated = npurun_outdated()
|
|
422
|
+
if outdated:
|
|
423
|
+
warn(f"npurun {version_str(outdated)} is older than the required "
|
|
424
|
+
f"{version_str(REQUIRED_NPURUN)}. Run hexcli --update.")
|
|
425
|
+
|
|
426
|
+
if not _npurun_model_ok():
|
|
427
|
+
print(f" Downloading {NPURUN_MODEL}...", flush=True)
|
|
428
|
+
try:
|
|
429
|
+
_pull_npurun_model()
|
|
430
|
+
_write_npurun_config()
|
|
431
|
+
except Exception as exc:
|
|
432
|
+
return _fail(f"Model download failed: {exc}")
|
|
433
|
+
elif not NPURUN_CONFIG.exists():
|
|
434
|
+
_write_npurun_config()
|
|
435
|
+
|
|
436
|
+
if not _is_npurun_up():
|
|
437
|
+
try:
|
|
438
|
+
_start_npurun_server()
|
|
439
|
+
except Exception as exc:
|
|
440
|
+
return _fail(f"The model server did not start: {exc}")
|
|
441
|
+
if not _wait_npurun(timeout=60):
|
|
442
|
+
return _fail("The model server did not start within 60 s.", f"Log: {NPURUN_LOG}")
|
|
443
|
+
|
|
444
|
+
# The REPL gets the server's environment too: an in-session restart
|
|
445
|
+
# (/undo a dead server, "Restart the model server? [Y/n]") then brings
|
|
446
|
+
# up the same SDK and Rewind settings, not whatever the shell had.
|
|
447
|
+
return subprocess.run(
|
|
448
|
+
[sys.executable, "-m", "hexcli.agent", "--config", str(NPURUN_CONFIG), *sys.argv[1:]],
|
|
449
|
+
env=_npurun_env(),
|
|
450
|
+
).returncode
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# Flags the REPL answers on its own; no server needed, so `hex --version`
|
|
454
|
+
# must not start one (or write the runtime config) first.
|
|
455
|
+
_NO_SERVER_FLAGS = frozenset({"-h", "--help", "--version", "--doctor", "--update",
|
|
456
|
+
"--uninstall", "--print-config"})
|
|
457
|
+
|
|
458
|
+
def main() -> int:
|
|
459
|
+
if any(a in _NO_SERVER_FLAGS for a in sys.argv[1:]):
|
|
460
|
+
from . import agent
|
|
461
|
+
return agent.main()
|
|
462
|
+
try:
|
|
463
|
+
_dress_console_window()
|
|
464
|
+
except Exception:
|
|
465
|
+
pass # cosmetics only; never block launch over them
|
|
466
|
+
|
|
467
|
+
# The NPU path is the product (CLAUDE.md §3): a missing prerequisite is
|
|
468
|
+
# reported with the fix, never silently substituted.
|
|
469
|
+
try:
|
|
470
|
+
if not _npurun_ready():
|
|
471
|
+
return _fail("npurun or the QAIRT SDK was not found.", "Run hexcli --doctor for the fix.")
|
|
472
|
+
return run_npurun_path()
|
|
473
|
+
except KeyboardInterrupt:
|
|
474
|
+
print()
|
|
475
|
+
return 0
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
return _fail(f"Error: {exc}")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
if __name__ == "__main__":
|
|
481
|
+
raise SystemExit(main())
|