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/session.py ADDED
@@ -0,0 +1,208 @@
1
+ """Session persistence: append-only JSONL transcripts.
2
+
3
+ Every message, tool result, usage record and compaction boundary is appended as
4
+ it happens, so a crashed session is fully resumable and a compaction can be
5
+ inspected or undone after the fact.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ import uuid
13
+ from dataclasses import asdict, dataclass, field, replace
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from hx.core.messages import Message, from_dict, to_dict
18
+ from hx.core.usage import TurnUsage, UsageLedger
19
+ from hx.paths import (
20
+ session_dir,
21
+ session_outputs_dir,
22
+ session_transcript_file,
23
+ sessions_dir,
24
+ )
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class SessionMeta:
29
+ session_id: str
30
+ cwd: str
31
+ created_at: float
32
+ updated_at: float
33
+ model: str
34
+ title: str | None = None
35
+ message_count: int = 0
36
+ parent_id: str | None = None
37
+ """Set for subagent sessions, which nest under their parent's directory."""
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class Session:
42
+ """In-memory session state, mirrored to ``~/.hx/sessions/<id>/transcript.jsonl``."""
43
+
44
+ meta: SessionMeta
45
+ messages: list[Message] = field(default_factory=list)
46
+ usage: UsageLedger = field(default_factory=UsageLedger)
47
+ _pending: list[dict[str, Any]] = field(default_factory=list, repr=False)
48
+
49
+ def append(self, message: Message) -> None:
50
+ """Add to memory and flush the JSONL line. Ephemeral messages are recorded
51
+ but marked so resume does not replay stale reminders."""
52
+ self.messages.append(message)
53
+ self.meta.message_count = len(self.messages)
54
+ self.meta.updated_at = time.time()
55
+ self._pending.append({"kind": "message", "data": to_dict(message)})
56
+ self.flush()
57
+
58
+ def record_compaction(
59
+ self,
60
+ dropped: list[Message],
61
+ kept: list[Message],
62
+ summary: Message,
63
+ ) -> None:
64
+ """Retire the pre-compaction history and re-lay it as summary + kept.
65
+
66
+ The transcript is append-only, so the summary cannot be spliced in
67
+ ahead of the messages it replaces. Instead every live message is
68
+ flagged compacted and the new sequence is appended in order. Nothing is
69
+ deleted, so a resume replays exactly what happened and a future undo
70
+ still has the original turns to restore.
71
+ """
72
+ superseded = [*dropped, *kept]
73
+ for message in superseded:
74
+ message.compacted = True
75
+
76
+ self._pending.append({"kind": "compaction", "data": {"count": len(superseded)}})
77
+ self.append(summary)
78
+ for message in kept:
79
+ self.append(replace(message, compacted=False))
80
+
81
+ def record_usage(self, usage: TurnUsage) -> None:
82
+ self.usage.record(usage)
83
+ self._pending.append({"kind": "usage", "data": asdict(usage)})
84
+
85
+ def active_messages(self) -> list[Message]:
86
+ """Messages eligible for context: not ``compacted``, not ``ephemeral``."""
87
+ return [m for m in self.messages if not m.compacted and not m.ephemeral]
88
+
89
+ def flush(self) -> None:
90
+ if not self._pending:
91
+ return
92
+ path = session_transcript_file(self.meta.session_id)
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ with path.open("a", encoding="utf-8") as handle:
95
+ for record in self._pending:
96
+ handle.write(json.dumps(record) + "\n")
97
+ self._pending.clear()
98
+ self._write_meta()
99
+
100
+ def _write_meta(self) -> None:
101
+ path = session_dir(self.meta.session_id) / "meta.json"
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ path.write_text(json.dumps(asdict(self.meta), indent=2))
104
+
105
+
106
+ def new_session(cwd: Path, model: str, parent_id: str | None = None) -> Session:
107
+ """Create a session with a fresh id and its on-disk directory.
108
+
109
+ A subagent session nests under its parent (``<parent>/sub-<id>``), which
110
+ keeps its transcript alongside the work it belongs to and keeps it out of
111
+ the ``/resume`` listing, where it would be noise.
112
+ """
113
+ now = time.time()
114
+ session_id = f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(now))}-{uuid.uuid4().hex[:8]}"
115
+ if parent_id:
116
+ if ".." in parent_id or parent_id.startswith("/"):
117
+ raise ValueError(f"unsafe parent session id: {parent_id!r}")
118
+ session_id = f"{parent_id}/sub-{uuid.uuid4().hex[:8]}"
119
+
120
+ meta = SessionMeta(
121
+ session_id=session_id,
122
+ cwd=str(cwd.resolve()),
123
+ created_at=now,
124
+ updated_at=now,
125
+ model=model,
126
+ parent_id=parent_id,
127
+ )
128
+ session_outputs_dir(session_id).mkdir(parents=True, exist_ok=True)
129
+ session = Session(meta=meta)
130
+ session._write_meta()
131
+ return session
132
+
133
+
134
+ def load_session(session_id: str) -> Session:
135
+ """Rehydrate from JSONL.
136
+
137
+ Raises:
138
+ SessionNotFound: if the id has no transcript.
139
+ """
140
+ path = session_transcript_file(session_id)
141
+ meta_path = session_dir(session_id) / "meta.json"
142
+ if not meta_path.exists():
143
+ raise SessionNotFound(session_id)
144
+
145
+ meta = SessionMeta(**json.loads(meta_path.read_text()))
146
+ session = Session(meta=meta)
147
+ if not path.exists():
148
+ return session
149
+
150
+ with path.open(encoding="utf-8") as handle:
151
+ for line in handle:
152
+ line = line.strip()
153
+ if not line:
154
+ continue
155
+ try:
156
+ record = json.loads(line)
157
+ except json.JSONDecodeError:
158
+ # A torn final line from a crashed write costs one record, not the session.
159
+ continue
160
+ kind = record.get("kind")
161
+ if kind == "message":
162
+ session.messages.append(from_dict(record["data"]))
163
+ elif kind == "usage":
164
+ session.usage.record(TurnUsage(**record["data"]))
165
+ elif kind == "compaction":
166
+ # Compaction always supersedes a prefix of the live messages,
167
+ # so replaying in order reproduces the same partition.
168
+ remaining = int(record["data"]["count"])
169
+ for message in session.messages:
170
+ if remaining <= 0:
171
+ break
172
+ if not message.compacted:
173
+ message.compacted = True
174
+ remaining -= 1
175
+ session.meta.message_count = len(session.messages)
176
+ return session
177
+
178
+
179
+ def list_sessions(cwd: Path | None = None, limit: int = 20) -> list[SessionMeta]:
180
+ """Most-recent-first. Filtered to ``cwd`` when given - powers ``/resume``."""
181
+ root = sessions_dir()
182
+ if not root.is_dir():
183
+ return []
184
+
185
+ metas: list[SessionMeta] = []
186
+ for entry in root.iterdir():
187
+ meta_path = entry / "meta.json"
188
+ if not meta_path.is_file():
189
+ continue
190
+ try:
191
+ meta = SessionMeta(**json.loads(meta_path.read_text()))
192
+ except (OSError, json.JSONDecodeError, TypeError):
193
+ continue
194
+ if cwd is not None and meta.cwd != str(cwd.resolve()):
195
+ continue
196
+ metas.append(meta)
197
+
198
+ metas.sort(key=lambda m: m.updated_at, reverse=True)
199
+ return metas[:limit]
200
+
201
+
202
+ def latest_session(cwd: Path) -> SessionMeta | None:
203
+ found = list_sessions(cwd, limit=1)
204
+ return found[0] if found else None
205
+
206
+
207
+ class SessionNotFound(Exception):
208
+ pass
hx/core/usage.py ADDED
@@ -0,0 +1,129 @@
1
+ """Token and cost ledger.
2
+
3
+ Feeds the status bar fields: context used, tokens used, cache-read/cache-write
4
+ tokens, and session cost. OpenRouter returns an authoritative ``usage.cost``
5
+ when ``usage: {include: true}`` is set on the request; we prefer that over our
6
+ own arithmetic and only compute locally as a fallback.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING: # avoids a providers -> core import cycle at runtime
15
+ from hx.providers.models import ModelPricing
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class TurnUsage:
20
+ """Usage for a single provider call."""
21
+
22
+ input_tokens: int = 0
23
+ """Prompt tokens billed at the full rate. Excludes ``cache_read_tokens``."""
24
+ output_tokens: int = 0
25
+ cache_read_tokens: int = 0
26
+ """Prompt tokens served from the provider's KV cache (billed at a discount)."""
27
+ cache_write_tokens: int = 0
28
+ """Prompt tokens written into the cache (billed at a premium on Anthropic)."""
29
+ reasoning_tokens: int = 0
30
+ cost_usd: float | None = None
31
+ """Provider-reported cost. ``None`` means fall back to local pricing."""
32
+ latency_ms: float = 0.0
33
+
34
+ @property
35
+ def prompt_tokens(self) -> int:
36
+ """Every prompt token, cached or not."""
37
+ return self.input_tokens + self.cache_read_tokens
38
+
39
+ @property
40
+ def cache_hit_rate(self) -> float:
41
+ """Cached share of prompt tokens, 0.0-1.0."""
42
+ total = self.prompt_tokens
43
+ return self.cache_read_tokens / total if total else 0.0
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class UsageLedger:
48
+ """Running totals for a session."""
49
+
50
+ turns: list[TurnUsage] = field(default_factory=list)
51
+ context_tokens: int = 0
52
+ """Tokens currently occupied by the assembled context."""
53
+ context_window: int = 0
54
+
55
+ def record(self, usage: TurnUsage) -> None:
56
+ self.turns.append(usage)
57
+
58
+ @property
59
+ def total_input(self) -> int:
60
+ return sum(t.input_tokens for t in self.turns)
61
+
62
+ @property
63
+ def total_output(self) -> int:
64
+ return sum(t.output_tokens for t in self.turns)
65
+
66
+ @property
67
+ def total_cache_read(self) -> int:
68
+ return sum(t.cache_read_tokens for t in self.turns)
69
+
70
+ @property
71
+ def total_cache_write(self) -> int:
72
+ return sum(t.cache_write_tokens for t in self.turns)
73
+
74
+ @property
75
+ def total_cost_usd(self) -> float:
76
+ return sum(t.cost_usd or 0.0 for t in self.turns)
77
+
78
+ @property
79
+ def cache_hit_rate(self) -> float:
80
+ prompt = self.total_input + self.total_cache_read
81
+ return self.total_cache_read / prompt if prompt else 0.0
82
+
83
+ @property
84
+ def last_latency_ms(self) -> float:
85
+ return self.turns[-1].latency_ms if self.turns else 0.0
86
+
87
+ @property
88
+ def context_fraction(self) -> float:
89
+ """``context_tokens / context_window``, clamped to 0.0-1.0. Drives the
90
+ compaction trigger and the status bar gauge."""
91
+ if self.context_window <= 0:
92
+ return 0.0
93
+ return min(1.0, max(0.0, self.context_tokens / self.context_window))
94
+
95
+
96
+ def compute_cost(usage: TurnUsage, pricing: ModelPricing) -> float:
97
+ """Local cost fallback when the provider reports none.
98
+
99
+ Cache reads and writes are priced separately from ordinary input tokens, and
100
+ ``input_tokens`` deliberately *excludes* cached tokens - double-counting here
101
+ silently inflates every cost display.
102
+ """
103
+ cache_read_rate = pricing.cache_read or pricing.prompt
104
+ cache_write_rate = pricing.cache_write or pricing.prompt
105
+ return (
106
+ usage.input_tokens * pricing.prompt
107
+ + usage.output_tokens * pricing.completion
108
+ + usage.cache_read_tokens * cache_read_rate
109
+ + usage.cache_write_tokens * cache_write_rate
110
+ + usage.reasoning_tokens * pricing.reasoning
111
+ )
112
+
113
+
114
+ def format_cost(cost_usd: float) -> str:
115
+ """Human-readable cost for the status bar (e.g. ``$0.0431``)."""
116
+ if cost_usd >= 100:
117
+ return f"${cost_usd:,.0f}"
118
+ if cost_usd >= 1:
119
+ return f"${cost_usd:.2f}"
120
+ return f"${cost_usd:.4f}"
121
+
122
+
123
+ def format_tokens(count: int) -> str:
124
+ """Compact token count (e.g. ``12.4k``, ``1.2M``)."""
125
+ if count < 1_000:
126
+ return str(count)
127
+ if count < 1_000_000:
128
+ return f"{count / 1_000:.1f}k".replace(".0k", "k")
129
+ return f"{count / 1_000_000:.1f}M".replace(".0M", "M")
hx/frontmatter.py ADDED
@@ -0,0 +1,80 @@
1
+ """YAML frontmatter parsing, shared by skills and agent definitions.
2
+
3
+ Both are Markdown files whose first block is ``---``-delimited YAML. Keeping
4
+ one parser means a malformed file fails the same way in both places.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ DELIMITER = "---"
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class Document:
20
+ metadata: dict[str, Any]
21
+ body: str
22
+ path: Path
23
+
24
+
25
+ def parse(text: str, path: Path) -> Document:
26
+ """Split frontmatter from body.
27
+
28
+ Raises:
29
+ FrontmatterError: when the block is unterminated or is not a mapping.
30
+ A file that silently parses as "no metadata" would be discovered
31
+ and then quietly ignored, which is harder to debug than a failure.
32
+ """
33
+ stripped = text.lstrip("")
34
+ if not stripped.startswith(DELIMITER):
35
+ return Document(metadata={}, body=stripped.strip(), path=path)
36
+
37
+ lines = stripped.splitlines()
38
+ closing = next(
39
+ (i for i, line in enumerate(lines[1:], start=1) if line.strip() == DELIMITER), None
40
+ )
41
+ if closing is None:
42
+ raise FrontmatterError(f"{path}: frontmatter opened with --- but never closed")
43
+
44
+ try:
45
+ metadata = yaml.safe_load("\n".join(lines[1:closing])) or {}
46
+ except yaml.YAMLError as exc:
47
+ raise FrontmatterError(f"{path}: invalid YAML frontmatter ({exc})") from exc
48
+
49
+ if not isinstance(metadata, dict):
50
+ raise FrontmatterError(f"{path}: frontmatter must be a mapping")
51
+
52
+ return Document(metadata=metadata, body="\n".join(lines[closing + 1 :]).strip(), path=path)
53
+
54
+
55
+ def read(path: Path) -> Document:
56
+ try:
57
+ return parse(path.read_text(encoding="utf-8"), path)
58
+ except OSError as exc:
59
+ raise FrontmatterError(f"{path}: {exc}") from exc
60
+
61
+
62
+ def require(document: Document, *keys: str) -> None:
63
+ missing = [key for key in keys if not str(document.metadata.get(key, "")).strip()]
64
+ if missing:
65
+ raise FrontmatterError(f"{document.path}: missing required field(s): {', '.join(missing)}")
66
+
67
+
68
+ def string_tuple(value: Any) -> tuple[str, ...]:
69
+ """Accept either a YAML list or a comma-separated string."""
70
+ if value is None:
71
+ return ()
72
+ if isinstance(value, str):
73
+ return tuple(part.strip() for part in value.split(",") if part.strip())
74
+ if isinstance(value, list):
75
+ return tuple(str(item).strip() for item in value if str(item).strip())
76
+ return ()
77
+
78
+
79
+ class FrontmatterError(Exception):
80
+ pass
hx/mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Model Context Protocol client: external tool servers."""