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/compaction.py ADDED
@@ -0,0 +1,309 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.compaction — history compression and the context budget, lifted
3
+ out of agent.py.
4
+
5
+ The deterministic merge-aware compactor, the LLM summarizer behind explicit
6
+ /compact, and the derived history budget + auto-compact guard.
7
+
8
+ Cross-cutting names (call_llm, build_autopilot_prompt, estimate_tokens,
9
+ sync_session_store, the token estimator, and compact_history itself when
10
+ auto-compact fires it) are resolved through the agent module AT CALL TIME —
11
+ the same idiom loop_v2 uses — so every existing sa.<name> patch site keeps
12
+ intercepting. Module-local calls stay module-local only when nothing patches
13
+ them.
14
+
15
+ Split stage 4 (docs/V2X_ROADMAP.md, "The Split"). Bodies moved verbatim
16
+ apart from those hub lookups.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from hexcli.parsing import strip_thinking
25
+ from hexcli.prompts import COMPACT_SYSTEM_PROMPT
26
+ from hexcli.sessions import touch_session
27
+ from hexcli.ui import C, cprint
28
+
29
+
30
+ def _agent():
31
+ from hexcli import agent
32
+ return agent
33
+
34
+
35
+ # Markers for the deterministic compactor's output. Constants because the
36
+ # compactor must RECOGNISE its own previous output on re-compaction (see
37
+ # below); inline strings in two places would drift apart silently.
38
+ _CONDENSED_MARKER = "[Earlier turns, condensed:]"
39
+ _CONDENSED_ACK = "Understood — continuing with that context in mind."
40
+ _CONDENSED_DROP_RE = re.compile(r"^- \[…(\d+) earlier turn")
41
+
42
+ # The history budget is derived from the server's INPUT budget
43
+ # (`context_window_tokens`: what npurun enforces before it starts dropping
44
+ # messages; adopted from /v1/models at the first turn, 3,000 when the server
45
+ # does not advertise one), not from a model "cliff". The 2,600-token cliff
46
+ # that lived here from July to September was never a length effect —
47
+ # V2_PLAN §14.7 records uc1 failing at 2,477 while uc2 passed at 2,911 (a
48
+ # regex bug), and the August sweep (evals/cases_cliff.py) found quality flat
49
+ # right up to the server's trim. See docs/RESEARCH_NEXT_LEVERS.md §8.
50
+ _DEFAULT_INPUT_BUDGET_TOKENS = 3_000
51
+ # Reserve for the parts of a turn that are neither system prompt nor history:
52
+ # workspace snapshot, the user's query, and the first tool result coming back.
53
+ _TURN_OVERHEAD_TOKENS = 500
54
+ # Never demand compaction below this — pathological when the prompt is huge.
55
+ _MIN_HISTORY_BUDGET_TOKENS = 250
56
+ # Auto-compact only fires when its dry run shows at least this much freed.
57
+ # Below that, compacting is churn: it rewrites history the model then has to
58
+ # re-read, without buying room for the next turn.
59
+ _AUTO_COMPACT_MIN_GAIN_TOKENS = 100
60
+
61
+
62
+ def _expand_condensed(content: str) -> tuple[list[str], int]:
63
+ """Split a previous condensed block back into (stub_lines, dropped_count)."""
64
+ lines: list[str] = []
65
+ dropped = 0
66
+ for raw in content.splitlines():
67
+ line = raw.strip()
68
+ if not line.startswith("- "):
69
+ continue # header/footer markers
70
+ m = _CONDENSED_DROP_RE.match(line)
71
+ if m:
72
+ dropped += int(m.group(1))
73
+ continue
74
+ lines.append(line)
75
+ return lines, dropped
76
+
77
+
78
+ def compact_history_deterministic(
79
+ session: dict[str, Any],
80
+ keep_recent: int = 4,
81
+ stub_chars: int = 160,
82
+ total_stub_chars: int = 900,
83
+ ) -> list[dict[str, str]]:
84
+ """Compact history WITHOUT an LLM call.
85
+
86
+ Auto-compact used to summarise via the same 4B model that was already at
87
+ its degradation cliff — the worst possible moment to ask it for a faithful
88
+ summary, and a full extra re-prefill besides (review finding W10). This
89
+ keeps the most recent turns verbatim and reduces older ones to one-line
90
+ stubs: instant, free, and impossible to hallucinate. The LLM summariser
91
+ stays available for explicit /compact, where the user opts into the cost.
92
+
93
+ Re-compaction is merge-aware: a previous run's condensed block is expanded
94
+ back into its stub lines instead of being stubbed as an opaque message.
95
+ Before this, every re-compact crushed the whole block into one 160-char
96
+ stub (stubs-of-stubs), so at the 250-token budget floor — where compaction
97
+ fires every couple of turns — older context was destroyed almost
98
+ immediately. Merging also makes the function idempotent: with no new
99
+ messages the output is byte-identical, which is what lets auto-compact
100
+ dry-run it as a thrash guard.
101
+ """
102
+ messages: list[dict[str, str]] = list(session.get("messages", []))
103
+ if len(messages) <= keep_recent + 1:
104
+ return messages
105
+
106
+ head, tail = messages[:-keep_recent], messages[-keep_recent:]
107
+ # Build stubs newest-first and stop at the total budget: recent context is
108
+ # worth more than old, and an unbounded stub list just recreates the
109
+ # oversized history we are trying to shed.
110
+ stub_lines: list[str] = []
111
+ used = 0
112
+ dropped = 0
113
+
114
+ def _take(line: str) -> None:
115
+ nonlocal used, dropped
116
+ if used + len(line) > total_stub_chars:
117
+ dropped += 1
118
+ return
119
+ stub_lines.append(line)
120
+ used += len(line)
121
+
122
+ for m in reversed(head):
123
+ role = m.get("role")
124
+ raw = m.get("content") or ""
125
+ if role == "assistant" and raw.strip() == _CONDENSED_ACK:
126
+ continue # scaffolding from a previous compaction, not content
127
+ if role == "user" and raw.lstrip().startswith(_CONDENSED_MARKER):
128
+ inner, inner_dropped = _expand_condensed(raw)
129
+ dropped += inner_dropped
130
+ for line in reversed(inner):
131
+ _take(line)
132
+ continue
133
+ text = " ".join(raw.split())
134
+ if not text:
135
+ continue
136
+ who = "You" if role == "user" else "Hex"
137
+ _take(f"- {who}: {text[:stub_chars]}" + ("…" if len(text) > stub_chars else ""))
138
+ stub_lines.reverse()
139
+ if dropped:
140
+ stub_lines.insert(0, f"- […{dropped} earlier turn(s) dropped]")
141
+
142
+ new_messages: list[dict[str, str]] = [
143
+ {
144
+ "role": "user",
145
+ "content": (_CONDENSED_MARKER + "\n" + "\n".join(stub_lines)
146
+ + "\n[Continue from here]"),
147
+ },
148
+ {
149
+ "role": "assistant",
150
+ "content": _CONDENSED_ACK,
151
+ },
152
+ *tail,
153
+ ]
154
+ session["messages"] = new_messages
155
+ session["compact_count"] = session.get("compact_count", 0) + 1
156
+ touch_session(session)
157
+ return new_messages
158
+
159
+
160
+ def compact_history(
161
+ config: dict[str, Any],
162
+ session: dict[str, Any],
163
+ *,
164
+ quiet: bool = False,
165
+ ) -> list[dict[str, str]]:
166
+ """Summarise the current message history and replace it with a compact version.
167
+
168
+ quiet=True suppresses the printed summary (used by auto-compact).
169
+ """
170
+ messages: list[dict[str, str]] = list(session.get("messages", []))
171
+ _COMPACT_KEEP_RECENT = 4
172
+ # Need at least keep_recent+3 messages so that 3+ messages are summarised
173
+ # and removed — otherwise the 2 summary messages + 4 tail can exceed the
174
+ # original count (e.g. 5 msgs → 6 msgs after compact).
175
+ if len(messages) < _COMPACT_KEEP_RECENT + 3:
176
+ cprint(" Nothing to compact yet.", C.DIM)
177
+ return messages
178
+
179
+ # /no_think disables Qwen3's chain-of-thought block so the token budget
180
+ # goes to the actual summary rather than being consumed by <think> tags.
181
+ summary_messages: list[dict[str, str]] = [
182
+ {"role": "system", "content": COMPACT_SYSTEM_PROMPT},
183
+ *messages,
184
+ {"role": "user", "content": "Produce the compact summary now. /no_think"},
185
+ ]
186
+ compact_tokens = max(512, int(config.get("compact_max_output_tokens", 512)))
187
+ config_with_compact = {**config, "_compact_tokens": compact_tokens}
188
+ summary, _ = _agent().call_llm(config_with_compact, summary_messages,
189
+ "_compact_tokens", label="compacting")
190
+ summary = strip_thinking(summary).strip()
191
+
192
+ # Keep the last few messages verbatim so in-progress task state survives compaction.
193
+ tail = messages[-_COMPACT_KEEP_RECENT:] if len(messages) > _COMPACT_KEEP_RECENT else []
194
+
195
+ new_messages: list[dict[str, str]] = [
196
+ {
197
+ "role": "user",
198
+ "content": (
199
+ "[Conversation compacted. Summary of prior context:]\n\n"
200
+ + summary
201
+ + "\n\n[Continue from here]"
202
+ ),
203
+ },
204
+ {
205
+ "role": "assistant",
206
+ "content": "Understood. I have the context summary and will continue from where we left off.",
207
+ },
208
+ *tail,
209
+ ]
210
+ session["messages"] = new_messages
211
+ session["compact_count"] = session.get("compact_count", 0) + 1
212
+ touch_session(session)
213
+
214
+ if not quiet:
215
+ cprint(" Chat history compacted.", C.DIM)
216
+ print()
217
+ print(summary)
218
+ print()
219
+ return new_messages
220
+
221
+
222
+ def _history_budget_tokens(config: dict[str, Any]) -> tuple[int, int]:
223
+ """Return (warn, critical) history-token thresholds derived from the ACTUAL
224
+ system prompt size and the server's input budget.
225
+
226
+ v1.3–v1.7 hardcoded warn=1,300 on a comment claiming the base prompt was
227
+ ~1,000 tokens. It is really ~2,100, so auto-compact fired ~900 tokens PAST
228
+ the degradation cliff — i.e. the safety net never once fired in time, which
229
+ is what made multi-turn coding sessions collapse from turn 4 (see
230
+ docs/V2_PLAN.md §14). Measuring the prompt instead of guessing keeps this
231
+ honest when the prompt changes again.
232
+ """
233
+ override = config.get("context_warn_tokens")
234
+ if override:
235
+ return int(override), int(override) * 5 // 4
236
+ ag = _agent()
237
+ try:
238
+ base = ag.estimate_tokens(ag.build_autopilot_prompt(
239
+ cwd=str(Path.cwd()), max_steps=int(config.get("max_agent_steps", 15)),
240
+ ))
241
+ except Exception:
242
+ base = 2_100
243
+ window = int(config.get("context_window_tokens") or _DEFAULT_INPUT_BUDGET_TOKENS)
244
+ warn = max(_MIN_HISTORY_BUDGET_TOKENS, window - base - _TURN_OVERHEAD_TOKENS)
245
+ return warn, warn * 5 // 4
246
+
247
+
248
+ def context_fill_percent(session: dict[str, Any] | None, config: dict[str, Any]) -> int:
249
+ """How full the history budget is, 0-100: 0 on a fresh session, 100 when
250
+ the next turn will auto-compact. The prompt shows it as a small gauge."""
251
+ msgs = (session or {}).get("messages", []) or []
252
+ if not msgs:
253
+ return 0
254
+ ag = _agent()
255
+ est = ag._TOKEN_ESTIMATOR.estimate(sum(len(m.get("content", "")) for m in msgs))
256
+ warn, _ = _history_budget_tokens(config)
257
+ return max(0, min(100, round(100 * est / max(warn, 1))))
258
+
259
+
260
+ def _maybe_auto_compact(
261
+ config: dict[str, Any],
262
+ session: dict[str, Any],
263
+ sessions: list[dict[str, Any]],
264
+ ) -> None:
265
+ """Silently compact history when the NEXT turn would cross the 4B
266
+ instruction-following cliff.
267
+
268
+ Fires after each autopilot turn. The threshold is derived from the actual
269
+ system-prompt size (see _history_budget_tokens), not a hardcoded guess.
270
+ The full summary is suppressed (quiet=True); only a one-line notice prints.
271
+
272
+ Thrash guard: before 2.5.0's window-derived budget, a 2,200-token prompt
273
+ clamped the history budget to the 250-token floor (now ~850 against a
274
+ 3,696-token server budget), and the compacted tail usually exceeded it —
275
+ so v2.2 re-fired every single turn, shredding the condensed block a little
276
+ further each time while freeing almost nothing (the user-reported "by the
277
+ time it compacts, it autocompacts again by the next message"). The
278
+ deterministic compactor is instant and idempotent, so dry-run it first and
279
+ fire only when it would actually reclaim meaningful room.
280
+ """
281
+ ag = _agent()
282
+ msgs = session.get("messages", [])
283
+ est = ag._TOKEN_ESTIMATOR.estimate(sum(len(m.get("content", "")) for m in msgs))
284
+ warn_tokens, _ = _history_budget_tokens(config)
285
+ if est < warn_tokens:
286
+ return
287
+ use_llm = bool(config.get("auto_compact_uses_llm", False))
288
+ if not use_llm:
289
+ probe: dict[str, Any] = {"messages": msgs}
290
+ est_after = ag._TOKEN_ESTIMATOR.estimate(sum(
291
+ len(m.get("content", ""))
292
+ for m in compact_history_deterministic(probe)))
293
+ if est - est_after < _AUTO_COMPACT_MIN_GAIN_TOKENS:
294
+ return
295
+ # One line, after the fact, no numbers — token detail lives in /stats.
296
+ # The slow LLM path announces itself first so the pause is explained;
297
+ # the deterministic path is instant and needs no preamble.
298
+ try:
299
+ if use_llm:
300
+ cprint(" Compacting chat history...", C.BCYAN)
301
+ ag.compact_history(config, session, quiet=True)
302
+ else:
303
+ compact_history_deterministic(session)
304
+ ag.sync_session_store(sessions, session)
305
+ cprint(" Chat history compacted.", C.DIM)
306
+ except ag.UserCancelled:
307
+ cprint(" Auto-compact cancelled. Run /compact manually.", C.YELLOW)
308
+ except Exception as exc: # noqa: BLE001
309
+ cprint(f" Auto-compact failed ({exc}). Run /compact manually.", C.YELLOW)
hexcli/config.py ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.config — defaults, load/merge, and the /config value tables,
3
+ lifted out of agent.py.
4
+
5
+ DEFAULT_CONFIG is re-bound in agent.py as the same dict object, so every
6
+ existing sa.DEFAULT_CONFIG reader (and {**sa.DEFAULT_CONFIG, ...} test
7
+ fixture) sees the canonical table.
8
+
9
+ Split stage 5 (docs/V2X_ROADMAP.md, "The Split"). Bodies moved verbatim.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from hexcli.tools import DEFAULT_TIMEOUT_SECONDS
19
+
20
+ DEFAULT_CONFIG: dict[str, Any] = {
21
+ "backend": "ollama",
22
+ "model": "qwen2.5-coder:7b",
23
+ "temperature": 0.1,
24
+ "timeout_seconds": DEFAULT_TIMEOUT_SECONDS,
25
+ "max_output_tokens": 512,
26
+ "autopilot_max_output_tokens": 2048,
27
+ "compact_max_output_tokens": 512,
28
+ "max_agent_steps": 15,
29
+ "tool_output_limit": 12000,
30
+ # Compiled Genie window. Per-step tool output is budgeted against it so a
31
+ # single tool result can never overflow the window (an overflow returns
32
+ # an EMPTY generation — measured 2026-09-01). Raise only with a bigger
33
+ # bundle.
34
+ "context_window_tokens": 3000,
35
+ "prewarm_after_turn": True,
36
+ "history_retention_days": 30,
37
+ "shell_exe": "",
38
+ "use_streaming": True,
39
+ # Render streamed answers live (text as it arrives, tool intent announced
40
+ # early). Off = the old token-counter behaviour.
41
+ "live_streaming": True,
42
+ # Print a diff after every successful file mutation.
43
+ "show_diffs": True,
44
+ # Network policy for fetch_url, the agent's only outbound channel:
45
+ # "ask" (default) confirms each fetch and denies when non-interactive;
46
+ # "allow" fetches silently; "deny" disables the tool and drops its schema.
47
+ "network_access": "ask",
48
+ # Omit the procedural rules (13/14) when the query cannot trigger them.
49
+ # OFF by default on measured evidence: it saves ~330 prompt tokens and 16%
50
+ # of first-token latency, but extended trap-4 went 5/8 -> 3/18 across three
51
+ # independent A/B runs (Fisher p~=0.017). See docs/V2_PLAN.md §14.15.
52
+ # Opt in only with a bigger-context bundle or a different model.
53
+ "conditional_rules": False,
54
+ "prompt_split": True,
55
+ # Byte-stable system prompt (date/cwd move to the first user message).
56
+ # Precondition for KV prefix reuse; default flips after the A/B.
57
+ "prompt_stable_prefix": False,
58
+ # Rich input line: persistent history, Tab completion, multi-line paste.
59
+ # Falls back to bare input() automatically when stdin/stdout is not a tty.
60
+ "rich_input": True,
61
+ "side_padding": 2,
62
+ "input_history_file": "",
63
+ "input_history_limit": 500,
64
+ # Input box pinned at the bottom with a status line under it (context
65
+ # fill, NPU load, memory in use). Needs rich_input and a console.
66
+ "status_bar": True,
67
+ # Light background band behind your echoed messages in the transcript.
68
+ "user_highlight": True,
69
+ # After an unverified file mutation, deflect the first "done" once and ask
70
+ # the agent to check its work. (Was read from config but declared nowhere,
71
+ # so `/config require_verification false` reported an unknown key.)
72
+ "require_verification": True,
73
+ # Confine file MUTATIONS to the working directory (reads stay free).
74
+ "workspace_write_scope": True,
75
+ # Extra roots the agent may write to (absolute paths, ~ expanded).
76
+ "workspace_write_allow": [],
77
+ "telemetry_enabled": True,
78
+ "chat_log_enabled": True,
79
+ "chat_log_dir": "",
80
+ "memory_enabled": True,
81
+ # The dreaming consolidation daemon is OFF by default: measured 2026-08-16
82
+ # writing the same five fabricated machine "facts" (wrong CPU, wrong RAM,
83
+ # an invented temperature) into memory_rules.md every idle cycle, which
84
+ # workspace_snapshot then injected as "Prior knowledge" — locking the
85
+ # model's hardware confabulations in permanently. V2X_ROADMAP already
86
+ # ruled it ships only with a quality eval; the eval now exists and it
87
+ # failed it. Re-enable only with new evidence.
88
+ "memory_dreaming": False,
89
+ "autopilot_confirm_destructive": True,
90
+ # Sensitive-data command gate (ssh keys, credential stores, security
91
+ # files, obfuscated execution). Separate from the destructive flag so
92
+ # injection defense holds even when destructive confirms are disabled.
93
+ "autopilot_confirm_sensitive": True,
94
+ # Agent protocol: "v1" (JSON action loop) or "v2" (native tool-call format,
95
+ # payload-block edits, persistent shell — see docs/V2_PLAN.md §5).
96
+ "protocol": "v1",
97
+ # Auto-compact is deterministic (no LLM call) by default: summarising via
98
+ # the same model that is already at its context cliff produced unverified
99
+ # summaries and cost a full extra re-prefill. Set true to restore the
100
+ # LLM summariser for auto-compact; explicit /compact always uses it.
101
+ "auto_compact_uses_llm": False,
102
+ # Override the derived history budget (tokens). Empty = derive from the
103
+ # measured system-prompt size.
104
+ "context_warn_tokens": 0,
105
+ # Local escalation ladder (docs/V2_PLAN.md §4): name of a bigger local
106
+ # npurun model to consult at hard moments (loop trips, ignored
107
+ # verification, prose-instead-of-edit). Empty = disabled. The server is
108
+ # spawned lazily on the bind address below and reused for the session.
109
+ "escalation_local_model": "",
110
+ "escalation_local_bind": "127.0.0.1:11436",
111
+ "escalation_max_output_tokens": 900,
112
+ "escalation_timeout_seconds": 240,
113
+ "ollama": {"host": "http://127.0.0.1:11434"},
114
+ "openai_compatible": {
115
+ "base_url": "http://127.0.0.1:8000/v1",
116
+ "api_key": "local",
117
+ },
118
+ "anthropic_api_key": "",
119
+ "escalation_model": "claude-haiku-4-5-20251001",
120
+ }
121
+
122
+
123
+ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
124
+ merged: dict[str, Any] = dict(base)
125
+ for key, value in override.items():
126
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
127
+ merged[key] = deep_merge(merged[key], value)
128
+ else:
129
+ merged[key] = value
130
+ return merged
131
+
132
+
133
+ def ensure_default_config(path: Path) -> None:
134
+ if not path.exists():
135
+ payload = json.dumps(DEFAULT_CONFIG, indent=2) + "\n"
136
+ tmp = path.with_suffix(".tmp")
137
+ tmp.write_text(payload, encoding="utf-8")
138
+ tmp.replace(path)
139
+
140
+
141
+ def load_config(path: Path) -> dict[str, Any]:
142
+ ensure_default_config(path)
143
+ with path.open("r", encoding="utf-8") as fh:
144
+ data = json.load(fh)
145
+ config = deep_merge(DEFAULT_CONFIG, data)
146
+ # Per-project override: .shellai/config.json in cwd deep-merges on top.
147
+ project_cfg = Path.cwd() / ".shellai" / "config.json"
148
+ if project_cfg != path and project_cfg.exists():
149
+ try:
150
+ with project_cfg.open("r", encoding="utf-8") as fh:
151
+ project_data = json.load(fh)
152
+ config = deep_merge(config, project_data)
153
+ except Exception:
154
+ pass
155
+ return config
156
+
157
+
158
+ _CONFIG_SETTABLE: dict[str, str] = {
159
+ "model": "str",
160
+ "temperature": "float",
161
+ "timeout_seconds": "int",
162
+ "max_output_tokens": "int",
163
+ "autopilot_max_output_tokens": "int",
164
+ "compact_max_output_tokens": "int",
165
+ "max_agent_steps": "int",
166
+ "tool_output_limit": "int",
167
+ "context_window_tokens": "int",
168
+ "prewarm_after_turn": "bool",
169
+ "history_retention_days": "int",
170
+ "use_streaming": "bool",
171
+ "live_streaming": "bool",
172
+ "workspace_write_scope": "bool",
173
+ "workspace_write_allow": "list",
174
+ "require_verification": "bool",
175
+ "show_diffs": "bool",
176
+ "conditional_rules": "bool",
177
+ "prompt_split": "bool",
178
+ "prompt_stable_prefix": "bool",
179
+ "network_access": "str",
180
+ "rich_input": "bool",
181
+ "side_padding": "int",
182
+ "input_history_file": "str",
183
+ "input_history_limit": "int",
184
+ "status_bar": "bool",
185
+ "user_highlight": "bool",
186
+ "telemetry_enabled": "bool",
187
+ "chat_log_enabled": "bool",
188
+ "chat_log_dir": "str",
189
+ "memory_enabled": "bool",
190
+ "memory_dreaming": "bool",
191
+ "autopilot_confirm_destructive": "bool",
192
+ "autopilot_confirm_sensitive": "bool",
193
+ "protocol": "str",
194
+ "auto_compact_uses_llm": "bool",
195
+ "context_warn_tokens": "int",
196
+ "escalation_local_model": "str",
197
+ "escalation_local_bind": "str",
198
+ "escalation_max_output_tokens": "int",
199
+ "escalation_timeout_seconds": "int",
200
+ "anthropic_api_key": "str",
201
+ "escalation_model": "str",
202
+ }
203
+
204
+
205
+ def _coerce_config_value(value: str, kind: str) -> Any:
206
+ if kind == "bool":
207
+ return value.lower() in ("1", "true", "yes", "on")
208
+ if kind == "int":
209
+ return int(value)
210
+ if kind == "float":
211
+ return float(value)
212
+ if kind == "list":
213
+ # Comma- or semicolon-separated; "" clears. Without this, the one
214
+ # setting the write-scope error message tells users to change
215
+ # (workspace_write_allow) could not be changed from inside the tool.
216
+ return [p.strip() for p in re.split(r"[;,]", value) if p.strip()]
217
+ return value
hexcli/diffview.py ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env python3
2
+ """hexcli.diffview — render what the agent actually changed.
3
+
4
+ Until now a file mutation showed only "◆ edit_file" and the user had to trust
5
+ it. The harness already holds both sides (undo snapshots are captured before
6
+ the first write to each path), so rendering a diff costs ZERO model tokens and
7
+ no latency — the cheapest trust feature available on 15 tok/s hardware.
8
+
9
+ Pure formatting: takes before/after text, returns coloured lines. No I/O.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import difflib
14
+
15
+ from .ui import C
16
+
17
+ _MAX_HUNK_LINES = 40 # per file, before eliding the middle
18
+ _MAX_LINE_CHARS = 200
19
+
20
+
21
+ def render_diff(before: str | None, after: str, path: str,
22
+ color: bool = True, max_lines: int = _MAX_HUNK_LINES) -> str:
23
+ """Unified diff for one file. `before=None` means the file was created."""
24
+ if before is None:
25
+ lines = after.splitlines()
26
+ shown = lines[:max_lines]
27
+ out = [_head(f"+ created {path} ({len(lines)} lines)", color)]
28
+ out += [_add("+" + _clip(ln), color) for ln in shown]
29
+ if len(lines) > max_lines:
30
+ out.append(_dim(f" … {len(lines) - max_lines} more lines", color))
31
+ return "\n".join(out)
32
+
33
+ if before == after:
34
+ return _dim(f" {path}: no change", color)
35
+
36
+ diff = list(difflib.unified_diff(
37
+ before.splitlines(), after.splitlines(),
38
+ lineterm="", n=2,
39
+ ))
40
+ # Drop the ---/+++ header lines; the path is in our own header.
41
+ body = [ln for ln in diff[2:] if ln]
42
+ added = sum(1 for ln in body if ln.startswith("+"))
43
+ removed = sum(1 for ln in body if ln.startswith("-"))
44
+
45
+ out = [_head(f"~ {path} (+{added} −{removed})", color)]
46
+ if len(body) > max_lines:
47
+ head_n = max_lines // 2
48
+ tail_n = max_lines - head_n
49
+ shown = body[:head_n] + [f"… {len(body) - max_lines} more diff lines …"] + body[-tail_n:]
50
+ else:
51
+ shown = body
52
+ for ln in shown:
53
+ clipped = _clip(ln)
54
+ if ln.startswith("+"):
55
+ out.append(_add(clipped, color))
56
+ elif ln.startswith("-"):
57
+ out.append(_rem(clipped, color))
58
+ elif ln.startswith("@@"):
59
+ out.append(_dim(clipped, color))
60
+ else:
61
+ out.append(_plain(clipped, color))
62
+ return "\n".join(out)
63
+
64
+
65
+ def render_turn_diffs(snapshots: dict[str, str | None],
66
+ read_current, color: bool = True) -> str:
67
+ """Diffs for every path a turn touched.
68
+
69
+ `snapshots` is the undo map {resolved_path: original_or_None};
70
+ `read_current(path)` returns the file's text now (or None if deleted).
71
+ """
72
+ blocks: list[str] = []
73
+ for path, before in snapshots.items():
74
+ try:
75
+ after = read_current(path)
76
+ except Exception:
77
+ continue
78
+ if after is None:
79
+ blocks.append(_head(f"- deleted {path}", color))
80
+ continue
81
+ blocks.append(render_diff(before, after, path, color=color))
82
+ return "\n".join(blocks)
83
+
84
+
85
+ def _clip(line: str) -> str:
86
+ return line if len(line) <= _MAX_LINE_CHARS else line[:_MAX_LINE_CHARS] + " …"
87
+
88
+
89
+ def _head(t: str, color: bool) -> str:
90
+ return f"{C.BCYAN}{t}{C.RESET}" if color else t
91
+
92
+
93
+ def _add(t: str, color: bool) -> str:
94
+ return f"{C.GREEN}{t}{C.RESET}" if color else t
95
+
96
+
97
+ def _rem(t: str, color: bool) -> str:
98
+ return f"{C.RED}{t}{C.RESET}" if color else t
99
+
100
+
101
+ def _dim(t: str, color: bool) -> str:
102
+ return f"{C.DIM}{t}{C.RESET}" if color else t
103
+
104
+
105
+ def _plain(t: str, color: bool) -> str:
106
+ return t