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,170 @@
1
+ """Memory/conversation management commands.
2
+
3
+ This module provides CLI commands for viewing and managing
4
+ conversation memory and session state with SHARED thread model.
5
+
6
+ All agents share the same conversation context, so clearing
7
+ memory affects all agents.
8
+ """
9
+
10
+ from sudosu.core.session import get_session_manager
11
+ from sudosu.ui import (
12
+ console,
13
+ print_success,
14
+ print_info,
15
+ print_error,
16
+ COLOR_PRIMARY,
17
+ COLOR_SECONDARY,
18
+ COLOR_ACCENT,
19
+ COLOR_INTERACTIVE,
20
+ )
21
+
22
+
23
+ async def handle_memory_command(args: list[str]):
24
+ """
25
+ Handle /memory commands for SHARED conversation memory management.
26
+
27
+ Usage:
28
+ /memory - Show current session info
29
+ /memory clear - Clear conversation (all agents share it)
30
+ /memory show - Show conversation summary
31
+ /memory stats - Show session statistics
32
+ /memory agent - Show which agent is currently active
33
+
34
+ Args:
35
+ args: Command arguments
36
+ """
37
+ session_mgr = get_session_manager()
38
+
39
+ if not args:
40
+ # Show session info
41
+ _show_session_info(session_mgr)
42
+ return
43
+
44
+ cmd = args[0].lower()
45
+
46
+ if cmd == "clear":
47
+ await _handle_clear(session_mgr)
48
+ elif cmd == "show":
49
+ _show_conversation_details(session_mgr)
50
+ elif cmd == "stats":
51
+ _show_stats(session_mgr)
52
+ elif cmd == "agent":
53
+ _show_active_agent(session_mgr)
54
+ elif cmd == "help":
55
+ _show_help()
56
+ else:
57
+ print_error(f"Unknown memory command: {cmd}")
58
+ _show_help()
59
+
60
+
61
+ def _show_session_info(session_mgr):
62
+ """Display current session information."""
63
+ console.print(f"\n[bold {COLOR_SECONDARY}]📝 Session Info[/bold {COLOR_SECONDARY}]")
64
+ console.print(f" Session ID: [dim]{session_mgr.session_id[:8]}...[/dim]")
65
+ console.print(f" Thread ID: [dim]{session_mgr.thread_id[:16]}...[/dim]")
66
+ console.print(f" Active Agent: [{COLOR_INTERACTIVE}]@{session_mgr.get_active_agent()}[/{COLOR_INTERACTIVE}]")
67
+ console.print(f" Messages: [{COLOR_PRIMARY}]{session_mgr.message_count}[/{COLOR_PRIMARY}]")
68
+
69
+ if session_mgr.is_routed:
70
+ console.print(f" Status: [{COLOR_INTERACTIVE}]Routed from sudosu[/{COLOR_INTERACTIVE}]")
71
+
72
+ console.print()
73
+ console.print("[dim]💡 All agents share the same conversation context.[/dim]")
74
+ console.print("[dim] Use '/back' to return to sudosu from a sub-agent.[/dim]")
75
+ console.print()
76
+
77
+
78
+ async def _handle_clear(session_mgr):
79
+ """Handle memory clear command."""
80
+ # With shared thread, there's only one conversation to clear
81
+ new_thread_id = session_mgr.clear_session()
82
+ print_success("Conversation cleared - starting fresh")
83
+ console.print(f"[dim]New thread: {new_thread_id[:16]}...[/dim]")
84
+ console.print("[dim]All agents will start fresh on next message.[/dim]")
85
+
86
+
87
+ def _show_conversation_details(session_mgr):
88
+ """Show detailed information about the shared conversation."""
89
+ if session_mgr.message_count > 0:
90
+ console.print(f"\n[bold {COLOR_SECONDARY}]💬 Shared Conversation[/bold {COLOR_SECONDARY}]")
91
+ console.print(f" Active Agent: [{COLOR_INTERACTIVE}]@{session_mgr.get_active_agent()}[/{COLOR_INTERACTIVE}]")
92
+ console.print(f" Messages exchanged: [{COLOR_PRIMARY}]{session_mgr.message_count}[/{COLOR_PRIMARY}]")
93
+ console.print(f" Thread ID: [dim]{session_mgr.thread_id}[/dim]")
94
+ console.print(f" Session ID: [dim]{session_mgr.session_id}[/dim]")
95
+
96
+ stats = session_mgr.get_stats()
97
+ duration = stats["duration_seconds"]
98
+ if duration < 60:
99
+ console.print(f" Duration: {duration:.0f} seconds")
100
+ elif duration < 3600:
101
+ console.print(f" Duration: {duration/60:.1f} minutes")
102
+ else:
103
+ console.print(f" Duration: {duration/3600:.1f} hours")
104
+
105
+ if session_mgr.is_routed:
106
+ console.print(f"\n[dim]You were routed here by sudosu.[/dim]")
107
+ console.print("[dim]Use '/back' to return to sudosu.[/dim]")
108
+ else:
109
+ console.print(f"\n[dim]All agents share this conversation context.[/dim]")
110
+ console.print("[dim]Use '/memory clear' to start fresh.[/dim]\n")
111
+ else:
112
+ print_info("No active conversation")
113
+ console.print("[dim]Start chatting with an agent to see conversation details.[/dim]")
114
+
115
+
116
+ def _show_active_agent(session_mgr):
117
+ """Show which agent is currently active."""
118
+ active = session_mgr.get_active_agent()
119
+ console.print(f"\nActive agent: [{COLOR_INTERACTIVE}]@{active}[/{COLOR_INTERACTIVE}]")
120
+
121
+ if session_mgr.is_routed:
122
+ console.print("[dim]You were routed here by sudosu.[/dim]")
123
+ console.print("[dim]Use /back to return to sudosu.[/dim]")
124
+ elif active != "sudosu":
125
+ console.print(f"[dim]You switched to this agent with @{active}.[/dim]")
126
+ console.print("[dim]Use /back to return to sudosu.[/dim]")
127
+ else:
128
+ console.print("[dim]Sudosu is the default orchestrator.[/dim]")
129
+ console.print("[dim]It can route you to specialized agents.[/dim]")
130
+ console.print()
131
+
132
+
133
+ def _show_stats(session_mgr):
134
+ """Show session statistics."""
135
+ stats = session_mgr.get_stats()
136
+
137
+ console.print(f"\n[bold {COLOR_SECONDARY}]📊 Session Statistics[/bold {COLOR_SECONDARY}]")
138
+ console.print(f" Session ID: [dim]{stats['session_id'][:8]}...[/dim]")
139
+ console.print(f" Thread ID: [dim]{stats['thread_id'][:16]}...[/dim]")
140
+ console.print(f" Active Agent: [{COLOR_INTERACTIVE}]@{stats['active_agent']}[/{COLOR_INTERACTIVE}]")
141
+ console.print(f" Total Messages: [{COLOR_PRIMARY}]{stats['message_count']}[/{COLOR_PRIMARY}]")
142
+
143
+ duration = stats["duration_seconds"]
144
+ if duration < 60:
145
+ console.print(f" Session Duration: {duration:.0f} seconds")
146
+ elif duration < 3600:
147
+ console.print(f" Session Duration: {duration/60:.1f} minutes")
148
+ else:
149
+ console.print(f" Session Duration: {duration/3600:.1f} hours")
150
+
151
+ if stats["is_routed"]:
152
+ console.print(f" Status: [{COLOR_INTERACTIVE}]Routed conversation[/{COLOR_INTERACTIVE}]")
153
+
154
+ console.print()
155
+
156
+
157
+ def _show_help():
158
+ """Show memory command help."""
159
+ console.print(f"\n[bold {COLOR_SECONDARY}]Memory Commands[/bold {COLOR_SECONDARY}]")
160
+ console.print(f" [{COLOR_INTERACTIVE}]/memory[/{COLOR_INTERACTIVE}] - Show session info and conversation status")
161
+ console.print(f" [{COLOR_INTERACTIVE}]/memory show[/{COLOR_INTERACTIVE}] - Show detailed conversation info")
162
+ console.print(f" [{COLOR_INTERACTIVE}]/memory clear[/{COLOR_INTERACTIVE}] - Clear conversation and start fresh")
163
+ console.print(f" [{COLOR_INTERACTIVE}]/memory agent[/{COLOR_INTERACTIVE}] - Show which agent is currently active")
164
+ console.print(f" [{COLOR_INTERACTIVE}]/memory stats[/{COLOR_INTERACTIVE}] - Show session statistics")
165
+ console.print(f" [{COLOR_INTERACTIVE}]/memory help[/{COLOR_INTERACTIVE}] - Show this help")
166
+ console.print()
167
+ console.print("[dim]💡 All agents share the same conversation context.")
168
+ console.print(" When sudosu routes you to a sub-agent, follow-up")
169
+ console.print(" messages go to that agent until you use /back.[/dim]")
170
+ console.print()
@@ -0,0 +1,319 @@
1
+ """First-time user onboarding flow.
2
+
3
+ This module handles the onboarding experience for new Sudosu users,
4
+ collecting profile information to personalize the agent experience.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ import httpx
10
+ from prompt_toolkit import PromptSession
11
+
12
+ from sudosu.commands.integrations import get_http_backend_url, get_user_id
13
+ from sudosu.core import get_config_value, set_config_value
14
+ from sudosu.ui import (
15
+ console,
16
+ print_info,
17
+ print_success,
18
+ COLOR_PRIMARY,
19
+ COLOR_SECONDARY,
20
+ COLOR_INTERACTIVE,
21
+ )
22
+
23
+
24
+ # Role options for selection
25
+ ROLE_OPTIONS = [
26
+ ("developer", "Developer / Engineer"),
27
+ ("pm", "Product Manager"),
28
+ ("designer", "Designer"),
29
+ ("founder", "Founder / Entrepreneur"),
30
+ ("marketer", "Marketer"),
31
+ ("writer", "Writer / Content Creator"),
32
+ ("student", "Student"),
33
+ ("other", "Other"),
34
+ ]
35
+
36
+
37
+ def is_onboarding_completed() -> bool:
38
+ """Check if user has completed onboarding."""
39
+ return get_config_value("onboarding_completed", False)
40
+
41
+
42
+ def mark_onboarding_completed():
43
+ """Mark onboarding as completed."""
44
+ set_config_value("onboarding_completed", True)
45
+
46
+
47
+ async def check_remote_profile() -> Optional[dict]:
48
+ """Check if user profile exists on backend.
49
+
50
+ This enables cross-device sync - if a user completes onboarding
51
+ on one device, they won't need to do it again on another.
52
+
53
+ Returns:
54
+ User profile dict if found, None otherwise
55
+ """
56
+ user_id = get_user_id()
57
+ backend_url = get_http_backend_url()
58
+
59
+ try:
60
+ async with httpx.AsyncClient(timeout=5.0) as client:
61
+ response = await client.get(f"{backend_url}/api/users/{user_id}")
62
+ if response.status_code == 200:
63
+ return response.json()
64
+ except Exception: # noqa: BLE001
65
+ # Silently fail - we'll just run onboarding locally
66
+ pass
67
+ return None
68
+
69
+
70
+ async def save_profile_to_backend(profile: dict) -> bool:
71
+ """Save user profile to backend database.
72
+
73
+ Args:
74
+ profile: User profile dictionary
75
+
76
+ Returns:
77
+ True if saved successfully, False otherwise
78
+ """
79
+ backend_url = get_http_backend_url()
80
+
81
+ try:
82
+ async with httpx.AsyncClient(timeout=10.0) as client:
83
+ response = await client.post(
84
+ f"{backend_url}/api/users/",
85
+ json=profile,
86
+ )
87
+ return response.status_code == 200
88
+ except Exception: # noqa: BLE001
89
+ # Silently fail - profile is still saved locally
90
+ return False
91
+
92
+
93
+ async def run_onboarding() -> dict:
94
+ """Run the interactive onboarding flow.
95
+
96
+ Asks the user a series of questions to personalize their experience.
97
+
98
+ Returns:
99
+ User profile dict
100
+ """
101
+ console.print()
102
+ console.print(f"[bold {COLOR_PRIMARY}]--- Welcome to Sudosu! ---[/bold {COLOR_PRIMARY}]")
103
+ console.print()
104
+ console.print(f"[{COLOR_SECONDARY}]Let's personalize your experience with a few quick questions.[/{COLOR_SECONDARY}]")
105
+ console.print("[dim](This only takes 30 seconds)[/dim]")
106
+ console.print()
107
+
108
+ profile = {"user_id": get_user_id()}
109
+ session = PromptSession()
110
+
111
+ # Question 1: Name
112
+ console.print(f"[bold {COLOR_INTERACTIVE}]What should I call you?[/bold {COLOR_INTERACTIVE}]")
113
+ try:
114
+ name = await session.prompt_async("Your name: ")
115
+ profile["name"] = name.strip() or "Friend"
116
+ except (EOFError, KeyboardInterrupt):
117
+ profile["name"] = "Friend"
118
+ console.print()
119
+
120
+ # Question 2: Email
121
+ console.print(f"[bold {COLOR_INTERACTIVE}]What's your email?[/bold {COLOR_INTERACTIVE}]")
122
+ console.print("[dim]We'll use this to identify your account[/dim]")
123
+ try:
124
+ email = await session.prompt_async("Your email: ")
125
+ email = email.strip()
126
+ # Basic email validation
127
+ if email and "@" in email and "." in email:
128
+ profile["email"] = email
129
+ else:
130
+ if email:
131
+ console.print("[dim]Invalid email format, skipping...[/dim]")
132
+ profile["email"] = None
133
+ except (EOFError, KeyboardInterrupt):
134
+ profile["email"] = None
135
+ console.print()
136
+
137
+ # Question 3: Role (with selection)
138
+ console.print(f"[bold {COLOR_INTERACTIVE}]What's your role?[/bold {COLOR_INTERACTIVE}]")
139
+ console.print("[dim]Pick the closest match:[/dim]")
140
+ for i, (_, label) in enumerate(ROLE_OPTIONS, 1):
141
+ console.print(f" [{COLOR_INTERACTIVE}]{i}[/{COLOR_INTERACTIVE}]. {label}")
142
+
143
+ try:
144
+ role_input = await session.prompt_async("Enter number (or type custom): ")
145
+ try:
146
+ role_idx = int(role_input.strip()) - 1
147
+ if 0 <= role_idx < len(ROLE_OPTIONS):
148
+ profile["role"] = ROLE_OPTIONS[role_idx][0]
149
+ else:
150
+ profile["role"] = role_input.strip() or None
151
+ except ValueError:
152
+ profile["role"] = role_input.strip() or None
153
+ except (EOFError, KeyboardInterrupt):
154
+ profile["role"] = None
155
+ console.print()
156
+
157
+ # Question 4: Work context
158
+ console.print(f"[bold {COLOR_INTERACTIVE}]What do you mainly work on?[/bold {COLOR_INTERACTIVE}]")
159
+ console.print("[dim]e.g., 'Building a SaaS product', 'Mobile apps', 'Content marketing'[/dim]")
160
+ try:
161
+ work_context = await session.prompt_async("Your work: ")
162
+ profile["work_context"] = work_context.strip() or None
163
+ except (EOFError, KeyboardInterrupt):
164
+ profile["work_context"] = None
165
+ console.print()
166
+
167
+ # Question 5: Goals
168
+ console.print(f"[bold {COLOR_INTERACTIVE}]What do you want to accomplish with Sudosu?[/bold {COLOR_INTERACTIVE}]")
169
+ console.print("[dim]e.g., 'Automate repetitive tasks', 'Write better content', 'Ship faster'[/dim]")
170
+ try:
171
+ goals = await session.prompt_async("Your goals: ")
172
+ profile["goals"] = goals.strip() or None
173
+ except (EOFError, KeyboardInterrupt):
174
+ profile["goals"] = None
175
+ console.print()
176
+
177
+ # Question 6: Daily tools (optional)
178
+ console.print(f"[bold {COLOR_INTERACTIVE}]Any tools you use daily?[/bold {COLOR_INTERACTIVE}] [dim](optional)[/dim]")
179
+ console.print("[dim]e.g., 'github, slack, notion' (comma-separated)[/dim]")
180
+ try:
181
+ tools_input = await session.prompt_async("Tools (or press Enter to skip): ")
182
+ if tools_input.strip():
183
+ profile["daily_tools"] = [t.strip().lower() for t in tools_input.split(",") if t.strip()]
184
+ else:
185
+ profile["daily_tools"] = []
186
+ except (EOFError, KeyboardInterrupt):
187
+ profile["daily_tools"] = []
188
+
189
+ console.print()
190
+
191
+ # Save locally first (always works)
192
+ set_config_value("user_profile", profile)
193
+
194
+ # Try to save to backend (may fail if offline)
195
+ console.print(f"[{COLOR_SECONDARY}]Saving your profile...[/{COLOR_SECONDARY}]")
196
+ saved_remote = await save_profile_to_backend(profile)
197
+
198
+ if saved_remote:
199
+ print_success("Profile saved!")
200
+ else:
201
+ print_info("Profile saved locally (will sync when online)")
202
+
203
+ # Mark completed
204
+ mark_onboarding_completed()
205
+
206
+ # Personalized welcome
207
+ console.print()
208
+ console.print(f"[bold {COLOR_PRIMARY}]--- All set, {profile['name']}! ---[/bold {COLOR_PRIMARY}]")
209
+ console.print()
210
+ console.print(f"[{COLOR_SECONDARY}]I'll remember your preferences and tailor my suggestions.[/{COLOR_SECONDARY}]")
211
+
212
+ # Show relevant integration suggestions based on daily tools
213
+ if profile.get("daily_tools"):
214
+ supported_tools = ["gmail", "github", "slack", "notion", "linear", "googledrive", "googledocs"]
215
+ suggested = [t for t in profile["daily_tools"] if t in supported_tools]
216
+ if suggested:
217
+ console.print()
218
+ console.print(f"[dim]Tip: Connect your tools with /connect {suggested[0]}[/dim]")
219
+
220
+ console.print()
221
+
222
+ return profile
223
+
224
+
225
+ async def ensure_onboarding() -> Optional[dict]:
226
+ """Ensure onboarding is completed. Returns user profile if available.
227
+
228
+ This is called at the start of interactive_session() in cli.py.
229
+ It handles three scenarios:
230
+ 1. Onboarding already completed locally -> return cached profile
231
+ 2. User exists on backend (cross-device) -> sync and return profile
232
+ 3. New user -> run onboarding flow
233
+
234
+ Returns:
235
+ User profile dict, or None if onboarding was skipped
236
+ """
237
+ # Check local flag first (fastest path)
238
+ if is_onboarding_completed():
239
+ return get_config_value("user_profile")
240
+
241
+ # Check if profile exists on backend (returning user on new device)
242
+ remote_profile = await check_remote_profile()
243
+ if remote_profile and remote_profile.get("onboarding_completed"):
244
+ # Cache locally and mark complete
245
+ set_config_value("user_profile", remote_profile)
246
+ mark_onboarding_completed()
247
+ name = remote_profile.get("name", "Friend")
248
+ console.print(f"[{COLOR_SECONDARY}]Welcome back, {name}![/{COLOR_SECONDARY}]")
249
+ console.print()
250
+ return remote_profile
251
+
252
+ # Run onboarding for new users
253
+ return await run_onboarding()
254
+
255
+
256
+ def get_user_profile() -> Optional[dict]:
257
+ """Get the cached user profile.
258
+
259
+ Returns:
260
+ User profile dict, or None if not set
261
+ """
262
+ return get_config_value("user_profile")
263
+
264
+
265
+ async def handle_profile_command(args: str = ""):
266
+ """Handle /profile command - view or update profile.
267
+
268
+ Usage:
269
+ /profile - View current profile
270
+ /profile edit - Re-run onboarding to update profile
271
+ """
272
+ parts = args.strip().split()
273
+
274
+ if parts and parts[0] == "edit":
275
+ # Re-run onboarding
276
+ await run_onboarding()
277
+ return
278
+
279
+ # Show current profile
280
+ profile = get_user_profile()
281
+
282
+ if not profile:
283
+ print_info("No profile found. Run /profile edit to set up.")
284
+ return
285
+
286
+ console.print()
287
+ console.print(f"[bold {COLOR_SECONDARY}]--- Your Profile ---[/bold {COLOR_SECONDARY}]")
288
+ console.print()
289
+ console.print(f" [bold]Name:[/bold] {profile.get('name', 'Not set')}")
290
+ console.print(f" [bold]Email:[/bold] {profile.get('email', 'Not set')}")
291
+ console.print(f" [bold]Role:[/bold] {profile.get('role', 'Not set')}")
292
+ console.print(f" [bold]Work:[/bold] {profile.get('work_context', 'Not set')}")
293
+ console.print(f" [bold]Goals:[/bold] {profile.get('goals', 'Not set')}")
294
+
295
+ tools = profile.get("daily_tools", [])
296
+ if tools:
297
+ console.print(f" [bold]Tools:[/bold] {', '.join(tools)}")
298
+
299
+ console.print()
300
+ console.print("[dim]Run /profile edit to update[/dim]")
301
+ console.print()
302
+
303
+
304
+ async def sync_profile_to_backend():
305
+ """Sync local profile to backend if not already synced.
306
+
307
+ Called opportunistically when backend becomes available.
308
+ """
309
+ profile = get_user_profile()
310
+ if not profile:
311
+ return
312
+
313
+ # Check if already on backend
314
+ remote = await check_remote_profile()
315
+ if remote:
316
+ return # Already synced
317
+
318
+ # Try to save
319
+ await save_profile_to_backend(profile)