chyrd 0.4.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 (73) hide show
  1. chyrd/__init__.py +6 -0
  2. chyrd/__main__.py +7 -0
  3. chyrd/_buildflag.py +2 -0
  4. chyrd/_hardening.py +111 -0
  5. chyrd/agent/__init__.py +6 -0
  6. chyrd/agent/compact.py +184 -0
  7. chyrd/agent/engine.py +760 -0
  8. chyrd/agent/events.py +89 -0
  9. chyrd/agent/prompts.py +245 -0
  10. chyrd/agent/subagents.py +351 -0
  11. chyrd/checkpoints.py +185 -0
  12. chyrd/cli.py +758 -0
  13. chyrd/config.py +136 -0
  14. chyrd/license.py +148 -0
  15. chyrd/mcp.py +245 -0
  16. chyrd/permissions.py +89 -0
  17. chyrd/providers/__init__.py +296 -0
  18. chyrd/providers/anthropic.py +317 -0
  19. chyrd/providers/base.py +179 -0
  20. chyrd/providers/catalog.py +1250 -0
  21. chyrd/providers/claude_agent.py +376 -0
  22. chyrd/providers/google.py +315 -0
  23. chyrd/providers/openai_compat.py +855 -0
  24. chyrd/py.typed +0 -0
  25. chyrd/session/__init__.py +6 -0
  26. chyrd/session/store.py +281 -0
  27. chyrd/skills/__init__.py +19 -0
  28. chyrd/skills/builtin/3d-model/SKILL.md +205 -0
  29. chyrd/skills/builtin/ai-app/SKILL.md +172 -0
  30. chyrd/skills/builtin/commit/SKILL.md +95 -0
  31. chyrd/skills/builtin/data-viz/SKILL.md +120 -0
  32. chyrd/skills/builtin/debug/SKILL.md +101 -0
  33. chyrd/skills/builtin/deploy/SKILL.md +144 -0
  34. chyrd/skills/builtin/docs/SKILL.md +122 -0
  35. chyrd/skills/builtin/game-dev/SKILL.md +145 -0
  36. chyrd/skills/builtin/image-gen/SKILL.md +109 -0
  37. chyrd/skills/builtin/init/SKILL.md +81 -0
  38. chyrd/skills/builtin/refactor/SKILL.md +93 -0
  39. chyrd/skills/builtin/review/SKILL.md +85 -0
  40. chyrd/skills/builtin/security/SKILL.md +108 -0
  41. chyrd/skills/builtin/test/SKILL.md +127 -0
  42. chyrd/skills/builtin/website/SKILL.md +114 -0
  43. chyrd/skills/loader.py +209 -0
  44. chyrd/tools/__init__.py +78 -0
  45. chyrd/tools/base.py +79 -0
  46. chyrd/tools/files.py +277 -0
  47. chyrd/tools/fs.py +573 -0
  48. chyrd/tools/search.py +294 -0
  49. chyrd/tools/shell.py +188 -0
  50. chyrd/tools/skill.py +89 -0
  51. chyrd/tools/task.py +111 -0
  52. chyrd/tools/todo.py +110 -0
  53. chyrd/tools/web.py +383 -0
  54. chyrd/tui/__init__.py +18 -0
  55. chyrd/tui/app.py +1830 -0
  56. chyrd/tui/chyrd.tcss +533 -0
  57. chyrd/tui/mascot.py +302 -0
  58. chyrd/tui/theme.py +202 -0
  59. chyrd/tui/widgets/__init__.py +35 -0
  60. chyrd/tui/widgets/chat.py +486 -0
  61. chyrd/tui/widgets/composer.py +236 -0
  62. chyrd/tui/widgets/header.py +95 -0
  63. chyrd/tui/widgets/modelpicker.py +232 -0
  64. chyrd/tui/widgets/palette_cmds.py +89 -0
  65. chyrd/tui/widgets/permission.py +110 -0
  66. chyrd/tui/widgets/splash.py +165 -0
  67. chyrd/tui/widgets/statusbar.py +183 -0
  68. chyrd-0.4.0.dist-info/METADATA +352 -0
  69. chyrd-0.4.0.dist-info/RECORD +73 -0
  70. chyrd-0.4.0.dist-info/WHEEL +5 -0
  71. chyrd-0.4.0.dist-info/entry_points.txt +2 -0
  72. chyrd-0.4.0.dist-info/licenses/LICENSE +21 -0
  73. chyrd-0.4.0.dist-info/top_level.txt +1 -0
chyrd/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """CHYRD - the bird-fast AI coding agent that lives in your terminal."""
2
+
3
+ __version__ = "0.4.0"
4
+
5
+ APP_NAME = "CHYRD"
6
+ TAGLINE = "fly through code."
chyrd/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Enable `python -m chyrd` to run the CLI."""
2
+ from __future__ import annotations
3
+
4
+ from chyrd.cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
chyrd/_buildflag.py ADDED
@@ -0,0 +1,2 @@
1
+ """Generated at build time by build_hardened.py - do not commit."""
2
+ HARDENED = True
chyrd/_hardening.py ADDED
@@ -0,0 +1,111 @@
1
+ """Runtime self-protection for hardened CHYRD release builds.
2
+
3
+ This module is INERT by default. It only does anything when the binary was
4
+ produced by the hardened build pipeline, which bakes in CHYRD_HARDENED=1 (and
5
+ optionally a signed license). That keeps development, tests, and source runs
6
+ completely unaffected - none of the anti-tamper logic fires from `python -m`.
7
+
8
+ The protections here raise the cost of reverse engineering and tampering; they
9
+ do NOT make it impossible (nothing local can - see build/HARDENING.md). The
10
+ genuinely un-bypassable controls are server-side (license activation, gated
11
+ features), which `license.py` handles.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ import time
18
+
19
+ def _baked_flag() -> bool:
20
+ """True if the hardened build wrote chyrd/_buildflag.py with HARDENED=True.
21
+
22
+ The build pipeline generates that module just before compiling and removes
23
+ it after, so it never exists in the source tree or normal installs.
24
+ """
25
+ try:
26
+ from chyrd import _buildflag # type: ignore
27
+ return bool(getattr(_buildflag, "HARDENED", False))
28
+ except Exception: # noqa: BLE001 - absent in source/dev/test
29
+ return False
30
+
31
+
32
+ # Active only in a hardened release build (baked flag) or when explicitly asked
33
+ # via env. Source, dev, and test runs leave this False so nothing below fires.
34
+ HARDENED = _baked_flag() or os.environ.get("CHYRD_HARDENED", "") == "1"
35
+
36
+
37
+ def _is_debugger_present() -> bool:
38
+ """Best-effort debugger detection across platforms. Never raises."""
39
+ # A Python-level tracer (pdb, profilers, some debuggers) is the cheapest tell.
40
+ try:
41
+ if sys.gettrace() is not None:
42
+ return True
43
+ except Exception: # noqa: BLE001
44
+ pass
45
+
46
+ if sys.platform == "win32":
47
+ try:
48
+ import ctypes
49
+
50
+ kernel32 = ctypes.windll.kernel32
51
+ if kernel32.IsDebuggerPresent():
52
+ return True
53
+ # CheckRemoteDebuggerPresent catches debuggers attached out-of-proc.
54
+ present = ctypes.c_int(0)
55
+ kernel32.CheckRemoteDebuggerPresent(
56
+ kernel32.GetCurrentProcess(), ctypes.byref(present)
57
+ )
58
+ if present.value:
59
+ return True
60
+ except Exception: # noqa: BLE001
61
+ pass
62
+ else:
63
+ # On Linux, a tracer shows up as a non-zero TracerPid in /proc/self/status.
64
+ try:
65
+ with open("/proc/self/status", "r", encoding="ascii", errors="ignore") as fh:
66
+ for line in fh:
67
+ if line.startswith("TracerPid:"):
68
+ if int(line.split(":", 1)[1].strip() or "0") != 0:
69
+ return True
70
+ break
71
+ except Exception: # noqa: BLE001
72
+ pass
73
+ return False
74
+
75
+
76
+ def _timing_anomaly() -> bool:
77
+ """A single-stepping debugger inflates the time for a trivial loop."""
78
+ try:
79
+ start = time.perf_counter()
80
+ acc = 0
81
+ for i in range(100_000):
82
+ acc += i
83
+ elapsed = time.perf_counter() - start
84
+ # A no-op 100k loop is sub-millisecond natively; >250ms means something
85
+ # (a step debugger or instrumentation) is watching every instruction.
86
+ return elapsed > 0.25
87
+ except Exception: # noqa: BLE001
88
+ return False
89
+
90
+
91
+ def guard(*, strict: bool = False) -> None:
92
+ """Run protections if this is a hardened build. No-op otherwise.
93
+
94
+ strict=True exits the process on detection (used at the binary's entry
95
+ point). strict=False (the default) only sets a flag other code can read,
96
+ so a partial detection never hard-crashes a legitimate user.
97
+ """
98
+ if not HARDENED:
99
+ return
100
+ detected = False
101
+ try:
102
+ detected = _is_debugger_present() or _timing_anomaly()
103
+ except Exception: # noqa: BLE001 - protection must never crash normal use
104
+ detected = False
105
+ if detected and strict:
106
+ # Exit quietly; do not reveal why (less signal for an attacker).
107
+ os._exit(1)
108
+
109
+
110
+ def is_hardened() -> bool:
111
+ return HARDENED
@@ -0,0 +1,6 @@
1
+ """Agent engine package: the loop, prompts, subagents, and compaction."""
2
+ from __future__ import annotations
3
+
4
+ from chyrd.agent.engine import AgentEngine
5
+
6
+ __all__ = ["AgentEngine"]
chyrd/agent/compact.py ADDED
@@ -0,0 +1,184 @@
1
+ """Context compaction: fold an over-long conversation into a summary.
2
+
3
+ When the running conversation approaches the model's context window, the engine
4
+ calls :func:`compact` to replace the older messages with a single summary
5
+ ``user`` Message produced by one provider call, keeping the most recent turns
6
+ verbatim. If summarisation fails for any reason we fall back to a plain
7
+ truncation so the loop can always continue.
8
+
9
+ ``estimate_tokens`` is the chars / 4 heuristic the blueprint specifies; it
10
+ counts every textual part (including tool results) so the estimate tracks the
11
+ real prompt size, not just assistant text.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from chyrd.providers.base import (
16
+ Message,
17
+ StreamError,
18
+ TextDelta,
19
+ TextPart,
20
+ ThinkingPart,
21
+ ToolCallPart,
22
+ ToolResultPart,
23
+ )
24
+
25
+ # How many of the most recent messages are always kept verbatim.
26
+ DEFAULT_KEEP_LAST = 6
27
+
28
+ _SUMMARY_PROMPT = (
29
+ "Summarize this conversation so work can continue seamlessly. Capture: the "
30
+ "user's goal and any explicit requirements or constraints; key decisions and "
31
+ "their rationale; files and symbols that were read, written, or edited (with "
32
+ "paths); commands that were run and their outcomes; what is still in progress "
33
+ "and the immediate next step. Be specific and dense - this summary REPLACES "
34
+ "the earlier transcript, so omit nothing load-bearing. Do not add commentary, "
35
+ "apologies, or a preamble; output only the summary."
36
+ )
37
+
38
+
39
+ def _part_text(part: object) -> str:
40
+ """Best-effort textual length contribution of a single Part."""
41
+ if isinstance(part, (TextPart, ThinkingPart)):
42
+ return part.text or ""
43
+ if isinstance(part, ToolResultPart):
44
+ return part.content or ""
45
+ if isinstance(part, ToolCallPart):
46
+ # name + a rough rendering of the arguments
47
+ try:
48
+ return part.name + " " + repr(part.arguments)
49
+ except Exception: # noqa: BLE001 - never let estimation raise
50
+ return part.name
51
+ return ""
52
+
53
+
54
+ def estimate_tokens(messages: list[Message]) -> int:
55
+ """Estimate prompt tokens via the chars // 4 heuristic.
56
+
57
+ Counts all textual content across every part - text, thinking, tool call
58
+ arguments, and tool results - so the estimate reflects the full serialized
59
+ prompt rather than just assistant output.
60
+ """
61
+ chars = 0
62
+ for msg in messages:
63
+ for part in msg.parts:
64
+ chars += len(_part_text(part))
65
+ return chars // 4
66
+
67
+
68
+ def _render_for_summary(messages: list[Message]) -> str:
69
+ """Flatten messages into a plain transcript for the summariser."""
70
+ lines: list[str] = []
71
+ for msg in messages:
72
+ role = msg.role
73
+ for part in msg.parts:
74
+ if isinstance(part, TextPart):
75
+ if part.text.strip():
76
+ lines.append(f"[{role}] {part.text}")
77
+ elif isinstance(part, ThinkingPart):
78
+ if part.text.strip():
79
+ lines.append(f"[{role}:thinking] {part.text}")
80
+ elif isinstance(part, ToolCallPart):
81
+ lines.append(f"[{role}] call {part.name}({part.arguments})")
82
+ elif isinstance(part, ToolResultPart):
83
+ marker = "error" if part.is_error else "result"
84
+ lines.append(f"[tool:{part.tool_name}:{marker}] {part.content}")
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _truncate(messages: list[Message], keep_last: int) -> list[Message]:
89
+ """Plain fallback: drop the oldest messages, keep the tail.
90
+
91
+ The cut snaps FORWARD to the next user message so the kept tail never
92
+ starts mid tool-exchange (dangling tool results 400 strict providers);
93
+ snapping forward guarantees the list still shrinks.
94
+ """
95
+ if keep_last <= 0:
96
+ return list(messages[-1:]) if messages else []
97
+ if len(messages) <= keep_last:
98
+ return list(messages)
99
+ start = len(messages) - keep_last
100
+ while start < len(messages) and messages[start].role != "user":
101
+ start += 1
102
+ if start >= len(messages):
103
+ start = len(messages) - 1 # no user message in the tail: keep the last
104
+ return list(messages[start:])
105
+
106
+
107
+ async def compact(
108
+ provider,
109
+ model: str,
110
+ messages: list[Message],
111
+ keep_last: int = DEFAULT_KEEP_LAST,
112
+ *,
113
+ max_tokens: int = 2048,
114
+ ) -> list[Message]:
115
+ """Return a compacted message list.
116
+
117
+ The most recent ``keep_last`` messages are preserved verbatim; everything
118
+ before them is summarised into one ``user`` Message via a single provider
119
+ call. On any failure (stream error, exception, empty summary) we fall back
120
+ to a plain truncation so the agent loop never stalls on compaction.
121
+ """
122
+ if len(messages) <= keep_last:
123
+ return list(messages)
124
+
125
+ # Snap the keep boundary BACK to a user message: a tail that starts
126
+ # mid tool-exchange leaves dangling tool_use/tool_result pairs, and a
127
+ # summary user-message followed by another user message breaks strict
128
+ # role alternation - both are 400s on Anthropic.
129
+ start = len(messages) - keep_last if keep_last > 0 else len(messages)
130
+ while start > 0 and messages[start].role != "user":
131
+ start -= 1
132
+ if start <= 0:
133
+ return list(messages) # no clean boundary - skip compaction
134
+ older = messages[:start]
135
+ recent = messages[start:]
136
+ if not older:
137
+ return list(messages)
138
+
139
+ transcript = _render_for_summary(older)
140
+ summary_request = [
141
+ Message(
142
+ role="user",
143
+ parts=[TextPart(_SUMMARY_PROMPT + "\n\nConversation to summarize:\n\n" + transcript)],
144
+ )
145
+ ]
146
+
147
+ summary_text = ""
148
+ try:
149
+ async for event in provider.stream(
150
+ model=model,
151
+ messages=summary_request,
152
+ tools=[],
153
+ system="You are a precise summarizer of engineering conversations.",
154
+ max_tokens=max_tokens,
155
+ ):
156
+ if isinstance(event, TextDelta):
157
+ summary_text += event.text
158
+ elif isinstance(event, StreamError):
159
+ summary_text = ""
160
+ break
161
+ except Exception: # noqa: BLE001 - summarisation must never crash the engine
162
+ summary_text = ""
163
+
164
+ if not summary_text.strip():
165
+ # Could not summarise - shrink by truncation instead.
166
+ return _truncate(messages, keep_last)
167
+
168
+ header = (
169
+ "[Earlier conversation summarized to save context]\n\n"
170
+ + summary_text.strip()
171
+ )
172
+ # recent[0] is a user message (boundary snapped above): MERGE the summary
173
+ # into it instead of prepending a second user message, so user/assistant
174
+ # alternation stays intact for strict providers.
175
+ first = recent[0]
176
+ merged = Message(
177
+ role="user",
178
+ parts=[TextPart(header + "\n\n---\n\n" + first.text())]
179
+ + [p for p in first.parts if not isinstance(p, TextPart)],
180
+ )
181
+ return [merged, *recent[1:]]
182
+
183
+
184
+ __all__ = ["estimate_tokens", "compact", "DEFAULT_KEEP_LAST"]