diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18f394b --- /dev/null +++ b/.env.example @@ -0,0 +1,106 @@ +# ============================================================================= +# PsAgentAI - Environment Configuration Example +# ============================================================================= +# Copy this file to .env and fill in your actual values: +# cp .env.example .env +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Telegram Configuration +# ----------------------------------------------------------------------------- +# Get your bot token from @BotFather (https://t.me/BotFather) +TELEGRAM_BOT_TOKEN=your_bot_token_here + +# Your Telegram chat ID (the bot will send proactive messages to this chat) +TELEGRAM_CHAT_ID=your_chat_id_here + +# ----------------------------------------------------------------------------- +# AI Provider Configuration +# ----------------------------------------------------------------------------- +# Provider: openrouter, stepfun, qwencloud or llamacpp +AI_PROVIDER=openrouter + +# Default model identifier (depends on the chosen provider) +DEFAULT_MODEL=poolside/laguna-s-2.1:free + +# Comma-separated fallback models if the primary fails +FALLBACK_MODELS=poolside/laguna-s-2.1:free,google/gemini-2.0-flash-lite-preview-02-05:free + +# ----------------------------------------------------------------------------- +# OpenRouter Configuration (https://openrouter.ai) +# ----------------------------------------------------------------------------- +OPENROUTER_API_KEY=your_openrouter_key_here +OPENROUTER_API_URL=https://openrouter.ai/api/v1 + +# ----------------------------------------------------------------------------- +# StepFun Configuration (https://platform.stepfun.com) +# ----------------------------------------------------------------------------- +STEPFUN_API_KEY=your_stepfun_key_here +STEPFUN_API_URL=https://api.stepfun.com/v1 + +# ----------------------------------------------------------------------------- +# QwenCloud Configuration (https://dashscope.aliyun.com) +# ----------------------------------------------------------------------------- +QWENCLOUD_API_KEY=your_qwencloud_key_here +QWENCLOUD_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 + +# ----------------------------------------------------------------------------- +# llama.cpp Configuration (local server) +# ----------------------------------------------------------------------------- +# URL of your local llama.cpp server (OpenAI-compatible endpoint) +LLAMA_CPP_API_URL=http://localhost:8080/v1 + +# ----------------------------------------------------------------------------- +# Proactive Messaging +# ----------------------------------------------------------------------------- +# Minimum and maximum interval between proactive messages (minutes) +MIN_INTERVAL_MINUTES=3 +MAX_INTERVAL_MINUTES=30 + +# ----------------------------------------------------------------------------- +# Context and History +# ----------------------------------------------------------------------------- +# Maximum number of messages kept in conversation context +MAX_HISTORY_LENGTH=50 + +# Maximum characters from uploaded files sent to AI (0 = no truncation) +UPLOAD_CONTEXT_MAX_CHARS=8000 + +# ----------------------------------------------------------------------------- +# Web Search +# ----------------------------------------------------------------------------- +# Enable automatic web search when the agent detects an information need +WEB_SEARCH_ENABLED=true + +# Maximum number of search results to include in context +WEB_SEARCH_MAX_RESULTS=5 + +# ----------------------------------------------------------------------------- +# Vision / Image Analysis +# ----------------------------------------------------------------------------- +# Model used for image analysis +VISION_MODEL=meta-llama/llama-4-maverick-17b-128b-instruct + +# Provider for the vision model (leave empty to auto-detect from model) +VISION_PROVIDER= + +# ----------------------------------------------------------------------------- +# Agent Behavior +# ----------------------------------------------------------------------------- +# Minimum number of lines per agent message +MIN_MESSAGE_LINES=3 + +# Auto-enrich user/agent profiles every N messages (0 = disabled) +AUTO_ENRICH_EVERY=2 + +# Auto-improve system every N messages (0 = disabled) +AUTO_IMPROVE_ENABLED=true +AUTO_IMPROVE_EVERY=20 + +# ----------------------------------------------------------------------------- +# Persistence +# ----------------------------------------------------------------------------- +# File paths for profiles and contexts (relative to app directory) +USER_PROFILE_FILE=user_profile.json +AGENT_PROFILE_FILE=agent_profile.json +CONTEXTS_DIR=contexts diff --git a/DEPLOY.md b/DEPLOY.md index 246b079..280a095 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,17 +1,38 @@ # PsAgentAI - Server Deployment -Deploy as a systemd service on Debian/Ubuntu to run 24/7 on a VPS. +Deploy PsAgentAI as a systemd service on Debian/Ubuntu to run 24/7 on a VPS. + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Copy Project to Server](#1-copy-project-to-server) +3. [Configure Environment](#2-configure-environment) +4. [Install Dependencies](#3-install-dependencies) +5. [Install systemd Service](#4-install-systemd-service) +6. [Service Management](#5-service-management) +7. [Updates](#6-updates) +8. [Multi-User Deployment](#7-multi-user-deployment) +9. [Environment Variables Reference](#8-environment-variables-reference) +10. [Backups](#9-backups) +11. [Security](#10-security) --- -## Prerequisites on the server +## Prerequisites + +A fresh Debian/Ubuntu server with: + +- `python3`, `python3-venv`, `python3-pip` +- `git` (if cloning from repository) +- `ffmpeg` (for voice message processing) +- `systemd-journal-remote` (optional, for persistent logs) ```bash sudo apt update sudo apt install -y python3 python3-venv python3-pip git ffmpeg ``` -Enable journal if you want persistent logs (optional): +Optional: enable persistent journal logs: ```bash sudo apt install -y systemd-journal-remote @@ -19,9 +40,9 @@ sudo apt install -y systemd-journal-remote --- -## 1) Copy the project to the server +## 1) Copy Project to Server -Use git or scp/rsync. Example with git: +### Using git ```bash cd /opt @@ -30,77 +51,92 @@ sudo chown -R $USER:$USER /opt/psagentai cd /opt/psagentai/app ``` -Adjust the path in the systemd service to the real directory: +### Using scp/rsync ```bash -nano psagentai.service -# change /home/marco/PsAgentAI/app to /opt/psagentai/app +# From your local machine: +scp -r psagentai/ user@server:/opt/psagentai/ ``` -> The service uses `User=marco` by default. Create the user if it doesn't exist: -> ```bash -> sudo useradd -m -s /bin/bash marco || true -> sudo usermod -aG sudo marco -> ``` -> Or edit `User=` in the `.service` to an existing user on your VPS. +Adjust paths in the systemd service file to match your actual installation directory. --- -## 2) Configure `.env` +## 2) Configure Environment ```bash -cp .env.example .env # if example exists +# If .env.example exists, copy it: +cp .env.example .env + +# Otherwise create .env manually: nano .env ``` -Fill in the required fields: +### Required settings -- `TELEGRAM_BOT_TOKEN` -- `TELEGRAM_CHAT_ID` -- `AI_PROVIDER` (`openrouter`, `stepfun`, `qwencloud` or `llamacpp`) -- `DEFAULT_MODEL` -- at least the AI key corresponding to the chosen provider +| Variable | Description | +|---|---| +| `TELEGRAM_BOT_TOKEN` | Token from @BotFather | +| `TELEGRAM_CHAT_ID` | Your Telegram chat ID | +| `AI_PROVIDER` | `openrouter`, `stepfun`, `qwencloud` or `llamacpp` | +| `DEFAULT_MODEL` | Model identifier for the chosen provider | +| `OPENROUTER_API_KEY` or `STEPFUN_API_KEY` or `QWENCLOUD_API_KEY` | At least one AI provider key | -Optionally adjust: +### Optional settings -- `MIN_INTERVAL_MINUTES` / `MAX_INTERVAL_MINUTES` -- `MIN_MESSAGE_LINES` +| Variable | Default | Description | +|---|---|---| +| `MIN_INTERVAL_MINUTES` | `3` | Minimum minutes between proactive messages | +| `MAX_INTERVAL_MINUTES` | `30` | Maximum minutes between proactive messages | +| `MIN_MESSAGE_LINES` | `3` | Minimum lines per agent message | +| `WEB_SEARCH_ENABLED` | `true` | Enable automatic web search | +| `WEB_SEARCH_MAX_RESULTS` | `5` | Max web search results | +| `VISION_MODEL` | `meta-llama/llama-4-maverick-17b-128b-instruct` | Vision model | +| `VISION_PROVIDER` | auto | Vision provider | +| `AUTO_ENRICH_EVERY` | `2` | Profile auto-enrichment frequency (`0` = disabled) | +| `AUTO_IMPROVE_EVERY` | `20` | Auto-improvement frequency (`0` = disabled) | +| `UPLOAD_CONTEXT_MAX_CHARS` | `8000` | Upload context limit (`0` = unlimited) | -> Important: on Telegram, send `/start` once to the bot before turning on the service, otherwise it won't be able to send starting messages. +> **Important**: Before starting the service, send `/start` once to the bot in Telegram. This allows the bot to resolve the chat and send proactive messages later. --- -## 3) Install dependencies +## 3) Install Dependencies ```bash +# Install system dependency for voice processing sudo apt install -y ffmpeg -```bash +# Make setup script executable and run it chmod +x iniciar.sh ./iniciar.sh ``` -This script creates the `venv`, installs dependencies and validates the `.env` variables. +The setup script will: +1. Create a Python virtual environment (`venv/`) +2. Upgrade `pip` +3. Install Python dependencies from `requirements.txt` +4. Validate `.env` configuration +5. Start the agent -If the above command doesn't install the expected `ffmpeg` version, also use: +If `ffmpeg` was not installed by the system package manager, install it separately: ```bash sudo apt install -y ffmpeg ``` -> Voice recognition uses `SpeechRecognition` + `pydub` + `ffmpeg`. +> Voice recognition requires `SpeechRecognition` + `pydub` + `ffmpeg`. --- -## 4) Install the systemd service +## 4) Install systemd Service + +Create the service file: ```bash -# absolute path on your server sudo nano /etc/systemd/system/psagentai.service ``` -Minimum content: - ```ini [Unit] Description=PsAgentAI @@ -109,6 +145,7 @@ After=network.target [Service] Type=simple User=marco +Group=marco WorkingDirectory=/opt/psagentai/app Environment="PATH=/opt/psagentai/app/venv/bin:/usr/local/bin:/usr/bin:/bin" ExecStart=/opt/psagentai/app/venv/bin/python3 /opt/psagentai/app/psagentai_agent.py @@ -121,7 +158,21 @@ StandardError=journal WantedBy=multi-user.target ``` -Enable and start: +Adjust the following to match your setup: +- `User=` and `Group=` — the user that will run the service +- `WorkingDirectory=` — absolute path to the `app/` directory +- `ExecStart=` — absolute path to the Python executable and agent script + +Create the service user if needed: + +```bash +sudo useradd -m -s /bin/bash marco || true +sudo usermod -aG sudo marco +``` + +Or change `User=` and `Group=` to an existing user on your VPS. + +Reload systemd and enable the service: ```bash sudo systemctl daemon-reload @@ -131,25 +182,28 @@ sudo systemctl start psagentai --- -## 5) Useful commands +## 5) Service Management ```bash -# status +# Check status sudo systemctl status psagentai -# real-time logs +# View real-time logs sudo journalctl -u psagentai -f -# restart after update +# Stop the service +sudo systemctl stop psagentai + +# Restart after changes sudo systemctl restart psagentai -# stop -sudo systemctl stop psagentai +# Disable auto-start +sudo systemctl disable psagentai ``` --- -## 6) Future updates +## 6) Updates ```bash cd /opt/psagentai/app @@ -160,80 +214,189 @@ sudo systemctl restart psagentai --- -## 7) Multiple users / VPSs +## 7) Multi-User Deployment -Each system instance is independent by Telegram chat ID. +Each PsAgentAI instance is independent by Telegram chat ID. To run multiple instances: -For each new user/VPS: +1. Create a separate bot via [@BotFather](https://t.me/BotFather) (`/newbot`) for each user +2. Copy the project to a separate directory for each instance, e.g.: + - `/opt/psagentai-user1/` + - `/opt/psagentai-user2/` +3. Create a dedicated `.env` for each instance with its own `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, and AI keys +4. Run `./iniciar.sh` in each directory to set up the environment +5. Create a separate systemd service file for each instance: -1. Create the bot via @BotFather (`/newbot`) and get the token. -2. Copy the project to a separate directory, for example `/opt/psagentai-user2`. -3. Create your own `.env` with `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` and your AI keys. -4. Install dependencies with `./iniciar.sh`. -5. Create a separate systemd service, ex: `psagentai-user2.service`, pointing to that directory. -6. Enable and start. +```bash +# Example for user2 +sudo nano /etc/systemd/system/psagentai-user2.service +``` -Recommended names to avoid conflicts: +```ini +[Unit] +Description=PsAgentAI - User 2 +After=network.target -- `/opt/psagentai-user1/`, `/opt/psagentai-user2/` -- `psagentai-user1.service`, `psagentai-user2.service` +[Service] +Type=simple +User=marco +Group=marco +WorkingDirectory=/opt/psagentai-user2/app +Environment="PATH=/opt/psagentai-user2/app/venv/bin:/usr/local/bin:/usr/bin:/bin" +ExecStart=/opt/psagentai-user2/app/venv/bin/python3 /opt/psagentai-user2/app/psagentai_agent.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +6. Enable and start each service: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable psagentai-user2 +sudo systemctl start psagentai-user2 +``` + +Recommended naming convention to avoid conflicts: + +- Directories: `/opt/psagentai-user1/`, `/opt/psagentai-user2/` +- Services: `psagentai-user1.service`, `psagentai-user2.service` --- -## 8) `.env` variables +## 8) Environment Variables Reference + +### Telegram | Variable | Required | Description | |---|---|---| -| `AI_PROVIDER` | no | Preferred provider: `openrouter`, `stepfun`, `qwencloud` or `llamacpp` | -| `TELEGRAM_BOT_TOKEN` | yes | Telegram bot token | -| `TELEGRAM_CHAT_ID` | yes | User chat ID | -| `DEFAULT_MODEL` | yes | Default AI model | -| `OPENROUTER_API_KEY` | 1 of the keys | OpenRouter key | -| `STEPFUN_API_KEY` | 1 of the keys | StepFun key | -| `QWENCLOUD_API_KEY` | 1 of the keys | QwenCloud key | -| `MIN_INTERVAL_MINUTES` | no | Minimum interval for proactive messages | -| `MAX_INTERVAL_MINUTES` | no | Maximum interval for proactive messages | -| `MIN_MESSAGE_LINES` | no | Minimum lines per agent message | -| `AUTO_ENRICH_EVERY` | no | Enriches profile every N messages | -| `AUTO_IMPROVE_EVERY` | no | Auto improvement every N messages | -| `UPLOAD_CONTEXT_MAX_CHARS` | no | Character limit of upload sent to AI (`0` = no limit) | -| `VISION_MODEL` | no | AI model for image analysis | -| `VISION_PROVIDER` | no | Vision model provider (`openrouter`, `stepfun`, `qwencloud`) | +| `TELEGRAM_BOT_TOKEN` | Yes | Telegram bot token from @BotFather | +| `TELEGRAM_CHAT_ID` | Yes | Target chat ID for proactive messages | + +### AI Providers + +| Variable | Required | Description | +|---|---|---| +| `AI_PROVIDER` | Yes | Preferred provider: `openrouter`, `stepfun`, `qwencloud` or `llamacpp` | +| `DEFAULT_MODEL` | Yes | Model identifier (provider-specific) | +| `OPENROUTER_API_KEY` | Conditional | Required for OpenRouter | +| `OPENROUTER_API_URL` | No | OpenRouter API base URL | +| `STEPFUN_API_KEY` | Conditional | Required for StepFun | +| `STEPFUN_API_URL` | No | StepFun API base URL | +| `QWENCLOUD_API_KEY` | Conditional | Required for QwenCloud | +| `QWENCLOUD_API_URL` | No | QwenCloud API base URL | +| `LLAMA_CPP_API_URL` | No | llama.cpp server URL (default: `http://localhost:8080/v1`) | +| `FALLBACK_MODELS` | No | Comma-separated fallback models | + +### Behavior Tuning + +| Variable | Default | Description | +|---|---|---| +| `MIN_INTERVAL_MINUTES` | `3` | Minimum minutes between proactive messages | +| `MAX_INTERVAL_MINUTES` | `30` | Maximum minutes between proactive messages | +| `MIN_MESSAGE_LINES` | `3` | Minimum lines per agent message | +| `MAX_HISTORY_LENGTH` | `50` | Max messages in conversation context | +| `UPLOAD_CONTEXT_MAX_CHARS` | `8000` | Max chars from uploads sent to AI (`0` = unlimited) | +| `WEB_SEARCH_ENABLED` | `true` | Enable automatic web search | +| `WEB_SEARCH_MAX_RESULTS` | `5` | Maximum search results returned | +| `AUTO_ENRICH_EVERY` | `2` | Auto-enrich profiles every N messages (`0` = disabled) | +| `AUTO_IMPROVE_EVERY` | `20` | Auto-improve system every N messages (`0` = disabled) | + +### Vision / Image Analysis + +| Variable | Default | Description | +|---|---|---| +| `VISION_MODEL` | `meta-llama/llama-4-maverick-17b-128b-instruct` | Model for image analysis | +| `VISION_PROVIDER` | auto | Provider for vision model | + +### Persistence + +| Variable | Default | Description | +|---|---|---| +| `USER_PROFILE_FILE` | `user_profile.json` | User profile file path | +| `AGENT_PROFILE_FILE` | `agent_profile.json` | Agent profile file path | +| `CONTEXTS_DIR` | `contexts` | Conversation context directory | --- -## Useful commands via Telegram +## 9) Backups -- `/viewai` — shows current AI provider and model -- `/configai` — interactively configures AI provider and model -- `/viewvision` — shows current vision provider and model -- `/configvision` — interactively configures vision model -- `/about` — displays system information, version, author and license +Regularly back up the data files in the `app/` directory: ---- +- `user_profile.json` — user profile data +- `agent_profile.json` — agent personality and traits +- `system_improvements.json` — auto-improvement history +- `uploads_metadata.json` — uploaded files index +- `config.json` — schedule configuration +- `contexts/` — conversation history by date +- `workspace/` — created and edited files -## 9) Important backups - -Regularly backup the data files inside `/app`: - -- `user_profile.json` -- `agent_profile.json` -- `system_improvements.json` -- `uploads_metadata.json` -- `contexts/` folder -- `workspace/` folder - -Simple daily backup example: +### Daily backup example ```bash -tar czf /root/backups/psagentai-$(date +%F).tar.gz -C /opt/psagentai/app user_profile.json agent_profile.json system_improvements.json uploads_metadata.json contexts workspace +# Create backups directory +sudo mkdir -p /root/backups + +# Run daily backup +BACKUP_FILE="/root/backups/psagentai-$(date +%F).tar.gz" +tar czf "$BACKUP_FILE" -C /opt/psagentai/app \ + user_profile.json \ + agent_profile.json \ + system_improvements.json \ + uploads_metadata.json \ + config.json \ + contexts \ + workspace + +echo "Backup created: $BACKUP_FILE" ``` +### Automated backup with cron + +```bash +sudo crontab -e +``` + +Add: + +```cron +0 2 * * * /usr/bin/tar czf /root/backups/psagentai-$(date +\%F).tar.gz -C /opt/psagentai/app user_profile.json agent_profile.json system_improvements.json uploads_metadata.json config.json contexts workspace +``` + +This creates a backup every day at 2:00 AM. + --- ## 10) Security -- Never commit `.env` to the repository. -- Restrict permissions: `chmod 600 .env`. -- Use an exclusive user for each service instance. -- For greater isolation, run each instance in a separate Docker container or on distinct VMs. +- **Never commit `.env`** to version control. Add it to `.gitignore`. +- **Restrict permissions**: `chmod 600 .env` to prevent other users from reading secrets. +- **Use dedicated users**: Run each instance under its own system user for isolation. +- **Firewall**: Only expose necessary ports (typically only outbound HTTPS is needed). +- **API keys**: Rotate keys periodically and monitor usage for anomalies. +- **Path validation**: The agent validates file paths to prevent directory traversal attacks. +- **Sensitive data**: Emails and phone numbers in profiles are preserved but not exposed in logs. + +--- + +## Useful Telegram Commands for Administration + +- `/viewai` — Check current AI provider and model +- `/configai` — Change AI provider/model on the fly +- `/viewvision` — Check vision configuration +- `/configvision` — Change vision model on the fly +- `/debug` — View system debug information +- `/reload` — Reload .env without restarting the service +- `/timezone` — Check server timezone + +--- + +## Support + +- System developed by: [Syspanel.com.br](https://syspanel.com.br) +- Author: Marco A. S. Costa +- Email: contato@syspanel.com.br +- License: Proprietary for syspanel.com.br diff --git a/README.md b/README.md index 298c562..0d924d3 100755 --- a/README.md +++ b/README.md @@ -1,43 +1,86 @@ # PsAgentAI - Personal Conversational AI Agent -Personal Conversational AI Agent system via Telegram that behaves as a personal companion, chatting, cooperating and maintaining continuous context with the user. +A Telegram-based conversational AI agent that acts as a personal companion. It maintains long-term memory through user/agent profiles, manages daily routines, supports file uploads/workspace editing, performs web searches, analyzes images, and sends proactive messages based on configured schedules. -## Structure +## Key Features + +- **Long-term memory**: User and agent profiles with auto-enrichment from conversations +- **Daily routine management**: Schedule activities, meals, work/study times +- **File workspace**: Create, edit, list and download files via natural language +- **Web search**: Automatic or on-demand search for up-to-date information +- **Vision**: Image analysis with configurable vision models +- **Voice**: Voice message transcription and response +- **Proactive messaging**: Scheduled automatic messages based on daily routine +- **Multi-provider AI**: OpenRouter, StepFun, QwenCloud, and llama.cpp (local) +- **System auto-improvement**: AI-driven suggestions to evolve the agent behavior + +## Project Structure ``` app/ -├── psagentai_agent.py # Main agent code -├── iniciar.sh # Installation and execution script -├── requirements.txt # Python dependencies -├── .env # Local settings (do not commit) -├── .env.example # Configuration example -├── user_profile.json # User profile -├── agent_profile.json # Agent profile (personality, traits, notes) -├── contexts/ # Conversation history by date +├── psagentai_agent.py # Main agent code (~5000 lines) +├── iniciar.sh # Setup and run script +├── requirements.txt # Python dependencies +├── .env # Local settings (do not commit) +├── user_profile.json # User profile (auto-created) +├── agent_profile.json # Agent profile (auto-created) +├── uploads_metadata.json # Uploaded files metadata (auto-created) +├── config.json # Schedule configuration (auto-created) +├── system_improvements.json # Auto-improvement history (auto-created) +├── contexts/ # Conversation history by date │ └── YYYY-MM-DD.json +├── workspace/ # Created and edited files +│ └── projects/ ├── README.md +├── DEPLOY.md +├── RULES.md └── LICENSE ``` ## Prerequisites - Python 3.9+ -- Telegram account with bot created via @BotFather -- At least one supported AI API key +- Telegram account with a bot created via [@BotFather](https://t.me/BotFather) +- At least one supported AI API key (OpenRouter, StepFun, or QwenCloud) ## Configuration -1. Copy `.env.example` to `.env` and fill in: - - `TELEGRAM_BOT_TOKEN` — Telegram bot token - - `TELEGRAM_CHAT_ID` — your Telegram chat ID - - `OPENROUTER_API_KEY` or other compatible API key +1. Copy `.env` from the example or create it manually: -2. Adjust intervals if desired: - - `MIN_INTERVAL_MINUTES` - - `MAX_INTERVAL_MINUTES` - - `MIN_MESSAGE_LINES` — defines the minimum number of lines per agent message (default: `15`) +```bash +cp .env.example .env # if .env.example exists +nano .env +``` -## Installation and execution +2. Required variables: + +| Variable | Required | Description | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | Yes | Token from @BotFather | +| `TELEGRAM_CHAT_ID` | Yes | Your Telegram chat ID | +| `AI_PROVIDER` | Yes | `openrouter`, `stepfun`, `qwencloud` or `llamacpp` | +| `DEFAULT_MODEL` | Yes | Model identifier for the chosen provider | +| `OPENROUTER_API_KEY` | Conditional | Required if using OpenRouter | +| `STEPFUN_API_KEY` | Conditional | Required if using StepFun | +| `QWENCLOUD_API_KEY` | Conditional | Required if using QwenCloud | + +3. Optional tuning: + +| Variable | Default | Description | +|---|---|---| +| `MIN_INTERVAL_MINUTES` | `3` | Minimum minutes between proactive messages | +| `MAX_INTERVAL_MINUTES` | `30` | Maximum minutes between proactive messages | +| `MIN_MESSAGE_LINES` | `3` | Minimum lines per agent response | +| `MAX_HISTORY_LENGTH` | `50` | Max messages kept in context | +| `UPLOAD_CONTEXT_MAX_CHARS` | `8000` | Max chars from uploads sent to AI (`0` = unlimited) | +| `WEB_SEARCH_ENABLED` | `true` | Enable automatic web search | +| `WEB_SEARCH_MAX_RESULTS` | `5` | Max search results returned | +| `VISION_MODEL` | `meta-llama/llama-4-maverick-17b-128b-instruct` | Model for image analysis | +| `VISION_PROVIDER` | auto | Provider for vision model | +| `AUTO_ENRICH_EVERY` | `2` | Update profiles every N messages (`0` = disabled) | +| `AUTO_IMPROVE_EVERY` | `20` | Run auto-improvement every N messages (`0` = disabled) | + +## Installation ```bash cd app @@ -45,71 +88,185 @@ chmod +x iniciar.sh ./iniciar.sh ``` -The script creates the virtual environment, installs dependencies and starts the agent. +The setup script will: +1. Create a Python virtual environment (`venv/`) +2. Upgrade `pip` +3. Install dependencies from `requirements.txt` +4. Validate `.env` configuration +5. Start the agent -## Features +## Running -- Initial onboarding with questions to build the profile -- Conversation history by day in `contexts/` -- User profile persisted in `user_profile.json` -- Agent profile persisted in `agent_profile.json` (name, personality, traits and notes) -- Proactive messages at configurable random intervals -- Minimum lines per agent message configurable via `.env` -- Support for multiple AI providers: - - OpenRouter - - StepFun - - QwenCloud - - llama.cpp (local) -- Dynamic profile update via natural commands in chat +```bash +# From the app directory: +python3 psagentai_agent.py -## Available commands +# Or use the setup script: +./iniciar.sh +``` -- `/start` — starts or restarts the conversation -- `/help` — displays help -- `/profile` — shows user and agent profile summary -- `/agentprofile` — shows only the agent profile -- `/reset` — resets user and agent profiles -- `/clear` — deletes all data (profiles, contexts, chat_id) -- `/onboarding` — restarts onboarding -- `/newconfig` — reconfigures everything from scratch -- `/cancel` — cancels ongoing operation -- `/viewai` — shows current AI provider and model -- `/configai` — interactively configures AI provider and model with button selection -- `/about` — displays system information, version, author and license -- `/viewvision` — shows current vision provider and model -- `/configvision` — interactively configures vision model with button selection -- `/download ` — downloads a workspace file by name +## Available Commands -## Editing workspace files +### Core +- `/start` — Starts or restarts the conversation and onboarding if needed +- `/help` — Displays quick command reference +- `/manual` — Full user manual with examples and tips +- `/about` — System information, version, author and license -You can ask to edit existing files in `workspace/` using commands like: +### Profile +- `/profile` — Displays your profile and agent summary +- `/editprofile` — Edit your profile fields interactively +- `/addtouserprofile ` — Add information to your profile +- `/agentprofile` — Displays the full agent profile +- `/editagentprofile` — Edit agent profile fields interactively +- `/configagentprofile` — Configure agent profile interactively (name, personality, traits, notes) +- `/addtoagentprofile ` — Add a note/characteristic to the agent profile +- `/onboarding` — Re-run your profile onboarding + +### AI Configuration +- `/viewai` — Shows current AI provider and model +- `/configai` — Configure AI provider and model interactively with buttons +- `/viewvision` — Shows current vision provider and model +- `/configvision` — Configure vision model interactively with buttons +- `/updatesystem` — Force analysis and application of auto improvements + +### Files and Documents +- `/uploadfile` — Instructions for sending files (`.pdf`, `.txt`, `.docx`, `.png`, `.jpg`) +- `/listfiles` — List all uploaded and created files +- `/download ` — Download a workspace file by name + +You can also send files directly in the chat. The agent will read text-based files and images automatically. + +### Workspace Editing + +You can ask the agent to create or edit files in `workspace/`: + +**Create:** +- `create a python script to calculate fibonacci` +- `develop a web project with html css and js` +- `generate a document about home automation` + +**Edit:** - `edit the file test.txt and add line 4` - `modify the file hello.py to print hello world` - `in the file test.txt replace Line 2 with Line 2 edited` -The Agent will locate the file, apply the requested change and save the result. -- `/addtouserprofile ` — updates information in your profile -- `/addtoagentprofile ` — adds characteristics to the agent profile +### Daily Routine and Schedule +- `/schedule` — Set days and times for automatic messages +- `/viewschedule` — View current schedule +- `/dailyroutine` — Manage your daily routine (wake up, meals, work/study, sleep) +- `/viewdailyroutine` — View current daily routine +- `/addactivity` — Add a new activity to your routine +- `/editactivity` — Edit an existing activity +- `/deleteactivity` — Delete an activity -## Updating profile during conversation +### Management +- `/newconfig` — Reset everything and reconfigure from scratch +- `/reset` — Reset only user and agent profiles +- `/clear` — Remove all data (profiles, contexts, chat_id) +- `/removetopic ` — Remove all content related to a topic from the system +- `/cancel` — Cancel an ongoing operation -### User profile +### Utilities +- `/debug` — Shows system debug information +- `/reload` — Reload .env settings without restarting +- `/timezone` — Shows the server timezone (read-only) -You can update your profile at any time using phrases like: +### Natural Language Profile Updates +During conversation, you can update profiles using phrases: + +**User profile:** - `Include this in my profile: ...` - `Note in my profile: ...` - `Add to my profile: ...` - `Save in my profile: ...` -### Agent profile - -Use `/addtoagentprofile ` to add characteristics to the agent profile. -Or use phrases like: - +**Agent profile:** - `Include this in the agent profile: ...` - `Note in the agent profile: ...` +## How It Works + +1. **Onboarding**: When you first start, the agent asks questions to build your profile (name, age, gender, location, interests, personality, etc.) +2. **Context**: Every message is saved to a daily context file (`contexts/YYYY-MM-DD.json`) +3. **Profiles**: User and agent profiles are persisted as JSON and enriched automatically based on conversations +4. **AI Calls**: The agent uses the configured provider/model to generate responses with full context +5. **Proactive Messages**: Based on your schedule and routine, the agent may initiate conversations +6. **Web Search**: When enabled, the agent searches the web for questions requiring current information +7. **Vision**: Images are analyzed using the configured vision model +8. **Auto-improvement**: The system periodically analyzes conversations and suggests improvements + +## File Upload and Analysis + +Supported formats: +- **Documents**: `.pdf`, `.txt`, `.docx` +- **Images**: `.png`, `.jpg`, `.jpeg` + +When you upload a file: +1. It's saved to `workspace/` or `uploads/` +2. Text is extracted and stored in `uploads_metadata.json` +3. Images are analyzed automatically +4. You can ask questions about the file content + +## Workspace Editing + +The agent can create and edit files in `workspace/` using AI. Files are organized in subdirectories based on the request type. + +To edit an existing file: +- Use `/listfiles` to see available files +- Use `/download ` to download a file +- Ask the agent to edit by name or partial name + +## Conversation Context + +- Contexts are stored in `contexts/YYYY-MM-DD.json` +- Old messages are automatically cleaned when limits are exceeded +- The agent references recent context to maintain coherent conversations + +## Scheduling + +The agent sends proactive messages only within the configured schedule: +- Configure days of the week and time range with `/schedule` +- Messages are sent at random intervals within the configured window +- The agent always uses server time + +## Security Notes + +- Never commit `.env` to version control +- Restrict `.env` permissions: `chmod 600 .env` +- Sensitive data (emails, phones) is preserved but not exposed in logs +- File paths are validated to prevent traversal attacks + +## Backups + +Regularly back up these files: + +- `user_profile.json` +- `agent_profile.json` +- `system_improvements.json` +- `uploads_metadata.json` +- `config.json` +- `contexts/` folder +- `workspace/` folder + +Example backup: + +```bash +tar czf /root/backups/psagentai-$(date +%F).tar.gz -C /opt/psagentai/app user_profile.json agent_profile.json system_improvements.json uploads_metadata.json config.json contexts workspace +``` + +## Troubleshooting + +- **Bot not responding**: Ensure `/start` has been sent once in the Telegram chat +- **AI errors**: Check API keys and provider configuration with `/viewai` +- **Voice not working**: Ensure `ffmpeg` is installed +- **Image analysis not working**: Check vision configuration with `/viewvision` + +## Development + +See `RULES.md` for development standards, code conventions, and contribution guidelines. + ## License -Proprietary use. See the `LICENSE` file. +Proprietary for Syspanel.com.br. See `LICENSE` file. diff --git a/RULES.md b/RULES.md index 7ca038d..fce720f 100644 --- a/RULES.md +++ b/RULES.md @@ -11,6 +11,7 @@ Official rules for AIs and developers to code the PsAgentAI system correctly, st - **Language:** Python 3.9+ - **Framework:** python-telegram-bot (v20+) - **Main file:** `psagentai_agent.py` +- **Entry script:** `iniciar.sh` - **Purpose:** Personal conversational companion with memory, profiles, file workspace, image analysis, web search and automations. ### Basic rule @@ -236,10 +237,10 @@ Always use the existing helper functions: ## 15. Language and Communication -- Bot interface: Brazilian Portuguese. -- Agent replies: Brazilian Portuguese. -- Logs and code: technical English for variable/function names; Portuguese for user-facing messages. -- Documentation: Brazilian Portuguese. +- Bot interface: English. +- Agent replies: English. +- Logs and code: technical English for variable/function names; English for user-facing messages. +- Documentation: English. --- diff --git a/__pycache__/psagentai_agent.cpython-311.pyc b/__pycache__/psagentai_agent.cpython-311.pyc index 0ef3b79..bd5e0e1 100644 Binary files a/__pycache__/psagentai_agent.cpython-311.pyc and b/__pycache__/psagentai_agent.cpython-311.pyc differ diff --git a/psagentai_agent.py b/psagentai_agent.py index 6dc480e..7153cc2 100755 --- a/psagentai_agent.py +++ b/psagentai_agent.py @@ -137,6 +137,9 @@ AUTO_IMPROVE_ENABLED = os.getenv("AUTO_IMPROVE_ENABLED", "true").lower() in ("tr AUTO_IMPROVE_EVERY = int(os.getenv("AUTO_IMPROVE_EVERY", "20")) # ===================== GLOBAL STATE ===================== +# Interactive wizard flags. Each is_* flag is a state machine gate checked in +# process_text_message; *_step tracks the current question index and *_answers +# / *_data accumulate input until finish_* or /cancel resets everything. app_instance: Optional[Application] = None proactive_task: Optional[asyncio.Task] = None @@ -161,7 +164,7 @@ edit_agent_profile_step = 0 is_schedule = False schedule_step = 0 -schedule_data: Dict[str, Any] = {} +schedule_answers: Dict[str, Any] = {} is_dailyroutine = False dailyroutine_step = 0 @@ -178,11 +181,17 @@ edit_agent_profile_field = "" _auto_improve_counter = 0 -is_schedule = False -schedule_step = 0 -schedule_answers: Dict[str, Any] = {} def _parse_time_to_minutes(time_str: str) -> Optional[int]: + """Parse a time string in HH:MM format into minutes since midnight. + + Args: + time_str: Time string such as "09:30" or "23:59". + + Returns: + Minutes since midnight (0-1439), or None if the input is malformed + or out of range. + """ time_str = time_str.strip() if ":" not in time_str: return None @@ -199,6 +208,13 @@ def _parse_time_to_minutes(time_str: str) -> Optional[int]: return None def get_schedule() -> Dict[str, Any]: + """Load the proactive message schedule from config.json. + + Returns: + Dict with "days" (0=Monday..6=Sunday), "start_time" and + "end_time". Falls back to every day 09:00-23:00 when no + schedule has been configured yet. + """ config = load_config() schedule = config.get("schedule", {}) if not schedule: @@ -211,6 +227,16 @@ def get_schedule() -> Dict[str, Any]: def _web_search(query: str, max_results: int = 5) -> List[Dict[str, str]]: + """Run a web search against DuckDuckGo's HTML endpoint. + + Args: + query: Search query text. + max_results: Maximum number of results to return. + + Returns: + List of dicts with "title" and "url" keys. Returns an empty + list on any network or parsing failure. + """ try: url = "https://html.duckduckgo.com/html/" payload = {"q": query, "b": ""} @@ -223,10 +249,12 @@ def _web_search(query: str, max_results: int = 5) -> List[Dict[str, str]]: results = [] import re + # Match DuckDuckGo HTML result anchors: capture href and inner title markup. pattern = r']+class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)' matches = re.findall(pattern, html, re.DOTALL) for href, title in matches[:max_results]: + # Strip residual HTML tags from the title text. title = re.sub(r'<[^>]+>', '', title).strip() if title and href: results.append({"title": title, "url": href}) @@ -238,6 +266,17 @@ def _web_search(query: str, max_results: int = 5) -> List[Dict[str, str]]: def set_schedule(days: List[int], start_time: str, end_time: str) -> bool: + """Validate and persist the proactive message schedule. + + Args: + days: Weekday numbers to allow (0=Monday .. 6=Sunday). + start_time: Window start in HH:MM format. + end_time: Window end in HH:MM format. + + Returns: + True if the schedule was valid and saved; False if either time + is invalid or start_time >= end_time. + """ start_minutes = _parse_time_to_minutes(start_time) end_minutes = _parse_time_to_minutes(end_time) if start_minutes is None or end_minutes is None: @@ -254,6 +293,13 @@ def set_schedule(days: List[int], start_time: str, end_time: str) -> bool: return True def is_within_schedule() -> bool: + """Check whether the current server time falls inside the configured schedule. + + Returns: + True if today is an allowed weekday and the current time is within + [start_time, end_time). Returns True on malformed schedule times + (fail-open) so a bad config does not silence the agent forever. + """ schedule = get_schedule() tz = get_timezone() tz_info = _get_timezone_info(tz) @@ -277,12 +323,23 @@ def is_within_schedule() -> bool: return start_minutes <= current_minutes < end_minutes def _is_appropriate_time_scheduled() -> bool: + """Decide if a proactive message may be sent right now. + + Returns: + True only when the current time is inside the schedule AND the + time period is not "madrugada" (late-night dead zone). + """ if not is_within_schedule(): return False time_period = get_time_period() return time_period != "madrugada" def load_telegram_chat_id() -> Optional[int]: + """Load the auto-detected Telegram chat id from disk. + + Returns: + Chat id as int, or None if the file is missing or corrupt. + """ if os.path.exists(TELEGRAM_CHAT_ID_FILE): try: with open(TELEGRAM_CHAT_ID_FILE, "r", encoding="utf-8") as f: @@ -295,15 +352,43 @@ def load_telegram_chat_id() -> Optional[int]: return None def save_telegram_chat_id(chat_id: int): + """Persist the auto-detected Telegram chat id to disk. + + Args: + chat_id: Numeric Telegram chat identifier. + + Raises: + OSError: If the JSON file cannot be written. + + Returns: + None. + """ with open(TELEGRAM_CHAT_ID_FILE, "w", encoding="utf-8") as f: json.dump({"chat_id": chat_id}, f, ensure_ascii=False, indent=2) def get_effective_chat_id() -> Optional[int]: + """Resolve the chat id used for proactive messages. + + Returns: + The TELEGRAM_CHAT_ID env value when set, otherwise the + auto-detected id from telegram_chat_id.json, or None. + """ if TELEGRAM_CHAT_ID: return int(TELEGRAM_CHAT_ID) return load_telegram_chat_id() async def auto_detect_chat_id(update: Update): + """Auto-detect and persist the chat id on the first incoming update. + + Only writes when TELEGRAM_CHAT_ID is empty in the environment, so + an explicitly configured chat id is never overridden. + + Args: + update: Incoming Telegram update carrying the effective chat. + + Returns: + None. + """ if not TELEGRAM_CHAT_ID and update.effective_chat: chat_id = update.effective_chat.id if chat_id: @@ -312,6 +397,11 @@ async def auto_detect_chat_id(update: Update): CONFIG_FILE = "config.json" def load_config() -> Dict[str, Any]: + """Read config.json into a dict. + + Returns: + Parsed config, or an empty dict if the file is missing or invalid. + """ if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, "r", encoding="utf-8") as f: @@ -321,10 +411,29 @@ def load_config() -> Dict[str, Any]: return {} def save_config(config: Dict[str, Any]): + """Write the config dict to config.json. + + Args: + config: Full configuration mapping to persist. + + Raises: + OSError: If the file cannot be written. + + Returns: + None. + """ with open(CONFIG_FILE, "w", encoding="utf-8") as f: json.dump(config, f, ensure_ascii=False, indent=2) def get_timezone() -> str: + """Return the server timezone as an informative string. + + Timezone is read-only in this system (see RULES.md); this only + reports what the host is using. + + Returns: + Timezone string, or "UTC" when it cannot be determined. + """ try: tz = datetime.now().astimezone().tzinfo if tz is not None: @@ -334,9 +443,26 @@ def get_timezone() -> str: return "UTC" def set_timezone(tz: str) -> bool: + """No-op: timezone changes are intentionally disabled. + + Args: + tz: Ignored timezone name. + + Returns: + Always False, indicating the change was not applied. + """ return False def _get_timezone_info(tz_name: str): + """Resolve a IANA timezone name to a tzinfo object. + + Args: + tz_name: IANA name such as "America/Sao_Paulo". + + Returns: + ZoneInfo instance, or None if zoneinfo is unavailable or the + name is unknown. + """ if ZoneInfo is None: return None try: @@ -345,11 +471,21 @@ def _get_timezone_info(tz_name: str): return None def get_local_now() -> datetime: + """Return the current datetime with local timezone attached. + + Returns: + Timezone-aware datetime for the server's local time. + """ return datetime.now().astimezone() # ===================== UTILITY FUNCTIONS ===================== def get_today_date() -> str: + """Return today's date formatted for context file naming. + + Returns: + Date string in YYYY-MM-DD format. + """ tz = get_timezone() tz_info = _get_timezone_info(tz) if tz_info: @@ -357,11 +493,24 @@ def get_today_date() -> str: return datetime.now().strftime("%Y-%m-%d") def get_context_file_path(date_str: Optional[str] = None) -> Path: + """Build the JSON path of the conversation context for a given date. + + Args: + date_str: Date in YYYY-MM-DD. Defaults to today. + + Returns: + Path to contexts/.json. + """ if date_str is None: date_str = get_today_date() return Path(CONTEXTS_DIR) / f"{date_str}.json" def ensure_contexts_dir(): + """Create the contexts directory if it does not exist. + + Returns: + None. + """ Path(CONTEXTS_DIR).mkdir(parents=True, exist_ok=True) @@ -373,10 +522,20 @@ WORKSPACE_PROJECTS_DIR = os.path.join(UPLOADS_DIR, "projects") def ensure_uploads_dir(): + """Create the workspace uploads directory if it does not exist. + + Returns: + None. + """ Path(UPLOADS_DIR).mkdir(parents=True, exist_ok=True) def load_uploads_metadata() -> Dict[str, Any]: + """Read uploads_metadata.json into a dict. + + Returns: + Mapping of stored filename -> metadata, or {} if missing. + """ if os.path.exists(UPLOADS_META_FILE): with open(UPLOADS_META_FILE, "r", encoding="utf-8") as f: return json.load(f) @@ -384,6 +543,17 @@ def load_uploads_metadata() -> Dict[str, Any]: def save_uploads_metadata(meta: Dict[str, Any]): + """Persist the uploads metadata mapping. + + Args: + meta: Full metadata mapping keyed by stored filename. + + Raises: + OSError: If the file cannot be written. + + Returns: + None. + """ with open(UPLOADS_META_FILE, "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) @@ -392,6 +562,14 @@ ALLOWED_EXTENSIONS = {".pdf", ".txt", ".docx", ".png", ".jpg", ".jpeg"} def _extract_text_from_pdf(filepath: str) -> str: + """Extract plain text from a PDF using PyMuPDF. + + Args: + filepath: Path to the PDF file. + + Returns: + Concatenated page text, or "" on any extraction failure. + """ try: import fitz doc = fitz.open(filepath) @@ -405,6 +583,14 @@ def _extract_text_from_pdf(filepath: str) -> str: def _extract_text_from_docx(filepath: str) -> str: + """Extract plain text from a DOCX file using python-docx. + + Args: + filepath: Path to the DOCX file. + + Returns: + Paragraphs joined by newlines, or "" on failure. + """ try: import docx doc = docx.Document(filepath) @@ -414,6 +600,15 @@ def _extract_text_from_docx(filepath: str) -> str: def _extract_text_from_txt(filepath: str) -> str: + """Read a plain-text file as UTF-8. + + Args: + filepath: Path to the text file. + + Returns: + File content stripped of surrounding whitespace, or "" on + failure. Undecodable bytes are replaced instead of raising. + """ try: with open(filepath, "r", encoding="utf-8", errors="replace") as f: return f.read().strip() @@ -422,6 +617,16 @@ def _extract_text_from_txt(filepath: str) -> str: def _get_file_summary(text: str, max_chars: int = 500) -> str: + """Truncate long text into a short preview for metadata. + + Args: + text: Full extracted text. + max_chars: Maximum preview length before truncation. + + Returns: + The original text when short enough, otherwise the first + max_chars characters plus a truncation notice. + """ if not text: return "" if len(text) <= max_chars: @@ -430,6 +635,15 @@ def _get_file_summary(text: str, max_chars: int = 500) -> str: def extract_file_text(filepath: str) -> str: + """Extract text from an uploaded file based on its extension. + + Args: + filepath: Path to the file. + + Returns: + Extracted text; images return a placeholder tag; unsupported + extensions return an empty string. + """ ext = os.path.splitext(filepath)[1].lower() if ext == ".pdf": return _extract_text_from_pdf(filepath) @@ -443,6 +657,17 @@ def extract_file_text(filepath: str) -> str: def register_upload(filename: str, original_name: str, filepath: str, chat_id: int) -> bool: + """Register an uploaded file in uploads_metadata.json after extracting text. + + Args: + filename: Stored filename (key in metadata). + original_name: Name given by the user. + filepath: Absolute path where the file was saved. + chat_id: Telegram chat that uploaded the file. + + Returns: + True if any text was extracted from the file. + """ ensure_uploads_dir() text = extract_file_text(filepath) meta = load_uploads_metadata() @@ -459,6 +684,18 @@ def register_upload(filename: str, original_name: str, filepath: str, chat_id: i def find_matching_upload(query: str) -> Optional[Dict[str, Any]]: + """Find an uploaded file whose original name matches the user's query. + + Splits the original filename into alphanumeric tokens (including + accented characters) and returns the first upload sharing any token + with the query. + + Args: + query: Free-form user message. + + Returns: + Metadata dict of the matching upload, or None. + """ q = query.lower().strip() meta = load_uploads_metadata() for filename, info in meta.items(): @@ -466,6 +703,8 @@ def find_matching_upload(query: str) -> Optional[Dict[str, Any]]: if not original_name: continue base = os.path.splitext(original_name)[0] + # Tokenize on non-alphanumerics (keeping accented chars) so "bus schedule" + # matches a query containing either word from "busschedule.pdf". tokens = re.split(r"[^a-z0-9áçéíóúâêôãõà]+", base, flags=re.IGNORECASE) for token in tokens: if token and token in q: @@ -473,6 +712,8 @@ def find_matching_upload(query: str) -> Optional[Dict[str, Any]]: return None +# Keyword phrases that indicate the user wants a live web lookup rather than +# a purely conversational answer (see _looks_like_search_request). _WEB_SEARCH_TRIGGERS = [ "search", "search for", @@ -505,6 +746,17 @@ _WEB_SEARCH_TRIGGERS = [ def _looks_like_search_request(user_message: str) -> bool: + """Detect whether a message asks for live/web information. + + Requires at least two words to avoid false positives, then checks + for any trigger phrase in _WEB_SEARCH_TRIGGERS. + + Args: + user_message: Raw user message. + + Returns: + True when a web search should be attempted. + """ q = user_message.lower().strip() if len(q.split()) < 2: return False @@ -512,6 +764,14 @@ def _looks_like_search_request(user_message: str) -> bool: def _looks_like_create_request(user_message: str) -> bool: + """Detect a file/project creation intent in the user message. + + Args: + user_message: Raw user message. + + Returns: + True when any create-related trigger keyword is present. + """ q = user_message.lower().strip() triggers = [ "create", @@ -547,6 +807,15 @@ def _looks_like_create_request(user_message: str) -> bool: def _extract_create_suggestion(user_message: str) -> Optional[str]: + """Strip the leading action verb from a create request. + + Args: + user_message: Raw user message. + + Returns: + Remainder of the message after the recognized prefix (the thing + to create), or None if no prefix matched. + """ q = user_message.lower().strip() for prefix in ["create", "create a", "develop", "develop a", "generate", "make", "make a", "write", "write a", "program", "code", "implement"]: if q.startswith(prefix): @@ -555,6 +824,18 @@ def _extract_create_suggestion(user_message: str) -> Optional[str]: def _looks_like_edit_request(user_message: str) -> bool: + """Detect a file edit intent while excluding read-only requests. + + Even if an edit trigger matches, messages containing read/list/show + keywords are rejected so "show what's in the file" is not treated + as an edit. + + Args: + user_message: Raw user message. + + Returns: + True when the message should be routed to the edit handler. + """ q = user_message.lower().strip() triggers = [ "edit", @@ -594,6 +875,8 @@ def _looks_like_edit_request(user_message: str) -> bool: "document", ] if any(t in q for t in triggers): + # Read-only verbs cancel an otherwise matching edit intent + # (e.g. "show what's in the file" must not trigger an edit). if any(name in q for name in ["read", "list", "show", "display", "what"]): return False return True @@ -601,6 +884,15 @@ def _looks_like_edit_request(user_message: str) -> bool: def _extract_edit_target(user_message: str) -> Optional[str]: + """Strip the leading edit verb from an edit request. + + Args: + user_message: Raw user message. + + Returns: + Text after the matched prefix (the edit target/description), + or None if no prefix matched. + """ q = user_message.lower().strip() for prefix in ["edit", "edit the", "modify", "modify the", "change", "change the", "update", "update the", "fix", "fix the", "adjust", "adjust the", "swap", "swap the", "replace", "replace the", "add", "add to", "remove", "remove from", "delete", "erase", "include", "include in", "rewrite", "rewrite the", "refactor", "refactor the", "improve", "improve the", "optimize", "optimize the", "in the file", "in the project", "in the script", "in the code", "in the document"]: if q.startswith(prefix): @@ -610,6 +902,17 @@ def _extract_edit_target(user_message: str) -> Optional[str]: def _extract_filename_from_text(text: str) -> Optional[str]: + """Pull the first filename-looking token out of free text. + + Uses a regex that accepts path characters and requires at least + one dot plus an extension (e.g. report.pdf, src/app.py). + + Args: + text: Text that may contain a filename. + + Returns: + First filename match, or None. + """ matches = re.findall(r"[A-Za-z0-9_./-]+\.[A-Za-z0-9]+", text) if matches: return matches[0] @@ -617,6 +920,18 @@ def _extract_filename_from_text(text: str) -> Optional[str]: def _find_workspace_file(target: str) -> Optional[str]: + """Locate a file inside the workspace by partial name. + + Walks workspace/ recursively and keeps candidates whose basename or + relative path contains the target. Shallower and shorter paths win + so the most direct match is returned. + + Args: + target: File name or fragment typed by the user. + + Returns: + Path relative to workspace/, or None if nothing matched. + """ filename = _extract_filename_from_text(target) if not filename and target and " " not in target: filename = target @@ -639,6 +954,21 @@ def _find_workspace_file(target: str) -> Optional[str]: async def _handle_edit_request(update: Update, context: ContextTypes.DEFAULT_TYPE, user_message: str) -> bool: + """Run the AI-assisted file edit flow end to end. + + Resolves the target file, asks the model to rewrite its content + from the user request, strips accidental markdown fences, writes + the result back, and replies with a short preview. + + Args: + update: Telegram update carrying the request. + context: PTB callback context. + user_message: Full user message describing the edit. + + Returns: + True if the request was handled (even on graceful failure), + False if no edit target could be extracted. + """ target = _extract_edit_target(user_message) if not target: return False @@ -666,8 +996,7 @@ async def _handle_edit_request(update: Update, context: ContextTypes.DEFAULT_TYP "You are a file editing assistant.\n" "Given the current file content and the user's request, return ONLY the final revised content of the file.\n" "DO NOT use markdown, DO NOT add explanations, DO NOT add code blocks.\n" - "Keep as much of the original content as possible, applying only the requested change.\n\n" - f"File path: {rel_path}\n\n" + "Keep as much of the original content as possible, applying only the requested change.\n\n" f"File path: {rel_path}\n\n" f"Current content:\n{current_content}\n\n" f"User request: {user_message}\n\n" "Return only the new final content of the file." @@ -677,6 +1006,7 @@ async def _handle_edit_request(update: Update, context: ContextTypes.DEFAULT_TYP if not new_content: await update.message.reply_text("I couldn't generate the edit. Try again.") return True + # Defensive markdown fence strip in case the model ignored the prompt. if new_content.startswith("```"): new_content = new_content.lstrip("`") new_content = new_content.split("\n", 1)[-1] if "\n" in new_content else new_content @@ -701,11 +1031,28 @@ async def _handle_edit_request(update: Update, context: ContextTypes.DEFAULT_TYP def ensure_workspace_dirs(): + """Create workspace/ and workspace/projects/ if missing. + + Returns: + None. + """ Path(UPLOADS_DIR).mkdir(parents=True, exist_ok=True) Path(WORKSPACE_PROJECTS_DIR).mkdir(parents=True, exist_ok=True) def save_workspace_file(relative_path: str, content: str) -> str: + """Write content to a file under workspace/, creating parent dirs. + + Args: + relative_path: Path relative to workspace/. + content: Text to write (UTF-8). + + Returns: + Absolute path of the written file. + + Raises: + OSError: If the directory or file cannot be created. + """ ensure_workspace_dirs() dest = os.path.join(UPLOADS_DIR, relative_path) os.makedirs(os.path.dirname(dest), exist_ok=True) @@ -715,8 +1062,28 @@ def save_workspace_file(relative_path: str, content: str) -> str: def build_workspace_tree(relative_path: str, content: str) -> List[str]: + """Materialize AI-generated file content into the workspace. + + Two input formats are supported: + 1. A multi-file tree whose first lines look like headings or + FILE:/DIR:/./ paths - creates directories from '#'/##' headers + and empty files from FILE lines or known extensions. + 2. Plain single-file content - written verbatim to relative_path. + + Args: + relative_path: Destination path for the single-file case. + content: Raw model output. + + Returns: + List of workspace-relative paths that were created. + + Raises: + OSError: If a file or directory cannot be created. + """ ensure_workspace_dirs() created = [] + # Heuristic: treat the model output as a multi-file tree only when the first + # 10 lines contain headings or FILE:/DIR:/path markers; otherwise single file. if "\n" in content and any(line.startswith(("## ", "# ", "FILE:", "DIR:", "./", "/")) for line in content.splitlines()[:10]): current_dir = "" for line in content.splitlines(): @@ -724,10 +1091,12 @@ def build_workspace_tree(relative_path: str, content: str) -> List[str]: if not line: continue if line.startswith(("## ", "# ")): + # Heading becomes a new directory under workspace/. current_dir = line.lstrip("# ").strip() Path(os.path.join(UPLOADS_DIR, current_dir)).mkdir(parents=True, exist_ok=True) created.append(current_dir) elif line.startswith(("FILE:", "./", "/")) or (current_dir and line.endswith((".py", ".html", ".css", ".js", ".json", ".md", ".txt"))): + # FILE lines and known source extensions become empty files. name = line.split(":", 1)[-1].strip() if ":" in line else line if name: dest = os.path.join(UPLOADS_DIR, current_dir, name) if current_dir else os.path.join(UPLOADS_DIR, name) @@ -745,6 +1114,11 @@ def build_workspace_tree(relative_path: str, content: str) -> List[str]: def _list_workspace_files() -> List[str]: + """List every file in the workspace as relative paths. + + Returns: + Sorted list of paths relative to workspace/. + """ ensure_workspace_dirs() files = [] for root, _, filenames in os.walk(UPLOADS_DIR): @@ -757,6 +1131,21 @@ def _list_workspace_files() -> List[str]: async def _handle_create_request(update: Update, context: ContextTypes.DEFAULT_TYPE, user_message: str) -> bool: + """Run the AI-assisted file/project creation flow. + + Extracts the creation suggestion, asks the model for content, + derives a timestamped safe root folder name, and saves the result + via build_workspace_tree. + + Args: + update: Telegram update carrying the request. + context: PTB callback context. + user_message: Full user message describing the creation. + + Returns: + True if handled (including graceful failures), False if no + creation suggestion could be extracted. + """ suggestion = _extract_create_suggestion(user_message) if not suggestion: return False @@ -769,14 +1158,15 @@ async def _handle_create_request(update: Update, context: ContextTypes.DEFAULT_T "If it's a project with multiple files, list the files and folders.\n" "Format:\n" "- For single file: just the final content.\n" - "- For project: start with the root folder name, then list the files with their paths and contents.\n\n" - f"User request: {suggestion}\n" + "- For project: start with the root folder name, then list the files with their paths and contents.\n\n" f"User request: {suggestion}\n" ) messages = build_messages_for_ai(profile, create_prompt, today_context) content, _ = AIProvider.chat(DEFAULT_MODEL, messages, temperature=0.7, max_tokens=6000) if not content: await update.message.reply_text("I couldn't generate the content. Try again.") return True + # Sanitize the suggestion into a filesystem-safe root folder name and + # prefix it with a timestamp so repeated creates never collide. safe_root = re.sub(r"[^a-zA-Z0-9_-]+", "_", suggestion.lower()).strip("_") or "projeto" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") root_name = f"{timestamp}_{safe_root}" @@ -794,11 +1184,28 @@ async def _handle_create_request(update: Update, context: ContextTypes.DEFAULT_T def get_all_uploads_for_chat(chat_id: int) -> Dict[str, Dict[str, Any]]: + """Filter upload metadata down to one chat. + + Args: + chat_id: Telegram chat id to filter by. + + Returns: + Mapping of filename -> metadata owned by that chat. + """ meta = load_uploads_metadata() return {fn: info for fn, info in meta.items() if info.get("chat_id") == chat_id} def encode_image_to_base64(filepath: str) -> Optional[str]: + """Read an image file and encode it as a data URI. + + Args: + filepath: Path to the image file. + + Returns: + "data:;base64,..." string, or None on failure. Falls + back to image/jpeg when the MIME type cannot be guessed. + """ try: with open(filepath, "rb") as f: data = f.read() @@ -812,6 +1219,17 @@ def encode_image_to_base64(filepath: str) -> Optional[str]: def analyze_image(filepath: str, question: str = "Describe what you see in this image.") -> Optional[str]: + """Send an image to the configured vision model for description. + + Args: + filepath: Path to the image. + question: Prompt accompanying the image. + + Returns: + Model response text, or None if the image cannot be encoded, + the provider is local-only, credentials are missing, or the + API call fails. + """ image_data = encode_image_to_base64(filepath) if not image_data: return None @@ -837,6 +1255,19 @@ def analyze_image(filepath: str, question: str = "Describe what you see in this def save_image_to_workspace(original_name: str, filepath: str, chat_id: int) -> str: + """Copy an image into the workspace and register it in metadata. + + When the original name already exists, a numeric suffix is added + until the destination is free. + + Args: + original_name: Original filename (may be empty). + filepath: Source path of the image. + chat_id: Telegram chat that sent the image. + + Returns: + Final stored filename inside workspace/. + """ ensure_uploads_dir() meta = load_uploads_metadata() if original_name: @@ -867,6 +1298,19 @@ def save_image_to_workspace(original_name: str, filepath: str, chat_id: int) -> async def photo_message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle incoming photo messages: download, save, and analyze. + + Picks the largest photo size, saves it to the workspace, runs + vision analysis, cleans up the temp file, and replies with the + analysis (or a saved-but-not-analyzed notice). + + Args: + update: Telegram update containing the photo. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) photo = update.message.photo if not photo: @@ -899,6 +1343,14 @@ async def photo_message_handler(update: Update, context: ContextTypes.DEFAULT_TY def load_context(date_str: Optional[str] = None) -> List[Dict[str, str]]: + """Load the conversation context for a date. + + Args: + date_str: Date in YYYY-MM-DD. Defaults to today. + + Returns: + List of message dicts, or [] when no context exists yet. + """ ensure_contexts_dir() file_path = get_context_file_path(date_str) if file_path.exists(): @@ -907,12 +1359,37 @@ def load_context(date_str: Optional[str] = None) -> List[Dict[str, str]]: return [] def save_context(context: List[Dict[str, str]], date_str: Optional[str] = None): + """Overwrite the conversation context file for a date. + + Args: + context: Full list of message dicts to persist. + date_str: Date in YYYY-MM-DD. Defaults to today. + + Raises: + OSError: If the file cannot be written. + + Returns: + None. + """ ensure_contexts_dir() file_path = get_context_file_path(date_str) with open(file_path, "w", encoding="utf-8") as f: json.dump(context, f, ensure_ascii=False, indent=2) def add_to_context(role: str, content: str, date_str: Optional[str] = None): + """Append a message to the daily context and trim history. + + Adds an ISO timestamp and keeps only the newest MAX_HISTORY_LENGTH + entries so the file never grows without bound. + + Args: + role: Message role ("user" or "assistant"). + content: Message text. + date_str: Date in YYYY-MM-DD. Defaults to today. + + Returns: + The context list after append and trimming. + """ context = load_context(date_str) tz = get_timezone() tz_info = _get_timezone_info(tz) @@ -927,6 +1404,12 @@ def add_to_context(role: str, content: str, date_str: Optional[str] = None): return context def load_user_profile() -> Dict[str, Any]: + """Load user_profile.json, creating a default profile if absent. + + Returns: + User profile dict. When the file is missing, a blank default + profile is written to disk and returned. + """ if os.path.exists(USER_PROFILE_FILE): with open(USER_PROFILE_FILE, "r", encoding="utf-8") as f: return json.load(f) @@ -959,6 +1442,17 @@ def load_user_profile() -> Dict[str, Any]: return default def save_user_profile(profile: Dict[str, Any]): + """Persist the user profile with a fresh last_updated timestamp. + + Args: + profile: Profile dict to write. + + Raises: + OSError: If the file cannot be written. + + Returns: + None. + """ profile["last_updated"] = datetime.now().isoformat() with open(USER_PROFILE_FILE, "w", encoding="utf-8") as f: json.dump(profile, f, ensure_ascii=False, indent=2) @@ -972,6 +1466,11 @@ AGENT_PROFILE_DEFAULT = { } def load_agent_profile() -> Dict[str, Any]: + """Load agent_profile.json, creating the default if absent. + + Returns: + Agent profile dict; defaults are saved on first load. + """ if os.path.exists(AGENT_PROFILE_FILE): with open(AGENT_PROFILE_FILE, "r", encoding="utf-8") as f: return json.load(f) @@ -980,11 +1479,30 @@ def load_agent_profile() -> Dict[str, Any]: return default def save_agent_profile(profile: Dict[str, Any]): + """Persist the agent profile with a fresh last_updated timestamp. + + Args: + profile: Agent profile dict to write. + + Raises: + OSError: If the file cannot be written. + + Returns: + None. + """ profile["last_updated"] = datetime.now().isoformat() with open(AGENT_PROFILE_FILE, "w", encoding="utf-8") as f: json.dump(profile, f, ensure_ascii=False, indent=2) def is_profile_complete(profile: Dict[str, Any]) -> bool: + """Check whether the user finished basic profile setup. + + Args: + profile: User profile dict. + + Returns: + True when a non-empty name is present. + """ return bool(profile.get("name", "").strip()) # ===================== NEW CONFIG ===================== @@ -1056,6 +1574,13 @@ AGENTCONFIG_QUESTIONS = [ ] def reset_all_data(): + """Delete all persisted user data: profiles, chat id, and contexts. + + Workspace files and config.json are intentionally left untouched. + + Returns: + None. + """ if os.path.exists(USER_PROFILE_FILE): os.remove(USER_PROFILE_FILE) if os.path.exists(AGENT_PROFILE_FILE): @@ -1069,6 +1594,15 @@ def reset_all_data(): os.remove(filepath) async def start_newconfig(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Start the full reconfiguration wizard (reset + ask everything). + + Args: + update: Telegram update that triggered the wizard. + context: PTB callback context. + + Returns: + None. + """ global is_newconfig, newconfig_step, newconfig_answers await auto_detect_chat_id(update) is_newconfig = True @@ -1081,6 +1615,15 @@ async def start_newconfig(update: Update, context: ContextTypes.DEFAULT_TYPE): await send_newconfig_question(update, context) async def send_newconfig_question(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Send the current newconfig question, or finish when done. + + Args: + update: Telegram update. + context_context: PTB callback context. + + Returns: + None. + """ global newconfig_step if newconfig_step >= len(NEWCONFIG_QUESTIONS): await finish_newconfig(update, context_context) @@ -1092,6 +1635,18 @@ async def send_newconfig_question(update: Update, context_context: ContextTypes. ) async def finish_newconfig(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Apply newconfig answers: wipe old data and write fresh profiles. + + Resets all data first, then rebuilds the user and agent profiles + from the collected answers and clears the wizard state. + + Args: + update: Telegram update. + context_context: PTB callback context. + + Returns: + None. + """ global is_newconfig, newconfig_step, newconfig_answers reset_all_data() profile = load_user_profile() @@ -1122,6 +1677,18 @@ async def finish_newconfig(update: Update, context_context: ContextTypes.DEFAULT ) async def handle_newconfig_answer(update: Update, context_context: ContextTypes.DEFAULT_TYPE) -> bool: + """Advance the newconfig wizard one step for an incoming answer. + + List-typed questions split the answer on commas; everything else is + stored as a raw string. The last answer triggers finish_newconfig. + + Args: + update: Telegram update carrying the answer. + context_context: PTB callback context. + + Returns: + True if the wizard consumed the message; False when inactive. + """ global newconfig_step, newconfig_answers if not is_newconfig: return False @@ -1144,6 +1711,15 @@ async def handle_newconfig_answer(update: Update, context_context: ContextTypes. # ===================== CONFIG AGENT PROFILE ===================== async def start_configagentprofile(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Start the agent profile configuration wizard. + + Args: + update: Telegram update that triggered the wizard. + context: PTB callback context. + + Returns: + None. + """ global is_configagentprofile, configagentprofile_step, configagentprofile_answers await auto_detect_chat_id(update) is_configagentprofile = True @@ -1156,6 +1732,15 @@ async def start_configagentprofile(update: Update, context: ContextTypes.DEFAULT await send_configagentprofile_question(update, context) async def send_configagentprofile_question(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Send the current agent-config question, or finish when done. + + Args: + update: Telegram update. + context_context: PTB callback context. + + Returns: + None. + """ global configagentprofile_step if configagentprofile_step >= len(AGENTCONFIG_QUESTIONS): await finish_configagentprofile(update, context_context) @@ -1167,6 +1752,18 @@ async def send_configagentprofile_question(update: Update, context_context: Cont ) async def finish_configagentprofile(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Merge agent-config answers into agent_profile.json. + + New notes are appended to any existing notes with a semicolon + separator, then the wizard state is cleared. + + Args: + update: Telegram update. + context_context: PTB callback context. + + Returns: + None. + """ global is_configagentprofile, configagentprofile_step, configagentprofile_answers agent = load_agent_profile() agent["name"] = configagentprofile_answers.get("agent_name", agent.get("name", "PsAgentAI")) @@ -1175,6 +1772,7 @@ async def finish_configagentprofile(update: Update, context_context: ContextType agent["traits"] = configagentprofile_answers.get("agent_traits", agent.get("traits", [])) notes = configagentprofile_answers.get("agent_notes", "").strip() if notes: + # Append new notes to existing ones with "; " instead of replacing. existing = agent.get("notes", "").strip() agent["notes"] = f"{existing}; {notes}" if existing else notes save_agent_profile(agent) @@ -1188,6 +1786,15 @@ async def finish_configagentprofile(update: Update, context_context: ContextType ) async def handle_configagentprofile_answer(update: Update, context_context: ContextTypes.DEFAULT_TYPE) -> bool: + """Advance the agent profile wizard one step for an incoming answer. + + Args: + update: Telegram update carrying the answer. + context_context: PTB callback context. + + Returns: + True if the wizard consumed the message; False when inactive. + """ global configagentprofile_step, configagentprofile_answers if not is_configagentprofile: return False @@ -1231,6 +1838,17 @@ EDIT_AGENT_PROFILE_FIELDS = [ ] async def start_edit_user_profile(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Start interactive editing of a single user profile field. + + Requires a completed profile; otherwise asks the user to run /start. + + Args: + update: Telegram update that triggered the editor. + context: PTB callback context. + + Returns: + None. + """ global is_edit_user_profile, edit_user_profile_step, edit_user_profile_field await auto_detect_chat_id(update) profile = load_user_profile() @@ -1248,6 +1866,18 @@ async def start_edit_user_profile(update: Update, context: ContextTypes.DEFAULT_ ) async def handle_edit_user_profile_answer(update: Update, context_context: ContextTypes.DEFAULT_TYPE) -> bool: + """Two-step state machine: pick a field, then provide its new value. + + Step 0 expects a 1-based field number; step 1 stores the value + (comma-split for list fields) and returns to idle. + + Args: + update: Telegram update carrying the answer. + context_context: PTB callback context. + + Returns: + True if the editor consumed the message; False when inactive. + """ global is_edit_user_profile, edit_user_profile_step, edit_user_profile_field if not is_edit_user_profile: return False @@ -1293,6 +1923,15 @@ async def handle_edit_user_profile_answer(update: Update, context_context: Conte return True async def start_edit_agent_profile(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Start interactive editing of a single agent profile field. + + Args: + update: Telegram update that triggered the editor. + context: PTB callback context. + + Returns: + None. + """ global is_edit_agent_profile, edit_agent_profile_step, edit_agent_profile_field await auto_detect_chat_id(update) is_edit_agent_profile = True @@ -1306,6 +1945,18 @@ async def start_edit_agent_profile(update: Update, context: ContextTypes.DEFAULT ) async def handle_edit_agent_profile_answer(update: Update, context_context: ContextTypes.DEFAULT_TYPE) -> bool: + """Two-step state machine: pick an agent field, then its new value. + + Step 0 expects a 1-based field number; step 1 stores the value + (comma-split for list fields) and returns to idle. + + Args: + update: Telegram update carrying the answer. + context_context: PTB callback context. + + Returns: + True if the editor consumed the message; False when inactive. + """ global is_edit_agent_profile, edit_agent_profile_step, edit_agent_profile_field if not is_edit_agent_profile: return False @@ -1353,6 +2004,18 @@ async def handle_edit_agent_profile_answer(update: Update, context_context: Cont # ===================== ADD TO USER PROFILE ===================== async def addtouserprofile_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /addtouserprofile : append text to the user profile. + + Routes the text through update_profile_with_preference first; when + no category matches, the text is appended to the notes field. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) text = update.message.text.strip() parts = text.split(maxsplit=1) @@ -1383,6 +2046,15 @@ async def addtouserprofile_command(update: Update, context: ContextTypes.DEFAULT # ===================== ADD TO AGENT PROFILE ===================== async def addtoagentprofile_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /addtoagentprofile : append a note to the agent profile. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) text = update.message.text.strip() parts = text.split(maxsplit=1) @@ -1405,6 +2077,8 @@ async def addtoagentprofile_command(update: Update, context: ContextTypes.DEFAUL # ===================== PROFILE UPDATE ===================== +# Regex triggers for "note this in my profile" style commands; paired with the +# literal phrase list PROFILE_UPDATE_TRIGGERS_LOWER (literal match wins). PROFILE_UPDATE_TRIGGERS = [ r"include(s)? (this|here)? (in|to)? (my)? profile", r"note(?!\s+the\s+name)( this)? (in|to)? (my)? profile", @@ -1433,9 +2107,29 @@ PROFILE_UPDATE_TRIGGERS_LOWER = [ ] def _normalize(text: str) -> str: + """Lowercase and strip whitespace for case-insensitive matching. + + Args: + text: Input text. + + Returns: + Normalized string. + """ return text.lower().strip() def _extract_profile_update_content(message: str) -> Optional[str]: + """Extract payload text from a "note this in my profile" style message. + + Tries literal trigger phrases first (start of message), then the + regex variants in PROFILE_UPDATE_TRIGGERS anywhere in the message. + + Args: + message: Raw user message. + + Returns: + Content after the trigger with punctuation trimmed, or None if + no profile-update trigger is present. + """ text = message.strip() lower = _normalize(text) @@ -1456,6 +2150,19 @@ def _extract_profile_update_content(message: str) -> Optional[str]: return None def _categorize_preference(text: str) -> tuple[str, str]: + """Route preference text to a profile section and field by keywords. + + Keyword checks run in priority order: wake/sleep/work routine + fields first, then avoid-topics, then like/interest/communication + preferences; unmatched text falls through to the general list. + + Args: + text: Preference content to categorize. + + Returns: + (section, field) tuple; field "append_list" means store the + whole text as a list item under section. + """ lower = _normalize(text) if any(k in lower for k in ["wake up", "wake", "alarm", "wake_up", "get up"]): @@ -1480,6 +2187,16 @@ def _categorize_preference(text: str) -> tuple[str, str]: return "general", "append_list" def _append_to_list(profile: Dict[str, Any], section: str, item: str): + """Append an item to a profile list field, avoiding duplicates. + + Args: + profile: Profile dict, mutated in place. + section: Top-level profile key holding the list. + item: Value to append. + + Returns: + None. + """ if section not in profile: profile[section] = [] if not isinstance(profile[section], list): @@ -1489,6 +2206,19 @@ def _append_to_list(profile: Dict[str, Any], section: str, item: str): profile[section].append(item) def _append_to_dict(profile: Dict[str, Any], section: str, value: str): + """Append free text into a dict-valued profile section. + + Stores under the "notas_adicionais" key, joining successive + values with "; ". Non-dict sections are replaced by a dict. + + Args: + profile: Profile dict, mutated in place. + section: Top-level profile key holding the dict. + value: Text to append. + + Returns: + None. + """ if section not in profile: profile[section] = {} if not isinstance(profile[section], dict): @@ -1500,6 +2230,20 @@ def _append_to_dict(profile: Dict[str, Any], section: str, value: str): profile[section]["notas_adicionais"] += f"; {value}" def _set_field(profile: Dict[str, Any], section: str, field: str, value: str): + """Set or merge a nested profile field. + + Empty fields are overwritten; string fields merge with "; "; + list fields append when the value is new. + + Args: + profile: Profile dict, mutated in place. + section: Parent key (created as a dict if missing). + field: Field name inside the section. + value: Value to store or merge. + + Returns: + None. + """ if section not in profile: profile[section] = {} if not isinstance(profile[section], dict): @@ -1516,6 +2260,15 @@ def _set_field(profile: Dict[str, Any], section: str, field: str, value: str): existing.append(value) def update_profile_with_preference(text: str) -> Dict[str, Any]: + """Parse, categorize, and store a profile preference from free text. + + Args: + text: Message containing a profile-update trigger. + + Returns: + Dict with section/field/content/updated profile on success, + or {} when no content could be extracted. + """ profile = load_user_profile() content = _extract_profile_update_content(text) @@ -1529,6 +2282,7 @@ def update_profile_with_preference(text: str) -> Dict[str, Any]: elif field == "append_dict": _append_to_dict(profile, section, content) elif field == "notes": + # Free-text notes merge with "; " instead of overwriting history. if not profile.get("notes"): profile["notes"] = content else: @@ -1540,6 +2294,16 @@ def update_profile_with_preference(text: str) -> Dict[str, Any]: return {"section": section, "field": field, "content": content, "profile": profile} async def handle_profile_update_if_triggered(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + """Detect a profile-update trigger, apply it, and confirm to the user. + + Args: + update: Telegram update carrying the message. + context: PTB callback context. + + Returns: + True if the message was a profile update and was applied. + When True, the message must not be forwarded to the AI chat. + """ user_message = (getattr(update.message, "text", None) or "").strip() content = _extract_profile_update_content(user_message) @@ -1567,8 +2331,25 @@ async def handle_profile_update_if_triggered(update: Update, context: ContextTyp # ===================== AI PROVIDERS ===================== class AIProvider: + """Static facade over the supported AI backends. + + Resolves provider/API credentials from environment configuration + and exposes chat completions with automatic model fallback. + """ @staticmethod def get_provider(model: str) -> str: + """Resolve which AI backend should serve a given model. + + Honors an explicit AI_PROVIDER override when its API key exists, + then falls back to model-name heuristics (poolside/meta/step/qwen/ + llama), and finally to whichever API key is configured. + + Args: + model: Model identifier string. + + Returns: + Provider key: openrouter, stepfun, qwencloud, or llamacpp. + """ forced = os.getenv("AI_PROVIDER", "").strip().lower() if forced == "openrouter": if OPENROUTER_API_KEY: @@ -1581,6 +2362,7 @@ class AIProvider: return "qwencloud" elif forced == "llamacpp": return "llamacpp" + # 2) Model-name heuristics: provider prefixes and vendor tokens. if model.startswith("poolside/") or model.startswith("meta-llama/") or "/" in model and "poolside" not in model: if OPENROUTER_API_KEY: return "openrouter" @@ -1592,6 +2374,7 @@ class AIProvider: return "qwencloud" if model.startswith("local-") or "llama" in model.lower(): return "llamacpp" + # 3) Last resort: first provider that has an API key configured. if OPENROUTER_API_KEY: return "openrouter" if STEPFUN_API_KEY: @@ -1602,6 +2385,14 @@ class AIProvider: @staticmethod def get_api_key(provider: str) -> str: + """Return the API key configured for a provider. + + Args: + provider: Provider key. + + Returns: + API key string, or "" when unknown/local. + """ return { "openrouter": OPENROUTER_API_KEY, "stepfun": STEPFUN_API_KEY, @@ -1611,6 +2402,14 @@ class AIProvider: @staticmethod def get_api_url(provider: str) -> str: + """Return the base API URL for a provider. + + Args: + provider: Provider key. + + Returns: + Base URL string; unknown providers fall back to OpenRouter. + """ return { "openrouter": OPENROUTER_API_URL, "stepfun": STEPFUN_API_URL, @@ -1620,6 +2419,18 @@ class AIProvider: @staticmethod def chat(model: str, messages: List[Dict[str, str]], temperature: float = 0.8, max_tokens: int = 2000) -> tuple[Optional[str], bool]: + """Perform a single chat completion against the resolved provider. + + Args: + model: Model identifier. + messages: OpenAI-style messages list. + temperature: Sampling temperature. + max_tokens: Maximum response length. + + Returns: + (content, was_truncated) where content is None on failure and + was_truncated is True when the provider stopped due to length. + """ provider = AIProvider.get_provider(model) api_key = AIProvider.get_api_key(provider) api_url = AIProvider.get_api_url(provider) @@ -1631,6 +2442,19 @@ class AIProvider: @staticmethod def chat_with_fallback(model: str, messages: List[Dict[str, str]], fallback_models: List[str] = None, temperature: float = 0.8, max_tokens: int = 2000) -> tuple[Optional[str], bool, str]: + """Chat with automatic fallback across FALLBACK_MODELS on failure. + + Args: + model: Primary model identifier. + messages: OpenAI-style messages list. + fallback_models: Candidates tried in order after the primary. + temperature: Sampling temperature. + max_tokens: Maximum response length. + + Returns: + (content, was_truncated, error_msg); error_msg is "" on + success and "All models failed" when every attempt errored. + """ result, was_truncated = AIProvider.chat(model, messages, temperature, max_tokens) if result is not None: return result, was_truncated, "" @@ -1649,6 +2473,17 @@ class AIProvider: @staticmethod def _chat_llamacpp(model: str, messages: List[Dict[str, str]], temperature: float, max_tokens: int) -> tuple[Optional[str], bool]: + """Call a local llama.cpp server via its OpenAI-compatible endpoint. + + Args: + model: Model name (a "local-" prefix is stripped). + messages: OpenAI-style messages list. + temperature: Sampling temperature. + max_tokens: Maximum response length. + + Returns: + (content, was_truncated); (None, False) on any error. + """ try: payload = { "model": model.replace("local-", ""), @@ -1675,6 +2510,24 @@ class AIProvider: @staticmethod def _chat_openai_compatible(provider: str, api_key: str, api_url: str, model: str, messages: List[Dict[str, str]], temperature: float, max_tokens: int) -> tuple[Optional[str], bool]: + """Call any OpenAI-compatible chat completions endpoint. + + Adds OpenRouter-specific referer headers when needed. Truncation + is detected from finish_reason == "length"; reasoning-only + replies are used as content when the content field is empty. + + Args: + provider: Provider key (openrouter/stepfun/qwencloud). + api_key: Bearer token. + api_url: Base API URL. + model: Model identifier. + messages: OpenAI-style messages list. + temperature: Sampling temperature. + max_tokens: Maximum response length. + + Returns: + (content, was_truncated); (None, False) on any error. + """ if not api_key: return None, False try: @@ -1711,12 +2564,14 @@ class AIProvider: return None, False choice = choices[0] finish_reason = choice.get("finish_reason", "") + # "length" means max_tokens cut the reply short; surface that flag. was_truncated = finish_reason == "length" if was_truncated: logger.warning(f"AI response truncated due to max_tokens limit ({max_tokens}) for model {model}") message = choice.get("message", {}) content = message.get("content") if not content: + # Some reasoning models emit only the "reasoning" field. content = message.get("reasoning", "") return content or "", was_truncated except Exception as e: @@ -1737,10 +2592,31 @@ Be authentic, spontaneous and never robotic. IMPORTANT: Be CONCISE. Respond directly and naturally. For simple questions, respond in 2-5 lines. Expand only when the subject really requires it or when the user shows interest in going deeper.""" def get_agent_name(profile: Dict[str, Any]) -> str: + """Return the configured agent display name. + + Args: + profile: Unused; kept for call-site compatibility. + + Returns: + Agent name from agent_profile.json, or "PsAgentAI". + """ agent = load_agent_profile() return agent.get("name", "PsAgentAI") def build_system_prompt(profile: Dict[str, Any]) -> str: + """Compose the full system prompt from agent and user profiles. + + Starts from PSAGENTAI_SYSTEM_PROMPT_BASE, substitutes the agent + name/personality, adjusts gendered wording, then appends user + profile facts, current date/time/timezone, web-search guidance, + and a name-stability reminder (old context may use other names). + + Args: + profile: User profile dict. + + Returns: + System prompt string ready for the messages array. + """ agent = load_agent_profile() agent_name = agent.get("name", "PsAgentAI") agent_gender = agent.get("gender", "").strip().lower() @@ -1753,6 +2629,8 @@ def build_system_prompt(profile: Dict[str, Any]) -> str: "direct and objective", agent_personality, ) + # Gendered wording swap: adjust Portuguese/English gendered terms in the + # base prompt so persona grammar matches the configured agent gender. agent_gender_normalized = agent_gender.strip().lower() if agent_gender_normalized in ("masculino", "masculina", "m"): prompt = prompt.replace("companheira", "companheiro") @@ -1793,6 +2671,8 @@ def build_system_prompt(profile: Dict[str, Any]) -> str: if profile.get("notes"): prompt += f"\nAdditional notes: {profile['notes']}" try: + # Live date/time block + the web-search guideline the model must follow + # for time-sensitive factual questions (see RULES.md / system prompt). now = get_local_now() current_date = now.strftime("%Y-%m-%d") current_time = now.strftime("%H:%M:%S") @@ -1812,6 +2692,16 @@ def build_system_prompt(profile: Dict[str, Any]) -> str: return prompt def build_messages_for_ai(profile: Dict[str, Any], user_message: str, context: List[Dict[str, str]]) -> List[Dict[str, str]]: + """Assemble the messages array sent to the AI provider. + + Args: + profile: User profile (for the system prompt). + user_message: Current user message. + context: Prior conversation messages (role/content dicts). + + Returns: + [system, *context, user] message list. + """ system_prompt = build_system_prompt(profile) messages = [{"role": "system", "content": system_prompt}] messages.extend(context) @@ -1820,6 +2710,14 @@ def build_messages_for_ai(profile: Dict[str, Any], user_message: str, context: L def count_lines(text: str) -> int: + """Count meaningful lines in a response (trailing blank lines ignored). + + Args: + text: Response text. + + Returns: + Number of lines, or 0 for whitespace-only input. + """ stripped = text.rstrip() if not stripped: return 0 @@ -1827,6 +2725,21 @@ def count_lines(text: str) -> int: def ensure_min_lines(response: str, user_message: str, profile: Dict[str, Any]) -> str: + """Force a response to meet the MIN_MESSAGE_LINES length floor. + + When the reply is too short, asks the model to expand it while + keeping tone and content. A truncated expansion is retried once + with a larger max_tokens budget; ultimate failure returns the + original reply unchanged. + + Args: + response: Original assistant reply. + user_message: User message that prompted the reply. + profile: User profile for the system prompt. + + Returns: + Possibly expanded response text. + """ _reload_env() min_lines = int(os.getenv("MIN_MESSAGE_LINES", "1")) if count_lines(response) >= min_lines: @@ -1836,6 +2749,8 @@ def ensure_min_lines(response: str, user_message: str, profile: Dict[str, Any]) agent_name = agent.get("name", "PsAgentAI") system_prompt = build_system_prompt(profile) + # Expansion prompt structure: quote user msg + current reply, then demand + # >= MIN_MESSAGE_LINES while preserving tone; metadata-free output only. expand_prompt = ( f"The user said: \"{user_message}\"\n\n" f"You replied: \"{response}\"\n\n" @@ -1856,6 +2771,8 @@ def ensure_min_lines(response: str, user_message: str, profile: Dict[str, Any]) if expanded and count_lines(expanded) >= min_lines: return expanded + # Truncation during expansion means the model may have met the line count + # only partially; retry once with a much larger token budget. if was_truncated and expanded: logger.warning("Expanded response truncated, retrying with higher max_tokens") expanded2, _ = AIProvider.chat(DEFAULT_MODEL, messages, temperature=0.85, max_tokens=12000) @@ -1870,10 +2787,24 @@ _auto_enrich_counter = 0 def _reload_env(): + """Reload .env over current environment variables (override=True). + + Returns: + None. + """ load_dotenv(override=True) def _dedupe_list(existing: List[str], new_items: List[str]) -> List[str]: + """Merge new items into an existing list without duplicates. + + Args: + existing: Base list (not mutated). + new_items: Items to add; blanks and duplicates are skipped. + + Returns: + New list containing existing plus unique new items. + """ result = list(existing) for item in new_items: item = item.strip() @@ -1883,10 +2814,27 @@ def _dedupe_list(existing: List[str], new_items: List[str]) -> List[str]: def _merge_user_updates(profile: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: + """Merge AI-extracted user facts into the user profile in place. + + List fields are unioned, notes are semicolon-appended, and scalar + fields (communication_style, daily_routine times) are only filled + when currently empty so existing data is never overwritten. + + Args: + profile: User profile, mutated in place. + updates: Parsed "user" object from extract_profile_facts. + + Returns: + The same profile dict for convenience. + """ + # Identity fields merge with "; "; list fields dedupe via _dedupe_list; + # scalars only fill when empty so enrichment never overwrites user data. if updates.get("interests_add"): profile["interests"] = _dedupe_list(profile.get("interests", []), updates["interests_add"]) if updates.get("personality_traits_add"): profile["personality_traits"] = _dedupe_list(profile.get("personality_traits", []), updates["personality_traits_add"]) + # Personal facts merge with "; "; only fill scalar fields when empty so + # explicit user answers are never silently replaced by AI guesses. if updates.get("notes_add"): note = updates["notes_add"].strip() if note: @@ -1895,6 +2843,7 @@ def _merge_user_updates(profile: Dict[str, Any], updates: Dict[str, Any]) -> Dic prefs = profile.setdefault("preferences", {}) if updates.get("communication_style"): style = updates["communication_style"].strip() + # Do not overwrite an explicitly chosen communication style. if style and not prefs.get("communication_style"): prefs["communication_style"] = style if updates.get("topics_of_interest_add"): @@ -1904,27 +2853,50 @@ def _merge_user_updates(profile: Dict[str, Any], updates: Dict[str, Any]) -> Dic if updates.get("daily_routine"): dr = profile.setdefault("daily_routine", {}) for key, value in updates["daily_routine"].items(): + # Fill routine blanks only; never clobber times the user already set. if value and not dr.get(key): dr[key] = value return profile def _merge_agent_updates(profile: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: + """Merge AI-extracted agent facts into the agent profile in place. + + Args: + profile: Agent profile, mutated in place. + updates: Parsed "agent" object from extract_profile_facts. + + Returns: + The same profile dict for convenience. + """ if updates.get("traits_add"): profile["traits"] = _dedupe_list(profile.get("traits", []), updates["traits_add"]) if updates.get("notes_add"): note = updates["notes_add"].strip() if note: + # Semicolon-join keeps agent notes as an append-only log. existing = profile.get("notes", "") profile["notes"] = f"{existing}; {note}" if existing else note if updates.get("personality_update"): personality = updates["personality_update"].strip() + # Personality is rare: only write when the field is currently empty. if personality and not profile.get("personality"): profile["personality"] = personality return profile def _safe_parse_json(text: str) -> Optional[Dict[str, Any]]: + """Parse JSON from an LLM reply that may be fenced or annotated. + + Strips markdown ``` fences first; if that fails, extracts the + first {...} block with a DOTALL regex and parses that. + + Args: + text: Raw model output. + + Returns: + Parsed dict, or None when no valid JSON object is found. + """ if not text: return None text = text.strip() @@ -1945,6 +2917,21 @@ def _safe_parse_json(text: str) -> Optional[Dict[str, Any]]: def extract_profile_facts(user_message: str, assistant_response: str, user_profile: Dict[str, Any], agent_profile: Dict[str, Any]) -> Dict[str, Any]: + """Ask the AI to extract profile-relevant facts from one exchange. + + Sends the conversation plus the current profiles so the model can + avoid repeating known information, and requests a strict JSON + payload with user.* and agent.* delta fields. + + Args: + user_message: Latest user message. + assistant_response: Latest assistant reply. + user_profile: Current user profile (for dedup context). + agent_profile: Current agent profile (for dedup context). + + Returns: + Parsed facts dict, or {} when the model returned nothing usable. + """ system_prompt = ( "You are an assistant that analyzes conversations and extracts relevant facts for user and AI agent profiles. " "Return ONLY a valid JSON (no extra text, no markdown) with the structure:\n" @@ -2008,6 +2995,7 @@ def extract_profile_facts(user_message: str, assistant_response: str, user_profi raw, _ = AIProvider.chat(DEFAULT_MODEL, messages, temperature=0.3, max_tokens=500) if not raw: return {} + # Temperature 0.3 + tiny max_tokens: extraction should be short and stable. parsed = _safe_parse_json(raw) if not parsed: return {} @@ -2015,6 +3003,14 @@ def extract_profile_facts(user_message: str, assistant_response: str, user_profi def apply_enrichment_to_profiles(facts: Dict[str, Any]) -> bool: + """Persist extracted facts into the user and agent profiles. + + Args: + facts: Output of extract_profile_facts. + + Returns: + True if either profile was modified. + """ if not facts: return False @@ -2023,7 +3019,7 @@ def apply_enrichment_to_profiles(facts: Dict[str, Any]) -> bool: user_changed = bool(facts.get("user")) agent_changed = bool(facts.get("agent")) - + # Dirty flags: persist each profile only when its section has changes. if user_changed: _merge_user_updates(user_profile, facts.get("user", {})) save_user_profile(user_profile) @@ -2036,6 +3032,19 @@ def apply_enrichment_to_profiles(facts: Dict[str, Any]) -> bool: async def maybe_enrich_profiles(user_message: str, assistant_response: str) -> None: + """Periodically enrich profiles after a conversation exchange. + + Runs only every AUTO_ENRICH_EVERY calls (counter-based gate) and + only when the user profile is complete; enrichment itself is a + blocking AI call. + + Args: + user_message: Latest user message. + assistant_response: Latest assistant reply. + + Returns: + None. + """ global _auto_enrich_counter _auto_enrich_counter += 1 _reload_env() @@ -2055,6 +3064,15 @@ async def maybe_enrich_profiles(user_message: str, assistant_response: str) -> N async def _safe_enrich(user_message: str, assistant_response: str) -> None: + """Run maybe_enrich_profiles without letting errors bubble up. + + Args: + user_message: Latest user message. + assistant_response: Latest assistant reply. + + Returns: + None. + """ try: await maybe_enrich_profiles(user_message, assistant_response) except Exception as e: @@ -2065,6 +3083,12 @@ IMPROVEMENTS_STATE_FILE = "system_improvements.json" def _load_improvements_state() -> Dict[str, Any]: + """Load the auto-improvement ledger. + + Returns: + Dict with "applied" (list of applied change ids) and + "last_analysis", defaulting to empty values. + """ if os.path.exists(IMPROVEMENTS_STATE_FILE): try: with open(IMPROVEMENTS_STATE_FILE, "r", encoding="utf-8") as f: @@ -2075,11 +3099,30 @@ def _load_improvements_state() -> Dict[str, Any]: def _save_improvements_state(state: Dict[str, Any]) -> None: + """Persist the auto-improvement ledger. + + Args: + state: Ledger dict to write. + + Raises: + OSError: If the file cannot be written. + + Returns: + None. + """ with open(IMPROVEMENTS_STATE_FILE, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def _get_current_system_snapshot() -> Dict[str, Any]: + """Build a compact snapshot of profiles and recent conversation. + + Used as input to analyze_and_improve_system; message contents are + capped at 300 chars and only the last 20 context entries are sent. + + Returns: + Dict with user_profile, agent_profile, and recent_conversation. + """ user_profile = load_user_profile() agent_profile = load_agent_profile() today_context = load_context() @@ -2106,6 +3149,16 @@ def _get_current_system_snapshot() -> Dict[str, Any]: def analyze_and_improve_system() -> Dict[str, Any]: + """Ask the AI for self-improvement suggestions and apply them. + + Sends the system snapshot plus the history of already-applied + improvements so suggestions are not repeated, then merges new + agent traits/notes into agent_profile.json and updates the ledger. + + Returns: + Result dict with "status" (disabled/ok/error), plus "applied" + and "reasoning" when successful. + """ _reload_env() auto_improve_enabled = os.getenv("AUTO_IMPROVE_ENABLED", "true").lower() in ("true", "1", "yes") if not auto_improve_enabled: @@ -2117,8 +3170,7 @@ def analyze_and_improve_system() -> Dict[str, Any]: analysis_prompt = ( "You are an expert in conversational AI system evolution. " "Analyze the current snapshot of this conversational agent and suggest improvements based on market best practices. " - "Return ONLY a valid JSON (no extra text, no markdown) with the structure:\n" - '{\n' + "Return ONLY a valid JSON (no extra text, no markdown) with the structure:\n" '{\n' ' "system_prompt_additions": ["add these guidelines to the system prompt..."],\n' ' "agent_traits_add": ["new trait for the agent"],\n' ' "agent_notes_add": "relevant fact about the agent evolution",\n' @@ -2143,11 +3195,13 @@ def analyze_and_improve_system() -> Dict[str, Any]: }, {"role": "user", "content": analysis_prompt}, ] + # Prompt embeds prior "applied" history so the model does not re-suggest + # improvements that were already recorded in system_improvements.json. raw, _, err_msg = AIProvider.chat_with_fallback(DEFAULT_MODEL, messages, FALLBACK_MODELS, temperature=0.4, max_tokens=2000) if not raw: return {"status": "error", "reason": f"AI did not return analysis. {err_msg}".strip()} - + # err_msg is non-empty only when every model in the fallback chain failed. parsed = _safe_parse_json(raw) if not parsed: return {"status": "error", "reason": "Failed to parse analysis JSON"} @@ -2156,9 +3210,12 @@ def analyze_and_improve_system() -> Dict[str, Any]: agent_profile = load_agent_profile() user_profile = load_user_profile() + # Only agent-side mutations are applied from analysis results today; + # system_prompt_additions is advisory and recorded via reasoning only. if parsed.get("agent_traits_add"): for trait in parsed["agent_traits_add"]: trait = trait.strip() + # Skip traits already present so repeated runs stay idempotent. if trait and trait not in agent_profile.get("traits", []): agent_profile.setdefault("traits", []).append(trait) applied_now.append(f"agent_trait:{trait}") @@ -2175,6 +3232,7 @@ def analyze_and_improve_system() -> Dict[str, Any]: state.setdefault("applied", []) state["applied"].extend(applied_now) + # dict.fromkeys preserves insertion order while de-duplicating applied ids. state["applied"] = list(dict.fromkeys(state["applied"])) state["last_analysis"] = parsed.get("reasoning", "") _save_improvements_state(state) @@ -2188,6 +3246,11 @@ def analyze_and_improve_system() -> Dict[str, Any]: async def _safe_auto_improve() -> None: + """Run one auto-improvement cycle without raising. + + Returns: + None. + """ try: result = analyze_and_improve_system() if result.get("status") == "ok" and result.get("applied"): @@ -2197,6 +3260,15 @@ async def _safe_auto_improve() -> None: async def maybe_auto_improve() -> None: + """Gate auto-improvement behind AUTO_IMPROVE_EVERY exchanges. + + Increments a module-level counter each call; when the counter + reaches the configured interval (and the feature is enabled), the + counter resets and a safe improvement cycle runs. + + Returns: + None. + """ global _auto_improve_counter _reload_env() auto_improve_enabled = os.getenv("AUTO_IMPROVE_ENABLED", "true").lower() in ("true", "1", "yes") @@ -2212,6 +3284,12 @@ async def maybe_auto_improve() -> None: def get_user_routine() -> Dict[str, Any]: + """Read the daily routine from the user profile with defaults filled in. + + Returns: + Routine dict guaranteed to contain wake_up_time, sleep_time, + work_schedule, and activities keys. + """ profile = load_user_profile() routine = profile.get("daily_routine", {}) if not routine: @@ -2229,12 +3307,37 @@ def get_user_routine() -> Dict[str, Any]: def save_user_routine(routine: Dict[str, Any]): + """Write the routine dict back into the user profile. + + Args: + routine: Routine mapping to store under daily_routine. + + Raises: + OSError: If the profile file cannot be written. + + Returns: + None. + """ profile = load_user_profile() profile["daily_routine"] = routine save_user_profile(profile) def add_user_routine_activity(name: str, start_time: str, end_time: str) -> bool: + """Add or replace a named activity in the daily routine. + + Validates HH:MM times (end must be after start). An activity with + the same name (case-insensitive) is replaced, and the list is + re-sorted by start time. + + Args: + name: Activity name. + start_time: Start time in HH:MM. + end_time: End time in HH:MM. + + Returns: + True if the activity was saved; False on invalid times. + """ routine = get_user_routine() start_minutes = _parse_time_to_minutes(start_time) end_minutes = _parse_time_to_minutes(end_time) @@ -2257,6 +3360,14 @@ def add_user_routine_activity(name: str, start_time: str, end_time: str) -> bool def remove_user_routine_activity(name: str) -> bool: + """Remove a named activity from the daily routine. + + Args: + name: Activity name (case-insensitive match). + + Returns: + True if an activity was removed; False if none matched. + """ routine = get_user_routine() activities = routine.get("activities", []) new_activities = [a for a in activities if a.get("name", "").lower() != name.lower()] @@ -2268,6 +3379,15 @@ def remove_user_routine_activity(name: str) -> bool: def get_user_routine_context(profile: Dict[str, Any]) -> str: + """Render the user's routine as a sentence for AI prompts. + + Args: + profile: User profile containing daily_routine. + + Returns: + Human-readable routine summary prefixed with "User routine:", + or "" when no routine data exists. + """ routine = profile.get("daily_routine", {}) parts = [] if routine.get("wake_up_time"): @@ -2295,6 +3415,22 @@ def get_user_routine_context(profile: Dict[str, Any]) -> str: def build_proactive_prompt_with_routine(profile: Dict[str, Any], today_context: List[Dict[str, str]], time_period: str, current_hour: int) -> str: + """Build the proactive outreach prompt with routine/activity awareness. + + Infers the user's current activity from the routine time windows + (including sleeping before wake_up_time or after sleep_time), + includes recent context and avoid-topics, and instructs the model + to write only the outgoing message. + + Args: + profile: User profile. + today_context: Today's conversation messages. + time_period: morning/afternoon/evening/night. + current_hour: Current hour (0-23) used for activity matching. + + Returns: + Prompt string to send as the final user message. + """ agent = load_agent_profile() agent_name = agent.get("name", "PsAgentAI") agent_personality = agent.get("personality", "direct") @@ -2307,6 +3443,7 @@ def build_proactive_prompt_with_routine(profile: Dict[str, Any], today_context: current_activity = "no specific activity" current_minutes = current_hour * 60 activities = routine.get("activities", []) + # First activity whose [start, end) window contains now wins. for activity in activities: start_minutes = _parse_time_to_minutes(activity.get("start", "00:00")) end_minutes = _parse_time_to_minutes(activity.get("end", "23:59")) @@ -2314,17 +3451,22 @@ def build_proactive_prompt_with_routine(profile: Dict[str, Any], today_context: if start_minutes <= current_minutes < end_minutes: current_activity = activity.get("name", "activity") break - + + # Override with "sleeping" outside the wake/sleep bounds. if routine.get("wake_up_time"): wake_up_minutes = _parse_time_to_minutes(routine.get("wake_up_time", "00:00")) if wake_up_minutes is not None and current_minutes < wake_up_minutes: current_activity = "sleeping" + # Activity detection: sleeping if now is before wake_up or at/after sleep; + # otherwise the first matching [start, end) activity window applies. if routine.get("sleep_time"): sleep_minutes = _parse_time_to_minutes(routine.get("sleep_time", "23:59")) if sleep_minutes is not None and current_minutes >= sleep_minutes: current_activity = "sleeping" + # Activity window: first match wins; sleeping overrides before/after bounds. + # time_descriptions sets the day-rhythm framing for the generated message. time_descriptions = { "morning": "It is morning, the day is starting. People are usually waking up, having breakfast or starting their activities.", "afternoon": "It is afternoon, middle of the day. People are usually working, studying or doing their activities.", @@ -2350,6 +3492,8 @@ def build_proactive_prompt_with_routine(profile: Dict[str, Any], today_context: if topics_to_avoid: avoid_instruction = f"\n8. Avoid these topics: {', '.join(topics_to_avoid)}." + # Proactive prompt contract: only the outgoing message text is expected + # back; instructions 1-8 constrain timing, activity, tone, and length. prompt = ( f"You are {agent_name}, an AI companion with a {agent_personality} personality.\n\n" f"CURRENT CONTEXT:\n" @@ -2375,6 +3519,19 @@ def build_proactive_prompt_with_routine(profile: Dict[str, Any], today_context: async def addautoactions_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /addautoactions: suggest and store new automatic actions. + + Loads profiles and recent context, gathers trend headlines via web + search, asks the AI for JSON suggestions, then merges new + preferences and auto_actions into both profiles. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) await update.message.reply_text("🔍 Analyzing contexts, profiles and internet trends to add automatic actions...") @@ -2392,6 +3549,7 @@ async def addautoactions_command(update: Update, context: ContextTypes.DEFAULT_T web_results = [] try: + # Fixed trend queries feed the personalization analysis prompt. search_queries = [ "AI personalization trends virtual assistant", "user preferences AI personal assistant", @@ -2412,8 +3570,7 @@ async def addautoactions_command(update: Update, context: ContextTypes.DEFAULT_T analysis_prompt = ( "You are an AI systems analyst specialized in virtual assistant personalization. " "Analyze the user profile, agent profile and conversation contexts provided, " - "along with internet trends, and suggest NEW automatic actions and preferences that can be added.\n\n" - f"USER PROFILE:\n{json.dumps(user_profile, ensure_ascii=False)}\n\n" + "along with internet trends, and suggest NEW automatic actions and preferences that can be added.\n\n" f"USER PROFILE:\n{json.dumps(user_profile, ensure_ascii=False)}\n\n" f"AGENT PROFILE:\n{json.dumps(agent_profile, ensure_ascii=False)}\n\n" f"RECENT CONTEXTS:\n{context_summary}\n\n" f"{web_context}\n\n" @@ -2457,6 +3614,8 @@ async def addautoactions_command(update: Update, context: ContextTypes.DEFAULT_T user_profile = load_user_profile() agent_profile = load_agent_profile() + # Non-destructive merges: only fill empty preference keys, and append + # auto_actions that are not already present. if parsed.get("new_preferences"): prefs = user_profile.setdefault("preferences", {}) for key, value in parsed["new_preferences"].items(): @@ -2486,6 +3645,7 @@ async def addautoactions_command(update: Update, context: ContextTypes.DEFAULT_T save_user_profile(user_profile) save_agent_profile(agent_profile) + # Report at most 10 applied items to keep the Telegram reply readable. lines = ["✅ Automatic actions and preferences added:", ""] for item in applied[:10]: lines.append(f"- {item}") @@ -2503,6 +3663,15 @@ async def addautoactions_command(update: Update, context: ContextTypes.DEFAULT_T async def updatesystem_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /updatesystem: force one self-improvement cycle now. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) await update.message.reply_text("Analyzing system and applying auto improvements...") result = analyze_and_improve_system() @@ -2525,6 +3694,15 @@ async def updatesystem_command(update: Update, context: ContextTypes.DEFAULT_TYP async def timezone_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /timezone: report the server timezone (read-only). + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) tz = get_timezone() tz_info = _get_timezone_info(tz) @@ -2544,6 +3722,17 @@ async def timezone_command(update: Update, context: ContextTypes.DEFAULT_TYPE): # ===================== SCHEDULE ===================== async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /schedule: start the 3-step schedule wizard. + + Shows the current schedule and prompts for days (step 1 of 3). + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) global is_schedule, schedule_step, schedule_answers is_schedule = True @@ -2594,6 +3783,21 @@ async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_schedule_answer(update: Update, context_context: ContextTypes.DEFAULT_TYPE) -> bool: + """Three-step state machine for the schedule wizard. + + Step 0: select weekdays ("all" or 1-7 numbers). + Step 1: start time (HH:MM). + Step 2: end time (HH:MM) - validates end > start, saves, and + resets the wizard state. + + Args: + update: Telegram update carrying the answer. + context_context: PTB callback context. + + Returns: + True if the wizard consumed the message; False when inactive + or the step index is out of range. + """ global is_schedule, schedule_step, schedule_answers if not is_schedule: return False @@ -2704,6 +3908,15 @@ async def handle_schedule_answer(update: Update, context_context: ContextTypes.D async def schedule_show_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Display the currently configured proactive schedule. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) schedule = get_schedule() days_names = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] @@ -2719,6 +3932,15 @@ async def schedule_show_command(update: Update, context: ContextTypes.DEFAULT_TY async def viewschedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /viewschedule as an alias of schedule_show_command. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await schedule_show_command(update, context) @@ -2773,6 +3995,17 @@ activity_crud_data: Dict[str, Any] = {} async def dailyroutine_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /dailyroutine: start the daily routine wizard. + + Shows current wake/sleep/work times and asks the first question. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) global is_dailyroutine, dailyroutine_step, dailyroutine_answers is_dailyroutine = True @@ -2797,12 +4030,27 @@ async def dailyroutine_command(update: Update, context: ContextTypes.DEFAULT_TYP async def handle_dailyroutine_answer(update: Update, context_context: ContextTypes.DEFAULT_TYPE) -> bool: + """Multi-step state machine for the daily routine wizard. + + Steps 0..N-1 collect the fixed HH:MM questions from + USER_AGENDA_QUESTIONS. Steps N..N+3 form the custom-activity + sub-loop (confirm -> name -> start -> end) which may repeat until + the user answers "no". + + Args: + update: Telegram update carrying the answer. + context_context: PTB callback context. + + Returns: + True if the wizard consumed the message; False when inactive. + """ global is_dailyroutine, dailyroutine_step, dailyroutine_answers if not is_dailyroutine: return False answer = update.message.text.strip() + # Fixed HH:MM questions occupy steps 0..len(USER_AGENDA_QUESTIONS)-1. if dailyroutine_step < len(USER_AGENDA_QUESTIONS): q = USER_AGENDA_QUESTIONS[dailyroutine_step] minutes = _parse_time_to_minutes(answer) @@ -2832,6 +4080,7 @@ async def handle_dailyroutine_answer(update: Update, context_context: ContextTyp dailyroutine_step = len(USER_AGENDA_QUESTIONS) return True + # Custom-activity sub-loop offsets: 0=confirm, 1=name, 2=start, 3=end. custom_step = dailyroutine_step - len(USER_AGENDA_QUESTIONS) if custom_step == 0: @@ -2909,6 +4158,20 @@ async def handle_dailyroutine_answer(update: Update, context_context: ContextTyp async def finish_dailyroutine(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Persist the routine wizard results into the user profile. + + Merges fixed times, builds work_schedule from start/end, seeds + meal activities (breakfast/lunch/dinner as zero-duration entries + when not already present), appends custom activities, sorts by + start time, then clears the wizard state. + + Args: + update: Telegram update. + context_context: PTB callback context. + + Returns: + None. + """ global is_dailyroutine, dailyroutine_step, dailyroutine_answers profile = load_user_profile() @@ -2921,6 +4184,8 @@ async def finish_dailyroutine(update: Update, context_context: ContextTypes.DEFA work_start = dailyroutine_answers.get("work_start", "") work_end = dailyroutine_answers.get("work_end", "") + # work_schedule is a human-readable range ("09:00 to 18:00") built from + # whichever endpoint(s) the user provided. if work_start and work_end: routine["work_schedule"] = f"{work_start} to {work_end}" elif work_start: @@ -2935,6 +4200,8 @@ async def finish_dailyroutine(update: Update, context_context: ContextTypes.DEFA "lunch_time": "lunch", "dinner_time": "dinner" } + # Seed meal activities as zero-duration entries (start == end) so they + # appear in the routine list without colliding with user-defined ones. existing_names = {a.get("name", "").lower() for a in routine.get("activities", [])} for key, name in activity_map.items(): time = dailyroutine_answers.get(key) @@ -2954,7 +4221,16 @@ async def finish_dailyroutine(update: Update, context_context: ContextTypes.DEFA existing_names.add(name.lower()) activities = routine.get("activities", []) + # Zero-duration point events (start == end) still sort by their start time. def parse_time_for_sort(activity): + """Convert an activity's start time to minutes for sorting. + + Args: + activity: Activity dict with a "start" HH:MM field. + + Returns: + Minutes since midnight, or 0 when unparseable. + """ start = activity.get("start", "00:00") parts = start.split(":") if len(parts) == 2: @@ -2970,6 +4246,7 @@ async def finish_dailyroutine(update: Update, context_context: ContextTypes.DEFA profile["daily_routine"] = routine save_user_profile(profile) + # Reset wizard state only after a successful save. is_dailyroutine = False dailyroutine_step = 0 dailyroutine_answers = {} @@ -2983,6 +4260,15 @@ async def finish_dailyroutine(update: Update, context_context: ContextTypes.DEFA async def dailyroutine_show_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Display the current daily routine and its activities. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) profile = load_user_profile() routine = get_user_routine() @@ -3017,12 +4303,29 @@ async def dailyroutine_show_command(update: Update, context: ContextTypes.DEFAUL async def viewdailyroutine_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /viewdailyroutine as an alias of dailyroutine_show_command. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await dailyroutine_show_command(update, context) # ===================== ACTIVITY CRUD ===================== async def _show_activity_list(activities: List[Dict[str, Any]]) -> str: + """Format routine activities as a numbered list for prompts. + + Args: + activities: List of activity dicts with name/start/end. + + Returns: + Numbered multiline string, or a no-activities notice. + """ if not activities: return "No activities registered." lines = ["Registered activities:"] @@ -3035,6 +4338,15 @@ async def _show_activity_list(activities: List[Dict[str, Any]]) -> str: async def addactivity_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /addactivity: start the add-activity wizard. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) global is_activity_crud, activity_crud_step, activity_crud_data is_activity_crud = True @@ -3050,6 +4362,17 @@ async def addactivity_command(update: Update, context: ContextTypes.DEFAULT_TYPE async def editactivity_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /editactivity: start the edit-activity wizard. + + Refuses to start when the routine has no activities. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) global is_activity_crud, activity_crud_step, activity_crud_data routines = get_user_routine() @@ -3075,6 +4398,17 @@ async def editactivity_command(update: Update, context: ContextTypes.DEFAULT_TYP async def deleteactivity_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /deleteactivity: start the delete-activity wizard. + + Refuses to start when the routine has no activities. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) global is_activity_crud, activity_crud_step, activity_crud_data routines = get_user_routine() @@ -3100,6 +4434,19 @@ async def deleteactivity_command(update: Update, context: ContextTypes.DEFAULT_T async def handle_activity_crud_answer(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: + """Dispatch activity CRUD answers to the active mode handler. + + Reads activity_crud_data["mode"] (add/edit/delete) and forwards + the message to the matching _handle_*_activity function. + + Args: + update: Telegram update carrying the answer. + context: PTB callback context. + + Returns: + True if a mode handler consumed the message; False when idle + or the mode is unknown. + """ global is_activity_crud, activity_crud_step, activity_crud_data if not is_activity_crud: return False @@ -3118,6 +4465,20 @@ async def handle_activity_crud_answer(update: Update, context: ContextTypes.DEFA async def _handle_add_activity(update: Update, answer: str) -> bool: + """Three-step state machine to add one routine activity. + + Step 0: activity name (max 80 chars). + Step 1: start time (HH:MM). + Step 2: end time (HH:MM), validated against start; then persists + via add_user_routine_activity and resets wizard state. + + Args: + update: Telegram update carrying the answer. + answer: Stripped message text. + + Returns: + True if the step was processed; False when out of range. + """ global is_activity_crud, activity_crud_step, activity_crud_data if activity_crud_step == 0: @@ -3187,6 +4548,22 @@ async def _handle_add_activity(update: Update, answer: str) -> bool: async def _handle_edit_activity(update: Update, answer: str) -> bool: + """Four-step state machine to edit one routine activity. + + Step 0: select activity by 1-based index. + Step 1: new name (or 'keep'). + Step 2: new start time (or 'keep'). + Step 3: new end time (or 'keep'); validates end > start, removes + the old entry when renamed, re-adds the updated activity, and + resets wizard state. + + Args: + update: Telegram update carrying the answer. + answer: Stripped message text. + + Returns: + True if the step was processed; False when out of range. + """ global is_activity_crud, activity_crud_step, activity_crud_data routines = get_user_routine() activities = routines.get("activities", []) @@ -3274,6 +4651,7 @@ async def _handle_edit_activity(update: Update, answer: str) -> bool: start = activity_crud_data["start"] end = activity_crud_data["end"] + # Renaming requires remove-old then add-new so the list stays unique. old_name = activity_crud_data["old_name"] if name.lower() != old_name.lower(): remove_user_routine_activity(old_name) @@ -3295,6 +4673,16 @@ async def _handle_edit_activity(update: Update, answer: str) -> bool: async def _handle_delete_activity(update: Update, answer: str) -> bool: + """Delete a routine activity selected by 1-based index. + + Args: + update: Telegram update carrying the answer. + answer: Stripped message text. + + Returns: + True once the selection is processed (including invalid input + that prompts for a retry). + """ global is_activity_crud, activity_crud_step, activity_crud_data routines = get_user_routine() activities = routines.get("activities", []) @@ -3388,6 +4776,15 @@ ONBOARDING_QUESTIONS = [ ] async def start_onboarding(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Start the onboarding questionnaire from the first question. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ global is_onboarding, onboarding_step, onboarding_answers is_onboarding = True onboarding_step = 0 @@ -3395,6 +4792,18 @@ async def start_onboarding(update: Update, context: ContextTypes.DEFAULT_TYPE): await send_onboarding_question(update, context) async def send_onboarding_question(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Send the current onboarding question, or finish when done. + + Interpolates {name} into question text when the name is already + known from an earlier answer. + + Args: + update: Telegram update. + context_context: PTB callback context. + + Returns: + None. + """ global onboarding_step if onboarding_step >= len(ONBOARDING_QUESTIONS): await finish_onboarding(update, context_context) @@ -3409,6 +4818,15 @@ async def send_onboarding_question(update: Update, context_context: ContextTypes ) async def finish_onboarding(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Save all onboarding answers into the user profile. + + Args: + update: Telegram update. + context_context: PTB callback context. + + Returns: + None. + """ global is_onboarding, onboarding_step, onboarding_answers profile = load_user_profile() for key, value in onboarding_answers.items(): @@ -3424,6 +4842,18 @@ async def finish_onboarding(update: Update, context_context: ContextTypes.DEFAUL ) async def handle_onboarding_answer(update: Update, context_context: ContextTypes.DEFAULT_TYPE): + """Advance the onboarding wizard one step for an incoming answer. + + List-typed questions split on commas; nested questions (e.g. + preferences) are stored under their key as collected. + + Args: + update: Telegram update carrying the answer. + context_context: PTB callback context. + + Returns: + True if the wizard consumed the message; False when inactive. + """ global onboarding_step, onboarding_answers if not is_onboarding: return False @@ -3446,6 +4876,19 @@ async def handle_onboarding_answer(update: Update, context_context: ContextTypes # ===================== AI CHAT ===================== async def get_psagentai_response(user_message: str) -> Optional[str]: + """Generate the main conversational reply for a user message. + + Builds messages from profile + daily context, calls the default + model (retrying once with a larger token budget if truncated), + then enforces the minimum-line floor. + + Args: + user_message: Message to respond to (may include injected file + or web-search context). + + Returns: + Reply text, or None when the provider produced nothing. + """ profile = load_user_profile() today_context = load_context() messages = build_messages_for_ai(profile, user_message, today_context) @@ -3458,6 +4901,15 @@ async def get_psagentai_response(user_message: str) -> Optional[str]: return response async def send_psagentai_message(chat_id: int, text: str): + """Send an HTML message to a chat via the running bot instance. + + Args: + chat_id: Target Telegram chat id. + text: Message text (may contain HTML). + + Returns: + None. + """ if app_instance is None: return try: @@ -3468,6 +4920,12 @@ async def send_psagentai_message(chat_id: int, text: str): # ===================== DAY RHYTHM ===================== def get_time_period() -> str: + """Map the current hour to a coarse time-of-day label. + + Returns: + "morning" (06-11), "afternoon" (12-17), "evening" (18-22), + or "night" (23-05). + """ tz = get_timezone() tz_info = _get_timezone_info(tz) if tz_info: @@ -3483,10 +4941,26 @@ def get_time_period() -> str: return "night" def is_appropriate_time(time_period: str) -> bool: + """Check whether a time period allows proactive messages. + + Args: + time_period: Label from get_time_period. + + Returns: + True for anything except "night". + """ return time_period != "night" def get_last_user_topic(today_context: List[Dict[str, str]]) -> Optional[str]: + """Find the most recent non-empty user message in today's context. + + Args: + today_context: Conversation messages for today. + + Returns: + First 120 characters of the last user message, or None. + """ for msg in reversed(today_context): if msg.get("role") == "user": content = msg.get("content", "").strip() @@ -3495,6 +4969,19 @@ def get_last_user_topic(today_context: List[Dict[str, str]]) -> Optional[str]: return None def build_context_aware_greeting(profile: Dict[str, Any], today_context: List[Dict[str, str]]) -> str: + """Build a time- and history-aware greeting for /start. + + Three tiers: no messages today -> time-of-day greeting; prior + conversation exists -> resume the last user topic; otherwise a + generic warm greeting. + + Args: + profile: User profile (for the user's name). + today_context: Today's conversation messages. + + Returns: + Greeting text to send. + """ agent = load_agent_profile() agent_name = agent.get("name", "PsAgentAI") name = profile.get("name", "friend") @@ -3504,6 +4991,7 @@ def build_context_aware_greeting(profile: Dict[str, Any], today_context: List[Di message_count = len(today_context) if message_count == 0: + # First contact of the day: pick a greeting by time of day. greetings = { "morning": f"Good morning, {name}! ☀️ Great to see you here!\nI'm here for you, as always. Tell me, how was your day?", "afternoon": f"Hi, {name}! 😊 Great to see you here!\nHow has your day been so far?", @@ -3513,6 +5001,7 @@ def build_context_aware_greeting(profile: Dict[str, Any], today_context: List[Di return greetings.get(time_period, f"Hi, {name}! Great to see you here. I'm here for you, as always. Tell me, how was your day?") if last_user_topic: + # Resume mode: reference the last user message to continue the thread. return ( f"Hi, {name}! Great to see you here again ✨\n" f"We were talking about: \"{last_user_topic}\"\n" @@ -3522,6 +5011,19 @@ def build_context_aware_greeting(profile: Dict[str, Any], today_context: List[Di return f"Hi, {name}! Great to see you here. I'm here for you, as always. Tell me, how was your day?" def build_proactive_prompt(profile: Dict[str, Any], today_context: List[Dict[str, str]], time_period: str) -> str: + """Build a proactive outreach prompt without activity inference. + + Similar to build_proactive_prompt_with_routine but does not + compute the current activity window; kept as the simpler variant. + + Args: + profile: User profile. + today_context: Today's conversation messages. + time_period: morning/afternoon/evening/night. + + Returns: + Prompt string for the proactive message. + """ agent = load_agent_profile() agent_name = agent.get("name", "PsAgentAI") agent_personality = agent.get("personality", "caring, attentive and engaging") @@ -3576,6 +5078,18 @@ def build_proactive_prompt(profile: Dict[str, Any], today_context: List[Dict[str # ===================== PROACTIVE MESSAGES ===================== async def proactive_loop(): + """Infinite background loop that sends scheduled proactive messages. + + Loop phases: wait for chat_id -> wait for a complete profile -> + sleep a random MIN/MAX interval -> re-check schedule and time + period -> generate and send a message -> repeat. Cancellation + exits cleanly; other errors log and back off 60s. + + Started as an asyncio task in main()'s post_init hook. + + Returns: + None. + """ global app_instance waited_for_chat = False while True: @@ -3613,9 +5127,11 @@ async def proactive_loop(): current_hour = datetime.now(tz_info).hour else: current_hour = datetime.now().hour + # Routine-aware proactive prompt uses current_hour for activity windows. proactive_prompt = build_proactive_prompt_with_routine(profile, today_context, time_period, current_hour) messages = [{"role": "system", "content": system_prompt}] + # High temperature (0.9) keeps proactive messages varied over time. recent_context = today_context[-10:] if len(today_context) > 10 else today_context for msg in recent_context: messages.append(msg) @@ -3627,10 +5143,12 @@ async def proactive_loop(): response, _ = AIProvider.chat(DEFAULT_MODEL, messages, temperature=0.9, max_tokens=4000) if response: response = ensure_min_lines(response, proactive_prompt, profile) + # Log the outgoing proactive message into today's context. add_to_context("assistant", response) await send_psagentai_message(int(effective_chat_id), response) except asyncio.CancelledError: + # Clean shutdown when the application cancels the task. break except Exception as e: logger.error(f"Proactive loop error: {e}") @@ -3639,6 +5157,15 @@ async def proactive_loop(): # ===================== FILE UPLOAD COMMANDS ===================== async def uploadfile_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /uploadfile: show file-upload instructions. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) await update.message.reply_text( "📁 **File upload**\n" @@ -3659,6 +5186,19 @@ async def uploadfile_command(update: Update, context: ContextTypes.DEFAULT_TYPE) async def listfiles_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /listfiles: show metadata uploads plus workspace-only files. + + Merges uploads_metadata entries (with summary previews) and files + that exist only on disk (created via AI), de-duplicating by path + or display name. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) chat_id = update.effective_chat.id files = get_all_uploads_for_chat(chat_id) @@ -3670,6 +5210,7 @@ async def listfiles_command(update: Update, context: ContextTypes.DEFAULT_TYPE): orig = info.get("original_name", filename) meta_files.append((orig, info.get("summary", ""))) + # Workspace-only files (AI-created) have no metadata entry; merge both lists. workspace_files = _list_workspace_files() workspace_only = [] for rel_path in workspace_files: @@ -3702,6 +5243,19 @@ async def listfiles_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def _send_workspace_file(update: Update, context: ContextTypes.DEFAULT_TYPE, rel_path: str): + """Send a workspace file back to the user as photo or document. + + Image extensions use send_photo; everything else uses + send_document. + + Args: + update: Telegram update (for chat id). + context: PTB callback context. + rel_path: Path relative to workspace/. + + Returns: + None. + """ abs_path = os.path.join(UPLOADS_DIR, rel_path) if not os.path.exists(abs_path): await context.bot.send_message(chat_id=update.effective_chat.id, text="File not found.") @@ -3721,6 +5275,15 @@ async def _send_workspace_file(update: Update, context: ContextTypes.DEFAULT_TYP async def download_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /download : locate and send a workspace file. + + Args: + update: Telegram update. + context: PTB callback context (uses context.args). + + Returns: + None. + """ await auto_detect_chat_id(update) if not context.args: await update.message.reply_text("Usage: /download ") @@ -3734,6 +5297,19 @@ async def download_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def _transcribe_voice_file(ogg_path: str) -> Optional[str]: + """Convert an OGG voice note to WAV and transcribe it. + + Uses pydub for format conversion and speech_recognition with + Google's recognizer (en-US) for STT. The recognizer runs in a + worker thread to avoid blocking the event loop. Both temp files + are deleted afterwards. + + Args: + ogg_path: Path to the downloaded OGG file. + + Returns: + Transcribed text, or None on any conversion/recognition failure. + """ wav_path = ogg_path.replace(".ogg", ".wav") try: from pydub import AudioSegment @@ -3743,6 +5319,11 @@ async def _transcribe_voice_file(ogg_path: str) -> Optional[str]: return None def _recognize(): + """Synchronous speech-to-text worker (run via asyncio.to_thread). + + Returns: + Recognized English text from the WAV file. + """ import speech_recognition as sr r = sr.Recognizer() with sr.AudioFile(wav_path) as source: @@ -3754,6 +5335,7 @@ async def _transcribe_voice_file(ogg_path: str) -> Optional[str]: except Exception: text = None finally: + # Always clean up both temp audio files, success or failure. for path in (ogg_path, wav_path): try: os.remove(path) @@ -3764,6 +5346,18 @@ async def _transcribe_voice_file(ogg_path: str) -> Optional[str]: async def voice_message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle voice messages: download, transcribe, and continue as text. + + After a successful transcription the text is fed to + process_text_message so voice input shares the full chat pipeline. + + Args: + update: Telegram update containing the voice note. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) voice = getattr(update.message, "voice", None) @@ -3775,6 +5369,7 @@ async def voice_message_handler(update: Update, context: ContextTypes.DEFAULT_TY print("[VOICE] received voice message") await update.message.reply_text("🎤 I received your audio, processing...") + # Download OGG to /tmp; transcription cleans it up afterwards. try: file = await context.bot.get_file(voice.file_id) ogg_path = f"/tmp/voice_{voice.file_id}.ogg" @@ -3789,6 +5384,7 @@ async def voice_message_handler(update: Update, context: ContextTypes.DEFAULT_TY print("[VOICE] transcription result:", repr(text)) if text: + # Feed transcribed text through the same pipeline as typed messages. await update.message.reply_text(f"🎤 Transcribed: \"{text}\"") await process_text_message(update, context, text) else: @@ -3796,6 +5392,20 @@ async def voice_message_handler(update: Update, context: ContextTypes.DEFAULT_TY async def handle_file_upload(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle document uploads: validate, store, extract, and summarize. + + Rejects unsupported extensions, downloads to workspace/ (images + keep their original name with collision suffixes; other files get + a timestamped name), registers metadata, runs vision analysis for + images, and replies with an extraction preview. + + Args: + update: Telegram update containing the document. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) document = update.message.document @@ -3805,6 +5415,7 @@ async def handle_file_upload(update: Update, context: ContextTypes.DEFAULT_TYPE) original_name = document.file_name or "file" ext = os.path.splitext(original_name)[1].lower() + # Allowlist check before any download happens (see ALLOWED_EXTENSIONS). if ext not in ALLOWED_EXTENSIONS: await update.message.reply_text( "Unsupported format: " + ext + ". Accepted: .pdf, .txt, .docx, .png, .jpg" @@ -3814,6 +5425,8 @@ async def handle_file_upload(update: Update, context: ContextTypes.DEFAULT_TYPE) ensure_uploads_dir() file_id = document.file_id + # Naming strategy: images keep the original name (with _N collision + # suffixes); other types get a timestamped name to avoid overwrites. if ext in (".png", ".jpg", ".jpeg"): filename = original_name dest = os.path.join(UPLOADS_DIR, filename) @@ -3838,6 +5451,8 @@ async def handle_file_upload(update: Update, context: ContextTypes.DEFAULT_TYPE) await update.message.reply_text("Error downloading the file. Try again.") return + # Register first so the file is queryable even if text extraction fails; + # image analysis below may then upgrade summary/full_text. had_text = register_upload(filename, original_name, filepath, update.effective_chat.id) meta = load_uploads_metadata() @@ -3845,6 +5460,7 @@ async def handle_file_upload(update: Update, context: ContextTypes.DEFAULT_TYPE) summary = info.get("summary", "") if ext in (".png", ".jpg", ".jpeg"): + # Vision analysis replaces the generic "[Imagem]" placeholder summary. analysis = analyze_image(filepath) if analysis: summary = analysis @@ -3870,6 +5486,15 @@ async def handle_file_upload(update: Update, context: ContextTypes.DEFAULT_TYPE) # ===================== TELEGRAM HANDLERS ===================== async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /start: begin onboarding or send a context-aware greeting. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) profile = load_user_profile() if not is_profile_complete(profile): @@ -3883,6 +5508,15 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): ) async def manual_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /manual: send the full user manual text. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) manual = """📚 PsAgentAI User Manual @@ -4018,6 +5652,15 @@ All rights reserved.""" await update.message.reply_text(manual) async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /help: send the concise command list. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) help_text = """Available commands: @@ -4068,6 +5711,15 @@ Tip: just send a normal message to chat with me! """ await update.message.reply_text(help_text) async def profile_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /profile: show a summary of user and agent profiles. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) profile = load_user_profile() agent = load_agent_profile() @@ -4094,6 +5746,15 @@ Use /reset to start from scratch.""" await update.message.reply_text(summary) async def agent_profile_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /agentprofile: show the full agent profile. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) agent = load_agent_profile() text = f"""🤖 Agent Profile: @@ -4108,6 +5769,17 @@ Use /editagentprofile or /configagentprofile to change.""" await update.message.reply_text(text) async def reset_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /reset: delete only the user and agent profiles. + + Contexts and chat id are preserved. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) if os.path.exists(USER_PROFILE_FILE): os.remove(USER_PROFILE_FILE) @@ -4116,11 +5788,32 @@ async def reset_command(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text("Profile reset! Let's get to know each other again? Use /start") async def clear_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /clear: delete all persisted data. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) reset_all_data() await update.message.reply_text("✅ All data has been deleted (profile, preferences, contexts, chat_id).") async def removetopic_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /removetopic : purge content related to a topic. + + Flow: AI scans profiles and archived contexts for related items, + then _apply_removal deletes them and reports what changed. + + Args: + update: Telegram update. + context: PTB callback context (uses context.args). + + Returns: + None. + """ await auto_detect_chat_id(update) args = context.args if not args: @@ -4164,6 +5857,19 @@ async def removetopic_command(update: Update, context: ContextTypes.DEFAULT_TYPE def _ai_analyze_for_removal(assunto: str) -> Dict[str, Any]: + """Ask the AI which stored items relate to a topic. + + Serializes both profiles and the last 30 messages of each context + file, then requests a strict JSON verdict {found, removed_items}. + Core/config files are explicitly protected in the prompt. + + Args: + assunto: Topic to search for (lowercase). + + Returns: + Parsed analysis dict; defaults to {found: False, removed_items: []} + on any failure. + """ model = DEFAULT_MODEL user_profile = load_user_profile() @@ -4183,6 +5889,7 @@ def _ai_analyze_for_removal(assunto: str) -> Dict[str, Any]: user_profile_str = json.dumps(user_profile, ensure_ascii=False, indent=2) agent_profile_str = json.dumps(agent_profile, ensure_ascii=False, indent=2) + # Only the last 30 messages per context file keep the prompt within limits. contexts_summary = {} for fname, data in contexts_data.items(): messages = data if isinstance(data, list) else data.get("messages", []) @@ -4217,11 +5924,12 @@ def _ai_analyze_for_removal(assunto: str) -> Dict[str, Any]: result, _, _ = AIProvider.chat_with_fallback( model, messages, FALLBACK_MODELS, temperature=0.1, max_tokens=2000 ) - + # Low temperature (0.1): removal analysis must be precise, not creative. if not result: return {"found": False, "removed_items": []} result = result.strip() + # Strip markdown fences the model sometimes adds around the JSON payload. if result.startswith("```json"): result = result[7:] if result.startswith("```"): @@ -4238,6 +5946,20 @@ def _ai_analyze_for_removal(assunto: str) -> Dict[str, Any]: async def _apply_removal(removal_plan: Dict[str, Any], assunto: str): + """Apply a topic-removal plan across profiles and context files. + + Filters topic/interest/trait/activity lists by substring match, + uses the AI to surgically clean free-text notes, drops matching + context messages, and records a human-readable removal list back + into the plan dict. + + Args: + removal_plan: Plan from _ai_analyze_for_removal, mutated in place. + assunto: Topic to remove (compared case-insensitively). + + Returns: + None. + """ model = DEFAULT_MODEL assunto_lower = assunto.lower() @@ -4245,7 +5967,7 @@ async def _apply_removal(removal_plan: Dict[str, Any], assunto: str): agent_profile = load_agent_profile() contexts_dir = CONTEXTS_DIR - # ---- 1. Remove do user_profile ---- + # ---- 1. Remove from user_profile ---- removed_user = [] # topics_of_interest @@ -4322,7 +6044,7 @@ async def _apply_removal(removal_plan: Dict[str, Any], assunto: str): save_user_profile(user_profile) - # ---- 2. Remove do agent_profile ---- + # ---- 2. Remove from agent_profile ---- removed_agent = [] # traits @@ -4354,7 +6076,7 @@ async def _apply_removal(removal_plan: Dict[str, Any], assunto: str): save_agent_profile(agent_profile) - # ---- 3. Remove das mensagens de contextos arquivadas ---- + # ---- 3. Remove from archived context messages ---- removed_context = [] if os.path.isdir(contexts_dir): for fname in sorted(os.listdir(contexts_dir)): @@ -4389,13 +6111,25 @@ async def _apply_removal(removal_plan: Dict[str, Any], assunto: str): json.dump(data, f, ensure_ascii=False, indent=2) all_removed = removed_user + removed_agent + removed_context + # Write the concrete removal report back into the plan for the reply text. removal_plan["itens_removidos"] = all_removed removal_plan["encontrou"] = len(all_removed) > 0 def _ai_clean_text_field(text: str, assunto: str) -> str: - """Uses AI to remove from the text only the portions related to the topic, - preserving everything else.""" + """Remove only the portions of a text field related to a topic. + + Uses the AI to surgically delete topic-related spans while preserving + everything else in the text. + + Args: + text: Original free-text field content. + assunto: Topic to remove (case-insensitive match). + + Returns: + Cleaned text with topic-related portions removed, or the original + text unchanged when the AI call fails. + """ model = DEFAULT_MODEL prompt = ( f"You are a text editor. Given the text below, remove ONLY the portions " @@ -4419,15 +6153,44 @@ def _ai_clean_text_field(text: str, assunto: str) -> str: def _is_related(item: Dict, assunto_lower: str) -> bool: - """Checks if a daily routine activity item is related to the topic.""" + """Check whether a daily routine activity relates to a topic. + + Args: + item: Activity dict whose values are stringified and searched. + assunto_lower: Lowercased topic string. + + Returns: + True if the topic appears in any of the activity's values. + """ combined = " ".join(str(v) for v in item.values()) return assunto_lower in combined.lower() async def newconfig_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /newconfig: delegate to start_newconfig. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await start_newconfig(update, context) async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /cancel: abort every interactive wizard in one call. + + Clears all is_* state flags and step counters; stored data is + never touched. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ global is_onboarding, is_newconfig, is_configagentprofile, is_edit_user_profile, is_edit_agent_profile, is_schedule, is_dailyroutine, is_activity_crud, activity_crud_step, activity_crud_data, is_configai is_onboarding = False is_newconfig = False @@ -4446,10 +6209,28 @@ async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE): ) async def onboarding_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /onboarding: restart the onboarding questionnaire. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) await start_onboarding(update, context) async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Entry point for plain text messages (non-command). + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ print("[MSG] handler called") await auto_detect_chat_id(update) user_message = (update.message.text or "").strip() @@ -4458,13 +6239,32 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): async def process_text_message(update: Update, context: ContextTypes.DEFAULT_TYPE, user_message: str) -> None: + """Central text routing state machine for the whole agent. + + Priority order: + 1. Active wizards (configai, activity CRUD, onboarding, newconfig, + agent config, profile editors, schedule, daily routine). + 2. Profile-update trigger phrases. + 3. File edit / file create intents. + 4. Normal chat: append context, inject matching upload content and + optional web search results, call the AI, persist the reply, + and schedule background enrichment/improvement tasks. + + Args: + update: Telegram update. + context: PTB callback context. + user_message: Extracted (stripped) user text. + + Returns: + None. + """ print("[PROCESS] text=", repr(user_message)) if not user_message: print("[PROCESS] empty message, returning") return global is_onboarding, is_newconfig, is_configagentprofile, is_edit_user_profile, is_edit_agent_profile, is_activity_crud, is_schedule, is_dailyroutine, is_configai if is_configai: - handled = await handle_configia_message(update, context) + handled = await handle_configai_message(update, context) print("[PROCESS] configia handled=", handled) if handled: return @@ -4541,6 +6341,7 @@ async def process_text_message(update: Update, context: ContextTypes.DEFAULT_TYP if upload_match: full = upload_match.get("full_text", "") if full: + # Cap injected file text so the prompt stays within token limits. max_chars = UPLOAD_CONTEXT_MAX_CHARS if UPLOAD_CONTEXT_MAX_CHARS > 0 else len(full) file_context = "\n\n[FILE CONTENT: " + upload_match.get("original_name", "") + "]\n" + full[:max_chars] + "\n[END OF FILE]" if len(full) > max_chars and UPLOAD_CONTEXT_MAX_CHARS > 0: @@ -4553,6 +6354,7 @@ async def process_text_message(update: Update, context: ContextTypes.DEFAULT_TYP if WEB_SEARCH_ENABLED and _looks_like_search_request(user_message): search_query = user_message + # Strip the leading intent verb ("search for X" -> "X") before querying. for prefix in ["search", "search for", "look up", "look for", "find", "on the internet", "tell me", "inform me", "what", "who", "how", "where", "when", "why"]: if search_query.lower().startswith(prefix): search_query = search_query[len(prefix):].strip(" :,-") @@ -4569,12 +6371,22 @@ async def process_text_message(update: Update, context: ContextTypes.DEFAULT_TYP if response: add_to_context("assistant", response) await update.message.reply_text(response, parse_mode=ParseMode.HTML) + # Fire-and-forget background tasks: profile enrichment + self-improvement. asyncio.create_task(_safe_enrich(user_message, response)) asyncio.create_task(maybe_auto_improve()) else: await update.message.reply_text("Sorry, I'm having a little problem right now. Can you try again? 😔") async def debug_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /debug: print connectivity and configuration diagnostics. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) profile = load_user_profile() today_context = load_context() @@ -4593,12 +6405,30 @@ async def debug_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def reload_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /reload: reload .env settings without restarting. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) _reload_env() await update.message.reply_text("Settings reloaded successfully!") async def about_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /about: show version, license, and current configuration. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) provider = (AI_PROVIDER or AIProvider.get_provider(DEFAULT_MODEL)).upper() vision_provider = (VISION_PROVIDER or AIProvider.get_provider(VISION_MODEL)).upper() @@ -4664,6 +6494,22 @@ CONFIGIA_MODELS = { def _update_env_var(key: str, value: str, env_path: str = ".env") -> None: + """Insert or replace a KEY=value entry in a .env file. + + Uses a multiline-anchored regex to replace an existing assignment; + otherwise appends a new line (ensuring a trailing newline first). + + Args: + key: Environment variable name. + value: New value (written raw, no quoting). + env_path: Path to the env file. + + Raises: + OSError: If the file cannot be read or written. + + Returns: + None. + """ path = Path(env_path) if path.exists(): text = path.read_text(encoding="utf-8") @@ -4680,6 +6526,15 @@ def _update_env_var(key: str, value: str, env_path: str = ".env") -> None: async def viewai_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /viewai: show the current AI provider and model. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) provider = (AI_PROVIDER or AIProvider.get_provider(DEFAULT_MODEL)).upper() msg = ( @@ -4691,6 +6546,18 @@ async def viewai_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def configai_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /configai: start interactive AI provider/model selection. + + Sends an inline keyboard of providers; selection continues in + configai_callback. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) global IA_CONFIG_STEPS, IA_CONFIG_ANSWERS, is_configai IA_CONFIG_STEPS = ["provider", "model"] @@ -4707,6 +6574,21 @@ async def configai_command(update: Update, context: ContextTypes.DEFAULT_TYPE): async def configai_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Inline-button state machine for AI provider/model selection. + + configia_provider_*: stores the provider, writes AI_PROVIDER to + .env, and shows that provider's model buttons. + configia_model_*: writes DEFAULT_MODEL to .env (or switches to a + free-text step for "custom..."), updates the in-process globals, + reloads .env, and finishes the wizard. + + Args: + update: Telegram update carrying the callback query. + context: PTB callback context. + + Returns: + None. + """ query = update.callback_query if not query: return @@ -4755,6 +6637,18 @@ async def configai_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_configai_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle free-text input while the AI config wizard awaits a model id. + + Only active when IA_CONFIG_STEPS is ["custom_model"]; validates + the model string, persists it, and resets the wizard. + + Args: + update: Telegram update carrying the text. + context: PTB callback context. + + Returns: + True if the message was consumed; False otherwise. + """ global IA_CONFIG_STEPS, IA_CONFIG_ANSWERS, is_configai if not IA_CONFIG_STEPS: return False @@ -4812,6 +6706,15 @@ VISION_CONFIG_MODELS = { async def viewvision_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /viewvision: show the current vision provider and model. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) provider = (VISION_PROVIDER or AIProvider.get_provider(VISION_MODEL)).upper() msg = ( @@ -4823,6 +6726,15 @@ async def viewvision_command(update: Update, context: ContextTypes.DEFAULT_TYPE) async def configvision_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /configvision: start interactive vision model selection. + + Args: + update: Telegram update. + context: PTB callback context. + + Returns: + None. + """ await auto_detect_chat_id(update) global VISION_CONFIG_STEPS, VISION_CONFIG_ANSWERS VISION_CONFIG_STEPS = ["provider", "model"] @@ -4838,6 +6750,18 @@ async def configvision_command(update: Update, context: ContextTypes.DEFAULT_TYP async def configvision_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Inline-button state machine for vision provider/model selection. + + Mirrors configai_callback but writes VISION_PROVIDER and + VISION_MODEL to .env and updates the corresponding globals. + + Args: + update: Telegram update carrying the callback query. + context: PTB callback context. + + Returns: + None. + """ query = update.callback_query if not query: return @@ -4884,6 +6808,15 @@ async def configvision_callback(update: Update, context: ContextTypes.DEFAULT_TY async def handle_configvision_message(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle free-text input when the vision wizard awaits a model id. + + Args: + update: Telegram update carrying the text. + context: PTB callback context. + + Returns: + True if the message was consumed; False otherwise. + """ global VISION_CONFIG_STEPS, VISION_CONFIG_ANSWERS if not VISION_CONFIG_STEPS: return False @@ -4917,6 +6850,17 @@ async def handle_configvision_message(update: Update, context: ContextTypes.DEFA # ===================== MAIN ===================== def _build_fake_update(chat_id: int, text: str) -> Update: + """Construct a minimal private-chat Update for programmatic handlers. + + Used at startup to trigger /start without a real inbound message. + + Args: + chat_id: Chat/user id for the synthetic update. + text: Message text to attach. + + Returns: + Update wrapping a synthetic Message. + """ chat = Chat(id=chat_id, type="private") user = User(id=chat_id, is_bot=False, first_name="User") message = Message( @@ -4929,6 +6873,19 @@ def _build_fake_update(chat_id: int, text: str) -> Update: return Update(update_id=0, message=message) def main(): + """Entry point: validate config, register handlers, and run the bot. + + Creates the PTB Application, registers all command/callback/media + handlers, schedules the proactive loop in post_init, and fires an + automatic /start toward the configured chat (skipped gracefully + when the user has not started the bot yet). + + Raises: + SystemExit: With status 1 when TELEGRAM_BOT_TOKEN is missing. + + Returns: + None. + """ if not TELEGRAM_BOT_TOKEN: logger.error("TELEGRAM_BOT_TOKEN not set in .env file!") sys.exit(1) @@ -4939,6 +6896,8 @@ def main(): ensure_uploads_dir() global app_instance, proactive_task + # Handler registration order matters: commands, then callbacks, then media + # (voice/photo/document), and finally plain text last so commands win. app_instance = Application.builder().token(TELEGRAM_BOT_TOKEN).connect_timeout(30).read_timeout(30).write_timeout(30).build() app_instance.add_handler(CommandHandler("start", start_command)) app_instance.add_handler(CommandHandler("help", help_command)) @@ -4978,12 +6937,25 @@ def main(): app_instance.add_handler(CommandHandler("configvision", configvision_command)) app_instance.add_handler(CallbackQueryHandler(configai_callback, pattern=r"^configai_(provider|model)_")) app_instance.add_handler(CallbackQueryHandler(configvision_callback, pattern=r"^vision_(provider|model)_")) + # Media handlers before generic text so photos/voice/docs are not swallowed. app_instance.add_handler(MessageHandler(filters.VOICE, voice_message_handler)) app_instance.add_handler(MessageHandler(filters.PHOTO, photo_message_handler)) app_instance.add_handler(MessageHandler(filters.Document.ALL, handle_file_upload)) app_instance.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, message_handler)) async def post_init(app: Application): + """PTB post-init hook: start the proactive loop and auto /start. + + Spawns proactive_loop as a background task, then tries to send + /start to the effective chat via a synthetic update. "Chat not + found" is tolerated (user has not opened the bot yet). + + Args: + app: The built Application instance. + + Returns: + None. + """ global proactive_task proactive_task = asyncio.create_task(proactive_loop()) @@ -4996,6 +6968,8 @@ def main(): fake_update = _build_fake_update(chat_id, "/start") class SimpleContext: + """Minimal context stand-in exposing .bot for fake updates.""" + bot = app.bot context = SimpleContext()