Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
395 lines
14 KiB
Python
395 lines
14 KiB
Python
"""Tools a playbook can call during chat.
|
|
|
|
Ollama drives the calling: `/api/chat` with a `tools` param returns
|
|
`message.tool_calls`, and this module is just the registry + dispatch.
|
|
|
|
Most tools READ local state (memory, history, documents, models). A few act:
|
|
`web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
|
|
memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
|
|
boundary — an action tool only fires when a playbook explicitly lists it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Awaitable, Callable
|
|
|
|
from .memory.store import store, MemoryItem
|
|
from .ollama_manager import get_ollama_manager
|
|
|
|
|
|
async def _search_memory(query: str = "", **_) -> str:
|
|
q = (query or "").strip().lower()
|
|
hits = [
|
|
{"section": it.section, "text": it.text}
|
|
for it in store.all()
|
|
if not q
|
|
or q in it.text.lower()
|
|
or q in (it.section or "").lower()
|
|
or any(q in t.lower() for t in it.tags)
|
|
]
|
|
return json.dumps(hits[:20])
|
|
|
|
|
|
async def _search_history(query: str = "", **_) -> str:
|
|
# Hybrid recall: semantic (embeddings) unioned with lexical, falls back to
|
|
# lexical if embeddings are down. Same retrieval the chat endpoint uses.
|
|
convs = await store.semantic_search_conversations(
|
|
query or "", get_ollama_manager().embed, limit=3
|
|
)
|
|
return json.dumps([{"matches": c.get("matches", [])} for c in convs])
|
|
|
|
|
|
async def _list_models(**_) -> str:
|
|
return json.dumps(await get_ollama_manager().list_models())
|
|
|
|
|
|
async def _search_documents(query: str = "", **_) -> str:
|
|
hits = await store.search_documents(query or "", get_ollama_manager().embed, limit=3)
|
|
return json.dumps([{"title": h["title"], "text": h["text"]} for h in hits])
|
|
|
|
|
|
async def _get_time(**_) -> str:
|
|
from datetime import datetime
|
|
return json.dumps({"now": datetime.now().isoformat(timespec="seconds")})
|
|
|
|
|
|
async def _web_search(query: str = "", **_) -> str:
|
|
import asyncio as _a
|
|
from .search import web_search
|
|
res = await _a.to_thread(web_search, query or "", 4)
|
|
return res or "(no results)"
|
|
|
|
|
|
_FETCH_MAX_REDIRECTS = 5
|
|
|
|
|
|
def _ip_is_blocked(ip: str) -> bool:
|
|
"""True if an address is one an outbound fetch has no business reaching:
|
|
loopback, RFC1918/ULA private, link-local (incl. 169.254.169.254 cloud
|
|
metadata), multicast, reserved, or unspecified. IPv4-mapped IPv6 is unwrapped
|
|
first so ::ffff:127.0.0.1 can't sneak a loopback past the check."""
|
|
import ipaddress
|
|
try:
|
|
addr = ipaddress.ip_address(ip.split("%")[0]) # drop any IPv6 zone id
|
|
except ValueError:
|
|
return True # unparseable -> refuse rather than guess
|
|
mapped = getattr(addr, "ipv4_mapped", None)
|
|
if mapped is not None:
|
|
addr = mapped
|
|
return (
|
|
addr.is_loopback or addr.is_private or addr.is_link_local
|
|
or addr.is_multicast or addr.is_reserved or addr.is_unspecified
|
|
)
|
|
|
|
|
|
def _ssrf_guard(host: str) -> str | None:
|
|
"""Resolve a hostname and return an error string if ANY of its A/AAAA
|
|
records is a blocked address, else None. Checking every answer stops a name
|
|
from smuggling one private record alongside a public one.
|
|
|
|
ponytail: this validates then httpx re-resolves on connect, so a sub-second
|
|
DNS-rebind could still slip a private address through the TOCTOU gap. That's
|
|
an advanced attack against a playbook-gated, single-user tool; pin the
|
|
connection to the resolved IP if this ever faces untrusted callers."""
|
|
import socket
|
|
if not host:
|
|
return "missing host"
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
except socket.gaierror as e:
|
|
return f"cannot resolve host: {e}"
|
|
ips = {info[4][0] for info in infos}
|
|
if not ips:
|
|
return "host did not resolve"
|
|
blocked = [ip for ip in ips if _ip_is_blocked(ip)]
|
|
if blocked:
|
|
return f"refusing to fetch a private/loopback/link-local address ({', '.join(sorted(blocked))})"
|
|
return None
|
|
|
|
|
|
async def _fetch_url(url: str = "", **_) -> str:
|
|
import re
|
|
import httpx
|
|
from urllib.parse import urlparse, urljoin
|
|
url = (url or "").strip()
|
|
if not url.startswith(("http://", "https://")):
|
|
return json.dumps({"error": "url must start with http:// or https://"})
|
|
# SSRF guard: validate the host of the initial URL AND every redirect hop
|
|
# against the private/loopback/link-local block-list before connecting, so a
|
|
# granted fetch_url can't be steered at 127.0.0.1:11434, cloud metadata, or
|
|
# LAN hosts — and a public URL can't 302 its way there either.
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=False) as c:
|
|
for _ in range(_FETCH_MAX_REDIRECTS + 1):
|
|
parsed = urlparse(url)
|
|
if parsed.scheme not in ("http", "https"):
|
|
return json.dumps({"error": "only http(s) URLs are allowed"})
|
|
err = _ssrf_guard(parsed.hostname or "")
|
|
if err:
|
|
return json.dumps({"error": f"blocked: {err}"})
|
|
r = await c.get(url, headers={"User-Agent": "NexusOS/1.0"})
|
|
location = r.headers.get("location")
|
|
if r.is_redirect and location:
|
|
url = urljoin(url, location)
|
|
continue
|
|
r.raise_for_status()
|
|
html = r.text
|
|
break
|
|
else:
|
|
return json.dumps({"error": "too many redirects"})
|
|
except Exception as e:
|
|
return json.dumps({"error": f"fetch failed: {e}"})
|
|
text = re.sub(r"(?is)<(script|style).*?</\1>", " ", html)
|
|
text = re.sub(r"(?s)<[^>]+>", " ", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
return text[:4000]
|
|
|
|
|
|
async def _remember(text: str = "", section: str = "General", **_) -> str:
|
|
"""WRITE tool: persist a memory fact. First action tool — allowlist-gated."""
|
|
import uuid as _uuid
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return json.dumps({"error": "text is required"})
|
|
store.add(MemoryItem(id=str(_uuid.uuid4()), section=(section or "General"), text=text))
|
|
return json.dumps({"saved": text, "section": section or "General"})
|
|
|
|
|
|
# --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
|
|
# Paths never leave the repo: every request is resolve()d and checked against
|
|
# PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of
|
|
# the tree that are either secrets, private data, or multi-GB noise.
|
|
_DENIED = {
|
|
".git", ".env", "Promethean", "node_modules", "models", "ollama",
|
|
"runtime", "dist", "__pycache__", ".git-credentials",
|
|
}
|
|
_READ_MAX = 60_000
|
|
|
|
|
|
def _repo_path(rel: str) -> "tuple[object, str | None]":
|
|
"""Resolve a repo-relative path. Returns (path, error-string)."""
|
|
from .nexus_config import PROJECT_ROOT
|
|
rel = (rel or "").strip().lstrip("/")
|
|
if not rel:
|
|
return None, "path is required"
|
|
target = (PROJECT_ROOT / rel).resolve()
|
|
if not target.is_relative_to(PROJECT_ROOT):
|
|
return None, "path escapes the project root"
|
|
parts = set(target.relative_to(PROJECT_ROOT).parts)
|
|
if parts & _DENIED or target.name.endswith((".db", ".db.sql", ".pem", ".key")):
|
|
return None, f"{rel} is not readable"
|
|
return target, None
|
|
|
|
|
|
async def _read_file(path: str = "", **_) -> str:
|
|
target, err = _repo_path(path)
|
|
if err:
|
|
return json.dumps({"error": err})
|
|
if not target.is_file():
|
|
return json.dumps({"error": f"{path} does not exist"})
|
|
try:
|
|
text = target.read_text(encoding="utf-8", errors="replace")
|
|
except OSError as e:
|
|
return json.dumps({"error": f"cannot read {path}: {e}"})
|
|
return json.dumps({
|
|
"path": path,
|
|
"truncated": len(text) > _READ_MAX,
|
|
"content": text[:_READ_MAX],
|
|
})
|
|
|
|
|
|
async def _list_files(pattern: str = "", **_) -> str:
|
|
"""Glob the repo so the model discovers real paths instead of inventing them."""
|
|
from .nexus_config import PROJECT_ROOT
|
|
pattern = (pattern or "**/*.py").strip().lstrip("/")
|
|
hits = []
|
|
for f in PROJECT_ROOT.glob(pattern):
|
|
if not f.is_file():
|
|
continue
|
|
target, err = _repo_path(str(f.relative_to(PROJECT_ROOT)))
|
|
if err:
|
|
continue
|
|
hits.append(str(f.relative_to(PROJECT_ROOT)))
|
|
if len(hits) >= 200:
|
|
break
|
|
return json.dumps(sorted(hits))
|
|
|
|
|
|
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
|
|
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
|
|
"search_memory": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_memory",
|
|
"description": "Search the user's persistent memory facts. Empty query returns all facts.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string", "description": "text to match"}},
|
|
},
|
|
},
|
|
},
|
|
_search_memory,
|
|
),
|
|
"search_history": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_history",
|
|
"description": "Search past conversations for exchanges containing the query text.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
_search_history,
|
|
),
|
|
"list_models": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_models",
|
|
"description": "List the locally installed Ollama models.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
_list_models,
|
|
),
|
|
"read_file": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "read_file",
|
|
"description": "Read a source file from the NexusOS repository. Path is relative to the project root, e.g. 'synapse/main.py'.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string", "description": "repo-relative file path"}},
|
|
"required": ["path"],
|
|
},
|
|
},
|
|
},
|
|
_read_file,
|
|
),
|
|
"list_files": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "list_files",
|
|
"description": "List files in the NexusOS repository matching a glob, e.g. 'synapse/**/*.py' or 'interface/web/src/*.jsx'. Use this to find real paths before reading.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"pattern": {"type": "string", "description": "glob relative to the project root"}},
|
|
},
|
|
},
|
|
},
|
|
_list_files,
|
|
),
|
|
"search_documents": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "search_documents",
|
|
"description": "Search the user's uploaded documents for relevant passages.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
_search_documents,
|
|
),
|
|
"get_time": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_time",
|
|
"description": "Get the current local date and time.",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
},
|
|
_get_time,
|
|
),
|
|
"web_search": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "web_search",
|
|
"description": "Search the web (DuckDuckGo) and return the top result snippets.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
},
|
|
_web_search,
|
|
),
|
|
"fetch_url": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "fetch_url",
|
|
"description": "Fetch a web page and return its visible text (truncated).",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"url": {"type": "string", "description": "http(s) URL"}},
|
|
"required": ["url"],
|
|
},
|
|
},
|
|
},
|
|
_fetch_url,
|
|
),
|
|
"remember": (
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "remember",
|
|
"description": "Save a durable fact to the user's persistent memory.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"text": {"type": "string", "description": "the fact to remember"},
|
|
"section": {"type": "string", "description": "optional category, e.g. Health"},
|
|
},
|
|
"required": ["text"],
|
|
},
|
|
},
|
|
},
|
|
_remember,
|
|
),
|
|
}
|
|
|
|
|
|
# Tools that act (write local state or reach the network). These require an
|
|
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook
|
|
# allowlist — a playbook granting one isn't enough on its own.
|
|
ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
|
|
|
|
|
|
def is_action(name: str) -> bool:
|
|
return name in ACTION_TOOLS
|
|
|
|
|
|
def schemas_for(names: list[str], allow_actions: bool = True) -> list[dict]:
|
|
"""Tool schemas for a playbook's allowlist; unknown names are dropped.
|
|
When allow_actions is False, action tools are withheld so the model can't
|
|
even call them."""
|
|
return [
|
|
REGISTRY[n][0] for n in (names or [])
|
|
if n in REGISTRY and (allow_actions or not is_action(n))
|
|
]
|
|
|
|
|
|
async def dispatch(name: str, args: dict | None) -> str:
|
|
"""Run a tool by name. Never raises — returns an error string on failure."""
|
|
entry = REGISTRY.get(name)
|
|
if not entry:
|
|
return json.dumps({"error": f"unknown tool: {name}"})
|
|
try:
|
|
return await entry[1](**(args or {}))
|
|
except Exception as e: # a broken tool must not kill the chat loop
|
|
return json.dumps({"error": f"{name} failed: {e}"})
|