Merge branch 'HKUDS:main' into feature/onboard_workspace

This commit is contained in:
Luke Milby 2026-02-13 08:45:31 -05:00 committed by GitHub
commit a9d911c80d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 158 additions and 99 deletions

1
.gitignore vendored
View File

@ -14,6 +14,7 @@ docs/
*.pywz *.pywz
*.pyzz *.pyzz
.venv/ .venv/
venv/
__pycache__/ __pycache__/
poetry.lock poetry.lock
.pytest_cache/ .pytest_cache/

View File

@ -16,10 +16,11 @@
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
📏 Real-time line count: **3,562 lines** (run `bash core_agent_lines.sh` to verify anytime) 📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime)
## 📢 News ## 📢 News
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! - **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms! - **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!

View File

@ -95,8 +95,8 @@ File operations have path traversal protection, but:
- Consider using a firewall to restrict outbound connections if needed - Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:** **WhatsApp Bridge:**
- The bridge runs on `localhost:3001` by default - The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
- If exposing to network, use proper authentication and TLS - Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700) - Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
### 6. Dependency Security ### 6. Dependency Security
@ -224,7 +224,7 @@ If you suspect a security breach:
✅ **Secure Communication** ✅ **Secure Communication**
- HTTPS for all external API calls - HTTPS for all external API calls
- TLS for Telegram API - TLS for Telegram API
- WebSocket security for WhatsApp bridge - WhatsApp bridge: localhost-only binding + optional token auth
## Known Limitations ## Known Limitations

View File

@ -25,11 +25,12 @@ import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10); const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth'); const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
const TOKEN = process.env.BRIDGE_TOKEN || undefined;
console.log('🐈 nanobot WhatsApp Bridge'); console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n'); console.log('========================\n');
const server = new BridgeServer(PORT, AUTH_DIR); const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
// Handle graceful shutdown // Handle graceful shutdown
process.on('SIGINT', async () => { process.on('SIGINT', async () => {

View File

@ -1,5 +1,6 @@
/** /**
* WebSocket server for Python-Node.js bridge communication. * WebSocket server for Python-Node.js bridge communication.
* Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth.
*/ */
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
@ -21,12 +22,13 @@ export class BridgeServer {
private wa: WhatsAppClient | null = null; private wa: WhatsAppClient | null = null;
private clients: Set<WebSocket> = new Set(); private clients: Set<WebSocket> = new Set();
constructor(private port: number, private authDir: string) {} constructor(private port: number, private authDir: string, private token?: string) {}
async start(): Promise<void> { async start(): Promise<void> {
// Create WebSocket server // Bind to localhost only — never expose to external network
this.wss = new WebSocketServer({ port: this.port }); this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port });
console.log(`🌉 Bridge server listening on ws://localhost:${this.port}`); console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
if (this.token) console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client // Initialize WhatsApp client
this.wa = new WhatsAppClient({ this.wa = new WhatsAppClient({
@ -38,7 +40,34 @@ export class BridgeServer {
// Handle WebSocket connections // Handle WebSocket connections
this.wss.on('connection', (ws) => { this.wss.on('connection', (ws) => {
if (this.token) {
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
}
});
} else {
console.log('🔗 Python client connected'); console.log('🔗 Python client connected');
this.setupClient(ws);
}
});
// Connect to WhatsApp
await this.wa.connect();
}
private setupClient(ws: WebSocket): void {
this.clients.add(ws); this.clients.add(ws);
ws.on('message', async (data) => { ws.on('message', async (data) => {
@ -61,10 +90,6 @@ export class BridgeServer {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
this.clients.delete(ws); this.clients.delete(ws);
}); });
});
// Connect to WhatsApp
await this.wa.connect();
} }
private async handleCommand(cmd: SendCommand): Promise<void> { private async handleCommand(cmd: SendCommand): Promise<void> {

View File

@ -164,7 +164,20 @@ class AgentLoop:
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
# Get or create session # Get or create session
session = self.sessions.get_or_create(session_key or msg.session_key) 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":
await self._consolidate_memory(session, archive_all=True)
session.clear()
self.sessions.save(session)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 New session started. Memory consolidated.")
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")
# Consolidate memory before processing if session is too large # Consolidate memory before processing if session is too large
if len(session.messages) > self.memory_window: if len(session.messages) > self.memory_window:
@ -243,6 +256,9 @@ class AgentLoop:
break break
if final_content is None: if final_content is None:
if iteration >= self.max_iterations:
final_content = f"Reached {self.max_iterations} iterations without completion."
else:
final_content = "I've completed processing but have no response to give." final_content = "I've completed processing but have no response to give."
# Log response preview # Log response preview
@ -363,11 +379,17 @@ class AgentLoop:
content=final_content content=final_content
) )
async def _consolidate_memory(self, session) -> None: async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session.""" """Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
if not session.messages:
return
memory = MemoryStore(self.workspace) memory = MemoryStore(self.workspace)
if archive_all:
old_messages = session.messages
keep_count = 0
else:
keep_count = min(10, max(2, self.memory_window // 2)) keep_count = min(10, max(2, self.memory_window // 2))
old_messages = session.messages[:-keep_count] # Everything except recent ones old_messages = session.messages[:-keep_count]
if not old_messages: if not old_messages:
return return
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}") logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
@ -404,12 +426,10 @@ Respond with ONLY valid JSON, no markdown fences."""
], ],
model=self.model, model=self.model,
) )
import json as _json
text = (response.content or "").strip() text = (response.content or "").strip()
# Strip markdown fences that LLMs often add despite instructions
if text.startswith("```"): if text.startswith("```"):
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
result = _json.loads(text) result = json.loads(text)
if entry := result.get("history_entry"): if entry := result.get("history_entry"):
memory.append_history(entry) memory.append_history(entry)
@ -417,8 +437,7 @@ Respond with ONLY valid JSON, no markdown fences."""
if update != current_memory: if update != current_memory:
memory.write_long_term(update) memory.write_long_term(update)
# Trim session to recent messages session.messages = session.messages[-keep_count:] if keep_count else []
session.messages = session.messages[-keep_count:]
self.sessions.save(session) self.sessions.save(session)
logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages") logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
except Exception as e: except Exception as e:

View File

@ -166,6 +166,10 @@ class FeishuChannel(BaseChannel):
re.MULTILINE, re.MULTILINE,
) )
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
_CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
@staticmethod @staticmethod
def _parse_md_table(table_text: str) -> dict | None: def _parse_md_table(table_text: str) -> dict | None:
"""Parse a markdown table into a Feishu table element.""" """Parse a markdown table into a Feishu table element."""
@ -185,17 +189,52 @@ class FeishuChannel(BaseChannel):
} }
def _build_card_elements(self, content: str) -> list[dict]: def _build_card_elements(self, content: str) -> list[dict]:
"""Split content into markdown + table elements for Feishu card.""" """Split content into div/markdown + table elements for Feishu card."""
elements, last_end = [], 0 elements, last_end = [], 0
for m in self._TABLE_RE.finditer(content): for m in self._TABLE_RE.finditer(content):
before = content[last_end:m.start()].strip() before = content[last_end:m.start()]
if before: if before.strip():
elements.append({"tag": "markdown", "content": before}) elements.extend(self._split_headings(before))
elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)}) elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)})
last_end = m.end() last_end = m.end()
remaining = content[last_end:].strip() remaining = content[last_end:]
if remaining.strip():
elements.extend(self._split_headings(remaining))
return elements or [{"tag": "markdown", "content": content}]
def _split_headings(self, content: str) -> list[dict]:
"""Split content by headings, converting headings to div elements."""
protected = content
code_blocks = []
for m in self._CODE_BLOCK_RE.finditer(content):
code_blocks.append(m.group(1))
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1)
elements = []
last_end = 0
for m in self._HEADING_RE.finditer(protected):
before = protected[last_end:m.start()].strip()
if before:
elements.append({"tag": "markdown", "content": before})
level = len(m.group(1))
text = m.group(2).strip()
elements.append({
"tag": "div",
"text": {
"tag": "lark_md",
"content": f"**{text}**",
},
})
last_end = m.end()
remaining = protected[last_end:].strip()
if remaining: if remaining:
elements.append({"tag": "markdown", "content": remaining}) elements.append({"tag": "markdown", "content": remaining})
for i, cb in enumerate(code_blocks):
for el in elements:
if el.get("tag") == "markdown":
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
return elements or [{"tag": "markdown", "content": content}] return elements or [{"tag": "markdown", "content": content}]
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:

View File

@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any, TYPE_CHECKING from typing import Any
from loguru import logger from loguru import logger
@ -12,9 +12,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config from nanobot.config.schema import Config
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
class ChannelManager: class ChannelManager:
""" """
@ -26,10 +23,9 @@ class ChannelManager:
- Route outbound messages - Route outbound messages
""" """
def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None): def __init__(self, config: Config, bus: MessageBus):
self.config = config self.config = config
self.bus = bus self.bus = bus
self.session_manager = session_manager
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None self._dispatch_task: asyncio.Task | None = None
@ -46,7 +42,6 @@ class ChannelManager:
self.config.channels.telegram, self.config.channels.telegram,
self.bus, self.bus,
groq_api_key=self.config.providers.groq.api_key, groq_api_key=self.config.providers.groq.api_key,
session_manager=self.session_manager,
) )
logger.info("Telegram channel enabled") logger.info("Telegram channel enabled")
except ImportError as e: except ImportError as e:

View File

@ -4,8 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
import re import re
from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
from telegram import BotCommand, Update from telegram import BotCommand, Update
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
@ -16,9 +14,6 @@ from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.schema import TelegramConfig from nanobot.config.schema import TelegramConfig
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
def _markdown_to_telegram_html(text: str) -> str: def _markdown_to_telegram_html(text: str) -> str:
""" """
@ -95,7 +90,7 @@ class TelegramChannel(BaseChannel):
# Commands registered with Telegram's command menu # Commands registered with Telegram's command menu
BOT_COMMANDS = [ BOT_COMMANDS = [
BotCommand("start", "Start the bot"), BotCommand("start", "Start the bot"),
BotCommand("reset", "Reset conversation history"), BotCommand("new", "Start a new conversation"),
BotCommand("help", "Show available commands"), BotCommand("help", "Show available commands"),
] ]
@ -104,12 +99,10 @@ class TelegramChannel(BaseChannel):
config: TelegramConfig, config: TelegramConfig,
bus: MessageBus, bus: MessageBus,
groq_api_key: str = "", groq_api_key: str = "",
session_manager: SessionManager | None = None,
): ):
super().__init__(config, bus) super().__init__(config, bus)
self.config: TelegramConfig = config self.config: TelegramConfig = config
self.groq_api_key = groq_api_key self.groq_api_key = groq_api_key
self.session_manager = session_manager
self._app: Application | None = None self._app: Application | None = None
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
@ -132,8 +125,8 @@ class TelegramChannel(BaseChannel):
# Add command handlers # Add command handlers
self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("reset", self._on_reset)) self._app.add_handler(CommandHandler("new", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help)) self._app.add_handler(CommandHandler("help", self._forward_command))
# Add message handler for text, photos, voice, documents # Add message handler for text, photos, voice, documents
self._app.add_handler( self._app.add_handler(
@ -229,40 +222,15 @@ class TelegramChannel(BaseChannel):
"Type /help to see available commands." "Type /help to see available commands."
) )
async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /reset command — clear conversation history.""" """Forward slash commands to the bus for unified handling in AgentLoop."""
if not update.message or not update.effective_user: if not update.message or not update.effective_user:
return return
await self._handle_message(
chat_id = str(update.message.chat_id) sender_id=str(update.effective_user.id),
session_key = f"{self.name}:{chat_id}" chat_id=str(update.message.chat_id),
content=update.message.text,
if self.session_manager is None:
logger.warning("/reset called but session_manager is not available")
await update.message.reply_text("⚠️ Session management is not available.")
return
session = self.session_manager.get_or_create(session_key)
msg_count = len(session.messages)
session.clear()
self.session_manager.save(session)
logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)")
await update.message.reply_text("🔄 Conversation history cleared. Let's start fresh!")
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /help command — show available commands."""
if not update.message:
return
help_text = (
"🐈 <b>nanobot commands</b>\n\n"
"/start — Start the bot\n"
"/reset — Reset conversation history\n"
"/help — Show this help message\n\n"
"Just send me a text message to chat!"
) )
await update.message.reply_text(help_text, parse_mode="HTML")
async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle incoming messages (text, photos, voice, documents).""" """Handle incoming messages (text, photos, voice, documents)."""

View File

@ -42,6 +42,9 @@ class WhatsAppChannel(BaseChannel):
try: try:
async with websockets.connect(bridge_url) as ws: async with websockets.connect(bridge_url) as ws:
self._ws = ws self._ws = ws
# Send auth token if configured
if self.config.bridge_token:
await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token}))
self._connected = True self._connected = True
logger.info("Connected to WhatsApp bridge") logger.info("Connected to WhatsApp bridge")

View File

@ -375,7 +375,7 @@ def gateway(
) )
# Create channel manager # Create channel manager
channels = ChannelManager(config, bus, session_manager=session_manager) channels = ChannelManager(config, bus)
if channels.enabled_channels: if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
@ -416,7 +416,7 @@ def gateway(
@app.command() @app.command()
def agent( def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"), session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"), markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
): ):
@ -642,14 +642,20 @@ def _get_bridge_dir() -> Path:
def channels_login(): def channels_login():
"""Link device via QR code.""" """Link device via QR code."""
import subprocess import subprocess
from nanobot.config.loader import load_config
config = load_config()
bridge_dir = _get_bridge_dir() bridge_dir = _get_bridge_dir()
console.print(f"{__logo__} Starting bridge...") console.print(f"{__logo__} Starting bridge...")
console.print("Scan the QR code to connect.\n") 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: try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
console.print(f"[red]Bridge failed: {e}[/red]") console.print(f"[red]Bridge failed: {e}[/red]")
except FileNotFoundError: except FileNotFoundError:

View File

@ -9,6 +9,7 @@ class WhatsAppConfig(BaseModel):
"""WhatsApp channel configuration.""" """WhatsApp channel configuration."""
enabled: bool = False enabled: bool = False
bridge_url: str = "ws://localhost:3001" bridge_url: str = "ws://localhost:3001"
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers

View File

@ -1,6 +1,6 @@
[project] [project]
name = "nanobot-ai" name = "nanobot-ai"
version = "0.1.3.post6" version = "0.1.3.post7"
description = "A lightweight personal AI assistant framework" description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11" requires-python = ">=3.11"
license = {text = "MIT"} license = {text = "MIT"}