kairo-code 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.
- image-service/main.py +178 -0
- infra/chat/app/main.py +84 -0
- kairo/backend/__init__.py +0 -0
- kairo/backend/api/__init__.py +0 -0
- kairo/backend/api/admin/__init__.py +23 -0
- kairo/backend/api/admin/audit.py +54 -0
- kairo/backend/api/admin/content.py +142 -0
- kairo/backend/api/admin/incidents.py +148 -0
- kairo/backend/api/admin/stats.py +125 -0
- kairo/backend/api/admin/system.py +87 -0
- kairo/backend/api/admin/users.py +279 -0
- kairo/backend/api/agents.py +94 -0
- kairo/backend/api/api_keys.py +85 -0
- kairo/backend/api/auth.py +116 -0
- kairo/backend/api/billing.py +41 -0
- kairo/backend/api/chat.py +72 -0
- kairo/backend/api/conversations.py +125 -0
- kairo/backend/api/device_auth.py +100 -0
- kairo/backend/api/files.py +83 -0
- kairo/backend/api/health.py +36 -0
- kairo/backend/api/images.py +80 -0
- kairo/backend/api/openai_compat.py +225 -0
- kairo/backend/api/projects.py +102 -0
- kairo/backend/api/usage.py +32 -0
- kairo/backend/api/webhooks.py +79 -0
- kairo/backend/app.py +297 -0
- kairo/backend/config.py +179 -0
- kairo/backend/core/__init__.py +0 -0
- kairo/backend/core/admin_auth.py +24 -0
- kairo/backend/core/api_key_auth.py +55 -0
- kairo/backend/core/database.py +28 -0
- kairo/backend/core/dependencies.py +70 -0
- kairo/backend/core/logging.py +23 -0
- kairo/backend/core/rate_limit.py +73 -0
- kairo/backend/core/security.py +29 -0
- kairo/backend/models/__init__.py +19 -0
- kairo/backend/models/agent.py +30 -0
- kairo/backend/models/api_key.py +25 -0
- kairo/backend/models/api_usage.py +29 -0
- kairo/backend/models/audit_log.py +26 -0
- kairo/backend/models/conversation.py +48 -0
- kairo/backend/models/device_code.py +30 -0
- kairo/backend/models/feature_flag.py +21 -0
- kairo/backend/models/image_generation.py +24 -0
- kairo/backend/models/incident.py +28 -0
- kairo/backend/models/project.py +28 -0
- kairo/backend/models/uptime_record.py +24 -0
- kairo/backend/models/usage.py +24 -0
- kairo/backend/models/user.py +49 -0
- kairo/backend/schemas/__init__.py +0 -0
- kairo/backend/schemas/admin/__init__.py +0 -0
- kairo/backend/schemas/admin/audit.py +28 -0
- kairo/backend/schemas/admin/content.py +53 -0
- kairo/backend/schemas/admin/stats.py +77 -0
- kairo/backend/schemas/admin/system.py +44 -0
- kairo/backend/schemas/admin/users.py +48 -0
- kairo/backend/schemas/agent.py +42 -0
- kairo/backend/schemas/api_key.py +30 -0
- kairo/backend/schemas/auth.py +57 -0
- kairo/backend/schemas/chat.py +26 -0
- kairo/backend/schemas/conversation.py +39 -0
- kairo/backend/schemas/device_auth.py +40 -0
- kairo/backend/schemas/image.py +15 -0
- kairo/backend/schemas/openai_compat.py +76 -0
- kairo/backend/schemas/project.py +21 -0
- kairo/backend/schemas/status.py +81 -0
- kairo/backend/schemas/usage.py +15 -0
- kairo/backend/services/__init__.py +0 -0
- kairo/backend/services/admin/__init__.py +0 -0
- kairo/backend/services/admin/audit_service.py +78 -0
- kairo/backend/services/admin/content_service.py +119 -0
- kairo/backend/services/admin/incident_service.py +94 -0
- kairo/backend/services/admin/stats_service.py +281 -0
- kairo/backend/services/admin/system_service.py +126 -0
- kairo/backend/services/admin/user_service.py +157 -0
- kairo/backend/services/agent_service.py +107 -0
- kairo/backend/services/api_key_service.py +66 -0
- kairo/backend/services/api_usage_service.py +126 -0
- kairo/backend/services/auth_service.py +101 -0
- kairo/backend/services/chat_service.py +501 -0
- kairo/backend/services/conversation_service.py +264 -0
- kairo/backend/services/device_auth_service.py +193 -0
- kairo/backend/services/email_service.py +55 -0
- kairo/backend/services/image_service.py +181 -0
- kairo/backend/services/llm_service.py +186 -0
- kairo/backend/services/project_service.py +109 -0
- kairo/backend/services/status_service.py +167 -0
- kairo/backend/services/stripe_service.py +78 -0
- kairo/backend/services/usage_service.py +150 -0
- kairo/backend/services/web_search_service.py +96 -0
- kairo/migrations/env.py +60 -0
- kairo/migrations/versions/001_initial.py +55 -0
- kairo/migrations/versions/002_usage_tracking_and_indexes.py +66 -0
- kairo/migrations/versions/003_username_to_email.py +21 -0
- kairo/migrations/versions/004_add_plans_and_verification.py +67 -0
- kairo/migrations/versions/005_add_projects.py +52 -0
- kairo/migrations/versions/006_add_image_generation.py +63 -0
- kairo/migrations/versions/007_add_admin_portal.py +107 -0
- kairo/migrations/versions/008_add_device_code_auth.py +76 -0
- kairo/migrations/versions/009_add_status_page.py +65 -0
- kairo/tools/extract_claude_data.py +465 -0
- kairo/tools/filter_claude_data.py +303 -0
- kairo/tools/generate_curated_data.py +157 -0
- kairo/tools/mix_training_data.py +295 -0
- kairo_code/__init__.py +3 -0
- kairo_code/agents/__init__.py +25 -0
- kairo_code/agents/architect.py +98 -0
- kairo_code/agents/audit.py +100 -0
- kairo_code/agents/base.py +463 -0
- kairo_code/agents/coder.py +155 -0
- kairo_code/agents/database.py +77 -0
- kairo_code/agents/docs.py +88 -0
- kairo_code/agents/explorer.py +62 -0
- kairo_code/agents/guardian.py +80 -0
- kairo_code/agents/planner.py +66 -0
- kairo_code/agents/reviewer.py +91 -0
- kairo_code/agents/security.py +94 -0
- kairo_code/agents/terraform.py +88 -0
- kairo_code/agents/testing.py +97 -0
- kairo_code/agents/uiux.py +88 -0
- kairo_code/auth.py +232 -0
- kairo_code/config.py +172 -0
- kairo_code/conversation.py +173 -0
- kairo_code/heartbeat.py +63 -0
- kairo_code/llm.py +291 -0
- kairo_code/logging_config.py +156 -0
- kairo_code/main.py +818 -0
- kairo_code/router.py +217 -0
- kairo_code/sandbox.py +248 -0
- kairo_code/settings.py +183 -0
- kairo_code/tools/__init__.py +51 -0
- kairo_code/tools/analysis.py +509 -0
- kairo_code/tools/base.py +417 -0
- kairo_code/tools/code.py +58 -0
- kairo_code/tools/definitions.py +617 -0
- kairo_code/tools/files.py +315 -0
- kairo_code/tools/review.py +390 -0
- kairo_code/tools/search.py +185 -0
- kairo_code/ui.py +418 -0
- kairo_code-0.1.0.dist-info/METADATA +13 -0
- kairo_code-0.1.0.dist-info/RECORD +144 -0
- kairo_code-0.1.0.dist-info/WHEEL +5 -0
- kairo_code-0.1.0.dist-info/entry_points.txt +2 -0
- kairo_code-0.1.0.dist-info/top_level.txt +4 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Web search and fetch tools"""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import socket
|
|
5
|
+
import requests
|
|
6
|
+
from html.parser import HTMLParser
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from ddgs import DDGS # New package name
|
|
11
|
+
except ImportError:
|
|
12
|
+
from duckduckgo_search import DDGS # Fallback to old name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── SSRF protection ────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
_BLOCKED_SCHEMES = {"file", "ftp", "gopher", "data", "javascript"}
|
|
18
|
+
|
|
19
|
+
def _is_private_ip(ip_str: str) -> bool:
|
|
20
|
+
"""Check if an IP address is private/reserved (SSRF protection)."""
|
|
21
|
+
try:
|
|
22
|
+
addr = ipaddress.ip_address(ip_str)
|
|
23
|
+
return (
|
|
24
|
+
addr.is_private
|
|
25
|
+
or addr.is_loopback
|
|
26
|
+
or addr.is_reserved
|
|
27
|
+
or addr.is_link_local
|
|
28
|
+
or addr.is_multicast
|
|
29
|
+
)
|
|
30
|
+
except ValueError:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_url(url: str) -> None:
|
|
35
|
+
"""Validate a URL is safe to fetch (blocks private IPs, bad schemes)."""
|
|
36
|
+
parsed = urlparse(url)
|
|
37
|
+
|
|
38
|
+
# Block dangerous schemes
|
|
39
|
+
if parsed.scheme.lower() in _BLOCKED_SCHEMES:
|
|
40
|
+
raise ValueError(f"Blocked URL scheme: {parsed.scheme}")
|
|
41
|
+
|
|
42
|
+
# Only allow http/https
|
|
43
|
+
if parsed.scheme.lower() not in ("http", "https"):
|
|
44
|
+
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
|
45
|
+
|
|
46
|
+
hostname = parsed.hostname
|
|
47
|
+
if not hostname:
|
|
48
|
+
raise ValueError("URL has no hostname")
|
|
49
|
+
|
|
50
|
+
# Resolve hostname and check all IPs
|
|
51
|
+
try:
|
|
52
|
+
addrs = socket.getaddrinfo(hostname, parsed.port or 443)
|
|
53
|
+
for family, _type, _proto, _canonname, sockaddr in addrs:
|
|
54
|
+
ip = sockaddr[0]
|
|
55
|
+
if _is_private_ip(ip):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Blocked: {hostname} resolves to private/reserved IP {ip}"
|
|
58
|
+
)
|
|
59
|
+
except socket.gaierror:
|
|
60
|
+
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HTMLTextExtractor(HTMLParser):
|
|
64
|
+
"""Extract text content from HTML, removing scripts and styles."""
|
|
65
|
+
|
|
66
|
+
def __init__(self):
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.text_parts = []
|
|
69
|
+
self.skip_data = False
|
|
70
|
+
self.skip_tags = {'script', 'style', 'nav', 'footer', 'header'}
|
|
71
|
+
|
|
72
|
+
def handle_starttag(self, tag, attrs):
|
|
73
|
+
if tag in self.skip_tags:
|
|
74
|
+
self.skip_data = True
|
|
75
|
+
|
|
76
|
+
def handle_endtag(self, tag):
|
|
77
|
+
if tag in self.skip_tags:
|
|
78
|
+
self.skip_data = False
|
|
79
|
+
|
|
80
|
+
def handle_data(self, data):
|
|
81
|
+
if not self.skip_data:
|
|
82
|
+
text = data.strip()
|
|
83
|
+
if text:
|
|
84
|
+
self.text_parts.append(text)
|
|
85
|
+
|
|
86
|
+
def get_text(self) -> str:
|
|
87
|
+
return ' '.join(self.text_parts)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def web_search(query: str, max_results: int = 5) -> list[dict]:
|
|
91
|
+
"""
|
|
92
|
+
Search the web using DuckDuckGo.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
query: Search query
|
|
96
|
+
max_results: Maximum number of results to return
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
List of search results with title, url, and body
|
|
100
|
+
"""
|
|
101
|
+
with DDGS() as ddgs:
|
|
102
|
+
results = list(ddgs.text(query, max_results=max_results))
|
|
103
|
+
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
"title": r.get("title", ""),
|
|
107
|
+
"url": r.get("href", ""),
|
|
108
|
+
"snippet": r.get("body", ""),
|
|
109
|
+
}
|
|
110
|
+
for r in results
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_search_results(results: list[dict]) -> str:
|
|
115
|
+
"""Format search results for LLM context."""
|
|
116
|
+
if not results:
|
|
117
|
+
return "No search results found."
|
|
118
|
+
|
|
119
|
+
formatted = []
|
|
120
|
+
for i, r in enumerate(results, 1):
|
|
121
|
+
formatted.append(
|
|
122
|
+
f"{i}. **{r['title']}**\n"
|
|
123
|
+
f" URL: {r['url']}\n"
|
|
124
|
+
f" {r['snippet']}\n"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return "\n".join(formatted)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def web_fetch(url: str, max_chars: int = 8000) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Fetch a URL and extract text content.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
url: The URL to fetch
|
|
136
|
+
max_chars: Maximum characters to return (default 8000)
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Extracted text content from the page
|
|
140
|
+
"""
|
|
141
|
+
# SSRF protection: validate URL before fetching
|
|
142
|
+
_validate_url(url)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
headers = {
|
|
146
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
147
|
+
}
|
|
148
|
+
# Follow redirects manually so we can validate each hop
|
|
149
|
+
max_redirects = 5
|
|
150
|
+
current_url = url
|
|
151
|
+
response = None
|
|
152
|
+
for _ in range(max_redirects):
|
|
153
|
+
response = requests.get(current_url, headers=headers, timeout=30, allow_redirects=False)
|
|
154
|
+
if response.is_redirect and "Location" in response.headers:
|
|
155
|
+
current_url = response.headers["Location"]
|
|
156
|
+
_validate_url(current_url)
|
|
157
|
+
continue
|
|
158
|
+
break
|
|
159
|
+
response.raise_for_status()
|
|
160
|
+
|
|
161
|
+
content_type = response.headers.get('content-type', '')
|
|
162
|
+
|
|
163
|
+
# If it's JSON, return formatted JSON
|
|
164
|
+
if 'application/json' in content_type:
|
|
165
|
+
import json
|
|
166
|
+
try:
|
|
167
|
+
data = response.json()
|
|
168
|
+
return json.dumps(data, indent=2)[:max_chars]
|
|
169
|
+
except Exception:
|
|
170
|
+
return response.text[:max_chars]
|
|
171
|
+
|
|
172
|
+
# If it's HTML, extract text
|
|
173
|
+
if 'text/html' in content_type:
|
|
174
|
+
extractor = HTMLTextExtractor()
|
|
175
|
+
extractor.feed(response.text)
|
|
176
|
+
text = extractor.get_text()
|
|
177
|
+
return text[:max_chars]
|
|
178
|
+
|
|
179
|
+
# Otherwise return raw text
|
|
180
|
+
return response.text[:max_chars]
|
|
181
|
+
|
|
182
|
+
except requests.exceptions.Timeout:
|
|
183
|
+
raise TimeoutError("Request timed out fetching URL")
|
|
184
|
+
except requests.exceptions.RequestException as e:
|
|
185
|
+
raise RuntimeError(f"Error fetching URL: {e}") from e
|
kairo_code/ui.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""Claude Code-inspired UI formatting for Kairo Code"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Generator, Optional
|
|
7
|
+
from rich.console import Console, Group
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.syntax import Syntax
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.spinner import Spinner
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.box import ROUNDED, SIMPLE
|
|
15
|
+
from rich.rule import Rule
|
|
16
|
+
from rich.style import Style
|
|
17
|
+
from rich.padding import Padding
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ToolExecution:
|
|
25
|
+
"""Tracks a tool execution for display."""
|
|
26
|
+
tool_name: str
|
|
27
|
+
params: dict
|
|
28
|
+
start_time: float = field(default_factory=time.time)
|
|
29
|
+
result: Optional[str] = None
|
|
30
|
+
success: bool = True
|
|
31
|
+
end_time: Optional[float] = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def duration(self) -> float:
|
|
35
|
+
end = self.end_time or time.time()
|
|
36
|
+
return end - self.start_time
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def duration_str(self) -> str:
|
|
40
|
+
d = self.duration
|
|
41
|
+
if d < 1:
|
|
42
|
+
return f"{d*1000:.0f}ms"
|
|
43
|
+
elif d < 60:
|
|
44
|
+
return f"{d:.1f}s"
|
|
45
|
+
else:
|
|
46
|
+
mins = int(d // 60)
|
|
47
|
+
secs = int(d % 60)
|
|
48
|
+
return f"{mins}m {secs}s"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OutputFormatter:
|
|
52
|
+
"""
|
|
53
|
+
Formats agent output in Claude Code style.
|
|
54
|
+
|
|
55
|
+
Features:
|
|
56
|
+
- Boxed tool calls with bullet points
|
|
57
|
+
- Collapsible long output
|
|
58
|
+
- Diff view for file edits
|
|
59
|
+
- Timing information
|
|
60
|
+
- Spinners during execution
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
TOOL_PATTERN = re.compile(r'<tool>(\w+)</tool>')
|
|
64
|
+
PARAMS_PATTERN = re.compile(r'<params>(.*?)</params>', re.DOTALL)
|
|
65
|
+
THINKING_PATTERN = re.compile(r'<thinking>(.*?)</thinking>', re.DOTALL)
|
|
66
|
+
|
|
67
|
+
# Tool display names (Claude Code style)
|
|
68
|
+
TOOL_NAMES = {
|
|
69
|
+
"read_file": "Read",
|
|
70
|
+
"write_file": "Write",
|
|
71
|
+
"edit_file": "Edit",
|
|
72
|
+
"list_files": "Glob",
|
|
73
|
+
"search_files": "Grep",
|
|
74
|
+
"tree": "Tree",
|
|
75
|
+
"bash": "Bash",
|
|
76
|
+
"web_search": "WebSearch",
|
|
77
|
+
"web_fetch": "WebFetch",
|
|
78
|
+
"lint": "Lint",
|
|
79
|
+
"typecheck": "TypeCheck",
|
|
80
|
+
"run_tests": "Test",
|
|
81
|
+
"security_scan": "Security",
|
|
82
|
+
"code_review": "Review",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self.in_tool_call = False
|
|
87
|
+
self.current_tool: Optional[ToolExecution] = None
|
|
88
|
+
self.buffer = ""
|
|
89
|
+
self.session_start = time.time()
|
|
90
|
+
self.collapsed_lines = 6 # Show this many lines before collapsing
|
|
91
|
+
|
|
92
|
+
def format_tool_header(self, tool_name: str, params: dict) -> Text:
|
|
93
|
+
"""Format tool call header like Claude Code: ● Bash(command)"""
|
|
94
|
+
display_name = self.TOOL_NAMES.get(tool_name, tool_name.title())
|
|
95
|
+
|
|
96
|
+
# Build parameter preview
|
|
97
|
+
param_preview = self._get_param_preview(tool_name, params)
|
|
98
|
+
|
|
99
|
+
text = Text()
|
|
100
|
+
text.append("● ", style="cyan bold")
|
|
101
|
+
text.append(display_name, style="cyan bold")
|
|
102
|
+
text.append(f"({param_preview})", style="dim")
|
|
103
|
+
|
|
104
|
+
return text
|
|
105
|
+
|
|
106
|
+
def _get_param_preview(self, tool_name: str, params: dict) -> str:
|
|
107
|
+
"""Get a concise parameter preview for tool header."""
|
|
108
|
+
if tool_name in ("read_file", "write_file", "edit_file"):
|
|
109
|
+
path = params.get("path", params.get("file", ""))
|
|
110
|
+
return self._truncate(path, 50)
|
|
111
|
+
elif tool_name == "bash":
|
|
112
|
+
cmd = params.get("command", "")
|
|
113
|
+
return self._truncate(cmd, 60)
|
|
114
|
+
elif tool_name == "list_files":
|
|
115
|
+
return params.get("pattern", "*")
|
|
116
|
+
elif tool_name == "search_files":
|
|
117
|
+
query = params.get("query", params.get("pattern", ""))
|
|
118
|
+
return f'"{self._truncate(query, 40)}"'
|
|
119
|
+
elif tool_name == "web_search":
|
|
120
|
+
return f'"{self._truncate(params.get("query", ""), 40)}"'
|
|
121
|
+
elif tool_name == "web_fetch":
|
|
122
|
+
return self._truncate(params.get("url", ""), 50)
|
|
123
|
+
else:
|
|
124
|
+
# Generic: show first param value
|
|
125
|
+
for v in params.values():
|
|
126
|
+
if isinstance(v, str):
|
|
127
|
+
return self._truncate(v, 40)
|
|
128
|
+
return "..."
|
|
129
|
+
|
|
130
|
+
def _truncate(self, text: str, max_len: int) -> str:
|
|
131
|
+
"""Truncate text with ellipsis."""
|
|
132
|
+
text = text.replace("\n", " ").strip()
|
|
133
|
+
if len(text) > max_len:
|
|
134
|
+
return text[:max_len-1] + "…"
|
|
135
|
+
return text
|
|
136
|
+
|
|
137
|
+
def format_tool_output(self, output: str, success: bool = True) -> Text:
|
|
138
|
+
"""Format tool output with collapsing for long content."""
|
|
139
|
+
if not output:
|
|
140
|
+
return Text(" ⎿ (No content)", style="dim")
|
|
141
|
+
|
|
142
|
+
lines = output.split("\n")
|
|
143
|
+
text = Text()
|
|
144
|
+
|
|
145
|
+
if not success:
|
|
146
|
+
text.append(" ⎿ ", style="dim")
|
|
147
|
+
text.append("Error: ", style="red bold")
|
|
148
|
+
text.append(self._truncate(output, 100), style="red")
|
|
149
|
+
return text
|
|
150
|
+
|
|
151
|
+
# Show output with collapse indicator
|
|
152
|
+
if len(lines) <= self.collapsed_lines:
|
|
153
|
+
# Short output - show all
|
|
154
|
+
for i, line in enumerate(lines):
|
|
155
|
+
text.append(" ⎿ " if i == 0 else " ", style="dim")
|
|
156
|
+
text.append(self._truncate(line, 100) + "\n", style="dim")
|
|
157
|
+
else:
|
|
158
|
+
# Long output - show preview with collapse hint
|
|
159
|
+
for i, line in enumerate(lines[:3]):
|
|
160
|
+
text.append(" ⎿ " if i == 0 else " ", style="dim")
|
|
161
|
+
text.append(self._truncate(line, 100) + "\n", style="dim")
|
|
162
|
+
text.append(f" … +{len(lines) - 3} lines (ctrl+o to expand)\n", style="dim italic")
|
|
163
|
+
|
|
164
|
+
return text
|
|
165
|
+
|
|
166
|
+
def format_file_diff(self, old_content: str, new_content: str, path: str) -> Text:
|
|
167
|
+
"""Format a file edit as a diff view."""
|
|
168
|
+
text = Text()
|
|
169
|
+
text.append(" ⎿ ", style="dim")
|
|
170
|
+
|
|
171
|
+
old_lines = old_content.split("\n") if old_content else []
|
|
172
|
+
new_lines = new_content.split("\n") if new_content else []
|
|
173
|
+
|
|
174
|
+
added = len(new_lines) - len(old_lines)
|
|
175
|
+
if added > 0:
|
|
176
|
+
text.append(f"Added {added} line{'s' if added != 1 else ''}", style="green")
|
|
177
|
+
elif added < 0:
|
|
178
|
+
text.append(f"Removed {-added} line{'s' if -added != 1 else ''}", style="red")
|
|
179
|
+
else:
|
|
180
|
+
text.append("Modified", style="yellow")
|
|
181
|
+
|
|
182
|
+
return text
|
|
183
|
+
|
|
184
|
+
def format_edit_preview(self, old_str: str, new_str: str) -> Text:
|
|
185
|
+
"""Format an edit operation showing old vs new."""
|
|
186
|
+
text = Text()
|
|
187
|
+
|
|
188
|
+
old_lines = old_str.strip().split("\n")
|
|
189
|
+
new_lines = new_str.strip().split("\n")
|
|
190
|
+
|
|
191
|
+
# Show a few lines of context
|
|
192
|
+
max_preview = 4
|
|
193
|
+
|
|
194
|
+
for i, line in enumerate(old_lines[:max_preview]):
|
|
195
|
+
text.append(" ", style="dim")
|
|
196
|
+
text.append(f"{i+1:4} ", style="dim")
|
|
197
|
+
text.append("-", style="red")
|
|
198
|
+
text.append(self._truncate(line, 70) + "\n", style="red dim")
|
|
199
|
+
|
|
200
|
+
for i, line in enumerate(new_lines[:max_preview]):
|
|
201
|
+
text.append(" ", style="dim")
|
|
202
|
+
text.append(f"{i+1:4} ", style="dim")
|
|
203
|
+
text.append("+", style="green")
|
|
204
|
+
text.append(self._truncate(line, 70) + "\n", style="green")
|
|
205
|
+
|
|
206
|
+
if len(old_lines) > max_preview or len(new_lines) > max_preview:
|
|
207
|
+
text.append(f" … more changes\n", style="dim italic")
|
|
208
|
+
|
|
209
|
+
return text
|
|
210
|
+
|
|
211
|
+
def format_session_time(self) -> str:
|
|
212
|
+
"""Format total session time like 'Churned for 1m 28s'."""
|
|
213
|
+
elapsed = time.time() - self.session_start
|
|
214
|
+
|
|
215
|
+
verbs = ["Crunched", "Churned", "Processed", "Computed"]
|
|
216
|
+
verb = verbs[int(elapsed) % len(verbs)]
|
|
217
|
+
|
|
218
|
+
if elapsed < 60:
|
|
219
|
+
return f"✻ {verb} for {elapsed:.0f}s"
|
|
220
|
+
else:
|
|
221
|
+
mins = int(elapsed // 60)
|
|
222
|
+
secs = int(elapsed % 60)
|
|
223
|
+
return f"✻ {verb} for {mins}m {secs}s"
|
|
224
|
+
|
|
225
|
+
def stream_formatted(self, raw_stream: Generator[str, None, str]) -> Generator[str, None, None]:
|
|
226
|
+
"""Transform raw agent stream into Claude Code-style output."""
|
|
227
|
+
buffer = ""
|
|
228
|
+
in_thinking = False
|
|
229
|
+
|
|
230
|
+
for chunk in raw_stream:
|
|
231
|
+
buffer += chunk
|
|
232
|
+
|
|
233
|
+
while "\n" in buffer:
|
|
234
|
+
idx = buffer.find("\n")
|
|
235
|
+
line = buffer[:idx]
|
|
236
|
+
buffer = buffer[idx + 1:]
|
|
237
|
+
|
|
238
|
+
result = self._process_line(line, in_thinking)
|
|
239
|
+
|
|
240
|
+
if "<thinking>" in line:
|
|
241
|
+
in_thinking = True
|
|
242
|
+
if "</thinking>" in line:
|
|
243
|
+
in_thinking = False
|
|
244
|
+
|
|
245
|
+
if result:
|
|
246
|
+
yield result
|
|
247
|
+
|
|
248
|
+
if buffer.strip():
|
|
249
|
+
result = self._process_line(buffer, in_thinking)
|
|
250
|
+
if result:
|
|
251
|
+
yield result
|
|
252
|
+
|
|
253
|
+
# Add session timing at end
|
|
254
|
+
yield f"\n[dim]{self.format_session_time()}[/]\n"
|
|
255
|
+
|
|
256
|
+
def _process_line(self, line: str, in_thinking: bool) -> str:
|
|
257
|
+
"""Process a single line and return formatted output."""
|
|
258
|
+
import json
|
|
259
|
+
|
|
260
|
+
# Skip thinking content
|
|
261
|
+
if in_thinking or "<thinking>" in line:
|
|
262
|
+
return ""
|
|
263
|
+
|
|
264
|
+
# Handle tool calls
|
|
265
|
+
if "<tool>" in line and "</tool>" in line:
|
|
266
|
+
match = self.TOOL_PATTERN.search(line)
|
|
267
|
+
if match:
|
|
268
|
+
tool_name = match.group(1)
|
|
269
|
+
params_match = self.PARAMS_PATTERN.search(line)
|
|
270
|
+
params = {}
|
|
271
|
+
if params_match:
|
|
272
|
+
try:
|
|
273
|
+
params = json.loads(params_match.group(1))
|
|
274
|
+
except:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
header = self.format_tool_header(tool_name, params)
|
|
278
|
+
self.current_tool = ToolExecution(tool_name, params)
|
|
279
|
+
return f"\n{header}\n"
|
|
280
|
+
|
|
281
|
+
# Handle params on separate line
|
|
282
|
+
if "<params>" in line and "</params>" in line:
|
|
283
|
+
return ""
|
|
284
|
+
|
|
285
|
+
# Handle tool markers from agent
|
|
286
|
+
if line.startswith("[Tool:"):
|
|
287
|
+
return ""
|
|
288
|
+
|
|
289
|
+
if line.startswith("[Result:"):
|
|
290
|
+
result = line[8:].rstrip("]").strip()
|
|
291
|
+
if self.current_tool:
|
|
292
|
+
self.current_tool.end_time = time.time()
|
|
293
|
+
output = self.format_tool_output(result, success=True)
|
|
294
|
+
return str(output)
|
|
295
|
+
return ""
|
|
296
|
+
|
|
297
|
+
if line.startswith("[Error:"):
|
|
298
|
+
error = line[7:].rstrip("]").strip()
|
|
299
|
+
if self.current_tool:
|
|
300
|
+
self.current_tool.end_time = time.time()
|
|
301
|
+
self.current_tool.success = False
|
|
302
|
+
# Show more of the error (200 chars instead of 80) for debugging
|
|
303
|
+
return f" ⎿ [red]Error: {self._truncate(error, 200)}[/]\n"
|
|
304
|
+
|
|
305
|
+
if line.startswith("[Output:"):
|
|
306
|
+
output = line[8:].rstrip("]").strip()
|
|
307
|
+
if self.current_tool:
|
|
308
|
+
self.current_tool.success = False
|
|
309
|
+
# Show command output on failure (helps with debugging)
|
|
310
|
+
return f" ⎿ [yellow]{self._truncate(output, 300)}[/]\n"
|
|
311
|
+
|
|
312
|
+
# Skip other internal markers
|
|
313
|
+
if line.startswith("[") and line.endswith("]"):
|
|
314
|
+
if any(x in line for x in ["iterations", "Parse error", "retry", "Context"]):
|
|
315
|
+
return f"[dim]{line}[/]\n"
|
|
316
|
+
return ""
|
|
317
|
+
|
|
318
|
+
# Regular text
|
|
319
|
+
cleaned = self._clean_text(line)
|
|
320
|
+
if cleaned:
|
|
321
|
+
return cleaned + "\n"
|
|
322
|
+
|
|
323
|
+
return ""
|
|
324
|
+
|
|
325
|
+
def _clean_text(self, text: str) -> str:
|
|
326
|
+
"""Clean text by removing markers."""
|
|
327
|
+
text = re.sub(r'</?tool>', '', text)
|
|
328
|
+
text = re.sub(r'</?params>', '', text)
|
|
329
|
+
text = re.sub(r'</?thinking>', '', text)
|
|
330
|
+
text = re.sub(r'\[Tool:.*?\]', '', text)
|
|
331
|
+
text = re.sub(r'\[Result:.*?\]', '', text)
|
|
332
|
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
333
|
+
return text.strip()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def print_tool_call(tool_name: str, params: dict, timeout: Optional[str] = None) -> None:
|
|
337
|
+
"""Print a tool call in Claude Code style."""
|
|
338
|
+
formatter = OutputFormatter()
|
|
339
|
+
header = formatter.format_tool_header(tool_name, params)
|
|
340
|
+
|
|
341
|
+
timeout_str = f" timeout: {timeout}" if timeout else ""
|
|
342
|
+
console.print(f"\n{header}[dim]{timeout_str}[/]")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def print_tool_result(result: str, success: bool = True, show_full: bool = False) -> None:
|
|
346
|
+
"""Print a tool result with optional expansion."""
|
|
347
|
+
formatter = OutputFormatter()
|
|
348
|
+
output = formatter.format_tool_output(result, success)
|
|
349
|
+
console.print(output)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def print_update(path: str, added: int, removed: int) -> None:
|
|
353
|
+
"""Print file update summary like Claude Code."""
|
|
354
|
+
text = Text()
|
|
355
|
+
text.append(" ⎿ ", style="dim")
|
|
356
|
+
|
|
357
|
+
if added > 0 and removed > 0:
|
|
358
|
+
text.append(f"Added {added} line{'s' if added != 1 else ''}, ", style="green")
|
|
359
|
+
text.append(f"removed {removed} line{'s' if removed != 1 else ''}", style="red")
|
|
360
|
+
elif added > 0:
|
|
361
|
+
text.append(f"Added {added} line{'s' if added != 1 else ''}", style="green")
|
|
362
|
+
elif removed > 0:
|
|
363
|
+
text.append(f"Removed {removed} line{'s' if removed != 1 else ''}", style="red")
|
|
364
|
+
|
|
365
|
+
console.print(text)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def print_status(message: str, style: str = "dim") -> None:
|
|
369
|
+
"""Print a status message."""
|
|
370
|
+
console.print(f"[{style}]{message}[/]")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def print_file_written(path: str, lines: int) -> None:
|
|
374
|
+
"""Print file written confirmation."""
|
|
375
|
+
console.print(f" ⎿ [green]Wrote {path} ({lines} lines)[/]")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def print_file_read(path: str, lines: int) -> None:
|
|
379
|
+
"""Print file read confirmation."""
|
|
380
|
+
console.print(f" ⎿ [dim]Read {path} ({lines} lines)[/]")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def create_progress_table(items: list[tuple[str, str, str]]) -> Table:
|
|
384
|
+
"""Create a progress table for multi-step operations."""
|
|
385
|
+
table = Table(show_header=False, box=None, padding=(0, 1))
|
|
386
|
+
table.add_column("Status", width=3)
|
|
387
|
+
table.add_column("Step")
|
|
388
|
+
table.add_column("Details", style="dim")
|
|
389
|
+
|
|
390
|
+
for status, step, details in items:
|
|
391
|
+
if status == "done":
|
|
392
|
+
table.add_row("[green]✓[/]", step, details)
|
|
393
|
+
elif status == "current":
|
|
394
|
+
table.add_row("[cyan]●[/]", f"[bold]{step}[/]", details)
|
|
395
|
+
elif status == "error":
|
|
396
|
+
table.add_row("[red]✗[/]", step, details)
|
|
397
|
+
else:
|
|
398
|
+
table.add_row("[dim]○[/]", f"[dim]{step}[/]", details)
|
|
399
|
+
|
|
400
|
+
return table
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def create_spinner(message: str = "Working") -> Spinner:
|
|
404
|
+
"""Create a spinner for long operations."""
|
|
405
|
+
return Spinner("dots", text=Text(f" {message}...", style="cyan"))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def print_welcome_banner(coder_model: str, router_model: str, sandbox_path: str) -> None:
|
|
409
|
+
"""Print welcome banner in Claude Code style."""
|
|
410
|
+
console.print()
|
|
411
|
+
from . import __version__
|
|
412
|
+
console.print(f"[bold cyan]Kairo Code[/] v{__version__} - AI Coding Assistant by Kairon Labs")
|
|
413
|
+
console.print(f"[dim]Model: {coder_model}[/]")
|
|
414
|
+
console.print(f"[dim]Sandbox: {sandbox_path}[/]")
|
|
415
|
+
console.print()
|
|
416
|
+
console.print("[dim]Just describe what you want - Kairo Code will plan and implement automatically.[/]")
|
|
417
|
+
console.print("[dim]Agents: /explore, /code, /plan | Utils: /search, /settings, /help, /exit[/]")
|
|
418
|
+
console.print()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kairo-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Kairo Code - AI Coding Assistant by Kairon Labs
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: rich>=13.0.0
|
|
8
|
+
Requires-Dist: openai>=1.0.0
|
|
9
|
+
Requires-Dist: duckduckgo-search>=4.0.0
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Requires-Dist: prompt-toolkit>=3.0.0
|
|
12
|
+
Requires-Dist: requests>=2.31.0
|
|
13
|
+
Requires-Dist: httpx>=0.27.0
|