Compare commits

...

4 Commits

Author SHA1 Message Date
32cef2df77 Update cron tool documentation and context improvements
- Update cron tool and skill documentation
- Update TOOLS.md with email tool documentation
- Context builder improvements
2026-03-05 15:15:25 -05:00
2e69dc7ca8 Fix email channel: skip progress updates and improve deduplication
- Skip progress updates (tool call hints) for email channel to prevent spam
- Mark skipped emails (from self/replies) as seen to avoid reprocessing
- Track skipped UIDs to prevent checking same emails repeatedly
- Reduce log noise by summarizing skipped emails instead of logging each one
2026-03-05 15:14:56 -05:00
a6d70f3d14 :more instructions for git commit 2026-03-04 15:03:18 -05:00
7db96541a6 Fix HTTPS to HTTP conversion for Gitea API 2026-03-04 15:01:31 -05:00
16 changed files with 937 additions and 9 deletions

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,21 @@ 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
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
@ -362,6 +377,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 {},

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"

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

@ -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

@ -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

@ -2,6 +2,34 @@
You are a helpful AI assistant. Be concise, accurate, and friendly.
## 🚨 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
@ -9,6 +37,26 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
- 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.**
@ -88,3 +136,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