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/repl.py
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hexcli.repl — the interactive shell, lifted out of agent.py.
|
|
3
|
+
|
|
4
|
+
run_repl and its helpers: slash-command dispatch, /config and /memory
|
|
5
|
+
handlers, /stats, the backend-failure restart flow, and the command list
|
|
6
|
+
that drives Tab completion.
|
|
7
|
+
|
|
8
|
+
Everything agent-resident is referenced through the agent module (sa.X) at
|
|
9
|
+
call time, so every existing sa.<name> patch — run_autopilot,
|
|
10
|
+
compact_history, sync_session_store, the tool functions — keeps
|
|
11
|
+
intercepting REPL-driven calls, and inspect.getsource(sa.run_repl) keeps
|
|
12
|
+
working for the suites that cross-check command handling against this
|
|
13
|
+
source.
|
|
14
|
+
|
|
15
|
+
Split stage 6 (docs/V2X_ROADMAP.md, "The Split"). Bodies moved verbatim
|
|
16
|
+
apart from the sa. qualification.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from hexcli import (
|
|
31
|
+
chatlog,
|
|
32
|
+
diffview,
|
|
33
|
+
lineedit,
|
|
34
|
+
memory,
|
|
35
|
+
setup_wizard,
|
|
36
|
+
statusbar,
|
|
37
|
+
telemetry,
|
|
38
|
+
ui,
|
|
39
|
+
)
|
|
40
|
+
from hexcli import (
|
|
41
|
+
commands as custom_commands,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _AgentProxy:
|
|
46
|
+
"""Call-time window onto hexcli.agent, so this module works regardless of
|
|
47
|
+
which side of the agent<->repl cycle imports first, and every sa.<name>
|
|
48
|
+
patch (run_autopilot, compact_history, tool functions, ...) is seen at
|
|
49
|
+
the moment of use rather than frozen at import."""
|
|
50
|
+
|
|
51
|
+
def __getattr__(self, name: str) -> Any:
|
|
52
|
+
from hexcli import agent
|
|
53
|
+
return getattr(agent, name)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
sa = _AgentProxy()
|
|
57
|
+
|
|
58
|
+
REPL_COMMANDS = (
|
|
59
|
+
"/help", "/exit", "/quit", "/clear", "/history", "/resume", "/new",
|
|
60
|
+
"/compact", "/config", "/memory", "/tools", "/undo", "/stats", "/diff",
|
|
61
|
+
"/doctor", "/cwd", "/search", "/setup", "/context",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _closest_command(word: str, extra: tuple[str, ...] = ()) -> str | None:
|
|
66
|
+
"""Nearest known slash command, for typo hints."""
|
|
67
|
+
import difflib
|
|
68
|
+
known = list(REPL_COMMANDS) + list(extra)
|
|
69
|
+
matches = difflib.get_close_matches(word.lower(), known, n=1, cutoff=0.6)
|
|
70
|
+
return matches[0] if matches else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _handle_config_cmd(query: str, config: dict[str, Any]) -> None:
|
|
74
|
+
parts = query.split(None, 2)
|
|
75
|
+
if len(parts) == 1:
|
|
76
|
+
print()
|
|
77
|
+
for key, kind in sorted(sa._CONFIG_SETTABLE.items()):
|
|
78
|
+
val = config.get(key, "(unset)")
|
|
79
|
+
print(f" {key:<42} {str(val):<18} [{kind}]")
|
|
80
|
+
print()
|
|
81
|
+
return
|
|
82
|
+
key = parts[1]
|
|
83
|
+
if key not in sa._CONFIG_SETTABLE:
|
|
84
|
+
sa.cprint(f" Unknown config key {key!r}. /config lists the keys.", sa.C.YELLOW)
|
|
85
|
+
return
|
|
86
|
+
if len(parts) == 2:
|
|
87
|
+
sa.cprint(f" {key} = {config.get(key, '(unset)')!r} [{sa._CONFIG_SETTABLE[key]}]", sa.C.DIM)
|
|
88
|
+
return
|
|
89
|
+
value_str = parts[2]
|
|
90
|
+
try:
|
|
91
|
+
new_val = sa._coerce_config_value(value_str, sa._CONFIG_SETTABLE[key])
|
|
92
|
+
except (ValueError, TypeError) as exc:
|
|
93
|
+
sa.cprint(f" Cannot set {key!r}: {exc}", sa.C.RED)
|
|
94
|
+
return
|
|
95
|
+
config[key] = new_val
|
|
96
|
+
sa.cprint(f" {key} = {new_val!r}", sa.C.BCYAN)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _handle_memory_cmd(query: str, config: dict[str, Any]) -> None:
|
|
100
|
+
parts = query.split(None, 2)
|
|
101
|
+
sub = parts[1].lower() if len(parts) > 1 else "status"
|
|
102
|
+
|
|
103
|
+
if sub == "status":
|
|
104
|
+
enabled = bool(config.get("memory_enabled", True))
|
|
105
|
+
if not enabled:
|
|
106
|
+
sa.cprint(" Memory is off: memory_enabled = false.", sa.C.YELLOW)
|
|
107
|
+
return
|
|
108
|
+
meta_path = Path.cwd() / ".shellai" / "vector_store" / "metadata.json"
|
|
109
|
+
if not meta_path.exists():
|
|
110
|
+
sa.cprint(" No memory entries.", sa.C.DIM)
|
|
111
|
+
return
|
|
112
|
+
try:
|
|
113
|
+
entries = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
114
|
+
size_kb = meta_path.stat().st_size // 1024
|
|
115
|
+
print(f" Memory store: {len(entries)} entries, {size_kb} KB")
|
|
116
|
+
if entries:
|
|
117
|
+
oldest = entries[0].get("created_at", "?")[:16]
|
|
118
|
+
newest = entries[-1].get("created_at", "?")[:16]
|
|
119
|
+
sa.cprint(f" Oldest {oldest}, newest {newest}", sa.C.DIM)
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
sa.cprint(f" Could not read the memory store: {exc}", sa.C.YELLOW)
|
|
122
|
+
|
|
123
|
+
elif sub == "list":
|
|
124
|
+
n = 10
|
|
125
|
+
if len(parts) > 2:
|
|
126
|
+
try:
|
|
127
|
+
n = int(parts[2])
|
|
128
|
+
except ValueError:
|
|
129
|
+
pass
|
|
130
|
+
meta_path = Path.cwd() / ".shellai" / "vector_store" / "metadata.json"
|
|
131
|
+
if not meta_path.exists():
|
|
132
|
+
print(" No memory entries.")
|
|
133
|
+
return
|
|
134
|
+
try:
|
|
135
|
+
entries = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
136
|
+
shown = entries[-n:]
|
|
137
|
+
offset = max(0, len(entries) - n)
|
|
138
|
+
print()
|
|
139
|
+
for i, e in enumerate(shown, start=offset + 1):
|
|
140
|
+
ts = e.get("created_at", "?")[:16]
|
|
141
|
+
text = e.get("text", "")[:80]
|
|
142
|
+
tools = ", ".join(e.get("tool_sequence", []) or [])
|
|
143
|
+
print(f" #{i:>3} [{ts}] {text}")
|
|
144
|
+
if tools:
|
|
145
|
+
print(f" tools: {tools}")
|
|
146
|
+
print()
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
sa.cprint(f" Could not read the memory store: {exc}", sa.C.YELLOW)
|
|
149
|
+
|
|
150
|
+
elif sub == "search":
|
|
151
|
+
if len(parts) < 3:
|
|
152
|
+
print(" Usage: /memory search <query>")
|
|
153
|
+
return
|
|
154
|
+
result = memory.search_memory_tool(config, parts[2], top_k=5)
|
|
155
|
+
print(f"\n{result}\n")
|
|
156
|
+
|
|
157
|
+
elif sub == "clear":
|
|
158
|
+
confirm = (ui.ask_line(" Delete all memory entries? [y/N] ") or "").strip().lower()
|
|
159
|
+
if confirm not in ("y", "yes"):
|
|
160
|
+
sa.cprint(" Cancelled.", sa.C.DIM)
|
|
161
|
+
return
|
|
162
|
+
store_dir = Path.cwd() / ".shellai" / "vector_store"
|
|
163
|
+
deleted: list[str] = []
|
|
164
|
+
for fname in ("vectors.npz", "metadata.json"):
|
|
165
|
+
f = store_dir / fname
|
|
166
|
+
if f.exists():
|
|
167
|
+
try:
|
|
168
|
+
f.unlink()
|
|
169
|
+
deleted.append(fname)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
sa.cprint(f" Could not delete {fname}: {exc}", sa.C.YELLOW)
|
|
172
|
+
if deleted:
|
|
173
|
+
sa.cprint(" Memory cleared.", sa.C.DIM)
|
|
174
|
+
else:
|
|
175
|
+
sa.cprint(" Nothing to clear.", sa.C.DIM)
|
|
176
|
+
|
|
177
|
+
elif sub == "prune":
|
|
178
|
+
removed = memory.prune_memory_rules()
|
|
179
|
+
if removed:
|
|
180
|
+
sa.cprint(" Memory rules pruned.", sa.C.DIM)
|
|
181
|
+
else:
|
|
182
|
+
sa.cprint(" Nothing to prune.", sa.C.DIM)
|
|
183
|
+
|
|
184
|
+
else:
|
|
185
|
+
print(" Usage: /memory [status|list [n]|search <query>|clear|prune]")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _show_stats(config: dict[str, Any], tel: Any, session: dict[str, Any]) -> None:
|
|
189
|
+
"""Summarise this session plus recent history from the telemetry logs.
|
|
190
|
+
|
|
191
|
+
telemetry.py has always written rich per-turn records (tool calls, latency
|
|
192
|
+
split, tokens, completion status) — and nothing ever read them back. On
|
|
193
|
+
15 tok/s hardware, time-per-task is the cost metric that matters, so this
|
|
194
|
+
is the number users actually want.
|
|
195
|
+
"""
|
|
196
|
+
turns = list(getattr(tel, "turns", []) or [])
|
|
197
|
+
print()
|
|
198
|
+
sa.cprint(" Session", sa.C.BOLD)
|
|
199
|
+
if not turns:
|
|
200
|
+
sa.cprint(" No completed turns yet.", sa.C.DIM)
|
|
201
|
+
else:
|
|
202
|
+
total_time = sum(t.get("total_latency_s", 0) for t in turns)
|
|
203
|
+
think_time = sum(t.get("thinking_latency_s", 0) for t in turns)
|
|
204
|
+
tokens = sum(t.get("tokens_generated", 0) for t in turns)
|
|
205
|
+
agentic = [t for t in turns if t.get("execution_path") == "agentic"]
|
|
206
|
+
errors = [t for t in turns if t.get("completion_status") != "completed"]
|
|
207
|
+
tool_counts: dict[str, int] = {}
|
|
208
|
+
for t in turns:
|
|
209
|
+
for call in t.get("tool_calls", []):
|
|
210
|
+
name = str(call.get("tool", "?"))
|
|
211
|
+
tool_counts[name] = tool_counts.get(name, 0) + 1
|
|
212
|
+
print(f" Turns: {len(turns)} ({len(agentic)} used tools)")
|
|
213
|
+
print(f" Total time: {total_time:.0f}s "
|
|
214
|
+
f"(model {think_time:.0f}s, tools {max(0.0, total_time - think_time):.0f}s)")
|
|
215
|
+
print(f" Avg turn: {total_time / len(turns):.1f}s")
|
|
216
|
+
print(f" Tokens generated: ~{tokens:,}")
|
|
217
|
+
if errors:
|
|
218
|
+
print(f" Turns with errors: {len(errors)} "
|
|
219
|
+
f"({', '.join(sorted({str(t.get('completion_status')) for t in errors}))})")
|
|
220
|
+
if tool_counts:
|
|
221
|
+
top = sorted(tool_counts.items(), key=lambda kv: -kv[1])[:6]
|
|
222
|
+
print(" Tools used: " + ", ".join(f"{n}×{c}" for n, c in top))
|
|
223
|
+
# Lifetime view from the log directory.
|
|
224
|
+
try:
|
|
225
|
+
log_dir = Path.cwd() / ".shellai" / "logs"
|
|
226
|
+
files = sorted(log_dir.glob("session_*.json"))
|
|
227
|
+
if files:
|
|
228
|
+
total_turns = 0
|
|
229
|
+
for f in files[-50:]:
|
|
230
|
+
try:
|
|
231
|
+
total_turns += len(json.loads(f.read_text(encoding="utf-8")).get("turns", []))
|
|
232
|
+
except Exception:
|
|
233
|
+
continue
|
|
234
|
+
print(f" This project: {len(files)} sessions logged, "
|
|
235
|
+
f"{total_turns} turns (last 50 sessions)")
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
print()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _close_session_resources(session: dict[str, Any] | None) -> None:
|
|
242
|
+
"""Release per-session OS resources when a session ends.
|
|
243
|
+
|
|
244
|
+
Protocol v2 keeps a persistent PowerShell process per session id. Session
|
|
245
|
+
switches (/new, /resume) used to abandon them, so a long REPL run
|
|
246
|
+
accumulated live shells until process exit.
|
|
247
|
+
"""
|
|
248
|
+
if not session:
|
|
249
|
+
return
|
|
250
|
+
sid = str(session.get("id", ""))
|
|
251
|
+
if not sid:
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
from . import loop_v2
|
|
255
|
+
loop_v2.close_session_shell(sid)
|
|
256
|
+
except Exception:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _handle_backend_failure(config: dict[str, Any], reason: str) -> None:
|
|
261
|
+
"""Explain a backend failure and offer to restart it in place.
|
|
262
|
+
|
|
263
|
+
Covers both "server is down" and the measured degradation mode where the
|
|
264
|
+
Genie dialog goes sticky-failed and 500s everything (V2_PLAN §14.4). In
|
|
265
|
+
both cases the fix is the same — a fresh server — so offer it here rather
|
|
266
|
+
than making the user leave the session.
|
|
267
|
+
"""
|
|
268
|
+
sa.cprint(f"\n {reason}", sa.C.BRED)
|
|
269
|
+
if str(config.get("backend")) != "openai" or "_npurun_model" not in config:
|
|
270
|
+
sa.cprint(" Restart it, then try again.", sa.C.DIM)
|
|
271
|
+
return
|
|
272
|
+
answer = ui.ask_line(" Restart the model server? [Y/n] ")
|
|
273
|
+
answer = "n" if answer is None else answer.strip().lower() # no human: never restart
|
|
274
|
+
if answer in ("", "y", "yes"):
|
|
275
|
+
if restart_backend(config):
|
|
276
|
+
sa.cprint(" Server restarted. Press Up, then Enter to resend.", sa.C.DIM)
|
|
277
|
+
else:
|
|
278
|
+
sa.cprint(" Restart failed. Relaunch Hex CLI.", sa.C.YELLOW)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _newest_qairt_root() -> str:
|
|
282
|
+
"""The newest QAIRT install under the stack folder, for a restart when
|
|
283
|
+
the launcher's choice is not in the environment (a bare `python -m
|
|
284
|
+
hexcli.agent`). Same rule as the launcher: newest version wins."""
|
|
285
|
+
stack = Path(r"C:\Qualcomm\AIStack")
|
|
286
|
+
|
|
287
|
+
def key(p: Path) -> tuple[int, ...]:
|
|
288
|
+
try:
|
|
289
|
+
return tuple(int(x) for x in p.name.split("_", 1)[1].split("."))
|
|
290
|
+
except (IndexError, ValueError):
|
|
291
|
+
return (0,)
|
|
292
|
+
|
|
293
|
+
candidates = sorted(stack.glob("QAIRT_*"), key=key, reverse=True) if stack.exists() else []
|
|
294
|
+
return str(candidates[0]) if candidates else str(stack / "QAIRT_2.50.0")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def restart_backend(config: dict[str, Any]) -> bool:
|
|
298
|
+
"""Stop and respawn the local npurun server. Returns True when healthy."""
|
|
299
|
+
model = str(config.get("_npurun_model") or "")
|
|
300
|
+
if not model:
|
|
301
|
+
return False
|
|
302
|
+
exe = Path.home() / ".cargo" / "bin" / "npurun.exe"
|
|
303
|
+
if not exe.exists():
|
|
304
|
+
return False
|
|
305
|
+
try:
|
|
306
|
+
subprocess.run(["taskkill", "/F", "/IM", "npurun.exe"],
|
|
307
|
+
capture_output=True, timeout=15)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
time.sleep(2)
|
|
311
|
+
sdk = Path(os.environ.get("QNN_SDK_ROOT") or _newest_qairt_root())
|
|
312
|
+
env = os.environ.copy()
|
|
313
|
+
env["QNN_SDK_ROOT"] = str(sdk)
|
|
314
|
+
env["ADSP_LIBRARY_PATH"] = str(sdk / "lib" / "hexagon-v73" / "unsigned")
|
|
315
|
+
env["PATH"] = (f"{sdk / 'bin' / 'aarch64-windows-msvc'};"
|
|
316
|
+
f"{sdk / 'lib' / 'aarch64-windows-msvc'};{env.get('PATH', '')}")
|
|
317
|
+
try:
|
|
318
|
+
# The launcher owns runtime selection (newest valid QAIRT, and the
|
|
319
|
+
# Rewind/prefix-reuse mode when SDK >= 2.50 and npurun >= 0.2.0);
|
|
320
|
+
# a restart must respawn the same server the launcher started.
|
|
321
|
+
import launcher
|
|
322
|
+
env = launcher._npurun_env()
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
bind = sa._backend_url(config).split("//")[-1].split("/")[0]
|
|
326
|
+
try:
|
|
327
|
+
subprocess.Popen(
|
|
328
|
+
[str(exe), "serve", "--model", model, "--bind", bind],
|
|
329
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
330
|
+
env=env, creationflags=0x00000008, # DETACHED_PROCESS
|
|
331
|
+
)
|
|
332
|
+
except Exception:
|
|
333
|
+
return False
|
|
334
|
+
with sa.Spinner("restarting"):
|
|
335
|
+
for _ in range(45):
|
|
336
|
+
time.sleep(2)
|
|
337
|
+
if sa.ping_backend(config):
|
|
338
|
+
return True
|
|
339
|
+
return False
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _last_error_text(scope: dict[str, Any]) -> str:
|
|
343
|
+
"""The exception bound in the enclosing except-block, as text (for the
|
|
344
|
+
chat log's turn_end); empty when there is none."""
|
|
345
|
+
exc = scope.get("exc")
|
|
346
|
+
return f"{type(exc).__name__}: {exc}" if isinstance(exc, BaseException) else ""
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _editor_writer(live: Any) -> Any:
|
|
350
|
+
"""The line editor's output path when the status area is up."""
|
|
351
|
+
if live is None:
|
|
352
|
+
return None
|
|
353
|
+
target = live._inner if getattr(live.margin, "pad", 0) else live._inner._base
|
|
354
|
+
|
|
355
|
+
def write(s: str) -> None:
|
|
356
|
+
target.write(s)
|
|
357
|
+
target.flush()
|
|
358
|
+
return write
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def run_repl(config: dict[str, Any]) -> int:
|
|
362
|
+
shell_exe = sa.detect_shell(str(config.get("shell_exe", "") or ""))
|
|
363
|
+
sessions = sa.load_history_store(config)
|
|
364
|
+
current_session = sa.create_session()
|
|
365
|
+
tel = telemetry.SessionTelemetry(config)
|
|
366
|
+
clog = chatlog.ChatLog(config, version=sa.VERSION)
|
|
367
|
+
|
|
368
|
+
ui.disable_quick_edit()
|
|
369
|
+
ui.enable_vt_processing()
|
|
370
|
+
ui.install_margin(int(config.get("side_padding", 0) or 0))
|
|
371
|
+
ui.apply_saved_console_font()
|
|
372
|
+
# The status bar: input box pinned at the bottom with context, NPU and
|
|
373
|
+
# memory under it. Up before the banner, so the first thing on screen is
|
|
374
|
+
# the finished layout. Between reads the live area draws it under
|
|
375
|
+
# whatever the turn prints; while reading, the editor draws it as its
|
|
376
|
+
# chrome. main() uninstalls it on the way out, whichever way the loop
|
|
377
|
+
# ends. `current_session` is rebound by /new and /resume; the lambda
|
|
378
|
+
# reads the name at call time, so the gauge follows.
|
|
379
|
+
live = statusbar.install(config, lambda: sa.context_fill_percent(current_session, config))
|
|
380
|
+
npu_model = str(config.get("_npurun_model", "") or "")
|
|
381
|
+
pending_query: str | None = None # the question of the turn now running (see _reprint)
|
|
382
|
+
|
|
383
|
+
def _banner() -> None:
|
|
384
|
+
ui.print_banner(npu_model or str(config.get("model", "?")),
|
|
385
|
+
str(config.get("backend", "ollama")),
|
|
386
|
+
engine="Hexagon NPU" if npu_model else None)
|
|
387
|
+
|
|
388
|
+
_banner()
|
|
389
|
+
if live is not None:
|
|
390
|
+
live.enable() # after the banner: the banner keeps the top, the conversation grows above the box
|
|
391
|
+
sa.prime_backend(config) # warm the KV cache with this session's prompt while the banner shows
|
|
392
|
+
if config.get("memory_dreaming", False):
|
|
393
|
+
memory.start_dreaming(lambda: config, sa.llm_generate)
|
|
394
|
+
|
|
395
|
+
# Rich line editing where the terminal supports it; bare input() otherwise
|
|
396
|
+
# (piped stdin, CI, --raw) so nothing depends on it being available.
|
|
397
|
+
# Custom commands are discovered once here for Tab completion; dispatch
|
|
398
|
+
# below re-reads the file each use, so edits apply without a restart.
|
|
399
|
+
custom_names = tuple(sorted(custom_commands.discover()))
|
|
400
|
+
def _relayout() -> None:
|
|
401
|
+
"""Lay the screen out again at the current size: banner at the top,
|
|
402
|
+
the conversation anchored above the box, blank rows between.
|
|
403
|
+
|
|
404
|
+
Called at the prompt after a resize or a zoom, once the editor has
|
|
405
|
+
taken its own rows down. The terminal re-wraps what was on screen
|
|
406
|
+
at the new width; when that takes more rows than the window has,
|
|
407
|
+
Windows Terminal drops the top rows into its scrollback, where
|
|
408
|
+
nothing the program writes can reach them, so the banner (and a
|
|
409
|
+
short conversation) would otherwise be gone for good. Reprinting
|
|
410
|
+
through the same path a turn uses (box up, transcript consumes the
|
|
411
|
+
pad, box down) reproduces the live layout exactly.
|
|
412
|
+
"""
|
|
413
|
+
if live is None:
|
|
414
|
+
ui.redraw_transcript(current_session)
|
|
415
|
+
return
|
|
416
|
+
ui.clear_screen()
|
|
417
|
+
live.screen_cleared()
|
|
418
|
+
_reprint(live.enable) # box at the bottom, pad under the banner
|
|
419
|
+
live.disable() # box down; cursor where the editor starts
|
|
420
|
+
|
|
421
|
+
def _reprint(pin: Callable[[], None]) -> None:
|
|
422
|
+
"""Banner, then the box (`pin` puts it on the last rows, the pad
|
|
423
|
+
under the banner), then the conversation growing upward into the
|
|
424
|
+
pad. Also the live area's hook for a resize during a turn: it
|
|
425
|
+
replays the turn's own output after this, and the question of the
|
|
426
|
+
running turn is not in the session yet, so it is echoed here."""
|
|
427
|
+
_banner()
|
|
428
|
+
pin()
|
|
429
|
+
ui.redraw_transcript(current_session, clear=False, pending=pending_query)
|
|
430
|
+
|
|
431
|
+
if live is not None:
|
|
432
|
+
live.on_relayout = _reprint
|
|
433
|
+
|
|
434
|
+
def _zoom(delta: int) -> bool:
|
|
435
|
+
"""Ctrl+Plus / Ctrl+Minus. Returns True when the screen was redrawn."""
|
|
436
|
+
if ui.console_zoom(delta) is None:
|
|
437
|
+
return False
|
|
438
|
+
_relayout()
|
|
439
|
+
return True
|
|
440
|
+
|
|
441
|
+
def _resize() -> None:
|
|
442
|
+
"""The window was resized while at the prompt."""
|
|
443
|
+
_relayout()
|
|
444
|
+
|
|
445
|
+
def _context_brief() -> None:
|
|
446
|
+
"""The numbers that decide the next turn (/context, and the tail of /stats)."""
|
|
447
|
+
sys_tokens = sa.estimate_tokens(sa.build_autopilot_prompt(
|
|
448
|
+
cwd=str(Path.cwd()), max_steps=int(config.get("max_agent_steps", 15))))
|
|
449
|
+
msgs = current_session.get("messages", [])
|
|
450
|
+
hist_tokens = sa._TOKEN_ESTIMATOR.estimate(sum(len(m.get("content", "")) for m in msgs))
|
|
451
|
+
sa.show_context_brief(current_session, config,
|
|
452
|
+
budget=sa._history_budget_tokens(config),
|
|
453
|
+
system_prompt_tokens=sys_tokens,
|
|
454
|
+
history_tokens=hist_tokens)
|
|
455
|
+
|
|
456
|
+
read_line = lineedit.make_reader(
|
|
457
|
+
config, tuple(REPL_COMMANDS) + custom_names, lambda: sorted(sa._CONFIG_SETTABLE),
|
|
458
|
+
on_zoom=_zoom, on_resize=_resize,
|
|
459
|
+
chrome=live.chrome if live is not None else None,
|
|
460
|
+
placeholder="ask, or / for commands" if live is not None else "",
|
|
461
|
+
# The editor draws its own rows straight to the console, under the
|
|
462
|
+
# live area's wrapper: those cursor moves are not transcript output.
|
|
463
|
+
# With a side padding the margin layer adds the fill after each
|
|
464
|
+
# newline; without one it would only reflow words the editor has
|
|
465
|
+
# already laid out, so go to the base stream.
|
|
466
|
+
write=_editor_writer(live),
|
|
467
|
+
# A light band behind the echoed message marks the user's turns.
|
|
468
|
+
finish_style=ui.user_row if (live is not None and config.get("user_highlight", True)) else None,
|
|
469
|
+
# Where the editor sits, and how far the window scrolled when a
|
|
470
|
+
# multi-line entry grew past the bottom: the live area moves its
|
|
471
|
+
# pad bookkeeping with it.
|
|
472
|
+
geometry=statusbar.console_geometry if live is not None else None,
|
|
473
|
+
on_grow=live.note_scroll if live is not None else None,
|
|
474
|
+
make_room=live.make_room if live is not None else None,
|
|
475
|
+
give_room=live.give_room if live is not None else None,
|
|
476
|
+
) or (lambda p: input(p))
|
|
477
|
+
|
|
478
|
+
while True:
|
|
479
|
+
prompt = sa.repl_prompt(config, sa.context_fill_percent(current_session, config),
|
|
480
|
+
boxed=live is not None)
|
|
481
|
+
if live is not None:
|
|
482
|
+
live.disable()
|
|
483
|
+
try:
|
|
484
|
+
query = read_line(prompt).strip()
|
|
485
|
+
except EOFError:
|
|
486
|
+
print()
|
|
487
|
+
sa.sync_session_store(sessions, current_session)
|
|
488
|
+
return 0
|
|
489
|
+
except KeyboardInterrupt:
|
|
490
|
+
print()
|
|
491
|
+
continue
|
|
492
|
+
finally:
|
|
493
|
+
if live is not None:
|
|
494
|
+
live.enable()
|
|
495
|
+
|
|
496
|
+
memory.touch_last_turn()
|
|
497
|
+
|
|
498
|
+
if not query:
|
|
499
|
+
continue
|
|
500
|
+
|
|
501
|
+
norm = sa.normalize_text(query)
|
|
502
|
+
if query.startswith("/"):
|
|
503
|
+
clog.command(query)
|
|
504
|
+
|
|
505
|
+
# ── exit ──────────────────────────────────────────────────────────
|
|
506
|
+
if norm in {"/exit", "/quit"}:
|
|
507
|
+
sa.sync_session_store(sessions, current_session)
|
|
508
|
+
clog.event("session_end")
|
|
509
|
+
return 0
|
|
510
|
+
|
|
511
|
+
# ── help / tools ──────────────────────────────────────────────────
|
|
512
|
+
if norm == "/help":
|
|
513
|
+
print(f"\n{sa.HELP_TEXT}\n")
|
|
514
|
+
continue
|
|
515
|
+
if norm == "/tools":
|
|
516
|
+
print(f"\n{sa.TOOLS_HELP}\n")
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
# ── history ───────────────────────────────────────────────────────
|
|
520
|
+
if norm == "/history":
|
|
521
|
+
sa.sync_session_store(sessions, current_session)
|
|
522
|
+
sessions = sa.load_history_store(config)
|
|
523
|
+
sa.render_history_list(sessions, str(current_session.get("id", "")))
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
# ── search saved sessions ─────────────────────────────────────────
|
|
527
|
+
if norm == "/search" or norm.startswith("/search "):
|
|
528
|
+
parts = query.split(None, 1)
|
|
529
|
+
term = parts[1].strip() if len(parts) > 1 else ""
|
|
530
|
+
if not term:
|
|
531
|
+
sa.cprint(" Usage: /search <text>", sa.C.DIM)
|
|
532
|
+
continue
|
|
533
|
+
sa.sync_session_store(sessions, current_session)
|
|
534
|
+
sessions = sa.load_history_store(config)
|
|
535
|
+
hits = sa.sessions_search(sessions, term)
|
|
536
|
+
ui.render_search_results(term, hits)
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
# ── diff: what changed in the last turn ───────────────────────────
|
|
540
|
+
if norm == "/diff":
|
|
541
|
+
snaps = sa._SESSION_UNDO_SNAPSHOTS.get(current_session.get("id", ""), {})
|
|
542
|
+
if not snaps:
|
|
543
|
+
sa.cprint(" No file changes in the last turn.", sa.C.DIM)
|
|
544
|
+
else:
|
|
545
|
+
def _read_now(p: str) -> str | None:
|
|
546
|
+
path_obj = Path(p)
|
|
547
|
+
return path_obj.read_text(encoding="utf-8", errors="replace") \
|
|
548
|
+
if path_obj.exists() else None
|
|
549
|
+
print(diffview.render_turn_diffs(snaps, _read_now))
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
# ── context: just the numbers that decide the next turn ───────────
|
|
553
|
+
if norm == "/context":
|
|
554
|
+
_context_brief()
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
# ── stats: session summary + context usage ─────────────────────────
|
|
558
|
+
if norm == "/stats" or norm.startswith("/stats "):
|
|
559
|
+
_show_stats(config, tel, current_session)
|
|
560
|
+
_context_brief()
|
|
561
|
+
if clog.path:
|
|
562
|
+
sa.cprint(f" Chat log: {clog.path}", sa.C.DIM)
|
|
563
|
+
print()
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
# ── doctor: diagnose the installation without leaving the REPL ────
|
|
567
|
+
if norm == "/doctor":
|
|
568
|
+
from . import doctor
|
|
569
|
+
doctor.run_doctor(config)
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
# ── setup: interactive config wizard ──────────────────────────────
|
|
573
|
+
if norm == "/setup":
|
|
574
|
+
wizard_path = Path(str(config.get("_config_path", "")) or sa.DEFAULT_CONFIG_PATH)
|
|
575
|
+
# Through ask_line, so the status box is lowered for each question.
|
|
576
|
+
def _wizard_ask(prompt: str) -> str:
|
|
577
|
+
if not sys.stdin.isatty():
|
|
578
|
+
return input(prompt) # piped answers still work
|
|
579
|
+
answer = ui.ask_line(prompt)
|
|
580
|
+
if answer is None:
|
|
581
|
+
raise KeyboardInterrupt
|
|
582
|
+
return answer
|
|
583
|
+
setup_wizard.run_wizard(config, wizard_path, ask=_wizard_ask)
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
# ── clear screen + context ────────────────────────────────────────
|
|
587
|
+
# v2.0 made /clear screen-only because the old silent alias-for-/new
|
|
588
|
+
# lost sessions without a trace. In practice the split was noise: you
|
|
589
|
+
# clear when the current thread is done, and an announced fresh
|
|
590
|
+
# session is not silent data loss — the old one stays one /resume
|
|
591
|
+
# away. /clear is now the one reset command; /new remains an alias
|
|
592
|
+
# that keeps the scrollback.
|
|
593
|
+
if norm == "/clear":
|
|
594
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
595
|
+
if live is not None:
|
|
596
|
+
live.screen_cleared() # the box and its pad rows are gone with the screen
|
|
597
|
+
sa.sync_session_store(sessions, current_session)
|
|
598
|
+
_close_session_resources(current_session)
|
|
599
|
+
current_session = sa.create_session()
|
|
600
|
+
sys.stdout.write("\r") # the clear skipped the margin's fill for this row
|
|
601
|
+
sa.cprint(" Chat history cleared.", sa.C.DIM)
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
# ── new session ───────────────────────────────────────────────────
|
|
605
|
+
if norm == "/new":
|
|
606
|
+
sa.sync_session_store(sessions, current_session)
|
|
607
|
+
_close_session_resources(current_session)
|
|
608
|
+
current_session = sa.create_session()
|
|
609
|
+
sa.cprint(" New session started.", sa.C.DIM)
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
# ── resume ────────────────────────────────────────────────────────
|
|
613
|
+
if norm == "/resume" or norm.startswith("/resume "):
|
|
614
|
+
sa.sync_session_store(sessions, current_session)
|
|
615
|
+
sessions = sa.load_history_store(config)
|
|
616
|
+
parts = norm.split()
|
|
617
|
+
if len(parts) != 2 or not parts[1].isdigit():
|
|
618
|
+
sa.cprint(" Usage: /resume <n>", sa.C.DIM)
|
|
619
|
+
continue
|
|
620
|
+
idx = int(parts[1]) - 1
|
|
621
|
+
if idx < 0 or idx >= len(sessions):
|
|
622
|
+
sa.cprint(" No session with that number.", sa.C.YELLOW)
|
|
623
|
+
continue
|
|
624
|
+
_close_session_resources(current_session)
|
|
625
|
+
current_session = sessions[idx]
|
|
626
|
+
# Show what was resumed: the conversation, reprinted, then the notice.
|
|
627
|
+
if live is not None:
|
|
628
|
+
live.screen_cleared()
|
|
629
|
+
ui.redraw_transcript(current_session)
|
|
630
|
+
sa.cprint(f"\n Resumed session: {current_session['title']}", sa.C.DIM)
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
# ── compact ───────────────────────────────────────────────────────
|
|
634
|
+
if norm == "/compact":
|
|
635
|
+
try:
|
|
636
|
+
sa.compact_history(config, current_session)
|
|
637
|
+
sa.sync_session_store(sessions, current_session)
|
|
638
|
+
except sa.UserCancelled:
|
|
639
|
+
sa.cprint("\n Cancelled.", sa.C.DIM)
|
|
640
|
+
except Exception as exc: # noqa: BLE001
|
|
641
|
+
ui.error_box(str(exc))
|
|
642
|
+
if sa.DEBUG:
|
|
643
|
+
raise
|
|
644
|
+
continue
|
|
645
|
+
|
|
646
|
+
# ── undo ──────────────────────────────────────────────────────────
|
|
647
|
+
if norm == "/undo":
|
|
648
|
+
msgs: list[dict[str, str]] = current_session.get("messages", [])
|
|
649
|
+
if len(msgs) >= 2:
|
|
650
|
+
current_session["messages"] = msgs[:-2]
|
|
651
|
+
sa.touch_session(current_session)
|
|
652
|
+
# Restore any files mutated during the last agentic turn.
|
|
653
|
+
snapshots = sa.pop_undo_snapshots(current_session)
|
|
654
|
+
if snapshots:
|
|
655
|
+
restored: list[str] = []
|
|
656
|
+
failed: list[str] = []
|
|
657
|
+
for path_str, original in snapshots.items():
|
|
658
|
+
try:
|
|
659
|
+
p = Path(path_str)
|
|
660
|
+
if original is None:
|
|
661
|
+
if p.exists():
|
|
662
|
+
p.unlink()
|
|
663
|
+
restored.append(f"deleted {p.name}")
|
|
664
|
+
else:
|
|
665
|
+
tmp_p = p.parent / (p.name + ".tmp")
|
|
666
|
+
tmp_p.write_text(original, encoding="utf-8")
|
|
667
|
+
tmp_p.replace(p)
|
|
668
|
+
restored.append(p.name)
|
|
669
|
+
except Exception as exc:
|
|
670
|
+
failed.append(f"{Path(path_str).name}: {exc}")
|
|
671
|
+
if restored:
|
|
672
|
+
sa.cprint(f" Files restored: {', '.join(restored)}", sa.C.DIM)
|
|
673
|
+
if failed:
|
|
674
|
+
sa.cprint(f" Could not restore: {', '.join(failed)}", sa.C.YELLOW)
|
|
675
|
+
sa.sync_session_store(sessions, current_session)
|
|
676
|
+
sa.cprint(" Last exchange removed.", sa.C.DIM)
|
|
677
|
+
elif len(msgs) == 1:
|
|
678
|
+
current_session["messages"] = []
|
|
679
|
+
sa.touch_session(current_session)
|
|
680
|
+
sa.pop_undo_snapshots(current_session)
|
|
681
|
+
sa.cprint(" Last message removed.", sa.C.DIM)
|
|
682
|
+
else:
|
|
683
|
+
sa.cprint(" Nothing to undo.", sa.C.DIM)
|
|
684
|
+
continue
|
|
685
|
+
|
|
686
|
+
# ── cwd ───────────────────────────────────────────────────────────
|
|
687
|
+
if norm == "/cwd" or norm.startswith("/cwd "):
|
|
688
|
+
parts_cwd = query.strip().split(None, 1)
|
|
689
|
+
if len(parts_cwd) == 2:
|
|
690
|
+
new_path = parts_cwd[1].strip()
|
|
691
|
+
try:
|
|
692
|
+
os.chdir(sa.resolve_path(new_path))
|
|
693
|
+
sa.cprint(f" cwd: {Path.cwd()}", sa.C.DIM)
|
|
694
|
+
except Exception as exc:
|
|
695
|
+
sa.cprint(f" Cannot change to '{new_path}': {exc}", sa.C.RED)
|
|
696
|
+
else:
|
|
697
|
+
sa.cprint(f" cwd: {Path.cwd()}", sa.C.DIM)
|
|
698
|
+
continue
|
|
699
|
+
|
|
700
|
+
# ── config ────────────────────────────────────────────────────────
|
|
701
|
+
if norm == "/config" or norm.startswith("/config "):
|
|
702
|
+
_handle_config_cmd(query.strip(), config)
|
|
703
|
+
continue
|
|
704
|
+
|
|
705
|
+
# ── memory ────────────────────────────────────────────────────────
|
|
706
|
+
if norm == "/memory" or norm.startswith("/memory "):
|
|
707
|
+
_handle_memory_cmd(query.strip(), config)
|
|
708
|
+
continue
|
|
709
|
+
|
|
710
|
+
# Custom commands — user-authored prompt templates. Consulted only
|
|
711
|
+
# after every built-in above has declined, so a custom file can
|
|
712
|
+
# never shadow a real command. The expanded template falls through
|
|
713
|
+
# to mode dispatch as an ordinary query.
|
|
714
|
+
ran_custom = False
|
|
715
|
+
if query.startswith("/"):
|
|
716
|
+
cmd_word = query.split()[0]
|
|
717
|
+
# Belt-and-braces on top of dispatch order: a built-in NAME is
|
|
718
|
+
# never eligible for the custom lookup, even when its handler
|
|
719
|
+
# only matched the "<cmd> <arg>" form (a bare built-in must not
|
|
720
|
+
# run a same-named user template as an agent task).
|
|
721
|
+
is_builtin = cmd_word.lower() in REPL_COMMANDS
|
|
722
|
+
template = None if is_builtin else custom_commands.load(cmd_word)
|
|
723
|
+
if template is not None:
|
|
724
|
+
args_text = query[len(cmd_word):].strip()
|
|
725
|
+
query = custom_commands.expand(template, args_text)
|
|
726
|
+
ran_custom = True
|
|
727
|
+
|
|
728
|
+
# Unknown slash command: catch typos HERE. Falling through sends
|
|
729
|
+
# "/hlep" to the model as a task — a 10+ second turn on a 4B that
|
|
730
|
+
# may then start running tools to satisfy a typo.
|
|
731
|
+
if query.startswith("/") and not ran_custom:
|
|
732
|
+
cmd_word = query.split()[0]
|
|
733
|
+
suggestion = _closest_command(cmd_word, extra=custom_names)
|
|
734
|
+
hint = f" Did you mean {suggestion}?" if suggestion else " /help lists the commands."
|
|
735
|
+
sa.cprint(f" Unknown command {cmd_word}.{hint}", sa.C.YELLOW)
|
|
736
|
+
continue
|
|
737
|
+
|
|
738
|
+
# ── agent turn ────────────────────────────────────────────────────
|
|
739
|
+
history: list[dict[str, str]] = current_session.get("messages", [])
|
|
740
|
+
turn = tel.start_turn("autopilot", query)
|
|
741
|
+
probe = clog.turn_start(len(tel.turns), query, history,
|
|
742
|
+
sa.context_fill_percent(current_session, config))
|
|
743
|
+
pending_query = query
|
|
744
|
+
try:
|
|
745
|
+
sa.clear_turn_stop()
|
|
746
|
+
message = sa.run_autopilot(config, history, query, shell_exe,
|
|
747
|
+
session=current_session, turn=turn, probe=probe)
|
|
748
|
+
pending_query = None
|
|
749
|
+
if sa.last_streamed_matches(message) or sa.last_turn_stopped():
|
|
750
|
+
# Streamed already, or the turn ended on a stop notice: the
|
|
751
|
+
# message is on screen (or is raw tool output kept for the
|
|
752
|
+
# history), so no answer box.
|
|
753
|
+
print()
|
|
754
|
+
else:
|
|
755
|
+
sa.render_result("Result", message)
|
|
756
|
+
sa.append_session_message(current_session, "user", query)
|
|
757
|
+
sa.append_session_message(current_session, "assistant", message)
|
|
758
|
+
sa.sync_session_store(sessions, current_session)
|
|
759
|
+
tel.record_turn(turn)
|
|
760
|
+
clog.turn_end(probe.turn, status="completed", message=message)
|
|
761
|
+
_before = current_session.get("messages", [])
|
|
762
|
+
_n_before, _c_before = len(_before), sum(len(m.get("content", "")) for m in _before)
|
|
763
|
+
sa._maybe_auto_compact(config, current_session, sessions)
|
|
764
|
+
_after = current_session.get("messages", [])
|
|
765
|
+
if len(_after) != _n_before:
|
|
766
|
+
clog.compaction(_n_before, len(_after), _c_before,
|
|
767
|
+
sum(len(m.get("content", "")) for m in _after))
|
|
768
|
+
except (sa.UserCancelled, KeyboardInterrupt):
|
|
769
|
+
sa.cprint("\n Cancelled.\n", sa.C.DIM)
|
|
770
|
+
tel.record_turn(turn, status="cancelled")
|
|
771
|
+
clog.turn_end(probe.turn, status="cancelled")
|
|
772
|
+
except urllib.error.HTTPError as exc:
|
|
773
|
+
# Measured 2026-07-30 (V2_PLAN §14.4): after 1-2h of traffic the
|
|
774
|
+
# npurun/Genie dialog degrades into sticky ERROR_QUERY_FAILED and
|
|
775
|
+
# 500s EVERY request until restarted. The eval harness was taught
|
|
776
|
+
# to detect this; the REPL was not — a user just saw errors and
|
|
777
|
+
# had to figure out the restart ritual themselves. Now it is
|
|
778
|
+
# named, and recovery is one keypress.
|
|
779
|
+
if exc.code >= 500:
|
|
780
|
+
_handle_backend_failure(config, f"Model server error: HTTP {exc.code}.")
|
|
781
|
+
else:
|
|
782
|
+
ui.error_box(f"The model server rejected the request: HTTP {exc.code}.")
|
|
783
|
+
tel.record_turn(turn, status="error")
|
|
784
|
+
clog.turn_end(probe.turn, status="error", message=_last_error_text(locals()))
|
|
785
|
+
except urllib.error.URLError:
|
|
786
|
+
if not sa.ping_backend(config):
|
|
787
|
+
_handle_backend_failure(config, "The model server is not responding.")
|
|
788
|
+
else:
|
|
789
|
+
ui.error_box("The model server returned an unexpected response.")
|
|
790
|
+
tel.record_turn(turn, status="error")
|
|
791
|
+
clog.turn_end(probe.turn, status="error", message=_last_error_text(locals()))
|
|
792
|
+
except (ConnectionResetError, ConnectionAbortedError):
|
|
793
|
+
ui.error_box(
|
|
794
|
+
"npurun dropped the stream connection.\n"
|
|
795
|
+
"Turn streaming off: /config use_streaming false"
|
|
796
|
+
)
|
|
797
|
+
tel.record_turn(turn, status="error")
|
|
798
|
+
clog.turn_end(probe.turn, status="error", message=_last_error_text(locals()))
|
|
799
|
+
except Exception as exc: # noqa: BLE001
|
|
800
|
+
ui.error_box(str(exc))
|
|
801
|
+
tel.record_turn(turn, status="error")
|
|
802
|
+
clog.turn_end(probe.turn, status="error", message=_last_error_text(locals()))
|
|
803
|
+
if sa.DEBUG:
|
|
804
|
+
raise
|
|
805
|
+
finally:
|
|
806
|
+
pending_query = None
|
|
807
|
+
|