hx-cli 0.1.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.
Files changed (64) hide show
  1. hx/__init__.py +5 -0
  2. hx/agents/__init__.py +1 -0
  3. hx/agents/definitions.py +106 -0
  4. hx/agents/subagent.py +190 -0
  5. hx/cli.py +667 -0
  6. hx/config.py +277 -0
  7. hx/core/__init__.py +1 -0
  8. hx/core/compaction.py +245 -0
  9. hx/core/context.py +271 -0
  10. hx/core/events.py +183 -0
  11. hx/core/lateinject.py +121 -0
  12. hx/core/loop.py +537 -0
  13. hx/core/messages.py +164 -0
  14. hx/core/session.py +208 -0
  15. hx/core/usage.py +129 -0
  16. hx/frontmatter.py +80 -0
  17. hx/mcp/__init__.py +1 -0
  18. hx/mcp/client.py +319 -0
  19. hx/mcp/manager.py +265 -0
  20. hx/paths.py +90 -0
  21. hx/permissions/__init__.py +7 -0
  22. hx/permissions/engine.py +406 -0
  23. hx/permissions/parser.py +306 -0
  24. hx/permissions/sandbox.py +227 -0
  25. hx/providers/__init__.py +1 -0
  26. hx/providers/base.py +77 -0
  27. hx/providers/fake.py +87 -0
  28. hx/providers/models.py +238 -0
  29. hx/providers/openrouter.py +468 -0
  30. hx/skills/__init__.py +1 -0
  31. hx/skills/loader.py +102 -0
  32. hx/skills/runtime.py +84 -0
  33. hx/tools/__init__.py +1 -0
  34. hx/tools/base.py +97 -0
  35. hx/tools/bash.py +544 -0
  36. hx/tools/edit.py +167 -0
  37. hx/tools/glob.py +75 -0
  38. hx/tools/grep.py +165 -0
  39. hx/tools/output.py +133 -0
  40. hx/tools/read.py +142 -0
  41. hx/tools/registry.py +149 -0
  42. hx/tools/task.py +76 -0
  43. hx/tools/todo.py +149 -0
  44. hx/tools/write.py +87 -0
  45. hx/tui/__init__.py +1 -0
  46. hx/tui/app.py +487 -0
  47. hx/tui/commands.py +399 -0
  48. hx/tui/hx.tcss +197 -0
  49. hx/tui/renderers.py +570 -0
  50. hx/tui/theme.py +322 -0
  51. hx/tui/widgets/__init__.py +1 -0
  52. hx/tui/widgets/configure.py +95 -0
  53. hx/tui/widgets/diff.py +25 -0
  54. hx/tui/widgets/input.py +145 -0
  55. hx/tui/widgets/palette.py +130 -0
  56. hx/tui/widgets/permission.py +97 -0
  57. hx/tui/widgets/statusbar.py +212 -0
  58. hx/tui/widgets/todos.py +116 -0
  59. hx/tui/widgets/transcript.py +316 -0
  60. hx/tui/widgets/working.py +78 -0
  61. hx_cli-0.1.0.dist-info/METADATA +430 -0
  62. hx_cli-0.1.0.dist-info/RECORD +64 -0
  63. hx_cli-0.1.0.dist-info/WHEEL +4 -0
  64. hx_cli-0.1.0.dist-info/entry_points.txt +2 -0
hx/config.py ADDED
@@ -0,0 +1,277 @@
1
+ """Layered configuration.
2
+
3
+ Precedence, lowest to highest::
4
+
5
+ defaults < ~/.hx/settings.json < <cwd>/.hx/settings.json < environment < CLI flags
6
+
7
+ Settings are a plain dataclass so the whole config is hashable/serialisable and
8
+ can be diffed in tests. Permission rules are kept as raw strings here; parsing
9
+ into matchers is ``hx.permissions.engine``'s job.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from dataclasses import dataclass, field
17
+ from dataclasses import fields as dataclasses_fields
18
+ from enum import StrEnum
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from hx.paths import project_settings_file, user_settings_file
23
+
24
+
25
+ class PermissionMode(StrEnum):
26
+ """How aggressively HX asks before acting."""
27
+
28
+ PLAN = "plan"
29
+ """Read-only. No writes, no command execution."""
30
+
31
+ DEFAULT = "default"
32
+ """Ask before writes and command execution."""
33
+
34
+ ACCEPT_EDITS = "acceptEdits"
35
+ """Auto-approve file edits; still ask for command execution."""
36
+
37
+ BYPASS = "bypass"
38
+ """Approve everything. Sandbox still applies."""
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class PermissionSettings:
43
+ mode: PermissionMode = PermissionMode.DEFAULT
44
+ allow: tuple[str, ...] = ()
45
+ ask: tuple[str, ...] = ()
46
+ deny: tuple[str, ...] = ()
47
+ sandbox: bool = True
48
+ """Use the OS sandbox when available (seatbelt / bubblewrap)."""
49
+ allow_network: bool = False
50
+ """Permit outbound network from sandboxed commands."""
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class ContextSettings:
55
+ compact_at: float = 0.80
56
+ """Fraction of the model context window that triggers auto-compaction."""
57
+ keep_recent_turns: int = 6
58
+ """Turns kept verbatim across a compaction."""
59
+ tool_output_char_cap: int = 25_000
60
+ tool_output_line_cap: int = 2_000
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class ModelSettings:
65
+ model: str = "anthropic/claude-sonnet-4.5"
66
+ subagent_model: str | None = None
67
+ """Model used by subagents. Falls back to ``model``."""
68
+ max_tokens: int = 8192
69
+ temperature: float | None = None
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class BashSettings:
74
+ timeout_seconds: int = 120
75
+ max_timeout_seconds: int = 600
76
+ shell: str | None = None
77
+ """Override the login shell used for the persistent Bash session."""
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class Settings:
82
+ """Fully resolved configuration for one HX session."""
83
+
84
+ cwd: Path = field(default_factory=Path.cwd)
85
+ models: ModelSettings = field(default_factory=ModelSettings)
86
+ permissions: PermissionSettings = field(default_factory=PermissionSettings)
87
+ context: ContextSettings = field(default_factory=ContextSettings)
88
+ bash: BashSettings = field(default_factory=BashSettings)
89
+ theme: str = "dark"
90
+ telemetry: bool = False
91
+
92
+
93
+ def load_settings(
94
+ cwd: Path | None = None,
95
+ overrides: dict[str, Any] | None = None,
96
+ ) -> Settings:
97
+ """Resolve the settings layers into a single :class:`Settings`.
98
+
99
+ Args:
100
+ cwd: Project root. Defaults to the process working directory.
101
+ overrides: CLI-flag layer, applied last.
102
+ """
103
+ root = (cwd or Path.cwd()).resolve()
104
+ layers = [
105
+ read_settings_file(user_settings_file()),
106
+ read_settings_file(project_settings_file(root)),
107
+ settings_from_env(dict(os.environ)),
108
+ overrides or {},
109
+ ]
110
+ merged = merge_layers(layers)
111
+ return _build_settings(merged, root)
112
+
113
+
114
+ def _default(cls: type, name: str) -> Any:
115
+ """Default value of a dataclass field.
116
+
117
+ ``slots=True`` replaces class attributes with descriptors, so
118
+ ``ModelSettings.max_tokens`` is not the default - it is a member_descriptor.
119
+ """
120
+ for f in dataclasses_fields(cls):
121
+ if f.name == name:
122
+ return f.default
123
+ raise KeyError(name)
124
+
125
+
126
+ def _build_settings(data: dict[str, Any], cwd: Path) -> Settings:
127
+ models = data.get("models", {})
128
+ permissions = data.get("permissions", {})
129
+ context = data.get("context", {})
130
+ bash = data.get("bash", {})
131
+
132
+ mode_raw = permissions.get("mode", PermissionMode.DEFAULT)
133
+ try:
134
+ mode = PermissionMode(mode_raw)
135
+ except ValueError as exc:
136
+ raise ConfigError(f"unknown permission mode: {mode_raw!r}") from exc
137
+
138
+ return Settings(
139
+ cwd=cwd,
140
+ models=ModelSettings(
141
+ model=models.get("model", _default(ModelSettings, "model")),
142
+ subagent_model=models.get("subagent_model"),
143
+ max_tokens=int(models.get("max_tokens", _default(ModelSettings, "max_tokens"))),
144
+ temperature=models.get("temperature"),
145
+ ),
146
+ permissions=PermissionSettings(
147
+ mode=mode,
148
+ allow=tuple(permissions.get("allow", ())),
149
+ ask=tuple(permissions.get("ask", ())),
150
+ deny=tuple(permissions.get("deny", ())),
151
+ sandbox=bool(permissions.get("sandbox", True)),
152
+ allow_network=bool(permissions.get("allow_network", False)),
153
+ ),
154
+ context=ContextSettings(
155
+ compact_at=float(context.get("compact_at", _default(ContextSettings, "compact_at"))),
156
+ keep_recent_turns=int(
157
+ context.get("keep_recent_turns", _default(ContextSettings, "keep_recent_turns"))
158
+ ),
159
+ tool_output_char_cap=int(
160
+ context.get(
161
+ "tool_output_char_cap", _default(ContextSettings, "tool_output_char_cap")
162
+ )
163
+ ),
164
+ tool_output_line_cap=int(
165
+ context.get(
166
+ "tool_output_line_cap", _default(ContextSettings, "tool_output_line_cap")
167
+ )
168
+ ),
169
+ ),
170
+ bash=BashSettings(
171
+ timeout_seconds=int(
172
+ bash.get("timeout_seconds", _default(BashSettings, "timeout_seconds"))
173
+ ),
174
+ max_timeout_seconds=int(
175
+ bash.get("max_timeout_seconds", _default(BashSettings, "max_timeout_seconds"))
176
+ ),
177
+ shell=bash.get("shell"),
178
+ ),
179
+ theme=data.get("theme", "dark"),
180
+ telemetry=bool(data.get("telemetry", False)),
181
+ )
182
+
183
+
184
+ def read_settings_file(path: Path) -> dict[str, Any]:
185
+ """Read one ``settings.json`` layer. Missing file -> empty dict.
186
+
187
+ Raises:
188
+ ConfigError: if the file exists but is not valid JSON.
189
+ """
190
+ if not path.is_file():
191
+ return {}
192
+ try:
193
+ data = json.loads(path.read_text())
194
+ except json.JSONDecodeError as exc:
195
+ raise ConfigError(f"{path}: invalid JSON ({exc})") from exc
196
+ except OSError as exc:
197
+ raise ConfigError(f"{path}: {exc}") from exc
198
+ if not isinstance(data, dict):
199
+ raise ConfigError(f"{path}: expected a JSON object")
200
+ return data
201
+
202
+
203
+ def write_settings_file(path: Path, data: dict[str, Any]) -> None:
204
+ """Write a settings layer atomically (temp file + rename)."""
205
+ path.parent.mkdir(parents=True, exist_ok=True)
206
+ tmp = path.with_suffix(".json.tmp")
207
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
208
+ tmp.replace(path)
209
+
210
+
211
+ #: Permission rule lists are unioned across layers rather than replaced - a
212
+ #: project must be able to add a deny rule without discarding the user's.
213
+ _UNIONED_KEYS = frozenset({"allow", "ask", "deny"})
214
+
215
+
216
+ def merge_layers(layers: list[dict[str, Any]]) -> dict[str, Any]:
217
+ """Deep-merge settings layers. Later layers win; lists are replaced, not concatenated,
218
+ except permission rule lists which are unioned."""
219
+ result: dict[str, Any] = {}
220
+ for layer in layers:
221
+ _merge_into(result, layer)
222
+ return result
223
+
224
+
225
+ def _merge_into(target: dict[str, Any], source: dict[str, Any]) -> None:
226
+ for key, value in source.items():
227
+ existing = target.get(key)
228
+ if isinstance(existing, dict) and isinstance(value, dict):
229
+ _merge_into(existing, value)
230
+ elif key in _UNIONED_KEYS and isinstance(existing, list) and isinstance(value, list):
231
+ target[key] = existing + [item for item in value if item not in existing]
232
+ else:
233
+ target[key] = value
234
+
235
+
236
+ _ENV_MAP: dict[str, tuple[str, ...]] = {
237
+ "HX_MODEL": ("models", "model"),
238
+ "HX_SUBAGENT_MODEL": ("models", "subagent_model"),
239
+ "HX_MAX_TOKENS": ("models", "max_tokens"),
240
+ "HX_PERMISSION_MODE": ("permissions", "mode"),
241
+ "HX_SANDBOX": ("permissions", "sandbox"),
242
+ "HX_COMPACT_AT": ("context", "compact_at"),
243
+ "HX_THEME": ("theme",),
244
+ }
245
+
246
+
247
+ def settings_from_env(env: dict[str, str]) -> dict[str, Any]:
248
+ """Extract the ``HX_*`` environment layer (e.g. ``HX_MODEL``, ``HX_PERMISSION_MODE``)."""
249
+ layer: dict[str, Any] = {}
250
+ for name, path in _ENV_MAP.items():
251
+ if name not in env:
252
+ continue
253
+ cursor = layer
254
+ for part in path[:-1]:
255
+ cursor = cursor.setdefault(part, {})
256
+ cursor[path[-1]] = _coerce(env[name])
257
+ return layer
258
+
259
+
260
+ def _coerce(raw: str) -> Any:
261
+ lowered = raw.strip().lower()
262
+ if lowered in {"true", "1", "yes"}:
263
+ return True
264
+ if lowered in {"false", "0", "no"}:
265
+ return False
266
+ try:
267
+ return int(raw)
268
+ except ValueError:
269
+ pass
270
+ try:
271
+ return float(raw)
272
+ except ValueError:
273
+ return raw
274
+
275
+
276
+ class ConfigError(Exception):
277
+ """Raised for malformed configuration files."""
hx/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Headless agent core. Knows nothing about the TUI."""
hx/core/compaction.py ADDED
@@ -0,0 +1,245 @@
1
+ """Conversation compaction.
2
+
3
+ Fires when the assembled context crosses ``context.compact_at`` of the model's
4
+ window, or on an explicit ``/compact [instructions]``. Older turns are replaced
5
+ by one structured summary; the last N turns and the full todo list survive
6
+ verbatim. The pre-compaction messages stay in the session JSONL (flagged
7
+ ``compacted``) so resume and undo still work.
8
+
9
+ Compaction reseeds the cache prefix by definition - that cost is accepted and
10
+ surfaced in the status bar rather than hidden.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, ClassVar
17
+
18
+ from hx.core.messages import (
19
+ Message,
20
+ TextBlock,
21
+ ThinkingBlock,
22
+ ToolResultBlock,
23
+ ToolUseBlock,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from hx.core.context import ContextBuilder
28
+ from hx.providers.base import Provider
29
+
30
+ SUMMARY_SECTIONS = (
31
+ "Goal",
32
+ "Decisions made",
33
+ "Files touched",
34
+ "Current state",
35
+ "Next steps",
36
+ "Open questions",
37
+ )
38
+
39
+ SUMMARY_MARKER = "<hx-compacted-summary>"
40
+ SUMMARY_END = "</hx-compacted-summary>"
41
+
42
+ SUMMARY_PROMPT = """\
43
+ Summarise the conversation below so that work can continue without it.
44
+
45
+ Write these sections, in order, using the exact headings:
46
+
47
+ {sections}
48
+
49
+ Be specific and concrete. Name files by path, record decisions with their
50
+ reasons, and state exactly where the work stopped. Preserve anything the next
51
+ turn would otherwise have to re-derive: chosen approaches, rejected approaches
52
+ and why, error messages still unresolved, and commands that must be re-run.
53
+
54
+ Do not summarise the summary instructions. Do not add commentary.
55
+ {extra}
56
+ --- conversation ---
57
+
58
+ {transcript}
59
+ """
60
+
61
+
62
+ @dataclass(slots=True)
63
+ class CompactionResult:
64
+ summary: Message
65
+ kept: list[Message]
66
+ dropped: list[Message]
67
+ tokens_before: int
68
+ tokens_after: int
69
+
70
+
71
+ class Compactor:
72
+ #: Cap on the summary itself. A summary that can grow without bound just
73
+ #: moves the context problem one turn later.
74
+ SUMMARY_MAX_TOKENS: ClassVar[int] = 2048
75
+ MIN_MESSAGES_TO_COMPACT: ClassVar[int] = 4
76
+
77
+ def __init__(
78
+ self,
79
+ provider: Provider | None = None,
80
+ model: str = "",
81
+ keep_recent_turns: int = 6,
82
+ context: ContextBuilder | None = None,
83
+ ) -> None:
84
+ self.provider = provider
85
+ self.model = model
86
+ self.keep_recent_turns = keep_recent_turns
87
+ self.context = context
88
+
89
+ def should_compact(self, context_fraction: float, threshold: float) -> bool:
90
+ return context_fraction >= threshold > 0
91
+
92
+ def split(self, messages: list[Message]) -> tuple[list[Message], list[Message]]:
93
+ """Partition into (to summarise, to keep verbatim).
94
+
95
+ The boundary snaps to a turn edge: never split an assistant tool-use
96
+ message from its tool results, or the next request is malformed.
97
+ """
98
+ if len(messages) <= self.keep_recent_turns:
99
+ return [], list(messages)
100
+
101
+ boundary = len(messages) - self.keep_recent_turns
102
+ while boundary > 0 and not _is_turn_edge(messages, boundary):
103
+ boundary -= 1
104
+
105
+ return list(messages[:boundary]), list(messages[boundary:])
106
+
107
+ async def compact(
108
+ self,
109
+ messages: list[Message],
110
+ instructions: str | None = None,
111
+ ) -> CompactionResult:
112
+ """Summarise via a provider call and assemble the replacement transcript."""
113
+ dropped, kept = self.split(messages)
114
+ if len(dropped) < self.MIN_MESSAGES_TO_COMPACT:
115
+ return CompactionResult(
116
+ summary=_summary_message("(nothing to compact)"),
117
+ kept=list(messages),
118
+ dropped=[],
119
+ tokens_before=self._estimate(messages),
120
+ tokens_after=self._estimate(messages),
121
+ )
122
+
123
+ text = await self._summarise(dropped, instructions)
124
+ summary = _summary_message(text)
125
+ return CompactionResult(
126
+ summary=summary,
127
+ kept=kept,
128
+ dropped=dropped,
129
+ tokens_before=self._estimate(messages),
130
+ tokens_after=self._estimate([summary, *kept]),
131
+ )
132
+
133
+ async def _summarise(self, messages: list[Message], instructions: str | None) -> str:
134
+ if self.provider is None:
135
+ # Without a provider there is nothing to call; fall back to a
136
+ # mechanical digest rather than silently dropping the history.
137
+ return _mechanical_summary(messages)
138
+
139
+ from hx.core.context import AssembledContext, PromptSection
140
+ from hx.providers.base import ProviderRequest, StreamDelta
141
+
142
+ prompt = self.build_summary_prompt(messages, instructions)
143
+ context = AssembledContext(
144
+ system=[PromptSection("system", "You write precise handover summaries.")],
145
+ messages=[Message(role="user", content=[TextBlock(text=prompt)])],
146
+ tools=[],
147
+ )
148
+ request = ProviderRequest(
149
+ context=context,
150
+ model=self.model,
151
+ max_tokens=self.SUMMARY_MAX_TOKENS,
152
+ )
153
+
154
+ parts: list[str] = []
155
+ async for item in self.provider.astream(request):
156
+ if isinstance(item, StreamDelta) and item.text:
157
+ parts.append(item.text)
158
+
159
+ return "".join(parts).strip() or _mechanical_summary(messages)
160
+
161
+ def build_summary_prompt(
162
+ self,
163
+ messages: list[Message],
164
+ instructions: str | None,
165
+ ) -> str:
166
+ """Prompt requesting the sections in :data:`SUMMARY_SECTIONS`."""
167
+ extra = f"\nAlso: {instructions.strip()}\n" if instructions else ""
168
+ return SUMMARY_PROMPT.format(
169
+ sections="\n".join(f"## {section}" for section in SUMMARY_SECTIONS),
170
+ extra=extra,
171
+ transcript=render_transcript(messages),
172
+ )
173
+
174
+ def _estimate(self, messages: list[Message]) -> int:
175
+ if self.context is not None:
176
+ return sum(self.context.estimate_tokens(_message_text(m)) for m in messages)
177
+ return sum(len(_message_text(m)) // 4 for m in messages)
178
+
179
+
180
+ def _is_turn_edge(messages: list[Message], index: int) -> bool:
181
+ """True when a split before ``index`` leaves every tool call with its results."""
182
+ if index <= 0 or index >= len(messages):
183
+ return True
184
+ previous = messages[index - 1]
185
+ current = messages[index]
186
+ if previous.tool_uses():
187
+ return False
188
+ return not any(isinstance(block, ToolResultBlock) for block in current.content)
189
+
190
+
191
+ def render_transcript(messages: list[Message]) -> str:
192
+ """Flatten messages to text for the summary prompt.
193
+
194
+ Rendering to text rather than replaying the structured messages keeps the
195
+ summary call free of tool-call pairing constraints, which a partial history
196
+ would otherwise violate.
197
+ """
198
+ lines: list[str] = []
199
+ for message in messages:
200
+ for block in message.content:
201
+ if isinstance(block, TextBlock):
202
+ lines.append(f"[{message.role}] {block.text}")
203
+ elif isinstance(block, ToolUseBlock):
204
+ lines.append(f"[{message.role}] calls {block.name}({block.input})")
205
+ elif isinstance(block, ToolResultBlock):
206
+ marker = "error" if block.is_error else "result"
207
+ lines.append(f"[tool {marker}] {_clip(block.content)}")
208
+ elif isinstance(block, ThinkingBlock):
209
+ continue
210
+ return "\n".join(lines)
211
+
212
+
213
+ def _mechanical_summary(messages: list[Message]) -> str:
214
+ """Last-resort digest when no model is available to write one."""
215
+ users = [m.text() for m in messages if m.role == "user" and m.text()]
216
+ tools = sorted({b.name for m in messages for b in m.tool_uses()})
217
+ lines = ["## Goal", users[0][:500] if users else "(unknown)", "", "## Current state"]
218
+ lines.append(f"{len(messages)} earlier messages were compacted without a model summary.")
219
+ if tools:
220
+ lines.append(f"Tools used: {', '.join(tools)}.")
221
+ if len(users) > 1:
222
+ lines += ["", "## Open questions", *[f"- {text[:200]}" for text in users[1:6]]]
223
+ return "\n".join(lines)
224
+
225
+
226
+ def _summary_message(text: str) -> Message:
227
+ body = f"{SUMMARY_MARKER}\nThis replaces the earlier conversation.\n\n{text}\n{SUMMARY_END}"
228
+ return Message(
229
+ role="user",
230
+ content=[TextBlock(text=body)],
231
+ metadata={"compaction_summary": True},
232
+ )
233
+
234
+
235
+ def _message_text(message: Message) -> str:
236
+ parts: list[str] = []
237
+ for block in message.content:
238
+ text = getattr(block, "text", None) or getattr(block, "content", None)
239
+ if isinstance(text, str):
240
+ parts.append(text)
241
+ return "\n".join(parts)
242
+
243
+
244
+ def _clip(text: str, limit: int = 2000) -> str:
245
+ return text if len(text) <= limit else text[:limit] + " … [clipped]"