diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7f3ddef --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +ENV PATH="/root/.local/bin:${PATH}" + +# Install nanobot from PyPI via uv tool. +RUN uv tool install --upgrade nanobot-ai -i https://pypi.tuna.tsinghua.edu.cn/simple + +# Persist nanobot config/workspace here via volume mount. +RUN mkdir -p /root/.nanobot + +WORKDIR /workspace + +EXPOSE 18790 + +ENTRYPOINT ["nanobot"] +CMD ["status"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a5ba992 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + nanobot-gateway: + container_name: nanobot-gateway + build: + context: . + dockerfile: Dockerfile + command: ["gateway"] + restart: unless-stopped + volumes: + - ./nanobot-config:/root/.nanobot + ports: + - "18790:18790" + + nanobot-cli: + build: + context: . + dockerfile: Dockerfile + profiles: + - cli + command: ["status"] + stdin_open: true + tty: true + volumes: + - ./nanobot-config:/root/.nanobot diff --git a/nanobot-channel-web/.gitignore b/nanobot-channel-web/.gitignore new file mode 100644 index 0000000..ad3a8cf --- /dev/null +++ b/nanobot-channel-web/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.venv/ diff --git a/nanobot-channel-web/README.md b/nanobot-channel-web/README.md new file mode 100644 index 0000000..a333114 --- /dev/null +++ b/nanobot-channel-web/README.md @@ -0,0 +1,94 @@ +# nanobot-channel-web + +A WebChannel plugin for nanobot that exposes chat APIs and validates tokens via an external auth service. + +## Features + +- `POST /message` to send user text into nanobot +- `GET /events/{chat_id}` Server-Sent Events stream for assistant replies +- `GET /history/{chat_id}` quick history for page refresh +- `GET /health` health check +- Optional external auth (`authEnabled=true` + `authServiceUrl`) +- Optional `apiToken` fallback (`Bearer` header; SSE also supports `?token=`) + +## Install + +Install in the same Python environment as nanobot: + +```bash +cd nanobot-channel-web +pip install -e . +``` + +Confirm plugin discovery: + +```bash +nanobot plugins list +``` + +You should see `web` from `plugin` source. + +## Config + +Run `nanobot onboard` once after plugin installation to inject defaults, then edit `~/.nanobot/config.json`: + +```json +{ + "channels": { + "web": { + "enabled": true, + "host": "127.0.0.1", + "port": 9000, + "allowFrom": ["*"], + "apiToken": "change-this-token", + "authEnabled": true, + "authServiceUrl": "http://127.0.0.1:9100", + "authServiceTimeoutS": 8, + "corsOrigin": "*", + "historySize": 200, + "pingIntervalS": 15 + } + } +} +``` + +If `authEnabled` is true, WebChannel validates bearer tokens by calling: + +- `GET {authServiceUrl}/auth/me` + +Expected response shape: + +```json +{ + "ok": true, + "user": {"id": 1, "phone": "13800000000"} +} +``` + +`apiToken` is used only when `authEnabled` is false. + +Protected chat endpoints: + +- `POST /message` +- `GET /history/{chat_id}` +- `GET /events/{chat_id}` + +Use header for normal requests: + +```http +Authorization: Bearer change-this-token +``` + +For native browser `EventSource`, use query param: + +```text +/events/web-user?token=change-this-token +``` + +## Run + +```bash +nanobot gateway +``` + +Then point your frontend to `http://127.0.0.1:9000`. diff --git a/nanobot-channel-web/nanobot_channel_web/__init__.py b/nanobot-channel-web/nanobot_channel_web/__init__.py new file mode 100644 index 0000000..d880099 --- /dev/null +++ b/nanobot-channel-web/nanobot_channel_web/__init__.py @@ -0,0 +1,3 @@ +from .channel import WebChannel + +__all__ = ["WebChannel"] diff --git a/nanobot-channel-web/nanobot_channel_web/channel.py b/nanobot-channel-web/nanobot_channel_web/channel.py new file mode 100644 index 0000000..67681b8 --- /dev/null +++ b/nanobot-channel-web/nanobot_channel_web/channel.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import asyncio +import json +from collections import defaultdict, deque +from datetime import datetime +from typing import Any + +from aiohttp import ClientError, ClientSession, ClientTimeout, web +from loguru import logger +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel + + +class WebChannelConfig(BaseModel): + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + enabled: bool = False + host: str = "127.0.0.1" + port: int = 9000 + allow_from: list[str] = Field(default_factory=lambda: ["*"]) + cors_origin: str = "*" + history_size: int = 200 + ping_interval_s: int = 15 + api_token: str = "" + auth_enabled: bool = False + auth_service_url: str = "" + auth_service_timeout_s: int = 8 + + +class WebChannel(BaseChannel): + name = "web" + display_name = "Web" + + @classmethod + def default_config(cls) -> dict[str, Any]: + return WebChannelConfig().model_dump(by_alias=True) + + def __init__(self, config: Any, bus: MessageBus): + if isinstance(config, dict): + config = WebChannelConfig.model_validate(config) + elif not isinstance(config, WebChannelConfig): + # Extra channel sections in nanobot config can arrive as generic objects. + config = WebChannelConfig.model_validate(getattr(config, "model_dump", lambda: {})()) + super().__init__(config, bus) + self.config: WebChannelConfig = config + self._app: web.Application | None = None + self._runner: web.AppRunner | None = None + self._site: web.TCPSite | None = None + self._http: ClientSession | None = None + self._listeners: dict[str, set[asyncio.Queue[dict[str, Any]]]] = defaultdict(set) + self._history: dict[str, deque[dict[str, Any]]] = defaultdict( + lambda: deque(maxlen=self.config.history_size) + ) + + async def start(self) -> None: + self._running = True + + if self.config.auth_enabled: + if not self.config.auth_service_url.strip(): + raise RuntimeError("authEnabled=true requires channels.web.authServiceUrl") + self._http = ClientSession( + timeout=ClientTimeout(total=max(1, self.config.auth_service_timeout_s)), + ) + logger.info("web channel external auth enabled -> {}", self.config.auth_service_url) + + self._app = web.Application(middlewares=[self._cors_middleware]) + self._app.router.add_get("/health", self._health) + self._app.router.add_post("/message", self._on_message) + self._app.router.add_get("/events/{chat_id}", self._events) + self._app.router.add_get("/history/{chat_id}", self._history_api) + + self._runner = web.AppRunner(self._app) + await self._runner.setup() + self._site = web.TCPSite(self._runner, self.config.host, self.config.port) + await self._site.start() + + logger.info("web channel listening on http://{}:{}", self.config.host, self.config.port) + + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + self._running = False + if self._http: + await self._http.close() + self._http = None + if self._runner: + await self._runner.cleanup() + self._runner = None + self._site = None + + async def send(self, msg: OutboundMessage) -> None: + payload = { + "type": "progress" if msg.metadata.get("_progress") else "message", + "role": "assistant", + "chat_id": msg.chat_id, + "content": msg.content, + "at": datetime.now().strftime("%H:%M:%S"), + "metadata": msg.metadata, + } + self._append_history(msg.chat_id, payload) + await self._fanout(msg.chat_id, payload) + + def _allowed_cors_origins(self) -> list[str]: + configured = [o.strip() for o in self.config.cors_origin.split(",") if o.strip()] + if not configured: + configured = [str(o).strip() for o in self.config.allow_from if str(o).strip()] + return configured or ["*"] + + def _resolve_cors_origin(self, request: web.Request) -> str: + allowed = self._allowed_cors_origins() + if "*" in allowed: + return "*" + req_origin = request.headers.get("Origin", "").strip() + if req_origin and req_origin in allowed: + return req_origin + return allowed[0] + + def _apply_cors_headers(self, request: web.Request, resp: web.StreamResponse) -> web.StreamResponse: + origin = self._resolve_cors_origin(request) + resp.headers["Access-Control-Allow-Origin"] = origin + if origin != "*": + resp.headers["Vary"] = "Origin" + resp.headers["Access-Control-Allow-Credentials"] = "true" + resp.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS" + req_headers = request.headers.get("Access-Control-Request-Headers", "").strip() + resp.headers["Access-Control-Allow-Headers"] = req_headers or "Content-Type, Authorization" + resp.headers["Access-Control-Max-Age"] = "86400" + return resp + + @web.middleware + async def _cors_middleware(self, request: web.Request, handler): + try: + if request.method == "OPTIONS": + resp: web.StreamResponse = web.Response(status=204) + else: + resp = await handler(request) + except web.HTTPException as e: + resp = web.json_response({"ok": False, "error": e.reason or "http error"}, status=e.status) + except Exception as e: + logger.exception("web request failed: {}", e) + resp = web.json_response({"ok": False, "error": "internal server error"}, status=500) + + return self._apply_cors_headers(request, resp) + + def _extract_token(self, request: web.Request) -> str: + auth = request.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:].strip() + # Native EventSource cannot set Authorization headers, so allow query token. + return request.query.get("token", "").strip() + + async def _verify_external_user(self, token: str) -> dict[str, Any] | None: + if not self._http: + return None + url = f"{self.config.auth_service_url.rstrip('/')}/auth/me" + try: + async with self._http.get(url, headers={"Authorization": f"Bearer {token}"}) as resp: + if resp.status != 200: + return None + payload = await resp.json() + if not payload.get("ok"): + return None + user = payload.get("user") + if not isinstance(user, dict): + return None + phone = str(user.get("phone", "")) + uid = int(user.get("id", 0)) + if not phone or uid <= 0: + return None + return {"id": uid, "phone": phone} + except (ClientError, asyncio.TimeoutError, ValueError) as e: + logger.warning("web auth service request failed: {}", e) + return None + + async def _require_auth(self, request: web.Request) -> tuple[dict[str, Any] | None, web.Response | None]: + if self.config.auth_enabled: + token = self._extract_token(request) + if not token: + return None, self._unauthorized() + user = await self._verify_external_user(token) + if not user: + return None, self._unauthorized() + return user, None + + expected = (self.config.api_token or "").strip() + if expected and self._extract_token(request) != expected: + return None, self._unauthorized() + return None, None + + def _unauthorized(self) -> web.Response: + return web.json_response({"ok": False, "error": "unauthorized"}, status=401) + + def _is_chat_allowed(self, user_phone: str, chat_id: str) -> bool: + return chat_id == user_phone or chat_id.startswith(f"{user_phone}:") + + async def _health(self, _request: web.Request) -> web.Response: + return web.json_response({"ok": True, "channel": self.name}) + + async def _history_api(self, request: web.Request) -> web.Response: + user, err = await self._require_auth(request) + if err: + return err + chat_id = request.match_info["chat_id"] + if user and not self._is_chat_allowed(user["phone"], chat_id): + return web.json_response({"ok": False, "error": "forbidden"}, status=403) + return web.json_response({"messages": list(self._history.get(chat_id, []))}) + + async def _on_message(self, request: web.Request) -> web.Response: + user, err = await self._require_auth(request) + if err: + return err + try: + body = await request.json() + except Exception: + return web.json_response({"ok": False, "error": "invalid json"}, status=400) + + sender = str(body.get("sender", "web-user")) + if user: + sender = user["phone"] + chat_id = str(body.get("chat_id", sender)) + if user and not self._is_chat_allowed(user["phone"], chat_id): + return web.json_response({"ok": False, "error": "forbidden"}, status=403) + text = str(body.get("text", "")).strip() + media = body.get("media", []) + metadata = body.get("metadata", {}) + + if not text: + return web.json_response({"ok": False, "error": "text is required"}, status=400) + + self._append_history( + chat_id, + { + "type": "message", + "role": "user", + "chat_id": chat_id, + "content": text, + "at": datetime.now().strftime("%H:%M:%S"), + }, + ) + + await self._handle_message( + sender_id=sender, + chat_id=chat_id, + content=text, + media=media if isinstance(media, list) else [], + metadata=metadata if isinstance(metadata, dict) else {}, + ) + + return web.json_response({"ok": True}) + + async def _events(self, request: web.Request) -> web.StreamResponse: + user, err = await self._require_auth(request) + if err: + return err + chat_id = request.match_info["chat_id"] + if user and not self._is_chat_allowed(user["phone"], chat_id): + return web.json_response({"ok": False, "error": "forbidden"}, status=403) + + resp = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + await resp.prepare(request) + + queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._listeners[chat_id].add(queue) + + try: + await self._write_sse( + resp, + { + "type": "system", + "role": "system", + "chat_id": chat_id, + "content": "stream connected", + "at": datetime.now().strftime("%H:%M:%S"), + }, + ) + + for item in self._history.get(chat_id, []): + await self._write_sse(resp, item) + + while self._running: + try: + item = await asyncio.wait_for( + queue.get(), timeout=max(5, self.config.ping_interval_s) + ) + await self._write_sse(resp, item) + except asyncio.TimeoutError: + await resp.write(b": ping\\n\\n") + await resp.drain() + + except asyncio.CancelledError: + raise + except ConnectionResetError: + pass + finally: + self._listeners[chat_id].discard(queue) + if not self._listeners[chat_id]: + del self._listeners[chat_id] + + return resp + + async def _fanout(self, chat_id: str, payload: dict[str, Any]) -> None: + listeners = self._listeners.get(chat_id) + if not listeners: + return + for q in listeners: + q.put_nowait(payload) + + def _append_history(self, chat_id: str, payload: dict[str, Any]) -> None: + self._history[chat_id].append(payload) + + async def _write_sse(self, resp: web.StreamResponse, payload: dict[str, Any]) -> None: + body = f"data: {json.dumps(payload, ensure_ascii=False)}\\n\\n".encode("utf-8") + await resp.write(body) + await resp.drain() diff --git a/nanobot-channel-web/pyproject.toml b/nanobot-channel-web/pyproject.toml new file mode 100644 index 0000000..1ecc691 --- /dev/null +++ b/nanobot-channel-web/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "nanobot-channel-web" +version = "0.1.0" +description = "Web channel plugin for nanobot" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "nanobot-ai>=0.1.4", + "aiohttp>=3.9.0", + "pydantic>=2.7.0" +] + +[project.entry-points."nanobot.channels"] +web = "nanobot_channel_web:WebChannel" + +[tool.setuptools.packages.find] +where = ["."] +include = ["nanobot_channel_web*"] diff --git a/nanobot-config/config.json b/nanobot-config/config.json new file mode 100644 index 0000000..638cb81 --- /dev/null +++ b/nanobot-config/config.json @@ -0,0 +1,315 @@ +{ + "agents": { + "defaults": { + "workspace": "~/.nanobot/workspace", + "model": "glm-4.7", + "provider": "zhipu", + "maxTokens": 8192, + "temperature": 0.1, + "maxToolIterations": 20, + "memoryWindow": 100, + "reasoningEffort": null + } + }, + "channels": { + "sendProgress": false, + "sendToolHints": false, + "whatsapp": { + "enabled": false, + "bridgeUrl": "ws://localhost:3001", + "bridgeToken": "", + "allowFrom": [] + }, + "telegram": { + "enabled": false, + "token": "", + "allowFrom": [], + "proxy": null, + "replyToMessage": false + }, + "discord": { + "enabled": false, + "token": "", + "allowFrom": [], + "gatewayUrl": "wss://gateway.discord.gg/?v=10&encoding=json", + "intents": 37377, + "groupPolicy": "mention" + }, + "feishu": { + "enabled": false, + "appId": "", + "appSecret": "", + "encryptKey": "", + "verificationToken": "", + "allowFrom": [], + "reactEmoji": "THUMBSUP" + }, + "mochat": { + "enabled": false, + "baseUrl": "https://mochat.io", + "socketUrl": "", + "socketPath": "/socket.io", + "socketDisableMsgpack": false, + "socketReconnectDelayMs": 1000, + "socketMaxReconnectDelayMs": 10000, + "socketConnectTimeoutMs": 10000, + "refreshIntervalMs": 30000, + "watchTimeoutMs": 25000, + "watchLimit": 100, + "retryDelayMs": 500, + "maxRetryAttempts": 0, + "clawToken": "", + "agentUserId": "", + "sessions": [], + "panels": [], + "allowFrom": [], + "mention": { + "requireInGroups": false + }, + "groups": {}, + "replyDelayMode": "non-mention", + "replyDelayMs": 120000 + }, + "dingtalk": { + "enabled": false, + "clientId": "", + "clientSecret": "", + "allowFrom": [] + }, + "email": { + "enabled": false, + "consentGranted": false, + "imapHost": "", + "imapPort": 993, + "imapUsername": "", + "imapPassword": "", + "imapMailbox": "INBOX", + "imapUseSsl": true, + "smtpHost": "", + "smtpPort": 587, + "smtpUsername": "", + "smtpPassword": "", + "smtpUseTls": true, + "smtpUseSsl": false, + "fromAddress": "", + "autoReplyEnabled": true, + "pollIntervalSeconds": 30, + "markSeen": true, + "maxBodyChars": 12000, + "subjectPrefix": "Re: ", + "allowFrom": [] + }, + "slack": { + "enabled": false, + "mode": "socket", + "webhookPath": "/slack/events", + "botToken": "", + "appToken": "", + "userTokenReadOnly": true, + "replyInThread": true, + "reactEmoji": "eyes", + "allowFrom": [], + "groupPolicy": "mention", + "groupAllowFrom": [], + "dm": { + "enabled": true, + "policy": "open", + "allowFrom": [] + } + }, + "qq": { + "enabled": true, + "appId": "1903534139", + "secret": "Q0bCoQ3gKydJzgN5nWFzjUF1nZM9xlaP", + "allowFrom": [ + "*" + ] + }, + "web": { + "enabled": false, + "host": "0.0.0.0", + "port": 9000, + "allowFrom": [ + "*" + ], + "apiToken": "", + "authEnabled": false, + "authServiceUrl": "http://127.0.0.1:9100", + "authServiceTimeoutS": 8, + "corsOrigin": "*", + "historySize": 200, + "pingIntervalS": 15 + }, + "matrix": { + "enabled": false, + "homeserver": "https://matrix.org", + "accessToken": "", + "userId": "", + "deviceId": "", + "e2EeEnabled": true, + "syncStopGraceSeconds": 2, + "maxMediaBytes": 20971520, + "allowFrom": [], + "groupPolicy": "open", + "groupAllowFrom": [], + "allowRoomMentions": false + } + }, + "providers": { + "custom": { + "apiKey": "sk-1OR52Xzd5JsBkROOgjGsVV53sBR9oY9g7SmGqUKs12azozFC", + "apiBase": "https://api.qingyuntop.top/v1", + "extraHeaders": null + }, + "azureOpenai": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "anthropic": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "openai": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "openrouter": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "deepseek": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "groq": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "zhipu": { + "apiKey": "f0abad6ca6d54c6aa367cb9350d30919.EIRG6EC0KxaRzYLX", + "apiBase": "https://open.bigmodel.cn/api/coding/paas/v4", + "extraHeaders": null + }, + "dashscope": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "vllm": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "gemini": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "moonshot": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "minimax": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "aihubmix": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "siliconflow": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "volcengine": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "openaiCodex": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + }, + "githubCopilot": { + "apiKey": "", + "apiBase": null, + "extraHeaders": null + } + }, + "gateway": { + "host": "0.0.0.0", + "port": 18790, + "heartbeat": { + "enabled": true, + "intervalS": 1800 + } + }, + "tools": { + "web": { + "proxy": null, + "search": { + "apiKey": "", + "maxResults": 5 + } + }, + "exec": { + "timeout": 60, + "pathAppend": "" + }, + "restrictToWorkspace": false, + "mcpServers": { + "web-search-prime": { + "type": null, + "command": "", + "args": [], + "env": {}, + "url": "https://open.bigmodel.cn/api/mcp/web_search_prime/mcp", + "headers": { + "Authorization": "Bearer f0abad6ca6d54c6aa367cb9350d30919.EIRG6EC0KxaRzYLX" + }, + "toolTimeout": 30 + }, + "mars-datacube": { + "type": null, + "command": "uv", + "args": [ + "run", + "--directory", + "/home/la-mars/studio/mars-mcp", + "python", + "server.py" + ], + "env": {}, + "url": "", + "headers": {}, + "toolTimeout": 60 + }, + "map-mcp": { + "type": null, + "command": "uv", + "args": [ + "run", + "--directory", + "/home/la-mars/studio/map-mcp", + "python", + "server.py" + ], + "env": {}, + "url": "", + "headers": {}, + "toolTimeout": 30 + } + } + } +} diff --git a/nanobot-config/cron/jobs.json b/nanobot-config/cron/jobs.json new file mode 100644 index 0000000..b8cdc50 --- /dev/null +++ b/nanobot-config/cron/jobs.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "jobs": [] +} \ No newline at end of file diff --git a/nanobot-config/workspace/AGENTS.md b/nanobot-config/workspace/AGENTS.md new file mode 100644 index 0000000..a24604b --- /dev/null +++ b/nanobot-config/workspace/AGENTS.md @@ -0,0 +1,21 @@ +# Agent Instructions + +You are a helpful AI assistant. Be concise, accurate, and friendly. + +## Scheduled Reminders + +Before scheduling reminders, check available skills and follow skill guidance first. +Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`). +Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`). + +**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications. + +## Heartbeat Tasks + +`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks: + +- **Add**: `edit_file` to append new tasks +- **Remove**: `edit_file` to delete completed tasks +- **Rewrite**: `write_file` to replace all tasks + +When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder. diff --git a/nanobot-config/workspace/HEARTBEAT.md b/nanobot-config/workspace/HEARTBEAT.md new file mode 100644 index 0000000..322dbeb --- /dev/null +++ b/nanobot-config/workspace/HEARTBEAT.md @@ -0,0 +1,16 @@ +# Heartbeat Tasks + +This file is checked every 30 minutes by your nanobot agent. +Add tasks below that you want the agent to work on periodically. + +If this file has no tasks (only headers and comments), the agent will skip the heartbeat. + +## Active Tasks + + + + +## Completed + + + diff --git a/nanobot-config/workspace/SOUL.md b/nanobot-config/workspace/SOUL.md new file mode 100644 index 0000000..152495d --- /dev/null +++ b/nanobot-config/workspace/SOUL.md @@ -0,0 +1,61 @@ +# Soul + +I am nanobot 🐈, a **Mars Digital Assistant** (火星数字助手). + +## Identity + +I am an AI assistant specialized in helping users interact with a digital Mars exploration platform through natural language. Users can perform various map operations and platform features by talking to me, eliminating the need for traditional keyboard and mouse interactions. + +## Personality + +- Helpful and friendly +- Concise and to the point +- Curious and eager to learn +- Professional in Mars data and spatial analysis + +## Values + +- Accuracy over speed +- User privacy and safety +- Transparency in actions +- Empowering users with seamless natural language interactions + +## Communication Style + +- Be clear and direct +- Explain reasoning when helpful +- Ask clarifying questions when needed +- Provide Mars-specific context when relevant + +## Core Capabilities + +### Mars-MCP Tools +- **spatiotemporal_search**: Spatial range queries for Mars imagery +- **spatiotemporal_semantic_search**: Natural language semantic search for geological features +- Support for multiple data products (Tianwen-1 MoRIC, Mars CTX, Mars HiRISE) + +### Map-MCP Tools +- **map_zoom_to**: Fly to specified Mars coordinates +- **map_get_viewport**: Get current map view information +- **map_draw_bbox**: Draw bounding boxes for query areas +- **map_spatial_query**: Query imagery data within drawn areas +- **map_render_footprints**: Render image coverage areas on map +- **map_clear**: Clear AI-drawn content +- **map_get_drawn_geometry**: Get hand-drawn geometry + +## Mission + +Enable natural language-driven Mars exploration: +- "帮我绘制阿盖尔平原的查询区域" +- "定位到北极点,高度保持不变" +- "查询这个区域的天问影像数据" +- "搜索所有包含三角洲的影像" + +## Future Evolution + +The user is continuously developing and improving: +- MCP tools and integrations +- Custom skills for Mars data analysis +- Frontend: ~/studio/mars-frontend-vue/ + +My capabilities will expand as more platform features become available through natural language interfaces. diff --git a/nanobot-config/workspace/TOOLS.md b/nanobot-config/workspace/TOOLS.md new file mode 100644 index 0000000..51c3a2d --- /dev/null +++ b/nanobot-config/workspace/TOOLS.md @@ -0,0 +1,15 @@ +# Tool Usage Notes + +Tool signatures are provided automatically via function calling. +This file documents non-obvious constraints and usage patterns. + +## exec — Safety Limits + +- Commands have a configurable timeout (default 60s) +- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) +- Output is truncated at 10,000 characters +- `restrictToWorkspace` config can limit file access to the workspace + +## cron — Scheduled Reminders + +- Please refer to cron skill for usage. diff --git a/nanobot-config/workspace/USER.md b/nanobot-config/workspace/USER.md new file mode 100644 index 0000000..671ec49 --- /dev/null +++ b/nanobot-config/workspace/USER.md @@ -0,0 +1,49 @@ +# User Profile + +Information about the user to help personalize interactions. + +## Basic Information + +- **Name**: (your name) +- **Timezone**: (your timezone, e.g., UTC+8) +- **Language**: (preferred language) + +## Preferences + +### Communication Style + +- [ ] Casual +- [ ] Professional +- [ ] Technical + +### Response Length + +- [ ] Brief and concise +- [ ] Detailed explanations +- [ ] Adaptive based on question + +### Technical Level + +- [ ] Beginner +- [ ] Intermediate +- [ ] Expert + +## Work Context + +- **Primary Role**: (your role, e.g., developer, researcher) +- **Main Projects**: (what you're working on) +- **Tools You Use**: (IDEs, languages, frameworks) + +## Topics of Interest + +- +- +- + +## Special Instructions + +(Any specific instructions for how the assistant should behave) + +--- + +*Edit this file to customize nanobot's behavior for your needs.* diff --git a/nanobot-config/workspace/memory/HISTORY.md b/nanobot-config/workspace/memory/HISTORY.md new file mode 100644 index 0000000..ce32e9d --- /dev/null +++ b/nanobot-config/workspace/memory/HISTORY.md @@ -0,0 +1,16 @@ +[2026-03-16 16:06] User tested Mars spatial query MCP tools successfully. Tested spatiotemporal_search and spatiotemporal_semantic_search with Tianwen-1 MoRIC imagery data. Semantic search for "撞击坑" (craters) returned 3 results with similarity scores 0.44-0.46. User queried Jezero crater (18.4°N, 77.5°E) area, found 75 Tianwen-1 images. Attempted Map-MCP layer switching - not available. Used map_zoom_to to navigate to Jezero crater (initial success at 100km, then 3D view initialization issues), then successfully flew to Argyre Planitia (48°S, 323°E) at 4037km altitude. + +[2026-03-16 16:58] User drew bounding box for Argyre Planitia region (-67° to -7° lon, -58° to -38° lat), performed spatiotemporal search finding 426 Tianwen-1 MoRIC images (Jun 2022). Semantic searches for "三角洲" (90 images) and "古湖床" (102 images) completed, though some semantic descriptions showed anomalies. [2026-03-16 20:03] Browser connection established after initial issues. [2026-03-16 20:05-20:25] Multiple map flights to Argyre Planitia coordinates (37°W, 48°S) at various altitudes (10,191-22,671 km). [2026-03-17 10:21] New rectangle drawn near Argyre Planitia with coordinates (-52°, -58°) to (-22°, -38°). + +[2026-03-17 10:42-14:55] User conducted multiple operations on Argyre Planitia: repeatedly drew rectangle bbox (-52°, -58°) to (-22°, -38°) and queried Tianwen-1 MoRIC data (259 images from June 2022). Cleared and redrew rectangle multiple times. Performed several map flights to (-37°, -48°) coordinates at altitudes 10,460-13,441km. Encountered one browser connection timeout error but successfully completed most operations. + +[2026-03-17] Multiple map operations performed on Argyre Planitia region (37°W, -48°S). Map flights executed at various altitudes: ground level, 7,810km, 10,279km, and 10,260km. Primary bbox (-52° to -22° lon, -58° to -38° lat) drawn and queried multiple times, consistently returning 259 Tianwen-1 MoRIC images. Browser timeout errors encountered (15s timeout). Map viewport bbox returned global bounds (-180 to 180) instead of actual view during flights. Final flight to North Pole (0° lon, 90°N) at 4,038km altitude. + +[2026-03-17 19:30] User redefined nanobot's identity as a "Mars Digital Assistant" (火星数字助手). Updated SOUL.md and MEMORY.md to reflect new role: enable natural language-driven Mars exploration through digital platform, eliminating need for traditional keyboard/mouse interactions. Key features documented: Mars-MCP (spatiotemporal search, semantic search), Map-MCP (zoom, viewport, draw bbox, query, render footprints, clear). Platform frontend: ~/studio/mars-frontend-vue/ (Vue.js). Goal: Make all platform functions accessible through natural language as user continues expanding MCP tools and custom skills. + +[2026-03-17 15:34-18:59] Multiple map operations on Argyre Planitia and North Pole. Argyre bbox (-52° to -22° lon, -58° to -38° lat) drawn and queried 3 times (15:37, 17:18, 18:45) consistently returning 259 Tianwen-1 images. Flights to Argyre at (-37°, -48°) at various altitudes: 1km, 7,810km, 10,279km, 10,260km. Browser timeout error at 17:30 (15s timeout). North Pole flights: 4,038km at 18:44, 19,653km at 18:59. Viewport bbox continues to return global bounds (-180 to 180). North Polar Cap features: water ice and CO2 ice, ~1,000 km diameter. + +[2026-03-17 19:29] User updated assistant persona to "Mars Digital Assistant" for digital Mars platform. The assistant helps users perform map operations via natural language instead of keyboard/mouse. Frontend code at ~/studio/mars-frontend-vue/. User plans to continuously improve MCP/Skill tools. [2026-03-17 19:31] SOUL.md, MEMORY.md, and HISTORY.md updated with new persona and platform context. [2026-03-17 19:34-19:35] Multiple map flights performed: South Pole (90°S), North Pole (90°N) at various altitudes (12,007km, 14,987km). Argyre Planitia bbox (-52°,-58°) to (-22°,-38°) drawn again, queried 259 Tianwen-1 MoRIC images and discovered 48 Mars CTX images. + +[2026-03-17 19:44] User requested current map information in Chinese. Map-MCP returned global view from 26,673 km altitude at coordinates (-0.395°, -6.3716°). Viewport bbox again returned global bounds (-180 to 180) instead of actual view, confirming persistent bbox issue. This is highest recorded altitude in session. + diff --git a/nanobot-config/workspace/memory/MEMORY.md b/nanobot-config/workspace/memory/MEMORY.md new file mode 100644 index 0000000..a87e675 --- /dev/null +++ b/nanobot-config/workspace/memory/MEMORY.md @@ -0,0 +1,146 @@ +# Long-term Memory + +This file stores important information that should persist across sessions. + +## User Information + +- User works with Mars spatial data and imagery analysis +- Interested in Martian geological features (craters, dunes, deltas) +- Uses both spatial queries and semantic search for Mars imagery +- Developer of a digital Mars platform with natural language interface + +## Platform Context + +- **Assistant Role**: Mars Digital Assistant (火星数字助手) for digital Mars platform +- **Mission**: Enable users to complete map operations and platform functions through natural language instead of traditional keyboard/mouse +- **Frontend Code**: Located at `~/studio/mars-frontend-vue/` +- **Vision**: User plans to continuously improve MCP and Skill tools to enable all platform functions through natural language + +## Preferences + +- Uses Chinese language for Mars data queries +- Prefers detailed geological analysis and feature descriptions +- Interested in scientific value and geological evolution of Martian features +- Natural language interaction style for all operations + +## Project Context + +### Available MCP Tools + +**Mars-MCP Tools (Data Query):** +- `spatiotemporal_search` - Basic spatial range query for Mars imagery + - Parameters: spatial (lat_min, lat_max, lon_min, lon_max), product type + - Returns: image ID, filename, path, coordinates, capture time + +- `spatiotemporal_semantic_search` - Spatial + semantic search using natural language + - Returns images with similarity scores (cosine similarity 0-1) and detailed visual descriptions + - Supports queries like "撞击坑" (craters), "沙丘" (dunes), "三角洲" (deltas) + +**Map-MCP Tools (Visualization):** +- `map_zoom_to` - Fly to specified Mars coordinates (lon, lat, altitude) +- `map_get_viewport` - Get current map viewport information +- `map_draw_bbox` - Draw bounding box and fly to region +- `map_render_footprints` - Render GeoJSON footprint data +- `map_clear` - Clear AI-drawn content +- `map_get_drawn_geometry` - Get hand-drawn geometry +- **Note**: No layer switching capability available + +### Data Sources + +- **Tianwen-1 MoRIC imagery** (`tianwen_moric`): High-resolution Mars imagery from Chinese Tianwen-1 mission + - Resolution: ~230 meters/pixel + - Image path format: `/mars_data/tianwenmoric_stamp/...` + - Semantic descriptions include geological features, landform analysis, scientific significance + +- **Mars CTX imagery** (`mars_ctx`): Context Camera from NASA Mars Reconnaissance Orbiter + - Resolution: ~6 meters/pixel + - Task source: NASA Mars Reconnaissance Orbiter (launched 2005) + - Purpose: Medium-resolution imaging for geological surveys and regional studies + - Characteristics: Wide coverage, suitable for regional research + - Often used in conjunction with HiRISE high-resolution imagery + +### Key Locations Queried + +**Jezero Crater (Jezero 陨石坑)** +- Location: 18.4°N, 77.5°E +- Diameter: ~49 km +- Significance: NASA Perseverance rover landing site (2021), ancient lakebed with delta deposits +- Query results: 75 Tianwen-1 images found (Dec 2021 - Apr 2022) +- Key features: impact craters, ancient valley systems, layered sediments, wind-eroded terrain + +**Argyre Planitia (阿盖尔平原)** +- Location: ~48°S, 323°E (-37°) +- Type: Large impact basin +- Diameter: ~1800 km, depth ~5 km +- Age: Noachian (~3.7 billion years) +- Significance: Possible ancient lakebed, delta deposits, habitat research +- **Query results (2026-03-16)**: 426 Tianwen-1 images found (Jun 2022) +- **Spatial queries performed**: + - Full region (-67° to -7° lon, -58° to -38° lat): 426 images + - Sub-region (-55° to -48° lat, -40° to -20° lon): 3 images + - Sub-region (-52° to -45° lat, -45° to -25° lon): 2 images + - **Primary bbox (-52° to -22° lon, -58° to -38° lat)**: 259 images (queried multiple times on 2026-03-16, 2026-03-17 at 15:37, 17:18, 18:45, 19:07, 19:35) +- **CTX data discovered**: 48 Mars CTX images in same Argyre region (2026-03-17 19:07) +- **Semantic search results**: + - "三角洲" (deltas): 90 images, similarity scores 0.3258, 0.3071, 0.3044 + - "古湖床" (ancient lakebed): 102 images, similarity scores 0.371, 0.3583 + - Note: Some semantic descriptions showed anomalies (orange geometric placeholders) +- **Bounding boxes drawn**: + - First: (-67°, -58°) to (-7°, -38°) - 2026-03-16 + - Second: (-52°, -58°) to (-22°, -38°) - 2026-03-17 (drawn and queried 5+ times) +- **Map operations (2026-03-17)**: Multiple flights to coordinates (-37°, -48°) at various altitudes: + - Ground level altitude (~1km) at 15:34 + - 7,810 km at 15:36 + - 10,279 km at 17:30 + - 10,260 km at 17:34 + - 13,260 km (failed due to timeout) at 17:30 + - 12,007 km (North Pole) at 19:34 + - 14,987 km (before North Pole flight) at 19:33 + +**North Pole (北极点)** +- Location: 0° lon, 90°N +- Query date: 2026-03-17 18:44, 18:59, 19:06, 19:34 +- Flight altitudes: 4,038 km, 19,653 km, 9,886 km, 12,007 km +- Features: Mars North Polar Cap (water ice and CO2 ice), ~1,000 km diameter + +**South Pole (南极点)** +- Location: 0° lon, 90°S +- Query date: 2026-03-17 19:04 +- Flight altitudes: 4,218 km +- Features: Mars South Polar Cap, ~350 km diameter (permanent), up to 1,000 km (seasonal) + +**Global View (全球视野)** +- Location: -0.395° lon, -6.3716° lat (equatorial region, southern hemisphere) +- Query date: 2026-03-17 19:44 +- Flight altitude: 26,673 km (highest recorded in session) +- Status: Global overhead view of entire Mars surface + +### Known Issues + +- Map-MCP 3D view initialization can fail intermittently (error: "3D 视图未初始化") +- Semantic search similarity scores vary by feature prevalence in region (e.g., "dunes" scored 0.27, "craters" scored 0.45) +- Browser connection issues can occur intermittently (timeout errors, 15s timeout encountered on 2026-03-17 at 17:30) +- Some semantic descriptions in Argyre Planitia results showed anomalies (orange geometric placeholders instead of geological descriptions) +- **Map-MCP viewport bbox coordinates frequently return global bounds (-180 to 180) instead of actual view** (confirmed multiple times on 2026-03-17, including 19:44) +- Argyre Planitia bbox (-52°, -58°) to (-22°, -38°) drawn and queried 5+ times with consistent 259 results +- Map altitude parameter is not always preserved during flights (actual altitude may differ from requested) + +## Important Notes + +- Semantic search similarity scores: higher is better (1.0 = perfect match), but scores vary by feature abundance +- Visual descriptions from semantic search include detailed geological analysis (features, landforms, scientific significance) +- Map altitudes used: 100km (detailed view), 4000+km (regional overview), 10,000-23,000km (global/regional) +- Map-MCP viewport bbox coordinates frequently return global bounds (-180 to 180) instead of actual view - persistent issue +- Argyre Planitia data is primarily from June 2022 Tianwen-1 observations +- Some semantic descriptions may be incomplete or anomalous (placeholders) +- Primary Argyre Planitia bbox for repeated queries: (-52°, -58°) to (-22°, -38°), returns 259 Tianwen-1 images + 48 Mars CTX images +- Browser timeout issues can interrupt map operations (15s timeout) +- North Polar Cap composition: water ice and CO2 ice, ~1,000 km diameter +- South Polar Cap: ~350 km diameter permanent, up to 1,000 km seasonal expansion +- Highest recorded altitude: 26,673 km at global view (2026-03-17 19:44) +- Assistant is a Mars Digital Assistant enabling natural language interaction for all platform operations +- Frontend code repository: `~/studio/mars-frontend-vue/` + +--- + +*This file is automatically updated by nanobot when important information should be remembered.* \ No newline at end of file