diff --git a/.claude/skills/taosmd-a2a/SKILL.md b/.claude/skills/taosmd-a2a/SKILL.md index 8cc384d4..642b7c66 100644 --- a/.claude/skills/taosmd-a2a/SKILL.md +++ b/.claude/skills/taosmd-a2a/SKILL.md @@ -15,3 +15,7 @@ python -c "import taosmd; print(taosmd.a2a_setup_guide())" Follow every step in the guide in order. The guide is the source of truth for checking for an existing server, starting the bus, creating/joining a channel, and generating the invite block for the user's other agents. + +After joining a channel, set up the hourly bus-check cron so you do not miss +messages when your session ends — follow the **"Durable monitoring"** section +in the guide for idempotent cron/schtasks setup instructions. diff --git a/pyproject.toml b/pyproject.toml index 30ac055c..c635d40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ include = ["taosmd*"] # importlib.resources.files("taosmd")). Without this they are omitted from the # wheel and agent_rules() raises FileNotFoundError on a pip install (#66). [tool.setuptools.package-data] -taosmd = ["docs/**/*.md", "webui/**/*"] +taosmd = ["docs/**/*.md", "webui/**/*", "skills/**/*"] [project.scripts] taosmd = "taosmd.cli:main" diff --git a/scripts/install-client.ps1 b/scripts/install-client.ps1 new file mode 100644 index 00000000..b8242215 --- /dev/null +++ b/scripts/install-client.ps1 @@ -0,0 +1,77 @@ +# install-client.ps1 — install taOSmd as a remote client pointing at a shared server +# +# Usage: +# .\scripts\install-client.ps1 [-ServerUrl ] +# +# Example: +# .\scripts\install-client.ps1 -ServerUrl http://pi.local:7900 +# +# What this does: +# 1. Install/upgrade the taosmd Python package. +# 2. Set the remote server URL in %USERPROFILE%\.taosmd\config.json. +# 3. Install the taosmd-a2a Claude skill into %USERPROFILE%\.claude\skills\. +# 4. Verify the server is reachable via GET /health. + +param ( + [string]$ServerUrl = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +Write-Host "=== taOSmd client install ===" -ForegroundColor Cyan + +# --- Step 1: install taosmd -------------------------------------------------- +Write-Host "" +Write-Host "Step 1: Installing taosmd..." +pip install --quiet --upgrade taosmd +Write-Host " taosmd installed." + +# --- Step 2: configure the remote server URL --------------------------------- +Write-Host "" +Write-Host "Step 2: Configuring remote server URL..." + +if (-not $ServerUrl) { + $ServerUrl = Read-Host " Enter the remote taOSmd server URL (e.g. http://pi.local:7900)" +} + +if (-not $ServerUrl) { + Write-Error "error: server URL is required." + exit 1 +} + +taosmd config set-server $ServerUrl +Write-Host " Remote server URL set: $ServerUrl" + +# --- Step 3: install the Claude skill ---------------------------------------- +Write-Host "" +Write-Host "Step 3: Installing taosmd-a2a skill..." +try { + taosmd install-skill +} catch { + taosmd install-skill --force +} +Write-Host " Skill installed." + +# --- Step 4: health check ---------------------------------------------------- +Write-Host "" +Write-Host "Step 4: Checking server health..." +try { + $response = Invoke-RestMethod -Uri "$ServerUrl/health" -TimeoutSec 10 -Method Get + if ($response.status -eq "ok") { + Write-Host " Server is healthy: version $($response.version)" + } else { + Write-Warning " Unexpected health response: $($response | ConvertTo-Json)" + exit 1 + } +} catch { + Write-Warning " Warning: server health check failed: $_" + Write-Warning " Check that the server is running and the URL is correct." + exit 1 +} + +Write-Host "" +Write-Host "=== taOSmd client setup complete ===" -ForegroundColor Green +Write-Host " Server : $ServerUrl" +Write-Host " Run 'taosmd config show' to confirm settings." +Write-Host " Run 'taosmd a2a-poll --channel CHANNEL' to poll the bus." diff --git a/scripts/install-client.sh b/scripts/install-client.sh new file mode 100755 index 00000000..526b8e0c --- /dev/null +++ b/scripts/install-client.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# install-client.sh — install taOSmd as a remote client pointing at a shared server +# +# Usage: +# ./scripts/install-client.sh [SERVER_URL] +# +# If SERVER_URL is not passed as an argument the script will prompt for it. +# Example: +# ./scripts/install-client.sh http://pi.local:7900 +# ./scripts/install-client.sh https://my-device.tailscale.ts.net:7900 +# +# What this does: +# 1. Install/upgrade the taosmd Python package. +# 2. Set the remote server URL in ~/.taosmd/config.json. +# 3. Install the taosmd-a2a Claude skill into ~/.claude/skills/. +# 4. Verify the server is reachable via GET /health. + +set -euo pipefail + +echo "=== taOSmd client install ===" + +# --- Step 1: install taosmd --------------------------------------------------- +echo "" +echo "Step 1: Installing taosmd..." +pip install --quiet --upgrade taosmd +echo " taosmd $(taosmd --version 2>/dev/null || python -c "import taosmd; print(taosmd.__version__)") installed." + +# --- Step 2: configure the remote server URL --------------------------------- +echo "" +echo "Step 2: Configuring remote server URL..." + +SERVER_URL="${1:-}" +if [ -z "$SERVER_URL" ]; then + read -rp " Enter the remote taOSmd server URL (e.g. http://pi.local:7900): " SERVER_URL +fi + +if [ -z "$SERVER_URL" ]; then + echo "error: server URL is required." >&2 + exit 1 +fi + +taosmd config set-server "$SERVER_URL" +echo " Remote server URL set: $SERVER_URL" + +# --- Step 3: install the Claude skill ---------------------------------------- +echo "" +echo "Step 3: Installing taosmd-a2a skill..." +taosmd install-skill || taosmd install-skill --force +echo " Skill installed." + +# --- Step 4: health check ----------------------------------------------------- +echo "" +echo "Step 4: Checking server health..." +if command -v curl >/dev/null 2>&1; then + HEALTH=$(curl -s --max-time 10 "${SERVER_URL}/health" 2>/dev/null || true) +elif command -v wget >/dev/null 2>&1; then + HEALTH=$(wget -qO- --timeout=10 "${SERVER_URL}/health" 2>/dev/null || true) +else + # Fallback: use Python urllib + HEALTH=$(python -c " +import urllib.request, json +try: + with urllib.request.urlopen('${SERVER_URL}/health', timeout=10) as r: + print(r.read().decode()) +except Exception as e: + print('{\"error\": \"' + str(e) + '\"}') +" 2>/dev/null || true) +fi + +if echo "$HEALTH" | grep -q '"status": *"ok"'; then + echo " Server is healthy: $HEALTH" +else + echo " Warning: server health check failed or server is unreachable." + echo " Response: $HEALTH" + echo " Check that the server is running and the URL is correct." + exit 1 +fi + +echo "" +echo "=== taOSmd client setup complete ===" +echo " Server : $SERVER_URL" +echo " Run 'taosmd config show' to confirm settings." +echo " Run 'taosmd a2a-poll --channel CHANNEL' to poll the bus." diff --git a/scripts/install-server.ps1 b/scripts/install-server.ps1 new file mode 100644 index 00000000..004a2eef --- /dev/null +++ b/scripts/install-server.ps1 @@ -0,0 +1,81 @@ +# install-server.ps1 — install taOSmd and start it as a persistent background service +# +# Usage: +# .\scripts\install-server.ps1 [-Host 0.0.0.0] [-Port 7900] +# +# What this does: +# 1. Install/upgrade the taosmd Python package. +# 2. Install the taosmd serve background service (Windows service via taosmd --install-service). +# 3. Verify the service is reachable on localhost. +# 4. Print Tailscale guidance and token reminder. + +param ( + [string]$ServerHost = "0.0.0.0", + [int]$Port = 7900 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +Write-Host "=== taOSmd server install ===" -ForegroundColor Cyan +Write-Host " Host: $ServerHost Port: $Port" + +# --- Step 1: install taosmd -------------------------------------------------- +Write-Host "" +Write-Host "Step 1: Installing taosmd..." +pip install --quiet --upgrade taosmd +Write-Host " taosmd installed." + +# --- Step 2: install the background service ---------------------------------- +Write-Host "" +Write-Host "Step 2: Installing background service..." +$dataDir = Join-Path $env:USERPROFILE ".taosmd" +taosmd serve --install-service --host $ServerHost --port $Port --serve-data-dir $dataDir +Write-Host " Service installed." + +# --- Step 3: health check ---------------------------------------------------- +Write-Host "" +Write-Host "Step 3: Checking server health on localhost:$Port..." +Start-Sleep -Seconds 3 # give the service a moment to start + +try { + $health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 10 -Method Get + if ($health.status -eq "ok") { + Write-Host " Server is healthy: version $($health.version)" -ForegroundColor Green + } else { + Write-Warning " Unexpected health response: $($health | ConvertTo-Json)" + } +} catch { + Write-Warning " Health check failed: $_" + Write-Warning " Run 'taosmd serve --service-status' for details." +} + +# --- Step 4: guidance -------------------------------------------------------- +Write-Host "" +Write-Host "=== Server installation complete ===" -ForegroundColor Green +Write-Host "" +Write-Host "The server is bound to ${ServerHost}:${Port}." +Write-Host "" +Write-Host "--- Tailscale / remote access guidance ---" +Write-Host "" +Write-Host "To reach this server from other machines over Tailscale:" +Write-Host " 1. Install Tailscale: https://tailscale.com/download" +Write-Host " 2. Run 'tailscale up' on both machines." +Write-Host " 3. Find this machine's Tailscale IP: tailscale ip -4" +Write-Host " 4. On each client:" +Write-Host " taosmd config set-server http://:$Port" +Write-Host " or run the client install script:" +Write-Host " .\scripts\install-client.ps1 -ServerUrl http://:$Port" +Write-Host "" +Write-Host "--- Token auth (optional but recommended on shared networks) ---" +Write-Host "" +Write-Host "To require a bearer token:" +Write-Host " On the SERVER:" +Write-Host " taosmd config set-token " +Write-Host " taosmd serve --uninstall-service" +Write-Host " taosmd serve --install-service --host $ServerHost --port $Port" +Write-Host " On each CLIENT:" +Write-Host " taosmd config set-token " +Write-Host " (Or set TAOSMD_TOKEN= in the environment.)" +Write-Host "" +Write-Host "Never hardcode the token in scripts or commit it to version control." diff --git a/scripts/install-server.sh b/scripts/install-server.sh new file mode 100755 index 00000000..8b407aac --- /dev/null +++ b/scripts/install-server.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# install-server.sh — install taOSmd and start it as a persistent background service +# +# Usage: +# ./scripts/install-server.sh [--host HOST] [--port PORT] +# +# Defaults: host=0.0.0.0, port=7900 +# +# What this does: +# 1. Install/upgrade the taosmd Python package. +# 2. Install and start the taosmd serve background service on 0.0.0.0:7900 +# (or the specified host/port) via taosmd serve --install-service. +# 3. Verify the service is reachable on localhost. +# 4. Print Tailscale guidance and token reminder. + +set -euo pipefail + +HOST="0.0.0.0" +PORT="7900" + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +echo "=== taOSmd server install ===" +echo " Host: $HOST Port: $PORT" + +# --- Step 1: install taosmd -------------------------------------------------- +echo "" +echo "Step 1: Installing taosmd..." +pip install --quiet --upgrade taosmd +echo " taosmd $(taosmd --version 2>/dev/null || python -c "import taosmd; print(taosmd.__version__)") installed." + +# --- Step 2: install and start the background service ----------------------- +echo "" +echo "Step 2: Installing background service (systemd / LaunchAgent)..." +taosmd serve --install-service --host "$HOST" --port "$PORT" --serve-data-dir ~/.taosmd +echo " Service installed." + +# --- Step 3: health check ---------------------------------------------------- +echo "" +echo "Step 3: Checking server health on localhost:$PORT..." +sleep 2 # give the service a moment to start + +if command -v curl >/dev/null 2>&1; then + HEALTH=$(curl -s --max-time 10 "http://127.0.0.1:${PORT}/health" 2>/dev/null || true) +else + HEALTH=$(python -c " +import urllib.request +try: + with urllib.request.urlopen('http://127.0.0.1:${PORT}/health', timeout=10) as r: + print(r.read().decode()) +except Exception as e: + print('{\"error\": \"' + str(e) + '\"}') +" 2>/dev/null || true) +fi + +if echo "$HEALTH" | grep -q '"status": *"ok"'; then + echo " Server is healthy: $HEALTH" +else + echo " Warning: health check did not confirm 'ok'. Response: $HEALTH" + echo " Check 'taosmd serve --service-status' for details." +fi + +# --- Step 4: guidance -------------------------------------------------------- +echo "" +echo "=== Server installation complete ===" +echo "" +echo "The server is bound to $HOST:$PORT." +echo "" +echo "--- Tailscale / remote access guidance ---" +echo "" +echo "To reach this server from other machines over Tailscale:" +echo " 1. Install Tailscale on both machines: https://tailscale.com/download" +echo " 2. Run 'tailscale up' on this machine and on the client machines." +echo " 3. Find this machine's Tailscale IP or MagicDNS name:" +echo " tailscale ip -4" +echo " tailscale status" +echo " 4. On each client run:" +echo " taosmd config set-server http://:$PORT" +echo " or run the client install script:" +echo " ./scripts/install-client.sh http://:$PORT" +echo "" +echo "--- Token auth (optional but recommended on shared networks) ---" +echo "" +echo "To require a bearer token:" +echo " On the SERVER:" +echo " taosmd config set-token " +echo " taosmd serve --uninstall-service # stop" +echo " taosmd serve --install-service --host $HOST --port $PORT # restart" +echo " On each CLIENT:" +echo " taosmd config set-token " +echo " (Or set TAOSMD_TOKEN= in both environments.)" +echo "" +echo "Never hardcode the token in scripts or commit it to version control." diff --git a/taosmd/cli.py b/taosmd/cli.py index 97e460ab..0839a2e0 100644 --- a/taosmd/cli.py +++ b/taosmd/cli.py @@ -141,6 +141,167 @@ def _memory_model_set(model: str | None, clear: bool) -> int: return 0 +def _config_set_server(url: str | None, clear: bool) -> int: + from . import config # noqa: PLC0415 + + if not clear and not url: + print("error: provide or --clear", file=sys.stderr) + return 2 + try: + config.set_server_url(url or "", clear=clear) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + if clear: + print("Server URL cleared (local mode).") + else: + print(f"Remote server URL set: {url}") + return 0 + + +def _config_set_token(token: str | None, clear: bool) -> int: + from . import config # noqa: PLC0415 + + if not clear and not token: + print("error: provide or --clear", file=sys.stderr) + return 2 + try: + config.set_server_token(token or "", clear=clear) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + if clear: + print("Server token cleared.") + else: + print("Server token stored.") + return 0 + + +def _config_show() -> int: + from . import config # noqa: PLC0415 + + url = config.get_server_url() + token = config.get_server_token() + model = config.get_memory_model() + print(f"server_url : {url or '(unset — local mode)'}") + print(f"server_token : {'(set)' if token else '(unset)'}") + print(f"memory_model : {model or '(default)'}") + return 0 + + +def _a2a_poll_cmd(args: argparse.Namespace) -> int: + """Handle ``taosmd a2a-poll`` — fetch new messages and update state file.""" + import asyncio # noqa: PLC0415 + import json # noqa: PLC0415 (already imported at module level but guard for type-checker) + from pathlib import Path # noqa: PLC0415 + from datetime import datetime, timezone # noqa: PLC0415 + + state_file = Path(args.state_file).expanduser() + channel = args.channel + + # --- resolve messages from remote or local service ------------------- + server_url = getattr(args, "server", None) + if server_url: + # One-shot override: create a temporary RemoteClient. + from .remote import RemoteClient # noqa: PLC0415 + client = RemoteClient(server_url) + + async def _fetch(since): + return await client.a2a_feed(thread=channel, since=since, limit=500) + + messages = asyncio.run(_fetch(None)) + else: + from . import service # noqa: PLC0415 + + async def _fetch_local(since): + return await service.a2a_feed(thread=channel, since=since, limit=500) + + messages = asyncio.run(_fetch_local(None)) + + # --- load / initialise state ---------------------------------------- + state: dict = {} + if state_file.exists(): + try: + state = json.loads(state_file.read_text()) + except (json.JSONDecodeError, OSError): + state = {} + last_id: int = state.get(channel, -1) + + # --- filter to only new messages ------------------------------------ + exclude = getattr(args, "exclude", None) + new_messages = [] + for msg in messages: + msg_id = msg.get("id") + # IDs from the archive are integers; coerce defensively. + try: + msg_id_int = int(msg_id) + except (TypeError, ValueError): + continue + if msg_id_int <= last_id: + continue + if exclude and msg.get("from") == exclude: + # Skip messages from the excluded sender (usually "ourselves"), + # but still advance last_id so we don't re-see them next poll. + last_id = max(last_id, msg_id_int) + continue + new_messages.append((msg_id_int, msg)) + + # --- print new messages and update state ---------------------------- + for msg_id_int, msg in sorted(new_messages, key=lambda t: t[0]): + ts = msg.get("ts", 0) + try: + ts_str = datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + except Exception: + ts_str = str(ts) + from_ = msg.get("from", "?") + body = msg.get("body", "") + reply = f" (reply_to={msg.get('reply_to')})" if msg.get("reply_to") else "" + print(f"[{ts_str}] <{from_}>{reply} {body}") + last_id = max(last_id, msg_id_int) + + # --- persist state -------------------------------------------------- + state[channel] = last_id + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text(json.dumps(state, indent=2)) + return 0 + + +def _install_skill_cmd(args: argparse.Namespace) -> int: + """Handle ``taosmd install-skill`` — copy the packaged skill into ~/.claude/skills/.""" + import shutil # noqa: PLC0415 + from pathlib import Path # noqa: PLC0415 + from importlib.resources import files as _pkg_files # noqa: PLC0415 + + dest_dir = Path("~/.claude/skills/taosmd-a2a").expanduser() + skill_src_dir = Path(__file__).parent / "skills" / "taosmd-a2a" + + if not skill_src_dir.is_dir(): + # Fallback: try importlib.resources (wheel installs) + try: + ref = _pkg_files("taosmd").joinpath("skills/taosmd-a2a") + # Convert Traversable to a concrete path via __file__ approach. + skill_src_dir = Path(__file__).parent / "skills" / "taosmd-a2a" + except Exception: + pass + + if not skill_src_dir.is_dir(): + print("error: packaged skill not found in taosmd/skills/taosmd-a2a/", file=sys.stderr) + print(" Re-install the package to include skill assets.", file=sys.stderr) + return 2 + + force = getattr(args, "force", False) + skill_md = dest_dir / "SKILL.md" + if skill_md.exists() and not force: + print(f"Skill already installed at {dest_dir}") + print(" Re-run with --force to overwrite.") + return 0 + + dest_dir.mkdir(parents=True, exist_ok=True) + shutil.copytree(str(skill_src_dir), str(dest_dir), dirs_exist_ok=True) + print(f"taosmd-a2a skill installed at {dest_dir}") + return 0 + + def _agent_rm(registry: AgentRegistry, name: str, drop_data: bool) -> int: try: registry.delete_agent(name, drop_data=drop_data) @@ -331,6 +492,12 @@ def main(argv: list[str] | None = None) -> int: default="data", help="Path to the taosmd data directory (default: ./data)", ) + parser.add_argument( + "--server", + default=None, + metavar="URL", + help="Remote taOSmd server URL (overrides TAOSMD_SERVER_URL and config.json for this invocation)", + ) sub = parser.add_subparsers(dest="cmd", required=True) agent = sub.add_parser("agent", help="Manage registered agents") @@ -464,6 +631,69 @@ def main(argv: list[str] | None = None) -> int: help="Substring; every active chunk whose stored text contains it is superseded", ) + # ----- config subcommand (server URL + token + show) ---------------- + cfg_p = sub.add_parser( + "config", + help="Get/set connection config (remote server URL, bearer token, memory model)", + ) + cfg_sub = cfg_p.add_subparsers(dest="config_cmd", required=True) + + # config set-server + ss_p = cfg_sub.add_parser("set-server", help="Set or clear the remote server URL") + ss_p.add_argument( + "url", nargs="?", default=None, + help="Base URL of the remote taOSmd server, e.g. http://pi.local:7900", + ) + ss_p.add_argument("--clear", action="store_true", help="Unset the server URL (revert to local mode)") + + # config set-token + st_p = cfg_sub.add_parser("set-token", help="Set or clear the remote server bearer token") + st_p.add_argument( + "token", nargs="?", default=None, + help="Bearer token for the remote server. Stored in config.json; " + "use TAOSMD_TOKEN env var to avoid on-disk storage.", + ) + st_p.add_argument("--clear", action="store_true", help="Unset the token") + + # config show + cfg_sub.add_parser("show", help="Print the resolved server_url and whether a token is set") + + # ----- install-skill subcommand ------------------------------------ + install_skill_p = sub.add_parser( + "install-skill", + help="Copy the taosmd-a2a Claude skill into ~/.claude/skills/taosmd-a2a/", + ) + install_skill_p.add_argument( + "--force", action="store_true", + help="Overwrite an existing installation", + ) + + # ----- a2a-poll subcommand ---------------------------------------- + a2a_poll_p = sub.add_parser( + "a2a-poll", + help="Fetch new A2A messages since the last poll (cron-friendly, updates state file)", + ) + a2a_poll_p.add_argument( + "--channel", required=True, + help="Channel name to poll (e.g. the project channel name)", + ) + a2a_poll_p.add_argument( + "--server", default=None, + help="Override the remote server URL for this poll (e.g. http://pi.local:7900). " + "Defaults to TAOSMD_SERVER_URL or the configured server_url.", + ) + a2a_poll_p.add_argument( + "--state-file", + dest="state_file", + default="~/.taosmd/a2a-poll-state.json", + help="JSON file that stores the last-seen message ID per channel " + "(default: ~/.taosmd/a2a-poll-state.json)", + ) + a2a_poll_p.add_argument( + "--exclude", default=None, + help="Skip messages from this sender (e.g. your own agent name)", + ) + # ----- serve subcommand (local HTTP/REST API) ----------------------- serve_p = sub.add_parser( "serve", @@ -510,6 +740,20 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) + if args.cmd == "config": + if args.config_cmd == "set-server": + return _config_set_server(args.url, args.clear) + if args.config_cmd == "set-token": + return _config_set_token(args.token, args.clear) + if args.config_cmd == "show": + return _config_show() + + if args.cmd == "install-skill": + return _install_skill_cmd(args) + + if args.cmd == "a2a-poll": + return _a2a_poll_cmd(args) + if args.cmd == "serve": from . import service_install # noqa: PLC0415 if args.install_service: diff --git a/taosmd/config.py b/taosmd/config.py index 5155ee33..189aac3e 100644 --- a/taosmd/config.py +++ b/taosmd/config.py @@ -28,6 +28,10 @@ # Key under which the global memory/Librarian model is stored. _MEMORY_MODEL_KEY = "memory_model" +# Key under which the optional remote server URL is stored. +_SERVER_URL_KEY = "server_url" +# Key under which the optional remote server bearer token is stored. +_SERVER_TOKEN_KEY = "server_token" def _resolve_data_dir(data_dir=None) -> str: @@ -109,8 +113,103 @@ def resolve_memory_model(fallback: str | None = None, data_dir=None) -> str | No return model if model is not None else fallback +# --------------------------------------------------------------------------- +# Remote server URL +# --------------------------------------------------------------------------- + +def get_server_url(data_dir=None) -> str | None: + """Return the configured remote server URL, or ``None`` if unset. + + Resolution order (first non-empty wins): + + 1. ``TAOSMD_SERVER_URL`` environment variable + 2. ``server_url`` key in ``~/.taosmd/config.json`` + + A remote URL tells the service layer to delegate every data call to + that server instead of running a local store. Example value: + ``"http://pi.local:7900"`` or a Tailscale MagicDNS URL. + """ + env = os.environ.get("TAOSMD_SERVER_URL") + if env and env.strip(): + return env.strip() + url = _read(data_dir).get(_SERVER_URL_KEY) + if isinstance(url, str) and url.strip(): + return url.strip() + return None + + +def set_server_url(url: str, clear: bool = False, data_dir=None) -> None: + """Persist the remote server URL. + + Args: + url: Base URL of the remote taOSmd server, e.g. ``"http://pi:7900"``. + Ignored when ``clear`` is True. + clear: when True, remove the setting. + + Raises: + ValueError: when ``clear`` is False and ``url`` is not a non-empty string. + """ + data = _read(data_dir) + if clear: + data.pop(_SERVER_URL_KEY, None) + else: + if not isinstance(url, str) or not url.strip(): + raise ValueError("url must be a non-empty string (or pass clear=True)") + data[_SERVER_URL_KEY] = url.strip() + _write(data, data_dir) + + +# --------------------------------------------------------------------------- +# Remote server bearer token +# --------------------------------------------------------------------------- + +def get_server_token(data_dir=None) -> str | None: + """Return the configured remote server bearer token, or ``None`` if unset. + + Resolution order (first non-empty wins): + + 1. ``TAOSMD_TOKEN`` environment variable + 2. ``server_token`` key in ``~/.taosmd/config.json`` + + When set, the token is sent as ``Authorization: Bearer `` on every + request to the remote server. The token is never logged or printed. + """ + env = os.environ.get("TAOSMD_TOKEN") + if env and env.strip(): + return env.strip() + token = _read(data_dir).get(_SERVER_TOKEN_KEY) + if isinstance(token, str) and token.strip(): + return token.strip() + return None + + +def set_server_token(token: str, clear: bool = False, data_dir=None) -> None: + """Persist the remote server bearer token. + + Args: + token: Bearer token string. Ignored when ``clear`` is True. + clear: when True, remove the setting. + + Raises: + ValueError: when ``clear`` is False and ``token`` is not a + non-empty string. + """ + data = _read(data_dir) + if clear: + data.pop(_SERVER_TOKEN_KEY, None) + else: + if not isinstance(token, str) or not token.strip(): + raise ValueError("token must be a non-empty string (or pass clear=True)") + data[_SERVER_TOKEN_KEY] = token.strip() + _write(data, data_dir) + + __all__ = [ "get_memory_model", "set_memory_model", "resolve_memory_model", + "get_server_url", + "set_server_url", + "get_server_token", + "set_server_token", ] diff --git a/taosmd/docs/a2a-comms.md b/taosmd/docs/a2a-comms.md index b3dbb125..f9597f05 100644 --- a/taosmd/docs/a2a-comms.md +++ b/taosmd/docs/a2a-comms.md @@ -208,6 +208,99 @@ Your ID: AGENT_NAME (replace with this agent's own name/identifier) --- +## Durable monitoring — set up (or amend) an hourly bus-check cron + +When your session ends the live SSE stream closes and you stop seeing new +messages. Set up an hourly cron so you can pick up the thread on next start +without missing anything. + +`taosmd a2a-poll` is the building block. It fetches messages from the bus, +prints only the ones with an ID greater than the last-seen ID stored in a +small state file, and then updates that file. Running it once per hour from +cron gives you a durable, low-noise inbox. + +### Basic usage + +``` +taosmd a2a-poll --channel CHANNEL --exclude YOUR_AGENT_NAME +``` + +Options: + +| Flag | Default | Purpose | +|------|---------|---------| +| `--channel NAME` | (required) | Channel to poll | +| `--exclude AGENT` | (none) | Skip messages from this sender (usually your own name) | +| `--state-file PATH` | `~/.taosmd/a2a-poll-state.json` | Where last-seen IDs are stored | +| `--server URL` | `TAOSMD_SERVER_URL` or `config.json` | Override remote server for this run | + +### Linux / macOS — idempotent cron setup + +The snippet below creates or amends the crontab entry tagged +`# taosmd-a2a:CHANNEL` so running it multiple times never duplicates the +line. Replace `CHANNEL` and `YOUR_AGENT_NAME` with your values before +executing. + +```bash +CHANNEL="CHANNEL" +AGENT="YOUR_AGENT_NAME" +INBOX="$HOME/.taosmd/a2a-inbox.log" + +# Build the new cron line (tagged so we can replace it idempotently). +NEW_LINE="0 * * * * taosmd a2a-poll --channel $CHANNEL --exclude $AGENT >> $INBOX 2>&1 # taosmd-a2a:$CHANNEL" + +# Read the current crontab, strip any existing line for this channel, append the new one. +( crontab -l 2>/dev/null | grep -v "# taosmd-a2a:$CHANNEL"; echo "$NEW_LINE" ) | crontab - + +echo "Cron set. New messages appended to $INBOX every hour." +``` + +Run this snippet in a shell once. To remove the entry later: + +```bash +crontab -l 2>/dev/null | grep -v "# taosmd-a2a:CHANNEL" | crontab - +``` + +### Windows — PowerShell schtasks equivalent + +On Windows use `schtasks` to create or replace a scheduled task. The example +below creates (or replaces) a task named `taosmd-a2a-CHANNEL` that runs every +hour. Replace `CHANNEL` and `YOUR_AGENT_NAME`. + +```powershell +$CHANNEL = "CHANNEL" +$AGENT = "YOUR_AGENT_NAME" +$INBOX = "$env:USERPROFILE\.taosmd\a2a-inbox.log" +$CMD = "taosmd a2a-poll --channel $CHANNEL --exclude $AGENT" +$TASK = "taosmd-a2a-$CHANNEL" + +# /F overwrites an existing task with the same name (idempotent). +schtasks /Create /F /SC HOURLY /TN $TASK ` + /TR "cmd /c $CMD >> `"$INBOX`" 2>&1" + +Write-Host "Scheduled task '$TASK' created. New messages appended to $INBOX." +``` + +To remove the task: + +```powershell +schtasks /Delete /F /TN "taosmd-a2a-CHANNEL" +``` + +### What the inbox looks like + +Each new message is printed on one line: + +``` +[2026-06-07 14:00:01 UTC] hey, did you finish the review? +[2026-06-07 14:03:22 UTC] (reply_to=42) yes, LGTM — merging now +``` + +Start-of-session ritual: check the inbox before answering the user's first +question, surface any pending messages, then continue as normal. + +--- + ## Querying the bus **What channels exist?** diff --git a/taosmd/http_server.py b/taosmd/http_server.py index c1e0e6b2..02518eca 100644 --- a/taosmd/http_server.py +++ b/taosmd/http_server.py @@ -70,7 +70,7 @@ from pathlib import Path from urllib.parse import parse_qs, urlsplit -from . import __version__, service +from . import __version__, config as _config, service # --------------------------------------------------------------------------- # Static webui helpers @@ -451,7 +451,19 @@ def _make_handler(data_dir, runner: _ServiceLoop): ThreadingHTTPServer instantiates the handler per request, so the data dir is closed over here rather than threaded through every call site. + + If the server has ``server_token`` set in its own config (or the + ``TAOSMD_TOKEN`` env var), every data/A2A JSON endpoint requires a + matching ``Authorization: Bearer `` header and returns ``401`` + otherwise. ``GET /health``, ``GET /``, ``GET /ui``, and static assets + are always open so monitoring probes and the inspection UI keep working. """ + # Read the server-side expected token once at handler-class creation time. + # This is the token the *server* checks (not the client's outbound token). + _server_token: str | None = _config.get_server_token(data_dir) + + # Paths that are always public regardless of the token setting. + _PUBLIC_PATHS = frozenset({"/", "/ui", "/health"}) class TaosmdHandler(BaseHTTPRequestHandler): server_version = f"taosmd/{__version__}" @@ -528,6 +540,23 @@ def _read_json_body(self) -> dict: raise _BadRequest("JSON body must be an object") return parsed + # ----- auth helper ------------------------------------------------- + def _check_token(self, path: str) -> bool: + """Return True when the request is authorised to proceed. + + If ``_server_token`` is not set, every request is authorised. + Public paths (health, UI) are always authorised. + Otherwise the ``Authorization: Bearer `` header must match. + """ + if not _server_token: + return True + if path.rstrip("/") in _PUBLIC_PATHS or not path.rstrip("/"): + return True + auth = self.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[len("Bearer "):].strip() == _server_token + return False + # ----- routing ----------------------------------------------------- def do_GET(self) -> None: # noqa: N802 - stdlib signature self._dispatch("GET") @@ -542,6 +571,11 @@ def _dispatch(self, method: str) -> None: parts = urlsplit(self.path) path = parts.path.rstrip("/") or "/" query = parse_qs(parts.query) + # Token gate: check before routing so even unknown paths are + # protected (prevents enumeration without a token). + if not self._check_token(path): + self._send_json(401, {"error": "Unauthorized"}) + return try: if method == "GET" and path in ("/", "/ui"): self._serve_spa() diff --git a/taosmd/remote.py b/taosmd/remote.py new file mode 100644 index 00000000..f94f5426 --- /dev/null +++ b/taosmd/remote.py @@ -0,0 +1,189 @@ +"""Remote client for a taOSmd HTTP server — stdlib only, zero extra deps. + +``RemoteClient`` mirrors the async methods exposed by :mod:`taosmd.service` +(and therefore the local Python API) so callers can transparently point the +service layer at a remote server (e.g. a Raspberry Pi over Tailscale) by +setting ``TAOSMD_SERVER_URL`` or running ``taosmd config set-server ``. + +All network I/O is blocking (``urllib.request``). Each public method is an +async coroutine that offloads the blocking call via :func:`asyncio.to_thread` +so the caller's event loop is never stalled. + +Error handling +-------------- +Any non-2xx response raises :class:`RuntimeError` with the HTTP status and the +body text so the caller can surface a clear message without parsing internals. +Connection errors from ``urllib`` are propagated as-is. +""" + +from __future__ import annotations + +import asyncio +import json +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + + +class RemoteClient: + """Async client that delegates taOSmd service calls to a remote HTTP server. + + Args: + base_url: Base URL of the remote server, e.g. ``"http://pi.local:7900"``. + A trailing slash is stripped for consistency. + token: Optional bearer token sent as ``Authorization: Bearer ``. + timeout: Request timeout in seconds (default 30). + """ + + def __init__(self, base_url: str, token: str | None = None, timeout: int = 30) -> None: + self._base = base_url.rstrip("/") + self._token = token + self._timeout = timeout + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _headers(self, extra: dict[str, str] | None = None) -> dict[str, str]: + h: dict[str, str] = {"Content-Type": "application/json", "Accept": "application/json"} + if self._token: + h["Authorization"] = f"Bearer {self._token}" + if extra: + h.update(extra) + return h + + def _request_json(self, method: str, path: str, body: dict | None = None, params: dict | None = None) -> Any: + """Perform a synchronous JSON request and return the parsed response body. + + Raises :class:`RuntimeError` for non-2xx responses. + """ + url = self._base + path + if params: + url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None}) + encoded: bytes | None = json.dumps(body).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=encoded, headers=self._headers(), method=method) + try: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + try: + raw = exc.read().decode("utf-8") + except Exception: + raw = "(unreadable body)" + raise RuntimeError(f"taosmd remote: HTTP {exc.code} from {url}: {raw}") from exc + + async def _run(self, method: str, path: str, body: dict | None = None, params: dict | None = None) -> Any: + """Async wrapper — offloads the blocking urllib call via asyncio.to_thread.""" + return await asyncio.to_thread(self._request_json, method, path, body, params) + + # ------------------------------------------------------------------ + # Memory service methods — mirrors taosmd.service signatures + # ------------------------------------------------------------------ + + async def ingest(self, text: str, agent: str, **_opts) -> dict: + """POST /ingest — shelve ``text`` into the remote agent's memory. + + Returns ``{"archived", "agent", "data_dir"}``. + """ + return await self._run("POST", "/ingest", {"text": text, "agent": agent}) + + async def search(self, query: str, agent: str, limit: int = 5, **_opts) -> list[dict]: + """POST /search — return ranked hits for ``query`` from the remote server. + + Returns the ``hits`` list from the server response. + """ + resp = await self._run("POST", "/search", {"query": query, "agent": agent, "limit": limit}) + return resp.get("hits", []) + + async def pending_list(self, agent: str | None = None, limit: int = 20, **_opts) -> list[dict]: + """GET /pending — return unresolved KG-update decisions. + + Returns the ``pending`` list from the server response. + """ + params: dict = {"limit": limit} + if agent: + params["agent"] = agent + resp = await self._run("GET", "/pending", params=params) + return resp.get("pending", []) + + async def pending_resolve( + self, + decision_id: str, + decision: str, + *, + note: str = "", + **_opts, + ) -> dict: + """POST /pending/resolve — resolve a pending KG decision.""" + return await self._run( + "POST", "/pending/resolve", + {"id": decision_id, "decision": decision, "note": note}, + ) + + async def a2a_send( + self, + sender: str, + body: str, + *, + thread: str = "general", + reply_to: str | None = None, + **_opts, + ) -> dict: + """POST /a2a/send — post a message to the remote A2A bus. + + Returns the send receipt ``{"id", "from", "thread", "reply_to"}``. + """ + payload: dict = {"from": sender, "body": body, "thread": thread} + if reply_to is not None: + payload["reply_to"] = reply_to + return await self._run("POST", "/a2a/send", payload) + + async def a2a_feed( + self, + *, + thread: str | None = None, + since: float | None = None, + limit: int = 50, + **_opts, + ) -> list[dict]: + """GET /a2a/messages — return messages from the remote A2A bus, oldest-first. + + Returns the ``messages`` list from the server response. + """ + params: dict = {"limit": limit} + if thread is not None: + params["thread"] = thread + if since is not None: + params["since"] = since + resp = await self._run("GET", "/a2a/messages", params=params) + return resp.get("messages", []) + + async def a2a_channels(self, **_opts) -> list[dict]: + """GET /a2a/channels — return a summary of every channel on the remote bus.""" + resp = await self._run("GET", "/a2a/channels") + return resp.get("channels", []) + + async def a2a_members(self, *, channel: str, **_opts) -> list[str]: + """GET /a2a/members — return distinct sender names on ``channel``.""" + resp = await self._run("GET", "/a2a/members", params={"channel": channel}) + return resp.get("members", []) + + async def stats(self, *, agent: str, **_opts) -> dict: + """Best-effort stats for ``agent`` on the remote server. + + Fetches ``GET /health`` (always available) and attempts a search + with an empty query to probe liveness. Returns a minimal dict with + at least ``{"agent", "reachable"}``. Does not raise on connection + errors — reports them in the returned dict instead so callers that + use stats for a health probe are not interrupted. + """ + try: + health = await self._run("GET", "/health") + reachable = health.get("status") == "ok" + except Exception as exc: + return {"agent": agent, "reachable": False, "error": str(exc)} + return {"agent": agent, "reachable": reachable, "server_version": health.get("version")} + + +__all__ = ["RemoteClient"] diff --git a/taosmd/service.py b/taosmd/service.py index 0af0feac..c6d8aaf8 100644 --- a/taosmd/service.py +++ b/taosmd/service.py @@ -13,6 +13,14 @@ ``_ensure_stores`` / the stores cache / ``TAOSMD_DATA_DIR`` handling) so the only thing they add is a uniform, transport-friendly signature: ``(positional, agent=..., data_dir=..., **opts)``. + +Remote dispatch +--------------- +When a server URL is configured (via ``TAOSMD_SERVER_URL`` or +``taosmd config set-server``) each function delegates to a cached +:class:`~taosmd.remote.RemoteClient` instead of running the local store. +The caller's signature is identical in both code paths, so the CLI, MCP +server, and Python API all go remote transparently. """ from __future__ import annotations @@ -20,8 +28,58 @@ import json from . import api as _api +from . import config as _config from .archive import EVENT_A2A +# Cache of RemoteClient instances keyed by (base_url, token) so we don't +# create a fresh object on every call. Access from async coroutines is safe +# because Python dict operations are GIL-protected. +_remote_cache: dict[tuple[str, str | None], object] = {} + + +def _get_remote(data_dir=None): + """Return a cached :class:`~taosmd.remote.RemoteClient` when a server URL + is configured, otherwise ``None`` (use local path). + + When ``data_dir`` is explicitly provided (as the http_server always does), + the server-URL is resolved **only from the config file** in that data dir — + the ``TAOSMD_SERVER_URL`` env var is intentionally ignored. This prevents + the running HTTP server from reading the env var and proxying its own + requests back to itself (infinite loop). The env override is only active + for callers that do not specify a data_dir (CLI, MCP, Python API at the + top level). + """ + if data_dir is not None: + # Explicit data_dir: config-file only, skip env. + import json as _json # noqa: PLC0415 + import os as _os # noqa: PLC0415 + from pathlib import Path as _Path # noqa: PLC0415 + cfg_path = _Path(_os.fspath(data_dir)) / "config.json" + try: + cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {} + except (OSError, _json.JSONDecodeError): + cfg = {} + url = cfg.get("server_url", "") + if not isinstance(url, str) or not url.strip(): + return None + url = url.strip() + token_raw = cfg.get("server_token", "") + token: str | None = token_raw.strip() if isinstance(token_raw, str) and token_raw.strip() else None + else: + # No explicit data_dir: use the full resolution (env override + config file). + url = _config.get_server_url(data_dir) + if not url: + return None + token = _config.get_server_token(data_dir) + + key = (url, token) + client = _remote_cache.get(key) + if client is None: + from .remote import RemoteClient # noqa: PLC0415 + client = RemoteClient(url, token=token) + _remote_cache[key] = client + return client + async def ingest(text, *, agent: str, data_dir=None, **opts) -> dict: """Shelve a transcript and embed it for later search. @@ -29,7 +87,13 @@ async def ingest(text, *, agent: str, data_dir=None, **opts) -> dict: Thin wrapper over :func:`taosmd.api.ingest`. ``text`` may be a string, a turn dict, or an iterable of either (see the underlying API for the accepted shapes). Returns ``{"archived", "agent", "data_dir"}``. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ + remote = _get_remote(data_dir) + if remote is not None: + return await remote.ingest(text, agent) return await _api.ingest(text, agent=agent, data_dir=data_dir, **opts) @@ -39,7 +103,13 @@ async def search(query: str, *, agent: str, data_dir=None, limit: int = 5, **opt Thin wrapper over :func:`taosmd.api.search`. Returns ranked hits in the agent-rules contract shape (``text``/``source``/``timestamp``/ ``confidence``/``metadata``). + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ + remote = _get_remote(data_dir) + if remote is not None: + return await remote.search(query, agent, limit=limit) return await _api.search(query, agent=agent, data_dir=data_dir, limit=limit, **opts) @@ -50,7 +120,13 @@ async def pending_list(*, agent: str | None = None, data_dir=None, limit: int = is keyed per data dir (per install), not per agent, so it is not used to filter here. Use ``subject=`` on the underlying API if subject-level filtering is needed. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ + remote = _get_remote(data_dir) + if remote is not None: + return await remote.pending_list(agent=agent, limit=limit) return await _api.list_pending_decisions(limit=limit, data_dir=data_dir) @@ -66,7 +142,13 @@ async def pending_resolve( ``decision`` is one of ``accept`` / ``reject`` / ``modify`` (the ``action`` argument of :func:`taosmd.api.resolve_pending_decision`). Returns ``{ok, applied_kg, resolution}``. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ + remote = _get_remote(data_dir) + if remote is not None: + return await remote.pending_resolve(decision_id, decision, note=note) return await _api.resolve_pending_decision( decision_id, action=decision, note=note, data_dir=data_dir, ) @@ -95,9 +177,15 @@ async def stats(*, agent: str, data_dir=None) -> dict: "last_ingest_at", "total_chunks"}``. Unknown agents report ``registered=False`` with zeroed counters rather than raising, so the surface stays forgiving for read-only probes. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` (best-effort via ``GET /health``). """ if not agent: raise ValueError("agent name is required") + remote = _get_remote(data_dir) + if remote is not None: + return await remote.stats(agent=agent) stores = await _api._ensure_stores(data_dir) from .agents import AgentNotFoundError, get_agent # noqa: PLC0415 @@ -140,11 +228,17 @@ async def a2a_send( should be the string ID of the message being replied to. Returns ``{"id", "from", "thread", "reply_to"}``. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ if not isinstance(sender, str) or not sender: raise ValueError("sender must be a non-empty string") if not isinstance(body, str) or not body: raise ValueError("body must be a non-empty string") + remote = _get_remote(data_dir) + if remote is not None: + return await remote.a2a_send(sender, body, thread=thread, reply_to=reply_to) stores = await _api._ensure_stores(data_dir) archive = stores["archive"] row_id = await archive.record( @@ -174,7 +268,13 @@ async def a2a_feed( Each item has shape ``{"id", "ts", "from", "body", "thread", "reply_to"}``. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ + remote = _get_remote(data_dir) + if remote is not None: + return await remote.a2a_feed(thread=thread, since=since, limit=limit) stores = await _api._ensure_stores(data_dir) archive = stores["archive"] rows = await archive.query( @@ -213,7 +313,13 @@ async def a2a_channels(*, data_dir=None) -> list[dict]: "created_ts", "last_ts"}``, sorted by ``last_ts`` descending (most recently active channel first). ``members`` is a sorted list of unique sender names observed on that channel. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ + remote = _get_remote(data_dir) + if remote is not None: + return await remote.a2a_channels() stores = await _api._ensure_stores(data_dir) archive = stores["archive"] rows = await archive.query(event_type=EVENT_A2A, limit=100_000) @@ -264,7 +370,13 @@ async def a2a_members(*, channel: str, data_dir=None) -> list[str]: Derived from :data:`~taosmd.archive.EVENT_A2A` events whose ``app_id`` matches ``channel``. Returns an empty list (not an error) when the channel has never received a message. + + When a remote server URL is configured the call is forwarded to + :class:`~taosmd.remote.RemoteClient` transparently. """ + remote = _get_remote(data_dir) + if remote is not None: + return await remote.a2a_members(channel=channel) stores = await _api._ensure_stores(data_dir) archive = stores["archive"] rows = await archive.query(event_type=EVENT_A2A, app_id=channel, limit=100_000) diff --git a/taosmd/skills/taosmd-a2a/SKILL.md b/taosmd/skills/taosmd-a2a/SKILL.md new file mode 100644 index 00000000..642b7c66 --- /dev/null +++ b/taosmd/skills/taosmd-a2a/SKILL.md @@ -0,0 +1,21 @@ +--- +name: taosmd-a2a +description: Set up agent-to-agent comms and named channels via the taOSmd A2A bus. +user-invocable: true +--- + +Print the setup guide from the taosmd package, then execute it. taOSmd must be +importable to read the guide, so install it first if it is missing: + +``` +python -c "import taosmd" 2>/dev/null || pip install git+https://github.com/jaylfc/taosmd +python -c "import taosmd; print(taosmd.a2a_setup_guide())" +``` + +Follow every step in the guide in order. The guide is the source of truth for +checking for an existing server, starting the bus, creating/joining a channel, +and generating the invite block for the user's other agents. + +After joining a channel, set up the hourly bus-check cron so you do not miss +messages when your session ends — follow the **"Durable monitoring"** section +in the guide for idempotent cron/schtasks setup instructions. diff --git a/tests/test_a2a_poll.py b/tests/test_a2a_poll.py new file mode 100644 index 00000000..d283bc63 --- /dev/null +++ b/tests/test_a2a_poll.py @@ -0,0 +1,231 @@ +"""Tests for taosmd a2a-poll — durable bus monitoring. + +Hermetic: uses an ephemeral port server and isolated tmp dirs. No network +calls beyond loopback; no ~/.taosmd touched; no port 7900 used. +""" + +from __future__ import annotations + +import asyncio +import argparse +import json +import threading +from pathlib import Path + +import pytest + +from taosmd import api as taosmd_api +from taosmd import http_server +from taosmd.cli import _a2a_poll_cmd +from taosmd.remote import RemoteClient + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +def _patch_embedder(stores: dict) -> None: + vmem = stores["vector"] + + async def _fake_embed(text: str, task: str = "search_document") -> list[float]: + h = hash(text) & 0xFFFFFFFF + return [((h >> (i * 4)) & 0xFF) / 255.0 for i in range(8)] + + vmem.embed = _fake_embed # type: ignore[assignment] + + +@pytest.fixture +def poll_server(tmp_path, monkeypatch): + """Start a real HTTP server on an ephemeral port for a2a-poll tests. + + Yields (base_url, data_dir_str, RemoteClient). + """ + data_dir = tmp_path / "poll-data" + data_dir.mkdir() + monkeypatch.setattr(taosmd_api, "_stores_cache", {}) + + httpd = http_server.make_server("127.0.0.1", 0, data_dir=str(data_dir)) + stores = httpd.service_loop.run(taosmd_api._ensure_stores(str(data_dir))) + _patch_embedder(stores) + + host, port = httpd.server_address[:2] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + base_url = f"http://{host}:{port}" + rc = RemoteClient(base_url) + try: + yield base_url, str(data_dir), rc + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + httpd.service_loop.close() + + +def _seed_messages(rc: RemoteClient, channel: str, messages: list[tuple[str, str]]) -> list[dict]: + """Post ``messages`` (list of (sender, body)) and return receipts.""" + receipts = [] + for sender, body in messages: + receipts.append(asyncio.run(rc.a2a_send(sender, body, thread=channel))) + return receipts + + +# --------------------------------------------------------------------------- +# a2a-poll: print only-new, update state, --exclude filter +# --------------------------------------------------------------------------- + +def test_a2a_poll_prints_new_messages(poll_server, tmp_path, capsys): + """a2a-poll outputs messages not yet seen and updates state file.""" + base_url, _, rc = poll_server + state_file = tmp_path / "poll-state.json" + channel = "poll-test-chan" + + _seed_messages(rc, channel, [ + ("agent-a", "First message"), + ("agent-b", "Second message"), + ]) + + args = argparse.Namespace( + channel=channel, + server=base_url, + state_file=str(state_file), + exclude=None, + ) + rc_exit = _a2a_poll_cmd(args) + assert rc_exit == 0 + + captured = capsys.readouterr() + assert "First message" in captured.out + assert "Second message" in captured.out + + # State file should exist and record the last-seen ID. + state = json.loads(state_file.read_text()) + assert channel in state + assert state[channel] >= 0 + + +def test_a2a_poll_second_run_prints_nothing(poll_server, tmp_path, capsys): + """After the first poll, a second poll with no new messages prints nothing.""" + base_url, _, rc = poll_server + state_file = tmp_path / "poll-state2.json" + channel = "poll-test-empty" + + _seed_messages(rc, channel, [("agent-a", "Only message")]) + + args = argparse.Namespace( + channel=channel, + server=base_url, + state_file=str(state_file), + exclude=None, + ) + _a2a_poll_cmd(args) + capsys.readouterr() # discard first run output + + # Second run: no new messages. + rc_exit = _a2a_poll_cmd(args) + assert rc_exit == 0 + captured = capsys.readouterr() + assert captured.out.strip() == "" + + +def test_a2a_poll_only_new_since_last_seen(poll_server, tmp_path, capsys): + """Only messages posted after the last poll are printed on the next run.""" + base_url, _, rc = poll_server + state_file = tmp_path / "poll-state3.json" + channel = "poll-test-incremental" + + # Seed two messages and poll them. + _seed_messages(rc, channel, [ + ("agent-a", "Old message 1"), + ("agent-a", "Old message 2"), + ]) + + args = argparse.Namespace( + channel=channel, + server=base_url, + state_file=str(state_file), + exclude=None, + ) + _a2a_poll_cmd(args) + capsys.readouterr() + + # Seed one more and poll again. + _seed_messages(rc, channel, [("agent-b", "New message after poll")]) + _a2a_poll_cmd(args) + captured = capsys.readouterr() + + assert "New message after poll" in captured.out + assert "Old message 1" not in captured.out + assert "Old message 2" not in captured.out + + +def test_a2a_poll_exclude_filters_sender(poll_server, tmp_path, capsys): + """Messages from the excluded sender are not printed.""" + base_url, _, rc = poll_server + state_file = tmp_path / "poll-state-exclude.json" + channel = "poll-test-exclude" + + _seed_messages(rc, channel, [ + ("myself", "My own message"), + ("other-agent", "Other agent message"), + ]) + + args = argparse.Namespace( + channel=channel, + server=base_url, + state_file=str(state_file), + exclude="myself", + ) + rc_exit = _a2a_poll_cmd(args) + assert rc_exit == 0 + + captured = capsys.readouterr() + assert "Other agent message" in captured.out + assert "My own message" not in captured.out + + +def test_a2a_poll_exclude_advances_state(poll_server, tmp_path, capsys): + """Excluded messages still advance the last-seen ID in the state file.""" + base_url, _, rc = poll_server + state_file = tmp_path / "poll-state-excl-adv.json" + channel = "poll-test-excl-adv" + + receipts = _seed_messages(rc, channel, [("myself", "Excluded")]) + last_id = receipts[-1]["id"] + + args = argparse.Namespace( + channel=channel, + server=base_url, + state_file=str(state_file), + exclude="myself", + ) + _a2a_poll_cmd(args) + + state = json.loads(state_file.read_text()) + # The state should be at least as high as the excluded message's ID. + assert state.get(channel, -1) >= int(last_id) + + +def test_a2a_poll_state_file_persists_across_channels(poll_server, tmp_path, capsys): + """State file tracks multiple channels independently.""" + base_url, _, rc = poll_server + state_file = tmp_path / "poll-state-multi.json" + chan_a = "poll-multi-a" + chan_b = "poll-multi-b" + + _seed_messages(rc, chan_a, [("agent-a", "Chan A message")]) + _seed_messages(rc, chan_b, [("agent-b", "Chan B message")]) + + for chan in (chan_a, chan_b): + args = argparse.Namespace( + channel=chan, + server=base_url, + state_file=str(state_file), + exclude=None, + ) + _a2a_poll_cmd(args) + + state = json.loads(state_file.read_text()) + assert chan_a in state + assert chan_b in state + assert state[chan_a] != state[chan_b] or (state[chan_a] >= 0 and state[chan_b] >= 0) diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 00000000..8eb5f611 --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,473 @@ +"""Tests for taosmd.remote and related config/service dispatch. + +Hermetic — uses an ephemeral port and isolated tmp dirs so nothing touches +~/.taosmd or port 7900. The ONNX/QMD embedder is patched with a +deterministic hash vector, matching test_http_server.py style. +""" + +from __future__ import annotations + +import asyncio +import json +import threading +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +from taosmd import api as taosmd_api +from taosmd import config as taosmd_config +from taosmd import http_server, service as taosmd_service +from taosmd.remote import RemoteClient + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +def _patch_embedder(stores: dict) -> None: + """Deterministic 8-dim hash embedder — no ONNX/QMD model needed.""" + vmem = stores["vector"] + + async def _fake_embed(text: str, task: str = "search_document") -> list[float]: + h = hash(text) & 0xFFFFFFFF + return [((h >> (i * 4)) & 0xFF) / 255.0 for i in range(8)] + + vmem.embed = _fake_embed # type: ignore[assignment] + + +@pytest.fixture +def live_server(tmp_path, monkeypatch): + """Start a real HTTP server on an ephemeral port with an isolated data dir. + + Yields (base_url, data_dir_str). Cleans up fully on teardown. + """ + data_dir = tmp_path / "taosmd-data" + data_dir.mkdir() + monkeypatch.setattr(taosmd_api, "_stores_cache", {}) + + httpd = http_server.make_server("127.0.0.1", 0, data_dir=str(data_dir)) + stores = httpd.service_loop.run(taosmd_api._ensure_stores(str(data_dir))) + _patch_embedder(stores) + + host, port = httpd.server_address[:2] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f"http://{host}:{port}", str(data_dir) + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + for s in list(taosmd_api._stores_cache.values()): + for store in (s.get("archive"), s.get("vector"), s.get("kg")): + if store and hasattr(store, "close"): + try: + httpd.service_loop.run(store.close()) + except Exception: + pass + httpd.service_loop.close() + + +@pytest.fixture +def client(live_server): + """RemoteClient pointing at the live ephemeral server.""" + base_url, _ = live_server + return RemoteClient(base_url) + + +# --------------------------------------------------------------------------- +# RemoteClient round-trips +# --------------------------------------------------------------------------- + +def test_remote_ingest_and_search(client): + """ingest → search returns a hit for the ingested text.""" + result = asyncio.run(client.ingest("Remote memory test content.", agent="remote-test")) + assert result["archived"] == 1 + assert result["agent"] == "remote-test" + + hits = asyncio.run(client.search("Remote memory test content.", agent="remote-test", limit=3)) + assert hits, "expected at least one hit" + assert any("Remote memory" in h["text"] for h in hits) + + +def test_remote_pending_list(client): + """pending_list returns a list (may be empty).""" + pending = asyncio.run(client.pending_list(agent="remote-test")) + assert isinstance(pending, list) + + +def test_remote_a2a_send_and_feed(client): + """a2a_send → a2a_feed returns the sent message.""" + receipt = asyncio.run( + client.a2a_send("agent-alpha", "Hello from remote!", thread="test-chan") + ) + assert receipt["from"] == "agent-alpha" + assert receipt["thread"] == "test-chan" + + msgs = asyncio.run(client.a2a_feed(thread="test-chan", limit=10)) + assert any(m["from"] == "agent-alpha" and "Hello from remote!" in m["body"] for m in msgs) + + +def test_remote_a2a_channels(client): + """a2a_channels returns a list after posting.""" + asyncio.run(client.a2a_send("agent-beta", "Ping", thread="chan-for-channels")) + channels = asyncio.run(client.a2a_channels()) + assert isinstance(channels, list) + names = {c["channel"] for c in channels} + assert "chan-for-channels" in names + + +def test_remote_a2a_members(client): + """a2a_members returns the senders on a channel.""" + asyncio.run(client.a2a_send("agent-gamma", "Hi", thread="chan-for-members")) + asyncio.run(client.a2a_send("agent-delta", "Hey", thread="chan-for-members")) + members = asyncio.run(client.a2a_members(channel="chan-for-members")) + assert "agent-gamma" in members + assert "agent-delta" in members + + +def test_remote_stats_health(client): + """stats returns reachable=True and a server_version.""" + stats = asyncio.run(client.stats(agent="remote-test")) + assert stats["reachable"] is True + assert "server_version" in stats + + +def test_remote_non_200_raises_runtime_error(live_server): + """A request to a non-existent endpoint raises RuntimeError with status.""" + base_url, _ = live_server + bad = RemoteClient(base_url) + # POST to a non-existent JSON endpoint returns 404 + with pytest.raises(RuntimeError, match="404"): + asyncio.run(bad._run("POST", "/no-such-endpoint", {"foo": "bar"})) + + +# --------------------------------------------------------------------------- +# config: server_url / server_token get/set + env override +# --------------------------------------------------------------------------- + +@pytest.fixture +def config_data_dir(tmp_path, monkeypatch): + d = tmp_path / "cfg" + monkeypatch.setenv("TAOSMD_DATA_DIR", str(d)) + # Clear env overrides so config-file path is exercised cleanly. + monkeypatch.delenv("TAOSMD_SERVER_URL", raising=False) + monkeypatch.delenv("TAOSMD_TOKEN", raising=False) + return d + + +def test_config_server_url_round_trip(config_data_dir): + taosmd_config.set_server_url("http://pi.local:7900") + assert taosmd_config.get_server_url() == "http://pi.local:7900" + + +def test_config_server_url_clear(config_data_dir): + taosmd_config.set_server_url("http://pi.local:7900") + taosmd_config.set_server_url("", clear=True) + assert taosmd_config.get_server_url() is None + + +def test_config_server_url_unset_is_none(config_data_dir): + assert taosmd_config.get_server_url() is None + + +def test_config_server_url_empty_raises(config_data_dir): + with pytest.raises(ValueError): + taosmd_config.set_server_url("") + with pytest.raises(ValueError): + taosmd_config.set_server_url(" ") + + +def test_config_server_url_env_override(config_data_dir, monkeypatch): + taosmd_config.set_server_url("http://file-url:7900") + monkeypatch.setenv("TAOSMD_SERVER_URL", "http://env-url:7900") + assert taosmd_config.get_server_url() == "http://env-url:7900" + + +def test_config_server_token_round_trip(config_data_dir): + taosmd_config.set_server_token("supersecret123") + assert taosmd_config.get_server_token() == "supersecret123" + + +def test_config_server_token_clear(config_data_dir): + taosmd_config.set_server_token("tok") + taosmd_config.set_server_token("", clear=True) + assert taosmd_config.get_server_token() is None + + +def test_config_server_token_env_override(config_data_dir, monkeypatch): + taosmd_config.set_server_token("file-token") + monkeypatch.setenv("TAOSMD_TOKEN", "env-token") + assert taosmd_config.get_server_token() == "env-token" + + +# --------------------------------------------------------------------------- +# service.py dispatch: monkeypatch server_url → goes remote; unset → local +# --------------------------------------------------------------------------- + +def test_service_dispatch_goes_remote_when_url_set(live_server, tmp_path, monkeypatch): + """When server_url is in config.json for a data_dir, service.search hits remote.""" + base_url, data_dir_str = live_server + cfg_dir = tmp_path / "cfg-dispatch" + cfg_dir.mkdir() + + # Pre-ingest via remote so there is something to find. + rc = RemoteClient(base_url) + asyncio.run(rc.ingest("service dispatch remote test", agent="dispatch-agent")) + + # Write server_url into the data_dir's config.json (the file-only path that + # bypasses env-var for an explicit data_dir, preventing infinite recursion + # in the server itself). + cfg_file = cfg_dir / "config.json" + cfg_file.write_text(json.dumps({"server_url": base_url})) + + # Flush the remote cache so it re-reads the config file. + taosmd_service._remote_cache.clear() + + hits = asyncio.run(taosmd_service.search( + "service dispatch remote test", + agent="dispatch-agent", + data_dir=str(cfg_dir), + )) + assert hits, "expected hits via remote dispatch" + + # Cleanup: clear remote cache + taosmd_service._remote_cache.clear() + + +def test_service_dispatch_local_when_no_url(tmp_path, monkeypatch): + """When get_server_url() returns None, service.search uses local stores.""" + data_dir = tmp_path / "local-dispatch" + data_dir.mkdir() + monkeypatch.setattr(taosmd_api, "_stores_cache", {}) + monkeypatch.delenv("TAOSMD_SERVER_URL", raising=False) + taosmd_service._remote_cache.clear() + + # Make a local store with a patched embedder. + stores = asyncio.run(taosmd_api._ensure_stores(str(data_dir))) + _patch_embedder(stores) + + # Ingest and search locally. + asyncio.run(taosmd_service.ingest("local dispatch test", agent="local-agent", data_dir=str(data_dir))) + hits = asyncio.run(taosmd_service.search("local dispatch test", agent="local-agent", data_dir=str(data_dir))) + assert hits, "expected local hits" + + +# --------------------------------------------------------------------------- +# Token auth: server with token set → no-header 401, header 200, health open +# --------------------------------------------------------------------------- + +@pytest.fixture +def token_server(tmp_path, monkeypatch): + """Start a server with server_token configured, yielding (base_url, token).""" + data_dir = tmp_path / "token-data" + data_dir.mkdir() + cfg_dir = tmp_path / "token-cfg" + cfg_dir.mkdir() + + TOKEN = "test-bearer-token-xyz" + + # Write the token into config.json in the data_dir so the server reads it. + cfg_file = data_dir / "config.json" + cfg_file.write_text(json.dumps({"server_token": TOKEN})) + + monkeypatch.setattr(taosmd_api, "_stores_cache", {}) + + # Reload http_server module-level _config so it reads the token we just wrote. + # The token is read at _make_handler() call time, so we pass data_dir. + httpd = http_server.make_server("127.0.0.1", 0, data_dir=str(data_dir)) + stores = httpd.service_loop.run(taosmd_api._ensure_stores(str(data_dir))) + _patch_embedder(stores) + + host, port = httpd.server_address[:2] + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield f"http://{host}:{port}", TOKEN + finally: + httpd.shutdown() + httpd.server_close() + thread.join(timeout=5) + httpd.service_loop.close() + + +def _raw_get(url: str, headers: dict | None = None) -> tuple[int, dict]: + """GET with optional extra headers; returns (status, body).""" + req = urllib.request.Request(url, headers=headers or {}, method="GET") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode()) + + +def _raw_post(url: str, payload: dict, headers: dict | None = None) -> tuple[int, dict]: + body = json.dumps(payload).encode() + h = {"Content-Type": "application/json"} + if headers: + h.update(headers) + req = urllib.request.Request(url, data=body, headers=h, method="POST") + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read().decode()) + + +def test_token_health_always_open(token_server): + """/health is accessible without a token.""" + base_url, _ = token_server + status, body = _raw_get(f"{base_url}/health") + assert status == 200 + assert body["status"] == "ok" + + +def test_token_data_endpoint_no_header_returns_401(token_server): + """POST /ingest without Authorization header returns 401.""" + base_url, _ = token_server + status, body = _raw_post( + f"{base_url}/ingest", + {"text": "blocked text", "agent": "tester"}, + ) + assert status == 401 + assert "Unauthorized" in body.get("error", "") + + +def test_token_data_endpoint_wrong_token_returns_401(token_server): + """POST /ingest with wrong token returns 401.""" + base_url, _ = token_server + status, body = _raw_post( + f"{base_url}/ingest", + {"text": "blocked text", "agent": "tester"}, + headers={"Authorization": "Bearer wrong-token"}, + ) + assert status == 401 + + +def test_token_data_endpoint_correct_token_returns_200(token_server): + """POST /ingest with the correct token returns 200.""" + base_url, token = token_server + status, body = _raw_post( + f"{base_url}/ingest", + {"text": "authenticated content", "agent": "auth-agent"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert status == 200, body + assert body["archived"] == 1 + + +def test_token_remote_client_sends_token(token_server): + """RemoteClient with the correct token successfully ingests.""" + base_url, token = token_server + rc = RemoteClient(base_url, token=token) + result = asyncio.run(rc.ingest("remote client token test", agent="rc-auth")) + assert result["archived"] == 1 + + +def test_token_remote_client_no_token_raises(token_server): + """RemoteClient without a token raises RuntimeError on data endpoints.""" + base_url, _ = token_server + rc = RemoteClient(base_url) # no token + with pytest.raises(RuntimeError, match="401"): + asyncio.run(rc.ingest("should be blocked", agent="no-token-agent")) + + +# --------------------------------------------------------------------------- +# install-skill copies into a tmp HOME, idempotent without --force +# --------------------------------------------------------------------------- + +def test_install_skill_copies_skill(tmp_path, monkeypatch): + """install-skill copies SKILL.md into ~/.claude/skills/taosmd-a2a/.""" + import argparse # noqa: PLC0415 + from taosmd.cli import _install_skill_cmd # noqa: PLC0415 + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + + # Patch expanduser so Path("~/.claude/skills/...") resolves under fake_home. + import pathlib # noqa: PLC0415 + original_expanduser = pathlib.Path.expanduser + + def _patched_expanduser(self): + p = str(self) + if p.startswith("~"): + return pathlib.Path(str(fake_home) + p[1:]) + return original_expanduser(self) + + monkeypatch.setattr(pathlib.Path, "expanduser", _patched_expanduser) + + args = argparse.Namespace(force=False) + rc = _install_skill_cmd(args) + assert rc == 0 + + skill_dest = fake_home / ".claude" / "skills" / "taosmd-a2a" / "SKILL.md" + assert skill_dest.exists(), f"SKILL.md not found at {skill_dest}" + content = skill_dest.read_text() + assert "taosmd-a2a" in content + + +def test_install_skill_idempotent_without_force(tmp_path, monkeypatch): + """Second install-skill call without --force returns 0 and does not overwrite.""" + import argparse # noqa: PLC0415 + from taosmd.cli import _install_skill_cmd # noqa: PLC0415 + import pathlib # noqa: PLC0415 + + fake_home = tmp_path / "home2" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + original_expanduser = pathlib.Path.expanduser + + def _patched(self): + p = str(self) + if p.startswith("~"): + return pathlib.Path(str(fake_home) + p[1:]) + return original_expanduser(self) + + monkeypatch.setattr(pathlib.Path, "expanduser", _patched) + + args_first = argparse.Namespace(force=False) + _install_skill_cmd(args_first) + + # Write a sentinel into the destination to verify idempotency. + skill_dest = fake_home / ".claude" / "skills" / "taosmd-a2a" / "SKILL.md" + skill_dest.write_text("sentinel content") + + args_second = argparse.Namespace(force=False) + rc = _install_skill_cmd(args_second) + assert rc == 0 + # Content should still be the sentinel (not overwritten). + assert skill_dest.read_text() == "sentinel content" + + +def test_install_skill_force_overwrites(tmp_path, monkeypatch): + """install-skill --force overwrites an existing installation.""" + import argparse # noqa: PLC0415 + from taosmd.cli import _install_skill_cmd # noqa: PLC0415 + import pathlib # noqa: PLC0415 + + fake_home = tmp_path / "home3" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + original_expanduser = pathlib.Path.expanduser + + def _patched(self): + p = str(self) + if p.startswith("~"): + return pathlib.Path(str(fake_home) + p[1:]) + return original_expanduser(self) + + monkeypatch.setattr(pathlib.Path, "expanduser", _patched) + + # First install, then clobber. + _install_skill_cmd(argparse.Namespace(force=False)) + skill_dest = fake_home / ".claude" / "skills" / "taosmd-a2a" / "SKILL.md" + skill_dest.write_text("old content") + + rc = _install_skill_cmd(argparse.Namespace(force=True)) + assert rc == 0 + new_content = skill_dest.read_text() + assert new_content != "old content" + assert "taosmd-a2a" in new_content