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/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"
|