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/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())