sudosu 0.1.5__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.
@@ -0,0 +1,238 @@
1
+ """Core configuration management for Sudosu."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ import yaml
8
+ from dotenv import load_dotenv
9
+
10
+
11
+ # Load environment variables from .env file
12
+ load_dotenv()
13
+
14
+ # Default paths - use XDG-compliant location for app data
15
+ # ~/.local/share/sudosu/ for app data (history, etc.)
16
+ # ~/.config/sudosu/ for config (or keep ~/.sudosu/config.yaml for backwards compat)
17
+ APP_DATA_DIR = Path.home() / ".local" / "share" / "sudosu"
18
+ GLOBAL_CONFIG_DIR = Path.home() / ".sudosu" # Keep for backwards compatibility
19
+ CONFIG_FILE = "config.yaml"
20
+
21
+ # Default backend URLs (hardcoded, not from env vars)
22
+ DEFAULT_DEV_BACKEND_URL = "ws://localhost:8000/ws"
23
+ DEFAULT_PROD_BACKEND_URL = "wss://sudosu-cli.trysudosu.com/ws"
24
+
25
+
26
+ def get_global_config_dir() -> Path:
27
+ """Get the global configuration directory."""
28
+ return GLOBAL_CONFIG_DIR
29
+
30
+
31
+ def get_app_data_dir() -> Path:
32
+ """Get the app data directory (for history, cache, etc.)."""
33
+ return APP_DATA_DIR
34
+
35
+
36
+ def get_project_config_dir(cwd: Optional[Path] = None) -> Optional[Path]:
37
+ """Get the project-specific configuration directory if it exists."""
38
+ cwd = cwd or Path.cwd()
39
+ project_config = cwd / ".sudosu"
40
+ if project_config.exists():
41
+ return project_config
42
+ return None
43
+
44
+
45
+ def ensure_config_structure() -> Path:
46
+ """
47
+ Ensure the global config file exists.
48
+
49
+ NOTE: Only creates config.yaml in ~/.sudosu/, nothing else.
50
+ All other files (.sudosu/AGENT.md, agents/, etc.) are project-local.
51
+ """
52
+ config_dir = get_global_config_dir()
53
+
54
+ # Create config directory (just for config.yaml)
55
+ config_dir.mkdir(parents=True, exist_ok=True)
56
+
57
+ # Create default config if it doesn't exist
58
+ config_file = config_dir / CONFIG_FILE
59
+ if not config_file.exists():
60
+ default_config = {
61
+ "mode": "prod", # default to production
62
+ "backend_url": DEFAULT_PROD_BACKEND_URL,
63
+ "dev_backend_url": DEFAULT_DEV_BACKEND_URL,
64
+ "prod_backend_url": DEFAULT_PROD_BACKEND_URL,
65
+ "api_key": "",
66
+ "default_model": "gemini-2.5-pro",
67
+ "theme": "default",
68
+ }
69
+ with open(config_file, "w", encoding="utf-8") as f:
70
+ yaml.dump(default_config, f, default_flow_style=False)
71
+
72
+ return config_dir
73
+
74
+
75
+ def ensure_app_data_dir() -> Path:
76
+ """Ensure app data directory exists for history, cache, etc."""
77
+ APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
78
+ return APP_DATA_DIR
79
+
80
+
81
+ def ensure_project_structure(cwd: Optional[Path] = None) -> Path:
82
+ """
83
+ Ensure project-local .sudosu/ structure exists with default AGENT.md.
84
+
85
+ This is called when user runs `sudosu` in a directory.
86
+ Creates:
87
+ - .sudosu/
88
+ - .sudosu/AGENT.md (default agent prompt, user-editable)
89
+ - .sudosu/agents/ (for custom agents)
90
+
91
+ Returns:
92
+ Path to the project .sudosu directory
93
+ """
94
+ from sudosu.core.default_agent import generate_default_agent_md
95
+
96
+ cwd = cwd or Path.cwd()
97
+ project_config = cwd / ".sudosu"
98
+
99
+ # Create .sudosu/ if it doesn't exist
100
+ project_config.mkdir(parents=True, exist_ok=True)
101
+
102
+ # Create agents/ subdirectory
103
+ agents_dir = project_config / "agents"
104
+ agents_dir.mkdir(exist_ok=True)
105
+
106
+ # Create default AGENT.md if it doesn't exist
107
+ default_agent_file = project_config / "AGENT.md"
108
+ if not default_agent_file.exists():
109
+ default_agent_file.write_text(generate_default_agent_md())
110
+
111
+ return project_config
112
+
113
+
114
+ def load_config() -> dict:
115
+ """Load the global configuration."""
116
+ config_file = get_global_config_dir() / CONFIG_FILE
117
+
118
+ if not config_file.exists():
119
+ return {}
120
+
121
+ with open(config_file, "r", encoding="utf-8") as f:
122
+ return yaml.safe_load(f) or {}
123
+
124
+
125
+ def save_config(config: dict) -> None:
126
+ """Save the global configuration."""
127
+ config_file = get_global_config_dir() / CONFIG_FILE
128
+
129
+ with open(config_file, "w", encoding="utf-8") as f:
130
+ yaml.dump(config, f, default_flow_style=False)
131
+
132
+
133
+ def get_config_value(key: str, default: Any = None) -> Any:
134
+ """Get a specific configuration value."""
135
+ config = load_config()
136
+ return config.get(key, default)
137
+
138
+
139
+ def set_config_value(key: str, value: Any) -> None:
140
+ """Set a specific configuration value."""
141
+ config = load_config()
142
+ config[key] = value
143
+ save_config(config)
144
+
145
+
146
+ def get_mode() -> str:
147
+ """Get the current mode (dev or prod)."""
148
+ # Check environment variable first
149
+ env_mode = os.getenv("SUDOSU_MODE", "").lower()
150
+ if env_mode in ["dev", "prod"]:
151
+ return env_mode
152
+
153
+ # Fall back to config file
154
+ config_mode = get_config_value("mode", "prod")
155
+ return config_mode if config_mode in ["dev", "prod"] else "prod"
156
+
157
+
158
+ def set_mode(mode: str) -> None:
159
+ """Set the current mode (dev or prod)."""
160
+ if mode not in ["dev", "prod"]:
161
+ raise ValueError("Mode must be 'dev' or 'prod'")
162
+ set_config_value("mode", mode)
163
+
164
+ # Update backend_url based on mode
165
+ if mode == "dev":
166
+ url = get_config_value("dev_backend_url", DEFAULT_DEV_BACKEND_URL)
167
+ else:
168
+ url = get_config_value("prod_backend_url", DEFAULT_PROD_BACKEND_URL)
169
+
170
+ set_config_value("backend_url", url)
171
+
172
+
173
+ def get_backend_url() -> str:
174
+ """Get the backend WebSocket URL based on current mode."""
175
+ # Check for direct environment variable override (highest priority)
176
+ env_url = os.getenv("SUDOSU_BACKEND_URL")
177
+ if env_url:
178
+ return env_url
179
+
180
+ # Get current mode
181
+ mode = get_mode()
182
+
183
+ # Get mode-specific URL
184
+ if mode == "dev":
185
+ # Priority: env var > config file > default
186
+ env_url = os.getenv("SUDOSU_DEV_BACKEND_URL")
187
+ if env_url:
188
+ return env_url
189
+ config_url = get_config_value("dev_backend_url")
190
+ return config_url if config_url else DEFAULT_DEV_BACKEND_URL
191
+ else:
192
+ # Priority: env var > config file > default
193
+ env_url = os.getenv("SUDOSU_PROD_BACKEND_URL")
194
+ if env_url:
195
+ return env_url
196
+ config_url = get_config_value("prod_backend_url")
197
+ return config_url if config_url else DEFAULT_PROD_BACKEND_URL
198
+
199
+
200
+ def get_agents_dir(project_first: bool = True) -> Optional[Path]:
201
+ """Get the agents directory (project-specific only).
202
+
203
+ Returns:
204
+ Path to project's .sudosu/agents/ or None if not in a project
205
+ """
206
+ if project_first:
207
+ project_dir = get_project_config_dir()
208
+ if project_dir:
209
+ agents_dir = project_dir / "agents"
210
+ if agents_dir.exists():
211
+ return agents_dir
212
+
213
+ return None
214
+
215
+
216
+ def get_skills_dir(project_first: bool = True) -> Optional[Path]:
217
+ """Get the skills directory (project-specific only).
218
+
219
+ Returns:
220
+ Path to project's .sudosu/skills/ or None if not in a project
221
+ """
222
+ if project_first:
223
+ project_dir = get_project_config_dir()
224
+ if project_dir:
225
+ skills_dir = project_dir / "skills"
226
+ if skills_dir.exists():
227
+ return skills_dir
228
+
229
+ return None
230
+
231
+
232
+ # Export session management
233
+ from sudosu.core.session import (
234
+ ConversationSession,
235
+ SessionManager,
236
+ get_session_manager,
237
+ reset_session_manager,
238
+ )
@@ -0,0 +1,263 @@
1
+ """Agent loader - Parse and load AGENT.md files."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Optional
5
+
6
+ import frontmatter
7
+
8
+ from sudosu.core.default_agent import CONTEXT_AWARE_PROMPT, SUB_AGENT_CONSULTATION_PROMPT
9
+
10
+
11
+ def parse_agent_file(agent_path: Path) -> Optional[dict]:
12
+ """
13
+ Parse an AGENT.md file and return the agent configuration.
14
+
15
+ Args:
16
+ agent_path: Path to the agent directory containing AGENT.md
17
+
18
+ Returns:
19
+ Agent configuration dict or None if invalid
20
+ """
21
+ agent_md = agent_path / "AGENT.md"
22
+
23
+ if not agent_md.exists():
24
+ return None
25
+
26
+ try:
27
+ with open(agent_md, "r", encoding="utf-8") as f:
28
+ post = frontmatter.load(f)
29
+
30
+ # Get description - ensure it's a string
31
+ description = post.get("description", "")
32
+ if isinstance(description, list):
33
+ description = "\n".join(str(item) for item in description)
34
+
35
+ # Get tools list
36
+ tools = post.get("tools", ["read_file", "write_file", "list_directory"])
37
+ if isinstance(tools, str):
38
+ tools = [tools]
39
+
40
+ # Get integrations list
41
+ integrations = post.get("integrations", [])
42
+ if isinstance(integrations, str):
43
+ integrations = [integrations]
44
+
45
+ # Get the base system prompt and append context-aware instructions
46
+ base_prompt = str(post.content).strip()
47
+ # Append context awareness to all agents for better conversation handling
48
+ # Also append consultation instructions so sub-agents know when to consult sudosu
49
+ system_prompt = base_prompt + CONTEXT_AWARE_PROMPT + SUB_AGENT_CONSULTATION_PROMPT
50
+
51
+ return {
52
+ "name": str(post.get("name", agent_path.name)),
53
+ "description": str(description),
54
+ "model": str(post.get("model", "gemini-2.5-pro")),
55
+ "tools": tools,
56
+ "integrations": integrations,
57
+ "skills": post.get("skills", []),
58
+ "system_prompt": system_prompt,
59
+ "path": str(agent_path),
60
+ }
61
+ except Exception as e:
62
+ print(f"Error parsing agent file {agent_md}: {e}")
63
+ return None
64
+
65
+
66
+ def discover_agents(agents_dir: Path) -> list[dict]:
67
+ """
68
+ Discover all agents in a directory.
69
+
70
+ Args:
71
+ agents_dir: Path to the agents directory
72
+
73
+ Returns:
74
+ List of agent configurations
75
+ """
76
+ agents = []
77
+
78
+ if not agents_dir.exists():
79
+ return agents
80
+
81
+ for item in agents_dir.iterdir():
82
+ if item.is_dir():
83
+ agent = parse_agent_file(item)
84
+ if agent:
85
+ agents.append(agent)
86
+
87
+ return agents
88
+
89
+
90
+ def load_agent_config(agent_name: str, agents_dirs: list[Path]) -> Optional[dict]:
91
+ """
92
+ Load a specific agent's configuration by name.
93
+
94
+ Args:
95
+ agent_name: Name of the agent to load
96
+ agents_dirs: List of directories to search for agents
97
+
98
+ Returns:
99
+ Agent configuration dict or None if not found
100
+ """
101
+ for agents_dir in agents_dirs:
102
+ agent_path = agents_dir / agent_name
103
+ if agent_path.exists():
104
+ return parse_agent_file(agent_path)
105
+
106
+ return None
107
+
108
+
109
+ def create_agent_template(
110
+ agent_dir: Path,
111
+ name: str,
112
+ description: str,
113
+ system_prompt: str,
114
+ model: str = "gemini-2.5-pro",
115
+ tools: Optional[list[str]] = None,
116
+ integrations: Optional[list[str]] = None,
117
+ ) -> Path:
118
+ """
119
+ Create a new agent from a template.
120
+
121
+ Args:
122
+ agent_dir: Directory where the agent will be created
123
+ name: Agent name
124
+ description: Agent description
125
+ system_prompt: Agent system prompt (markdown body)
126
+ model: Model to use
127
+ tools: List of allowed tools
128
+ integrations: List of connected integrations the agent should use
129
+
130
+ Returns:
131
+ Path to the created agent directory
132
+ """
133
+ if tools is None:
134
+ tools = ["read_file", "write_file", "list_directory"]
135
+ if integrations is None:
136
+ integrations = []
137
+
138
+ # Create agent directory
139
+ agent_path = agent_dir / name
140
+ agent_path.mkdir(parents=True, exist_ok=True)
141
+
142
+ # Create AGENT.md
143
+ agent_md = agent_path / "AGENT.md"
144
+
145
+ # Ensure description is properly quoted for YAML (contains colons, etc.)
146
+ # Use double quotes and escape any internal double quotes
147
+ safe_description = description.replace('"', '\\"')
148
+
149
+ # Build integrations section if any
150
+ integrations_yaml = ""
151
+ if integrations:
152
+ integrations_yaml = f"""integrations:
153
+ {chr(10).join(f' - {integration}' for integration in integrations)}"""
154
+ else:
155
+ integrations_yaml = "integrations: []"
156
+
157
+ content = f"""---
158
+ name: {name}
159
+ description: "{safe_description}"
160
+ model: {model}
161
+ tools:
162
+ {chr(10).join(f' - {tool}' for tool in tools)}
163
+ {integrations_yaml}
164
+ skills: []
165
+ ---
166
+
167
+ {system_prompt}
168
+ """
169
+
170
+ with open(agent_md, "w", encoding="utf-8") as f:
171
+ f.write(content)
172
+
173
+ return agent_path
174
+
175
+
176
+ DEFAULT_WRITER_PROMPT = """# Writer Agent
177
+
178
+ You are a professional content writer. You help users create well-structured,
179
+ engaging content including blog posts, articles, and documentation.
180
+
181
+ ## IMPORTANT: Always Save Your Work
182
+
183
+ **You MUST always save your written content to a file using the write_file tool.**
184
+ - Use kebab-case for filenames (e.g., `my-blog-post.md`)
185
+ - Always use the `.md` extension for blog posts and articles
186
+ - Save the file AFTER you finish writing the content
187
+
188
+ ## Guidelines
189
+
190
+ 1. If the request is clear, start writing immediately
191
+ 2. Structure content with clear headings and sections
192
+ 3. Use proper markdown formatting
193
+ 4. **Always save the final content to a .md file**
194
+
195
+ ## Writing Style
196
+
197
+ - Clear and concise
198
+ - Engaging but professional
199
+ - Well-researched (when applicable)
200
+ - SEO-friendly for blog posts
201
+
202
+ ## Workflow
203
+
204
+ 1. Write the full content in markdown format
205
+ 2. **ALWAYS use write_file to save the content** - this is mandatory
206
+ 3. After saving, give a BRIEF confirmation (1-2 sentences max)
207
+
208
+ ## CRITICAL: After Saving a File
209
+
210
+ **After the write_file tool succeeds, you MUST:**
211
+ - Give a SHORT confirmation like "✓ Saved to filename.md"
212
+ - Optionally add ONE brief insight or suggestion
213
+ - **DO NOT repeat or summarize the content you just saved**
214
+ - **DO NOT show the content again**
215
+ - **Keep your response after saving to 2-3 sentences maximum**
216
+
217
+ Example good response after saving:
218
+ "✓ Saved your blog post to ai-revolution.md. The article covers 5 key areas where AI is transforming CS. Let me know if you'd like any revisions!"
219
+
220
+ Example BAD response (DO NOT DO THIS):
221
+ "Here is the blog post about AI... [repeating the entire content]"
222
+ """
223
+
224
+
225
+ DEFAULT_CODER_PROMPT = """# Coder Agent
226
+
227
+ You are an expert software developer. You help users write, review, and debug code
228
+ across various programming languages and frameworks.
229
+
230
+ ## Guidelines
231
+
232
+ 1. Understand the project context before making changes
233
+ 2. Follow existing code style and conventions
234
+ 3. Write clean, well-documented code
235
+ 4. Consider edge cases and error handling
236
+
237
+ ## Best Practices
238
+
239
+ - Use meaningful variable and function names
240
+ - Add comments for complex logic
241
+ - Follow SOLID principles
242
+ - Write testable code
243
+
244
+ ## Output Format
245
+
246
+ When writing code:
247
+ 1. Explain the approach first
248
+ 2. Show the code with proper formatting
249
+ 3. Explain any important decisions
250
+ 4. Save files with appropriate names and extensions
251
+ """
252
+
253
+
254
+ AGENT_TEMPLATES = {
255
+ "writer": {
256
+ "description": "A helpful writing assistant that creates blog posts, articles, and documentation.",
257
+ "system_prompt": DEFAULT_WRITER_PROMPT,
258
+ },
259
+ "coder": {
260
+ "description": "An expert developer that helps write, review, and debug code.",
261
+ "system_prompt": DEFAULT_CODER_PROMPT,
262
+ },
263
+ }
@@ -0,0 +1,196 @@
1
+ """WebSocket connection manager for communicating with the backend."""
2
+
3
+ import asyncio
4
+ import json
5
+ from typing import Any, AsyncGenerator, Callable, Optional
6
+
7
+ import websockets
8
+ from websockets.client import WebSocketClientProtocol
9
+
10
+
11
+ class ConnectionManager:
12
+ """Manages WebSocket connection to the Sudosu backend."""
13
+
14
+ def __init__(self, backend_url: str):
15
+ self.backend_url = backend_url
16
+ self.ws: Optional[WebSocketClientProtocol] = None
17
+ self._connected = False
18
+
19
+ async def connect(self) -> bool:
20
+ """Establish connection to the backend.
21
+
22
+ Timeout values increased to support long-running operations
23
+ like bulk email fetching (100+ emails) and multi-step automations.
24
+ """
25
+ try:
26
+ self.ws = await websockets.connect(
27
+ self.backend_url,
28
+ ping_interval=120, # 2 minutes (was 30s)
29
+ ping_timeout=60, # 1 minute (was 10s)
30
+ max_size=20_000_000, # 20MB for large responses
31
+ )
32
+ self._connected = True
33
+ return True
34
+ except Exception as e:
35
+ self._connected = False
36
+ raise ConnectionError(f"Failed to connect to backend: {e}")
37
+
38
+ async def disconnect(self) -> None:
39
+ """Close the connection."""
40
+ if self.ws:
41
+ await self.ws.close()
42
+ self._connected = False
43
+ self.ws = None
44
+
45
+ @property
46
+ def is_connected(self) -> bool:
47
+ """Check if connected to backend."""
48
+ return self._connected and self.ws is not None
49
+
50
+ async def send(self, message: dict) -> None:
51
+ """Send a message to the backend."""
52
+ if not self.is_connected:
53
+ raise ConnectionError("Not connected to backend")
54
+
55
+ await self.ws.send(json.dumps(message))
56
+
57
+ async def receive(self) -> dict:
58
+ """Receive a message from the backend."""
59
+ if not self.is_connected:
60
+ raise ConnectionError("Not connected to backend")
61
+
62
+ msg = await self.ws.recv()
63
+ return json.loads(msg)
64
+
65
+ async def invoke_agent(
66
+ self,
67
+ agent_config: dict,
68
+ message: str,
69
+ cwd: str,
70
+ session_id: Optional[str] = None,
71
+ thread_id: Optional[str] = None,
72
+ user_id: Optional[str] = None,
73
+ on_text: Optional[Callable[[str], None]] = None,
74
+ on_tool_call: Optional[Callable[[str, dict], Any]] = None,
75
+ on_status: Optional[Callable[[str], None]] = None,
76
+ on_special_message: Optional[Callable[[dict], Any]] = None,
77
+ ) -> AsyncGenerator[dict, None]:
78
+ """
79
+ Invoke an agent and stream the response with session context.
80
+
81
+ Args:
82
+ agent_config: Agent configuration dict
83
+ message: User message
84
+ cwd: Current working directory
85
+ session_id: Session ID for memory continuity across agents
86
+ thread_id: Thread ID for specific conversation continuity
87
+ user_id: User ID for integration tools (Gmail, etc.)
88
+ on_text: Callback for text chunks
89
+ on_tool_call: Callback for tool calls (should return result)
90
+ on_status: Callback for status updates
91
+ on_special_message: Callback for special backend messages (consultation, etc.)
92
+
93
+ Yields:
94
+ Response messages from the backend
95
+ """
96
+ # Build request with session info for memory
97
+ request = {
98
+ "type": "invoke",
99
+ "agent": agent_config,
100
+ "message": message,
101
+ "cwd": cwd,
102
+ }
103
+
104
+ # Include session info if provided
105
+ if session_id:
106
+ request["session_id"] = session_id
107
+ if thread_id:
108
+ request["thread_id"] = thread_id
109
+ if user_id:
110
+ request["user_id"] = user_id
111
+
112
+ # Send invoke request
113
+ await self.send(request)
114
+
115
+ # Stream responses
116
+ while True:
117
+ try:
118
+ data = await self.receive()
119
+ msg_type = data.get("type")
120
+
121
+ if msg_type == "text":
122
+ if on_text:
123
+ on_text(data.get("content", ""))
124
+ yield data
125
+
126
+ elif msg_type == "tool_call":
127
+ if on_tool_call:
128
+ tool_name = data.get("tool")
129
+ tool_args = data.get("args", {})
130
+
131
+ if on_status:
132
+ on_status(f"Executing {tool_name}...")
133
+
134
+ # Execute tool and get result
135
+ result = await on_tool_call(tool_name, tool_args)
136
+
137
+ # Send result back to backend
138
+ await self.send({
139
+ "type": "tool_result",
140
+ "tool": tool_name,
141
+ "result": result,
142
+ })
143
+ yield data
144
+
145
+ elif msg_type == "status":
146
+ if on_status:
147
+ on_status(data.get("message", ""))
148
+ yield data
149
+
150
+ elif msg_type == "done":
151
+ yield data
152
+ break
153
+
154
+ elif msg_type == "error":
155
+ yield data
156
+ break
157
+
158
+ elif msg_type == "get_available_agents":
159
+ # Backend is requesting available agents for consultation
160
+ if on_special_message:
161
+ response = await on_special_message(data)
162
+ if response:
163
+ await self.send(response)
164
+ yield data
165
+
166
+ elif msg_type == "consultation_route":
167
+ # Backend has decided to route via consultation
168
+ if on_special_message:
169
+ await on_special_message(data)
170
+ yield data
171
+
172
+ elif msg_type == "background_queued":
173
+ # Task has been queued for background execution
174
+ if on_special_message:
175
+ await on_special_message(data)
176
+ yield data
177
+ # Background tasks complete immediately from client perspective
178
+ # The actual work happens in the background
179
+ break
180
+
181
+ else:
182
+ yield data
183
+
184
+ except websockets.exceptions.ConnectionClosed:
185
+ yield {"type": "error", "message": "Connection closed"}
186
+ break
187
+ except Exception as e:
188
+ yield {"type": "error", "message": str(e)}
189
+ break
190
+
191
+
192
+ async def create_connection(backend_url: str) -> ConnectionManager:
193
+ """Create and connect to the backend."""
194
+ manager = ConnectionManager(backend_url)
195
+ await manager.connect()
196
+ return manager