refactor: unify instance path resolution and preserve workspace override
This commit is contained in:
192
README.md
192
README.md
@@ -905,7 +905,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
|
|
||||||
## Multiple Instances
|
## Multiple Instances
|
||||||
|
|
||||||
Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage.
|
Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
@@ -920,35 +920,31 @@ nanobot gateway --config ~/.nanobot-discord/config.json
|
|||||||
nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
|
nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792
|
||||||
```
|
```
|
||||||
|
|
||||||
### Complete Isolation
|
### Path Resolution
|
||||||
|
|
||||||
When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation:
|
When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`.
|
||||||
|
|
||||||
| Component | Isolation | Example |
|
| Component | Resolved From | Example |
|
||||||
|-----------|-----------|---------|
|
|-----------|---------------|---------|
|
||||||
| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` |
|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
|
||||||
| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` |
|
| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
|
||||||
| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` |
|
| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
|
||||||
| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` |
|
| **Media / runtime state** | config directory | `~/.nanobot-A/media/` |
|
||||||
| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` |
|
|
||||||
|
|
||||||
### Setup Example
|
### How It Works
|
||||||
|
|
||||||
**1. Create directory structure for each instance:**
|
- `--config` selects which config file to load
|
||||||
|
- By default, the workspace comes from `agents.defaults.workspace` in that config
|
||||||
|
- If you pass `--workspace`, it overrides the workspace from the config file
|
||||||
|
|
||||||
```bash
|
### Minimal Setup
|
||||||
# Instance A
|
|
||||||
mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media}
|
|
||||||
cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json
|
|
||||||
|
|
||||||
# Instance B
|
1. Copy your base config into a new instance directory.
|
||||||
mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media}
|
2. Set a different `agents.defaults.workspace` for that instance.
|
||||||
cp ~/.nanobot/config.json ~/.nanobot-discord/config.json
|
3. Start the instance with `--config`.
|
||||||
```
|
|
||||||
|
|
||||||
**2. Configure each instance:**
|
Example config:
|
||||||
|
|
||||||
Edit `~/.nanobot-telegram/config.json`:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -969,160 +965,32 @@ Edit `~/.nanobot-telegram/config.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `~/.nanobot-discord/config.json`:
|
Start separate instances:
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"workspace": "~/.nanobot-discord/workspace",
|
|
||||||
"model": "anthropic/claude-opus-4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"channels": {
|
|
||||||
"discord": {
|
|
||||||
"enabled": true,
|
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"gateway": {
|
|
||||||
"port": 18791
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Start instances:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1
|
|
||||||
nanobot gateway --config ~/.nanobot-telegram/config.json
|
nanobot gateway --config ~/.nanobot-telegram/config.json
|
||||||
|
|
||||||
# Terminal 2
|
|
||||||
nanobot gateway --config ~/.nanobot-discord/config.json
|
nanobot gateway --config ~/.nanobot-discord/config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use Cases
|
Override workspace for one-off runs when needed:
|
||||||
|
|
||||||
- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc.
|
|
||||||
- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously
|
|
||||||
- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent)
|
|
||||||
- **Multi-Tenant**: Serve multiple users/teams with isolated configurations
|
|
||||||
|
|
||||||
### Management Scripts
|
|
||||||
|
|
||||||
For production deployments, create management scripts for each instance:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
#!/bin/bash
|
nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
|
||||||
# manage-telegram.sh
|
|
||||||
|
|
||||||
INSTANCE_NAME="telegram"
|
|
||||||
CONFIG_FILE="$HOME/.nanobot-telegram/config.json"
|
|
||||||
LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log"
|
|
||||||
|
|
||||||
case "$1" in
|
|
||||||
start)
|
|
||||||
nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 &
|
|
||||||
echo "Started $INSTANCE_NAME instance (PID: $!)"
|
|
||||||
;;
|
|
||||||
stop)
|
|
||||||
pkill -f "nanobot gateway.*$CONFIG_FILE"
|
|
||||||
echo "Stopped $INSTANCE_NAME instance"
|
|
||||||
;;
|
|
||||||
restart)
|
|
||||||
$0 stop
|
|
||||||
sleep 2
|
|
||||||
$0 start
|
|
||||||
;;
|
|
||||||
status)
|
|
||||||
pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "$INSTANCE_NAME instance is running"
|
|
||||||
else
|
|
||||||
echo "$INSTANCE_NAME instance is not running"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 {start|stop|restart|status}"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### systemd Service (Linux)
|
### Common Use Cases
|
||||||
|
|
||||||
For automatic startup and crash recovery, create a systemd service for each instance:
|
- Run separate bots for Telegram, Discord, Feishu, and other platforms
|
||||||
|
- Keep testing and production instances isolated
|
||||||
```ini
|
- Use different models or providers for different teams
|
||||||
# ~/.config/systemd/user/nanobot-telegram.service
|
- Serve multiple tenants with separate configs and runtime data
|
||||||
[Unit]
|
|
||||||
Description=Nanobot Telegram Instance
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable and start:
|
|
||||||
```bash
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
systemctl --user enable --now nanobot-telegram
|
|
||||||
systemctl --user enable --now nanobot-discord
|
|
||||||
```
|
|
||||||
|
|
||||||
### launchd Service (macOS)
|
|
||||||
|
|
||||||
Create a plist file for each instance:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- ~/Library/LaunchAgents/com.nanobot.telegram.plist -->
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.nanobot.telegram</string>
|
|
||||||
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/path/to/nanobot</string>
|
|
||||||
<string>gateway</string>
|
|
||||||
<string>--config</string>
|
|
||||||
<string>/Users/yourname/.nanobot-telegram/config.json</string>
|
|
||||||
</array>
|
|
||||||
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/Users/yourname/.nanobot-telegram/logs/stdout.log</string>
|
|
||||||
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/Users/yourname/.nanobot-telegram/logs/stderr.log</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
```
|
|
||||||
|
|
||||||
Load the service:
|
|
||||||
```bash
|
|
||||||
launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist
|
|
||||||
launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist
|
|
||||||
```
|
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- Each instance must use a different port (default: 18790)
|
- Each instance must use a different port if they run at the same time
|
||||||
- Instances are completely independent — no shared state or cross-talk
|
- Use a different workspace per instance if you want isolated memory, sessions, and skills
|
||||||
- You can run different LLM models, providers, and channel configurations per instance
|
- `--workspace` overrides the workspace defined in the config file
|
||||||
- Memory, sessions, and cron jobs are fully isolated between instances
|
- Cron jobs and runtime media/state are derived from the config directory
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import qrcode from 'qrcode-terminal';
|
|||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { writeFile, mkdir } from 'fs/promises';
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
const VERSION = '0.1.0';
|
const VERSION = '0.1.0';
|
||||||
@@ -162,7 +161,7 @@ export class WhatsAppClient {
|
|||||||
|
|
||||||
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
|
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const mediaDir = join(homedir(), '.nanobot', 'media');
|
const mediaDir = join(this.options.authDir, '..', 'media');
|
||||||
await mkdir(mediaDir, { recursive: true });
|
await mkdir(mediaDir, { recursive: true });
|
||||||
|
|
||||||
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
|
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from loguru import logger
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import DiscordConfig
|
from nanobot.config.schema import DiscordConfig
|
||||||
from nanobot.utils.helpers import split_message
|
from nanobot.utils.helpers import split_message
|
||||||
|
|
||||||
@@ -289,7 +290,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
|
|
||||||
content_parts = [content] if content else []
|
content_parts = [content] if content else []
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
media_dir = Path.home() / ".nanobot" / "media"
|
media_dir = get_media_dir("discord")
|
||||||
|
|
||||||
for attachment in payload.get("attachments") or []:
|
for attachment in payload.get("attachments") or []:
|
||||||
url = attachment.get("url")
|
url = attachment.get("url")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from loguru import logger
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import FeishuConfig
|
from nanobot.config.schema import FeishuConfig
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
@@ -732,8 +733,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
(file_path, content_text) - file_path is None if download failed
|
(file_path, content_text) - file_path is None if download failed
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
media_dir = Path.home() / ".nanobot" / "media"
|
media_dir = get_media_dir("feishu")
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
data, filename = None, None
|
data, filename = None, None
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ except ImportError as e:
|
|||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.loader import get_data_dir
|
from nanobot.config.paths import get_data_dir, get_media_dir
|
||||||
from nanobot.utils.helpers import safe_filename
|
from nanobot.utils.helpers import safe_filename
|
||||||
|
|
||||||
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
TYPING_NOTICE_TIMEOUT_MS = 30_000
|
||||||
@@ -490,9 +490,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _media_dir(self) -> Path:
|
def _media_dir(self) -> Path:
|
||||||
d = get_data_dir() / "media" / "matrix"
|
return get_media_dir("matrix")
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
|
||||||
return d
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
|
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ from loguru import logger
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
from nanobot.config.schema import MochatConfig
|
from nanobot.config.schema import MochatConfig
|
||||||
from nanobot.utils.helpers import get_data_path
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import socketio
|
import socketio
|
||||||
@@ -224,7 +224,7 @@ class MochatChannel(BaseChannel):
|
|||||||
self._socket: Any = None
|
self._socket: Any = None
|
||||||
self._ws_connected = self._ws_ready = False
|
self._ws_connected = self._ws_ready = False
|
||||||
|
|
||||||
self._state_dir = get_data_path() / "mochat"
|
self._state_dir = get_runtime_subdir("mochat")
|
||||||
self._cursor_path = self._state_dir / "session_cursors.json"
|
self._cursor_path = self._state_dir / "session_cursors.json"
|
||||||
self._session_cursor: dict[str, int] = {}
|
self._session_cursor: dict[str, int] = {}
|
||||||
self._cursor_save_task: asyncio.Task | None = None
|
self._cursor_save_task: asyncio.Task | None = None
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from telegram.request import HTTPXRequest
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import TelegramConfig
|
from nanobot.config.schema import TelegramConfig
|
||||||
from nanobot.utils.helpers import split_message
|
from nanobot.utils.helpers import split_message
|
||||||
|
|
||||||
@@ -536,10 +537,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
getattr(media_file, 'mime_type', None),
|
getattr(media_file, 'mime_type', None),
|
||||||
getattr(media_file, 'file_name', None),
|
getattr(media_file, 'file_name', None),
|
||||||
)
|
)
|
||||||
# Save to workspace/media/
|
media_dir = get_media_dir("telegram")
|
||||||
from pathlib import Path
|
|
||||||
media_dir = Path.home() / ".nanobot" / "media"
|
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
|
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
|
||||||
await file.download_to_drive(str(file_path))
|
await file.download_to_drive(str(file_path))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from rich.table import Table
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from nanobot import __logo__, __version__
|
from nanobot import __logo__, __version__
|
||||||
|
from nanobot.config.paths import get_workspace_path
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
|
||||||
@@ -99,7 +100,9 @@ def _init_prompt_session() -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
history_file = Path.home() / ".nanobot" / "history" / "cli_history"
|
from nanobot.config.paths import get_cli_history_path
|
||||||
|
|
||||||
|
history_file = get_cli_history_path()
|
||||||
history_file.parent.mkdir(parents=True, exist_ok=True)
|
history_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
_PROMPT_SESSION = PromptSession(
|
_PROMPT_SESSION = PromptSession(
|
||||||
@@ -170,7 +173,6 @@ def onboard():
|
|||||||
"""Initialize nanobot configuration and workspace."""
|
"""Initialize nanobot configuration and workspace."""
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.utils.helpers import get_workspace_path
|
|
||||||
|
|
||||||
config_path = get_config_path()
|
config_path = get_config_path()
|
||||||
|
|
||||||
@@ -271,8 +273,9 @@ def _make_provider(config: Config):
|
|||||||
@app.command()
|
@app.command()
|
||||||
def gateway(
|
def gateway(
|
||||||
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
config: str = typer.Option(None, "--config", "-c", help="Path to config file"),
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
):
|
):
|
||||||
"""Start the nanobot gateway."""
|
"""Start the nanobot gateway."""
|
||||||
# Set config path if provided (must be done before any imports that use get_data_dir)
|
# Set config path if provided (must be done before any imports that use get_data_dir)
|
||||||
@@ -288,7 +291,8 @@ def gateway(
|
|||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
from nanobot.config.loader import get_data_dir, load_config
|
from nanobot.config.loader import load_config
|
||||||
|
from nanobot.config.paths import get_cron_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.heartbeat.service import HeartbeatService
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
@@ -301,13 +305,15 @@ def gateway(
|
|||||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
if workspace:
|
||||||
|
config.agents.defaults.workspace = workspace
|
||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
session_manager = SessionManager(config.workspace_path)
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
# Create cron service first (callback set after agent creation)
|
# Create cron service first (callback set after agent creation)
|
||||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
cron_store_path = get_cron_dir() / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
@@ -476,7 +482,8 @@ def agent(
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.loader import get_data_dir, load_config
|
from nanobot.config.loader import load_config
|
||||||
|
from nanobot.config.paths import get_cron_dir
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@@ -486,7 +493,7 @@ def agent(
|
|||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
|
|
||||||
# Create cron service for tool usage (no callback needed for CLI unless running)
|
# Create cron service for tool usage (no callback needed for CLI unless running)
|
||||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
cron_store_path = get_cron_dir() / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
if logs:
|
if logs:
|
||||||
@@ -752,7 +759,9 @@ def _get_bridge_dir() -> Path:
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# User's bridge location
|
# User's bridge location
|
||||||
user_bridge = Path.home() / ".nanobot" / "bridge"
|
from nanobot.config.paths import get_bridge_install_dir
|
||||||
|
|
||||||
|
user_bridge = get_bridge_install_dir()
|
||||||
|
|
||||||
# Check if already built
|
# Check if already built
|
||||||
if (user_bridge / "dist" / "index.js").exists():
|
if (user_bridge / "dist" / "index.js").exists():
|
||||||
@@ -810,6 +819,7 @@ def channels_login():
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
bridge_dir = _get_bridge_dir()
|
bridge_dir = _get_bridge_dir()
|
||||||
@@ -820,6 +830,7 @@ def channels_login():
|
|||||||
env = {**os.environ}
|
env = {**os.environ}
|
||||||
if config.channels.whatsapp.bridge_token:
|
if config.channels.whatsapp.bridge_token:
|
||||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||||
|
env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
"""Configuration module for nanobot."""
|
"""Configuration module for nanobot."""
|
||||||
|
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import get_config_path, load_config
|
||||||
|
from nanobot.config.paths import (
|
||||||
|
get_bridge_install_dir,
|
||||||
|
get_cli_history_path,
|
||||||
|
get_cron_dir,
|
||||||
|
get_data_dir,
|
||||||
|
get_legacy_sessions_dir,
|
||||||
|
get_logs_dir,
|
||||||
|
get_media_dir,
|
||||||
|
get_runtime_subdir,
|
||||||
|
get_workspace_path,
|
||||||
|
)
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
__all__ = ["Config", "load_config", "get_config_path"]
|
__all__ = [
|
||||||
|
"Config",
|
||||||
|
"load_config",
|
||||||
|
"get_config_path",
|
||||||
|
"get_data_dir",
|
||||||
|
"get_runtime_subdir",
|
||||||
|
"get_media_dir",
|
||||||
|
"get_cron_dir",
|
||||||
|
"get_logs_dir",
|
||||||
|
"get_workspace_path",
|
||||||
|
"get_cli_history_path",
|
||||||
|
"get_bridge_install_dir",
|
||||||
|
"get_legacy_sessions_dir",
|
||||||
|
]
|
||||||
|
|||||||
@@ -23,14 +23,6 @@ def get_config_path() -> Path:
|
|||||||
return Path.home() / ".nanobot" / "config.json"
|
return Path.home() / ".nanobot" / "config.json"
|
||||||
|
|
||||||
|
|
||||||
def get_data_dir() -> Path:
|
|
||||||
"""Get the nanobot data directory (derived from config path)."""
|
|
||||||
config_path = get_config_path()
|
|
||||||
# If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/
|
|
||||||
# If config is ~/.nanobot/config.json, data dir is ~/.nanobot/
|
|
||||||
return config_path.parent
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: Path | None = None) -> Config:
|
def load_config(config_path: Path | None = None) -> Config:
|
||||||
"""
|
"""
|
||||||
Load configuration from file or create default.
|
Load configuration from file or create default.
|
||||||
|
|||||||
55
nanobot/config/paths.py
Normal file
55
nanobot/config/paths.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Runtime path helpers derived from the active config context."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
from nanobot.utils.helpers import ensure_dir
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_dir() -> Path:
|
||||||
|
"""Return the instance-level runtime data directory."""
|
||||||
|
return ensure_dir(get_config_path().parent)
|
||||||
|
|
||||||
|
|
||||||
|
def get_runtime_subdir(name: str) -> Path:
|
||||||
|
"""Return a named runtime subdirectory under the instance data dir."""
|
||||||
|
return ensure_dir(get_data_dir() / name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_media_dir(channel: str | None = None) -> Path:
|
||||||
|
"""Return the media directory, optionally namespaced per channel."""
|
||||||
|
base = get_runtime_subdir("media")
|
||||||
|
return ensure_dir(base / channel) if channel else base
|
||||||
|
|
||||||
|
|
||||||
|
def get_cron_dir() -> Path:
|
||||||
|
"""Return the cron storage directory."""
|
||||||
|
return get_runtime_subdir("cron")
|
||||||
|
|
||||||
|
|
||||||
|
def get_logs_dir() -> Path:
|
||||||
|
"""Return the logs directory."""
|
||||||
|
return get_runtime_subdir("logs")
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_path(workspace: str | None = None) -> Path:
|
||||||
|
"""Resolve and ensure the agent workspace path."""
|
||||||
|
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||||
|
return ensure_dir(path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cli_history_path() -> Path:
|
||||||
|
"""Return the shared CLI history file path."""
|
||||||
|
return Path.home() / ".nanobot" / "history" / "cli_history"
|
||||||
|
|
||||||
|
|
||||||
|
def get_bridge_install_dir() -> Path:
|
||||||
|
"""Return the shared WhatsApp bridge installation directory."""
|
||||||
|
return Path.home() / ".nanobot" / "bridge"
|
||||||
|
|
||||||
|
|
||||||
|
def get_legacy_sessions_dir() -> Path:
|
||||||
|
"""Return the legacy global session directory used for migration fallback."""
|
||||||
|
return Path.home() / ".nanobot" / "sessions"
|
||||||
@@ -9,6 +9,7 @@ from typing import Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.config.paths import get_legacy_sessions_dir
|
||||||
from nanobot.utils.helpers import ensure_dir, safe_filename
|
from nanobot.utils.helpers import ensure_dir, safe_filename
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +80,7 @@ class SessionManager:
|
|||||||
def __init__(self, workspace: Path):
|
def __init__(self, workspace: Path):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
self.sessions_dir = ensure_dir(self.workspace / "sessions")
|
self.sessions_dir = ensure_dir(self.workspace / "sessions")
|
||||||
self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
|
self.legacy_sessions_dir = get_legacy_sessions_dir()
|
||||||
self._cache: dict[str, Session] = {}
|
self._cache: dict[str, Session] = {}
|
||||||
|
|
||||||
def _get_session_path(self, key: str) -> Path:
|
def _get_session_path(self, key: str) -> Path:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Utility functions for nanobot."""
|
"""Utility functions for nanobot."""
|
||||||
|
|
||||||
from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path
|
from nanobot.utils.helpers import ensure_dir
|
||||||
|
|
||||||
__all__ = ["ensure_dir", "get_workspace_path", "get_data_path"]
|
__all__ = ["ensure_dir"]
|
||||||
|
|||||||
@@ -24,18 +24,6 @@ def ensure_dir(path: Path) -> Path:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_data_path() -> Path:
|
|
||||||
"""Get nanobot data directory (derived from config path)."""
|
|
||||||
from nanobot.config.loader import get_data_dir
|
|
||||||
return ensure_dir(get_data_dir())
|
|
||||||
|
|
||||||
|
|
||||||
def get_workspace_path(workspace: str | None = None) -> Path:
|
|
||||||
"""Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
|
|
||||||
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
|
||||||
return ensure_dir(path)
|
|
||||||
|
|
||||||
|
|
||||||
def timestamp() -> str:
|
def timestamp() -> str:
|
||||||
"""Current ISO timestamp."""
|
"""Current ISO timestamp."""
|
||||||
return datetime.now().isoformat()
|
return datetime.now().isoformat()
|
||||||
|
|||||||
@@ -14,13 +14,17 @@ from nanobot.providers.registry import find_by_model
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
class _StopGateway(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_paths():
|
def mock_paths():
|
||||||
"""Mock config/workspace paths for test isolation."""
|
"""Mock config/workspace paths for test isolation."""
|
||||||
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
||||||
patch("nanobot.config.loader.save_config") as mock_sc, \
|
patch("nanobot.config.loader.save_config") as mock_sc, \
|
||||||
patch("nanobot.config.loader.load_config") as mock_lc, \
|
patch("nanobot.config.loader.load_config") as mock_lc, \
|
||||||
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
|
patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
|
||||||
|
|
||||||
base_dir = Path("./test_onboard_data")
|
base_dir = Path("./test_onboard_data")
|
||||||
if base_dir.exists():
|
if base_dir.exists():
|
||||||
@@ -128,3 +132,94 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
|
|||||||
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
||||||
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||||
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "instance" / "config.json"
|
||||||
|
config_file.parent.mkdir(parents=True)
|
||||||
|
config_file.write_text("{}")
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||||
|
seen: dict[str, Path] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.config.loader.set_config_path",
|
||||||
|
lambda path: seen.__setitem__("config_path", path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.commands.sync_workspace_templates",
|
||||||
|
lambda path: seen.__setitem__("workspace", path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.commands._make_provider",
|
||||||
|
lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
|
||||||
|
assert isinstance(result.exception, _StopGateway)
|
||||||
|
assert seen["config_path"] == config_file.resolve()
|
||||||
|
assert seen["workspace"] == Path(config.agents.defaults.workspace)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "instance" / "config.json"
|
||||||
|
config_file.parent.mkdir(parents=True)
|
||||||
|
config_file.write_text("{}")
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||||
|
override = tmp_path / "override-workspace"
|
||||||
|
seen: dict[str, Path] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.commands.sync_workspace_templates",
|
||||||
|
lambda path: seen.__setitem__("workspace", path),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.cli.commands._make_provider",
|
||||||
|
lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
["gateway", "--config", str(config_file), "--workspace", str(override)],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result.exception, _StopGateway)
|
||||||
|
assert seen["workspace"] == override
|
||||||
|
assert config.workspace_path == override
|
||||||
|
|
||||||
|
|
||||||
|
def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "instance" / "config.json"
|
||||||
|
config_file.parent.mkdir(parents=True)
|
||||||
|
config_file.write_text("{}")
|
||||||
|
|
||||||
|
config = Config()
|
||||||
|
config.agents.defaults.workspace = str(tmp_path / "config-workspace")
|
||||||
|
seen: dict[str, Path] = {}
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config)
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron")
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object())
|
||||||
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
|
monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object())
|
||||||
|
|
||||||
|
class _StopCron:
|
||||||
|
def __init__(self, store_path: Path) -> None:
|
||||||
|
seen["cron_store"] = store_path
|
||||||
|
raise _StopGateway("stop")
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
|
||||||
|
|
||||||
|
assert isinstance(result.exception, _StopGateway)
|
||||||
|
assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json"
|
||||||
|
|||||||
42
tests/test_config_paths.py
Normal file
42
tests/test_config_paths.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.config.paths import (
|
||||||
|
get_bridge_install_dir,
|
||||||
|
get_cli_history_path,
|
||||||
|
get_cron_dir,
|
||||||
|
get_data_dir,
|
||||||
|
get_legacy_sessions_dir,
|
||||||
|
get_logs_dir,
|
||||||
|
get_media_dir,
|
||||||
|
get_runtime_subdir,
|
||||||
|
get_workspace_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_dirs_follow_config_path(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "instance-a" / "config.json"
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
|
||||||
|
|
||||||
|
assert get_data_dir() == config_file.parent
|
||||||
|
assert get_runtime_subdir("cron") == config_file.parent / "cron"
|
||||||
|
assert get_cron_dir() == config_file.parent / "cron"
|
||||||
|
assert get_logs_dir() == config_file.parent / "logs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
config_file = tmp_path / "instance-b" / "config.json"
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file)
|
||||||
|
|
||||||
|
assert get_media_dir() == config_file.parent / "media"
|
||||||
|
assert get_media_dir("telegram") == config_file.parent / "media" / "telegram"
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_and_legacy_paths_remain_global() -> None:
|
||||||
|
assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history"
|
||||||
|
assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge"
|
||||||
|
assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_path_is_explicitly_resolved() -> None:
|
||||||
|
assert get_workspace_path() == Path.home() / ".nanobot" / "workspace"
|
||||||
|
assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace"
|
||||||
Reference in New Issue
Block a user