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/network.py ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.network — Online detection and URL fetching for the fetch_url tool."""
3
+ from __future__ import annotations
4
+
5
+ import html.parser
6
+ import ipaddress
7
+ import re
8
+ import socket
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Online probe — cached 30 s
16
+ # ---------------------------------------------------------------------------
17
+
18
+ _online_result: bool | None = None
19
+ _online_ts: float = 0.0
20
+ _ONLINE_TTL = 30.0
21
+ _PROBE_HOST = "8.8.8.8"
22
+ _PROBE_PORT = 53
23
+ _PROBE_TIMEOUT = 0.2 # 200 ms
24
+
25
+
26
+ def is_online() -> bool:
27
+ """Return True if the internet appears reachable. Result is cached for 30 s."""
28
+ global _online_result, _online_ts
29
+ now = time.monotonic()
30
+ if _online_result is not None and now - _online_ts < _ONLINE_TTL:
31
+ return _online_result
32
+ try:
33
+ sock = socket.create_connection((_PROBE_HOST, _PROBE_PORT), timeout=_PROBE_TIMEOUT)
34
+ sock.close()
35
+ result = True
36
+ except OSError:
37
+ result = False
38
+ _online_result = result
39
+ _online_ts = now
40
+ return result
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # URL security check
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _is_blocked_url(url: str) -> str | None:
48
+ """Return an error string if the URL should be blocked, None if safe to fetch."""
49
+ try:
50
+ parsed = urllib.parse.urlsplit(url)
51
+ except Exception:
52
+ return "fetch_url: invalid URL."
53
+
54
+ scheme = (parsed.scheme or "").lower()
55
+ if scheme not in ("http", "https"):
56
+ return f"fetch_url: only http/https URLs are allowed (got scheme {scheme!r})."
57
+
58
+ host = (parsed.hostname or "").lower()
59
+ if not host:
60
+ return "fetch_url: URL has no host."
61
+ if host == "localhost":
62
+ return "fetch_url: private/local addresses are blocked."
63
+
64
+ try:
65
+ addr = ipaddress.ip_address(host)
66
+ if addr.is_private or addr.is_loopback or addr.is_link_local:
67
+ return f"fetch_url: private address {host!r} is blocked."
68
+ except ValueError:
69
+ pass # hostname — let DNS resolve it; private hostnames can't be easily checked here
70
+
71
+ return None
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # HTML stripping
76
+ # ---------------------------------------------------------------------------
77
+
78
+ class _StripHTML(html.parser.HTMLParser):
79
+ _SKIP = frozenset({"script", "style", "nav", "header", "footer", "aside"})
80
+ _BLOCK = frozenset({"p", "br", "div", "li", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "td", "section", "article"})
81
+
82
+ def __init__(self) -> None:
83
+ super().__init__(convert_charrefs=True)
84
+ self._parts: list[str] = []
85
+ self._skip_depth = 0
86
+
87
+ def handle_starttag(self, tag: str, attrs: object) -> None:
88
+ if tag in self._SKIP:
89
+ self._skip_depth += 1
90
+
91
+ def handle_endtag(self, tag: str) -> None:
92
+ if tag in self._SKIP:
93
+ self._skip_depth = max(0, self._skip_depth - 1)
94
+ elif tag in self._BLOCK and not self._skip_depth:
95
+ self._parts.append("\n")
96
+
97
+ def handle_data(self, data: str) -> None:
98
+ if not self._skip_depth:
99
+ self._parts.append(data)
100
+
101
+ def get_text(self) -> str:
102
+ text = "".join(self._parts)
103
+ text = re.sub(r"\n{3,}", "\n\n", text)
104
+ text = re.sub(r"[ \t]+", " ", text)
105
+ return text.strip()
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # fetch_url
110
+ # ---------------------------------------------------------------------------
111
+
112
+ def fetch_url(url: str, max_chars: int = 2500) -> str:
113
+ """Fetch a URL and return plain text. Blocks private IPs and non-HTTP/HTTPS schemes."""
114
+ block = _is_blocked_url(url)
115
+ if block:
116
+ return block
117
+
118
+ if not is_online():
119
+ return "fetch_url: no network connection available."
120
+
121
+ try:
122
+ req = urllib.request.Request(
123
+ url,
124
+ headers={"User-Agent": "hexcli/1.7 (local agent; +https://github.com/NathanL15/Hex-CLI)"},
125
+ )
126
+ with urllib.request.urlopen(req, timeout=10) as resp:
127
+ content_type = resp.headers.get("Content-Type", "")
128
+ raw = resp.read(max_chars * 8)
129
+ except urllib.error.HTTPError as exc:
130
+ return f"fetch_url: HTTP {exc.code} {exc.reason} — {url}"
131
+ except urllib.error.URLError as exc:
132
+ return f"fetch_url: could not reach {url!r}: {exc.reason}"
133
+ except Exception as exc:
134
+ return f"fetch_url: error — {exc}"
135
+
136
+ try:
137
+ text_raw = raw.decode("utf-8", errors="replace")
138
+ except Exception:
139
+ text_raw = raw.decode("latin-1", errors="replace")
140
+
141
+ if "html" in content_type.lower() or text_raw.strip().lower().startswith("<!"):
142
+ parser = _StripHTML()
143
+ try:
144
+ parser.feed(text_raw)
145
+ text = parser.get_text()
146
+ except Exception:
147
+ text = text_raw
148
+ else:
149
+ text = text_raw
150
+
151
+ if len(text) > max_chars:
152
+ text = text[:max_chars] + f"\n...[truncated to {max_chars} chars]"
153
+
154
+ return text or f"(empty response from {url})"
hexcli/parsing.py ADDED
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.parsing — the wire protocol's text side, lifted out of agent.py.
3
+
4
+ Everything here is pure text/JSON interpretation with no agent state: trim
5
+ helpers, the query normalizers, <think>-stripping, and the JSON-action parser
6
+ that turns a model response into a dispatchable action. TOOL_NAMES lives here
7
+ because it is the action vocabulary this parser interprets; agent.py re-binds
8
+ it (and everything else) by name, so `sa.parse_agent_action` and friends keep
9
+ working for every existing caller and eval.
10
+
11
+ Split stage 1 (docs/V2X_ROADMAP.md, "The Split"). Function bodies are moved
12
+ verbatim — behavior changes do not belong in split commits.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ import shutil
19
+ from typing import Any
20
+
21
+ # ruff is an optional hard dependency: if present, lint_code is registered as a
22
+ # live tool and injected into the system prompt. If absent, the tool simply does
23
+ # not appear — no fallback needed since verify_syntax covers the critical path.
24
+ _RUFF: str | None = shutil.which("ruff")
25
+
26
+ TOOL_NAMES = frozenset({
27
+ "run_command", "read_file", "edit_file", "write_file",
28
+ "append_file", "list_directory", "search_files", "find_files",
29
+ "verify_syntax", "search_memory", "run_code",
30
+ "fetch_url", "batch", "delegate",
31
+ *(["lint_code"] if _RUFF else []),
32
+ })
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Text utilities
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def trim_text(text: str, limit: int) -> str:
40
+ """Head-only truncation. Use trim_tool_output for command/tool results."""
41
+ if len(text) <= limit:
42
+ return text
43
+ return text[:limit] + f"\n...[truncated to {limit} chars]"
44
+
45
+
46
+ def trim_tool_output(text: str, limit: int) -> str:
47
+ """Head+tail truncation for tool results.
48
+
49
+ Command output carries its verdict at the END — exit summaries, stack
50
+ traces, assertion messages. v1.7's head-only trim hid exactly the part
51
+ the model needed to recover from a failure, so keep both ends.
52
+ """
53
+ if len(text) <= limit:
54
+ return text
55
+ head = int(limit * 0.6)
56
+ tail = max(0, limit - head)
57
+ omitted = len(text) - head - tail
58
+ return f"{text[:head]}\n...[{omitted} chars omitted]...\n{text[-tail:]}" if tail else trim_text(text, limit)
59
+
60
+
61
+ def normalize_text(value: str) -> str:
62
+ return re.sub(r"\s+", " ", value.strip().lower())
63
+
64
+
65
+ def is_help_request(query: str) -> bool:
66
+ normalized = re.sub(r"[?!.,]+", "", normalize_text(query))
67
+ return normalized in {"help", "/help", "what can you do", "what is this", "how do i use this"}
68
+
69
+
70
+ def is_small_talk(query: str) -> bool:
71
+ normalized = re.sub(r"[?!.,]+", "", normalize_text(query))
72
+ return normalized in {"hi", "hello", "hey", "whats up", "what is up", "yo"}
73
+
74
+
75
+ def local_meta_response(query: str, config: dict[str, Any]) -> str | None:
76
+ normalized = re.sub(r"[?!.,]+", "", normalize_text(query))
77
+ model = str(config.get("model", "unknown")).strip() or "unknown"
78
+ backend = str(config.get("backend", "ollama")).strip()
79
+ label = f"{model} via {'Ollama' if backend == 'ollama' else 'local OpenAI-compatible endpoint'}"
80
+ if any(p in normalized for p in ("what model are you", "which model are you", "what llm")):
81
+ return f"Using {label}."
82
+ if normalized in {"who are you", "what are you"}:
83
+ return f"Local coding and system agent powered by {label}."
84
+ return None
85
+
86
+
87
+ def strip_thinking(text: str) -> str:
88
+ """Remove <think>...</think> blocks emitted by reasoning models like deepseek-r1."""
89
+ return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Parsing
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def parse_json_object(raw_text: str) -> dict[str, Any] | None:
97
+ text = strip_thinking(raw_text).strip()
98
+ if not text:
99
+ return None
100
+ # Direct parse
101
+ try:
102
+ parsed = json.loads(text)
103
+ if isinstance(parsed, dict):
104
+ return parsed
105
+ except json.JSONDecodeError:
106
+ pass
107
+ # Strip markdown fences
108
+ stripped = re.sub(r"^```[a-zA-Z]*\s*|```\s*$", "", text, flags=re.MULTILINE).strip()
109
+ try:
110
+ parsed = json.loads(stripped)
111
+ if isinstance(parsed, dict):
112
+ return parsed
113
+ except json.JSONDecodeError:
114
+ pass
115
+ # Extract the FIRST complete {...} object by brace balancing.
116
+ #
117
+ # This used to be a greedy re.search(r"\{.*\}"), which spans from the first
118
+ # brace to the LAST one. When the model emits several actions in a row —
119
+ # {"action":"edit_file",…},{"action":"verify_syntax",…},{"action":"run_code",…}
120
+ # — that match is not valid JSON, so the whole response was discarded, the
121
+ # identical retry was issued up to 3×, and the turn ended with no tool call
122
+ # at all. Measured 2026-07-30: this is what actually killed uc1-t4/t5/t6
123
+ # (0/3 each), NOT a context-length cliff. Batching is a natural response to
124
+ # rules that prescribe an edit→verify→run sequence, so take the first
125
+ # action and let the loop drive the rest.
126
+ for candidate in _iter_json_objects(text):
127
+ try:
128
+ parsed = json.loads(candidate)
129
+ if isinstance(parsed, dict):
130
+ return parsed
131
+ except json.JSONDecodeError:
132
+ continue
133
+ return None
134
+
135
+
136
+ def _iter_json_objects(text: str):
137
+ """Yield complete brace-balanced {...} substrings, in order.
138
+
139
+ String-literal aware, so braces inside JSON strings never affect depth.
140
+ """
141
+ depth = 0
142
+ start = -1
143
+ in_string = False
144
+ escaped = False
145
+ for i, ch in enumerate(text):
146
+ if in_string:
147
+ if escaped:
148
+ escaped = False
149
+ elif ch == "\\":
150
+ escaped = True
151
+ elif ch == '"':
152
+ in_string = False
153
+ continue
154
+ if ch == '"':
155
+ in_string = True
156
+ elif ch == "{":
157
+ if depth == 0:
158
+ start = i
159
+ depth += 1
160
+ elif ch == "}":
161
+ if depth > 0:
162
+ depth -= 1
163
+ if depth == 0 and start >= 0:
164
+ yield text[start:i + 1]
165
+ start = -1
166
+
167
+
168
+ def parse_agent_action(raw_text: str) -> dict[str, Any]:
169
+ parsed = parse_json_object(raw_text)
170
+ if isinstance(parsed, dict):
171
+ action = str(parsed.get("action", "")).strip().lower()
172
+ args = parsed.get("args")
173
+ args = args if isinstance(args, dict) else {}
174
+
175
+ if action in TOOL_NAMES:
176
+ return {"action": "tool", "tool": action, "args": args}
177
+ if action == "tool":
178
+ tool = str(parsed.get("tool", "")).strip()
179
+ return {"action": "tool", "tool": tool, "args": args}
180
+ if action == "finish":
181
+ return {"action": "finish", "message": str(parsed.get("message") or "").strip()}
182
+
183
+ tool = str(parsed.get("tool", "")).strip()
184
+ if tool in TOOL_NAMES:
185
+ return {"action": "tool", "tool": tool, "args": args}
186
+
187
+ message = str(parsed.get("message") or "").strip()
188
+ if message:
189
+ # Valid JSON, but the action name (if any) matched nothing. Tag it
190
+ # so the loop can distinguish "model typo'd a tool name" (worth a
191
+ # retry naming the bad action) from an intended finish.
192
+ return {"action": "finish", "message": message,
193
+ "fallback": "unknown-action", "bad_action": action}
194
+
195
+ # No parseable JSON anywhere: the message is just the raw prose. This is
196
+ # the deliberate direct-answer path for knowledge questions — but the loop
197
+ # retries it when the text shows signs of an ATTEMPTED action (see
198
+ # _looks_like_botched_action), because "prose instead of action" was the
199
+ # uc1-t5/t6 failure mode.
200
+ return {"action": "finish", "message": strip_thinking(raw_text).strip(),
201
+ "fallback": "prose"}
202
+
203
+
204
+ def _looks_like_botched_action(raw_text: str) -> bool:
205
+ """Does an unparseable response look like it TRIED to be an action?
206
+
207
+ Braces or code fences mean attempted JSON; a tool name means attempted
208
+ tool use. Pure prose with none of those is accepted as an implicit finish
209
+ — that path is load-bearing for direct answers, so this must stay
210
+ conservative about flagging it.
211
+ """
212
+ text = strip_thinking(raw_text)
213
+ if "{" in text or "```" in text:
214
+ return True
215
+ return any(name in text for name in TOOL_NAMES)
hexcli/paths.py ADDED
@@ -0,0 +1,127 @@
1
+ """hexcli.paths — where the app keeps its things.
2
+
3
+ Two homes:
4
+
5
+ * the package (``hexcli/``), read-only once installed: code and the icon;
6
+ * the data directory, ``~/.shellai``: the user config, the runtime config
7
+ the launcher writes, the embedding model for memory, the server log,
8
+ the session history, chat logs, memory stores, input history.
9
+
10
+ A git checkout is also recognised (``pyproject.toml`` next to the
11
+ package). It never wins over the data directory, but files that older
12
+ versions kept in the checkout — ``shellai.json``, ``history.json``,
13
+ ``onnx/``, a downloaded ``npurun-arm64.exe`` — are still found there, so
14
+ a developer's checkout keeps working and a first run migrates the
15
+ history file rather than losing it.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import shutil
21
+ from pathlib import Path
22
+
23
+ PACKAGE_DIR = Path(__file__).resolve().parent
24
+ CHECKOUT_DIR: Path | None = (
25
+ PACKAGE_DIR.parent if (PACKAGE_DIR.parent / "pyproject.toml").exists() else None
26
+ )
27
+ DATA_DIR_NAME = ".shellai"
28
+
29
+ EMBEDDING_MODEL_FILE = "model_qint8_arm64.onnx"
30
+ EMBEDDING_TOKENIZER_FILE = "tokenizer.json"
31
+ EMBEDDING_BASE_URL = "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/resolve/main"
32
+ NPURUN_ASSET = "npurun-arm64.exe"
33
+
34
+
35
+ def data_dir(create: bool = True) -> Path:
36
+ """``~/.shellai`` (``HEXCLI_HOME`` overrides it, for tests and sandboxes)."""
37
+ override = os.environ.get("HEXCLI_HOME")
38
+ d = Path(override).expanduser() if override else Path.home() / DATA_DIR_NAME
39
+ if create:
40
+ try:
41
+ d.mkdir(parents=True, exist_ok=True)
42
+ except OSError:
43
+ pass
44
+ return d
45
+
46
+
47
+ def _first_existing(*candidates: Path | None) -> Path | None:
48
+ for c in candidates:
49
+ if c is not None and c.exists():
50
+ return c
51
+ return None
52
+
53
+
54
+ def _migrated(name: str) -> Path:
55
+ """``~/.shellai/<name>``, copied once from a checkout's ``<name>`` when
56
+ only the checkout has it (older versions kept these files there)."""
57
+ home = data_dir() / name
58
+ if not home.exists() and CHECKOUT_DIR is not None:
59
+ old = CHECKOUT_DIR / name
60
+ if old.exists():
61
+ try:
62
+ shutil.copy2(old, home)
63
+ except OSError:
64
+ return old
65
+ return home
66
+
67
+
68
+ def user_config_path() -> Path:
69
+ """``~/.shellai/shellai.json`` (migrated once from a checkout's copy)."""
70
+ return _migrated("shellai.json")
71
+
72
+
73
+ def runtime_config_path() -> Path:
74
+ """The config the launcher writes for the npurun server (backend wiring)."""
75
+ return _migrated("shellai_npurun.json")
76
+
77
+
78
+ def history_path() -> Path:
79
+ """Session history. Migrated once from a checkout's ``history.json``."""
80
+ return _migrated("history.json")
81
+
82
+
83
+ def npurun_log_path() -> Path:
84
+ return data_dir() / "npurun_server.log"
85
+
86
+
87
+ def embedding_dir(for_download: bool = False) -> Path:
88
+ """Where the MiniLM files live: ``~/.shellai/onnx``, or a checkout's
89
+ ``onnx/`` when only that has them. ``for_download`` asks for the place
90
+ to put them."""
91
+ home = data_dir() / "onnx"
92
+ if for_download:
93
+ return home
94
+ checkout = CHECKOUT_DIR / "onnx" if CHECKOUT_DIR else None
95
+ for d in (home, checkout):
96
+ if d is not None and (d / EMBEDDING_MODEL_FILE).exists():
97
+ return d
98
+ return home
99
+
100
+
101
+ def embedding_model_path() -> Path:
102
+ return embedding_dir() / EMBEDDING_MODEL_FILE
103
+
104
+
105
+ def embedding_tokenizer_path() -> Path:
106
+ return embedding_dir() / EMBEDDING_TOKENIZER_FILE
107
+
108
+
109
+ def npurun_bin_dir() -> Path:
110
+ """Where a downloaded npurun binary goes (``~/.shellai/bin``)."""
111
+ return data_dir() / "bin"
112
+
113
+
114
+ def npurun_download_candidates() -> list[Path]:
115
+ """Downloaded binaries, newest home first, then a checkout's copy."""
116
+ out = [npurun_bin_dir() / NPURUN_ASSET]
117
+ if CHECKOUT_DIR is not None:
118
+ out.append(CHECKOUT_DIR / NPURUN_ASSET)
119
+ return out
120
+
121
+
122
+ def icon_path() -> Path:
123
+ return PACKAGE_DIR / "assets" / "hexcli.ico"
124
+
125
+
126
+ def icon_png_path() -> Path:
127
+ return PACKAGE_DIR / "assets" / "hexcli.png"