From 59017aa9bb9d8d114c7f5345d831eac81e81ed43 Mon Sep 17 00:00:00 2001
From: "tao.jun" <61566027@163.com>
Date: Sun, 8 Feb 2026 13:03:32 +0800
Subject: [PATCH 001/124] feat(feishu): Add event handlers for reactions,
message read, and p2p chat events
- Register handlers for message reaction created events
- Register handlers for message read events
- Register handlers for bot entering p2p chat events
- Prevent error logs for these common but unprocessed events
- Import required event types from lark_oapi
---
nanobot/channels/feishu.py | 33 ++++++++++++++++++++++++++++++++-
1 file changed, 32 insertions(+), 1 deletion(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 1c176a2..a4c7454 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -23,6 +23,8 @@ try:
CreateMessageReactionRequestBody,
Emoji,
P2ImMessageReceiveV1,
+ P2ImMessageMessageReadV1,
+ P2ImMessageReactionCreatedV1,
)
FEISHU_AVAILABLE = True
except ImportError:
@@ -82,12 +84,18 @@ class FeishuChannel(BaseChannel):
.log_level(lark.LogLevel.INFO) \
.build()
- # Create event handler (only register message receive, ignore other events)
+ # Create event handler (register message receive and other common events)
event_handler = lark.EventDispatcherHandler.builder(
self.config.encrypt_key or "",
self.config.verification_token or "",
).register_p2_im_message_receive_v1(
self._on_message_sync
+ ).register_p2_im_message_reaction_created_v1(
+ self._on_reaction_created
+ ).register_p2_im_message_message_read_v1(
+ self._on_message_read
+ ).register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(
+ self._on_bot_p2p_chat_entered
).build()
# Create WebSocket client for long connection
@@ -305,3 +313,26 @@ class FeishuChannel(BaseChannel):
except Exception as e:
logger.error(f"Error processing Feishu message: {e}")
+
+ def _on_reaction_created(self, data: "P2ImMessageReactionCreatedV1") -> None:
+ """
+ Handler for message reaction events.
+ We don't need to process these, but registering prevents error logs.
+ """
+ pass
+
+ def _on_message_read(self, data: "P2ImMessageMessageReadV1") -> None:
+ """
+ Handler for message read events.
+ We don't need to process these, but registering prevents error logs.
+ """
+ pass
+
+ def _on_bot_p2p_chat_entered(self, data: Any) -> None:
+ """
+ Handler for bot entering p2p chat events.
+ This is triggered when a user opens a chat with the bot.
+ We don't need to process these, but registering prevents error logs.
+ """
+ logger.debug("Bot entered p2p chat (user opened chat window)")
+ pass
From 4d6f02ec0dee02f532df2295e76ea7c6c2b15ae5 Mon Sep 17 00:00:00 2001
From: eric
Date: Tue, 3 Mar 2026 18:14:26 +0800
Subject: [PATCH 007/124] sync missing scripts from upstream openclaw
repository
---
nanobot/skills/skill-creator/SKILL.md | 3 +-
.../skill-creator/scripts/init_skill.py | 378 ++++++++++++++++++
.../skill-creator/scripts/package_skill.py | 139 +++++++
3 files changed, 519 insertions(+), 1 deletion(-)
create mode 100755 nanobot/skills/skill-creator/scripts/init_skill.py
create mode 100755 nanobot/skills/skill-creator/scripts/package_skill.py
diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md
index 9b5eb6f..f4d6e0b 100644
--- a/nanobot/skills/skill-creator/SKILL.md
+++ b/nanobot/skills/skill-creator/SKILL.md
@@ -349,7 +349,6 @@ scripts/package_skill.py ./dist
The packaging script will:
1. **Validate** the skill automatically, checking:
-
- YAML frontmatter format and required fields
- Skill naming conventions and directory structure
- Description completeness and quality
@@ -357,6 +356,8 @@ The packaging script will:
2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension.
+ Security restriction: symlinks are rejected and packaging fails when any symlink is present.
+
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
### Step 6: Iterate
diff --git a/nanobot/skills/skill-creator/scripts/init_skill.py b/nanobot/skills/skill-creator/scripts/init_skill.py
new file mode 100755
index 0000000..8633fe9
--- /dev/null
+++ b/nanobot/skills/skill-creator/scripts/init_skill.py
@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+"""
+Skill Initializer - Creates a new skill from template
+
+Usage:
+ init_skill.py
display."""
+
+ def dw(s: str) -> int:
+ return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s)
+
+ rows: list[list[str]] = []
+ has_sep = False
+ for line in table_lines:
+ cells = [_strip_md(c) for c in line.strip().strip('|').split('|')]
+ if all(re.match(r'^:?-+:?$', c) for c in cells if c):
+ has_sep = True
+ continue
+ rows.append(cells)
+ if not rows or not has_sep:
+ return '\n'.join(table_lines)
+
+ ncols = max(len(r) for r in rows)
+ for r in rows:
+ r.extend([''] * (ncols - len(r)))
+ widths = [max(dw(r[c]) for r in rows) for c in range(ncols)]
+
+ def dr(cells: list[str]) -> str:
+ return ' '.join(f'{c}{" " * (w - dw(c))}' for c, w in zip(cells, widths))
+
+ out = [dr(rows[0])]
+ out.append(' '.join('─' * w for w in widths))
+ for row in rows[1:]:
+ out.append(dr(row))
+ return '\n'.join(out)
+
+
def _markdown_to_telegram_html(text: str) -> str:
"""
Convert markdown to Telegram-safe HTML.
@@ -34,6 +77,27 @@ def _markdown_to_telegram_html(text: str) -> str:
text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text)
+ # 1.5. Convert markdown tables to box-drawing (reuse code_block placeholders)
+ lines = text.split('\n')
+ rebuilt: list[str] = []
+ li = 0
+ while li < len(lines):
+ if re.match(r'^\s*\|.+\|', lines[li]):
+ tbl: list[str] = []
+ while li < len(lines) and re.match(r'^\s*\|.+\|', lines[li]):
+ tbl.append(lines[li])
+ li += 1
+ box = _render_table_box(tbl)
+ if box != '\n'.join(tbl):
+ code_blocks.append(box)
+ rebuilt.append(f"\x00CB{len(code_blocks) - 1}\x00")
+ else:
+ rebuilt.extend(tbl)
+ else:
+ rebuilt.append(lines[li])
+ li += 1
+ text = '\n'.join(rebuilt)
+
# 2. Extract and protect inline code
inline_codes: list[str] = []
def save_inline_code(m: re.Match) -> str:
@@ -255,42 +319,48 @@ class TelegramChannel(BaseChannel):
# Send text content
if msg.content and msg.content != "[empty message]":
is_progress = msg.metadata.get("_progress", False)
- draft_id = msg.metadata.get("message_id")
for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN):
- try:
- html = _markdown_to_telegram_html(chunk)
- if is_progress and draft_id:
- await self._app.bot.send_message_draft(
- chat_id=chat_id,
- draft_id=draft_id,
- text=html,
- parse_mode="HTML"
- )
- else:
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=html,
- parse_mode="HTML",
- reply_parameters=reply_params
- )
- except Exception as e:
- logger.warning("HTML parse failed, falling back to plain text: {}", e)
- try:
- if is_progress and draft_id:
- await self._app.bot.send_message_draft(
- chat_id=chat_id,
- draft_id=draft_id,
- text=chunk
- )
- else:
- await self._app.bot.send_message(
- chat_id=chat_id,
- text=chunk,
- reply_parameters=reply_params
- )
- except Exception as e2:
- logger.error("Error sending Telegram message: {}", e2)
+ # Final response: simulate streaming via draft, then persist
+ if not is_progress:
+ await self._send_with_streaming(chat_id, chunk, reply_params)
+ else:
+ await self._send_text(chat_id, chunk, reply_params)
+
+ async def _send_text(self, chat_id: int, text: str, reply_params=None) -> None:
+ """Send a plain text message with HTML fallback."""
+ try:
+ html = _markdown_to_telegram_html(text)
+ await self._app.bot.send_message(
+ chat_id=chat_id, text=html, parse_mode="HTML",
+ reply_parameters=reply_params,
+ )
+ except Exception as e:
+ logger.warning("HTML parse failed, falling back to plain text: {}", e)
+ try:
+ await self._app.bot.send_message(
+ chat_id=chat_id, text=text, reply_parameters=reply_params,
+ )
+ except Exception as e2:
+ logger.error("Error sending Telegram message: {}", e2)
+
+ async def _send_with_streaming(self, chat_id: int, text: str, reply_params=None) -> None:
+ """Simulate streaming via send_message_draft, then persist with send_message."""
+ draft_id = int(time.time() * 1000) % (2**31)
+ try:
+ step = max(len(text) // 8, 40)
+ for i in range(step, len(text), step):
+ await self._app.bot.send_message_draft(
+ chat_id=chat_id, draft_id=draft_id, text=text[:i],
+ )
+ await asyncio.sleep(0.04)
+ await self._app.bot.send_message_draft(
+ chat_id=chat_id, draft_id=draft_id, text=text,
+ )
+ await asyncio.sleep(0.15)
+ except Exception:
+ pass
+ await self._send_text(chat_id, text, reply_params)
async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /start command."""
From 43022b17184070ce6b1a4fe487b27517238050d7 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar
Date: Fri, 6 Mar 2026 17:20:52 +0000
Subject: [PATCH 033/124] Fix unit test after updating error message
---
tests/test_azure_openai_provider.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py
index df2cdc3..680ddf4 100644
--- a/tests/test_azure_openai_provider.py
+++ b/tests/test_azure_openai_provider.py
@@ -291,7 +291,7 @@ async def test_chat_connection_error():
result = await provider.chat(messages)
assert isinstance(result, LLMResponse)
- assert "Error calling Azure OpenAI: Connection failed" in result.content
+ assert "Error calling Azure OpenAI: Exception('Connection failed')" in result.content
assert result.finish_reason == "error"
From 7e4594e08dc74ab438d3d903a1fac6441a498615 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar
Date: Fri, 6 Mar 2026 18:12:46 +0000
Subject: [PATCH 034/124] Increase timeout for chat completion calls
---
nanobot/providers/azure_openai_provider.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index fc8e950..6da37e7 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider):
)
try:
- async with httpx.AsyncClient() as client:
+ async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code != 200:
return LLMResponse(
From 73be53d4bd7e5ff7363644248ab47296959bd3c9 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar
Date: Fri, 6 Mar 2026 18:16:15 +0000
Subject: [PATCH 035/124] Add SSL verification
---
nanobot/providers/azure_openai_provider.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index 6da37e7..3f325aa 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider):
)
try:
- async with httpx.AsyncClient(timeout=60.0) as client:
+ async with httpx.AsyncClient(timeout=60.0, verify=True) as client:
response = await client.post(url, headers=headers, json=payload)
if response.status_code != 200:
return LLMResponse(
From 79f3ca4f12ffe6497f30958f4959e579b5d4434b Mon Sep 17 00:00:00 2001
From: Maciej Wojcik
Date: Fri, 6 Mar 2026 20:32:10 +0000
Subject: [PATCH 036/124] feat(cli): add workspace and config flags to agent
---
README.md | 14 ++++++
nanobot/cli/commands.py | 24 ++++++----
tests/test_commands.py | 97 ++++++++++++++++++++++++++++++++++++++++-
3 files changed, 125 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index 0c49608..86869a2 100644
--- a/README.md
+++ b/README.md
@@ -710,6 +710,9 @@ nanobot provider login openai-codex
**3. Chat:**
```bash
nanobot agent -m "Hello!"
+
+# Target a specific workspace/config locally
+nanobot agent -w ~/.nanobot/botA -c ~/.nanobot/botA/config.json -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
@@ -917,6 +920,15 @@ Each instance has its own:
- Cron jobs storage (`workspace/cron/jobs.json`)
- Configuration (if using `--config`)
+To open a CLI session against one of these instances locally:
+
+```bash
+nanobot agent -w ~/.nanobot/botA -m "Hello from botA"
+nanobot agent -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json
+```
+
+> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
+
## CLI Reference
@@ -924,6 +936,8 @@ Each instance has its own:
|---------|-------------|
| `nanobot onboard` | Initialize config & workspace |
| `nanobot agent -m "..."` | Chat with the agent |
+| `nanobot agent -w ` | Chat against a specific workspace |
+| `nanobot agent -w -c ` | Chat against a specific workspace/config |
| `nanobot agent` | Interactive chat mode |
| `nanobot agent --no-markdown` | Show plain-text replies |
| `nanobot agent --logs` | Show runtime logs during chat |
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 7d2c161..5987796 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -9,7 +9,6 @@ from pathlib import Path
# Force UTF-8 encoding for Windows console
if sys.platform == "win32":
- import locale
if sys.stdout.encoding != "utf-8":
os.environ["PYTHONIOENCODING"] = "utf-8"
# Re-open stdout/stderr with UTF-8 encoding
@@ -248,6 +247,17 @@ def _make_provider(config: Config):
)
+def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
+ """Load config and optionally override the active workspace."""
+ from nanobot.config.loader import load_config
+
+ config_path = Path(config) if config else None
+ loaded = load_config(config_path)
+ if workspace:
+ loaded.agents.defaults.workspace = workspace
+ return loaded
+
+
# ============================================================================
# Gateway / Server
# ============================================================================
@@ -264,7 +274,6 @@ def gateway(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
- from nanobot.config.loader import load_config
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
@@ -274,10 +283,7 @@ def gateway(
import logging
logging.basicConfig(level=logging.DEBUG)
- config_path = Path(config) if config else None
- config = load_config(config_path)
- if workspace:
- config.agents.defaults.workspace = workspace
+ config = _load_runtime_config(config, workspace)
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
sync_workspace_templates(config.workspace_path)
@@ -448,6 +454,8 @@ def gateway(
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
+ workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
+ config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
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"),
):
@@ -456,10 +464,10 @@ def agent(
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
- from nanobot.config.loader import get_data_dir, load_config
+ from nanobot.config.loader import get_data_dir
from nanobot.cron.service import CronService
- config = load_config()
+ config = _load_runtime_config(config, workspace)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 044d113..46ee7d0 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -1,6 +1,6 @@
import shutil
from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from typer.testing import CliRunner
@@ -19,7 +19,7 @@ def mock_paths():
"""Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \
- patch("nanobot.config.loader.load_config") as mock_lc, \
+ patch("nanobot.config.loader.load_config"), \
patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
base_dir = Path("./test_onboard_data")
@@ -128,3 +128,96 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
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"
+
+
+@pytest.fixture
+def mock_agent_runtime(tmp_path):
+ """Mock agent command dependencies for focused CLI tests."""
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "default-workspace")
+ data_dir = tmp_path / "data"
+
+ with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
+ patch("nanobot.config.loader.get_data_dir", return_value=data_dir), \
+ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
+ patch("nanobot.cli.commands._make_provider", return_value=object()), \
+ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
+ patch("nanobot.bus.queue.MessageBus"), \
+ patch("nanobot.cron.service.CronService"), \
+ patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls:
+
+ agent_loop = MagicMock()
+ agent_loop.channels_config = None
+ agent_loop.process_direct = AsyncMock(return_value="mock-response")
+ agent_loop.close_mcp = AsyncMock(return_value=None)
+ mock_agent_loop_cls.return_value = agent_loop
+
+ yield {
+ "config": config,
+ "load_config": mock_load_config,
+ "sync_templates": mock_sync_templates,
+ "agent_loop_cls": mock_agent_loop_cls,
+ "agent_loop": agent_loop,
+ "print_response": mock_print_response,
+ }
+
+
+def test_agent_help_shows_workspace_and_config_options():
+ result = runner.invoke(app, ["agent", "--help"])
+
+ assert result.exit_code == 0
+ assert "--workspace" in result.stdout
+ assert "-w" in result.stdout
+ assert "--config" in result.stdout
+ assert "-c" in result.stdout
+
+
+def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_runtime):
+ result = runner.invoke(app, ["agent", "-m", "hello"])
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["load_config"].call_args.args == (None,)
+ assert mock_agent_runtime["sync_templates"].call_args.args == (
+ mock_agent_runtime["config"].workspace_path,
+ )
+ assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == (
+ mock_agent_runtime["config"].workspace_path
+ )
+ mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
+ mock_agent_runtime["print_response"].assert_called_once_with("mock-response", render_markdown=True)
+
+
+def test_agent_uses_explicit_config_path(mock_agent_runtime):
+ config_path = Path("/tmp/agent-config.json")
+
+ result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+
+
+def test_agent_overrides_workspace_path(mock_agent_runtime):
+ workspace_path = Path("/tmp/agent-workspace")
+
+ result = runner.invoke(app, ["agent", "-m", "hello", "-w", str(workspace_path)])
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
+ assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
+ assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
+
+
+def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime):
+ config_path = Path("/tmp/agent-config.json")
+ workspace_path = Path("/tmp/agent-workspace")
+
+ result = runner.invoke(
+ app,
+ ["agent", "-m", "hello", "-c", str(config_path), "-w", str(workspace_path)],
+ )
+
+ assert result.exit_code == 0
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+ assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
+ assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
+ assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
From fdd161d7b2f65d1564b23c445eaef56f85665ce3 Mon Sep 17 00:00:00 2001
From: fat-operator
Date: Fri, 6 Mar 2026 23:36:54 +0000
Subject: [PATCH 037/124] Implemented image support for whatsapp
---
bridge/src/server.ts | 13 ++++++-
bridge/src/whatsapp.ts | 75 ++++++++++++++++++++++++++++++------
nanobot/channels/whatsapp.py | 34 +++++++++++++---
3 files changed, 102 insertions(+), 20 deletions(-)
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index 7d48f5e..ec5573a 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -12,6 +12,13 @@ interface SendCommand {
text: string;
}
+interface SendImageCommand {
+ type: 'send_image';
+ to: string;
+ imagePath: string;
+ caption?: string;
+}
+
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
@@ -72,7 +79,7 @@ export class BridgeServer {
ws.on('message', async (data) => {
try {
- const cmd = JSON.parse(data.toString()) as SendCommand;
+ const cmd = JSON.parse(data.toString()) as SendCommand | SendImageCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
@@ -92,9 +99,11 @@ export class BridgeServer {
});
}
- private async handleCommand(cmd: SendCommand): Promise {
+ private async handleCommand(cmd: SendCommand | SendImageCommand): Promise {
if (cmd.type === 'send' && this.wa) {
await this.wa.sendMessage(cmd.to, cmd.text);
+ } else if (cmd.type === 'send_image' && this.wa) {
+ await this.wa.sendImage(cmd.to, cmd.imagePath, cmd.caption);
}
}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 069d72b..d34100f 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -9,11 +9,17 @@ import makeWASocket, {
useMultiFileAuthState,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
+ downloadMediaMessage,
+ extractMessageContent as baileysExtractMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
+import { writeFile, mkdir, readFile } from 'fs/promises';
+import { join } from 'path';
+import { homedir } from 'os';
+import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
@@ -24,6 +30,7 @@ export interface InboundMessage {
content: string;
timestamp: number;
isGroup: boolean;
+ media?: string[];
}
export interface WhatsAppClientOptions {
@@ -110,14 +117,21 @@ export class WhatsAppClient {
if (type !== 'notify') return;
for (const msg of messages) {
- // Skip own messages
if (msg.key.fromMe) continue;
-
- // Skip status updates
if (msg.key.remoteJid === 'status@broadcast') continue;
- const content = this.extractMessageContent(msg);
- if (!content) continue;
+ const unwrapped = baileysExtractMessageContent(msg.message);
+ if (!unwrapped) continue;
+
+ const content = this.getTextContent(unwrapped);
+ const mediaPaths: string[] = [];
+
+ if (unwrapped.imageMessage) {
+ const path = await this.downloadImage(msg, unwrapped.imageMessage.mimetype ?? undefined);
+ if (path) mediaPaths.push(path);
+ }
+
+ if (!content && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
@@ -125,18 +139,43 @@ export class WhatsAppClient {
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
- content,
+ content: content || '',
timestamp: msg.messageTimestamp as number,
isGroup,
+ ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
});
}
- private extractMessageContent(msg: any): string | null {
- const message = msg.message;
- if (!message) return null;
+ private async downloadImage(msg: any, mimetype?: string): Promise {
+ try {
+ const mediaDir = join(homedir(), '.nanobot', 'media');
+ await mkdir(mediaDir, { recursive: true });
+ const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
+
+ const mime = mimetype || 'image/jpeg';
+ const extMap: Record = {
+ 'image/jpeg': '.jpg',
+ 'image/png': '.png',
+ 'image/gif': '.gif',
+ 'image/webp': '.webp',
+ };
+ const ext = extMap[mime] || '.jpg';
+
+ const filename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
+ const filepath = join(mediaDir, filename);
+ await writeFile(filepath, buffer);
+
+ return filepath;
+ } catch (err) {
+ console.error('Failed to download image:', err);
+ return null;
+ }
+ }
+
+ private getTextContent(message: any): string | null {
// Text message
if (message.conversation) {
return message.conversation;
@@ -147,9 +186,9 @@ export class WhatsAppClient {
return message.extendedTextMessage.text;
}
- // Image with caption
- if (message.imageMessage?.caption) {
- return `[Image] ${message.imageMessage.caption}`;
+ // Image with optional caption
+ if (message.imageMessage) {
+ return message.imageMessage.caption || '';
}
// Video with caption
@@ -178,6 +217,18 @@ export class WhatsAppClient {
await this.sock.sendMessage(to, { text });
}
+ async sendImage(to: string, imagePath: string, caption?: string): Promise {
+ if (!this.sock) {
+ throw new Error('Not connected');
+ }
+
+ const buffer = await readFile(imagePath);
+ await this.sock.sendMessage(to, {
+ image: buffer,
+ caption: caption || undefined,
+ });
+ }
+
async disconnect(): Promise {
if (this.sock) {
this.sock.end(undefined);
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 0d1ec7e..1a96753 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -83,12 +83,26 @@ class WhatsAppChannel(BaseChannel):
return
try:
- payload = {
- "type": "send",
- "to": msg.chat_id,
- "text": msg.content
- }
- await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ # Send media files first
+ for media_path in (msg.media or []):
+ try:
+ payload = {
+ "type": "send_image",
+ "to": msg.chat_id,
+ "imagePath": media_path,
+ }
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ except Exception as e:
+ logger.error("Error sending WhatsApp media {}: {}", media_path, e)
+
+ # Send text message if there's content
+ if msg.content:
+ payload = {
+ "type": "send",
+ "to": msg.chat_id,
+ "text": msg.content
+ }
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp message: {}", e)
@@ -128,10 +142,18 @@ class WhatsAppChannel(BaseChannel):
logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
content = "[Voice Message: Transcription not available for WhatsApp yet]"
+ # Extract media paths (images downloaded by the bridge)
+ media_paths = data.get("media") or []
+
+ # For image messages without caption, provide descriptive content
+ if not content and media_paths:
+ content = "[image]"
+
await self._handle_message(
sender_id=sender_id,
chat_id=sender, # Use full LID for replies
content=content,
+ media=media_paths,
metadata={
"message_id": message_id,
"timestamp": data.get("timestamp"),
From 8c2589753292936212593b463168c983cf573a14 Mon Sep 17 00:00:00 2001
From: fat-operator
Date: Fri, 6 Mar 2026 23:48:54 +0000
Subject: [PATCH 038/124] Remove image sending capabilities - cant be tested
---
bridge/package-lock.json | 1362 ++++++++++++++++++++++++++++++++++
bridge/src/server.ts | 13 +-
bridge/src/whatsapp.ts | 14 +-
nanobot/channels/whatsapp.py | 26 +-
4 files changed, 1371 insertions(+), 44 deletions(-)
create mode 100644 bridge/package-lock.json
diff --git a/bridge/package-lock.json b/bridge/package-lock.json
new file mode 100644
index 0000000..7847d20
--- /dev/null
+++ b/bridge/package-lock.json
@@ -0,0 +1,1362 @@
+{
+ "name": "nanobot-whatsapp-bridge",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "nanobot-whatsapp-bridge",
+ "version": "0.1.0",
+ "dependencies": {
+ "@whiskeysockets/baileys": "7.0.0-rc.9",
+ "pino": "^9.0.0",
+ "qrcode-terminal": "^0.12.0",
+ "ws": "^8.17.1"
+ },
+ "devDependencies": {
+ "@types/node": "^20.14.0",
+ "@types/ws": "^8.5.10",
+ "typescript": "^5.4.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@borewit/text-codec": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
+ "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@cacheable/memory": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
+ "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.4.0",
+ "@keyv/bigmap": "^1.3.1",
+ "hookified": "^1.15.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@cacheable/node-cache": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
+ "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.3.1",
+ "hookified": "^1.14.0",
+ "keyv": "^5.5.5"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
+ "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.5.0",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@hapi/boom": {
+ "version": "9.1.4",
+ "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
+ "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@hapi/hoek": "9.x.x"
+ }
+ },
+ "node_modules/@hapi/hoek": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+ "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "peer": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "peer": true,
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@keyv/bigmap": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+ "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.4.0",
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "license": "MIT"
+ },
+ "node_modules/@pinojs/redact": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+ "license": "MIT"
+ },
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
+ "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
+ "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
+ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@tokenizer/inflate": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
+ "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "token-types": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/@tokenizer/token": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/long": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
+ "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.37",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
+ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@whiskeysockets/baileys": {
+ "version": "7.0.0-rc.9",
+ "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
+ "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/node-cache": "^1.4.0",
+ "@hapi/boom": "^9.1.3",
+ "async-mutex": "^0.5.0",
+ "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
+ "lru-cache": "^11.1.0",
+ "music-metadata": "^11.7.0",
+ "p-queue": "^9.0.0",
+ "pino": "^9.6",
+ "protobufjs": "^7.2.4",
+ "ws": "^8.13.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "audio-decode": "^2.1.3",
+ "jimp": "^1.6.0",
+ "link-preview-js": "^3.0.0",
+ "sharp": "*"
+ },
+ "peerDependenciesMeta": {
+ "audio-decode": {
+ "optional": true
+ },
+ "jimp": {
+ "optional": true
+ },
+ "link-preview-js": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/async-mutex": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
+ "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/atomic-sleep": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+ "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/cacheable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
+ "integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.0.8",
+ "@cacheable/utils": "^2.4.0",
+ "hookified": "^1.15.0",
+ "keyv": "^5.6.0",
+ "qified": "^0.6.0"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/curve25519-js": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
+ "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/file-type": {
+ "version": "21.3.0",
+ "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
+ "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/inflate": "^0.4.1",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.1",
+ "uint8array-extras": "^1.4.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+ }
+ },
+ "node_modules/hashery": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
+ "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.14.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/hookified": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+ "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/keyv": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/libsignal": {
+ "name": "@whiskeysockets/libsignal-node",
+ "version": "2.0.1",
+ "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
+ "license": "GPL-3.0",
+ "dependencies": {
+ "curve25519-js": "^0.0.4",
+ "protobufjs": "6.8.8"
+ }
+ },
+ "node_modules/libsignal/node_modules/@types/node": {
+ "version": "10.17.60",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+ "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
+ "license": "MIT"
+ },
+ "node_modules/libsignal/node_modules/long": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+ "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/libsignal/node_modules/protobufjs": {
+ "version": "6.8.8",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
+ "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/long": "^4.0.0",
+ "@types/node": "^10.1.0",
+ "long": "^4.0.0"
+ },
+ "bin": {
+ "pbjs": "bin/pbjs",
+ "pbts": "bin/pbts"
+ }
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru-cache": {
+ "version": "11.2.6",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+ "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/music-metadata": {
+ "version": "11.12.1",
+ "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz",
+ "integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ },
+ {
+ "type": "buymeacoffee",
+ "url": "https://buymeacoffee.com/borewit"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.1",
+ "@tokenizer/token": "^0.3.0",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "file-type": "^21.3.0",
+ "media-typer": "^1.1.0",
+ "strtok3": "^10.3.4",
+ "token-types": "^6.1.2",
+ "uint8array-extras": "^1.5.0",
+ "win-guid": "^0.2.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/on-exit-leak-free": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+ "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/p-queue": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
+ "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "p-timeout": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+ "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/pino": {
+ "version": "9.14.0",
+ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
+ "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
+ "license": "MIT",
+ "dependencies": {
+ "@pinojs/redact": "^0.4.0",
+ "atomic-sleep": "^1.0.0",
+ "on-exit-leak-free": "^2.1.0",
+ "pino-abstract-transport": "^2.0.0",
+ "pino-std-serializers": "^7.0.0",
+ "process-warning": "^5.0.0",
+ "quick-format-unescaped": "^4.0.3",
+ "real-require": "^0.2.0",
+ "safe-stable-stringify": "^2.3.1",
+ "sonic-boom": "^4.0.1",
+ "thread-stream": "^3.0.0"
+ },
+ "bin": {
+ "pino": "bin.js"
+ }
+ },
+ "node_modules/pino-abstract-transport": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+ "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.0.0"
+ }
+ },
+ "node_modules/pino-std-serializers": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+ "license": "MIT"
+ },
+ "node_modules/process-warning": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+ "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/protobufjs": {
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
+ "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.4",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.0",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.0",
+ "@types/node": ">=13.7.0",
+ "long": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/qified": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
+ "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.14.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/qrcode-terminal": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
+ "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
+ "bin": {
+ "qrcode-terminal": "bin/qrcode-terminal.js"
+ }
+ },
+ "node_modules/quick-format-unescaped": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+ "license": "MIT"
+ },
+ "node_modules/real-require": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+ "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12.13.0"
+ }
+ },
+ "node_modules/safe-stable-stringify": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+ "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "peer": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sonic-boom": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+ "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "atomic-sleep": "^1.0.0"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/strtok3": {
+ "version": "10.3.4",
+ "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
+ "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tokenizer/token": "^0.3.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/thread-stream": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
+ "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
+ "license": "MIT",
+ "dependencies": {
+ "real-require": "^0.2.0"
+ }
+ },
+ "node_modules/token-types": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
+ "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@borewit/text-codec": "^0.2.1",
+ "@tokenizer/token": "^0.3.0",
+ "ieee754": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Borewit"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uint8array-extras": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+ "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "license": "MIT"
+ },
+ "node_modules/win-guid": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
+ "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
+ "license": "MIT"
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index ec5573a..7d48f5e 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -12,13 +12,6 @@ interface SendCommand {
text: string;
}
-interface SendImageCommand {
- type: 'send_image';
- to: string;
- imagePath: string;
- caption?: string;
-}
-
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
@@ -79,7 +72,7 @@ export class BridgeServer {
ws.on('message', async (data) => {
try {
- const cmd = JSON.parse(data.toString()) as SendCommand | SendImageCommand;
+ const cmd = JSON.parse(data.toString()) as SendCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
@@ -99,11 +92,9 @@ export class BridgeServer {
});
}
- private async handleCommand(cmd: SendCommand | SendImageCommand): Promise {
+ private async handleCommand(cmd: SendCommand): Promise {
if (cmd.type === 'send' && this.wa) {
await this.wa.sendMessage(cmd.to, cmd.text);
- } else if (cmd.type === 'send_image' && this.wa) {
- await this.wa.sendImage(cmd.to, cmd.imagePath, cmd.caption);
}
}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index d34100f..793e518 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -16,7 +16,7 @@ import makeWASocket, {
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
-import { writeFile, mkdir, readFile } from 'fs/promises';
+import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { homedir } from 'os';
import { randomBytes } from 'crypto';
@@ -217,18 +217,6 @@ export class WhatsAppClient {
await this.sock.sendMessage(to, { text });
}
- async sendImage(to: string, imagePath: string, caption?: string): Promise {
- if (!this.sock) {
- throw new Error('Not connected');
- }
-
- const buffer = await readFile(imagePath);
- await this.sock.sendMessage(to, {
- image: buffer,
- caption: caption || undefined,
- });
- }
-
async disconnect(): Promise {
if (this.sock) {
this.sock.end(undefined);
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 1a96753..21793b7 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -83,26 +83,12 @@ class WhatsAppChannel(BaseChannel):
return
try:
- # Send media files first
- for media_path in (msg.media or []):
- try:
- payload = {
- "type": "send_image",
- "to": msg.chat_id,
- "imagePath": media_path,
- }
- await self._ws.send(json.dumps(payload, ensure_ascii=False))
- except Exception as e:
- logger.error("Error sending WhatsApp media {}: {}", media_path, e)
-
- # Send text message if there's content
- if msg.content:
- payload = {
- "type": "send",
- "to": msg.chat_id,
- "text": msg.content
- }
- await self._ws.send(json.dumps(payload, ensure_ascii=False))
+ payload = {
+ "type": "send",
+ "to": msg.chat_id,
+ "text": msg.content
+ }
+ await self._ws.send(json.dumps(payload, ensure_ascii=False))
except Exception as e:
logger.error("Error sending WhatsApp message: {}", e)
From 067965da507853d29d9939095cd06d232871005f Mon Sep 17 00:00:00 2001
From: fat-operator
Date: Sat, 7 Mar 2026 00:13:38 +0000
Subject: [PATCH 039/124] Refactored from image support to generic media
---
bridge/package-lock.json | 1362 ----------------------------------
bridge/src/whatsapp.ts | 47 +-
nanobot/channels/whatsapp.py | 13 +-
3 files changed, 37 insertions(+), 1385 deletions(-)
delete mode 100644 bridge/package-lock.json
diff --git a/bridge/package-lock.json b/bridge/package-lock.json
deleted file mode 100644
index 7847d20..0000000
--- a/bridge/package-lock.json
+++ /dev/null
@@ -1,1362 +0,0 @@
-{
- "name": "nanobot-whatsapp-bridge",
- "version": "0.1.0",
- "lockfileVersion": 3,
- "requires": true,
- "packages": {
- "": {
- "name": "nanobot-whatsapp-bridge",
- "version": "0.1.0",
- "dependencies": {
- "@whiskeysockets/baileys": "7.0.0-rc.9",
- "pino": "^9.0.0",
- "qrcode-terminal": "^0.12.0",
- "ws": "^8.17.1"
- },
- "devDependencies": {
- "@types/node": "^20.14.0",
- "@types/ws": "^8.5.10",
- "typescript": "^5.4.0"
- },
- "engines": {
- "node": ">=20.0.0"
- }
- },
- "node_modules/@borewit/text-codec": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
- "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@cacheable/memory": {
- "version": "2.0.8",
- "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
- "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/utils": "^2.4.0",
- "@keyv/bigmap": "^1.3.1",
- "hookified": "^1.15.1",
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@cacheable/node-cache": {
- "version": "1.7.6",
- "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
- "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
- "license": "MIT",
- "dependencies": {
- "cacheable": "^2.3.1",
- "hookified": "^1.14.0",
- "keyv": "^5.5.5"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@cacheable/utils": {
- "version": "2.4.0",
- "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
- "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.5.0",
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
- "license": "MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@hapi/boom": {
- "version": "9.1.4",
- "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
- "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@hapi/hoek": "9.x.x"
- }
- },
- "node_modules/@hapi/hoek": {
- "version": "9.3.0",
- "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
- "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@img/colour": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
- "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
- "cpu": [
- "arm"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
- "cpu": [
- "ppc64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
- "cpu": [
- "riscv64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
- "cpu": [
- "s390x"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
- "cpu": [
- "arm"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
- "cpu": [
- "ppc64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
- "cpu": [
- "riscv64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
- "cpu": [
- "s390x"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "cpu": [
- "wasm32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
- "optional": true,
- "peer": true,
- "dependencies": {
- "@emnapi/runtime": "^1.7.0"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
- "cpu": [
- "ia32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "peer": true,
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@keyv/bigmap": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
- "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.4.0",
- "hookified": "^1.15.0"
- },
- "engines": {
- "node": ">= 18"
- },
- "peerDependencies": {
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@keyv/serialize": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
- "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
- "license": "MIT"
- },
- "node_modules/@pinojs/redact": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
- "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
- "license": "MIT"
- },
- "node_modules/@protobufjs/aspromise": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
- "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/base64": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
- "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/eventemitter": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
- }
- },
- "node_modules/@protobufjs/float": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
- "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/path": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
- "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/pool": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
- "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@tokenizer/inflate": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
- "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.3",
- "token-types": "^6.1.1"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@tokenizer/token": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
- "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
- "license": "MIT"
- },
- "node_modules/@types/long": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
- "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
- "license": "MIT"
- },
- "node_modules/@types/node": {
- "version": "20.19.37",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
- "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
- "license": "MIT",
- "dependencies": {
- "undici-types": "~6.21.0"
- }
- },
- "node_modules/@types/ws": {
- "version": "8.18.1",
- "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
- "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/node": "*"
- }
- },
- "node_modules/@whiskeysockets/baileys": {
- "version": "7.0.0-rc.9",
- "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
- "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "@cacheable/node-cache": "^1.4.0",
- "@hapi/boom": "^9.1.3",
- "async-mutex": "^0.5.0",
- "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
- "lru-cache": "^11.1.0",
- "music-metadata": "^11.7.0",
- "p-queue": "^9.0.0",
- "pino": "^9.6",
- "protobufjs": "^7.2.4",
- "ws": "^8.13.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "audio-decode": "^2.1.3",
- "jimp": "^1.6.0",
- "link-preview-js": "^3.0.0",
- "sharp": "*"
- },
- "peerDependenciesMeta": {
- "audio-decode": {
- "optional": true
- },
- "jimp": {
- "optional": true
- },
- "link-preview-js": {
- "optional": true
- }
- }
- },
- "node_modules/async-mutex": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
- "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/atomic-sleep": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
- "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/cacheable": {
- "version": "2.3.3",
- "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
- "integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/memory": "^2.0.8",
- "@cacheable/utils": "^2.4.0",
- "hookified": "^1.15.0",
- "keyv": "^5.6.0",
- "qified": "^0.6.0"
- }
- },
- "node_modules/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/curve25519-js": {
- "version": "0.0.4",
- "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
- "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/detect-libc": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
- "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "license": "Apache-2.0",
- "peer": true,
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/eventemitter3": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
- "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
- "license": "MIT"
- },
- "node_modules/file-type": {
- "version": "21.3.0",
- "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
- "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/inflate": "^0.4.1",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.1",
- "uint8array-extras": "^1.4.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/file-type?sponsor=1"
- }
- },
- "node_modules/hashery": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
- "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/hookified": {
- "version": "1.15.1",
- "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
- "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
- "license": "MIT"
- },
- "node_modules/ieee754": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/feross"
- },
- {
- "type": "patreon",
- "url": "https://www.patreon.com/feross"
- },
- {
- "type": "consulting",
- "url": "https://feross.org/support"
- }
- ],
- "license": "BSD-3-Clause"
- },
- "node_modules/keyv": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
- "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
- "license": "MIT",
- "dependencies": {
- "@keyv/serialize": "^1.1.1"
- }
- },
- "node_modules/libsignal": {
- "name": "@whiskeysockets/libsignal-node",
- "version": "2.0.1",
- "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
- "license": "GPL-3.0",
- "dependencies": {
- "curve25519-js": "^0.0.4",
- "protobufjs": "6.8.8"
- }
- },
- "node_modules/libsignal/node_modules/@types/node": {
- "version": "10.17.60",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
- "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
- "license": "MIT"
- },
- "node_modules/libsignal/node_modules/long": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
- "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
- "license": "Apache-2.0"
- },
- "node_modules/libsignal/node_modules/protobufjs": {
- "version": "6.8.8",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
- "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/long": "^4.0.0",
- "@types/node": "^10.1.0",
- "long": "^4.0.0"
- },
- "bin": {
- "pbjs": "bin/pbjs",
- "pbts": "bin/pbts"
- }
- },
- "node_modules/long": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
- "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
- "license": "Apache-2.0"
- },
- "node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
- "node_modules/media-typer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
- "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/music-metadata": {
- "version": "11.12.1",
- "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz",
- "integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- },
- {
- "type": "buymeacoffee",
- "url": "https://buymeacoffee.com/borewit"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "content-type": "^1.0.5",
- "debug": "^4.4.3",
- "file-type": "^21.3.0",
- "media-typer": "^1.1.0",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.2",
- "uint8array-extras": "^1.5.0",
- "win-guid": "^0.2.1"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/on-exit-leak-free": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
- "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
- "license": "MIT",
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/p-queue": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
- "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
- "license": "MIT",
- "dependencies": {
- "eventemitter3": "^5.0.1",
- "p-timeout": "^7.0.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-timeout": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
- "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
- "license": "MIT",
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/pino": {
- "version": "9.14.0",
- "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
- "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
- "license": "MIT",
- "dependencies": {
- "@pinojs/redact": "^0.4.0",
- "atomic-sleep": "^1.0.0",
- "on-exit-leak-free": "^2.1.0",
- "pino-abstract-transport": "^2.0.0",
- "pino-std-serializers": "^7.0.0",
- "process-warning": "^5.0.0",
- "quick-format-unescaped": "^4.0.3",
- "real-require": "^0.2.0",
- "safe-stable-stringify": "^2.3.1",
- "sonic-boom": "^4.0.1",
- "thread-stream": "^3.0.0"
- },
- "bin": {
- "pino": "bin.js"
- }
- },
- "node_modules/pino-abstract-transport": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
- "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
- "license": "MIT",
- "dependencies": {
- "split2": "^4.0.0"
- }
- },
- "node_modules/pino-std-serializers": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
- "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
- "license": "MIT"
- },
- "node_modules/process-warning": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
- "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/fastify"
- },
- {
- "type": "opencollective",
- "url": "https://opencollective.com/fastify"
- }
- ],
- "license": "MIT"
- },
- "node_modules/protobufjs": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/node": ">=13.7.0",
- "long": "^5.0.0"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
- "node_modules/qified": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
- "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/qrcode-terminal": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
- "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
- "bin": {
- "qrcode-terminal": "bin/qrcode-terminal.js"
- }
- },
- "node_modules/quick-format-unescaped": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
- "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
- "license": "MIT"
- },
- "node_modules/real-require": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
- "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
- "license": "MIT",
- "engines": {
- "node": ">= 12.13.0"
- }
- },
- "node_modules/safe-stable-stringify": {
- "version": "2.5.0",
- "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
- "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
- "license": "ISC",
- "peer": true,
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@img/colour": "^1.0.0",
- "detect-libc": "^2.1.2",
- "semver": "^7.7.3"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
- }
- },
- "node_modules/sonic-boom": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
- "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
- "license": "MIT",
- "dependencies": {
- "atomic-sleep": "^1.0.0"
- }
- },
- "node_modules/split2": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
- "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
- "license": "ISC",
- "engines": {
- "node": ">= 10.x"
- }
- },
- "node_modules/strtok3": {
- "version": "10.3.4",
- "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
- "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/token": "^0.3.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/thread-stream": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
- "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
- "license": "MIT",
- "dependencies": {
- "real-require": "^0.2.0"
- }
- },
- "node_modules/token-types": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
- "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "ieee754": "^1.2.1"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
- },
- "node_modules/typescript": {
- "version": "5.9.3",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
- "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
- "node_modules/uint8array-extras": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
- "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "license": "MIT"
- },
- "node_modules/win-guid": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
- "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
- "license": "MIT"
- },
- "node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- }
- }
-}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 793e518..279fe5a 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -127,7 +127,14 @@ export class WhatsAppClient {
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
- const path = await this.downloadImage(msg, unwrapped.imageMessage.mimetype ?? undefined);
+ const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
+ if (path) mediaPaths.push(path);
+ } else if (unwrapped.documentMessage) {
+ const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
+ unwrapped.documentMessage.fileName ?? undefined);
+ if (path) mediaPaths.push(path);
+ } else if (unwrapped.videoMessage) {
+ const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
}
@@ -148,29 +155,31 @@ export class WhatsAppClient {
});
}
- private async downloadImage(msg: any, mimetype?: string): Promise {
+ private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise {
try {
const mediaDir = join(homedir(), '.nanobot', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
- const mime = mimetype || 'image/jpeg';
- const extMap: Record = {
- 'image/jpeg': '.jpg',
- 'image/png': '.png',
- 'image/gif': '.gif',
- 'image/webp': '.webp',
- };
- const ext = extMap[mime] || '.jpg';
+ let outFilename: string;
+ if (fileName) {
+ // Documents have a filename — use it with a unique prefix to avoid collisions
+ const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
+ outFilename = prefix + fileName;
+ } else {
+ const mime = mimetype || 'application/octet-stream';
+ // Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf")
+ const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
+ outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
+ }
- const filename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
- const filepath = join(mediaDir, filename);
+ const filepath = join(mediaDir, outFilename);
await writeFile(filepath, buffer);
return filepath;
} catch (err) {
- console.error('Failed to download image:', err);
+ console.error('Failed to download media:', err);
return null;
}
}
@@ -191,14 +200,14 @@ export class WhatsAppClient {
return message.imageMessage.caption || '';
}
- // Video with caption
- if (message.videoMessage?.caption) {
- return `[Video] ${message.videoMessage.caption}`;
+ // Video with optional caption
+ if (message.videoMessage) {
+ return message.videoMessage.caption || '';
}
- // Document with caption
- if (message.documentMessage?.caption) {
- return `[Document] ${message.documentMessage.caption}`;
+ // Document with optional caption
+ if (message.documentMessage) {
+ return message.documentMessage.caption || '';
}
// Voice/Audio message
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 21793b7..1307716 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -2,6 +2,7 @@
import asyncio
import json
+import mimetypes
from collections import OrderedDict
from loguru import logger
@@ -128,12 +129,16 @@ class WhatsAppChannel(BaseChannel):
logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
content = "[Voice Message: Transcription not available for WhatsApp yet]"
- # Extract media paths (images downloaded by the bridge)
+ # Extract media paths (images/documents/videos downloaded by the bridge)
media_paths = data.get("media") or []
- # For image messages without caption, provide descriptive content
- if not content and media_paths:
- content = "[image]"
+ # Build content tags matching Telegram's pattern: [image: /path] or [file: /path]
+ if media_paths:
+ for p in media_paths:
+ mime, _ = mimetypes.guess_type(p)
+ media_type = "image" if mime and mime.startswith("image/") else "file"
+ media_tag = f"[{media_type}: {p}]"
+ content = f"{content}\n{media_tag}" if content else media_tag
await self._handle_message(
sender_id=sender_id,
From e3810573568d6ea269f5d9ebfaa39623ad2ea30c Mon Sep 17 00:00:00 2001
From: 04cb <0x04cb@gmail.com>
Date: Sat, 7 Mar 2026 08:31:15 +0800
Subject: [PATCH 040/124] Fix tool_call_id length error for GitHub Copilot
provider
GitHub Copilot and some other providers have a 64-character limit on
tool_call_id. When switching from providers that generate longer IDs
(such as OpenAI Codex), this caused validation errors.
This fix truncates tool_call_id to 64 characters by preserving the first
32 and last 32 characters to maintain uniqueness while respecting the
provider's limit.
Fixes #1554
---
nanobot/providers/litellm_provider.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 620424e..767c8da 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -169,6 +169,8 @@ class LiteLLMProvider(LLMProvider):
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
+ # GitHub Copilot and some other providers have a 64-character limit on tool_call_id
+ MAX_TOOL_CALL_ID_LENGTH = 64
allowed = _ALLOWED_MSG_KEYS | extra_keys
sanitized = []
for msg in messages:
@@ -176,6 +178,13 @@ class LiteLLMProvider(LLMProvider):
# Strict providers require "content" even when assistant only has tool_calls
if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None
+ # Truncate tool_call_id if it exceeds the provider's limit
+ # This can happen when switching from providers that generate longer IDs
+ if "tool_call_id" in clean and clean["tool_call_id"]:
+ tool_call_id = clean["tool_call_id"]
+ if isinstance(tool_call_id, str) and len(tool_call_id) > MAX_TOOL_CALL_ID_LENGTH:
+ # Preserve first 32 chars and last 32 chars to maintain uniqueness
+ clean["tool_call_id"] = tool_call_id[:32] + tool_call_id[-32:]
sanitized.append(clean)
return sanitized
From 64112eb9ba985bae151b2c40a1760886823b5747 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 03:06:19 +0000
Subject: [PATCH 041/124] fix(whatsapp): avoid dropping media-only messages
---
README.md | 4 ++++
bridge/src/whatsapp.ts | 9 +++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 0c49608..d2a1c59 100644
--- a/README.md
+++ b/README.md
@@ -420,6 +420,10 @@ nanobot channels login
nanobot gateway
```
+> WhatsApp bridge updates are not applied automatically for existing installations.
+> If you upgrade nanobot and need the latest WhatsApp bridge, run:
+> `rm -rf ~/.nanobot/bridge && nanobot channels login`
+
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 279fe5a..b91bacc 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -124,21 +124,26 @@ export class WhatsAppClient {
if (!unwrapped) continue;
const content = this.getTextContent(unwrapped);
+ let fallbackContent: string | null = null;
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
+ fallbackContent = '[Image]';
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.documentMessage) {
+ fallbackContent = '[Document]';
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
unwrapped.documentMessage.fileName ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.videoMessage) {
+ fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
}
- if (!content && mediaPaths.length === 0) continue;
+ const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
+ if (!finalContent && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
@@ -146,7 +151,7 @@ export class WhatsAppClient {
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
- content: content || '',
+ content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
From c94ac351f1a285e22fc0796a54a11d2821755ab6 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 03:30:36 +0000
Subject: [PATCH 042/124] fix(litellm): normalize tool call ids
---
nanobot/providers/litellm_provider.py | 40 +++++++++++++++++++++------
1 file changed, 32 insertions(+), 8 deletions(-)
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 767c8da..2fd6c18 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -1,5 +1,6 @@
"""LiteLLM provider implementation for multi-provider support."""
+import hashlib
import os
import secrets
import string
@@ -166,25 +167,48 @@ class LiteLLMProvider(LLMProvider):
return _ANTHROPIC_EXTRA_KEYS
return frozenset()
+ @staticmethod
+ def _normalize_tool_call_id(tool_call_id: Any) -> Any:
+ """Normalize tool_call_id to a provider-safe 9-char alphanumeric form."""
+ if not isinstance(tool_call_id, str):
+ return tool_call_id
+ if len(tool_call_id) == 9 and tool_call_id.isalnum():
+ return tool_call_id
+ return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
+
@staticmethod
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
- # GitHub Copilot and some other providers have a 64-character limit on tool_call_id
- MAX_TOOL_CALL_ID_LENGTH = 64
allowed = _ALLOWED_MSG_KEYS | extra_keys
sanitized = []
+ id_map: dict[str, str] = {}
+
+ def map_id(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value))
+
for msg in messages:
clean = {k: v for k, v in msg.items() if k in allowed}
# Strict providers require "content" even when assistant only has tool_calls
if clean.get("role") == "assistant" and "content" not in clean:
clean["content"] = None
- # Truncate tool_call_id if it exceeds the provider's limit
- # This can happen when switching from providers that generate longer IDs
+
+ # Keep assistant tool_calls[].id and tool tool_call_id in sync after
+ # shortening, otherwise strict providers reject the broken linkage.
+ if isinstance(clean.get("tool_calls"), list):
+ normalized_tool_calls = []
+ for tc in clean["tool_calls"]:
+ if not isinstance(tc, dict):
+ normalized_tool_calls.append(tc)
+ continue
+ tc_clean = dict(tc)
+ tc_clean["id"] = map_id(tc_clean.get("id"))
+ normalized_tool_calls.append(tc_clean)
+ clean["tool_calls"] = normalized_tool_calls
+
if "tool_call_id" in clean and clean["tool_call_id"]:
- tool_call_id = clean["tool_call_id"]
- if isinstance(tool_call_id, str) and len(tool_call_id) > MAX_TOOL_CALL_ID_LENGTH:
- # Preserve first 32 chars and last 32 chars to maintain uniqueness
- clean["tool_call_id"] = tool_call_id[:32] + tool_call_id[-32:]
+ clean["tool_call_id"] = map_id(clean["tool_call_id"])
sanitized.append(clean)
return sanitized
From 576ad12ef16fbf7813fb88d46f43c48a23d98ed8 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 03:57:57 +0000
Subject: [PATCH 043/124] fix(azure): sanitize messages and handle temperature
---
nanobot/providers/azure_openai_provider.py | 25 +++++++++-
nanobot/providers/base.py | 14 ++++++
nanobot/providers/litellm_provider.py | 10 +---
tests/test_azure_openai_provider.py | 57 +++++++++++++++++++---
4 files changed, 89 insertions(+), 17 deletions(-)
diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index 3f325aa..bd79b00 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -11,6 +11,8 @@ import json_repair
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+_AZURE_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"})
+
class AzureOpenAIProvider(LLMProvider):
"""
@@ -67,19 +69,38 @@ class AzureOpenAIProvider(LLMProvider):
"x-session-affinity": uuid.uuid4().hex, # For cache locality
}
+ @staticmethod
+ def _supports_temperature(
+ deployment_name: str,
+ reasoning_effort: str | None = None,
+ ) -> bool:
+ """Return True when temperature is likely supported for this deployment."""
+ if reasoning_effort:
+ return False
+ name = deployment_name.lower()
+ return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
+
def _prepare_request_payload(
self,
+ deployment_name: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
max_tokens: int = 4096,
+ temperature: float = 0.7,
reasoning_effort: str | None = None,
) -> dict[str, Any]:
"""Prepare the request payload with Azure OpenAI 2024-10-21 compliance."""
payload: dict[str, Any] = {
- "messages": self._sanitize_empty_content(messages),
+ "messages": self._sanitize_request_messages(
+ self._sanitize_empty_content(messages),
+ _AZURE_MSG_KEYS,
+ ),
"max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens
}
+ if self._supports_temperature(deployment_name, reasoning_effort):
+ payload["temperature"] = temperature
+
if reasoning_effort:
payload["reasoning_effort"] = reasoning_effort
@@ -116,7 +137,7 @@ class AzureOpenAIProvider(LLMProvider):
url = self._build_chat_url(deployment_name)
headers = self._build_headers()
payload = self._prepare_request_payload(
- messages, tools, max_tokens, reasoning_effort
+ deployment_name, messages, tools, max_tokens, temperature, reasoning_effort
)
try:
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index 55bd805..0f73544 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -87,6 +87,20 @@ class LLMProvider(ABC):
result.append(msg)
return result
+ @staticmethod
+ def _sanitize_request_messages(
+ messages: list[dict[str, Any]],
+ allowed_keys: frozenset[str],
+ ) -> list[dict[str, Any]]:
+ """Keep only provider-safe message keys and normalize assistant content."""
+ sanitized = []
+ for msg in messages:
+ clean = {k: v for k, v in msg.items() if k in allowed_keys}
+ if clean.get("role") == "assistant" and "content" not in clean:
+ clean["content"] = None
+ sanitized.append(clean)
+ return sanitized
+
@abstractmethod
async def chat(
self,
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 2fd6c18..cb67635 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -180,7 +180,7 @@ class LiteLLMProvider(LLMProvider):
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
"""Strip non-standard keys and ensure assistant messages have a content key."""
allowed = _ALLOWED_MSG_KEYS | extra_keys
- sanitized = []
+ sanitized = LLMProvider._sanitize_request_messages(messages, allowed)
id_map: dict[str, str] = {}
def map_id(value: Any) -> Any:
@@ -188,12 +188,7 @@ class LiteLLMProvider(LLMProvider):
return value
return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value))
- for msg in messages:
- clean = {k: v for k, v in msg.items() if k in allowed}
- # Strict providers require "content" even when assistant only has tool_calls
- if clean.get("role") == "assistant" and "content" not in clean:
- clean["content"] = None
-
+ for clean in sanitized:
# Keep assistant tool_calls[].id and tool tool_call_id in sync after
# shortening, otherwise strict providers reject the broken linkage.
if isinstance(clean.get("tool_calls"), list):
@@ -209,7 +204,6 @@ class LiteLLMProvider(LLMProvider):
if "tool_call_id" in clean and clean["tool_call_id"]:
clean["tool_call_id"] = map_id(clean["tool_call_id"])
- sanitized.append(clean)
return sanitized
async def chat(
diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py
index 680ddf4..77f36d4 100644
--- a/tests/test_azure_openai_provider.py
+++ b/tests/test_azure_openai_provider.py
@@ -1,9 +1,9 @@
"""Test Azure OpenAI provider implementation (updated for model-based deployment names)."""
-import asyncio
-import pytest
from unittest.mock import AsyncMock, Mock, patch
+import pytest
+
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.base import LLMResponse
@@ -89,22 +89,65 @@ def test_prepare_request_payload():
)
messages = [{"role": "user", "content": "Hello"}]
- payload = provider._prepare_request_payload(messages, max_tokens=1500)
+ payload = provider._prepare_request_payload("gpt-4o", messages, max_tokens=1500, temperature=0.8)
assert payload["messages"] == messages
assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens
- assert "temperature" not in payload # Temperature not included in payload
+ assert payload["temperature"] == 0.8
assert "tools" not in payload
# Test with tools
tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}]
- payload_with_tools = provider._prepare_request_payload(messages, tools=tools)
+ payload_with_tools = provider._prepare_request_payload("gpt-4o", messages, tools=tools)
assert payload_with_tools["tools"] == tools
assert payload_with_tools["tool_choice"] == "auto"
# Test with reasoning_effort
- payload_with_reasoning = provider._prepare_request_payload(messages, reasoning_effort="medium")
+ payload_with_reasoning = provider._prepare_request_payload(
+ "gpt-5-chat", messages, reasoning_effort="medium"
+ )
assert payload_with_reasoning["reasoning_effort"] == "medium"
+ assert "temperature" not in payload_with_reasoning
+
+
+def test_prepare_request_payload_sanitizes_messages():
+ """Test Azure payload strips non-standard message keys before sending."""
+ provider = AzureOpenAIProvider(
+ api_key="test-key",
+ api_base="https://test-resource.openai.azure.com",
+ default_model="gpt-4o",
+ )
+
+ messages = [
+ {
+ "role": "assistant",
+ "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}],
+ "reasoning_content": "hidden chain-of-thought",
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_123",
+ "name": "x",
+ "content": "ok",
+ "extra_field": "should be removed",
+ },
+ ]
+
+ payload = provider._prepare_request_payload("gpt-4o", messages)
+
+ assert payload["messages"] == [
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}],
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_123",
+ "name": "x",
+ "content": "ok",
+ },
+ ]
@pytest.mark.asyncio
@@ -349,7 +392,7 @@ if __name__ == "__main__":
# Test payload preparation
messages = [{"role": "user", "content": "Test"}]
- payload = provider._prepare_request_payload(messages, max_tokens=1000)
+ payload = provider._prepare_request_payload("gpt-4o-deployment", messages, max_tokens=1000)
assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format
print("✅ Payload preparation works correctly")
From c81d32c40f6c2baac34c73eec53c731fb00ae6d2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 04:07:25 +0000
Subject: [PATCH 044/124] fix(discord): handle attachment reply fallback
---
nanobot/channels/discord.py | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 8672327..0187c62 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -84,20 +84,31 @@ class DiscordChannel(BaseChannel):
headers = {"Authorization": f"Bot {self.config.token}"}
try:
+ sent_media = False
+ failed_media: list[str] = []
+
# Send file attachments first
for media_path in msg.media or []:
- await self._send_file(url, headers, media_path, reply_to=msg.reply_to)
+ if await self._send_file(url, headers, media_path, reply_to=msg.reply_to):
+ sent_media = True
+ else:
+ failed_media.append(Path(media_path).name)
# Send text content
chunks = split_message(msg.content or "", MAX_MESSAGE_LEN)
+ if not chunks and failed_media and not sent_media:
+ chunks = split_message(
+ "\n".join(f"[attachment: {name} - send failed]" for name in failed_media),
+ MAX_MESSAGE_LEN,
+ )
if not chunks:
return
for i, chunk in enumerate(chunks):
payload: dict[str, Any] = {"content": chunk}
- # Only set reply reference on the first chunk (if no media was sent)
- if i == 0 and msg.reply_to and not msg.media:
+ # Let the first successful attachment carry the reply if present.
+ if i == 0 and msg.reply_to and not sent_media:
payload["message_reference"] = {"message_id": msg.reply_to}
payload["allowed_mentions"] = {"replied_user": False}
From c3f2d1b01dbf02ec278c7714a85e7cc07f38280c Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 05:28:12 +0000
Subject: [PATCH 045/124] fix(tools): narrow parameter auto-casting
---
nanobot/agent/tools/base.py | 113 ++++++++++------------------------
tests/test_tool_validation.py | 54 +++++-----------
2 files changed, 48 insertions(+), 119 deletions(-)
diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py
index fb34fe8..06f5bdd 100644
--- a/nanobot/agent/tools/base.py
+++ b/nanobot/agent/tools/base.py
@@ -3,8 +3,6 @@
from abc import ABC, abstractmethod
from typing import Any
-from loguru import logger
-
class Tool(ABC):
"""
@@ -55,11 +53,7 @@ class Tool(ABC):
pass
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
- """
- Attempt to cast parameters to match schema types.
- Returns modified params dict. If casting fails, returns original value
- and logs a debug message, allowing validation to catch the error.
- """
+ """Apply safe schema-driven casts before validation."""
schema = self.parameters or {}
if schema.get("type", "object") != "object":
return params
@@ -86,91 +80,44 @@ class Tool(ABC):
"""Cast a single value according to schema."""
target_type = schema.get("type")
- # Already correct type
- # Note: check bool before int since bool is subclass of int
if target_type == "boolean" and isinstance(val, bool):
return val
if target_type == "integer" and isinstance(val, int) and not isinstance(val, bool):
return val
- # For array/object, don't early-return - we need to recurse into contents
- if target_type in self._TYPE_MAP and target_type not in (
- "boolean",
- "integer",
- "array",
- "object",
- ):
+ if target_type in self._TYPE_MAP and target_type not in ("boolean", "integer", "array", "object"):
expected = self._TYPE_MAP[target_type]
if isinstance(val, expected):
return val
- # Attempt casting
- try:
- if target_type == "integer":
- if isinstance(val, bool):
- # Don't silently convert bool to int
- raise ValueError("Cannot cast bool to integer")
- if isinstance(val, str):
- return int(val)
- if isinstance(val, (int, float)):
- return int(val)
+ if target_type == "integer" and isinstance(val, str):
+ try:
+ return int(val)
+ except ValueError:
+ return val
- elif target_type == "number":
- if isinstance(val, bool):
- # Don't silently convert bool to number
- raise ValueError("Cannot cast bool to number")
- if isinstance(val, str):
- return float(val)
- if isinstance(val, (int, float)):
- return float(val)
+ if target_type == "number" and isinstance(val, str):
+ try:
+ return float(val)
+ except ValueError:
+ return val
- elif target_type == "string":
- # Preserve None vs empty string distinction
- if val is None:
- return val
- return str(val)
+ if target_type == "string":
+ return val if val is None else str(val)
- elif target_type == "boolean":
- if isinstance(val, str):
- val_lower = val.lower()
- if val_lower in ("true", "1", "yes"):
- return True
- elif val_lower in ("false", "0", "no"):
- return False
- # For other strings, raise error to let validation handle it
- raise ValueError(f"Cannot convert string '{val}' to boolean")
- return bool(val)
+ if target_type == "boolean" and isinstance(val, str):
+ val_lower = val.lower()
+ if val_lower in ("true", "1", "yes"):
+ return True
+ if val_lower in ("false", "0", "no"):
+ return False
+ return val
- elif target_type == "array":
- if isinstance(val, list):
- # Recursively cast array items if schema defines items
- if "items" in schema:
- return [self._cast_value(item, schema["items"]) for item in val]
- return val
- # Preserve None vs empty array distinction
- if val is None:
- return val
- # Empty string → empty array
- if val == "":
- return []
- # Don't auto-wrap single values, let validation catch the error
- raise ValueError(f"Cannot convert {type(val).__name__} to array")
+ if target_type == "array" and isinstance(val, list):
+ item_schema = schema.get("items")
+ return [self._cast_value(item, item_schema) for item in val] if item_schema else val
- elif target_type == "object":
- if isinstance(val, dict):
- return self._cast_object(val, schema)
- # Preserve None vs empty object distinction
- if val is None:
- return val
- # Empty string → empty object
- if val == "":
- return {}
- # Cannot cast to object
- raise ValueError(f"Cannot cast {type(val).__name__} to object")
-
- except (ValueError, TypeError) as e:
- # Log failed casts for debugging, return original value
- # Let validation catch the error
- logger.debug("Failed to cast value %r to %s: %s", val, target_type, e)
+ if target_type == "object" and isinstance(val, dict):
+ return self._cast_object(val, schema)
return val
@@ -185,7 +132,13 @@ class Tool(ABC):
def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]:
t, label = schema.get("type"), path or "parameter"
- if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]):
+ if t == "integer" and (not isinstance(val, int) or isinstance(val, bool)):
+ return [f"{label} should be integer"]
+ if t == "number" and (
+ not isinstance(val, self._TYPE_MAP[t]) or isinstance(val, bool)
+ ):
+ return [f"{label} should be number"]
+ if t in self._TYPE_MAP and t not in ("integer", "number") and not isinstance(val, self._TYPE_MAP[t]):
return [f"{label} should be {t}"]
errors = []
diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py
index 6fb87ea..c2b4b6a 100644
--- a/tests/test_tool_validation.py
+++ b/tests/test_tool_validation.py
@@ -210,9 +210,10 @@ def test_cast_params_bool_not_cast_to_int() -> None:
"properties": {"count": {"type": "integer"}},
}
)
- # Bool input should remain bool (validation will catch it)
result = tool.cast_params({"count": True})
- assert result["count"] is True # Not cast to 1
+ assert result["count"] is True
+ errors = tool.validate_params(result)
+ assert any("count should be integer" in e for e in errors)
def test_cast_params_preserves_empty_string() -> None:
@@ -283,6 +284,18 @@ def test_cast_params_invalid_string_to_number() -> None:
assert result["rate"] == "not_a_number"
+def test_validate_params_bool_not_accepted_as_number() -> None:
+ """Booleans should not pass number validation."""
+ tool = CastTestTool(
+ {
+ "type": "object",
+ "properties": {"rate": {"type": "number"}},
+ }
+ )
+ errors = tool.validate_params({"rate": False})
+ assert any("rate should be number" in e for e in errors)
+
+
def test_cast_params_none_values() -> None:
"""Test None handling for different types."""
tool = CastTestTool(
@@ -324,40 +337,3 @@ def test_cast_params_single_value_not_auto_wrapped_to_array() -> None:
assert result["items"] == 5 # Not wrapped to [5]
result = tool.cast_params({"items": "text"})
assert result["items"] == "text" # Not wrapped to ["text"]
-
-
-def test_cast_params_empty_string_to_array() -> None:
- """Empty string should convert to empty array."""
- tool = CastTestTool(
- {
- "type": "object",
- "properties": {"items": {"type": "array"}},
- }
- )
- result = tool.cast_params({"items": ""})
- assert result["items"] == []
-
-
-def test_cast_params_empty_string_to_object() -> None:
- """Empty string should convert to empty object."""
- tool = CastTestTool(
- {
- "type": "object",
- "properties": {"config": {"type": "object"}},
- }
- )
- result = tool.cast_params({"config": ""})
- assert result["config"] == {}
-
-
-def test_cast_params_float_to_int() -> None:
- """Float values should be cast to integers."""
- tool = CastTestTool(
- {
- "type": "object",
- "properties": {"count": {"type": "integer"}},
- }
- )
- result = tool.cast_params({"count": 42.7})
- assert result["count"] == 42
- assert isinstance(result["count"], int)
From 215360113fa967f197301352416d694697b049ba Mon Sep 17 00:00:00 2001
From: chengyongru
Date: Sat, 7 Mar 2026 16:19:55 +0800
Subject: [PATCH 046/124] feat(feishu): add audio transcription support using
Groq Whisper
---
nanobot/channels/feishu.py | 15 ++++++++++++++-
nanobot/channels/manager.py | 3 ++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 8f69c09..611c95e 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -244,9 +244,10 @@ class FeishuChannel(BaseChannel):
name = "feishu"
- def __init__(self, config: FeishuConfig, bus: MessageBus):
+ def __init__(self, config: FeishuConfig, bus: MessageBus, groq_api_key: str = ""):
super().__init__(config, bus)
self.config: FeishuConfig = config
+ self.groq_api_key = groq_api_key
self._client: Any = None
self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None
@@ -909,6 +910,18 @@ class FeishuChannel(BaseChannel):
file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id)
if file_path:
media_paths.append(file_path)
+
+ # Transcribe audio using Groq Whisper
+ if msg_type == "audio" and file_path and self.groq_api_key:
+ try:
+ from nanobot.providers.transcription import GroqTranscriptionProvider
+ transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
+ transcription = await transcriber.transcribe(file_path)
+ if transcription:
+ content_text = f"[transcription: {transcription}]"
+ except Exception as e:
+ logger.warning("Failed to transcribe audio: {}", e)
+
content_parts.append(content_text)
elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"):
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 7d7d110..51539dd 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -74,7 +74,8 @@ class ChannelManager:
try:
from nanobot.channels.feishu import FeishuChannel
self.channels["feishu"] = FeishuChannel(
- self.config.channels.feishu, self.bus
+ self.config.channels.feishu, self.bus,
+ groq_api_key=self.config.providers.groq.api_key,
)
logger.info("Feishu channel enabled")
except ImportError as e:
From cf76011c1aae1b397361f85751443b36b6418e79 Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sat, 7 Mar 2026 17:09:59 +0800
Subject: [PATCH 047/124] fix: hide reasoning_content from user progress
updates
---
nanobot/agent/loop.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 7f129a2..56a91c1 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -202,9 +202,10 @@ class AgentLoop:
if response.has_tool_calls:
if on_progress:
+ # Only show stripped content and thinking blocks in progress, not reasoning_content
+ # reasoning_content is internal thinking and should not be shown to users
thoughts = [
self._strip_think(response.content),
- response.reasoning_content,
*(
f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}"
for b in (response.thinking_blocks or [])
From 44327d6457f87884954bde79c25415ba69134a41 Mon Sep 17 00:00:00 2001
From: Gleb
Date: Sat, 7 Mar 2026 12:38:52 +0200
Subject: [PATCH 048/124] fix(telegram): added "stop" command handler, fixed
stop command
---
nanobot/channels/telegram.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index aaa24e7..c83edd3 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -197,6 +197,7 @@ class TelegramChannel(BaseChannel):
# Add command handlers
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command))
+ self._app.add_handler(CommandHandler("stop", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help))
# Add message handler for text, photos, voice, documents
From 43fc59da0073f760b27221990cd5bc294682239f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 14:53:14 +0000
Subject: [PATCH 049/124] fix: hide internal reasoning in progress
---
nanobot/agent/loop.py | 16 +++------------
tests/test_message_tool_suppress.py | 30 +++++++++++++++++++++++++++++
2 files changed, 33 insertions(+), 13 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 56a91c1..ca9a06e 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -202,19 +202,9 @@ class AgentLoop:
if response.has_tool_calls:
if on_progress:
- # Only show stripped content and thinking blocks in progress, not reasoning_content
- # reasoning_content is internal thinking and should not be shown to users
- thoughts = [
- self._strip_think(response.content),
- *(
- f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}"
- for b in (response.thinking_blocks or [])
- if isinstance(b, dict) and "signature" in b
- ),
- ]
- combined_thoughts = "\n\n".join(filter(None, thoughts))
- if combined_thoughts:
- await on_progress(combined_thoughts)
+ thought = self._strip_think(response.content)
+ if thought:
+ await on_progress(thought)
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py
index 26b8a16..f5e65c9 100644
--- a/tests/test_message_tool_suppress.py
+++ b/tests/test_message_tool_suppress.py
@@ -86,6 +86,36 @@ class TestMessageToolSuppressLogic:
assert result is not None
assert "Hello" in result.content
+ @pytest.mark.asyncio
+ async def test_progress_hides_internal_reasoning(self, tmp_path: Path) -> None:
+ loop = _make_loop(tmp_path)
+ tool_call = ToolCallRequest(id="call1", name="read_file", arguments={"path": "foo.txt"})
+ calls = iter([
+ LLMResponse(
+ content="Visiblehidden ",
+ tool_calls=[tool_call],
+ reasoning_content="secret reasoning",
+ thinking_blocks=[{"signature": "sig", "thought": "secret thought"}],
+ ),
+ LLMResponse(content="Done", tool_calls=[]),
+ ])
+ loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.execute = AsyncMock(return_value="ok")
+
+ progress: list[tuple[str, bool]] = []
+
+ async def on_progress(content: str, *, tool_hint: bool = False) -> None:
+ progress.append((content, tool_hint))
+
+ final_content, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
+
+ assert final_content == "Done"
+ assert progress == [
+ ("Visible", False),
+ ('read_file("foo.txt")', True),
+ ]
+
class TestMessageToolTurnTracking:
From a9f3552d6e7cdb441c0bc376605d06d83ab5ee2a Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 15:11:09 +0000
Subject: [PATCH 050/124] test(telegram): cover proxy request initialization
---
tests/test_telegram_channel.py | 107 +++++++++++++++++++++++++++++++++
1 file changed, 107 insertions(+)
create mode 100644 tests/test_telegram_channel.py
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
new file mode 100644
index 0000000..3bacf96
--- /dev/null
+++ b/tests/test_telegram_channel.py
@@ -0,0 +1,107 @@
+from types import SimpleNamespace
+
+import pytest
+
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.telegram import TelegramChannel
+from nanobot.config.schema import TelegramConfig
+
+
+class _FakeHTTPXRequest:
+ instances: list["_FakeHTTPXRequest"] = []
+
+ def __init__(self, **kwargs) -> None:
+ self.kwargs = kwargs
+ self.__class__.instances.append(self)
+
+
+class _FakeUpdater:
+ def __init__(self, on_start_polling) -> None:
+ self._on_start_polling = on_start_polling
+
+ async def start_polling(self, **kwargs) -> None:
+ self._on_start_polling()
+
+
+class _FakeBot:
+ async def get_me(self):
+ return SimpleNamespace(username="nanobot_test")
+
+ async def set_my_commands(self, commands) -> None:
+ self.commands = commands
+
+
+class _FakeApp:
+ def __init__(self, on_start_polling) -> None:
+ self.bot = _FakeBot()
+ self.updater = _FakeUpdater(on_start_polling)
+ self.handlers = []
+ self.error_handlers = []
+
+ def add_error_handler(self, handler) -> None:
+ self.error_handlers.append(handler)
+
+ def add_handler(self, handler) -> None:
+ self.handlers.append(handler)
+
+ async def initialize(self) -> None:
+ pass
+
+ async def start(self) -> None:
+ pass
+
+
+class _FakeBuilder:
+ def __init__(self, app: _FakeApp) -> None:
+ self.app = app
+ self.token_value = None
+ self.request_value = None
+ self.get_updates_request_value = None
+
+ def token(self, token: str):
+ self.token_value = token
+ return self
+
+ def request(self, request):
+ self.request_value = request
+ return self
+
+ def get_updates_request(self, request):
+ self.get_updates_request_value = request
+ return self
+
+ def proxy(self, _proxy):
+ raise AssertionError("builder.proxy should not be called when request is set")
+
+ def get_updates_proxy(self, _proxy):
+ raise AssertionError("builder.get_updates_proxy should not be called when request is set")
+
+ def build(self):
+ return self.app
+
+
+@pytest.mark.asyncio
+async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None:
+ config = TelegramConfig(
+ enabled=True,
+ token="123:abc",
+ allow_from=["*"],
+ proxy="http://127.0.0.1:7890",
+ )
+ bus = MessageBus()
+ channel = TelegramChannel(config, bus)
+ app = _FakeApp(lambda: setattr(channel, "_running", False))
+ builder = _FakeBuilder(app)
+
+ monkeypatch.setattr("nanobot.channels.telegram.HTTPXRequest", _FakeHTTPXRequest)
+ monkeypatch.setattr(
+ "nanobot.channels.telegram.Application",
+ SimpleNamespace(builder=lambda: builder),
+ )
+
+ await channel.start()
+
+ assert len(_FakeHTTPXRequest.instances) == 1
+ assert _FakeHTTPXRequest.instances[0].kwargs["proxy"] == config.proxy
+ assert builder.request_value is _FakeHTTPXRequest.instances[0]
+ assert builder.get_updates_request_value is _FakeHTTPXRequest.instances[0]
From 26670d3e8042746e4ce0feaaa3761aae7a97b436 Mon Sep 17 00:00:00 2001
From: shawn_wxn
Date: Fri, 6 Mar 2026 19:08:44 +0800
Subject: [PATCH 051/124] feat(dingtalk): add support for group chat messages
---
nanobot/channels/dingtalk.py | 32 +++++++++++++++++++++++++-------
1 file changed, 25 insertions(+), 7 deletions(-)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 8d02fa6..8e2a2be 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -70,6 +70,13 @@ class NanobotDingTalkHandler(CallbackHandler):
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
sender_name = chatbot_msg.sender_nick or "Unknown"
+ # Extract conversation info
+ conversation_type = message.data.get("conversationType")
+ conversation_id = message.data.get("conversationId") or message.data.get("openConversationId")
+
+ if conversation_type == "2" and conversation_id:
+ sender_id = f"group:{conversation_id}"
+
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
# Forward to Nanobot via _on_message (non-blocking).
@@ -301,14 +308,25 @@ class DingTalkChannel(BaseChannel):
logger.warning("DingTalk HTTP client not initialized, cannot send")
return False
- url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
headers = {"x-acs-dingtalk-access-token": token}
- payload = {
- "robotCode": self.config.client_id,
- "userIds": [chat_id],
- "msgKey": msg_key,
- "msgParam": json.dumps(msg_param, ensure_ascii=False),
- }
+ if chat_id.startswith("group:"):
+ # Group chat
+ url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
+ payload = {
+ "robotCode": self.config.client_id,
+ "openConversationId": chat_id[6:], # Remove "group:" prefix,
+ "msgKey": "sampleMarkdown",
+ "msgParam": json.dumps(msg_param, ensure_ascii=False),
+ }
+ else:
+ # Private chat
+ url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
+ payload = {
+ "robotCode": self.config.client_id,
+ "userIds": [chat_id],
+ "msgKey": msg_key,
+ "msgParam": json.dumps(msg_param, ensure_ascii=False),
+ }
try:
resp = await self._http.post(url, json=payload, headers=headers)
From caa2aa596dbaabb121af618e00635d47d1126f02 Mon Sep 17 00:00:00 2001
From: shawn_wxn
Date: Fri, 6 Mar 2026 19:08:59 +0800
Subject: [PATCH 052/124] fix(dingtalk): correct msgKey parameter for group
messages
---
nanobot/channels/dingtalk.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 8e2a2be..bd6a8c2 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -315,7 +315,7 @@ class DingTalkChannel(BaseChannel):
payload = {
"robotCode": self.config.client_id,
"openConversationId": chat_id[6:], # Remove "group:" prefix,
- "msgKey": "sampleMarkdown",
+ "msgKey": "msg_key",
"msgParam": json.dumps(msg_param, ensure_ascii=False),
}
else:
From 73991779b3bc82dcd39c0b9b6b189577380c7b1a Mon Sep 17 00:00:00 2001
From: shawn_wxn
Date: Fri, 6 Mar 2026 19:58:22 +0800
Subject: [PATCH 053/124] fix(dingtalk): use msg_key variable instead of
hardcoded
---
nanobot/channels/dingtalk.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index bd6a8c2..78ca6c9 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -315,7 +315,7 @@ class DingTalkChannel(BaseChannel):
payload = {
"robotCode": self.config.client_id,
"openConversationId": chat_id[6:], # Remove "group:" prefix,
- "msgKey": "msg_key",
+ "msgKey": msg_key,
"msgParam": json.dumps(msg_param, ensure_ascii=False),
}
else:
From 4e25ac5c82f8210bb4acf18bf0abd9e5f47841d2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 16:07:57 +0000
Subject: [PATCH 054/124] test(dingtalk): cover group reply routing
---
nanobot/channels/dingtalk.py | 35 ++++++++++++------
tests/test_dingtalk_channel.py | 66 ++++++++++++++++++++++++++++++++++
2 files changed, 91 insertions(+), 10 deletions(-)
create mode 100644 tests/test_dingtalk_channel.py
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 78ca6c9..3c301a9 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -70,19 +70,24 @@ class NanobotDingTalkHandler(CallbackHandler):
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id
sender_name = chatbot_msg.sender_nick or "Unknown"
- # Extract conversation info
conversation_type = message.data.get("conversationType")
- conversation_id = message.data.get("conversationId") or message.data.get("openConversationId")
-
- if conversation_type == "2" and conversation_id:
- sender_id = f"group:{conversation_id}"
+ conversation_id = (
+ message.data.get("conversationId")
+ or message.data.get("openConversationId")
+ )
logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content)
# Forward to Nanobot via _on_message (non-blocking).
# Store reference to prevent GC before task completes.
task = asyncio.create_task(
- self.channel._on_message(content, sender_id, sender_name)
+ self.channel._on_message(
+ content,
+ sender_id,
+ sender_name,
+ conversation_type,
+ conversation_id,
+ )
)
self.channel._background_tasks.add(task)
task.add_done_callback(self.channel._background_tasks.discard)
@@ -102,8 +107,8 @@ class DingTalkChannel(BaseChannel):
Uses WebSocket to receive events via `dingtalk-stream` SDK.
Uses direct HTTP API to send messages (SDK is mainly for receiving).
- Note: Currently only supports private (1:1) chat. Group messages are
- received but replies are sent back as private messages to the sender.
+ Supports both private (1:1) and group chats.
+ Group chat_id is stored with a "group:" prefix to route replies back.
"""
name = "dingtalk"
@@ -435,7 +440,14 @@ class DingTalkChannel(BaseChannel):
f"[Attachment send failed: {filename}]",
)
- async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None:
+ async def _on_message(
+ self,
+ content: str,
+ sender_id: str,
+ sender_name: str,
+ conversation_type: str | None = None,
+ conversation_id: str | None = None,
+ ) -> None:
"""Handle incoming message (called by NanobotDingTalkHandler).
Delegates to BaseChannel._handle_message() which enforces allow_from
@@ -443,13 +455,16 @@ class DingTalkChannel(BaseChannel):
"""
try:
logger.info("DingTalk inbound: {} from {}", content, sender_name)
+ is_group = conversation_type == "2" and conversation_id
+ chat_id = f"group:{conversation_id}" if is_group else sender_id
await self._handle_message(
sender_id=sender_id,
- chat_id=sender_id, # For private chat, chat_id == sender_id
+ chat_id=chat_id,
content=str(content),
metadata={
"sender_name": sender_name,
"platform": "dingtalk",
+ "conversation_type": conversation_type,
},
)
except Exception as e:
diff --git a/tests/test_dingtalk_channel.py b/tests/test_dingtalk_channel.py
new file mode 100644
index 0000000..7595a33
--- /dev/null
+++ b/tests/test_dingtalk_channel.py
@@ -0,0 +1,66 @@
+from types import SimpleNamespace
+
+import pytest
+
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.dingtalk import DingTalkChannel
+from nanobot.config.schema import DingTalkConfig
+
+
+class _FakeResponse:
+ def __init__(self, status_code: int = 200, json_body: dict | None = None) -> None:
+ self.status_code = status_code
+ self._json_body = json_body or {}
+ self.text = "{}"
+
+ def json(self) -> dict:
+ return self._json_body
+
+
+class _FakeHttp:
+ def __init__(self) -> None:
+ self.calls: list[dict] = []
+
+ async def post(self, url: str, json=None, headers=None):
+ self.calls.append({"url": url, "json": json, "headers": headers})
+ return _FakeResponse()
+
+
+@pytest.mark.asyncio
+async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None:
+ config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"])
+ bus = MessageBus()
+ channel = DingTalkChannel(config, bus)
+
+ await channel._on_message(
+ "hello",
+ sender_id="user1",
+ sender_name="Alice",
+ conversation_type="2",
+ conversation_id="conv123",
+ )
+
+ msg = await bus.consume_inbound()
+ assert msg.sender_id == "user1"
+ assert msg.chat_id == "group:conv123"
+ assert msg.metadata["conversation_type"] == "2"
+
+
+@pytest.mark.asyncio
+async def test_group_send_uses_group_messages_api() -> None:
+ config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"])
+ channel = DingTalkChannel(config, MessageBus())
+ channel._http = _FakeHttp()
+
+ ok = await channel._send_batch_message(
+ "token",
+ "group:conv123",
+ "sampleMarkdown",
+ {"text": "hello", "title": "Nanobot Reply"},
+ )
+
+ assert ok is True
+ call = channel._http.calls[0]
+ assert call["url"] == "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
+ assert call["json"]["openConversationId"] == "conv123"
+ assert call["json"]["msgKey"] == "sampleMarkdown"
From 057927cd24871c73ecd46e47291ec0aa4ac3a2ce Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sat, 7 Mar 2026 16:36:12 +0000
Subject: [PATCH 055/124] fix(auth): prevent allowlist bypass via sender_id
token splitting
---
nanobot/channels/base.py | 5 +----
nanobot/channels/telegram.py | 19 +++++++++++++++++++
tests/test_base_channel.py | 25 +++++++++++++++++++++++++
tests/test_telegram_channel.py | 15 +++++++++++++++
4 files changed, 60 insertions(+), 4 deletions(-)
create mode 100644 tests/test_base_channel.py
diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py
index b38fcaf..dc53ba4 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -66,10 +66,7 @@ class BaseChannel(ABC):
return False
if "*" in allow_list:
return True
- sender_str = str(sender_id)
- return sender_str in allow_list or any(
- p in allow_list for p in sender_str.split("|") if p
- )
+ return str(sender_id) in allow_list
async def _handle_message(
self,
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 81cf0ca..501a3c1 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -179,6 +179,25 @@ class TelegramChannel(BaseChannel):
self._media_group_tasks: dict[str, asyncio.Task] = {}
self._message_threads: dict[tuple[str, int], int] = {}
+ def is_allowed(self, sender_id: str) -> bool:
+ """Preserve Telegram's legacy id|username allowlist matching."""
+ if super().is_allowed(sender_id):
+ return True
+
+ allow_list = getattr(self.config, "allow_from", [])
+ if not allow_list or "*" in allow_list:
+ return False
+
+ sender_str = str(sender_id)
+ if sender_str.count("|") != 1:
+ return False
+
+ sid, username = sender_str.split("|", 1)
+ if not sid.isdigit() or not username:
+ return False
+
+ return sid in allow_list or username in allow_list
+
async def start(self) -> None:
"""Start the Telegram bot with long polling."""
if not self.config.token:
diff --git a/tests/test_base_channel.py b/tests/test_base_channel.py
new file mode 100644
index 0000000..5d10d4e
--- /dev/null
+++ b/tests/test_base_channel.py
@@ -0,0 +1,25 @@
+from types import SimpleNamespace
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.base import BaseChannel
+
+
+class _DummyChannel(BaseChannel):
+ name = "dummy"
+
+ async def start(self) -> None:
+ return None
+
+ async def stop(self) -> None:
+ return None
+
+ async def send(self, msg: OutboundMessage) -> None:
+ return None
+
+
+def test_is_allowed_requires_exact_match() -> None:
+ channel = _DummyChannel(SimpleNamespace(allow_from=["allow@email.com"]), MessageBus())
+
+ assert channel.is_allowed("allow@email.com") is True
+ assert channel.is_allowed("attacker|allow@email.com") is False
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
index acd2a96..88c3f54 100644
--- a/tests/test_telegram_channel.py
+++ b/tests/test_telegram_channel.py
@@ -131,6 +131,21 @@ def test_get_extension_falls_back_to_original_filename() -> None:
assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz"
+def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None:
+ channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus())
+
+ assert channel.is_allowed("12345|carol") is True
+ assert channel.is_allowed("99999|alice") is True
+ assert channel.is_allowed("67890|bob") is True
+
+
+def test_is_allowed_rejects_invalid_legacy_telegram_sender_shapes() -> None:
+ channel = TelegramChannel(TelegramConfig(allow_from=["alice"]), MessageBus())
+
+ assert channel.is_allowed("attacker|alice|extra") is False
+ assert channel.is_allowed("not-a-number|alice") is False
+
+
@pytest.mark.asyncio
async def test_send_progress_keeps_message_in_topic() -> None:
config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"])
From 3ca89d7821a0eccfc4e66b11e511fd4565c4f6b1 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 01:42:30 +0000
Subject: [PATCH 056/124] docs: update nanobot news
---
README.md | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index 03f042a..3c20adb 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,20 @@
## 📢 News
+- **2026-03-07** 🚀 Azure OpenAI, WhatsApp media, Discord attachments, QQ group chats, and lots of Telegram/Feishu polish.
+- **2026-03-06** 🪄 Lighter provider loading, smarter message/media handling, and more robust memory and CLI compatibility.
+- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, multi-instance gateway runs, and broader channel reliability fixes.
+- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and a fresh round of test, Cron, and validation reliability fixes.
+- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal session saves, and stronger Cron scheduling guards.
+- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
+- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, Feishu rich-text parsing, and more cleanup across the codebase.
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
+
+
+Earlier news
+
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
@@ -30,10 +41,6 @@
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
-
-
-Earlier news
-
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
From 822d2311e0c4eee4a51fe2c62a89bc543f027458 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 01:44:06 +0000
Subject: [PATCH 057/124] docs: update nanobot march news
---
README.md | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 3c20adb..18770dc 100644
--- a/README.md
+++ b/README.md
@@ -20,13 +20,13 @@
## 📢 News
-- **2026-03-07** 🚀 Azure OpenAI, WhatsApp media, Discord attachments, QQ group chats, and lots of Telegram/Feishu polish.
-- **2026-03-06** 🪄 Lighter provider loading, smarter message/media handling, and more robust memory and CLI compatibility.
-- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, multi-instance gateway runs, and broader channel reliability fixes.
-- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and a fresh round of test, Cron, and validation reliability fixes.
-- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal session saves, and stronger Cron scheduling guards.
+- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
+- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
+- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
+- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
+- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
-- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, Feishu rich-text parsing, and more cleanup across the codebase.
+- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
From 20dfaa5d34968cf8d3f19a180e053f145a7dfad3 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 02:58:25 +0000
Subject: [PATCH 058/124] refactor: unify instance path resolution and preserve
workspace override
---
README.md | 192 ++++++-----------------------------
bridge/src/whatsapp.ts | 3 +-
nanobot/channels/discord.py | 3 +-
nanobot/channels/feishu.py | 4 +-
nanobot/channels/matrix.py | 6 +-
nanobot/channels/mochat.py | 4 +-
nanobot/channels/telegram.py | 6 +-
nanobot/cli/commands.py | 27 +++--
nanobot/config/__init__.py | 26 ++++-
nanobot/config/loader.py | 8 --
nanobot/config/paths.py | 55 ++++++++++
nanobot/session/manager.py | 3 +-
nanobot/utils/__init__.py | 4 +-
nanobot/utils/helpers.py | 12 ---
tests/test_commands.py | 97 +++++++++++++++++-
tests/test_config_paths.py | 42 ++++++++
16 files changed, 282 insertions(+), 210 deletions(-)
create mode 100644 nanobot/config/paths.py
create mode 100644 tests/test_config_paths.py
diff --git a/README.md b/README.md
index fdbd5cf..5bd11f8 100644
--- a/README.md
+++ b/README.md
@@ -905,7 +905,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
## 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
@@ -920,35 +920,31 @@ nanobot gateway --config ~/.nanobot-discord/config.json
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 |
-|-----------|-----------|---------|
-| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` |
-| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` |
-| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` |
-| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` |
-| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` |
+| Component | Resolved From | Example |
+|-----------|---------------|---------|
+| **Config** | `--config` path | `~/.nanobot-A/config.json` |
+| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` |
+| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` |
+| **Media / runtime state** | config directory | `~/.nanobot-A/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
-# Instance A
-mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media}
-cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json
+### Minimal Setup
-# Instance B
-mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media}
-cp ~/.nanobot/config.json ~/.nanobot-discord/config.json
-```
+1. Copy your base config into a new instance directory.
+2. Set a different `agents.defaults.workspace` for that instance.
+3. Start the instance with `--config`.
-**2. Configure each instance:**
+Example config:
-Edit `~/.nanobot-telegram/config.json`:
```json
{
"agents": {
@@ -969,160 +965,32 @@ Edit `~/.nanobot-telegram/config.json`:
}
```
-Edit `~/.nanobot-discord/config.json`:
-```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:**
+Start separate instances:
```bash
-# Terminal 1
nanobot gateway --config ~/.nanobot-telegram/config.json
-
-# Terminal 2
nanobot gateway --config ~/.nanobot-discord/config.json
```
-### Use Cases
-
-- **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:
+Override workspace for one-off runs when needed:
```bash
-#!/bin/bash
-# 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
+nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test
```
-### systemd Service (Linux)
+### Common Use Cases
-For automatic startup and crash recovery, create a systemd service for each instance:
-
-```ini
-# ~/.config/systemd/user/nanobot-telegram.service
-[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
-
-
-
-
-
- Label
- com.nanobot.telegram
-
- ProgramArguments
-
- /path/to/nanobot
- gateway
- --config
- /Users/yourname/.nanobot-telegram/config.json
-
-
- RunAtLoad
-
-
- KeepAlive
-
-
- StandardOutPath
- /Users/yourname/.nanobot-telegram/logs/stdout.log
-
- StandardErrorPath
- /Users/yourname/.nanobot-telegram/logs/stderr.log
-
-
-```
-
-Load the service:
-```bash
-launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist
-launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist
-```
+- Run separate bots for Telegram, Discord, Feishu, and other platforms
+- Keep testing and production instances isolated
+- Use different models or providers for different teams
+- Serve multiple tenants with separate configs and runtime data
### Notes
-- Each instance must use a different port (default: 18790)
-- Instances are completely independent — no shared state or cross-talk
-- You can run different LLM models, providers, and channel configurations per instance
-- Memory, sessions, and cron jobs are fully isolated between instances
+- Each instance must use a different port if they run at the same time
+- Use a different workspace per instance if you want isolated memory, sessions, and skills
+- `--workspace` overrides the workspace defined in the config file
+- Cron jobs and runtime media/state are derived from the config directory
## CLI Reference
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index b91bacc..f0485bd 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -18,7 +18,6 @@ import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
-import { homedir } from 'os';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
@@ -162,7 +161,7 @@ export class WhatsAppClient {
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise {
try {
- const mediaDir = join(homedir(), '.nanobot', 'media');
+ const mediaDir = join(this.options.authDir, '..', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py
index 0187c62..2ee4f77 100644
--- a/nanobot/channels/discord.py
+++ b/nanobot/channels/discord.py
@@ -12,6 +12,7 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import DiscordConfig
from nanobot.utils.helpers import split_message
@@ -289,7 +290,7 @@ class DiscordChannel(BaseChannel):
content_parts = [content] if content else []
media_paths: list[str] = []
- media_dir = Path.home() / ".nanobot" / "media"
+ media_dir = get_media_dir("discord")
for attachment in payload.get("attachments") or []:
url = attachment.get("url")
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 2dcf710..a637025 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -14,6 +14,7 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import FeishuConfig
import importlib.util
@@ -732,8 +733,7 @@ class FeishuChannel(BaseChannel):
(file_path, content_text) - file_path is None if download failed
"""
loop = asyncio.get_running_loop()
- media_dir = Path.home() / ".nanobot" / "media"
- media_dir.mkdir(parents=True, exist_ok=True)
+ media_dir = get_media_dir("feishu")
data, filename = None, None
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 4967ac1..63cb0ca 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -38,7 +38,7 @@ except ImportError as e:
from nanobot.bus.events import OutboundMessage
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
TYPING_NOTICE_TIMEOUT_MS = 30_000
@@ -490,9 +490,7 @@ class MatrixChannel(BaseChannel):
return False
def _media_dir(self) -> Path:
- d = get_data_dir() / "media" / "matrix"
- d.mkdir(parents=True, exist_ok=True)
- return d
+ return get_media_dir("matrix")
@staticmethod
def _event_source_content(event: RoomMessage) -> dict[str, Any]:
diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py
index e762dfd..09e31c3 100644
--- a/nanobot/channels/mochat.py
+++ b/nanobot/channels/mochat.py
@@ -15,8 +15,8 @@ from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_runtime_subdir
from nanobot.config.schema import MochatConfig
-from nanobot.utils.helpers import get_data_path
try:
import socketio
@@ -224,7 +224,7 @@ class MochatChannel(BaseChannel):
self._socket: Any = None
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._session_cursor: dict[str, int] = {}
self._cursor_save_task: asyncio.Task | None = None
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 501a3c1..ecb1440 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -15,6 +15,7 @@ from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
from nanobot.config.schema import TelegramConfig
from nanobot.utils.helpers import split_message
@@ -536,10 +537,7 @@ class TelegramChannel(BaseChannel):
getattr(media_file, 'mime_type', None),
getattr(media_file, 'file_name', None),
)
- # Save to workspace/media/
- from pathlib import Path
- media_dir = Path.home() / ".nanobot" / "media"
- media_dir.mkdir(parents=True, exist_ok=True)
+ media_dir = get_media_dir("telegram")
file_path = media_dir / f"{media_file.file_id[:16]}{ext}"
await file.download_to_drive(str(file_path))
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 47c9a30..da8906d 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -30,6 +30,7 @@ from rich.table import Table
from rich.text import Text
from nanobot import __logo__, __version__
+from nanobot.config.paths import get_workspace_path
from nanobot.config.schema import Config
from nanobot.utils.helpers import sync_workspace_templates
@@ -99,7 +100,9 @@ def _init_prompt_session() -> None:
except Exception:
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)
_PROMPT_SESSION = PromptSession(
@@ -170,7 +173,6 @@ def onboard():
"""Initialize nanobot configuration and workspace."""
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()
@@ -271,8 +273,9 @@ def _make_provider(config: Config):
@app.command()
def gateway(
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"),
- 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."""
# 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.bus.queue import MessageBus
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.types import CronJob
from nanobot.heartbeat.service import HeartbeatService
@@ -301,13 +305,15 @@ def gateway(
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
config = load_config()
+ if workspace:
+ config.agents.defaults.workspace = workspace
sync_workspace_templates(config.workspace_path)
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_store_path = get_cron_dir() / "jobs.json"
cron = CronService(cron_store_path)
# Create agent with cron service
@@ -476,7 +482,8 @@ def agent(
from nanobot.agent.loop import AgentLoop
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
config = load_config()
@@ -486,7 +493,7 @@ def agent(
provider = _make_provider(config)
# 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)
if logs:
@@ -752,7 +759,9 @@ def _get_bridge_dir() -> Path:
import subprocess
# 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
if (user_bridge / "dist" / "index.js").exists():
@@ -810,6 +819,7 @@ def channels_login():
import subprocess
from nanobot.config.loader import load_config
+ from nanobot.config.paths import get_runtime_subdir
config = load_config()
bridge_dir = _get_bridge_dir()
@@ -820,6 +830,7 @@ def channels_login():
env = {**os.environ}
if config.channels.whatsapp.bridge_token:
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
+ env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
try:
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py
index 6c59668..e2c24f8 100644
--- a/nanobot/config/__init__.py
+++ b/nanobot/config/__init__.py
@@ -1,6 +1,30 @@
"""Configuration module for nanobot."""
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
-__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",
+]
diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py
index 4355bd3..7d309e5 100644
--- a/nanobot/config/loader.py
+++ b/nanobot/config/loader.py
@@ -23,14 +23,6 @@ def get_config_path() -> Path:
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:
"""
Load configuration from file or create default.
diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py
new file mode 100644
index 0000000..f4dfbd9
--- /dev/null
+++ b/nanobot/config/paths.py
@@ -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"
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index dce4b2e..f0a6484 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -9,6 +9,7 @@ from typing import Any
from loguru import logger
+from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.utils.helpers import ensure_dir, safe_filename
@@ -79,7 +80,7 @@ class SessionManager:
def __init__(self, workspace: Path):
self.workspace = workspace
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] = {}
def _get_session_path(self, key: str) -> Path:
diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py
index 9163e38..46f02ac 100644
--- a/nanobot/utils/__init__.py
+++ b/nanobot/utils/__init__.py
@@ -1,5 +1,5 @@
"""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"]
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
index 6e8ecd5..57c60dc 100644
--- a/nanobot/utils/helpers.py
+++ b/nanobot/utils/helpers.py
@@ -24,18 +24,6 @@ def ensure_dir(path: Path) -> 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:
"""Current ISO timestamp."""
return datetime.now().isoformat()
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 044d113..a276653 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -14,13 +14,17 @@ from nanobot.providers.registry import find_by_model
runner = CliRunner()
+class _StopGateway(RuntimeError):
+ pass
+
+
@pytest.fixture
def mock_paths():
"""Mock config/workspace paths for test isolation."""
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
patch("nanobot.config.loader.save_config") as mock_sc, \
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")
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():
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"
diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py
new file mode 100644
index 0000000..473a6c8
--- /dev/null
+++ b/tests/test_config_paths.py
@@ -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"
From 0a5daf3c86f2d5c78bdfd63409e8bf45058211b2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 03:03:25 +0000
Subject: [PATCH 059/124] docs: update readme for multiple instances and cli
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 5bd11f8..0bb6efe 100644
--- a/README.md
+++ b/README.md
@@ -903,7 +903,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
-## Multiple Instances
+## 🧩 Multiple Instances
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.
@@ -992,7 +992,7 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo
- `--workspace` overrides the workspace defined in the config file
- Cron jobs and runtime media/state are derived from the config directory
-## CLI Reference
+## 💻 CLI Reference
| Command | Description |
|---------|-------------|
From bf0ab93b06c395dec1b155ba46dd8e80352a19df Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 03:24:15 +0000
Subject: [PATCH 060/124] Merge branch 'main' into pr-1635
---
README.md | 13 ++++++++---
nanobot/cli/commands.py | 22 ++++++++---------
tests/test_commands.py | 52 ++++++++++++++++++++++++++++++++++++-----
3 files changed, 66 insertions(+), 21 deletions(-)
diff --git a/README.md b/README.md
index bc11cc8..13971e2 100644
--- a/README.md
+++ b/README.md
@@ -724,7 +724,10 @@ nanobot provider login openai-codex
nanobot agent -m "Hello!"
# Target a specific workspace/config locally
-nanobot agent -w ~/.nanobot/botA -c ~/.nanobot/botA/config.json -m "Hello!"
+nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
+
+# One-off workspace override on top of that config
+nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
```
> Docker users: use `docker run -it` for interactive OAuth login.
@@ -930,11 +933,15 @@ When using `--config`, nanobot derives its runtime data directory from the confi
To open a CLI session against one of these instances locally:
```bash
-nanobot agent -w ~/.nanobot/botA -m "Hello from botA"
-nanobot agent -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json
+nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
+nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
+
+# Optional one-off workspace override
+nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
```
> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
+
| Component | Resolved From | Example |
|-----------|---------------|---------|
| **Config** | `--config` path | `~/.nanobot-A/config.json` |
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index d03ef93..2c8d6d3 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -266,9 +266,17 @@ def _make_provider(config: Config):
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
"""Load config and optionally override the active workspace."""
- from nanobot.config.loader import load_config
+ from nanobot.config.loader import load_config, set_config_path
+
+ config_path = None
+ if config:
+ config_path = Path(config).expanduser().resolve()
+ if not config_path.exists():
+ console.print(f"[red]Error: Config file not found: {config_path}[/red]")
+ raise typer.Exit(1)
+ set_config_path(config_path)
+ console.print(f"[dim]Using config: {config_path}[/dim]")
- config_path = Path(config) if config else None
loaded = load_config(config_path)
if workspace:
loaded.agents.defaults.workspace = workspace
@@ -288,16 +296,6 @@ def gateway(
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Start the nanobot gateway."""
- # Set config path if provided (must be done before any imports that use get_data_dir)
- if config:
- from nanobot.config.loader import set_config_path
- config_path = Path(config).expanduser().resolve()
- if not config_path.exists():
- console.print(f"[red]Error: Config file not found: {config_path}[/red]")
- raise typer.Exit(1)
- set_config_path(config_path)
- console.print(f"[dim]Using config: {config_path}[/dim]")
-
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
diff --git a/tests/test_commands.py b/tests/test_commands.py
index e3709da..19c1998 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -191,13 +191,52 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
mock_agent_runtime["print_response"].assert_called_once_with("mock-response", render_markdown=True)
-def test_agent_uses_explicit_config_path(mock_agent_runtime):
- config_path = Path("/tmp/agent-config.json")
+def test_agent_uses_explicit_config_path(mock_agent_runtime, tmp_path: Path):
+ config_path = tmp_path / "agent-config.json"
+ config_path.write_text("{}")
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
assert result.exit_code == 0
- assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
+
+
+def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
+ config_file = tmp_path / "instance" / "config.json"
+ config_file.parent.mkdir(parents=True)
+ config_file.write_text("{}")
+
+ config = Config()
+ 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 _path=None: 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.cron.service.CronService", lambda _store: object())
+
+ class _FakeAgentLoop:
+ def __init__(self, *args, **kwargs) -> None:
+ pass
+
+ async def process_direct(self, *_args, **_kwargs) -> str:
+ return "ok"
+
+ async def close_mcp(self) -> None:
+ return None
+
+ monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop)
+ monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
+
+ result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
+
+ assert result.exit_code == 0
+ assert seen["config_path"] == config_file.resolve()
def test_agent_overrides_workspace_path(mock_agent_runtime):
@@ -211,8 +250,9 @@ def test_agent_overrides_workspace_path(mock_agent_runtime):
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
-def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime):
- config_path = Path("/tmp/agent-config.json")
+def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path):
+ config_path = tmp_path / "agent-config.json"
+ config_path.write_text("{}")
workspace_path = Path("/tmp/agent-workspace")
result = runner.invoke(
@@ -221,7 +261,7 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime)
)
assert result.exit_code == 0
- assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+ assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
From dbc518098e913d2f382121820dd58bbaf7a04234 Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sun, 8 Mar 2026 14:20:16 +0800
Subject: [PATCH 061/124] refactor: implement token-based context compression
mechanism
Major changes:
- Replace message-count-based memory window with token-budget-based compression
- Add max_tokens_input, compression_start_ratio, compression_target_ratio config
- Implement _maybe_compress_history() that triggers based on prompt token usage
- Use _build_compressed_history_view() to provide compressed history to LLM
- Refactor MemoryStore.consolidate() -> consolidate_chunk() for chunk-based compression
- Remove last_consolidated from Session, use _compressed_until metadata instead
- Add background compression scheduling to avoid blocking message processing
Key improvements:
- Compression now based on actual token usage, not arbitrary message counts
- Better handling of long conversations with large context windows
- Non-destructive compression: old messages remain in session, but excluded from prompt
- Automatic compression when history exceeds configured token thresholds
---
nanobot/agent/loop.py | 521 +++++++++++++++++++++++++++++++++----
nanobot/agent/memory.py | 62 ++---
nanobot/config/schema.py | 25 +-
nanobot/session/manager.py | 20 +-
4 files changed, 529 insertions(+), 99 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index ca9a06e..696e2a7 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -5,19 +5,24 @@ from __future__ import annotations
import asyncio
import json
import re
-import weakref
from contextlib import AsyncExitStack
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
+try:
+ import tiktoken # type: ignore
+except Exception: # pragma: no cover - optional dependency
+ tiktoken = None
+
from nanobot.agent.context import ContextBuilder
-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.huggingface import HuggingFaceModelSearchTool
from nanobot.agent.tools.message import MessageTool
+from nanobot.agent.tools.model_config import ValidateDeployJSONTool, ValidateUsageYAMLTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
@@ -55,8 +60,11 @@ class AgentLoop:
max_iterations: int = 40,
temperature: float = 0.1,
max_tokens: int = 4096,
- memory_window: int = 100,
+ memory_window: int | None = None, # backward-compat only (unused)
reasoning_effort: str | None = None,
+ max_tokens_input: int = 128_000,
+ compression_start_ratio: float = 0.7,
+ compression_target_ratio: float = 0.4,
brave_api_key: str | None = None,
web_proxy: str | None = None,
exec_config: ExecToolConfig | None = None,
@@ -74,9 +82,18 @@ class AgentLoop:
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
self.temperature = temperature
+ # max_tokens: per-call output token cap (maxTokensOutput in config)
self.max_tokens = max_tokens
+ # Keep legacy attribute for older call sites/tests; compression no longer uses it.
self.memory_window = memory_window
self.reasoning_effort = reasoning_effort
+ # max_tokens_input: model native context window (maxTokensInput in config)
+ self.max_tokens_input = max_tokens_input
+ # Token-based compression watermarks (fractions of available input budget)
+ self.compression_start_ratio = compression_start_ratio
+ self.compression_target_ratio = compression_target_ratio
+ # Reserve tokens for safety margin
+ self._reserve_tokens = 1000
self.brave_api_key = brave_api_key
self.web_proxy = web_proxy
self.exec_config = exec_config or ExecToolConfig()
@@ -105,18 +122,373 @@ class AgentLoop:
self._mcp_stack: AsyncExitStack | None = None
self._mcp_connected = False
self._mcp_connecting = False
- self._consolidating: set[str] = set() # Session keys with consolidation in progress
- self._consolidation_tasks: set[asyncio.Task] = set() # Strong refs to in-flight tasks
- self._consolidation_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
+ self._compression_tasks: dict[str, asyncio.Task] = {} # session_key -> task
self._processing_lock = asyncio.Lock()
self._register_default_tools()
+ @staticmethod
+ def _estimate_prompt_tokens(
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ ) -> int:
+ """Estimate prompt tokens with tiktoken (fallback only)."""
+ if tiktoken is None:
+ return 0
+
+ try:
+ enc = tiktoken.get_encoding("cl100k_base")
+ parts: list[str] = []
+ for msg in messages:
+ content = msg.get("content")
+ if isinstance(content, str):
+ parts.append(content)
+ elif isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict) and part.get("type") == "text":
+ txt = part.get("text", "")
+ if txt:
+ parts.append(txt)
+ if tools:
+ parts.append(json.dumps(tools, ensure_ascii=False))
+ return len(enc.encode("\n".join(parts)))
+ except Exception:
+ return 0
+
+ def _estimate_prompt_tokens_chain(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ ) -> tuple[int, str]:
+ """Unified prompt-token estimation: provider counter -> tiktoken."""
+ provider_counter = getattr(self.provider, "estimate_prompt_tokens", None)
+ if callable(provider_counter):
+ try:
+ tokens, source = provider_counter(messages, tools, self.model)
+ if isinstance(tokens, (int, float)) and tokens > 0:
+ return int(tokens), str(source or "provider_counter")
+ except Exception:
+ logger.debug("Provider token counter failed; fallback to tiktoken")
+
+ estimated = self._estimate_prompt_tokens(messages, tools)
+ if estimated > 0:
+ return int(estimated), "tiktoken"
+ return 0, "none"
+
+ @staticmethod
+ def _estimate_completion_tokens(content: str) -> int:
+ """Estimate completion tokens with tiktoken (fallback only)."""
+ if tiktoken is None:
+ return 0
+ try:
+ enc = tiktoken.get_encoding("cl100k_base")
+ return len(enc.encode(content or ""))
+ except Exception:
+ return 0
+
+ def _get_compressed_until(self, session: Session) -> int:
+ """Read/normalize compressed boundary and migrate old metadata format."""
+ raw = session.metadata.get("_compressed_until", 0)
+ try:
+ compressed_until = int(raw)
+ except (TypeError, ValueError):
+ compressed_until = 0
+
+ if compressed_until <= 0:
+ ranges = session.metadata.get("_compressed_ranges")
+ if isinstance(ranges, list):
+ inferred = 0
+ for item in ranges:
+ if not isinstance(item, (list, tuple)) or len(item) != 2:
+ continue
+ try:
+ inferred = max(inferred, int(item[1]))
+ except (TypeError, ValueError):
+ continue
+ compressed_until = inferred
+
+ compressed_until = max(0, min(compressed_until, len(session.messages)))
+ session.metadata["_compressed_until"] = compressed_until
+ # 兼容旧版本:一旦迁移出连续边界,就可以清理旧字段
+ session.metadata.pop("_compressed_ranges", None)
+ session.metadata.pop("_cumulative_tokens", None)
+ return compressed_until
+
+ def _set_compressed_until(self, session: Session, idx: int) -> None:
+ """Persist a contiguous compressed boundary."""
+ session.metadata["_compressed_until"] = max(0, min(int(idx), len(session.messages)))
+ session.metadata.pop("_compressed_ranges", None)
+ session.metadata.pop("_cumulative_tokens", None)
+
+ @staticmethod
+ def _estimate_message_tokens(message: dict[str, Any]) -> int:
+ """Rough token estimate for a single persisted message."""
+ content = message.get("content")
+ parts: list[str] = []
+ if isinstance(content, str):
+ parts.append(content)
+ elif isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict) and part.get("type") == "text":
+ txt = part.get("text", "")
+ if txt:
+ parts.append(txt)
+ else:
+ parts.append(json.dumps(part, ensure_ascii=False))
+ elif content is not None:
+ parts.append(json.dumps(content, ensure_ascii=False))
+
+ for key in ("name", "tool_call_id"):
+ val = message.get(key)
+ if isinstance(val, str) and val:
+ parts.append(val)
+ if message.get("tool_calls"):
+ parts.append(json.dumps(message["tool_calls"], ensure_ascii=False))
+
+ payload = "\n".join(parts)
+ if not payload:
+ return 1
+ if tiktoken is not None:
+ try:
+ enc = tiktoken.get_encoding("cl100k_base")
+ return max(1, len(enc.encode(payload)))
+ except Exception:
+ pass
+ return max(1, len(payload) // 4)
+
+ def _pick_compression_chunk_by_tokens(
+ self,
+ session: Session,
+ reduction_tokens: int,
+ *,
+ tail_keep: int = 12,
+ ) -> tuple[int, int, int] | None:
+ """
+ Pick one contiguous old chunk so its estimated size is roughly enough
+ to reduce `reduction_tokens`.
+ """
+ messages = session.messages
+ start = self._get_compressed_until(session)
+ if len(messages) - start <= tail_keep + 2:
+ return None
+
+ end_limit = len(messages) - tail_keep
+ if end_limit - start < 2:
+ return None
+
+ target = max(1, reduction_tokens)
+ end = start
+ collected = 0
+ while end < end_limit and collected < target:
+ collected += self._estimate_message_tokens(messages[end])
+ end += 1
+
+ if end - start < 2:
+ end = min(end_limit, start + 2)
+ collected = sum(self._estimate_message_tokens(m) for m in messages[start:end])
+ if end - start < 2:
+ return None
+ return start, end, collected
+
+ def _estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
+ """
+ Estimate current full prompt tokens for this session view
+ (system + compressed history view + runtime/user placeholder + tools).
+ """
+ history = self._build_compressed_history_view(session)
+ channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
+ probe_messages = self.context.build_messages(
+ history=history,
+ current_message="[token-probe]",
+ channel=channel,
+ chat_id=chat_id,
+ )
+ return self._estimate_prompt_tokens_chain(probe_messages, self.tools.get_definitions())
+
+ async def _maybe_compress_history(
+ self,
+ session: Session,
+ ) -> None:
+ """
+ End-of-turn policy:
+ - Estimate current prompt usage from persisted session view.
+ - If above start ratio, perform one best-effort compression chunk.
+ """
+ if not session.messages:
+ self._set_compressed_until(session, 0)
+ return
+
+ budget = max(1, self.max_tokens_input - self.max_tokens - self._reserve_tokens)
+ start_threshold = int(budget * self.compression_start_ratio)
+ target_threshold = int(budget * self.compression_target_ratio)
+ if target_threshold >= start_threshold:
+ target_threshold = max(0, start_threshold - 1)
+
+ current_tokens, token_source = self._estimate_session_prompt_tokens(session)
+ current_ratio = current_tokens / budget if budget else 0.0
+ if current_tokens <= 0:
+ logger.debug("Compression skip {}: token estimate unavailable", session.key)
+ return
+ if current_tokens < start_threshold:
+ logger.debug(
+ "Compression idle {}: {}/{} ({:.1%}) via {}",
+ session.key,
+ current_tokens,
+ budget,
+ current_ratio,
+ token_source,
+ )
+ return
+ logger.info(
+ "Compression trigger {}: {}/{} ({:.1%}) via {}",
+ session.key,
+ current_tokens,
+ budget,
+ current_ratio,
+ token_source,
+ )
+
+ reduction_by_target = max(0, current_tokens - target_threshold)
+ reduction_by_delta = max(1, start_threshold - target_threshold)
+ reduction_need = max(reduction_by_target, reduction_by_delta)
+
+ chunk_range = self._pick_compression_chunk_by_tokens(session, reduction_need, tail_keep=10)
+ if chunk_range is None:
+ logger.info("Compression skipped for {}: no compressible chunk", session.key)
+ return
+
+ start_idx, end_idx, estimated_chunk_tokens = chunk_range
+ chunk = session.messages[start_idx:end_idx]
+ if len(chunk) < 2:
+ return
+
+ logger.info(
+ "Compression chunk {}: msgs {}-{} (count={}, est~{}, need~{})",
+ session.key,
+ start_idx,
+ end_idx - 1,
+ len(chunk),
+ estimated_chunk_tokens,
+ reduction_need,
+ )
+ success, _ = await self.context.memory.consolidate_chunk(
+ chunk,
+ self.provider,
+ self.model,
+ )
+ if not success:
+ logger.warning("Compression aborted for {}: consolidation failed", session.key)
+ return
+
+ self._set_compressed_until(session, end_idx)
+ self.sessions.save(session)
+
+ after_tokens, after_source = self._estimate_session_prompt_tokens(session)
+ after_ratio = after_tokens / budget if budget else 0.0
+ reduced = max(0, current_tokens - after_tokens)
+ reduced_ratio = (reduced / current_tokens) if current_tokens > 0 else 0.0
+ logger.info(
+ "Compression done {}: {}/{} ({:.1%}) via {}, reduced={} ({:.1%})",
+ session.key,
+ after_tokens,
+ budget,
+ after_ratio,
+ after_source,
+ reduced,
+ reduced_ratio,
+ )
+
+ def _schedule_background_compression(self, session_key: str) -> None:
+ """Schedule best-effort background compression for a session."""
+ existing = self._compression_tasks.get(session_key)
+ if existing is not None and not existing.done():
+ return
+
+ async def _runner() -> None:
+ session = self.sessions.get_or_create(session_key)
+ try:
+ await self._maybe_compress_history(session)
+ except Exception:
+ logger.exception("Background compression failed for {}", session_key)
+
+ task = asyncio.create_task(_runner())
+ self._compression_tasks[session_key] = task
+
+ def _cleanup(t: asyncio.Task) -> None:
+ cur = self._compression_tasks.get(session_key)
+ if cur is t:
+ self._compression_tasks.pop(session_key, None)
+ try:
+ t.result()
+ except BaseException:
+ pass
+
+ task.add_done_callback(_cleanup)
+
+ async def wait_for_background_compression(self, timeout_s: float | None = None) -> None:
+ """Wait for currently scheduled compression tasks."""
+ pending = [t for t in self._compression_tasks.values() if not t.done()]
+ if not pending:
+ return
+
+ logger.info("Waiting for {} background compression task(s)", len(pending))
+ waiter = asyncio.gather(*pending, return_exceptions=True)
+ if timeout_s is None:
+ await waiter
+ return
+
+ try:
+ await asyncio.wait_for(waiter, timeout=timeout_s)
+ except asyncio.TimeoutError:
+ logger.warning(
+ "Background compression wait timed out after {}s ({} task(s) still running)",
+ timeout_s,
+ len([t for t in self._compression_tasks.values() if not t.done()]),
+ )
+
+ def _build_compressed_history_view(
+ self,
+ session: Session,
+ ) -> list[dict]:
+ """Build non-destructive history view using the compressed boundary."""
+ compressed_until = self._get_compressed_until(session)
+ if compressed_until <= 0:
+ return session.get_history(max_messages=0)
+
+ notice_msg: dict[str, Any] = {
+ "role": "assistant",
+ "content": (
+ "As your assistant, I have compressed earlier context. "
+ "If you need details, please check memory/HISTORY.md."
+ ),
+ }
+
+ tail: list[dict[str, Any]] = []
+ for msg in session.messages[compressed_until:]:
+ entry: dict[str, Any] = {"role": msg["role"], "content": msg.get("content", "")}
+ for k in ("tool_calls", "tool_call_id", "name"):
+ if k in msg:
+ entry[k] = msg[k]
+ tail.append(entry)
+
+ # Drop leading non-user entries from tail to avoid orphan tool blocks.
+ for i, m in enumerate(tail):
+ if m.get("role") == "user":
+ tail = tail[i:]
+ break
+ else:
+ tail = []
+
+ return [notice_msg, *tail]
+
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None
for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool):
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
+ self.tools.register(ValidateDeployJSONTool())
+ self.tools.register(ValidateUsageYAMLTool())
+ self.tools.register(HuggingFaceModelSearchTool())
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
@@ -181,25 +553,78 @@ class AgentLoop:
self,
initial_messages: list[dict],
on_progress: Callable[..., Awaitable[None]] | None = None,
- ) -> tuple[str | None, list[str], list[dict]]:
- """Run the agent iteration loop. Returns (final_content, tools_used, messages)."""
+ ) -> tuple[str | None, list[str], list[dict], int, str]:
+ """
+ Run the agent iteration loop.
+
+ Returns:
+ (final_content, tools_used, messages, total_tokens_this_turn, token_source)
+ total_tokens_this_turn: total tokens (prompt + completion) for this turn
+ token_source: provider_total / provider_sum / provider_prompt /
+ provider_counter+tiktoken_completion / tiktoken / none
+ """
messages = initial_messages
iteration = 0
final_content = None
tools_used: list[str] = []
+ total_tokens_this_turn = 0
+ token_source = "none"
while iteration < self.max_iterations:
iteration += 1
+ tool_defs = self.tools.get_definitions()
+
response = await self.provider.chat(
messages=messages,
- tools=self.tools.get_definitions(),
+ tools=tool_defs,
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
reasoning_effort=self.reasoning_effort,
)
+ # Prefer provider usage from the turn-ending model call; fallback to tiktoken.
+ # Calculate total tokens (prompt + completion) for this turn.
+ usage = response.usage or {}
+ t_tokens = usage.get("total_tokens")
+ p_tokens = usage.get("prompt_tokens")
+ c_tokens = usage.get("completion_tokens")
+
+ if isinstance(t_tokens, (int, float)) and t_tokens > 0:
+ total_tokens_this_turn = int(t_tokens)
+ token_source = "provider_total"
+ elif isinstance(p_tokens, (int, float)) and isinstance(c_tokens, (int, float)):
+ # If we have both prompt and completion tokens, sum them
+ total_tokens_this_turn = int(p_tokens) + int(c_tokens)
+ token_source = "provider_sum"
+ elif isinstance(p_tokens, (int, float)) and p_tokens > 0:
+ # Fallback: use prompt tokens only (completion might be 0 for tool calls)
+ total_tokens_this_turn = int(p_tokens)
+ token_source = "provider_prompt"
+ else:
+ # Estimate with unified chain (provider counter -> tiktoken), plus completion tiktoken.
+ estimated_prompt, prompt_source = self._estimate_prompt_tokens_chain(messages, tool_defs)
+ estimated_completion = self._estimate_completion_tokens(response.content or "")
+ total_tokens_this_turn = estimated_prompt + estimated_completion
+ if total_tokens_this_turn > 0:
+ token_source = (
+ "tiktoken"
+ if prompt_source == "tiktoken"
+ else f"{prompt_source}+tiktoken_completion"
+ )
+ if total_tokens_this_turn <= 0:
+ total_tokens_this_turn = 0
+ token_source = "none"
+
+ logger.debug(
+ "Turn token usage: source={}, total={}, prompt={}, completion={}",
+ token_source,
+ total_tokens_this_turn,
+ p_tokens if isinstance(p_tokens, (int, float)) else None,
+ c_tokens if isinstance(c_tokens, (int, float)) else None,
+ )
+
if response.has_tool_calls:
if on_progress:
thought = self._strip_think(response.content)
@@ -254,7 +679,7 @@ class AgentLoop:
"without completing the task. You can try breaking the task into smaller steps."
)
- return final_content, tools_used, messages
+ return final_content, tools_used, messages, total_tokens_this_turn, token_source
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
@@ -279,6 +704,9 @@ class AgentLoop:
"""Cancel all active tasks and subagents for the session."""
tasks = self._active_tasks.pop(msg.session_key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
+ comp = self._compression_tasks.get(msg.session_key)
+ if comp is not None and not comp.done() and comp.cancel():
+ cancelled += 1
for t in tasks:
try:
await t
@@ -325,6 +753,9 @@ class AgentLoop:
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
+ for task in list(self._compression_tasks.values()):
+ if not task.done():
+ task.cancel()
logger.info("Agent loop stopping")
async def _process_message(
@@ -342,14 +773,15 @@ class AgentLoop:
key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
- history = session.get_history(max_messages=self.memory_window)
+ history = self._build_compressed_history_view(session)
messages = self.context.build_messages(
history=history,
current_message=msg.content, channel=channel, chat_id=chat_id,
)
- final_content, _, all_msgs = await self._run_agent_loop(messages)
+ final_content, _, all_msgs, _, _ = await self._run_agent_loop(messages)
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
+ self._schedule_background_compression(session.key)
return OutboundMessage(channel=channel, chat_id=chat_id,
content=final_content or "Background task completed.")
@@ -362,27 +794,27 @@ class AgentLoop:
# Slash commands
cmd = msg.content.strip().lower()
if cmd == "/new":
- lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock())
- self._consolidating.add(session.key)
try:
- async with lock:
- snapshot = session.messages[session.last_consolidated:]
- if snapshot:
- temp = Session(key=session.key)
- temp.messages = list(snapshot)
- if not await self._consolidate_memory(temp, archive_all=True):
- return OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id,
- content="Memory archival failed, session not cleared. Please try again.",
- )
+ # 在清空会话前,将当前完整对话做一次归档压缩到 MEMORY/HISTORY 中
+ if session.messages:
+ ok, _ = await self.context.memory.consolidate_chunk(
+ session.messages,
+ self.provider,
+ self.model,
+ )
+ if not ok:
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="Memory archival failed, session not cleared. Please try again.",
+ )
except Exception:
logger.exception("/new archival failed for {}", session.key)
return OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id,
+ channel=msg.channel,
+ chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
)
- finally:
- self._consolidating.discard(session.key)
session.clear()
self.sessions.save(session)
@@ -393,36 +825,23 @@ class AgentLoop:
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands")
- unconsolidated = len(session.messages) - session.last_consolidated
- if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
- self._consolidating.add(session.key)
- lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock())
-
- async def _consolidate_and_unlock():
- try:
- async with lock:
- await self._consolidate_memory(session)
- finally:
- self._consolidating.discard(session.key)
- _task = asyncio.current_task()
- if _task is not None:
- self._consolidation_tasks.discard(_task)
-
- _task = asyncio.create_task(_consolidate_and_unlock())
- self._consolidation_tasks.add(_task)
-
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
message_tool.start_turn()
- history = session.get_history(max_messages=self.memory_window)
+ # 正常对话:使用压缩后的历史视图(压缩在回合结束后进行)
+ history = self._build_compressed_history_view(session)
initial_messages = self.context.build_messages(
history=history,
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel, chat_id=msg.chat_id,
)
+ # Add [CRON JOB] identifier for cron sessions (session_key starts with "cron:")
+ if session_key and session_key.startswith("cron:"):
+ if initial_messages and initial_messages[0].get("role") == "system":
+ initial_messages[0]["content"] = f"[CRON JOB] {initial_messages[0]['content']}"
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
meta = dict(msg.metadata or {})
@@ -432,7 +851,7 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
))
- final_content, _, all_msgs = await self._run_agent_loop(
+ final_content, _, all_msgs, _, _ = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress,
)
@@ -441,6 +860,7 @@ class AgentLoop:
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
+ self._schedule_background_compression(session.key)
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
return None
@@ -487,13 +907,6 @@ class AgentLoop:
session.messages.append(entry)
session.updated_at = datetime.now()
- async def _consolidate_memory(self, session, archive_all: bool = False) -> bool:
- """Delegate to MemoryStore.consolidate(). Returns True on success."""
- return await MemoryStore(self.workspace).consolidate(
- session, self.provider, self.model,
- archive_all=archive_all, memory_window=self.memory_window,
- )
-
async def process_direct(
self,
content: str,
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index 21fe77d..c8896c8 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -66,36 +66,25 @@ class MemoryStore:
long_term = self.read_long_term()
return f"## Long-term Memory\n{long_term}" if long_term else ""
- async def consolidate(
+ async def consolidate_chunk(
self,
- session: Session,
+ messages: list[dict],
provider: LLMProvider,
model: str,
- *,
- archive_all: bool = False,
- memory_window: int = 50,
- ) -> bool:
- """Consolidate old messages into MEMORY.md + HISTORY.md via LLM tool call.
+ ) -> tuple[bool, str | None]:
+ """Consolidate a chunk of messages into MEMORY.md + HISTORY.md via LLM tool call.
- Returns True on success (including no-op), False on failure.
+ Returns (success, None).
+
+ - success: True on success (including no-op), False on failure.
+ - The second return value is reserved for future use (e.g. RAG-style summaries) and is
+ always None in the current implementation.
"""
- if archive_all:
- old_messages = session.messages
- keep_count = 0
- logger.info("Memory consolidation (archive_all): {} messages", len(session.messages))
- else:
- keep_count = memory_window // 2
- if len(session.messages) <= keep_count:
- return True
- if len(session.messages) - session.last_consolidated <= 0:
- return True
- old_messages = session.messages[session.last_consolidated:-keep_count]
- if not old_messages:
- return True
- logger.info("Memory consolidation: {} to consolidate, {} keep", len(old_messages), keep_count)
+ if not messages:
+ return True, None
lines = []
- for m in old_messages:
+ for m in messages:
if not m.get("content"):
continue
tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
@@ -113,7 +102,19 @@ class MemoryStore:
try:
response = await provider.chat(
messages=[
- {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
+ {
+ "role": "system",
+ "content": (
+ "You are a memory consolidation agent.\n"
+ "Your job is to:\n"
+ "1) Append a concise but grep-friendly entry to HISTORY.md summarizing key events, decisions and topics.\n"
+ " - Write 1 paragraph of 2–5 sentences that starts with [YYYY-MM-DD HH:MM].\n"
+ " - Include concrete names, IDs and numbers so it is easy to search with grep.\n"
+ "2) Update long-term MEMORY.md with stable facts and user preferences as markdown, including all existing facts plus new ones.\n"
+ "3) Optionally return a short context_summary (1–3 sentences) that will replace the raw messages in future dialogue history.\n\n"
+ "Always call the save_memory tool with history_entry, memory_update and (optionally) context_summary."
+ ),
+ },
{"role": "user", "content": prompt},
],
tools=_SAVE_MEMORY_TOOL,
@@ -122,7 +123,7 @@ class MemoryStore:
if not response.has_tool_calls:
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
- return False
+ return False, None
args = response.tool_calls[0].arguments
# Some providers return arguments as a JSON string instead of dict
@@ -134,10 +135,10 @@ class MemoryStore:
args = args[0]
else:
logger.warning("Memory consolidation: unexpected arguments as empty or non-dict list")
- return False
+ return False, None
if not isinstance(args, dict):
logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
- return False
+ return False, None
if entry := args.get("history_entry"):
if not isinstance(entry, str):
@@ -149,9 +150,8 @@ class MemoryStore:
if update != current_memory:
self.write_long_term(update)
- session.last_consolidated = 0 if archive_all else len(session.messages) - keep_count
- logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated)
- return True
+ logger.info("Memory consolidation done for {} messages", len(messages))
+ return True, None
except Exception:
logger.exception("Memory consolidation failed")
- return False
+ return False, None
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 803cb61..1ebde20 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -189,11 +189,22 @@ class SlackConfig(Base):
class QQConfig(Base):
- """QQ channel configuration using botpy SDK."""
+ """QQ channel configuration.
+
+ Supports two implementations:
+ 1. Official botpy SDK: requires app_id and secret
+ 2. OneBot protocol: requires api_url (and optionally ws_reverse_url, bot_qq, access_token)
+ """
enabled: bool = False
+ # Official botpy SDK fields
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
+ # OneBot protocol fields
+ api_url: str = "" # OneBot HTTP API URL (e.g. "http://localhost:5700")
+ ws_reverse_url: str = "" # OneBot WebSocket reverse URL (e.g. "ws://localhost:8080/ws/reverse")
+ bot_qq: int | None = None # Bot's QQ number (for filtering self messages)
+ access_token: str = "" # Optional access token for OneBot API
allow_from: list[str] = Field(
default_factory=list
) # Allowed user openids (empty = public access)
@@ -226,10 +237,18 @@ class AgentDefaults(Base):
provider: str = (
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
)
- max_tokens: int = 8192
+ # 原生上下文最大窗口(通常对应模型的 max_input_tokens / max_context_tokens)
+ # 默认按照主流大模型(如 GPT-4o、Claude 3.x 等)的 128k 上下文给一个宽松上限,实际应根据所选模型文档手动调整。
+ max_tokens_input: int = 128_000
+ # 默认单次回复的最大输出 token 上限(调用时可按需要再做截断或比例分配)
+ # 8192 足以覆盖大多数实际对话/工具使用场景,同样可按需手动调整。
+ max_tokens_output: int = 8192
+ # 会话历史压缩触发比例:当估算的输入 token 使用量 >= maxTokensInput * compressionStartRatio 时开始压缩。
+ compression_start_ratio: float = 0.7
+ # 会话历史压缩目标比例:每轮压缩后尽量把估算的输入 token 使用量压到 maxTokensInput * compressionTargetRatio 附近。
+ compression_target_ratio: float = 0.4
temperature: float = 0.1
max_tool_iterations: int = 40
- memory_window: int = 100
reasoning_effort: str | None = None # low / medium / high — enables LLM thinking mode
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index f0a6484..1cb8a51 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -9,7 +9,6 @@ from typing import Any
from loguru import logger
-from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.utils.helpers import ensure_dir, safe_filename
@@ -30,7 +29,6 @@ class Session:
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
- last_consolidated: int = 0 # Number of messages already consolidated to files
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session."""
@@ -44,9 +42,13 @@ class Session:
self.updated_at = datetime.now()
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
- """Return unconsolidated messages for LLM input, aligned to a user turn."""
- unconsolidated = self.messages[self.last_consolidated:]
- sliced = unconsolidated[-max_messages:]
+ """
+ Return messages for LLM input, aligned to a user turn.
+
+ - max_messages > 0 时只保留最近 max_messages 条;
+ - max_messages <= 0 时不做条数截断,返回全部消息。
+ """
+ sliced = self.messages if max_messages <= 0 else self.messages[-max_messages:]
# Drop leading non-user messages to avoid orphaned tool_result blocks
for i, m in enumerate(sliced):
@@ -66,7 +68,7 @@ class Session:
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
self.messages = []
- self.last_consolidated = 0
+ self.metadata = {}
self.updated_at = datetime.now()
@@ -80,7 +82,7 @@ class SessionManager:
def __init__(self, workspace: Path):
self.workspace = workspace
self.sessions_dir = ensure_dir(self.workspace / "sessions")
- self.legacy_sessions_dir = get_legacy_sessions_dir()
+ self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions"
self._cache: dict[str, Session] = {}
def _get_session_path(self, key: str) -> Path:
@@ -132,7 +134,6 @@ class SessionManager:
messages = []
metadata = {}
created_at = None
- last_consolidated = 0
with open(path, encoding="utf-8") as f:
for line in f:
@@ -145,7 +146,6 @@ class SessionManager:
if data.get("_type") == "metadata":
metadata = data.get("metadata", {})
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
- last_consolidated = data.get("last_consolidated", 0)
else:
messages.append(data)
@@ -154,7 +154,6 @@ class SessionManager:
messages=messages,
created_at=created_at or datetime.now(),
metadata=metadata,
- last_consolidated=last_consolidated
)
except Exception as e:
logger.warning("Failed to load session {}: {}", key, e)
@@ -171,7 +170,6 @@ class SessionManager:
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
"metadata": session.metadata,
- "last_consolidated": session.last_consolidated
}
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
for msg in session.messages:
From 2dcb4de422ddec8c0f114dc6b0fdce06b9388b8f Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sun, 8 Mar 2026 15:04:38 +0800
Subject: [PATCH 062/124] fix(commands): update AgentLoop calls to use
token-based compression parameters
---
nanobot/cli/commands.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2c8d6d3..cf29cc5 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -330,8 +330,10 @@ def gateway(
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
- memory_window=config.agents.defaults.memory_window,
reasoning_effort=config.agents.defaults.reasoning_effort,
+ max_tokens_input=config.agents.defaults.max_tokens_input,
+ compression_start_ratio=config.agents.defaults.compression_start_ratio,
+ compression_target_ratio=config.agents.defaults.compression_target_ratio,
brave_api_key=config.tools.web.search.api_key or None,
web_proxy=config.tools.web.proxy or None,
exec_config=config.tools.exec,
@@ -515,8 +517,10 @@ def agent(
temperature=config.agents.defaults.temperature,
max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
- memory_window=config.agents.defaults.memory_window,
reasoning_effort=config.agents.defaults.reasoning_effort,
+ max_tokens_input=config.agents.defaults.max_tokens_input,
+ compression_start_ratio=config.agents.defaults.compression_start_ratio,
+ compression_target_ratio=config.agents.defaults.compression_target_ratio,
brave_api_key=config.tools.web.search.api_key or None,
web_proxy=config.tools.web.proxy or None,
exec_config=config.tools.exec,
From 2706d3c317be7325795e9dac74d07512e57112f4 Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sun, 8 Mar 2026 15:20:34 +0800
Subject: [PATCH 063/124] fix(commands): use max_tokens_output instead of
max_tokens from AgentDefaults
---
nanobot/cli/commands.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index cf29cc5..18c9d56 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -328,7 +328,7 @@ def gateway(
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
- max_tokens=config.agents.defaults.max_tokens,
+ max_tokens=config.agents.defaults.max_tokens_output,
max_iterations=config.agents.defaults.max_tool_iterations,
reasoning_effort=config.agents.defaults.reasoning_effort,
max_tokens_input=config.agents.defaults.max_tokens_input,
@@ -515,7 +515,7 @@ def agent(
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
- max_tokens=config.agents.defaults.max_tokens,
+ max_tokens=config.agents.defaults.max_tokens_output,
max_iterations=config.agents.defaults.max_tool_iterations,
reasoning_effort=config.agents.defaults.reasoning_effort,
max_tokens_input=config.agents.defaults.max_tokens_input,
From a984e0df3752f6a8883a0e9b6d8efee4abd7f9dd Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sun, 8 Mar 2026 15:23:55 +0800
Subject: [PATCH 064/124] feat(loop): add history message count logging in
compression
---
nanobot/agent/loop.py | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 696e2a7..5d316ea 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -362,6 +362,7 @@ class AgentLoop:
if len(chunk) < 2:
return
+ before_msg_count = len(session.messages)
logger.info(
"Compression chunk {}: msgs {}-{} (count={}, est~{}, need~{})",
session.key,
@@ -383,12 +384,13 @@ class AgentLoop:
self._set_compressed_until(session, end_idx)
self.sessions.save(session)
+ after_msg_count = len(session.messages)
after_tokens, after_source = self._estimate_session_prompt_tokens(session)
after_ratio = after_tokens / budget if budget else 0.0
reduced = max(0, current_tokens - after_tokens)
reduced_ratio = (reduced / current_tokens) if current_tokens > 0 else 0.0
logger.info(
- "Compression done {}: {}/{} ({:.1%}) via {}, reduced={} ({:.1%})",
+ "Compression done {}: {}/{} ({:.1%}) via {}, reduced={} ({:.1%}), history: {} -> {}",
session.key,
after_tokens,
budget,
@@ -396,6 +398,8 @@ class AgentLoop:
after_source,
reduced,
reduced_ratio,
+ before_msg_count,
+ after_msg_count,
)
def _schedule_background_compression(self, session_key: str) -> None:
From 1b16d48390b3fded3438f4fdbc3f0ae0a0379878 Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sun, 8 Mar 2026 15:26:49 +0800
Subject: [PATCH 065/124] fix(loop): update _cumulative_tokens in _save_turn
and preserve it in compression methods
---
nanobot/agent/loop.py | 24 ++++++++++++++----------
1 file changed, 14 insertions(+), 10 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 5d316ea..5e01b79 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -211,14 +211,14 @@ class AgentLoop:
session.metadata["_compressed_until"] = compressed_until
# 兼容旧版本:一旦迁移出连续边界,就可以清理旧字段
session.metadata.pop("_compressed_ranges", None)
- session.metadata.pop("_cumulative_tokens", None)
+ # 注意:不要删除 _cumulative_tokens,压缩逻辑需要它来跟踪累积 token 计数
return compressed_until
def _set_compressed_until(self, session: Session, idx: int) -> None:
"""Persist a contiguous compressed boundary."""
session.metadata["_compressed_until"] = max(0, min(int(idx), len(session.messages)))
session.metadata.pop("_compressed_ranges", None)
- session.metadata.pop("_cumulative_tokens", None)
+ # 注意:不要删除 _cumulative_tokens,压缩逻辑需要它来跟踪累积 token 计数
@staticmethod
def _estimate_message_tokens(message: dict[str, Any]) -> int:
@@ -362,7 +362,6 @@ class AgentLoop:
if len(chunk) < 2:
return
- before_msg_count = len(session.messages)
logger.info(
"Compression chunk {}: msgs {}-{} (count={}, est~{}, need~{})",
session.key,
@@ -384,13 +383,12 @@ class AgentLoop:
self._set_compressed_until(session, end_idx)
self.sessions.save(session)
- after_msg_count = len(session.messages)
after_tokens, after_source = self._estimate_session_prompt_tokens(session)
after_ratio = after_tokens / budget if budget else 0.0
reduced = max(0, current_tokens - after_tokens)
reduced_ratio = (reduced / current_tokens) if current_tokens > 0 else 0.0
logger.info(
- "Compression done {}: {}/{} ({:.1%}) via {}, reduced={} ({:.1%}), history: {} -> {}",
+ "Compression done {}: {}/{} ({:.1%}) via {}, reduced={} ({:.1%})",
session.key,
after_tokens,
budget,
@@ -398,8 +396,6 @@ class AgentLoop:
after_source,
reduced,
reduced_ratio,
- before_msg_count,
- after_msg_count,
)
def _schedule_background_compression(self, session_key: str) -> None:
@@ -855,14 +851,14 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
))
- final_content, _, all_msgs, _, _ = await self._run_agent_loop(
+ final_content, _, all_msgs, total_tokens_this_turn, token_source = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress,
)
if final_content is None:
final_content = "I've completed processing but have no response to give."
- self._save_turn(session, all_msgs, 1 + len(history))
+ self._save_turn(session, all_msgs, 1 + len(history), total_tokens_this_turn)
self.sessions.save(session)
self._schedule_background_compression(session.key)
@@ -876,7 +872,7 @@ class AgentLoop:
metadata=msg.metadata or {},
)
- def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
+ def _save_turn(self, session: Session, messages: list[dict], skip: int, total_tokens_this_turn: int = 0) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
for m in messages[skip:]:
@@ -910,6 +906,14 @@ class AgentLoop:
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
session.updated_at = datetime.now()
+
+ # Update cumulative token count for compression tracking
+ if total_tokens_this_turn > 0:
+ current_cumulative = session.metadata.get("_cumulative_tokens", 0)
+ if isinstance(current_cumulative, (int, float)):
+ session.metadata["_cumulative_tokens"] = int(current_cumulative) + total_tokens_this_turn
+ else:
+ session.metadata["_cumulative_tokens"] = total_tokens_this_turn
async def process_direct(
self,
From 274edc5451c1d0f79eda80c76127f497ec6923e9 Mon Sep 17 00:00:00 2001
From: VITOHJL
Date: Sun, 8 Mar 2026 17:25:59 +0800
Subject: [PATCH 066/124] fix(compression): prefer provider prompt token usage
---
nanobot/agent/loop.py | 43 ++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 42 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 5e01b79..4f6a051 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -124,6 +124,8 @@ class AgentLoop:
self._mcp_connecting = False
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
self._compression_tasks: dict[str, asyncio.Task] = {} # session_key -> task
+ self._last_turn_prompt_tokens: int = 0
+ self._last_turn_prompt_source: str = "none"
self._processing_lock = asyncio.Lock()
self._register_default_tools()
@@ -324,7 +326,15 @@ class AgentLoop:
if target_threshold >= start_threshold:
target_threshold = max(0, start_threshold - 1)
- current_tokens, token_source = self._estimate_session_prompt_tokens(session)
+ # Prefer provider usage prompt tokens from the turn-ending call.
+ # If unavailable, fall back to estimator chain.
+ raw_prompt_tokens = session.metadata.get("_last_prompt_tokens")
+ if isinstance(raw_prompt_tokens, (int, float)) and raw_prompt_tokens > 0:
+ current_tokens = int(raw_prompt_tokens)
+ token_source = str(session.metadata.get("_last_prompt_source") or "usage_prompt")
+ else:
+ current_tokens, token_source = self._estimate_session_prompt_tokens(session)
+
current_ratio = current_tokens / budget if budget else 0.0
if current_tokens <= 0:
logger.debug("Compression skip {}: token estimate unavailable", session.key)
@@ -569,6 +579,8 @@ class AgentLoop:
tools_used: list[str] = []
total_tokens_this_turn = 0
token_source = "none"
+ self._last_turn_prompt_tokens = 0
+ self._last_turn_prompt_source = "none"
while iteration < self.max_iterations:
iteration += 1
@@ -594,19 +606,35 @@ class AgentLoop:
if isinstance(t_tokens, (int, float)) and t_tokens > 0:
total_tokens_this_turn = int(t_tokens)
token_source = "provider_total"
+ if isinstance(p_tokens, (int, float)) and p_tokens > 0:
+ self._last_turn_prompt_tokens = int(p_tokens)
+ self._last_turn_prompt_source = "usage_prompt"
+ elif isinstance(c_tokens, (int, float)):
+ prompt_derived = int(t_tokens) - int(c_tokens)
+ if prompt_derived > 0:
+ self._last_turn_prompt_tokens = prompt_derived
+ self._last_turn_prompt_source = "usage_total_minus_completion"
elif isinstance(p_tokens, (int, float)) and isinstance(c_tokens, (int, float)):
# If we have both prompt and completion tokens, sum them
total_tokens_this_turn = int(p_tokens) + int(c_tokens)
token_source = "provider_sum"
+ if p_tokens > 0:
+ self._last_turn_prompt_tokens = int(p_tokens)
+ self._last_turn_prompt_source = "usage_prompt"
elif isinstance(p_tokens, (int, float)) and p_tokens > 0:
# Fallback: use prompt tokens only (completion might be 0 for tool calls)
total_tokens_this_turn = int(p_tokens)
token_source = "provider_prompt"
+ self._last_turn_prompt_tokens = int(p_tokens)
+ self._last_turn_prompt_source = "usage_prompt"
else:
# Estimate with unified chain (provider counter -> tiktoken), plus completion tiktoken.
estimated_prompt, prompt_source = self._estimate_prompt_tokens_chain(messages, tool_defs)
estimated_completion = self._estimate_completion_tokens(response.content or "")
total_tokens_this_turn = estimated_prompt + estimated_completion
+ if estimated_prompt > 0:
+ self._last_turn_prompt_tokens = int(estimated_prompt)
+ self._last_turn_prompt_source = str(prompt_source or "tiktoken")
if total_tokens_this_turn > 0:
token_source = (
"tiktoken"
@@ -779,6 +807,12 @@ class AgentLoop:
current_message=msg.content, channel=channel, chat_id=chat_id,
)
final_content, _, all_msgs, _, _ = await self._run_agent_loop(messages)
+ if self._last_turn_prompt_tokens > 0:
+ session.metadata["_last_prompt_tokens"] = self._last_turn_prompt_tokens
+ session.metadata["_last_prompt_source"] = self._last_turn_prompt_source
+ else:
+ session.metadata.pop("_last_prompt_tokens", None)
+ session.metadata.pop("_last_prompt_source", None)
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
self._schedule_background_compression(session.key)
@@ -858,6 +892,13 @@ class AgentLoop:
if final_content is None:
final_content = "I've completed processing but have no response to give."
+ if self._last_turn_prompt_tokens > 0:
+ session.metadata["_last_prompt_tokens"] = self._last_turn_prompt_tokens
+ session.metadata["_last_prompt_source"] = self._last_turn_prompt_source
+ else:
+ session.metadata.pop("_last_prompt_tokens", None)
+ session.metadata.pop("_last_prompt_source", None)
+
self._save_turn(session, all_msgs, 1 + len(history), total_tokens_this_turn)
self.sessions.save(session)
self._schedule_background_compression(session.key)
From 1421ac501c381c253dfca156558b16d6a0f73a64 Mon Sep 17 00:00:00 2001
From: TheAutomatic
Date: Sun, 8 Mar 2026 07:04:06 -0700
Subject: [PATCH 067/124] feat(qq): send messages using markdown payload
---
nanobot/channels/qq.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
index 4809fd3..5ac06e3 100644
--- a/nanobot/channels/qq.py
+++ b/nanobot/channels/qq.py
@@ -113,16 +113,16 @@ class QQChannel(BaseChannel):
if msg_type == "group":
await self._client.api.post_group_message(
group_openid=msg.chat_id,
- msg_type=0,
- content=msg.content,
+ msg_type=2,
+ markdown={"content": msg.content},
msg_id=msg_id,
msg_seq=self._msg_seq,
)
else:
await self._client.api.post_c2c_message(
openid=msg.chat_id,
- msg_type=0,
- content=msg.content,
+ msg_type=2,
+ markdown={"content": msg.content},
msg_id=msg_id,
msg_seq=self._msg_seq,
)
From ed3b9c16f959d5820298673fe732d899dec9a593 Mon Sep 17 00:00:00 2001
From: Alfredo Arenas
Date: Sun, 8 Mar 2026 08:05:18 -0600
Subject: [PATCH 068/124] fix: handle CancelledError in MCP tool calls to
prevent process crash
MCP SDK's anyio cancel scopes can leak CancelledError on timeout or
failure paths. Since CancelledError is a BaseException (not Exception),
it escapes both MCPToolWrapper.execute() and ToolRegistry.execute(),
crashing the agent loop.
Now catches CancelledError and returns a graceful error to the LLM,
while still re-raising genuine task cancellations from /stop.
Also catches general Exception for other MCP failures (connection
drops, invalid responses, etc.).
Related: #1055
---
nanobot/agent/tools/mcp.py | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index 2cbffd0..cf0a842 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -34,7 +34,7 @@ class MCPToolWrapper(Tool):
def parameters(self) -> dict[str, Any]:
return self._parameters
- async def execute(self, **kwargs: Any) -> str:
+async def execute(self, **kwargs: Any) -> str:
from mcp import types
try:
result = await asyncio.wait_for(
@@ -44,13 +44,24 @@ class MCPToolWrapper(Tool):
except asyncio.TimeoutError:
logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout)
return f"(MCP tool call timed out after {self._tool_timeout}s)"
+ except asyncio.CancelledError:
+ # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure.
+ # Re-raise only if our task was externally cancelled (e.g. /stop).
+ task = asyncio.current_task()
+ if task is not None and task.cancelling() > 0:
+ raise
+ logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
+ return f"(MCP tool call was cancelled)"
+ except Exception as exc:
+ logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc)
+ return f"(MCP tool call failed: {type(exc).__name__})"
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)"
+ return "\n".join(parts) or "(no output)
async def connect_mcp_servers(
From 7cbb254a8e5140d8393d608a2f41c2885b080ce7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 15:39:40 +0000
Subject: [PATCH 069/124] fix: remove stale IDENTITY bootstrap entry
---
nanobot/agent/context.py | 2 +-
tests/test_context_prompt_cache.py | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 27511fa..820baf5 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -16,7 +16,7 @@ from nanobot.utils.helpers import detect_image_mime
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
- BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"]
+ BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
def __init__(self, workspace: Path):
diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py
index ce796e2..6eb4b4f 100644
--- a/tests/test_context_prompt_cache.py
+++ b/tests/test_context_prompt_cache.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from datetime import datetime as real_datetime
+from importlib.resources import files as pkg_files
from pathlib import Path
import datetime as datetime_module
@@ -23,6 +24,13 @@ def _make_workspace(tmp_path: Path) -> Path:
return workspace
+def test_bootstrap_files_are_backed_by_templates() -> None:
+ template_dir = pkg_files("nanobot") / "templates"
+
+ for filename in ContextBuilder.BOOTSTRAP_FILES:
+ assert (template_dir / filename).is_file(), f"missing bootstrap template: {filename}"
+
+
def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None:
"""System prompt should not change just because wall clock minute changes."""
monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime)
From 5eb67facff3b1e063302e5386072f02ca9a528c2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:01:06 +0000
Subject: [PATCH 070/124] Merge branch 'main' into pr-1728 and harden MCP tool
cancellation handling
---
nanobot/agent/tools/mcp.py | 15 ++++--
tests/test_mcp_tool.py | 99 ++++++++++++++++++++++++++++++++++++++
2 files changed, 110 insertions(+), 4 deletions(-)
create mode 100644 tests/test_mcp_tool.py
diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py
index cf0a842..400979b 100644
--- a/nanobot/agent/tools/mcp.py
+++ b/nanobot/agent/tools/mcp.py
@@ -34,8 +34,9 @@ class MCPToolWrapper(Tool):
def parameters(self) -> dict[str, Any]:
return self._parameters
-async def execute(self, **kwargs: Any) -> str:
+ async def execute(self, **kwargs: Any) -> str:
from mcp import types
+
try:
result = await asyncio.wait_for(
self._session.call_tool(self._original_name, arguments=kwargs),
@@ -51,17 +52,23 @@ async def execute(self, **kwargs: Any) -> str:
if task is not None and task.cancelling() > 0:
raise
logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name)
- return f"(MCP tool call was cancelled)"
+ return "(MCP tool call was cancelled)"
except Exception as exc:
- logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc)
+ logger.exception(
+ "MCP tool '{}' failed: {}: {}",
+ self._name,
+ type(exc).__name__,
+ exc,
+ )
return f"(MCP tool call failed: {type(exc).__name__})"
+
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)
+ return "\n".join(parts) or "(no output)"
async def connect_mcp_servers(
diff --git a/tests/test_mcp_tool.py b/tests/test_mcp_tool.py
new file mode 100644
index 0000000..bf68425
--- /dev/null
+++ b/tests/test_mcp_tool.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import asyncio
+import sys
+from types import ModuleType, SimpleNamespace
+
+import pytest
+
+from nanobot.agent.tools.mcp import MCPToolWrapper
+
+
+class _FakeTextContent:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+
+@pytest.fixture(autouse=True)
+def _fake_mcp_module(monkeypatch: pytest.MonkeyPatch) -> None:
+ mod = ModuleType("mcp")
+ mod.types = SimpleNamespace(TextContent=_FakeTextContent)
+ monkeypatch.setitem(sys.modules, "mcp", mod)
+
+
+def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
+ tool_def = SimpleNamespace(
+ name="demo",
+ description="demo tool",
+ inputSchema={"type": "object", "properties": {}},
+ )
+ return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
+
+
+@pytest.mark.asyncio
+async def test_execute_returns_text_blocks() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ assert arguments == {"value": 1}
+ return SimpleNamespace(content=[_FakeTextContent("hello"), 42])
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
+
+ result = await wrapper.execute(value=1)
+
+ assert result == "hello\n42"
+
+
+@pytest.mark.asyncio
+async def test_execute_returns_timeout_message() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ await asyncio.sleep(1)
+ return SimpleNamespace(content=[])
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=0.01)
+
+ result = await wrapper.execute()
+
+ assert result == "(MCP tool call timed out after 0.01s)"
+
+
+@pytest.mark.asyncio
+async def test_execute_handles_server_cancelled_error() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ raise asyncio.CancelledError()
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
+
+ result = await wrapper.execute()
+
+ assert result == "(MCP tool call was cancelled)"
+
+
+@pytest.mark.asyncio
+async def test_execute_re_raises_external_cancellation() -> None:
+ started = asyncio.Event()
+
+ async def call_tool(_name: str, arguments: dict) -> object:
+ started.set()
+ await asyncio.sleep(60)
+ return SimpleNamespace(content=[])
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=10)
+ task = asyncio.create_task(wrapper.execute())
+ await started.wait()
+
+ task.cancel()
+
+ with pytest.raises(asyncio.CancelledError):
+ await task
+
+
+@pytest.mark.asyncio
+async def test_execute_handles_generic_exception() -> None:
+ async def call_tool(_name: str, arguments: dict) -> object:
+ raise RuntimeError("boom")
+
+ wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
+
+ result = await wrapper.execute()
+
+ assert result == "(MCP tool call failed: RuntimeError)"
From 4715321319f7282ffe9df99be59898a9782a2440 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:39:37 +0000
Subject: [PATCH 071/124] Merge branch 'main' into pr-1579 and tighten platform
guidance
---
nanobot/agent/context.py | 25 ++++++-------------------
nanobot/skills/memory/SKILL.md | 13 +++++++------
2 files changed, 13 insertions(+), 25 deletions(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 3dced80..2c648eb 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -62,27 +62,14 @@ Skills with available="false" need dependencies installed first - you can try in
platform_policy = ""
if system == "Windows":
platform_policy = """## Platform Policy (Windows)
-- You are running on Windows. Shell commands executed via the `exec` tool run under the default Windows shell (PowerShell or cmd.exe) unless you explicitly invoke another shell.
-- Prefer UTF-8 for file I/O and command output. If terminal output is garbled/mojibake, retry with:
- - PowerShell: `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; `
- - cmd.exe: `chcp 65001 >NUL & `
-- Do NOT assume GNU tools like `grep`, `sed`, `awk` exist. Prefer Windows built-ins:
- - Search text: `findstr /i "keyword" path\\to\\file`
- - List files: `dir`
- - Show file: `type path\\to\\file`
-- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reliability.
-"""
- elif system == "Darwin":
- platform_policy = """## Platform Policy (macOS)
-- You are running on macOS. Prefer POSIX tools and UTF-8.
-- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations.
-- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility.
+- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist.
+- Prefer Windows-native commands or file tools when they are more reliable.
+- If terminal output is garbled, retry with UTF-8 output enabled.
"""
else:
- platform_policy = """## Platform Policy (Linux)
-- You are running on Linux. Prefer POSIX tools and UTF-8.
-- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations.
-- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility.
+ platform_policy = """## Platform Policy (POSIX)
+- You are running on a POSIX system. Prefer UTF-8 and standard shell tools.
+- Use file tools when they are simpler or more reliable than shell commands.
"""
return f"""# nanobot 🐈
diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md
index 865f11f..3f0a8fc 100644
--- a/nanobot/skills/memory/SKILL.md
+++ b/nanobot/skills/memory/SKILL.md
@@ -13,16 +13,17 @@ always: true
## Search Past Events
-**Recommended approach (cross-platform):**
-- Use `read_file` to read `memory/HISTORY.md`, then search in-memory
-- This is the most reliable and portable method on all platforms
+Choose the search method based on file size:
-**Alternative (if you need command-line search):**
+- Small `memory/HISTORY.md`: use `read_file`, then search in-memory
+- Large or long-lived `memory/HISTORY.md`: use the `exec` tool for targeted search
+
+Examples:
- **Linux/macOS:** `grep -i "keyword" memory/HISTORY.md`
- **Windows:** `findstr /i "keyword" memory\HISTORY.md`
-- **Python (cross-platform):** `python -c "import re; content=open('memory/HISTORY.md', encoding='utf-8').read(); print('\n'.join([l for l in content.split('\n') if 'keyword' in l.lower()][-20:]))"`
+- **Cross-platform Python:** `python -c "from pathlib import Path; text = Path('memory/HISTORY.md').read_text(encoding='utf-8'); print('\n'.join([l for l in text.splitlines() if 'keyword' in l.lower()][-20:]))"`
-Use the `exec` tool to run these commands. For complex searches, prefer `read_file` + in-memory filtering.
+Prefer targeted command-line search for large history files.
## When to Update MEMORY.md
From a0bb4320f48bd4f25e9daf98de7ad2eb9276a42e Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:44:47 +0000
Subject: [PATCH 072/124] chore: bump version to 0.1.4.post4
---
nanobot/__init__.py | 2 +-
pyproject.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/nanobot/__init__.py b/nanobot/__init__.py
index 4dba5f4..d331109 100644
--- a/nanobot/__init__.py
+++ b/nanobot/__init__.py
@@ -2,5 +2,5 @@
nanobot - A lightweight AI agent framework
"""
-__version__ = "0.1.4.post3"
+__version__ = "0.1.4.post4"
__logo__ = "🐈"
diff --git a/pyproject.toml b/pyproject.toml
index 41d0fbb..62cf616 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.4.post3"
+version = "0.1.4.post4"
description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11"
license = {text = "MIT"}
From 998021f571a140574af0c29a3c36f51b7ff71e79 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 16:57:28 +0000
Subject: [PATCH 073/124] docs: refresh install/update guidance and bump
v0.1.4.post4
---
README.md | 31 +++++++++++++++++++++++++++----
SECURITY.md | 4 ++--
2 files changed, 29 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 13971e2..d3401ea 100644
--- a/README.md
+++ b/README.md
@@ -122,6 +122,29 @@ uv tool install nanobot-ai
pip install nanobot-ai
```
+### Update to latest version
+
+**PyPI / pip**
+
+```bash
+pip install -U nanobot-ai
+nanobot --version
+```
+
+**uv**
+
+```bash
+uv tool upgrade nanobot-ai
+nanobot --version
+```
+
+**Using WhatsApp?** Rebuild the local bridge after upgrading:
+
+```bash
+rm -rf ~/.nanobot/bridge
+nanobot channels login
+```
+
## 🚀 Quick Start
> [!TIP]
@@ -374,7 +397,7 @@ pip install nanobot-ai[matrix]
| Option | Description |
|--------|-------------|
-| `allowFrom` | User IDs allowed to interact. Empty = all senders. |
+| `allowFrom` | User IDs allowed to interact. Empty denies all; use `["*"]` to allow everyone. |
| `groupPolicy` | `open` (default), `mention`, or `allowlist`. |
| `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). |
| `allowRoomMentions` | Accept `@room` mentions in mention mode. |
@@ -428,7 +451,7 @@ nanobot gateway
```
> WhatsApp bridge updates are not applied automatically for existing installations.
-> If you upgrade nanobot and need the latest WhatsApp bridge, run:
+> After upgrading nanobot, rebuild the local bridge with:
> `rm -rf ~/.nanobot/bridge && nanobot channels login`
@@ -900,13 +923,13 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
-> **Change in source / post-`v0.1.4.post3`:** In `v0.1.4.post3` and earlier, an empty `allowFrom` means "allow all senders". In newer versions (including building from source), **empty `allowFrom` denies all access by default**. To allow all senders, set `"allowFrom": ["*"]`.
+> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`.
| Option | Default | Description |
|--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
-| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
+| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. |
## 🧩 Multiple Instances
diff --git a/SECURITY.md b/SECURITY.md
index af4da71..d98adb6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json
```
**Security Notes:**
-- In `v0.1.4.post3` and earlier, an empty `allowFrom` allows all users. In newer versions (including source builds), **empty `allowFrom` denies all access** — set `["*"]` to explicitly allow everyone.
+- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
- Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp
- Review access logs regularly for unauthorized access attempts
@@ -212,7 +212,7 @@ If you suspect a security breach:
- Input length limits on HTTP requests
✅ **Authentication**
-- Allow-list based access control — in `v0.1.4.post3` and earlier empty means allow all; in newer versions empty means deny all (`["*"]` to explicitly allow all)
+- Allow-list based access control — in `v0.1.4.post3` and earlier empty `allowFrom` allowed all; since `v0.1.4.post4` it denies all (`["*"]` explicitly allows all)
- Failed authentication attempt logging
✅ **Resource Protection**
From 4147d0ff9d12f9faaa3aefe5be449b18461588d1 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 17:00:09 +0000
Subject: [PATCH 074/124] docs: update v0.1.4.post4 release news
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index d3401ea..2450b8c 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,7 @@
## 📢 News
+- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
From f19cefb1b9b61dcf902afb5666aea80b1c362e46 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Sun, 8 Mar 2026 17:00:46 +0000
Subject: [PATCH 075/124] docs: update v0.1.4.post4 release news
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 2450b8c..f169bd7 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## 📢 News
-- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
+- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
From 4044b85d4bfa9104b633f3cb408894f0459a0164 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Mon, 9 Mar 2026 01:32:10 +0800
Subject: [PATCH 076/124] fix: ensure feishu audio file has .opus extension for
Groq Whisper compatibility
---
nanobot/channels/feishu.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index a637025..0409c32 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -753,8 +753,9 @@ class FeishuChannel(BaseChannel):
None, self._download_file_sync, message_id, file_key, msg_type
)
if not filename:
- ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "")
- filename = f"{file_key[:16]}{ext}"
+ filename = file_key[:16]
+ if msg_type == "audio" and not filename.endswith(".opus"):
+ filename = f"{filename}.opus"
if data and filename:
file_path = media_dir / filename
From 85c56d7410ab4eed78ec70d75489cf453afcfbb3 Mon Sep 17 00:00:00 2001
From: Renato Machado
Date: Mon, 9 Mar 2026 01:37:35 +0000
Subject: [PATCH 077/124] feat: add "restart" command
---
nanobot/agent/loop.py | 11 +++++++++++
nanobot/channels/telegram.py | 2 ++
2 files changed, 13 insertions(+)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index ca9a06e..5311921 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -5,6 +5,8 @@ from __future__ import annotations
import asyncio
import json
import re
+import os
+import sys
import weakref
from contextlib import AsyncExitStack
from pathlib import Path
@@ -392,6 +394,15 @@ 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/stop — Stop the current task\n/help — Show available commands")
+ if cmd == "/restart":
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel, chat_id=msg.chat_id, content="🔄 Restarting..."
+ ))
+ async def _r():
+ await asyncio.sleep(1)
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+ asyncio.create_task(_r())
+ return None
unconsolidated = len(session.messages) - session.last_consolidated
if (unconsolidated >= self.memory_window and session.key not in self._consolidating):
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index ecb1440..f37ab1d 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -162,6 +162,7 @@ class TelegramChannel(BaseChannel):
BotCommand("new", "Start a new conversation"),
BotCommand("stop", "Stop the current task"),
BotCommand("help", "Show available commands"),
+ BotCommand("restart", "Restart the bot"),
]
def __init__(
@@ -223,6 +224,7 @@ class TelegramChannel(BaseChannel):
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command))
self._app.add_handler(CommandHandler("stop", self._forward_command))
+ self._app.add_handler(CommandHandler("restart", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help))
# Add message handler for text, photos, voice, documents
From 711903bc5fd00be72009c0b04ab1e42d46239311 Mon Sep 17 00:00:00 2001
From: Zek
Date: Mon, 9 Mar 2026 17:54:02 +0800
Subject: [PATCH 078/124] feat(feishu): add global group mention policy
- Add group_policy config: 'open' (default) or 'mention'
- 'open': Respond to all group messages (backward compatible)
- 'mention': Only respond when @mentioned in any group
- Auto-detect bot mentions by pattern matching:
* If open_id configured: match against mentions
* Otherwise: detect bot by empty user_id + ou_ open_id pattern
- Support @_all mentions
- Private chats unaffected (always respond)
- Clean implementation with minimal logging
docs: update Feishu README with group policy documentation
---
README.md | 15 +++++++-
nanobot/channels/feishu.py | 78 ++++++++++++++++++++++++++++++++++++++
nanobot/config/schema.py | 2 +
3 files changed, 94 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f169bd7..29221a7 100644
--- a/README.md
+++ b/README.md
@@ -482,7 +482,8 @@ Uses **WebSocket** long connection — no public IP required.
"appSecret": "xxx",
"encryptKey": "",
"verificationToken": "",
- "allowFrom": ["ou_YOUR_OPEN_ID"]
+ "allowFrom": ["ou_YOUR_OPEN_ID"],
+ "groupPolicy": "open"
}
}
}
@@ -491,6 +492,18 @@ Uses **WebSocket** long connection — no public IP required.
> `encryptKey` and `verificationToken` are optional for Long Connection mode.
> `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users.
+**Group Chat Policy** (optional):
+
+| Option | Values | Default | Description |
+|--------|--------|---------|-------------|
+| `groupPolicy` | `"open"` | `"open"` | Respond to all group messages (backward compatible) |
+| | `"mention"` | | Only respond when @mentioned |
+
+> [!NOTE]
+> - `"open"`: Respond to all messages in all groups
+> - `"mention"`: Only respond when @mentioned in any group
+> - Private chats are unaffected (always respond)
+
**3. Run**
```bash
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index a637025..78bf2df 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -352,6 +352,74 @@ class FeishuChannel(BaseChannel):
self._running = False
logger.info("Feishu bot stopped")
+ def _get_bot_open_id_sync(self) -> str | None:
+ """Get bot's own open_id for mention detection.
+
+ 飞书 SDK 没有直接的 bot info API,从配置或缓存获取。
+ """
+ # 尝试从配置获取 open_id(用户可以在配置中指定)
+ if hasattr(self.config, 'open_id') and self.config.open_id:
+ return self.config.open_id
+
+ return None
+
+ def _is_bot_mentioned(self, message: Any, bot_open_id: str | None) -> bool:
+ """Check if bot is mentioned in the message.
+
+ 飞书 mentions 数组包含被@的对象。匹配策略:
+ 1. 如果配置了 bot_open_id,则匹配 open_id
+ 2. 否则,检查 mentions 中是否有空的 user_id(bot 的特征)
+
+ Handles:
+ - Direct mentions in message.mentions
+ - @all mentions
+ """
+ # Check @all
+ raw_content = message.content or ""
+ if "@_all" in raw_content:
+ logger.debug("Feishu: @_all mention detected")
+ return True
+
+ # Check mentions array
+ mentions = message.mentions if hasattr(message, 'mentions') and message.mentions else []
+ if mentions:
+ if bot_open_id:
+ # 策略 1: 匹配配置的 open_id
+ for mention in mentions:
+ if mention.id:
+ open_id = getattr(mention.id, 'open_id', None)
+ if open_id == bot_open_id:
+ logger.debug("Feishu: bot mention matched")
+ return True
+ else:
+ # 策略 2: 检查 bot 特征 - user_id 为空且 open_id 存在
+ for mention in mentions:
+ if mention.id:
+ user_id = getattr(mention.id, 'user_id', None)
+ open_id = getattr(mention.id, 'open_id', None)
+ # Bot 的特征:user_id 为空字符串,open_id 存在
+ if user_id == '' and open_id and open_id.startswith('ou_'):
+ logger.debug("Feishu: bot mention matched")
+ return True
+
+ return False
+
+ def _should_respond_in_group(
+ self,
+ chat_id: str,
+ mentioned: bool
+ ) -> tuple[bool, str]:
+ """Determine if bot should respond in a group chat.
+
+ Returns:
+ (should_respond, reason)
+ """
+ # Check mention requirement
+ if self.config.group_policy == "mention" and not mentioned:
+ return False, "not mentioned in group"
+
+ return True, ""
+
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
"""Sync helper for adding reaction (runs in thread pool)."""
from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji
@@ -892,6 +960,16 @@ class FeishuChannel(BaseChannel):
chat_type = message.chat_type
msg_type = message.message_type
+ # Check group policy and mention requirement
+ if chat_type == "group":
+ bot_open_id = self._get_bot_open_id_sync()
+ mentioned = self._is_bot_mentioned(message, bot_open_id)
+ should_respond, reason = self._should_respond_in_group(chat_id, mentioned)
+
+ if not should_respond:
+ logger.debug("Feishu: ignoring group message - {}", reason)
+ return
+
# Add reaction
await self._add_reaction(message_id, self.config.react_emoji)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 803cb61..6b2eb35 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -47,6 +47,8 @@ class FeishuConfig(Base):
react_emoji: str = (
"THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
)
+ # Group chat settings
+ group_policy: Literal["open", "mention"] = "open" # Group response policy (default: open for backward compatibility)
class DingTalkConfig(Base):
From a660a25504b48170579a57496378e2fd843a556f Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Mon, 9 Mar 2026 22:00:45 +0800
Subject: [PATCH 079/124] feat(wecom): add wecom channel [wobsocket]
support text/audio[wecom support audio message by default]
---
nanobot/channels/manager.py | 14 +-
nanobot/channels/wecom.py | 352 ++++++++++++++++++++++++++++++++++++
nanobot/config/schema.py | 9 +
pyproject.toml | 1 +
4 files changed, 375 insertions(+), 1 deletion(-)
create mode 100644 nanobot/channels/wecom.py
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 51539dd..369795a 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -7,7 +7,6 @@ from typing import Any
from loguru import logger
-from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
@@ -150,6 +149,19 @@ class ChannelManager:
except ImportError as e:
logger.warning("Matrix channel not available: {}", e)
+ # WeCom channel
+ if self.config.channels.wecom.enabled:
+ try:
+ from nanobot.channels.wecom import WecomChannel
+ self.channels["wecom"] = WecomChannel(
+ self.config.channels.wecom,
+ self.bus,
+ groq_api_key=self.config.providers.groq.api_key,
+ )
+ logger.info("WeCom channel enabled")
+ except ImportError as e:
+ logger.warning("WeCom channel not available: {}", e)
+
self._validate_allow_from()
def _validate_allow_from(self) -> None:
diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py
new file mode 100644
index 0000000..dc97311
--- /dev/null
+++ b/nanobot/channels/wecom.py
@@ -0,0 +1,352 @@
+"""WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk."""
+
+import asyncio
+import importlib.util
+from collections import OrderedDict
+from typing import Any
+
+from loguru import logger
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.base import BaseChannel
+from nanobot.config.paths import get_media_dir
+from nanobot.config.schema import WecomConfig
+
+WECOM_AVAILABLE = importlib.util.find_spec("wecom_aibot_sdk") is not None
+
+# Message type display mapping
+MSG_TYPE_MAP = {
+ "image": "[image]",
+ "voice": "[voice]",
+ "file": "[file]",
+ "mixed": "[mixed content]",
+}
+
+
+class WecomChannel(BaseChannel):
+ """
+ WeCom (Enterprise WeChat) channel using WebSocket long connection.
+
+ Uses WebSocket to receive events - no public IP or webhook required.
+
+ Requires:
+ - Bot ID and Secret from WeCom AI Bot platform
+ """
+
+ name = "wecom"
+
+ def __init__(self, config: WecomConfig, bus: MessageBus, groq_api_key: str = ""):
+ super().__init__(config, bus)
+ self.config: WecomConfig = config
+ self.groq_api_key = groq_api_key
+ self._client: Any = None
+ self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
+ self._loop: asyncio.AbstractEventLoop | None = None
+ self._generate_req_id = None
+ # Store frame headers for each chat to enable replies
+ self._chat_frames: dict[str, Any] = {}
+
+ async def start(self) -> None:
+ """Start the WeCom bot with WebSocket long connection."""
+ if not WECOM_AVAILABLE:
+ logger.error("WeCom SDK not installed. Run: pip install wecom-aibot-sdk-python")
+ return
+
+ if not self.config.bot_id or not self.config.secret:
+ logger.error("WeCom bot_id and secret not configured")
+ return
+
+ from wecom_aibot_sdk import WSClient, generate_req_id
+
+ self._running = True
+ self._loop = asyncio.get_running_loop()
+ self._generate_req_id = generate_req_id
+
+ # Create WebSocket client
+ self._client = WSClient({
+ "bot_id": self.config.bot_id,
+ "secret": self.config.secret,
+ "reconnect_interval": 1000,
+ "max_reconnect_attempts": -1, # Infinite reconnect
+ "heartbeat_interval": 30000,
+ })
+
+ # Register event handlers
+ self._client.on("connected", self._on_connected)
+ self._client.on("authenticated", self._on_authenticated)
+ self._client.on("disconnected", self._on_disconnected)
+ self._client.on("error", self._on_error)
+ self._client.on("message.text", self._on_text_message)
+ self._client.on("message.image", self._on_image_message)
+ self._client.on("message.voice", self._on_voice_message)
+ self._client.on("message.file", self._on_file_message)
+ self._client.on("message.mixed", self._on_mixed_message)
+ self._client.on("event.enter_chat", self._on_enter_chat)
+
+ logger.info("WeCom bot starting with WebSocket long connection")
+ logger.info("No public IP required - using WebSocket to receive events")
+
+ # Connect
+ await self._client.connect_async()
+
+ # Keep running until stopped
+ while self._running:
+ await asyncio.sleep(1)
+
+ async def stop(self) -> None:
+ """Stop the WeCom bot."""
+ self._running = False
+ if self._client:
+ self._client.disconnect()
+ logger.info("WeCom bot stopped")
+
+ async def _on_connected(self, frame: Any) -> None:
+ """Handle WebSocket connected event."""
+ logger.info("WeCom WebSocket connected")
+
+ async def _on_authenticated(self, frame: Any) -> None:
+ """Handle authentication success event."""
+ logger.info("WeCom authenticated successfully")
+
+ async def _on_disconnected(self, frame: Any) -> None:
+ """Handle WebSocket disconnected event."""
+ reason = frame.body if hasattr(frame, 'body') else str(frame)
+ logger.warning("WeCom WebSocket disconnected: {}", reason)
+
+ async def _on_error(self, frame: Any) -> None:
+ """Handle error event."""
+ logger.error("WeCom error: {}", frame)
+
+ async def _on_text_message(self, frame: Any) -> None:
+ """Handle text message."""
+ await self._process_message(frame, "text")
+
+ async def _on_image_message(self, frame: Any) -> None:
+ """Handle image message."""
+ await self._process_message(frame, "image")
+
+ async def _on_voice_message(self, frame: Any) -> None:
+ """Handle voice message."""
+ await self._process_message(frame, "voice")
+
+ async def _on_file_message(self, frame: Any) -> None:
+ """Handle file message."""
+ await self._process_message(frame, "file")
+
+ async def _on_mixed_message(self, frame: Any) -> None:
+ """Handle mixed content message."""
+ await self._process_message(frame, "mixed")
+
+ async def _on_enter_chat(self, frame: Any) -> None:
+ """Handle enter_chat event (user opens chat with bot)."""
+ try:
+ # Extract body from WsFrame dataclass or dict
+ if hasattr(frame, 'body'):
+ body = frame.body or {}
+ elif isinstance(frame, dict):
+ body = frame.get("body", frame)
+ else:
+ body = {}
+
+ chat_id = body.get("chatid", "") if isinstance(body, dict) else ""
+
+ if chat_id and self.config.welcome_message:
+ await self._client.reply_welcome(frame, {
+ "msgtype": "text",
+ "text": {"content": self.config.welcome_message},
+ })
+ except Exception as e:
+ logger.error("Error handling enter_chat: {}", e)
+
+ async def _process_message(self, frame: Any, msg_type: str) -> None:
+ """Process incoming message and forward to bus."""
+ try:
+ # Extract body from WsFrame dataclass or dict
+ if hasattr(frame, 'body'):
+ body = frame.body or {}
+ elif isinstance(frame, dict):
+ body = frame.get("body", frame)
+ else:
+ body = {}
+
+ # Ensure body is a dict
+ if not isinstance(body, dict):
+ logger.warning("Invalid body type: {}", type(body))
+ return
+
+ # Extract message info
+ msg_id = body.get("msgid", "")
+ if not msg_id:
+ msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
+
+ # Deduplication check
+ if msg_id in self._processed_message_ids:
+ return
+ self._processed_message_ids[msg_id] = None
+
+ # Trim cache
+ while len(self._processed_message_ids) > 1000:
+ self._processed_message_ids.popitem(last=False)
+
+ # Extract sender info from "from" field (SDK format)
+ from_info = body.get("from", {})
+ sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown"
+
+ # For single chat, chatid is the sender's userid
+ # For group chat, chatid is provided in body
+ chat_type = body.get("chattype", "single")
+ chat_id = body.get("chatid", sender_id)
+
+ content_parts = []
+
+ if msg_type == "text":
+ text = body.get("text", {}).get("content", "")
+ if text:
+ content_parts.append(text)
+
+ elif msg_type == "image":
+ image_info = body.get("image", {})
+ file_url = image_info.get("url", "")
+ aes_key = image_info.get("aeskey", "")
+
+ if file_url and aes_key:
+ file_path = await self._download_and_save_media(file_url, aes_key, "image")
+ if file_path:
+ import os
+ filename = os.path.basename(file_path)
+ content_parts.append(f"[image: {filename}]\n[Image: source: {file_path}]")
+ else:
+ content_parts.append("[image: download failed]")
+ else:
+ content_parts.append("[image: download failed]")
+
+ elif msg_type == "voice":
+ voice_info = body.get("voice", {})
+ # Voice message already contains transcribed content from WeCom
+ voice_content = voice_info.get("content", "")
+ if voice_content:
+ content_parts.append(f"[voice] {voice_content}")
+ else:
+ content_parts.append("[voice]")
+
+ elif msg_type == "file":
+ file_info = body.get("file", {})
+ file_url = file_info.get("url", "")
+ aes_key = file_info.get("aeskey", "")
+ file_name = file_info.get("name", "unknown")
+
+ if file_url and aes_key:
+ file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name)
+ if file_path:
+ content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]")
+ else:
+ content_parts.append(f"[file: {file_name}: download failed]")
+ else:
+ content_parts.append(f"[file: {file_name}: download failed]")
+
+ elif msg_type == "mixed":
+ # Mixed content contains multiple message items
+ msg_items = body.get("mixed", {}).get("item", [])
+ for item in msg_items:
+ item_type = item.get("type", "")
+ if item_type == "text":
+ text = item.get("text", {}).get("content", "")
+ if text:
+ content_parts.append(text)
+ else:
+ content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]"))
+
+ else:
+ content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
+
+ content = "\n".join(content_parts) if content_parts else ""
+
+ if not content:
+ return
+
+ # Store frame for this chat to enable replies
+ self._chat_frames[chat_id] = frame
+
+ # Forward to message bus
+ # Note: media paths are included in content for broader model compatibility
+ await self._handle_message(
+ sender_id=sender_id,
+ chat_id=chat_id,
+ content=content,
+ media=None,
+ metadata={
+ "message_id": msg_id,
+ "msg_type": msg_type,
+ "chat_type": chat_type,
+ }
+ )
+
+ except Exception as e:
+ logger.error("Error processing WeCom message: {}", e)
+
+ async def _download_and_save_media(
+ self,
+ file_url: str,
+ aes_key: str,
+ media_type: str,
+ filename: str | None = None,
+ ) -> str | None:
+ """
+ Download and decrypt media from WeCom.
+
+ Returns:
+ file_path or None if download failed
+ """
+ try:
+ data, fname = await self._client.download_file(file_url, aes_key)
+
+ if not data:
+ logger.warning("Failed to download media from WeCom")
+ return None
+
+ media_dir = get_media_dir("wecom")
+ if not filename:
+ filename = fname or f"{media_type}_{hash(file_url) % 100000}"
+
+ file_path = media_dir / filename
+ file_path.write_bytes(data)
+ logger.debug("Downloaded {} to {}", media_type, file_path)
+ return str(file_path)
+
+ except Exception as e:
+ logger.error("Error downloading media: {}", e)
+ return None
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send a message through WeCom."""
+ if not self._client:
+ logger.warning("WeCom client not initialized")
+ return
+
+ try:
+ content = msg.content.strip()
+ if not content:
+ return
+
+ # Get the stored frame for this chat
+ frame = self._chat_frames.get(msg.chat_id)
+ if not frame:
+ logger.warning("No frame found for chat {}, cannot reply", msg.chat_id)
+ return
+
+ # Use streaming reply for better UX
+ stream_id = self._generate_req_id("stream")
+
+ # Send as streaming message with finish=True
+ await self._client.reply_stream(
+ frame,
+ stream_id,
+ content,
+ finish=True,
+ )
+
+ logger.debug("WeCom message sent to {}", msg.chat_id)
+
+ except Exception as e:
+ logger.error("Error sending WeCom message: {}", e)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 803cb61..63eae48 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -199,7 +199,15 @@ class QQConfig(Base):
) # Allowed user openids (empty = public access)
+class WecomConfig(Base):
+ """WeCom (Enterprise WeChat) AI Bot channel configuration."""
+ enabled: bool = False
+ bot_id: str = "" # Bot ID from WeCom AI Bot platform
+ secret: str = "" # Bot Secret from WeCom AI Bot platform
+ allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
+ welcome_message: str = "" # Welcome message for enter_chat event
+ react_emoji: str = "eyes" # Emoji for message reactions
class ChannelsConfig(Base):
"""Configuration for chat channels."""
@@ -216,6 +224,7 @@ class ChannelsConfig(Base):
slack: SlackConfig = Field(default_factory=SlackConfig)
qq: QQConfig = Field(default_factory=QQConfig)
matrix: MatrixConfig = Field(default_factory=MatrixConfig)
+ wecom: WecomConfig = Field(default_factory=WecomConfig)
class AgentDefaults(Base):
diff --git a/pyproject.toml b/pyproject.toml
index 62cf616..fac53ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ dependencies = [
"json-repair>=0.57.0,<1.0.0",
"chardet>=3.0.2,<6.0.0",
"openai>=2.8.0",
+ "wecom-aibot-sdk-python>=0.1.2",
]
[project.optional-dependencies]
From 620d7896c710748053257695d25c3391aa637dc5 Mon Sep 17 00:00:00 2001
From: ailuntz
Date: Tue, 10 Mar 2026 00:14:34 +0800
Subject: [PATCH 080/124] fix(slack): define thread usage when sending messages
---
nanobot/channels/slack.py | 2 +-
tests/test_slack_channel.py | 88 +++++++++++++++++++++++++++++++++++++
2 files changed, 89 insertions(+), 1 deletion(-)
create mode 100644 tests/test_slack_channel.py
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index a4e7324..e36c4c9 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -82,6 +82,7 @@ class SlackChannel(BaseChannel):
thread_ts = slack_meta.get("thread_ts")
channel_type = slack_meta.get("channel_type")
# Only reply in thread for channel/group messages; DMs don't use threads
+ use_thread = bool(thread_ts and channel_type != "im")
thread_ts_param = thread_ts if use_thread else None
# Slack rejects empty text payloads. Keep media-only messages media-only,
@@ -278,4 +279,3 @@ class SlackChannel(BaseChannel):
if parts:
rows.append(" · ".join(parts))
return "\n".join(rows)
-
diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py
new file mode 100644
index 0000000..18b96ef
--- /dev/null
+++ b/tests/test_slack_channel.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import pytest
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.slack import SlackChannel
+from nanobot.config.schema import SlackConfig
+
+
+class _FakeAsyncWebClient:
+ def __init__(self) -> None:
+ self.chat_post_calls: list[dict[str, object | None]] = []
+ self.file_upload_calls: list[dict[str, object | None]] = []
+
+ async def chat_postMessage(
+ self,
+ *,
+ channel: str,
+ text: str,
+ thread_ts: str | None = None,
+ ) -> None:
+ self.chat_post_calls.append(
+ {
+ "channel": channel,
+ "text": text,
+ "thread_ts": thread_ts,
+ }
+ )
+
+ async def files_upload_v2(
+ self,
+ *,
+ channel: str,
+ file: str,
+ thread_ts: str | None = None,
+ ) -> None:
+ self.file_upload_calls.append(
+ {
+ "channel": channel,
+ "file": file,
+ "thread_ts": thread_ts,
+ }
+ )
+
+
+@pytest.mark.asyncio
+async def test_send_uses_thread_for_channel_messages() -> None:
+ channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
+ fake_web = _FakeAsyncWebClient()
+ channel._web_client = fake_web
+
+ await channel.send(
+ OutboundMessage(
+ channel="slack",
+ chat_id="C123",
+ content="hello",
+ media=["/tmp/demo.txt"],
+ metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "channel"}},
+ )
+ )
+
+ assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
+ assert len(fake_web.file_upload_calls) == 1
+ assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
+
+
+@pytest.mark.asyncio
+async def test_send_omits_thread_for_dm_messages() -> None:
+ channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
+ fake_web = _FakeAsyncWebClient()
+ channel._web_client = fake_web
+
+ await channel.send(
+ OutboundMessage(
+ channel="slack",
+ chat_id="D123",
+ content="hello",
+ media=["/tmp/demo.txt"],
+ metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "im"}},
+ )
+ )
+
+ assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["thread_ts"] is None
+ assert len(fake_web.file_upload_calls) == 1
+ assert fake_web.file_upload_calls[0]["thread_ts"] is None
From 9c88e40a616190aca65ce3d3149f4529865ca5d8 Mon Sep 17 00:00:00 2001
From: ailuntz
Date: Tue, 10 Mar 2026 00:32:42 +0800
Subject: [PATCH 081/124] fix(cli): respect gateway port from config when
--port omitted
---
nanobot/cli/commands.py | 5 +++--
tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 47 insertions(+), 2 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2c8d6d3..a5906d2 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
@app.command()
def gateway(
- port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
+ port: int | None = typer.Option(None, "--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"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
@@ -310,8 +310,9 @@ def gateway(
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
+ selected_port = port if port is not None else config.gateway.port
- console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
+ console.print(f"{__logo__} Starting nanobot gateway on port {selected_port}...")
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 19c1998..9479dad 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -328,6 +328,50 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path)
assert config.workspace_path == override
+def test_gateway_uses_port_from_config_when_cli_port_is_omitted(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.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ 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 "Starting nanobot gateway on port 18791" in result.stdout
+
+
+def test_gateway_cli_port_overrides_config_port(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.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18801"])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert "Starting nanobot gateway on port 18801" in result.stdout
+
+
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)
From 45c0eebae5a700cfa5da28c2ff31208f34180509 Mon Sep 17 00:00:00 2001
From: chengyongru <2755839590@qq.com>
Date: Tue, 10 Mar 2026 00:53:23 +0800
Subject: [PATCH 082/124] docs(wecom): add wecom configuration guide in readme
---
README.md | 39 +++++++++++++++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/README.md b/README.md
index d3401ea..3d5fb63 100644
--- a/README.md
+++ b/README.md
@@ -207,6 +207,7 @@ Connect nanobot to your favorite chat platform.
| **Slack** | Bot token + App-Level token |
| **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret |
+| **Wecom** | Bot ID + App Secret |
Telegram (Recommended)
@@ -676,6 +677,44 @@ nanobot gateway
+
+Wecom (企业微信)
+
+Uses **WebSocket** long connection — no public IP required.
+
+**1. Create a wecom bot**
+
+In the client's workspace, click on "Intelligent Robot" to create a robot and choose API mode for creation.
+Select to create in "long connection" mode, and obtain Bot ID and Secret.
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "botId": "your_bot_id",
+ "secret": "your_secret",
+ "allowFrom": [
+ "your_id"
+ ]
+ }
+ }
+}
+```
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+> [!TIP]
+> wecom uses WebSocket to receive messages — no webhook or public IP needed!
+
+
+
## 🌐 Agent Social Network
🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!**
From 28330940d0b2cefbfe740957ee8f51ed9349c24e Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Mon, 9 Mar 2026 17:18:10 +0000
Subject: [PATCH 083/124] fix(slack): skip thread_ts for direct messages
---
nanobot/channels/slack.py | 5 ++---
tests/test_slack_channel.py | 2 ++
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
index e36c4c9..0384d8d 100644
--- a/nanobot/channels/slack.py
+++ b/nanobot/channels/slack.py
@@ -81,9 +81,8 @@ class SlackChannel(BaseChannel):
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
thread_ts = slack_meta.get("thread_ts")
channel_type = slack_meta.get("channel_type")
- # Only reply in thread for channel/group messages; DMs don't use threads
- use_thread = bool(thread_ts and channel_type != "im")
- thread_ts_param = thread_ts if use_thread else None
+ # Slack DMs don't use threads; channel/group replies may keep thread_ts.
+ thread_ts_param = thread_ts if thread_ts and channel_type != "im" else None
# Slack rejects empty text payloads. Keep media-only messages media-only,
# but send a single blank message when the bot has no text or files to send.
diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py
index 18b96ef..891f86a 100644
--- a/tests/test_slack_channel.py
+++ b/tests/test_slack_channel.py
@@ -61,6 +61,7 @@ async def test_send_uses_thread_for_channel_messages() -> None:
)
assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["text"] == "hello\n"
assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100"
assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100"
@@ -83,6 +84,7 @@ async def test_send_omits_thread_for_dm_messages() -> None:
)
assert len(fake_web.chat_post_calls) == 1
+ assert fake_web.chat_post_calls[0]["text"] == "hello\n"
assert fake_web.chat_post_calls[0]["thread_ts"] is None
assert len(fake_web.file_upload_calls) == 1
assert fake_web.file_upload_calls[0]["thread_ts"] is None
From 1284c7217ea2c59a5a9e2786c5f550e9fb5ace1b Mon Sep 17 00:00:00 2001
From: Protocol Zero <257158451+Protocol-zero-0@users.noreply.github.com>
Date: Mon, 9 Mar 2026 20:12:11 +0000
Subject: [PATCH 084/124] fix(cli): let gateway use config port by default
Respect config.gateway.port when --port is omitted, while keeping CLI flags as the highest-precedence override.
---
nanobot/cli/commands.py | 3 ++-
tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 46 insertions(+), 1 deletion(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 2c8d6d3..37f08b2 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
@app.command()
def gateway(
- port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
+ port: int | None = typer.Option(None, "--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"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
@@ -310,6 +310,7 @@ def gateway(
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
+ port = port if port is not None else config.gateway.port
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
sync_workspace_templates(config.workspace_path)
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 19c1998..5d38942 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -356,3 +356,47 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat
assert isinstance(result.exception, _StopGateway)
assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json"
+
+
+def test_gateway_uses_configured_port_when_cli_flag_is_missing(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.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ 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 "port 18791" in result.stdout
+
+
+def test_gateway_cli_port_overrides_configured_port(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.gateway.port = 18791
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ monkeypatch.setattr(
+ "nanobot.cli.commands._make_provider",
+ lambda _config: (_ for _ in ()).throw(_StopGateway("stop")),
+ )
+
+ result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"])
+
+ assert isinstance(result.exception, _StopGateway)
+ assert "port 18792" in result.stdout
From 4f9857f85f1f8aeddceb019bc0062d3ba7cab032 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Mar 2026 04:34:15 +0000
Subject: [PATCH 085/124] feat(telegram): add configurable group mention policy
---
nanobot/channels/telegram.py | 86 ++++++++++++++----
nanobot/config/schema.py | 2 +-
tests/test_telegram_channel.py | 156 ++++++++++++++++++++++++++++++++-
3 files changed, 226 insertions(+), 18 deletions(-)
diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py
index 0821b7d..5b294cc 100644
--- a/nanobot/channels/telegram.py
+++ b/nanobot/channels/telegram.py
@@ -179,6 +179,8 @@ class TelegramChannel(BaseChannel):
self._media_group_buffers: dict[str, dict] = {}
self._media_group_tasks: dict[str, asyncio.Task] = {}
self._message_threads: dict[tuple[str, int], int] = {}
+ self._bot_user_id: int | None = None
+ self._bot_username: str | None = None
def is_allowed(self, sender_id: str) -> bool:
"""Preserve Telegram's legacy id|username allowlist matching."""
@@ -242,6 +244,8 @@ class TelegramChannel(BaseChannel):
# Get bot info and register command menu
bot_info = await self._app.bot.get_me()
+ self._bot_user_id = getattr(bot_info, "id", None)
+ self._bot_username = getattr(bot_info, "username", None)
logger.info("Telegram bot @{} connected", bot_info.username)
try:
@@ -462,6 +466,70 @@ class TelegramChannel(BaseChannel):
"is_forum": bool(getattr(message.chat, "is_forum", False)),
}
+ async def _ensure_bot_identity(self) -> tuple[int | None, str | None]:
+ """Load bot identity once and reuse it for mention/reply checks."""
+ if self._bot_user_id is not None or self._bot_username is not None:
+ return self._bot_user_id, self._bot_username
+ if not self._app:
+ return None, None
+ bot_info = await self._app.bot.get_me()
+ self._bot_user_id = getattr(bot_info, "id", None)
+ self._bot_username = getattr(bot_info, "username", None)
+ return self._bot_user_id, self._bot_username
+
+ @staticmethod
+ def _has_mention_entity(
+ text: str,
+ entities,
+ bot_username: str,
+ bot_id: int | None,
+ ) -> bool:
+ """Check Telegram mention entities against the bot username."""
+ handle = f"@{bot_username}".lower()
+ for entity in entities or []:
+ entity_type = getattr(entity, "type", None)
+ if entity_type == "text_mention":
+ user = getattr(entity, "user", None)
+ if user is not None and bot_id is not None and getattr(user, "id", None) == bot_id:
+ return True
+ continue
+ if entity_type != "mention":
+ continue
+ offset = getattr(entity, "offset", None)
+ length = getattr(entity, "length", None)
+ if offset is None or length is None:
+ continue
+ if text[offset : offset + length].lower() == handle:
+ return True
+ return handle in text.lower()
+
+ async def _is_group_message_for_bot(self, message) -> bool:
+ """Allow group messages when policy is open, @mentioned, or replying to the bot."""
+ if message.chat.type == "private" or self.config.group_policy == "open":
+ return True
+
+ bot_id, bot_username = await self._ensure_bot_identity()
+ if bot_username:
+ text = message.text or ""
+ caption = message.caption or ""
+ if self._has_mention_entity(
+ text,
+ getattr(message, "entities", None),
+ bot_username,
+ bot_id,
+ ):
+ return True
+ if self._has_mention_entity(
+ caption,
+ getattr(message, "caption_entities", None),
+ bot_username,
+ bot_id,
+ ):
+ return True
+
+ reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None)
+ return bool(bot_id and reply_user and reply_user.id == bot_id)
+
def _remember_thread_context(self, message) -> None:
"""Cache topic thread id by chat/message id for follow-up replies."""
message_thread_id = getattr(message, "message_thread_id", None)
@@ -501,22 +569,8 @@ class TelegramChannel(BaseChannel):
# Store chat_id for replies
self._chat_ids[sender_id] = chat_id
- # Enforce group_policy: in group chats with "mention" policy,
- # only respond when the bot is @mentioned or the message is a reply to the bot.
- is_group = message.chat.type != "private"
- if is_group and getattr(self.config, "group_policy", "open") == "mention":
- bot_username = (await self._app.bot.get_me()).username if self._app else None
- mentioned = False
- # Check if bot is @mentioned in text
- if bot_username and message.text:
- mentioned = f"@{bot_username}" in message.text
- # Check if the message is a reply to the bot
- if not mentioned and message.reply_to_message and message.reply_to_message.from_user:
- bot_id = (await self._app.bot.get_me()).id if self._app else None
- if bot_id and message.reply_to_message.from_user.id == bot_id:
- mentioned = True
- if not mentioned:
- return
+ if not await self._is_group_message_for_bot(message):
+ return
# Build content from text and/or media
content_parts = []
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 3c5e315..8cfcad6 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -33,7 +33,7 @@ class TelegramConfig(Base):
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
)
reply_to_message: bool = False # If true, bot replies quote the original message
- group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned or replied to
+ group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all
class FeishuConfig(Base):
diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py
index 88c3f54..678512d 100644
--- a/tests/test_telegram_channel.py
+++ b/tests/test_telegram_channel.py
@@ -27,9 +27,11 @@ class _FakeUpdater:
class _FakeBot:
def __init__(self) -> None:
self.sent_messages: list[dict] = []
+ self.get_me_calls = 0
async def get_me(self):
- return SimpleNamespace(username="nanobot_test")
+ self.get_me_calls += 1
+ return SimpleNamespace(id=999, username="nanobot_test")
async def set_my_commands(self, commands) -> None:
self.commands = commands
@@ -37,6 +39,9 @@ class _FakeBot:
async def send_message(self, **kwargs) -> None:
self.sent_messages.append(kwargs)
+ async def send_chat_action(self, **kwargs) -> None:
+ pass
+
class _FakeApp:
def __init__(self, on_start_polling) -> None:
@@ -87,6 +92,35 @@ class _FakeBuilder:
return self.app
+def _make_telegram_update(
+ *,
+ chat_type: str = "group",
+ text: str | None = None,
+ caption: str | None = None,
+ entities=None,
+ caption_entities=None,
+ reply_to_message=None,
+):
+ user = SimpleNamespace(id=12345, username="alice", first_name="Alice")
+ message = SimpleNamespace(
+ chat=SimpleNamespace(type=chat_type, is_forum=False),
+ chat_id=-100123,
+ text=text,
+ caption=caption,
+ entities=entities or [],
+ caption_entities=caption_entities or [],
+ reply_to_message=reply_to_message,
+ photo=None,
+ voice=None,
+ audio=None,
+ document=None,
+ media_group_id=None,
+ message_thread_id=None,
+ message_id=1,
+ )
+ return SimpleNamespace(message=message, effective_user=user)
+
+
@pytest.mark.asyncio
async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None:
config = TelegramConfig(
@@ -131,6 +165,10 @@ def test_get_extension_falls_back_to_original_filename() -> None:
assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz"
+def test_telegram_group_policy_defaults_to_mention() -> None:
+ assert TelegramConfig().group_policy == "mention"
+
+
def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None:
channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus())
@@ -182,3 +220,119 @@ async def test_send_reply_infers_topic_from_message_id_cache() -> None:
assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42
assert channel._app.bot.sent_messages[0]["reply_parameters"].message_id == 10
+
+
+@pytest.mark.asyncio
+async def test_group_policy_mention_ignores_unmentioned_group_message() -> None:
+ channel = TelegramChannel(
+ TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
+ MessageBus(),
+ )
+ channel._app = _FakeApp(lambda: None)
+
+ handled = []
+
+ async def capture_handle(**kwargs) -> None:
+ handled.append(kwargs)
+
+ channel._handle_message = capture_handle
+ channel._start_typing = lambda _chat_id: None
+
+ await channel._on_message(_make_telegram_update(text="hello everyone"), None)
+
+ assert handled == []
+ assert channel._app.bot.get_me_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_group_policy_mention_accepts_text_mention_and_caches_bot_identity() -> None:
+ channel = TelegramChannel(
+ TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
+ MessageBus(),
+ )
+ channel._app = _FakeApp(lambda: None)
+
+ handled = []
+
+ async def capture_handle(**kwargs) -> None:
+ handled.append(kwargs)
+
+ channel._handle_message = capture_handle
+ channel._start_typing = lambda _chat_id: None
+
+ mention = SimpleNamespace(type="mention", offset=0, length=13)
+ await channel._on_message(_make_telegram_update(text="@nanobot_test hi", entities=[mention]), None)
+ await channel._on_message(_make_telegram_update(text="@nanobot_test again", entities=[mention]), None)
+
+ assert len(handled) == 2
+ assert channel._app.bot.get_me_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_group_policy_mention_accepts_caption_mention() -> None:
+ channel = TelegramChannel(
+ TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
+ MessageBus(),
+ )
+ channel._app = _FakeApp(lambda: None)
+
+ handled = []
+
+ async def capture_handle(**kwargs) -> None:
+ handled.append(kwargs)
+
+ channel._handle_message = capture_handle
+ channel._start_typing = lambda _chat_id: None
+
+ mention = SimpleNamespace(type="mention", offset=0, length=13)
+ await channel._on_message(
+ _make_telegram_update(caption="@nanobot_test photo", caption_entities=[mention]),
+ None,
+ )
+
+ assert len(handled) == 1
+ assert handled[0]["content"] == "@nanobot_test photo"
+
+
+@pytest.mark.asyncio
+async def test_group_policy_mention_accepts_reply_to_bot() -> None:
+ channel = TelegramChannel(
+ TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"),
+ MessageBus(),
+ )
+ channel._app = _FakeApp(lambda: None)
+
+ handled = []
+
+ async def capture_handle(**kwargs) -> None:
+ handled.append(kwargs)
+
+ channel._handle_message = capture_handle
+ channel._start_typing = lambda _chat_id: None
+
+ reply = SimpleNamespace(from_user=SimpleNamespace(id=999))
+ await channel._on_message(_make_telegram_update(text="reply", reply_to_message=reply), None)
+
+ assert len(handled) == 1
+
+
+@pytest.mark.asyncio
+async def test_group_policy_open_accepts_plain_group_message() -> None:
+ channel = TelegramChannel(
+ TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"),
+ MessageBus(),
+ )
+ channel._app = _FakeApp(lambda: None)
+
+ handled = []
+
+ async def capture_handle(**kwargs) -> None:
+ handled.append(kwargs)
+
+ channel._handle_message = capture_handle
+ channel._start_typing = lambda _chat_id: None
+
+ await channel._on_message(_make_telegram_update(text="hello group"), None)
+
+ assert len(handled) == 1
+ assert channel._app.bot.get_me_calls == 0
From 6c70154feeeff638cfb79a6e19d263f36ea7f5f6 Mon Sep 17 00:00:00 2001
From: suger-m
Date: Tue, 10 Mar 2026 15:55:04 +0800
Subject: [PATCH 086/124] fix(exec): enforce workspace guard for home-expanded
paths
---
nanobot/agent/tools/shell.py | 6 ++++--
tests/test_tool_validation.py | 13 +++++++++++++
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index ce19920..4726e3c 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -143,7 +143,8 @@ class ExecTool(Tool):
for raw in self._extract_absolute_paths(cmd):
try:
- p = Path(raw.strip()).resolve()
+ expanded = os.path.expandvars(raw.strip())
+ p = Path(expanded).expanduser().resolve()
except Exception:
continue
if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
@@ -155,4 +156,5 @@ class ExecTool(Tool):
def _extract_absolute_paths(command: str) -> list[str]:
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\...
posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) # POSIX: /absolute only
- return win_paths + posix_paths
+ home_paths = re.findall(r"(?:^|[\s|>])(~[^\s\"'>;|<]*)", command) # POSIX/Windows home shortcut: ~
+ return win_paths + posix_paths + home_paths
diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py
index c2b4b6a..cf648bf 100644
--- a/tests/test_tool_validation.py
+++ b/tests/test_tool_validation.py
@@ -108,6 +108,19 @@ def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None:
assert "/tmp/out.txt" in paths
+def test_exec_extract_absolute_paths_captures_home_paths() -> None:
+ cmd = "cat ~/.nanobot/config.json > ~/out.txt"
+ paths = ExecTool._extract_absolute_paths(cmd)
+ assert "~/.nanobot/config.json" in paths
+ assert "~/out.txt" in paths
+
+
+def test_exec_guard_blocks_home_path_outside_workspace(tmp_path) -> None:
+ tool = ExecTool(restrict_to_workspace=True)
+ error = tool._guard_command("cat ~/.nanobot/config.json", str(tmp_path))
+ assert error == "Error: Command blocked by safety guard (path outside working dir)"
+
+
# --- cast_params tests ---
From b7ecc94c9b85aadc79e0d6598ea42ad7dbaa15f1 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Mar 2026 09:16:23 +0000
Subject: [PATCH 087/124] fix(skill-creator): restore validation and align
packaging docs
---
nanobot/skills/skill-creator/SKILL.md | 10 +-
.../skill-creator/scripts/package_skill.py | 77 ++++---
.../skill-creator/scripts/quick_validate.py | 213 ++++++++++++++++++
tests/test_skill_creator_scripts.py | 127 +++++++++++
4 files changed, 392 insertions(+), 35 deletions(-)
create mode 100644 nanobot/skills/skill-creator/scripts/quick_validate.py
create mode 100644 tests/test_skill_creator_scripts.py
diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md
index f4d6e0b..ea53abe 100644
--- a/nanobot/skills/skill-creator/SKILL.md
+++ b/nanobot/skills/skill-creator/SKILL.md
@@ -268,6 +268,8 @@ Skip this step only if the skill being developed already exists, and iteration o
When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
+For `nanobot`, custom skills should live under the active workspace `skills/` directory so they can be discovered automatically at runtime (for example, `/skills/my-skill/SKILL.md`).
+
Usage:
```bash
@@ -277,9 +279,9 @@ scripts/init_skill.py --path [--resources script
Examples:
```bash
-scripts/init_skill.py my-skill --path skills/public
-scripts/init_skill.py my-skill --path skills/public --resources scripts,references
-scripts/init_skill.py my-skill --path skills/public --resources scripts --examples
+scripts/init_skill.py my-skill --path ./workspace/skills
+scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts,references
+scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts --examples
```
The script:
@@ -326,7 +328,7 @@ Write the YAML frontmatter with `name` and `description`:
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent.
- Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
-Do not include any other fields in YAML frontmatter.
+Keep frontmatter minimal. In `nanobot`, `metadata` and `always` are also supported when needed, but avoid adding extra fields unless they are actually required.
##### Body
diff --git a/nanobot/skills/skill-creator/scripts/package_skill.py b/nanobot/skills/skill-creator/scripts/package_skill.py
index aa4de89..48fcbbe 100755
--- a/nanobot/skills/skill-creator/scripts/package_skill.py
+++ b/nanobot/skills/skill-creator/scripts/package_skill.py
@@ -3,11 +3,11 @@
Skill Packager - Creates a distributable .skill file of a skill folder
Usage:
- python utils/package_skill.py [output-directory]
+ python package_skill.py [output-directory]
Example:
- python utils/package_skill.py skills/public/my-skill
- python utils/package_skill.py skills/public/my-skill ./dist
+ python package_skill.py skills/public/my-skill
+ python package_skill.py skills/public/my-skill ./dist
"""
import sys
@@ -25,6 +25,14 @@ def _is_within(path: Path, root: Path) -> bool:
return False
+def _cleanup_partial_archive(skill_filename: Path) -> None:
+ try:
+ if skill_filename.exists():
+ skill_filename.unlink()
+ except OSError:
+ pass
+
+
def package_skill(skill_path, output_dir=None):
"""
Package a skill folder into a .skill file.
@@ -74,49 +82,56 @@ def package_skill(skill_path, output_dir=None):
EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"}
+ files_to_package = []
+ resolved_archive = skill_filename.resolve()
+
+ for file_path in skill_path.rglob("*"):
+ # Fail closed on symlinks so the packaged contents are explicit and predictable.
+ if file_path.is_symlink():
+ print(f"[ERROR] Symlink not allowed in packaged skill: {file_path}")
+ _cleanup_partial_archive(skill_filename)
+ return None
+
+ rel_parts = file_path.relative_to(skill_path).parts
+ if any(part in EXCLUDED_DIRS for part in rel_parts):
+ continue
+
+ if file_path.is_file():
+ resolved_file = file_path.resolve()
+ if not _is_within(resolved_file, skill_path):
+ print(f"[ERROR] File escapes skill root: {file_path}")
+ _cleanup_partial_archive(skill_filename)
+ return None
+ # If output lives under skill_path, avoid writing archive into itself.
+ if resolved_file == resolved_archive:
+ print(f"[WARN] Skipping output archive: {file_path}")
+ continue
+ files_to_package.append(file_path)
+
# Create the .skill file (zip format)
try:
with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf:
- # Walk through the skill directory
- for file_path in skill_path.rglob("*"):
- # Security: never follow or package symlinks.
- if file_path.is_symlink():
- print(f"[WARN] Skipping symlink: {file_path}")
- continue
-
- rel_parts = file_path.relative_to(skill_path).parts
- if any(part in EXCLUDED_DIRS for part in rel_parts):
- continue
-
- if file_path.is_file():
- resolved_file = file_path.resolve()
- if not _is_within(resolved_file, skill_path):
- print(f"[ERROR] File escapes skill root: {file_path}")
- return None
- # If output lives under skill_path, avoid writing archive into itself.
- if resolved_file == skill_filename.resolve():
- print(f"[WARN] Skipping output archive: {file_path}")
- continue
-
- # Calculate the relative path within the zip.
- arcname = Path(skill_name) / file_path.relative_to(skill_path)
- zipf.write(file_path, arcname)
- print(f" Added: {arcname}")
+ for file_path in files_to_package:
+ # Calculate the relative path within the zip.
+ arcname = Path(skill_name) / file_path.relative_to(skill_path)
+ zipf.write(file_path, arcname)
+ print(f" Added: {arcname}")
print(f"\n[OK] Successfully packaged skill to: {skill_filename}")
return skill_filename
except Exception as e:
+ _cleanup_partial_archive(skill_filename)
print(f"[ERROR] Error creating .skill file: {e}")
return None
def main():
if len(sys.argv) < 2:
- print("Usage: python utils/package_skill.py [output-directory]")
+ print("Usage: python package_skill.py [output-directory]")
print("\nExample:")
- print(" python utils/package_skill.py skills/public/my-skill")
- print(" python utils/package_skill.py skills/public/my-skill ./dist")
+ print(" python package_skill.py skills/public/my-skill")
+ print(" python package_skill.py skills/public/my-skill ./dist")
sys.exit(1)
skill_path = sys.argv[1]
diff --git a/nanobot/skills/skill-creator/scripts/quick_validate.py b/nanobot/skills/skill-creator/scripts/quick_validate.py
new file mode 100644
index 0000000..03d246d
--- /dev/null
+++ b/nanobot/skills/skill-creator/scripts/quick_validate.py
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+"""
+Minimal validator for nanobot skill folders.
+"""
+
+import re
+import sys
+from pathlib import Path
+from typing import Optional
+
+try:
+ import yaml
+except ModuleNotFoundError:
+ yaml = None
+
+MAX_SKILL_NAME_LENGTH = 64
+ALLOWED_FRONTMATTER_KEYS = {
+ "name",
+ "description",
+ "metadata",
+ "always",
+ "license",
+ "allowed-tools",
+}
+ALLOWED_RESOURCE_DIRS = {"scripts", "references", "assets"}
+PLACEHOLDER_MARKERS = ("[todo", "todo:")
+
+
+def _extract_frontmatter(content: str) -> Optional[str]:
+ lines = content.splitlines()
+ if not lines or lines[0].strip() != "---":
+ return None
+ for i in range(1, len(lines)):
+ if lines[i].strip() == "---":
+ return "\n".join(lines[1:i])
+ return None
+
+
+def _parse_simple_frontmatter(frontmatter_text: str) -> Optional[dict[str, str]]:
+ """Fallback parser for simple frontmatter when PyYAML is unavailable."""
+ parsed: dict[str, str] = {}
+ current_key: Optional[str] = None
+ multiline_key: Optional[str] = None
+
+ for raw_line in frontmatter_text.splitlines():
+ stripped = raw_line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+
+ is_indented = raw_line[:1].isspace()
+ if is_indented:
+ if current_key is None:
+ return None
+ current_value = parsed[current_key]
+ parsed[current_key] = f"{current_value}\n{stripped}" if current_value else stripped
+ continue
+
+ if ":" not in stripped:
+ return None
+
+ key, value = stripped.split(":", 1)
+ key = key.strip()
+ value = value.strip()
+ if not key:
+ return None
+
+ if value in {"|", ">"}:
+ parsed[key] = ""
+ current_key = key
+ multiline_key = key
+ continue
+
+ if (value.startswith('"') and value.endswith('"')) or (
+ value.startswith("'") and value.endswith("'")
+ ):
+ value = value[1:-1]
+ parsed[key] = value
+ current_key = key
+ multiline_key = None
+
+ if multiline_key is not None and multiline_key not in parsed:
+ return None
+ return parsed
+
+
+def _load_frontmatter(frontmatter_text: str) -> tuple[Optional[dict], Optional[str]]:
+ if yaml is not None:
+ try:
+ frontmatter = yaml.safe_load(frontmatter_text)
+ except yaml.YAMLError as exc:
+ return None, f"Invalid YAML in frontmatter: {exc}"
+ if not isinstance(frontmatter, dict):
+ return None, "Frontmatter must be a YAML dictionary"
+ return frontmatter, None
+
+ frontmatter = _parse_simple_frontmatter(frontmatter_text)
+ if frontmatter is None:
+ return None, "Invalid YAML in frontmatter: unsupported syntax without PyYAML installed"
+ return frontmatter, None
+
+
+def _validate_skill_name(name: str, folder_name: str) -> Optional[str]:
+ if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
+ return (
+ f"Name '{name}' should be hyphen-case "
+ "(lowercase letters, digits, and single hyphens only)"
+ )
+ if len(name) > MAX_SKILL_NAME_LENGTH:
+ return (
+ f"Name is too long ({len(name)} characters). "
+ f"Maximum is {MAX_SKILL_NAME_LENGTH} characters."
+ )
+ if name != folder_name:
+ return f"Skill name '{name}' must match directory name '{folder_name}'"
+ return None
+
+
+def _validate_description(description: str) -> Optional[str]:
+ trimmed = description.strip()
+ if not trimmed:
+ return "Description cannot be empty"
+ lowered = trimmed.lower()
+ if any(marker in lowered for marker in PLACEHOLDER_MARKERS):
+ return "Description still contains TODO placeholder text"
+ if "<" in trimmed or ">" in trimmed:
+ return "Description cannot contain angle brackets (< or >)"
+ if len(trimmed) > 1024:
+ return f"Description is too long ({len(trimmed)} characters). Maximum is 1024 characters."
+ return None
+
+
+def validate_skill(skill_path):
+ """Validate a skill folder structure and required frontmatter."""
+ skill_path = Path(skill_path).resolve()
+
+ if not skill_path.exists():
+ return False, f"Skill folder not found: {skill_path}"
+ if not skill_path.is_dir():
+ return False, f"Path is not a directory: {skill_path}"
+
+ skill_md = skill_path / "SKILL.md"
+ if not skill_md.exists():
+ return False, "SKILL.md not found"
+
+ try:
+ content = skill_md.read_text(encoding="utf-8")
+ except OSError as exc:
+ return False, f"Could not read SKILL.md: {exc}"
+
+ frontmatter_text = _extract_frontmatter(content)
+ if frontmatter_text is None:
+ return False, "Invalid frontmatter format"
+
+ frontmatter, error = _load_frontmatter(frontmatter_text)
+ if error:
+ return False, error
+
+ unexpected_keys = sorted(set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS)
+ if unexpected_keys:
+ allowed = ", ".join(sorted(ALLOWED_FRONTMATTER_KEYS))
+ unexpected = ", ".join(unexpected_keys)
+ return (
+ False,
+ f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}",
+ )
+
+ if "name" not in frontmatter:
+ return False, "Missing 'name' in frontmatter"
+ if "description" not in frontmatter:
+ return False, "Missing 'description' in frontmatter"
+
+ name = frontmatter["name"]
+ if not isinstance(name, str):
+ return False, f"Name must be a string, got {type(name).__name__}"
+ name_error = _validate_skill_name(name.strip(), skill_path.name)
+ if name_error:
+ return False, name_error
+
+ description = frontmatter["description"]
+ if not isinstance(description, str):
+ return False, f"Description must be a string, got {type(description).__name__}"
+ description_error = _validate_description(description)
+ if description_error:
+ return False, description_error
+
+ always = frontmatter.get("always")
+ if always is not None and not isinstance(always, bool):
+ return False, f"'always' must be a boolean, got {type(always).__name__}"
+
+ for child in skill_path.iterdir():
+ if child.name == "SKILL.md":
+ continue
+ if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS:
+ continue
+ if child.is_symlink():
+ continue
+ return (
+ False,
+ f"Unexpected file or directory in skill root: {child.name}. "
+ "Only SKILL.md, scripts/, references/, and assets/ are allowed.",
+ )
+
+ return True, "Skill is valid!"
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ print("Usage: python quick_validate.py ")
+ sys.exit(1)
+
+ valid, message = validate_skill(sys.argv[1])
+ print(message)
+ sys.exit(0 if valid else 1)
diff --git a/tests/test_skill_creator_scripts.py b/tests/test_skill_creator_scripts.py
new file mode 100644
index 0000000..4207c6f
--- /dev/null
+++ b/tests/test_skill_creator_scripts.py
@@ -0,0 +1,127 @@
+import importlib
+import shutil
+import sys
+import zipfile
+from pathlib import Path
+
+
+SCRIPT_DIR = Path("nanobot/skills/skill-creator/scripts").resolve()
+if str(SCRIPT_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPT_DIR))
+
+init_skill = importlib.import_module("init_skill")
+package_skill = importlib.import_module("package_skill")
+quick_validate = importlib.import_module("quick_validate")
+
+
+def test_init_skill_creates_expected_files(tmp_path: Path) -> None:
+ skill_dir = init_skill.init_skill(
+ "demo-skill",
+ tmp_path,
+ ["scripts", "references", "assets"],
+ include_examples=True,
+ )
+
+ assert skill_dir == tmp_path / "demo-skill"
+ assert (skill_dir / "SKILL.md").exists()
+ assert (skill_dir / "scripts" / "example.py").exists()
+ assert (skill_dir / "references" / "api_reference.md").exists()
+ assert (skill_dir / "assets" / "example_asset.txt").exists()
+
+
+def test_validate_skill_accepts_existing_skill_creator() -> None:
+ valid, message = quick_validate.validate_skill(
+ Path("nanobot/skills/skill-creator").resolve()
+ )
+
+ assert valid, message
+
+
+def test_validate_skill_rejects_placeholder_description(tmp_path: Path) -> None:
+ skill_dir = tmp_path / "placeholder-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\n"
+ "name: placeholder-skill\n"
+ 'description: "[TODO: fill me in]"\n'
+ "---\n"
+ "# Placeholder\n",
+ encoding="utf-8",
+ )
+
+ valid, message = quick_validate.validate_skill(skill_dir)
+
+ assert not valid
+ assert "TODO placeholder" in message
+
+
+def test_validate_skill_rejects_root_files_outside_allowed_dirs(tmp_path: Path) -> None:
+ skill_dir = tmp_path / "bad-root-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\n"
+ "name: bad-root-skill\n"
+ "description: Valid description\n"
+ "---\n"
+ "# Skill\n",
+ encoding="utf-8",
+ )
+ (skill_dir / "README.md").write_text("extra\n", encoding="utf-8")
+
+ valid, message = quick_validate.validate_skill(skill_dir)
+
+ assert not valid
+ assert "Unexpected file or directory in skill root" in message
+
+
+def test_package_skill_creates_archive(tmp_path: Path) -> None:
+ skill_dir = tmp_path / "package-me"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\n"
+ "name: package-me\n"
+ "description: Package this skill.\n"
+ "---\n"
+ "# Skill\n",
+ encoding="utf-8",
+ )
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir()
+ (scripts_dir / "helper.py").write_text("print('ok')\n", encoding="utf-8")
+
+ archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist")
+
+ assert archive_path == (tmp_path / "dist" / "package-me.skill")
+ assert archive_path.exists()
+ with zipfile.ZipFile(archive_path, "r") as archive:
+ names = set(archive.namelist())
+ assert "package-me/SKILL.md" in names
+ assert "package-me/scripts/helper.py" in names
+
+
+def test_package_skill_rejects_symlink(tmp_path: Path) -> None:
+ skill_dir = tmp_path / "symlink-skill"
+ skill_dir.mkdir()
+ (skill_dir / "SKILL.md").write_text(
+ "---\n"
+ "name: symlink-skill\n"
+ "description: Reject symlinks during packaging.\n"
+ "---\n"
+ "# Skill\n",
+ encoding="utf-8",
+ )
+ scripts_dir = skill_dir / "scripts"
+ scripts_dir.mkdir()
+ target = tmp_path / "outside.txt"
+ target.write_text("secret\n", encoding="utf-8")
+ link = scripts_dir / "outside.txt"
+
+ try:
+ link.symlink_to(target)
+ except (OSError, NotImplementedError):
+ return
+
+ archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist")
+
+ assert archive_path is None
+ assert not (tmp_path / "dist" / "symlink-skill.skill").exists()
From b0a5435b8720a5968e683ce5aa82a8b16e614452 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Mar 2026 10:10:37 +0000
Subject: [PATCH 088/124] refactor(llm): share transient retry across agent
paths
---
nanobot/agent/loop.py | 29 +-------
nanobot/agent/memory.py | 2 +-
nanobot/agent/subagent.py | 2 +-
nanobot/heartbeat/service.py | 2 +-
nanobot/providers/base.py | 84 ++++++++++++++++++++++
tests/test_heartbeat_service.py | 47 +++++++++++-
tests/test_memory_consolidation_types.py | 50 ++++++++++++-
tests/test_provider_retry.py | 92 ++++++++++++++++++++++++
8 files changed, 274 insertions(+), 34 deletions(-)
create mode 100644 tests/test_provider_retry.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index b67baae..fcbc880 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -159,33 +159,6 @@ class AgentLoop:
if hasattr(tool, "set_context"):
tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
- _RETRY_DELAYS = (1, 2, 4) # seconds — exponential backoff for transient LLM errors
-
- async def _chat_with_retry(self, **kwargs: Any) -> Any:
- """Call provider.chat() with retry on transient errors (429, 5xx, network)."""
- from nanobot.providers.base import LLMResponse
-
- last_response: LLMResponse | None = None
- for attempt, delay in enumerate(self._RETRY_DELAYS):
- response = await self.provider.chat(**kwargs)
- if response.finish_reason != "error":
- return response
- # Check if the error looks transient (rate limit, server error, network)
- err = (response.content or "").lower()
- is_transient = any(kw in err for kw in (
- "429", "rate limit", "500", "502", "503", "504",
- "overloaded", "timeout", "connection", "server error",
- ))
- if not is_transient:
- return response # permanent error (400, 401, etc.) — don't retry
- last_response = response
- logger.warning("LLM transient error (attempt {}/{}), retrying in {}s: {}",
- attempt + 1, len(self._RETRY_DELAYS), delay, err[:120])
- await asyncio.sleep(delay)
- # All retries exhausted — make one final attempt
- response = await self.provider.chat(**kwargs)
- return response if response.finish_reason != "error" else (last_response or response)
-
@staticmethod
def _strip_think(text: str | None) -> str | None:
"""Remove … blocks that some models embed in content."""
@@ -218,7 +191,7 @@ class AgentLoop:
while iteration < self.max_iterations:
iteration += 1
- response = await self._chat_with_retry(
+ response = await self.provider.chat_with_retry(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model,
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index 21fe77d..66efec2 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -111,7 +111,7 @@ class MemoryStore:
{chr(10).join(lines)}"""
try:
- response = await provider.chat(
+ response = await provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
{"role": "user", "content": prompt},
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index f2d6ee5..f9eda1f 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -123,7 +123,7 @@ class SubagentManager:
while iteration < max_iterations:
iteration += 1
- response = await self.provider.chat(
+ response = await self.provider.chat_with_retry(
messages=messages,
tools=tools.get_definitions(),
model=self.model,
diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py
index e534017..831ae85 100644
--- a/nanobot/heartbeat/service.py
+++ b/nanobot/heartbeat/service.py
@@ -87,7 +87,7 @@ class HeartbeatService:
Returns (action, tasks) where action is 'skip' or 'run'.
"""
- response = await self.provider.chat(
+ response = await self.provider.chat_with_retry(
messages=[
{"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."},
{"role": "user", "content": (
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index 0f73544..a3b6c47 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -1,9 +1,12 @@
"""Base LLM provider interface."""
+import asyncio
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
+from loguru import logger
+
@dataclass
class ToolCallRequest:
@@ -37,6 +40,22 @@ class LLMProvider(ABC):
while maintaining a consistent interface.
"""
+ _CHAT_RETRY_DELAYS = (1, 2, 4)
+ _TRANSIENT_ERROR_MARKERS = (
+ "429",
+ "rate limit",
+ "500",
+ "502",
+ "503",
+ "504",
+ "overloaded",
+ "timeout",
+ "timed out",
+ "connection",
+ "server error",
+ "temporarily unavailable",
+ )
+
def __init__(self, api_key: str | None = None, api_base: str | None = None):
self.api_key = api_key
self.api_base = api_base
@@ -126,6 +145,71 @@ class LLMProvider(ABC):
"""
pass
+ @classmethod
+ def _is_transient_error(cls, content: str | None) -> bool:
+ err = (content or "").lower()
+ return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
+
+ async def chat_with_retry(
+ self,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+ model: str | None = None,
+ max_tokens: int = 4096,
+ temperature: float = 0.7,
+ reasoning_effort: str | None = None,
+ ) -> LLMResponse:
+ """Call chat() with retry on transient provider failures."""
+ for attempt, delay in enumerate(self._CHAT_RETRY_DELAYS, start=1):
+ try:
+ response = await self.chat(
+ messages=messages,
+ tools=tools,
+ model=model,
+ max_tokens=max_tokens,
+ temperature=temperature,
+ reasoning_effort=reasoning_effort,
+ )
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ response = LLMResponse(
+ content=f"Error calling LLM: {exc}",
+ finish_reason="error",
+ )
+
+ if response.finish_reason != "error":
+ return response
+ if not self._is_transient_error(response.content):
+ return response
+
+ err = (response.content or "").lower()
+ logger.warning(
+ "LLM transient error (attempt {}/{}), retrying in {}s: {}",
+ attempt,
+ len(self._CHAT_RETRY_DELAYS),
+ delay,
+ err[:120],
+ )
+ await asyncio.sleep(delay)
+
+ try:
+ return await self.chat(
+ messages=messages,
+ tools=tools,
+ model=model,
+ max_tokens=max_tokens,
+ temperature=temperature,
+ reasoning_effort=reasoning_effort,
+ )
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc:
+ return LLMResponse(
+ content=f"Error calling LLM: {exc}",
+ finish_reason="error",
+ )
+
@abstractmethod
def get_default_model(self) -> str:
"""Get the default model for this provider."""
diff --git a/tests/test_heartbeat_service.py b/tests/test_heartbeat_service.py
index c5478af..9ce8912 100644
--- a/tests/test_heartbeat_service.py
+++ b/tests/test_heartbeat_service.py
@@ -3,18 +3,24 @@ import asyncio
import pytest
from nanobot.heartbeat.service import HeartbeatService
-from nanobot.providers.base import LLMResponse, ToolCallRequest
+from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
-class DummyProvider:
+class DummyProvider(LLMProvider):
def __init__(self, responses: list[LLMResponse]):
+ super().__init__()
self._responses = list(responses)
+ self.calls = 0
async def chat(self, *args, **kwargs) -> LLMResponse:
+ self.calls += 1
if self._responses:
return self._responses.pop(0)
return LLMResponse(content="", tool_calls=[])
+ def get_default_model(self) -> str:
+ return "test-model"
+
@pytest.mark.asyncio
async def test_start_is_idempotent(tmp_path) -> None:
@@ -115,3 +121,40 @@ async def test_trigger_now_returns_none_when_decision_is_skip(tmp_path) -> None:
)
assert await service.trigger_now() is None
+
+
+@pytest.mark.asyncio
+async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatch) -> None:
+ provider = DummyProvider([
+ LLMResponse(content="429 rate limit", finish_reason="error"),
+ LLMResponse(
+ content="",
+ tool_calls=[
+ ToolCallRequest(
+ id="hb_1",
+ name="heartbeat",
+ arguments={"action": "run", "tasks": "check open tasks"},
+ )
+ ],
+ ),
+ ])
+
+ delays: list[int] = []
+
+ async def _fake_sleep(delay: int) -> None:
+ delays.append(delay)
+
+ monkeypatch.setattr(asyncio, "sleep", _fake_sleep)
+
+ service = HeartbeatService(
+ workspace=tmp_path,
+ provider=provider,
+ model="openai/gpt-4o-mini",
+ )
+
+ action, tasks = await service._decide("heartbeat content")
+
+ assert action == "run"
+ assert tasks == "check open tasks"
+ assert provider.calls == 2
+ assert delays == [1]
diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py
index ff15584..2605bf7 100644
--- a/tests/test_memory_consolidation_types.py
+++ b/tests/test_memory_consolidation_types.py
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.memory import MemoryStore
-from nanobot.providers.base import LLMResponse, ToolCallRequest
+from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
def _make_session(message_count: int = 30, memory_window: int = 50):
@@ -43,6 +43,22 @@ def _make_tool_response(history_entry, memory_update):
)
+class ScriptedProvider(LLMProvider):
+ def __init__(self, responses: list[LLMResponse]):
+ super().__init__()
+ self._responses = list(responses)
+ self.calls = 0
+
+ async def chat(self, *args, **kwargs) -> LLMResponse:
+ self.calls += 1
+ if self._responses:
+ return self._responses.pop(0)
+ return LLMResponse(content="", tool_calls=[])
+
+ def get_default_model(self) -> str:
+ return "test-model"
+
+
class TestMemoryConsolidationTypeHandling:
"""Test that consolidation handles various argument types correctly."""
@@ -57,6 +73,7 @@ class TestMemoryConsolidationTypeHandling:
memory_update="# Memory\nUser likes testing.",
)
)
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
@@ -77,6 +94,7 @@ class TestMemoryConsolidationTypeHandling:
memory_update={"facts": ["User likes testing"], "topics": ["testing"]},
)
)
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
@@ -112,6 +130,7 @@ class TestMemoryConsolidationTypeHandling:
],
)
provider.chat = AsyncMock(return_value=response)
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
@@ -127,6 +146,7 @@ class TestMemoryConsolidationTypeHandling:
provider.chat = AsyncMock(
return_value=LLMResponse(content="I summarized the conversation.", tool_calls=[])
)
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
@@ -139,6 +159,7 @@ class TestMemoryConsolidationTypeHandling:
"""Consolidation should be a no-op when messages < keep_count."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=10)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
@@ -167,6 +188,7 @@ class TestMemoryConsolidationTypeHandling:
],
)
provider.chat = AsyncMock(return_value=response)
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
@@ -192,6 +214,7 @@ class TestMemoryConsolidationTypeHandling:
],
)
provider.chat = AsyncMock(return_value=response)
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
@@ -215,8 +238,33 @@ class TestMemoryConsolidationTypeHandling:
],
)
provider.chat = AsyncMock(return_value=response)
+ provider.chat_with_retry = provider.chat
session = _make_session(message_count=60)
result = await store.consolidate(session, provider, "test-model", memory_window=50)
assert result is False
+
+ @pytest.mark.asyncio
+ async def test_retries_transient_error_then_succeeds(self, tmp_path: Path, monkeypatch) -> None:
+ store = MemoryStore(tmp_path)
+ provider = ScriptedProvider([
+ LLMResponse(content="503 server error", finish_reason="error"),
+ _make_tool_response(
+ history_entry="[2026-01-01] User discussed testing.",
+ memory_update="# Memory\nUser likes testing.",
+ ),
+ ])
+ session = _make_session(message_count=60)
+ delays: list[int] = []
+
+ async def _fake_sleep(delay: int) -> None:
+ delays.append(delay)
+
+ monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
+
+ result = await store.consolidate(session, provider, "test-model", memory_window=50)
+
+ assert result is True
+ assert provider.calls == 2
+ assert delays == [1]
diff --git a/tests/test_provider_retry.py b/tests/test_provider_retry.py
new file mode 100644
index 0000000..751ecc3
--- /dev/null
+++ b/tests/test_provider_retry.py
@@ -0,0 +1,92 @@
+import asyncio
+
+import pytest
+
+from nanobot.providers.base import LLMProvider, LLMResponse
+
+
+class ScriptedProvider(LLMProvider):
+ def __init__(self, responses):
+ super().__init__()
+ self._responses = list(responses)
+ self.calls = 0
+
+ async def chat(self, *args, **kwargs) -> LLMResponse:
+ self.calls += 1
+ response = self._responses.pop(0)
+ if isinstance(response, BaseException):
+ raise response
+ return response
+
+ def get_default_model(self) -> str:
+ return "test-model"
+
+
+@pytest.mark.asyncio
+async def test_chat_with_retry_retries_transient_error_then_succeeds(monkeypatch) -> None:
+ provider = ScriptedProvider([
+ LLMResponse(content="429 rate limit", finish_reason="error"),
+ LLMResponse(content="ok"),
+ ])
+ delays: list[int] = []
+
+ async def _fake_sleep(delay: int) -> None:
+ delays.append(delay)
+
+ monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
+
+ response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
+
+ assert response.finish_reason == "stop"
+ assert response.content == "ok"
+ assert provider.calls == 2
+ assert delays == [1]
+
+
+@pytest.mark.asyncio
+async def test_chat_with_retry_does_not_retry_non_transient_error(monkeypatch) -> None:
+ provider = ScriptedProvider([
+ LLMResponse(content="401 unauthorized", finish_reason="error"),
+ ])
+ delays: list[int] = []
+
+ async def _fake_sleep(delay: int) -> None:
+ delays.append(delay)
+
+ monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
+
+ response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
+
+ assert response.content == "401 unauthorized"
+ assert provider.calls == 1
+ assert delays == []
+
+
+@pytest.mark.asyncio
+async def test_chat_with_retry_returns_final_error_after_retries(monkeypatch) -> None:
+ provider = ScriptedProvider([
+ LLMResponse(content="429 rate limit a", finish_reason="error"),
+ LLMResponse(content="429 rate limit b", finish_reason="error"),
+ LLMResponse(content="429 rate limit c", finish_reason="error"),
+ LLMResponse(content="503 final server error", finish_reason="error"),
+ ])
+ delays: list[int] = []
+
+ async def _fake_sleep(delay: int) -> None:
+ delays.append(delay)
+
+ monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
+
+ response = await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
+
+ assert response.content == "503 final server error"
+ assert provider.calls == 4
+ assert delays == [1, 2, 4]
+
+
+@pytest.mark.asyncio
+async def test_chat_with_retry_preserves_cancelled_error() -> None:
+ provider = ScriptedProvider([asyncio.CancelledError()])
+
+ with pytest.raises(asyncio.CancelledError):
+ await provider.chat_with_retry(messages=[{"role": "user", "content": "hello"}])
From 947ed508ad876bdc227c27fd1b008b163ea830b3 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Mar 2026 10:13:46 +0000
Subject: [PATCH 089/124] chore: exclude skills from core agent line count
---
core_agent_lines.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/core_agent_lines.sh b/core_agent_lines.sh
index 3f5301a..df32394 100755
--- a/core_agent_lines.sh
+++ b/core_agent_lines.sh
@@ -15,7 +15,7 @@ root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
printf " %-16s %5s lines\n" "(root)" "$root"
echo ""
-total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l)
+total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" ! -path "*/skills/*" | xargs cat | wc -l)
echo " Core total: $total lines"
echo ""
-echo " (excludes: channels/, cli/, providers/)"
+echo " (excludes: channels/, cli/, providers/, skills/)"
From 808064e26bf03ad1b645b76af2181d3356d35e47 Mon Sep 17 00:00:00 2001
From: Nikolas de Hor
Date: Tue, 10 Mar 2026 13:45:05 -0300
Subject: [PATCH 090/124] fix: detect tilde paths in restrictToWorkspace shell
guard
_extract_absolute_paths() only matched paths starting with / or drive
letters, missing ~ paths that expand to the home directory. This
allowed agents to bypass restrictToWorkspace by using commands like
cat ~/.nanobot/config.json to access files outside the workspace.
Add tilde path extraction regex and use expanduser() before resolving.
Also switch from manual parent-chain check to is_relative_to() for
more robust path containment validation.
Fixes #1817
---
nanobot/agent/tools/shell.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py
index ce19920..b4a4044 100644
--- a/nanobot/agent/tools/shell.py
+++ b/nanobot/agent/tools/shell.py
@@ -143,10 +143,10 @@ class ExecTool(Tool):
for raw in self._extract_absolute_paths(cmd):
try:
- p = Path(raw.strip()).resolve()
+ p = Path(raw.strip()).expanduser().resolve()
except Exception:
continue
- if p.is_absolute() and cwd_path not in p.parents and p != cwd_path:
+ if not p.is_relative_to(cwd_path):
return "Error: Command blocked by safety guard (path outside working dir)"
return None
@@ -155,4 +155,5 @@ class ExecTool(Tool):
def _extract_absolute_paths(command: str) -> list[str]:
win_paths = re.findall(r"[A-Za-z]:\\[^\s\"'|><;]+", command) # Windows: C:\...
posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", command) # POSIX: /absolute only
- return win_paths + posix_paths
+ tilde_paths = re.findall(r"(?:^|[\s|>])(~[^\s\"'>]*)", command) # Tilde: ~/...
+ return win_paths + posix_paths + tilde_paths
From 2ffeb9295bdb4a5ef308498f60f45b2448ab48d2 Mon Sep 17 00:00:00 2001
From: lailoo
Date: Wed, 11 Mar 2026 00:47:09 +0800
Subject: [PATCH 091/124] fix(subagent): preserve reasoning_content in
assistant messages
Subagent's _run_subagent() was dropping reasoning_content and
thinking_blocks when building assistant messages for the conversation
history. Providers like Deepseek Reasoner require reasoning_content on
every assistant message when thinking mode is active, causing a 400
BadRequestError on the second LLM round-trip.
Align with the main AgentLoop which already preserves these fields via
ContextBuilder.add_assistant_message().
Closes #1834
---
nanobot/agent/subagent.py | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index f9eda1f..308e67d 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -145,11 +145,19 @@ class SubagentManager:
}
for tc in response.tool_calls
]
- messages.append({
+ assistant_msg: dict[str, Any] = {
"role": "assistant",
"content": response.content or "",
"tool_calls": tool_call_dicts,
- })
+ }
+ # Preserve reasoning_content for providers that require it
+ # (e.g. Deepseek Reasoner mandates this field on every
+ # assistant message when thinking mode is active).
+ if response.reasoning_content is not None:
+ assistant_msg["reasoning_content"] = response.reasoning_content
+ if response.thinking_blocks:
+ assistant_msg["thinking_blocks"] = response.thinking_blocks
+ messages.append(assistant_msg)
# Execute tools
for tool_call in response.tool_calls:
From 62ccda43b980d53c5ac7a79adf8edf43294f1fdb Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Tue, 10 Mar 2026 19:55:06 +0000
Subject: [PATCH 092/124] refactor(memory): switch consolidation to token-based
context windows
Move consolidation policy into MemoryConsolidator, keep backward compatibility for legacy config, and compress history by token budget instead of message count.
---
nanobot/agent/loop.py | 544 ++---------------------
nanobot/agent/memory.py | 243 +++++++---
nanobot/cli/commands.py | 26 +-
nanobot/config/schema.py | 32 +-
nanobot/session/manager.py | 20 +-
nanobot/utils/helpers.py | 85 ++++
pyproject.toml | 1 +
tests/test_commands.py | 33 ++
tests/test_config_migration.py | 88 ++++
tests/test_consolidate_offset.py | 297 ++-----------
tests/test_loop_consolidation_tokens.py | 190 ++++++++
tests/test_memory_consolidation_types.py | 51 +--
tests/test_message_tool_suppress.py | 10 +-
13 files changed, 709 insertions(+), 911 deletions(-)
create mode 100644 tests/test_config_migration.py
create mode 100644 tests/test_loop_consolidation_tokens.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index ba35a23..8605a09 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -11,18 +11,12 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
-try:
- import tiktoken # type: ignore
-except Exception: # pragma: no cover - optional dependency
- tiktoken = None
-
from nanobot.agent.context import ContextBuilder
+from nanobot.agent.memory import MemoryConsolidator
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.huggingface import HuggingFaceModelSearchTool
from nanobot.agent.tools.message import MessageTool
-from nanobot.agent.tools.model_config import ValidateDeployJSONTool, ValidateUsageYAMLTool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
@@ -60,11 +54,8 @@ class AgentLoop:
max_iterations: int = 40,
temperature: float = 0.1,
max_tokens: int = 4096,
- memory_window: int | None = None, # backward-compat only (unused)
reasoning_effort: str | None = None,
- max_tokens_input: int = 128_000,
- compression_start_ratio: float = 0.7,
- compression_target_ratio: float = 0.4,
+ context_window_tokens: int = 65_536,
brave_api_key: str | None = None,
web_proxy: str | None = None,
exec_config: ExecToolConfig | None = None,
@@ -82,18 +73,9 @@ class AgentLoop:
self.model = model or provider.get_default_model()
self.max_iterations = max_iterations
self.temperature = temperature
- # max_tokens: per-call output token cap (maxTokensOutput in config)
self.max_tokens = max_tokens
- # Keep legacy attribute for older call sites/tests; compression no longer uses it.
- self.memory_window = memory_window
self.reasoning_effort = reasoning_effort
- # max_tokens_input: model native context window (maxTokensInput in config)
- self.max_tokens_input = max_tokens_input
- # Token-based compression watermarks (fractions of available input budget)
- self.compression_start_ratio = compression_start_ratio
- self.compression_target_ratio = compression_target_ratio
- # Reserve tokens for safety margin
- self._reserve_tokens = 1000
+ self.context_window_tokens = context_window_tokens
self.brave_api_key = brave_api_key
self.web_proxy = web_proxy
self.exec_config = exec_config or ExecToolConfig()
@@ -123,382 +105,23 @@ class AgentLoop:
self._mcp_connected = False
self._mcp_connecting = False
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
- self._compression_tasks: dict[str, asyncio.Task] = {} # session_key -> task
- self._last_turn_prompt_tokens: int = 0
- self._last_turn_prompt_source: str = "none"
self._processing_lock = asyncio.Lock()
+ self.memory_consolidator = MemoryConsolidator(
+ workspace=workspace,
+ provider=provider,
+ model=self.model,
+ sessions=self.sessions,
+ context_window_tokens=context_window_tokens,
+ build_messages=self.context.build_messages,
+ get_tool_definitions=self.tools.get_definitions,
+ )
self._register_default_tools()
- @staticmethod
- def _estimate_prompt_tokens(
- messages: list[dict[str, Any]],
- tools: list[dict[str, Any]] | None = None,
- ) -> int:
- """Estimate prompt tokens with tiktoken (fallback only)."""
- if tiktoken is None:
- return 0
-
- try:
- enc = tiktoken.get_encoding("cl100k_base")
- parts: list[str] = []
- for msg in messages:
- content = msg.get("content")
- if isinstance(content, str):
- parts.append(content)
- elif isinstance(content, list):
- for part in content:
- if isinstance(part, dict) and part.get("type") == "text":
- txt = part.get("text", "")
- if txt:
- parts.append(txt)
- if tools:
- parts.append(json.dumps(tools, ensure_ascii=False))
- return len(enc.encode("\n".join(parts)))
- except Exception:
- return 0
-
- def _estimate_prompt_tokens_chain(
- self,
- messages: list[dict[str, Any]],
- tools: list[dict[str, Any]] | None = None,
- ) -> tuple[int, str]:
- """Unified prompt-token estimation: provider counter -> tiktoken."""
- provider_counter = getattr(self.provider, "estimate_prompt_tokens", None)
- if callable(provider_counter):
- try:
- tokens, source = provider_counter(messages, tools, self.model)
- if isinstance(tokens, (int, float)) and tokens > 0:
- return int(tokens), str(source or "provider_counter")
- except Exception:
- logger.debug("Provider token counter failed; fallback to tiktoken")
-
- estimated = self._estimate_prompt_tokens(messages, tools)
- if estimated > 0:
- return int(estimated), "tiktoken"
- return 0, "none"
-
- @staticmethod
- def _estimate_completion_tokens(content: str) -> int:
- """Estimate completion tokens with tiktoken (fallback only)."""
- if tiktoken is None:
- return 0
- try:
- enc = tiktoken.get_encoding("cl100k_base")
- return len(enc.encode(content or ""))
- except Exception:
- return 0
-
- def _get_compressed_until(self, session: Session) -> int:
- """Read/normalize compressed boundary and migrate old metadata format."""
- raw = session.metadata.get("_compressed_until", 0)
- try:
- compressed_until = int(raw)
- except (TypeError, ValueError):
- compressed_until = 0
-
- if compressed_until <= 0:
- ranges = session.metadata.get("_compressed_ranges")
- if isinstance(ranges, list):
- inferred = 0
- for item in ranges:
- if not isinstance(item, (list, tuple)) or len(item) != 2:
- continue
- try:
- inferred = max(inferred, int(item[1]))
- except (TypeError, ValueError):
- continue
- compressed_until = inferred
-
- compressed_until = max(0, min(compressed_until, len(session.messages)))
- session.metadata["_compressed_until"] = compressed_until
- # 兼容旧版本:一旦迁移出连续边界,就可以清理旧字段
- session.metadata.pop("_compressed_ranges", None)
- # 注意:不要删除 _cumulative_tokens,压缩逻辑需要它来跟踪累积 token 计数
- return compressed_until
-
- def _set_compressed_until(self, session: Session, idx: int) -> None:
- """Persist a contiguous compressed boundary."""
- session.metadata["_compressed_until"] = max(0, min(int(idx), len(session.messages)))
- session.metadata.pop("_compressed_ranges", None)
- # 注意:不要删除 _cumulative_tokens,压缩逻辑需要它来跟踪累积 token 计数
-
- @staticmethod
- def _estimate_message_tokens(message: dict[str, Any]) -> int:
- """Rough token estimate for a single persisted message."""
- content = message.get("content")
- parts: list[str] = []
- if isinstance(content, str):
- parts.append(content)
- elif isinstance(content, list):
- for part in content:
- if isinstance(part, dict) and part.get("type") == "text":
- txt = part.get("text", "")
- if txt:
- parts.append(txt)
- else:
- parts.append(json.dumps(part, ensure_ascii=False))
- elif content is not None:
- parts.append(json.dumps(content, ensure_ascii=False))
-
- for key in ("name", "tool_call_id"):
- val = message.get(key)
- if isinstance(val, str) and val:
- parts.append(val)
- if message.get("tool_calls"):
- parts.append(json.dumps(message["tool_calls"], ensure_ascii=False))
-
- payload = "\n".join(parts)
- if not payload:
- return 1
- if tiktoken is not None:
- try:
- enc = tiktoken.get_encoding("cl100k_base")
- return max(1, len(enc.encode(payload)))
- except Exception:
- pass
- return max(1, len(payload) // 4)
-
- def _pick_compression_chunk_by_tokens(
- self,
- session: Session,
- reduction_tokens: int,
- *,
- tail_keep: int = 12,
- ) -> tuple[int, int, int] | None:
- """
- Pick one contiguous old chunk so its estimated size is roughly enough
- to reduce `reduction_tokens`.
- """
- messages = session.messages
- start = self._get_compressed_until(session)
- if len(messages) - start <= tail_keep + 2:
- return None
-
- end_limit = len(messages) - tail_keep
- if end_limit - start < 2:
- return None
-
- target = max(1, reduction_tokens)
- end = start
- collected = 0
- while end < end_limit and collected < target:
- collected += self._estimate_message_tokens(messages[end])
- end += 1
-
- if end - start < 2:
- end = min(end_limit, start + 2)
- collected = sum(self._estimate_message_tokens(m) for m in messages[start:end])
- if end - start < 2:
- return None
- return start, end, collected
-
- def _estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
- """
- Estimate current full prompt tokens for this session view
- (system + compressed history view + runtime/user placeholder + tools).
- """
- history = self._build_compressed_history_view(session)
- channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
- probe_messages = self.context.build_messages(
- history=history,
- current_message="[token-probe]",
- channel=channel,
- chat_id=chat_id,
- )
- return self._estimate_prompt_tokens_chain(probe_messages, self.tools.get_definitions())
-
- async def _maybe_compress_history(
- self,
- session: Session,
- ) -> None:
- """
- End-of-turn policy:
- - Estimate current prompt usage from persisted session view.
- - If above start ratio, perform one best-effort compression chunk.
- """
- if not session.messages:
- self._set_compressed_until(session, 0)
- return
-
- budget = max(1, self.max_tokens_input - self.max_tokens - self._reserve_tokens)
- start_threshold = int(budget * self.compression_start_ratio)
- target_threshold = int(budget * self.compression_target_ratio)
- if target_threshold >= start_threshold:
- target_threshold = max(0, start_threshold - 1)
-
- # Prefer provider usage prompt tokens from the turn-ending call.
- # If unavailable, fall back to estimator chain.
- raw_prompt_tokens = session.metadata.get("_last_prompt_tokens")
- if isinstance(raw_prompt_tokens, (int, float)) and raw_prompt_tokens > 0:
- current_tokens = int(raw_prompt_tokens)
- token_source = str(session.metadata.get("_last_prompt_source") or "usage_prompt")
- else:
- current_tokens, token_source = self._estimate_session_prompt_tokens(session)
-
- current_ratio = current_tokens / budget if budget else 0.0
- if current_tokens <= 0:
- logger.debug("Compression skip {}: token estimate unavailable", session.key)
- return
- if current_tokens < start_threshold:
- logger.debug(
- "Compression idle {}: {}/{} ({:.1%}) via {}",
- session.key,
- current_tokens,
- budget,
- current_ratio,
- token_source,
- )
- return
- logger.info(
- "Compression trigger {}: {}/{} ({:.1%}) via {}",
- session.key,
- current_tokens,
- budget,
- current_ratio,
- token_source,
- )
-
- reduction_by_target = max(0, current_tokens - target_threshold)
- reduction_by_delta = max(1, start_threshold - target_threshold)
- reduction_need = max(reduction_by_target, reduction_by_delta)
-
- chunk_range = self._pick_compression_chunk_by_tokens(session, reduction_need, tail_keep=10)
- if chunk_range is None:
- logger.info("Compression skipped for {}: no compressible chunk", session.key)
- return
-
- start_idx, end_idx, estimated_chunk_tokens = chunk_range
- chunk = session.messages[start_idx:end_idx]
- if len(chunk) < 2:
- return
-
- logger.info(
- "Compression chunk {}: msgs {}-{} (count={}, est~{}, need~{})",
- session.key,
- start_idx,
- end_idx - 1,
- len(chunk),
- estimated_chunk_tokens,
- reduction_need,
- )
- success, _ = await self.context.memory.consolidate_chunk(
- chunk,
- self.provider,
- self.model,
- )
- if not success:
- logger.warning("Compression aborted for {}: consolidation failed", session.key)
- return
-
- self._set_compressed_until(session, end_idx)
- self.sessions.save(session)
-
- after_tokens, after_source = self._estimate_session_prompt_tokens(session)
- after_ratio = after_tokens / budget if budget else 0.0
- reduced = max(0, current_tokens - after_tokens)
- reduced_ratio = (reduced / current_tokens) if current_tokens > 0 else 0.0
- logger.info(
- "Compression done {}: {}/{} ({:.1%}) via {}, reduced={} ({:.1%})",
- session.key,
- after_tokens,
- budget,
- after_ratio,
- after_source,
- reduced,
- reduced_ratio,
- )
-
- def _schedule_background_compression(self, session_key: str) -> None:
- """Schedule best-effort background compression for a session."""
- existing = self._compression_tasks.get(session_key)
- if existing is not None and not existing.done():
- return
-
- async def _runner() -> None:
- session = self.sessions.get_or_create(session_key)
- try:
- await self._maybe_compress_history(session)
- except Exception:
- logger.exception("Background compression failed for {}", session_key)
-
- task = asyncio.create_task(_runner())
- self._compression_tasks[session_key] = task
-
- def _cleanup(t: asyncio.Task) -> None:
- cur = self._compression_tasks.get(session_key)
- if cur is t:
- self._compression_tasks.pop(session_key, None)
- try:
- t.result()
- except BaseException:
- pass
-
- task.add_done_callback(_cleanup)
-
- async def wait_for_background_compression(self, timeout_s: float | None = None) -> None:
- """Wait for currently scheduled compression tasks."""
- pending = [t for t in self._compression_tasks.values() if not t.done()]
- if not pending:
- return
-
- logger.info("Waiting for {} background compression task(s)", len(pending))
- waiter = asyncio.gather(*pending, return_exceptions=True)
- if timeout_s is None:
- await waiter
- return
-
- try:
- await asyncio.wait_for(waiter, timeout=timeout_s)
- except asyncio.TimeoutError:
- logger.warning(
- "Background compression wait timed out after {}s ({} task(s) still running)",
- timeout_s,
- len([t for t in self._compression_tasks.values() if not t.done()]),
- )
-
- def _build_compressed_history_view(
- self,
- session: Session,
- ) -> list[dict]:
- """Build non-destructive history view using the compressed boundary."""
- compressed_until = self._get_compressed_until(session)
- if compressed_until <= 0:
- return session.get_history(max_messages=0)
-
- notice_msg: dict[str, Any] = {
- "role": "assistant",
- "content": (
- "As your assistant, I have compressed earlier context. "
- "If you need details, please check memory/HISTORY.md."
- ),
- }
-
- tail: list[dict[str, Any]] = []
- for msg in session.messages[compressed_until:]:
- entry: dict[str, Any] = {"role": msg["role"], "content": msg.get("content", "")}
- for k in ("tool_calls", "tool_call_id", "name"):
- if k in msg:
- entry[k] = msg[k]
- tail.append(entry)
-
- # Drop leading non-user entries from tail to avoid orphan tool blocks.
- for i, m in enumerate(tail):
- if m.get("role") == "user":
- tail = tail[i:]
- break
- else:
- tail = []
-
- return [notice_msg, *tail]
-
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None
for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool):
self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir))
- self.tools.register(ValidateDeployJSONTool())
- self.tools.register(ValidateUsageYAMLTool())
- self.tools.register(HuggingFaceModelSearchTool())
self.tools.register(ExecTool(
working_dir=str(self.workspace),
timeout=self.exec_config.timeout,
@@ -563,24 +186,12 @@ class AgentLoop:
self,
initial_messages: list[dict],
on_progress: Callable[..., Awaitable[None]] | None = None,
- ) -> tuple[str | None, list[str], list[dict], int, str]:
- """
- Run the agent iteration loop.
-
- Returns:
- (final_content, tools_used, messages, total_tokens_this_turn, token_source)
- total_tokens_this_turn: total tokens (prompt + completion) for this turn
- token_source: provider_total / provider_sum / provider_prompt /
- provider_counter+tiktoken_completion / tiktoken / none
- """
+ ) -> tuple[str | None, list[str], list[dict]]:
+ """Run the agent iteration loop."""
messages = initial_messages
iteration = 0
final_content = None
tools_used: list[str] = []
- total_tokens_this_turn = 0
- token_source = "none"
- self._last_turn_prompt_tokens = 0
- self._last_turn_prompt_source = "none"
while iteration < self.max_iterations:
iteration += 1
@@ -596,63 +207,6 @@ class AgentLoop:
reasoning_effort=self.reasoning_effort,
)
- # Prefer provider usage from the turn-ending model call; fallback to tiktoken.
- # Calculate total tokens (prompt + completion) for this turn.
- usage = response.usage or {}
- t_tokens = usage.get("total_tokens")
- p_tokens = usage.get("prompt_tokens")
- c_tokens = usage.get("completion_tokens")
-
- if isinstance(t_tokens, (int, float)) and t_tokens > 0:
- total_tokens_this_turn = int(t_tokens)
- token_source = "provider_total"
- if isinstance(p_tokens, (int, float)) and p_tokens > 0:
- self._last_turn_prompt_tokens = int(p_tokens)
- self._last_turn_prompt_source = "usage_prompt"
- elif isinstance(c_tokens, (int, float)):
- prompt_derived = int(t_tokens) - int(c_tokens)
- if prompt_derived > 0:
- self._last_turn_prompt_tokens = prompt_derived
- self._last_turn_prompt_source = "usage_total_minus_completion"
- elif isinstance(p_tokens, (int, float)) and isinstance(c_tokens, (int, float)):
- # If we have both prompt and completion tokens, sum them
- total_tokens_this_turn = int(p_tokens) + int(c_tokens)
- token_source = "provider_sum"
- if p_tokens > 0:
- self._last_turn_prompt_tokens = int(p_tokens)
- self._last_turn_prompt_source = "usage_prompt"
- elif isinstance(p_tokens, (int, float)) and p_tokens > 0:
- # Fallback: use prompt tokens only (completion might be 0 for tool calls)
- total_tokens_this_turn = int(p_tokens)
- token_source = "provider_prompt"
- self._last_turn_prompt_tokens = int(p_tokens)
- self._last_turn_prompt_source = "usage_prompt"
- else:
- # Estimate with unified chain (provider counter -> tiktoken), plus completion tiktoken.
- estimated_prompt, prompt_source = self._estimate_prompt_tokens_chain(messages, tool_defs)
- estimated_completion = self._estimate_completion_tokens(response.content or "")
- total_tokens_this_turn = estimated_prompt + estimated_completion
- if estimated_prompt > 0:
- self._last_turn_prompt_tokens = int(estimated_prompt)
- self._last_turn_prompt_source = str(prompt_source or "tiktoken")
- if total_tokens_this_turn > 0:
- token_source = (
- "tiktoken"
- if prompt_source == "tiktoken"
- else f"{prompt_source}+tiktoken_completion"
- )
- if total_tokens_this_turn <= 0:
- total_tokens_this_turn = 0
- token_source = "none"
-
- logger.debug(
- "Turn token usage: source={}, total={}, prompt={}, completion={}",
- token_source,
- total_tokens_this_turn,
- p_tokens if isinstance(p_tokens, (int, float)) else None,
- c_tokens if isinstance(c_tokens, (int, float)) else None,
- )
-
if response.has_tool_calls:
if on_progress:
thought = self._strip_think(response.content)
@@ -707,7 +261,7 @@ class AgentLoop:
"without completing the task. You can try breaking the task into smaller steps."
)
- return final_content, tools_used, messages, total_tokens_this_turn, token_source
+ return final_content, tools_used, messages
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
@@ -732,9 +286,6 @@ class AgentLoop:
"""Cancel all active tasks and subagents for the session."""
tasks = self._active_tasks.pop(msg.session_key, [])
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
- comp = self._compression_tasks.get(msg.session_key)
- if comp is not None and not comp.done() and comp.cancel():
- cancelled += 1
for t in tasks:
try:
await t
@@ -781,9 +332,6 @@ class AgentLoop:
def stop(self) -> None:
"""Stop the agent loop."""
self._running = False
- for task in list(self._compression_tasks.values()):
- if not task.done():
- task.cancel()
logger.info("Agent loop stopping")
async def _process_message(
@@ -800,22 +348,17 @@ class AgentLoop:
logger.info("Processing system message from {}", msg.sender_id)
key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
+ await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
- history = self._build_compressed_history_view(session)
+ history = session.get_history(max_messages=0)
messages = self.context.build_messages(
history=history,
current_message=msg.content, channel=channel, chat_id=chat_id,
)
- final_content, _, all_msgs, _, _ = await self._run_agent_loop(messages)
- if self._last_turn_prompt_tokens > 0:
- session.metadata["_last_prompt_tokens"] = self._last_turn_prompt_tokens
- session.metadata["_last_prompt_source"] = self._last_turn_prompt_source
- else:
- session.metadata.pop("_last_prompt_tokens", None)
- session.metadata.pop("_last_prompt_source", None)
+ final_content, _, all_msgs = await self._run_agent_loop(messages)
self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
- self._schedule_background_compression(session.key)
+ await self.memory_consolidator.maybe_consolidate_by_tokens(session)
return OutboundMessage(channel=channel, chat_id=chat_id,
content=final_content or "Background task completed.")
@@ -829,19 +372,12 @@ class AgentLoop:
cmd = msg.content.strip().lower()
if cmd == "/new":
try:
- # 在清空会话前,将当前完整对话做一次归档压缩到 MEMORY/HISTORY 中
- if session.messages:
- ok, _ = await self.context.memory.consolidate_chunk(
- session.messages,
- self.provider,
- self.model,
+ if not await self.memory_consolidator.archive_unconsolidated(session):
+ return OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="Memory archival failed, session not cleared. Please try again.",
)
- if not ok:
- return OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="Memory archival failed, session not cleared. Please try again.",
- )
except Exception:
logger.exception("/new archival failed for {}", session.key)
return OutboundMessage(
@@ -859,23 +395,20 @@ class AgentLoop:
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands")
+ await self.memory_consolidator.maybe_consolidate_by_tokens(session)
+
self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id"))
if message_tool := self.tools.get("message"):
if isinstance(message_tool, MessageTool):
message_tool.start_turn()
- # 正常对话:使用压缩后的历史视图(压缩在回合结束后进行)
- history = self._build_compressed_history_view(session)
+ history = session.get_history(max_messages=0)
initial_messages = self.context.build_messages(
history=history,
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel, chat_id=msg.chat_id,
)
- # Add [CRON JOB] identifier for cron sessions (session_key starts with "cron:")
- if session_key and session_key.startswith("cron:"):
- if initial_messages and initial_messages[0].get("role") == "system":
- initial_messages[0]["content"] = f"[CRON JOB] {initial_messages[0]['content']}"
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
meta = dict(msg.metadata or {})
@@ -885,23 +418,16 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, content=content, metadata=meta,
))
- final_content, _, all_msgs, total_tokens_this_turn, token_source = await self._run_agent_loop(
+ final_content, _, all_msgs = await self._run_agent_loop(
initial_messages, on_progress=on_progress or _bus_progress,
)
if final_content is None:
final_content = "I've completed processing but have no response to give."
- if self._last_turn_prompt_tokens > 0:
- session.metadata["_last_prompt_tokens"] = self._last_turn_prompt_tokens
- session.metadata["_last_prompt_source"] = self._last_turn_prompt_source
- else:
- session.metadata.pop("_last_prompt_tokens", None)
- session.metadata.pop("_last_prompt_source", None)
-
- self._save_turn(session, all_msgs, 1 + len(history), total_tokens_this_turn)
+ self._save_turn(session, all_msgs, 1 + len(history))
self.sessions.save(session)
- self._schedule_background_compression(session.key)
+ await self.memory_consolidator.maybe_consolidate_by_tokens(session)
if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn:
return None
@@ -913,7 +439,7 @@ class AgentLoop:
metadata=msg.metadata or {},
)
- def _save_turn(self, session: Session, messages: list[dict], skip: int, total_tokens_this_turn: int = 0) -> None:
+ def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
for m in messages[skip:]:
@@ -947,14 +473,6 @@ class AgentLoop:
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
session.updated_at = datetime.now()
-
- # Update cumulative token count for compression tracking
- if total_tokens_this_turn > 0:
- current_cumulative = session.metadata.get("_cumulative_tokens", 0)
- if isinstance(current_cumulative, (int, float)):
- session.metadata["_cumulative_tokens"] = int(current_cumulative) + total_tokens_this_turn
- else:
- session.metadata["_cumulative_tokens"] = total_tokens_this_turn
async def process_direct(
self,
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index e29788a..cd5f54f 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -2,17 +2,19 @@
from __future__ import annotations
+import asyncio
import json
+import weakref
from pathlib import Path
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any, Callable
from loguru import logger
-from nanobot.utils.helpers import ensure_dir
+from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
- from nanobot.session.manager import Session
+ from nanobot.session.manager import Session, SessionManager
_SAVE_MEMORY_TOOL = [
@@ -26,7 +28,7 @@ _SAVE_MEMORY_TOOL = [
"properties": {
"history_entry": {
"type": "string",
- "description": "A paragraph (2-5 sentences) summarizing key events/decisions/topics. "
+ "description": "A paragraph summarizing key events/decisions/topics. "
"Start with [YYYY-MM-DD HH:MM]. Include detail useful for grep search.",
},
"memory_update": {
@@ -42,6 +44,20 @@ _SAVE_MEMORY_TOOL = [
]
+def _ensure_text(value: Any) -> str:
+ """Normalize tool-call payload values to text for file storage."""
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
+
+
+def _normalize_save_memory_args(args: Any) -> dict[str, Any] | None:
+ """Normalize provider tool-call arguments to the expected dict shape."""
+ if isinstance(args, str):
+ args = json.loads(args)
+ if isinstance(args, list):
+ return args[0] if args and isinstance(args[0], dict) else None
+ return args if isinstance(args, dict) else None
+
+
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
@@ -66,29 +82,27 @@ class MemoryStore:
long_term = self.read_long_term()
return f"## Long-term Memory\n{long_term}" if long_term else ""
- async def consolidate_chunk(
+ @staticmethod
+ def _format_messages(messages: list[dict]) -> str:
+ lines = []
+ for message in messages:
+ if not message.get("content"):
+ continue
+ tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
+ lines.append(
+ f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}"
+ )
+ return "\n".join(lines)
+
+ async def consolidate(
self,
messages: list[dict],
provider: LLMProvider,
model: str,
- ) -> tuple[bool, str | None]:
- """Consolidate a chunk of messages into MEMORY.md + HISTORY.md via LLM tool call.
-
- Returns (success, None).
-
- - success: True on success (including no-op), False on failure.
- - The second return value is reserved for future use (e.g. RAG-style summaries) and is
- always None in the current implementation.
- """
+ ) -> bool:
+ """Consolidate the provided message chunk into MEMORY.md + HISTORY.md."""
if not messages:
- return True, None
-
- lines = []
- for m in messages:
- if not m.get("content"):
- continue
- tools = f" [tools: {', '.join(m['tools_used'])}]" if m.get("tools_used") else ""
- lines.append(f"[{m.get('timestamp', '?')[:16]}] {m['role'].upper()}{tools}: {m['content']}")
+ return True
current_memory = self.read_long_term()
prompt = f"""Process this conversation and call the save_memory tool with your consolidation.
@@ -97,24 +111,12 @@ class MemoryStore:
{current_memory or "(empty)"}
## Conversation to Process
-{chr(10).join(lines)}"""
+{self._format_messages(messages)}"""
try:
response = await provider.chat_with_retry(
messages=[
- {
- "role": "system",
- "content": (
- "You are a memory consolidation agent.\n"
- "Your job is to:\n"
- "1) Append a concise but grep-friendly entry to HISTORY.md summarizing key events, decisions and topics.\n"
- " - Write 1 paragraph of 2–5 sentences that starts with [YYYY-MM-DD HH:MM].\n"
- " - Include concrete names, IDs and numbers so it is easy to search with grep.\n"
- "2) Update long-term MEMORY.md with stable facts and user preferences as markdown, including all existing facts plus new ones.\n"
- "3) Optionally return a short context_summary (1–3 sentences) that will replace the raw messages in future dialogue history.\n\n"
- "Always call the save_memory tool with history_entry, memory_update and (optionally) context_summary."
- ),
- },
+ {"role": "system", "content": "You are a memory consolidation agent. Call the save_memory tool with your consolidation of the conversation."},
{"role": "user", "content": prompt},
],
tools=_SAVE_MEMORY_TOOL,
@@ -123,35 +125,160 @@ class MemoryStore:
if not response.has_tool_calls:
logger.warning("Memory consolidation: LLM did not call save_memory, skipping")
- return False, None
+ return False
- args = response.tool_calls[0].arguments
- # Some providers return arguments as a JSON string instead of dict
- if isinstance(args, str):
- args = json.loads(args)
- # Some providers return arguments as a list (handle edge case)
- if isinstance(args, list):
- if args and isinstance(args[0], dict):
- args = args[0]
- else:
- logger.warning("Memory consolidation: unexpected arguments as empty or non-dict list")
- return False, None
- if not isinstance(args, dict):
- logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
- return False, None
+ args = _normalize_save_memory_args(response.tool_calls[0].arguments)
+ if args is None:
+ logger.warning("Memory consolidation: unexpected save_memory arguments")
+ return False
if entry := args.get("history_entry"):
- if not isinstance(entry, str):
- entry = json.dumps(entry, ensure_ascii=False)
- self.append_history(entry)
+ self.append_history(_ensure_text(entry))
if update := args.get("memory_update"):
- if not isinstance(update, str):
- update = json.dumps(update, ensure_ascii=False)
+ update = _ensure_text(update)
if update != current_memory:
self.write_long_term(update)
logger.info("Memory consolidation done for {} messages", len(messages))
- return True, None
+ return True
except Exception:
logger.exception("Memory consolidation failed")
- return False, None
+ return False
+
+
+class MemoryConsolidator:
+ """Owns consolidation policy, locking, and session offset updates."""
+
+ _MAX_CONSOLIDATION_ROUNDS = 5
+
+ def __init__(
+ self,
+ workspace: Path,
+ provider: LLMProvider,
+ model: str,
+ sessions: SessionManager,
+ context_window_tokens: int,
+ build_messages: Callable[..., list[dict[str, Any]]],
+ get_tool_definitions: Callable[[], list[dict[str, Any]]],
+ ):
+ self.store = MemoryStore(workspace)
+ self.provider = provider
+ self.model = model
+ self.sessions = sessions
+ self.context_window_tokens = context_window_tokens
+ self._build_messages = build_messages
+ self._get_tool_definitions = get_tool_definitions
+ self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
+
+ def get_lock(self, session_key: str) -> asyncio.Lock:
+ """Return the shared consolidation lock for one session."""
+ return self._locks.setdefault(session_key, asyncio.Lock())
+
+ async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
+ """Archive a selected message chunk into persistent memory."""
+ return await self.store.consolidate(messages, self.provider, self.model)
+
+ def pick_consolidation_boundary(
+ self,
+ session: Session,
+ tokens_to_remove: int,
+ ) -> tuple[int, int] | None:
+ """Pick a user-turn boundary that removes enough old prompt tokens."""
+ start = session.last_consolidated
+ if start >= len(session.messages) or tokens_to_remove <= 0:
+ return None
+
+ removed_tokens = 0
+ last_boundary: tuple[int, int] | None = None
+ for idx in range(start, len(session.messages)):
+ message = session.messages[idx]
+ if idx > start and message.get("role") == "user":
+ last_boundary = (idx, removed_tokens)
+ if removed_tokens >= tokens_to_remove:
+ return last_boundary
+ removed_tokens += estimate_message_tokens(message)
+
+ return last_boundary
+
+ def estimate_session_prompt_tokens(self, session: Session) -> tuple[int, str]:
+ """Estimate current prompt size for the normal session history view."""
+ history = session.get_history(max_messages=0)
+ channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
+ probe_messages = self._build_messages(
+ history=history,
+ current_message="[token-probe]",
+ channel=channel,
+ chat_id=chat_id,
+ )
+ return estimate_prompt_tokens_chain(
+ self.provider,
+ self.model,
+ probe_messages,
+ self._get_tool_definitions(),
+ )
+
+ async def archive_unconsolidated(self, session: Session) -> bool:
+ """Archive the full unconsolidated tail for /new-style session rollover."""
+ lock = self.get_lock(session.key)
+ async with lock:
+ snapshot = session.messages[session.last_consolidated:]
+ if not snapshot:
+ return True
+ return await self.consolidate_messages(snapshot)
+
+ async def maybe_consolidate_by_tokens(self, session: Session) -> None:
+ """Loop: archive old messages until prompt fits within half the context window."""
+ if not session.messages or self.context_window_tokens <= 0:
+ return
+
+ lock = self.get_lock(session.key)
+ async with lock:
+ target = self.context_window_tokens // 2
+ estimated, source = self.estimate_session_prompt_tokens(session)
+ if estimated <= 0:
+ return
+ if estimated < self.context_window_tokens:
+ logger.debug(
+ "Token consolidation idle {}: {}/{} via {}",
+ session.key,
+ estimated,
+ self.context_window_tokens,
+ source,
+ )
+ return
+
+ for round_num in range(self._MAX_CONSOLIDATION_ROUNDS):
+ if estimated <= target:
+ return
+
+ boundary = self.pick_consolidation_boundary(session, max(1, estimated - target))
+ if boundary is None:
+ logger.debug(
+ "Token consolidation: no safe boundary for {} (round {})",
+ session.key,
+ round_num,
+ )
+ return
+
+ end_idx = boundary[0]
+ chunk = session.messages[session.last_consolidated:end_idx]
+ if not chunk:
+ return
+
+ logger.info(
+ "Token consolidation round {} for {}: {}/{} via {}, chunk={} msgs",
+ round_num,
+ session.key,
+ estimated,
+ self.context_window_tokens,
+ source,
+ len(chunk),
+ )
+ if not await self.consolidate_messages(chunk):
+ return
+ session.last_consolidated = end_idx
+ self.sessions.save(session)
+
+ estimated, source = self.estimate_session_prompt_tokens(session)
+ if estimated <= 0:
+ return
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 36e2a53..cf69450 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -191,6 +191,8 @@ def onboard():
save_config(Config())
console.print(f"[green]✓[/green] Created config at {config_path}")
+ console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]")
+
# Create workspace
workspace = get_workspace_path()
@@ -283,6 +285,16 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
return loaded
+def _print_deprecated_memory_window_notice(config: Config) -> None:
+ """Warn when running with old memoryWindow-only config."""
+ if config.agents.defaults.should_warn_deprecated_memory_window:
+ console.print(
+ "[yellow]Hint:[/yellow] Detected deprecated `memoryWindow` without "
+ "`contextWindowTokens`. `memoryWindow` is ignored; run "
+ "[cyan]nanobot onboard[/cyan] to refresh your config template."
+ )
+
+
# ============================================================================
# Gateway / Server
# ============================================================================
@@ -310,6 +322,7 @@ def gateway(
logging.basicConfig(level=logging.DEBUG)
config = _load_runtime_config(config, workspace)
+ _print_deprecated_memory_window_notice(config)
port = port if port is not None else config.gateway.port
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
@@ -329,12 +342,10 @@ def gateway(
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
- max_tokens=config.agents.defaults.max_tokens_output,
+ max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
reasoning_effort=config.agents.defaults.reasoning_effort,
- max_tokens_input=config.agents.defaults.max_tokens_input,
- compression_start_ratio=config.agents.defaults.compression_start_ratio,
- compression_target_ratio=config.agents.defaults.compression_target_ratio,
+ context_window_tokens=config.agents.defaults.context_window_tokens,
brave_api_key=config.tools.web.search.api_key or None,
web_proxy=config.tools.web.proxy or None,
exec_config=config.tools.exec,
@@ -496,6 +507,7 @@ def agent(
from nanobot.cron.service import CronService
config = _load_runtime_config(config, workspace)
+ _print_deprecated_memory_window_notice(config)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
@@ -516,12 +528,10 @@ def agent(
workspace=config.workspace_path,
model=config.agents.defaults.model,
temperature=config.agents.defaults.temperature,
- max_tokens=config.agents.defaults.max_tokens_output,
+ max_tokens=config.agents.defaults.max_tokens,
max_iterations=config.agents.defaults.max_tool_iterations,
reasoning_effort=config.agents.defaults.reasoning_effort,
- max_tokens_input=config.agents.defaults.max_tokens_input,
- compression_start_ratio=config.agents.defaults.compression_start_ratio,
- compression_target_ratio=config.agents.defaults.compression_target_ratio,
+ context_window_tokens=config.agents.defaults.context_window_tokens,
brave_api_key=config.tools.web.search.api_key or None,
web_proxy=config.tools.web.proxy or None,
exec_config=config.tools.exec,
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 0e41d12..a2de239 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -190,22 +190,11 @@ class SlackConfig(Base):
class QQConfig(Base):
- """QQ channel configuration.
-
- Supports two implementations:
- 1. Official botpy SDK: requires app_id and secret
- 2. OneBot protocol: requires api_url (and optionally ws_reverse_url, bot_qq, access_token)
- """
+ """QQ channel configuration using botpy SDK."""
enabled: bool = False
- # Official botpy SDK fields
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
- # OneBot protocol fields
- api_url: str = "" # OneBot HTTP API URL (e.g. "http://localhost:5700")
- ws_reverse_url: str = "" # OneBot WebSocket reverse URL (e.g. "ws://localhost:8080/ws/reverse")
- bot_qq: int | None = None # Bot's QQ number (for filtering self messages)
- access_token: str = "" # Optional access token for OneBot API
allow_from: list[str] = Field(
default_factory=list
) # Allowed user openids (empty = public access)
@@ -238,20 +227,19 @@ class AgentDefaults(Base):
provider: str = (
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
)
- # 原生上下文最大窗口(通常对应模型的 max_input_tokens / max_context_tokens)
- # 默认按照主流大模型(如 GPT-4o、Claude 3.x 等)的 128k 上下文给一个宽松上限,实际应根据所选模型文档手动调整。
- max_tokens_input: int = 128_000
- # 默认单次回复的最大输出 token 上限(调用时可按需要再做截断或比例分配)
- # 8192 足以覆盖大多数实际对话/工具使用场景,同样可按需手动调整。
- max_tokens_output: int = 8192
- # 会话历史压缩触发比例:当估算的输入 token 使用量 >= maxTokensInput * compressionStartRatio 时开始压缩。
- compression_start_ratio: float = 0.7
- # 会话历史压缩目标比例:每轮压缩后尽量把估算的输入 token 使用量压到 maxTokensInput * compressionTargetRatio 附近。
- compression_target_ratio: float = 0.4
+ max_tokens: int = 8192
+ context_window_tokens: int = 65_536
temperature: float = 0.1
max_tool_iterations: int = 40
+ # Deprecated compatibility field: accepted from old configs but ignored at runtime.
+ memory_window: int | None = Field(default=None, exclude=True)
reasoning_effort: str | None = None # low / medium / high — enables LLM thinking mode
+ @property
+ def should_warn_deprecated_memory_window(self) -> bool:
+ """Return True when old memoryWindow is present without contextWindowTokens."""
+ return self.memory_window is not None and "context_window_tokens" not in self.model_fields_set
+
class AgentsConfig(Base):
"""Agent configuration."""
diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py
index 1cb8a51..f0a6484 100644
--- a/nanobot/session/manager.py
+++ b/nanobot/session/manager.py
@@ -9,6 +9,7 @@ from typing import Any
from loguru import logger
+from nanobot.config.paths import get_legacy_sessions_dir
from nanobot.utils.helpers import ensure_dir, safe_filename
@@ -29,6 +30,7 @@ class Session:
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
+ last_consolidated: int = 0 # Number of messages already consolidated to files
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the session."""
@@ -42,13 +44,9 @@ class Session:
self.updated_at = datetime.now()
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
- """
- Return messages for LLM input, aligned to a user turn.
-
- - max_messages > 0 时只保留最近 max_messages 条;
- - max_messages <= 0 时不做条数截断,返回全部消息。
- """
- sliced = self.messages if max_messages <= 0 else self.messages[-max_messages:]
+ """Return unconsolidated messages for LLM input, aligned to a user turn."""
+ unconsolidated = self.messages[self.last_consolidated:]
+ sliced = unconsolidated[-max_messages:]
# Drop leading non-user messages to avoid orphaned tool_result blocks
for i, m in enumerate(sliced):
@@ -68,7 +66,7 @@ class Session:
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
self.messages = []
- self.metadata = {}
+ self.last_consolidated = 0
self.updated_at = datetime.now()
@@ -82,7 +80,7 @@ class SessionManager:
def __init__(self, workspace: Path):
self.workspace = workspace
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] = {}
def _get_session_path(self, key: str) -> Path:
@@ -134,6 +132,7 @@ class SessionManager:
messages = []
metadata = {}
created_at = None
+ last_consolidated = 0
with open(path, encoding="utf-8") as f:
for line in f:
@@ -146,6 +145,7 @@ class SessionManager:
if data.get("_type") == "metadata":
metadata = data.get("metadata", {})
created_at = datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None
+ last_consolidated = data.get("last_consolidated", 0)
else:
messages.append(data)
@@ -154,6 +154,7 @@ class SessionManager:
messages=messages,
created_at=created_at or datetime.now(),
metadata=metadata,
+ last_consolidated=last_consolidated
)
except Exception as e:
logger.warning("Failed to load session {}: {}", key, e)
@@ -170,6 +171,7 @@ class SessionManager:
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
"metadata": session.metadata,
+ "last_consolidated": session.last_consolidated
}
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
for msg in session.messages:
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
index 57c60dc..9242ba6 100644
--- a/nanobot/utils/helpers.py
+++ b/nanobot/utils/helpers.py
@@ -1,8 +1,12 @@
"""Utility functions for nanobot."""
+import json
import re
from datetime import datetime
from pathlib import Path
+from typing import Any
+
+import tiktoken
def detect_image_mime(data: bytes) -> str | None:
@@ -68,6 +72,87 @@ def split_message(content: str, max_len: int = 2000) -> list[str]:
return chunks
+def estimate_prompt_tokens(
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+) -> int:
+ """Estimate prompt tokens with tiktoken."""
+ try:
+ enc = tiktoken.get_encoding("cl100k_base")
+ parts: list[str] = []
+ for msg in messages:
+ content = msg.get("content")
+ if isinstance(content, str):
+ parts.append(content)
+ elif isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict) and part.get("type") == "text":
+ txt = part.get("text", "")
+ if txt:
+ parts.append(txt)
+ if tools:
+ parts.append(json.dumps(tools, ensure_ascii=False))
+ return len(enc.encode("\n".join(parts)))
+ except Exception:
+ return 0
+
+
+def estimate_message_tokens(message: dict[str, Any]) -> int:
+ """Estimate prompt tokens contributed by one persisted message."""
+ content = message.get("content")
+ parts: list[str] = []
+ if isinstance(content, str):
+ parts.append(content)
+ elif isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict) and part.get("type") == "text":
+ text = part.get("text", "")
+ if text:
+ parts.append(text)
+ else:
+ parts.append(json.dumps(part, ensure_ascii=False))
+ elif content is not None:
+ parts.append(json.dumps(content, ensure_ascii=False))
+
+ for key in ("name", "tool_call_id"):
+ value = message.get(key)
+ if isinstance(value, str) and value:
+ parts.append(value)
+ if message.get("tool_calls"):
+ parts.append(json.dumps(message["tool_calls"], ensure_ascii=False))
+
+ payload = "\n".join(parts)
+ if not payload:
+ return 1
+ try:
+ enc = tiktoken.get_encoding("cl100k_base")
+ return max(1, len(enc.encode(payload)))
+ except Exception:
+ return max(1, len(payload) // 4)
+
+
+def estimate_prompt_tokens_chain(
+ provider: Any,
+ model: str | None,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
+) -> tuple[int, str]:
+ """Estimate prompt tokens via provider counter first, then tiktoken fallback."""
+ provider_counter = getattr(provider, "estimate_prompt_tokens", None)
+ if callable(provider_counter):
+ try:
+ tokens, source = provider_counter(messages, tools, model)
+ if isinstance(tokens, (int, float)) and tokens > 0:
+ return int(tokens), str(source or "provider_counter")
+ except Exception:
+ pass
+
+ estimated = estimate_prompt_tokens(messages, tools)
+ if estimated > 0:
+ return int(estimated), "tiktoken"
+ return 0, "none"
+
+
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
"""Sync bundled templates to workspace. Only creates missing files."""
from importlib.resources import files as pkg_files
diff --git a/pyproject.toml b/pyproject.toml
index 62cf616..0344348 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,6 +44,7 @@ dependencies = [
"json-repair>=0.57.0,<1.0.0",
"chardet>=3.0.2,<6.0.0",
"openai>=2.8.0",
+ "tiktoken>=0.12.0,<1.0.0",
]
[project.optional-dependencies]
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 5e3760a..1375a3a 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -267,6 +267,16 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
+def test_agent_warns_about_deprecated_memory_window(mock_agent_runtime):
+ mock_agent_runtime["config"].agents.defaults.memory_window = 100
+
+ result = runner.invoke(app, ["agent", "-m", "hello"])
+
+ assert result.exit_code == 0
+ assert "memoryWindow" in result.stdout
+ assert "contextWindowTokens" in result.stdout
+
+
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)
@@ -327,6 +337,29 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path)
assert seen["workspace"] == override
assert config.workspace_path == override
+
+def test_gateway_warns_about_deprecated_memory_window(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.memory_window = 100
+
+ monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
+ monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
+ monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
+ 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 "memoryWindow" in result.stdout
+ assert "contextWindowTokens" in result.stdout
+
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)
diff --git a/tests/test_config_migration.py b/tests/test_config_migration.py
new file mode 100644
index 0000000..62e601e
--- /dev/null
+++ b/tests/test_config_migration.py
@@ -0,0 +1,88 @@
+import json
+
+from typer.testing import CliRunner
+
+from nanobot.cli.commands import app
+from nanobot.config.loader import load_config, save_config
+
+runner = CliRunner()
+
+
+def test_load_config_keeps_max_tokens_and_warns_on_legacy_memory_window(tmp_path) -> None:
+ config_path = tmp_path / "config.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "agents": {
+ "defaults": {
+ "maxTokens": 1234,
+ "memoryWindow": 42,
+ }
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ config = load_config(config_path)
+
+ assert config.agents.defaults.max_tokens == 1234
+ assert config.agents.defaults.context_window_tokens == 65_536
+ assert config.agents.defaults.should_warn_deprecated_memory_window is True
+
+
+def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path) -> None:
+ config_path = tmp_path / "config.json"
+ config_path.write_text(
+ json.dumps(
+ {
+ "agents": {
+ "defaults": {
+ "maxTokens": 2222,
+ "memoryWindow": 30,
+ }
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ config = load_config(config_path)
+ save_config(config, config_path)
+ saved = json.loads(config_path.read_text(encoding="utf-8"))
+ defaults = saved["agents"]["defaults"]
+
+ assert defaults["maxTokens"] == 2222
+ assert defaults["contextWindowTokens"] == 65_536
+ assert "memoryWindow" not in defaults
+
+
+def test_onboard_refresh_rewrites_legacy_config_template(tmp_path, monkeypatch) -> None:
+ config_path = tmp_path / "config.json"
+ workspace = tmp_path / "workspace"
+ config_path.write_text(
+ json.dumps(
+ {
+ "agents": {
+ "defaults": {
+ "maxTokens": 3333,
+ "memoryWindow": 50,
+ }
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
+ monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda: workspace)
+
+ result = runner.invoke(app, ["onboard"], input="n\n")
+
+ assert result.exit_code == 0
+ assert "contextWindowTokens" in result.stdout
+ saved = json.loads(config_path.read_text(encoding="utf-8"))
+ defaults = saved["agents"]["defaults"]
+ assert defaults["maxTokens"] == 3333
+ assert defaults["contextWindowTokens"] == 65_536
+ assert "memoryWindow" not in defaults
diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py
index a3213dd..7d12338 100644
--- a/tests/test_consolidate_offset.py
+++ b/tests/test_consolidate_offset.py
@@ -480,226 +480,35 @@ class TestEmptyAndBoundarySessions:
assert_messages_content(old_messages, 10, 34)
-class TestConsolidationDeduplicationGuard:
- """Test that consolidation tasks are deduplicated and serialized."""
+class TestNewCommandArchival:
+ """Test /new archival behavior with the simplified consolidation flow."""
- @pytest.mark.asyncio
- async def test_consolidation_guard_prevents_duplicate_tasks(self, tmp_path: Path) -> None:
- """Concurrent messages above memory_window spawn only one consolidation task."""
+ @staticmethod
+ def _make_loop(tmp_path: Path):
from nanobot.agent.loop import AgentLoop
- from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
+ provider.estimate_prompt_tokens.return_value = (10_000, "test")
loop = AgentLoop(
- bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
+ bus=bus,
+ provider=provider,
+ workspace=tmp_path,
+ model="test-model",
+ context_window_tokens=1,
)
-
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+ loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
-
- session = loop.sessions.get_or_create("cli:test")
- for i in range(15):
- session.add_message("user", f"msg{i}")
- session.add_message("assistant", f"resp{i}")
- loop.sessions.save(session)
-
- consolidation_calls = 0
-
- async def _fake_consolidate(_session, archive_all: bool = False) -> None:
- nonlocal consolidation_calls
- consolidation_calls += 1
- await asyncio.sleep(0.05)
-
- loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
-
- msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
- await loop._process_message(msg)
- await loop._process_message(msg)
- await asyncio.sleep(0.1)
-
- assert consolidation_calls == 1, (
- f"Expected exactly 1 consolidation, got {consolidation_calls}"
- )
-
- @pytest.mark.asyncio
- async def test_new_command_guard_prevents_concurrent_consolidation(
- self, tmp_path: Path
- ) -> None:
- """/new command does not run consolidation concurrently with in-flight consolidation."""
- from nanobot.agent.loop import AgentLoop
- from nanobot.bus.events import InboundMessage
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "test-model"
- loop = AgentLoop(
- bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
- )
-
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
- loop.tools.get_definitions = MagicMock(return_value=[])
-
- session = loop.sessions.get_or_create("cli:test")
- for i in range(15):
- session.add_message("user", f"msg{i}")
- session.add_message("assistant", f"resp{i}")
- loop.sessions.save(session)
-
- consolidation_calls = 0
- active = 0
- max_active = 0
-
- async def _fake_consolidate(_session, archive_all: bool = False) -> None:
- nonlocal consolidation_calls, active, max_active
- consolidation_calls += 1
- active += 1
- max_active = max(max_active, active)
- await asyncio.sleep(0.05)
- active -= 1
-
- loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
-
- msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
- await loop._process_message(msg)
-
- new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
- await loop._process_message(new_msg)
- await asyncio.sleep(0.1)
-
- assert consolidation_calls == 2, (
- f"Expected normal + /new consolidations, got {consolidation_calls}"
- )
- assert max_active == 1, (
- f"Expected serialized consolidation, observed concurrency={max_active}"
- )
-
- @pytest.mark.asyncio
- async def test_consolidation_tasks_are_referenced(self, tmp_path: Path) -> None:
- """create_task results are tracked in _consolidation_tasks while in flight."""
- from nanobot.agent.loop import AgentLoop
- from nanobot.bus.events import InboundMessage
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "test-model"
- loop = AgentLoop(
- bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
- )
-
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
- loop.tools.get_definitions = MagicMock(return_value=[])
-
- session = loop.sessions.get_or_create("cli:test")
- for i in range(15):
- session.add_message("user", f"msg{i}")
- session.add_message("assistant", f"resp{i}")
- loop.sessions.save(session)
-
- started = asyncio.Event()
-
- async def _slow_consolidate(_session, archive_all: bool = False) -> None:
- started.set()
- await asyncio.sleep(0.1)
-
- loop._consolidate_memory = _slow_consolidate # type: ignore[method-assign]
-
- msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
- await loop._process_message(msg)
-
- await started.wait()
- assert len(loop._consolidation_tasks) == 1, "Task must be referenced while in-flight"
-
- await asyncio.sleep(0.15)
- assert len(loop._consolidation_tasks) == 0, (
- "Task reference must be removed after completion"
- )
-
- @pytest.mark.asyncio
- async def test_new_waits_for_inflight_consolidation_and_preserves_messages(
- self, tmp_path: Path
- ) -> None:
- """/new waits for in-flight consolidation and archives before clear."""
- from nanobot.agent.loop import AgentLoop
- from nanobot.bus.events import InboundMessage
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "test-model"
- loop = AgentLoop(
- bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
- )
-
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
- loop.tools.get_definitions = MagicMock(return_value=[])
-
- session = loop.sessions.get_or_create("cli:test")
- for i in range(15):
- session.add_message("user", f"msg{i}")
- session.add_message("assistant", f"resp{i}")
- loop.sessions.save(session)
-
- started = asyncio.Event()
- release = asyncio.Event()
- archived_count = 0
-
- async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
- nonlocal archived_count
- if archive_all:
- archived_count = len(sess.messages)
- return True
- started.set()
- await release.wait()
- return True
-
- loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
-
- msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
- await loop._process_message(msg)
- await started.wait()
-
- new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
- pending_new = asyncio.create_task(loop._process_message(new_msg))
-
- await asyncio.sleep(0.02)
- assert not pending_new.done(), "/new should wait while consolidation is in-flight"
-
- release.set()
- response = await pending_new
- assert response is not None
- assert "new session started" in response.content.lower()
- assert archived_count > 0, "Expected /new archival to process a non-empty snapshot"
-
- session_after = loop.sessions.get_or_create("cli:test")
- assert session_after.messages == [], "Session should be cleared after successful archival"
+ return loop
@pytest.mark.asyncio
async def test_new_does_not_clear_session_when_archive_fails(self, tmp_path: Path) -> None:
- """/new must keep session data if archive step reports failure."""
- from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "test-model"
- loop = AgentLoop(
- bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
- )
-
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
- loop.tools.get_definitions = MagicMock(return_value=[])
+ loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(5):
session.add_message("user", f"msg{i}")
@@ -707,111 +516,61 @@ class TestConsolidationDeduplicationGuard:
loop.sessions.save(session)
before_count = len(session.messages)
- async def _failing_consolidate(sess, archive_all: bool = False) -> bool:
- if archive_all:
- return False
- return True
+ async def _failing_consolidate(_messages) -> bool:
+ return False
- loop._consolidate_memory = _failing_consolidate # type: ignore[method-assign]
+ loop.memory_consolidator.consolidate_messages = _failing_consolidate # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg)
assert response is not None
assert "failed" in response.content.lower()
- session_after = loop.sessions.get_or_create("cli:test")
- assert len(session_after.messages) == before_count, (
- "Session must remain intact when /new archival fails"
- )
+ assert len(loop.sessions.get_or_create("cli:test").messages) == before_count
@pytest.mark.asyncio
- async def test_new_archives_only_unconsolidated_messages_after_inflight_task(
- self, tmp_path: Path
- ) -> None:
- """/new should archive only messages not yet consolidated by prior task."""
- from nanobot.agent.loop import AgentLoop
+ async def test_new_archives_only_unconsolidated_messages(self, tmp_path: Path) -> None:
from nanobot.bus.events import InboundMessage
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "test-model"
- loop = AgentLoop(
- bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
- )
-
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
- loop.tools.get_definitions = MagicMock(return_value=[])
+ loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(15):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
+ session.last_consolidated = len(session.messages) - 3
loop.sessions.save(session)
- started = asyncio.Event()
- release = asyncio.Event()
archived_count = -1
- async def _fake_consolidate(sess, archive_all: bool = False) -> bool:
+ async def _fake_consolidate(messages) -> bool:
nonlocal archived_count
- if archive_all:
- archived_count = len(sess.messages)
- return True
-
- started.set()
- await release.wait()
- sess.last_consolidated = len(sess.messages) - 3
+ archived_count = len(messages)
return True
- loop._consolidate_memory = _fake_consolidate # type: ignore[method-assign]
-
- msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="hello")
- await loop._process_message(msg)
- await started.wait()
+ loop.memory_consolidator.consolidate_messages = _fake_consolidate # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
- pending_new = asyncio.create_task(loop._process_message(new_msg))
- await asyncio.sleep(0.02)
- assert not pending_new.done()
-
- release.set()
- response = await pending_new
+ response = await loop._process_message(new_msg)
assert response is not None
assert "new session started" in response.content.lower()
- assert archived_count == 3, (
- f"Expected only unconsolidated tail to archive, got {archived_count}"
- )
+ assert archived_count == 3
@pytest.mark.asyncio
async def test_new_clears_session_and_responds(self, tmp_path: Path) -> None:
- """/new clears session and returns confirmation."""
- from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
- from nanobot.bus.queue import MessageBus
- from nanobot.providers.base import LLMResponse
-
- bus = MessageBus()
- provider = MagicMock()
- provider.get_default_model.return_value = "test-model"
- loop = AgentLoop(
- bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10
- )
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
- loop.tools.get_definitions = MagicMock(return_value=[])
+ loop = self._make_loop(tmp_path)
session = loop.sessions.get_or_create("cli:test")
for i in range(3):
session.add_message("user", f"msg{i}")
session.add_message("assistant", f"resp{i}")
loop.sessions.save(session)
- async def _ok_consolidate(sess, archive_all: bool = False) -> bool:
+ async def _ok_consolidate(_messages) -> bool:
return True
- loop._consolidate_memory = _ok_consolidate # type: ignore[method-assign]
+ loop.memory_consolidator.consolidate_messages = _ok_consolidate # type: ignore[method-assign]
new_msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/new")
response = await loop._process_message(new_msg)
diff --git a/tests/test_loop_consolidation_tokens.py b/tests/test_loop_consolidation_tokens.py
new file mode 100644
index 0000000..b0f3dda
--- /dev/null
+++ b/tests/test_loop_consolidation_tokens.py
@@ -0,0 +1,190 @@
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from nanobot.agent.loop import AgentLoop
+import nanobot.agent.memory as memory_module
+from nanobot.bus.queue import MessageBus
+from nanobot.providers.base import LLMResponse
+
+
+def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -> AgentLoop:
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ provider.estimate_prompt_tokens.return_value = (estimated_tokens, "test-counter")
+ provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="ok", tool_calls=[]))
+
+ loop = AgentLoop(
+ bus=MessageBus(),
+ provider=provider,
+ workspace=tmp_path,
+ model="test-model",
+ context_window_tokens=context_window_tokens,
+ )
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ return loop
+
+
+@pytest.mark.asyncio
+async def test_prompt_below_threshold_does_not_consolidate(tmp_path) -> None:
+ loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
+ loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
+
+ await loop.process_direct("hello", session_key="cli:test")
+
+ loop.memory_consolidator.consolidate_messages.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_prompt_above_threshold_triggers_consolidation(tmp_path, monkeypatch) -> None:
+ loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
+ loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
+ session = loop.sessions.get_or_create("cli:test")
+ session.messages = [
+ {"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
+ {"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
+ {"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
+ ]
+ loop.sessions.save(session)
+ monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _message: 500)
+
+ await loop.process_direct("hello", session_key="cli:test")
+
+ assert loop.memory_consolidator.consolidate_messages.await_count >= 1
+
+
+@pytest.mark.asyncio
+async def test_prompt_above_threshold_archives_until_next_user_boundary(tmp_path, monkeypatch) -> None:
+ loop = _make_loop(tmp_path, estimated_tokens=1000, context_window_tokens=200)
+ loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
+
+ session = loop.sessions.get_or_create("cli:test")
+ session.messages = [
+ {"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
+ {"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
+ {"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
+ {"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
+ {"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
+ ]
+ loop.sessions.save(session)
+
+ token_map = {"u1": 120, "a1": 120, "u2": 120, "a2": 120, "u3": 120}
+ monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda message: token_map[message["content"]])
+
+ await loop.memory_consolidator.maybe_consolidate_by_tokens(session)
+
+ archived_chunk = loop.memory_consolidator.consolidate_messages.await_args.args[0]
+ assert [message["content"] for message in archived_chunk] == ["u1", "a1", "u2", "a2"]
+ assert session.last_consolidated == 4
+
+
+@pytest.mark.asyncio
+async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> None:
+ """Verify maybe_consolidate_by_tokens keeps looping until under threshold."""
+ loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
+ loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
+
+ session = loop.sessions.get_or_create("cli:test")
+ session.messages = [
+ {"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
+ {"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
+ {"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
+ {"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
+ {"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
+ {"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
+ {"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
+ ]
+ loop.sessions.save(session)
+
+ call_count = [0]
+ def mock_estimate(_session):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return (500, "test")
+ if call_count[0] == 2:
+ return (300, "test")
+ return (80, "test")
+
+ loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
+ monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
+
+ await loop.memory_consolidator.maybe_consolidate_by_tokens(session)
+
+ assert loop.memory_consolidator.consolidate_messages.await_count == 2
+ assert session.last_consolidated == 6
+
+
+@pytest.mark.asyncio
+async def test_consolidation_continues_below_trigger_until_half_target(tmp_path, monkeypatch) -> None:
+ """Once triggered, consolidation should continue until it drops below half threshold."""
+ loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
+ loop.memory_consolidator.consolidate_messages = AsyncMock(return_value=True) # type: ignore[method-assign]
+
+ session = loop.sessions.get_or_create("cli:test")
+ session.messages = [
+ {"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
+ {"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
+ {"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
+ {"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
+ {"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
+ {"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
+ {"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
+ ]
+ loop.sessions.save(session)
+
+ call_count = [0]
+
+ def mock_estimate(_session):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ return (500, "test")
+ if call_count[0] == 2:
+ return (150, "test")
+ return (80, "test")
+
+ loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
+ monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
+
+ await loop.memory_consolidator.maybe_consolidate_by_tokens(session)
+
+ assert loop.memory_consolidator.consolidate_messages.await_count == 2
+ assert session.last_consolidated == 6
+
+
+@pytest.mark.asyncio
+async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> None:
+ """Verify preflight consolidation runs before the LLM call in process_direct."""
+ order: list[str] = []
+
+ loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
+
+ async def track_consolidate(messages):
+ order.append("consolidate")
+ return True
+ loop.memory_consolidator.consolidate_messages = track_consolidate # type: ignore[method-assign]
+
+ async def track_llm(*args, **kwargs):
+ order.append("llm")
+ return LLMResponse(content="ok", tool_calls=[])
+ loop.provider.chat_with_retry = track_llm
+
+ session = loop.sessions.get_or_create("cli:test")
+ session.messages = [
+ {"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
+ {"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
+ {"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
+ ]
+ loop.sessions.save(session)
+ monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 500)
+
+ call_count = [0]
+ def mock_estimate(_session):
+ call_count[0] += 1
+ return (1000 if call_count[0] <= 1 else 80, "test")
+ loop.memory_consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
+
+ await loop.process_direct("hello", session_key="cli:test")
+
+ assert "consolidate" in order
+ assert "llm" in order
+ assert order.index("consolidate") < order.index("llm")
diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py
index 2605bf7..0263f01 100644
--- a/tests/test_memory_consolidation_types.py
+++ b/tests/test_memory_consolidation_types.py
@@ -7,7 +7,7 @@ tool call response, it should serialize them to JSON instead of raising TypeErro
import json
from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import AsyncMock
import pytest
@@ -15,15 +15,12 @@ from nanobot.agent.memory import MemoryStore
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
-def _make_session(message_count: int = 30, memory_window: int = 50):
- """Create a mock session with messages."""
- session = MagicMock()
- session.messages = [
+def _make_messages(message_count: int = 30):
+ """Create a list of mock messages."""
+ return [
{"role": "user", "content": f"msg{i}", "timestamp": "2026-01-01 00:00"}
for i in range(message_count)
]
- session.last_consolidated = 0
- return session
def _make_tool_response(history_entry, memory_update):
@@ -74,9 +71,9 @@ class TestMemoryConsolidationTypeHandling:
)
)
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert store.history_file.exists()
@@ -95,9 +92,9 @@ class TestMemoryConsolidationTypeHandling:
)
)
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert store.history_file.exists()
@@ -131,9 +128,9 @@ class TestMemoryConsolidationTypeHandling:
)
provider.chat = AsyncMock(return_value=response)
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert "User discussed testing." in store.history_file.read_text()
@@ -147,22 +144,22 @@ class TestMemoryConsolidationTypeHandling:
return_value=LLMResponse(content="I summarized the conversation.", tool_calls=[])
)
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is False
assert not store.history_file.exists()
@pytest.mark.asyncio
- async def test_skips_when_few_messages(self, tmp_path: Path) -> None:
- """Consolidation should be a no-op when messages < keep_count."""
+ async def test_skips_when_message_chunk_is_empty(self, tmp_path: Path) -> None:
+ """Consolidation should be a no-op when the selected chunk is empty."""
store = MemoryStore(tmp_path)
provider = AsyncMock()
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=10)
+ messages: list[dict] = []
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is True
provider.chat.assert_not_called()
@@ -189,9 +186,9 @@ class TestMemoryConsolidationTypeHandling:
)
provider.chat = AsyncMock(return_value=response)
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert "User discussed testing." in store.history_file.read_text()
@@ -215,9 +212,9 @@ class TestMemoryConsolidationTypeHandling:
)
provider.chat = AsyncMock(return_value=response)
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is False
@@ -239,9 +236,9 @@ class TestMemoryConsolidationTypeHandling:
)
provider.chat = AsyncMock(return_value=response)
provider.chat_with_retry = provider.chat
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is False
@@ -255,7 +252,7 @@ class TestMemoryConsolidationTypeHandling:
memory_update="# Memory\nUser likes testing.",
),
])
- session = _make_session(message_count=60)
+ messages = _make_messages(message_count=60)
delays: list[int] = []
async def _fake_sleep(delay: int) -> None:
@@ -263,7 +260,7 @@ class TestMemoryConsolidationTypeHandling:
monkeypatch.setattr("nanobot.providers.base.asyncio.sleep", _fake_sleep)
- result = await store.consolidate(session, provider, "test-model", memory_window=50)
+ result = await store.consolidate(messages, provider, "test-model")
assert result is True
assert provider.calls == 2
diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py
index 63b0fd1..1091de4 100644
--- a/tests/test_message_tool_suppress.py
+++ b/tests/test_message_tool_suppress.py
@@ -16,7 +16,7 @@ def _make_loop(tmp_path: Path) -> AgentLoop:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
- return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10)
+ return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
class TestMessageToolSuppressLogic:
@@ -33,7 +33,7 @@ class TestMessageToolSuppressLogic:
LLMResponse(content="", tool_calls=[tool_call]),
LLMResponse(content="Done", tool_calls=[]),
])
- loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
sent: list[OutboundMessage] = []
@@ -58,7 +58,7 @@ class TestMessageToolSuppressLogic:
LLMResponse(content="", tool_calls=[tool_call]),
LLMResponse(content="I've sent the email.", tool_calls=[]),
])
- loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
sent: list[OutboundMessage] = []
@@ -77,7 +77,7 @@ class TestMessageToolSuppressLogic:
@pytest.mark.asyncio
async def test_not_suppress_when_no_message_tool_used(self, tmp_path: Path) -> None:
loop = _make_loop(tmp_path)
- loop.provider.chat = AsyncMock(return_value=LLMResponse(content="Hello!", tool_calls=[]))
+ loop.provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Hello!", tool_calls=[]))
loop.tools.get_definitions = MagicMock(return_value=[])
msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Hi")
@@ -98,7 +98,7 @@ class TestMessageToolSuppressLogic:
),
LLMResponse(content="Done", tool_calls=[]),
])
- loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.execute = AsyncMock(return_value="ok")
From a44ee115d1188a62012d3d7cc38077ff5013f4ee Mon Sep 17 00:00:00 2001
From: greyishsong
Date: Wed, 11 Mar 2026 09:02:28 +0800
Subject: [PATCH 093/124] fix: bump litellm version to 1.82.1 for Moonshot
provider support
see issue #1628
---
pyproject.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pyproject.toml b/pyproject.toml
index 62cf616..7127354 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,7 +18,7 @@ classifiers = [
dependencies = [
"typer>=0.20.0,<1.0.0",
- "litellm>=1.81.5,<2.0.0",
+ "litellm>=1.82.1,<2.0.0",
"pydantic>=2.12.0,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"websockets>=16.0,<17.0",
From d1df53aaf783d44394d3d335948b5eaf31af803f Mon Sep 17 00:00:00 2001
From: YinAnPing
Date: Wed, 11 Mar 2026 09:30:33 +0800
Subject: [PATCH 094/124] fix: exclude hidden files when syncing workspace
templates
Skip files starting with '.' (e.g., macOS extended attributes like ._AGENTS.md)
to prevent UnicodeDecodeError during template synchronization.
---
nanobot/utils/helpers.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
mode change 100644 => 100755 nanobot/utils/helpers.py
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
old mode 100644
new mode 100755
index 57c60dc..a387b79
--- a/nanobot/utils/helpers.py
+++ b/nanobot/utils/helpers.py
@@ -88,7 +88,7 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
added.append(str(dest.relative_to(workspace)))
for item in tpl.iterdir():
- if item.name.endswith(".md"):
+ if item.name.endswith(".md") and not item.name.startswith("."):
_write(item, workspace / item.name)
_write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md")
_write(None, workspace / "memory" / "HISTORY.md")
From 35d811c99790b71ef34c5908b23168eeb526ca6b Mon Sep 17 00:00:00 2001
From: dingyanyi2019
Date: Wed, 11 Mar 2026 10:19:43 +0800
Subject: [PATCH 095/124] feat: support retrieving DingTalk voice recognition
text
---
nanobot/channels/dingtalk.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py
index 3c301a9..cdcba57 100644
--- a/nanobot/channels/dingtalk.py
+++ b/nanobot/channels/dingtalk.py
@@ -57,6 +57,8 @@ class NanobotDingTalkHandler(CallbackHandler):
content = ""
if chatbot_msg.text:
content = chatbot_msg.text.content.strip()
+ elif chatbot_msg.extensions.get("content", {}).get("recognition"):
+ content = chatbot_msg.extensions["content"]["recognition"].strip()
if not content:
content = message.data.get("text", {}).get("content", "").strip()
From 91f17cad00b14b7a550f154791be3fc8eb12b746 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 03:40:33 +0000
Subject: [PATCH 096/124] feat(dingtalk): support voice recognition text
fallback
Read DingTalk recognition text when text.content is empty, and add a handler-level regression test for voice transcript delivery.
---
tests/test_dingtalk_channel.py | 47 +++++++++++++++++++++++++++++++++-
1 file changed, 46 insertions(+), 1 deletion(-)
diff --git a/tests/test_dingtalk_channel.py b/tests/test_dingtalk_channel.py
index 7595a33..6051014 100644
--- a/tests/test_dingtalk_channel.py
+++ b/tests/test_dingtalk_channel.py
@@ -1,9 +1,11 @@
+import asyncio
from types import SimpleNamespace
import pytest
from nanobot.bus.queue import MessageBus
-from nanobot.channels.dingtalk import DingTalkChannel
+import nanobot.channels.dingtalk as dingtalk_module
+from nanobot.channels.dingtalk import DingTalkChannel, NanobotDingTalkHandler
from nanobot.config.schema import DingTalkConfig
@@ -64,3 +66,46 @@ async def test_group_send_uses_group_messages_api() -> None:
assert call["url"] == "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
assert call["json"]["openConversationId"] == "conv123"
assert call["json"]["msgKey"] == "sampleMarkdown"
+
+
+@pytest.mark.asyncio
+async def test_handler_uses_voice_recognition_text_when_text_is_empty(monkeypatch) -> None:
+ bus = MessageBus()
+ channel = DingTalkChannel(
+ DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]),
+ bus,
+ )
+ handler = NanobotDingTalkHandler(channel)
+
+ class _FakeChatbotMessage:
+ text = None
+ extensions = {"content": {"recognition": "voice transcript"}}
+ sender_staff_id = "user1"
+ sender_id = "fallback-user"
+ sender_nick = "Alice"
+ message_type = "audio"
+
+ @staticmethod
+ def from_dict(_data):
+ return _FakeChatbotMessage()
+
+ monkeypatch.setattr(dingtalk_module, "ChatbotMessage", _FakeChatbotMessage)
+ monkeypatch.setattr(dingtalk_module, "AckMessage", SimpleNamespace(STATUS_OK="OK"))
+
+ status, body = await handler.process(
+ SimpleNamespace(
+ data={
+ "conversationType": "2",
+ "conversationId": "conv123",
+ "text": {"content": ""},
+ }
+ )
+ )
+
+ await asyncio.gather(*list(channel._background_tasks))
+ msg = await bus.consume_inbound()
+
+ assert (status, body) == ("OK", "OK")
+ assert msg.content == "voice transcript"
+ assert msg.sender_id == "user1"
+ assert msg.chat_id == "group:conv123"
From ddccf25bb1be8529d453d2344eea21bd593021c2 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 03:47:24 +0000
Subject: [PATCH 097/124] fix(subagent): preserve reasoning fields across tool
turns
Share assistant message construction between the main agent and subagents, and add a regression test to keep reasoning_content and thinking_blocks in follow-up tool rounds.
---
nanobot/agent/context.py | 16 +++++++--------
nanobot/agent/subagent.py | 21 +++++++------------
nanobot/utils/helpers.py | 17 ++++++++++++++++
tests/test_task_cancel.py | 43 +++++++++++++++++++++++++++++++++++++++
4 files changed, 74 insertions(+), 23 deletions(-)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 2c648eb..e47fcb8 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -10,7 +10,7 @@ from typing import Any
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
-from nanobot.utils.helpers import detect_image_mime
+from nanobot.utils.helpers import build_assistant_message, detect_image_mime
class ContextBuilder:
@@ -182,12 +182,10 @@ Reply directly with text for conversations. Only use the 'message' tool to send
thinking_blocks: list[dict] | None = None,
) -> list[dict[str, Any]]:
"""Add an assistant message to the message list."""
- msg: dict[str, Any] = {"role": "assistant", "content": content}
- if tool_calls:
- msg["tool_calls"] = tool_calls
- if reasoning_content is not None:
- msg["reasoning_content"] = reasoning_content
- if thinking_blocks:
- msg["thinking_blocks"] = thinking_blocks
- messages.append(msg)
+ messages.append(build_assistant_message(
+ content,
+ tool_calls=tool_calls,
+ reasoning_content=reasoning_content,
+ thinking_blocks=thinking_blocks,
+ ))
return messages
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 308e67d..eff0b4f 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -16,6 +16,7 @@ from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ExecToolConfig
from nanobot.providers.base import LLMProvider
+from nanobot.utils.helpers import build_assistant_message
class SubagentManager:
@@ -133,7 +134,6 @@ class SubagentManager:
)
if response.has_tool_calls:
- # Add assistant message with tool calls
tool_call_dicts = [
{
"id": tc.id,
@@ -145,19 +145,12 @@ class SubagentManager:
}
for tc in response.tool_calls
]
- assistant_msg: dict[str, Any] = {
- "role": "assistant",
- "content": response.content or "",
- "tool_calls": tool_call_dicts,
- }
- # Preserve reasoning_content for providers that require it
- # (e.g. Deepseek Reasoner mandates this field on every
- # assistant message when thinking mode is active).
- if response.reasoning_content is not None:
- assistant_msg["reasoning_content"] = response.reasoning_content
- if response.thinking_blocks:
- assistant_msg["thinking_blocks"] = response.thinking_blocks
- messages.append(assistant_msg)
+ messages.append(build_assistant_message(
+ response.content or "",
+ tool_calls=tool_call_dicts,
+ reasoning_content=response.reasoning_content,
+ thinking_blocks=response.thinking_blocks,
+ ))
# Execute tools
for tool_call in response.tool_calls:
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
index 9242ba6..6d2c670 100644
--- a/nanobot/utils/helpers.py
+++ b/nanobot/utils/helpers.py
@@ -72,6 +72,23 @@ def split_message(content: str, max_len: int = 2000) -> list[str]:
return chunks
+def build_assistant_message(
+ content: str | None,
+ tool_calls: list[dict[str, Any]] | None = None,
+ reasoning_content: str | None = None,
+ thinking_blocks: list[dict] | None = None,
+) -> dict[str, Any]:
+ """Build a provider-safe assistant message with optional reasoning fields."""
+ msg: dict[str, Any] = {"role": "assistant", "content": content}
+ if tool_calls:
+ msg["tool_calls"] = tool_calls
+ if reasoning_content is not None:
+ msg["reasoning_content"] = reasoning_content
+ if thinking_blocks:
+ msg["thinking_blocks"] = thinking_blocks
+ return msg
+
+
def estimate_prompt_tokens(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py
index 27a2d73..62ab2cc 100644
--- a/tests/test_task_cancel.py
+++ b/tests/test_task_cancel.py
@@ -165,3 +165,46 @@ class TestSubagentCancellation:
provider.get_default_model.return_value = "test-model"
mgr = SubagentManager(provider=provider, workspace=MagicMock(), bus=bus)
assert await mgr.cancel_by_session("nonexistent") == 0
+
+ @pytest.mark.asyncio
+ async def test_subagent_preserves_reasoning_fields_in_tool_turn(self, monkeypatch, tmp_path):
+ from nanobot.agent.subagent import SubagentManager
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse, ToolCallRequest
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+
+ captured_second_call: list[dict] = []
+
+ call_count = {"n": 0}
+
+ async def scripted_chat_with_retry(*, messages, **kwargs):
+ call_count["n"] += 1
+ if call_count["n"] == 1:
+ return LLMResponse(
+ content="thinking",
+ tool_calls=[ToolCallRequest(id="call_1", name="list_dir", arguments={})],
+ reasoning_content="hidden reasoning",
+ thinking_blocks=[{"type": "thinking", "thinking": "step"}],
+ )
+ captured_second_call[:] = messages
+ return LLMResponse(content="done", tool_calls=[])
+ provider.chat_with_retry = scripted_chat_with_retry
+ mgr = SubagentManager(provider=provider, workspace=tmp_path, bus=bus)
+
+ async def fake_execute(self, name, arguments):
+ return "tool result"
+
+ monkeypatch.setattr("nanobot.agent.tools.registry.ToolRegistry.execute", fake_execute)
+
+ await mgr._run_subagent("sub-1", "do task", "label", {"channel": "test", "chat_id": "c1"})
+
+ assistant_messages = [
+ msg for msg in captured_second_call
+ if msg.get("role") == "assistant" and msg.get("tool_calls")
+ ]
+ assert len(assistant_messages) == 1
+ assert assistant_messages[0]["reasoning_content"] == "hidden reasoning"
+ assert assistant_messages[0]["thinking_blocks"] == [{"type": "thinking", "thinking": "step"}]
From 76c6063141f84d8bde3f3a95896c36e4e673c5c7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 03:50:54 +0000
Subject: [PATCH 098/124] chore: normalize helpers.py file mode
---
nanobot/utils/helpers.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
mode change 100755 => 100644 nanobot/utils/helpers.py
diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py
old mode 100755
new mode 100644
From dee4f27dce4a8837eea4b97b882314c50a2b74e3 Mon Sep 17 00:00:00 2001
From: "Jerome Sonnet (letzdoo)"
Date: Wed, 11 Mar 2026 07:43:28 +0400
Subject: [PATCH 099/124] feat: add Ollama as a local LLM provider
Add native Ollama support so local models (e.g. nemotron-3-nano) can be
used without an API key. Adds ProviderSpec with ollama_chat LiteLLM
prefix, ProvidersConfig field, and skips API key validation for local
providers.
Co-Authored-By: Claude Opus 4.6
---
nanobot/cli/commands.py | 2 +-
nanobot/config/schema.py | 5 +++--
nanobot/providers/registry.py | 17 +++++++++++++++++
3 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index cf69450..8387b28 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -252,7 +252,7 @@ def _make_provider(config: Config):
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.registry import find_by_name
spec = find_by_name(provider_name)
- if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth):
+ if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and (spec.is_oauth or spec.is_local)):
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index a2de239..9b5821b 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -272,6 +272,7 @@ class ProvidersConfig(Base):
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
+ ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
@@ -375,14 +376,14 @@ class Config(BaseSettings):
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and model_prefix and normalized_prefix == spec.name:
- if spec.is_oauth or p.api_key:
+ if spec.is_oauth or spec.is_local or p.api_key:
return p, spec.name
# Match by keyword (order follows PROVIDERS registry)
for spec in PROVIDERS:
p = getattr(self.providers, spec.name, None)
if p and any(_kw_matches(kw) for kw in spec.keywords):
- if spec.is_oauth or p.api_key:
+ if spec.is_oauth or spec.is_local or p.api_key:
return p, spec.name
# Fallback: gateways first, then others (follows registry order)
diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py
index 3ba1a0e..c4bcfe2 100644
--- a/nanobot/providers/registry.py
+++ b/nanobot/providers/registry.py
@@ -360,6 +360,23 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
strip_model_prefix=False,
model_overrides=(),
),
+ # === Ollama (local, OpenAI-compatible) ===================================
+ ProviderSpec(
+ name="ollama",
+ keywords=("ollama", "nemotron"),
+ env_key="OLLAMA_API_KEY",
+ display_name="Ollama",
+ litellm_prefix="ollama_chat", # model → ollama_chat/model
+ skip_prefixes=("ollama/", "ollama_chat/"),
+ env_extras=(),
+ is_gateway=False,
+ is_local=True,
+ detect_by_key_prefix="",
+ detect_by_base_keyword="11434",
+ default_api_base="http://localhost:11434",
+ strip_model_prefix=False,
+ model_overrides=(),
+ ),
# === Auxiliary (not a primary LLM provider) ============================
# Groq: mainly used for Whisper voice transcription, also usable for LLM.
# Needs "groq/" prefix for LiteLLM routing. Placed last — it rarely wins fallback.
From c7e2622ee1cb313ca3f7a4a31779813cc3ebc27b Mon Sep 17 00:00:00 2001
From: ethanclaw
Date: Wed, 11 Mar 2026 12:25:28 +0800
Subject: [PATCH 100/124] fix(subagent): pass reasoning_content and
thinking_blocks in subagent messages
Fix issue #1834: Spawn/subagent tool fails with Deepseek Reasoner
due to missing reasoning_content field when using thinking mode.
The subagent was not including reasoning_content and thinking_blocks
in assistant messages with tool calls, causing the Deepseek API to
reject subsequent requests.
- Add reasoning_content to assistant message when subagent makes tool calls
- Add thinking_blocks to assistant message for Anthropic extended thinking
- Add tests to verify both fields are properly passed
Fixes #1834
---
nanobot/agent/subagent.py | 2 +
tests/test_subagent_reasoning.py | 144 +++++++++++++++++++++++++++++++
2 files changed, 146 insertions(+)
create mode 100644 tests/test_subagent_reasoning.py
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index f9eda1f..6163a52 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -149,6 +149,8 @@ class SubagentManager:
"role": "assistant",
"content": response.content or "",
"tool_calls": tool_call_dicts,
+ "reasoning_content": response.reasoning_content,
+ "thinking_blocks": response.thinking_blocks,
})
# Execute tools
diff --git a/tests/test_subagent_reasoning.py b/tests/test_subagent_reasoning.py
new file mode 100644
index 0000000..5e70506
--- /dev/null
+++ b/tests/test_subagent_reasoning.py
@@ -0,0 +1,144 @@
+"""Tests for subagent reasoning_content and thinking_blocks handling."""
+
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+class TestSubagentReasoningContent:
+ """Test that subagent properly handles reasoning_content and thinking_blocks."""
+
+ @pytest.mark.asyncio
+ async def test_subagent_message_includes_reasoning_content(self):
+ """Verify reasoning_content is included in assistant messages with tool calls.
+
+ This is the fix for issue #1834: Spawn/subagent tool fails with
+ Deepseek Reasoner due to missing reasoning_content field.
+ """
+ from nanobot.agent.subagent import SubagentManager
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse, ToolCallRequest
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "deepseek-reasoner"
+
+ # Create a real Path object for workspace
+ workspace = Path("/tmp/test_workspace")
+ workspace.mkdir(parents=True, exist_ok=True)
+
+ # Capture messages that are sent to the provider
+ captured_messages = []
+
+ async def mock_chat(*args, **kwargs):
+ captured_messages.append(kwargs.get("messages", []))
+ # Return response with tool calls and reasoning_content
+ tool_call = ToolCallRequest(
+ id="test-1",
+ name="read_file",
+ arguments={"path": "/test.txt"},
+ )
+ return LLMResponse(
+ content="",
+ tool_calls=[tool_call],
+ reasoning_content="I need to read this file first",
+ )
+
+ provider.chat_with_retry = AsyncMock(side_effect=mock_chat)
+
+ mgr = SubagentManager(provider=provider, workspace=workspace, bus=bus)
+
+ # Mock the tools registry
+ with patch("nanobot.agent.subagent.ToolRegistry") as MockToolRegistry:
+ mock_registry = MagicMock()
+ mock_registry.get_definitions.return_value = []
+ mock_registry.execute = AsyncMock(return_value="file content")
+ MockToolRegistry.return_value = mock_registry
+
+ result = await mgr.spawn(
+ task="Read a file",
+ label="test",
+ origin_channel="cli",
+ origin_chat_id="direct",
+ session_key="cli:direct",
+ )
+
+ # Wait for the task to complete
+ await asyncio.sleep(0.5)
+
+ # Check the captured messages
+ assert len(captured_messages) >= 1
+ # Find the assistant message with tool_calls
+ found = False
+ for msg_list in captured_messages:
+ for msg in msg_list:
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
+ assert "reasoning_content" in msg, "reasoning_content should be in assistant message with tool_calls"
+ assert msg["reasoning_content"] == "I need to read this file first"
+ found = True
+ assert found, "Should have found an assistant message with tool_calls"
+
+ @pytest.mark.asyncio
+ async def test_subagent_message_includes_thinking_blocks(self):
+ """Verify thinking_blocks is included in assistant messages with tool calls."""
+ from nanobot.agent.subagent import SubagentManager
+ from nanobot.bus.queue import MessageBus
+ from nanobot.providers.base import LLMResponse, ToolCallRequest
+
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "claude-sonnet"
+
+ workspace = Path("/tmp/test_workspace2")
+ workspace.mkdir(parents=True, exist_ok=True)
+
+ captured_messages = []
+
+ async def mock_chat(*args, **kwargs):
+ captured_messages.append(kwargs.get("messages", []))
+ tool_call = ToolCallRequest(
+ id="test-2",
+ name="read_file",
+ arguments={"path": "/test.txt"},
+ )
+ return LLMResponse(
+ content="",
+ tool_calls=[tool_call],
+ thinking_blocks=[
+ {"signature": "sig1", "thought": "thinking step 1"},
+ {"signature": "sig2", "thought": "thinking step 2"},
+ ],
+ )
+
+ provider.chat_with_retry = AsyncMock(side_effect=mock_chat)
+
+ mgr = SubagentManager(provider=provider, workspace=workspace, bus=bus)
+
+ with patch("nanobot.agent.subagent.ToolRegistry") as MockToolRegistry:
+ mock_registry = MagicMock()
+ mock_registry.get_definitions.return_value = []
+ mock_registry.execute = AsyncMock(return_value="file content")
+ MockToolRegistry.return_value = mock_registry
+
+ result = await mgr.spawn(
+ task="Read a file",
+ label="test",
+ origin_channel="cli",
+ origin_chat_id="direct",
+ )
+
+ await asyncio.sleep(0.5)
+
+ # Check the captured messages
+ found = False
+ for msg_list in captured_messages:
+ for msg in msg_list:
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
+ assert "thinking_blocks" in msg, "thinking_blocks should be in assistant message with tool_calls"
+ assert len(msg["thinking_blocks"]) == 2
+ found = True
+ assert found, "Should have found an assistant message with tool_calls"
From 12104c8d46c0b688e0db21617b23d54f012970ba Mon Sep 17 00:00:00 2001
From: ethanclaw
Date: Wed, 11 Mar 2026 14:22:33 +0800
Subject: [PATCH 101/124] fix(memory): pass temperature, max_tokens and
reasoning_effort to memory consolidation
Fix issue #1823: Memory consolidation does not inherit agent temperature
and maxTokens configuration.
The agent's configured generation parameters were not being passed through
to the memory consolidation call, causing it to fall back to default values.
This resulted in the consolidation response being truncated before the
save_memory tool call was emitted.
- Pass temperature, max_tokens, reasoning_effort from AgentLoop to
MemoryConsolidator and then to MemoryStore.consolidate()
- Forward these parameters to the provider.chat_with_retry() call
Fixes #1823
---
nanobot/agent/loop.py | 3 +++
nanobot/agent/memory.py | 21 ++++++++++++++++++++-
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 8605a09..edf1e8e 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -114,6 +114,9 @@ class AgentLoop:
context_window_tokens=context_window_tokens,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
+ temperature=self.temperature,
+ max_tokens=self.max_tokens,
+ reasoning_effort=self.reasoning_effort,
)
self._register_default_tools()
diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py
index cd5f54f..d79887b 100644
--- a/nanobot/agent/memory.py
+++ b/nanobot/agent/memory.py
@@ -99,6 +99,9 @@ class MemoryStore:
messages: list[dict],
provider: LLMProvider,
model: str,
+ temperature: float | None = None,
+ max_tokens: int | None = None,
+ reasoning_effort: str | None = None,
) -> bool:
"""Consolidate the provided message chunk into MEMORY.md + HISTORY.md."""
if not messages:
@@ -121,6 +124,9 @@ class MemoryStore:
],
tools=_SAVE_MEMORY_TOOL,
model=model,
+ temperature=temperature,
+ max_tokens=max_tokens,
+ reasoning_effort=reasoning_effort,
)
if not response.has_tool_calls:
@@ -160,6 +166,9 @@ class MemoryConsolidator:
context_window_tokens: int,
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
+ temperature: float | None = None,
+ max_tokens: int | None = None,
+ reasoning_effort: str | None = None,
):
self.store = MemoryStore(workspace)
self.provider = provider
@@ -168,6 +177,9 @@ class MemoryConsolidator:
self.context_window_tokens = context_window_tokens
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
+ self._temperature = temperature
+ self._max_tokens = max_tokens
+ self._reasoning_effort = reasoning_effort
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def get_lock(self, session_key: str) -> asyncio.Lock:
@@ -176,7 +188,14 @@ class MemoryConsolidator:
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
- return await self.store.consolidate(messages, self.provider, self.model)
+ return await self.store.consolidate(
+ messages,
+ self.provider,
+ self.model,
+ temperature=self._temperature,
+ max_tokens=self._max_tokens,
+ reasoning_effort=self._reasoning_effort,
+ )
def pick_consolidation_boundary(
self,
From ed82f95f0ca23605d896ff1785dd93dbb4ab70c4 Mon Sep 17 00:00:00 2001
From: WhalerO
Date: Wed, 11 Mar 2026 09:56:18 +0800
Subject: [PATCH 102/124] fix: preserve provider-specific tool call metadata
for Gemini
---
nanobot/agent/loop.py | 25 ++++++++----
nanobot/agent/subagent.py | 25 ++++++++----
nanobot/providers/base.py | 2 +
nanobot/providers/litellm_provider.py | 7 ++++
tests/test_gemini_thought_signature.py | 54 ++++++++++++++++++++++++++
5 files changed, 97 insertions(+), 16 deletions(-)
create mode 100644 tests/test_gemini_thought_signature.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index fcbc880..147327d 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -208,14 +208,7 @@ class AgentLoop:
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False)
- }
- }
+ self._build_tool_call_message(tc)
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
@@ -256,6 +249,22 @@ class AgentLoop:
return final_content, tools_used, messages
+ @staticmethod
+ def _build_tool_call_message(tc: Any) -> dict[str, Any]:
+ tool_call = {
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False)
+ }
+ }
+ if getattr(tc, "provider_specific_fields", None):
+ tool_call["provider_specific_fields"] = tc.provider_specific_fields
+ if getattr(tc, "function_provider_specific_fields", None):
+ tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
+ return tool_call
+
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index f9eda1f..5f98272 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -135,14 +135,7 @@ class SubagentManager:
if response.has_tool_calls:
# Add assistant message with tool calls
tool_call_dicts = [
- {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False),
- },
- }
+ self._build_tool_call_message(tc)
for tc in response.tool_calls
]
messages.append({
@@ -230,6 +223,22 @@ Stay focused on the assigned task. Your final response will be reported back to
parts.append(f"## Skills\n\nRead SKILL.md with read_file to use a skill.\n\n{skills_summary}")
return "\n\n".join(parts)
+
+ @staticmethod
+ def _build_tool_call_message(tc: Any) -> dict[str, Any]:
+ tool_call = {
+ "id": tc.id,
+ "type": "function",
+ "function": {
+ "name": tc.name,
+ "arguments": json.dumps(tc.arguments, ensure_ascii=False),
+ },
+ }
+ if getattr(tc, "provider_specific_fields", None):
+ tool_call["provider_specific_fields"] = tc.provider_specific_fields
+ if getattr(tc, "function_provider_specific_fields", None):
+ tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
+ return tool_call
async def cancel_by_session(self, session_key: str) -> int:
"""Cancel all subagents for the given session. Returns count cancelled."""
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index a3b6c47..b41ce28 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -14,6 +14,8 @@ class ToolCallRequest:
id: str
name: str
arguments: dict[str, Any]
+ provider_specific_fields: dict[str, Any] | None = None
+ function_provider_specific_fields: dict[str, Any] | None = None
@dataclass
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index cb67635..af91c2f 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -309,10 +309,17 @@ class LiteLLMProvider(LLMProvider):
if isinstance(args, str):
args = json_repair.loads(args)
+ provider_specific_fields = getattr(tc, "provider_specific_fields", None) or None
+ function_provider_specific_fields = (
+ getattr(tc.function, "provider_specific_fields", None) or None
+ )
+
tool_calls.append(ToolCallRequest(
id=_short_tool_id(),
name=tc.function.name,
arguments=args,
+ provider_specific_fields=provider_specific_fields,
+ function_provider_specific_fields=function_provider_specific_fields,
))
usage = {}
diff --git a/tests/test_gemini_thought_signature.py b/tests/test_gemini_thought_signature.py
new file mode 100644
index 0000000..db57c7f
--- /dev/null
+++ b/tests/test_gemini_thought_signature.py
@@ -0,0 +1,54 @@
+from types import SimpleNamespace
+
+from nanobot.agent.loop import AgentLoop
+from nanobot.providers.base import ToolCallRequest
+from nanobot.providers.litellm_provider import LiteLLMProvider
+
+
+def test_litellm_parse_response_preserves_tool_call_provider_fields() -> None:
+ provider = LiteLLMProvider(default_model="gemini/gemini-3-flash")
+
+ response = SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ finish_reason="tool_calls",
+ message=SimpleNamespace(
+ content=None,
+ tool_calls=[
+ SimpleNamespace(
+ id="call_123",
+ function=SimpleNamespace(
+ name="read_file",
+ arguments='{"path":"todo.md"}',
+ provider_specific_fields={"inner": "value"},
+ ),
+ provider_specific_fields={"thought_signature": "signed-token"},
+ )
+ ],
+ ),
+ )
+ ],
+ usage=None,
+ )
+
+ parsed = provider._parse_response(response)
+
+ assert len(parsed.tool_calls) == 1
+ assert parsed.tool_calls[0].provider_specific_fields == {"thought_signature": "signed-token"}
+ assert parsed.tool_calls[0].function_provider_specific_fields == {"inner": "value"}
+
+
+def test_agent_loop_replays_tool_call_provider_fields() -> None:
+ tool_call = ToolCallRequest(
+ id="abc123xyz",
+ name="read_file",
+ arguments={"path": "todo.md"},
+ provider_specific_fields={"thought_signature": "signed-token"},
+ function_provider_specific_fields={"inner": "value"},
+ )
+
+ message = AgentLoop._build_tool_call_message(tool_call)
+
+ assert message["provider_specific_fields"] == {"thought_signature": "signed-token"}
+ assert message["function"]["provider_specific_fields"] == {"inner": "value"}
+ assert message["function"]["arguments"] == '{"path": "todo.md"}'
From 6ef7ab53d089f9b9d25651e37ab0d8c4a3c607a1 Mon Sep 17 00:00:00 2001
From: WhalerO
Date: Wed, 11 Mar 2026 15:01:18 +0800
Subject: [PATCH 103/124] refactor: centralize tool call serialization in
ToolCallRequest
---
nanobot/agent/loop.py | 18 +-----------------
nanobot/agent/subagent.py | 18 +-----------------
nanobot/providers/base.py | 17 +++++++++++++++++
tests/test_gemini_thought_signature.py | 5 ++---
4 files changed, 21 insertions(+), 37 deletions(-)
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 147327d..8949844 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -208,7 +208,7 @@ class AgentLoop:
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
tool_call_dicts = [
- self._build_tool_call_message(tc)
+ tc.to_openai_tool_call()
for tc in response.tool_calls
]
messages = self.context.add_assistant_message(
@@ -249,22 +249,6 @@ class AgentLoop:
return final_content, tools_used, messages
- @staticmethod
- def _build_tool_call_message(tc: Any) -> dict[str, Any]:
- tool_call = {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False)
- }
- }
- if getattr(tc, "provider_specific_fields", None):
- tool_call["provider_specific_fields"] = tc.provider_specific_fields
- if getattr(tc, "function_provider_specific_fields", None):
- tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
- return tool_call
-
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py
index 5f98272..0049f9a 100644
--- a/nanobot/agent/subagent.py
+++ b/nanobot/agent/subagent.py
@@ -135,7 +135,7 @@ class SubagentManager:
if response.has_tool_calls:
# Add assistant message with tool calls
tool_call_dicts = [
- self._build_tool_call_message(tc)
+ tc.to_openai_tool_call()
for tc in response.tool_calls
]
messages.append({
@@ -224,22 +224,6 @@ Stay focused on the assigned task. Your final response will be reported back to
return "\n\n".join(parts)
- @staticmethod
- def _build_tool_call_message(tc: Any) -> dict[str, Any]:
- tool_call = {
- "id": tc.id,
- "type": "function",
- "function": {
- "name": tc.name,
- "arguments": json.dumps(tc.arguments, ensure_ascii=False),
- },
- }
- if getattr(tc, "provider_specific_fields", None):
- tool_call["provider_specific_fields"] = tc.provider_specific_fields
- if getattr(tc, "function_provider_specific_fields", None):
- tool_call["function"]["provider_specific_fields"] = tc.function_provider_specific_fields
- return tool_call
-
async def cancel_by_session(self, session_key: str) -> int:
"""Cancel all subagents for the given session. Returns count cancelled."""
tasks = [self._running_tasks[tid] for tid in self._session_tasks.get(session_key, [])
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index b41ce28..391f903 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -1,6 +1,7 @@
"""Base LLM provider interface."""
import asyncio
+import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@@ -17,6 +18,22 @@ class ToolCallRequest:
provider_specific_fields: dict[str, Any] | None = None
function_provider_specific_fields: dict[str, Any] | None = None
+ def to_openai_tool_call(self) -> dict[str, Any]:
+ """Serialize to an OpenAI-style tool_call payload."""
+ tool_call = {
+ "id": self.id,
+ "type": "function",
+ "function": {
+ "name": self.name,
+ "arguments": json.dumps(self.arguments, ensure_ascii=False),
+ },
+ }
+ if self.provider_specific_fields:
+ tool_call["provider_specific_fields"] = self.provider_specific_fields
+ if self.function_provider_specific_fields:
+ tool_call["function"]["provider_specific_fields"] = self.function_provider_specific_fields
+ return tool_call
+
@dataclass
class LLMResponse:
diff --git a/tests/test_gemini_thought_signature.py b/tests/test_gemini_thought_signature.py
index db57c7f..bc4132c 100644
--- a/tests/test_gemini_thought_signature.py
+++ b/tests/test_gemini_thought_signature.py
@@ -1,6 +1,5 @@
from types import SimpleNamespace
-from nanobot.agent.loop import AgentLoop
from nanobot.providers.base import ToolCallRequest
from nanobot.providers.litellm_provider import LiteLLMProvider
@@ -38,7 +37,7 @@ def test_litellm_parse_response_preserves_tool_call_provider_fields() -> None:
assert parsed.tool_calls[0].function_provider_specific_fields == {"inner": "value"}
-def test_agent_loop_replays_tool_call_provider_fields() -> None:
+def test_tool_call_request_serializes_provider_fields() -> None:
tool_call = ToolCallRequest(
id="abc123xyz",
name="read_file",
@@ -47,7 +46,7 @@ def test_agent_loop_replays_tool_call_provider_fields() -> None:
function_provider_specific_fields={"inner": "value"},
)
- message = AgentLoop._build_tool_call_message(tool_call)
+ message = tool_call.to_openai_tool_call()
assert message["provider_specific_fields"] == {"thought_signature": "signed-token"}
assert message["function"]["provider_specific_fields"] == {"inner": "value"}
From d0b4f0d70d025ba3ffa0a9127b280d8325bb698f Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 07:57:12 +0000
Subject: [PATCH 104/124] feat(wecom): add WeCom channel with SDK pinned to
GitHub tag v0.1.2
---
README.md | 25 ++++++++++++++-----------
nanobot/channels/manager.py | 1 -
nanobot/channels/wecom.py | 8 ++++----
nanobot/config/schema.py | 2 +-
pyproject.toml | 4 +++-
5 files changed, 22 insertions(+), 18 deletions(-)
diff --git a/README.md b/README.md
index 5be0ce5..6e8211e 100644
--- a/README.md
+++ b/README.md
@@ -208,7 +208,7 @@ Connect nanobot to your favorite chat platform.
| **Slack** | Bot token + App-Level token |
| **Email** | IMAP/SMTP credentials |
| **QQ** | App ID + App Secret |
-| **Wecom** | Bot ID + App Secret |
+| **Wecom** | Bot ID + Bot Secret |
Telegram (Recommended)
@@ -683,12 +683,17 @@ nanobot gateway
Uses **WebSocket** long connection — no public IP required.
-**1. Create a wecom bot**
+**1. Install the optional dependency**
-In the client's workspace, click on "Intelligent Robot" to create a robot and choose API mode for creation.
-Select to create in "long connection" mode, and obtain Bot ID and Secret.
+```bash
+pip install nanobot-ai[wecom]
+```
-**2. Configure**
+**2. Create a WeCom AI Bot**
+
+Go to the WeCom admin console → Intelligent Robot → Create Robot → select **API mode** with **long connection**. Copy the Bot ID and Secret.
+
+**3. Configure**
```json
{
@@ -696,23 +701,21 @@ Select to create in "long connection" mode, and obtain Bot ID and Secret.
"wecom": {
"enabled": true,
"botId": "your_bot_id",
- "secret": "your_secret",
- "allowFrom": [
- "your_id"
- ]
+ "secret": "your_bot_secret",
+ "allowFrom": ["your_id"]
}
}
}
```
-**3. Run**
+**4. Run**
```bash
nanobot gateway
```
> [!TIP]
-> wecom uses WebSocket to receive messages — no webhook or public IP needed!
+> WeCom uses WebSocket to receive messages — no webhook or public IP needed!
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 369795a..2c5cd3f 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -156,7 +156,6 @@ class ChannelManager:
self.channels["wecom"] = WecomChannel(
self.config.channels.wecom,
self.bus,
- groq_api_key=self.config.providers.groq.api_key,
)
logger.info("WeCom channel enabled")
except ImportError as e:
diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py
index dc97311..1c44451 100644
--- a/nanobot/channels/wecom.py
+++ b/nanobot/channels/wecom.py
@@ -2,6 +2,7 @@
import asyncio
import importlib.util
+import os
from collections import OrderedDict
from typing import Any
@@ -36,10 +37,9 @@ class WecomChannel(BaseChannel):
name = "wecom"
- def __init__(self, config: WecomConfig, bus: MessageBus, groq_api_key: str = ""):
+ def __init__(self, config: WecomConfig, bus: MessageBus):
super().__init__(config, bus)
self.config: WecomConfig = config
- self.groq_api_key = groq_api_key
self._client: Any = None
self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
self._loop: asyncio.AbstractEventLoop | None = None
@@ -50,7 +50,7 @@ class WecomChannel(BaseChannel):
async def start(self) -> None:
"""Start the WeCom bot with WebSocket long connection."""
if not WECOM_AVAILABLE:
- logger.error("WeCom SDK not installed. Run: pip install wecom-aibot-sdk-python")
+ logger.error("WeCom SDK not installed. Run: pip install nanobot-ai[wecom]")
return
if not self.config.bot_id or not self.config.secret:
@@ -213,7 +213,6 @@ class WecomChannel(BaseChannel):
if file_url and aes_key:
file_path = await self._download_and_save_media(file_url, aes_key, "image")
if file_path:
- import os
filename = os.path.basename(file_path)
content_parts.append(f"[image: {filename}]\n[Image: source: {file_path}]")
else:
@@ -308,6 +307,7 @@ class WecomChannel(BaseChannel):
media_dir = get_media_dir("wecom")
if not filename:
filename = fname or f"{media_type}_{hash(file_url) % 100000}"
+ filename = os.path.basename(filename)
file_path = media_dir / filename
file_path.write_bytes(data)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index b772d18..bb0d286 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -208,7 +208,7 @@ class WecomConfig(Base):
secret: str = "" # Bot Secret from WeCom AI Bot platform
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
welcome_message: str = "" # Welcome message for enter_chat event
- react_emoji: str = "eyes" # Emoji for message reactions
+
class ChannelsConfig(Base):
"""Configuration for chat channels."""
diff --git a/pyproject.toml b/pyproject.toml
index 0582be6..9868513 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -44,11 +44,13 @@ dependencies = [
"json-repair>=0.57.0,<1.0.0",
"chardet>=3.0.2,<6.0.0",
"openai>=2.8.0",
- "wecom-aibot-sdk-python>=0.1.2",
"tiktoken>=0.12.0,<1.0.0",
]
[project.optional-dependencies]
+wecom = [
+ "wecom-aibot-sdk-python @ git+https://github.com/chengyongru/wecom_aibot_sdk.git@v0.1.2",
+]
matrix = [
"matrix-nio[e2e]>=0.25.2",
"mistune>=3.0.0,<4.0.0",
From 7ceddcded643432f0f4b78aa22de7ad107b61f3a Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:04:14 +0000
Subject: [PATCH 105/124] fix(wecom): await async disconnect, add SDK
attribution in README
---
README.md | 7 +++----
nanobot/channels/wecom.py | 2 +-
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 6e8211e..2a49214 100644
--- a/README.md
+++ b/README.md
@@ -681,7 +681,9 @@ nanobot gateway
Wecom (企业微信)
-Uses **WebSocket** long connection — no public IP required.
+> Here we use [wecom-aibot-sdk-python](https://github.com/chengyongru/wecom_aibot_sdk) (community Python version of the official [@wecom/aibot-node-sdk](https://www.npmjs.com/package/@wecom/aibot-node-sdk)).
+>
+> Uses **WebSocket** long connection — no public IP required.
**1. Install the optional dependency**
@@ -714,9 +716,6 @@ Go to the WeCom admin console → Intelligent Robot → Create Robot → select
nanobot gateway
```
-> [!TIP]
-> WeCom uses WebSocket to receive messages — no webhook or public IP needed!
-
## 🌐 Agent Social Network
diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py
index 1c44451..72be9e2 100644
--- a/nanobot/channels/wecom.py
+++ b/nanobot/channels/wecom.py
@@ -98,7 +98,7 @@ class WecomChannel(BaseChannel):
"""Stop the WeCom bot."""
self._running = False
if self._client:
- self._client.disconnect()
+ await self._client.disconnect()
logger.info("WeCom bot stopped")
async def _on_connected(self, frame: Any) -> None:
From 486df1ddbd8db4fb248115851254b8fbb03c09f0 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:10:38 +0000
Subject: [PATCH 106/124] docs: update table of contents in README
---
README.md | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/README.md b/README.md
index 2a49214..ed4e8e7 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,25 @@
📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime.
+## Table of Contents
+
+- [News](#-news)
+- [Key Features](#key-features-of-nanobot)
+- [Architecture](#️-architecture)
+- [Features](#-features)
+- [Install](#-install)
+- [Quick Start](#-quick-start)
+- [Chat Apps](#-chat-apps)
+- [Agent Social Network](#-agent-social-network)
+- [Configuration](#️-configuration)
+- [Multiple Instances](#-multiple-instances)
+- [CLI Reference](#-cli-reference)
+- [Docker](#-docker)
+- [Linux Service](#-linux-service)
+- [Project Structure](#-project-structure)
+- [Contribute & Roadmap](#-contribute--roadmap)
+- [Star History](#-star-history)
+
## 📢 News
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
From ec87946c04ccf4d453ffea02febcb747139c415c Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Wed, 11 Mar 2026 08:11:28 +0000
Subject: [PATCH 107/124] docs: update table of contents position
---
README.md | 38 +++++++++++++++++++-------------------
1 file changed, 19 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index ed4e8e7..f0e1a6b 100644
--- a/README.md
+++ b/README.md
@@ -18,25 +18,6 @@
📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime.
-## Table of Contents
-
-- [News](#-news)
-- [Key Features](#key-features-of-nanobot)
-- [Architecture](#️-architecture)
-- [Features](#-features)
-- [Install](#-install)
-- [Quick Start](#-quick-start)
-- [Chat Apps](#-chat-apps)
-- [Agent Social Network](#-agent-social-network)
-- [Configuration](#️-configuration)
-- [Multiple Instances](#-multiple-instances)
-- [CLI Reference](#-cli-reference)
-- [Docker](#-docker)
-- [Linux Service](#-linux-service)
-- [Project Structure](#-project-structure)
-- [Contribute & Roadmap](#-contribute--roadmap)
-- [Star History](#-star-history)
-
## 📢 News
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
@@ -97,6 +78,25 @@