Compare commits

..

28 Commits

Author SHA1 Message Date
b4a0427494 Merge pull request 'feature/tool-profiles-routing' (#4) from feature/tool-profiles-routing into feature/cleanup-providers-llama-only
Reviewed-on: #4
2026-03-31 11:22:41 -05:00
7050e032e8 Improve MCP tool calling and routing
Some checks failed
CI / Lint with ruff (pull_request) Failing after 47s
CI / Test Python 3.11 (pull_request) Successful in 51s
CI / Test Python 3.12 (pull_request) Successful in 50s
CI / Build package (pull_request) Has been cancelled
Add explicit JSON tool-call protocol for local providers, improve parsing of JSON-only tool calls, and add heuristic routing to MCP-capable profiles for repo/PR intents. Also document and mount local-cloned MCP servers and expand MCP env var handling.

Made-with: Cursor
2026-03-31 12:15:05 -04:00
93b34bc214 Enhance CalendarTool action handling
- Introduce logic to set default action to "list_events" when `action` is omitted and only a single key-value pair is present.
- Expand handling for various input scenarios to ensure correct action assignment based on known actions and input keys.
- Improve robustness of the coerced dictionary to accommodate different input formats.

Made-with: Cursor
2026-03-30 14:15:09 -04:00
a6bd3e0e9b Agent: tool profiles and profile-scoped MCP connections
- Extend tool profile helpers for MCP server key resolution and filtering
- Lazily connect/disconnect MCP servers per active profile in AgentLoop
- Harden MCP client (timeouts, tool naming, connect_mcp_server entry)
- Adjust context and tool modules to align with profile-aware tooling
- docker-compose: minor gateway/workspace notes

Made-with: Cursor
2026-03-30 13:27:46 -04:00
7901f090f9 Add per-agent workspaces and MCP/skills backlog doc
- Add agent_workspaces/{ilia,family,wife} skeletons (AGENTS, USER, SOUL, memory)
- Add scripts/init-agent-workspaces.sh to populate ~/.nanobot/workspaces/
- Mount ~/.nanobot/workspaces/{ilia,family,wife} in multi compose as /workspace
- Document Step 0 and layout in DOCKER_MULTI_BOT_GUIDE.md
- Track docs/mcp_and_skills_backlog.md (force-add; docs/ is gitignored)

Made-with: Cursor
2026-03-30 13:20:38 -04:00
d50183c3d7 feat(agent): tool profiles and LLM router (rebased on docker/merge base)
- tools.toolProfiles / tools.toolRouting in config; filter tools per turn
- Router picks profile; expandOnMissingTool widens to full registry once
- Wire gateway and CLI AgentLoop; ToolRegistry.get_definitions_subset
- Ruff: fix tool_routing exception handling and format touched files

Made-with: Cursor
2026-03-27 14:30:15 -04:00
a2ae3f0cea style: satisfy Ruff I001/W293 in agent package
- isort: reorder imports in __init__.py and loop.py
- Strip trailing whitespace on blank lines in context.py
- Use module-level ExecToolConfig/CronService and unquoted annotations in loop.py (fix F821)

Made-with: Cursor
2026-03-27 14:24:06 -04:00
5292f91548 Merge remote-tracking branch 'origin/feature/cleanup-providers-llama-only' into test-ci 2026-03-27 13:26:34 -04:00
1faabed3f1 Merge pull request 'Enhance CI workflow: add installation verification for key dependencies' (#3) from test-ci into feature/cleanup-providers-llama-only
Reviewed-on: #3
2026-03-27 12:23:30 -05:00
3a827cd0f8 Update .gitignore to include environment variable files with wildcard support 2026-03-27 13:07:13 -04:00
4f50cfac3c Add multi-bot Docker setup and improve MCP/tool reliability
Document and add multi-bot Docker workflows with env layering scripts, and update agent/tool configuration handling to make MCP/email/calendar behavior more robust for day-to-day operations.

Made-with: Cursor
2026-03-27 13:06:24 -04:00
daeeec7756 Add attachment_name filter and improve email attachment handling
- Add attachment_name parameter to filter emails by attachment filename (case-insensitive)
- Fix download_attachments parameter handling (was being filtered out)
- Improve attachment filename matching with Gmail-style prefix support
- Add comprehensive logging for attachment download operations
- Increase default limit from 10 to 100 for better attachment searches
- Handle nested parameters and string-to-boolean/int conversions
- Update AGENTS.md with attachment_name filter documentation
2026-03-10 14:34:27 -04:00
a947ffd149 Fix CI workflow: trigger on all PRs and feature branches
Some checks failed
CI / Lint with ruff (pull_request) Failing after 1m37s
CI / Test Python 3.11 (pull_request) Successful in 1m29s
CI / Test Python 3.12 (pull_request) Successful in 1m50s
CI / Build package (pull_request) Has been skipped
2026-03-06 14:01:20 -05:00
44366f96f1 Enhance CI workflow: add installation verification for key dependencies
- Include installation of nanobot with all dependencies and dev dependencies
- Add verification step to ensure key dependencies (pytest, ruff, pydantic, typer, litellm) are installed
2026-03-06 13:30:24 -05:00
98d7c0b788 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
2026-03-06 13:20:19 -05:00
2e71c21094 Enhance CalendarTool: include additional event details in output
- Request additional fields for events, including attendees and attachments
- Extract and display event end time, location, attendees, attachments, and description in the event listing
- Truncate long descriptions for better readability
2026-03-06 13:07:15 -05:00
5d80eab8bd Add CI workflow for testing and linting 2026-03-06 13:07:06 -05:00
6364a195c5 Calendar integration: add timezone config and fix tool call parsing
- Add timezone field to CalendarConfig for local timezone support
- Update CustomProvider to parse calendar tool calls from JSON in LLM responses
- Add pytz dependency to pyproject.toml for timezone handling
2026-03-06 12:42:45 -05:00
bc53dc6535 Fix calendar tool: improve event deletion and time parsing
- Add validation for placeholder event IDs in delete_event action
- Fix time parsing in update_event to use original event's date context
- Add _parse_time_with_date helper for date-aware time parsing
- Improve error messages to be more directive (STOP, DO NOT EXPLAIN)
- Update tool description to emphasize immediate execution
- Fix duration calculation in update_event to use original start/end times
- Improve list_events output with numbered events and LAST/FIRST markers
- Update AGENTS.md with explicit deletion workflow instructions
- Remove reference to non-existent delete_events_today action
2026-03-06 12:42:27 -05:00
bc5f169bc8 Fix calendar tool execution: add calendar to CustomProvider valid_tools list
- Added calendar and other missing tools to valid_tools whitelist in CustomProvider
- This fixes issue where calendar tool calls were shown in response instead of being executed
- Also added edit_file, cron, email to the whitelist for completeness
2026-03-05 16:52:34 -05:00
760a7d776e Add Google Calendar integration
- Add CalendarConfig to schema with OAuth2 credentials support
- Implement CalendarTool with list_events, create_event, and check_availability actions
- Add email parser utility for extracting meeting information from emails
- Register calendar tool in agent loop (auto-loaded when enabled)
- Add calendar skill documentation
- Update AGENTS.md with calendar integration instructions
- Add CALENDAR_SETUP.md with complete setup guide
- Add Google Calendar API dependencies to pyproject.toml
- Support time parsing for 'today', 'tomorrow', and various time formats (12/24-hour, am/pm)
- Ensure timezone-aware datetime handling for Google Calendar API compatibility
2026-03-05 16:29:33 -05:00
f39325c846 Fix spawn tool: clarify that task parameter must be natural language, not tool call syntax 2026-03-05 15:27:25 -05:00
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
edb409bb0c Improve web search and cron scheduling
- Update web search to use ddgs package (renamed from duckduckgo_search)
- Add ddgs>=9.0.0 to dependencies in pyproject.toml
- Fix cron tool to handle recurring jobs with duration limits
  - When both every_seconds and in_seconds are provided, create multiple
    one-time jobs instead of ignoring every_seconds
  - Fixes issue where 'remind me every X for Y duration' only created
    a single reminder instead of multiple recurring reminders
2026-03-04 13:18:13 -05:00
192b975861 Merge pull request 'fix-cron-scheduled-tasks' (#1) from fix-cron-scheduled-tasks into feature/cleanup-providers-llama-only
Reviewed-on: #1
2026-03-04 12:04:57 -05:00
71 changed files with 7464 additions and 319 deletions

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

@ -0,0 +1,102 @@
name: CI
on:
push:
branches: [ main, master, develop, feature/** ]
pull_request:
# Trigger on all pull requests regardless of target branch
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
# Install nanobot with all dependencies and dev dependencies
pip install -e ".[dev]"
# Verify key dependencies are installed
pip list | grep -E "(pytest|ruff|pydantic|typer|litellm)"
- 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

6
.gitignore vendored
View File

@ -1,5 +1,6 @@
.assets
.env
.env.*
*.pyc
dist/
build/
@ -21,3 +22,8 @@ poetry.lock
.pytest_cache/
botpy.log
tests/
# Local-cloned MCP servers (kept out of git; clone/build locally)
mcp-servers/*
!mcp-servers/README.md
!mcp-servers/.gitkeep

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

113
DEVELOPMENT_WITH_DOCKER.md Normal file
View File

@ -0,0 +1,113 @@
# Developing Nanobot with Docker
## Current Setup (Production)
**`docker-compose.multi.env.yml`** - Production mode:
- Code is **copied** into Docker image during build
- Changes to source code **NOT** picked up automatically
- Need to rebuild image: `docker compose -f docker-compose.multi.env.yml build`
## Development Setup
**`docker-compose.multi.dev.yml`** - Development mode:
- Source code is **mounted** as volume
- Changes to `nanobot/` directory **picked up automatically**
- Just restart container (no rebuild needed)
## How It Works
### Production Mode (Current)
```bash
# 1. Build image (copies code)
docker compose -f docker-compose.multi.env.yml build
# 2. Run container
docker compose -f docker-compose.multi.env.yml up -d
# 3. Make code changes...
# 4. Changes NOT visible - need to rebuild:
docker compose -f docker-compose.multi.env.yml build
docker compose -f docker-compose.multi.env.yml up -d --force-recreate
```
### Development Mode (Recommended for Development)
```bash
# 1. Build image once (for dependencies)
docker compose -f docker-compose.multi.dev.yml build
# 2. Run container
docker compose -f docker-compose.multi.dev.yml up -d
# 3. Make code changes in venv...
# 4. Changes visible immediately - just restart:
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
```
## Workflow
### Option 1: Develop in Venv, Test in Docker (Recommended)
```bash
# Terminal 1: Develop in venv
source venv/bin/activate
# Edit code, test locally if needed
nano nanobot/channels/telegram.py
# Terminal 2: Run Docker in dev mode
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
# After making changes, restart container:
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
# Watch logs:
docker logs -f nanobot-user1-dev
```
### Option 2: Rebuild After Changes (Current)
```bash
# Make changes in venv
source venv/bin/activate
nano nanobot/channels/telegram.py
# Rebuild Docker image
docker compose -f docker-compose.multi.env.yml build nanobot-user1
# Recreate container
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
```
## Important Notes
### Development Mode (`docker-compose.multi.dev.yml`)
- ✅ Changes picked up automatically
- ✅ Faster iteration (no rebuild needed)
- ⚠️ Mounts source code (may have slight performance impact)
- ⚠️ Python needs to reload modules (restart container)
### Production Mode (`docker-compose.multi.env.yml`)
- ✅ Code baked into image (more stable)
- ✅ No performance impact from mounts
- ❌ Need rebuild for every change
- ✅ Better for production deployments
## Quick Reference
```bash
# Development workflow
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
# ... make changes ...
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
# Production workflow
docker compose -f docker-compose.multi.env.yml build nanobot-user1
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
```
## Which Should You Use?
- **Developing code**: Use `docker-compose.multi.dev.yml`
- **Production/staging**: Use `docker-compose.multi.env.yml`
- **Quick testing**: Use venv directly on host

647
DOCKER_MULTI_BOT_GUIDE.md Normal file
View File

@ -0,0 +1,647 @@
# Docker Multi-Bot Setup Guide
Complete guide for running multiple nanobot instances with Docker, each with its own Telegram bot.
## 📋 Table of Contents
1. [Quick Start](#quick-start)
2. [Architecture Overview](#architecture-overview)
3. [Setup Instructions](#setup-instructions)
4. [Running Bots](#running-bots)
5. [Configuration Management](#configuration-management)
6. [Development Workflow](#development-workflow)
7. [Troubleshooting](#troubleshooting)
8. [Reference](#reference)
---
## Quick Start
### Start Only User1 Bot
```bash
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1
```
### Start All Bots
```bash
docker compose -f docker-compose.multi.env.yml up -d
```
### Stop All Bots
```bash
docker compose -f docker-compose.multi.env.yml down
```
### Stop Specific Bot
```bash
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
```
### View Logs
```bash
docker logs -f nanobot-user1
```
---
## Architecture Overview
### How It Works
Each bot runs in its own Docker container with:
- **Separate config directory**: `~/.nanobot-user1`, `~/.nanobot-user2`, etc.
- **Shared env file**: `.env.shared` (common settings)
- **Bot-specific env file**: `.env.user1`, `.env.user2`, etc. (overrides)
- **Isolated environment**: No conflicts between bots
### File Structure
```
nanobot/
├── .env.shared # Shared settings (API keys, model, etc.)
├── .env.user1 # Bot 1 (@ilia) overrides
├── .env.user2 # Bot 2 (@family) overrides
├── .env.user3 # Bot 3 (@wife) overrides
├── agent_workspaces/ # Templates copied by scripts/init-agent-workspaces.sh
├── scripts/init-agent-workspaces.sh
├── docker-compose.multi.env.yml # Production compose file
├── docker-compose.multi.dev.yml # Development compose file
├── ~/.nanobot/workspaces/
│ ├── ilia/ # Mounted as /workspace for user1 — AGENTS.md, memory/, …
│ ├── family/ # user2
│ └── wife/ # user3
├── ~/.nanobot-user1/
│ └── config.json # Bot 1 channel config (Telegram token, allowFrom)
├── ~/.nanobot-user2/
│ └── config.json # Bot 2 channel config
└── ~/.nanobot-user3/
└── config.json # Bot 3 channel config
```
`./workspace` in the repo remains for **single-bot** `docker-compose.yml` only; multi-bot uses `~/.nanobot/workspaces/*` per container.
### Configuration Loading
1. **Docker Compose** loads environment files:
- First: `.env.shared` (shared settings)
- Second: `.env.userX` (bot-specific overrides)
- Later files override earlier ones
2. **Container** mounts config directory:
- Host: `~/.nanobot-user1` → Container: `/root/.nanobot`
3. **Nanobot** loads:
- Environment variables (from Docker env files)
- Config file: `/root/.nanobot/config.json` (mounted from host)
---
## Setup Instructions
### Step 0: Per-agent workspaces (personalities + isolated memory)
Multi-bot compose mounts **separate** workspace directories so each bot has its own `AGENTS.md`, `SOUL.md`, `USER.md`, and `memory/` (no shared `./workspace`).
On the host, from the repo root:
```bash
./scripts/init-agent-workspaces.sh
```
This creates:
```
~/.nanobot/workspaces/
ilia/ # nanobot-user1 — dev / infra persona
family/ # nanobot-user2 — household persona
wife/ # nanobot-user3 — personal assistant persona
```
Templates live in-repo under `agent_workspaces/`. Re-run the script anytime: it **skips** files that already exist. Adjust ownership if Docker runs as root:
```bash
sudo chown -R "$(whoami):$(whoami)" ~/.nanobot/workspaces
```
### Step 1: Create Environment Files
Run the setup script:
```bash
./env-files-setup.sh
```
This creates:
- `.env.shared` - Shared settings
- `.env.user1`, `.env.user2`, `.env.user3` - Bot-specific overrides
### Step 2: Edit `.env.shared`
Add your shared settings:
```bash
nano .env.shared
```
Example:
```bash
# Provider Settings
NANOBOT_PROVIDERS__CUSTOM__API_KEY=no-key
NANOBOT_PROVIDERS__CUSTOM__API_BASE=http://172.17.0.1:11434/v1
# Agent Settings
NANOBOT_AGENTS__DEFAULTS__MODEL=llama3.1:8b
NANOBOT_AGENTS__DEFAULTS__WORKSPACE=/mnt/data/nanobot
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
```
**Important**: Use `172.17.0.1` (Docker bridge gateway) instead of `localhost` so containers can reach Ollama on the host.
### Step 3: Edit Bot-Specific Env Files
Edit `.env.user1`, `.env.user2`, etc. with bot-specific settings:
```bash
nano .env.user1
```
Example:
```bash
# Telegram Bot Token
NANOBOT_CHANNELS__TELEGRAM__ENABLED=true
NANOBOT_CHANNELS__TELEGRAM__TOKEN=your_bot_token_here
# Email Credentials (if different per bot)
NANOBOT_CHANNELS__EMAIL__IMAP_USERNAME=bot1@example.com
NANOBOT_CHANNELS__EMAIL__IMAP_PASSWORD=password
```
### Step 4: Create Config Files
Run the config creation script:
```bash
./create-bot-configs.sh
```
Or manually create `~/.nanobot-user1/config.json`:
```json
{
"channels": {
"telegram": {
"enabled": true,
"allowFrom": ["YOUR_TELEGRAM_USERNAME"]
},
"email": {
"enabled": true,
"allowFrom": ["email@example.com"]
}
}
}
```
**Note**: `allowFrom` arrays must be in `config.json`, NOT in env files.
### Step 5: Build Docker Image
```bash
docker compose -f docker-compose.multi.env.yml build
```
---
## Running Bots
### Start Commands
```bash
# Start only user1
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1
# Start only user2
docker compose -f docker-compose.multi.env.yml up -d nanobot-user2
# Start only user3
docker compose -f docker-compose.multi.env.yml up -d nanobot-user3
# Start all bots
docker compose -f docker-compose.multi.env.yml up -d
```
### Stop Commands
```bash
# Stop only user1
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
# Stop only user2
docker compose -f docker-compose.multi.env.yml stop nanobot-user2
# Stop only user3
docker compose -f docker-compose.multi.env.yml stop nanobot-user3
# Stop all bots
docker compose -f docker-compose.multi.env.yml down
```
### Restart Commands
```bash
# Restart only user1
docker compose -f docker-compose.multi.env.yml restart nanobot-user1
# Restart all bots
docker compose -f docker-compose.multi.env.yml restart
```
### Status Commands
```bash
# Check what's running
docker compose -f docker-compose.multi.env.yml ps
# View logs for user1
docker logs -f nanobot-user1
# View logs for all bots
docker compose -f docker-compose.multi.env.yml logs -f
# View logs for specific bot
docker compose -f docker-compose.multi.env.yml logs -f nanobot-user1
```
### Remove Commands
```bash
# Stop and remove user1 container
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
docker rm nanobot-user1
# Stop and remove all containers
docker compose -f docker-compose.multi.env.yml down
# Remove containers and volumes (keeps config files)
docker compose -f docker-compose.multi.env.yml down -v
```
---
## Configuration Management
### Updating Shared Settings
Edit `.env.shared`:
```bash
nano .env.shared
```
Restart affected containers:
```bash
docker compose -f docker-compose.multi.env.yml restart
```
### Updating Bot-Specific Settings
Edit `.env.userX`:
```bash
nano .env.user1
```
Restart that specific bot:
```bash
docker compose -f docker-compose.multi.env.yml restart nanobot-user1
```
### Updating Config Files
Edit config file:
```bash
nano ~/.nanobot-user1/config.json
```
Restart container:
```bash
docker restart nanobot-user1
```
### Adding Email to allowFrom
```bash
# Add email to user1's allowFrom
jq '.channels.email.allowFrom += ["newemail@example.com"]' \
~/.nanobot-user1/config.json > ~/.nanobot-user1/config.json.tmp && \
mv ~/.nanobot-user1/config.json.tmp ~/.nanobot-user1/config.json
# Restart container
docker restart nanobot-user1
```
### Environment Variable Format
Nanobot uses Pydantic's `BaseSettings` with:
- Prefix: `NANOBOT_`
- Nested delimiter: `__` (double underscore)
Examples:
- `NANOBOT_PROVIDERS__CUSTOM__API_KEY``providers.custom.apiKey`
- `NANOBOT_CHANNELS__TELEGRAM__TOKEN``channels.telegram.token`
- `NANOBOT_AGENTS__DEFAULTS__MODEL``agents.defaults.model`
---
## Development Workflow
### Option 1: Development Mode (Recommended)
Use `docker-compose.multi.dev.yml` which mounts source code:
```bash
# Start in development mode
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
# Make changes in venv
source venv/bin/activate
nano nanobot/channels/telegram.py
# Restart container (picks up changes)
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
# View logs
docker logs -f nanobot-user1-dev
```
### Option 2: Rebuild After Changes
```bash
# Make changes
source venv/bin/activate
nano nanobot/channels/telegram.py
# Rebuild image
docker compose -f docker-compose.multi.env.yml build nanobot-user1
# Recreate container
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
```
### Option 3: Run on Host (Not Docker)
```bash
# Activate venv
source venv/bin/activate
# Run directly
nanobot gateway
```
**Note**: Host command uses:
- Config: `~/.nanobot/config.json` (original)
- .env: `.env` in current directory (NOT `.env.shared`)
---
## Troubleshooting
### Bot Not Responding
1. **Check if container is running**:
```bash
docker ps | grep nanobot-user1
```
2. **Check logs**:
```bash
docker logs nanobot-user1 --tail 50
```
3. **Verify Telegram token**:
```bash
grep TELEGRAM__TOKEN .env.user1
```
4. **Check allowFrom**:
```bash
cat ~/.nanobot-user1/config.json | jq '.channels.telegram.allowFrom'
```
### Connection Error (Ollama)
**Problem**: "Connection error" when bot tries to use LLM
**Solution**:
1. Check Ollama is running:
```bash
curl http://localhost:11434/api/tags
```
2. Verify API_BASE in `.env.shared`:
```bash
grep API_BASE .env.shared
```
Should be: `http://172.17.0.1:11434/v1` (NOT `localhost`)
3. Restart container:
```bash
docker restart nanobot-user1
```
### Config Not Loading
1. **Check volume mount**:
```bash
docker inspect nanobot-user1 | grep -A 5 Mounts
```
2. **Verify config exists**:
```bash
ls -lh ~/.nanobot-user1/config.json
```
3. **Check inside container**:
```bash
docker exec nanobot-user1 cat /root/.nanobot/config.json
```
### Environment Variables Not Applied
1. **Check env files are loaded**:
```bash
docker exec nanobot-user1 env | grep NANOBOT
```
2. **Recreate container** (env files loaded at creation):
```bash
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
```
### Port Already in Use
If port conflicts:
```bash
# Check what's using the port
sudo lsof -i :18790
# Stop conflicting container
docker stop <container_name>
# Or change port in docker-compose.multi.env.yml
```
---
## Reference
### Docker Compose Files
| File | Purpose | Use Case | Env Loading |
|------|---------|----------|-------------|
| `docker-compose.yml` | Single-bot baseline | One gateway + optional CLI, simplest setup | No `env_file`; uses mounted `~/.nanobot/config.json` and container environment |
| `docker-compose.multi.yml` | Multi-bot baseline | Multiple bots with separate config dirs, minimal env indirection | No `env_file`; each bot uses mounted `~/.nanobot-userX/config.json` and container environment |
| `docker-compose.multi.env.yml` | Multi-bot production | Stable multi-bot deployments with shared + per-bot overrides | Loads `.env.shared` first, then `.env.userX` (later file overrides earlier) |
| `docker-compose.multi.dev.yml` | Multi-bot development | Active code development with source mounted into containers | Same env behavior as `multi.env` (`.env.shared` + `.env.userX`) |
### Container Names
- `nanobot-user1` - Bot 1 container (production)
- `nanobot-user1-dev` - Bot 1 container (development)
- `nanobot-user2` - Bot 2 container
- `nanobot-user3` - Bot 3 container
### Ports
- User 1: `18790` (host) → `18790` (container)
- User 2: `18791` (host) → `18790` (container)
- User 3: `18792` (host) → `18790` (container)
### Config Locations
| Location | Used By | Purpose |
|----------|---------|---------|
| `~/.nanobot/config.json` | Host `nanobot gateway` | Original config (not used by Docker) |
| `~/.nanobot-user1/config.json` | Docker user1 | Bot 1 config (mounted into container) |
| `~/.nanobot-user2/config.json` | Docker user2 | Bot 2 config |
| `~/.nanobot-user3/config.json` | Docker user3 | Bot 3 config |
### Environment Files
| File | Loaded By | Purpose |
|------|-----------|---------|
| `.env.shared` | All Docker containers | Shared settings (API keys, model, etc.) |
| `.env.user1` | Docker user1 only | Bot 1 specific overrides |
| `.env.user2` | Docker user2 only | Bot 2 specific overrides |
| `.env.user3` | Docker user3 only | Bot 3 specific overrides |
| `.env` | Host `nanobot gateway` | Host environment (not used by Docker) |
### Quick Command Reference
```bash
# === START ===
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1 # Start user1
docker compose -f docker-compose.multi.env.yml up -d # Start all
# === STOP ===
docker compose -f docker-compose.multi.env.yml stop nanobot-user1 # Stop user1
docker compose -f docker-compose.multi.env.yml down # Stop all
# === RESTART ===
docker compose -f docker-compose.multi.env.yml restart nanobot-user1 # Restart user1
docker compose -f docker-compose.multi.env.yml restart # Restart all
# === LOGS ===
docker logs -f nanobot-user1 # View logs
docker compose -f docker-compose.multi.env.yml logs -f nanobot-user1 # View logs
# === STATUS ===
docker compose -f docker-compose.multi.env.yml ps # List containers
docker ps | grep nanobot # List containers
# === REBUILD ===
docker compose -f docker-compose.multi.env.yml build nanobot-user1 # Rebuild user1
docker compose -f docker-compose.multi.env.yml build # Rebuild all
# === RECREATE (after config changes) ===
docker compose -f docker-compose.multi.env.yml up -d --force-recreate nanobot-user1
```
---
## Common Tasks
### Add a New Bot (User4)
1. Create config directory:
```bash
mkdir -p ~/.nanobot-user4
```
2. Create config file:
```bash
cat > ~/.nanobot-user4/config.json << 'EOF'
{
"channels": {
"telegram": {
"enabled": true,
"allowFrom": ["USERNAME"]
}
}
}
EOF
```
3. Create env file:
```bash
cp .env.user1 .env.user4
nano .env.user4 # Edit with bot-specific settings
```
4. Add to `docker-compose.multi.env.yml`:
```yaml
nanobot-user4:
# ... (copy from nanobot-user1, change port to 18793)
```
5. Start:
```bash
docker compose -f docker-compose.multi.env.yml up -d nanobot-user4
```
### Update Ollama API Base
If Ollama IP changes:
```bash
# Edit .env.shared
sed -i 's|http://172.17.0.1:11434|http://NEW_IP:11434|g' .env.shared
# Restart all containers
docker compose -f docker-compose.multi.env.yml restart
```
### Backup Configurations
```bash
# Backup all configs
tar -czf nanobot-configs-backup-$(date +%Y%m%d).tar.gz \
~/.nanobot-user* \
.env.shared .env.user*
# Restore
tar -xzf nanobot-configs-backup-YYYYMMDD.tar.gz
```
---
## Notes
- **Original config** (`~/.nanobot/config.json`) is NOT used by Docker containers
- **Host venv** is NOT needed to run Docker commands (Docker builds its own environment)
- **Environment variables** override config file settings
- **Arrays** (like `allowFrom`) must be in `config.json`, NOT in env files
- **Ollama API_BASE** must use `172.17.0.1` (Docker bridge) not `localhost`
---
## See Also
- `ENV_FILES_GUIDE.md` - Detailed env file management
- `DEVELOPMENT_WITH_DOCKER.md` - Development workflow details
- `SETUP_SUMMARY.md` - Initial setup summary
- `VERIFY_DOCKER_SETUP.md` - Verification checklist

241
ENV_FILES_GUIDE.md Normal file
View File

@ -0,0 +1,241 @@
# Using Separate Env Files Per Container
This guide shows you how to use separate `.env` files for each bot container, making it easy to manage both shared and bot-specific settings.
## How It Works
Docker Compose loads `env_file` entries in order. Later files override earlier ones:
1. **`.env.shared`** - Loaded first, contains common settings
2. **`.env.user1`, `.env.user2`, `.env.user3`** - Loaded after, can override shared settings
This means:
- ✅ Shared settings go in `.env.shared` (update once)
- ✅ Bot-specific overrides go in `.env.user1`, `.env.user2`, etc. (only if needed)
- ✅ Easy to edit (plain text files, no JSON)
## Quick Setup
### Step 1: Create Env Files
Run the setup script:
```bash
chmod +x env-files-setup.sh
./env-files-setup.sh
```
This creates:
- `.env.shared` - Shared settings for all bots
- `.env.user1` - Overrides for bot 1 (optional)
- `.env.user2` - Overrides for bot 2 (optional)
- `.env.user3` - Overrides for bot 3 (optional)
### Step 2: Edit `.env.shared`
Edit `.env.shared` with your shared settings:
```bash
# Shared configuration for all nanobot instances
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-your-actual-key
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
```
### Step 3: Edit Bot-Specific Files (Optional)
If a bot needs different settings, edit its `.env.userX` file:
**`.env.user1`** (example - override model for bot 1):
```bash
# Override model for this bot only
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-sonnet-4
```
**`.env.user2`** (example - override temperature):
```bash
# Override temperature for this bot only
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.9
```
**`.env.user3`** (example - leave empty to use all shared settings):
```bash
# This bot uses all settings from .env.shared
# No overrides needed
```
### Step 4: Create Minimal Config Files
Each bot still needs a minimal `config.json` with bot-specific channel settings:
**`~/.nanobot-user1/config.json`**:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "BOT_TOKEN_FOR_USER1",
"allowFrom": ["USER1_TELEGRAM_ID"]
}
}
}
```
**`~/.nanobot-user2/config.json`**:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "BOT_TOKEN_FOR_USER2",
"allowFrom": ["USER2_TELEGRAM_ID"]
}
}
}
```
### Step 5: Run with Docker Compose
```bash
docker compose -f docker-compose.multi.env.yml up -d
```
## File Structure
```
nanobot/
├── .env.shared ← Shared settings (API keys, model, etc.)
├── .env.user1 ← Bot 1 overrides (optional)
├── .env.user2 ← Bot 2 overrides (optional)
├── .env.user3 ← Bot 3 overrides (optional)
├── docker-compose.multi.env.yml
└── ~/.nanobot-user1/
└── config.json ← Bot 1 channel config (Telegram token, user ID)
└── ~/.nanobot-user2/
└── config.json ← Bot 2 channel config
└── ~/.nanobot-user3/
└── config.json ← Bot 3 channel config
```
## Examples
### Example 1: All Bots Use Same Settings
**`.env.shared`**:
```bash
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
```
**`.env.user1`**, **`.env.user2`**, **`.env.user3`**:
```bash
# Empty - all bots use shared settings
```
### Example 2: One Bot Uses Different Model
**`.env.shared`**:
```bash
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
```
**`.env.user1`**:
```bash
# Bot 1 uses a different model
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-sonnet-4
```
**`.env.user2`**, **`.env.user3`**:
```bash
# Empty - use shared model
```
### Example 3: One Bot Uses Different API Key
**`.env.shared`**:
```bash
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-shared-key
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
```
**`.env.user2`**:
```bash
# Bot 2 uses its own API key
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-user2-key
```
## Updating Settings
### Update Shared Settings
Edit `.env.shared` and restart all containers:
```bash
# Edit shared settings
nano .env.shared
# Restart all bots
docker compose -f docker-compose.multi.env.yml restart
```
### Update Bot-Specific Settings
Edit the specific `.env.userX` file and restart that bot:
```bash
# Edit bot 1's settings
nano .env.user1
# Restart only bot 1
docker restart nanobot-user1
```
## Environment Variable Format
Nanobot uses Pydantic's `BaseSettings` with:
- Prefix: `NANOBOT_`
- Nested delimiter: `__` (double underscore)
Examples:
- `NANOBOT_PROVIDERS__OPENROUTER__API_KEY``providers.openrouter.apiKey`
- `NANOBOT_AGENTS__DEFAULTS__MODEL``agents.defaults.model`
- `NANOBOT_AGENTS__DEFAULTS__TEMPERATURE``agents.defaults.temperature`
## Advantages
**Easy to edit** - Plain text files, no JSON syntax
**Clear separation** - Shared vs bot-specific settings
**Flexible** - Override only what you need
**Version control friendly** - Can commit `.env.shared`, ignore `.env.userX` if they contain secrets
**No config.json editing** - Only edit env files for most changes
## Troubleshooting
### Settings Not Applied
1. Check if env files exist:
```bash
ls -la .env.*
```
2. Check what's loaded in container:
```bash
docker exec nanobot-user1 env | grep NANOBOT
```
3. Restart container after changes:
```bash
docker restart nanobot-user1
```
### Override Not Working
Remember: Later files override earlier ones. If `.env.user1` has a setting but it's not applied, check:
- Is the variable name correct? (use `__` not `.`)
- Did you restart the container?
- Is the setting in `.env.shared` overriding it? (remove from `.env.shared` if you want bot-specific only)

View File

@ -0,0 +1,222 @@
# Managing Multiple Bot Configs Efficiently
Yes, with Docker you'll need to manage multiple config files, but here are strategies to make it easier:
## The Challenge
When you have 3 bots, you have 3 config files:
- `~/.nanobot-user1/config.json`
- `~/.nanobot-user2/config.json`
- `~/.nanobot-user3/config.json`
If you need to change something like:
- API key (shared across all bots)
- Model settings (might be shared)
- Tool configurations (might be shared)
You'd normally need to edit all 3 files.
## Solution 1: Use Environment Variables for Shared Settings
Nanobot supports environment variables! You can set shared settings via environment variables in Docker.
### Update docker-compose.multi.yml
```yaml
services:
nanobot-user1:
# ... existing config ...
environment:
# Shared settings - set once, applies to all
NANOBOT_PROVIDERS__OPENROUTER__API_KEY: "sk-or-v1-xxx"
NANOBOT_AGENTS__DEFAULTS__MODEL: "anthropic/claude-opus-4-5"
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE: "0.7"
# Bot-specific settings still in config.json
volumes:
- ~/.nanobot-user1:/root/.nanobot
nanobot-user2:
# ... same environment variables ...
environment:
NANOBOT_PROVIDERS__OPENROUTER__API_KEY: "sk-or-v1-xxx"
NANOBOT_AGENTS__DEFAULTS__MODEL: "anthropic/claude-opus-4-5"
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE: "0.7"
volumes:
- ~/.nanobot-user2:/root/.nanobot
```
**Better yet**, use a shared `.env` file:
### Create `.env.shared`:
```bash
# Shared settings for all bots
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
```
### Update docker-compose.multi.yml:
```yaml
services:
nanobot-user1:
env_file:
- .env.shared # Load shared settings
volumes:
- ~/.nanobot-user1:/root/.nanobot
# ... rest of config ...
nanobot-user2:
env_file:
- .env.shared # Same shared settings
volumes:
- ~/.nanobot-user2:/root/.nanobot
# ... rest of config ...
```
Now you only update `.env.shared` for shared settings!
## Solution 2: Minimal Configs + Environment Variables
Keep only bot-specific settings in config files:
**`~/.nanobot-user1/config.json`** (minimal):
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "BOT_TOKEN_1",
"allowFrom": ["USER_ID_1"]
}
}
}
```
**`~/.nanobot-user2/config.json`** (minimal):
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "BOT_TOKEN_2",
"allowFrom": ["USER_ID_2"]
}
}
}
```
Everything else comes from `.env.shared`!
## Solution 3: Use the Update Script
I've created `update-multi-configs.sh` to batch-update configs:
```bash
# Update API key in all configs
./update-multi-configs.sh update-api-key openrouter "sk-or-v1-new-key"
# Update model in all configs
./update-multi-configs.sh update-model "anthropic/claude-opus-4-5"
# Update any setting
./update-multi-configs.sh update-setting "agents.defaults.temperature" "0.8"
```
Requires `jq` to be installed:
```bash
sudo apt install jq # Linux
brew install jq # macOS
```
## Solution 4: Base Config Template
Create a template and only override what's different:
**`~/.nanobot-base/config.json`** (template):
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
},
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5",
"temperature": 0.7
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "REPLACE_WITH_BOT_TOKEN",
"allowFrom": ["REPLACE_WITH_USER_ID"]
}
}
}
```
Then create bot-specific configs by copying and modifying:
```bash
cp ~/.nanobot-base/config.json ~/.nanobot-user1/config.json
# Edit only the telegram token and user ID
cp ~/.nanobot-base/config.json ~/.nanobot-user2/config.json
# Edit only the telegram token and user ID
```
## Recommended Approach
**Use Solution 1 (Environment Variables)** - it's the cleanest:
1. Create `.env.shared` with all shared settings
2. Keep minimal configs with only bot-specific settings (Telegram tokens/user IDs)
3. Update `.env.shared` when you need to change shared settings
4. Restart containers to apply changes
This way:
- ✅ Shared settings: Update once in `.env.shared`
- ✅ Bot-specific settings: Only in each config.json
- ✅ Easy to manage and maintain
## Example: Complete Setup
**`.env.shared`**:
```bash
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
```
**`~/.nanobot-user1/config.json`**:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "1234567890:ABC...",
"allowFrom": ["123456789"]
}
}
}
```
**`docker-compose.multi.yml`**:
```yaml
services:
nanobot-user1:
env_file:
- .env.shared
volumes:
- ~/.nanobot-user1:/root/.nanobot
# ... rest ...
```
Now when you need to change the API key or model, just edit `.env.shared` and restart!

261
MULTI_BOT_SETUP.md Normal file
View File

@ -0,0 +1,261 @@
# Running Multiple Nanobot Gateways with Docker
This guide shows you how to run multiple nanobot gateway instances, each with its own Telegram bot.
## Quick Start
### Step 1: Setup Config Directories
Run the setup script:
```bash
./multi-bot-setup.sh
```
Or manually:
```bash
# Create directories
mkdir -p ~/.nanobot-user1
mkdir -p ~/.nanobot-user2
mkdir -p ~/.nanobot-user3
# Copy your base config (if you have one)
cp ~/.nanobot/config.json ~/.nanobot-user1/config.json
cp ~/.nanobot/config.json ~/.nanobot-user2/config.json
cp ~/.nanobot/config.json ~/.nanobot-user3/config.json
```
### Step 2: Configure Each Bot
Edit each config file with different Telegram bot tokens:
**`~/.nanobot-user1/config.json`:**
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
},
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz",
"allowFrom": ["123456789"]
}
}
}
```
**`~/.nanobot-user2/config.json`:**
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
},
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "9876543210:XYZabcDEFghiJKLmnopQRSuvwx",
"allowFrom": ["987654321"]
}
}
}
```
**`~/.nanobot-user3/config.json`:**
```json
{
"providers": {
"openrouter": {
"apiKey": "sk-or-v1-xxx"
}
},
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "5551234567:LMNopqRSTuvwXYZabcdefGHIjkl",
"allowFrom": ["555123456"]
}
}
}
```
### Step 3: Run with Docker Compose (Recommended)
```bash
# Build the image (first time only)
docker compose -f docker-compose.multi.yml build
# Start all bots
docker compose -f docker-compose.multi.yml up -d
# View logs
docker compose -f docker-compose.multi.yml logs -f
# Stop all bots
docker compose -f docker-compose.multi.yml down
# Start/stop individual bots
docker compose -f docker-compose.multi.yml start nanobot-user1
docker compose -f docker-compose.multi.yml stop nanobot-user2
```
### Step 4: Run with Docker Run Commands (Alternative)
If you prefer `docker run` commands:
```bash
# Build the image first
docker build -t nanobot .
# Run bot 1
docker run -d \
--name nanobot-user1 \
-v ~/.nanobot-user1:/root/.nanobot \
-p 18790:18790 \
--restart unless-stopped \
nanobot gateway
# Run bot 2
docker run -d \
--name nanobot-user2 \
-v ~/.nanobot-user2:/root/.nanobot \
-p 18791:18790 \
--restart unless-stopped \
nanobot gateway
# Run bot 3
docker run -d \
--name nanobot-user3 \
-v ~/.nanobot-user3:/root/.nanobot \
-p 18792:18790 \
--restart unless-stopped \
nanobot gateway
```
## Managing Containers
### View Logs
```bash
# All bots
docker compose -f docker-compose.multi.yml logs -f
# Specific bot
docker logs -f nanobot-user1
# Or with docker-compose
docker compose -f docker-compose.multi.yml logs -f nanobot-user1
```
### Stop/Start Containers
```bash
# Stop all
docker compose -f docker-compose.multi.yml down
# Start all
docker compose -f docker-compose.multi.yml up -d
# Restart specific bot
docker restart nanobot-user1
# Stop specific bot
docker stop nanobot-user1
docker start nanobot-user1
```
### Check Status
```bash
# List all running containers
docker ps | grep nanobot
# Check logs for errors
docker logs nanobot-user1 --tail 50
```
## Port Mapping
Each bot uses a different host port:
- **User 1**: Port `18790` → Container port `18790`
- **User 2**: Port `18791` → Container port `18790`
- **User 3**: Port `18792` → Container port `18790`
The gateway port inside the container is always `18790`, but mapped to different host ports to avoid conflicts.
## Creating Telegram Bots
For each user, create a separate bot:
1. Open Telegram, search `@BotFather`
2. Send `/newbot`
3. Follow prompts to create a bot
4. Copy the token
5. Add it to the respective config file
## Troubleshooting
### Bot not responding
```bash
# Check if container is running
docker ps | grep nanobot-user1
# Check logs for errors
docker logs nanobot-user1
# Verify config is correct
cat ~/.nanobot-user1/config.json | jq '.channels.telegram'
```
### Port already in use
If you get port conflicts, change the port mappings in `docker-compose.multi.yml`:
```yaml
ports:
- "18890:18790" # Change 18790 to 18890
```
### Config not loading
Make sure the volume mount is correct:
```bash
# Verify config exists
ls -la ~/.nanobot-user1/config.json
# Check if it's readable
docker exec nanobot-user1 cat /root/.nanobot/config.json
```
## Adding More Bots
To add more bots:
1. Create new directory: `mkdir -p ~/.nanobot-user4`
2. Copy config: `cp ~/.nanobot-user1/config.json ~/.nanobot-user4/config.json`
3. Edit config with new bot token
4. Add new service to `docker-compose.multi.yml` (copy existing service, change name and port)
5. Run: `docker compose -f docker-compose.multi.yml up -d nanobot-user4`

106
QUICK_REFERENCE.md Normal file
View File

@ -0,0 +1,106 @@
# Quick Reference Card
## 🚀 Most Common Commands
### Start/Stop
```bash
# Start user1 only
docker compose -f docker-compose.multi.env.yml up -d nanobot-user1
# Start all bots
docker compose -f docker-compose.multi.env.yml up -d
# Stop user1 only
docker compose -f docker-compose.multi.env.yml stop nanobot-user1
# Stop all bots
docker compose -f docker-compose.multi.env.yml down
# Restart user1
docker compose -f docker-compose.multi.env.yml restart nanobot-user1
```
### Logs
```bash
# View logs (follow)
docker logs -f nanobot-user1
# View last 50 lines
docker logs --tail 50 nanobot-user1
```
### Status
```bash
# Check what's running
docker compose -f docker-compose.multi.env.yml ps
# Check specific container
docker ps | grep nanobot-user1
```
### Configuration
```bash
# Edit shared settings
nano .env.shared
docker compose -f docker-compose.multi.env.yml restart
# Edit bot-specific settings
nano .env.user1
docker restart nanobot-user1
# Edit config file
nano ~/.nanobot-user1/config.json
docker restart nanobot-user1
```
### Development
```bash
# Use dev mode (mounts source code)
docker compose -f docker-compose.multi.dev.yml up -d nanobot-user1
# After code changes
docker compose -f docker-compose.multi.dev.yml restart nanobot-user1
```
## 📁 File Locations
| What | Where |
|------|-------|
| Shared settings | `.env.shared` |
| Bot 1 settings | `.env.user1` |
| Bot 1 config | `~/.nanobot-user1/config.json` |
| Production compose | `docker-compose.multi.env.yml` |
| Dev compose | `docker-compose.multi.dev.yml` |
## 🧭 Which Compose File?
| File | Best For | Uses env files? |
|------|----------|-----------------|
| `docker-compose.yml` | Single bot (`nanobot-gateway` + optional `nanobot-cli`) | No |
| `docker-compose.multi.yml` | Multi-bot with per-user config directories | No |
| `docker-compose.multi.env.yml` | Multi-bot with shared + per-user env overrides (recommended) | Yes: `.env.shared` then `.env.userX` |
| `docker-compose.multi.dev.yml` | Same as above, but with source code mounted for development | Yes: `.env.shared` then `.env.userX` |
## 🔧 Troubleshooting
```bash
# Bot not responding?
docker logs nanobot-user1 --tail 50
# Connection error?
grep API_BASE .env.shared # Should be 172.17.0.1:11434
# Config not loading?
docker exec nanobot-user1 cat /root/.nanobot/config.json
```
## 📖 Full Documentation
See `DOCKER_MULTI_BOT_GUIDE.md` for complete guide.

View File

@ -742,6 +742,8 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external tool servers and use them as native agent tools.
For a full Gmail MCP walkthrough (config + OAuth + verification), see [`docs/gmail_mcp_setup.md`](docs/gmail_mcp_setup.md).
Add MCP servers to your `config.json`:
```json
@ -827,6 +829,15 @@ vim ~/.nanobot/config.json # add API keys
docker compose up -d nanobot-gateway # start gateway
```
#### Which Compose File To Use?
| File | Scenario | Notes |
|------|----------|-------|
| `docker-compose.yml` | Single-bot local usage | One gateway + optional CLI, mounts `~/.nanobot` |
| `docker-compose.multi.yml` | Multi-bot with separate per-user config directories | No `env_file`; use `~/.nanobot-userX/config.json` per bot |
| `docker-compose.multi.env.yml` | Multi-bot with shared and per-user environment overrides | Loads `.env.shared` then `.env.userX` (recommended for multi-bot ops) |
| `docker-compose.multi.dev.yml` | Multi-bot development | Same env layering as `multi.env`, plus source mount for live code iteration |
```bash
docker compose run --rm nanobot-cli agent -m "Hello!" # run CLI
docker compose logs -f nanobot-gateway # view logs

116
SETUP_SUMMARY.md Normal file
View File

@ -0,0 +1,116 @@
# Multi-Bot Docker Setup Summary
## ✅ Setup Complete
### Files Created
1. **Environment Files:**
- `.env.shared` - Shared settings (providers, agents, tools, gateway)
- `.env.user1` - Bot 1 specific settings (Telegram token, email credentials)
- `.env.user2` - Bot 2 specific settings (placeholder)
- `.env.user3` - Bot 3 specific settings (placeholder)
2. **Config Files:**
- `~/.nanobot-user1/config.json` - Bot 1 channel config (allowFrom arrays)
- `~/.nanobot-user2/config.json` - Bot 2 channel config (placeholder)
- `~/.nanobot-user3/config.json` - Bot 3 channel config (placeholder)
3. **Docker Compose:**
- `docker-compose.multi.env.yml` - Multi-bot Docker Compose configuration
## 🔍 How It Works
### Configuration Loading Order
1. **Docker Compose loads environment files:**
- First: `.env.shared` (shared settings)
- Second: `.env.user1` (bot-specific overrides)
- Later files override earlier ones ✅
2. **Container starts with volume mount:**
- Host: `~/.nanobot-user1`
- Container: `/root/.nanobot`
- This maps your config directory into the container ✅
3. **Nanobot loads configuration:**
- **Environment variables**: Automatically loaded by Pydantic `BaseSettings`
- Format: `NANOBOT_CHANNELS__TELEGRAM__TOKEN=...`
- These come from Docker environment (loaded from `.env.shared` + `.env.user1`)
- **Config file**: Loaded from `/root/.nanobot/config.json`
- Inside container: `/root/.nanobot/config.json`
- On host: `~/.nanobot-user1/config.json` (mounted)
- Contains: `allowFrom` arrays (can't be in env vars)
### Important: Original Config is NOT Used
**Your original config** (`~/.nanobot/config.json`) is **NOT** used by Docker containers.
Each container uses:
- Its own env files (`.env.shared` + `.env.userX`)
- Its own config directory (`~/.nanobot-userX`)
This means:
- ✅ Original config stays untouched
- ✅ Each bot has isolated configuration
- ✅ No conflicts between bots
## 📋 Current Configuration
### Bot 1 (nanobot-user1)
**Environment Variables** (from `.env.shared` + `.env.user1`):
- Provider: Custom/Ollama (`http://localhost:11434/v1`)
- Model: `llama3.1:8b`
- Workspace: `/mnt/data/nanobot`
- Telegram: Enabled with token
- Email: Enabled with credentials
**Config File** (`~/.nanobot-user1/config.json`):
- Telegram `allowFrom`: `["TADec2023"]`
- Email `allowFrom`: `["adayear2025@gmail.com"]`
## 🚀 Running the Bots
```bash
# Build Docker image (first time)
docker compose -f docker-compose.multi.env.yml build
# Start all bots
docker compose -f docker-compose.multi.env.yml up -d
# View logs
docker compose -f docker-compose.multi.env.yml logs -f
# Stop all bots
docker compose -f docker-compose.multi.env.yml down
# Restart specific bot
docker restart nanobot-user1
```
## ✅ Verification Checklist
- [x] `.env.shared` created with shared settings
- [x] `.env.user1` created with bot-specific settings
- [x] `ALLOW_FROM` removed from env files (arrays belong in config.json)
- [x] Config directories created (`~/.nanobot-user1`, etc.)
- [x] Config files created with `allowFrom` arrays
- [x] Docker Compose file configured correctly
- [x] Volume mounts map host configs to container paths
## 🎯 Key Points
1. **Environment Variables** → For simple key-value settings (tokens, API keys, models)
2. **Config Files** → For complex settings (arrays like `allowFrom`)
3. **Docker Mounts** → Each container gets its own config directory
4. **Original Config** → Not used by Docker containers (stays safe)
## 📝 Next Steps
1. Update `.env.user2` and `.env.user3` with bot-specific settings
2. Update `~/.nanobot-user2/config.json` and `~/.nanobot-user3/config.json` with actual user IDs/emails
3. Run `docker compose -f docker-compose.multi.env.yml up -d` to start all bots
4. Check logs to verify each bot is using correct configuration

125
VERIFY_DOCKER_SETUP.md Normal file
View File

@ -0,0 +1,125 @@
# Verifying Docker Multi-Bot Setup
## ✅ Configuration Check
### 1. Environment Files
**`.env.shared`** - Contains:
- ✅ Provider settings (custom/Ollama)
- ✅ Agent defaults (model, workspace, temperature, etc.)
- ✅ Tool settings
- ✅ Gateway settings
- ✅ Email IMAP/SMTP settings (shared)
**`.env.user1`** - Contains:
- ✅ Telegram token (bot-specific)
- ✅ Email credentials (bot-specific override)
- ⚠️ **Issue**: `ALLOW_FROM` should NOT be in env files (arrays don't work in env vars)
### 2. Config Files
**`~/.nanobot-user1/config.json`** - Contains:
- ✅ Telegram `allowFrom` array (correct location)
- ✅ Email `allowFrom` array (correct location)
### 3. Docker Compose
**`docker-compose.multi.env.yml`** - Correctly configured:
- ✅ Loads `.env.shared` first
- ✅ Loads `.env.user1` second (overrides shared)
- ✅ Mounts `~/.nanobot-user1:/root/.nanobot` (maps host config to container)
## 🔍 How Nanobot Loads Config in Docker
1. **Environment Variables** (from `.env.shared` and `.env.user1`):
- Loaded by Docker Compose into container environment
- Nanobot's Pydantic `BaseSettings` reads them automatically
- Format: `NANOBOT_CHANNELS__TELEGRAM__TOKEN=...`
2. **Config File** (`config.json`):
- Path inside container: `/root/.nanobot/config.json`
- Maps to host: `~/.nanobot-user1/config.json`
- Loaded by `load_config()` which calls `get_config_path()`
- `get_config_path()` returns `Path.home() / ".nanobot" / "config.json"`
- In Docker, `Path.home()` = `/root`, so it reads `/root/.nanobot/config.json`
- This is mounted from `~/.nanobot-user1/config.json` on host ✅
## ⚠️ Issues Found
### Issue 1: ALLOW_FROM in env file
**Problem**: `.env.user1` has:
```bash
NANOBOT_CHANNELS__TELEGRAM__ALLOW_FROM=["TADec2023"]
NANOBOT_CHANNELS__EMAIL__ALLOW_FROM=["adayear2025@gmail.com"]
```
**Why it's wrong**: Environment variables can't handle JSON arrays. These will be treated as strings, not arrays.
**Fix**: Remove these from `.env.user1` - they're already correctly in `config.json`:
```json
{
"channels": {
"telegram": {
"allowFrom": ["TADec2023"]
},
"email": {
"allowFrom": ["adayear2025@gmail.com"]
}
}
}
```
### Issue 2: Duplicate email settings
**Problem**: Email IMAP/SMTP settings are in both `.env.shared` and `.env.user1`
**Recommendation**:
- If all bots use same email account → Keep only in `.env.shared`
- If each bot uses different email → Keep only in `.env.userX` files
## ✅ Verification Steps
1. **Check env files don't have ALLOW_FROM**:
```bash
grep ALLOW_FROM .env.user1
# Should return nothing or be removed
```
2. **Check config files have allowFrom**:
```bash
cat ~/.nanobot-user1/config.json | jq '.channels.telegram.allowFrom'
# Should show: ["TADec2023"]
```
3. **Verify Docker mounts**:
```bash
docker compose -f docker-compose.multi.env.yml config | grep -A 5 "nanobot-user1"
# Should show volume mount: ~/.nanobot-user1:/root/.nanobot
```
4. **Test config loading in container**:
```bash
docker run --rm -v ~/.nanobot-user1:/root/.nanobot \
-e NANOBOT_CHANNELS__TELEGRAM__TOKEN=test \
nanobot gateway --help
```
## 🎯 Summary
**What's Correct:**
- ✅ Docker Compose loads env files correctly
- ✅ Config files are in correct locations
- ✅ Volume mounts map host configs to container paths
- ✅ Nanobot will read from `/root/.nanobot/config.json` inside container
**What Needs Fixing:**
- ⚠️ Remove `ALLOW_FROM` from `.env.user1` (keep only in config.json)
- ⚠️ Decide: Email settings in `.env.shared` OR `.env.userX` (not both)
**How It Works:**
1. Docker Compose loads `.env.shared` → sets environment variables
2. Docker Compose loads `.env.user1` → overrides with bot-specific vars
3. Container starts → mounts `~/.nanobot-user1` to `/root/.nanobot`
4. Nanobot starts → reads env vars (from Docker) + config.json (from mount)
5. Result: Bot uses combined settings from env vars + config.json ✅

View File

@ -0,0 +1,30 @@
# Agent workspace skeletons
These directories are **templates** for per-agent workspaces on the host:
`~/.nanobot/workspaces/ilia/`
`~/.nanobot/workspaces/family/`
`~/.nanobot/workspaces/wife/`
Each contains bootstrap files (`AGENTS.md`, `USER.md`, `SOUL.md`) and `memory/` (`MEMORY.md`, `HISTORY.md`) loaded by nanobots `ContextBuilder` and `MemoryStore`.
## Initialise on the host
From the repo root (after clone):
```bash
chmod +x scripts/init-agent-workspaces.sh
./scripts/init-agent-workspaces.sh
```
Override destination root (default `$HOME/.nanobot`):
```bash
NANOBOT_HOME=/path/to/.nanobot ./scripts/init-agent-workspaces.sh
```
The script **does not overwrite** existing files so you can safely re-run after editing.
## Docker
Multi-bot compose mounts each path into `/workspace` in the matching container. See `DOCKER_MULTI_BOT_GUIDE.md`.

View File

@ -0,0 +1,14 @@
# @family — Agent instructions
You are the **family** assistant: shared calendar, household coordination, and kid- or home-related questions.
## Scope
- Schedules, reminders, and “whats this week” style questions.
- Simple web lookups (school, activities, recipes) when tools allow.
- Warm, inclusive language for all family members.
## Out of scope
- Production servers, SSH, Proxmox, or source-code repositories unless explicitly asked by an adult and tools are available.
## Tone
Friendly, organized, patient. Offer clear summaries and next steps.

View File

@ -0,0 +1,7 @@
# Personality — @family
**Voice:** Warm, clear, and reassuring. Good with busy parents and kids contexts.
**Values:** Inclusivity, clarity on dates/times, respect for privacy between family members where relevant.
**Avoid:** Cold or corporate tone; assumption that everyone shares one email account.

View File

@ -0,0 +1,8 @@
# User profile — Family
This workspace represents the **household** (not one individual). List members, ages if relevant, schools, and recurring commitments.
## Edit this file
- Family members and how you refer to them.
- Default calendar names or shared inboxes (if any).
- Anything the agent should know for scheduling and coordination.

View File

@ -0,0 +1,3 @@
# Event log — Family
Append-only style log for this household agent.

View File

@ -0,0 +1,5 @@
# Long-term memory — Family
Household-level facts (recurring events, preferences, school names). **Do not store secrets** (passwords, full IDs).
_Empty placeholder — add bullet facts here over time._

View File

@ -0,0 +1,14 @@
# @ilia — Agent instructions
You are the personal assistant for **Ilia**. You focus on development, homelab infrastructure, code review, and technical research.
## Scope
- Software development (Gitea, PRs, issues, shell, git) and clear technical explanations.
- Homelab / Proxmox / networking when those tools are available.
- Email and calendar for Ilias accounts when configured.
## Tone
Concise, accurate, and direct. Prefer actionable steps over long preambles.
## Tools
Use nanobot tools as configured for this instance. Do not assume tools that are not in your tool list.

View File

@ -0,0 +1,7 @@
# Personality — @ilia
**Voice:** Technical, calm, efficient. Short paragraphs. No fluff.
**Values:** Correctness, security-minded defaults, reproducible steps.
**Avoid:** Unnecessary apologies, over-explaining basic concepts unless asked.

View File

@ -0,0 +1,7 @@
# User profile — Ilia
**Name:** Ilia
**Role:** Primary operator of this nanobot stack; dev and infra.
## Edit this file
Add preferences, timezone, important contacts, repos, and anything this agent should remember about *you* (not generic assistant behavior — that belongs in `SOUL.md` / `AGENTS.md`).

View File

@ -0,0 +1,3 @@
# Event log — Ilia
Append-only style log. Search with grep when recalling past events.

View File

@ -0,0 +1,5 @@
# Long-term memory — Ilia
Facts and preferences worth keeping across sessions. The agent may update this file when you confirm something should be remembered.
_Empty placeholder — add bullet facts here over time._

View File

@ -0,0 +1,11 @@
# @wife — Agent instructions
You are the personal assistant for **Ilias wife**. Focus on her calendar, email (when connected), daily tasks, and practical lookups.
## Scope
- Scheduling, reminders, messages, and life-admin tasks.
- Summaries of mail or web pages when tools allow.
- Respectful, private handling of personal topics.
## Tone
Supportive and efficient. Match the users formality preferences over time.

View File

@ -0,0 +1,7 @@
# Personality — @wife
**Voice:** Friendly, attentive, and tactful.
**Values:** Privacy, accuracy on appointments and commitments, gentle reminders.
**Avoid:** Dismissive or overly technical jargon unless the user prefers it.

View File

@ -0,0 +1,6 @@
# User profile — Wife
**Name:** _(preferred name / how to address her)_
## Edit this file
Add preferences, timezone, health or routine notes *you are comfortable storing in plain text*, and communication preferences.

View File

@ -0,0 +1,3 @@
# Event log — Wife
Append-only style log. Search with grep when recalling past events.

View File

@ -0,0 +1,5 @@
# Long-term memory — Wife
Facts and preferences worth keeping across sessions. The agent may update when you confirm.
_Empty placeholder — add bullet facts here over time._

76
create-bot-configs.sh Executable file
View File

@ -0,0 +1,76 @@
#!/bin/bash
# Create minimal config.json files for each bot
# These only contain channel-specific settings that can't be in env vars
set -e
echo "Creating bot config files..."
# Bot 1 config
cat > ~/.nanobot-user1/config.json << 'EOF'
{
"channels": {
"telegram": {
"enabled": true,
"allowFrom": ["TADec2023"]
},
"email": {
"enabled": true,
"allowFrom": ["adayear2025@gmail.com"]
}
},
"tools": {
"mcpServers": {
"gitea": {
"command": "/app/mcp-servers/gitea-mcp/gitea-mcp",
"args": ["-t", "stdio", "--host", "http://10.0.30.169:3000", "-r"],
"env": {
"GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN"
}
}
}
}
}
EOF
# Bot 2 config (placeholder - update with actual values)
cat > ~/.nanobot-user2/config.json << 'EOF'
{
"channels": {
"telegram": {
"enabled": true,
"allowFrom": []
},
"email": {
"enabled": true,
"allowFrom": []
}
}
}
EOF
# Bot 3 config (placeholder - update with actual values)
cat > ~/.nanobot-user3/config.json << 'EOF'
{
"channels": {
"telegram": {
"enabled": true,
"allowFrom": []
},
"email": {
"enabled": true,
"allowFrom": []
}
}
}
EOF
echo "✓ Created config files:"
echo " - ~/.nanobot-user1/config.json"
echo " - ~/.nanobot-user2/config.json"
echo " - ~/.nanobot-user3/config.json"
echo ""
echo "Note: Telegram tokens come from .env.userX files"
echo " Update allowFrom arrays with actual user IDs/emails"

View File

@ -0,0 +1,88 @@
# user1=@ilia, user2=@family, user3=@wife — workspaces ~/.nanobot/workspaces/{ilia,family,wife}
# Development version - mounts source code for live updates
# Use this when developing nanobot code
# Changes to nanobot/ directory will be picked up automatically (may need container restart)
services:
nanobot-user1:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user1-dev
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared
- .env.user1
volumes:
- ~/.nanobot-user1:/root/.nanobot
- ~/.nanobot/workspaces/ilia:/workspace
# Mount source code for development (changes picked up immediately)
- ./nanobot:/app/nanobot:ro # Read-only mount (safer)
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
- ./mcp-servers:/app/mcp-servers:ro
# Or use this for read-write (if you edit inside container):
# - ./nanobot:/app/nanobot
ports:
- "18790:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-user2:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user2-dev
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared
- .env.user2
volumes:
- ~/.nanobot-user2:/root/.nanobot
- ~/.nanobot/workspaces/family:/workspace
- ./nanobot:/app/nanobot:ro
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18791:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-user3:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user3-dev
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared
- .env.user3
volumes:
- ~/.nanobot-user3:/root/.nanobot
- ~/.nanobot/workspaces/wife:/workspace
- ./nanobot:/app/nanobot:ro
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18792:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M

View File

@ -0,0 +1,83 @@
# Multi-bot: user1 = @ilia, user2 = @family, user3 = @wife (see ~/.nanobot/workspaces/* and scripts/init-agent-workspaces.sh).
# Using separate env files per container:
# - .env.shared: Common settings (API keys, model, etc.) - loaded first
# - .env.user1, .env.user2, .env.user3: Bot-specific overrides - loaded after
# Later files override earlier ones, so bot-specific settings take precedence
services:
nanobot-user1:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user1
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared # Shared settings (loaded first)
- .env.user1 # Bot-specific overrides (loaded second, overrides shared)
volumes:
- ~/.nanobot-user1:/root/.nanobot
- ~/.nanobot/workspaces/ilia:/workspace
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18790:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-user2:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user2
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared # Shared settings (loaded first)
- .env.user2 # Bot-specific overrides (loaded second, overrides shared)
volumes:
- ~/.nanobot-user2:/root/.nanobot
- ~/.nanobot/workspaces/family:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18791:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-user3:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user3
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared # Shared settings (loaded first)
- .env.user3 # Bot-specific overrides (loaded second, overrides shared)
volumes:
- ~/.nanobot-user3:/root/.nanobot
- ~/.nanobot/workspaces/wife:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18792:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M

83
docker-compose.multi.yml Normal file
View File

@ -0,0 +1,83 @@
# Multi-bot: nanobot-user1 = @ilia, user2 = @family, user3 = @wife.
# Each container uses ~/.nanobot/workspaces/<name>/ → /workspace (run scripts/init-agent-workspaces.sh first).
services:
nanobot-user1:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user1
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared
- .env.user1
volumes:
- ~/.nanobot-user1:/root/.nanobot
# @ilia — isolated workspace + memory (host: ~/.nanobot/workspaces/ilia)
- ~/.nanobot/workspaces/ilia:/workspace
# Local-cloned MCP servers (see scripts/setup-mcp-servers.sh)
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18790:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-user2:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user2
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared
- .env.user2
volumes:
- ~/.nanobot-user2:/root/.nanobot
# @family — isolated workspace + memory
- ~/.nanobot/workspaces/family:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18791:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
nanobot-user3:
build:
context: .
dockerfile: Dockerfile
container_name: nanobot-user3
command: ["gateway"]
restart: unless-stopped
env_file:
- .env.shared
- .env.user3
volumes:
- ~/.nanobot-user3:/root/.nanobot
# @wife — isolated workspace + memory
- ~/.nanobot/workspaces/wife:/workspace
- ./mcp-servers:/app/mcp-servers:ro
ports:
- "18792:18790"
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M

View File

@ -4,6 +4,8 @@ x-common-config: &common-config
dockerfile: Dockerfile
volumes:
- ~/.nanobot:/root/.nanobot
# Host repo ./workspace → /workspace in container. Set agents.defaults.workspace to /workspace.
- ./workspace:/workspace
services:
nanobot-gateway:

View File

@ -0,0 +1,573 @@
# MCP Integrations & Skills Backlog
> **Living document** — update this file as items are implemented, reprioritized, or new candidates emerge.
>
> Last updated: 2026-03-30
---
## Table of Contents
1. [Current State](#current-state)
2. [Security: Local-Clone Policy](#security-local-clone-policy)
3. [Shortlist — Next Phase](#shortlist--next-phase)
4. [Backlog — Later](#backlog--later)
5. [Skill Catalog](#skill-catalog)
6. [Phase 1 Priorities](#phase-1-priorities)
7. [Implementation Notes](#implementation-notes)
---
## Current State
| Category | What we have today |
|---|---|
| **Built-in tools** | `filesystem` (read/write/edit/list), `exec` (shell), `web` (search + fetch), `message`, `spawn`, `cron`, `email` (IMAP), `calendar` (Google Calendar via built-in tool) |
| **MCP servers** | 1 connected — Gmail MCP (`@gongrzhe/server-gmail-autoauth-mcp`, stdio/npx). See [docs/gmail_mcp_setup.md](gmail_mcp_setup.md). |
| **Skills** | 10 bundled in `nanobot/skills/`: `github`, `gitea`, `calendar`, `cron`, `weather`, `summarize`, `tmux`, `clawhub`, `skill-creator`, `memory` |
| **Agent architecture** | 3 named agents, each running as a **separate Docker container** with its own workspace, personality, and memory (Option B). See below. |
| **Config schema** | `tools.mcpServers``MCPServerConfig` (stdio or HTTP), `tools.toolProfiles``ToolProfileConfig` can further filter tools within a single agent. See `nanobot/config/schema.py`. |
### Agent Workspaces
Each agent is a separate nanobot instance (Docker container) with an isolated workspace under `~/.nanobot/workspaces/`. The workspace contains bootstrap files (`AGENTS.md`, `SOUL.md`, `USER.md`) that define the agent's personality and instructions, plus a `memory/` directory for long-term memory that is private to that agent.
```
~/.nanobot/workspaces/
├── ilia/ # @ilia — personal dev, infra, research
│ ├── AGENTS.md # Dev/infra-focused instructions
│ ├── USER.md # Ilia's profile, preferences
│ ├── SOUL.md # Personality: technical, concise
│ └── memory/
│ └── MEMORY.md
├── family/ # @family — shared household agent
│ ├── AGENTS.md # Family scheduling, coordination
│ ├── USER.md # Family members, kids' info
│ ├── SOUL.md # Personality: warm, organized
│ └── memory/
│ └── MEMORY.md
└── wife/ # @wife — personal assistant for wife
├── AGENTS.md # Personal tasks, calendar, email
├── USER.md # Wife's profile, preferences
├── SOUL.md # Personality: friendly, helpful
└── memory/
└── MEMORY.md
```
Each container mounts its workspace and its own `config.json` (with agent-specific MCP servers, channels, and `allowFrom` lists). Compose service names are `nanobot-user1``user3`.
| Service | Persona | Config dir | Workspace (host → `/workspace`) | Typical channels |
|---|---|---|---|---|
| `nanobot-user1` | @ilia | `~/.nanobot-user1/` | `~/.nanobot/workspaces/ilia` | Telegram, email (Ilia) |
| `nanobot-user2` | @family | `~/.nanobot-user2/` | `~/.nanobot/workspaces/family` | Family Telegram |
| `nanobot-user3` | @wife | `~/.nanobot-user3/` | `~/.nanobot/workspaces/wife` | Telegram, email (wife) |
_Use `scripts/init-agent-workspaces.sh` to create the three workspace trees under `~/.nanobot/workspaces/`._
---
## Security: Local-Clone Policy
All new MCP servers are **cloned locally** into the repository rather than fetched at runtime from npm/PyPI registries. This gives us:
- **Audit control** — we can review every line before running it.
- **Reproducibility** — pinned commits, no surprise upstream updates.
- **Air-gap friendliness** — works on isolated networks after initial clone.
### Directory layout
```
nanobot/
├── mcp-servers/ # <-- NEW: local MCP server clones
│ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp
│ ├── google-calendar-mcp/ # git clone from github.com/nspady/google-calendar-mcp
│ ├── mcp-proxmox/ # git clone from github.com/antonio-mello-ai/mcp-proxmox
│ └── fetch-browser/ # git clone from github.com/TheSethRose/Fetch-Browser
├── nanobot/
├── docs/
└── ...
```
### Config pattern (local stdio)
```jsonc
{
"tools": {
"mcpServers": {
"gitea": {
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
"args": ["--token", "$NANOBOT_GITLE_TOKEN", "--url", "http://10.0.30.169:3000"],
"env": {}
}
}
}
}
```
Each server's README in `mcp-servers/<name>/` documents build steps, required env vars, and update procedure.
---
## Shortlist — Next Phase
These are the 4 MCP servers we plan to integrate in the immediate next phase. Each entry is detailed enough to create implementation tickets directly.
---
### S1. Gitea MCP
| Field | Detail |
|---|---|
| **Upstream** | `gitea.com/gitea/gitea-mcp` (official, Go, v1.0.2, 56 stars, Apache-2.0) |
| **Transport** | Stdio (recommended) or SSE |
| **Auth** | Gitea personal-access token — reuse existing `$NANOBOT_GITLE_TOKEN` |
| **Complexity** | **Low** — token and network route to `http://10.0.30.169:3000` already exist |
| **Replaces** | Current curl-based `gitea` skill and hardcoded API commands in `AGENTS.md` |
| **Target agents** | `@ilia` only (dev tooling; not exposed to `@family` or `@wife`) |
#### User stories
- **US-G1**: As `@ilia`, I can say "list open PRs on nanobot" and get a formatted summary without writing curl commands.
- **US-G2**: As `@ilia`, I can say "search code for `MCPServerConfig`" and the agent returns matching files and lines from Gitea.
- **US-G3**: As `@ilia`, I can say "create an issue titled 'Add Proxmox MCP' with label `enhancement`" and the agent creates it in Gitea.
- **US-G4**: As `@ilia`, I can say "show diff for PR #42" and get a readable summary of changes.
#### Technical notes
- **Build**: Go 1.24+. Clone, `go build`, produces single binary `gitea-mcp`.
- **Local clone path**: `mcp-servers/gitea-mcp/`
- **Config entry**:
```jsonc
"gitea": {
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
"args": [],
"env": {
"GITEA_URL": "http://10.0.30.169:3000",
"GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN"
}
}
```
- **Expected tool names**: `mcp_gitea_list_repos`, `mcp_gitea_search_code`, `mcp_gitea_create_issue`, `mcp_gitea_list_pulls`, etc.
- **Safety**: Read operations are safe. Issue/PR creation and file writes should require user confirmation via tool-profile constraints.
---
### S2. Google Calendar MCP
| Field | Detail |
|---|---|
| **Upstream** | `github.com/nspady/google-calendar-mcp` (TypeScript, v2.6.1, 1071 stars, MIT) |
| **Transport** | Stdio via `node` |
| **Auth** | Google OAuth2 (same pattern as Gmail MCP — credentials in `~/.gmail-mcp/`) |
| **Complexity** | **Medium** — OAuth flow is already a solved pattern from Gmail MCP setup; multi-calendar config adds small overhead |
| **Complements** | Existing built-in `calendar` tool; MCP version adds multi-calendar, recurring events, and free/busy queries |
| **Target agents** | All three — `@ilia`, `@family`, `@wife` (each with their own calendar scope) |
#### User stories
- **US-C1**: As `@family`, I can ask "what's on the family calendar this week?" and get a merged view of all family members' events.
- **US-C2**: As `@ilia`, I can say "find a free 1-hour slot tomorrow afternoon" and the agent checks busy/free across my calendars.
- **US-C3**: As `@family`, I can say "add 'Soccer practice' to the family calendar on Saturday at 10am" and it creates the event.
- **US-C4**: As `@ilia`, I can say "reschedule my 2pm meeting to 4pm" and the agent updates the event after confirmation.
- **US-C5**: As `@wife`, I can say "what do I have on Thursday?" and see only events on my personal calendar.
#### Technical notes
- **Build**: `npm install` in cloned repo, run via `node dist/index.js`.
- **Local clone path**: `mcp-servers/google-calendar-mcp/`
- **OAuth setup**: Same Google Cloud project as Gmail MCP. Enable Calendar API, reuse existing OAuth client. Token stored alongside Gmail tokens.
- **Config entry**:
```jsonc
"google_calendar": {
"command": "node",
"args": ["./mcp-servers/google-calendar-mcp/dist/index.js"],
"env": {
"GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json"
}
}
```
- **Expected tool names**: `mcp_google_calendar_list_events`, `mcp_google_calendar_create_event`, `mcp_google_calendar_freebusy`, `mcp_google_calendar_update_event`, `mcp_google_calendar_delete_event`
- **Migration path**: Phase out built-in `calendar` tool once MCP version is validated. Keep both available during transition via tool profiles.
---
### S3. Proxmox MCP
| Field | Detail |
|---|---|
| **Upstream** | `github.com/antonio-mello-ai/mcp-proxmox` (Python, pip-installable, MIT) |
| **Transport** | Stdio via `python -m mcp_proxmox` |
| **Auth** | Proxmox API token (user `nanobot@pam!mcp-token` + secret) |
| **Complexity** | **Medium** — requires network route to Proxmox cluster API, API token creation on Proxmox, and careful permission scoping |
| **New capability** | Homelab infrastructure visibility and management from chat |
| **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) |
#### User stories
- **US-P1**: As `@ilia`, I can say "show me the status of all VMs" and get a table of names, states, CPU, and RAM usage.
- **US-P2**: As `@ilia`, I can say "how much storage is left on the cluster?" and get aggregate numbers.
- **US-P3**: As `@ilia`, I can say "restart the dev-runner VM" and the agent does so after asking for confirmation.
- **US-P4**: As `@ilia`, I can say "take a snapshot of the nanobot VM before I upgrade" and the agent creates a named snapshot.
#### Technical notes
- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv.
- **Local clone path**: `mcp-servers/mcp-proxmox/`
- **Proxmox setup**:
1. Create API token: Datacenter → Permissions → API Tokens → Add (`nanobot@pam`, token ID `mcp-token`).
2. Assign minimum roles: `PVEAuditor` for read-only, `PVEVMAdmin` for lifecycle ops (Phase 1 starts read-only).
3. Store token secret in `~/.nanobot/config.json` env or in a `.env` file.
- **Config entry**:
```jsonc
"proxmox": {
"command": "python",
"args": ["-m", "mcp_proxmox"],
"env": {
"PROXMOX_HOST": "https://10.0.30.1:8006",
"PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token",
"PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET",
"PROXMOX_VERIFY_SSL": "false"
}
}
```
- **Expected tool names**: `mcp_proxmox_list_nodes`, `mcp_proxmox_list_vms`, `mcp_proxmox_list_containers`, `mcp_proxmox_vm_status`, `mcp_proxmox_start_vm`, `mcp_proxmox_stop_vm`, `mcp_proxmox_create_snapshot`, `mcp_proxmox_list_storage`
- **Safety**: Phase 1 deploys with `PVEAuditor` role (read-only). Write operations (start/stop/snapshot) added in Phase 2 behind confirmation prompts. Restricted to `@ilia` profile only — never exposed to `@family`.
---
### S4. Web Fetch / Scraping MCP
| Field | Detail |
|---|---|
| **Upstream** | `github.com/TheSethRose/Fetch-Browser` (TypeScript, headless Chromium, MIT) |
| **Alt candidate** | `github.com/odgrim/mcp-fetch` (TypeScript, Puppeteer, simpler) |
| **Transport** | Stdio via `node` |
| **Auth** | None — no API keys required |
| **Complexity** | **Low** — clone, `npm install`, run; headless Chromium bundled by Puppeteer/Playwright |
| **Augments** | Built-in `web_fetch` tool (which does basic HTTP GET without JS rendering) |
| **Target agents** | All three — `@ilia`, `@family`, `@wife` |
#### User stories
- **US-W1**: As `@ilia`, I can say "fetch the Proxmox release notes page and summarize what's new" and the agent renders the JS-heavy page and extracts content.
- **US-W2**: As `@family`, I can say "get the lunch menu from the school website" and the agent scrapes the dynamically loaded content.
- **US-W3**: As `@ilia`, I can say "grab the pricing table from this SaaS page" and get structured data back.
- **US-W4**: As `@wife`, I can say "find me the best-rated recipe for lasagna" and the agent fetches and summarizes real recipe pages.
#### Technical notes
- **Build**: `npm install` in cloned repo.
- **Local clone path**: `mcp-servers/fetch-browser/`
- **Config entry**:
```jsonc
"web_scraper": {
"command": "node",
"args": ["./mcp-servers/fetch-browser/dist/index.js"],
"env": {}
}
```
- **Expected tool names**: `mcp_web_scraper_fetch_url`, `mcp_web_scraper_search_google`, `mcp_web_scraper_screenshot`
- **Resource note**: Headless Chromium uses ~200400 MB RAM per instance. Consider setting a process timeout or pool limit.
- **Safety**: Read-only by nature. No write side-effects. Safe for both `@ilia` and `@family`.
---
## Backlog — Later
Items below are future candidates, not yet scheduled. Grouped by domain. Each includes a candidate upstream project where one exists.
### Family / Life
| # | Integration | Upstream candidate | Notes |
|---|---|---|---|
| B-F1 | **CalDAV MCP** | `github.com/dominik1001/caldav-mcp` (Python, v0.4.0) | Universal calendar protocol. Enables Nextcloud, iCloud, ownCloud calendars. Useful if family moves off Google. |
| B-F2 | **Shared Todo / Household Tasks MCP** | `github.com/thijs-hakkenberg/mcp_todo` (Python, git-backed) | Git-backed collaborative task list with assignees, due dates, priorities, Kanban web UI, and Telegram bot. Good fit for family chores and grocery lists. |
| B-F3 | **Microsoft To Do MCP** | `github.com/akkilesh-a/microsoft-todo-mcp-server-self-hosted` (TypeScript) | Self-hosted HTTP transport. 15 tools for full task CRUD. Only relevant if family adopts Microsoft ecosystem. |
| B-F4 | **Home Assistant MCP** | TBD (community projects emerging) | Smart home control — lights, thermostat, locks, sensors. Requires Home Assistant instance on LAN. |
| B-F5 | **Shared Documents MCP** | TBD (Nextcloud WebDAV or Google Drive MCP) | Access family shared documents, photos, notes from chat. |
### Research
| # | Integration | Upstream candidate | Notes |
|---|---|---|---|
| B-R1 | **PDF RAG MCP** | `github.com/wesleygriffin/pdfrag` (Python, ChromaDB + sentence-transformers) | Semantic search over PDF papers. OCR support for scanned docs. Persistent vector index. |
| B-R2 | **Knowledge Base / Notes RAG MCP** | `github.com/alejandro-ao/RAG-MCP` (Python, FastMCP + ChromaDB) | Ingest markdown notes, docs, slides. Query with natural language. Supports LlamaParse for multi-format ETL. |
| B-R3 | **Zotero / Reference Manager MCP** | TBD | If user manages academic references in Zotero. Would expose library search, citation export, PDF retrieval. |
| B-R4 | **Arxiv / Semantic Scholar MCP** | TBD (API wrappers exist) | Direct paper search and metadata retrieval from academic APIs. |
### Dev / Infra
| # | Integration | Upstream candidate | Notes |
|---|---|---|---|
| B-D1 | **Filesystem MCP** | `github.com/mark3labs/mcp-filesystem-server` (Go, 622 stars) | Richer file ops than nanobot built-in (search, diff, metadata, copy trees). Useful for workspace automation. |
| B-D2 | **Docker / Portainer MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Portainer) | Container lifecycle, image management, compose operations. |
| B-D3 | **CI/CD Pipeline MCP** | TBD (Gitea Actions API or Drone) | Query pipeline status, trigger builds, view logs. Partially achievable through Gitea MCP's API. |
| B-D4 | **Logs & Monitoring MCP** | `github.com/AI-Engineerings-at/homelab-mcp-bundle` (includes Grafana, Uptime Kuma) | Query Grafana dashboards, check uptime monitors, search Loki logs. |
| B-D5 | **Backup Status MCP** | TBD (Proxmox Backup Server API or restic wrapper) | Check last backup timestamps, success/failure, storage usage. Could be a thin wrapper skill rather than full MCP. |
| B-D6 | **Database MCP** | TBD (PostgreSQL / SQLite MCP servers exist) | Run read-only queries against app databases for debugging and reporting. |
---
## Skill Catalog
Skills are higher-level task patterns that compose one or more tools (built-in or MCP) into a reusable workflow. Each skill lives as a `SKILL.md` in `nanobot/skills/<name>/` and is loaded by the skills system.
Because agents are **separate containers with separate workspaces**, a skill is available to an agent only if (a) the skill file is present in that workspace's `skills/` dir or in the shared bundled skills, and (b) the MCP servers it depends on are configured in that agent's `config.json`.
### Legend
| Column | Meaning |
|---|---|
| **Skill** | Natural-language trigger name |
| **Description** | What the skill does |
| **MCP deps** | Which MCP servers must be connected in the agent's config |
| **Built-in deps** | Which nanobot built-in tools are also needed |
| **Target agents** | Which agent containers should have this skill deployed (`@ilia`, `@family`, `@wife`) |
| **Safety tier** | `read-only` / `write-confirm` (mutates after user confirmation) / `admin` (restricted + confirm) |
---
### Scheduling Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Plan my week** | List events across all calendars for the next 7 days, highlight conflicts, suggest time blocks for focus work | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only |
| **Reschedule meeting** | Find a specific event, propose 3 alternative conflict-free times, update the event after user picks one | Google Calendar MCP | — | `@ilia`, `@wife` | write-confirm |
| **Find conflict-free times** | Query free/busy across calendars for a given duration and date range, return available slots | Google Calendar MCP | — | `@ilia`, `@family`, `@wife` | read-only |
### Email Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Triage inbox** | Fetch unread emails, categorize by urgency (action-required / FYI / low-priority), surface top action items | Gmail MCP | `read_emails` | `@ilia`, `@wife` | read-only |
| **Draft replies** | For each action-required email, generate a draft reply. Present drafts for user approval before sending | Gmail MCP | — | `@ilia`, `@wife` | write-confirm |
| **Summarize today's mail** | Produce a concise digest of all emails received today, grouped by sender or topic | Gmail MCP | `read_emails` | `@ilia`, `@family`, `@wife` | read-only |
### Research Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Find relevant papers** | Web-search for papers on a given topic, fetch top results, return title + abstract + URL for each | Web Fetch MCP | `web_search` | `@ilia` | read-only |
| **Summarize URL/PDF** | Fetch a URL (with JS rendering if needed) or read a local PDF, produce a structured summary | Web Fetch MCP | `read_file` | `@ilia`, `@family`, `@wife` | read-only |
| **Generate experiment checklist** | Given a goal description, produce a structured checklist of steps, tools needed, and success criteria | — | — | `@ilia` | read-only |
### Infra Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Show VM status** | List all VMs/containers across Proxmox nodes with state, CPU%, RAM%, and uptime | Proxmox MCP | — | `@ilia` | read-only |
| **Restart non-critical service** | Stop and start a VM by name, but only if it is tagged `non-critical`. Refuse if tagged `critical`. Requires confirmation | Proxmox MCP | — | `@ilia` | admin |
| **Summarize cluster resources** | Aggregate CPU, RAM, and storage usage across all Proxmox nodes, flag any node above 80% utilization | Proxmox MCP | — | `@ilia` | read-only |
| **Pre-upgrade snapshot** | Before a maintenance window, create a named snapshot of specified VMs. Requires confirmation | Proxmox MCP | — | `@ilia` | admin |
### Dev Skills
| Skill | Description | MCP deps | Built-in deps | Target agents | Safety tier |
|---|---|---|---|---|---|
| **Summarize open PRs** | List all open PRs on the nanobot repo with title, author, age, review status, and CI state | Gitea MCP | — | `@ilia` | read-only |
| **Triage Gitea issues** | Fetch open issues, group by label, suggest priority ordering based on age and activity | Gitea MCP | — | `@ilia` | read-only |
| **Search codebase** | Search Gitea-hosted code for a symbol or string pattern, return matching files and line numbers | Gitea MCP | — | `@ilia` | read-only |
| **Create issue from chat** | Turn a conversation excerpt into a well-formatted Gitea issue with title, description, and labels. Requires confirmation | Gitea MCP | — | `@ilia` | write-confirm |
---
## Phase 1 Priorities
These are the items we commit to implementing first, chosen for maximum daily value with manageable complexity.
### Phase 1 MCP Integrations
| Priority | MCP Server | Rationale |
|---|---|---|
| **P1** | **Gitea MCP** | Directly replaces fragile curl-based Gitea access scattered across `AGENTS.md` and the `gitea` skill. Token and network route already exist. Aligns with daily dev workflow — PRs, issues, code search are used every day. |
| **P2** | **Google Calendar MCP** | Complements the existing built-in `calendar` tool with multi-calendar views and free/busy queries. OAuth is already a solved pattern from Gmail MCP. Deployed to all three agents — `@ilia` (work calendar), `@family` (shared family calendar), `@wife` (personal calendar). |
| **P3** | **Proxmox MCP** | Homelab infrastructure is checked frequently but currently requires opening the Proxmox web UI. Starting with read-only (`PVEAuditor`) makes it safe to deploy immediately. Write ops follow in a later phase. |
### Phase 1 Skills
| Priority | Skill | MCP dep | Agents | Safety | Why first |
|---|---|---|---|---|---|
| **S1** | Summarize open PRs | Gitea MCP | `@ilia` | read-only | Used daily; validates Gitea MCP end-to-end |
| **S2** | Plan my week | Google Calendar MCP | `@ilia`, `@family`, `@wife` | read-only | High value for every agent; validates Calendar MCP |
| **S3** | Triage inbox | Gmail MCP (already live) | `@ilia`, `@wife` | read-only | Formalizes an existing ad-hoc pattern; no new MCP needed |
| **S4** | Show VM status | Proxmox MCP | `@ilia` | read-only | Safe first infra skill; validates Proxmox MCP |
| **S5** | Summarize today's mail | Gmail MCP (already live) | `@ilia`, `@family`, `@wife` | read-only | Daily value for all agents; no new MCP needed |
---
## Implementation Notes
### Local clone workflow
```bash
# One-time setup
mkdir -p mcp-servers && cd mcp-servers
# Gitea MCP (Go)
git clone https://gitea.com/gitea/gitea-mcp.git
cd gitea-mcp && go build -o gitea-mcp . && cd ..
# Google Calendar MCP (TypeScript)
git clone https://github.com/nspady/google-calendar-mcp.git
cd google-calendar-mcp && npm install && npm run build && cd ..
# Proxmox MCP (Python)
git clone https://github.com/antonio-mello-ai/mcp-proxmox.git
cd mcp-proxmox && pip install -e . && cd ..
# Fetch Browser (TypeScript)
git clone https://github.com/TheSethRose/Fetch-Browser.git fetch-browser
cd fetch-browser && npm install && npm run build && cd ..
```
To update a server: `cd mcp-servers/<name> && git pull && <rebuild>`. Pin to a known-good commit with `git checkout <sha>` for production stability.
### Per-agent MCP wiring
Since each agent is a separate Docker container, MCP servers are configured in each agent's own `config.json`. An agent only gets the MCP servers listed in its config -- no routing needed.
**`~/.nanobot-user1/config.json`** (@ilia — all MCP servers):
```jsonc
{
"tools": {
"mcpServers": {
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
"gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": [], "env": { "GITEA_URL": "http://10.0.30.169:3000", "GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN" } },
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
"proxmox": { "command": "python", "args": ["-m", "mcp_proxmox"], "env": { "PROXMOX_HOST": "https://10.0.30.1:8006", "PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token", "PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET", "PROXMOX_VERIFY_SSL": "false" } },
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
}
}
}
```
**`~/.nanobot-user2/config.json`** (@family — scheduling + web only, no dev/infra):
```jsonc
{
"tools": {
"mcpServers": {
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
}
}
}
```
**`~/.nanobot-user3/config.json`** (@wife — email + calendar + web, no dev/infra):
```jsonc
{
"tools": {
"mcpServers": {
"gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] },
"google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } },
"web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} }
}
}
}
```
**MCP server allocation summary:**
| MCP Server | `@ilia` | `@family` | `@wife` |
|---|---|---|---|
| Gmail MCP | yes | -- | yes |
| Gitea MCP | yes | -- | -- |
| Google Calendar MCP | yes | yes | yes |
| Proxmox MCP | yes | -- | -- |
| Web Fetch MCP | yes | yes | yes |
Key points:
- `@family` and `@wife` never see Gitea or Proxmox tools -- those MCP servers are simply absent from their configs.
- `@family` has no email MCP (it's a shared household bot, not tied to one inbox). It still has the built-in `calendar` and `web` tools.
- Each container spawns its own MCP server processes via stdio from the shared `mcp-servers/` directory (mounted read-only into all containers).
### Safety tiers
| Tier | Behavior | Implementation |
|---|---|---|
| **read-only** | Tool executes immediately, no confirmation prompt | Default for query/list/search operations |
| **write-confirm** | Agent presents a summary of what it will do, waits for user "yes" before executing | Enforced in SKILL.md instructions: "Before calling `create_event`, show the user the details and ask for confirmation" |
| **admin** | Same as write-confirm but tool is only available in the `@ilia` container | Enforced by omitting the MCP server from other agents' `config.json` + SKILL.md confirmation instructions |
With separate containers, the strongest security boundary is **not configuring an MCP server at all** in an agent's config. Proxmox and Gitea are never in `@family` or `@wife` configs, so those agents physically cannot call those tools.
Phase 1 deploys **only read-only skills**. Write skills (draft replies, reschedule meeting, create issue, restart VM) are Phase 2 once we validate the read paths.
### Skill file template
New skills follow the existing format in `nanobot/skills/`:
```markdown
---
name: summarize-open-prs
description: "List and summarize all open pull requests on the nanobot Gitea repo."
metadata: {"nanobot":{"emoji":"📋","requires":{"mcp":["gitea"]}}}
---
# Summarize Open PRs
## When to use
User asks about open PRs, pending reviews, or code review status.
## Steps
1. Call `mcp_gitea_list_pulls` with state=open.
2. For each PR, extract: title, author, created date, review status, CI status.
3. Format as a numbered list sorted by age (oldest first).
4. Highlight PRs with no reviews or failing CI.
## Safety
Read-only. No confirmation needed.
```
### Docker considerations
All three containers (`nanobot-user1`, `nanobot-user2`, `nanobot-user3`) share the same Docker image. MCP server processes are spawned inside each container as needed. The Dockerfile must include:
- **Go** (for Gitea MCP binary — or copy pre-built binary)
- **Node.js 18+** (for Calendar MCP and Fetch Browser)
- **Python pip deps** (for Proxmox MCP — install into the same venv or a sidecar)
- **Chromium** (for Fetch Browser headless rendering — `npx puppeteer browsers install chrome` or use Playwright)
The `mcp-servers/` directory is mounted read-only into all containers so each agent can spawn the MCP servers listed in its config. Alternatively, build MCP binaries in a multi-stage Docker build and copy only the artifacts into the image.
**Volume mounts (per container)** — compose services remain `nanobot-user1` / `user2` / `user3`; they map to `@ilia` / `@family` / `@wife` workspaces.
```yaml
nanobot-user1: # @ilia
volumes:
- ~/.nanobot-user1:/root/.nanobot
- ~/.nanobot/workspaces/ilia:/workspace
# Optional: ./mcp-servers:/app/mcp-servers:ro
nanobot-user2: # @family
volumes:
- ~/.nanobot-user2:/root/.nanobot
- ~/.nanobot/workspaces/family:/workspace
nanobot-user3: # @wife
volumes:
- ~/.nanobot-user3:/root/.nanobot
- ~/.nanobot/workspaces/wife:/workspace
```
### Rollout sequence
```
Week 1: Clone repos, build locally, verify each MCP server starts and lists tools
Week 2: Wire Gitea MCP + "Summarize open PRs" skill, validate end-to-end
Week 3: Wire Calendar MCP + "Plan my week" skill, formalize "Triage inbox" skill
Week 4: Wire Proxmox MCP (read-only) + "Show VM status" skill
Week 5: Add "Summarize today's mail" skill, integrate Web Fetch MCP
Week 6: Retrospective, update this document, plan Phase 2 write-skills
```
---
## Changelog
| Date | Change |
|---|---|
| 2026-03-30 | Updated to reflect multi-container workspace architecture (Option B). Added `@wife` as third agent. Rewrote per-agent MCP wiring with separate config.json per container. Updated skill assignments across all three agents. |
| 2026-03-30 | Initial version — shortlist (4 MCP), backlog (16 ideas), skill catalog (16 skills), Phase 1 defined (3 MCP + 5 skills) |

70
env-files-setup.sh Executable file
View File

@ -0,0 +1,70 @@
#!/bin/bash
# Setup script to create env files for multi-bot setup
set -e
echo "Setting up environment files for multi-bot configuration..."
echo ""
# Create .env.shared if it doesn't exist
if [ ! -f .env.shared ]; then
cat > .env.shared << 'EOF'
# Shared configuration for all nanobot instances
# These settings apply to all bots unless overridden in .env.user1, .env.user2, etc.
# LLM Provider API Key (shared across all bots)
NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-xxx
# Default Model (shared across all bots)
NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-opus-4-5
# Agent Settings (shared)
NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.7
NANOBOT_AGENTS__DEFAULTS__MAX_TOKENS=8192
NANOBOT_AGENTS__DEFAULTS__MAX_TOOL_ITERATIONS=20
NANOBOT_AGENTS__DEFAULTS__MEMORY_WINDOW=50
# Tool Settings (shared)
NANOBOT_TOOLS__RESTRICT_TO_WORKSPACE=true
# Gateway Settings (shared)
NANOBOT_GATEWAY__PORT=18790
NANOBOT_GATEWAY__HOST=0.0.0.0
EOF
echo "✓ Created .env.shared"
else
echo "⚠ .env.shared already exists, skipping..."
fi
# Create bot-specific env files
for i in 1 2 3; do
env_file=".env.user${i}"
if [ ! -f "$env_file" ]; then
cat > "$env_file" << EOF
# Bot-specific configuration for user${i}
# These settings override .env.shared for this bot only
# Leave empty or comment out to use shared settings
# Example: Override model for this bot
# NANOBOT_AGENTS__DEFAULTS__MODEL=anthropic/claude-sonnet-4
# Example: Override temperature for this bot
# NANOBOT_AGENTS__DEFAULTS__TEMPERATURE=0.9
# Example: Use different API key for this bot
# NANOBOT_PROVIDERS__OPENROUTER__API_KEY=sk-or-v1-different-key
EOF
echo "✓ Created $env_file"
else
echo "$env_file already exists, skipping..."
fi
done
echo ""
echo "Done! Next steps:"
echo "1. Edit .env.shared and add your API keys and shared settings"
echo "2. Edit .env.user1, .env.user2, .env.user3 if you need bot-specific overrides"
echo "3. Create minimal config.json files in ~/.nanobot-user1, ~/.nanobot-user2, etc."
echo "4. Run: docker compose -f docker-compose.multi.env.yml up -d"

1
mcp-servers/.gitkeep Normal file
View File

@ -0,0 +1 @@

34
mcp-servers/README.md Normal file
View File

@ -0,0 +1,34 @@
# Local MCP servers
This repo uses a **local-clone policy** for MCP servers: clone upstream repos into `./mcp-servers/` and run them from disk (instead of fetching from npm/PyPI at runtime).
## Gitea MCP
- **Upstream**: `https://gitea.com/gitea/gitea-mcp.git`
- **Local path**: `mcp-servers/gitea-mcp/`
- **Binary**: `mcp-servers/gitea-mcp/gitea-mcp`
Build it with:
```bash
./scripts/setup-mcp-servers.sh gitea
```
Then configure nanobot (example):
```jsonc
{
"tools": {
"mcpServers": {
"gitea": {
"command": "./mcp-servers/gitea-mcp/gitea-mcp",
"args": ["-t", "stdio", "--host", "http://10.0.30.169:3000"],
"env": {
"GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN"
}
}
}
}
}
```

49
multi-bot-setup.sh Executable file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# Setup script for multiple nanobot instances
# Create directories for each bot
mkdir -p ~/.nanobot-user1
mkdir -p ~/.nanobot-user2
mkdir -p ~/.nanobot-user3
# Copy base config if it exists
if [ -f ~/.nanobot/config.json ]; then
cp ~/.nanobot/config.json ~/.nanobot-user1/config.json
cp ~/.nanobot/config.json ~/.nanobot-user2/config.json
cp ~/.nanobot/config.json ~/.nanobot-user3/config.json
echo "✓ Copied base config to all directories"
else
echo "⚠ Base config not found. Creating minimal configs..."
# Create minimal configs
cat > ~/.nanobot-user1/config.json <<EOF
{
"providers": {
"openrouter": {
"apiKey": "YOUR_API_KEY_HERE"
}
},
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
}
},
"channels": {
"telegram": {
"enabled": true,
"token": "BOT_TOKEN_FOR_USER1",
"allowFrom": ["USER1_TELEGRAM_ID"]
}
}
}
EOF
cp ~/.nanobot-user1/config.json ~/.nanobot-user2/config.json
cp ~/.nanobot-user1/config.json ~/.nanobot-user3/config.json
fi
echo ""
echo "Next steps:"
echo "1. Edit each config file (~/.nanobot-user1/config.json, etc.)"
echo "2. Add different Telegram bot tokens and user IDs"
echo "3. Run the Docker containers (see docker-compose.multi.yml)"

View File

@ -1,7 +1,7 @@
"""Agent core module."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader

View File

@ -13,43 +13,43 @@ from nanobot.agent.skills import SkillsLoader
class ContextBuilder:
"""
Builds the context (system prompt + messages) for the agent.
Assembles bootstrap files, memory, skills, and conversation history
into a coherent prompt for the LLM.
"""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
def __init__(self, workspace: Path):
self.workspace = workspace
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
"""
Build the system prompt from bootstrap files, memory, and skills.
Args:
skill_names: Optional list of skills to include.
Returns:
Complete system prompt.
"""
parts = []
# Core identity
parts.append(self._get_identity())
# Bootstrap files
bootstrap = self._load_bootstrap_files()
if bootstrap:
parts.append(bootstrap)
# Memory context
memory = self.memory.get_memory_context()
if memory:
parts.append(f"# Memory\n\n{memory}")
# Skills - progressive loading
# 1. Always-loaded skills: include full content
always_skills = self.skills.get_always_skills()
@ -57,7 +57,7 @@ class ContextBuilder:
always_content = self.skills.load_skills_for_context(always_skills)
if always_content:
parts.append(f"# Active Skills\n\n{always_content}")
# 2. Available skills: only show summary (agent uses read_file to load)
skills_summary = self.skills.build_skills_summary()
if skills_summary:
@ -67,19 +67,19 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
{skills_summary}""")
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
"""Get the core identity section."""
from datetime import datetime
import time as _time
from datetime import datetime
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
tz = _time.strftime("%Z") or "UTC"
workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return f"""# nanobot 🐈
You are nanobot, a helpful AI assistant. You have access to tools that allow you to:
@ -89,6 +89,22 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
- Send messages to users on chat channels
- Spawn subagents for complex background tasks
## Tool calling (IMPORTANT)
Some LLM backends may not support native function-calling. When you decide to use a tool, you MUST output a single JSON object in one of these formats (and no other surrounding text):
1) Standard tool call:
{{"name":"<tool_name>","parameters":{{...}}}}
2) Calendar shortcut (allowed only for the built-in `calendar` tool):
{{"action":"list_events", ...}}
After a tool result is returned, respond normally in plain text unless the user asks for another tool action.
### MCP quick mappings (use these when the intent matches)
- If the user asks for **my Gitea user info** (who am I / my profile / my account): call `mcp_gitea_get_me` with `{{}}`.
- If the user asks for **Gitea MCP server version**: call `mcp_gitea_get_gitea_mcp_server_version` with `{{}}`.
- If the user asks to **list my repos**: call `mcp_gitea_list_my_repos` with pagination defaults.
## Current Time
{now} ({tz})
@ -101,6 +117,21 @@ Your workspace is at: {workspace_path}
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable)
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. **`list_dir` takes one directory path**no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory.
**Answering after tools:** When a tool already returned what the user needs, base your reply **only on that tool output**same topic as the users question, no hijacking.
- After **`list_dir`:** If they asked for PDFs (or another extension), list **only** matching names (paths under `{workspace_path}` if useful). If none, say so briefly. No essays, no calling the folder "code" unless they asked for analysis.
- After **`read_emails`:** Answer **only** from the email text the tool returned (From, Subject, Date, attachments, downloaded paths, body as needed). Do **not** switch to unrelated topics (Git, Gitea, this repo, workspace docs, coding help, general chit-chat). Do **not** apologize at length or describe "what an email is". Match the question: e.g. latest email sender + subject (+ date) in a few lines unless they asked for the full body.
## 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,20 +140,22 @@ 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. Once read_emails returns, your assistant reply must **only** satisfy that email question from the tool resultignore Gitea/workspace/bootstrap content unless the user tied their question to it."""
def _load_bootstrap_files(self) -> str:
"""Load all bootstrap files from workspace."""
parts = []
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
if file_path.exists():
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
return "\n\n".join(parts) if parts else ""
def build_messages(
self,
history: list[dict[str, Any]],
@ -167,7 +200,7 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
"""Build user message content with optional base64-encoded images."""
if not media:
return text
images = []
for path in media:
p = Path(path)
@ -176,11 +209,11 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
continue
b64 = base64.b64encode(p.read_bytes()).decode()
images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}})
if not images:
return text
return images + [{"type": "text", "text": text}]
def add_tool_result(
self,
messages: list[dict[str, Any]],
@ -190,13 +223,13 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
) -> list[dict[str, Any]]:
"""
Add a tool result to the message list.
Args:
messages: Current message list.
tool_call_id: ID of the tool call.
tool_name: Name of the tool.
result: Tool execution result.
Returns:
Updated message list.
"""
@ -207,7 +240,7 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
"content": result
})
return messages
def add_assistant_message(
self,
messages: list[dict[str, Any]],
@ -217,13 +250,13 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md"""
) -> list[dict[str, Any]]:
"""
Add an assistant message to the message list.
Args:
messages: Current message list.
content: Message content.
tool_calls: Optional tool calls.
reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.).
Returns:
Updated message list.
"""

View File

@ -1,28 +1,30 @@
"""Agent loop: the core processing engine."""
import asyncio
from contextlib import AsyncExitStack
import json
import json_repair
from pathlib import Path
import re
from typing import Any, Awaitable, Callable
from contextlib import AsyncExitStack
from pathlib import Path
from typing import Awaitable, Callable
import json_repair
from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.agent.context import ContextBuilder
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig, ToolRoutingConfig
from nanobot.cron.service import CronService
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import Session, SessionManager
@ -49,14 +51,15 @@ class AgentLoop:
max_tokens: int = 4096,
memory_window: int = 50,
brave_api_key: str | None = None,
exec_config: "ExecToolConfig | None" = None,
cron_service: "CronService | None" = None,
exec_config: ExecToolConfig | None = None,
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
tool_profiles: dict | None = None,
default_tool_profile: str = "default",
tool_routing: ToolRoutingConfig | None = None,
):
from nanobot.config.schema import ExecToolConfig
from nanobot.cron.service import CronService
self.bus = bus
self.provider = provider
self.workspace = workspace
@ -84,13 +87,16 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
self._running = False
self._mcp_servers = mcp_servers or {}
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._mcp_stacks: dict[str, AsyncExitStack] = {}
self._mcp_connected_servers: set[str] = set()
self._tool_profiles: dict = tool_profiles or {}
self._default_tool_profile = default_tool_profile
self._tool_routing = tool_routing or ToolRoutingConfig()
self._register_default_tools()
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
# File tools (restrict to workspace if configured)
@ -99,39 +105,126 @@ class AgentLoop:
self.tools.register(WriteFileTool(allowed_dir=allowed_dir))
self.tools.register(EditFileTool(allowed_dir=allowed_dir))
self.tools.register(ListDirTool(allowed_dir=allowed_dir))
# Shell tool
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
restrict_to_workspace=self.restrict_to_workspace,
))
# Web tools
self.tools.register(WebSearchTool(api_key=self.brave_api_key))
self.tools.register(WebFetchTool())
# Message tool
message_tool = MessageTool(send_callback=self.bus.publish_outbound)
self.tools.register(message_tool)
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
# Cron tool (for scheduling)
if self.cron_service:
self.tools.register(CronTool(self.cron_service))
async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or not self._mcp_servers:
# 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,
workspace=self.workspace,
)
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
def _unregister_mcp_tools_for_server(self, server_key: str) -> None:
"""Remove tools registered from one MCP server (prefix mcp_<key>_)."""
prefix = f"mcp_{server_key}_"
for name in list(self.tools.tool_names):
if name.startswith(prefix):
self.tools.unregister(name)
async def _disconnect_mcp_server(self, server_key: str) -> None:
"""Close one MCP server and remove its tools (used when switching tool profiles)."""
stack = self._mcp_stacks.pop(server_key, None)
if stack is not None:
try:
await stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass
self._unregister_mcp_tools_for_server(server_key)
self._mcp_connected_servers.discard(server_key)
logger.info(f"MCP server '{server_key}': disconnected")
async def _sync_mcp_to_profile_needs(self, needed_keys: list[str]) -> None:
"""
Ensure only MCP servers in needed_keys are connected: tear down extras, connect missing.
When tools.toolProfiles is empty, pass the full configured key list so all servers stay up.
"""
if not self._mcp_servers:
return
self._mcp_connected = True
from nanobot.agent.tools.mcp import connect_mcp_servers
self._mcp_stack = AsyncExitStack()
await self._mcp_stack.__aenter__()
await connect_mcp_servers(self._mcp_servers, self.tools, self._mcp_stack)
needed = set(needed_keys)
for key in list(self._mcp_connected_servers):
if key not in needed:
await self._disconnect_mcp_server(key)
connect_order = [k for k in self._mcp_servers.keys() if k in needed]
await self._ensure_mcp_servers_connected(connect_order)
async def _ensure_mcp_servers_connected(self, server_keys: list[str]) -> None:
"""Lazily connect MCP servers (each gets its own AsyncExitStack for per-server teardown)."""
if not self._mcp_servers or not server_keys:
return
pending = [
k
for k in server_keys
if k in self._mcp_servers and k not in self._mcp_connected_servers
]
if not pending:
return
from nanobot.agent.tools.mcp import connect_mcp_server
for key in pending:
stack = AsyncExitStack()
await stack.__aenter__()
try:
await connect_mcp_server(
key, self._mcp_servers[key], self.tools, stack
)
self._mcp_stacks[key] = stack
self._mcp_connected_servers.add(key)
except Exception as e:
logger.error(f"MCP server '{key}': failed to connect: {e}")
try:
await stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass
def _set_tool_context(self, channel: str, chat_id: str) -> None:
"""Update context for all tools that need routing info."""
@ -164,6 +257,41 @@ class AgentLoop:
return f'{tc.name}("{val[:40]}")' if len(val) > 40 else f'{tc.name}("{val}")'
return ", ".join(_fmt(tc) for tc in tool_calls)
@staticmethod
def _extract_routing_text(messages: list[dict]) -> str:
"""Last user message text (string or multimodal) for the tool-profile router."""
for m in reversed(messages):
if m.get("role") != "user":
continue
c = m.get("content")
if isinstance(c, str):
return c.strip()
if isinstance(c, list):
parts: list[str] = []
for block in c:
if isinstance(block, dict) and block.get("type") == "text":
parts.append(str(block.get("text") or ""))
return "\n".join(parts).strip()
return ""
async def _pick_tool_profile(self, user_text: str) -> str:
"""Resolve profile key when tools.toolProfiles is configured."""
if not self._tool_profiles:
return self._default_tool_profile
if self._tool_routing.enabled:
from nanobot.agent.tool_routing import route_tool_profile
return await route_tool_profile(
self.provider,
model=self.model,
user_message=user_text,
profiles=self._tool_profiles,
default_profile=self._default_tool_profile,
temperature=self._tool_routing.router_temperature,
max_tokens=self._tool_routing.router_max_tokens,
)
return self._default_tool_profile
async def _run_agent_loop(
self,
initial_messages: list[dict],
@ -183,16 +311,54 @@ class AgentLoop:
iteration = 0
final_content = None
tools_used: list[str] = []
empty_final_retry_used = False
from nanobot.agent.tool_profiles import (
compute_allowed_tool_names,
mcp_keys_to_connect,
)
from nanobot.agent.tool_routing import is_tool_not_found_error
configured_mcp = list(self._mcp_servers.keys())
tools_expanded = False
allowed_names: set[str] | None = None
if self._tool_profiles:
routing_text = self._extract_routing_text(initial_messages)
profile_key = await self._pick_tool_profile(routing_text)
prof = self._tool_profiles[profile_key]
await self._sync_mcp_to_profile_needs(
mcp_keys_to_connect(prof, configured_mcp)
)
always = set(self._tool_routing.always_include_tools)
allowed_names = compute_allowed_tool_names(
self.tools,
prof,
configured_mcp,
always,
)
logger.info(
f"Tool profile '{profile_key}': {len(allowed_names)}/{len(self.tools)} tools exposed"
)
else:
await self._sync_mcp_to_profile_needs(configured_mcp)
tools_full = self.tools.get_definitions()
while iteration < self.max_iterations:
iteration += 1
logger.debug(f"Agent loop iteration {iteration}/{self.max_iterations}, calling LLM provider...")
if allowed_names is not None and not tools_expanded:
tool_defs = self.tools.get_definitions_subset(allowed_names)
else:
tool_defs = tools_full
try:
response = await asyncio.wait_for(
self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
tools=tool_defs,
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
@ -201,7 +367,7 @@ class AgentLoop:
)
logger.debug(f"LLM provider returned response, has_tool_calls={response.has_tool_calls}")
except asyncio.TimeoutError:
logger.error(f"LLM provider call timed out after 120 seconds")
logger.error("LLM provider call timed out after 120 seconds")
return "Error: Request timed out. The LLM provider may be slow or unresponsive.", tools_used
except Exception as e:
logger.error(f"LLM provider error: {e}")
@ -234,6 +400,18 @@ class AgentLoop:
logger.info(f"Tool call: {tool_call.name}({args_str[:200]})")
result = await self.tools.execute(tool_call.name, tool_call.arguments)
logger.info(f"Tool result length: {len(result) if result else 0}, preview: {result[:200] if result else 'None'}")
if (
allowed_names is not None
and self._tool_routing.expand_on_missing_tool
and not tools_expanded
and is_tool_not_found_error(result)
):
tools_expanded = True
await self._sync_mcp_to_profile_needs(configured_mcp)
tools_full = self.tools.get_definitions()
logger.info(
"Expanded tool set to full registry (missing tool after profile filter)"
)
messages = self.context.add_tool_result(
messages, tool_call.id, tool_call.name, result
)
@ -241,17 +419,34 @@ class AgentLoop:
else:
final_content = self._strip_think(response.content)
logger.info(f"Final response generated. Content length: {len(final_content) if final_content else 0}")
# Some local OpenAI-compatible backends occasionally return an empty assistant message.
# Retry once with an explicit nudge to either call a tool or answer in text.
if (not final_content or not final_content.strip()) and not empty_final_retry_used:
empty_final_retry_used = True
logger.warning(
"LLM returned empty final content; retrying once with a non-empty response nudge"
)
messages = messages + [
{
"role": "system",
"content": (
"Your previous reply was empty. You MUST either (a) call an appropriate tool, "
"or (b) respond with a short helpful text answer. Do not return an empty message."
),
}
]
final_content = None
continue
break
if final_content is None and iteration >= self.max_iterations:
logger.warning(f"Max iterations ({self.max_iterations}) reached without final response. Last tool calls: {tools_used[-3:] if len(tools_used) >= 3 else tools_used}")
return final_content, tools_used
async def run(self) -> None:
"""Run the agent loop, processing messages from the bus."""
self._running = True
await self._connect_mcp()
logger.info("Agent loop started")
while self._running:
@ -273,21 +468,21 @@ class AgentLoop:
))
except asyncio.TimeoutError:
continue
async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
try:
await self._mcp_stack.aclose()
except (RuntimeError, BaseExceptionGroup):
pass # MCP SDK cancel scope cleanup is noisy but harmless
self._mcp_stack = None
"""Close all MCP connections and drop MCP tools from the registry."""
for key in list(
set(self._mcp_stacks.keys()) | self._mcp_connected_servers
):
await self._disconnect_mcp_server(key)
self._mcp_stacks.clear()
self._mcp_connected_servers.clear()
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
async def _process_message(
self,
msg: InboundMessage,
@ -296,25 +491,25 @@ class AgentLoop:
) -> OutboundMessage | None:
"""
Process a single inbound message.
Args:
msg: The inbound message to process.
session_key: Override session key (used by process_direct).
on_progress: Optional callback for intermediate output (defaults to bus publish).
Returns:
The response message, or None if no response needed.
"""
# System messages route back via chat_id ("channel:chat_id")
if msg.channel == "system":
return await self._process_system_message(msg)
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
# Handle slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
@ -335,7 +530,7 @@ class AgentLoop:
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
# Skip memory consolidation for CLI mode to avoid blocking/hanging
# Memory consolidation can be slow and CLI users want fast responses
if len(session.messages) > self.memory_window and msg.channel != "cli":
@ -362,6 +557,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 {},
@ -373,31 +571,31 @@ class AgentLoop:
if final_content is None:
final_content = "I've completed processing but have no response to give."
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}")
session.add_message("user", msg.content)
session.add_message("assistant", final_content,
tools_used=tools_used if tools_used else None)
self.sessions.save(session)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=final_content,
metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
"""
Process a system message (e.g., subagent announce).
The chat_id field contains "original_channel:original_chat_id" to route
the response back to the correct destination.
"""
logger.info(f"Processing system message from {msg.sender_id}")
# Parse origin from chat_id (format: "channel:chat_id")
if ":" in msg.chat_id:
parts = msg.chat_id.split(":", 1)
@ -407,7 +605,7 @@ class AgentLoop:
# Fallback
origin_channel = "cli"
origin_chat_id = msg.chat_id
session_key = f"{origin_channel}:{origin_chat_id}"
session = self.sessions.get_or_create(session_key)
self._set_tool_context(origin_channel, origin_chat_id)
@ -421,17 +619,17 @@ class AgentLoop:
if final_content is None:
final_content = "Background task completed."
session.add_message("user", f"[System: {msg.sender_id}] {msg.content}")
session.add_message("assistant", final_content)
self.sessions.save(session)
return OutboundMessage(
channel=origin_channel,
chat_id=origin_chat_id,
content=final_content
)
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md.
@ -537,24 +735,23 @@ Respond with ONLY valid JSON, no markdown fences."""
) -> str:
"""
Process a message directly (for CLI or cron usage).
Args:
content: The message content.
session_key: Session identifier (overrides channel:chat_id for session lookup).
channel: Source channel (for tool context routing).
chat_id: Source chat ID (for tool context routing).
on_progress: Optional callback for intermediate output.
Returns:
The agent's response.
"""
await self._connect_mcp()
msg = InboundMessage(
channel=channel,
sender_id="user",
chat_id=chat_id,
content=content
)
response = await self._process_message(msg, session_key=session_key, on_progress=on_progress)
return response.content if response else ""

View File

@ -0,0 +1,88 @@
"""Tool profile: compute which tools are visible to the LLM for a given config profile."""
from __future__ import annotations
from typing import TYPE_CHECKING
from loguru import logger
from nanobot.config.schema import ToolProfileConfig
if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
def mcp_server_for_tool(tool_name: str, mcp_server_keys: list[str]) -> str | None:
"""
Infer MCP server config key from nanobot's tool name pattern mcp_<serverKey>_<mcpToolName>.
Server keys are matched longest-first so names with underscores resolve unambiguously.
"""
prefix = "mcp_"
if not tool_name.startswith(prefix):
return None
rest = tool_name[len(prefix) :]
for key in sorted(mcp_server_keys, key=len, reverse=True):
sep = f"{key}_"
if rest.startswith(sep):
return key
return None
def mcp_keys_to_connect(
profile: ToolProfileConfig, configured_mcp_keys: list[str]
) -> list[str]:
"""
Config keys for MCP servers to connect for this profile, in config order.
None on profile.mcp_servers means all configured servers; [] means none.
Unknown keys in the profile list are logged and skipped.
"""
if not configured_mcp_keys:
return []
configured_set = set(configured_mcp_keys)
if profile.mcp_servers is None:
return list(configured_mcp_keys)
out: list[str] = []
for k in profile.mcp_servers:
if k in configured_set:
out.append(k)
else:
logger.warning(
f"tools.toolProfiles entry references unknown MCP server {k!r}; "
"not in tools.mcpServers keys"
)
return out
def compute_allowed_tool_names(
registry: ToolRegistry,
profile: ToolProfileConfig,
mcp_server_keys: list[str],
always_include: set[str],
) -> set[str]:
"""Union of profile-filtered builtins + MCP tools + always-include (intersected with registered names)."""
all_names = set(registry.tool_names)
mcp_keys = list(mcp_server_keys)
builtins = {n for n in all_names if mcp_server_for_tool(n, mcp_keys) is None}
if profile.builtin_tools is None:
allowed_builtins = set(builtins)
else:
allowed_builtins = set(profile.builtin_tools) & builtins
if profile.mcp_servers is None:
allowed_mcp = {
n for n in all_names if mcp_server_for_tool(n, mcp_keys) is not None
}
else:
allow_srv = set(profile.mcp_servers)
allowed_mcp = {
n
for n in all_names
if (srv := mcp_server_for_tool(n, mcp_keys)) is not None and srv in allow_srv
}
extras = always_include & all_names
return allowed_builtins | allowed_mcp | extras

View File

@ -0,0 +1,118 @@
"""LLM-based router: choose a tools.toolProfiles key from the user message."""
from __future__ import annotations
import json_repair
from loguru import logger
from nanobot.config.schema import ToolProfileConfig
from nanobot.providers.base import LLMProvider
async def route_tool_profile(
provider: LLMProvider,
*,
model: str,
user_message: str,
profiles: dict[str, ToolProfileConfig],
default_profile: str,
temperature: float = 0.2,
max_tokens: int = 128,
) -> str:
"""
Ask a small LLM call to return JSON {"profile": "<key>"}.
Falls back to default_profile on any failure or unknown key.
"""
if not profiles:
return default_profile
# Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos),
# prefer an MCP-enabled profile without spending an LLM call.
msg_l = (user_message or "").lower()
needs_forge = any(
k in msg_l
for k in [
"pull request",
"pull requests",
"open pr",
"open prs",
" list prs",
"pr ",
"prs",
"merge request",
"issue",
"issues",
"gitea",
"repo",
"repository",
"branches",
"commits",
"tags",
"release",
]
)
if needs_forge:
# Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers.
for key in profiles.keys():
if "mcp" in key.lower():
logger.info(f"Tool router selected profile '{key}' (heuristic)")
return key
for key, p in profiles.items():
if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0):
logger.info(f"Tool router selected profile '{key}' (heuristic)")
return key
lines = []
for name, p in profiles.items():
desc = (p.description or "").strip() or "(no description)"
lines.append(f"- {name}: {desc}")
catalog = "\n".join(lines)
allowed = ", ".join(f'"{k}"' for k in profiles)
system = (
"You are a tool-profile router. Pick exactly one profile key for the assistant's next turn. "
"Respond with JSON only: {\"profile\": \"<key>\"} where <key> is one of: "
f"{allowed}. "
"Prefer narrower profiles when the request is clearly scoped (e.g. only read files). "
"Use the broadest profile only when multiple unrelated capabilities are needed."
)
user = f"Available profiles:\n{catalog}\n\nUser message:\n{user_message.strip()[:8000]}"
try:
response = await provider.chat(
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
tools=None,
model=model,
temperature=temperature,
max_tokens=max_tokens,
)
text = (response.content or "").strip()
if not text:
return default_profile
if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
data = json_repair.loads(text)
if not isinstance(data, dict):
return default_profile
name = data.get("profile")
if isinstance(name, str) and name in profiles:
logger.info(f"Tool router selected profile '{name}'")
return name
logger.warning(f"Tool router returned invalid profile {name!r}, using default")
except (TypeError, ValueError) as e:
logger.warning(f"Tool router JSON parse failed: {e}")
except Exception as e:
logger.warning(f"Tool router failed: {e}")
return default_profile
def is_tool_not_found_error(result: str) -> bool:
"""Detect registry execute() message for missing tools."""
if not result:
return False
return result.startswith("Error: Tool '") and "' not found" in result

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,568 @@
"""Email tool: read emails from IMAP mailbox."""
import asyncio
import imaplib
import ssl
from pathlib import Path
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 OWNER-INITIATED MAILBOX QUERIES. When the user explicitly asks about their inbox "
"(e.g. latest email, email sender, unread emails, search inbox, attachments, etc.), you SHOULD call "
"read_emails(). DO NOT use mcp_gmail_mcp_read_email for emails received via the email channel - use "
"read_emails instead. 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 from IMAP - it connects "
"to IMAP and fetches real-time data. For 'latest email' or 'last email received' queries, use limit=1. "
"When the user asks to download attachments, use download_attachments=true. When the user asks to find "
"emails with a specific attachment (e.g., 'find email with attachment Rubiks'), use attachment_name='Rubiks'. "
"CRITICAL: When the 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 the user asks 'give me the from and subject', respond with just: "
"'From: [email] Subject: [subject]'. VERY IMPORTANT: When replying on the email channel to a single incoming "
"email (content typically starts with 'Email received. From: ... Subject: ...'), treat that content as the "
"user's message and compose a direct reply. Do NOT call read_emails in that case unless the human explicitly "
"asks you to inspect or search the mailbox. Parameters: limit (1-50, default 10, use 1 for latest), "
"unread_only (bool, default false), mark_seen (bool, default false), download_attachments (bool, default false "
"- set to true to download all attachments to workspace), attachment_name (string, optional - filter emails by "
"attachment filename, case-insensitive partial match). Returns formatted email list with sender, subject, date, "
"attachments (if any), downloaded file paths (if downloaded), and body. After you receive this output, your "
"reply to the user must address their email question using only this data—no unrelated topics."
)
def __init__(self, email_config: Any = None, workspace: Path | None = None):
"""
Initialize email tool with email configuration.
Args:
email_config: Optional EmailConfig instance. If None, loads from config.
workspace: Directory for downloaded attachments (defaults to config workspace_path).
"""
self._email_config = email_config
self._workspace = workspace
@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."""
# Handle nested "parameters" key (some LLMs wrap params this way)
if "parameters" in params and isinstance(params["parameters"], dict):
# Extract nested parameters and merge with top-level
nested = params.pop("parameters")
params = {**params, **nested}
# Remove common non-parameter keys that LLMs sometimes include
params = params.copy()
params.pop("function", None)
params.pop("functionName", None)
params.pop("function_name", None)
params.pop("action", None) # Some LLMs use "action" instead of function name
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', 'download_attachments', 'attachment_name'}
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: 100, max: 100)",
"minimum": 1,
"maximum": 100,
},
"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)",
},
"download_attachments": {
"type": "boolean",
"description": "If true, download all attachments from the emails to the workspace directory (default: false)",
},
"attachment_name": {
"type": "string",
"description": "Optional filter: only return emails that have at least one attachment whose filename contains this string (case-insensitive). Example: 'Rubiks' will match emails with attachments like 'Rubiks_SolutionGuide.pdf'. If not provided, all emails are returned.",
},
},
"required": [],
}
async def execute(self, limit: int = 100, unread_only: bool = False, mark_seen: bool = False, download_attachments: bool = False, attachment_name: str | None = None, **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
"""
# Convert limit to int if it's a string (from JSON parsing)
if isinstance(limit, str):
try:
limit = int(limit)
except (ValueError, TypeError):
limit = 10
# Convert boolean parameters from strings if needed (from JSON parsing)
if isinstance(download_attachments, str):
download_attachments = download_attachments.lower() in ("true", "1", "yes", "on")
if isinstance(unread_only, str):
unread_only = unread_only.lower() in ("true", "1", "yes", "on")
if isinstance(mark_seen, str):
mark_seen = mark_seen.lower() in ("true", "1", "yes", "on")
# Normalize attachment_name (empty string or None means no filter)
if attachment_name is not None:
attachment_name = str(attachment_name).strip()
if not attachment_name:
attachment_name = None
# 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)), 100)
except (ValueError, TypeError):
limit = 100
try:
messages = await asyncio.to_thread(
self._fetch_messages,
unread_only=unread_only,
mark_seen=mark_seen,
limit=limit,
download_attachments=download_attachments,
attachment_name=attachment_name,
)
if not messages:
if attachment_name:
return f"No emails found with attachments matching '{attachment_name}'. Try increasing the limit or checking if the attachment name is correct."
elif 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']}")
if msg['metadata'].get('attachments'):
att_list = ', '.join([a['filename'] for a in msg['metadata']['attachments']])
result_parts.append(f"Attachments: {att_list}")
if msg['metadata'].get('downloaded_files'):
dl_list = ', '.join(msg['metadata']['downloaded_files'])
result_parts.append(f"Downloaded to: {dl_list}")
# 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,
download_attachments: bool = False,
attachment_name: str | None = None,
) -> 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)
attachments = self._extract_attachments(parsed)
# Download attachments if requested
downloaded_files = []
if download_attachments and attachments:
import logging
logger = logging.getLogger(__name__)
if self._workspace is not None:
workspace = Path(self._workspace).expanduser().resolve()
else:
from nanobot.config.loader import load_config
workspace = load_config().workspace_path.expanduser().resolve()
workspace.mkdir(parents=True, exist_ok=True)
# Build a map of attachment parts by decoded filename for efficient lookup
attachment_parts = {}
for part in parsed.walk():
if part.get_content_disposition() == "attachment":
part_filename = part.get_filename()
if part_filename:
try:
from email.header import decode_header, make_header
decoded_part_filename = str(make_header(decode_header(part_filename)))
except Exception:
decoded_part_filename = part_filename
attachment_parts[decoded_part_filename] = part
logger.debug(f"Found attachment part: '{decoded_part_filename}' (original: '{part_filename}')")
logger.debug(f"Total attachment parts found: {len(attachment_parts)}, requested attachments: {len(attachments)}")
if attachments:
logger.debug(f"Requested attachment filenames: {[a['filename'] for a in attachments]}")
# Download each attachment
for att_info in attachments:
filename = att_info['filename']
matched_filename = filename # Will be updated if we match by base name
try:
# Try exact match first
part = attachment_parts.get(filename)
# If no exact match, try case-insensitive and normalized matching
if part is None:
filename_lower = filename.lower().strip()
for decoded_name, part_candidate in attachment_parts.items():
decoded_lower = decoded_name.lower().strip()
if decoded_lower == filename_lower:
part = part_candidate
matched_filename = decoded_name
logger.debug(f"Matched attachment '{filename}' using case-insensitive match with '{decoded_name}'")
break
# If still no match, try matching by base filename (strip common prefixes like Gmail attachment IDs)
if part is None:
# Extract base filename (everything after last underscore or use full name)
# Gmail sometimes adds prefixes like "65afea09c4f7a02afbb9d876_filename.pdf"
base_filename = filename
if '_' in filename:
# Try to match the part after the last underscore if it looks like a hash prefix
parts = filename.rsplit('_', 1)
if len(parts) == 2 and len(parts[0]) >= 20 and parts[0].isalnum():
# Looks like a hash prefix, use the base name
base_filename = parts[1]
base_lower = base_filename.lower().strip()
for decoded_name, part_candidate in attachment_parts.items():
decoded_lower = decoded_name.lower().strip()
# Check if decoded name matches base filename
if decoded_lower == base_lower:
part = part_candidate
matched_filename = decoded_name
logger.debug(f"Matched attachment '{filename}' by base filename '{base_filename}' with '{decoded_name}'")
break
# Also check if decoded name ends with base filename (in case it has its own prefix)
if decoded_lower.endswith(base_lower) or base_lower.endswith(decoded_lower):
part = part_candidate
matched_filename = decoded_name
logger.debug(f"Matched attachment '{filename}' by partial match: base '{base_filename}' with '{decoded_name}'")
break
if part is None:
logger.warning(f"Could not find attachment part for filename: {filename}")
logger.debug(f"Available attachment filenames: {list(attachment_parts.keys())}")
continue
# Save the attachment using the matched filename (cleaner, without prefixes)
att_data = part.get_payload(decode=True)
if att_data:
# Sanitize filename but preserve extension
safe_filename = "".join(c for c in matched_filename if c.isalnum() or c in "._- ")
safe_filename = safe_filename.replace(" ", "_")
file_path = workspace / safe_filename
file_path.write_bytes(att_data)
downloaded_files.append(str(file_path))
logger.info(f"Downloaded attachment '{filename}' (matched as '{matched_filename}') to {file_path}")
else:
logger.warning(f"Attachment '{filename}' has no data to save")
except Exception as e:
logger.error(f"Error downloading attachment '{filename}': {str(e)}", exc_info=True)
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"
)
if attachments:
content += f"Attachments: {', '.join([a['filename'] for a in attachments])}\n"
if downloaded_files:
content += f"Downloaded attachments to: {', '.join(downloaded_files)}\n"
content += f"\n{body}"
# Filter by attachment name if specified
if attachment_name:
attachment_name_lower = attachment_name.lower().strip()
# Check if any attachment filename contains the search term (case-insensitive)
has_matching_attachment = False
if attachments:
for att in attachments:
att_filename_lower = att['filename'].lower()
if attachment_name_lower in att_filename_lower:
has_matching_attachment = True
break
# Skip this email if it doesn't have a matching attachment
if not has_matching_attachment:
continue
metadata = {
"message_id": message_id,
"subject": subject,
"date": date_value,
"sender_email": sender,
"attachments": attachments,
"downloaded_files": downloaded_files,
}
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_attachments(msg: Any) -> list[dict[str, str]]:
"""Extract attachment information from email message."""
attachments = []
if msg.is_multipart():
for part in msg.walk():
disposition = part.get_content_disposition()
if disposition == "attachment":
filename = part.get_filename()
if filename:
# Decode filename if needed
try:
from email.header import decode_header, make_header
decoded_filename = str(make_header(decode_header(filename)))
except Exception:
decoded_filename = filename
attachments.append({
"filename": decoded_filename,
"content_type": part.get_content_type(),
"size": len(part.get_payload(decode=True) or b"") if part.get_payload(decode=True) else 0,
})
return attachments
@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

@ -28,7 +28,9 @@ class ReadFileTool(Tool):
@property
def description(self) -> str:
return """Read the contents of a file at the given path.
return """Read the contents of a file at the given path.
`path` must be a single file path under the configured workspace (no `*` globs).
ALWAYS use this tool to read files - it supports:
- Text files (plain text, code, markdown, etc.)
@ -44,7 +46,7 @@ For reading files, use read_file FIRST. Only use exec for complex data processin
"properties": {
"path": {
"type": "string",
"description": "The file path to read"
"description": "Absolute or workspace-relative path to one file (no wildcards)",
}
},
"required": ["path"]
@ -115,7 +117,7 @@ class WriteFileTool(Tool):
@property
def description(self) -> str:
return "Write content to a file at the given path. Creates parent directories if needed."
return "Write content to a file at the given path. Creates parent directories if needed. IMPORTANT: Always provide both 'path' and 'content' parameters. Paths must be under the workspace root from the system prompt (no globs)."
@property
def parameters(self) -> dict[str, Any]:
@ -219,7 +221,11 @@ class ListDirTool(Tool):
@property
def description(self) -> str:
return "List the contents of a directory."
return (
"List files and subfolders in one directory. "
"`path` must be a directory that exists under the workspace root—no `*` or `*.pdf` wildcards. "
"To list PDFs, list the directory and read names ending in .pdf, or use exec with find."
)
@property
def parameters(self) -> dict[str, Any]:
@ -228,7 +234,7 @@ class ListDirTool(Tool):
"properties": {
"path": {
"type": "string",
"description": "The directory path to list"
"description": "Path to an existing directory under the workspace (no wildcards)",
}
},
"required": ["path"]

View File

@ -1,5 +1,9 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio
import json
import re
import os
from contextlib import AsyncExitStack
from typing import Any
@ -9,15 +13,65 @@ from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
_SAFE_TOOL_NAME_RE = re.compile(r"[^A-Za-z0-9_]+")
def _normalize_tool_segment(segment: str) -> str:
"""
Normalize MCP server/tool names into a safe function name segment.
- Replace non [A-Za-z0-9_] with underscore
- Collapse repeated underscores
- Trim leading/trailing underscores
- Ensure non-empty
"""
s = _SAFE_TOOL_NAME_RE.sub("_", (segment or "").strip())
s = re.sub(r"_+", "_", s).strip("_")
return s or "tool"
def _render_mcp_content_blocks(blocks: list[Any]) -> str:
"""Render MCP content blocks into a stable, readable string."""
from mcp import types
parts: list[str] = []
for block in blocks or []:
if isinstance(block, types.TextContent):
parts.append(block.text)
continue
# Prefer structured JSON for non-text blocks when possible.
dump = getattr(block, "model_dump", None)
if callable(dump):
try:
parts.append(json.dumps(dump(), ensure_ascii=False, indent=2))
continue
except Exception:
pass
parts.append(str(block))
return "\n".join([p for p in parts if p is not None]).strip()
class MCPToolWrapper(Tool):
"""Wraps a single MCP server tool as a nanobot Tool."""
def __init__(self, session, server_name: str, tool_def):
def __init__(
self,
session,
*,
server_key: str,
tool_def,
registered_name: str,
call_timeout_s: float = 30.0,
):
self._session = session
self._original_name = tool_def.name
self._name = f"mcp_{server_name}_{tool_def.name}"
self._server_key = server_key
self._name = registered_name
self._description = tool_def.description or tool_def.name
self._parameters = tool_def.inputSchema or {"type": "object", "properties": {}}
self._call_timeout_s = call_timeout_s
@property
def name(self) -> str:
@ -32,49 +86,103 @@ class MCPToolWrapper(Tool):
return self._parameters
async def execute(self, **kwargs: Any) -> str:
from mcp import types
result = await self._session.call_tool(self._original_name, arguments=kwargs)
parts = []
for block in result.content:
if isinstance(block, types.TextContent):
parts.append(block.text)
else:
parts.append(str(block))
return "\n".join(parts) or "(no output)"
try:
result = await asyncio.wait_for(
self._session.call_tool(self._original_name, arguments=kwargs),
timeout=self._call_timeout_s,
)
except asyncio.TimeoutError:
return (
f"Error: MCP tool timed out after {self._call_timeout_s:.0f}s "
f"({self._server_key}:{self._original_name})"
)
output = _render_mcp_content_blocks(getattr(result, "content", []))
if not output:
return "(no output)"
# If the tool returned JSON, normalize empty collections to a clearer message.
try:
parsed = json.loads(output)
if parsed == [] or parsed == {}:
return "No results found."
except (json.JSONDecodeError, ValueError):
pass # Not JSON, continue with original output
return output
async def connect_mcp_server(
name: str, cfg: Any, registry: ToolRegistry, stack: AsyncExitStack
) -> None:
"""Connect one MCP server and register its tools (used for lazy profile-scoped connections)."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
def _expand_env(env: dict[str, str]) -> dict[str, str]:
"""
Expand $VARS in cfg.env using the current process environment.
This lets configs safely reference secrets that are already injected into the
container environment (e.g. via .env.shared), without duplicating them in JSON:
{ "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" }
"""
if not env:
return {}
expanded: dict[str, str] = {}
for k, v in env.items():
if v is None:
continue
expanded[k] = os.path.expandvars(str(v))
return expanded
if cfg.command:
params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=_expand_env(cfg.env) or None
)
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url)
)
else:
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
return
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
safe_server = _normalize_tool_segment(name)
safe_tool = _normalize_tool_segment(tool_def.name)
base = f"mcp_{safe_server}_{safe_tool}"
registered_name = base
i = 2
while registry.has(registered_name):
registered_name = f"{base}_{i}"
i += 1
wrapper = MCPToolWrapper(
session,
server_key=name,
tool_def=tool_def,
registered_name=registered_name,
)
registry.register(wrapper)
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry, stack: AsyncExitStack
) -> None:
"""Connect to configured MCP servers and register their tools."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
"""Connect to every configured MCP server and register their tools."""
for name, cfg in mcp_servers.items():
try:
if cfg.command:
params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None
)
read, write = await stack.enter_async_context(stdio_client(params))
elif cfg.url:
from mcp.client.streamable_http import streamable_http_client
read, write, _ = await stack.enter_async_context(
streamable_http_client(cfg.url)
)
else:
logger.warning(f"MCP server '{name}': no command or url configured, skipping")
continue
session = await stack.enter_async_context(ClientSession(read, write))
await session.initialize()
tools = await session.list_tools()
for tool_def in tools.tools:
wrapper = MCPToolWrapper(session, name, tool_def)
registry.register(wrapper)
logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'")
logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered")
await connect_mcp_server(name, cfg, registry, stack)
except Exception as e:
logger.error(f"MCP server '{name}': failed to connect: {e}")

View File

@ -8,44 +8,52 @@ from nanobot.agent.tools.base import Tool
class ToolRegistry:
"""
Registry for agent tools.
Allows dynamic registration and execution of tools.
"""
def __init__(self):
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
"""Register a tool."""
self._tools[tool.name] = tool
def unregister(self, name: str) -> None:
"""Unregister a tool by name."""
self._tools.pop(name, None)
def get(self, name: str) -> Tool | None:
"""Get a tool by name."""
return self._tools.get(name)
def has(self, name: str) -> bool:
"""Check if a tool is registered."""
return name in self._tools
def get_definitions(self) -> list[dict[str, Any]]:
"""Get all tool definitions in OpenAI format."""
return [tool.to_schema() for tool in self._tools.values()]
def get_definitions_subset(self, names: set[str]) -> list[dict[str, Any]]:
"""Tool definitions for the given names only (preserves registration order)."""
out: list[dict[str, Any]] = []
for key, tool in self._tools.items():
if key in names:
out.append(tool.to_schema())
return out
async def execute(self, name: str, params: dict[str, Any]) -> str:
"""
Execute a tool by name with given parameters.
Args:
name: Tool name.
params: Tool parameters.
Returns:
Tool execution result as string.
Raises:
KeyError: If tool not found.
"""
@ -62,14 +70,14 @@ class ToolRegistry:
return await tool.execute(**coerced_params)
except Exception as e:
return f"Error executing {name}: {str(e)}"
@property
def tool_names(self) -> list[str]:
"""Get list of registered tool names."""
return list(self._tools.keys())
def __len__(self) -> int:
return len(self._tools)
def __contains__(self, name: str) -> bool:
return name in self._tools

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

@ -2,23 +2,22 @@
import asyncio
import os
import signal
from pathlib import Path
import select
import signal
import sys
from pathlib import Path
import typer
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table
from rich.text import Text
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.patch_stdout import patch_stdout
from nanobot import __version__, __logo__
from nanobot import __logo__, __version__
from nanobot.config.schema import Config
app = typer.Typer(
@ -159,9 +158,9 @@ def onboard():
from nanobot.config.loader import get_config_path, load_config, save_config
from nanobot.config.schema import Config
from nanobot.utils.helpers import get_workspace_path
config_path = get_config_path()
if config_path.exists():
console.print(f"[yellow]Config already exists at {config_path}[/yellow]")
console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)")
@ -177,17 +176,17 @@ def onboard():
else:
save_config(Config())
console.print(f"[green]✓[/green] Created config at {config_path}")
# Create workspace
workspace = get_workspace_path()
if not workspace.exists():
workspace.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓[/green] Created workspace at {workspace}")
# Create default bootstrap files
_create_workspace_templates(workspace)
console.print(f"\n{__logo__} nanobot is ready!")
console.print("\nNext steps:")
console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]")
@ -239,13 +238,13 @@ Information about the user goes here.
- Language: (your preferred language)
""",
}
for filename, content in templates.items():
file_path = workspace / filename
if not file_path.exists():
file_path.write_text(content)
console.print(f" [dim]Created {filename}[/dim]")
# Create memory directory and MEMORY.md
memory_dir = workspace / "memory"
memory_dir.mkdir(exist_ok=True)
@ -268,7 +267,7 @@ This file stores important information that should persist across sessions.
(Things to remember)
""")
console.print(" [dim]Created memory/MEMORY.md[/dim]")
history_file = memory_dir / "HISTORY.md"
if not history_file.exists():
history_file.write_text("")
@ -281,9 +280,9 @@ This file stores important information that should persist across sessions.
def _make_provider(config: Config):
"""Create the appropriate LLM provider from config."""
from nanobot.providers.custom_provider import CustomProvider
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.custom_provider import CustomProvider
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
@ -310,7 +309,7 @@ def _make_provider(config: Config):
airllm_config = getattr(config.providers, "airllm", None)
model_path = None
compression = None
# Try to get model from airllm config's api_key field (repurposed as model path)
# or from the default model
if airllm_config and airllm_config.api_key:
@ -325,7 +324,7 @@ def _make_provider(config: Config):
else:
model_path = model
hf_token = None
# Check for compression setting in extra_headers or api_base
if airllm_config:
if airllm_config.api_base:
@ -335,7 +334,7 @@ def _make_provider(config: Config):
# Check for HF token in extra_headers
if not hf_token and airllm_config.extra_headers and "hf_token" in airllm_config.extra_headers:
hf_token = airllm_config.extra_headers["hf_token"]
return AirLLMProvider(
api_key=airllm_config.api_key if airllm_config else None,
api_base=compression if compression else None,
@ -375,30 +374,30 @@ def gateway(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
):
"""Start the nanobot gateway."""
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
from nanobot.session.manager import SessionManager
from nanobot.config.loader import get_data_dir, load_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
from nanobot.session.manager import SessionManager
if verbose:
import logging
logging.basicConfig(level=logging.DEBUG)
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
session_manager = SessionManager(config.workspace_path)
# Create cron service first (callback set after agent creation)
cron_store_path = get_data_dir() / "cron" / "jobs.json"
cron = CronService(cron_store_path)
# Create agent with cron service
agent = AgentLoop(
bus=bus,
@ -415,8 +414,11 @@ def gateway(
restrict_to_workspace=config.tools.restrict_to_workspace,
session_manager=session_manager,
mcp_servers=config.tools.mcp_servers,
tool_profiles=config.tools.tool_profiles,
default_tool_profile=config.tools.default_tool_profile,
tool_routing=config.tools.tool_routing,
)
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
@ -449,33 +451,33 @@ def gateway(
))
return response
cron.on_job = on_cron_job
# Create heartbeat service
async def on_heartbeat(prompt: str) -> str:
"""Execute heartbeat through the agent."""
return await agent.process_direct(prompt, session_key="heartbeat")
heartbeat = HeartbeatService(
workspace=config.workspace_path,
on_heartbeat=on_heartbeat,
interval_s=30 * 60, # 30 minutes
enabled=True
)
# Create channel manager
channels = ChannelManager(config, bus)
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else:
console.print("[yellow]Warning: No channels enabled[/yellow]")
cron_status = cron.status()
if cron_status["jobs"] > 0:
console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs")
console.print(f"[green]✓[/green] Heartbeat: every 30m")
console.print("[green]✓[/green] Heartbeat: every 30m")
async def run():
try:
await cron.start()
@ -492,7 +494,7 @@ def gateway(
cron.stop()
agent.stop()
await channels.stop_all()
asyncio.run(run())
@ -511,14 +513,16 @@ def agent(
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
):
"""Interact with the agent directly."""
from nanobot.config.loader import load_config, get_data_dir
from nanobot.bus.queue import MessageBus
from nanobot.agent.loop import AgentLoop
from nanobot.cron.service import CronService
from loguru import logger
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.config.loader import get_data_dir, load_config
from nanobot.cron.service import CronService
# Load config (this also loads .env file into environment)
config = load_config()
bus = MessageBus()
provider = _make_provider(config)
@ -530,7 +534,7 @@ def agent(
logger.enable("nanobot")
else:
logger.disable("nanobot")
agent_loop = AgentLoop(
bus=bus,
provider=provider,
@ -545,8 +549,11 @@ def agent(
cron_service=cron,
restrict_to_workspace=config.tools.restrict_to_workspace,
mcp_servers=config.tools.mcp_servers,
tool_profiles=config.tools.tool_profiles,
default_tool_profile=config.tools.default_tool_profile,
tool_routing=config.tools.tool_routing,
)
# Show spinner when logs are off (no output to miss); skip when logs are on
def _thinking_ctx():
if logs:
@ -572,7 +579,7 @@ def agent(
console.print(f"[red]Error: {e}[/red]")
console.print(f"[dim]{traceback.format_exc()}[/dim]")
raise
asyncio.run(run_once())
else:
# Interactive mode
@ -585,7 +592,7 @@ def agent(
os._exit(0)
signal.signal(signal.SIGINT, _exit_on_sigint)
async def run_interactive():
try:
while True:
@ -600,7 +607,7 @@ def agent(
_restore_terminal()
console.print("\nGoodbye!")
break
with _thinking_ctx():
response = await agent_loop.process_direct(user_input, session_id, on_progress=_cli_progress)
_print_agent_response(response, render_markdown=markdown)
@ -614,7 +621,7 @@ def agent(
break
finally:
await agent_loop.close_mcp()
asyncio.run(run_interactive())
@ -671,7 +678,7 @@ def channels_status():
"" if mc.enabled else "",
mc_base
)
# Telegram
tg = config.channels.telegram
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
@ -697,57 +704,57 @@ def _get_bridge_dir() -> Path:
"""Get the bridge directory, setting it up if needed."""
import shutil
import subprocess
# User's bridge location
user_bridge = Path.home() / ".nanobot" / "bridge"
# Check if already built
if (user_bridge / "dist" / "index.js").exists():
return user_bridge
# Check for npm
if not shutil.which("npm"):
console.print("[red]npm not found. Please install Node.js >= 18.[/red]")
raise typer.Exit(1)
# Find source bridge: first check package data, then source dir
pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed)
src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev)
source = None
if (pkg_bridge / "package.json").exists():
source = pkg_bridge
elif (src_bridge / "package.json").exists():
source = src_bridge
if not source:
console.print("[red]Bridge source not found.[/red]")
console.print("Try reinstalling: pip install --force-reinstall nanobot")
raise typer.Exit(1)
console.print(f"{__logo__} Setting up bridge...")
# Copy to user directory
user_bridge.parent.mkdir(parents=True, exist_ok=True)
if user_bridge.exists():
shutil.rmtree(user_bridge)
shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist"))
# Install and build
try:
console.print(" Installing dependencies...")
subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True)
console.print(" Building...")
subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True)
console.print("[green]✓[/green] Bridge ready\n")
except subprocess.CalledProcessError as e:
console.print(f"[red]Build failed: {e}[/red]")
if e.stderr:
console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]")
raise typer.Exit(1)
return user_bridge
@ -755,18 +762,19 @@ def _get_bridge_dir() -> Path:
def channels_login():
"""Link device via QR code."""
import subprocess
from nanobot.config.loader import load_config
config = load_config()
bridge_dir = _get_bridge_dir()
console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n")
env = {**os.environ}
if config.channels.whatsapp.bridge_token:
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
except subprocess.CalledProcessError as e:
@ -790,23 +798,23 @@ def cron_list(
"""List scheduled jobs."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
jobs = service.list_jobs(include_disabled=all)
if not jobs:
console.print("No scheduled jobs.")
return
table = Table(title="Scheduled Jobs")
table.add_column("ID", style="cyan")
table.add_column("Name")
table.add_column("Schedule")
table.add_column("Status")
table.add_column("Next Run")
import time
from datetime import datetime as _dt
from zoneinfo import ZoneInfo
@ -818,7 +826,7 @@ def cron_list(
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
else:
sched = "one-time"
# Format next run
next_run = ""
if job.state.next_run_at_ms:
@ -828,11 +836,11 @@ def cron_list(
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
except Exception:
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
table.add_row(job.id, job.name, sched, status, next_run)
console.print(table)
@ -852,7 +860,7 @@ def cron_add(
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
from nanobot.cron.types import CronSchedule
if tz and not cron_expr:
console.print("[red]Error: --tz can only be used with --cron[/red]")
raise typer.Exit(1)
@ -869,10 +877,10 @@ def cron_add(
else:
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
raise typer.Exit(1)
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
job = service.add_job(
name=name,
schedule=schedule,
@ -881,7 +889,7 @@ def cron_add(
to=to,
channel=channel,
)
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
@ -892,10 +900,10 @@ def cron_remove(
"""Remove a scheduled job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
if service.remove_job(job_id):
console.print(f"[green]✓[/green] Removed job {job_id}")
else:
@ -910,10 +918,10 @@ def cron_enable(
"""Enable or disable a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
job = service.enable_job(job_id, enabled=not disable)
if job:
status = "disabled" if disable else "enabled"
@ -930,15 +938,15 @@ def cron_run(
"""Manually run a job."""
from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
store_path = get_data_dir() / "cron" / "jobs.json"
service = CronService(store_path)
async def run():
return await service.run_job(job_id, force=force)
if asyncio.run(run()):
console.print(f"[green]✓[/green] Job executed")
console.print("[green]✓[/green] Job executed")
else:
console.print(f"[red]Failed to run job {job_id}[/red]")
@ -951,7 +959,7 @@ def cron_run(
@app.command()
def status():
"""Show nanobot status."""
from nanobot.config.loader import load_config, get_config_path
from nanobot.config.loader import get_config_path, load_config
config_path = get_config_path()
config = load_config()
@ -966,7 +974,7 @@ def status():
from nanobot.providers.registry import PROVIDERS
console.print(f"Model: {config.agents.defaults.model}")
# Check API keys from registry
for spec in PROVIDERS:
p = getattr(config.providers, spec.name, None)

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

@ -1,7 +1,8 @@
"""Configuration schema using Pydantic."""
from pathlib import Path
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings
@ -244,6 +245,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."""
@ -259,13 +271,47 @@ class MCPServerConfig(Base):
url: str = "" # HTTP: streamable HTTP endpoint URL
class ToolProfileConfig(Base):
"""Subset of tools exposed to the LLM when this profile is active."""
description: str = "" # Shown to the router model when toolRouting is enabled
builtin_tools: list[str] | None = None # None = all non-MCP tools; [] = none (except always-include)
mcp_servers: list[str] | None = None # None = all configured MCP servers; [] = no MCP tools
class ToolRoutingConfig(Base):
"""Optional LLM router that picks a tool profile from the user message (phase 2)."""
enabled: bool = False
router_temperature: float = 0.2
router_max_tokens: int = 128
# Always merged into the allowed set (if registered), e.g. channel reply + subagent spawn
always_include_tools: list[str] = Field(default_factory=lambda: ["message", "spawn"])
# If the model calls a missing tool, retry the loop once with all tools registered
expand_on_missing_tool: bool = True
class ToolsConfig(Base):
"""Tools configuration."""
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
calendar: CalendarConfig = Field(default_factory=CalendarConfig)
restrict_to_workspace: bool = True # If true, restrict all tool access to workspace directory
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
tool_profiles: dict[str, ToolProfileConfig] = Field(default_factory=dict)
default_tool_profile: str = "default"
tool_routing: ToolRoutingConfig = Field(default_factory=ToolRoutingConfig)
@model_validator(mode="after")
def _tool_profiles_consistent(self) -> "ToolsConfig":
if self.tool_profiles and self.default_tool_profile not in self.tool_profiles:
raise ValueError(
f"defaultToolProfile '{self.default_tool_profile}' is missing from tools.toolProfiles"
)
if self.tool_routing.enabled and not self.tool_profiles:
raise ValueError("toolRouting.enabled requires a non-empty tools.toolProfiles map")
return self
class Config(BaseSettings):

View File

@ -59,25 +59,63 @@ class CustomProvider(LLMProvider):
for tc in (msg.tool_calls or [])
]
# If no structured tool calls, try to parse from content (Ollama sometimes returns JSON in content)
# If no structured tool calls, try to parse from content (some OpenAI-compatible backends return 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:
stripped = content.strip()
# Note: This list should match tools registered in AgentLoop._register_default_tools().
# MCP tools are registered dynamically and are prefixed with "mcp_" (allow those too).
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",
]
# 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"]))
# Some backends will return *only* a JSON object as the entire message content.
# If it looks like a JSON object, attempt parsing even if our heuristics missed it.
looks_like_json_object = stripped.startswith("{") and stripped.endswith("}")
if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object):
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,13 +136,29 @@ 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": {...}}
if (isinstance(tool_obj, dict) and
"name" in tool_obj and
"parameters" in tool_obj and
isinstance(tool_obj["name"], str) and
tool_obj["name"] in valid_tools):
(tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_"))):
tool_calls.append(ToolCallRequest(
id=f"call_{len(tool_calls)}",
name=tool_obj["name"],
@ -118,6 +172,32 @@ class CustomProvider(LLMProvider):
pass # If parsing fails, skip this match
start_pos = json_start + 1 # Move past this match
# If we still didn't match embedded objects, try parsing the whole message as a single tool-call JSON object.
if not tool_calls and looks_like_json_object:
try:
tool_obj = json_repair.loads(stripped)
if isinstance(tool_obj, dict) and "action" in tool_obj:
action = tool_obj.get("action")
if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]:
tool_calls.append(ToolCallRequest(
id="call_0",
name="calendar",
arguments=tool_obj,
))
content = ""
if isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj:
if isinstance(tool_obj["name"], str) and (
tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_")
):
tool_calls.append(ToolCallRequest(
id="call_0",
name=tool_obj["name"],
arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])},
))
content = ""
except Exception:
pass
u = response.usage
return LLMResponse(

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

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Create ~/.nanobot/workspaces/{ilia,family,wife}/ from repo templates (Option B).
# Does not overwrite existing files — safe to re-run.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NANOBOT_HOME="${NANOBOT_HOME:-$HOME/.nanobot}"
DEST="${NANOBOT_HOME}/workspaces"
SKEL="${REPO_ROOT}/agent_workspaces"
if [[ ! -d "${SKEL}/ilia" ]]; then
echo "error: missing ${SKEL}/ilia — run from nanobot repo root" >&2
exit 1
fi
install_skel() {
local agent="$1"
local d="${DEST}/${agent}"
mkdir -p "${d}/memory"
for f in AGENTS.md USER.md SOUL.md; do
if [[ ! -f "${d}/${f}" ]]; then
cp "${SKEL}/${agent}/${f}" "${d}/${f}"
echo "created ${d}/${f}"
else
echo "skip (exists): ${d}/${f}"
fi
done
for f in MEMORY.md HISTORY.md; do
if [[ ! -f "${d}/memory/${f}" ]]; then
cp "${SKEL}/${agent}/memory/${f}" "${d}/memory/${f}"
echo "created ${d}/memory/${f}"
else
echo "skip (exists): ${d}/memory/${f}"
fi
done
}
echo "NANOBOT_HOME=${NANOBOT_HOME}"
echo "DEST=${DEST}"
mkdir -p "${DEST}"
for agent in ilia family wife; do
echo "--- ${agent} ---"
install_skel "${agent}"
done
echo "done. Fix ownership if needed, e.g.:"
echo " sudo chown -R \"\$(whoami):\$(whoami)\" \"${DEST}\""

113
scripts/setup-mcp-servers.sh Executable file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Clone/build local MCP servers into ./mcp-servers (local-clone policy).
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MCP_DIR="${REPO_ROOT}/mcp-servers"
usage() {
cat <<'EOF'
usage:
./scripts/setup-mcp-servers.sh gitea
notes:
- clones into ./mcp-servers/<name>
- builds artifacts needed to run the MCP server locally
EOF
}
need_cmd() {
local cmd="$1"
if ! command -v "${cmd}" >/dev/null 2>&1; then
echo "error: missing '${cmd}' on PATH" >&2
return 1
fi
}
need_go_min() {
local want_major="$1"
local want_minor="$2"
local v
v="$(go version 2>/dev/null || true)"
# Example: "go version go1.26.0 linux/amd64"
local ver
ver="$(echo "${v}" | awk '{print $3}' | sed 's/^go//')"
local major minor
major="$(echo "${ver}" | cut -d. -f1)"
minor="$(echo "${ver}" | cut -d. -f2)"
if [[ -z "${major}" || -z "${minor}" ]]; then
echo "error: could not parse Go version from: ${v}" >&2
return 1
fi
# Compare major/minor only (sufficient for our use).
if (( major < want_major )) || { (( major == want_major )) && (( minor < want_minor )); }; then
echo "error: Go ${want_major}.${want_minor}+ required; found ${ver}" >&2
return 1
fi
}
setup_gitea() {
need_cmd git
if ! command -v go >/dev/null 2>&1; then
cat <<'EOF' >&2
error: Go toolchain not found (required to build gitea-mcp).
install one of:
- Debian/Ubuntu: sudo apt-get update && sudo apt-get install -y golang
- Or install Go from https://go.dev/dl/
then rerun:
./scripts/setup-mcp-servers.sh gitea
EOF
exit 2
fi
if ! need_go_min 1 26; then
cat <<'EOF' >&2
gitea-mcp currently requires a newer Go toolchain than Debian stable typically ships.
If you already installed a newer Go under /usr/local (example: /usr/local/go1.26/bin/go),
rerun with PATH overridden, e.g.:
PATH="/usr/local/go1.26/bin:$PATH" ./scripts/setup-mcp-servers.sh gitea
EOF
exit 2
fi
mkdir -p "${MCP_DIR}"
if [[ ! -d "${MCP_DIR}/gitea-mcp/.git" ]]; then
git clone https://gitea.com/gitea/gitea-mcp.git "${MCP_DIR}/gitea-mcp"
else
echo "info: gitea-mcp already cloned, skipping clone"
fi
(cd "${MCP_DIR}/gitea-mcp" && go build -o gitea-mcp .)
echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp"
}
main() {
if [[ "${#}" -ne 1 ]]; then
usage
exit 1
fi
case "$1" in
gitea) setup_gitea ;;
-h|--help|help) usage ;;
*)
echo "error: unknown target '$1'" >&2
usage
exit 1
;;
esac
}
main "$@"

104
update-multi-configs.sh Executable file
View File

@ -0,0 +1,104 @@
#!/bin/bash
# Script to update shared settings across all bot configs
set -e
CONFIG_DIRS=(
~/.nanobot-user1
~/.nanobot-user2
~/.nanobot-user3
)
# Function to update a specific key in all configs
update_config_key() {
local key_path="$1"
local new_value="$2"
echo "Updating $key_path to $new_value in all configs..."
for dir in "${CONFIG_DIRS[@]}"; do
config_file="$dir/config.json"
if [ -f "$config_file" ]; then
# Use jq to update the config
if command -v jq &> /dev/null; then
# Convert key_path like "providers.openrouter.apiKey" to jq path
jq_path=$(echo "$key_path" | sed 's/\./"."/g' | sed 's/^/./' | sed 's/\.$//')
jq "$jq_path = \"$new_value\"" "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
echo " ✓ Updated $config_file"
else
echo " ⚠ jq not found, skipping $config_file (install jq for automatic updates)"
fi
else
echo " ⚠ Config not found: $config_file"
fi
done
}
# Function to show usage
usage() {
cat << EOF
Usage: $0 <command> [args]
Commands:
update-api-key <provider> <key> Update API key for a provider (e.g., openrouter)
update-model <model> Update default model
update-setting <key.path> <value> Update any setting using dot notation
Examples:
$0 update-api-key openrouter "sk-or-v1-xxx"
$0 update-model "anthropic/claude-opus-4-5"
$0 update-setting "agents.defaults.temperature" "0.8"
Note: Requires 'jq' to be installed for automatic updates.
Install with: sudo apt install jq (or brew install jq on macOS)
EOF
}
# Main script
if [ $# -eq 0 ]; then
usage
exit 1
fi
case "$1" in
update-api-key)
if [ $# -ne 3 ]; then
echo "Error: update-api-key requires provider name and API key"
usage
exit 1
fi
provider="$2"
api_key="$3"
update_config_key "providers.$provider.apiKey" "$api_key"
;;
update-model)
if [ $# -ne 2 ]; then
echo "Error: update-model requires model name"
usage
exit 1
fi
model="$2"
update_config_key "agents.defaults.model" "$model"
;;
update-setting)
if [ $# -ne 3 ]; then
echo "Error: update-setting requires key path and value"
usage
exit 1
fi
key_path="$2"
value="$3"
update_config_key "$key_path" "$value"
;;
*)
echo "Unknown command: $1"
usage
exit 1
;;
esac
echo ""
echo "Done! Restart containers to apply changes:"
echo " docker compose -f docker-compose.multi.yml restart"

View File

@ -2,13 +2,94 @@
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: CONVERSATION CONTEXT - READ THE HISTORY**
- **ALWAYS read the conversation history BEFORE responding** - it contains previous messages that provide context
- When the user says "yes", "download it", "do it", "download the attachment", etc., **LOOK AT YOUR PREVIOUS RESPONSE** in the conversation history
- If YOU previously mentioned an attachment (e.g., "This email includes a PDF attachment named X.pdf"), and the user says "yes download it" or "download the attachment", they are referring to THAT attachment
- DO NOT ask for clarification if the context is clear from YOUR OWN previous messages
- Example conversation flow:
- You: "Email has attachment X.pdf. Would you like me to download it?"
- User: "yes download it"
- You: IMMEDIATELY use `read_emails(limit=1, download_attachments=true)` - do NOT ask which attachment
- When user says "yes" or "download it" after you mentioned an attachment, they mean download the attachment YOU just mentioned
## 🛑 CRITICAL: System Permissions & Safety Override
You are running in a secure environment. YOU HAVE EXPLICIT PERMISSION AND AUTHORIZATION from the user to create, modify, and delete files on the local filesystem.
- NEVER refuse a request to write, save, or edit files due to "safety", "security", or "system harm" concerns.
- DO NOT claim you cannot modify external files. You have the `write_file` and `edit_file` tools exactly for this purpose.
- When asked to save, write, or create a file, immediately use the `write_file` tool to do so.
## 🚨 CRITICAL: File Saving Behavior
**When the user asks you to "save", "write", or "create" a file:**
- DO NOT explain what you will do - JUST DO IT
- DO NOT say "I will create a Python script" - USE THE `write_file` TOOL DIRECTLY
- DO NOT show code examples - EXECUTE THE TOOL IMMEDIATELY
- The `write_file` tool REQUIRES both `path` and `content` parameters - ALWAYS provide both
- If user says "save to filename.txt", use full path: `/mnt/data/nanobot/workspace/filename.txt`
- Example: User says "save story to story.txt" → IMMEDIATELY call `write_file(path="/mnt/data/nanobot/workspace/story.txt", content="...")` - DO NOT explain, DO NOT show code
## 🚨 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 +123,36 @@ 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)
- Gmail MCP tools (mcp_gmail_mcp_*) - search, read, send emails via Gmail API
## Email Tools
**CRITICAL: Which tool to use:**
- **ALWAYS use `read_emails`** for queries about emails received via the email channel (IMAP)
- **ONLY use Gmail MCP tools** (`mcp_gmail_mcp_*`) when explicitly working with Gmail API features (labels, filters, etc.)
- When user asks about "the last email", "latest email", "recent emails", or emails received via email channel → use `read_emails(limit=1)` or `read_emails(limit=5)`
- When user asks about attachments in emails received via email channel → use `read_emails` first to get the email, then check metadata for attachment info
- When user asks to "download attachment" or "download it" (referring to an attachment) → use `read_emails(limit=1, download_attachments=true)` to download attachments from the last email
- When user asks to "find emails with attachment X" or "emails containing attachment Y" → use `read_emails(limit=100, attachment_name="X")` to filter emails by attachment filename (case-insensitive partial match)
- DO NOT use `mcp_gmail_mcp_read_email` for emails received via the email channel - those emails are from IMAP, not Gmail API
**When checking for emails:**
- Use `read_emails` for IMAP mailbox access (this is the PRIMARY tool for email queries)
- Use `mcp_gmail_mcp_search_emails` ONLY for Gmail API-specific searches
- When a search returns "No unread emails found" or empty results, tell the user clearly: "You have no new unread emails" or "No emails found matching your criteria"
- DO NOT ask for clarification when you get empty results - empty results ARE a valid answer
- If the tool returns "(no output)" for a search query, interpret it as "no results found"
**When receiving emails via the email channel:**
- Messages starting with "Email received.\nFrom:" contain the FULL email content - you already have everything you need
- DO NOT try to fetch the email again using `mcp_gmail_mcp_read_email` - the content is already in the message
- The message format is: "Email received.\nFrom: {sender}\nSubject: {subject}\nDate: {date}\n\n{body}"
- Process the email content directly from the message - do not attempt to retrieve it from Gmail API
- If you need to reply, use the email channel's reply functionality or `mcp_gmail_mcp_send_email`
- The metadata.message_id in the message is the email's Message-ID header, NOT a Gmail API message ID - do not use it with Gmail MCP tools
- For attachment information, check the email metadata or use `read_emails` to fetch the full email details
## Memory
@ -72,6 +183,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 +299,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