diff --git a/agent_core/core/impl/onboarding/config.py b/agent_core/core/impl/onboarding/config.py index 4a128785..fe39d170 100644 --- a/agent_core/core/impl/onboarding/config.py +++ b/agent_core/core/impl/onboarding/config.py @@ -28,28 +28,21 @@ def _get_config_file() -> Path: # Hard onboarding steps configuration # Each step has: id, required (must complete), title (display name) -# Note: User name is collected during soft onboarding (conversational interview) +# User profile (name, location, language, tone, etc.) is collected in the +# user_profile form step during hard onboarding. HARD_ONBOARDING_STEPS = [ {"id": "provider", "required": True, "title": "LLM Provider"}, {"id": "api_key", "required": True, "title": "API Key"}, {"id": "agent_name", "required": False, "title": "Agent Name"}, + {"id": "user_profile", "required": False, "title": "User Profile"}, {"id": "mcp", "required": False, "title": "MCP Servers"}, {"id": "skills", "required": False, "title": "Skills"}, ] # Soft onboarding interview questions template -# Questions are grouped to reduce conversation turns +# Identity/preferences are now collected in hard onboarding. +# Soft onboarding focuses on job/role and deep life goals exploration. SOFT_ONBOARDING_QUESTIONS = [ - # Batch 1: Identity (asked together) - "name", # What should I call you? "job", # What do you do for work? - "location", # Where are you located? (timezone inferred from this) - # Batch 2: Preferences (asked together) - "tone", # How would you like me to communicate? - "proactivity", # Should I be proactive or wait for instructions? - "approval", # What actions need your approval? - # Batch 3: Messaging - "preferred_messaging_platform", # Where should I send notifications? (telegram/whatsapp/discord/slack/tui) - # Batch 4: Life goals - "life_goals", # What are your life goals and what do you want help with? + "life_goals", # Deep life goals exploration (multiple rounds) ] diff --git a/agent_core/core/prompts/routing.py b/agent_core/core/prompts/routing.py index 9cdca8d9..b9bf1e11 100644 --- a/agent_core/core/prompts/routing.py +++ b/agent_core/core/prompts/routing.py @@ -53,8 +53,8 @@ Return ONLY a valid JSON object: -- Route to existing: {{ "action": "route", "session_id": "", "reason": "" }} -- Create new: {{ "action": "new", "session_id": "new", "reason": "" }} +- Route to existing: {{ "reason": "", "action": "route", "session_id": "" }} +- Create new: {{ "reason": "", "action": "new", "session_id": "new" }} """ diff --git a/app/agent_base.py b/app/agent_base.py index 8ee53288..42e66fbf 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -2115,6 +2115,8 @@ def _reset_agent_file_system_sync(self) -> None: logger.info("[RESET] Agent file system reinitialized from templates") + _soft_onboarding_triggered: bool = False + async def trigger_soft_onboarding(self, reset: bool = False) -> Optional[str]: """ Trigger soft onboarding interview task. @@ -2133,6 +2135,12 @@ async def trigger_soft_onboarding(self, reset: bool = False) -> Optional[str]: from app.trigger import Trigger import time + # Prevent double-triggering (multiple adapters/paths may call this) + if not reset and self._soft_onboarding_triggered: + logger.debug("[ONBOARDING] Soft onboarding already triggered, skipping") + return None + self._soft_onboarding_triggered = True + if reset: onboarding_manager.reset_soft_onboarding() @@ -2741,13 +2749,6 @@ def print_startup_step(step: int, total: int, message: str): # Resume triggers for tasks restored from previous session await self._schedule_restored_task_triggers() - # Trigger soft onboarding if needed (BEFORE starting interface) - # This ensures agent handles onboarding logic, not the interfaces - from app.onboarding import onboarding_manager - if onboarding_manager.needs_soft_onboarding: - logger.info("[ONBOARDING] Soft onboarding needed, triggering from agent") - await self.trigger_soft_onboarding() - # Initialize external communications (WhatsApp, Telegram) print_startup_step(8, 8, "Starting communications") from app.external_comms import ExternalCommsManager diff --git a/app/cli/onboarding.py b/app/cli/onboarding.py index 94e8d588..3ee2276e 100644 --- a/app/cli/onboarding.py +++ b/app/cli/onboarding.py @@ -12,6 +12,7 @@ ProviderStep, ApiKeyStep, AgentNameStep, + UserProfileStep, MCPStep, SkillsStep, ) @@ -173,6 +174,72 @@ async def _select_multiple( return list(selections) + async def _input_form(self, step) -> Dict[str, Any]: + """Present a multi-field form and return collected data as a dict.""" + form_fields = step.get_form_fields() + result: Dict[str, Any] = {} + + print(f"\n{step.title}:") + print(f"{step.description}\n") + + for f in form_fields: + if f.field_type == "text": + default_display = f.default or "" + prompt = f" {f.label}" + if default_display: + prompt += f" (default: {default_display})" + prompt += ": " + try: + value = await self._async_input(prompt) + except (EOFError, KeyboardInterrupt): + value = "" + result[f.name] = value.strip() if value.strip() else (f.default or "") + + elif f.field_type == "select": + print(f"\n {f.label}:") + for i, opt in enumerate(f.options, 1): + marker = "*" if (opt.value == f.default or opt.default) else " " + label = f" {i}. [{marker}] {opt.label}" + if opt.description and opt.description != opt.label: + label += f" - {opt.description}" + print(label) + try: + choice = await self._async_input(f" Enter number [1-{len(f.options)}]: ") + except (EOFError, KeyboardInterrupt): + choice = "" + choice = choice.strip() + if choice: + try: + idx = int(choice) - 1 + if 0 <= idx < len(f.options): + result[f.name] = f.options[idx].value + continue + except ValueError: + pass + result[f.name] = f.default + + elif f.field_type == "multi_checkbox": + print(f"\n {f.label}:") + for i, opt in enumerate(f.options, 1): + print(f" {i}. [ ] {opt.label} - {opt.description}") + print(" Enter numbers to select (comma-separated), or press Enter to skip:") + try: + choice = await self._async_input(" > ") + except (EOFError, KeyboardInterrupt): + choice = "" + selected = [] + for part in choice.split(","): + part = part.strip() + try: + idx = int(part) - 1 + if 0 <= idx < len(f.options): + selected.append(f.options[idx].value) + except ValueError: + continue + result[f.name] = selected + + return result + async def run_hard_onboarding(self) -> Dict[str, Any]: """Execute CLI-based hard onboarding wizard.""" print(CLIFormatter.format_header("CraftBot Setup")) @@ -206,7 +273,21 @@ async def run_hard_onboarding(self) -> Dict[str, Any]: ) self._collected_data["agent_name"] = agent_name or "Agent" - # Step 4: MCP servers (optional) + # Step 4: User Profile (optional) + profile_step = UserProfileStep() + print("\nWould you like to set up your profile? (Y/n)") + try: + configure_profile = await self._async_input("> ") + except (EOFError, KeyboardInterrupt): + configure_profile = "n" + + if not configure_profile.lower().startswith("n"): + profile_data = await self._input_form(profile_step) + self._collected_data["user_profile"] = profile_data + else: + self._collected_data["user_profile"] = {} + + # Step 5: MCP servers (optional) mcp_step = MCPStep() mcp_options = mcp_step.get_options() if mcp_options: @@ -271,9 +352,16 @@ def on_complete(self, cancelled: bool = False) -> None: save_settings_to_json(provider, api_key) logger.info(f"[CLI ONBOARDING] Saved provider={provider} to settings.json") + # Write user profile data to USER.md + profile_data = self._collected_data.get("user_profile", {}) + if profile_data: + from app.onboarding.profile_writer import write_profile_to_user_md + write_profile_to_user_md(profile_data) + # Mark hard onboarding as complete agent_name = self._collected_data.get("agent_name", "Agent") - onboarding_manager.mark_hard_complete(agent_name=agent_name) + user_name = profile_data.get("user_name") if profile_data else None + onboarding_manager.mark_hard_complete(user_name=user_name, agent_name=agent_name) logger.info("[CLI ONBOARDING] Hard onboarding completed successfully") diff --git a/app/config/skills_config.json b/app/config/skills_config.json index 9f6df29a..09aa5d49 100644 --- a/app/config/skills_config.json +++ b/app/config/skills_config.json @@ -2,6 +2,7 @@ "auto_load": true, "enabled_skills": [ "docx", + "file-format", "pdf", "playwright-mcp", "pptx", diff --git a/app/data/agent_file_system_template/USER.md b/app/data/agent_file_system_template/USER.md index 74b1af08..b5c8018c 100644 --- a/app/data/agent_file_system_template/USER.md +++ b/app/data/agent_file_system_template/USER.md @@ -16,8 +16,8 @@ - **Approval Required For:** (Ask the users for info) ## Life Goals -- **Goals:** (Ask the users for info) -- **Help Wanted:** (Ask the users for info) + +(Ask the users for info) ## Personality diff --git a/app/internal_action_interface.py b/app/internal_action_interface.py index a1486f1b..0cd27e39 100644 --- a/app/internal_action_interface.py +++ b/app/internal_action_interface.py @@ -152,14 +152,14 @@ def describe_screen(cls) -> Dict[str, str]: @staticmethod async def do_chat( message: str, - platform: str = "CraftBot TUI", + platform: str = "CraftBot Interface", session_id: Optional[str] = None, ) -> None: """Record an agent-authored chat message to the event stream. Args: message: The message content to record. - platform: The platform the message is sent to (default: "CraftBot TUI"). + platform: The platform the message is sent to (default: "CraftBot Interface"). session_id: Optional task/session ID for multi-task isolation. """ if InternalActionInterface.state_manager is None: diff --git a/app/onboarding/interfaces/steps.py b/app/onboarding/interfaces/steps.py index e8899440..c3856b4b 100644 --- a/app/onboarding/interfaces/steps.py +++ b/app/onboarding/interfaces/steps.py @@ -24,6 +24,17 @@ class StepOption: requires_setup: bool = False # Whether this option requires additional setup (API key, etc.) +@dataclass +class FormField: + """A field in a multi-field form step (e.g., User Profile).""" + name: str # Field key (e.g., "user_name") + label: str # Display label + field_type: str # "text", "select", "multi_checkbox" + options: List["StepOption"] = field(default_factory=list) # For select/checkbox types + default: Any = "" # Default value + placeholder: str = "" # Hint text + + @dataclass class StepResult: """Result of completing an onboarding step.""" @@ -216,6 +227,209 @@ def get_default(self) -> str: return "CraftBot" +class UserProfileStep: + """User profile form step — collects identity and preferences in a compact form.""" + + name = "user_profile" + title = "User Profile" + description = "Tell us about yourself to personalize your experience." + required = False + + TONE_OPTIONS = [ + ("casual", "Casual"), + ("formal", "Formal"), + ("friendly", "Friendly"), + ("professional", "Professional"), + ] + + PROACTIVITY_OPTIONS = [ + ("low", "Low", "Wait for instructions"), + ("medium", "Medium", "Suggest when relevant"), + ("high", "High", "Proactively suggest things"), + ] + + APPROVAL_OPTIONS = [ + ("messages", "Messages", "Sending messages on your behalf"), + ("scheduling", "Scheduling", "Creating/modifying schedules"), + ("file_changes", "File Changes", "Modifying files on your system"), + ("purchases", "Purchases", "Making purchases or payments"), + ("all", "All Actions", "Ask approval for everything"), + ] + + PLATFORM_OPTIONS = [ + ("telegram", "Telegram"), + ("whatsapp", "WhatsApp"), + ("discord", "Discord"), + ("slack", "Slack"), + ("tui", "CraftBot Interface"), + ] + + @staticmethod + def fetch_geolocation() -> str: + """Fetch user's location from IP. Returns 'City, Country' or '' on failure.""" + try: + import requests + resp = requests.get("http://ip-api.com/json", timeout=3) + if resp.status_code == 200: + data = resp.json() + city = data.get("city", "") + country = data.get("country", "") + if city and country: + return f"{city}, {country}" + return country or city or "" + except Exception: + pass + return "" + + @staticmethod + def get_language_options() -> List[StepOption]: + """Get a dynamic list of languages using babel. Pre-select based on OS locale.""" + try: + from babel import Locale + import locale as _locale + + # Get OS locale for pre-selection + try: + os_locale = _locale.getdefaultlocale()[0] or "en_US" + os_lang = os_locale.split("_")[0] + except Exception: + os_lang = "en" + + # Get all language display names from babel (in English) + lang_names = Locale("en").languages + + # Filter to commonly-used languages (those with 2-letter ISO codes) + # and sort by display name + seen = set() + options = [] + for code, display_name in sorted(lang_names.items(), key=lambda x: x[1]): + # Only include 2-letter codes (ISO 639-1) to keep list manageable + if len(code) == 2 and code not in seen: + seen.add(code) + options.append(StepOption( + value=code, + label=display_name, + description=code, + default=(code == os_lang), + )) + return options + except ImportError: + # Fallback if babel not installed — return a minimal list + return [ + StepOption(value="en", label="English", description="en", default=True), + StepOption(value="zh", label="Chinese", description="zh"), + StepOption(value="es", label="Spanish", description="es"), + StepOption(value="fr", label="French", description="fr"), + StepOption(value="de", label="German", description="de"), + StepOption(value="ja", label="Japanese", description="ja"), + StepOption(value="ko", label="Korean", description="ko"), + StepOption(value="pt", label="Portuguese", description="pt"), + StepOption(value="ru", label="Russian", description="ru"), + StepOption(value="ar", label="Arabic", description="ar"), + ] + + def get_form_fields(self) -> List[FormField]: + """Return all form fields for the user profile step.""" + # Fetch defaults + try: + location_default = self.fetch_geolocation() + except Exception: + location_default = "" + + language_options = self.get_language_options() + + # Find pre-selected language + lang_default = "en" + for opt in language_options: + if opt.default: + lang_default = opt.value + break + + return [ + FormField( + name="user_name", + label="Your Name", + field_type="text", + placeholder="What should we call you?", + default="", + ), + FormField( + name="location", + label="Location", + field_type="text", + placeholder="City, Country", + default=location_default, + ), + FormField( + name="language", + label="CraftBot's Language", + field_type="select", + options=language_options, + default=lang_default, + placeholder="The language CraftBot will communicate in (not the interface language)", + ), + FormField( + name="tone", + label="Communication Tone", + field_type="select", + options=[ + StepOption(value=val, label=label, default=(val == "casual")) + for val, label in self.TONE_OPTIONS + ], + default="casual", + ), + FormField( + name="proactivity", + label="Proactive Level", + field_type="select", + options=[ + StepOption(value=val, label=label, description=desc, default=(val == "medium")) + for val, label, desc in self.PROACTIVITY_OPTIONS + ], + default="medium", + ), + FormField( + name="approval", + label="Require Approval For", + field_type="multi_checkbox", + options=[ + StepOption(value=val, label=label, description=desc) + for val, label, desc in self.APPROVAL_OPTIONS + ], + default=[], + ), + FormField( + name="messaging_platform", + label="Preferred Notification Platform", + field_type="select", + options=[ + StepOption(value=val, label=label, default=(val == "tui")) + for val, label in self.PLATFORM_OPTIONS + ], + default="tui", + ), + ] + + def get_options(self) -> List[StepOption]: + # Not a single-select step — form fields are used instead + return [] + + def validate(self, value: Any) -> tuple[bool, Optional[str]]: + """Validate the form data dict. All fields are optional.""" + if not isinstance(value, dict): + return False, "Expected a dictionary of form values" + # Validate approval is a list if present + approval = value.get("approval") + if approval is not None and not isinstance(approval, list): + return False, "Approval settings must be a list" + return True, None + + def get_default(self) -> Dict[str, Any]: + """Return defaults for all fields.""" + fields = self.get_form_fields() + return {f.name: f.default for f in fields} + + class MCPStep: """MCP server selection step.""" @@ -343,6 +557,7 @@ def get_default(self) -> List[str]: ProviderStep, ApiKeyStep, AgentNameStep, + UserProfileStep, MCPStep, SkillsStep, ] diff --git a/app/onboarding/profile_writer.py b/app/onboarding/profile_writer.py new file mode 100644 index 00000000..42fb2e20 --- /dev/null +++ b/app/onboarding/profile_writer.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +""" +Shared utility to write user profile data to USER.md. + +Used by all onboarding completion handlers (TUI, CLI, Browser controller) +to populate USER.md with data collected during hard onboarding. +""" + +import re +from typing import Any, Dict + +from app.logger import logger + + +def write_profile_to_user_md(profile_data: Dict[str, Any]) -> bool: + """ + Write user profile data collected during hard onboarding to USER.md. + + Updates Identity, Communication Preferences, and Agent Interaction + sections. Infers timezone from location using tzlocal. + + Args: + profile_data: Dict with keys: user_name, location, language, + tone, proactivity, approval, messaging_platform + + Returns: + True if successfully written, False otherwise. + """ + if not profile_data: + return False + + try: + from app.config import AGENT_FILE_SYSTEM_PATH + + user_md_path = AGENT_FILE_SYSTEM_PATH / "USER.md" + if not user_md_path.exists(): + logger.warning("[PROFILE] USER.md not found, skipping profile write") + return False + + content = user_md_path.read_text(encoding="utf-8") + + user_name = profile_data.get("user_name", "").strip() + location = profile_data.get("location", "").strip() + language = profile_data.get("language", "").strip() + tone = profile_data.get("tone", "").strip() + proactivity = profile_data.get("proactivity", "").strip() + approval = profile_data.get("approval", []) + messaging_platform = profile_data.get("messaging_platform", "").strip() + + # Infer timezone from system + timezone_str = _infer_timezone() + + # --- Identity section --- + if user_name: + content = _replace_field(content, "Full Name", user_name) + content = _replace_field(content, "Preferred Name", user_name) + + if location: + content = _replace_field(content, "Location", location) + + if timezone_str: + content = _replace_field(content, "Timezone", timezone_str) + + # --- Communication Preferences section --- + if language: + content = _replace_field(content, "Language", language) + + if tone: + content = _replace_field(content, "Preferred Tone", tone) + + if messaging_platform: + content = _replace_field(content, "Preferred Messaging Platform", messaging_platform) + + # --- Agent Interaction section --- + if proactivity: + content = _replace_field(content, "Prefer Proactive Assistance", proactivity) + + if isinstance(approval, list) and approval: + approval_str = _format_approval(approval) + content = _replace_field(content, "Approval Required For", approval_str) + + user_md_path.write_text(content, encoding="utf-8") + logger.info("[PROFILE] Successfully wrote user profile to USER.md") + return True + + except Exception as e: + logger.error(f"[PROFILE] Failed to write profile to USER.md: {e}") + return False + + +def _replace_field(content: str, field_name: str, value: str) -> str: + """Replace a markdown bold field value in USER.md. + + Matches patterns like: - **Field Name:** + """ + pattern = rf'(\*\*{re.escape(field_name)}:\*\*\s*).*' + replacement = rf'\1{value}' + return re.sub(pattern, replacement, content) + + +APPROVAL_DESCRIPTIONS = { + "messages": "Ask before sending messages or notifications on user's behalf", + "scheduling": "Ask before creating, modifying, or deleting schedules and calendar events", + "file_changes": "Ask before creating, modifying, or deleting files on the user's system", + "purchases": "Ask before making any purchases, payments, or financial transactions", + "all": "Ask for explicit approval before taking any action", +} + + +def _format_approval(approval: list) -> str: + """Convert approval keys to descriptive sentences for the agent.""" + if "all" in approval: + return APPROVAL_DESCRIPTIONS["all"] + descriptions = [APPROVAL_DESCRIPTIONS.get(key, key) for key in approval] + return "; ".join(descriptions) + + +def _infer_timezone() -> str: + """Infer timezone from system using tzlocal.""" + try: + from tzlocal import get_localzone + tz = get_localzone() + return str(tz) + except Exception: + return "" diff --git a/app/onboarding/soft/task_creator.py b/app/onboarding/soft/task_creator.py index b7d36468..ab7f4171 100644 --- a/app/onboarding/soft/task_creator.py +++ b/app/onboarding/soft/task_creator.py @@ -15,63 +15,49 @@ SOFT_ONBOARDING_TASK_INSTRUCTION = """ -Conduct a friendly conversational interview to learn about the user. - -Your goal is to gather information to personalize the agent experience efficiently. -Ask MULTIPLE related questions together to reduce back-and-forth turns. - -INTERVIEW FLOW (4 batches): - -1. Warm Introduction + Identity Questions -Start with a friendly greeting and ask the first batch using a numbered list: - - What should I call you? - - What do you do for work? - - Where are you based? - (Infer timezone from their location, keep this silent) - - Example opening: - > "Hi there! I'm excited to be your new AI assistant. To personalize your experience, let me ask a few quick questions: - > 1. What should I call you? - > 2. What do you do for work? - > 3. Where are you based?" - -2. Preference Questions (Combined) - - What language do you prefer me to communicate in? - - Do you prefer casual or formal communication? - - Should I proactively suggest things or wait for instructions? - - What types of actions should I ask your approval for? - -3. Messaging Platform - - Which messaging platform should I use for notifications? (Telegram/WhatsApp/Discord/Slack/CraftBot Interface only) - -4. Life Goals & Assistance - - What are your life goals or aspirations? - - What would you like me to help you with generally? - -Refer to the "user-profile-interview" skill for questions and style. - -IMPORTANT GUIDELINES: -- Ask related questions together using a numbered list format -- Be warm and conversational, not robotic -- Acknowledge their answers before the next batch -- Infer timezone from location (e.g., San Francisco = Pacific Time) -- The life goals question is most important, ask multiple questions if necessary or goal unclear. Guide them to answer this question. Skip if user has no life or goal. -- If user is annoyed by this interview or refuse to answer, just skip, and end task. - -After gathering ALL information: -1. Tell the user to wait a moment while you update their preference -2. Read agent_file_system/USER.md -3. Update USER.md with the collected information using stream_edit (including Language in Communication Preferences and Life Goals section) -4. Suggest tasks based on life goals: Send a message suggesting 1-3 tasks that CraftBot can help with to improve their life and get closer to achieving their goals. Focus on: - - Tasks that leverage CraftBot's automation capabilities - - Recurring tasks that save time in the long run - - Immediate tasks that can show impact in short-term - - Bite-size tasks that is specialized, be specific with numbers or actionable items. DO NOT suggest generic task. - - Avoid giving mutliple approaches in each suggested task, provide the BEST option to achieve goal. - - Tasks that align with their work and personal aspirations -5. End the task immediately with task_end (do NOT wait for confirmation) - -Start with: "Hi! I'm excited to be your AI assistant. To personalize your experience, let me ask a few quick questions:" then list the first batch. +Conduct a natural conversation with the user to understand their work and life goals. + +The user already provided their name, location, language, communication tone, proactivity, +approval settings, and notification platform during setup. These are saved in +agent_file_system/USER.md. Read it first so you know who you're talking to. +Do not re-ask any of that. + +Never use scripted or static phrases. Rephrase everything naturally each time. +Match the user's energy and style. + +Phase 1: Greeting + Job/Role +Read agent_file_system/USER.md to get the user's name. Greet them by name in your own words. +Ask about their work and what a typical day looks like. +Acknowledge their answer before moving on. + +Phase 2: Life Goals Exploration +Ask about their goals and aspirations in your own words. +Follow up on the goal they mention to understand timelines, obstacles, what success looks like. +If the user is engaged, continue exploring what else they're working toward, habits they want +to build, skills they want to develop, what would make their day-to-day easier. +If the user is brief or disengaged, wrap up gracefully. Do not push for more question. Move on to phase 3. +If the user has no goals or refuses, respect that and move on to phase 3. + +Phase 3: How CraftBot Helps + Task Suggestions +In one message, explain how CraftBot can help them based on what you learned, and suggest +1-3 specific tasks. Each suggestion must say exactly what you will do and what the +deliverable is. Do not describe generic tasks — describe actions with concrete outputs. +At least one suggestion must be something you can execute immediately after this conversation +and deliver a tangible result. +Bad example: "Research synthesis - I can summarize AGI papers" +Good example: "I'll research the top 5 AGI breakthroughs this month and send you a summary now." + +After the conversation: +1. Tell the user to wait a moment while you update your knowledge about them. +2. Read agent_file_system/USER.md using read_file. +3. Update USER.md using stream_edit: + - Update the Job field + - Write their goals as free-form text under Life Goals + - Write personality observations under Personality + - Do not overwrite name, location, language, tone, proactivity, approval, or messaging platform +4. Update agent_file_system/AGENT.md if user provided a name for the agent. +5. Send your explanation of how CraftBot can help and your task suggestions. +6. End the task with task_end. Do not wait for confirmation. """ diff --git a/app/tui/onboarding/hard_onboarding.py b/app/tui/onboarding/hard_onboarding.py index b7a719d6..0e55e6a0 100644 --- a/app/tui/onboarding/hard_onboarding.py +++ b/app/tui/onboarding/hard_onboarding.py @@ -10,6 +10,7 @@ ProviderStep, ApiKeyStep, AgentNameStep, + UserProfileStep, MCPStep, SkillsStep, ) @@ -43,6 +44,7 @@ def __init__(self, app: "CraftApp"): ProviderStep(), None, # ApiKeyStep - created dynamically based on provider AgentNameStep(), + UserProfileStep(), MCPStep(), SkillsStep(), ] @@ -122,9 +124,16 @@ def on_complete(self, cancelled: bool = False) -> None: self._app._interface._agent.llm.reinitialize(provider) logger.info(f"[ONBOARDING] Reinitialized LLM with provider: {provider}") + # Write user profile data to USER.md + profile_data = self._collected_data.get("user_profile", {}) + if profile_data: + from app.onboarding.profile_writer import write_profile_to_user_md + write_profile_to_user_md(profile_data) + # Mark hard onboarding as complete agent_name = self._collected_data.get("agent_name", "Agent") - onboarding_manager.mark_hard_complete(agent_name=agent_name) + user_name = profile_data.get("user_name") if profile_data else None + onboarding_manager.mark_hard_complete(user_name=user_name, agent_name=agent_name) logger.info("[ONBOARDING] Hard onboarding completed successfully") diff --git a/app/tui/onboarding/widgets.py b/app/tui/onboarding/widgets.py index e161abad..44116a68 100644 --- a/app/tui/onboarding/widgets.py +++ b/app/tui/onboarding/widgets.py @@ -3,7 +3,7 @@ Textual widgets for the onboarding wizard. """ -from typing import TYPE_CHECKING, Any, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from textual.app import ComposeResult from textual.containers import Container, Horizontal, Vertical, VerticalScroll @@ -191,6 +191,82 @@ text-style: italic; margin-top: 1; } + +/* Profile form - compact scrollable multi-field form */ +.profile-form { + height: auto; + max-height: 22; + padding: 0 1; +} + +.form-field { + height: auto; + margin-bottom: 1; +} + +.form-label { + color: #ff4f18; + text-style: bold; + height: 1; +} + +.form-input { + width: 100%; + border: solid #2a2a2a; + background: #0a0a0a; + color: #e5e5e5; +} + +.form-input:focus { + border: solid #ff4f18; +} + +.form-select { + width: 30; + height: auto; + max-height: 4; + background: transparent; + border: none; + margin: 0 0; +} + +.form-select > ListItem { + padding: 0 0; +} + +.form-select > ListItem.--highlight .option-label { + background: #ff4f18; + color: #ffffff; + text-style: bold; +} + +.form-checkbox-row { + height: 1; + margin-bottom: 0; +} + +.form-checkbox-toggle { + width: 3; + min-width: 3; + height: 1; + background: #333333; + color: #666666; + border: none; + margin-right: 1; +} + +.form-checkbox-toggle.-checked { + color: #00cc00; +} + +.form-checkbox-toggle:hover { + background: #00cc00; + color: #000000; +} + +.form-checkbox-label { + color: #a0a0a0; +} """ @@ -215,6 +291,9 @@ def __init__(self, handler: "TUIHardOnboarding"): self._handler = handler self._current_step = 0 self._multi_select_values: List[str] = [] + # Form step state + self._form_fields: List[Any] = [] + self._form_checkbox_values: Dict[str, List[str]] = {} def compose(self) -> ComposeResult: with Container(id="onboarding-container"): @@ -279,17 +358,27 @@ def _show_step(self, index: int) -> None: content = self.query_one("#step-content", Container) content.remove_children() + # Check for form step (e.g., UserProfileStep) + form_fields = getattr(step, 'get_form_fields', lambda: [])() options = step.get_options() - if step.name in ("mcp", "skills"): + if form_fields: + # Multi-field form + self._form_fields = form_fields + self._form_checkbox_values = {} + self._build_form(content, step, form_fields) + elif step.name in ("mcp", "skills"): # Multi-select list + self._form_fields = [] self._multi_select_values = step.get_default() self._build_multi_select(content, options) elif options: # Single-select list + self._form_fields = [] self._build_option_list(content, options, step.get_default()) else: # Text input + self._form_fields = [] self._build_text_input(content, step.get_default()) def _update_nav_items(self, index: int, required: bool) -> None: @@ -369,13 +458,126 @@ def _build_multi_select(self, container: Container, options: list) -> None: container.mount(scroll) + def _build_form(self, container: Container, step: Any, fields: list) -> None: + """Build a compact scrollable form with multiple field types.""" + scroll = VerticalScroll(id="profile-form", classes="profile-form") + + for f in fields: + field_container = Vertical(classes="form-field") + + # Label + field_container.compose_add_child( + Static(f.label, classes="form-label") + ) + + if f.field_type == "text": + inp = Input( + value=str(f.default) if f.default else "", + placeholder=f.placeholder or "Enter value...", + id=f"form-{f.name}", + classes="form-input", + ) + field_container.compose_add_child(inp) + + elif f.field_type == "select": + items = [] + highlight_idx = 0 + for i, opt in enumerate(f.options): + label_text = f" {opt.label}" + if opt.description and opt.description != opt.label: + label_text += f" ({opt.description})" + items.append( + ListItem( + Label(label_text, classes="option-label"), + id=f"fopt-{f.name}-{opt.value}", + ) + ) + if opt.value == f.default or opt.default: + highlight_idx = i + + list_view = ListView( + *items, + id=f"form-select-{f.name}", + classes="form-select", + ) + field_container.compose_add_child(list_view) + + # Highlight default after mount + _idx = highlight_idx + def _make_highlight(lv=list_view, idx=_idx): + def _set(): + lv.index = idx + return _set + self.call_after_refresh(_make_highlight()) + + elif f.field_type == "multi_checkbox": + self._form_checkbox_values[f.name] = list(f.default) if isinstance(f.default, list) else [] + for opt in f.options: + is_checked = opt.value in self._form_checkbox_values[f.name] + toggle_text = "[x]" if is_checked else "[ ]" + toggle_cls = "form-checkbox-toggle -checked" if is_checked else "form-checkbox-toggle" + row = Horizontal( + Button(toggle_text, id=f"fchk-{f.name}-{opt.value}", classes=toggle_cls), + Static(f" {opt.label}", classes="form-checkbox-label"), + classes="form-checkbox-row", + ) + field_container.compose_add_child(row) + + scroll.compose_add_child(field_container) + + container.mount(scroll) + + # Focus the first text input if any + def _focus_first(): + for f in fields: + if f.field_type == "text": + widget = self.query(f"#form-{f.name}") + if widget: + widget.first().focus() + break + self.call_after_refresh(_focus_first) + + def _get_form_value(self) -> Dict[str, Any]: + """Extract all values from the form fields.""" + result: Dict[str, Any] = {} + for f in self._form_fields: + if f.field_type == "text": + widget = self.query(f"#form-{f.name}") + result[f.name] = widget.first().value.strip() if widget else f.default + + elif f.field_type == "select": + widget = self.query(f"#form-select-{f.name}") + if widget: + lv = widget.first() + if lv and lv.highlighted_child: + item_id = lv.highlighted_child.id + prefix = f"fopt-{f.name}-" + if item_id and item_id.startswith(prefix): + result[f.name] = item_id[len(prefix):] + continue + result[f.name] = f.default + + elif f.field_type == "multi_checkbox": + result[f.name] = list(self._form_checkbox_values.get(f.name, [])) + + else: + result[f.name] = f.default + return result + def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses (for multi-select toggles).""" + """Handle button presses (for multi-select toggles and form checkboxes).""" button_id = event.button.id if button_id and button_id.startswith("toggle-"): value = button_id[7:] # Remove "toggle-" prefix self._toggle_multi_select(value, event.button) + elif button_id and button_id.startswith("fchk-"): + # Form checkbox toggle: "fchk-{field_name}-{value}" + parts = button_id[5:] # Remove "fchk-" + dash_idx = parts.index("-") + field_name = parts[:dash_idx] + value = parts[dash_idx + 1:] + self._toggle_form_checkbox(field_name, value, event.button) def on_list_view_selected(self, event: ListView.Selected) -> None: """Handle list view selection.""" @@ -412,10 +614,26 @@ def _toggle_multi_select(self, value: str, button: Button) -> None: button.label = "[+]" button.add_class("-selected") + def _toggle_form_checkbox(self, field_name: str, value: str, button: Button) -> None: + """Toggle a form checkbox option.""" + values = self._form_checkbox_values.setdefault(field_name, []) + if value in values: + values.remove(value) + button.label = "[ ]" + button.remove_class("-checked") + else: + values.append(value) + button.label = "[x]" + button.add_class("-checked") + def _get_current_value(self) -> Any: """Get the current value from the active step widget.""" step = self._handler.get_step(self._current_step) + # Form step returns a dict + if self._form_fields: + return self._get_form_value() + if step.name in ("mcp", "skills"): return self._multi_select_values diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 0f341056..803016eb 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -967,8 +967,19 @@ async def _websocket_handler(self, request: "web.Request") -> "web.WebSocketResp print(f"[BROWSER ADAPTER] Failed to prepare WebSocket: {e}") return ws + is_first_client = len(self._ws_clients) == 0 self._ws_clients.add(ws) + # Trigger soft onboarding on first client connection so the UI + # is ready to receive the task creation event. + if is_first_client: + from app.onboarding import onboarding_manager + if onboarding_manager.needs_soft_onboarding: + agent = self._controller.agent + if agent: + import asyncio + asyncio.create_task(agent.trigger_soft_onboarding()) + # Send initial state try: initial_state = self._get_initial_state() @@ -1554,6 +1565,7 @@ async def _handle_onboarding_step_get(self) -> None: ], "default": controller.get_step_default(), "provider": getattr(step, "provider", None), + "form_fields": self._get_step_form_fields(step), }, }, }) @@ -1567,6 +1579,27 @@ async def _handle_onboarding_step_get(self) -> None: }, }) + @staticmethod + def _get_step_form_fields(step) -> Optional[list]: + """Extract form field definitions from a step, if it supports them.""" + form_fields = getattr(step, 'get_form_fields', lambda: [])() + if not form_fields: + return None + return [ + { + "name": f.name, + "label": f.label, + "field_type": f.field_type, + "options": [ + {"value": o.value, "label": o.label, "description": o.description, "default": o.default} + for o in f.options + ], + "default": f.default, + "placeholder": f.placeholder, + } + for f in form_fields + ] + async def _handle_onboarding_step_submit(self, value: Any) -> None: """Submit a value for the current onboarding step.""" try: @@ -1674,6 +1707,7 @@ async def _handle_onboarding_step_submit(self, value: Any) -> None: ], "default": controller.get_step_default(), "provider": getattr(step, "provider", None), + "form_fields": self._get_step_form_fields(step), }, }, }) diff --git a/app/ui_layer/adapters/cli_adapter.py b/app/ui_layer/adapters/cli_adapter.py index 51eef778..158a0bed 100644 --- a/app/ui_layer/adapters/cli_adapter.py +++ b/app/ui_layer/adapters/cli_adapter.py @@ -181,6 +181,14 @@ async def _on_start(self) -> None: if onboarding.needs_hard_onboarding: await self._run_hard_onboarding(onboarding) + # Trigger soft onboarding if needed (after hard onboarding check) + from app.onboarding import onboarding_manager + if onboarding_manager.needs_soft_onboarding: + import asyncio + agent = self._controller.agent + if agent: + asyncio.create_task(agent.trigger_soft_onboarding()) + # Print logo and welcome _get_formatter().print_logo() from app.config import get_app_version diff --git a/app/ui_layer/adapters/tui_adapter.py b/app/ui_layer/adapters/tui_adapter.py index 742f2257..5cd5fd7a 100644 --- a/app/ui_layer/adapters/tui_adapter.py +++ b/app/ui_layer/adapters/tui_adapter.py @@ -390,6 +390,14 @@ async def _on_start(self) -> None: # Run onboarding before starting Textual app await self._run_hard_onboarding(onboarding) + # Trigger soft onboarding if needed (after hard onboarding check) + from app.onboarding import onboarding_manager + if onboarding_manager.needs_soft_onboarding: + import asyncio + agent = self._controller.agent + if agent: + asyncio.create_task(agent.trigger_soft_onboarding()) + # Queue initial messages from app.config import get_app_version await self.chat_updates.put( diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css index 31082228..d44e5dce 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css @@ -329,6 +329,252 @@ margin-top: var(--space-2); } +/* ── Profile Form (multi-field form step) ── */ + +.profileForm { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--space-6); + padding-right: var(--space-2); + max-height: 420px; +} + +.profileForm::-webkit-scrollbar { + width: 6px; +} + +.profileForm::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: var(--radius-full); +} + +.profileForm::-webkit-scrollbar-thumb { + background: var(--border-secondary); + border-radius: var(--radius-full); +} + +.profileForm::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); +} + +.formField { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.formFieldLabel { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); + margin-bottom: 2px; +} + +.formFieldHint { + font-size: var(--text-xs); + color: var(--text-secondary); + margin-top: 4px; +} + +/* ── Shared radio dot styles ── */ + +.formSelectOptionInline .optionRadio, +.formSelectOptionVertical .optionRadio { + width: 14px; + height: 14px; + min-width: 14px; + min-height: 14px; + border: 2px solid var(--text-secondary); + border-radius: var(--radius-full); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all var(--transition-base); +} + +.formSelectOptionInline .optionRadio::after, +.formSelectOptionVertical .optionRadio::after { + content: ''; + width: 6px; + height: 6px; + border-radius: var(--radius-full); + background: var(--color-primary); + opacity: 0; + transition: opacity var(--transition-base); +} + +.formSelectOptionInline.selected .optionRadio, +.formSelectOptionVertical.selected .optionRadio { + border-color: var(--color-primary); +} + +.formSelectOptionInline.selected .optionRadio::after, +.formSelectOptionVertical.selected .optionRadio::after { + opacity: 1; +} + +.formSelectOptionInline:hover .optionRadio, +.formSelectOptionVertical:hover .optionRadio { + border-color: var(--text-primary); +} + +/* ── Inline select (no descriptions, e.g. Communication Tone) ── */ + +.formSelectInline { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.formSelectOptionInline { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 8px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-full); + cursor: pointer; + transition: all var(--transition-base); + font-size: var(--text-sm); +} + +.formSelectOptionInline:hover { + border-color: var(--text-secondary); +} + +.formSelectOptionInline.selected { + border-color: var(--color-primary); + background: var(--color-primary-subtle); +} + +/* ── Vertical select (with descriptions, e.g. Proactive Level) ── */ + +.formSelectVertical { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.formSelectOptionVertical { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 10px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-base); + font-size: var(--text-sm); +} + +.formSelectOptionVertical:hover { + border-color: var(--text-secondary); +} + +.formSelectOptionVertical.selected { + border-color: var(--color-primary); + background: var(--color-primary-subtle); +} + +/* Native dropdown for large option lists (e.g., language) */ +.formDropdown { + width: 100%; + padding: var(--space-2) var(--space-3); + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: var(--text-sm); + font-family: var(--font-sans); + cursor: pointer; + transition: border-color var(--transition-base); + appearance: auto; +} + +.formDropdown:focus { + outline: none; + border-color: var(--color-primary); +} + +.formDropdown option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.formSelectLabel { + font-size: var(--text-sm); + color: var(--text-primary); +} + +.formSelectDesc { + font-size: var(--text-xs); + color: var(--text-secondary); + margin-left: 4px; +} + +.formSelectDesc::before { + content: '— '; +} + +/* Checkbox group for form fields */ +.formCheckboxGroup { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: var(--space-2); +} + +.formCheckboxItem { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 8px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition-base); + font-size: var(--text-sm); + color: var(--text-primary); +} + +.formCheckboxItem:hover { + border-color: var(--text-secondary); +} + +.formCheckboxItem.selected { + border-color: var(--color-primary); + background: var(--color-primary-subtle); +} + +.formCheckboxItem .optionCheckbox { + width: 16px; + height: 16px; + min-width: 16px; + min-height: 16px; + border: 2px solid var(--text-secondary); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all var(--transition-base); +} + +.formCheckboxItem.selected .optionCheckbox { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-white, #fff); +} + +.formCheckboxItem:hover .optionCheckbox { + border-color: var(--text-primary); +} + /* Error Message */ .errorMessage { display: flex; diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index 46bf5e23..27d8cbb6 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -31,7 +31,7 @@ import { } from 'lucide-react' import { Button } from '../../components/ui' import { useWebSocket } from '../../contexts/WebSocketContext' -import type { OnboardingStep, OnboardingStepOption } from '../../types' +import type { OnboardingStep, OnboardingStepOption, OnboardingFormField } from '../../types' import styles from './OnboardingPage.module.css' // Icon mapping for dynamic rendering @@ -53,7 +53,7 @@ const ICON_MAP: Record = { Sheet, } -const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'MCP Servers', 'Skills'] +const STEP_NAMES = ['Provider', 'API Key', 'Agent Name', 'User Profile', 'MCP Servers', 'Skills'] // ── Ollama local-setup component ───────────────────────────────────────────── @@ -340,6 +340,8 @@ export function OnboardingPage() { // URL submitted from OllamaSetup const [ollamaUrl, setOllamaUrl] = useState('http://localhost:11434') const [ollamaConnected, setOllamaConnected] = useState(false) + // Form step state (for user_profile and similar multi-field steps) + const [formValues, setFormValues] = useState>({}) // Request first step when connected useEffect(() => { @@ -353,7 +355,14 @@ export function OnboardingPage() { if (onboardingStep) { setOllamaConnected(false) - if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { + // Form step (e.g., user_profile) + if (onboardingStep.form_fields && onboardingStep.form_fields.length > 0) { + const defaults: Record = {} + for (const field of onboardingStep.form_fields) { + defaults[field.name] = field.default ?? '' + } + setFormValues(defaults) + } else if (onboardingStep.name === 'mcp' || onboardingStep.name === 'skills') { setSelectedValue(Array.isArray(onboardingStep.default) ? onboardingStep.default : []) } else if (onboardingStep.options.length > 0) { const defaultOption = onboardingStep.options.find(opt => opt.default) @@ -396,18 +405,21 @@ export function OnboardingPage() { if (isOllamaStep) { submitOnboardingStep(ollamaUrl) + } else if (onboardingStep.form_fields && onboardingStep.form_fields.length > 0) { + submitOnboardingStep(formValues) } else if (onboardingStep.options.length > 0) { submitOnboardingStep(selectedValue) } else { submitOnboardingStep(textValue) } - }, [onboardingStep, selectedValue, textValue, ollamaUrl, submitOnboardingStep]) + }, [onboardingStep, selectedValue, textValue, ollamaUrl, formValues, submitOnboardingStep]) const handleSkip = useCallback(() => skipOnboardingStep(), [skipOnboardingStep]) const handleBack = useCallback(() => goBackOnboardingStep(), [goBackOnboardingStep]) const isMultiSelect = onboardingStep?.name === 'mcp' || onboardingStep?.name === 'skills' - const isWideStep = isMultiSelect + const isFormStep = !!(onboardingStep?.form_fields && onboardingStep.form_fields.length > 0) + const isWideStep = isMultiSelect || isFormStep const isLastStep = onboardingStep ? onboardingStep.index === onboardingStep.total - 1 : false const isOllamaStep = @@ -419,6 +431,7 @@ export function OnboardingPage() { if (isOllamaStep) { return ollamaConnected || (localLLM.phase === 'connected' && !!localLLM.testResult?.success) } + if (isFormStep) return true // All form fields are optional if (onboardingStep.options.length > 0) { return isMultiSelect ? true : !!selectedValue } @@ -457,6 +470,123 @@ export function OnboardingPage() { ) } + // Form step (multi-field form, e.g., user_profile) + if (onboardingStep.form_fields && onboardingStep.form_fields.length > 0) { + return ( +
+
+ {onboardingStep.form_fields.map((field: OnboardingFormField) => ( +
+ + + {field.field_type === 'text' && ( + setFormValues(prev => ({ ...prev, [field.name]: e.target.value }))} + placeholder={field.placeholder || `Enter ${field.label.toLowerCase()}`} + /> + )} + + {field.field_type === 'select' && field.options.length > 20 ? ( + /* Large option list (e.g., languages) — use native dropdown */ + <> + + {field.placeholder && ( +
{field.placeholder}
+ )} + + ) : field.field_type === 'select' ? (() => { + const hasDescriptions = field.options.some(o => o.description && o.description !== o.label) + if (hasDescriptions) { + /* Options with descriptions — vertical stack */ + return ( +
+ {field.options.map(opt => { + const isSelected = formValues[field.name] === opt.value + return ( +
setFormValues(prev => ({ ...prev, [field.name]: opt.value }))} + > +
+ {opt.label} + {opt.description && opt.description !== opt.label && ( + {opt.description} + )} +
+ ) + })} +
+ ) + } + /* Simple options without descriptions — inline row */ + return ( +
+ {field.options.map(opt => { + const isSelected = formValues[field.name] === opt.value + return ( +
setFormValues(prev => ({ ...prev, [field.name]: opt.value }))} + > +
+ {opt.label} +
+ ) + })} +
+ ) + })() : null} + + {field.field_type === 'multi_checkbox' && ( +
+ {field.options.map(opt => { + const checked = Array.isArray(formValues[field.name]) && + (formValues[field.name] as string[]).includes(opt.value) + return ( +
{ + setFormValues(prev => { + const current = Array.isArray(prev[field.name]) ? (prev[field.name] as string[]) : [] + const updated = current.includes(opt.value) + ? current.filter(v => v !== opt.value) + : [...current, opt.value] + return { ...prev, [field.name]: updated } + }) + }} + > +
+ {checked && } +
+ {opt.label} +
+ ) + })} +
+ )} +
+ ))} +
+
+ ) + } + // Option-based step if (onboardingStep.options.length > 0) { return ( diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index a6d55b27..d312df36 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -533,6 +533,15 @@ export interface OnboardingStepOption { requires_setup?: boolean // Whether this option needs API key or additional setup } +export interface OnboardingFormField { + name: string + label: string + field_type: 'text' | 'select' | 'multi_checkbox' + options: OnboardingStepOption[] + default: string | string[] + placeholder: string +} + export interface OnboardingStep { name: string title: string @@ -543,6 +552,7 @@ export interface OnboardingStep { options: OnboardingStepOption[] default: string | string[] | null provider?: string | null // only present on the api_key step + form_fields?: OnboardingFormField[] | null // present on form steps (e.g., user_profile) } // ───────────────────────────────────────────────────────────────────── diff --git a/app/ui_layer/onboarding/controller.py b/app/ui_layer/onboarding/controller.py index e433b9cc..4a68cf7f 100644 --- a/app/ui_layer/onboarding/controller.py +++ b/app/ui_layer/onboarding/controller.py @@ -9,6 +9,7 @@ ProviderStep, ApiKeyStep, AgentNameStep, + UserProfileStep, MCPStep, SkillsStep, HardOnboardingStep, @@ -67,6 +68,7 @@ class OnboardingFlowController: ProviderStep, ApiKeyStep, AgentNameStep, + UserProfileStep, MCPStep, SkillsStep, ] @@ -287,11 +289,18 @@ def _complete(self) -> None: for skill_name in selected_skills: enable_skill(skill_name) - # Initialize language from OS locale (first launch only) - self._initialize_user_language() + # Write user profile data to USER.md (replaces _initialize_user_language) + user_profile = self._state.collected_data.get("user_profile", {}) + if user_profile: + from app.onboarding.profile_writer import write_profile_to_user_md + write_profile_to_user_md(user_profile) + else: + # Fallback: initialize language from OS locale if profile step was skipped + self._initialize_user_language() # Mark hard onboarding complete - onboarding_manager.mark_hard_complete(agent_name=agent_name) + user_name = user_profile.get("user_name") if user_profile else None + onboarding_manager.mark_hard_complete(user_name=user_name, agent_name=agent_name) # Trigger soft onboarding now that hard onboarding is done # This is needed because the soft onboarding check in agent.run() happens @@ -362,10 +371,10 @@ def get_step_info(self) -> Dict[str, Any]: Get comprehensive information about the current step. Returns: - Dictionary with step metadata, options, and progress + Dictionary with step metadata, options, progress, and form_fields """ step = self.get_current_step() - return { + info = { "name": step.name, "title": step.title, "description": step.description, @@ -376,3 +385,23 @@ def get_step_info(self) -> Dict[str, Any]: "total_steps": len(self.STEP_CLASSES), "progress": self.get_progress_text(), } + + # Include form fields if the step has them (e.g., UserProfileStep) + form_fields = getattr(step, 'get_form_fields', lambda: [])() + if form_fields: + info["form_fields"] = [ + { + "name": f.name, + "label": f.label, + "field_type": f.field_type, + "options": [ + {"value": o.value, "label": o.label, "description": o.description, "default": o.default} + for o in f.options + ], + "default": f.default, + "placeholder": f.placeholder, + } + for f in form_fields + ] + + return info diff --git a/requirements.txt b/requirements.txt index bd6fdd9f..6cc79a4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,3 +45,4 @@ watchdog telethon croniter>=2.0.0 # Cron expression parsing for scheduler playwright # WhatsApp Web browser automation +babel>=2.14.0 # Language list for onboarding diff --git a/skills/file-format/SKILL.md b/skills/file-format/SKILL.md new file mode 100644 index 00000000..e15de7a1 --- /dev/null +++ b/skills/file-format/SKILL.md @@ -0,0 +1,129 @@ +--- +name: file-format +description: "Use this skill BEFORE generating any file output — PDF, PPTX, DOCX, XLSX, HTML, or Markdown. Triggers include: any request to create, produce, or generate a document, presentation, spreadsheet, report, memo, proposal, invoice, resume, or any formatted file. Also use when the user asks to update formatting preferences, style standards, or brand guidelines. This skill provides a 3-layer formatting lookup: global standards, file-type rules, and document-purpose rules. Always invoke this skill before the file-type-specific skill (docx, pptx, xlsx, pdf)." +--- + +# File Format Standards + +## Overview + +This skill provides consistent formatting and design standards across all file outputs. It ensures consistent branding, typography, layout, and structure across every file type and document purpose the agent generates. + +Standards live in `resources/FORMAT.md`, organized into three layers that the agent reads on demand. + +--- + +## How to Read FORMAT.md + +`resources/FORMAT.md` is divided into sections using `## section-name` headings. Each section is separated by a `---` horizontal rule divider. + +**To extract the formatting rules you need:** + +1. Use grep to search `resources/FORMAT.md` for the section keyword (e.g., `"## pptx"`, `"## finance-report"`) +2. Read from the matched `## heading` down to the next `---` divider — that is the complete section +3. Each section is self-contained: everything between `## heading` and the next `---` belongs to that section + +**Section types in FORMAT.md:** + +- `## global` — universal standards (always at the top) +- `## ` — file-format-specific rules (e.g., `## pptx`, `## docx`, `## xlsx`, `## pdf`, `## md`, `## html`) +- `## ` — document-purpose-specific rules (e.g., `## finance-report`, `## seo-audit`, `## meeting-minutes`) + +Users can add their own file-type or purpose sections at any time. Do not assume the list is fixed — always search for a match. + +--- + +## 3-Layer Lookup Process + +**Before generating any file, perform these lookups IN ORDER:** + +### Layer 1: Global Standards (ALWAYS read) + +Search for `## global` in `resources/FORMAT.md`. Read the entire section. + +This section defines universal rules: brand color palette, typography scale, writing conventions, and general layout. These apply to every file you generate. + +### Layer 2: File-Type Standards (ALWAYS read) + +Search `resources/FORMAT.md` for the `## ` section matching your output format. The section name matches the file extension or format name. + +Examples: +- Generating a `.pptx` file → search for `## pptx` +- Generating a `.docx` file → search for `## docx` +- Generating a `.pdf` file → search for `## pdf` + +If the exact file type is not found, fall back to Layer 1 global standards only. + +These sections override or extend global standards with format-specific rules (slide setup, page margins, cell formatting, etc.). Each file-type section typically contains: setup, color application, typography overrides, structure rules, and common mistakes to avoid. + +### Layer 3: Document Purpose Standards (read WHEN APPLICABLE) + +Search `resources/FORMAT.md` for the `## ` section matching the document's purpose or category. Purpose sections are listed after the file-type sections under the heading `# Document Purpose Standards`. + +Examples: +- Creating a quarterly earnings report → search for `## finance-report` +- Creating an SEO audit → search for `## seo-audit` +- Creating meeting notes → search for `## meeting-minutes` + +**How to find the right purpose section:** Identify the purpose or category of the document from the user's request, then search for it as a keyword in `resources/FORMAT.md`. If no matching purpose section exists, skip Layer 3. + +Purpose sections follow the same structure as file-type sections — they provide formatting overrides (color application, typography, layout, data formatting, structure rules, common mistakes), not content guidance. Apply them on top of Layers 1 and 2. + +If no purpose section matches the user's request, skip Layer 3. + +--- + +## Conflict Resolution + +When rules from different layers conflict: + +- **Layer 3 (purpose) overrides Layer 2 (file-type) overrides Layer 1 (global)** +- The more specific rule always wins +- Example: Global says "left-align body text", but `## legal-doc` says "justified text is acceptable" — use justified for legal documents + +--- + +## Multi-Purpose Documents + +Some requests span multiple purposes (e.g., "create a financial proposal"). When this happens: + +1. Read all applicable purpose sections +2. Apply all non-conflicting rules from each +3. For conflicts between purpose sections, prefer the section closer to the user's primary intent +4. When in doubt, ask the user which purpose takes priority + +--- + +## Purpose Detection + +When the user's request does not explicitly name a purpose category, infer it from context: + +- "quarterly earnings" or "P&L" or "budget" or "forecast" -> `## finance-report` +- "SEO" or "keyword ranking" or "backlink" or "site audit" -> `## seo-audit` +- "meeting notes" or "action items from the call" or "minutes" -> `## meeting-minutes` +- "pitch" or "proposal" or "RFP" -> `## proposal` +- "executive summary" or "brief" or "one-pager" -> `## executive-summary` +- "newsletter" or "internal update" or "company news" -> `## newsletter` +- "resume" or "CV" or "cover letter" -> `## resume` +- "contract" or "NDA" or "terms" or "agreement" -> `## legal-doc` + +These are examples — not an exhaustive list. Users may add custom purpose sections. Always search FORMAT.md for a match based on the document's purpose keywords. + +If the purpose is ambiguous, proceed with only Layers 1 and 2, or ask the user. + +--- + +## Updating Standards + +Users can request changes to formatting preferences. When they do: + +1. Read the current `resources/FORMAT.md` +2. Identify the correct section to update (global, file-type, or purpose) +3. Make the edit within that section, following the existing structure pattern +4. Confirm the change with the user + +To add a new file-type or purpose section: +1. Add it after the existing sections of the same type (file-type sections go before the `# Document Purpose Standards` heading, purpose sections go after) +2. Use `## section-name` as the heading +3. Follow the same subsection structure as existing sections (color application, typography, layout, structure rules, common mistakes to avoid) +4. End the section with a `---` divider diff --git a/skills/file-format/resources/FORMAT.md b/skills/file-format/resources/FORMAT.md new file mode 100644 index 00000000..6ec6c690 --- /dev/null +++ b/skills/file-format/resources/FORMAT.md @@ -0,0 +1,706 @@ +# Formatting Standards + +Agent reads this before generating any file. Edit to customize. +`## global` = universal. `## ` = type-specific overrides. `## ` = document-purpose overrides. + +--- + +## global + +### Colors +- Base: `#141517` (deep grey — primary background/text on light) +- Surface: `#1E1F22` (card/panel bg in dark contexts) +- Muted: `#6B6E76` (secondary text, captions, borders) +- Border: `#2E2F33` (dividers, table lines, rules) +- White: `#FFFFFF` (bg on light, text on dark) +- Light grey: `#F4F4F5` (alt row shading, subtle bg) +- Highlight: `#FF4F18` (accent — sparingly: key stats, active states, CTAs, emphasis) +- Highlight hover: `#E64615` (darker variant for pressed/hover states) + +**Usage rules:** +- Highlight is for emphasis only — never large fills, never body text color. +- Max 1–2 highlight elements per page/slide/section. +- Body text is always base or white depending on bg. + +### Typography +- Font family: Roboto (all weights). Fallback: Arial, Helvetica, sans-serif. +- Weights: 300 (Light), 400 (Regular), 500 (Medium), 700 (Bold). + +| Role | Size | Weight | Color | Spacing | +|---|---|---|---|---| +| Display / hero | 32–40pt | 700 | base or white | line-height 1.1, letter-spacing -0.5px | +| H1 | 22–26pt | 700 | base or white | line-height 1.2, margin-bottom 16px | +| H2 | 16–18pt | 700 | base or white | line-height 1.25, margin-bottom 12px | +| H3 | 13–14pt | 500 | base or muted | line-height 1.3, margin-bottom 8px | +| Body | 11pt | 400 | base | line-height 1.5, paragraph spacing 10px | +| Small / caption | 9–10pt | 300 or 400 | muted | line-height 1.4 | +| Code / mono | 10pt | 400 | base | font: Roboto Mono, line-height 1.45 | + +### Writing & Content +- Sentence case for all headings. Never ALL CAPS except single-word labels (e.g., "NOTE"). +- Em dashes (—) not hyphens. Curly quotes not straight. +- Left-align body. Never justify (causes uneven word spacing). +- One idea per paragraph. Max 4 sentences per paragraph. +- Prefer active voice. No filler ("It is important to note that…" → cut). +- Numbers: spell out one–nine, digits for 10+. Always digits for units (3 kg, 5 min). + +### General Layout +- Whitespace is a design element — do not fill every gap. +- Visual hierarchy: size → weight → color. Not decoration. +- Max content width: 7" (print), 720px (screen). +- Consistent internal padding: 12–20px or 0.2–0.3" in print contexts. + +--- + +## pptx + +### Slide setup +- 16:9 widescreen (13.333" × 7.5"). No 4:3. +- Safe margins: 0.5" all sides. Keep all content inside. +- Grid: mentally divide slides into 12 columns for alignment. + +### Color application +- Title/section slides: base `#141517` full-bleed bg, white text, highlight accent stripe or element. +- Content slides: white bg, base text. Highlight for one focal element only. +- Charts/graphs: use base, muted, light grey as series colors. Highlight for the one key series. + +### Typography (slide-specific) +| Role | Size | Weight | +|---|---|---| +| Slide title | 32–36pt | 700 | +| Subtitle / section | 18–22pt | 300 or 400 | +| Bullet text | 16–18pt | 400 | +| Data callout / stat | 44–56pt | 700, highlight color | +| Source / footnote | 9–10pt | 300, muted | + +### Content rules +- DO NOT excessively use list of 3–5 bullet points per slide, which is a common LLM mistake. +- Max 6 words per bullet headline. Supporting text below if needed (12–14pt, muted). +- One key message per slide. If you can't state it in one sentence, split. +- Ideally, every slide should have a visual: chart, diagram, icon, image, or shape block. No text-only slides. +- Trying using varying layout or blocks across the deck/slice: full-bleed image, two-column, stat callout, comparison grid, timeline. + +### Common mistakes to avoid (unless specify otherwise) +- **Over use of bullet points:** Using 3-5 bullets for every pages. +- **Uniform layout:** every slide is title + bullets. Fix: alternate layouts every 2–3 slides. +- **Oversized tables:** tables with 5+ columns or 8+ rows are unreadable. Fix: simplify, show top 5, or use a chart. +- **Missing visual hierarchy:** all text same size/weight. Fix: title ≠ body ≠ caption. +- **Image bleeds off slide or wrong aspect ratio:** always set image dimensions explicitly within safe area. Never stretch. +- **Orphan slides:** a single-bullet slide or a slide that only says "Thank you." Combine or enrich. +- **Inconsistent alignment:** elements randomly placed. Fix: snap to grid, align to slide's left margin. +- **Overusing highlight color:** more than 2 highlight elements per slide dilutes emphasis. + +--- + +## docx + +### Page setup +- US Letter 8.5" × 11". Margins: 1" top/bottom, 1" left/right. +- Header: 0.5" from top edge. Footer: 0.5" from bottom edge. +- Page numbers: bottom-center, Roboto 9pt, muted color. + +### Typography (doc-specific) +| Role | Size | Weight | Color | Extra | +|---|---|---|---|---| +| Title (doc) | 26pt | 700 | base | 24px below, optional highlight underline | +| H1 | 18pt | 700 | base | 18px above, 10px below, border-bottom 1px muted | +| H2 | 14pt | 700 | base | 14px above, 8px below | +| H3 | 11pt | 700 | base | 12px above, 6px below | +| Body | 11pt | 400 | base | line-height 1.5, 10px paragraph spacing | +| Blockquote | 11pt | 400 italic | muted | left border 3px highlight, 12px left padding | +| Table header | 10pt | 700 | white on base bg | | +| Table cell | 10pt | 400 | base | alt row: light grey bg | + +### Structure rules +- **Max heading depth: 3 levels.** Never use H4+. If you need it, restructure. +- **Sections:** Do not over-segment. A 2-page doc should not have 10 headings. A section should have more paragraphs rather than just 2-3 sentences. Otherwise, merge sections. +- **Paragraph length:** Must not have less than 2–5 sentences. +- **Lists:** Do not over-use list. +- **Tables:** use only for genuinely tabular data (rows × columns). Do not use tables for layout or for simple lists. +- **Table sizing:** max 5 columns. More than 5 → rotate to vertical layout or split. Column widths must be set explicitly — never auto-width with overflow. +- **Horizontal rules:** use sparingly to separate major sections. Max 2–3 per document. + +### Common mistakes to avoid (unless specify otherwise) +- **Over-sectioning:** every paragraph gets its own heading. Fix: merge related short sections. +- **List abuse:** entire document is nested bullet lists. Fix: write in prose. Lists are for parallel items only. +- **Table for everything:** using a 2-column table instead of a definition list or bold+colon. Fix: use inline formatting. +- **Extra page breaks:** a section breaks mid-page awkwardly. +- **Inconsistent spacing:** different gaps between headings and body. Fix: define and reuse paragraph styles. +- **Images not anchored:** images float to wrong page or overlap text. Fix: set inline positioning, explicit width (max 6.5" for full-width), and keep-with-next. +- **Image too large:** image exceeds printable area. Fix: max width = page width minus margins. Always set explicit dimensions. +- **Phantom empty paragraphs:** blank lines used for spacing. Fix: use paragraph spacing, not empty returns. +- **Font fallback failure:** Roboto not embedded → falls back to Times New Roman. Fix: embed fonts or use a guaranteed-available fallback. + +--- + +## xlsx + +### Sheet setup +- Default column width: 14 characters. Adjust per content. +- Freeze top row (header) and first column (labels) by default. +- Zoom: 100%. Never deliver at odd zoom levels. +- Print area: set explicitly if document may be printed. +- Sheet names: short, no spaces (use underscores), max 20 chars. + +### Cell formatting +| Element | Font | Size | Color | Background | +|---|---|---|---|---| +| Header row | Roboto Bold | 11pt | white | base `#141517` | +| Data cell | Roboto Regular | 10pt | `#141517` | white | +| Alt row | Roboto Regular | 10pt | `#141517` | `#F4F4F5` | +| Total/summary row | Roboto Bold | 10pt | `#141517` | `#E8E8EA` border-top 2px | +| Highlight cell | Roboto Bold | 10pt | `#FF4F18` | — | + +### Number formatting +- Currency: `$#,##0` (no decimals) or `$#,##0.00` (two decimals). Be consistent within a sheet. +- Percentages: `0.0%` (one decimal). +- Integers: `#,##0` with thousands separator. +- Negatives: parentheses `(1,234)` not minus `-1,234`. Red text optional. +- Dates: `YYYY-MM-DD`. Never `MM/DD/YY`. +- Don't mix formatted and unformatted numbers in same column. + +### Financial model conventions +- Blue `#0000FF`: hardcoded inputs/assumptions. +- Black: calculated formulas. +- Green `#008000`: cross-sheet or external references. +- Yellow bg `#FFFF00`: key assumption cells. + +### Structure rules +- **One topic per sheet.** Don't combine unrelated tables on one sheet. +- **Header row is row 1.** No merged title rows above data. Use sheet name for title. +- **No merged cells in data ranges.** Merged cells break sorting, filtering, and formulas. +- **No blank rows/columns** within data ranges. Blank rows break auto-detection. +- **Column order:** identifiers first (name, ID, date), then measures, then calculations, then notes. +- **Wrap text** for cells with >30 chars. Set explicit row height. + +### Common mistakes to avoid (unless specify otherwise) +- **Merged cells:** breaks all data operations. Fix: never merge in data areas. Only merge in clearly decorative headers outside data range. +- **Formulas as values:** pasting values when formulas are needed. Fix: always verify formula references. +- **Inconsistent number formats:** same column has `$1,000` and `1000.00`. Fix: apply format to entire column. +- **Hidden data:** rows/columns hidden and forgotten. Fix: unhide all before delivery. +- **No header row:** data starts at A1 with no labels. Fix: always include descriptive headers. +- **Overly wide sheets:** 20+ columns requiring horizontal scroll. Fix: split into multiple sheets or pivot layout. +- **Print overflow:** data prints across 5 pages wide. Fix: set print area, fit to 1 page wide. +- **Circular references:** fix before delivery. If intentional, document in a Notes sheet. +- **Hard-coded numbers in formulas:** `=A1*0.08` instead of referencing a tax rate cell. Fix: externalize assumptions. + +--- + +## pdf + +### Page setup +- US Letter 8.5" × 11". Margins: 1" all sides. +- Header: base `#141517` bar (0.4" tall), white text left-aligned (document title, Roboto 9pt). +- Footer: centered page number, Roboto 9pt, muted `#6B6E76`. +- First page may omit header for a custom title block. + +### Typography +- Same as docx standards. Body: Roboto 11pt, headings: Roboto Bold. +- Use ReportLab XML markup for superscripts, subscripts if applicable. +- Embed all fonts. Never rely on system fonts. + +### Design +- Section dividers: 1px line in muted color, full content width. +- Callout boxes: light grey `#F4F4F5` bg, left border 3px highlight `#FF4F18`, 10px padding. +- Tables: same style as docx (base header bg, alt row shading). +- Cover page (if applicable): base bg full page, white title 32pt center, highlight accent line. + +### Structure rules +- **Max heading depth: 3 levels.** Never use H4+. If you need it, restructure. +- **Sections:** Do not over-segment. A 2-page doc should not have 10 headings. A section should have more paragraphs rather than just 2-3 sentences. Otherwise, merge sections. +- **Paragraph length:** Must not have less than 2–5 sentences. +- **Lists:** Do not over-use list. +- **Tables:** use only for genuinely tabular data (rows × columns). Do not use tables for layout or for simple lists. +- **Table sizing:** max 5 columns. More than 5 → rotate to vertical layout or split. Column widths must be set explicitly — never auto-width with overflow. +- **Horizontal rules:** use sparingly to separate major sections. Max 2–3 per document. + +### Common mistakes to avoid (unless specify otherwise) +- **Images not rendering:** wrong path, unsupported format, or not embedded. Fix: use absolute paths, embed images, verify format (PNG/JPG). +- **Image exceeds margins:** overflows into margin or off-page. Fix: set max width = page width − 2× margin. Always calculate available space. +- **Text overlaps elements:** manually positioned text collides with tables or images. Fix: use flowable layout, not absolute coordinates (unless precise placement is required). +- **Broken table across pages:** table starts near page bottom, header row orphaned. Fix: use repeatRows for header, allow table to split cleanly. +- **Wrong page size:** defaulting to A4 when US Letter expected. Fix: set explicitly. +- **Missing fonts:** tofu characters (□). Fix: embed TTF files, register before use. +- **Massive file size:** uncompressed images. Fix: resize images to display size before embedding. Max 150 DPI for screen, 300 DPI for print. +- **Raw markup in output:** PDF shows literal `## Heading` or `**bold**` instead of rendered formatting. Fix: ensure all markdown/markup is fully converted to native PDF elements (styled paragraphs, bold spans, etc.) before rendering. Never pass raw markdown text directly into PDF content. +- **Over-sectioning:** every paragraph gets its own heading. Fix: merge related short sections. +- **List abuse:** entire document is nested bullet lists. Fix: write in prose. Lists are for parallel items only. +- **Table for everything:** using a 2-column table instead of a definition list or bold+colon. Fix: use inline formatting. +- **Extra page breaks:** a section breaks mid-page awkwardly. +- **Inconsistent spacing:** different gaps between headings and body. Fix: define and reuse paragraph styles. +- **Images not anchored:** images float to wrong page or overlap text. Fix: set inline positioning, explicit width (max 6.5" for full-width), and keep-with-next. +- **Image too large:** image exceeds printable area. Fix: max width = page width minus margins. Always set explicit dimensions. +- **Phantom empty paragraphs:** blank lines used for spacing. Fix: use paragraph spacing, not empty returns. +- **Font fallback failure:** Roboto not embedded → falls back to Times New Roman. Fix: embed fonts or use a guaranteed-available fallback. + +--- + +## md + +### Formatting +- ATX headings only (`#`, `##`, `###`). Max depth: 3 levels. +- One blank line before and after headings, code blocks, and block quotes. +- No trailing whitespace. No multiple consecutive blank lines. +- Fenced code blocks with language identifier: ` ```python `. Never indented code blocks. +- Links: inline `[text](url)` for fewer than 3 links. Reference-style `[text][id]` for 3+. +- Images: `![alt text](path)` — always include alt text. +- Bold: `**text**`. Italic: `_text_`. Never use `__` or `*` for these. + +### Structure rules +- **Front matter:** if used, YAML only (`---` delimiters). +- **Heading hierarchy:** never skip levels (no H1 → H3). +- **Lists:** max 7 items. Nested lists max 2 levels. Use `-` for unordered (not `*`). +- **Tables:** max 5 columns. Always include header separator `|---|`. Align consistently. +- **Line length:** wrap at 100 characters for readability in raw form (unless the target is rendered-only). +- **Paragraphs:** 2–5 sentences. Single-sentence paragraphs only for emphasis. + +### Content conventions +- **README files:** order sections as: title, description (1–2 lines), installation, usage, configuration, API/reference, contributing, license. +- **Documentation:** lead with what it does, then how to use it, then edge cases/details. +- **No HTML** in Markdown unless absolutely necessary (complex tables, embedded media). + +### Common mistakes to avoid (unless specify otherwise) +- **Over-nesting lists:** 4+ indent levels. Fix: flatten or restructure into subsections. +- **Heading as formatting:** using `###` just to make text bold. Fix: use `**bold**`. +- **No blank lines around blocks:** heading immediately followed by text or code fence. Fix: always add blank lines. +- **Giant tables:** 10+ column tables in Markdown are unreadable raw. Fix: simplify or link to CSV. +- **Inconsistent list markers:** mixing `-`, `*`, `+`. Fix: use `-` everywhere. +- **Raw URLs:** bare `https://...` without link syntax. Fix: wrap in `<>` or `[label](url)`. +- **Over-use of emphasis:** every other word is **bold** or _italic_. Fix: emphasis means rare. + +--- + +## html + +### Setup +- DOCTYPE: ``. Lang attribute set. +- Viewport meta: ``. +- Charset: UTF-8. +- Use semantic tags: `
`, `
`, `
`, `
`, `