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/assets/hexcli.ico
ADDED
|
Binary file
|
hexcli/assets/hexcli.png
ADDED
|
Binary file
|
hexcli/cancel.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.cancel — Esc-to-cancel primitives, lifted out of agent.py.
|
|
3
|
+
|
|
4
|
+
UserCancelled, the msvcrt-polling CancelMonitor, and run_cancellable. The
|
|
5
|
+
eval runner replaces CancelMonitor and Spinner with no-ops so unattended
|
|
6
|
+
runs never poll the keyboard — code in THIS module resolves both names
|
|
7
|
+
module-locally, so the runner patches hexcli.cancel as well as hexcli.agent
|
|
8
|
+
(evals/runner.py, _SilencedUI). Spinner is re-bound here from ui for exactly
|
|
9
|
+
that patchability.
|
|
10
|
+
|
|
11
|
+
Split stage 3a (docs/V2X_ROADMAP.md, "The Split"). Bodies moved verbatim.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import msvcrt
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from hexcli import ui
|
|
20
|
+
|
|
21
|
+
Spinner = ui.Spinner
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UserCancelled(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clear_keyboard_buffer() -> None:
|
|
29
|
+
while msvcrt.kbhit():
|
|
30
|
+
msvcrt.getwch()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CancelMonitor:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self.cancelled = threading.Event()
|
|
36
|
+
self._stop = threading.Event()
|
|
37
|
+
self._thread = threading.Thread(target=self._watch, daemon=True)
|
|
38
|
+
|
|
39
|
+
def _watch(self) -> None:
|
|
40
|
+
while not self._stop.wait(0.05):
|
|
41
|
+
if msvcrt.kbhit():
|
|
42
|
+
if msvcrt.getwch() == "\x1b":
|
|
43
|
+
self.cancelled.set()
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> CancelMonitor:
|
|
46
|
+
clear_keyboard_buffer()
|
|
47
|
+
self._thread.start()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def __exit__(self, *_: object) -> None:
|
|
51
|
+
self._stop.set()
|
|
52
|
+
self._thread.join(timeout=1)
|
|
53
|
+
clear_keyboard_buffer()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_cancellable(label: str, work: Any) -> Any:
|
|
57
|
+
result: dict[str, Any] = {}
|
|
58
|
+
error: dict[str, BaseException] = {}
|
|
59
|
+
|
|
60
|
+
def worker() -> None:
|
|
61
|
+
try:
|
|
62
|
+
result["value"] = work()
|
|
63
|
+
except BaseException as exc: # noqa: BLE001
|
|
64
|
+
error["value"] = exc
|
|
65
|
+
|
|
66
|
+
thread = threading.Thread(target=worker, daemon=True)
|
|
67
|
+
with CancelMonitor() as monitor, Spinner(label):
|
|
68
|
+
thread.start()
|
|
69
|
+
while thread.is_alive():
|
|
70
|
+
if monitor.cancelled.is_set():
|
|
71
|
+
raise UserCancelled()
|
|
72
|
+
thread.join(0.05)
|
|
73
|
+
|
|
74
|
+
if "value" in error:
|
|
75
|
+
raise error["value"]
|
|
76
|
+
return result.get("value")
|
hexcli/chatlog.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.chatlog — full-detail transcript log, one JSONL file per session.
|
|
3
|
+
|
|
4
|
+
Telemetry (.shellai/logs) is a redacted summary: prompts cut at 500 chars,
|
|
5
|
+
edit contents replaced by their lengths. This log keeps everything, so a
|
|
6
|
+
session can be replayed and a failure understood after the fact:
|
|
7
|
+
|
|
8
|
+
session_start version, model, backend, server budget, npurun/QAIRT
|
|
9
|
+
versions, the config in force (secrets redacted)
|
|
10
|
+
command every slash command typed
|
|
11
|
+
turn_start the request as typed, history size, context gauge
|
|
12
|
+
system_prompt the system prompt text, once per distinct prompt (by hash)
|
|
13
|
+
request what the model was sent, per call: the messages added since
|
|
14
|
+
the previous call (the system prompt by reference)
|
|
15
|
+
reply the raw model reply, per call, with latency and retry index
|
|
16
|
+
tool each tool call: name, args, full output, latency, status
|
|
17
|
+
turn_end how the turn ended, the final message, duration
|
|
18
|
+
compaction history sizes before/after an auto-compact
|
|
19
|
+
error backend or loop failures the REPL caught
|
|
20
|
+
|
|
21
|
+
Local only, under ~/.shellai/chatlog/ (config chat_log_dir), off with
|
|
22
|
+
chat_log_enabled=false. One-way dependency like telemetry: hexcli.agent may
|
|
23
|
+
import this module, never the reverse. Every method swallows its own
|
|
24
|
+
exceptions — a logging failure must never reach the terminal or the loop.
|
|
25
|
+
Read it back with tools/chatlog_report.py.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import hashlib
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import platform
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import time
|
|
37
|
+
import uuid
|
|
38
|
+
from datetime import UTC, datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
_DEFAULT_DIR = Path.home() / ".shellai" / "chatlog"
|
|
43
|
+
_SECRET_MARKERS = ("key", "token", "secret", "password")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _now() -> str:
|
|
47
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _redact(obj: Any) -> Any:
|
|
51
|
+
if isinstance(obj, dict):
|
|
52
|
+
out = {}
|
|
53
|
+
for k, v in obj.items():
|
|
54
|
+
if any(m in str(k).lower() for m in _SECRET_MARKERS) and isinstance(v, str) and v:
|
|
55
|
+
out[k] = "<redacted>"
|
|
56
|
+
else:
|
|
57
|
+
out[k] = _redact(v)
|
|
58
|
+
return out
|
|
59
|
+
if isinstance(obj, list):
|
|
60
|
+
return [_redact(x) for x in obj]
|
|
61
|
+
return obj
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _hash(text: str) -> str:
|
|
65
|
+
return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()[:12]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _npurun_version() -> str:
|
|
69
|
+
exe = shutil.which("npurun") or str(Path.home() / ".cargo" / "bin" / "npurun.exe")
|
|
70
|
+
try:
|
|
71
|
+
out = subprocess.run([exe, "--version"], capture_output=True, text=True, timeout=5)
|
|
72
|
+
return (out.stdout or out.stderr).strip()
|
|
73
|
+
except Exception:
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _server_info(config: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
"""The budget and window the server advertises, when it is npurun."""
|
|
79
|
+
if config.get("backend") != "openai":
|
|
80
|
+
return {}
|
|
81
|
+
try:
|
|
82
|
+
from hexcli import http_client
|
|
83
|
+
base = str(config["openai_compatible"]["base_url"]).rstrip("/")
|
|
84
|
+
data = http_client.http_json_get(f"{base}/models", timeout_s=3)
|
|
85
|
+
first = (data.get("data") or [{}])[0]
|
|
86
|
+
return {k: first.get(k) for k in ("id", "context_size", "input_token_budget") if first.get(k) is not None}
|
|
87
|
+
except Exception:
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ChatLog:
|
|
92
|
+
"""Append-only JSONL writer for one process session."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, config: dict[str, Any], version: str = "", cwd: str | None = None,
|
|
95
|
+
kind: str = "repl") -> None:
|
|
96
|
+
self.enabled = bool(config.get("chat_log_enabled", True))
|
|
97
|
+
self.session_id = str(uuid.uuid4())
|
|
98
|
+
self.path: Path | None = None
|
|
99
|
+
self._system_prompts: set[str] = set()
|
|
100
|
+
self._turn_started: float = 0.0
|
|
101
|
+
if not self.enabled:
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
log_dir = Path(str(config.get("chat_log_dir") or "")).expanduser() if config.get("chat_log_dir") else _DEFAULT_DIR
|
|
105
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
stamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
|
107
|
+
self.path = log_dir / f"{stamp}_{self.session_id[:8]}.jsonl"
|
|
108
|
+
self.event(
|
|
109
|
+
"session_start",
|
|
110
|
+
mode=kind,
|
|
111
|
+
version=version,
|
|
112
|
+
model=str(config.get("model", "")),
|
|
113
|
+
backend=str(config.get("backend", "")),
|
|
114
|
+
server=_server_info(config),
|
|
115
|
+
npurun=_npurun_version(),
|
|
116
|
+
qairt=os.environ.get("QNN_SDK_ROOT", ""),
|
|
117
|
+
rewind_mode=os.environ.get("NPURUN_REWIND", ""),
|
|
118
|
+
cwd=cwd or str(Path.cwd()),
|
|
119
|
+
python=platform.python_version(),
|
|
120
|
+
os=f"{platform.system()} {platform.release()}",
|
|
121
|
+
config=_redact({k: v for k, v in config.items() if not str(k).startswith("_")}),
|
|
122
|
+
)
|
|
123
|
+
except Exception:
|
|
124
|
+
self.enabled = False
|
|
125
|
+
self.path = None
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------ core
|
|
128
|
+
def event(self, kind: str, **fields: Any) -> None:
|
|
129
|
+
if not self.enabled or self.path is None:
|
|
130
|
+
return
|
|
131
|
+
try:
|
|
132
|
+
record = {"ts": _now(), "session": self.session_id, "kind": kind, **fields}
|
|
133
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
134
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
135
|
+
except Exception:
|
|
136
|
+
self.enabled = False
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------- events
|
|
139
|
+
def command(self, text: str) -> None:
|
|
140
|
+
self.event("command", text=text)
|
|
141
|
+
|
|
142
|
+
def turn_start(self, index: int, query: str, history: list[dict[str, str]],
|
|
143
|
+
context_percent: int | None = None) -> TurnProbe:
|
|
144
|
+
self._turn_started = time.monotonic()
|
|
145
|
+
self.event(
|
|
146
|
+
"turn_start", turn=index, query=query,
|
|
147
|
+
history_messages=len(history),
|
|
148
|
+
history_chars=sum(len(m.get("content", "")) for m in history),
|
|
149
|
+
context_percent=context_percent,
|
|
150
|
+
)
|
|
151
|
+
return TurnProbe(self, index)
|
|
152
|
+
|
|
153
|
+
def turn_end(self, index: int, status: str, message: str = "", kind: str = "") -> None:
|
|
154
|
+
self.event(
|
|
155
|
+
"turn_end", turn=index, status=status, end_kind=kind, message=message,
|
|
156
|
+
duration_s=round(time.monotonic() - self._turn_started, 3) if self._turn_started else None,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def compaction(self, before: int, after: int, chars_before: int, chars_after: int) -> None:
|
|
160
|
+
self.event("compaction", messages_before=before, messages_after=after,
|
|
161
|
+
chars_before=chars_before, chars_after=chars_after)
|
|
162
|
+
|
|
163
|
+
def error(self, where: str, message: str) -> None:
|
|
164
|
+
self.event("error", where=where, message=message)
|
|
165
|
+
|
|
166
|
+
def _system_prompt(self, text: str) -> str:
|
|
167
|
+
h = _hash(text)
|
|
168
|
+
if h not in self._system_prompts:
|
|
169
|
+
self._system_prompts.add(h)
|
|
170
|
+
self.event("system_prompt", hash=h, chars=len(text), text=text)
|
|
171
|
+
return h
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TurnProbe:
|
|
175
|
+
"""AutopilotProbe-shaped observer for one turn (duck-typed: hexcli.agent
|
|
176
|
+
calls these through _probe(), which tolerates missing methods)."""
|
|
177
|
+
|
|
178
|
+
def __init__(self, log: ChatLog, turn: int) -> None:
|
|
179
|
+
self.log = log
|
|
180
|
+
self.turn = turn
|
|
181
|
+
self._seen = 0
|
|
182
|
+
self._system_hash = ""
|
|
183
|
+
|
|
184
|
+
def _encode(self, messages: list[dict[str, str]]) -> list[dict[str, Any]]:
|
|
185
|
+
out: list[dict[str, Any]] = []
|
|
186
|
+
for m in messages:
|
|
187
|
+
if m.get("role") == "system":
|
|
188
|
+
out.append({"role": "system", "ref": self.log._system_prompt(m.get("content", ""))})
|
|
189
|
+
else:
|
|
190
|
+
out.append({"role": m.get("role", ""), "content": m.get("content", "")})
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
def on_start(self, system_prompt: str, messages: list[dict[str, str]]) -> None:
|
|
194
|
+
self._system_hash = self.log._system_prompt(system_prompt)
|
|
195
|
+
self._seen = 0
|
|
196
|
+
|
|
197
|
+
def on_request(self, step: int, attempt: int, messages: list[dict[str, str]]) -> None:
|
|
198
|
+
new = messages[self._seen:]
|
|
199
|
+
self.log.event(
|
|
200
|
+
"request", turn=self.turn, step=step, attempt=attempt,
|
|
201
|
+
total_messages=len(messages),
|
|
202
|
+
total_chars=sum(len(m.get("content", "")) for m in messages),
|
|
203
|
+
new_messages=self._encode(new),
|
|
204
|
+
)
|
|
205
|
+
self._seen = len(messages)
|
|
206
|
+
|
|
207
|
+
def on_llm(self, step: int, attempt: int, raw: str, latency_s: float) -> None:
|
|
208
|
+
self.log.event("reply", turn=self.turn, step=step, attempt=attempt,
|
|
209
|
+
latency_s=round(latency_s, 3), empty=not raw.strip(), raw=raw)
|
|
210
|
+
|
|
211
|
+
def on_tool(self, step: int, tool: str, args: dict[str, Any], output: str,
|
|
212
|
+
latency_s: float, status: str) -> None:
|
|
213
|
+
self.log.event("tool", turn=self.turn, step=step, tool=tool, args=args,
|
|
214
|
+
status=status, latency_s=round(latency_s, 3),
|
|
215
|
+
output_chars=len(output), output=output)
|
|
216
|
+
|
|
217
|
+
def on_end(self, kind: str, message: str) -> None:
|
|
218
|
+
self.log.event("turn_result", turn=self.turn, end_kind=kind, message=message)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def latest_log_path(config: dict[str, Any] | None = None) -> Path | None:
|
|
222
|
+
"""The most recent log file, for /stats and the report tool."""
|
|
223
|
+
log_dir = Path(str((config or {}).get("chat_log_dir") or "")).expanduser() if (config or {}).get("chat_log_dir") else _DEFAULT_DIR
|
|
224
|
+
try:
|
|
225
|
+
files = sorted(log_dir.glob("*.jsonl"))
|
|
226
|
+
return files[-1] if files else None
|
|
227
|
+
except Exception:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
if __name__ == "__main__": # pragma: no cover
|
|
232
|
+
print(latest_log_path() or "no chat log yet", file=sys.stderr)
|
hexcli/commands.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""hexcli/commands.py — user-authored custom slash commands.
|
|
2
|
+
|
|
3
|
+
A custom command is a markdown file whose stem is the command name:
|
|
4
|
+
|
|
5
|
+
~/.shellai/commands/review.md → /review (global)
|
|
6
|
+
<cwd>/.shellai/commands/review.md → /review (project; wins collisions)
|
|
7
|
+
|
|
8
|
+
The file body is a prompt template that runs as a normal agent turn.
|
|
9
|
+
``$ARGUMENTS`` inside the template is replaced with whatever the user typed
|
|
10
|
+
after the command; a template with no placeholder gets the arguments
|
|
11
|
+
appended instead, so both styles just work.
|
|
12
|
+
|
|
13
|
+
Built-in commands always win: run_repl only consults this module after its
|
|
14
|
+
own dispatch has not matched, so a custom ``/help`` can exist but can never
|
|
15
|
+
shadow the real one. Templates are the user's own local files — the same
|
|
16
|
+
trust level as their config — so their content is not sanitised.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Command names stay boring on purpose: they share a namespace with
|
|
24
|
+
# built-ins and Tab completion, and "my command.md" or "café.md" would
|
|
25
|
+
# produce names the parser splits apart.
|
|
26
|
+
_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
|
27
|
+
|
|
28
|
+
ARGUMENTS_PLACEHOLDER = "$ARGUMENTS"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _command_dirs(home: Path | None = None, cwd: Path | None = None) -> list[Path]:
|
|
32
|
+
"""Global dir first, project dir last — later entries win collisions."""
|
|
33
|
+
home = home or Path.home()
|
|
34
|
+
cwd = cwd or Path.cwd()
|
|
35
|
+
dirs = [home / ".shellai" / "commands"]
|
|
36
|
+
project = cwd / ".shellai" / "commands"
|
|
37
|
+
if project.resolve() != dirs[0].resolve():
|
|
38
|
+
dirs.append(project)
|
|
39
|
+
return dirs
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def discover(home: Path | None = None, cwd: Path | None = None) -> dict[str, Path]:
|
|
43
|
+
"""Map of ``/name`` → template path for every valid command file."""
|
|
44
|
+
found: dict[str, Path] = {}
|
|
45
|
+
for directory in _command_dirs(home, cwd):
|
|
46
|
+
if not directory.is_dir():
|
|
47
|
+
continue
|
|
48
|
+
for path in sorted(directory.glob("*.md")):
|
|
49
|
+
name = path.stem.lower()
|
|
50
|
+
if _VALID_NAME.match(name):
|
|
51
|
+
found["/" + name] = path
|
|
52
|
+
return found
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load(command_word: str, home: Path | None = None, cwd: Path | None = None) -> str | None:
|
|
56
|
+
"""Template text for ``/name``, or None if no such custom command."""
|
|
57
|
+
path = discover(home, cwd).get(command_word.lower())
|
|
58
|
+
if path is None:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
62
|
+
except OSError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def expand(template: str, args: str) -> str:
|
|
67
|
+
"""Substitute the user's arguments into the template."""
|
|
68
|
+
template = template.strip()
|
|
69
|
+
if ARGUMENTS_PLACEHOLDER in template:
|
|
70
|
+
return template.replace(ARGUMENTS_PLACEHOLDER, args)
|
|
71
|
+
if args:
|
|
72
|
+
return f"{template}\n\n{args}"
|
|
73
|
+
return template
|