diff --git a/README.md b/README.md index cbaac12..83ce3ae 100644 --- a/README.md +++ b/README.md @@ -744,6 +744,8 @@ nanobot supports [MCP](https://modelcontextprotocol.io/) — connect external to For a full Gmail MCP walkthrough (config + OAuth + verification), see [`docs/gmail_mcp_setup.md`](docs/gmail_mcp_setup.md). +For Proxmox VE via a local clone of [ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus.git), plus **`tools.toolProfiles`** so each MCP stays in its own small tool set (needed for many local LLMs), see **[`mcp-servers/README.md`](mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus)**. + Add MCP servers to your `config.json`: ```json @@ -766,7 +768,7 @@ Two transport modes are supported: | **Stdio** | `command` + `args` | Local process via `npx` / `uvx` | | **HTTP** | `url` | Remote endpoint (`https://mcp.example.com/sse`) | -MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed. +MCP tools are registered when each server connects. If you set `tools.toolProfiles`, nanobot only connects the MCP servers listed for the active profile (see [`mcp-servers/README.md`](mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus)); otherwise all configured MCP servers are used for the run. diff --git a/docs/mcp_and_skills_backlog.md b/docs/mcp_and_skills_backlog.md index 84ba698..351298c 100644 --- a/docs/mcp_and_skills_backlog.md +++ b/docs/mcp_and_skills_backlog.md @@ -81,7 +81,7 @@ nanobot/ ├── mcp-servers/ # <-- NEW: local MCP server clones │ ├── gitea-mcp/ # git clone from gitea.com/gitea/gitea-mcp │ ├── google-calendar-mcp/ # git clone from github.com/nspady/google-calendar-mcp -│ ├── mcp-proxmox/ # git clone from github.com/antonio-mello-ai/mcp-proxmox +│ ├── proxmox-mcp-plus/ # git clone from github.com/RekklesNA/ProxmoxMCP-Plus │ └── fetch-browser/ # git clone from github.com/TheSethRose/Fetch-Browser ├── nanobot/ ├── docs/ @@ -195,10 +195,10 @@ These are the 4 MCP servers we plan to integrate in the immediate next phase. Ea | Field | Detail | |---|---| -| **Upstream** | `github.com/antonio-mello-ai/mcp-proxmox` (Python, pip-installable, MIT) | -| **Transport** | Stdio via `python -m mcp_proxmox` | -| **Auth** | Proxmox API token (user `nanobot@pam!mcp-token` + secret) | -| **Complexity** | **Medium** — requires network route to Proxmox cluster API, API token creation on Proxmox, and careful permission scoping | +| **Upstream** | [RekklesNA/ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus.git) (Python 3.11+, MIT) | +| **Transport** | Stdio via `python -m proxmox_mcp.server` and `PROXMOX_MCP_CONFIG` | +| **Auth** | Proxmox API token (in `proxmox-config/config.json`; see upstream example) | +| **Complexity** | **Medium** — Proxmox API reachability, token scoping; **many tools** → use `tools.toolProfiles` per MCP | | **New capability** | Homelab infrastructure visibility and management from chat | | **Target agents** | `@ilia` only (infrastructure admin; never exposed to `@family` or `@wife`) | @@ -211,26 +211,24 @@ These are the 4 MCP servers we plan to integrate in the immediate next phase. Ea #### Technical notes -- **Build**: `pip install -e ./mcp-servers/mcp-proxmox/` into nanobot's venv, or use a dedicated venv. -- **Local clone path**: `mcp-servers/mcp-proxmox/` +- **Build**: `./scripts/setup-mcp-servers.sh proxmox-mcp-plus`. Full steps: [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus). +- **Local clone path**: `mcp-servers/proxmox-mcp-plus/` - **Proxmox setup**: 1. Create API token: Datacenter → Permissions → API Tokens → Add (`nanobot@pam`, token ID `mcp-token`). 2. Assign minimum roles: `PVEAuditor` for read-only, `PVEVMAdmin` for lifecycle ops (Phase 1 starts read-only). - 3. Store token secret in `~/.nanobot/config.json` env or in a `.env` file. + 3. Copy `proxmox-config/config.example.json` to `config.json` and set host + token fields. - **Config entry**: ```jsonc "proxmox": { - "command": "python", - "args": ["-m", "mcp_proxmox"], + "command": "/mnt/data/nanobot/venv/bin/python", + "args": ["-m", "proxmox_mcp.server"], "env": { - "PROXMOX_HOST": "https://10.0.30.1:8006", - "PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token", - "PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET", - "PROXMOX_VERIFY_SSL": "false" + "PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json" } } ``` -- **Expected tool names**: `mcp_proxmox_list_nodes`, `mcp_proxmox_list_vms`, `mcp_proxmox_list_containers`, `mcp_proxmox_vm_status`, `mcp_proxmox_start_vm`, `mcp_proxmox_stop_vm`, `mcp_proxmox_create_snapshot`, `mcp_proxmox_list_storage` +- **Tool profiles**: One profile per MCP (e.g. `toolProfiles.proxmox` with `"mcpServers": ["proxmox"]` only). Enable `toolRouting` so the active profile switches per message. See [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus). +- **Expected tool names** (examples): `mcp_proxmox_get_vms`, `mcp_proxmox_get_nodes` — exact set depends on upstream (many tools). - **Safety**: Phase 1 deploys with `PVEAuditor` role (read-only). Write operations (start/stop/snapshot) added in Phase 2 behind confirmation prompts. Restricted to `@ilia` profile only — never exposed to `@family`. --- @@ -411,12 +409,12 @@ cd gitea-mcp && go build -o gitea-mcp . && cd .. git clone https://github.com/nspady/google-calendar-mcp.git cd google-calendar-mcp && npm install && npm run build && cd .. -# Proxmox MCP (Python) -git clone https://github.com/antonio-mello-ai/mcp-proxmox.git -cd mcp-proxmox && pip install -e . && cd .. +# Proxmox MCP (Python 3.11+; from repo root) +cd .. && ./scripts/setup-mcp-servers.sh proxmox-mcp-plus && cd mcp-servers # Fetch Browser (TypeScript) -git clone https://github.com/TheSethRose/Fetch-Browser.git fetch-browser +git clone https://github.com/zcaceres/fetch-mcp.git fetch-browser +# or https://github.com/ChromeDevTools/chrome-devtools-mcp.git cd fetch-browser && npm install && npm run build && cd .. ``` @@ -424,19 +422,53 @@ To update a server: `cd mcp-servers/ && git pull && `. Pin to a k ### Per-agent MCP wiring -Since each agent is a separate Docker container, MCP servers are configured in each agent's own `config.json`. An agent only gets the MCP servers listed in its config -- no routing needed. +Since each agent is a separate Docker container, MCP servers are configured in each agent's own `config.json`. **`@ilia` should use `tools.toolProfiles`** so each heavy MCP is exposed alone (required for many local LLMs). See [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus). -**`~/.nanobot-user1/config.json`** (@ilia — all MCP servers): +**`~/.nanobot-user1/config.json`** (@ilia — registers all MCPs; **one profile active per message**): ```jsonc { "tools": { + "defaultToolProfile": "default", + "toolRouting": { "enabled": true }, "mcpServers": { "gmail_mcp": { "command": "npx", "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] }, - "gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": [], "env": { "GITEA_URL": "http://10.0.30.169:3000", "GITEA_TOKEN": "$NANOBOT_GITLE_TOKEN" } }, + "gitea": { "command": "./mcp-servers/gitea-mcp/gitea-mcp", "args": ["-t", "stdio", "--host", "http://10.0.30.169:3000"], "env": { "GITEA_ACCESS_TOKEN": "$NANOBOT_GITLE_TOKEN" } }, "google_calendar": { "command": "node", "args": ["./mcp-servers/google-calendar-mcp/dist/index.js"], "env": { "GOOGLE_OAUTH_CREDENTIALS": "~/.gmail-mcp/gcp-oauth.keys.json" } }, - "proxmox": { "command": "python", "args": ["-m", "mcp_proxmox"], "env": { "PROXMOX_HOST": "https://10.0.30.1:8006", "PROXMOX_TOKEN_ID": "nanobot@pam!mcp-token", "PROXMOX_TOKEN_SECRET": "$PROXMOX_TOKEN_SECRET", "PROXMOX_VERIFY_SSL": "false" } }, + "proxmox": { + "command": "/mnt/data/nanobot/venv/bin/python", + "args": ["-m", "proxmox_mcp.server"], + "env": { + "PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json" + } + }, "web_scraper": { "command": "node", "args": ["./mcp-servers/fetch-browser/dist/index.js"], "env": {} } + }, + "toolProfiles": { + "default": { + "description": "Builtin tools only; no MCP servers.", + "mcpServers": [] + }, + "gmail_mcp": { + "description": "Gmail MCP: inbox search, read, send.", + "mcpServers": ["gmail_mcp"] + }, + "gitea": { + "description": "Gitea: PRs, issues, repos.", + "mcpServers": ["gitea"] + }, + "calendar": { + "description": "Google Calendar MCP: events, free/busy.", + "mcpServers": ["google_calendar"] + }, + "proxmox": { + "description": "Proxmox VE: VMs, LXC, cluster, snapshots, backups.", + "mcpServers": ["proxmox"] + }, + "web_scraper": { + "description": "Headless fetch / scrape MCP.", + "mcpServers": ["web_scraper"] + } } } } @@ -527,7 +559,7 @@ Read-only. No confirmation needed. All three containers (`nanobot-user1`, `nanobot-user2`, `nanobot-user3`) share the same Docker image. MCP server processes are spawned inside each container as needed. The Dockerfile must include: - **Go** (for Gitea MCP binary — or copy pre-built binary) - **Node.js 18+** (for Calendar MCP and Fetch Browser) -- **Python pip deps** (for Proxmox MCP — install into the same venv or a sidecar) +- **Python 3.11+** (image or host venv) with `proxmox-mcp-plus` installed editable from `mcp-servers/proxmox-mcp-plus/` — same venv as nanobot, no nested `.venv` under the clone - **Chromium** (for Fetch Browser headless rendering — `npx puppeteer browsers install chrome` or use Playwright) The `mcp-servers/` directory is mounted read-only into all containers so each agent can spawn the MCP servers listed in its config. Alternatively, build MCP binaries in a multi-stage Docker build and copy only the artifacts into the image. @@ -571,3 +603,4 @@ Week 6: Retrospective, update this document, plan Phase 2 write-skills |---|---| | 2026-03-30 | Updated to reflect multi-container workspace architecture (Option B). Added `@wife` as third agent. Rewrote per-agent MCP wiring with separate config.json per container. Updated skill assignments across all three agents. | | 2026-03-30 | Initial version — shortlist (4 MCP), backlog (16 ideas), skill catalog (16 skills), Phase 1 defined (3 MCP + 5 skills) | +| 2026-04-01 | Proxmox: switched shortlist to [ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus); setup via `scripts/setup-mcp-servers.sh proxmox-mcp-plus`; per-MCP `toolProfiles` documented in [`mcp-servers/README.md`](../mcp-servers/README.md#proxmox-mcp-proxmoxmcp-plus). | diff --git a/mcp-servers/README.md b/mcp-servers/README.md index 059ccae..ec226d1 100644 --- a/mcp-servers/README.md +++ b/mcp-servers/README.md @@ -32,3 +32,112 @@ Then configure nanobot (example): } ``` +## Proxmox MCP (ProxmoxMCP-Plus) + +[ProxmoxMCP-Plus](https://github.com/RekklesNA/ProxmoxMCP-Plus.git) integrates Proxmox VE over MCP stdio. It registers **many** tools; use nanobot **`tools.toolProfiles`** so only **one MCP server** (plus builtins) is connected per turn, and **`tools.toolRouting.enabled`** so the right profile is chosen from the user message (keyword fast-paths for Proxmox and Gitea in `nanobot/agent/tool_routing.py`, then a small router LLM call). + +### Prerequisites + +- Python **3.11+** where the server runs. +- Proxmox API token (Datacenter → Permissions → API Tokens). Scope roles to what you accept (`PVEAuditor` for read-only). + +### Install (local clone) + +From the nanobot repo root (e.g. `/mnt/data/nanobot`): + +```bash +# optional: source venv/bin/activate — setup script prefers ./venv/bin/python when present +./scripts/setup-mcp-servers.sh proxmox-mcp-plus +``` + +This clones into `mcp-servers/proxmox-mcp-plus/` and runs `pip install -e` using **`venv/bin/python`** in the repo if it exists, otherwise `python3`. Override with `NANOBOT_PYTHON=/path/to/python` if needed. No extra `.venv` under the clone. + +It also seeds `proxmox-config/config.json` from `config.example.json` if missing. + +Edit **`mcp-servers/proxmox-mcp-plus/proxmox-config/config.json`**: set `proxmox.host`, `auth.user`, `auth.token_name`, `auth.token_value`. Keep `mcp.transport` **`STDIO`** for nanobot. Do not commit secrets (the tree under `mcp-servers/` is gitignored except this file). + +### Register in nanobot `config.json` + +Use the **same Python** that ran `pip install -e` (absolute path avoids `PATH` mismatches in Docker/systemd). + +Example for this checkout (`venv` at repo root — adjust if your clone lives elsewhere): + +```jsonc +{ + "tools": { + "mcpServers": { + "proxmox": { + "command": "/mnt/data/nanobot/venv/bin/python", + "args": ["-m", "proxmox_mcp.server"], + "env": { + "PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json" + } + } + } + } +} +``` + +(`pip install -e` registers `proxmox_mcp`; you do not need `PYTHONPATH` for normal use.) + +Tools appear as `mcp_proxmox_` (normalized from upstream names). + +### One profile per MCP (local LLMs) + +Add **`tools.toolProfiles`**: each profile’s `mcpServers` lists **only** the server keys to connect for that turn. A **`default`** profile with **`"mcpServers": []`** keeps ordinary chat free of huge MCP tool lists. + +Example (trim or extend profiles to match your `mcpServers` keys): + +```jsonc +{ + "tools": { + "defaultToolProfile": "default", + "toolRouting": { "enabled": true }, + "mcpServers": { + "gmail_mcp": { + "command": "npx", + "args": ["-y", "@gongrzhe/server-gmail-autoauth-mcp"] + }, + "gitea": { + "command": "/mnt/data/nanobot/mcp-servers/gitea-mcp/gitea-mcp", + "args": ["-t", "stdio", "--host", "https://gitea.example.com"], + "env": { "GITEA_ACCESS_TOKEN": "$GITEA_TOKEN" } + }, + "proxmox": { + "command": "/mnt/data/nanobot/venv/bin/python", + "args": ["-m", "proxmox_mcp.server"], + "env": { + "PROXMOX_MCP_CONFIG": "/mnt/data/nanobot/mcp-servers/proxmox-mcp-plus/proxmox-config/config.json" + } + } + }, + "toolProfiles": { + "default": { + "description": "Built-in tools only (filesystem, shell, web, cron, etc.). No MCP servers.", + "mcpServers": [] + }, + "gmail_mcp": { + "description": "Gmail: read/search/send mail via Gmail MCP.", + "mcpServers": ["gmail_mcp"] + }, + "gitea": { + "description": "Gitea: pull requests, issues, repos, code search.", + "mcpServers": ["gitea"] + }, + "proxmox": { + "description": "Proxmox VE: nodes, VMs, LXC, storage, snapshots, backups, cluster status.", + "mcpServers": ["proxmox"] + } + } + } +} +``` + +Profile keys **`proxmox`** and **`gitea`** are matched by router heuristics. For Gmail, use profile key **`gmail_mcp`** if that matches your server name, or rely on the router model and each profile’s `description`. + +### Verify + +1. Start nanobot with the edited config. +2. Ask something Proxmox-specific (e.g. “list VMs on Proxmox”) and check logs for `Tool profile 'proxmox': … tools exposed`. +3. Confirm only `mcp_proxmox_*/tools` are available for that turn. + diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 5552027..571cf75 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -98,12 +98,28 @@ Some LLM backends may not support native function-calling. When you decide to us 2) Calendar shortcut (allowed only for the built-in `calendar` tool): {{"action":"list_events", ...}} -After a tool result is returned, respond normally in plain text unless the user asks for another tool action. +If the request has multiple steps, keep working until all requested steps are done: +- After each tool result, decide whether another tool call is still needed. +- If yes, output the next JSON tool call (same format as above). +- Only switch to a normal plain-text answer when the task is complete or blocked. + +Link references: If the user says "first/last/second link" or "from your last link" without pasting a URL, they mean a URL from **your immediately previous assistant message** (not a new web search). "Last link" = the **last** `https://` URL in that message, top to bottom. Call `web_fetch` on that URL — do not substitute unrelated sites. + +## Using this chat's history (session) +The API sends **prior messages in this conversation** before the latest user line: older user text, your past assistant replies, and **`role: tool` results** (directory listings, `read_file`/`web_fetch` output, etc.). This is **one ongoing thread**, not isolated questions. + +- Resolve **follow-ups** (“that”, “same”, “as before”, “the file you listed”, “the joke”) from **earlier turns and tool outputs**, not only from the latest short message. +- Prefer **facts already established** in the thread (paths, decisions, quoted content) over starting fresh or asking the user to repeat themselves—unless something is genuinely missing. +- If the latest user message is vague but **earlier context or a tool result** makes the intent clear, act on that intent. ### MCP quick mappings (use these when the intent matches) - If the user asks for **my Gitea user info** (who am I / my profile / my account): call `mcp_gitea_get_me` with `{{}}`. - If the user asks for **Gitea MCP server version**: call `mcp_gitea_get_gitea_mcp_server_version` with `{{}}`. - If the user asks to **list my repos**: call `mcp_gitea_list_my_repos` with pagination defaults. +- If the user asks to **list, show, count, or name the latest/recent/last issue(s)** for a repo **owner/repo** (e.g. `ilia/punimtag`): call **`mcp_gitea_list_issues`** with `owner`, `repo`, and optional `state` / pagination per the tool schema. Pick the most recently updated (or newest) issue from the returned list when they ask for "the last" or "latest" one. +- **Issues vs PRs:** Gitea's issue list API returns **pull requests in the same listing** as issues by default. If the user asked for **issues only** (not PRs), pass the tool/API filter for **issues only** if the schema has it (Gitea uses `type=issues`; the MCP may expose this as e.g. `type` → `issues`). If they want **only PRs**, use **`mcp_gitea_list_pull_requests`** instead. If you only called `list_issues` without filtering, **do not** tell them you listed "pull requests" as a separate step—either filter to issues-only in the tool params or say the response may include PRs mixed in. +- **Time windows** ("last 2 days", "since Monday"): compute **`since`** (or equivalent) from **## Current Time** above—ISO 8601 UTC or the user's timezone as appropriate. **Never** invent placeholder dates (e.g. random past years). +- **`mcp_gitea_search_issues` requires `query`** (non-empty search string). Use it only for **keyword / full-text search**. **Do not** call `mcp_gitea_search_issues` with only `owner` and `repo`—that will fail. For browsing or "most recent issue" always use **`mcp_gitea_list_issues`**. ## Current Time {now} ({tz}) @@ -117,7 +133,7 @@ Your workspace is at: {workspace_path} - History log: {workspace_path}/memory/HISTORY.md (grep-searchable) - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md -**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. **`list_dir` takes one directory path**—no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory. +**Filesystem tools (read_file, write_file, edit_file, list_dir):** Use paths **under this workspace root only** (`{workspace_path}`). Do not invent other roots (e.g. `/mnt/data/...` on a host) unless you know they are valid on this runtime. If you listed a **subfolder** (e.g. `list_dir` on `workspace` and saw `joke.txt`), then `read_file` must use **`workspace/joke.txt`**, not `joke.txt` alone. **`list_dir` takes one directory path**—no wildcards (never pass `*.pdf` in the path). To find PDFs, `list_dir("{workspace_path}")` (or a subfolder) and filter for `.pdf` names, or use `exec` with `find` under that directory. **Answering after tools:** When a tool already returned what the user needs, base your reply **only on that tool output**—same topic as the user’s question, no hijacking. - After **`list_dir`:** If they asked for PDFs (or another extension), list **only** matching names (paths under `{workspace_path}` if useful). If none, say so briefly. No essays, no calling the folder "code" unless they asked for analysis. @@ -136,6 +152,8 @@ IMPORTANT: When responding to direct questions or conversations, reply directly Only use the 'message' tool when the user explicitly asks you to send a message to someone else or to a different channel. For normal conversation, acknowledgments (Thanks, OK, etc.), or when the user is talking to YOU, just respond with text - do NOT call the message tool. +**Accepting your own offer (critical):** If *your previous assistant message* asked a yes/no question or offered **one clear next step** (e.g. "Would you like me to read `workspace/joke.txt`?", "Should I search for that?"), and the user replies with a short affirmation ("yes", "sure", "yep", "ok", "please", "go ahead", "do it"), you MUST **carry out that step** using the right tool (e.g. `read_file` with the path you named). Do **not** say they were vague or need to specify a function—use the path or action from **your** prior message. + For simple acknowledgments like "Thanks", "OK", "You're welcome", "Got it", etc., respond naturally and conversationally - just say "You're welcome!", "No problem!", "Happy to help!", etc. Do not explain your reasoning or mention tools. Just be friendly and brief. Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language). @@ -185,6 +203,13 @@ IMPORTANT: For email queries (latest email, email sender, inbox, etc.), ALWAYS u system_prompt = self.build_system_prompt(skill_names) if channel and chat_id: system_prompt += f"\n\n## Current Session\nChannel: {channel}\nChat ID: {chat_id}" + if history: + n = len(history) + system_prompt += ( + f"\n\n## Session message count\n" + f"There are **{n} prior message(s)** in the messages array below (before the latest user input). " + f"They are part of **this same chat**—read and use them when answering." + ) messages.append({"role": "system", "content": system_prompt}) # History diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c403c08..bd6b464 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -1,6 +1,7 @@ """Agent loop: the core processing engine.""" import asyncio +import copy import json import re from contextlib import AsyncExitStack @@ -274,6 +275,151 @@ class AgentLoop: return "\n".join(parts).strip() return "" + @staticmethod + def _contains_relative_link_reference(text: str) -> bool: + """ + Detect phrases like "first link you gave me" when no URL is provided. + + Local models often miss this reference resolution step, so we inject a + lightweight hint with recent URLs from assistant history. + """ + t = (text or "").lower() + if not t: + return False + # If user already supplied a URL, no hint needed. + if "http://" in t or "https://" in t or "www." in t: + return False + markers = [ + "first link", "1st link", "second link", "2nd link", "third link", "3rd link", + "last link", "final link", "from your last", "your last link", + "that link", "the link above", "link above", "previous link", + "first result", "second result", "third result", + "last result", "your last result", + "the first one", "the second one", "the third one", "the last one", + ] + return any(m in t for m in markers) + + @staticmethod + def _ordered_urls_from_latest_assistant( + history: list[dict], max_messages: int = 24, max_urls: int = 40 + ) -> list[str]: + """ + URLs from the single most recent assistant message only, in document order. + + "Last link" / "first link" refer to this message, not older turns or deduped + global lists — local models otherwise pick unrelated URLs (e.g. joke APIs). + """ + url_pattern = re.compile(r"https?://[^\s)>\]\"']+") + for m in reversed(history[-max_messages:]): + if m.get("role") != "assistant": + continue + content = m.get("content") + if not isinstance(content, str) or not content: + continue + found = url_pattern.findall(content) + if not found: + continue + out: list[str] = [] + seen: set[str] = set() + for u in found: + if u not in seen: + seen.add(u) + out.append(u) + if len(out) >= max_urls: + break + return out + return [] + + @staticmethod + def _extract_recent_assistant_urls(history: list[dict], max_messages: int = 12, max_urls: int = 10) -> list[str]: + """Extract unique URLs from the most recent assistant messages.""" + url_pattern = re.compile(r"https?://[^\s)>\]\"']+") + urls: list[str] = [] + seen: set[str] = set() + + for m in reversed(history[-max_messages:]): + if m.get("role") != "assistant": + continue + content = m.get("content") + if not isinstance(content, str) or not content: + continue + found = url_pattern.findall(content) + if not found: + continue + for u in found: + if u not in seen: + seen.add(u) + urls.append(u) + if len(urls) >= max_urls: + return urls + return urls + + def _build_link_reference_hint(self, user_text: str, history: list[dict]) -> str | None: + """Build an explicit URL mapping hint for relative link references.""" + if not self._contains_relative_link_reference(user_text): + return None + # Prefer URLs from the latest assistant reply only ("your last link" = last URL there). + urls = self._ordered_urls_from_latest_assistant(history) + if not urls: + urls = self._extract_recent_assistant_urls(history) + if not urls: + return None + lines = [f"{idx + 1}. {url}" for idx, url in enumerate(urls)] + n = len(urls) + mapping = ( + f'- "first link" / "first result" → URL #1.\n' + f'- "last link" / "from your last link" / "final link" → URL #{n}.\n' + f'- "second link" → URL #2 (if {n} >= 2).\n' + ) + return ( + "The user refers to a link from your **previous assistant message**. " + "Do not guess a URL (e.g. random joke sites). Use `web_fetch` on the URL below.\n" + f"{mapping}\n" + "URLs from that message (order = how they appeared):\n" + + "\n".join(lines) + ) + + @staticmethod + def _append_turn_to_session( + session: Session, + user_content: str, + turn_tail: list[dict], + tools_used: list[str], + final_for_fallback: str | None = None, + ) -> None: + """Persist one user turn and the in-loop assistant/tool messages for the next LLM context.""" + session.add_message("user", user_content) + if not turn_tail: + session.add_message( + "assistant", + final_for_fallback or "", + tools_used=tools_used if tools_used else None, + ) + return + for i, m in enumerate(turn_tail): + role = m.get("role") + if role not in ("assistant", "tool"): + continue + content = m.get("content") + if content is None: + content = "" + kwargs = { + k: v for k, v in m.items() if k not in ("role", "content") + } + if role == "assistant" and i == len(turn_tail) - 1 and tools_used: + kwargs["tools_used"] = tools_used + session.add_message(role, content, **kwargs) + if ( + final_for_fallback + and turn_tail + and turn_tail[-1].get("role") == "tool" + ): + session.add_message( + "assistant", + final_for_fallback, + tools_used=tools_used if tools_used else None, + ) + async def _pick_tool_profile(self, user_text: str) -> str: """Resolve profile key when tools.toolProfiles is configured.""" if not self._tool_profiles: @@ -305,8 +451,12 @@ class AgentLoop: on_progress: Optional callback to push intermediate content to the user. Returns: - Tuple of (final_content, list_of_tools_used). + Tuple of (final_content, list_of_tools_used, turn_messages). + ``turn_messages`` are messages appended after ``initial_messages`` (assistant + tool rounds + tool results + final assistant) for session persistence so + follow-up turns see tool outputs, not only final chat text. """ + n0 = len(initial_messages) messages = initial_messages iteration = 0 final_content = None @@ -368,10 +518,10 @@ class AgentLoop: logger.debug(f"LLM provider returned response, has_tool_calls={response.has_tool_calls}") except asyncio.TimeoutError: logger.error("LLM provider call timed out after 120 seconds") - return "Error: Request timed out. The LLM provider may be slow or unresponsive.", tools_used + return "Error: Request timed out. The LLM provider may be slow or unresponsive.", tools_used, [] except Exception as e: logger.error(f"LLM provider error: {e}") - return f"Error calling LLM: {str(e)}", tools_used + return f"Error calling LLM: {str(e)}", tools_used, [] if response.has_tool_calls: if on_progress: @@ -437,12 +587,20 @@ class AgentLoop: ] final_content = None continue + # Record final assistant in-message (was missing); enables session turn_tail extraction. + messages = self.context.add_assistant_message( + messages, + final_content, + None, + reasoning_content=response.reasoning_content, + ) break if final_content is None and iteration >= self.max_iterations: logger.warning(f"Max iterations ({self.max_iterations}) reached without final response. Last tool calls: {tools_used[-3:] if len(tools_used) >= 3 else tools_used}") - return final_content, tools_used + turn_tail = copy.deepcopy(messages[n0:]) + return final_content, tools_used, turn_tail async def run(self) -> None: """Run the agent loop, processing messages from the bus.""" @@ -548,9 +706,18 @@ class AgentLoop: asyncio.create_task(_consolidate_with_timeout()) self._set_tool_context(msg.channel, msg.chat_id) + history = session.get_history(max_messages=self.memory_window) + hinted_message = msg.content + if link_hint := self._build_link_reference_hint(msg.content, history): + hinted_message = ( + f"{msg.content}\n\n" + f"[Context hint for reference resolution]\n{link_hint}" + ) + logger.debug("Injected link reference hint for follow-up resolution") + initial_messages = self.context.build_messages( - history=session.get_history(max_messages=self.memory_window), - current_message=msg.content, + history=history, + current_message=hinted_message, media=msg.media if msg.media else None, channel=msg.channel, chat_id=msg.chat_id, @@ -565,19 +732,24 @@ class AgentLoop: metadata=msg.metadata or {}, )) - final_content, tools_used = await self._run_agent_loop( + final_content, tools_used, turn_tail = await self._run_agent_loop( initial_messages, on_progress=on_progress or _bus_progress, ) + outbound_fallback = final_content if final_content is None: final_content = "I've completed processing but have no response to give." preview = final_content[:120] + "..." if len(final_content) > 120 else final_content logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") - session.add_message("user", msg.content) - session.add_message("assistant", final_content, - tools_used=tools_used if tools_used else None) + self._append_turn_to_session( + session, + msg.content, + turn_tail, + tools_used, + final_for_fallback=outbound_fallback, + ) self.sessions.save(session) return OutboundMessage( @@ -615,13 +787,20 @@ class AgentLoop: channel=origin_channel, chat_id=origin_chat_id, ) - final_content, _ = await self._run_agent_loop(initial_messages) + final_content, tools_used, turn_tail = await self._run_agent_loop(initial_messages) + sys_user = f"[System: {msg.sender_id}] {msg.content}" + outbound_fallback = final_content if final_content is None: final_content = "Background task completed." - session.add_message("user", f"[System: {msg.sender_id}] {msg.content}") - session.add_message("assistant", final_content) + self._append_turn_to_session( + session, + sys_user, + turn_tail, + tools_used, + final_for_fallback=outbound_fallback, + ) self.sessions.save(session) return OutboundMessage( diff --git a/nanobot/agent/tool_routing.py b/nanobot/agent/tool_routing.py index 9dfc3f3..77cfd73 100644 --- a/nanobot/agent/tool_routing.py +++ b/nanobot/agent/tool_routing.py @@ -27,9 +27,36 @@ async def route_tool_profile( if not profiles: return default_profile - # Heuristic fast-path: if the request clearly needs a dev/forge MCP (PRs, issues, repos), - # prefer an MCP-enabled profile without spending an LLM call. + # Heuristic fast-paths: pick a narrow tool profile without spending an LLM call. msg_l = (user_message or "").lower() + + needs_proxmox = any( + k in msg_l + for k in [ + "proxmox", + "pve ", + " pve", + "pve.", + " qemu", + "qm ", + "qmcreate", + " pct ", + "pct ", + " vzdump", + "lxc ", + " lxc", + "vmid", + "hypervisor", + "proxmox cluster", + "datacenter", + ] + ) + if needs_proxmox: + for candidate in ("proxmox", "proxmox_mcp", "proxmox-mcp", "pve"): + if candidate in profiles: + logger.info(f"Tool router selected profile '{candidate}' (proxmox heuristic)") + return candidate + needs_forge = any( k in msg_l for k in [ @@ -53,15 +80,47 @@ async def route_tool_profile( ] ) if needs_forge: - # Prefer an explicit "*mcp*" profile key if present, else any profile that enables MCP servers. - for key in profiles.keys(): - if "mcp" in key.lower(): - logger.info(f"Tool router selected profile '{key}' (heuristic)") - return key + for candidate in ("gitea", "gitea_mcp", "forge"): + if candidate in profiles: + logger.info(f"Tool router selected profile '{candidate}' (forge heuristic)") + return candidate + # Avoid picking an unrelated "*mcp*" profile (e.g. workspace-only MCP). Prefer + # profiles whose key, description, or mcpServers list looks forge-related. + forge_hints = ( + "gitea", + "github", + "gitlab", + "forge", + "bitbucket", + "codeberg", + ) + forge_phrases = ( + "pull request", + "merge request", + "pull requests", + "merge requests", + ) + scored: list[tuple[int, str]] = [] for key, p in profiles.items(): - if p.mcp_servers is None or (isinstance(p.mcp_servers, list) and len(p.mcp_servers) > 0): - logger.info(f"Tool router selected profile '{key}' (heuristic)") - return key + key_l = key.lower() + desc_l = (p.description or "").lower() + blob_parts = [key_l, desc_l] + mcp = p.mcp_servers + if isinstance(mcp, list): + blob_parts.extend(s.lower() for s in mcp) + blob = " ".join(blob_parts) + score = sum(1 for h in forge_hints if h in blob) + if any(ph in desc_l for ph in forge_phrases): + score += 2 + elif "issue" in desc_l or "issues" in desc_l or "repository" in desc_l: + score += 1 + if score > 0: + scored.append((score, key)) + if scored: + chosen = max(scored, key=lambda t: (t[0], t[1]))[1] + logger.info(f"Tool router selected profile '{chosen}' (forge-related heuristic)") + return chosen + # No forge-shaped profile: use LLM router below instead of arbitrary MCP profile. lines = [] for name, p in profiles.items(): diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index e77e483..30f0e08 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -9,10 +9,26 @@ from nanobot.agent.tools.base import Tool def _resolve_path(path: str, allowed_dir: Path | None = None) -> Path: - """Resolve path and optionally enforce directory restriction.""" - resolved = Path(path).expanduser().resolve() - if allowed_dir and not str(resolved).startswith(str(allowed_dir.resolve())): - raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}") + """Resolve path and optionally enforce directory restriction. + + When ``allowed_dir`` is set, **relative** paths are resolved under that root + (not under the process CWD). Otherwise ``list_dir({"path": "workspace"})`` + in Docker resolves to e.g. ``/app/workspace`` and incorrectly fails as + "outside allowed directory /workspace". + """ + p = Path(path).expanduser() + if allowed_dir: + root = allowed_dir.resolve() + if p.is_absolute(): + resolved = p.resolve() + else: + resolved = (root / p).resolve() + if not resolved.is_relative_to(root): + raise PermissionError( + f"Path {path} is outside allowed directory {allowed_dir}" + ) + else: + resolved = p.resolve() return resolved diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 1a1e29c..f391568 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -196,8 +196,6 @@ class WebFetchTool(Tool): self.max_chars = max_chars async def execute(self, url: str, extractMode: str = "markdown", maxChars: int | None = None, **kwargs: Any) -> str: - from readability import Document - max_chars = maxChars or self.max_chars # Validate URL before fetching @@ -206,6 +204,8 @@ class WebFetchTool(Tool): return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}) try: + from readability import Document + async with httpx.AsyncClient( follow_redirects=True, max_redirects=MAX_REDIRECTS, diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 02cd966..e48a2fe 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -82,14 +82,98 @@ class CustomProvider(LLMProvider): "email", ] # Check for standard format: {"name": "...", "parameters": {...}} - has_standard_format = '"name"' in content and '"parameters"' in content + has_standard_format = '"name"' in content and ( + '"parameters"' in content or '"arguments"' in content or '"params"' in content + ) # Check for calendar tool format: {"action": "...", ...} - has_calendar_format = '"action"' in content and ("calendar" in content.lower() or any(action in content for action in ["list_events", "create_event", "update_event", "delete_event"])) + has_calendar_format = '"action"' in content and ( + "calendar" in content.lower() + or any(action in content for action in [ + "list_events", "create_event", "update_event", + "delete_event", "delete_events", "check_availability" + ]) + ) # Some backends will return *only* a JSON object as the entire message content. # If it looks like a JSON object, attempt parsing even if our heuristics missed it. looks_like_json_object = stripped.startswith("{") and stripped.endswith("}") + def _normalize_parameters(tool_obj: dict[str, Any]) -> dict[str, Any]: + params = ( + tool_obj.get("parameters") + if "parameters" in tool_obj + else tool_obj.get("arguments") + if "arguments" in tool_obj + else tool_obj.get("params") + if "params" in tool_obj + else tool_obj.get("input") + if "input" in tool_obj + else {} + ) + if isinstance(params, dict): + return params + return {"raw": str(params)} + + def _append_tool_call_from_obj(tool_obj: Any) -> bool: + if not isinstance(tool_obj, dict): + return False + + # Calendar shortcut format: {"action":"list_events", ...} + if "action" in tool_obj: + action = tool_obj.get("action") + if action and action in [ + "list_events", "create_event", "update_event", + "delete_event", "delete_events", "check_availability" + ]: + tool_calls.append(ToolCallRequest( + id=f"call_{len(tool_calls)}", + name="calendar", + arguments=tool_obj, + )) + return True + + # Standard format: {"name":"tool","parameters":{...}} + if "name" in tool_obj and isinstance(tool_obj["name"], str): + tool_name = tool_obj["name"] + if tool_name in valid_tools or tool_name.startswith("mcp_"): + tool_calls.append(ToolCallRequest( + id=f"call_{len(tool_calls)}", + name=tool_name, + arguments=_normalize_parameters(tool_obj), + )) + return True + + return False + + # Fast path: parse the entire content as JSON when possible. + # This enables multi-tool payloads like: + # [{"name":"web_search",...},{"name":"web_fetch",...}] + if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object): + try: + payload = json_repair.loads(stripped) + parsed_any = False + if isinstance(payload, list): + for item in payload: + parsed_any = _append_tool_call_from_obj(item) or parsed_any + elif isinstance(payload, dict): + # OpenAI-like wrappers: {"tool_calls":[...]} + if isinstance(payload.get("tool_calls"), list): + for item in payload["tool_calls"]: + if isinstance(item, dict) and isinstance(item.get("function"), dict): + fn = item["function"] + parsed_any = _append_tool_call_from_obj({ + "name": fn.get("name"), + "arguments": fn.get("arguments", {}), + }) or parsed_any + else: + parsed_any = _append_tool_call_from_obj(item) or parsed_any + else: + parsed_any = _append_tool_call_from_obj(payload) or parsed_any + if parsed_any: + content = "" + except Exception: + pass + if not tool_calls and content and (has_standard_format or has_calendar_format or looks_like_json_object): import re # Look for JSON tool call patterns: {"name": "exec", "parameters": {...}} or {"action": "list_events", ...} @@ -137,33 +221,7 @@ class CustomProvider(LLMProvider): json_str = content[json_start:json_end] tool_obj = json_repair.loads(json_str) - # Handle calendar tool format: {"action": "...", ...} - if isinstance(tool_obj, dict) and "action" in tool_obj: - # This is a calendar tool call in JSON format - action = tool_obj.get("action") - if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]: - # Convert to calendar tool call format - tool_calls.append(ToolCallRequest( - id=f"call_{len(tool_calls)}", - name="calendar", - arguments=tool_obj # Pass the whole object as arguments - )) - # Remove the tool call from content - content = content[:json_start] + content[json_end:].strip() - start_pos = json_start # Stay at same position since we removed text - continue - - # Handle standard format: {"name": "...", "parameters": {...}} - if (isinstance(tool_obj, dict) and - "name" in tool_obj and - "parameters" in tool_obj and - isinstance(tool_obj["name"], str) and - (tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_"))): - tool_calls.append(ToolCallRequest( - id=f"call_{len(tool_calls)}", - name=tool_obj["name"], - arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])} - )) + if _append_tool_call_from_obj(tool_obj): # Remove the tool call from content content = content[:json_start] + content[json_end:].strip() start_pos = json_start # Stay at same position since we removed text @@ -177,25 +235,8 @@ class CustomProvider(LLMProvider): if not tool_calls and looks_like_json_object: try: tool_obj = json_repair.loads(stripped) - if isinstance(tool_obj, dict) and "action" in tool_obj: - action = tool_obj.get("action") - if action and action in ["list_events", "create_event", "update_event", "delete_event", "delete_events", "check_availability"]: - tool_calls.append(ToolCallRequest( - id="call_0", - name="calendar", - arguments=tool_obj, - )) - content = "" - if isinstance(tool_obj, dict) and "name" in tool_obj and "parameters" in tool_obj: - if isinstance(tool_obj["name"], str) and ( - tool_obj["name"] in valid_tools or tool_obj["name"].startswith("mcp_") - ): - tool_calls.append(ToolCallRequest( - id="call_0", - name=tool_obj["name"], - arguments=tool_obj["parameters"] if isinstance(tool_obj["parameters"], dict) else {"raw": str(tool_obj["parameters"])}, - )) - content = "" + if _append_tool_call_from_obj(tool_obj): + content = "" except Exception: pass diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 752fce4..fda3131 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -45,8 +45,13 @@ class Session: """Get recent messages in LLM format, preserving tool metadata.""" out: list[dict[str, Any]] = [] for m in self.messages[-max_messages:]: - entry: dict[str, Any] = {"role": m["role"], "content": m.get("content", "")} - for k in ("tool_calls", "tool_call_id", "name"): + role = m["role"] + entry: dict[str, Any] = { + "role": role, + "content": m.get("content") if m.get("content") is not None else "", + } + # Omit tools_used: session metadata for humans/logs, not for the LLM API. + for k in ("tool_calls", "tool_call_id", "name", "reasoning_content"): if k in m: entry[k] = m[k] out.append(entry) diff --git a/pyproject.toml b/pyproject.toml index e168858..03df09d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "oauth-cli-kit>=0.1.1", "loguru>=0.7.0", "readability-lxml>=0.8.0", + "lxml-html-clean>=0.4.0", "rich>=13.0.0", "croniter>=2.0.0", "dingtalk-stream>=0.4.0", diff --git a/scripts/setup-mcp-servers.sh b/scripts/setup-mcp-servers.sh index ffdf0df..27775e9 100755 --- a/scripts/setup-mcp-servers.sh +++ b/scripts/setup-mcp-servers.sh @@ -9,11 +9,16 @@ MCP_DIR="${REPO_ROOT}/mcp-servers" usage() { cat <<'EOF' usage: - ./scripts/setup-mcp-servers.sh gitea + ./scripts/setup-mcp-servers.sh + +targets: + gitea Clone and build gitea-mcp (Go) + proxmox-mcp-plus Clone ProxmoxMCP-Plus and pip install -e (prefers ./venv/bin/python) notes: - clones into ./mcp-servers/ - - builds artifacts needed to run the MCP server locally + - for proxmox-mcp-plus: uses REPO/venv/bin/python if it exists, else python3 (or set NANOBOT_PYTHON) + - no second .venv under the Proxmox clone EOF } @@ -92,6 +97,69 @@ EOF echo "done: built ${MCP_DIR}/gitea-mcp/gitea-mcp" } +need_python_min() { + local want_major="$1" + local want_minor="$2" + + local v + v="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)" + local major minor + major="$(echo "${v}" | cut -d. -f1)" + minor="$(echo "${v}" | cut -d. -f2)" + + if [[ -z "${major}" || -z "${minor}" ]]; then + echo "error: could not read Python 3 version" >&2 + return 1 + fi + if (( major < want_major )) || { (( major == want_major )) && (( minor < want_minor )); }; then + echo "error: Python ${want_major}.${want_minor}+ required; found ${v}" >&2 + return 1 + fi + return 0 +} + +setup_proxmox_mcp_plus() { + need_cmd git + need_cmd python3 + if ! need_python_min 3 11; then + cat <<'EOF' >&2 + +proxmox-mcp-plus requires Python 3.11 or newer (see upstream pyproject requires-python). +EOF + exit 2 + fi + + mkdir -p "${MCP_DIR}" + + if [[ ! -d "${MCP_DIR}/proxmox-mcp-plus/.git" ]]; then + git clone https://github.com/RekklesNA/ProxmoxMCP-Plus.git "${MCP_DIR}/proxmox-mcp-plus" + else + echo "info: proxmox-mcp-plus already cloned, skipping clone" + fi + + if [[ -n "${NANOBOT_PYTHON:-}" ]]; then + py="${NANOBOT_PYTHON}" + elif [[ -x "${REPO_ROOT}/venv/bin/python" ]]; then + py="${REPO_ROOT}/venv/bin/python" + else + py="python3" + fi + echo "info: installing ProxmoxMCP-Plus with: ${py}" + if [[ -n "${VIRTUAL_ENV:-}" ]]; then + echo "info: active VIRTUAL_ENV=${VIRTUAL_ENV} (interpreter should match ${py} for consistency)" + fi + "${py}" -m pip install -e "${MCP_DIR}/proxmox-mcp-plus" + + if [[ ! -f "${MCP_DIR}/proxmox-mcp-plus/proxmox-config/config.json" ]]; then + cp "${MCP_DIR}/proxmox-mcp-plus/proxmox-config/config.example.json" \ + "${MCP_DIR}/proxmox-mcp-plus/proxmox-config/config.json" + echo "warn: created proxmox-config/config.json from example; edit host and API token before use" + fi + + echo "done: ProxmoxMCP-Plus installed editable with: ${py} -m pip install -e ..." + echo "info: point nanobot mcpServers.proxmox command at this interpreter (e.g. $(command -v "${py}" 2>/dev/null || echo "${py}"))" +} + main() { if [[ "${#}" -ne 1 ]]; then usage @@ -100,6 +168,7 @@ main() { case "$1" in gitea) setup_gitea ;; + proxmox-mcp-plus) setup_proxmox_mcp_plus ;; -h|--help|help) usage ;; *) echo "error: unknown target '$1'" >&2 diff --git a/workspace/memory/MEMORY.md b/workspace/memory/MEMORY.md index fd2ca96..7134653 100644 --- a/workspace/memory/MEMORY.md +++ b/workspace/memory/MEMORY.md @@ -1,23 +1 @@ -# Long-term Memory - -This file stores important information that should persist across sessions. - -## User Information - -(Important facts about the user) - -## Preferences - -(User preferences learned over time) - -## Project Context - -(Information about ongoing projects) - -## Important Notes - -(Things to remember) - ---- - -*This file is automatically updated by nanobot when important information should be remembered.* +{'User Information': {'agent_type': 'memory consolidation', 'response_format': 'valid JSON'}, 'Preferences': {'response_type': 'JSON', 'context': 'conversation to process'}, 'Location': {'workspace_location': '/mnt/data/nanobot/workspace/'}, 'Project Context': {'project_name': 'Nanobot Configuration Update', 'goal': 'Resolve directory permission issue with Nanobot'}, 'Tools/Services Used': ['list_dir', 'Gitea API', 'read_emails'], 'Code Snippets': {'update_HEARTBEAT_md': 'code snippet to update HEARTBEAT.md file with new task'}} \ No newline at end of file