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/core/context.py ADDED
@@ -0,0 +1,271 @@
1
+ """Context assembly and cache-breakpoint placement.
2
+
3
+ This module owns the single most performance-sensitive invariant in HX:
4
+ **the request prefix must be byte-stable across turns.** Providers key their KV
5
+ cache on an exact prefix match, so any reordering, timestamp, or per-turn
6
+ counter placed above a breakpoint silently costs full price on every request.
7
+
8
+ Layout, in order::
9
+
10
+ [1] system prompt static for the session
11
+ [2] tool schemas deterministic sort: builtins, then mcp__* alphabetical
12
+ [3] skills index name + description only (progressive disclosure)
13
+ [4] project context HX.md, cwd, git branch, top-level listing
14
+ --- breakpoint A (static) ---
15
+ [5] conversation history
16
+ --- breakpoint B (rolling, before the last few turns) ---
17
+ [6] latest user turn + late-injected ephemeral blocks
18
+
19
+ Volatile data belongs below breakpoint B, via ``hx.core.lateinject``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import subprocess
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any, ClassVar
30
+
31
+ from hx.core.messages import Message
32
+
33
+ MAX_CACHE_BREAKPOINTS = 4
34
+ """Anthropic's per-request limit on explicit ``cache_control`` markers."""
35
+
36
+
37
+ @dataclass(slots=True)
38
+ class PromptSection:
39
+ """One addressable chunk of the prefix, tracked so ``/context`` can show a breakdown."""
40
+
41
+ name: str
42
+ text: str
43
+ tokens: int = 0
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class AssembledContext:
48
+ """The exact payload handed to a provider, plus the accounting behind it.
49
+
50
+ ``messages`` holds the conversation only. The provider prepends the system
51
+ message when serialising, so ``breakpoints`` index the *payload* list where
52
+ position 0 is that system message.
53
+ """
54
+
55
+ system: list[PromptSection]
56
+ messages: list[Message]
57
+ tools: list[dict[str, Any]]
58
+ breakpoints: tuple[int, ...] = ()
59
+ """Indices into the payload message list that carry an explicit cache
60
+ breakpoint. Empty for implicit-caching models."""
61
+ total_tokens: int = 0
62
+ sections: list[PromptSection] = field(default_factory=list)
63
+
64
+ def system_text(self) -> str:
65
+ """Concatenated system sections, in declared order."""
66
+ return "\n\n".join(section.text for section in self.system if section.text)
67
+
68
+ def prefix_fingerprint(self) -> str:
69
+ """Stable hash of everything above the first breakpoint.
70
+
71
+ Tests assert this is unchanged across turns; a change means the cache
72
+ was invalidated.
73
+ """
74
+ digest = hashlib.sha256()
75
+ digest.update(self.system_text().encode())
76
+ digest.update(b"\x00")
77
+ digest.update(json.dumps(self.tools, sort_keys=True, separators=(",", ":")).encode())
78
+ return digest.hexdigest()
79
+
80
+
81
+ class ContextBuilder:
82
+ """Assembles :class:`AssembledContext` from session state."""
83
+
84
+ #: Estimated tokens that must accumulate below the rolling breakpoint before
85
+ #: it is allowed to move. Advancing it every turn rewrites the cache
86
+ #: constantly and costs more than the hit it buys.
87
+ BREAKPOINT_HYSTERESIS_TOKENS: ClassVar[int] = 4_096
88
+ CHARS_PER_TOKEN: ClassVar[float] = 3.7
89
+
90
+ def __init__(
91
+ self,
92
+ system_prompt: str,
93
+ cwd: Path,
94
+ keep_recent_turns: int = 6,
95
+ ) -> None:
96
+ self.system_prompt = system_prompt
97
+ self.cwd = cwd
98
+ self.keep_recent_turns = keep_recent_turns
99
+ self._breakpoint_b: int | None = None
100
+ self._tokens_below_b: int = 0
101
+
102
+ def build(
103
+ self,
104
+ messages: list[Message],
105
+ tools: list[dict[str, Any]],
106
+ skills_index: str | None = None,
107
+ project_context: str | None = None,
108
+ cache_mode: str = "explicit",
109
+ ) -> AssembledContext:
110
+ """Assemble the request.
111
+
112
+ Args:
113
+ cache_mode: ``explicit`` (place ``cache_control`` markers),
114
+ ``implicit`` (rely on prefix stability alone), or ``none``.
115
+ """
116
+ system = [PromptSection("system", self.system_prompt)]
117
+ if skills_index:
118
+ system.append(PromptSection("skills", skills_index))
119
+ if project_context:
120
+ system.append(PromptSection("project", project_context))
121
+ for section in system:
122
+ section.tokens = self.estimate_tokens(section.text)
123
+
124
+ active = [m for m in messages if not m.compacted]
125
+ sorted_tools = sort_tools(tools)
126
+
127
+ breakpoints = self.place_breakpoints(active) if cache_mode == "explicit" else ()
128
+
129
+ sections = [
130
+ *system,
131
+ PromptSection(
132
+ "tools",
133
+ json.dumps(sorted_tools, sort_keys=True),
134
+ self.estimate_tokens(json.dumps(sorted_tools, sort_keys=True)),
135
+ ),
136
+ PromptSection(
137
+ "history",
138
+ "",
139
+ sum(self.estimate_tokens(_message_text(m)) for m in active),
140
+ ),
141
+ ]
142
+
143
+ return AssembledContext(
144
+ system=system,
145
+ messages=active,
146
+ tools=sorted_tools,
147
+ breakpoints=breakpoints,
148
+ total_tokens=sum(s.tokens for s in sections),
149
+ sections=sections,
150
+ )
151
+
152
+ def place_breakpoints(self, messages: list[Message]) -> tuple[int, ...]:
153
+ """Choose breakpoint indices into the payload list (0 = system message).
154
+
155
+ Breakpoint A sits on the system message, at the end of the static prefix.
156
+ Breakpoint B rolls forward to just before the last ``keep_recent_turns``
157
+ messages, and only moves once enough tokens have accumulated below it -
158
+ see :attr:`BREAKPOINT_HYSTERESIS_TOKENS`.
159
+ """
160
+ breakpoints = [0]
161
+ if len(messages) <= self.keep_recent_turns:
162
+ self._breakpoint_b = None
163
+ return tuple(breakpoints)
164
+
165
+ # +1 converts a conversation index into a payload index.
166
+ candidate = len(messages) - self.keep_recent_turns
167
+ if self._breakpoint_b is None:
168
+ self._breakpoint_b = candidate
169
+ else:
170
+ below = sum(
171
+ self.estimate_tokens(_message_text(m))
172
+ for m in messages[self._breakpoint_b : candidate]
173
+ )
174
+ if below >= self.BREAKPOINT_HYSTERESIS_TOKENS:
175
+ self._breakpoint_b = candidate
176
+
177
+ payload_index = self._breakpoint_b
178
+ if payload_index > 0:
179
+ breakpoints.append(payload_index)
180
+ return tuple(breakpoints[:MAX_CACHE_BREAKPOINTS])
181
+
182
+ def estimate_tokens(self, text: str) -> int:
183
+ """Cheap local token estimate for gauges and the compaction trigger.
184
+
185
+ Deliberately approximate; the authoritative counts come back from the
186
+ provider in ``usage``.
187
+ """
188
+ return int(len(text) / self.CHARS_PER_TOKEN) + 1 if text else 0
189
+
190
+
191
+ def sort_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
192
+ """Canonical tool ordering: builtins first, then ``mcp__*``, each by name.
193
+
194
+ The serialised tool block sits in the cached prefix, so the order must not
195
+ depend on registration order, MCP connection order, or dict iteration.
196
+ """
197
+ return sorted(
198
+ tools, key=lambda t: (str(t.get("name", "")).startswith("mcp__"), t.get("name", ""))
199
+ )
200
+
201
+
202
+ def _message_text(message: Message) -> str:
203
+ parts: list[str] = []
204
+ for block in message.content:
205
+ text = getattr(block, "text", None) or getattr(block, "content", None)
206
+ if isinstance(text, str):
207
+ parts.append(text)
208
+ return "\n".join(parts)
209
+
210
+
211
+ def build_project_context(cwd: Path) -> str:
212
+ """Static per-session project preamble: HX.md contents, cwd, git branch, listing.
213
+
214
+ Computed once at startup and then frozen - refreshing it mid-session would
215
+ invalidate the prefix.
216
+ """
217
+ lines = [f"Working directory: {cwd}"]
218
+
219
+ branch = git_branch(cwd)
220
+ if branch:
221
+ lines.append(f"Git branch: {branch}")
222
+
223
+ entries = sorted(
224
+ p.name + ("/" if p.is_dir() else "") for p in cwd.iterdir() if not p.name.startswith(".")
225
+ )
226
+ if entries:
227
+ lines.append("Top level: " + ", ".join(entries[:60]))
228
+
229
+ for name in ("HX.md", ".hx/HX.md"):
230
+ candidate = cwd / name
231
+ if candidate.is_file():
232
+ lines.append(f"\n# Project instructions ({name})\n\n{candidate.read_text()}")
233
+ break
234
+
235
+ return "\n".join(lines)
236
+
237
+
238
+ def git_branch(cwd: Path) -> str | None:
239
+ try:
240
+ result = subprocess.run(
241
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
242
+ cwd=cwd,
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=2,
246
+ check=False,
247
+ )
248
+ except (OSError, subprocess.SubprocessError):
249
+ return None
250
+ return result.stdout.strip() or None if result.returncode == 0 else None
251
+
252
+
253
+ SYSTEM_PROMPT = """\
254
+ You are HX, an agentic coding assistant running in the user's terminal.
255
+
256
+ Work directly on the user's codebase using the tools available to you. Prefer
257
+ reading the relevant code over guessing at it. Make the change that was asked
258
+ for - do not narrow it, widen it, or substitute a different one.
259
+
260
+ Be concise. The user is reading your output in a terminal, so favour short
261
+ direct answers and code over prose. Reference files as `path/to/file.py:42`.
262
+
263
+ When a task takes several steps, track them so the user can see the plan and
264
+ what remains. Report what actually happened: if a command failed, say so and
265
+ show the output.
266
+ """
267
+
268
+
269
+ def load_system_prompt(cwd: Path) -> str:
270
+ """The base HX system prompt. Contains no volatile values (no clock, no counters)."""
271
+ return SYSTEM_PROMPT
hx/core/events.py ADDED
@@ -0,0 +1,183 @@
1
+ """Async event bus.
2
+
3
+ The agent core is a producer; the TUI and ``hx -p`` print mode are consumers.
4
+ Keeping this one-way means the core never imports Textual and can be driven
5
+ head-lessly in tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import AsyncIterator
12
+ from contextlib import suppress
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, ClassVar
15
+
16
+
17
+ @dataclass(slots=True)
18
+ class Event:
19
+ """Base class for everything on the bus."""
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class TurnStarted(Event):
24
+ turn_index: int
25
+ model: str
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class TextDelta(Event):
30
+ """Streaming assistant text."""
31
+
32
+ text: str
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class ThinkingDelta(Event):
37
+ text: str
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class ToolCallStarted(Event):
42
+ tool_use_id: str
43
+ name: str
44
+ input: dict[str, Any]
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class ToolCallProgress(Event):
49
+ """Incremental output (e.g. Bash stdout) shown live in the TUI, uncapped."""
50
+
51
+ tool_use_id: str
52
+ chunk: str
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class ToolCallFinished(Event):
57
+ tool_use_id: str
58
+ is_error: bool
59
+ duration_ms: float
60
+ summary: str
61
+ metadata: dict[str, Any] = field(default_factory=dict)
62
+ """Structured detail the renderer draws with - an edit's diff, a command's
63
+ exit code. Presentation only: the model never sees it."""
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class PermissionRequested(Event):
68
+ """Core is blocked until a consumer answers via the paired future."""
69
+
70
+ request_id: str
71
+ tool_name: str
72
+ description: str
73
+ detail: str
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class UsageUpdated(Event):
78
+ input_tokens: int
79
+ output_tokens: int
80
+ cache_read_tokens: int
81
+ cache_write_tokens: int
82
+ context_tokens: int
83
+ context_window: int
84
+ cost_usd: float
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class TodosUpdated(Event):
89
+ todos: list[dict[str, Any]] = field(default_factory=list)
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class SubagentStarted(Event):
94
+ subagent_id: str
95
+ agent_type: str
96
+ description: str
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class SubagentFinished(Event):
101
+ subagent_id: str
102
+ is_error: bool
103
+
104
+
105
+ @dataclass(slots=True)
106
+ class CompactionStarted(Event):
107
+ reason: str
108
+
109
+
110
+ @dataclass(slots=True)
111
+ class CompactionFinished(Event):
112
+ tokens_before: int
113
+ tokens_after: int
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class TurnFinished(Event):
118
+ turn_index: int
119
+ stop_reason: str
120
+
121
+
122
+ @dataclass(slots=True)
123
+ class ErrorRaised(Event):
124
+ message: str
125
+ recoverable: bool = True
126
+
127
+
128
+ class EventBus:
129
+ """Fan-out pub/sub over ``asyncio.Queue``.
130
+
131
+ Slow consumers must not stall the agent loop: each subscriber gets its own
132
+ bounded queue and drops coalescible deltas rather than applying backpressure.
133
+ """
134
+
135
+ #: Event types safe to drop when a subscriber falls behind. Losing a text
136
+ #: delta degrades the render; losing a ToolCallFinished corrupts it.
137
+ COALESCIBLE: ClassVar[tuple[type[Event], ...]] = (
138
+ TextDelta,
139
+ ThinkingDelta,
140
+ ToolCallProgress,
141
+ UsageUpdated,
142
+ )
143
+
144
+ def __init__(self, max_queue: int = 1024) -> None:
145
+ self._max_queue = max_queue
146
+ self._queues: list[asyncio.Queue[Event | None]] = []
147
+ self._closed = False
148
+
149
+ def publish(self, event: Event) -> None:
150
+ """Non-blocking. Safe to call from inside the agent loop."""
151
+ if self._closed:
152
+ return
153
+ for queue in self._queues:
154
+ try:
155
+ queue.put_nowait(event)
156
+ except asyncio.QueueFull:
157
+ if isinstance(event, self.COALESCIBLE):
158
+ continue
159
+ # Structural events must survive: evict the oldest item instead.
160
+ with suppress(asyncio.QueueEmpty):
161
+ queue.get_nowait()
162
+ with suppress(asyncio.QueueFull):
163
+ queue.put_nowait(event)
164
+
165
+ async def subscribe(self) -> AsyncIterator[Event]:
166
+ """Yield events until :meth:`close` is called."""
167
+ queue: asyncio.Queue[Event | None] = asyncio.Queue(maxsize=self._max_queue)
168
+ self._queues.append(queue)
169
+ try:
170
+ while True:
171
+ event = await queue.get()
172
+ if event is None:
173
+ return
174
+ yield event
175
+ finally:
176
+ if queue in self._queues:
177
+ self._queues.remove(queue)
178
+
179
+ def close(self) -> None:
180
+ self._closed = True
181
+ for queue in self._queues:
182
+ with suppress(asyncio.QueueFull):
183
+ queue.put_nowait(None)
hx/core/lateinject.py ADDED
@@ -0,0 +1,121 @@
1
+ """Late injection of per-turn context.
2
+
3
+ Anything that changes every turn - todo state, files modified since last read,
4
+ plan-mode notices, permission-mode changes - must NOT live in the system prompt.
5
+ Mutating the prefix invalidates the provider KV cache on every single turn,
6
+ which is the single most expensive mistake this harness can make.
7
+
8
+ Instead, injectors emit ``<hx-reminder>`` blocks that are appended to the tail
9
+ of the newest user message and flagged ``ephemeral=True``. Before each turn the
10
+ previous ephemeral blocks are stripped and regenerated. Nothing above the
11
+ rolling cache breakpoint is ever rewritten.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass, replace
19
+
20
+ from hx.core.messages import ContentBlock, Message, TextBlock
21
+
22
+ REMINDER_OPEN = "<hx-reminder>"
23
+ REMINDER_CLOSE = "</hx-reminder>"
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class Injection:
28
+ """One block of late-injected context."""
29
+
30
+ source: str
31
+ """Injector name, for debugging via ``/context``."""
32
+ text: str
33
+ priority: int = 0
34
+ """Higher priority renders closer to the end of the message."""
35
+
36
+
37
+ Injector = Callable[[], Injection | None]
38
+ """Returns the current injection, or ``None`` to contribute nothing this turn."""
39
+
40
+
41
+ class InjectionRegistry:
42
+ """Holds the active injectors and applies them to a message list."""
43
+
44
+ def __init__(self) -> None:
45
+ self._injectors: dict[str, Injector] = {}
46
+
47
+ def register(self, name: str, injector: Injector) -> None:
48
+ self._injectors[name] = injector
49
+
50
+ def unregister(self, name: str) -> None:
51
+ self._injectors.pop(name, None)
52
+
53
+ def collect(self) -> list[Injection]:
54
+ """Run every injector, drop ``None`` results, sort by priority then name
55
+ so output is deterministic (a reordered prefix is a cache miss)."""
56
+ collected: list[Injection] = []
57
+ for name in sorted(self._injectors):
58
+ try:
59
+ injection = self._injectors[name]()
60
+ except Exception:
61
+ # A broken injector must not kill the turn.
62
+ continue
63
+ if injection is not None and injection.text.strip():
64
+ collected.append(injection)
65
+ return sorted(collected, key=lambda i: (i.priority, i.source))
66
+
67
+ def apply(self, messages: list[Message]) -> list[Message]:
68
+ """Return a copy of ``messages`` with stale ephemeral content stripped and
69
+ fresh injections appended to the final user message.
70
+
71
+ The input list is never mutated: the caller keeps the clean transcript
72
+ and only the assembled request carries injections.
73
+ """
74
+ cleaned = [strip_injections(m) if m.role == "user" else m for m in messages]
75
+
76
+ injections = self.collect()
77
+ if not injections:
78
+ return cleaned
79
+
80
+ block = render(injections)
81
+ tail = cleaned[-1] if cleaned else None
82
+ if tail is not None and tail.role == "user":
83
+ content = [*tail.content, TextBlock(text="\n\n" + block)]
84
+ cleaned[-1] = replace(tail, content=content, ephemeral=True)
85
+ else:
86
+ # No trailing user turn to ride on - carry the reminders on their own
87
+ # ephemeral message rather than touching anything already cached.
88
+ cleaned.append(Message(role="user", content=[TextBlock(text=block)], ephemeral=True))
89
+ return cleaned
90
+
91
+
92
+ def strip_injections(message: Message) -> Message:
93
+ """Remove ``<hx-reminder>`` blocks from a message's text content."""
94
+ if REMINDER_OPEN not in _text_of(message):
95
+ return message
96
+
97
+ content: list[ContentBlock] = []
98
+ for block in message.content:
99
+ if isinstance(block, TextBlock):
100
+ stripped = _REMINDER_RE.sub("", block.text).rstrip()
101
+ if stripped:
102
+ content.append(TextBlock(text=stripped))
103
+ else:
104
+ content.append(block)
105
+ return replace(message, content=content, ephemeral=False)
106
+
107
+
108
+ def render(injections: list[Injection]) -> str:
109
+ """Render injections into a single reminder-wrapped string."""
110
+ body = "\n\n".join(i.text.strip() for i in injections)
111
+ return f"{REMINDER_OPEN}\n{body}\n{REMINDER_CLOSE}"
112
+
113
+
114
+ def _text_of(message: Message) -> str:
115
+ return "".join(b.text for b in message.content if isinstance(b, TextBlock))
116
+
117
+
118
+ _REMINDER_RE = re.compile(
119
+ re.escape(REMINDER_OPEN) + r".*?" + re.escape(REMINDER_CLOSE),
120
+ re.DOTALL,
121
+ )