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/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
+