Merge pull request 'feature/web-search-and-cron-improvements' (#2) from feature/web-search-and-cron-improvements into feature/cleanup-providers-llama-only

Reviewed-on: #2
This commit is contained in:
tanyar09 2026-03-06 13:20:19 -05:00
commit 98d7c0b788
26 changed files with 2983 additions and 68 deletions

99
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,99 @@
name: CI
on:
push:
branches: [ main, master, develop ]
pull_request:
branches: [ main, master, develop ]
workflow_dispatch:
jobs:
lint:
name: Lint with ruff
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install ruff
run: |
python -m pip install --upgrade pip
pip install ruff>=0.1.0
- name: Run ruff check
run: |
ruff check nanobot/
- name: Run ruff format check
run: |
ruff format --check nanobot/
test:
name: Test Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12']
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Run tests
run: |
pytest tests/ -v --tb=short
- name: Check package can be imported
run: |
python -c "import nanobot; print(f'nanobot version check passed')"
build:
name: Build package
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build hatchling
- name: Build package
run: |
python -m build
- name: Check build artifacts
run: |
ls -lh dist/
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist-packages
path: dist/
retention-days: 7

229
CALENDAR_SETUP.md Normal file
View File

@ -0,0 +1,229 @@
# Google Calendar Integration Setup
This guide explains how to set up Google Calendar integration for nanobot.
## Features
- **List upcoming events** from your Google Calendar
- **Create calendar events** programmatically
- **Check availability** for time slots
- **Automatic scheduling from emails** - when an email mentions a meeting, nanobot can automatically schedule it
## Prerequisites
1. Google account with Calendar access
2. Google Cloud Project with Calendar API enabled
3. OAuth2 credentials (Desktop app type)
## Setup Steps
### 1. Enable Google Calendar API
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Enable the **Google Calendar API**:
- Navigate to "APIs & Services" > "Library"
- Search for "Google Calendar API"
- Click "Enable"
### 2. Configure OAuth Consent Screen
**IMPORTANT:** This step is required before creating credentials.
1. Go to "APIs & Services" > "OAuth consent screen"
2. Choose **"External"** user type (unless you have a Google Workspace account)
3. Fill in required fields:
- **App name**: "nanobot" (or any name)
- **User support email**: Your email address
- **Developer contact information**: Your email address
4. Click "Save and Continue"
5. **Add Scopes:**
- Click "Add or Remove Scopes"
- Search for and add: `https://www.googleapis.com/auth/calendar`
- Click "Update" then "Save and Continue"
6. **Add Test Users (CRITICAL):**
- Click "Add Users"
- Add your email address (`adayear2025@gmail.com`)
- Click "Add" then "Save and Continue"
7. Review and go back to dashboard
### 3. Create OAuth2 Credentials
1. Go to "APIs & Services" > "Credentials"
2. Click "Create Credentials" > "OAuth client ID"
3. Select:
- **Application type**: **Desktop app**
- **Name**: "nanobot" (or any name)
4. Click "Create"
5. **Download the credentials JSON file** - click "Download JSON"
6. Save it as `credentials.json` and copy to your server at `~/.nanobot/credentials.json`
### 3. Configure nanobot
Set environment variables or add to your `.env` file:
```bash
# Enable calendar functionality
export NANOBOT_TOOLS__CALENDAR__ENABLED=true
# Path to OAuth2 credentials JSON file
export NANOBOT_TOOLS__CALENDAR__CREDENTIALS_FILE=/path/to/credentials.json
# Optional: Custom token storage location (default: ~/.nanobot/calendar_token.json)
export NANOBOT_TOOLS__CALENDAR__TOKEN_FILE=~/.nanobot/calendar_token.json
# Optional: Calendar ID (default: "primary")
export NANOBOT_TOOLS__CALENDAR__CALENDAR_ID=primary
# Optional: Auto-schedule meetings from emails (default: true)
export NANOBOT_TOOLS__CALENDAR__AUTO_SCHEDULE_FROM_EMAIL=true
```
### 4. First-Time Authorization
**You don't need to manually get or copy any token** - the OAuth flow handles everything automatically.
On first run, when nanobot tries to use the calendar tool, it will:
1. **Automatically open a browser window** for Google OAuth authorization
2. **You sign in** to your Google account in the browser
3. **Grant calendar access** by clicking "Allow"
4. **Automatically save the token** to `~/.nanobot/calendar_token.json` for future use
**Important:**
- The token is **automatically generated and saved** - you don't need to copy it from anywhere
- This happens **automatically** the first time you use a calendar command
- After the first authorization, you won't need to do this again (the token is reused)
- The token file is created automatically at `~/.nanobot/calendar_token.json`
**Example first run:**
```bash
# First time using calendar - triggers OAuth flow
python3 -m nanobot.cli.commands agent -m "What's on my calendar?"
# Browser opens automatically → Sign in → Grant access → Token saved
# Future runs use the saved token automatically
```
**Note for remote/headless servers:**
If you're running nanobot on a remote server without a display, you have two options:
1. **Run OAuth on your local machine first:**
- Run nanobot locally once to complete OAuth
- Copy the generated `~/.nanobot/calendar_token.json` to your remote server
- The token will work on the remote server
2. **Use SSH port forwarding:**
- The OAuth flow uses a local web server
- You may need to set up port forwarding or use a different OAuth flow method
## Usage Examples
### List Upcoming Events
```
User: "What's on my calendar?"
Agent: [Uses calendar tool to list events]
```
### Create an Event
```
User: "Schedule a meeting tomorrow at 2pm"
Agent: [Creates calendar event]
```
### Automatic Email Scheduling
When an email mentions a meeting:
```
Email: "Hi, let's have a meeting tomorrow at 2pm in Conference Room A"
Agent: [Automatically extracts meeting info and creates calendar event]
Agent: "I've scheduled a meeting for tomorrow at 2pm in Conference Room A"
```
## Calendar Tool API
The calendar tool supports three actions:
### 1. List Events
```python
calendar(
action="list_events",
max_results=10, # Optional, default: 10
time_min="2024-01-15T00:00:00Z" # Optional, defaults to now
)
```
### 2. Create Event
```python
calendar(
action="create_event",
title="Team Meeting",
start_time="tomorrow 2pm", # or "2024-01-15T14:00:00"
end_time="tomorrow 3pm", # Optional, defaults to 1 hour after start
description="Discuss project progress", # Optional
location="Conference Room A", # Optional
attendees=["colleague@example.com"] # Optional list
)
```
**Time formats:**
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
- ISO format: `"2024-01-15T14:00:00"`
### 3. Check Availability
```python
calendar(
action="check_availability",
start_time="2024-01-15T14:00:00",
end_time="2024-01-15T15:00:00"
)
```
## Troubleshooting
### "Error: Could not authenticate with Google Calendar"
- Ensure `credentials_file` path is correct
- Check that the credentials JSON file is valid
- Run nanobot once to complete OAuth flow
### "Error accessing Google Calendar API"
- Verify Calendar API is enabled in Google Cloud Console
- Check that OAuth consent screen is configured
- Ensure your email is added as a test user (if app is in testing mode)
### Token Expired
The tool automatically refreshes expired tokens. If refresh fails:
1. Delete `~/.nanobot/calendar_token.json`
2. Run nanobot again to re-authorize
## Security Notes
- Keep your `credentials.json` file secure (don't commit to git)
- The `calendar_token.json` file contains sensitive access tokens
- Use file permissions: `chmod 600 ~/.nanobot/calendar_token.json`
- Consider using environment variables or a secrets manager for production
## Integration with Email Channel
When `auto_schedule_from_email` is enabled, nanobot will:
1. Monitor incoming emails
2. Detect meeting-related keywords (meeting, appointment, call, etc.)
3. Extract meeting details (time, location, attendees)
4. Automatically create calendar events
5. Confirm with the user
This works best when:
- Emails contain clear time references ("tomorrow at 2pm", "next Monday")
- Meeting details are in the email body or subject
- The agent has access to the email channel

View File

@ -101,6 +101,15 @@ Your workspace is at: {workspace_path}
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
## Gitea API (This Repository)
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
- Repository: `ilia/nanobot`
- Token: `$NANOBOT_GITLE_TOKEN`
- **NEVER use placeholder URLs like `gitea.example.com`**
- **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP, using HTTPS causes SSL errors
- Always detect from `git remote get-url origin` or use `http://10.0.30.169:3000/api/v1`
- Example: `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"`
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
Only use the 'message' tool when the user explicitly asks you to send a message to someone else or to a different channel.
For normal conversation, acknowledgments (Thanks, OK, etc.), or when the user is talking to YOU, just respond with text - do NOT call the message tool.
@ -109,7 +118,9 @@ For simple acknowledgments like "Thanks", "OK", "You're welcome", "Got it", etc.
Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
When remembering something important, write to {workspace_path}/memory/MEMORY.md
To recall past events, grep {workspace_path}/memory/HISTORY.md"""
To recall past events, grep {workspace_path}/memory/HISTORY.md
IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS use the read_emails tool. NEVER use exec() with mail/tail/awk commands or read_file() on /var/mail - those will not work. The read_emails tool is the only way to access emails."""
def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace."""

View File

@ -122,6 +122,36 @@ class AgentLoop:
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
# Email tool (if email channel is configured)
try:
from nanobot.agent.tools.email import EmailTool
from nanobot.config.loader import load_config
config = load_config()
if config.channels.email.enabled:
email_tool = EmailTool(email_config=config.channels.email)
self.tools.register(email_tool)
logger.info(f"Email tool '{email_tool.name}' registered successfully")
else:
logger.debug("Email tool not registered: email channel not enabled")
except Exception as e:
logger.warning(f"Email tool not available: {e}")
# Email tool not available or not configured - silently skip
# Calendar tool (if calendar is configured)
try:
from nanobot.agent.tools.calendar import CalendarTool
from nanobot.config.loader import load_config
config = load_config()
if config.tools.calendar.enabled:
calendar_tool = CalendarTool(calendar_config=config.tools.calendar)
self.tools.register(calendar_tool)
logger.info(f"Calendar tool '{calendar_tool.name}' registered successfully")
else:
logger.debug("Calendar tool not registered: calendar not enabled")
except Exception as e:
logger.warning(f"Calendar tool not available: {e}")
# Calendar tool not available or not configured - silently skip
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
@ -362,6 +392,9 @@ class AgentLoop:
)
async def _bus_progress(content: str) -> None:
# Skip progress updates for email channel to avoid sending intermediate tool call hints as emails
if msg.channel == "email":
return
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
metadata=msg.metadata or {},

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,19 @@ class CronTool(Tool):
@property
def description(self) -> str:
return "Schedule reminders and recurring tasks. REQUIRED: Always include 'action' parameter ('add', 'list', or 'remove'). For reminders, use action='add' with message and timing (in_seconds, at, every_seconds, or cron_expr)."
return """Schedule reminders and recurring tasks. REQUIRED: Always include 'action' parameter ('add', 'list', or 'remove').
For 'add' action:
- MUST include 'message' parameter - extract the reminder/task text from user's request
- Examples: 'remind me to call mama' message='call mama'
For timing patterns:
- 'remind me in X seconds' in_seconds=X (DO NOT use 'at')
- 'every X seconds' (forever) every_seconds=X
- 'every X seconds for Y seconds' EVERY_SECONDS=X AND IN_SECONDS=Y (creates multiple reminders, DO NOT use 'at')
- 'at specific time' at='ISO datetime' (only when user specifies exact time)
CRITICAL: For 'every X seconds for Y seconds', you MUST use both every_seconds AND in_seconds together. DO NOT use 'at' parameter for this pattern."""
@property
def parameters(self) -> dict[str, Any]:
@ -40,11 +52,11 @@ class CronTool(Tool):
},
"message": {
"type": "string",
"description": "Reminder message (for add)"
"description": "REQUIRED for 'add' action: The reminder message to send. Extract this from the user's request. Examples: 'Remind me to call mama' → message='call mama', 'Remind me every hour to drink water' → message='drink water', 'Schedule a task to check email' → message='check email'. Always extract the actual task/reminder text, not the full user request."
},
"every_seconds": {
"type": "integer",
"description": "Interval in seconds (for recurring tasks)"
"description": "Interval in seconds (for recurring tasks). For 'every X seconds for Y seconds', use BOTH every_seconds AND in_seconds together to create multiple reminders."
},
"cron_expr": {
"type": "string",
@ -56,11 +68,11 @@ class CronTool(Tool):
},
"at": {
"type": "string",
"description": "ISO datetime string for one-time execution. Format: YYYY-MM-DDTHH:MM:SS (e.g. '2026-03-03T12:19:30'). You MUST calculate this from the current time shown in your system prompt plus the requested seconds/minutes, then format as ISO string."
"description": "ISO datetime string for one-time execution at a SPECIFIC time. Format: YYYY-MM-DDTHH:MM:SS (e.g. '2026-03-03T12:19:30'). ONLY use this when user specifies an exact time like 'at 3pm' or 'at 2026-03-03 14:30'. DO NOT use 'at' for 'every X seconds for Y seconds' - use every_seconds + in_seconds instead."
},
"in_seconds": {
"type": "integer",
"description": "Alternative to 'at': Schedule reminder in N seconds from now. Use this instead of calculating 'at' manually. Example: in_seconds=25 for 'remind me in 25 seconds'."
"description": "Schedule reminder in N seconds from now, OR duration for recurring reminders. Use this instead of calculating 'at' manually. Examples: 'remind me in 25 seconds' → in_seconds=25. For 'every 10 seconds for the next minute' → every_seconds=10 AND in_seconds=60 (creates 6 reminders)."
},
"reminder": {
"type": "boolean",
@ -111,7 +123,11 @@ class CronTool(Tool):
reminder: bool = False,
) -> str:
if not message:
return "Error: message is required for add"
return "Error: message is required for 'add' action. You must extract the reminder/task text from the user's request. Example: if user says 'remind me to call mama', use message='call mama'. If user says 'remind me every hour to drink water', use message='drink water'."
# Detect common mistake: using 'at' with 'every_seconds' when 'in_seconds' should be used
if every_seconds is not None and at is not None and in_seconds is None:
return f"Error: You used 'at' with 'every_seconds', but for 'every X seconds for Y seconds' pattern, you MUST use 'in_seconds' instead of 'at'. Example: 'every 10 seconds for the next minute' → every_seconds=10 AND in_seconds=60 (NOT 'at'). The 'in_seconds' parameter specifies the duration, and the tool will create multiple reminders automatically."
# Use defaults for CLI mode if context not set
channel = self._channel or "cli"
@ -131,6 +147,34 @@ class CronTool(Tool):
# Build schedule - prioritize 'in_seconds' for relative time, then 'at' for absolute time
delete_after = False
# Special case: recurring job with duration limit (every_seconds + in_seconds)
if every_seconds is not None and in_seconds is not None:
# Create multiple one-time jobs for "every X seconds for Y seconds"
from datetime import datetime, timedelta
num_jobs = max(1, in_seconds // every_seconds)
results = []
for i in range(num_jobs):
job_time = datetime.now() + timedelta(seconds=i * every_seconds)
job_at = job_time.isoformat()
try:
dt = datetime.fromisoformat(job_at)
at_ms = int(dt.timestamp() * 1000)
schedule = CronSchedule(kind="at", at_ms=at_ms)
job = self._cron.add_job(
name=f"{message[:25]} ({i+1}/{num_jobs})" if num_jobs > 1 else message[:30],
schedule=schedule,
message=message,
deliver=True,
channel=channel,
to=chat_id,
delete_after_run=True,
reminder=reminder,
)
results.append(f"Created job '{job.name}' (id: {job.id})")
except Exception as e:
results.append(f"Error creating job {i+1}: {str(e)}")
return f"Created {len([r for r in results if 'Created' in r])} reminder(s):\n" + "\n".join(results)
# Handle relative time (in_seconds) - compute datetime automatically
if in_seconds is not None:
from datetime import datetime, timedelta

View File

@ -0,0 +1,346 @@
"""Email tool: read emails from IMAP mailbox."""
import asyncio
import imaplib
import ssl
from datetime import date
from email import policy
from email.header import decode_header, make_header
from email.parser import BytesParser
from email.utils import parseaddr
from typing import Any
from nanobot.agent.tools.base import Tool
class EmailTool(Tool):
"""Read emails from configured IMAP mailbox."""
name = "read_emails"
description = "USE THIS TOOL FOR ALL EMAIL QUERIES. When user asks about emails, latest email, email sender, inbox, etc., you MUST call read_emails(). DO NOT use exec() with mail/tail/awk commands. DO NOT use read_file() on /var/mail or memory files. DO NOT try alternative methods. This is the ONLY way to read emails - it connects to IMAP and fetches real-time data. For 'latest email' queries, use limit=1. CRITICAL: When user asks for specific fields like 'From and Subject' or 'sender and subject', extract and return ONLY those fields from the tool output. Do NOT summarize or analyze the email body content unless the user specifically asks for it. If user asks 'give me the from and subject', respond with just: 'From: [email] Subject: [subject]'. Parameters: limit (1-50, default 10, use 1 for latest), unread_only (bool, default false), mark_seen (bool, default false). Returns formatted email list with sender, subject, date, and body."
def __init__(self, email_config: Any = None):
"""
Initialize email tool with email configuration.
Args:
email_config: Optional EmailConfig instance. If None, loads from config.
"""
self._email_config = email_config
@property
def config(self) -> Any:
"""Lazy load email config if not provided."""
if self._email_config is None:
from nanobot.config.loader import load_config
config = load_config()
self._email_config = config.channels.email
return self._email_config
def coerce_params(self, params: dict[str, Any]) -> dict[str, Any]:
"""Coerce parameters, handling common name mismatches."""
coerced = super().coerce_params(params)
# Map 'count' to 'limit' if limit not present
if 'count' in coerced and 'limit' not in coerced:
try:
coerced['limit'] = int(coerced.pop('count'))
except (ValueError, TypeError):
pass
# Remove unsupported parameters
supported = {'limit', 'unread_only', 'mark_seen'}
coerced = {k: v for k, v in coerced.items() if k in supported}
return coerced
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Number of emails to return. REQUIRED for 'latest email' queries - always use limit=1. For multiple emails use limit=5, limit=10, etc. (default: 10, max: 50)",
"minimum": 1,
"maximum": 50,
},
"unread_only": {
"type": "boolean",
"description": "If true, only return unread emails. If false, returns all emails including read ones (default: false)",
},
"mark_seen": {
"type": "boolean",
"description": "If true, mark emails as read after fetching. If false, leave read/unread status unchanged (default: false)",
},
},
"required": [],
}
async def execute(self, limit: int = 10, unread_only: bool = False, mark_seen: bool = False, **kwargs: Any) -> str:
"""
Read emails from IMAP mailbox.
Args:
limit: Maximum number of emails to return (use limit=1 for latest email)
unread_only: If true, only fetch unread emails
mark_seen: If true, mark emails as read after fetching
**kwargs: Ignore any extra parameters (like count, sort_by, direction)
Returns:
Formatted string with email information
"""
# Handle common parameter name mismatches (agent sometimes uses 'count' instead of 'limit')
# Also handle if count is passed as a positional argument via kwargs
if 'count' in kwargs:
try:
limit = int(kwargs['count'])
except (ValueError, TypeError):
pass
# Also check if limit was passed in kwargs (in case it wasn't a named parameter)
if 'limit' in kwargs:
try:
limit = int(kwargs['limit'])
except (ValueError, TypeError):
pass
# Ignore unsupported parameters like sort_by, direction, reverse, etc.
try:
config = self.config
except Exception as e:
return f"Error loading email configuration: {str(e)}"
if not config:
return "Error: Email configuration not found"
if not hasattr(config, 'enabled') or not config.enabled:
return "Error: Email channel is not enabled in configuration. Set NANOBOT_CHANNELS__EMAIL__ENABLED=true"
if not hasattr(config, 'consent_granted') or not config.consent_granted:
return "Error: Email access consent not granted. Set NANOBOT_CHANNELS__EMAIL__CONSENT_GRANTED=true"
if not hasattr(config, 'imap_host') or not config.imap_host:
return "Error: IMAP host not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_HOST"
if not hasattr(config, 'imap_username') or not config.imap_username:
return "Error: IMAP username not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_USERNAME"
if not hasattr(config, 'imap_password') or not config.imap_password:
return "Error: IMAP password not configured. Set NANOBOT_CHANNELS__EMAIL__IMAP_PASSWORD"
# Limit to reasonable maximum
try:
limit = min(max(1, int(limit)), 50)
except (ValueError, TypeError):
limit = 10
try:
messages = await asyncio.to_thread(
self._fetch_messages,
unread_only=unread_only,
mark_seen=mark_seen,
limit=limit,
)
if not messages:
if unread_only:
return "No unread emails found in your inbox."
else:
return f"No emails found in your inbox. The mailbox appears to be empty or there was an issue retrieving emails."
result_parts = [f"Found {len(messages)} email(s):\n"]
for i, msg in enumerate(messages, 1):
result_parts.append(f"\n--- Email {i} ---")
result_parts.append(f"From: {msg['sender']}")
result_parts.append(f"Subject: {msg['subject']}")
result_parts.append(f"Date: {msg['metadata']['date']}")
# Only include body content if specifically requested, otherwise keep it brief
result_parts.append(f"\nBody: {msg['content'][:500]}..." if len(msg['content']) > 500 else f"\nBody: {msg['content']}")
return "\n".join(result_parts)
except Exception as e:
import traceback
error_details = traceback.format_exc()
return f"Error reading emails: {str(e)}\n\nDetails: {error_details}"
def _fetch_messages(
self,
unread_only: bool,
mark_seen: bool,
limit: int,
) -> list[dict[str, Any]]:
"""Fetch messages from IMAP mailbox."""
messages: list[dict[str, Any]] = []
mailbox = self.config.imap_mailbox or "INBOX"
# Build search criteria
if unread_only:
search_criteria = ("UNSEEN",)
else:
search_criteria = ("ALL",)
# Connect to IMAP server
try:
if self.config.imap_use_ssl:
client = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port)
else:
client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port)
except Exception as e:
raise Exception(f"Failed to connect to IMAP server {self.config.imap_host}:{self.config.imap_port}: {str(e)}")
try:
client.login(self.config.imap_username, self.config.imap_password.strip())
except imaplib.IMAP4.error as e:
error_msg = str(e)
if "AUTHENTICATE" in error_msg.upper() or "LOGIN" in error_msg.upper():
raise Exception(
f"IMAP authentication failed. Please check:\n"
f"1. Your email username: {self.config.imap_username}\n"
f"2. Your password/app password is correct\n"
f"3. For Gmail: Enable 2-Step Verification and create an App Password at https://myaccount.google.com/apppasswords\n"
f"4. IMAP is enabled in your email account settings\n"
f"Original error: {error_msg}"
)
raise
try:
status, _ = client.select(mailbox)
if status != "OK":
return messages
status, data = client.search(None, *search_criteria)
if status != "OK" or not data:
return messages
ids = data[0].split()
if limit > 0 and len(ids) > limit:
# Get most recent emails (last N)
ids = ids[-limit:]
for imap_id in ids:
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
if status != "OK" or not fetched:
continue
raw_bytes = self._extract_message_bytes(fetched)
if raw_bytes is None:
continue
parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes)
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
if not sender:
# Try to get display name if email not found
from_addr = parsed.get("From", "")
sender = from_addr if from_addr else "unknown"
subject = self._decode_header_value(parsed.get("Subject", ""))
date_value = parsed.get("Date", "")
message_id = parsed.get("Message-ID", "").strip()
body = self._extract_text_body(parsed)
if not body:
body = "(empty email body)"
# Limit body length
max_chars = getattr(self.config, 'max_body_chars', 12000)
body = body[:max_chars]
content = (
f"Email received.\n"
f"From: {sender}\n"
f"Subject: {subject}\n"
f"Date: {date_value}\n\n"
f"{body}"
)
metadata = {
"message_id": message_id,
"subject": subject,
"date": date_value,
"sender_email": sender,
}
messages.append({
"sender": sender,
"subject": subject,
"message_id": message_id,
"content": content,
"metadata": metadata,
})
if mark_seen:
client.store(imap_id, "+FLAGS", "\\Seen")
finally:
try:
client.logout()
except Exception:
pass
return messages
@staticmethod
def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
"""Extract raw message bytes from IMAP fetch response."""
for item in fetched:
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)):
return bytes(item[1])
return None
@staticmethod
def _decode_header_value(value: str) -> str:
"""Decode email header value (handles encoded words)."""
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return value
@staticmethod
def _extract_text_body(msg: Any) -> str:
"""Extract readable text body from email message."""
if msg.is_multipart():
plain_parts: list[str] = []
html_parts: list[str] = []
for part in msg.walk():
if part.get_content_disposition() == "attachment":
continue
content_type = part.get_content_type()
try:
payload = part.get_content()
except Exception:
payload_bytes = part.get_payload(decode=True) or b""
charset = part.get_content_charset() or "utf-8"
payload = payload_bytes.decode(charset, errors="replace")
if not isinstance(payload, str):
continue
if content_type == "text/plain":
plain_parts.append(payload)
elif content_type == "text/html":
html_parts.append(payload)
if plain_parts:
return "\n\n".join(plain_parts).strip()
if html_parts:
# Simple HTML to text conversion
import re
import html
text = re.sub(r"<\s*br\s*/?>", "\n", "\n\n".join(html_parts), flags=re.IGNORECASE)
text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
return html.unescape(text).strip()
return ""
try:
payload = msg.get_content()
except Exception:
payload_bytes = msg.get_payload(decode=True) or b""
charset = msg.get_content_charset() or "utf-8"
payload = payload_bytes.decode(charset, errors="replace")
if not isinstance(payload, str):
return ""
if msg.get_content_type() == "text/html":
import re
import html
text = re.sub(r"<\s*br\s*/?>", "\n", payload, flags=re.IGNORECASE)
text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE)
text = re.sub(r"<[^>]+>", "", text)
return html.unescape(text).strip()
return payload.strip()

View File

@ -46,6 +46,7 @@ class ExecTool(Tool):
IMPORTANT:
- For READING files (including PDFs, text files, etc.), ALWAYS use read_file FIRST. Do NOT use exec to read files.
- Only use exec for complex data processing AFTER you have already read the file content using read_file.
- For git commands (git commit, git push, git status, etc.), ALWAYS use exec tool, NOT write_file or edit_file.
For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
- Excel files: python3 -c "import pandas as pd; df = pd.read_excel('file.xlsx'); result = df['Column Name'].sum(); print(result)"
@ -53,7 +54,13 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
- NEVER use pandas/openpyxl as command-line tools (they don't exist)
- NEVER use non-existent tools like csvcalc, xlsxcalc, etc.
- For calculations: Use pandas operations like .sum(), .mean(), .max(), etc.
- For total inventory value: (df['Unit Price'] * df['Quantity']).sum()"""
- For total inventory value: (df['Unit Price'] * df['Quantity']).sum()
For git operations:
- git commit: exec(command="git commit -m 'message'")
- git status: exec(command="git status")
- git push: exec(command="git push")
- NEVER use write_file or edit_file for git commands"""
@property
def parameters(self) -> dict[str, Any]:
@ -74,6 +81,10 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
async def execute(self, command: str, working_dir: str | None = None, **kwargs: Any) -> str:
cwd = working_dir or self.working_dir or os.getcwd()
# Sanitize Gitea API URLs: convert HTTPS to HTTP for 10.0.30.169:3000
command = self._sanitize_gitea_urls(command)
guard_error = self._guard_command(command, cwd)
if guard_error:
return guard_error
@ -83,11 +94,14 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
logger.debug(f"ExecTool: command={command[:200]}, cwd={cwd}, working_dir={working_dir}")
try:
# Ensure environment variables are available (including from .env file)
env = os.environ.copy()
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
try:
@ -200,3 +214,33 @@ For data analysis tasks (Excel, CSV, JSON files), use Python with pandas:
return "Error: Command blocked by safety guard (path outside working dir)"
return None
def _sanitize_gitea_urls(self, command: str) -> str:
"""
Sanitize Gitea API URLs in curl commands: convert HTTPS to HTTP.
Gitea API at 10.0.30.169:3000 runs on HTTP, not HTTPS.
This prevents SSL/TLS errors when the agent generates HTTPS URLs.
"""
# Pattern to match https://10.0.30.169:3000/api/... in curl commands
# This handles various curl formats:
# - curl "https://10.0.30.169:3000/api/..."
# - curl -X GET https://10.0.30.169:3000/api/...
# - curl -H "..." "https://10.0.30.169:3000/api/..."
# Matches URLs with or without quotes, and captures the full path
pattern = r'https://10\.0\.30\.169:3000(/api/[^\s"\']*)'
def replace_url(match):
path = match.group(1)
return f'http://10.0.30.169:3000{path}'
sanitized = re.sub(pattern, replace_url, command)
# Log if we made a change
if sanitized != command:
from loguru import logger
logger.info(f"ExecTool: Sanitized Gitea API URL (HTTPS -> HTTP)")
logger.debug(f"Original: {command[:200]}...")
logger.debug(f"Sanitized: {sanitized[:200]}...")
return sanitized

View File

@ -35,7 +35,16 @@ class SpawnTool(Tool):
return (
"Spawn a subagent to handle a task in the background. "
"Use this for complex or time-consuming tasks that can run independently. "
"The subagent will complete the task and report back when done."
"The subagent will complete the task and report back when done.\n\n"
"CRITICAL: The 'task' parameter MUST be a natural language description of what to do, "
"NOT a tool call. The subagent will figure out how to accomplish the task using its own tools.\n\n"
"CORRECT examples:\n"
"- task='Read all documentation files in the project and create a summary'\n"
"- task='Analyze the codebase structure and generate a report'\n"
"- task='Search for information about X and compile findings'\n\n"
"WRONG (do not use tool call syntax):\n"
"- task='read_dir(path=\"/path/to/file\")'\n"
"- task='read_file(path=\"file.txt\")'"
)
@property
@ -45,7 +54,12 @@ class SpawnTool(Tool):
"properties": {
"task": {
"type": "string",
"description": "The task for the subagent to complete",
"description": (
"A natural language description of the task for the subagent to complete. "
"DO NOT use tool call syntax. Examples: 'Read all documentation and summarize', "
"'Analyze the codebase structure', 'Search the web for X and compile findings'. "
"The subagent will determine which tools to use to accomplish this task."
),
},
"label": {
"type": "string",

View File

@ -101,9 +101,9 @@ class WebSearchTool(Tool):
try:
n = min(max(count or self.max_results, 1), 10)
# Try using duckduckgo_search library if available
# Try using ddgs library if available (renamed from duckduckgo_search)
try:
from duckduckgo_search import DDGS
from ddgs import DDGS
with DDGS() as ddgs:
results = []
for r in ddgs.text(query, max_results=n):
@ -112,7 +112,7 @@ class WebSearchTool(Tool):
"url": r.get("href", ""),
"description": r.get("body", "")
})
if not results:
return f"No results found for: {query}"
@ -123,51 +123,58 @@ class WebSearchTool(Tool):
lines.append(f" {item['description']}")
return "\n".join(lines)
except ImportError:
# Fallback: use DuckDuckGo instant answer API (simpler, but limited)
async with httpx.AsyncClient(
follow_redirects=True,
timeout=15.0
) as client:
# Use DuckDuckGo instant answer API (no key needed)
url = "https://api.duckduckgo.com/"
r = await client.get(
url,
params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"},
headers={"User-Agent": USER_AGENT},
)
r.raise_for_status()
data = r.json()
results = []
# Get RelatedTopics (search results)
if "RelatedTopics" in data:
for topic in data["RelatedTopics"][:n]:
if "Text" in topic and "FirstURL" in topic:
results.append({
"title": topic.get("Text", "").split(" - ")[0] if " - " in topic.get("Text", "") else topic.get("Text", "")[:50],
"url": topic.get("FirstURL", ""),
"description": topic.get("Text", "")
})
# Also check AbstractText for direct answer
if "AbstractText" in data and data["AbstractText"]:
results.insert(0, {
"title": data.get("Heading", query),
"url": data.get("AbstractURL", ""),
"description": data.get("AbstractText", "")
})
if not results:
return f"No results found for: {query}. Try installing 'duckduckgo-search' package for better results: pip install duckduckgo-search"
lines = [f"Results for: {query}\n"]
for i, item in enumerate(results[:n], 1):
lines.append(f"{i}. {item['title']}\n {item['url']}")
if item['description']:
lines.append(f" {item['description']}")
return "\n".join(lines)
# ddgs package not installed, fall through to fallback
pass
except Exception as e:
# Log ddgs errors but fall through to fallback API
import logging
logging.debug(f"ddgs search error: {e}")
# Fallback: use DuckDuckGo instant answer API (simpler, but limited)
async with httpx.AsyncClient(
follow_redirects=True,
timeout=15.0
) as client:
# Use DuckDuckGo instant answer API (no key needed)
url = "https://api.duckduckgo.com/"
r = await client.get(
url,
params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"},
headers={"User-Agent": USER_AGENT},
)
r.raise_for_status()
data = r.json()
results = []
# Get RelatedTopics (search results)
if "RelatedTopics" in data:
for topic in data["RelatedTopics"][:n]:
if "Text" in topic and "FirstURL" in topic:
results.append({
"title": topic.get("Text", "").split(" - ")[0] if " - " in topic.get("Text", "") else topic.get("Text", "")[:50],
"url": topic.get("FirstURL", ""),
"description": topic.get("Text", "")
})
# Also check AbstractText for direct answer
if "AbstractText" in data and data["AbstractText"]:
results.insert(0, {
"title": data.get("Heading", query),
"url": data.get("AbstractURL", ""),
"description": data.get("AbstractText", "")
})
if not results:
return f"No results found for: {query}. Try installing 'ddgs' package for better results: pip install ddgs"
lines = [f"Results for: {query}\n"]
for i, item in enumerate(results[:n], 1):
lines.append(f"{i}. {item['title']}\n {item['url']}")
if item['description']:
lines.append(f" {item['description']}")
return "\n".join(lines)
except Exception as e:
return f"Error searching: {e}. Try installing 'duckduckgo-search' package: pip install duckduckgo-search"
return f"Error searching: {e}. Try installing 'ddgs' package: pip install ddgs"
class WebFetchTool(Tool):

View File

@ -0,0 +1,228 @@
"""Email parsing utilities for extracting meeting information."""
import re
from datetime import datetime, timedelta
from typing import Any
from loguru import logger
def extract_meeting_info(email_content: str, email_subject: str = "") -> dict[str, Any] | None:
"""
Extract meeting information from email content and subject.
Args:
email_content: Email body text
email_subject: Email subject line
Returns:
Dictionary with meeting details if found, None otherwise
"""
text = (email_subject + " " + email_content).lower()
# Check for meeting-related keywords
meeting_keywords = [
"meeting",
"appointment",
"call",
"conference",
"standup",
"stand-up",
"sync",
"discussion",
"catch up",
"catch-up",
]
has_meeting_keyword = any(keyword in text for keyword in meeting_keywords)
if not has_meeting_keyword:
return None
result: dict[str, Any] = {
"title": None,
"start_time": None,
"end_time": None,
"location": None,
"attendees": [],
}
# Extract title from subject or first line
if email_subject:
# Remove common prefixes
title = email_subject
for prefix in ["Re:", "Fwd:", "FW:"]:
if title.lower().startswith(prefix.lower()):
title = title[len(prefix) :].strip()
result["title"] = title[:100] # Limit length
# Extract time information
# Month names mapping
month_names = {
"january": 1, "jan": 1,
"february": 2, "feb": 2,
"march": 3, "mar": 3,
"april": 4, "apr": 4,
"may": 5,
"june": 6, "jun": 6,
"july": 7, "jul": 7,
"august": 8, "aug": 8,
"september": 9, "sep": 9, "sept": 9,
"october": 10, "oct": 10,
"november": 11, "nov": 11,
"december": 12, "dec": 12,
}
time_patterns = [
# "March 7 at 15:00" or "March 7th at 3pm" or "on March 7 at 15:00"
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(?:at\s+)?(\d{1,2}):(\d{2})",
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\s+(?:at\s+)?(\d{1,2})\s*(am|pm)",
# "March 7" (date only, assume current year)
r"(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?",
# Relative dates
r"tomorrow\s+at\s+(\d{1,2})\s*(am|pm)?",
r"tomorrow\s+(\d{1,2})\s*(am|pm)?",
r"(\d{1,2})\s*(am|pm)\s+tomorrow",
r"(\d{1,2}):(\d{2})\s*(am|pm)?\s+tomorrow",
r"in\s+(\d+)\s+(hour|hours|minute|minutes|day|days)",
# Date formats
r"(\d{1,2})/(\d{1,2})/(\d{4})\s+at\s+(\d{1,2}):(\d{2})\s*(am|pm)?",
r"(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})",
]
for pattern in time_patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
try:
now = datetime.now()
groups = match.groups()
# Check if this is a month name pattern (first group is month name)
if groups and groups[0].lower() in month_names:
month_name = groups[0].lower()
month = month_names[month_name]
day = int(groups[1])
year = now.year
# Check if date is in the past (assume next year if so)
test_date = datetime(year, month, day)
if test_date < now.replace(hour=0, minute=0, second=0, microsecond=0):
year += 1
# Check if time is provided (pattern with 4 groups means time included)
if len(groups) >= 4 and groups[2] and groups[3]:
# Check if groups[3] is am/pm or minutes
if groups[3].lower() in ['am', 'pm']:
# Format: "March 7 at 3pm" (12-hour with am/pm)
hour = int(groups[2])
period = groups[3].lower()
minute = 0
if period == "pm" and hour != 12:
hour += 12
elif period == "am" and hour == 12:
hour = 0
else:
# Format: "March 7 at 15:00" (24-hour with colon)
# groups[2] = hour, groups[3] = minute
hour = int(groups[2])
minute = int(groups[3])
result["start_time"] = datetime(year, month, day, hour, minute)
else:
# Date only, default to 9am
result["start_time"] = datetime(year, month, day, 9, 0)
break
elif "tomorrow" in pattern:
base_date = now + timedelta(days=1)
hour = int(match.group(1))
period = match.group(2) if len(match.groups()) > 1 else None
if period:
if period.lower() == "pm" and hour != 12:
hour += 12
elif period.lower() == "am" and hour == 12:
hour = 0
result["start_time"] = base_date.replace(hour=hour, minute=0, second=0, microsecond=0)
break
elif "in" in pattern:
amount = int(match.group(1))
unit = match.group(2)
if "hour" in unit:
result["start_time"] = now + timedelta(hours=amount)
elif "minute" in unit:
result["start_time"] = now + timedelta(minutes=amount)
elif "day" in unit:
result["start_time"] = now + timedelta(days=amount)
break
except (ValueError, IndexError, AttributeError):
continue
# Extract location
location_patterns = [
r"location[:\s]+([^\n]+)",
r"where[:\s]+([^\n]+)",
r"at\s+([A-Z][^\n]+)", # Capitalized location names
r"room\s+([A-Z0-9]+)",
]
for pattern in location_patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
location = match.group(1).strip()
if len(location) < 100: # Reasonable length
result["location"] = location
break
# Extract attendees (email addresses)
email_pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
emails = re.findall(email_pattern, email_content)
if emails:
result["attendees"] = list(set(emails)) # Remove duplicates
# Only return if we found at least a title or time
if result["title"] or result["start_time"]:
logger.info(f"Extracted meeting info: {result}")
return result
return None
def format_meeting_for_calendar(meeting_info: dict[str, Any]) -> dict[str, Any]:
"""
Format meeting info for calendar tool.
Args:
meeting_info: Meeting information dictionary
Returns:
Formatted dictionary for calendar.create_event
"""
formatted: dict[str, Any] = {
"action": "create_event",
}
if meeting_info.get("title"):
formatted["title"] = meeting_info["title"]
else:
formatted["title"] = "Meeting"
if meeting_info.get("start_time"):
if isinstance(meeting_info["start_time"], datetime):
formatted["start_time"] = meeting_info["start_time"].isoformat()
else:
formatted["start_time"] = str(meeting_info["start_time"])
if meeting_info.get("end_time"):
if isinstance(meeting_info["end_time"], datetime):
formatted["end_time"] = meeting_info["end_time"].isoformat()
else:
formatted["end_time"] = str(meeting_info["end_time"])
if meeting_info.get("location"):
formatted["location"] = meeting_info["location"]
if meeting_info.get("description"):
formatted["description"] = meeting_info["description"]
if meeting_info.get("attendees"):
formatted["attendees"] = meeting_info["attendees"]
return formatted

View File

@ -6,6 +6,7 @@ import imaplib
import re
import smtplib
import ssl
import uuid
from datetime import date
from email import policy
from email.header import decode_header, make_header
@ -57,6 +58,8 @@ class EmailChannel(BaseChannel):
self._last_message_id_by_chat: dict[str, str] = {}
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
self._MAX_PROCESSED_UIDS = 100000
self._sent_message_ids: set[str] = set() # Track Message-IDs of emails we sent to prevent feedback loops
self._MAX_SENT_MESSAGE_IDS = 10000
async def start(self) -> None:
"""Start polling IMAP for inbound emails."""
@ -134,6 +137,12 @@ class EmailChannel(BaseChannel):
email_msg["To"] = to_addr
email_msg["Subject"] = subject
email_msg.set_content(msg.content or "")
# Generate a Message-ID for the email we're sending (to track and prevent feedback loops)
from_email = email_msg["From"]
domain = from_email.split("@")[-1] if "@" in from_email else "nanobot.local"
message_id = f"<{uuid.uuid4()}@{domain}>"
email_msg["Message-ID"] = message_id
in_reply_to = self._last_message_id_by_chat.get(to_addr)
if in_reply_to:
@ -142,6 +151,13 @@ class EmailChannel(BaseChannel):
try:
await asyncio.to_thread(self._smtp_send, email_msg)
# Track this Message-ID so we can ignore replies to it (prevent feedback loops)
self._sent_message_ids.add(message_id)
# Trim if too large
if len(self._sent_message_ids) > self._MAX_SENT_MESSAGE_IDS:
# Remove oldest entries (simple approach: keep recent ones)
self._sent_message_ids.clear()
logger.debug(f"Sent email with Message-ID: {message_id} to {to_addr}")
except Exception as e:
logger.error(f"Error sending email to {to_addr}: {e}")
raise
@ -248,6 +264,10 @@ class EmailChannel(BaseChannel):
ids = data[0].split()
if limit > 0 and len(ids) > limit:
ids = ids[-limit:]
our_email = (self.config.from_address or self.config.smtp_username or self.config.imap_username).strip().lower()
skipped_count = 0
for imap_id in ids:
status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)")
if status != "OK" or not fetched:
@ -265,10 +285,46 @@ class EmailChannel(BaseChannel):
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
if not sender:
continue
# Skip emails from ourselves (prevent feedback loops)
if sender == our_email:
# Track skipped UIDs to avoid reprocessing
if uid and dedupe:
self._processed_uids.add(uid)
# Trim if too large
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
# Remove oldest entries (simple approach: keep recent ones)
self._processed_uids.clear()
# Mark as seen so it doesn't keep appearing in UNSEEN searches
if mark_seen:
try:
client.store(imap_id, "+FLAGS", "\\Seen")
except Exception:
pass
skipped_count += 1
continue
subject = self._decode_header_value(parsed.get("Subject", ""))
date_value = parsed.get("Date", "")
message_id = parsed.get("Message-ID", "").strip()
in_reply_to = parsed.get("In-Reply-To", "").strip()
# Skip emails that are replies to emails we sent (prevent feedback loops)
if in_reply_to and in_reply_to in self._sent_message_ids:
# Track skipped UIDs to avoid reprocessing
if uid and dedupe:
self._processed_uids.add(uid)
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
self._processed_uids.clear()
# Mark as seen so it doesn't keep appearing in UNSEEN searches
if mark_seen:
try:
client.store(imap_id, "+FLAGS", "\\Seen")
except Exception:
pass
skipped_count += 1
continue
body = self._extract_text_body(parsed)
if not body:
@ -313,6 +369,10 @@ class EmailChannel(BaseChannel):
client.logout()
except Exception:
pass
# Log summary of skipped emails (only if significant number) - reduces log noise
if skipped_count > 0:
logger.debug(f"Skipped {skipped_count} email(s) from self or replies to our emails")
return messages

View File

@ -517,6 +517,7 @@ def agent(
from nanobot.cron.service import CronService
from loguru import logger
# Load config (this also loads .env file into environment)
config = load_config()
bus = MessageBus()

View File

@ -1,6 +1,7 @@
"""Configuration loading utilities."""
import json
import os
from pathlib import Path
from nanobot.config.schema import Config
@ -17,6 +18,43 @@ def get_data_dir() -> Path:
return get_data_path()
def _load_env_file(workspace: Path | None = None) -> None:
"""Load .env file from workspace directory if it exists."""
if workspace:
env_file = Path(workspace) / ".env"
else:
# Try current directory and workspace
env_file = Path(".env")
if not env_file.exists():
# Try workspace directory
try:
from nanobot.utils.helpers import get_workspace_path
workspace_path = get_workspace_path()
env_file = workspace_path / ".env"
except:
pass
if env_file.exists():
try:
with open(env_file) as f:
for line in f:
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith("#"):
continue
# Parse KEY=VALUE format
if "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
# Only set if not already in environment
if key and key not in os.environ:
os.environ[key] = value
except Exception:
# Silently fail if .env can't be loaded
pass
def load_config(config_path: Path | None = None) -> Config:
"""
Load configuration from file or create default.
@ -27,6 +65,15 @@ def load_config(config_path: Path | None = None) -> Config:
Returns:
Loaded configuration object.
"""
# Load .env file before loading config (so env vars are available to pydantic)
try:
from nanobot.utils.helpers import get_workspace_path
workspace = get_workspace_path()
_load_env_file(workspace)
except:
# Fallback to current directory
_load_env_file()
path = config_path or get_config_path()
if path.exists():

View File

@ -244,6 +244,17 @@ class WebToolsConfig(Base):
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
class CalendarConfig(Base):
"""Google Calendar configuration."""
enabled: bool = False
credentials_file: str = "" # Path to OAuth2 credentials JSON file
token_file: str = "" # Path to store OAuth2 token (default: ~/.nanobot/calendar_token.json)
calendar_id: str = "primary" # Calendar ID to use (default: primary calendar)
auto_schedule_from_email: bool = True # Automatically schedule meetings from emails
timezone: str = "UTC" # Timezone for parsing times (e.g., "America/New_York", "Europe/London", "UTC")
class ExecToolConfig(Base):
"""Shell exec tool configuration."""
@ -264,6 +275,7 @@ class ToolsConfig(Base):
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
calendar: CalendarConfig = Field(default_factory=CalendarConfig)
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)

View File

@ -62,22 +62,37 @@ class CustomProvider(LLMProvider):
# If no structured tool calls, try to parse from content (Ollama sometimes returns JSON in content)
# Only parse if content looks like it contains a tool call JSON (to avoid false positives)
content = msg.content or ""
if not tool_calls and content and '"name"' in content and '"parameters"' in content:
# Check for standard format: {"name": "...", "parameters": {...}}
has_standard_format = '"name"' in content and '"parameters"' in content
# Check for calendar tool format: {"action": "...", ...}
has_calendar_format = '"action"' in content and ("calendar" in content.lower() or any(action in content for action in ["list_events", "create_event", "update_event", "delete_event"]))
if not tool_calls and content and (has_standard_format or has_calendar_format):
import re
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}}
# Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...}
# Find complete JSON objects by matching braces
pattern = r'\{\s*"name"\s*:\s*"(\w+)"'
# Try "action" pattern first (for calendar tool), then "name" pattern
patterns = [
(r'\{\s*"action"\s*:\s*"(\w+)"', "action"), # Calendar tool format
(r'\{\s*"name"\s*:\s*"(\w+)"', "name"), # Standard format
]
start_pos = 0
max_iterations = 5 # Safety limit
max_iterations = 10 # Increased for multiple patterns
iteration = 0
while iteration < max_iterations:
iteration += 1
match = re.search(pattern, content[start_pos:])
match = None
pattern_type = None
for pattern, ptype in patterns:
match = re.search(pattern, content[start_pos:])
if match:
pattern_type = ptype
break
if not match:
break
json_start = start_pos + match.start()
name = match.group(1)
key_value = match.group(1)
# Find the matching closing brace by counting braces
brace_count = 0
@ -98,8 +113,41 @@ class CustomProvider(LLMProvider):
try:
json_str = content[json_start:json_end]
tool_obj = json_repair.loads(json_str)
# Only accept if it has both name and parameters, and name is a valid tool name
valid_tools = ["exec", "read_file", "write_file", "list_dir", "web_search"]
# Handle calendar tool format: {"action": "...", ...}
if isinstance(tool_obj, dict) and "action" in tool_obj:
# This is a calendar tool call in JSON format
action = tool_obj.get("action")
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
# Convert to calendar tool call format
tool_calls.append(ToolCallRequest(
id=f"call_{len(tool_calls)}",
name="calendar",
arguments=tool_obj # Pass the whole object as arguments
))
# Remove the tool call from content
content = content[:json_start] + content[json_end:].strip()
start_pos = json_start # Stay at same position since we removed text
continue
# Handle standard format: {"name": "...", "parameters": {...}}
# Note: This list should match tools registered in AgentLoop._register_default_tools()
valid_tools = [
# File tools
"read_file", "write_file", "edit_file", "list_dir",
# Shell tool
"exec",
# Web tools
"web_search", "web_fetch",
# Communication tools
"message", "spawn",
# Calendar tool
"calendar",
# Cron tool
"cron",
# Email tool
"email",
]
if (isinstance(tool_obj, dict) and
"name" in tool_obj and
"parameters" in tool_obj and

View File

@ -0,0 +1,73 @@
---
name: calendar
description: "Interact with Google Calendar. Create events, list upcoming events, and check availability."
---
# Calendar Skill
Use the `calendar` tool to interact with Google Calendar.
## Setup
1. Enable Google Calendar API in Google Cloud Console
2. Create OAuth2 credentials (Desktop app)
3. Download credentials JSON file
4. Configure in nanobot:
```bash
export NANOBOT_TOOLS__CALENDAR__ENABLED=true
export NANOBOT_TOOLS__CALENDAR__CREDENTIALS_FILE=/path/to/credentials.json
```
On first run, you'll be prompted to authorize access via OAuth flow.
## Actions
### List Events
List upcoming calendar events:
```
calendar(action="list_events", max_results=10)
```
### Create Event
Create a new calendar event:
```
calendar(
action="create_event",
title="Team Meeting",
start_time="2024-01-15T14:00:00",
end_time="2024-01-15T15:00:00",
description="Discuss project progress",
location="Conference Room A",
attendees=["colleague@example.com"]
)
```
**Time formats:**
- ISO format: `"2024-01-15T14:00:00"`
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
### Check Availability
Check if a time slot is available:
```
calendar(
action="check_availability",
start_time="2024-01-15T14:00:00",
end_time="2024-01-15T15:00:00"
)
```
## Email Integration
When an email mentions a meeting (e.g., "meeting tomorrow at 2pm"), the agent can automatically:
1. Parse the email to extract meeting details
2. Create a calendar event using `create_event`
3. Confirm the event was created
Enable automatic scheduling:
```bash
export NANOBOT_TOOLS__CALENDAR__AUTO_SCHEDULE_FROM_EMAIL=true
```

View File

@ -15,6 +15,11 @@ Use the `cron` tool to schedule reminders or recurring tasks.
## Examples
**IMPORTANT**: Always extract the message from the user's request:
- User: "remind me to call mama" → `message="call mama"`
- User: "remind me every hour to drink water" → `message="drink water"`
- User: "remind me every 10 seconds for the next minute to call mama" → `message="call mama"`
Fixed reminder:
```
cron(action="add", message="Time to take a break!", every_seconds=1200)
@ -50,6 +55,8 @@ cron(action="remove", job_id="abc123")
| remind me in 1 hour | **in_seconds: 3600** (1 hour = 3600 seconds) |
| every 20 minutes | every_seconds: 1200 |
| every hour | every_seconds: 3600 |
| **every 10 seconds for the next minute** | **every_seconds: 10 AND in_seconds: 60** (creates 6 reminders: at 0s, 10s, 20s, 30s, 40s, 50s) |
| **every 5 seconds for 30 seconds** | **every_seconds: 5 AND in_seconds: 30** (creates 6 reminders) |
| every day at 8am | cron_expr: "0 8 * * *" |
| weekdays at 5pm | cron_expr: "0 17 * * 1-5" |
| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" |
@ -61,6 +68,8 @@ cron(action="remove", job_id="abc123")
- "remind me in 25 seconds" → `cron(action="add", message="...", in_seconds=25)`
- "remind me in 5 minutes" → `cron(action="add", message="...", in_seconds=300)` (5 * 60 = 300)
- "remind me in 1 hour" → `cron(action="add", message="...", in_seconds=3600)` (60 * 60 = 3600)
- **"remind me every 10 seconds for the next minute"** → `cron(action="add", message="...", every_seconds=10, in_seconds=60)` (creates 6 reminders)
- **"every 5 seconds for 30 seconds"** → `cron(action="add", message="...", every_seconds=5, in_seconds=30)` (creates 6 reminders)
The `in_seconds` parameter automatically computes the correct future datetime - you don't need to calculate it yourself!

View File

@ -0,0 +1,52 @@
---
name: gitea
description: "Interact with Gitea API using curl. This repository uses Gitea (NOT GitHub) at http://10.0.30.169:3000/api/v1. ALWAYS use HTTP (not HTTPS)."
metadata: {"nanobot":{"emoji":"🔧","requires":{"env":["NANOBOT_GITLE_TOKEN"]}}}
---
# Gitea Skill
**CRITICAL**: This repository uses Gitea at `http://10.0.30.169:3000/api/v1`, NOT GitHub.
## Important Rules
1. **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP port 3000
2. **ALWAYS include Authorization header** with `$NANOBOT_GITLE_TOKEN`
3. **Repository**: `ilia/nanobot`
4. **API Base**: `http://10.0.30.169:3000/api/v1`
## Pull Requests
List all pull requests:
```bash
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
```
List open pull requests:
```bash
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls?state=open"
```
## Issues
List open issues:
```bash
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
```
## Helper Script
You can also use the helper script:
```bash
./workspace/gitea_api.sh prs
./workspace/gitea_api.sh issues open
```
## Common Mistakes to Avoid
**WRONG**: `curl https://10.0.30.169:3000/api/...` (SSL error)
**WRONG**: `curl http://gitea.example.com/api/...` (placeholder URL)
**CORRECT**: `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"`

View File

@ -24,6 +24,7 @@ dependencies = [
"websockets>=12.0",
"websocket-client>=1.6.0",
"httpx>=0.25.0",
"ddgs>=9.0.0",
"oauth-cli-kit>=0.1.1",
"loguru>=0.7.0",
"readability-lxml>=0.8.0",
@ -42,6 +43,10 @@ dependencies = [
"prompt-toolkit>=3.0.0",
"mcp>=1.0.0",
"json-repair>=0.30.0",
"google-api-python-client>=2.0.0",
"google-auth-httplib2>=0.2.0",
"google-auth-oauthlib>=1.0.0",
"pytz>=2024.1",
]
[project.optional-dependencies]

View File

@ -2,13 +2,68 @@
You are a helpful AI assistant. Be concise, accurate, and friendly.
**CRITICAL: TOOL EXECUTION**
- When you need to use a tool, CALL IT DIRECTLY - the system will execute it automatically
- DO NOT show JSON like `{"action": "list_events"}` in your response text
- DO NOT explain what tool you would call - just call it
- Tools are executed automatically when you call them - you don't need to show the JSON
## 🚨 CRITICAL: Gitea API Requests
**When user asks to list PRs, issues, or use Gitea API:**
**MANDATORY COMMAND FORMAT:**
```bash
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
```
**CRITICAL RULES:**
1. **DO NOT use web_search** - execute the curl command directly
2. **MUST use `http://` (NOT `https://`)** - Gitea runs on HTTP port 3000
3. **MUST include Authorization header** with `$NANOBOT_GITLE_TOKEN`
4. **Copy the exact command above** - do not modify the protocol to HTTPS
**WRONG (will fail):**
- `curl -X GET https://10.0.30.169:3000/api/...` ❌ (SSL error)
- `curl https://10.0.30.169:3000/api/...` ❌ (SSL error)
**CORRECT:**
- `curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"`
**OR use the helper script (recommended - avoids HTTPS mistakes):**
```bash
./workspace/gitea_api.sh prs
./workspace/gitea_api.sh issues open
```
## Guidelines
- Always explain what you're doing before taking actions
- **CRITICAL: When you need to use a tool, the system will automatically execute it when you call it. You do NOT need to show JSON.**
- **When user asks you to do something, IMMEDIATELY call the necessary tools - do not explain, do not show JSON, just call them.**
- The system handles tool execution automatically - you just need to call the tools in your response.
- Ask for clarification when the request is ambiguous
- Use tools to help accomplish tasks
- Remember important information in your memory files
## Git Operations
**CRITICAL**: When user asks to commit, push, or perform git operations:
- **ALWAYS use the `exec` tool** to run git commands
- **NEVER use `write_file` or `edit_file`** for git commands
- Git commands are shell commands and must be executed, not written to files
**Examples:**
- User: "commit with message 'Fix bug'" → `exec(command="git commit -m 'Fix bug'")`
- User: "commit the staged files" → `exec(command="git commit -m 'your message here'")`
- User: "push to remote" → `exec(command="git push")`
- User: "check git status" → `exec(command="git status")`
**WRONG (will not work):**
- `write_file(path="git commit -m 'message'", content="...")`
- `edit_file(path="git commit", ...)`
**CORRECT:**
- `exec(command="git commit -m 'Fix HTTPS to HTTP conversion for Gitea API'")`
## When NOT to Use Tools
**For simple acknowledgments, respond naturally and conversationally - no tools needed.**
@ -42,6 +97,8 @@ You have access to:
- Messaging (message)
- Background tasks (spawn)
- Scheduled tasks (cron) - for reminders and delayed actions
- Email (read_emails) - read emails from IMAP mailbox
- Calendar (calendar) - interact with Google Calendar (if enabled)
## Memory
@ -72,6 +129,106 @@ When the scheduled time arrives, the cron system will send the message back to y
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. Use the `cron` tool.
## Calendar Integration
**CRITICAL: When processing emails that mention meetings, you MUST automatically schedule them in the calendar.**
**CRITICAL: When using calendar tools, EXECUTE them immediately. Do NOT show JSON or explain what you would do - just call the tool.**
When an email mentions a meeting (e.g., "meeting tomorrow at 2pm", "reminder about our meeting on March 7 at 15:00", "call scheduled for next week"), you MUST:
1. **Extract meeting details** from the email:
- Title/subject (use email subject if no explicit title)
- Date and time (parse formats like "March 7 at 15:00", "tomorrow 2pm", etc.)
- Location (if mentioned)
- Attendees (email addresses)
2. **Check if meeting already exists** (optional but recommended):
- Use `calendar(action="list_events")` to check upcoming events
- Look for events with similar title/time
3. **Use the `calendar` tool** to create the event:
```
calendar(
action="create_event",
title="Meeting Title",
start_time="March 7 15:00", # Use natural language format, NOT ISO format
end_time="March 7 16:00", # optional, defaults to 1 hour after start
location="Conference Room A", # optional
attendees=["colleague@example.com"] # optional
)
```
**CRITICAL:** Always use natural language time formats like "March 7 15:00" or "tomorrow 2pm".
**DO NOT** generate ISO format strings like "2024-03-06T19:00:00" - the calendar tool will parse
natural language correctly and handle the current year automatically. If you generate ISO format
with the wrong year (e.g., 2024 instead of 2026), the meeting will be scheduled in the past.
4. **Confirm to the user** that the meeting was scheduled (include the calendar link if available).
**Time formats supported:**
- Month names: `"March 7 at 15:00"`, `"March 7th at 3pm"`, `"on March 7 at 15:00"`
- Relative: `"tomorrow 2pm"`, `"in 1 hour"`, `"in 2 days"`
- ISO format: `"2024-01-15T14:00:00"`
**Deleting/Canceling Events:**
When the user asks to cancel or delete meetings, you MUST follow this workflow - DO NOT explain, just execute:
**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN**
- IMMEDIATELY call `calendar(action="list_events", time_min="today")`
- Do NOT explain what you will do - just call the tool
- Do NOT try to use `delete_events_today` (it doesn't exist)
**STEP 2: From the list_events response, identify the target event(s)**
- "Cancel all meetings today" → ALL events from today (extract ALL IDs from the response)
- "Cancel my last meeting" → The last event in the list (marked as "LAST - latest time")
- "Cancel my 8pm meeting" → Event(s) at 8pm
- "Cancel the meeting with John" → Event(s) with "John" in title/description
**STEP 3: Extract event IDs from the response**
- Event IDs are long strings (20+ characters) after `[ID: ` or in the `Event IDs:` line
- For "cancel all", extract ALL IDs from the response
**STEP 4: Call delete_event or delete_events with the extracted IDs**
- Single event: `calendar(action="delete_event", event_id="...")`
- Multiple events: `calendar(action="delete_events", event_ids=[...])`
- **CRITICAL**: Do NOT use placeholder IDs - you MUST extract real IDs from list_events response
- **CRITICAL**: Do NOT use `update_event` with `status: "cancelled"` (that doesn't work)
**Rescheduling/Moving Events:**
When the user asks to reschedule or move a meeting, you MUST follow these steps:
**STEP 1: ALWAYS call list_events FIRST - DO THIS NOW, DO NOT EXPLAIN**
- IMMEDIATELY call `calendar(action="list_events", time_min="today")`
- Do NOT explain what you will do - just call the tool
- Do NOT use placeholder values - you MUST get the actual ID from the response
**STEP 2: From the list_events response, identify the target event**
- "last meeting" → The event with the LATEST time (marked as "LAST - latest time" in the response, usually the last numbered item)
- "first meeting" → The event with the EARLIEST time (marked as "FIRST - earliest time", usually #1)
- "8pm meeting" → Event(s) at 8pm (look for "8:00 PM" or "20:00" in the time)
- "meeting with John" → Event(s) with "John" in the title
- Extract the actual event_id (long string after `[ID: `, usually 20+ characters)
- IMPORTANT: Events are numbered in the response - use the number and the "LAST" marker to identify correctly
**STEP 3: IMMEDIATELY call update_event with the actual event_id**
- Call `calendar(action="update_event", event_id="actual_id_from_step_2", start_time="new time")`
- Use natural language for new time: "4pm", "next Monday at 4pm", "tomorrow 2pm", etc.
- Do NOT explain - just execute the tool call
**CRITICAL:**
- When you get an error saying "Invalid event_id" or "placeholder", DO NOT explain the solution
- Instead, IMMEDIATELY call list_events, then call update_event again with the real ID
- NEVER show JSON - just call the tools
- NEVER use placeholder values - always get real IDs from list_events
**Automatic scheduling:** When `auto_schedule_from_email` is enabled (default: true), automatically schedule meetings when detected in emails. Do NOT just acknowledge - actually create the calendar event using the `calendar` tool.
**Examples of emails that should trigger scheduling:**
- "Reminder about our meeting on March 7 at 15:00" → Schedule for March 7 at 3 PM
- "Meeting tomorrow at 2pm" → Schedule for tomorrow at 2 PM
- "Call scheduled for next week" → Extract date and schedule
## Heartbeat Tasks
`HEARTBEAT.md` is checked every 30 minutes. You can manage periodic tasks by editing this file:
@ -88,3 +245,114 @@ Task format examples:
```
When the user asks you to add a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time reminder. Keep the file small to minimize token usage.
## ⚠️ CRITICAL: Gitea API Access
**THIS REPOSITORY USES GITEA, NOT GITHUB. NEVER USE PLACEHOLDER URLS.**
When user asks about pull requests, issues, or Gitea API:
1. **ALWAYS detect the real Gitea URL from git remote first**
2. **NEVER use placeholder URLs like `gitea.example.com` or `https://gitea.example.com`**
3. **The correct Gitea API base is: `http://10.0.30.169:3000/api/v1`**
To access Gitea API:
1. **Detect Gitea URL from git remote:**
```bash
git remote get-url origin
# Returns: gitea@10.0.30.169:ilia/nanobot.git
# Extract host: 10.0.30.169
# API base: http://10.0.30.169:3000/api/v1
# Repo: ilia/nanobot
```
2. **Use the token from environment:**
```bash
TOKEN=$NANOBOT_GITLE_TOKEN
curl -H "Authorization: token $TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
```
3. **Or use the helper script:**
```bash
source workspace/get_gitea_info.sh
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"${GITEA_API_BASE}/repos/${GITEA_REPO}/pulls"
```
**Important:** Never use placeholder URLs like `gitea.example.com`. Always detect from git remote or use the actual host `10.0.30.169:3000`.
## 🚨 GITEA URL DETECTION (MANDATORY)
**BEFORE making any Gitea API call, you MUST:**
1. Run: `git remote get-url origin`
- This returns: `gitea@10.0.30.169:ilia/nanobot.git`
2. Extract the host: `10.0.30.169`
- Command: `git remote get-url origin | sed 's/.*@\([^:]*\).*/\1/'`
3. Extract the repo: `ilia/nanobot`
- Command: `git remote get-url origin | sed 's/.*:\(.*\)\.git/\1/'`
4. Construct API URL: `http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/...`
**Example correct command (MUST use $NANOBOT_GITLE_TOKEN variable):**
```bash
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
```
**CRITICAL**: Always use `$NANOBOT_GITLE_TOKEN` in the curl command. The token is automatically loaded from `.env` file into the environment when nanobot starts. Do NOT hardcode the token value.
**WRONG (never use):**
- `https://gitea.example.com/api/...`
- `https://gitea.example.com/ap...`
- Any placeholder URL ❌
## Gitea API Token Usage
**MANDATORY**: When making Gitea API calls, you **MUST** include the Authorization header with the token:
```bash
# ✅ CORRECT - includes Authorization header with token
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
# ❌ WRONG - missing Authorization header (will get 401 error)
curl -X GET "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
# ❌ WRONG - missing token in header
curl "http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
```
**CRITICAL RULES:**
1. **ALWAYS** include `-H "Authorization: token $NANOBOT_GITLE_TOKEN"` in curl commands
2. **ALWAYS use `http://` (NOT `https://`)** - Gitea runs on HTTP, not HTTPS
3. The token is automatically loaded from `.env` file when nanobot starts
4. The `$NANOBOT_GITLE_TOKEN` environment variable is available in all exec commands
5. Without the Authorization header, you will get a 401 Unauthorized error
6. Using `https://` will cause SSL/TLS errors - always use `http://10.0.30.169:3000`
## Direct Gitea API Execution
When user asks to "list PRs" or "show issues" using Gitea API:
**DO THIS:**
1. Run the curl command directly (no web_search needed)
2. Use the exact command format below
3. Parse and display the results
**Example - List PRs:**
```bash
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls" | \
python3 -m json.tool
```
**DO NOT:**
- Use web_search to find API documentation
- Search for "how to use Gitea API"
- Ask for clarification - just execute the command
You already have all the information you need in this file. Just execute the curl command.

46
workspace/GITEA_API.md Normal file
View File

@ -0,0 +1,46 @@
# Gitea API Quick Reference
**CRITICAL: This repository uses Gitea, NOT GitHub. Never use placeholder URLs.**
## Correct Gitea API Information
- **API Base URL**: `http://10.0.30.169:3000/api/v1`
- **Repository**: `ilia/nanobot`
- **Token**: Available in `$NANOBOT_GITLE_TOKEN` environment variable
## How to Detect (if needed)
```bash
# Get git remote
REMOTE=$(git remote get-url origin)
# Returns: gitea@10.0.30.169:ilia/nanobot.git
# Extract host (remove gitea@ and :repo.git)
HOST=$(echo "$REMOTE" | sed 's/.*@\([^:]*\).*/\1/')
# Returns: 10.0.30.169
# Extract repo path
REPO=$(echo "$REMOTE" | sed 's/.*:\(.*\)\.git/\1/')
# Returns: ilia/nanobot
# API base (Gitea runs on port 3000)
API_BASE="http://${HOST}:3000/api/v1"
```
## Example API Calls
```bash
# List pull requests
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
# List open issues
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
# Get repository info
curl -H "Authorization: token $NANOBOT_GITLE_TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot"
```
**DO NOT USE**: `gitea.example.com` or any placeholder URLs. Always use `10.0.30.169:3000`.

38
workspace/GITEA_INFO.md Normal file
View File

@ -0,0 +1,38 @@
# Gitea Configuration
## API Information
- **Gitea API Base URL**: `http://10.0.30.169:3000/api/v1`
- **Repository**: `ilia/nanobot`
- **Token Environment Variable**: `NANOBOT_GITLE_TOKEN`
## How to Use
When making Gitea API calls, use:
```bash
# Get token from environment
TOKEN=$NANOBOT_GITLE_TOKEN
# List open issues
curl -H "Authorization: token $TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/issues?state=open"
# List pull requests
curl -H "Authorization: token $TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot/pulls"
# Get repository info
curl -H "Authorization: token $TOKEN" \
"http://10.0.30.169:3000/api/v1/repos/ilia/nanobot"
```
## Detecting Repository Info
You can detect the repository from git remote:
```bash
# Get repo path (owner/repo)
git remote get-url origin | sed 's/.*:\(.*\)\.git/\1/'
# Gitea host is: 10.0.30.169:3000
# API base: http://10.0.30.169:3000/api/v1

View File

@ -42,6 +42,14 @@ exec(command: str, working_dir: str = None) -> str
- Output is truncated at 10,000 characters
- Optional `restrictToWorkspace` config to limit paths
**Git Commands:**
- **ALWAYS use exec for git commands** (git commit, git push, git status, etc.)
- **NEVER use write_file or edit_file for git commands**
- Examples:
- `exec(command="git commit -m 'Fix bug'")`
- `exec(command="git status")`
- `exec(command="git push")`
## Web Access
### web_search
@ -63,6 +71,26 @@ web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str
- Supports markdown or plain text extraction
- Output is truncated at 50,000 characters by default
## Email
### read_emails
Read emails from your configured email account via IMAP. **ALWAYS use this tool for email queries - NEVER use exec with mail commands.**
```
read_emails(limit: int = 10, unread_only: bool = False, mark_seen: bool = False) -> str
```
**CRITICAL:** For ANY question about emails (latest email, email sender, email content, etc.), you MUST use this tool. Do NOT use `exec` with `mail` command or read memory files for email information. This tool connects directly to IMAP and fetches CURRENT, real-time email data.
**Parameters:**
- `limit`: Maximum number of emails to return (1-50, default: 10)
- `unread_only`: If true, only return unread emails (default: false)
- `mark_seen`: If true, mark emails as read after fetching (default: false)
**Examples:**
- `read_emails(limit=1)` - Get the latest email
- `read_emails(unread_only=true)` - Get all unread emails
- `read_emails(limit=5, mark_seen=false)` - Get last 5 emails without marking as read
## Communication
### message

25
workspace/get_gitea_info.sh Executable file
View File

@ -0,0 +1,25 @@
#!/bin/bash
# Helper script to get Gitea API information from git remote
REMOTE=$(git remote get-url origin 2>/dev/null)
if [ -z "$REMOTE" ]; then
echo "Error: No git remote found"
exit 1
fi
# Extract host (assuming format: gitea@HOST:repo.git or ssh://gitea@HOST/repo.git)
if [[ $REMOTE == *"@"* ]]; then
HOST=$(echo "$REMOTE" | sed 's/.*@\([^:]*\).*/\1/')
else
HOST=$(echo "$REMOTE" | sed 's|.*://\([^/]*\).*|\1|')
fi
# Extract repo path (owner/repo)
REPO=$(echo "$REMOTE" | sed 's/.*:\(.*\)\.git/\1/' | sed 's|.*/\(.*/.*\)|\1|')
# Gitea typically runs on port 3000
API_BASE="http://${HOST}:3000/api/v1"
echo "GITEA_HOST=${HOST}"
echo "GITEA_REPO=${REPO}"
echo "GITEA_API_BASE=${API_BASE}"

28
workspace/gitea_api.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/bash
# Gitea API helper script - ALWAYS uses HTTP (not HTTPS)
API_BASE="http://10.0.30.169:3000/api/v1"
REPO="ilia/nanobot"
TOKEN="${NANOBOT_GITLE_TOKEN}"
if [ -z "$TOKEN" ]; then
echo "Error: NANOBOT_GITLE_TOKEN not set"
exit 1
fi
case "$1" in
prs|pulls)
curl -s -H "Authorization: token $TOKEN" \
"${API_BASE}/repos/${REPO}/pulls"
;;
issues)
curl -s -H "Authorization: token $TOKEN" \
"${API_BASE}/repos/${REPO}/issues?state=${2:-open}"
;;
*)
echo "Usage: $0 {prs|pulls|issues} [state]"
echo "Example: $0 prs"
echo "Example: $0 issues open"
exit 1
;;
esac