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.
@@ -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)