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