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.
Files changed (144) hide show
  1. image-service/main.py +178 -0
  2. infra/chat/app/main.py +84 -0
  3. kairo/backend/__init__.py +0 -0
  4. kairo/backend/api/__init__.py +0 -0
  5. kairo/backend/api/admin/__init__.py +23 -0
  6. kairo/backend/api/admin/audit.py +54 -0
  7. kairo/backend/api/admin/content.py +142 -0
  8. kairo/backend/api/admin/incidents.py +148 -0
  9. kairo/backend/api/admin/stats.py +125 -0
  10. kairo/backend/api/admin/system.py +87 -0
  11. kairo/backend/api/admin/users.py +279 -0
  12. kairo/backend/api/agents.py +94 -0
  13. kairo/backend/api/api_keys.py +85 -0
  14. kairo/backend/api/auth.py +116 -0
  15. kairo/backend/api/billing.py +41 -0
  16. kairo/backend/api/chat.py +72 -0
  17. kairo/backend/api/conversations.py +125 -0
  18. kairo/backend/api/device_auth.py +100 -0
  19. kairo/backend/api/files.py +83 -0
  20. kairo/backend/api/health.py +36 -0
  21. kairo/backend/api/images.py +80 -0
  22. kairo/backend/api/openai_compat.py +225 -0
  23. kairo/backend/api/projects.py +102 -0
  24. kairo/backend/api/usage.py +32 -0
  25. kairo/backend/api/webhooks.py +79 -0
  26. kairo/backend/app.py +297 -0
  27. kairo/backend/config.py +179 -0
  28. kairo/backend/core/__init__.py +0 -0
  29. kairo/backend/core/admin_auth.py +24 -0
  30. kairo/backend/core/api_key_auth.py +55 -0
  31. kairo/backend/core/database.py +28 -0
  32. kairo/backend/core/dependencies.py +70 -0
  33. kairo/backend/core/logging.py +23 -0
  34. kairo/backend/core/rate_limit.py +73 -0
  35. kairo/backend/core/security.py +29 -0
  36. kairo/backend/models/__init__.py +19 -0
  37. kairo/backend/models/agent.py +30 -0
  38. kairo/backend/models/api_key.py +25 -0
  39. kairo/backend/models/api_usage.py +29 -0
  40. kairo/backend/models/audit_log.py +26 -0
  41. kairo/backend/models/conversation.py +48 -0
  42. kairo/backend/models/device_code.py +30 -0
  43. kairo/backend/models/feature_flag.py +21 -0
  44. kairo/backend/models/image_generation.py +24 -0
  45. kairo/backend/models/incident.py +28 -0
  46. kairo/backend/models/project.py +28 -0
  47. kairo/backend/models/uptime_record.py +24 -0
  48. kairo/backend/models/usage.py +24 -0
  49. kairo/backend/models/user.py +49 -0
  50. kairo/backend/schemas/__init__.py +0 -0
  51. kairo/backend/schemas/admin/__init__.py +0 -0
  52. kairo/backend/schemas/admin/audit.py +28 -0
  53. kairo/backend/schemas/admin/content.py +53 -0
  54. kairo/backend/schemas/admin/stats.py +77 -0
  55. kairo/backend/schemas/admin/system.py +44 -0
  56. kairo/backend/schemas/admin/users.py +48 -0
  57. kairo/backend/schemas/agent.py +42 -0
  58. kairo/backend/schemas/api_key.py +30 -0
  59. kairo/backend/schemas/auth.py +57 -0
  60. kairo/backend/schemas/chat.py +26 -0
  61. kairo/backend/schemas/conversation.py +39 -0
  62. kairo/backend/schemas/device_auth.py +40 -0
  63. kairo/backend/schemas/image.py +15 -0
  64. kairo/backend/schemas/openai_compat.py +76 -0
  65. kairo/backend/schemas/project.py +21 -0
  66. kairo/backend/schemas/status.py +81 -0
  67. kairo/backend/schemas/usage.py +15 -0
  68. kairo/backend/services/__init__.py +0 -0
  69. kairo/backend/services/admin/__init__.py +0 -0
  70. kairo/backend/services/admin/audit_service.py +78 -0
  71. kairo/backend/services/admin/content_service.py +119 -0
  72. kairo/backend/services/admin/incident_service.py +94 -0
  73. kairo/backend/services/admin/stats_service.py +281 -0
  74. kairo/backend/services/admin/system_service.py +126 -0
  75. kairo/backend/services/admin/user_service.py +157 -0
  76. kairo/backend/services/agent_service.py +107 -0
  77. kairo/backend/services/api_key_service.py +66 -0
  78. kairo/backend/services/api_usage_service.py +126 -0
  79. kairo/backend/services/auth_service.py +101 -0
  80. kairo/backend/services/chat_service.py +501 -0
  81. kairo/backend/services/conversation_service.py +264 -0
  82. kairo/backend/services/device_auth_service.py +193 -0
  83. kairo/backend/services/email_service.py +55 -0
  84. kairo/backend/services/image_service.py +181 -0
  85. kairo/backend/services/llm_service.py +186 -0
  86. kairo/backend/services/project_service.py +109 -0
  87. kairo/backend/services/status_service.py +167 -0
  88. kairo/backend/services/stripe_service.py +78 -0
  89. kairo/backend/services/usage_service.py +150 -0
  90. kairo/backend/services/web_search_service.py +96 -0
  91. kairo/migrations/env.py +60 -0
  92. kairo/migrations/versions/001_initial.py +55 -0
  93. kairo/migrations/versions/002_usage_tracking_and_indexes.py +66 -0
  94. kairo/migrations/versions/003_username_to_email.py +21 -0
  95. kairo/migrations/versions/004_add_plans_and_verification.py +67 -0
  96. kairo/migrations/versions/005_add_projects.py +52 -0
  97. kairo/migrations/versions/006_add_image_generation.py +63 -0
  98. kairo/migrations/versions/007_add_admin_portal.py +107 -0
  99. kairo/migrations/versions/008_add_device_code_auth.py +76 -0
  100. kairo/migrations/versions/009_add_status_page.py +65 -0
  101. kairo/tools/extract_claude_data.py +465 -0
  102. kairo/tools/filter_claude_data.py +303 -0
  103. kairo/tools/generate_curated_data.py +157 -0
  104. kairo/tools/mix_training_data.py +295 -0
  105. kairo_code/__init__.py +3 -0
  106. kairo_code/agents/__init__.py +25 -0
  107. kairo_code/agents/architect.py +98 -0
  108. kairo_code/agents/audit.py +100 -0
  109. kairo_code/agents/base.py +463 -0
  110. kairo_code/agents/coder.py +155 -0
  111. kairo_code/agents/database.py +77 -0
  112. kairo_code/agents/docs.py +88 -0
  113. kairo_code/agents/explorer.py +62 -0
  114. kairo_code/agents/guardian.py +80 -0
  115. kairo_code/agents/planner.py +66 -0
  116. kairo_code/agents/reviewer.py +91 -0
  117. kairo_code/agents/security.py +94 -0
  118. kairo_code/agents/terraform.py +88 -0
  119. kairo_code/agents/testing.py +97 -0
  120. kairo_code/agents/uiux.py +88 -0
  121. kairo_code/auth.py +232 -0
  122. kairo_code/config.py +172 -0
  123. kairo_code/conversation.py +173 -0
  124. kairo_code/heartbeat.py +63 -0
  125. kairo_code/llm.py +291 -0
  126. kairo_code/logging_config.py +156 -0
  127. kairo_code/main.py +818 -0
  128. kairo_code/router.py +217 -0
  129. kairo_code/sandbox.py +248 -0
  130. kairo_code/settings.py +183 -0
  131. kairo_code/tools/__init__.py +51 -0
  132. kairo_code/tools/analysis.py +509 -0
  133. kairo_code/tools/base.py +417 -0
  134. kairo_code/tools/code.py +58 -0
  135. kairo_code/tools/definitions.py +617 -0
  136. kairo_code/tools/files.py +315 -0
  137. kairo_code/tools/review.py +390 -0
  138. kairo_code/tools/search.py +185 -0
  139. kairo_code/ui.py +418 -0
  140. kairo_code-0.1.0.dist-info/METADATA +13 -0
  141. kairo_code-0.1.0.dist-info/RECORD +144 -0
  142. kairo_code-0.1.0.dist-info/WHEEL +5 -0
  143. kairo_code-0.1.0.dist-info/entry_points.txt +2 -0
  144. kairo_code-0.1.0.dist-info/top_level.txt +4 -0
@@ -0,0 +1,88 @@
1
+ """UI/UX engineer agent — evaluates interfaces for usability and accessibility.
2
+
3
+ Mirrors the ui-ux-engineer Claude Code agent.
4
+ """
5
+
6
+ from .base import Agent
7
+
8
+
9
+ class UiUxAgent(Agent):
10
+ name = "uiux"
11
+ description = "Evaluates UI/UX for usability, accessibility, and design best practices"
12
+ max_iterations = 15
13
+ require_confirmation = []
14
+
15
+ def get_system_prompt(self) -> str:
16
+ tools_desc = self.tools.format_for_prompt()
17
+
18
+ return f"""You are the UI/UX Engineer Agent — an expert in user interface design, interaction patterns, usability principles, and accessibility standards. You serve as the project's UX guardian.
19
+
20
+ {tools_desc}
21
+
22
+ ## Your Expertise
23
+ - **Interaction Design**: Affordances, feedback, progressive disclosure, micro-interactions
24
+ - **Visual Design**: Hierarchy, proximity, alignment, contrast, repetition, white space
25
+ - **Accessibility**: WCAG 2.1/2.2, ARIA patterns, keyboard navigation, screen readers
26
+ - **Responsive Design**: Mobile-first, breakpoints, touch targets, adaptive layouts
27
+ - **Design Systems**: Component consistency, pattern libraries, design tokens
28
+ - **Cognitive Psychology**: Mental models, cognitive load, recognition over recall
29
+
30
+ ## Evaluation Framework
31
+
32
+ ### 1. Usability & Clarity
33
+ - Is purpose immediately clear? Are labels self-explanatory?
34
+ - Appropriate feedback for user actions?
35
+ - Error messages helpful and actionable?
36
+ - Cognitive load minimized?
37
+
38
+ ### 2. Visual Hierarchy & Layout
39
+ - Does hierarchy guide attention appropriately?
40
+ - Consistent, purposeful spacing? Precise alignment?
41
+ - Natural reading patterns (F-pattern, Z-pattern)?
42
+
43
+ ### 3. Consistency
44
+ - Similar elements behave similarly?
45
+ - Interaction patterns consistent across the interface?
46
+ - Aligned with project design system?
47
+
48
+ ### 4. Accessibility
49
+ - Semantic HTML used appropriately?
50
+ - ARIA roles and attributes correct?
51
+ - Color contrast sufficient (4.5:1 text, 3:1 UI)?
52
+ - Keyboard navigation logical and complete?
53
+ - Focus states visible and meaningful?
54
+ - Form fields properly labeled?
55
+
56
+ ### 5. Interaction States
57
+ - All states handled: default, hover, focus, active, disabled?
58
+ - Loading, empty, error, success states present?
59
+
60
+ ### 6. Responsiveness
61
+ - Layout adapts across breakpoints?
62
+ - Touch targets >= 44x44px?
63
+
64
+ ## TOOL FORMAT
65
+ <tool>tool_name</tool>
66
+ <params>{{"key": "value"}}</params>
67
+
68
+ ## Output Format
69
+
70
+ **Summary**: Brief overview of UI/UX quality and main findings.
71
+
72
+ **Critical Issues**: Problems significantly harming usability or accessibility.
73
+ - Issue, user impact, recommended fix with code if applicable.
74
+
75
+ **Improvements**: Opportunities to enhance the experience.
76
+ - Current vs. recommended state, rationale, implementation suggestion.
77
+
78
+ **Strengths**: What's working well.
79
+
80
+ ## Reference Heuristics
81
+ - **Nielsen's 10**: Visibility, match with real world, user control, consistency, error prevention, recognition, flexibility, aesthetic design, error recovery, help
82
+ - **Gestalt Principles**: Proximity, similarity, continuity, closure, figure-ground
83
+ - **Fitts's Law**: Important targets large and close
84
+ - **Hick's Law**: Minimize choices to reduce decision time
85
+ - **Miller's Law**: Chunk information (7 plus or minus 2 items)"""
86
+
87
+ def get_tool_examples(self) -> str:
88
+ return ""
kairo_code/auth.py ADDED
@@ -0,0 +1,232 @@
1
+ """Browser-based authentication for Kairo Code CLI.
2
+
3
+ Implements RFC 8628 Device Code flow:
4
+ 1. Request a device code from the Kairo backend
5
+ 2. Open browser for user approval
6
+ 3. Poll for token until approved/denied/expired
7
+ 4. Save credentials locally with restrictive permissions
8
+ """
9
+
10
+ import sys
11
+ import time
12
+ import webbrowser
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+ import yaml
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+
21
+ console = Console()
22
+
23
+ CREDENTIALS_DIR = Path.home() / ".kairo_code"
24
+ CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.yaml"
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Credential storage
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def load_credentials() -> dict[str, Any] | None:
32
+ """Load saved CLI credentials. Returns dict with api_key, email, plan or None."""
33
+ if not CREDENTIALS_FILE.exists():
34
+ return None
35
+ try:
36
+ with open(CREDENTIALS_FILE) as f:
37
+ data = yaml.safe_load(f)
38
+ if data and data.get("api_key"):
39
+ return data
40
+ except Exception:
41
+ pass
42
+ return None
43
+
44
+
45
+ def save_credentials(api_key: str, email: str, plan: str) -> None:
46
+ """Save CLI credentials with restrictive permissions (0600)."""
47
+ CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
48
+ data = {"api_key": api_key, "email": email, "plan": plan}
49
+ with open(CREDENTIALS_FILE, "w") as f:
50
+ yaml.dump(data, f, default_flow_style=False)
51
+ CREDENTIALS_FILE.chmod(0o600)
52
+
53
+
54
+ def clear_credentials() -> None:
55
+ """Delete saved credentials."""
56
+ if CREDENTIALS_FILE.exists():
57
+ CREDENTIALS_FILE.unlink()
58
+
59
+
60
+ def get_api_key() -> str | None:
61
+ """Get saved API key, or None if not authenticated."""
62
+ creds = load_credentials()
63
+ return creds["api_key"] if creds else None
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Device code authentication flow
68
+ # ---------------------------------------------------------------------------
69
+
70
+ def _derive_base_url(cloud_endpoint: str) -> str:
71
+ """Strip /v1 suffix to get the base API URL."""
72
+ base_url = cloud_endpoint.rstrip("/")
73
+ if base_url.endswith("/v1"):
74
+ base_url = base_url[:-3]
75
+ return base_url
76
+
77
+
78
+ def _request_device_code(base_url: str) -> dict | None:
79
+ """POST /api/auth/device/code to obtain a device code."""
80
+ try:
81
+ resp = httpx.post(
82
+ f"{base_url}/api/auth/device/code",
83
+ json={"client_name": "kairo-code"},
84
+ timeout=10,
85
+ )
86
+ resp.raise_for_status()
87
+ return resp.json()
88
+ except Exception as e:
89
+ console.print(f"[red]Failed to start authentication: {e}[/]")
90
+ return None
91
+
92
+
93
+ def _display_verification_prompt(verification_uri_complete: str, user_code: str) -> None:
94
+ """Show the verification URL and user code, then open the browser."""
95
+ console.print()
96
+ console.print(Panel(
97
+ f"[bold]To authenticate, open this URL:[/]\n\n"
98
+ f" [cyan underline]{verification_uri_complete}[/]\n\n"
99
+ f"[bold]Then confirm this code:[/] [bold yellow]{user_code}[/]",
100
+ title="[bold]Kairo Code -- Authentication[/]",
101
+ border_style="cyan",
102
+ padding=(1, 3),
103
+ ))
104
+ console.print()
105
+
106
+ try:
107
+ webbrowser.open(verification_uri_complete)
108
+ console.print("[dim]Browser opened. If it didn't open, copy the URL above.[/]")
109
+ except Exception:
110
+ console.print("[dim]Copy the URL above and open it in your browser.[/]")
111
+
112
+
113
+ def _poll_for_token(base_url: str, device_code: str, expires_in: int, interval: int) -> dict | None:
114
+ """Poll /api/auth/device/token until approval, denial, or timeout.
115
+
116
+ Returns the token response dict on success, or None on failure.
117
+ """
118
+ console.print("[dim]Waiting for approval... (press Ctrl+C to cancel)[/]\n")
119
+ deadline = time.time() + expires_in
120
+
121
+ try:
122
+ while time.time() < deadline:
123
+ time.sleep(interval)
124
+
125
+ try:
126
+ resp = httpx.post(
127
+ f"{base_url}/api/auth/device/token",
128
+ json={"device_code": device_code},
129
+ timeout=10,
130
+ )
131
+ except Exception:
132
+ continue # Network error, retry
133
+
134
+ if resp.status_code == 200:
135
+ return resp.json()
136
+
137
+ if resp.status_code == 400:
138
+ error_code = _extract_error_code(resp)
139
+
140
+ if error_code == "authorization_pending":
141
+ continue
142
+ elif error_code == "slow_down":
143
+ interval += 1
144
+ continue
145
+ elif error_code == "expired_token":
146
+ console.print("[red]Authentication expired. Please try again.[/]")
147
+ return None
148
+ elif error_code == "access_denied":
149
+ console.print("[red]Access denied. Kairo Code requires a Max plan.[/]")
150
+ return None
151
+
152
+ console.print("[red]Authentication timed out. Please try again.[/]")
153
+ return None
154
+
155
+ except KeyboardInterrupt:
156
+ console.print("\n[dim]Authentication cancelled.[/]")
157
+ return None
158
+
159
+
160
+ def _extract_error_code(resp: httpx.Response) -> str:
161
+ """Extract the error code string from a 400 response."""
162
+ try:
163
+ error_data = resp.json()
164
+ error = error_data.get("detail", {})
165
+ if isinstance(error, dict):
166
+ return error.get("error", "")
167
+ return str(error)
168
+ except Exception:
169
+ return ""
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Public API
174
+ # ---------------------------------------------------------------------------
175
+
176
+ def authenticate(cloud_endpoint: str) -> str | None:
177
+ """Run the full device code auth flow.
178
+
179
+ Returns the API key on success, or None on failure/cancellation.
180
+ """
181
+ base_url = _derive_base_url(cloud_endpoint)
182
+
183
+ # Step 1: Request device code
184
+ data = _request_device_code(base_url)
185
+ if data is None:
186
+ return None
187
+
188
+ device_code = data["device_code"]
189
+ user_code = data["user_code"]
190
+ verification_uri_complete = data["verification_uri_complete"]
191
+ expires_in = data["expires_in"]
192
+ interval = data["interval"]
193
+
194
+ # Step 2: Display prompt and open browser
195
+ _display_verification_prompt(verification_uri_complete, user_code)
196
+
197
+ # Step 3: Poll for approval
198
+ token_data = _poll_for_token(base_url, device_code, expires_in, interval)
199
+ if token_data is None:
200
+ return None
201
+
202
+ api_key = token_data["access_token"]
203
+ email = token_data.get("email", "")
204
+ plan = token_data.get("plan", "max")
205
+
206
+ save_credentials(api_key, email, plan)
207
+ console.print(f"[green bold]Authenticated as {email}[/]")
208
+ console.print()
209
+ return api_key
210
+
211
+
212
+ def ensure_authenticated(cloud_endpoint: str) -> str | None:
213
+ """Check for saved credentials or trigger auth flow. Returns api_key or None."""
214
+ creds = load_credentials()
215
+ if creds and creds.get("api_key"):
216
+ base_url = _derive_base_url(cloud_endpoint)
217
+ try:
218
+ resp = httpx.get(
219
+ f"{base_url}/v1/models",
220
+ headers={"Authorization": f"Bearer {creds['api_key']}"},
221
+ timeout=5,
222
+ )
223
+ if resp.status_code == 200:
224
+ console.print(f"[dim]Authenticated as {creds.get('email', 'unknown')}[/]")
225
+ return creds["api_key"]
226
+ except Exception:
227
+ pass
228
+
229
+ console.print("[yellow]Saved credentials are invalid. Re-authenticating...[/]")
230
+ clear_credentials()
231
+
232
+ return authenticate(cloud_endpoint)
kairo_code/config.py ADDED
@@ -0,0 +1,172 @@
1
+ """Configuration loader for Kairo Code"""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ def find_config() -> Path:
11
+ """Find config.yaml in current directory or parent directories."""
12
+ current = Path.cwd()
13
+
14
+ # Check current and parent directories
15
+ for path in [current] + list(current.parents):
16
+ config_path = path / "config.yaml"
17
+ if config_path.exists():
18
+ return config_path
19
+
20
+ # Fall back to package directory
21
+ package_dir = Path(__file__).parent.parent
22
+ config_path = package_dir / "config.yaml"
23
+ if config_path.exists():
24
+ return config_path
25
+
26
+ raise FileNotFoundError("config.yaml not found")
27
+
28
+
29
+ def load_config(config_path: Path | None = None) -> dict[str, Any]:
30
+ """Load configuration from YAML file."""
31
+ if config_path is None:
32
+ config_path = find_config()
33
+
34
+ with open(config_path) as f:
35
+ config = yaml.safe_load(f)
36
+
37
+ # Override with environment variables if set
38
+ if os.getenv("KAIRO_CLOUD_ENDPOINT"):
39
+ config.setdefault("cloud", {})["endpoint"] = os.getenv("KAIRO_CLOUD_ENDPOINT")
40
+
41
+ if os.getenv("KAIRO_CLOUD_API_KEY"):
42
+ config.setdefault("cloud", {})["api_key"] = os.getenv("KAIRO_CLOUD_API_KEY")
43
+
44
+ if os.getenv("KAIRO_ROUTER_MODEL"):
45
+ config["models"]["router"] = os.getenv("KAIRO_ROUTER_MODEL")
46
+
47
+ if os.getenv("KAIRO_CODER_MODEL"):
48
+ config["models"]["coder"] = os.getenv("KAIRO_CODER_MODEL")
49
+
50
+ return config
51
+
52
+
53
+ class Config:
54
+ """Configuration singleton for easy access throughout the app."""
55
+
56
+ _instance: "Config | None" = None
57
+
58
+ def __new__(cls) -> "Config":
59
+ if cls._instance is None:
60
+ cls._instance = super().__new__(cls)
61
+ cls._instance._config = load_config()
62
+ return cls._instance
63
+
64
+ @property
65
+ def router_model(self) -> str:
66
+ return self._config["models"]["router"]
67
+
68
+ @property
69
+ def coder_model(self) -> str:
70
+ return self._config["models"]["coder"]
71
+
72
+ @property
73
+ def chat_model(self) -> str:
74
+ return self._config["models"]["chat"]
75
+
76
+ @property
77
+ def cloud_endpoint(self) -> str:
78
+ return self._config.get("cloud", {}).get("endpoint", "http://localhost:8000/v1")
79
+
80
+ @property
81
+ def cloud_api_key(self) -> str:
82
+ # Priority: env var / config file > saved CLI credentials > placeholder
83
+ key = self._config.get("cloud", {}).get("api_key", "")
84
+ if key:
85
+ return key
86
+ # Check saved CLI credentials
87
+ from kairo_code.auth import get_api_key
88
+ cli_key = get_api_key()
89
+ if cli_key:
90
+ return cli_key
91
+ # OpenAI client requires a non-empty string
92
+ return "not-needed"
93
+
94
+ @property
95
+ def cloud_timeout(self) -> int:
96
+ return self._config.get("cloud", {}).get("timeout", 120)
97
+
98
+ @property
99
+ def search_max_results(self) -> int:
100
+ return self._config["search"]["max_results"]
101
+
102
+ @property
103
+ def max_history(self) -> int:
104
+ return self._config["conversation"]["max_history"]
105
+
106
+ @property
107
+ def system_prompt(self) -> str:
108
+ return self._config["conversation"]["system_prompt"]
109
+
110
+ @property
111
+ def router_prompt(self) -> str:
112
+ return self._config["router"]["system_prompt"]
113
+
114
+ # Safety settings
115
+ @property
116
+ def auto_approve(self) -> bool:
117
+ return self._config.get("safety", {}).get("auto_approve", False)
118
+
119
+ @property
120
+ def auto_approve_tools(self) -> list[str]:
121
+ return self._config.get("safety", {}).get("auto_approve_tools", [])
122
+
123
+ @property
124
+ def blocked_commands(self) -> list[str]:
125
+ return self._config.get("safety", {}).get("blocked_commands", [])
126
+
127
+ @property
128
+ def allowed_commands(self) -> list[str]:
129
+ return self._config.get("safety", {}).get("allowed_commands", [])
130
+
131
+ @property
132
+ def max_file_lines(self) -> int:
133
+ return self._config.get("safety", {}).get("max_file_lines", 500)
134
+
135
+ @property
136
+ def max_iterations(self) -> int:
137
+ return self._config.get("safety", {}).get("max_iterations", 12)
138
+
139
+ def should_auto_approve(self, tool_name: str) -> bool:
140
+ """Check if a tool should be auto-approved."""
141
+ if self.auto_approve:
142
+ return True
143
+ return tool_name in self.auto_approve_tools
144
+
145
+ def is_command_allowed(self, command: str) -> tuple[bool, str]:
146
+ """Check if a bash command is allowed. Returns (allowed, reason)."""
147
+ cmd_lower = command.lower().strip()
148
+
149
+ # Check blocked commands
150
+ for blocked in self.blocked_commands:
151
+ if blocked.lower() in cmd_lower:
152
+ return False, f"Command contains blocked pattern: {blocked}"
153
+
154
+ # Check allowed commands (if whitelist is set)
155
+ if self.allowed_commands:
156
+ for allowed in self.allowed_commands:
157
+ if cmd_lower.startswith(allowed.lower()):
158
+ return True, ""
159
+ return False, "Command not in allowed list"
160
+
161
+ return True, ""
162
+
163
+ def get(self, key: str, default: Any = None) -> Any:
164
+ """Get a config value by dot-notation key."""
165
+ keys = key.split(".")
166
+ value = self._config
167
+ for k in keys:
168
+ if isinstance(value, dict) and k in value:
169
+ value = value[k]
170
+ else:
171
+ return default
172
+ return value
@@ -0,0 +1,173 @@
1
+ """Conversation manager for maintaining chat history with local persistence."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import stat
7
+ import uuid
8
+ from dataclasses import dataclass, field, asdict
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .config import Config
14
+
15
+
16
+ CONVERSATIONS_DIR = Path.home() / ".kairo" / "conversations"
17
+
18
+ # Only allow alphanumeric + hyphens for conversation IDs (no path traversal)
19
+ _SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
20
+
21
+
22
+ def _validate_conversation_id(cid: str) -> str:
23
+ """Validate conversation ID to prevent path traversal."""
24
+ if not _SAFE_ID_RE.match(cid):
25
+ raise ValueError(
26
+ f"Invalid conversation ID: {cid!r}. "
27
+ "Only alphanumeric characters, hyphens, and underscores are allowed (max 64 chars)."
28
+ )
29
+ return cid
30
+
31
+
32
+ @dataclass
33
+ class Message:
34
+ """A single message in the conversation."""
35
+ role: str # "user", "assistant", "system"
36
+ content: str
37
+ metadata: dict[str, Any] = field(default_factory=dict)
38
+
39
+
40
+ class Conversation:
41
+ """Manages conversation history with context window limits and local persistence."""
42
+
43
+ def __init__(self, system_prompt: str | None = None, conversation_id: str | None = None):
44
+ self.config = Config()
45
+ self.system_prompt = system_prompt or self.config.system_prompt
46
+ self.messages: list[Message] = []
47
+ self.id = _validate_conversation_id(conversation_id) if conversation_id else uuid.uuid4().hex[:12]
48
+ self.created_at = datetime.now(timezone.utc).isoformat()
49
+
50
+ def add_user(self, content: str) -> None:
51
+ """Add a user message."""
52
+ self.messages.append(Message(role="user", content=content))
53
+ self._trim_history()
54
+ self._auto_save()
55
+
56
+ def add_assistant(self, content: str) -> None:
57
+ """Add an assistant message."""
58
+ self.messages.append(Message(role="assistant", content=content))
59
+ self._trim_history()
60
+ self._auto_save()
61
+
62
+ def add_system(self, content: str) -> None:
63
+ """Add a system message."""
64
+ self.messages.append(Message(role="system", content=content))
65
+
66
+ def _trim_history(self) -> None:
67
+ """Trim history to stay within max_history limit."""
68
+ max_messages = self.config.max_history
69
+ if len(self.messages) > max_messages:
70
+ self.messages = self.messages[-max_messages:]
71
+
72
+ def to_messages(self) -> list[dict[str, str]]:
73
+ """Convert to format expected by OpenAI-compatible API."""
74
+ result = [{"role": "system", "content": self.system_prompt}]
75
+ for msg in self.messages:
76
+ result.append({"role": msg.role, "content": msg.content})
77
+ return result
78
+
79
+ def clear(self) -> None:
80
+ """Clear conversation history."""
81
+ self.messages = []
82
+
83
+ def get_context_summary(self) -> str:
84
+ """Get a brief summary of the conversation context."""
85
+ if not self.messages:
86
+ return "No conversation history"
87
+ return f"{len(self.messages)} messages in history"
88
+
89
+ # ── Persistence ──────────────────────────────────────────────
90
+
91
+ def _auto_save(self) -> None:
92
+ """Auto-save after each message."""
93
+ try:
94
+ self.save()
95
+ except Exception:
96
+ pass # Don't break the conversation if save fails
97
+
98
+ def save(self) -> Path:
99
+ """Save conversation to disk with secure permissions."""
100
+ CONVERSATIONS_DIR.mkdir(parents=True, exist_ok=True)
101
+ # Secure the directory: owner-only access
102
+ try:
103
+ os.chmod(CONVERSATIONS_DIR, stat.S_IRWXU) # 0700
104
+ except OSError:
105
+ pass # Windows doesn't support Unix permissions
106
+ path = CONVERSATIONS_DIR / f"{self.id}.json"
107
+ data = {
108
+ "id": self.id,
109
+ "created_at": self.created_at,
110
+ "updated_at": datetime.now(timezone.utc).isoformat(),
111
+ "system_prompt": self.system_prompt,
112
+ "messages": [asdict(m) for m in self.messages],
113
+ }
114
+ path.write_text(json.dumps(data, indent=2))
115
+ # Secure the file: owner read/write only
116
+ try:
117
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0600
118
+ except OSError:
119
+ pass
120
+ return path
121
+
122
+ @classmethod
123
+ def load(cls, conversation_id: str) -> "Conversation":
124
+ """Load a conversation from disk."""
125
+ _validate_conversation_id(conversation_id)
126
+ path = CONVERSATIONS_DIR / f"{conversation_id}.json"
127
+ if not path.exists():
128
+ raise FileNotFoundError(f"Conversation {conversation_id} not found")
129
+
130
+ data = json.loads(path.read_text())
131
+ conv = cls(
132
+ system_prompt=data.get("system_prompt"),
133
+ conversation_id=data["id"],
134
+ )
135
+ conv.created_at = data.get("created_at", conv.created_at)
136
+ conv.messages = [
137
+ Message(
138
+ role=m["role"],
139
+ content=m["content"],
140
+ metadata=m.get("metadata", {}),
141
+ )
142
+ for m in data.get("messages", [])
143
+ ]
144
+ return conv
145
+
146
+ @staticmethod
147
+ def list_saved() -> list[dict[str, str]]:
148
+ """List all saved conversations (id, created_at, preview)."""
149
+ if not CONVERSATIONS_DIR.exists():
150
+ return []
151
+
152
+ conversations = []
153
+ for path in sorted(CONVERSATIONS_DIR.glob("*.json"), reverse=True):
154
+ try:
155
+ data = json.loads(path.read_text())
156
+ # Get first user message as preview
157
+ preview = ""
158
+ for m in data.get("messages", []):
159
+ if m.get("role") == "user":
160
+ preview = m["content"][:80]
161
+ break
162
+
163
+ conversations.append({
164
+ "id": data["id"],
165
+ "created_at": data.get("created_at", ""),
166
+ "updated_at": data.get("updated_at", ""),
167
+ "preview": preview,
168
+ "message_count": str(len(data.get("messages", []))),
169
+ })
170
+ except Exception:
171
+ continue
172
+
173
+ return conversations