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/stream_render.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.stream_render — incremental rendering of a streaming agent response.
|
|
3
|
+
|
|
4
|
+
At ~15 tok/s a 300-token answer takes 20+ seconds. v1.7 streamed nothing to
|
|
5
|
+
the user: it showed a token counter and printed the finished text at the end,
|
|
6
|
+
so every turn looked like a hang (review finding W6). But the agent protocol
|
|
7
|
+
emits JSON actions, and dumping raw JSON at the user is worse than a spinner.
|
|
8
|
+
|
|
9
|
+
This module resolves that: it consumes streamed deltas and decides what a
|
|
10
|
+
human should see, without waiting for the response to finish.
|
|
11
|
+
|
|
12
|
+
* `{"action":"finish","message":"..."}` → the MESSAGE TEXT streams live,
|
|
13
|
+
JSON escapes decoded, quotes/braces never shown.
|
|
14
|
+
* `{"action":"read_file",...}` → announce the tool as soon as the action
|
|
15
|
+
name is complete ("→ read_file"), then stay quiet.
|
|
16
|
+
* Plain prose (no JSON) → stream as-is.
|
|
17
|
+
|
|
18
|
+
Pure state machine: no I/O, no globals. The caller supplies an emit callback,
|
|
19
|
+
which makes it fully testable and lets the REPL, evals, and future UIs share
|
|
20
|
+
one implementation.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
_ESCAPES = {'"': '"', "\\": "\\", "n": "\n", "t": "\t", "r": "\r", "/": "/", "b": "\b", "f": "\f"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StreamRenderer:
|
|
30
|
+
"""Feed it deltas; it emits only what a human should read.
|
|
31
|
+
|
|
32
|
+
States:
|
|
33
|
+
probing — undecided: JSON action or prose?
|
|
34
|
+
prose — not JSON; everything passes through
|
|
35
|
+
scanning — JSON: watching for "action" / "message" values
|
|
36
|
+
message — inside the message string; decoded chars stream out
|
|
37
|
+
done — message closed; ignore the rest (trailing JSON)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, emit: Callable[[str], None],
|
|
41
|
+
on_tool: Callable[[str], None] | None = None) -> None:
|
|
42
|
+
self._emit = emit
|
|
43
|
+
self._on_tool = on_tool
|
|
44
|
+
self._state = "probing"
|
|
45
|
+
self._buf = "" # raw text seen so far (probing/scanning)
|
|
46
|
+
self._pending_esc = False
|
|
47
|
+
self._unicode: str | None = None
|
|
48
|
+
self._announced = False
|
|
49
|
+
self.tool_announced: str | None = None
|
|
50
|
+
self.text_emitted = ""
|
|
51
|
+
|
|
52
|
+
def _announce(self, name: str) -> None:
|
|
53
|
+
self.tool_announced = name
|
|
54
|
+
self._announced = True
|
|
55
|
+
if self._on_tool:
|
|
56
|
+
self._on_tool(name)
|
|
57
|
+
|
|
58
|
+
# -- public API ---------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def feed(self, delta: str) -> None:
|
|
61
|
+
for ch in delta:
|
|
62
|
+
self._feed_char(ch)
|
|
63
|
+
|
|
64
|
+
def finish(self) -> None:
|
|
65
|
+
"""Flush anything still buffered (short prose that never resolved)."""
|
|
66
|
+
if self._state == "probing" and self._buf.strip():
|
|
67
|
+
self._emit_text(self._buf)
|
|
68
|
+
self._state = "prose"
|
|
69
|
+
self._buf = ""
|
|
70
|
+
|
|
71
|
+
# -- internals ----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _emit_text(self, text: str) -> None:
|
|
74
|
+
if text:
|
|
75
|
+
self.text_emitted += text
|
|
76
|
+
self._emit(text)
|
|
77
|
+
|
|
78
|
+
def _feed_char(self, ch: str) -> None:
|
|
79
|
+
st = self._state
|
|
80
|
+
if st == "prose":
|
|
81
|
+
self._emit_text(ch)
|
|
82
|
+
return
|
|
83
|
+
if st == "done":
|
|
84
|
+
return
|
|
85
|
+
if st == "v2_tag":
|
|
86
|
+
self._feed_v2_char(ch)
|
|
87
|
+
return
|
|
88
|
+
if st == "message":
|
|
89
|
+
self._feed_message_char(ch)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# probing / scanning
|
|
93
|
+
self._buf += ch
|
|
94
|
+
if st == "probing":
|
|
95
|
+
stripped = self._buf.lstrip()
|
|
96
|
+
if not stripped:
|
|
97
|
+
return
|
|
98
|
+
# A leading '{' means JSON. So does a ``` fence whose body has
|
|
99
|
+
# started with '{' — models routinely wrap the action in a fence.
|
|
100
|
+
if stripped[0] == "{":
|
|
101
|
+
self._state = "scanning"
|
|
102
|
+
elif stripped.startswith("`"):
|
|
103
|
+
after_fence = stripped.lstrip("`")
|
|
104
|
+
# Skip an optional language tag, then the newline.
|
|
105
|
+
nl = after_fence.find("\n")
|
|
106
|
+
if nl != -1:
|
|
107
|
+
body = after_fence[nl + 1:].lstrip()
|
|
108
|
+
if body.startswith("{"):
|
|
109
|
+
self._state = "scanning"
|
|
110
|
+
self._buf = body
|
|
111
|
+
elif body:
|
|
112
|
+
self._state = "prose"
|
|
113
|
+
self._emit_text(self._buf)
|
|
114
|
+
self._buf = ""
|
|
115
|
+
return # still ambiguous until the fence line completes
|
|
116
|
+
elif stripped[0] == "<":
|
|
117
|
+
# Protocol v2 emits <action>{...}</action> / <write path=…>.
|
|
118
|
+
# Without this branch the probe classified it as prose and
|
|
119
|
+
# streamed the raw block to the user — the exact thing this
|
|
120
|
+
# renderer exists to prevent.
|
|
121
|
+
self._state = "v2_tag"
|
|
122
|
+
return
|
|
123
|
+
else:
|
|
124
|
+
# Definitely prose: release everything buffered so far.
|
|
125
|
+
self._state = "prose"
|
|
126
|
+
self._emit_text(self._buf)
|
|
127
|
+
self._buf = ""
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if self._state == "scanning":
|
|
131
|
+
self._scan()
|
|
132
|
+
|
|
133
|
+
def _scan(self) -> None:
|
|
134
|
+
"""Look for a completed "action" value or the start of "message"."""
|
|
135
|
+
if not self._announced:
|
|
136
|
+
act = _completed_string_value(self._buf, "action")
|
|
137
|
+
if act == "tool":
|
|
138
|
+
# v1's nested form {"action":"tool","tool":"read_file"} — the
|
|
139
|
+
# real name arrives in a second field; wait for it rather than
|
|
140
|
+
# announcing the placeholder (and re-announcing every char).
|
|
141
|
+
name = _completed_string_value(self._buf, "tool")
|
|
142
|
+
if name:
|
|
143
|
+
self._announce(name)
|
|
144
|
+
elif act:
|
|
145
|
+
self.tool_announced = act
|
|
146
|
+
self._announced = True
|
|
147
|
+
if act != "finish" and self._on_tool:
|
|
148
|
+
self._on_tool(act)
|
|
149
|
+
|
|
150
|
+
marker = _message_value_start(self._buf)
|
|
151
|
+
if marker is not None:
|
|
152
|
+
rest = self._buf[marker:]
|
|
153
|
+
self._state = "message"
|
|
154
|
+
self._buf = ""
|
|
155
|
+
for ch in rest:
|
|
156
|
+
self._feed_message_char(ch)
|
|
157
|
+
|
|
158
|
+
def _feed_v2_char(self, ch: str) -> None:
|
|
159
|
+
"""Protocol-v2 action block: announce the tool, show nothing else."""
|
|
160
|
+
self._buf += ch
|
|
161
|
+
if self._announced:
|
|
162
|
+
return
|
|
163
|
+
buf = self._buf
|
|
164
|
+
# <write path="x"> / <edit path="x"> — the tag itself is the tool.
|
|
165
|
+
for tag in ("write", "edit"):
|
|
166
|
+
if buf.lower().startswith(f"<{tag}") and ">" in buf:
|
|
167
|
+
self._announce(tag)
|
|
168
|
+
return
|
|
169
|
+
# <action>{"name":"shell",...}</action>
|
|
170
|
+
if buf.startswith("<action") and '"name"' in buf:
|
|
171
|
+
name = _completed_string_value(buf, "name")
|
|
172
|
+
if name:
|
|
173
|
+
self._announce(name)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _feed_message_char(self, ch: str) -> None:
|
|
177
|
+
if self._unicode is not None:
|
|
178
|
+
self._unicode += ch
|
|
179
|
+
if len(self._unicode) == 4:
|
|
180
|
+
try:
|
|
181
|
+
self._emit_text(chr(int(self._unicode, 16)))
|
|
182
|
+
except ValueError:
|
|
183
|
+
pass
|
|
184
|
+
self._unicode = None
|
|
185
|
+
return
|
|
186
|
+
if self._pending_esc:
|
|
187
|
+
self._pending_esc = False
|
|
188
|
+
if ch == "u":
|
|
189
|
+
self._unicode = ""
|
|
190
|
+
else:
|
|
191
|
+
self._emit_text(_ESCAPES.get(ch, ch))
|
|
192
|
+
return
|
|
193
|
+
if ch == "\\":
|
|
194
|
+
self._pending_esc = True
|
|
195
|
+
return
|
|
196
|
+
if ch == '"':
|
|
197
|
+
self._state = "done"
|
|
198
|
+
return
|
|
199
|
+
self._emit_text(ch)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Helpers — string scanning that respects JSON escaping
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def _completed_string_value(buf: str, key: str) -> str | None:
|
|
207
|
+
"""Return the value of "key": "value" once its closing quote has arrived."""
|
|
208
|
+
needle = f'"{key}"'
|
|
209
|
+
i = buf.find(needle)
|
|
210
|
+
if i == -1:
|
|
211
|
+
return None
|
|
212
|
+
j = buf.find(":", i + len(needle))
|
|
213
|
+
if j == -1:
|
|
214
|
+
return None
|
|
215
|
+
k = buf.find('"', j + 1)
|
|
216
|
+
if k == -1:
|
|
217
|
+
return None
|
|
218
|
+
end = _closing_quote(buf, k + 1)
|
|
219
|
+
if end == -1:
|
|
220
|
+
return None
|
|
221
|
+
return buf[k + 1:end]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _message_value_start(buf: str) -> int | None:
|
|
225
|
+
"""Index just past the opening quote of "message": "…", if present."""
|
|
226
|
+
needle = '"message"'
|
|
227
|
+
i = buf.find(needle)
|
|
228
|
+
if i == -1:
|
|
229
|
+
return None
|
|
230
|
+
j = buf.find(":", i + len(needle))
|
|
231
|
+
if j == -1:
|
|
232
|
+
return None
|
|
233
|
+
k = buf.find('"', j + 1)
|
|
234
|
+
if k == -1:
|
|
235
|
+
return None
|
|
236
|
+
return k + 1
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _closing_quote(buf: str, start: int) -> int:
|
|
240
|
+
"""Index of the unescaped closing quote at/after `start`, or -1."""
|
|
241
|
+
esc = False
|
|
242
|
+
for idx in range(start, len(buf)):
|
|
243
|
+
ch = buf[idx]
|
|
244
|
+
if esc:
|
|
245
|
+
esc = False
|
|
246
|
+
elif ch == "\\":
|
|
247
|
+
esc = True
|
|
248
|
+
elif ch == '"':
|
|
249
|
+
return idx
|
|
250
|
+
return -1
|
hexcli/telemetry.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.telemetry — silent structured session logging for Hex CLI.
|
|
3
|
+
|
|
4
|
+
One-way dependency, mirroring hexcli.ui: hexcli.agent imports this module,
|
|
5
|
+
never the reverse. Every public method swallows its own exceptions — a
|
|
6
|
+
telemetry failure must never surface in the terminal UI or interrupt the
|
|
7
|
+
agent loop.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_LOG_DIR_NAME = ".shellai/logs"
|
|
19
|
+
_REDACT_KEYS = {"content", "old_string", "new_string"}
|
|
20
|
+
_MAX_PROMPT_LOG = 500
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _redact_args(args: dict[str, Any]) -> dict[str, Any]:
|
|
24
|
+
out: dict[str, Any] = {}
|
|
25
|
+
for key, value in args.items():
|
|
26
|
+
if key in _REDACT_KEYS and isinstance(value, str):
|
|
27
|
+
out[key] = f"<{len(value)} chars>"
|
|
28
|
+
else:
|
|
29
|
+
out[key] = value
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TurnRecorder:
|
|
34
|
+
"""Accumulates tool calls and LLM latency for a single user turn."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, turn_index: int, mode: str, prompt: str) -> None:
|
|
37
|
+
self.turn_index = turn_index
|
|
38
|
+
self.mode = mode
|
|
39
|
+
self.prompt = prompt[:_MAX_PROMPT_LOG] + ("…" if len(prompt) > _MAX_PROMPT_LOG else "")
|
|
40
|
+
self.timestamp = datetime.now(UTC).isoformat()
|
|
41
|
+
self.execution_path = "direct"
|
|
42
|
+
self.tool_calls: list[dict[str, Any]] = []
|
|
43
|
+
self.steps_used = 0
|
|
44
|
+
self.thinking_latency_s = 0.0
|
|
45
|
+
self.tokens_generated = 0
|
|
46
|
+
self._start = time.monotonic()
|
|
47
|
+
|
|
48
|
+
def record_llm(self, latency_s: float, tokens: int = 0) -> None:
|
|
49
|
+
self.thinking_latency_s += latency_s
|
|
50
|
+
self.tokens_generated += tokens
|
|
51
|
+
self.steps_used += 1
|
|
52
|
+
|
|
53
|
+
def record_tool(self, tool: str, args: dict[str, Any], latency_s: float, status: str) -> None:
|
|
54
|
+
self.execution_path = "agentic"
|
|
55
|
+
self.tool_calls.append({
|
|
56
|
+
"tool": tool,
|
|
57
|
+
"args_summary": _redact_args(args),
|
|
58
|
+
"latency_s": round(latency_s, 3),
|
|
59
|
+
"status": status,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
def finish(self, status: str = "completed") -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"turn_index": self.turn_index,
|
|
65
|
+
"timestamp": self.timestamp,
|
|
66
|
+
"mode": self.mode,
|
|
67
|
+
"prompt": self.prompt,
|
|
68
|
+
"execution_path": self.execution_path,
|
|
69
|
+
"tool_calls": self.tool_calls,
|
|
70
|
+
"steps_used": self.steps_used,
|
|
71
|
+
"thinking_latency_s": round(self.thinking_latency_s, 3),
|
|
72
|
+
"total_latency_s": round(time.monotonic() - self._start, 3),
|
|
73
|
+
"tokens_generated": self.tokens_generated,
|
|
74
|
+
"completion_status": status,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SessionTelemetry:
|
|
79
|
+
"""Writes one JSON file per process session to .shellai/logs/.
|
|
80
|
+
|
|
81
|
+
Disabled (no-op) if config["telemetry_enabled"] is falsy, or if the log
|
|
82
|
+
directory can't be created/written — in either case every method
|
|
83
|
+
becomes a silent no-op rather than raising.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, config: dict[str, Any], cwd: str | None = None) -> None:
|
|
87
|
+
self.enabled = bool(config.get("telemetry_enabled", True))
|
|
88
|
+
self.session_id = str(uuid.uuid4())
|
|
89
|
+
self.started_at = datetime.now(UTC).isoformat()
|
|
90
|
+
self.model = str(config.get("model", "unknown"))
|
|
91
|
+
self.backend = str(config.get("backend", "unknown"))
|
|
92
|
+
self.cwd = cwd or str(Path.cwd())
|
|
93
|
+
self.turns: list[dict[str, Any]] = []
|
|
94
|
+
self._path: Path | None = None
|
|
95
|
+
if self.enabled:
|
|
96
|
+
try:
|
|
97
|
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
98
|
+
short_id = self.session_id[:8]
|
|
99
|
+
log_dir = Path.cwd() / _LOG_DIR_NAME
|
|
100
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
self._path = log_dir / f"session_{stamp}_{short_id}.json"
|
|
102
|
+
except Exception:
|
|
103
|
+
self.enabled = False
|
|
104
|
+
self._path = None
|
|
105
|
+
|
|
106
|
+
def start_turn(self, mode: str, prompt: str) -> TurnRecorder:
|
|
107
|
+
return TurnRecorder(len(self.turns), mode, prompt)
|
|
108
|
+
|
|
109
|
+
def record_turn(self, recorder: TurnRecorder, status: str = "completed") -> None:
|
|
110
|
+
if not self.enabled:
|
|
111
|
+
return
|
|
112
|
+
try:
|
|
113
|
+
self.turns.append(recorder.finish(status))
|
|
114
|
+
self._write()
|
|
115
|
+
except Exception:
|
|
116
|
+
self.enabled = False
|
|
117
|
+
|
|
118
|
+
def _write(self) -> None:
|
|
119
|
+
if not self._path:
|
|
120
|
+
return
|
|
121
|
+
payload = {
|
|
122
|
+
"session_id": self.session_id,
|
|
123
|
+
"started_at": self.started_at,
|
|
124
|
+
"model": self.model,
|
|
125
|
+
"backend": self.backend,
|
|
126
|
+
"cwd": self.cwd,
|
|
127
|
+
"turns": self.turns,
|
|
128
|
+
}
|
|
129
|
+
tmp = self._path.with_suffix(".tmp")
|
|
130
|
+
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
131
|
+
tmp.replace(self._path)
|