kubrick-cli 0.1.2__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.
kubrick_cli/config.py ADDED
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env python3
2
+ """Configuration management for Kubrick CLI."""
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ class KubrickConfig:
10
+ """Manages Kubrick configuration and data directories."""
11
+
12
+ def __init__(self, skip_wizard: bool = False):
13
+ """
14
+ Initialize config manager and ensure directories exist.
15
+
16
+ Args:
17
+ skip_wizard: Skip setup wizard even if config doesn't exist (for testing)
18
+ """
19
+ # Use ~/.kubrick in all environments (Docker and non-Docker)
20
+ # In Docker, this resolves to /home/kubrick/.kubrick
21
+ # Outside Docker, this resolves to the current user's home directory
22
+ self.kubrick_dir = Path.home() / ".kubrick"
23
+ self.config_file = self.kubrick_dir / "config.json"
24
+ self.conversations_dir = self.kubrick_dir / "conversations"
25
+
26
+ # Ensure directories exist
27
+ self._ensure_directories()
28
+
29
+ # Load or create config (with optional setup wizard)
30
+ self.config = self._load_config(skip_wizard=skip_wizard)
31
+
32
+ def _ensure_directories(self):
33
+ """Create necessary directories if they don't exist."""
34
+ self.kubrick_dir.mkdir(exist_ok=True)
35
+ self.conversations_dir.mkdir(exist_ok=True)
36
+
37
+ def _load_config(self, skip_wizard: bool = False) -> Dict[str, Any]:
38
+ """
39
+ Load configuration from file or create default.
40
+
41
+ Args:
42
+ skip_wizard: Skip setup wizard even if config doesn't exist
43
+
44
+ Returns:
45
+ Configuration dictionary
46
+ """
47
+ if self.config_file.exists():
48
+ try:
49
+ with open(self.config_file, "r") as f:
50
+ loaded_config = json.load(f)
51
+
52
+ # Merge with defaults to ensure all keys exist
53
+ default_config = self._get_default_config()
54
+ default_config.update(loaded_config)
55
+ return default_config
56
+
57
+ except (json.JSONDecodeError, IOError):
58
+ # If config is corrupted, start fresh
59
+ return self._get_default_config()
60
+ else:
61
+ # First time setup - run wizard unless skipped
62
+ if not skip_wizard:
63
+ from .setup_wizard import SetupWizard
64
+
65
+ wizard_config = SetupWizard.run()
66
+
67
+ # Merge wizard config with defaults
68
+ config = self._get_default_config()
69
+ config.update(wizard_config)
70
+
71
+ self._save_config(config)
72
+ return config
73
+ else:
74
+ # Create default config without wizard
75
+ config = self._get_default_config()
76
+ self._save_config(config)
77
+ return config
78
+
79
+ def _get_default_config(self) -> Dict[str, Any]:
80
+ """Get default configuration values."""
81
+ return {
82
+ # Provider settings
83
+ "provider": "triton",
84
+ # Triton settings
85
+ "triton_url": "localhost:8000",
86
+ "triton_model": "llm_decoupled",
87
+ # OpenAI settings
88
+ "openai_api_key": None,
89
+ "openai_model": "gpt-4",
90
+ # Anthropic settings
91
+ "anthropic_api_key": None,
92
+ "anthropic_model": "claude-sonnet-4-5-20250929",
93
+ # Legacy compatibility
94
+ "model_name": "llm_decoupled",
95
+ "use_openai": False,
96
+ "default_working_dir": None,
97
+ # Agent loop settings
98
+ "max_iterations": 15,
99
+ "max_tools_per_turn": 5,
100
+ "total_timeout_seconds": 600,
101
+ "enable_parallel_tools": True,
102
+ "max_parallel_workers": 3,
103
+ # Safety settings
104
+ "require_dangerous_command_confirmation": True,
105
+ "tool_timeout_seconds": 30,
106
+ "max_file_size_mb": 10,
107
+ # Display settings
108
+ "display_mode": "natural", # natural, json, verbose
109
+ "show_tool_results": True,
110
+ "show_progress": True,
111
+ # Task classification settings
112
+ "enable_task_classification": True,
113
+ "enable_planning_phase": True,
114
+ # Conversation settings
115
+ "auto_save_conversations": True,
116
+ "max_conversations": 100,
117
+ }
118
+
119
+ def _save_config(self, config: Dict[str, Any]):
120
+ """Save configuration to file."""
121
+ with open(self.config_file, "w") as f:
122
+ json.dump(config, f, indent=2)
123
+
124
+ def get(self, key: str, default: Any = None) -> Any:
125
+ """Get a configuration value."""
126
+ return self.config.get(key, default)
127
+
128
+ def set(self, key: str, value: Any):
129
+ """Set a configuration value and save."""
130
+ self.config[key] = value
131
+ self._save_config(self.config)
132
+
133
+ def get_all(self) -> Dict[str, Any]:
134
+ """Get all configuration values."""
135
+ return self.config.copy()
136
+
137
+ def save_conversation(
138
+ self, conversation_id: str, messages: list, metadata: Dict = None
139
+ ):
140
+ """
141
+ Save a conversation to disk.
142
+
143
+ Args:
144
+ conversation_id: Unique identifier for the conversation (e.g., timestamp)
145
+ messages: List of message dictionaries
146
+ metadata: Optional metadata (working_dir, triton_url, etc.)
147
+ """
148
+ conversation_file = self.conversations_dir / f"{conversation_id}.json"
149
+
150
+ data = {
151
+ "id": conversation_id,
152
+ "messages": messages,
153
+ "metadata": metadata or {},
154
+ }
155
+
156
+ with open(conversation_file, "w") as f:
157
+ json.dump(data, f, indent=2)
158
+
159
+ # Clean up old conversations if we exceed max
160
+ self._cleanup_old_conversations()
161
+
162
+ def load_conversation(self, conversation_id: str) -> Optional[Dict]:
163
+ """
164
+ Load a conversation from disk.
165
+
166
+ Args:
167
+ conversation_id: Unique identifier for the conversation
168
+
169
+ Returns:
170
+ Dictionary with 'id', 'messages', and 'metadata', or None if not found
171
+ """
172
+ conversation_file = self.conversations_dir / f"{conversation_id}.json"
173
+
174
+ if not conversation_file.exists():
175
+ return None
176
+
177
+ try:
178
+ with open(conversation_file, "r") as f:
179
+ return json.load(f)
180
+ except (json.JSONDecodeError, IOError):
181
+ return None
182
+
183
+ def list_conversations(self, limit: int = None) -> list:
184
+ """
185
+ List all saved conversations.
186
+
187
+ Args:
188
+ limit: Optional limit on number of conversations to return
189
+
190
+ Returns:
191
+ List of conversation metadata sorted by modification time (newest first)
192
+ """
193
+ conversations = []
194
+
195
+ for conv_file in self.conversations_dir.glob("*.json"):
196
+ try:
197
+ with open(conv_file, "r") as f:
198
+ data = json.load(f)
199
+
200
+ conversations.append(
201
+ {
202
+ "id": data.get("id", conv_file.stem),
203
+ "metadata": data.get("metadata", {}),
204
+ "message_count": len(data.get("messages", [])),
205
+ "modified": conv_file.stat().st_mtime,
206
+ }
207
+ )
208
+ except (json.JSONDecodeError, IOError):
209
+ continue
210
+
211
+ # Sort by modification time (newest first)
212
+ conversations.sort(key=lambda x: x["modified"], reverse=True)
213
+
214
+ if limit:
215
+ conversations = conversations[:limit]
216
+
217
+ return conversations
218
+
219
+ def delete_conversation(self, conversation_id: str) -> bool:
220
+ """
221
+ Delete a conversation.
222
+
223
+ Args:
224
+ conversation_id: Unique identifier for the conversation
225
+
226
+ Returns:
227
+ True if deleted, False if not found
228
+ """
229
+ conversation_file = self.conversations_dir / f"{conversation_id}.json"
230
+
231
+ if conversation_file.exists():
232
+ conversation_file.unlink()
233
+ return True
234
+ return False
235
+
236
+ def _cleanup_old_conversations(self):
237
+ """Remove oldest conversations if we exceed max_conversations."""
238
+ max_conversations = self.config.get("max_conversations", 100)
239
+
240
+ conversations = list(self.conversations_dir.glob("*.json"))
241
+
242
+ if len(conversations) > max_conversations:
243
+ # Sort by modification time (oldest first)
244
+ conversations.sort(key=lambda x: x.stat().st_mtime)
245
+
246
+ # Delete oldest conversations
247
+ for conv_file in conversations[: len(conversations) - max_conversations]:
248
+ conv_file.unlink()
kubrick_cli/display.py ADDED
@@ -0,0 +1,165 @@
1
+ """Display manager for natural language tool output."""
2
+
3
+ import json
4
+ from typing import Any, Dict
5
+
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.syntax import Syntax
9
+
10
+ console = Console()
11
+
12
+
13
+ class DisplayManager:
14
+ """
15
+ Manages display of tool calls and results in natural language.
16
+
17
+ Supports multiple display modes:
18
+ - natural: Human-friendly descriptions (default)
19
+ - json: Raw JSON panels (old behavior)
20
+ - verbose: Both natural + JSON
21
+ """
22
+
23
+ def __init__(self, config: Dict):
24
+ """
25
+ Initialize display manager.
26
+
27
+ Args:
28
+ config: Configuration dict with display settings
29
+ """
30
+ self.display_mode = config.get("display_mode", "natural")
31
+ self.show_tool_results = config.get("show_tool_results", True)
32
+
33
+ def display_tool_call(self, tool_name: str, parameters: Dict[str, Any]):
34
+ """
35
+ Display a tool call in the configured mode.
36
+
37
+ Args:
38
+ tool_name: Name of the tool being called
39
+ parameters: Tool parameters
40
+ """
41
+ if self.display_mode == "natural":
42
+ self._display_natural_tool_call(tool_name, parameters)
43
+ elif self.display_mode == "json":
44
+ self._display_json_tool_call(tool_name, parameters)
45
+ elif self.display_mode == "verbose":
46
+ self._display_natural_tool_call(tool_name, parameters)
47
+ self._display_json_tool_call(tool_name, parameters)
48
+
49
+ def display_tool_result(
50
+ self, tool_name: str, result: Dict[str, Any], success: bool
51
+ ):
52
+ """
53
+ Display a tool execution result.
54
+
55
+ Args:
56
+ tool_name: Name of the tool that was executed
57
+ result: Result dictionary
58
+ success: Whether the execution succeeded
59
+ """
60
+ if not self.show_tool_results:
61
+ return
62
+
63
+ if self.display_mode == "natural":
64
+ self._display_natural_result(tool_name, result, success)
65
+ elif self.display_mode in ("json", "verbose"):
66
+ self._display_json_result(tool_name, result, success)
67
+
68
+ def _display_natural_tool_call(
69
+ self, tool_name: str, parameters: Dict[str, Any]
70
+ ):
71
+ """Display tool call in natural language."""
72
+ description = self._get_natural_description(tool_name, parameters)
73
+ console.print(f"[cyan]→ {description}[/cyan]")
74
+
75
+ def _display_json_tool_call(
76
+ self, tool_name: str, parameters: Dict[str, Any]
77
+ ):
78
+ """Display tool call as JSON panel."""
79
+ tool_data = {"tool": tool_name, "parameters": parameters}
80
+ json_str = json.dumps(tool_data, indent=2)
81
+ syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False)
82
+ console.print(Panel(syntax, title="Tool Call", border_style="cyan"))
83
+
84
+ def _display_natural_result(
85
+ self, tool_name: str, result: Dict[str, Any], success: bool
86
+ ):
87
+ """Display result in natural language."""
88
+ if success:
89
+ # Extract key information from result
90
+ result_text = result.get("result", "")
91
+
92
+ # Truncate long results for display
93
+ if len(result_text) > 200:
94
+ preview = result_text[:200] + "..."
95
+ console.print(
96
+ f"[green]✓ {tool_name} succeeded[/green] [dim](output truncated)[/dim]"
97
+ )
98
+ else:
99
+ console.print(f"[green]✓ {tool_name} succeeded[/green]")
100
+ else:
101
+ error = result.get("error", "Unknown error")
102
+ console.print(f"[red]✗ {tool_name} failed: {error}[/red]")
103
+
104
+ def _display_json_result(
105
+ self, tool_name: str, result: Dict[str, Any], success: bool
106
+ ):
107
+ """Display result as JSON panel."""
108
+ json_str = json.dumps(result, indent=2)
109
+ syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False)
110
+ border_style = "green" if success else "red"
111
+ title = f"Result: {tool_name}"
112
+ console.print(Panel(syntax, title=title, border_style=border_style))
113
+
114
+ def _get_natural_description(
115
+ self, tool_name: str, parameters: Dict[str, Any]
116
+ ) -> str:
117
+ """
118
+ Generate natural language description for a tool call.
119
+
120
+ Args:
121
+ tool_name: Name of the tool
122
+ parameters: Tool parameters
123
+
124
+ Returns:
125
+ Human-friendly description
126
+ """
127
+ if tool_name == "read_file":
128
+ file_path = parameters.get("file_path", "unknown")
129
+ return f"Reading {file_path}"
130
+
131
+ elif tool_name == "write_file":
132
+ file_path = parameters.get("file_path", "unknown")
133
+ content_length = len(parameters.get("content", ""))
134
+ return (
135
+ f"Writing {content_length} characters to {file_path}"
136
+ )
137
+
138
+ elif tool_name == "edit_file":
139
+ file_path = parameters.get("file_path", "unknown")
140
+ return f"Editing {file_path}"
141
+
142
+ elif tool_name == "list_files":
143
+ pattern = parameters.get("pattern", "*")
144
+ directory = parameters.get("directory", ".")
145
+ return f"Listing files matching '{pattern}' in {directory}"
146
+
147
+ elif tool_name == "search_files":
148
+ pattern = parameters.get("pattern", "")
149
+ file_pattern = parameters.get("file_pattern", "**/*")
150
+ return f"Searching for '{pattern}' in files matching '{file_pattern}'"
151
+
152
+ elif tool_name == "run_bash":
153
+ command = parameters.get("command", "")
154
+ # Truncate long commands
155
+ if len(command) > 60:
156
+ command = command[:60] + "..."
157
+ return f"Running bash command: {command}"
158
+
159
+ elif tool_name == "create_directory":
160
+ path = parameters.get("path", "unknown")
161
+ return f"Creating directory {path}"
162
+
163
+ else:
164
+ # Fallback for unknown tools
165
+ return f"Calling {tool_name}"
@@ -0,0 +1,197 @@
1
+ """Execution strategy configuration based on task complexity."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Dict, Optional
5
+
6
+ from .classifier import TaskClassification
7
+
8
+
9
+ @dataclass
10
+ class ExecutionConfig:
11
+ """Configuration for task execution."""
12
+
13
+ mode: str # "conversational", "agentic_simple", "agentic_complex"
14
+ max_iterations: int
15
+ use_agent_loop: bool
16
+ use_planning: bool
17
+ model_tier: str # "fast", "balanced", "best"
18
+ temperature: float
19
+ max_tokens: Optional[int]
20
+ hyperparameters: Dict
21
+
22
+
23
+ class ExecutionStrategy:
24
+ """
25
+ Determines execution strategy based on task classification.
26
+
27
+ Optimizes for:
28
+ - Cost (use smaller models for simple tasks)
29
+ - Speed (fewer iterations for simple tasks)
30
+ - Quality (best models for complex tasks)
31
+ """
32
+
33
+ # Model tiers by provider
34
+ MODEL_TIERS = {
35
+ "openai": {
36
+ "fast": "gpt-3.5-turbo",
37
+ "balanced": "gpt-4",
38
+ "best": "gpt-4",
39
+ },
40
+ "anthropic": {
41
+ "fast": "claude-haiku-4-5-20251001", # 4-5x faster, 1/3 cost
42
+ "balanced": "claude-sonnet-4-5-20250929", # Claude Sonnet 4.5 (best balance, workhorse)
43
+ "best": "claude-opus-4-1-20250805", # Claude Opus 4.1 (most capable)
44
+ },
45
+ "triton": {
46
+ # Triton uses same model for all tiers
47
+ "fast": None, # Use configured model
48
+ "balanced": None,
49
+ "best": None,
50
+ },
51
+ }
52
+
53
+ @staticmethod
54
+ def get_execution_config(
55
+ classification: Optional[TaskClassification],
56
+ provider_name: str,
57
+ default_model: str,
58
+ ) -> ExecutionConfig:
59
+ """
60
+ Get execution configuration based on task classification.
61
+
62
+ Args:
63
+ classification: Task classification result (None for fallback)
64
+ provider_name: Provider name (triton, openai, anthropic)
65
+ default_model: Default model name from config
66
+
67
+ Returns:
68
+ ExecutionConfig with optimized settings
69
+ """
70
+ # Handle None classification (fallback to simple)
71
+ if classification is None:
72
+ return ExecutionStrategy._simple_config(provider_name, default_model)
73
+
74
+ complexity = classification.complexity
75
+
76
+ if complexity == "CONVERSATIONAL":
77
+ return ExecutionStrategy._conversational_config(
78
+ provider_name, default_model
79
+ )
80
+ elif complexity == "SIMPLE":
81
+ return ExecutionStrategy._simple_config(provider_name, default_model)
82
+ elif complexity == "COMPLEX":
83
+ return ExecutionStrategy._complex_config(provider_name, default_model)
84
+ else:
85
+ # Fallback to simple
86
+ return ExecutionStrategy._simple_config(provider_name, default_model)
87
+
88
+ @staticmethod
89
+ def _conversational_config(
90
+ provider_name: str, default_model: str
91
+ ) -> ExecutionConfig:
92
+ """
93
+ Configuration for conversational tasks.
94
+
95
+ - Single-turn response
96
+ - No agent loop
97
+ - Fast/cheap model
98
+ - Higher temperature for creativity
99
+ """
100
+ # Get fast model for provider
101
+ model_tier = ExecutionStrategy.MODEL_TIERS.get(
102
+ provider_name, ExecutionStrategy.MODEL_TIERS["triton"]
103
+ )
104
+ fast_model = model_tier.get("fast") or default_model
105
+
106
+ return ExecutionConfig(
107
+ mode="conversational",
108
+ max_iterations=1,
109
+ use_agent_loop=False,
110
+ use_planning=False,
111
+ model_tier="fast",
112
+ temperature=0.7, # Higher for natural conversation
113
+ max_tokens=1000, # Shorter responses
114
+ hyperparameters={
115
+ "model": fast_model,
116
+ "temperature": 0.7,
117
+ "max_tokens": 1000,
118
+ },
119
+ )
120
+
121
+ @staticmethod
122
+ def _simple_config(provider_name: str, default_model: str) -> ExecutionConfig:
123
+ """
124
+ Configuration for simple tasks.
125
+
126
+ - Agent loop with low iterations
127
+ - No planning phase
128
+ - Balanced model
129
+ - Medium temperature
130
+ """
131
+ model_tier = ExecutionStrategy.MODEL_TIERS.get(
132
+ provider_name, ExecutionStrategy.MODEL_TIERS["triton"]
133
+ )
134
+ balanced_model = model_tier.get("balanced") or default_model
135
+
136
+ return ExecutionConfig(
137
+ mode="agentic_simple",
138
+ max_iterations=5, # Lower for simple tasks
139
+ use_agent_loop=True,
140
+ use_planning=False, # Skip planning for simple tasks
141
+ model_tier="balanced",
142
+ temperature=0.4, # Lower for accuracy
143
+ max_tokens=2000,
144
+ hyperparameters={
145
+ "model": balanced_model,
146
+ "temperature": 0.4,
147
+ "max_tokens": 2000,
148
+ },
149
+ )
150
+
151
+ @staticmethod
152
+ def _complex_config(provider_name: str, default_model: str) -> ExecutionConfig:
153
+ """
154
+ Configuration for complex tasks.
155
+
156
+ - Full agent loop
157
+ - Planning phase available
158
+ - Best model
159
+ - Low temperature for consistency
160
+ """
161
+ model_tier = ExecutionStrategy.MODEL_TIERS.get(
162
+ provider_name, ExecutionStrategy.MODEL_TIERS["triton"]
163
+ )
164
+ best_model = model_tier.get("best") or default_model
165
+
166
+ return ExecutionConfig(
167
+ mode="agentic_complex",
168
+ max_iterations=15, # Full iterations
169
+ use_agent_loop=True,
170
+ use_planning=True, # Enable planning for complex tasks
171
+ model_tier="best",
172
+ temperature=0.3, # Lower for consistency
173
+ max_tokens=4000,
174
+ hyperparameters={
175
+ "model": best_model,
176
+ "temperature": 0.3,
177
+ "max_tokens": 4000,
178
+ },
179
+ )
180
+
181
+ @staticmethod
182
+ def get_model_for_tier(provider_name: str, tier: str, default_model: str) -> str:
183
+ """
184
+ Get model name for a specific tier.
185
+
186
+ Args:
187
+ provider_name: Provider name
188
+ tier: Model tier (fast, balanced, best)
189
+ default_model: Fallback model
190
+
191
+ Returns:
192
+ Model name
193
+ """
194
+ model_tier = ExecutionStrategy.MODEL_TIERS.get(
195
+ provider_name, ExecutionStrategy.MODEL_TIERS["triton"]
196
+ )
197
+ return model_tier.get(tier) or default_model