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.
- chyrd/__init__.py +6 -0
- chyrd/__main__.py +7 -0
- chyrd/_buildflag.py +2 -0
- chyrd/_hardening.py +111 -0
- chyrd/agent/__init__.py +6 -0
- chyrd/agent/compact.py +184 -0
- chyrd/agent/engine.py +760 -0
- chyrd/agent/events.py +89 -0
- chyrd/agent/prompts.py +245 -0
- chyrd/agent/subagents.py +351 -0
- chyrd/checkpoints.py +185 -0
- chyrd/cli.py +758 -0
- chyrd/config.py +136 -0
- chyrd/license.py +148 -0
- chyrd/mcp.py +245 -0
- chyrd/permissions.py +89 -0
- chyrd/providers/__init__.py +296 -0
- chyrd/providers/anthropic.py +317 -0
- chyrd/providers/base.py +179 -0
- chyrd/providers/catalog.py +1250 -0
- chyrd/providers/claude_agent.py +376 -0
- chyrd/providers/google.py +315 -0
- chyrd/providers/openai_compat.py +855 -0
- chyrd/py.typed +0 -0
- chyrd/session/__init__.py +6 -0
- chyrd/session/store.py +281 -0
- chyrd/skills/__init__.py +19 -0
- chyrd/skills/builtin/3d-model/SKILL.md +205 -0
- chyrd/skills/builtin/ai-app/SKILL.md +172 -0
- chyrd/skills/builtin/commit/SKILL.md +95 -0
- chyrd/skills/builtin/data-viz/SKILL.md +120 -0
- chyrd/skills/builtin/debug/SKILL.md +101 -0
- chyrd/skills/builtin/deploy/SKILL.md +144 -0
- chyrd/skills/builtin/docs/SKILL.md +122 -0
- chyrd/skills/builtin/game-dev/SKILL.md +145 -0
- chyrd/skills/builtin/image-gen/SKILL.md +109 -0
- chyrd/skills/builtin/init/SKILL.md +81 -0
- chyrd/skills/builtin/refactor/SKILL.md +93 -0
- chyrd/skills/builtin/review/SKILL.md +85 -0
- chyrd/skills/builtin/security/SKILL.md +108 -0
- chyrd/skills/builtin/test/SKILL.md +127 -0
- chyrd/skills/builtin/website/SKILL.md +114 -0
- chyrd/skills/loader.py +209 -0
- chyrd/tools/__init__.py +78 -0
- chyrd/tools/base.py +79 -0
- chyrd/tools/files.py +277 -0
- chyrd/tools/fs.py +573 -0
- chyrd/tools/search.py +294 -0
- chyrd/tools/shell.py +188 -0
- chyrd/tools/skill.py +89 -0
- chyrd/tools/task.py +111 -0
- chyrd/tools/todo.py +110 -0
- chyrd/tools/web.py +383 -0
- chyrd/tui/__init__.py +18 -0
- chyrd/tui/app.py +1830 -0
- chyrd/tui/chyrd.tcss +533 -0
- chyrd/tui/mascot.py +302 -0
- chyrd/tui/theme.py +202 -0
- chyrd/tui/widgets/__init__.py +35 -0
- chyrd/tui/widgets/chat.py +486 -0
- chyrd/tui/widgets/composer.py +236 -0
- chyrd/tui/widgets/header.py +95 -0
- chyrd/tui/widgets/modelpicker.py +232 -0
- chyrd/tui/widgets/palette_cmds.py +89 -0
- chyrd/tui/widgets/permission.py +110 -0
- chyrd/tui/widgets/splash.py +165 -0
- chyrd/tui/widgets/statusbar.py +183 -0
- chyrd-0.4.0.dist-info/METADATA +352 -0
- chyrd-0.4.0.dist-info/RECORD +73 -0
- chyrd-0.4.0.dist-info/WHEEL +5 -0
- chyrd-0.4.0.dist-info/entry_points.txt +2 -0
- chyrd-0.4.0.dist-info/licenses/LICENSE +21 -0
- chyrd-0.4.0.dist-info/top_level.txt +1 -0
chyrd/__init__.py
ADDED
chyrd/__main__.py
ADDED
chyrd/_buildflag.py
ADDED
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
|
chyrd/agent/__init__.py
ADDED
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"]
|