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.
Binary file
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