mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 18:11:31 +02:00
8dfa6a7199
Bumps every hardcoded 0.9.79 → 0.9.8 across backend, frontend,
desktop-shell, helm, lockfiles, test fixtures. Refreshes the in-app
ChangelogModal HEADLINE_FEATURES, NEW_FEATURES, and BUG_FIXES with the
v0.9.8 highlights.
Release artifacts built locally and hashed into release_digests.json:
ShadowBroker_v0.9.8.zip 6.06 MB
d506f6b8462ccb12096f0cd9462233be58928094240416b65fb3127bdd1f3820
ShadowBroker_0.9.8_x64_en-US.msi 122.4 MB
d4be4cb68c3e6409fff54c225acdcdd08e27d5d6d2b31616d78d2a4f6812991d
ShadowBroker_0.9.8_x64-setup.exe 76.5 MB
1115d1f5cf37edd03ea2c21d821c7626e1bf3319c990402aaa0293bca46fea67
Sizes match the v0.9.79 reference shape (5.76 MB / 117 MB / 72.9 MB)
within expected drift for new code. The .zip is a `git archive` of the
v0.9.8 source tree (matching v0.9.79's approach).
Audit confirms no .env, .key, .venv-dir, or cache files leaked into the
backend-runtime bundle. Python 3.11.9 + 199 site-packages + privacy_core
all staged correctly.
Headline changes since v0.9.79:
* Cumulative fuel/CO2 per flight (#317) — running totals since first
observation, not just per-hour rate.
* AIS maritime resilience (#314, #316) — outage banner + AISHub REST
fallback when AISStream WebSocket primary is offline.
* Data-layer repair (#311, #312) — UAP fallback respects the 60-day
cutoff; GPS jamming threshold tuning + nac_p=0 inclusion so the layer
actually fires.
* Per-flight source attribution (#313) — source field on every record.
* Cross-node DM mailbox replication (#309).
* Infonet sync HTTP 429 honored (#310).
Test fixtures updated:
* test_per_operator_outbound_attribution.py — added v0.9.8 UA strings
to the banned-aggregate-literals list (alongside v0.9.79).
* updateRuntime.test.ts — bumped asset filename fixtures to v0.9.8.
release_digests.json keeps the v0.9.79 block alongside v0.9.8 so
operators still on 0.9.79 validate cleanly during the rollout.
The accent narrowing fix in ChangelogModal (one feature uses 'purple',
two use 'cyan' so the renderer's `accent === 'purple'` comparison
still type-checks) is included.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
3407 lines
163 KiB
Python
3407 lines
163 KiB
Python
"""AI Intel API — endpoints for OpenClaw and the AI co-pilot.
|
|
|
|
All endpoints require local operator access (loopback or X-Admin-Key).
|
|
Provides: pin management, satellite imagery, news-near, data injection.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import math
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
from auth import require_local_operator, require_openclaw_or_local
|
|
from limiter import limiter
|
|
from services.fetchers._store import latest_data as _latest_data
|
|
|
|
|
|
|
|
def _ai_intel_user_agent() -> str:
|
|
from services.network_utils import outbound_user_agent
|
|
return outbound_user_agent("ai-intel")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent Actions Queue — agent pushes display commands to the frontend
|
|
# ---------------------------------------------------------------------------
|
|
# When the agent wants to show something to the user (satellite image,
|
|
# fly-to location, etc.), it pushes an action here. The frontend polls
|
|
# this lightweight endpoint and executes the action in the UI.
|
|
#
|
|
# Actions are consumed on read (destructive poll) so they don't pile up.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import threading as _actions_threading
|
|
import collections as _collections
|
|
|
|
_agent_actions_lock = _actions_threading.Lock()
|
|
_agent_actions: _collections.deque = _collections.deque(maxlen=20)
|
|
|
|
|
|
def push_agent_action(action: dict[str, Any]) -> None:
|
|
"""Push an action for the frontend to pick up."""
|
|
action.setdefault("ts", time.time())
|
|
with _agent_actions_lock:
|
|
_agent_actions.append(action)
|
|
|
|
|
|
def pop_agent_actions() -> list[dict[str, Any]]:
|
|
"""Pop all pending actions (destructive read)."""
|
|
with _agent_actions_lock:
|
|
actions = list(_agent_actions)
|
|
_agent_actions.clear()
|
|
return actions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class EntityAttachment(BaseModel):
|
|
entity_type: str = "" # "ship", "flight", "satellite", etc.
|
|
entity_id: str = ""
|
|
entity_label: str = ""
|
|
|
|
|
|
class PinCreate(BaseModel):
|
|
lat: float = Field(..., ge=-90, le=90)
|
|
lng: float = Field(..., ge=-180, le=180)
|
|
label: str = Field(..., max_length=200)
|
|
category: str = "custom"
|
|
layer_id: str = ""
|
|
color: str = ""
|
|
description: str = ""
|
|
source: str = "user"
|
|
source_url: str = ""
|
|
confidence: float = 1.0
|
|
ttl_hours: float = 0
|
|
metadata: dict = Field(default_factory=dict)
|
|
entity_attachment: EntityAttachment | None = None
|
|
|
|
|
|
class PinBatchCreate(BaseModel):
|
|
pins: list[dict[str, Any]] = Field(..., max_length=200)
|
|
layer_id: str = ""
|
|
|
|
|
|
class PinUpdate(BaseModel):
|
|
label: str | None = Field(None, max_length=200)
|
|
description: str | None = Field(None, max_length=2000)
|
|
category: str | None = None
|
|
color: str | None = None
|
|
|
|
|
|
class PinCommentCreate(BaseModel):
|
|
text: str = Field(..., max_length=4000)
|
|
author: str = "user" # "user" | "agent" | "openclaw"
|
|
author_label: str = ""
|
|
reply_to: str = "" # parent comment id (optional)
|
|
|
|
|
|
class LayerCreate(BaseModel):
|
|
name: str = Field(..., max_length=100)
|
|
description: str = ""
|
|
source: str = "user"
|
|
color: str = ""
|
|
feed_url: str = ""
|
|
feed_interval: int = 300
|
|
|
|
|
|
class LayerUpdate(BaseModel):
|
|
name: str | None = None
|
|
description: str | None = None
|
|
visible: bool | None = None
|
|
color: str | None = None
|
|
feed_url: str | None = None
|
|
feed_interval: int | None = None
|
|
|
|
|
|
class InjectRequest(BaseModel):
|
|
layer: str
|
|
items: list[dict[str, Any]] = Field(..., max_length=200)
|
|
mode: str = "append" # "append" or "replace"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/api/ai/status", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def ai_status(request: Request):
|
|
"""Health check and capability overview for the AI Intel subsystem."""
|
|
from services.ai_pin_store import pin_count
|
|
|
|
counts = pin_count()
|
|
return {
|
|
"ok": True,
|
|
"service": "ShadowBroker AI Intel",
|
|
"version": "1.0.0",
|
|
"pin_count": sum(counts.values()),
|
|
"pin_categories": counts,
|
|
"capabilities": [
|
|
"pin_placement", "pin_batch", "satellite_imagery",
|
|
"news_near", "data_injection", "geojson_export",
|
|
],
|
|
"timestamp": time.time(),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pin CRUD
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/api/ai/pins", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def create_pin(request: Request, body: PinCreate):
|
|
"""Place a single AI Intel pin on the map."""
|
|
from services.ai_pin_store import create_pin as _create_pin
|
|
|
|
ea = body.entity_attachment.model_dump() if body.entity_attachment else None
|
|
pin = _create_pin(
|
|
lat=body.lat,
|
|
lng=body.lng,
|
|
label=body.label,
|
|
category=body.category,
|
|
layer_id=body.layer_id,
|
|
color=body.color,
|
|
description=body.description,
|
|
source=body.source,
|
|
source_url=body.source_url,
|
|
confidence=body.confidence,
|
|
ttl_hours=body.ttl_hours,
|
|
metadata=body.metadata,
|
|
entity_attachment=ea,
|
|
)
|
|
return {"ok": True, "pin": pin}
|
|
|
|
|
|
@router.post("/api/ai/pins/batch", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("20/minute")
|
|
async def create_pins_batch(request: Request, body: PinBatchCreate):
|
|
"""Place multiple AI Intel pins at once (max 100)."""
|
|
from services.ai_pin_store import create_pins_batch as _create_batch
|
|
|
|
pins = _create_batch(body.pins, default_layer_id=body.layer_id)
|
|
return {"ok": True, "created": len(pins), "pins": pins}
|
|
|
|
|
|
@router.get("/api/ai/pins", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def list_pins(
|
|
request: Request,
|
|
category: str = "",
|
|
source: str = "",
|
|
layer_id: str = "",
|
|
limit: int = Query(500, ge=1, le=2000),
|
|
):
|
|
"""List AI Intel pins with optional filters."""
|
|
from services.ai_pin_store import get_pins
|
|
|
|
pins = get_pins(category=category, source=source, layer_id=layer_id, limit=limit)
|
|
return {"ok": True, "count": len(pins), "pins": pins}
|
|
|
|
|
|
@router.get("/api/ai/pins/geojson", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def pins_geojson(request: Request, layer_id: str = ""):
|
|
"""Export all active AI Intel pins as GeoJSON for the map layer."""
|
|
from services.ai_pin_store import pins_as_geojson
|
|
|
|
return pins_as_geojson(layer_id=layer_id)
|
|
|
|
|
|
@router.get("/api/ai/pins/{pin_id}", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("120/minute")
|
|
async def get_pin_detail(request: Request, pin_id: str):
|
|
"""Return a single pin with its full comment thread."""
|
|
from services.ai_pin_store import get_pin
|
|
|
|
pin = get_pin(pin_id)
|
|
if not pin:
|
|
raise HTTPException(404, f"Pin '{pin_id}' not found")
|
|
return {"ok": True, "pin": pin}
|
|
|
|
|
|
@router.patch("/api/ai/pins/{pin_id}", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def patch_pin(request: Request, pin_id: str, body: PinUpdate):
|
|
"""Edit a pin's label, description, category, or color."""
|
|
from services.ai_pin_store import update_pin
|
|
|
|
updated = update_pin(
|
|
pin_id,
|
|
label=body.label,
|
|
description=body.description,
|
|
category=body.category,
|
|
color=body.color,
|
|
)
|
|
if not updated:
|
|
raise HTTPException(404, f"Pin '{pin_id}' not found")
|
|
return {"ok": True, "pin": updated}
|
|
|
|
|
|
@router.post("/api/ai/pins/{pin_id}/comments", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def post_pin_comment(request: Request, pin_id: str, body: PinCommentCreate):
|
|
"""Append a comment (or reply) to a pin's thread."""
|
|
from services.ai_pin_store import add_pin_comment
|
|
|
|
pin = add_pin_comment(
|
|
pin_id,
|
|
text=body.text,
|
|
author=body.author,
|
|
author_label=body.author_label,
|
|
reply_to=body.reply_to,
|
|
)
|
|
if not pin:
|
|
raise HTTPException(404, f"Pin '{pin_id}' not found or empty comment")
|
|
return {"ok": True, "pin": pin}
|
|
|
|
|
|
@router.delete(
|
|
"/api/ai/pins/{pin_id}/comments/{comment_id}",
|
|
dependencies=[Depends(require_openclaw_or_local)],
|
|
)
|
|
@limiter.limit("60/minute")
|
|
async def delete_pin_comment_route(request: Request, pin_id: str, comment_id: str):
|
|
"""Delete a single comment from a pin's thread."""
|
|
from services.ai_pin_store import delete_pin_comment
|
|
|
|
if delete_pin_comment(pin_id, comment_id):
|
|
return {"ok": True, "deleted": comment_id}
|
|
raise HTTPException(404, "Comment not found")
|
|
|
|
|
|
@router.delete("/api/ai/pins/{pin_id}", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def delete_pin(request: Request, pin_id: str):
|
|
"""Delete a single pin by ID."""
|
|
from services.ai_pin_store import delete_pin as _delete
|
|
|
|
if _delete(pin_id):
|
|
return {"ok": True, "deleted": pin_id}
|
|
raise HTTPException(404, f"Pin '{pin_id}' not found")
|
|
|
|
|
|
@router.delete("/api/ai/pins", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("10/minute")
|
|
async def clear_pins(
|
|
request: Request,
|
|
category: str = "",
|
|
source: str = "",
|
|
):
|
|
"""Clear pins — all, or filtered by category/source."""
|
|
from services.ai_pin_store import clear_pins as _clear
|
|
|
|
removed = _clear(category=category, source=source)
|
|
return {"ok": True, "removed": removed}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pin Layers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/api/ai/layers", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def api_create_layer(request: Request, body: LayerCreate):
|
|
"""Create a new pin layer."""
|
|
from services.ai_pin_store import create_layer as _create_layer
|
|
|
|
layer = _create_layer(
|
|
name=body.name,
|
|
description=body.description,
|
|
source=body.source,
|
|
color=body.color,
|
|
feed_url=body.feed_url,
|
|
feed_interval=body.feed_interval,
|
|
)
|
|
return {"ok": True, "layer": layer}
|
|
|
|
|
|
@router.get("/api/ai/layers", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def api_list_layers(request: Request):
|
|
"""List all pin layers with pin counts."""
|
|
from services.ai_pin_store import get_layers as _get_layers
|
|
|
|
layers = _get_layers()
|
|
return {"ok": True, "count": len(layers), "layers": layers}
|
|
|
|
|
|
@router.patch("/api/ai/layers/{layer_id}", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def api_update_layer(request: Request, layer_id: str, body: LayerUpdate):
|
|
"""Update a pin layer (name, visibility, color, etc.)."""
|
|
from services.ai_pin_store import update_layer as _update_layer
|
|
|
|
updates = body.model_dump(exclude_none=True)
|
|
result = _update_layer(layer_id, **updates)
|
|
if result is None:
|
|
raise HTTPException(404, f"Layer '{layer_id}' not found")
|
|
return {"ok": True, "layer": result}
|
|
|
|
|
|
@router.delete("/api/ai/layers/{layer_id}", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("10/minute")
|
|
async def api_delete_layer(request: Request, layer_id: str):
|
|
"""Delete a layer and all its pins."""
|
|
from services.ai_pin_store import delete_layer as _delete_layer
|
|
|
|
removed = _delete_layer(layer_id)
|
|
return {"ok": True, "layer_id": layer_id, "pins_removed": removed}
|
|
|
|
|
|
@router.post("/api/ai/layers/{layer_id}/refresh", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("10/minute")
|
|
async def api_refresh_layer_feed(request: Request, layer_id: str):
|
|
"""Manually trigger a feed refresh for a layer."""
|
|
from services.ai_pin_store import get_layers as _get_layers
|
|
|
|
layers = _get_layers()
|
|
target = next((l for l in layers if l["id"] == layer_id), None)
|
|
if target is None:
|
|
raise HTTPException(404, f"Layer '{layer_id}' not found")
|
|
if not target.get("feed_url"):
|
|
raise HTTPException(400, "Layer has no feed URL")
|
|
|
|
from services.feed_ingester import _fetch_layer_feed
|
|
_fetch_layer_feed(target)
|
|
|
|
# Re-fetch to get updated pin count
|
|
updated_layers = _get_layers()
|
|
updated = next((l for l in updated_layers if l["id"] == layer_id), target)
|
|
return {"ok": True, "layer": updated}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent Actions endpoint — frontend polls this for UI commands from the agent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/api/ai/agent-actions", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("120/minute")
|
|
async def get_agent_actions(request: Request):
|
|
"""Frontend polls for pending agent display actions (destructive read).
|
|
|
|
Local operator access is required because polling destructively drains
|
|
the shared operator action queue.
|
|
"""
|
|
actions = pop_agent_actions()
|
|
return {"ok": True, "actions": actions}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Satellite Imagery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/api/ai/satellite-images", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("20/minute")
|
|
async def ai_satellite_images(
|
|
request: Request,
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
lng: float = Query(..., ge=-180, le=180),
|
|
count: int = Query(3, ge=1, le=5),
|
|
):
|
|
"""Fetch latest Sentinel-2 satellite imagery for a coordinate.
|
|
Uses Microsoft Planetary Computer STAC API (free, no key needed).
|
|
Falls back to Esri World Imagery if Planetary Computer is unavailable."""
|
|
import requests as req
|
|
from datetime import datetime, timedelta
|
|
|
|
results = []
|
|
end = datetime.utcnow()
|
|
start = end - timedelta(days=60)
|
|
|
|
search_payload = {
|
|
"collections": ["sentinel-2-l2a"],
|
|
"intersects": {"type": "Point", "coordinates": [lng, lat]},
|
|
"datetime": f"{start.isoformat()}Z/{end.isoformat()}Z",
|
|
"sortby": [{"field": "datetime", "direction": "desc"}],
|
|
"limit": count,
|
|
"query": {"eo:cloud_cover": {"lt": 30}},
|
|
}
|
|
|
|
def _do_stac_search() -> list[dict]:
|
|
"""Run STAC search + SAS signing in a single worker thread."""
|
|
|
|
def _sign_href(href: str) -> str:
|
|
"""Sign a Planetary Computer asset URL with a short-lived SAS token."""
|
|
if not href or "blob.core.windows.net" not in href:
|
|
return href
|
|
try:
|
|
# Extract storage account name for token request
|
|
account = href.split(".blob.core.windows.net")[0].split("//")[-1]
|
|
token_resp = req.get(
|
|
f"https://planetarycomputer.microsoft.com/api/sas/v1/token/{account}",
|
|
timeout=5,
|
|
)
|
|
token_resp.raise_for_status()
|
|
token = token_resp.json().get("token", "")
|
|
sep = "&" if "?" in href else "?"
|
|
return f"{href}{sep}{token}" if token else href
|
|
except Exception:
|
|
return href
|
|
|
|
resp = req.post(
|
|
"https://planetarycomputer.microsoft.com/api/stac/v1/search",
|
|
json=search_payload,
|
|
timeout=10,
|
|
headers={"User-Agent": _ai_intel_user_agent()},
|
|
)
|
|
resp.raise_for_status()
|
|
features = resp.json().get("features", [])
|
|
|
|
out: list[dict] = []
|
|
for item in features[:count]:
|
|
assets = item.get("assets", {})
|
|
rendered = assets.get("rendered_preview", {})
|
|
thumbnail = assets.get("thumbnail", {})
|
|
props = item.get("properties", {})
|
|
|
|
thumb_href = _sign_href(thumbnail.get("href", "") or rendered.get("href", ""))
|
|
full_href = _sign_href(rendered.get("href", "") or thumbnail.get("href", ""))
|
|
|
|
out.append({
|
|
"scene_id": item.get("id"),
|
|
"datetime": props.get("datetime"),
|
|
"cloud_cover": props.get("eo:cloud_cover"),
|
|
"platform": props.get("platform", "Sentinel-2"),
|
|
"thumbnail_url": thumb_href,
|
|
"fullres_url": full_href,
|
|
"bbox": item.get("bbox"),
|
|
})
|
|
return out
|
|
|
|
try:
|
|
results = await asyncio.to_thread(_do_stac_search)
|
|
except Exception as e:
|
|
logger.warning(f"Sentinel-2 STAC search failed: {e}")
|
|
# Fallback to Esri World Imagery
|
|
from services.sentinel_search import _esri_imagery_fallback
|
|
fallback = _esri_imagery_fallback(lat, lng)
|
|
results = [fallback]
|
|
|
|
return {
|
|
"ok": True,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"scenes": results,
|
|
"count": len(results),
|
|
"source": "Microsoft Planetary Computer / Sentinel-2 L2A",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# News Near (GDELT + news by proximity)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _haversine_miles(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
"""Great-circle distance in miles using the Haversine formula."""
|
|
R = 3958.8 # Earth radius in miles
|
|
dlat = math.radians(lat2 - lat1)
|
|
dlng = math.radians(lng2 - lng1)
|
|
a = (math.sin(dlat / 2) ** 2 +
|
|
math.cos(math.radians(lat1)) *
|
|
math.cos(math.radians(lat2)) *
|
|
math.sin(dlng / 2) ** 2)
|
|
return R * 2 * math.asin(math.sqrt(a))
|
|
|
|
|
|
@router.get("/api/ai/news-near", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def ai_news_near(
|
|
request: Request,
|
|
lat: float = Query(..., ge=-90, le=90),
|
|
lng: float = Query(..., ge=-180, le=180),
|
|
radius: float = Query(500, ge=10, le=5000),
|
|
):
|
|
"""Get GDELT incidents and news articles near a coordinate.
|
|
Returns headlines with source URLs, filtered by proximity."""
|
|
from services.fetchers._store import latest_data
|
|
|
|
# Filter GDELT incidents
|
|
gdelt_results = []
|
|
for incident in (latest_data.get("gdelt") or []):
|
|
coords = incident.get("geometry", {}).get("coordinates", [])
|
|
if len(coords) >= 2:
|
|
try:
|
|
dist = _haversine_miles(lat, lng, coords[1], coords[0])
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if dist <= radius:
|
|
props = incident.get("properties", {})
|
|
gdelt_results.append({
|
|
"name": props.get("name", "Unknown"),
|
|
"count": props.get("count", 1),
|
|
"urls": props.get("_urls_list", []),
|
|
"headlines": props.get("_headlines_list", []),
|
|
"lat": coords[1],
|
|
"lng": coords[0],
|
|
"distance_miles": round(dist, 1),
|
|
})
|
|
gdelt_results.sort(key=lambda x: -x["count"])
|
|
|
|
# Filter news articles
|
|
news_results = []
|
|
for article in (latest_data.get("news") or []):
|
|
a_lat = article.get("lat")
|
|
a_lng = article.get("lng")
|
|
if a_lat is not None and a_lng is not None:
|
|
try:
|
|
dist = _haversine_miles(lat, lng, float(a_lat), float(a_lng))
|
|
except (ValueError, TypeError):
|
|
continue
|
|
if dist <= radius:
|
|
news_results.append({
|
|
"title": article.get("title", ""),
|
|
"summary": article.get("summary", ""),
|
|
"source": article.get("source", ""),
|
|
"link": article.get("link", ""),
|
|
"risk_score": article.get("risk_score", 0),
|
|
"lat": float(a_lat),
|
|
"lng": float(a_lng),
|
|
"distance_miles": round(dist, 1),
|
|
})
|
|
news_results.sort(key=lambda x: -(x.get("risk_score") or 0))
|
|
|
|
return {
|
|
"ok": True,
|
|
"center": {"lat": lat, "lng": lng},
|
|
"radius_miles": radius,
|
|
"gdelt": gdelt_results[:20],
|
|
"gdelt_count": len(gdelt_results),
|
|
"news": news_results[:10],
|
|
"news_count": len(news_results),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Native Layer Data Injection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
INJECTABLE_LAYERS = {
|
|
"cctv", "ships", "sigint", "kiwisdr",
|
|
"military_bases", "datacenters", "power_plants",
|
|
"satnogs_stations", "volcanoes", "earthquakes",
|
|
"news", "viirs_change_nodes", "air_quality",
|
|
}
|
|
|
|
|
|
@router.post("/api/ai/inject", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def inject_data(request: Request, body: InjectRequest):
|
|
"""Inject custom data into ANY native ShadowBroker layer.
|
|
Items appear as real telemetry alongside automated feeds.
|
|
Tagged with _source='user:openclaw' so they can be filtered/removed."""
|
|
from services.fetchers._store import latest_data, _data_lock, bump_data_version
|
|
|
|
if body.layer not in INJECTABLE_LAYERS:
|
|
raise HTTPException(400, f"Layer '{body.layer}' is not injectable. "
|
|
f"Valid layers: {sorted(INJECTABLE_LAYERS)}")
|
|
|
|
now = time.time()
|
|
items = body.items[:200] # cap at 200
|
|
|
|
# Tag every injected item
|
|
for item in items:
|
|
item["_injected"] = True
|
|
item["_source"] = "user:openclaw"
|
|
item["_injected_at"] = now
|
|
|
|
with _data_lock:
|
|
existing = list(latest_data.get(body.layer) or [])
|
|
if body.mode == "replace":
|
|
existing = [x for x in existing if not x.get("_injected")]
|
|
existing.extend(items)
|
|
latest_data[body.layer] = existing
|
|
bump_data_version()
|
|
|
|
total = len(latest_data.get(body.layer, []))
|
|
return {
|
|
"ok": True,
|
|
"layer": body.layer,
|
|
"injected": len(items),
|
|
"total": total,
|
|
}
|
|
|
|
|
|
@router.delete("/api/ai/inject", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("10/minute")
|
|
async def remove_injected(
|
|
request: Request,
|
|
layer: str = "",
|
|
):
|
|
"""Remove user-injected data from native layers."""
|
|
from services.fetchers._store import latest_data, _data_lock, bump_data_version
|
|
|
|
removed = 0
|
|
with _data_lock:
|
|
if layer:
|
|
if layer not in INJECTABLE_LAYERS:
|
|
raise HTTPException(400, f"Layer '{layer}' is not injectable")
|
|
existing = list(latest_data.get(layer) or [])
|
|
cleaned = [x for x in existing if not x.get("_injected")]
|
|
removed = len(existing) - len(cleaned)
|
|
latest_data[layer] = cleaned
|
|
else:
|
|
for key in INJECTABLE_LAYERS:
|
|
existing = list(latest_data.get(key) or [])
|
|
cleaned = [x for x in existing if not x.get("_injected")]
|
|
removed += len(existing) - len(cleaned)
|
|
latest_data[key] = cleaned
|
|
if removed:
|
|
bump_data_version()
|
|
|
|
return {"ok": True, "removed": removed, "layer": layer or "all"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Intelligence Report Generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/api/ai/report", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("10/minute")
|
|
async def generate_report(request: Request):
|
|
"""Generate a full intelligence report from current telemetry.
|
|
Returns a structured markdown-style report suitable for export."""
|
|
from services.fetchers._store import latest_data
|
|
from services.ai_pin_store import pin_count, get_pins
|
|
from datetime import datetime
|
|
|
|
now = datetime.utcnow()
|
|
pins = get_pins(limit=500)
|
|
counts = pin_count()
|
|
|
|
# Gather stats
|
|
mil_flights = latest_data.get("military_flights", [])
|
|
ships = latest_data.get("ships", [])
|
|
tracked = latest_data.get("tracked_flights", [])
|
|
earthquakes = latest_data.get("earthquakes", [])
|
|
gdelt = latest_data.get("gdelt", [])
|
|
correlations = latest_data.get("correlations", [])
|
|
sigint_totals = latest_data.get("sigint_totals", {})
|
|
|
|
report = {
|
|
"ok": True,
|
|
"generated_at": now.isoformat() + "Z",
|
|
"title": f"ShadowBroker Intelligence Report — {now.strftime('%Y-%m-%d %H:%M')} UTC",
|
|
"summary": {
|
|
"military_flights": len(mil_flights),
|
|
"tracked_aircraft": len(tracked),
|
|
"ships": len(ships),
|
|
"earthquakes": len(earthquakes),
|
|
"gdelt_events": len(gdelt),
|
|
"correlations": len(correlations),
|
|
"ai_pins": sum(counts.values()),
|
|
"sigint": sigint_totals,
|
|
},
|
|
"top_military": [
|
|
{
|
|
"callsign": f.get("callsign"),
|
|
"type": f.get("type"),
|
|
"lat": f.get("lat"),
|
|
"lon": f.get("lon"),
|
|
"altitude": f.get("altitude"),
|
|
}
|
|
for f in mil_flights[:10]
|
|
],
|
|
"top_correlations": [
|
|
{
|
|
"type": c.get("type"),
|
|
"description": c.get("description"),
|
|
"severity": c.get("severity"),
|
|
}
|
|
for c in correlations[:5]
|
|
],
|
|
"recent_earthquakes": [
|
|
{
|
|
"magnitude": q.get("magnitude"),
|
|
"place": q.get("place"),
|
|
"lat": q.get("lat"),
|
|
"lng": q.get("lng"),
|
|
}
|
|
for q in sorted(
|
|
earthquakes, key=lambda x: -(x.get("magnitude") or 0),
|
|
)[:5]
|
|
],
|
|
"ai_pin_summary": counts,
|
|
}
|
|
|
|
return report
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Telemetry summary (lightweight — for AI to quickly assess the state)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/api/ai/summary", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def ai_telemetry_summary(request: Request):
|
|
"""Lightweight telemetry summary — counts and top entities.
|
|
Designed for OpenClaw to quickly assess the state without fetching full data."""
|
|
from services.fetchers._store import latest_data
|
|
from services.telemetry import get_telemetry_summary
|
|
|
|
summary = get_telemetry_summary()
|
|
return {
|
|
"ok": True,
|
|
"timestamp": time.time(),
|
|
"counts": summary.get("counts", {}),
|
|
"available_layers": summary.get("available_layers", []),
|
|
"non_empty_layers": summary.get("non_empty_layers", []),
|
|
"layer_aliases": summary.get("layer_aliases", {}),
|
|
"sigint_totals": latest_data.get("sigint_totals", {}),
|
|
"version": summary.get("version"),
|
|
"last_updated": summary.get("last_updated"),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Time Machine — Telemetry Snapshots (Hybrid: full + compressed_v1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
import copy
|
|
import gzip
|
|
import json
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from threading import Lock as TMLock
|
|
|
|
# Snapshot storage
|
|
_snapshots: list[dict] = []
|
|
_snapshots_lock = TMLock()
|
|
_snapshot_max = 1000 # max retained snapshots
|
|
|
|
# Configuration
|
|
_timemachine_config: dict = {
|
|
"profiles": {
|
|
"high_freq": {
|
|
"interval_minutes": 15,
|
|
"layers": [
|
|
"military_flights", "ships", "satellites",
|
|
"tracked_flights", "private_jets",
|
|
],
|
|
"max_snapshots": 672,
|
|
},
|
|
"standard": {
|
|
"interval_minutes": 120,
|
|
"layers": [
|
|
"gdelt", "news", "earthquakes", "weather_alerts",
|
|
"sigint", "gps_jamming", "correlations",
|
|
"liveuamap", "firms_fires",
|
|
],
|
|
"max_snapshots": 84,
|
|
},
|
|
},
|
|
"preset": "active",
|
|
"presets": {
|
|
"paranoid": {"high_freq": 5, "standard": 30},
|
|
"active": {"high_freq": 15, "standard": 120},
|
|
"casual": {"high_freq": 60, "standard": 360},
|
|
"minimal": {"high_freq": 360, "standard": 0},
|
|
},
|
|
}
|
|
|
|
# Persistence path
|
|
_TM_DIR = Path(os.environ.get("SB_DATA_DIR", ".")) / "timemachine"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layer compressors — keep only positional + identity data per entity type.
|
|
# Reduces snapshot size ~80-90% vs full deep-copy while retaining enough
|
|
# to render entities on the map.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _round(v, n=3):
|
|
"""Safely round a numeric value. Default 3 decimal places (~111 m)."""
|
|
try:
|
|
return round(float(v), n) if v is not None else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _strip_none(d: dict) -> dict:
|
|
"""Return a copy of *d* with all None-valued keys removed."""
|
|
return {k: v for k, v in d.items() if v is not None}
|
|
|
|
|
|
LAYER_COMPRESSORS: dict[str, callable] = {
|
|
"military_flights": lambda e: _strip_none({
|
|
"cs": e.get("callsign"), "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "alt": e.get("alt"),
|
|
"hdg": e.get("heading"), "t": e.get("type"), "ic": e.get("icao24"),
|
|
}),
|
|
"tracked_flights": lambda e: _strip_none({
|
|
"cs": e.get("callsign"), "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "alt": e.get("alt"),
|
|
"hdg": e.get("heading"), "ic": e.get("icao24"),
|
|
"reg": e.get("registration"),
|
|
}),
|
|
"private_jets": lambda e: _strip_none({
|
|
"cs": e.get("callsign"), "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "alt": e.get("alt"),
|
|
"hdg": e.get("heading"), "ic": e.get("icao24"),
|
|
"owner": (e.get("owner") or "")[:60] or None,
|
|
}),
|
|
"commercial_flights": lambda e: _strip_none({
|
|
"cs": e.get("callsign"), "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "alt": e.get("alt"),
|
|
"hdg": e.get("heading"),
|
|
}),
|
|
"ships": lambda e: _strip_none({
|
|
"mmsi": e.get("mmsi"), "nm": (e.get("name") or "")[:40] or None,
|
|
"lat": _round(e.get("lat")), "lng": _round(e.get("lng")),
|
|
"hdg": e.get("heading"), "st": e.get("ship_type"),
|
|
}),
|
|
"satellites": lambda e: _strip_none({
|
|
"id": e.get("id") or e.get("norad_id"), "nm": (e.get("name") or "")[:40] or None,
|
|
"lat": _round(e.get("lat")), "lng": _round(e.get("lng")),
|
|
"alt": _round(e.get("alt"), 1),
|
|
}),
|
|
"news": lambda e: _strip_none({
|
|
"t": (e.get("title") or "")[:80] or None, "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "rs": e.get("risk_score"),
|
|
"pd": e.get("pub_date"), "src": (e.get("source") or "")[:30] or None,
|
|
}),
|
|
"earthquakes": lambda e: _strip_none({
|
|
"id": e.get("id"), "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "mag": e.get("magnitude"),
|
|
"t": (e.get("title") or "")[:60] or None,
|
|
}),
|
|
"weather_alerts": lambda e: _strip_none({
|
|
"id": e.get("id"), "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "sev": e.get("severity"),
|
|
"ev": (e.get("event") or "")[:40] or None,
|
|
}),
|
|
"firms_fires": lambda e: _strip_none({
|
|
"lat": _round(e.get("lat")), "lng": _round(e.get("lng")),
|
|
"frp": e.get("frp"), "conf": e.get("confidence"),
|
|
}),
|
|
"crowdthreat": lambda e: _strip_none({
|
|
"id": e.get("id"), "lat": _round(e.get("lat")),
|
|
"lng": _round(e.get("lng")), "t": (e.get("title") or "")[:60] or None,
|
|
"cat": e.get("category"),
|
|
}),
|
|
"sigint": lambda e: {
|
|
# sigint is a dict of sub-arrays; pass through keys
|
|
k: [_strip_none({"lat": _round(i.get("lat")), "lng": _round(i.get("lng")),
|
|
"cs": i.get("callsign") or i.get("call") or i.get("id")})
|
|
for i in (v if isinstance(v, list) else [])]
|
|
for k, v in (e.items() if isinstance(e, dict) else [])
|
|
},
|
|
}
|
|
|
|
|
|
def _compress_entity(layer: str, entity) -> dict | None:
|
|
"""Compress a single entity using the layer's compressor, or a generic fallback."""
|
|
if not isinstance(entity, dict):
|
|
return None
|
|
compressor = LAYER_COMPRESSORS.get(layer)
|
|
if compressor:
|
|
return compressor(entity)
|
|
# Generic fallback: keep lat/lng + id/name
|
|
return _strip_none({
|
|
"id": entity.get("id"),
|
|
"lat": _round(entity.get("lat")),
|
|
"lng": _round(entity.get("lng")),
|
|
"nm": (entity.get("name") or entity.get("title") or "")[:60] or None,
|
|
})
|
|
|
|
|
|
def _compress_layer_data(layer: str, data) -> list | dict:
|
|
"""Compress an entire layer's data."""
|
|
# sigint is a dict of sub-arrays, handle specially
|
|
if layer == "sigint" and isinstance(data, dict):
|
|
compressor = LAYER_COMPRESSORS.get("sigint")
|
|
if compressor:
|
|
return compressor(data)
|
|
return data
|
|
if isinstance(data, list):
|
|
compressed = []
|
|
for entity in data:
|
|
c = _compress_entity(layer, entity)
|
|
if c is not None:
|
|
compressed.append(c)
|
|
return compressed
|
|
# Scalar or unknown shape — return as-is
|
|
return data
|
|
|
|
|
|
def _load_snapshots():
|
|
"""Load snapshots from disk on startup.
|
|
|
|
Reads gzipped format first (``snapshots.json.gz``). Falls back to the
|
|
legacy uncompressed ``snapshots.json`` if the gzip file doesn't exist,
|
|
then migrates the data to gzip on next save.
|
|
"""
|
|
global _snapshots
|
|
gz_file = _TM_DIR / "snapshots.json.gz"
|
|
legacy_file = _TM_DIR / "snapshots.json"
|
|
|
|
if gz_file.exists():
|
|
try:
|
|
with gzip.open(gz_file, "rt", encoding="utf-8") as f:
|
|
_snapshots = json.load(f)
|
|
logger.info("Time Machine: loaded %d snapshots from %s", len(_snapshots), gz_file)
|
|
return
|
|
except Exception as e:
|
|
logger.warning("Time Machine: failed to load gzip snapshots: %s", e)
|
|
|
|
if legacy_file.exists():
|
|
try:
|
|
with open(legacy_file, "r") as f:
|
|
_snapshots = json.load(f)
|
|
logger.info(
|
|
"Time Machine: loaded %d snapshots from legacy %s (will migrate to gzip on next save)",
|
|
len(_snapshots), legacy_file,
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Time Machine: failed to load legacy snapshots: %s", e)
|
|
|
|
|
|
def _save_snapshots_to_disk():
|
|
"""Persist snapshots to disk as gzip-compressed JSON.
|
|
|
|
JSON compresses ~90% with gzip, cutting ~68 MB/day to ~5-8 MB/day.
|
|
Also removes the legacy uncompressed file if it exists.
|
|
"""
|
|
try:
|
|
_TM_DIR.mkdir(parents=True, exist_ok=True)
|
|
gz_file = _TM_DIR / "snapshots.json.gz"
|
|
data = json.dumps(_snapshots[-_snapshot_max:], separators=(",", ":")).encode("utf-8")
|
|
with gzip.open(gz_file, "wb", compresslevel=6) as f:
|
|
f.write(data)
|
|
# Remove legacy uncompressed file after successful gzip write
|
|
legacy_file = _TM_DIR / "snapshots.json"
|
|
if legacy_file.exists():
|
|
try:
|
|
legacy_file.unlink()
|
|
logger.info("Time Machine: migrated to gzip, removed legacy snapshots.json")
|
|
except Exception:
|
|
pass # not critical
|
|
except Exception as e:
|
|
logger.warning("Time Machine: failed to save snapshots: %s", e)
|
|
|
|
|
|
# Load on import
|
|
_load_snapshots()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Core snapshot logic (callable from API, scheduler, and OpenClaw)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _take_snapshot_internal(
|
|
layers: list[str] | None = None,
|
|
profile: str = "manual",
|
|
compress: bool = False,
|
|
) -> dict:
|
|
"""Take a snapshot of current telemetry data.
|
|
|
|
Args:
|
|
layers: Specific layers to capture. None = all configured layers.
|
|
profile: Label for the snapshot (manual, auto_high_freq, auto_standard, openclaw).
|
|
compress: If True, use compressed_v1 format (positions + IDs only).
|
|
|
|
Returns:
|
|
Snapshot metadata dict (without the full data payload).
|
|
"""
|
|
from services.fetchers._store import latest_data, _data_lock
|
|
|
|
requested_layers = list(layers) if layers else []
|
|
if not requested_layers:
|
|
for prof in _timemachine_config["profiles"].values():
|
|
requested_layers.extend(prof["layers"])
|
|
requested_layers = list(set(requested_layers))
|
|
|
|
now = datetime.utcnow()
|
|
snapshot_data = {}
|
|
with _data_lock:
|
|
for layer in requested_layers:
|
|
val = latest_data.get(layer)
|
|
if val is not None:
|
|
if compress:
|
|
snapshot_data[layer] = _compress_layer_data(layer, val)
|
|
elif isinstance(val, (list, dict)):
|
|
snapshot_data[layer] = copy.deepcopy(val)
|
|
else:
|
|
snapshot_data[layer] = val
|
|
|
|
snapshot_id = f"snap-{now.strftime('%Y%m%d-%H%M%S')}-{len(_snapshots)}"
|
|
snapshot = {
|
|
"id": snapshot_id,
|
|
"timestamp": now.isoformat() + "Z",
|
|
"unix_ts": now.timestamp(),
|
|
"format": "compressed_v1" if compress else "full",
|
|
"layers": list(snapshot_data.keys()),
|
|
"layer_counts": {k: len(v) if isinstance(v, list) else 1 for k, v in snapshot_data.items()},
|
|
"profile": profile,
|
|
"data": snapshot_data,
|
|
}
|
|
|
|
with _snapshots_lock:
|
|
_snapshots.append(snapshot)
|
|
if len(_snapshots) > _snapshot_max:
|
|
_snapshots[:] = _snapshots[-_snapshot_max:]
|
|
|
|
_save_snapshots_to_disk()
|
|
|
|
return {
|
|
"ok": True,
|
|
"snapshot_id": snapshot_id,
|
|
"timestamp": snapshot["timestamp"],
|
|
"format": snapshot["format"],
|
|
"layers": snapshot["layers"],
|
|
"layer_counts": snapshot["layer_counts"],
|
|
}
|
|
|
|
|
|
@router.post("/api/ai/timemachine/snapshot", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def take_snapshot(request: Request, body: dict = None):
|
|
"""Take a snapshot of current telemetry.
|
|
Optional body: {"layers": [...], "profile": "...", "compress": true}"""
|
|
body = body or {}
|
|
result = await asyncio.to_thread(
|
|
_take_snapshot_internal,
|
|
layers=body.get("layers"),
|
|
profile=body.get("profile", "manual"),
|
|
compress=body.get("compress", False),
|
|
)
|
|
return result
|
|
|
|
|
|
@router.get("/api/ai/timemachine/snapshots", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def list_snapshots(
|
|
request: Request,
|
|
layer: str = "",
|
|
since: float = 0,
|
|
until: float = 0,
|
|
limit: int = Query(20, ge=1, le=100),
|
|
):
|
|
"""List available snapshots, optionally filtered by layer and time range.
|
|
Returns metadata only (no full data) for fast listing."""
|
|
results = []
|
|
with _snapshots_lock:
|
|
for snap in reversed(_snapshots):
|
|
# Time filters
|
|
ts = snap.get("unix_ts", 0)
|
|
if since and ts < since:
|
|
continue
|
|
if until and ts > until:
|
|
continue
|
|
# Layer filter
|
|
if layer and layer not in snap.get("layers", []):
|
|
continue
|
|
|
|
results.append({
|
|
"id": snap["id"],
|
|
"timestamp": snap["timestamp"],
|
|
"unix_ts": snap.get("unix_ts"),
|
|
"format": snap.get("format", "full"),
|
|
"layers": snap["layers"],
|
|
"layer_counts": snap["layer_counts"],
|
|
"profile": snap.get("profile"),
|
|
})
|
|
if len(results) >= limit:
|
|
break
|
|
|
|
return {"ok": True, "count": len(results), "snapshots": results}
|
|
|
|
|
|
@router.get("/api/ai/timemachine/snapshot/{snapshot_id}", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def get_snapshot(
|
|
request: Request,
|
|
snapshot_id: str,
|
|
layer: str = "",
|
|
):
|
|
"""Retrieve a specific snapshot's full data.
|
|
Optional layer filter returns only that layer's data."""
|
|
with _snapshots_lock:
|
|
for snap in _snapshots:
|
|
if snap["id"] == snapshot_id:
|
|
if layer:
|
|
data = snap.get("data", {}).get(layer)
|
|
if data is None:
|
|
raise HTTPException(404, f"Layer '{layer}' not in snapshot")
|
|
return {
|
|
"ok": True,
|
|
"snapshot_id": snapshot_id,
|
|
"timestamp": snap["timestamp"],
|
|
"layer": layer,
|
|
"count": len(data) if isinstance(data, list) else 1,
|
|
"data": data,
|
|
}
|
|
return {
|
|
"ok": True,
|
|
"snapshot_id": snapshot_id,
|
|
"timestamp": snap["timestamp"],
|
|
"layers": snap["layers"],
|
|
"layer_counts": snap["layer_counts"],
|
|
"data": snap.get("data", {}),
|
|
}
|
|
raise HTTPException(404, f"Snapshot '{snapshot_id}' not found")
|
|
|
|
|
|
@router.get("/api/ai/timemachine/hourly-index", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def timemachine_hourly_index(request: Request):
|
|
"""Return snapshot availability per hour for the last 24h.
|
|
Used by the TimelineScrubber to show which bins are clickable."""
|
|
now = datetime.utcnow()
|
|
cutoff = now.timestamp() - 24 * 3600
|
|
|
|
hours: dict[int, dict] = {}
|
|
with _snapshots_lock:
|
|
for snap in reversed(_snapshots):
|
|
ts = snap.get("unix_ts", 0)
|
|
if ts < cutoff:
|
|
continue
|
|
snap_dt = datetime.utcfromtimestamp(ts)
|
|
h = snap_dt.hour
|
|
if h not in hours:
|
|
hours[h] = {
|
|
"count": 0,
|
|
"latest_id": snap["id"],
|
|
"latest_ts": snap["timestamp"],
|
|
"snapshot_ids": [],
|
|
}
|
|
hours[h]["count"] += 1
|
|
hours[h]["snapshot_ids"].append(snap["id"])
|
|
|
|
return {"ok": True, "hours": hours}
|
|
|
|
|
|
@router.get("/api/ai/timemachine/playback/{snapshot_id}", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def timemachine_playback(request: Request, snapshot_id: str):
|
|
"""Load a snapshot's data in the same shape as /api/live-data/* for map rendering.
|
|
Compressed snapshots are expanded with sensible defaults so map components
|
|
can render them without modification."""
|
|
with _snapshots_lock:
|
|
target = None
|
|
for snap in _snapshots:
|
|
if snap["id"] == snapshot_id:
|
|
target = snap
|
|
break
|
|
if target is None:
|
|
raise HTTPException(404, f"Snapshot '{snapshot_id}' not found")
|
|
|
|
snap_format = target.get("format", "full")
|
|
data = target.get("data", {})
|
|
|
|
# For compressed snapshots, expand shortened keys back to full field names
|
|
if snap_format == "compressed_v1":
|
|
expanded = {}
|
|
for layer, items in data.items():
|
|
if isinstance(items, list):
|
|
expanded[layer] = [_expand_compressed_entity(layer, e) for e in items]
|
|
else:
|
|
expanded[layer] = items
|
|
data = expanded
|
|
|
|
return {
|
|
"ok": True,
|
|
"snapshot_id": target["id"],
|
|
"timestamp": target["timestamp"],
|
|
"unix_ts": target.get("unix_ts"),
|
|
"format": snap_format,
|
|
"mode": "playback",
|
|
"layers": target["layers"],
|
|
"layer_counts": target["layer_counts"],
|
|
"data": data,
|
|
}
|
|
|
|
|
|
# Expansion maps: compressed short keys → full field names expected by frontend
|
|
_EXPAND_MAPS: dict[str, dict[str, str]] = {
|
|
"military_flights": {"cs": "callsign", "lng": "lng", "lat": "lat", "alt": "alt", "hdg": "heading", "t": "type", "ic": "icao24"},
|
|
"tracked_flights": {"cs": "callsign", "lng": "lng", "lat": "lat", "alt": "alt", "hdg": "heading", "ic": "icao24", "reg": "registration"},
|
|
"private_jets": {"cs": "callsign", "lng": "lng", "lat": "lat", "alt": "alt", "hdg": "heading", "ic": "icao24", "owner": "owner"},
|
|
"commercial_flights": {"cs": "callsign", "lng": "lng", "lat": "lat", "alt": "alt", "hdg": "heading"},
|
|
"ships": {"mmsi": "mmsi", "nm": "name", "lng": "lng", "lat": "lat", "hdg": "heading", "st": "ship_type"},
|
|
"satellites": {"id": "id", "nm": "name", "lng": "lng", "lat": "lat", "alt": "alt"},
|
|
"news": {"t": "title", "lng": "lng", "lat": "lat", "rs": "risk_score", "pd": "pub_date", "src": "source"},
|
|
"earthquakes": {"id": "id", "lng": "lng", "lat": "lat", "mag": "magnitude", "t": "title"},
|
|
"weather_alerts": {"id": "id", "lng": "lng", "lat": "lat", "sev": "severity", "ev": "event"},
|
|
"firms_fires": {"lng": "lng", "lat": "lat", "frp": "frp", "conf": "confidence"},
|
|
"crowdthreat": {"id": "id", "lng": "lng", "lat": "lat", "t": "title", "cat": "category"},
|
|
}
|
|
|
|
|
|
def _expand_compressed_entity(layer: str, entity: dict) -> dict:
|
|
"""Expand a compressed entity back to full field names."""
|
|
expand_map = _EXPAND_MAPS.get(layer)
|
|
if not expand_map:
|
|
return entity
|
|
expanded = {}
|
|
for short_key, val in entity.items():
|
|
full_key = expand_map.get(short_key, short_key)
|
|
expanded[full_key] = val
|
|
return expanded
|
|
|
|
|
|
@router.delete("/api/ai/timemachine/snapshots", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("10/minute")
|
|
async def clear_snapshots(
|
|
request: Request,
|
|
before: float = 0,
|
|
):
|
|
"""Clear snapshots. If 'before' unix timestamp provided, only clears older ones."""
|
|
with _snapshots_lock:
|
|
if before:
|
|
original = len(_snapshots)
|
|
_snapshots[:] = [s for s in _snapshots if s.get("unix_ts", 0) >= before]
|
|
removed = original - len(_snapshots)
|
|
else:
|
|
removed = len(_snapshots)
|
|
_snapshots.clear()
|
|
|
|
await asyncio.to_thread(_save_snapshots_to_disk)
|
|
return {"ok": True, "removed": removed, "remaining": len(_snapshots)}
|
|
|
|
|
|
@router.get("/api/ai/timemachine/config", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def get_timemachine_config(request: Request):
|
|
"""Get current Time Machine configuration."""
|
|
return {"ok": True, "config": _timemachine_config}
|
|
|
|
|
|
@router.put("/api/ai/timemachine/config", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("10/minute")
|
|
async def update_timemachine_config(request: Request, body: dict):
|
|
"""Update Time Machine configuration.
|
|
Can set a preset ("paranoid", "active", "casual", "minimal")
|
|
or customize individual profile intervals and layers."""
|
|
preset = body.get("preset")
|
|
if preset and preset in _timemachine_config["presets"]:
|
|
intervals = _timemachine_config["presets"][preset]
|
|
_timemachine_config["profiles"]["high_freq"]["interval_minutes"] = intervals["high_freq"]
|
|
_timemachine_config["profiles"]["standard"]["interval_minutes"] = intervals["standard"]
|
|
_timemachine_config["preset"] = preset
|
|
|
|
# Custom profile overrides
|
|
if "high_freq" in body:
|
|
for k, v in body["high_freq"].items():
|
|
if k in _timemachine_config["profiles"]["high_freq"]:
|
|
_timemachine_config["profiles"]["high_freq"][k] = v
|
|
if "standard" in body:
|
|
for k, v in body["standard"].items():
|
|
if k in _timemachine_config["profiles"]["standard"]:
|
|
_timemachine_config["profiles"]["standard"][k] = v
|
|
|
|
return {"ok": True, "config": _timemachine_config}
|
|
|
|
|
|
@router.get("/api/ai/timemachine/diff", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def diff_snapshots(
|
|
request: Request,
|
|
snapshot_a: str = Query(..., description="Earlier snapshot ID"),
|
|
snapshot_b: str = Query(..., description="Later snapshot ID"),
|
|
layer: str = Query(..., description="Layer to compare"),
|
|
):
|
|
"""Compare two snapshots and return what changed in a specific layer.
|
|
Returns added, removed, and count changes."""
|
|
snap_a_data = None
|
|
snap_b_data = None
|
|
|
|
with _snapshots_lock:
|
|
for snap in _snapshots:
|
|
if snap["id"] == snapshot_a:
|
|
snap_a_data = snap.get("data", {}).get(layer, [])
|
|
if snap["id"] == snapshot_b:
|
|
snap_b_data = snap.get("data", {}).get(layer, [])
|
|
|
|
if snap_a_data is None:
|
|
raise HTTPException(404, f"Snapshot '{snapshot_a}' not found or missing layer '{layer}'")
|
|
if snap_b_data is None:
|
|
raise HTTPException(404, f"Snapshot '{snapshot_b}' not found or missing layer '{layer}'")
|
|
|
|
# Simple count diff
|
|
count_a = len(snap_a_data) if isinstance(snap_a_data, list) else 0
|
|
count_b = len(snap_b_data) if isinstance(snap_b_data, list) else 0
|
|
|
|
return {
|
|
"ok": True,
|
|
"snapshot_a": snapshot_a,
|
|
"snapshot_b": snapshot_b,
|
|
"layer": layer,
|
|
"count_a": count_a,
|
|
"count_b": count_b,
|
|
"delta": count_b - count_a,
|
|
"summary": f"{layer}: {count_a} → {count_b} ({'+' if count_b >= count_a else ''}{count_b - count_a})",
|
|
}
|
|
|
|
|
|
# ───────────────────────── AI NEWS SUMMARY ────────────────────────
|
|
@router.get("/ai/news/summary")
|
|
@limiter.limit("10/minute")
|
|
async def ai_news_summary(request: Request):
|
|
"""Return a structured AI-generated summary of current news articles.
|
|
Works without an LLM — extracts top stories, regional breakdown,
|
|
threat distribution, and trending keywords from the raw feed."""
|
|
from collections import Counter
|
|
|
|
news = _latest_data.get("news", [])
|
|
if not news:
|
|
return {
|
|
"ok": True,
|
|
"article_count": 0,
|
|
"summary": "No news articles currently available.",
|
|
"top_stories": [],
|
|
"regions": {},
|
|
"threat_distribution": {},
|
|
"keywords": [],
|
|
}
|
|
|
|
# Top stories (highest risk score)
|
|
sorted_news = sorted(news, key=lambda a: a.get("risk_score", 0), reverse=True)
|
|
top_stories = []
|
|
for article in sorted_news[:8]:
|
|
top_stories.append({
|
|
"title": article.get("title", "Untitled"),
|
|
"source": article.get("source", "Unknown"),
|
|
"risk_score": article.get("risk_score", 0),
|
|
"sentiment": article.get("sentiment"),
|
|
"link": article.get("link", ""),
|
|
"published": article.get("published", ""),
|
|
})
|
|
|
|
# Regional breakdown
|
|
region_counter: Counter = Counter()
|
|
for article in news:
|
|
src = str(article.get("source", "Unknown")).strip()
|
|
if src:
|
|
region_counter[src] += 1
|
|
|
|
# Threat distribution
|
|
threat_dist: dict = {"CRITICAL": 0, "HIGH": 0, "ELEVATED": 0, "MODERATE": 0, "LOW": 0}
|
|
for article in news:
|
|
score = article.get("risk_score", 0)
|
|
if score >= 9:
|
|
threat_dist["CRITICAL"] += 1
|
|
elif score >= 7:
|
|
threat_dist["HIGH"] += 1
|
|
elif score >= 5:
|
|
threat_dist["ELEVATED"] += 1
|
|
elif score >= 3:
|
|
threat_dist["MODERATE"] += 1
|
|
else:
|
|
threat_dist["LOW"] += 1
|
|
|
|
# Keyword extraction (simple word frequency from titles)
|
|
stop_words = {
|
|
"the", "a", "an", "in", "on", "at", "to", "for", "of", "and",
|
|
"is", "are", "was", "were", "be", "been", "has", "had", "have",
|
|
"with", "by", "from", "as", "it", "its", "this", "that", "or",
|
|
"but", "not", "no", "will", "can", "may", "would", "could",
|
|
"should", "do", "does", "did", "he", "she", "they", "we", "you",
|
|
"i", "me", "my", "our", "your", "their", "his", "her", "us",
|
|
"up", "out", "if", "about", "into", "over", "after", "new",
|
|
"says", "said", "also", "more", "than", "just", "been", "being",
|
|
}
|
|
word_counter: Counter = Counter()
|
|
for article in news:
|
|
title = str(article.get("title", ""))
|
|
words = title.lower().split()
|
|
for word in words:
|
|
clean = word.strip(".,!?:;\"'()[]{}–—-")
|
|
if len(clean) > 2 and clean not in stop_words:
|
|
word_counter[clean] += 1
|
|
|
|
trending = [{"word": w, "count": c} for w, c in word_counter.most_common(15)]
|
|
|
|
# Build plain-text summary
|
|
breaking_count = sum(1 for a in news if a.get("breaking"))
|
|
avg_risk = sum(a.get("risk_score", 0) for a in news) / len(news) if news else 0
|
|
summary_lines = [
|
|
f"📊 {len(news)} articles tracked across {len(region_counter)} sources.",
|
|
f"⚡ {breaking_count} BREAKING articles." if breaking_count else "",
|
|
f"🎯 Average threat score: {avg_risk:.1f}/10.",
|
|
f"🔴 {threat_dist['CRITICAL']} critical, {threat_dist['HIGH']} high-threat articles.",
|
|
]
|
|
summary = " ".join(line for line in summary_lines if line)
|
|
|
|
return {
|
|
"ok": True,
|
|
"article_count": len(news),
|
|
"breaking_count": breaking_count,
|
|
"avg_risk_score": round(avg_risk, 2),
|
|
"summary": summary,
|
|
"top_stories": top_stories,
|
|
"regions": dict(region_counter.most_common(20)),
|
|
"threat_distribution": threat_dist,
|
|
"keywords": trending,
|
|
}
|
|
|
|
|
|
# ───────────────────────── CORRELATION EXPLANATIONS ────────────────────────
|
|
_CORR_TYPE_LABELS = {
|
|
"rf_anomaly": "RF ANOMALY — Electromagnetic Interference Detected",
|
|
"military_buildup": "MILITARY BUILDUP — Force Concentration Alert",
|
|
"infra_cascade": "INFRASTRUCTURE CASCADE — Multi-System Failure",
|
|
}
|
|
|
|
_CORR_TYPE_IMPLICATIONS = {
|
|
"rf_anomaly": [
|
|
"Active GPS/GNSS jamming or spoofing is occurring in this zone.",
|
|
"Civilian aviation may be affected — pilots should cross-check inertial navigation.",
|
|
"Electronic warfare (EW) operations may be underway.",
|
|
"Simultaneous internet outages suggest coordinated infrastructure targeting.",
|
|
],
|
|
"military_buildup": [
|
|
"Concentration of military assets indicates potential operational staging.",
|
|
"GDELT conflict events corroborate elevated tensions in this zone.",
|
|
"Naval and air assets co-located suggests multi-domain readiness posture.",
|
|
"Monitor for NOTAM closures or TFRs that may confirm military activity.",
|
|
],
|
|
"infra_cascade": [
|
|
"Internet outages are disrupting SIGINT-grade radio monitoring (KiwiSDR).",
|
|
"Loss of KiwiSDR receivers reduces ally HF intelligence collection capability.",
|
|
"May indicate power grid failure, cable cuts, or deliberate network isolation.",
|
|
"Correlate with regional news for civil unrest or natural disaster reports.",
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/ai/correlations/explain")
|
|
@limiter.limit("10/minute")
|
|
async def ai_correlation_explanations(request: Request):
|
|
"""Return structured intelligence explanations for each active correlation alert.
|
|
Works without an LLM — generates explanations from pre-built templates and data."""
|
|
|
|
correlations = _latest_data.get("correlations", [])
|
|
if not correlations:
|
|
return {
|
|
"ok": True,
|
|
"count": 0,
|
|
"explanations": [],
|
|
"summary": "No cross-layer correlations are currently active.",
|
|
}
|
|
|
|
explanations = []
|
|
for i, corr in enumerate(correlations):
|
|
ctype = corr.get("type", "unknown")
|
|
sev = corr.get("severity", "low")
|
|
score = corr.get("score", 0)
|
|
drivers = corr.get("drivers", [])
|
|
lat = corr.get("lat", 0)
|
|
lng = corr.get("lng", 0)
|
|
|
|
label = _CORR_TYPE_LABELS.get(ctype, f"UNKNOWN CORRELATION — {ctype}")
|
|
implications = _CORR_TYPE_IMPLICATIONS.get(ctype, [
|
|
"Unknown correlation type — manual analysis recommended.",
|
|
])
|
|
|
|
# Generate driver analysis
|
|
driver_text = " | ".join(drivers) if drivers else "No driver data"
|
|
|
|
# Severity assessment
|
|
if sev == "high":
|
|
sev_text = "⚠️ HIGH — Immediate attention required"
|
|
action = "Deploy monitoring assets and establish continuous watch."
|
|
elif sev == "medium":
|
|
sev_text = "🟡 MEDIUM — Elevated concern"
|
|
action = "Increase polling frequency and flag for analyst review."
|
|
else:
|
|
sev_text = "🟢 LOW — Awareness level"
|
|
action = "Log for trend analysis and continue standard monitoring."
|
|
|
|
explanations.append({
|
|
"index": i,
|
|
"type": ctype,
|
|
"label": label,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"severity": sev,
|
|
"severity_text": sev_text,
|
|
"score": score,
|
|
"drivers": drivers,
|
|
"driver_summary": driver_text,
|
|
"implications": implications[:3],
|
|
"recommended_action": action,
|
|
"explanation": (
|
|
f"{label}\n"
|
|
f"Location: {lat:.2f}°, {lng:.2f}°\n"
|
|
f"Severity: {sev_text}\n"
|
|
f"Indicators: {driver_text}\n"
|
|
f"Assessment: {implications[0]}\n"
|
|
f"Action: {action}"
|
|
),
|
|
})
|
|
|
|
# Build overall summary
|
|
by_type: dict = {}
|
|
for e in explanations:
|
|
by_type.setdefault(e["type"], []).append(e)
|
|
|
|
type_summaries = []
|
|
for ctype, items in by_type.items():
|
|
high = sum(1 for x in items if x["severity"] == "high")
|
|
med = sum(1 for x in items if x["severity"] == "medium")
|
|
label = _CORR_TYPE_LABELS.get(ctype, ctype).split("—")[0].strip()
|
|
type_summaries.append(f"{label}: {len(items)} alerts ({high} high, {med} medium)")
|
|
|
|
summary = (
|
|
f"🌍📡 CORRELATION ANALYSIS: {len(correlations)} active cross-layer alerts. "
|
|
+ " | ".join(type_summaries)
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"count": len(explanations),
|
|
"explanations": explanations,
|
|
"by_type": {k: len(v) for k, v in by_type.items()},
|
|
"summary": summary,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent Tool Manifest — machine-readable tool definitions for LLM agents
|
|
# ---------------------------------------------------------------------------
|
|
# Any LLM agent (OpenClaw, custom, etc.) hits this endpoint once on connect
|
|
# and loads the result as its tool definitions. No manual reading required.
|
|
|
|
@router.get("/api/ai/tools", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def agent_tool_manifest(request: Request):
|
|
"""Return structured tool definitions that an LLM agent can load directly.
|
|
|
|
This is the machine-readable equivalent of /api/ai/capabilities.
|
|
An agent loads these as its available tools on first connect.
|
|
Each tool has: name, description, parameters (with types), and examples.
|
|
"""
|
|
from services.openclaw_channel import READ_COMMANDS, WRITE_COMMANDS
|
|
from services.config import get_settings
|
|
|
|
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
|
available_commands = sorted(READ_COMMANDS | WRITE_COMMANDS) if access_tier == "full" else sorted(READ_COMMANDS)
|
|
|
|
return {
|
|
"ok": True,
|
|
"version": "0.9.8",
|
|
"access_tier": access_tier,
|
|
"available_commands": available_commands,
|
|
"transport": {
|
|
"commands": "POST /api/ai/channel/command body: {\"cmd\": \"<tool_name>\", \"args\": {<params>}}",
|
|
"batch": "POST /api/ai/channel/batch body: {\"commands\": [{\"cmd\": \"...\", \"args\": {...}}, ...]} (max 20, concurrent execution, one HTTP round-trip)",
|
|
"realtime_stream": "GET /api/ai/channel/sse (Server-Sent Events — keeps Tor circuit warm, receives push events)",
|
|
"auth": "HMAC-SHA256 headers: X-SB-Timestamp, X-SB-Nonce, X-SB-Signature. Sign: HMAC(key, METHOD|path|ts|nonce|sha256(body))",
|
|
},
|
|
"tools": [
|
|
# ── Read Tools ────────────────────────────────────
|
|
{
|
|
"name": "get_summary",
|
|
"type": "read",
|
|
"description": "Get counts of all live fast-tier and slow-tier telemetry layers, plus available layer names and common aliases. Use this first for layer discovery before pulling datasets.",
|
|
"parameters": {},
|
|
"returns": "{counts: {...}, available_layers: [...], non_empty_layers: [...], layer_aliases: {...}, last_updated, version}",
|
|
},
|
|
{
|
|
"name": "get_layer_slice",
|
|
"type": "read",
|
|
"description": "Get only specific top-level telemetry layers, with optional version gating so unchanged reads return empty. Accepts friendly aliases like gfw/global_fishing_watch → fishing_activity and uap/ufo → uap_sightings. Layer slices are uncapped unless you pass a positive limit_per_layer.",
|
|
"parameters": {
|
|
"layers": {"type": "array", "required": True, "description": "Requested top-level layer names, e.g. ['tracked_flights', 'ships', 'news']"},
|
|
"limit_per_layer": {"type": "integer", "required": False, "description": "Optional positive cap per layer. Omit or pass 0/negative for the full layer."},
|
|
"since_version": {"type": "integer", "required": False, "description": "If equal to current server version, response returns changed=false with empty layers"},
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, layers are returned in compressed_v1 schema (short keys, 3-decimal lat/lng, None-stripped). Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "{version: int, changed: bool, layers: {...}, requested_layers: [...], missing_layers: [...], available_layers: [...], truncated: {...}}",
|
|
},
|
|
{
|
|
"name": "find_flights",
|
|
"type": "read",
|
|
"description": "Search flights server-side by callsign, registration, ICAO24, owner/operator, or free-text query. Returns a compact result set instead of the full flight snapshot.",
|
|
"parameters": {
|
|
"query": {"type": "string", "required": False, "description": "Free-text match across callsign, registration, owner, operator, type"},
|
|
"callsign": {"type": "string", "required": False, "description": "Exact/partial callsign filter"},
|
|
"registration": {"type": "string", "required": False, "description": "Tail number filter"},
|
|
"icao24": {"type": "string", "required": False, "description": "ICAO24 hex id filter"},
|
|
"owner": {"type": "string", "required": False, "description": "Owner/operator/person filter"},
|
|
"categories": {"type": "array", "required": False, "description": "Flight layers to search: tracked, military, jets, private, commercial"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max results (default 25, max 100)"},
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, strips empty/None fields from each result and rounds lat/lng to 3 decimals. Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "{results: [{source_layer, callsign, registration, icao24, owner, type, lat, lng, ...}], version: int, truncated: bool}",
|
|
},
|
|
{
|
|
"name": "find_ships",
|
|
"type": "read",
|
|
"description": "Search ships server-side by MMSI, IMO, name, yacht-owner enrichment, or free-text query. Returns only compact ship matches.",
|
|
"parameters": {
|
|
"query": {"type": "string", "required": False, "description": "Free-text match across ship name, MMSI, IMO, callsign, type, yacht owner, tracked yacht name"},
|
|
"mmsi": {"type": "string", "required": False, "description": "Exact MMSI filter"},
|
|
"imo": {"type": "string", "required": False, "description": "Exact IMO filter"},
|
|
"name": {"type": "string", "required": False, "description": "Ship name filter"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max results (default 25, max 100)"},
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, strips empty/None fields from each result and rounds lat/lng to 3 decimals. Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "{results: [{mmsi, imo, name, owner, tracked_name, tracked_category, callsign, type, lat, lng, ...}], version: int, truncated: bool}",
|
|
},
|
|
{
|
|
"name": "find_entity",
|
|
"type": "read",
|
|
"description": "Resolve a plane, ship, person, operator, callsign, registration, MMSI, IMO, ICAO24, or named entity with exact aircraft/ship matching first and universal telemetry search second. Use this before tracking a named entity.",
|
|
"parameters": {
|
|
"query": {"type": "string", "required": False, "description": "Natural-language name, operator, owner, callsign, vessel name, or entity label"},
|
|
"entity_type": {"type": "string", "required": False, "description": "Optional type hint: aircraft, plane, ship, vessel, maritime, person, infrastructure, event"},
|
|
"callsign": {"type": "string", "required": False, "description": "Aircraft or vessel callsign"},
|
|
"registration": {"type": "string", "required": False, "description": "Aircraft tail number / registration"},
|
|
"icao24": {"type": "string", "required": False, "description": "Aircraft ICAO24 hex identifier"},
|
|
"mmsi": {"type": "string", "required": False, "description": "Ship MMSI"},
|
|
"imo": {"type": "string", "required": False, "description": "Ship IMO number"},
|
|
"name": {"type": "string", "required": False, "description": "Known aircraft/vessel/entity name"},
|
|
"owner": {"type": "string", "required": False, "description": "Owner, operator, yacht owner, or alert_operator"},
|
|
"layers": {"type": "array", "required": False, "description": "Optional telemetry layer subset for universal fallback search"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max results (default 10, max 50)"},
|
|
},
|
|
"returns": "{best_match: {...}|null, results: [{source_layer, entity_type, label, id, callsign, registration, icao24, mmsi, imo, owner, lat, lng, score, confidence}], searched_layers: [...], strategy: [...]}",
|
|
"example": {"cmd": "find_entity", "args": {"entity_type": "aircraft", "callsign": "AF1", "owner": "USAF"}},
|
|
},
|
|
{
|
|
"name": "correlate_entity",
|
|
"type": "read",
|
|
"description": "Resolve an entity exactly, then build a compact evidence pack around its current position: nearby tracked entities, active correlations, SAR anomalies, outages, weather/RF hazards, and nearby reporting. This is a lead generator, not a causation verdict.",
|
|
"parameters": {
|
|
"query": {"type": "string", "required": False, "description": "Natural-language name, operator, owner, callsign, vessel name, or entity label"},
|
|
"entity_type": {"type": "string", "required": False, "description": "Optional type hint: aircraft, ship, person, event, infrastructure"},
|
|
"callsign": {"type": "string", "required": False, "description": "Aircraft or vessel callsign"},
|
|
"registration": {"type": "string", "required": False, "description": "Aircraft tail number / registration"},
|
|
"icao24": {"type": "string", "required": False, "description": "Aircraft ICAO24 hex identifier"},
|
|
"mmsi": {"type": "string", "required": False, "description": "Ship MMSI"},
|
|
"imo": {"type": "string", "required": False, "description": "Ship IMO number"},
|
|
"name": {"type": "string", "required": False, "description": "Known aircraft/vessel/entity name"},
|
|
"owner": {"type": "string", "required": False, "description": "Owner, operator, yacht owner, or alert_operator"},
|
|
"radius_km": {"type": "float", "required": False, "description": "Context radius in km (default 100, max 1000)"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max records per evidence group (default 10, max 50)"},
|
|
},
|
|
"returns": "{status, claim_level, entity, center, radius_km, signals, evidence: {proximate_entities, context_layers}, recommended_next}",
|
|
"example": {"cmd": "correlate_entity", "args": {"entity_type": "aircraft", "callsign": "AF1", "radius_km": 150}},
|
|
},
|
|
{
|
|
"name": "search_telemetry",
|
|
"type": "read",
|
|
"description": "Universal compact search across telemetry layers. Use this when you know what you are looking for but not which layer holds it.",
|
|
"parameters": {
|
|
"query": {"type": "string", "required": True, "description": "Keyword, person, vessel, place, protest topic, owner, callsign, etc."},
|
|
"layers": {"type": "array", "required": False, "description": "Optional layer subset to constrain search. Omit to search the full universal index across telemetry."},
|
|
"limit": {"type": "integer", "required": False, "description": "Max results (default 25, max 100)"},
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, strips empty/None fields from each result and rounds lat/lng to 3 decimals. Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "{results: [{source_layer, label, summary, type, id, lat, lng, time, score}], version: int, truncated: bool, searched_layers: [...]}",
|
|
},
|
|
{
|
|
"name": "search_news",
|
|
"type": "read",
|
|
"description": "Search news and event layers server-side by keyword. Includes news, GDELT, CrowdThreat, and major incident/event feeds without pulling the full slow telemetry feed.",
|
|
"parameters": {
|
|
"query": {"type": "string", "required": True, "description": "Keyword or phrase to search for"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max results (default 10, max 50)"},
|
|
"include_gdelt": {"type": "boolean", "required": False, "description": "Include GDELT matches (default true)"},
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, strips empty/None fields from each result and rounds lat/lng to 3 decimals. Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "{results: [{source_layer, title, summary, source, link, lat, lng, risk_score}], version: int, truncated: bool}",
|
|
},
|
|
{
|
|
"name": "entities_near",
|
|
"type": "read",
|
|
"description": "Run a proximity search around a coordinate across selected telemetry layers. Useful for 'what is near here?' without pulling whole datasets.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Center latitude"},
|
|
"lng": {"type": "float", "required": True, "description": "Center longitude"},
|
|
"radius_km": {"type": "float", "required": False, "description": "Search radius in km (default 50)"},
|
|
"entity_types": {"type": "array", "required": False, "description": "Layers to search: tracked, military, jets, private, commercial, ships, uavs, satellites, earthquakes, news"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max results (default 25, max 100)"},
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, strips empty/None fields from each result and rounds lat/lng to 3 decimals. Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "{results: [{source_layer, label, lat, lng, distance_km, type, id}], version: int, truncated: bool}",
|
|
},
|
|
{
|
|
"name": "brief_area",
|
|
"type": "read",
|
|
"description": "Compact area briefing around a coordinate: nearby entities, optional topic news, and selected context layers. Use instead of pulling full fast+slow telemetry for a location question.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Center latitude"},
|
|
"lng": {"type": "float", "required": True, "description": "Center longitude"},
|
|
"radius_km": {"type": "float", "required": False, "description": "Search radius in km (default 50)"},
|
|
"entity_types": {"type": "array", "required": False, "description": "Nearby entity layers/types to include, default aircraft and ships"},
|
|
"query": {"type": "string", "required": False, "description": "Optional topic/news keyword for the area brief"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max nearby entities (default 25)"},
|
|
"context_limit": {"type": "integer", "required": False, "description": "Max records from each context layer (default 10)"},
|
|
},
|
|
"returns": "{center, radius_km, nearby, topic_news, context_layers}",
|
|
},
|
|
{
|
|
"name": "what_changed",
|
|
"type": "read",
|
|
"description": "Incremental change helper. Without layers, returns summary/version metadata. With layers, returns only requested layer slices with since_version or since_layer_versions gating.",
|
|
"parameters": {
|
|
"layers": {"type": "array", "required": False, "description": "Optional top-level layers to check"},
|
|
"since_version": {"type": "integer", "required": False, "description": "Global version previously seen by the agent"},
|
|
"since_layer_versions": {"type": "object", "required": False, "description": "Per-layer versions previously seen by the agent"},
|
|
"limit_per_layer": {"type": "integer", "required": False, "description": "Optional cap per changed layer"},
|
|
"compact": {"type": "boolean", "required": False, "description": "Return compact layer payloads when true"},
|
|
},
|
|
"returns": "{version, changed, layers, layer_versions, requested_layers, truncated} or summary metadata",
|
|
},
|
|
{
|
|
"name": "get_telemetry",
|
|
"type": "read",
|
|
"description": "Get all fast-refresh telemetry data: flights (commercial, military, private, tracked/VIP), ships, satellites, sigint, CCTV, trains, GPS jamming, conflict zones. "
|
|
"The 'tracked_flights' array contains enriched VIP aircraft with alert_operator (person name), alert_category, alert_socials, alert_color. "
|
|
"This is the 'Tracked Aircraft — People' layer — it includes billionaires, politicians, military, etc. "
|
|
"Pass compact=true for a smaller compressed_v1 payload (~60-90% reduction) — parses faster for agents.",
|
|
"parameters": {
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, emit compressed_v1 schema (short keys like cs/ic/hdg/t, 3-decimal lat/lng, None-stripped). Same information, ~60-90% smaller. Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "Object with arrays: commercial_flights, military_flights, private_flights, private_jets, tracked_flights, ships, satellites, sigint, cctv, uavs, liveuamap, gps_jamming, trains",
|
|
},
|
|
{
|
|
"name": "get_slow_telemetry",
|
|
"type": "read",
|
|
"description": "Get slow-refresh data: news headlines, GDELT conflict events, prediction markets, earthquakes, weather, internet outages, military bases, power plants, volcanoes, fire hotspots, correlations, air quality. "
|
|
"Pass compact=true for a smaller compressed_v1 payload — parses faster for agents.",
|
|
"parameters": {
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, emit compressed_v1 schema (short keys, 3-decimal lat/lng, None-stripped). Same information, ~60-90% smaller. Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "Object with arrays/objects for each slow data source",
|
|
},
|
|
{
|
|
"name": "get_report",
|
|
"type": "read",
|
|
"description": "Get combined fast + slow telemetry in one call. Large response — use get_summary first, then targeted get_telemetry or get_slow_telemetry. "
|
|
"Pass compact=true to shrink both halves to compressed_v1 schema.",
|
|
"parameters": {
|
|
"compact": {"type": "boolean", "required": False, "description": "If true, both fast and slow halves emit compressed_v1 schema (short keys, 3-decimal lat/lng, None-stripped). Response includes format: 'compressed_v1'."},
|
|
},
|
|
"returns": "{fast: <telemetry>, slow: <slow_telemetry>}",
|
|
},
|
|
{
|
|
"name": "get_sigint_totals",
|
|
"type": "read",
|
|
"description": "Get signal intelligence counts: Meshtastic mesh nodes, APRS ham radio, JS8Call digital mode.",
|
|
"parameters": {},
|
|
"returns": "{meshtastic: N, aprs: N, js8call: N}",
|
|
},
|
|
{
|
|
"name": "get_prediction_markets",
|
|
"type": "read",
|
|
"description": "Get live prediction market data from Polymarket and Kalshi. Includes probabilities, volume, and event descriptions.",
|
|
"parameters": {},
|
|
"returns": "Array of market objects with title, probability, volume, source",
|
|
},
|
|
{
|
|
"name": "get_ai_pins",
|
|
"type": "read",
|
|
"description": "Get all intel pins currently placed on the map (by agents or operators).",
|
|
"parameters": {},
|
|
"returns": "Array of pin objects with id, lat, lng, label, category, description, source, created_at",
|
|
},
|
|
{
|
|
"name": "get_layers",
|
|
"type": "read",
|
|
"description": "Get all custom pin layers (groupings of pins).",
|
|
"parameters": {},
|
|
"returns": "Array of layer objects with id, name, color, pin_count",
|
|
},
|
|
{
|
|
"name": "get_correlations",
|
|
"type": "read",
|
|
"description": "Get cross-domain correlation alerts: infrastructure cascades, possible contradictions (official denials near outages), anomaly clusters.",
|
|
"parameters": {},
|
|
"returns": "Array of correlation alert objects with type, confidence, location, drivers, alternatives",
|
|
},
|
|
{
|
|
"name": "list_watches",
|
|
"type": "read",
|
|
"description": "List all active watchdog watches (aircraft tracking, geofences, keyword monitors, etc.).",
|
|
"parameters": {},
|
|
"returns": "Array of watch objects with id, type, params, created_at",
|
|
},
|
|
{
|
|
"name": "sar_status",
|
|
"type": "read",
|
|
"description": "Get SAR/OpenClaw integration status, catalog readiness, product fetch status, and private-tier publish requirement.",
|
|
"parameters": {},
|
|
"returns": "{catalog_enabled, products, require_private_tier}",
|
|
},
|
|
{
|
|
"name": "sar_anomalies_recent",
|
|
"type": "read",
|
|
"description": "List recent SAR anomalies, optionally filtered by anomaly kind.",
|
|
"parameters": {
|
|
"kind": {"type": "string", "required": False, "description": "Optional anomaly kind filter"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max anomalies (default 25)"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_anomalies_near",
|
|
"type": "read",
|
|
"description": "Find SAR anomalies near a coordinate.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Center latitude"},
|
|
"lng": {"type": "float", "required": True, "description": "Center longitude"},
|
|
"radius_km": {"type": "float", "required": False, "description": "Search radius in km (default 50)"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max anomalies (default 25)"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_scene_search",
|
|
"type": "read",
|
|
"description": "Search cached SAR scenes, optionally scoped to an AOI.",
|
|
"parameters": {
|
|
"aoi_id": {"type": "string", "required": False, "description": "AOI id to filter scenes"},
|
|
"limit": {"type": "integer", "required": False, "description": "Max scenes (default 25)"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_coverage_for_aoi",
|
|
"type": "read",
|
|
"description": "Return SAR coverage records for one AOI or all AOIs.",
|
|
"parameters": {
|
|
"aoi_id": {"type": "string", "required": False, "description": "AOI id to filter coverage"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_aoi_list",
|
|
"type": "read",
|
|
"description": "List configured SAR areas of interest.",
|
|
"parameters": {},
|
|
},
|
|
{
|
|
"name": "sar_pin_click",
|
|
"type": "read",
|
|
"description": "Return full detail for a SAR anomaly pin without screen scraping the UI popup.",
|
|
"parameters": {
|
|
"anomaly_id": {"type": "string", "required": True, "description": "SAR anomaly id"},
|
|
},
|
|
},
|
|
{
|
|
"name": "list_analysis_zones",
|
|
"type": "read",
|
|
"description": "List OpenClaw analysis zones currently shown on the map.",
|
|
"parameters": {},
|
|
"returns": "{zones: [...]}",
|
|
},
|
|
{
|
|
"name": "timemachine_list",
|
|
"type": "read",
|
|
"description": "List recent Time Machine snapshots available for playback.",
|
|
"parameters": {},
|
|
"returns": "{enabled: bool, count: int, snapshots: [{id, timestamp, format, layers, layer_counts}]}",
|
|
},
|
|
{
|
|
"name": "timemachine_config",
|
|
"type": "read",
|
|
"description": "Get Time Machine settings, enabled state, cadence, and storage notice.",
|
|
"parameters": {},
|
|
"returns": "{enabled: bool, interval_minutes: int, storage_notice: str, ...}",
|
|
},
|
|
{
|
|
"name": "channel_status",
|
|
"type": "read",
|
|
"description": "Get command channel health: queue sizes, access tier, uptime stats.",
|
|
"parameters": {},
|
|
"returns": "Object with channel health metrics",
|
|
},
|
|
# ── Write Tools ───────────────────────────────────
|
|
{
|
|
"name": "place_pin",
|
|
"type": "write",
|
|
"description": "Place an intel pin on the map. The pin is visible to the operator in the UI immediately.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Latitude"},
|
|
"lng": {"type": "float", "required": True, "description": "Longitude"},
|
|
"label": {"type": "string", "required": True, "description": "Pin label (short title)"},
|
|
"category": {"type": "string", "required": True, "description": "Pin category",
|
|
"enum": ["threat", "news", "geolocation", "custom", "anomaly", "military",
|
|
"maritime", "flight", "infrastructure", "weather", "sigint",
|
|
"prediction", "research"]},
|
|
"description": {"type": "string", "required": False, "description": "Detailed description"},
|
|
"source": {"type": "string", "required": False, "description": "Attribution (default: 'openclaw')"},
|
|
"layer_id": {"type": "string", "required": False, "description": "Assign to a specific layer"},
|
|
"color": {"type": "string", "required": False, "description": "Hex color override"},
|
|
},
|
|
"example": {"cmd": "place_pin", "args": {"lat": 35.6892, "lng": 51.389, "label": "Tehran Activity", "category": "research", "description": "Unusual satellite passes detected"}},
|
|
},
|
|
{
|
|
"name": "delete_pin",
|
|
"type": "write",
|
|
"description": "Remove a pin from the map by its ID.",
|
|
"parameters": {
|
|
"id": {"type": "string", "required": True, "description": "Pin ID to delete"},
|
|
},
|
|
},
|
|
{
|
|
"name": "create_layer",
|
|
"type": "write",
|
|
"description": "Create a new pin layer (group of pins with shared color/category).",
|
|
"parameters": {
|
|
"name": {"type": "string", "required": True, "description": "Layer name"},
|
|
"color": {"type": "string", "required": False, "description": "Layer color (hex)"},
|
|
"feed_url": {"type": "string", "required": False, "description": "RSS/Atom feed URL to auto-ingest pins from"},
|
|
},
|
|
},
|
|
{
|
|
"name": "delete_layer",
|
|
"type": "write",
|
|
"description": "Delete a layer and all its pins.",
|
|
"parameters": {
|
|
"id": {"type": "string", "required": True, "description": "Layer ID to delete"},
|
|
},
|
|
},
|
|
{
|
|
"name": "inject_data",
|
|
"type": "write",
|
|
"description": "Push custom data items into a layer. Items appear as pins on the map.",
|
|
"parameters": {
|
|
"layer": {"type": "string", "required": True, "description": "Layer ID to inject into"},
|
|
"items": {"type": "array", "required": True, "description": "Array of {lat, lng, label, description} objects"},
|
|
},
|
|
},
|
|
{
|
|
"name": "show_satellite",
|
|
"type": "write",
|
|
"description": "Display satellite imagery of a location fullscreen on the operator's map. Uses free Sentinel-2 data from Microsoft Planetary Computer. The image pops up centered on screen — same viewer as right-clicking the map.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Latitude of target location"},
|
|
"lng": {"type": "float", "required": True, "description": "Longitude of target location"},
|
|
"caption": {"type": "string", "required": False, "description": "Caption to display with the image"},
|
|
},
|
|
"example": {"cmd": "show_satellite", "args": {"lat": 35.6892, "lng": 51.389, "caption": "Tehran — latest Sentinel-2 pass"}},
|
|
},
|
|
{
|
|
"name": "show_sentinel",
|
|
"type": "write",
|
|
"description": "Display Sentinel Hub imagery with a specific analysis preset. Requires Copernicus CDSE credentials configured on the server. Falls back to free Sentinel-2 if not available.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Latitude of target location"},
|
|
"lng": {"type": "float", "required": True, "description": "Longitude of target location"},
|
|
"preset": {"type": "string", "required": False, "description": "Imagery preset (default: TRUE-COLOR)",
|
|
"enum": ["TRUE-COLOR", "FALSE-COLOR", "NDVI", "MOISTURE-INDEX"]},
|
|
"caption": {"type": "string", "required": False, "description": "Caption to display with the image"},
|
|
},
|
|
"example": {"cmd": "show_sentinel", "args": {"lat": 35.6892, "lng": 51.389, "preset": "NDVI", "caption": "Tehran vegetation index"}},
|
|
},
|
|
{
|
|
"name": "add_watch",
|
|
"type": "write",
|
|
"description": "Set up a watchdog alert. When triggered, alerts push instantly via SSE stream. Debounced: same watch won't re-fire within 60 seconds.",
|
|
"parameters": {
|
|
"type": {"type": "string", "required": True, "description": "Watch type",
|
|
"enum": ["track_aircraft", "track_callsign", "track_registration", "track_ship", "track_entity", "geofence", "keyword", "prediction_market"]},
|
|
"params": {"type": "object", "required": True, "description": "Type-specific parameters (see subtypes)"},
|
|
},
|
|
"subtypes": {
|
|
"track_aircraft": {"params": {"callsign": "string (optional)", "registration": "string (optional)", "icao24": "string (optional)", "owner": "string (optional)", "query": "string (optional)"}, "description": "Alert when a matching aircraft appears across split flight layers"},
|
|
"track_callsign": {"params": {"callsign": "string"}, "description": "Alert when aircraft with this callsign appears"},
|
|
"track_registration": {"params": {"registration": "string"}, "description": "Alert when aircraft with this tail number appears"},
|
|
"track_ship": {"params": {"mmsi": "string (optional)", "imo": "string (optional)", "name": "string (optional)", "owner": "string (optional)", "callsign": "string (optional)"}, "description": "Alert when ship appears by MMSI, IMO, name, owner, or callsign"},
|
|
"track_entity": {"params": {"query": "string", "entity_type": "string (optional)", "layers": "list (optional)"}, "description": "Generic exact-first entity tracker when aircraft/ship fields are not known yet"},
|
|
"geofence": {"params": {"lat": "float", "lng": "float", "radius_km": "float (default 50)", "entity_types": "list (default ['flights','ships'])"}, "description": "Alert when any entity enters a geographic zone"},
|
|
"keyword": {"params": {"keyword": "string"}, "description": "Alert when keyword appears in news/GDELT headlines"},
|
|
"prediction_market": {"params": {"query": "string", "threshold": "float 0-1 (optional)"}, "description": "Alert on prediction market movements matching query"},
|
|
},
|
|
"example": {"cmd": "add_watch", "args": {"type": "track_registration", "params": {"registration": "N3880"}}},
|
|
},
|
|
{
|
|
"name": "track_entity",
|
|
"type": "write",
|
|
"description": "Create the most precise non-hostile tracking watch for an entity. It resolves identifiers first, then installs track_aircraft, track_ship, or generic track_entity. If the entity is not visible now, it still creates a pending generic watch from the query.",
|
|
"parameters": {
|
|
"query": {"type": "string", "required": False, "description": "Name, owner, operator, callsign, vessel name, or entity label"},
|
|
"entity_type": {"type": "string", "required": False, "description": "Optional type hint: aircraft, ship, person, event, infrastructure"},
|
|
"callsign": {"type": "string", "required": False, "description": "Aircraft or vessel callsign"},
|
|
"registration": {"type": "string", "required": False, "description": "Aircraft registration / tail number"},
|
|
"icao24": {"type": "string", "required": False, "description": "Aircraft ICAO24 hex identifier"},
|
|
"mmsi": {"type": "string", "required": False, "description": "Ship MMSI"},
|
|
"imo": {"type": "string", "required": False, "description": "Ship IMO number"},
|
|
"name": {"type": "string", "required": False, "description": "Known vessel/entity name"},
|
|
"owner": {"type": "string", "required": False, "description": "Owner/operator/person"},
|
|
"layers": {"type": "array", "required": False, "description": "Optional fallback layers for generic tracking"},
|
|
},
|
|
"returns": "{watch, watch_type, initial_lookup}",
|
|
"example": {"cmd": "track_entity", "args": {"entity_type": "ship", "name": "BRAVO EUGENIA", "owner": "Jerry Jones"}},
|
|
},
|
|
{
|
|
"name": "watch_area",
|
|
"type": "write",
|
|
"description": "Create a geofence watch around a coordinate using sensible defaults for moving entities. Alerts arrive over SSE and poll fallback.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Center latitude"},
|
|
"lng": {"type": "float", "required": True, "description": "Center longitude"},
|
|
"radius_km": {"type": "float", "required": False, "description": "Geofence radius in km (default 50)"},
|
|
"entity_types": {"type": "array", "required": False, "description": "Entity types to watch, default ['aircraft', 'ships']"},
|
|
},
|
|
"returns": "Watch object",
|
|
},
|
|
{
|
|
"name": "remove_watch",
|
|
"type": "write",
|
|
"description": "Remove a watchdog watch by ID.",
|
|
"parameters": {
|
|
"id": {"type": "string", "required": True, "description": "Watch ID to remove"},
|
|
},
|
|
},
|
|
{
|
|
"name": "clear_watches",
|
|
"type": "write",
|
|
"description": "Remove all active watches.",
|
|
"parameters": {},
|
|
},
|
|
{
|
|
"name": "take_snapshot",
|
|
"type": "write",
|
|
"description": "Take a time-machine snapshot of current data state for later playback.",
|
|
"parameters": {
|
|
"layers": {"type": "array", "required": False, "description": "Specific layers to snapshot (default: all)"},
|
|
"compress": {"type": "boolean", "required": False, "description": "Compress snapshot (default: true)"},
|
|
},
|
|
},
|
|
{
|
|
"name": "timemachine_playback",
|
|
"type": "write",
|
|
"description": "Load a saved Time Machine snapshot for playback or offline analysis.",
|
|
"parameters": {
|
|
"snapshot_id": {"type": "string", "required": True, "description": "Snapshot ID to load"},
|
|
},
|
|
},
|
|
{
|
|
"name": "update_layer",
|
|
"type": "write",
|
|
"description": "Update an existing pin layer's metadata or visibility.",
|
|
"parameters": {
|
|
"layer_id": {"type": "string", "required": True, "description": "Layer ID to update"},
|
|
"name": {"type": "string", "required": False, "description": "New layer name"},
|
|
"description": {"type": "string", "required": False, "description": "Updated description"},
|
|
"visible": {"type": "boolean", "required": False, "description": "Set layer visibility"},
|
|
"color": {"type": "string", "required": False, "description": "Hex color"},
|
|
"feed_url": {"type": "string", "required": False, "description": "RSS/Atom feed URL"},
|
|
"feed_interval": {"type": "integer", "required": False, "description": "Feed poll interval in seconds"},
|
|
},
|
|
},
|
|
{
|
|
"name": "refresh_feed",
|
|
"type": "write",
|
|
"description": "Re-fetch a layer's RSS/Atom feed and update its pins.",
|
|
"parameters": {
|
|
"id": {"type": "string", "required": True, "description": "Layer ID with a feed_url configured"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_aoi_add",
|
|
"type": "write",
|
|
"description": "Add a SAR area of interest for catalog/coverage/anomaly workflows.",
|
|
"parameters": {
|
|
"id": {"type": "string", "required": True, "description": "Stable AOI id"},
|
|
"name": {"type": "string", "required": False, "description": "Human-readable AOI name"},
|
|
"center_lat": {"type": "float", "required": True, "description": "AOI center latitude"},
|
|
"center_lon": {"type": "float", "required": True, "description": "AOI center longitude"},
|
|
"radius_km": {"type": "float", "required": False, "description": "AOI radius in km"},
|
|
"priority": {"type": "integer", "required": False, "description": "AOI priority"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_aoi_remove",
|
|
"type": "write",
|
|
"description": "Remove a SAR area of interest.",
|
|
"parameters": {
|
|
"aoi_id": {"type": "string", "required": True, "description": "AOI id to remove"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_pin_from_anomaly",
|
|
"type": "write",
|
|
"description": "Create an intel pin from a SAR anomaly id.",
|
|
"parameters": {
|
|
"anomaly_id": {"type": "string", "required": True, "description": "SAR anomaly id"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_watch_anomaly",
|
|
"type": "write",
|
|
"description": "Create a watchdog alert for SAR anomalies, optionally scoped by AOI/kind/magnitude.",
|
|
"parameters": {
|
|
"aoi_id": {"type": "string", "required": False, "description": "AOI id to watch"},
|
|
"kind": {"type": "string", "required": False, "description": "Anomaly kind"},
|
|
"min_magnitude": {"type": "float", "required": False, "description": "Minimum anomaly magnitude"},
|
|
},
|
|
},
|
|
{
|
|
"name": "sar_focus_aoi",
|
|
"type": "write",
|
|
"description": "Move the operator map to a SAR AOI center and optionally open its details.",
|
|
"parameters": {
|
|
"aoi_id": {"type": "string", "required": True, "description": "AOI id"},
|
|
"zoom": {"type": "float", "required": False, "description": "Map zoom level"},
|
|
},
|
|
},
|
|
{
|
|
"name": "place_analysis_zone",
|
|
"type": "write",
|
|
"description": "Place an OpenClaw analysis zone overlay on the map.",
|
|
"parameters": {
|
|
"lat": {"type": "float", "required": True, "description": "Zone center latitude"},
|
|
"lng": {"type": "float", "required": True, "description": "Zone center longitude"},
|
|
"radius_km": {"type": "float", "required": False, "description": "Zone radius in km"},
|
|
"label": {"type": "string", "required": False, "description": "Zone label"},
|
|
"description": {"type": "string", "required": False, "description": "Zone notes"},
|
|
"color": {"type": "string", "required": False, "description": "Zone color"},
|
|
},
|
|
},
|
|
{
|
|
"name": "delete_analysis_zone",
|
|
"type": "write",
|
|
"description": "Delete an OpenClaw analysis zone.",
|
|
"parameters": {
|
|
"zone_id": {"type": "string", "required": True, "description": "Zone id"},
|
|
},
|
|
},
|
|
{
|
|
"name": "clear_analysis_zones",
|
|
"type": "write",
|
|
"description": "Clear OpenClaw-created analysis zones from the map.",
|
|
"parameters": {},
|
|
},
|
|
],
|
|
"sse_events": {
|
|
"description": "Events pushed via GET /api/ai/channel/sse — keep connection open to receive these in real-time.",
|
|
"events": {
|
|
"connected": "Sent on connect. Contains access_tier.",
|
|
"task": "Operator-pushed tasks (instructions, sync requests).",
|
|
"alert": "Watchdog alert fired (aircraft spotted, geofence breach, keyword hit, market move).",
|
|
"heartbeat": "Every 15s — keeps Tor circuit alive. Contains timestamp.",
|
|
},
|
|
},
|
|
"tips": [
|
|
"COMPACT MODE: Pass compact=true on ANY read command to get compressed_v1 responses — ~60-90% smaller payloads, faster parse, fewer tokens. Short keys (cs/ic/hdg/t), 3-decimal lat/lng, null-stripped. Use this by default unless you need verbose field names.",
|
|
"BATCH for speed: POST /api/ai/channel/batch with {\"commands\": [{cmd, args}, ...]} runs up to 20 commands concurrently in ONE HTTP round-trip. Use this whenever you need 2+ lookups — it eliminates round-trip latency.",
|
|
"BATCH + COMPACT: Combine both — {\"commands\": [{\"cmd\": \"find_flights\", \"args\": {\"query\": \"N189AM\", \"compact\": true}}, ...]} — for maximum speed.",
|
|
"INCREMENTAL polling: get_layer_slice accepts since_layer_versions (preferred) or since_version. Pass {layer: version} from the previous response's layer_versions field — only layers that actually changed are serialized. Combined with SSE layer_changed events, the agent knows exactly which layers to fetch.",
|
|
"Start with get_summary to understand data volume before pulling full datasets.",
|
|
"Prefer compact lookups first: search_telemetry, find_flights, find_ships, search_news, entities_near, get_layer_slice. Use get_telemetry/get_slow_telemetry/get_report only when focused commands are insufficient.",
|
|
"ShadowBroker does expose UAP sightings, wastewater, and tracked_flights/VIP aircraft when those layers are populated. Verify with get_summary or get_layer_slice before claiming a layer is absent.",
|
|
"ShadowBroker also exposes fishing_activity, which is the fishing-vessel activity layer backed by Global Fishing Watch data when GFW_API_TOKEN is configured. Do not confuse it with the AIS ships layer.",
|
|
"Use search_telemetry as the Google-style entry point whenever the user gives you a person, place, company, topic, owner, nickname, or natural-language phrase and you do not already know the source layer.",
|
|
"Example: for 'Where is Jerry Jones yacht?' search 'Jerry Jones' across all telemetry first, identify the ship match, then refine with find_ships or raw layer context only if needed.",
|
|
"For fuzzy natural-language lookups like 'Patriots jet' or 'Jerry Jones yacht', use search_telemetry first and inspect the ranked candidate list before making a hard claim.",
|
|
"search_telemetry returns ranked candidates grouped by entity type, so use the group list to narrow aircraft vs ships vs events before answering.",
|
|
"Example: for protests, facilities, or incident topics, search the phrase across telemetry first instead of guessing one layer and returning null.",
|
|
"For AF1/AF2 and other VIP aircraft, use find_flights first when the domain is obvious, then inspect tracked_flights via get_layer_slice if you need raw layer context.",
|
|
"If one domain-specific command returns no match, do not conclude the entity is absent. Fall back to search_telemetry before any broad layer pull.",
|
|
"If search_telemetry returns multiple plausible candidates, summarize the top matches instead of pretending one uncertain match is definitive.",
|
|
"tracked_flights contains VIP aircraft with person names, social links, and categories — this is the 'People' layer.",
|
|
"Use show_satellite to display imagery to the operator — it pops up fullscreen, no need for them to search manually.",
|
|
"Set up watches for persistent monitoring — alerts push instantly via SSE, no polling needed.",
|
|
"Single commands: POST /api/ai/channel/command with body {\"cmd\": \"name\", \"args\": {}}.",
|
|
"Multi-command: POST /api/ai/channel/batch with body {\"commands\": [...]} — faster than sequential single calls.",
|
|
"Open GET /api/ai/channel/sse once and keep it open — all alerts/tasks stream to you in real-time.",
|
|
],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API Discovery — lets the agent learn all available endpoints on first connect
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/api/ai/capabilities", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def api_capabilities(request: Request):
|
|
"""Return full API manifest so the agent knows every available endpoint."""
|
|
from services.openclaw_channel import READ_COMMANDS, WRITE_COMMANDS, detect_tier
|
|
from services.config import get_settings
|
|
tier = detect_tier()
|
|
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
|
return {
|
|
"ok": True,
|
|
"version": "0.9.8",
|
|
"auth": {
|
|
"method": "HMAC-SHA256",
|
|
"headers": ["X-SB-Timestamp", "X-SB-Nonce", "X-SB-Signature"],
|
|
"signature_format": "HMAC-SHA256(secret, METHOD|path|timestamp|nonce|sha256(body))",
|
|
"remote_agent_http_auth_identity": "shared_hmac_secret",
|
|
"agent_ed25519_identity_used_for_http_auth": False,
|
|
"agent_ed25519_identity_used_for_mesh_signing": True,
|
|
"notes": [
|
|
"The live OpenClaw HTTP channel authenticates possession of the shared HMAC secret, not a specific Ed25519 agent keypair.",
|
|
"If multiple callers know the HMAC secret, the backend treats them as the same remote OpenClaw trust principal.",
|
|
"The OpenClaw Ed25519/X25519 identity is used for mesh signing and future private-lane upgrades, not current HTTP command authentication.",
|
|
],
|
|
},
|
|
"trust_boundary": {
|
|
"remote_api_principal": "holder_of_openclaw_hmac_secret",
|
|
"operator_principal": "local_operator_or_admin_key_holder",
|
|
"access_tier": access_tier,
|
|
"transport_tier": tier,
|
|
"remote_route_surface": {
|
|
"auth_dependency": "require_openclaw_or_local",
|
|
"family": "/api/ai/*",
|
|
"notes": [
|
|
"Remote OpenClaw access is broader than /api/ai/channel/* and includes other AI Intel routes protected by require_openclaw_or_local.",
|
|
"The command allowlist still gates what the remote agent can invoke through /api/ai/channel/command and /api/ai/channel/batch.",
|
|
],
|
|
},
|
|
"durability": {
|
|
"command_queue": "memory_only",
|
|
"task_queue": "memory_only",
|
|
"watch_registry": "memory_only",
|
|
"notes": [
|
|
"Restarting the backend drops in-memory channel state, pending tasks, and watchdog watches.",
|
|
"This channel is currently a singleton process-local integration, not a multi-agent durable broker.",
|
|
],
|
|
},
|
|
},
|
|
"sse_channel": {
|
|
"description": "PREFERRED for Tor agents: Server-Sent Events stream. One long-lived HTTP GET "
|
|
"connection for real-time push. Works perfectly over Tor SOCKS5 (unlike WebSocket).",
|
|
"stream_endpoint": "GET /api/ai/channel/sse (long-lived, returns text/event-stream)",
|
|
"command_endpoint": "POST /api/ai/channel/command (send commands, same as HTTP channel)",
|
|
"auth": "Same HMAC auth as all other endpoints (X-SB-Timestamp, X-SB-Nonce, X-SB-Signature)",
|
|
"protocol": {
|
|
"events_from_server": {
|
|
"connected": 'event: connected\\ndata: {"access_tier": "...", "layer_versions": {"ships": 42, ...}}',
|
|
"layer_changed": 'event: layer_changed\\ndata: {"layers": {"ships": {"layer": "ships", "version": 43, "count": 1287}, ...}} (pushed on every data refresh — agent fetches only changed layers)',
|
|
"task": 'event: task\\ndata: {"task_type": "...", "payload": {...}}',
|
|
"alert": 'event: alert\\ndata: {"alert_type": "...", ...}',
|
|
"heartbeat": 'event: heartbeat\\ndata: {"ts": 1234567890, "layer_versions": {...}} (every 15s, full version snapshot)',
|
|
},
|
|
"commands_from_agent": "POST /api/ai/channel/command with {\"cmd\": \"get_summary\", \"args\": {}}",
|
|
},
|
|
"usage": [
|
|
"1. Open GET /api/ai/channel/sse FIRST — keep connection open for the session",
|
|
"2. On 'connected' event: receive full layer_versions snapshot (current state)",
|
|
"3. On 'layer_changed' event: know exactly which layers updated — fetch only those via get_layer_slice with since_layer_versions",
|
|
"4. On 'alert' event: receive watchdog hits instantly (geofence, callsign, keyword)",
|
|
"5. On 'task' event: receive operator-pushed tasks instantly",
|
|
"6. Send commands via POST /api/ai/channel/command as needed",
|
|
"7. Heartbeats every 15s include full layer_versions for drift recovery",
|
|
],
|
|
"benefits": [
|
|
"Works over Tor SOCKS5 — plain HTTP, no WebSocket upgrade needed",
|
|
"Single connection — Tor circuit stays warm, no 10-20s reconnect",
|
|
"Layer changes pushed instantly — fetch only what changed, not everything",
|
|
"Tasks and alerts pushed instantly — no polling delay",
|
|
"HMAC authenticated once at connect — no per-event signing overhead",
|
|
"Heartbeat keeps connection alive through proxies and Tor",
|
|
],
|
|
"python_example": (
|
|
"from sb_query import ShadowBrokerClient\n"
|
|
"sb = ShadowBrokerClient()\n"
|
|
"async for event in sb.stream_updates():\n"
|
|
" if event['event'] == 'layer_changed':\n"
|
|
" changed = list(event['data']['layers'].keys())\n"
|
|
" data = await sb.get_layer_slice(changed) # only changed layers"
|
|
),
|
|
},
|
|
"websocket_channel": {
|
|
"description": "Alternative for LOCAL agents only. WebSocket does NOT work over Tor SOCKS5. "
|
|
"Use SSE channel instead for remote/Tor connections.",
|
|
"endpoint": "ws://{host}/api/ai/channel/ws?ts={timestamp}&nonce={nonce}&sig={hmac_signature}",
|
|
"auth": "HMAC signature in query params. Sign: GET|/api/ai/channel/ws|ts|nonce|sha256('')",
|
|
"note": "WebSocket upgrade hangs over Tor SOCKS5. Use GET /api/ai/channel/sse instead.",
|
|
},
|
|
"command_channel_http": {
|
|
"description": "HTTP commands — use with SSE stream for real-time, or standalone for simple requests.",
|
|
"send": "POST /api/ai/channel/command body: {cmd, args}",
|
|
"batch": "POST /api/ai/channel/batch body: {commands: [{cmd, args}, ...]} (max 20, concurrent execution, one round-trip)",
|
|
"poll": "POST /api/ai/channel/poll body: {} (returns completed results + pending tasks)",
|
|
"authorization_model": "coarse_access_tier",
|
|
"authorization_notes": [
|
|
"restricted = read commands only",
|
|
"full = read + write commands",
|
|
"This is a coarse operator-selected policy, not a per-command scoped capability token model.",
|
|
],
|
|
"read_commands": sorted(READ_COMMANDS),
|
|
"write_commands": sorted(WRITE_COMMANDS),
|
|
"command_reference": {
|
|
"get_telemetry": {"args": {}, "description": "All live fast-refresh data (flights, ships, sigint, earthquakes, weather, CCTV, etc)"},
|
|
"get_slow_telemetry": {"args": {}, "description": "Slow-refresh data (prediction markets, news, military bases, power plants, volcanoes, etc)"},
|
|
"get_summary": {"args": {}, "description": "Counts and discovery metadata for all live telemetry layers, including available layer names and common aliases."},
|
|
"get_layer_slice": {
|
|
"args": {"layers": "list[str]", "limit_per_layer": "int (optional, omit or <=0 for full layer)", "since_version": "int (optional)"},
|
|
"description": "Fetch only selected top-level layers. Accepts aliases such as gfw/global_fishing_watch → fishing_activity. If since_version matches current version, returns changed=false and no layer payload.",
|
|
},
|
|
"find_flights": {
|
|
"args": {"query": "str (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "owner": "str (optional)", "categories": "list[str] (optional)", "limit": "int (default 25)"},
|
|
"description": "Compact server-side flight search across tracked/military/private/commercial layers.",
|
|
},
|
|
"find_ships": {
|
|
"args": {"query": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "limit": "int (default 25)"},
|
|
"description": "Compact server-side ship search by MMSI/IMO/name/query, including yacht-owner enrichment.",
|
|
},
|
|
"find_entity": {
|
|
"args": {"query": "str (optional)", "entity_type": "aircraft|ship|person|event|infrastructure (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "layers": "list[str] (optional)", "limit": "int (default 10)"},
|
|
"description": "Exact-first resolver for planes, ships, operators, callsigns, registrations, MMSI/IMO, and named entities. Use before tracking to avoid fuzzy prompt matching.",
|
|
},
|
|
"correlate_entity": {
|
|
"args": {"query": "str (optional)", "entity_type": "str (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "radius_km": "float (default 100)", "limit": "int (default 10)"},
|
|
"description": "Resolve an entity and return nearby context/correlation evidence. Co-location is reported as a lead, not proof.",
|
|
},
|
|
"search_telemetry": {
|
|
"args": {"query": "str", "layers": "list[str] (optional)", "limit": "int (default 25)"},
|
|
"description": "Universal compact search across telemetry when the entity type or source layer is not obvious.",
|
|
},
|
|
"search_news": {
|
|
"args": {"query": "str", "limit": "int (default 10)", "include_gdelt": "bool (default true)"},
|
|
"description": "Search news and event layers by keyword without pulling the whole slow feed.",
|
|
},
|
|
"entities_near": {
|
|
"args": {"lat": "float", "lng": "float", "radius_km": "float (default 50)", "entity_types": "list[str] (optional)", "limit": "int (default 25)"},
|
|
"description": "Compact proximity search around a point across selected layers.",
|
|
},
|
|
"brief_area": {
|
|
"args": {"lat": "float", "lng": "float", "radius_km": "float (default 50)", "entity_types": "list[str] (optional)", "query": "str (optional)", "limit": "int (default 25)", "context_limit": "int (default 10)"},
|
|
"description": "One compact area brief: nearby aircraft/ships/entities, optional topic news, and selected context layers.",
|
|
},
|
|
"what_changed": {
|
|
"args": {"layers": "list[str] (optional)", "since_version": "int (optional)", "since_layer_versions": "dict[str,int] (optional)", "limit_per_layer": "int (optional)", "compact": "bool (optional)"},
|
|
"description": "Incremental polling helper. Use with SSE layer_versions to fetch only changed layer slices.",
|
|
},
|
|
"get_report": {"args": {}, "description": "Combined fast + slow telemetry"},
|
|
"get_sigint_totals": {"args": {}, "description": "Meshtastic/APRS/JS8Call signal counts"},
|
|
"get_prediction_markets": {"args": {}, "description": "Polymarket + Kalshi prediction markets"},
|
|
"get_ai_pins": {"args": {}, "description": "All intel pins placed on the map"},
|
|
"get_layers": {"args": {}, "description": "All pin layers"},
|
|
"get_correlations": {"args": {}, "description": "Cross-domain correlation alerts"},
|
|
"channel_status": {"args": {}, "description": "Command channel health + stats"},
|
|
"sar_status": {"args": {}, "description": "SAR/OpenClaw catalog readiness and product fetch status"},
|
|
"sar_anomalies_recent": {"args": {"kind": "str (optional)", "limit": "int (default 25)"}, "description": "Recent SAR anomaly list"},
|
|
"sar_anomalies_near": {"args": {"lat": "float", "lng": "float", "radius_km": "float (default 50)", "limit": "int (default 25)"}, "description": "SAR anomalies near a coordinate"},
|
|
"sar_scene_search": {"args": {"aoi_id": "str (optional)", "limit": "int (default 25)"}, "description": "Search cached SAR scenes"},
|
|
"sar_coverage_for_aoi": {"args": {"aoi_id": "str (optional)"}, "description": "SAR coverage records by AOI"},
|
|
"sar_aoi_list": {"args": {}, "description": "List SAR areas of interest"},
|
|
"sar_pin_click": {"args": {"anomaly_id": "str"}, "description": "Inspect SAR anomaly pin details"},
|
|
"list_analysis_zones": {"args": {}, "description": "List OpenClaw analysis zones on the map"},
|
|
"place_pin": {
|
|
"args": {"lat": "float", "lng": "float", "label": "str",
|
|
"category": "threat|news|geolocation|custom|anomaly|military|maritime|flight|infrastructure|weather|sigint|prediction|research",
|
|
"description": "str (optional)", "source": "str (default: openclaw)",
|
|
"layer_id": "str (optional)", "color": "str (optional)",
|
|
"confidence": "float 0-1 (default: 1.0)",
|
|
"entity_attachment": {"entity_type": "str", "entity_id": "str", "entity_label": "str"}},
|
|
"description": "Place an intel pin on the map (full access only)",
|
|
},
|
|
"delete_pin": {"args": {"id": "str"}, "description": "Remove a pin (full access only)"},
|
|
"create_layer": {"args": {"name": "str", "description": "str (optional)", "color": "str (optional)"}, "description": "Create a pin layer"},
|
|
"update_layer": {"args": {"layer_id": "str", "name": "str (optional)", "visible": "bool (optional)"}, "description": "Update layer properties"},
|
|
"delete_layer": {"args": {"layer_id": "str"}, "description": "Delete layer and all its pins"},
|
|
"inject_data": {"args": {"layer": "str", "items": "list"}, "description": "Inject data into a layer"},
|
|
"refresh_feed": {"args": {"layer_id": "str"}, "description": "Refresh a layer's RSS/feed source"},
|
|
"take_snapshot": {"args": {"layers": "list (optional)", "compress": "bool (default: true)"}, "description": "Take a Time Machine snapshot"},
|
|
"timemachine_list": {"args": {}, "description": "List recent Time Machine snapshots"},
|
|
"timemachine_playback": {"args": {"snapshot_id": "str"}, "description": "Load a snapshot for playback"},
|
|
"timemachine_config": {"args": {}, "description": "Get Time Machine config (enabled, interval)"},
|
|
"track_entity": {
|
|
"args": {"query": "str (optional)", "entity_type": "str (optional)", "callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "layers": "list[str] (optional)"},
|
|
"description": "Resolve then install the most precise aircraft/ship/generic watch. If unresolved now, keeps a generic watch instead of failing the user flow.",
|
|
},
|
|
"watch_area": {"args": {"lat": "float", "lng": "float", "radius_km": "float (default 50)", "entity_types": "list[str] (default aircraft+ships)"}, "description": "Create a geofence watch around a coordinate"},
|
|
"sar_aoi_add": {"args": {"id": "str", "name": "str (optional)", "center_lat": "float", "center_lon": "float", "radius_km": "float (optional)", "priority": "int (optional)"}, "description": "Add a SAR area of interest"},
|
|
"sar_aoi_remove": {"args": {"aoi_id": "str"}, "description": "Remove a SAR area of interest"},
|
|
"sar_pin_from_anomaly": {"args": {"anomaly_id": "str"}, "description": "Create an intel pin from a SAR anomaly"},
|
|
"sar_watch_anomaly": {"args": {"aoi_id": "str (optional)", "kind": "str (optional)", "min_magnitude": "float (optional)"}, "description": "Create SAR anomaly watch"},
|
|
"sar_focus_aoi": {"args": {"aoi_id": "str", "zoom": "float (optional)"}, "description": "Move the operator map to a SAR AOI"},
|
|
"place_analysis_zone": {"args": {"lat": "float", "lng": "float", "radius_km": "float (optional)", "label": "str (optional)", "description": "str (optional)", "color": "str (optional)"}, "description": "Place an OpenClaw analysis zone"},
|
|
"delete_analysis_zone": {"args": {"zone_id": "str"}, "description": "Delete an analysis zone"},
|
|
"clear_analysis_zones": {"args": {}, "description": "Clear OpenClaw-created analysis zones"},
|
|
"show_satellite": {
|
|
"args": {"lat": "float", "lng": "float", "caption": "str (optional)"},
|
|
"description": "Show Sentinel-2 satellite imagery to user in full-screen viewer. "
|
|
"Same display as right-click on the map. Image appears centered on screen.",
|
|
},
|
|
"show_sentinel": {
|
|
"args": {"lat": "float", "lng": "float",
|
|
"preset": "TRUE-COLOR|FALSE-COLOR|NDVI|MOISTURE-INDEX (default: TRUE-COLOR)",
|
|
"caption": "str (optional)"},
|
|
"description": "Show Copernicus Sentinel Hub imagery (requires user's CDSE credentials). "
|
|
"Falls back to free Sentinel-2 STAC. Presets: TRUE-COLOR (visible), "
|
|
"FALSE-COLOR (vegetation), NDVI (plant health), MOISTURE-INDEX (water stress).",
|
|
},
|
|
},
|
|
},
|
|
"rest_endpoints": {
|
|
"pins": {
|
|
"POST /api/ai/pins": "Create a pin (body: {lat, lng, label, category, ...})",
|
|
"GET /api/ai/pins": "List pins (?limit=500&category=threat&layer_id=...)",
|
|
"GET /api/ai/pins/{id}": "Get single pin",
|
|
"PATCH /api/ai/pins/{id}": "Update pin (body: {label, description, category, color})",
|
|
"DELETE /api/ai/pins/{id}": "Delete pin",
|
|
"POST /api/ai/pins/batch": "Create up to 200 pins at once (body: {pins: [...]})",
|
|
"GET /api/ai/pins/geojson": "Pins as GeoJSON FeatureCollection",
|
|
},
|
|
"layers": {
|
|
"POST /api/ai/layers": "Create layer (body: {name, description, color, feed_url, feed_interval})",
|
|
"GET /api/ai/layers": "List all layers",
|
|
"PATCH /api/ai/layers/{id}": "Update layer",
|
|
"DELETE /api/ai/layers/{id}": "Delete layer + all pins",
|
|
"POST /api/ai/layers/{id}/refresh": "Refresh layer feed",
|
|
},
|
|
"telemetry": {
|
|
"GET /api/live-data/fast": "Fast-refresh data (flights, ships, sigint, earthquakes ~10s)",
|
|
"GET /api/live-data/slow": "Slow-refresh data (markets, news, bases ~60s)",
|
|
"GET /api/ai/summary": "Lightweight summary with counts",
|
|
"GET /api/ai/report": "Full combined report",
|
|
},
|
|
"intelligence": {
|
|
"GET /api/ai/news-near": "News near coordinates (?lat=&lng=&radius_km=100)",
|
|
"GET /api/ai/satellite-images": "Satellite imagery (?lat=&lng=&days=7)",
|
|
"GET /api/region-dossier": "Region dossier (?lat=&lng=)",
|
|
"GET /api/ai/status": "AI Intel system status",
|
|
},
|
|
"timemachine": {
|
|
"POST /api/ai/timemachine/snapshot": "Take snapshot",
|
|
"GET /api/ai/timemachine/snapshots": "List snapshots",
|
|
"GET /api/ai/timemachine/snapshot/{id}": "Get snapshot data",
|
|
"GET /api/ai/timemachine/playback/{id}": "Playback snapshot",
|
|
"GET /api/ai/timemachine/diff": "Diff two snapshots (?from=&to=)",
|
|
"GET /api/ai/timemachine/config": "Get TM config",
|
|
"PUT /api/ai/timemachine/config": "Update TM config",
|
|
},
|
|
},
|
|
"watchdog": {
|
|
"description": "Set up alert triggers so you get pushed notifications instead of polling. "
|
|
"Alerts are delivered as tasks via the poll endpoint.",
|
|
"commands": {
|
|
"add_watch": {
|
|
"description": "Register an alert trigger. Alerts push to you via channel poll.",
|
|
"types": {
|
|
"track_aircraft": {"params": {"callsign": "str (optional)", "registration": "str (optional)", "icao24": "str (optional)", "owner": "str (optional)", "query": "str (optional)"}, "description": "Alert when a matching aircraft appears across flight layers"},
|
|
"track_callsign": {"params": {"callsign": "str (e.g. 'KAL076')"}, "description": "Alert when aircraft with this callsign appears"},
|
|
"track_registration": {"params": {"registration": "str (e.g. 'N189AM')"}, "description": "Alert when aircraft with this tail number appears"},
|
|
"track_ship": {"params": {"mmsi": "str (optional)", "imo": "str (optional)", "name": "str (optional)", "owner": "str (optional)", "callsign": "str (optional)"}, "description": "Alert when ship appears by MMSI, IMO, name, owner, or callsign"},
|
|
"track_entity": {"params": {"query": "str", "entity_type": "str (optional)", "layers": "list[str] (optional)"}, "description": "Generic exact-first entity watch"},
|
|
"geofence": {"params": {"lat": "float", "lng": "float", "radius_km": "float (default 50)", "entity_types": "list (default ['flights','ships'])"}, "description": "Alert when any entity enters a geographic zone"},
|
|
"keyword": {"params": {"keyword": "str"}, "description": "Alert when keyword appears in news/GDELT"},
|
|
"prediction_market": {"params": {"query": "str", "threshold": "float 0-1 (optional)"}, "description": "Alert on prediction market movements"},
|
|
},
|
|
},
|
|
"remove_watch": {"args": {"id": "str"}, "description": "Remove a watch by ID"},
|
|
"list_watches": {"args": {}, "description": "List all active watches (read command)"},
|
|
"clear_watches": {"args": {}, "description": "Remove all watches"},
|
|
},
|
|
"how_it_works": "Watchdog checks telemetry every 15s. When a watch matches, an alert is pushed "
|
|
"instantly over SSE stream (event: alert) AND queued as a task for HTTP poll fallback. "
|
|
"Same watch won't re-fire within 60s (debounce).",
|
|
"example": "command: add_watch, args: {type: 'track_callsign', params: {callsign: 'N189AM'}} — "
|
|
"over SSE you'll get an instant alert push. Over HTTP poll, check /api/ai/channel/poll.",
|
|
},
|
|
"tips": {
|
|
"connection": "PREFERRED: Open GET /api/ai/channel/sse FIRST and keep it open. The server pushes "
|
|
"layer_changed events whenever data refreshes — you know exactly which layers to fetch "
|
|
"instead of blind-polling. Also delivers watchdog alerts and operator tasks instantly. "
|
|
"Send commands via POST /api/ai/channel/command alongside the stream. Works over Tor.",
|
|
"performance": "1) Open SSE stream for layer_changed push notifications. "
|
|
"2) Use get_layer_slice with per-layer incremental (since_layer_versions) — only changed "
|
|
"layers are serialized, unchanged layers transfer zero bytes. The client tracks versions "
|
|
"automatically from SSE events and previous responses. "
|
|
"3) Pass compact=true on every read command for compressed_v1 responses (~60-90% smaller). "
|
|
"4) Use targeted commands first (find_flights, search_telemetry, entities_near). "
|
|
"Reserve get_telemetry/get_slow_telemetry for rare full-context pulls.",
|
|
"pins": "Pins are server-side, NOT localStorage. Use place_pin command or POST /api/ai/pins. The agent can place and delete pins.",
|
|
"tracking": "To track a specific aircraft without polling: use add_watch with track_callsign or track_registration. Over SSE, you'll get instant push alerts.",
|
|
"agency": "You can: place pins, set geofences, track entities, monitor keywords, get pushed alerts — all without user intervention. SSE stream makes all of this real-time.",
|
|
},
|
|
"transport": tier,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OpenClaw Connection Management (local-operator only — NOT via HMAC)
|
|
# These endpoints manage the HMAC secret itself, so they MUST require
|
|
# local operator access to prevent privilege escalation.
|
|
#
|
|
# Issue #302 (tg12): pre-fix, GET /api/ai/connect-info had two problems:
|
|
#
|
|
# 1. ``?reveal=true`` made the full secret travel through every operator
|
|
# page-load that opened the Connect modal. Even gated to
|
|
# ``require_local_operator``, that put the secret into browser
|
|
# history, dev-tools network panels, browser disk caches, HAR
|
|
# exports, and screen captures. Every time the modal opened.
|
|
#
|
|
# 2. The same GET endpoint auto-bootstrapped (generated + persisted)
|
|
# the secret on first read. Side effects on a GET are a footgun:
|
|
# browser prefetchers, mirror tools, and casual curl-from-history
|
|
# would all silently mint+persist a fresh secret. (Gated, but
|
|
# still surprising — and noisy in the audit log.)
|
|
#
|
|
# Resolution:
|
|
#
|
|
# GET /api/ai/connect-info — always returns the MASKED
|
|
# secret. No ?reveal param.
|
|
# No auto-bootstrap; if the
|
|
# secret is missing,
|
|
# ``hmac_secret_set: false``
|
|
# tells the frontend to call
|
|
# /bootstrap.
|
|
#
|
|
# POST /api/ai/connect-info/bootstrap — NEW. Generates + persists the
|
|
# secret if missing. Idempotent.
|
|
# Returns metadata only, never
|
|
# the full secret.
|
|
#
|
|
# POST /api/ai/connect-info/reveal — NEW. Returns the full secret in
|
|
# the body with strict
|
|
# ``Cache-Control: no-store,
|
|
# no-cache, must-revalidate``
|
|
# + ``Pragma: no-cache`` so
|
|
# it does not land in browser
|
|
# caches. POST means it does
|
|
# not land in URL history.
|
|
#
|
|
# POST /api/ai/connect-info/regenerate — keeps existing one-time-reveal
|
|
# behavior (regenerate IS a
|
|
# deliberate destructive action
|
|
# the operator triggered, so
|
|
# displaying the new secret
|
|
# once is the only path that
|
|
# makes the operation useful).
|
|
# Same no-store headers added.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Cache-Control headers that should accompany every response carrying the
|
|
# full HMAC secret. Reused across the reveal + regenerate endpoints so a
|
|
# future refactor that splits or renames them can't forget the headers.
|
|
_NO_STORE_HEADERS = {
|
|
"Cache-Control": "no-store, no-cache, must-revalidate, private",
|
|
"Pragma": "no-cache",
|
|
"Expires": "0",
|
|
}
|
|
|
|
|
|
def _mask_hmac_secret(secret: str) -> str:
|
|
"""Return a fingerprint-style mask (first6 + bullets + last4) suitable
|
|
for display in the UI before the operator clicks Reveal."""
|
|
if not secret:
|
|
return ""
|
|
if len(secret) > 10:
|
|
return secret[:6] + "••••••••" + secret[-4:]
|
|
return "••••••••"
|
|
|
|
|
|
def _connect_info_metadata(settings) -> dict:
|
|
"""Return everything the Connect modal needs EXCEPT the secret itself.
|
|
|
|
Shared between GET /api/ai/connect-info (where the full secret is
|
|
masked) and POST /api/ai/connect-info/bootstrap (where the operator
|
|
just generated a secret but we don't return it inline — they have to
|
|
call /reveal to see it).
|
|
"""
|
|
access_tier = str(settings.OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
|
return {
|
|
"access_tier": access_tier,
|
|
"trust_model": {
|
|
"remote_http_principal": "holder_of_openclaw_hmac_secret",
|
|
"agent_ed25519_identity_bound_to_http_session": False,
|
|
"agent_ed25519_identity_purpose": [
|
|
"mesh signing",
|
|
"future private-lane upgrade",
|
|
],
|
|
"authorization_model": {
|
|
"type": "coarse_access_tier",
|
|
"restricted": "read commands only",
|
|
"full": "read and write commands",
|
|
},
|
|
"durability": {
|
|
"command_queue": "memory_only",
|
|
"task_queue": "memory_only",
|
|
"watch_registry": "memory_only",
|
|
},
|
|
},
|
|
"connection_modes": {
|
|
"direct": {
|
|
"enabled": True,
|
|
"description": "HMAC-signed HTTP requests for local/VPN/Tor connections",
|
|
},
|
|
"wormhole": {
|
|
"enabled": False,
|
|
"description": "Planned — E2EE via Wormhole DM (not yet implemented for this channel)",
|
|
},
|
|
},
|
|
"access_tiers": {
|
|
"restricted": {
|
|
"description": "Read-only telemetry, pins, satellite, news queries",
|
|
"risk": "Low — agent can observe but cannot modify data or post to mesh",
|
|
"capabilities": [
|
|
"get_telemetry", "get_pins", "satellite_images",
|
|
"news_near", "ai_summary", "ai_report",
|
|
"timemachine_list", "timemachine_view",
|
|
],
|
|
},
|
|
"full": {
|
|
"description": "Full access — read, write, inject, post, snapshot",
|
|
"risk": "High — agent can place pins, inject data into layers, take snapshots, and interact with the mesh network on your behalf. You are responsible for its actions.",
|
|
"capabilities": [
|
|
"get_telemetry", "get_pins", "create_pin", "delete_pin",
|
|
"satellite_images", "news_near", "data_injection",
|
|
"ai_summary", "ai_report", "timemachine_snapshot",
|
|
"timemachine_list", "timemachine_view", "timemachine_diff",
|
|
],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/api/ai/connect-info", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("30/minute")
|
|
async def get_connect_info(request: Request):
|
|
"""Return connection details for the OpenClaw Connect modal.
|
|
|
|
The HMAC secret is always returned as a fingerprint mask
|
|
(``first6 + bullets + last4``); the full value is only ever served by
|
|
``POST /api/ai/connect-info/reveal`` (see #302). When the secret has
|
|
not been bootstrapped yet, ``hmac_secret_set`` is false and the
|
|
frontend should call ``POST /api/ai/connect-info/bootstrap``.
|
|
|
|
Private keys are NEVER returned.
|
|
"""
|
|
from services.config import get_settings
|
|
|
|
settings = get_settings()
|
|
hmac_secret = str(settings.OPENCLAW_HMAC_SECRET or "").strip()
|
|
|
|
return {
|
|
"ok": True,
|
|
"masked_hmac_secret": _mask_hmac_secret(hmac_secret),
|
|
"hmac_secret_set": bool(hmac_secret),
|
|
"bootstrap_behavior": {
|
|
"auto_generates_when_missing": False,
|
|
"notes": [
|
|
"Call POST /api/ai/connect-info/bootstrap to mint a secret on first use.",
|
|
"Call POST /api/ai/connect-info/reveal to see the full secret (no-store).",
|
|
"Regenerating the HMAC secret revokes all existing direct-mode OpenClaw callers at once.",
|
|
],
|
|
},
|
|
**_connect_info_metadata(settings),
|
|
}
|
|
|
|
|
|
@router.post("/api/ai/connect-info/bootstrap", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("10/minute")
|
|
async def bootstrap_hmac_secret(request: Request):
|
|
"""Mint and persist the OpenClaw HMAC secret if it isn't already set.
|
|
|
|
Idempotent: if a secret already exists, returns ``generated: false``
|
|
and leaves the existing secret untouched. Never returns the secret
|
|
value in the response body — the operator calls
|
|
``POST /api/ai/connect-info/reveal`` to see it.
|
|
"""
|
|
import secrets
|
|
from services.config import get_settings
|
|
|
|
settings = get_settings()
|
|
existing = str(settings.OPENCLAW_HMAC_SECRET or "").strip()
|
|
if existing:
|
|
return {
|
|
"ok": True,
|
|
"generated": False,
|
|
"hmac_secret_set": True,
|
|
"masked_hmac_secret": _mask_hmac_secret(existing),
|
|
"detail": "HMAC secret already configured. Use /reveal to see it.",
|
|
}
|
|
|
|
new_secret = secrets.token_hex(24) # 48 chars
|
|
_write_env_value("OPENCLAW_HMAC_SECRET", new_secret)
|
|
get_settings.cache_clear()
|
|
|
|
return {
|
|
"ok": True,
|
|
"generated": True,
|
|
"hmac_secret_set": True,
|
|
"masked_hmac_secret": _mask_hmac_secret(new_secret),
|
|
"detail": "HMAC secret generated. Call /reveal to copy it into your OpenClaw config.",
|
|
}
|
|
|
|
|
|
@router.post("/api/ai/connect-info/reveal", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("10/minute")
|
|
async def reveal_hmac_secret(request: Request):
|
|
"""Return the full HMAC secret in the response body.
|
|
|
|
POST (not GET) so the secret never lands in URL history, access logs,
|
|
or browser visit history. Strict ``Cache-Control: no-store`` headers
|
|
prevent intermediaries from persisting the response. Returns 404 if
|
|
no secret has been bootstrapped — the frontend should call
|
|
``POST /api/ai/connect-info/bootstrap`` first.
|
|
"""
|
|
from services.config import get_settings
|
|
|
|
settings = get_settings()
|
|
hmac_secret = str(settings.OPENCLAW_HMAC_SECRET or "").strip()
|
|
if not hmac_secret:
|
|
raise HTTPException(
|
|
404,
|
|
"No HMAC secret configured. Call POST /api/ai/connect-info/bootstrap first.",
|
|
)
|
|
return JSONResponse(
|
|
content={
|
|
"ok": True,
|
|
"hmac_secret": hmac_secret,
|
|
"masked_hmac_secret": _mask_hmac_secret(hmac_secret),
|
|
},
|
|
headers=_NO_STORE_HEADERS,
|
|
)
|
|
|
|
|
|
@router.post("/api/ai/connect-info/regenerate", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("5/minute")
|
|
async def regenerate_hmac_secret(request: Request):
|
|
"""Generate a new HMAC secret. Old secret immediately stops working.
|
|
|
|
Returns the new secret in the response body — this is the only
|
|
operation where the full secret travels back through the response,
|
|
because regenerating IS a deliberate destructive action the operator
|
|
triggered and they need to see the new value once to update their
|
|
OpenClaw configuration. Strict ``Cache-Control: no-store`` headers
|
|
keep it from being persisted by browser caches, proxies, or HAR
|
|
capture tooling.
|
|
"""
|
|
import secrets
|
|
from services.config import get_settings
|
|
|
|
new_secret = secrets.token_hex(24) # 48 chars
|
|
_write_env_value("OPENCLAW_HMAC_SECRET", new_secret)
|
|
get_settings.cache_clear()
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"ok": True,
|
|
"hmac_secret": new_secret,
|
|
"masked_hmac_secret": _mask_hmac_secret(new_secret),
|
|
"detail": "HMAC secret regenerated. Update your OpenClaw agent configuration.",
|
|
},
|
|
headers=_NO_STORE_HEADERS,
|
|
)
|
|
|
|
|
|
@router.put("/api/ai/connect-info/access-tier", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("10/minute")
|
|
async def set_access_tier(request: Request, body: dict):
|
|
"""Set the access tier for remote OpenClaw agents."""
|
|
from services.config import get_settings
|
|
|
|
tier = str(body.get("tier", "") or "").strip().lower()
|
|
if tier not in ("full", "restricted"):
|
|
raise HTTPException(400, "Invalid tier. Must be 'full' or 'restricted'.")
|
|
|
|
_write_env_value("OPENCLAW_ACCESS_TIER", tier)
|
|
get_settings.cache_clear()
|
|
|
|
return {"ok": True, "access_tier": tier}
|
|
|
|
|
|
def _write_env_value(key: str, value: str) -> None:
|
|
"""Write or update a key=value pair in the .env file.
|
|
|
|
Uses atomic write-to-temp-then-rename to prevent corruption from
|
|
concurrent access. Does NOT log the value to avoid leaking secrets.
|
|
"""
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
env_path = Path(__file__).resolve().parent.parent / ".env"
|
|
lines: list[str] = []
|
|
found = False
|
|
|
|
if env_path.exists():
|
|
with open(env_path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
stripped = line.strip()
|
|
if stripped.startswith(f"{key}=") or stripped.startswith(f"# {key}="):
|
|
lines.append(f"{key}={value}\n")
|
|
found = True
|
|
else:
|
|
lines.append(line)
|
|
|
|
if not found:
|
|
lines.append(f"\n# -- OpenClaw Agent --\n{key}={value}\n")
|
|
|
|
# Atomic write: write to temp file in same directory, then rename
|
|
fd, tmp_path = tempfile.mkstemp(
|
|
dir=str(env_path.parent), prefix=".env.tmp.", suffix=""
|
|
)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
f.writelines(lines)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp_path, str(env_path))
|
|
except BaseException:
|
|
# Clean up temp file on any failure
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
# Also set in current process env so Settings picks it up
|
|
os.environ[key] = value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent Identity Management (Ed25519 keypair — used for mesh signing;
|
|
# Wormhole DM E2EE upgrade is planned but not yet wired into this channel)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/api/ai/agent-identity", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("30/minute")
|
|
async def get_agent_identity(request: Request):
|
|
"""Get the OpenClaw agent's public identity info.
|
|
|
|
Returns the agent's node_id and public key — never the private key.
|
|
"""
|
|
from services.openclaw_bridge import get_agent_public_info
|
|
return get_agent_public_info()
|
|
|
|
|
|
@router.post("/api/ai/agent-identity/bootstrap", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("5/minute")
|
|
async def bootstrap_agent_identity(request: Request):
|
|
"""Generate (or regenerate) the agent's Ed25519 keypair.
|
|
|
|
Pass ?force=true to regenerate. The old identity is permanently lost.
|
|
"""
|
|
body = {}
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
pass
|
|
force = bool(body.get("force", False))
|
|
from services.openclaw_bridge import generate_agent_keypair
|
|
return generate_agent_keypair(force=force)
|
|
|
|
|
|
@router.delete("/api/ai/agent-identity", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("3/minute")
|
|
async def revoke_agent_identity(request: Request):
|
|
"""Permanently revoke the agent's identity.
|
|
|
|
The keypair is destroyed. A new one must be bootstrapped.
|
|
"""
|
|
from services.openclaw_bridge import revoke_agent_identity
|
|
return revoke_agent_identity()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Command Channel — Bidirectional Agent ↔ SB communication
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class ChannelCommand(BaseModel):
|
|
cmd: str = Field(..., min_length=1, max_length=64)
|
|
args: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ChannelBatchRequest(BaseModel):
|
|
"""Batch of commands submitted in a single HTTP round-trip."""
|
|
commands: list[ChannelCommand] = Field(..., min_length=1, max_length=20)
|
|
|
|
|
|
class ChannelTask(BaseModel):
|
|
task_type: str = Field(default="custom", min_length=1, max_length=32)
|
|
payload: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
@router.post("/api/ai/channel/command", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("60/minute")
|
|
async def channel_submit_command(request: Request, body: ChannelCommand):
|
|
"""Agent submits a command through the channel.
|
|
|
|
The command is executed immediately and the result is returned.
|
|
Allowed commands depend on the current access tier.
|
|
"""
|
|
from services.config import get_settings
|
|
from services.openclaw_channel import channel
|
|
|
|
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
|
result = channel.submit_command(body.cmd, body.args, access_tier)
|
|
|
|
if not result.get("ok"):
|
|
raise HTTPException(status_code=403 if "requires full" in str(result.get("detail", "")) else 400,
|
|
detail=result.get("detail", "command failed"))
|
|
return result
|
|
|
|
|
|
@router.post("/api/ai/channel/batch", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("30/minute")
|
|
async def channel_submit_batch(request: Request, body: ChannelBatchRequest):
|
|
"""Submit multiple commands in a single HTTP round-trip.
|
|
|
|
Commands execute concurrently — independent queries (find_flights +
|
|
search_news + entities_near) overlap instead of serialising behind
|
|
N separate HTTP calls. Max 20 commands per batch.
|
|
|
|
Returns {"ok": true, "results": [...], "tier": int, "count": int}.
|
|
"""
|
|
from services.config import get_settings
|
|
from services.openclaw_channel import channel
|
|
|
|
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
|
batch = [{"cmd": c.cmd, "args": c.args} for c in body.commands]
|
|
result = channel.submit_batch(batch, access_tier)
|
|
|
|
if not result.get("ok"):
|
|
raise HTTPException(status_code=400, detail=result.get("detail", "batch failed"))
|
|
return result
|
|
|
|
|
|
@router.post("/api/ai/channel/poll", dependencies=[Depends(require_openclaw_or_local)])
|
|
@limiter.limit("120/minute")
|
|
async def channel_poll(request: Request):
|
|
"""Agent polls for command results and pending tasks.
|
|
|
|
Returns any completed command results (destructive read) plus
|
|
any tasks pushed by the operator that haven't been picked up yet.
|
|
"""
|
|
from services.openclaw_channel import channel
|
|
|
|
completed = channel.get_completed_commands()
|
|
tasks = channel.poll_tasks()
|
|
return {
|
|
"ok": True,
|
|
"commands": completed,
|
|
"tasks": tasks,
|
|
"commands_count": len(completed),
|
|
"tasks_count": len(tasks),
|
|
}
|
|
|
|
|
|
@router.post("/api/ai/channel/task", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("30/minute")
|
|
async def channel_push_task(request: Request, body: ChannelTask):
|
|
"""Operator pushes a task to the agent.
|
|
|
|
Task types: alert, request, sync, custom.
|
|
The agent picks up tasks on its next poll.
|
|
"""
|
|
from services.openclaw_channel import channel
|
|
|
|
result = channel.push_task(body.task_type, body.payload)
|
|
if not result.get("ok"):
|
|
raise HTTPException(status_code=400, detail=result.get("detail", "task push failed"))
|
|
return result
|
|
|
|
|
|
@router.get("/api/ai/channel/status", dependencies=[Depends(require_local_operator)])
|
|
@limiter.limit("30/minute")
|
|
async def channel_status(request: Request):
|
|
"""Get command channel status: tier, queue sizes, stats."""
|
|
from services.openclaw_channel import channel
|
|
return channel.status()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SSE Channel — Server-Sent Events for real-time push over plain HTTP
|
|
# ---------------------------------------------------------------------------
|
|
# Works perfectly over Tor SOCKS5 (unlike WebSocket which hangs on upgrade).
|
|
# Agent keeps one long-lived GET connection open → server pushes events.
|
|
# Commands still go via POST /api/ai/channel/command (existing endpoint).
|
|
#
|
|
# Protocol:
|
|
# event: connected data: {"access_tier": "...", "layer_versions": {...}}
|
|
# event: layer_changed data: {"layers": {"ships": {"layer": "ships", "version": 42, "count": 1287}, ...}}
|
|
# event: task data: {"task_type": "...", "payload": {...}}
|
|
# event: alert data: {"alert_type": "...", ...}
|
|
# event: heartbeat data: {"ts": 1234567890, "layer_versions": {...}}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
from starlette.responses import StreamingResponse
|
|
|
|
# Track SSE clients for broadcast (parallel to WS clients)
|
|
_sse_queues: list[asyncio.Queue] = []
|
|
_sse_queues_lock = asyncio.Lock()
|
|
|
|
|
|
async def broadcast_to_sse_clients(event_type: str, data: dict[str, Any]):
|
|
"""Push an event to all connected SSE clients."""
|
|
async with _sse_queues_lock:
|
|
queues = list(_sse_queues)
|
|
for q in queues:
|
|
try:
|
|
q.put_nowait({"event": event_type, "data": data})
|
|
except asyncio.QueueFull:
|
|
pass # Drop if client is too slow — they'll catch up
|
|
|
|
|
|
@router.get("/api/ai/channel/sse", dependencies=[Depends(require_openclaw_or_local)])
|
|
async def channel_sse(request: Request):
|
|
"""Server-Sent Events stream for real-time push to agents.
|
|
|
|
Keeps one HTTP connection open. Tor-friendly — no WebSocket upgrade.
|
|
Tasks, alerts, and watchdog hits are pushed instantly.
|
|
Layer changes are pushed as they happen — agent fetches only what changed.
|
|
Agent sends commands via POST /api/ai/channel/command.
|
|
|
|
Events pushed:
|
|
layer_changed — {layers: {layer_name: {version, count}, ...}}
|
|
task — operator-pushed task
|
|
alert — watchdog alert
|
|
heartbeat — keep-alive with current layer versions
|
|
connected — initial handshake with access tier + all layer versions
|
|
"""
|
|
from services.config import get_settings
|
|
from services.openclaw_channel import channel
|
|
from services.fetchers._store import (
|
|
get_layer_versions,
|
|
register_layer_change_callback,
|
|
unregister_layer_change_callback,
|
|
)
|
|
|
|
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
|
|
|
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
|
|
|
|
# Bridge thread-based layer change notifications into the async queue.
|
|
# _mark_fresh() fires from fetcher threads; we use call_soon_threadsafe
|
|
# to safely enqueue into the asyncio.Queue from a non-async context.
|
|
loop = asyncio.get_event_loop()
|
|
|
|
def _on_layer_change(layer: str, version: int, count: int):
|
|
try:
|
|
loop.call_soon_threadsafe(
|
|
queue.put_nowait,
|
|
{"event": "layer_changed", "data": {"layer": layer, "version": version, "count": count}},
|
|
)
|
|
except (RuntimeError, asyncio.QueueFull):
|
|
pass # Loop closed or queue full — drop, agent will catch up
|
|
|
|
register_layer_change_callback(_on_layer_change)
|
|
|
|
async with _sse_queues_lock:
|
|
_sse_queues.append(queue)
|
|
|
|
async def event_stream():
|
|
try:
|
|
# Send connected event with full layer version snapshot so the
|
|
# agent knows the current state before any deltas arrive.
|
|
yield _sse_format("connected", {
|
|
"access_tier": access_tier,
|
|
"layer_versions": get_layer_versions(),
|
|
"message": "SSE channel active. Send commands via POST /api/ai/channel/command.",
|
|
})
|
|
|
|
heartbeat_interval = 15 # seconds — keeps Tor circuit alive
|
|
last_heartbeat = time.time()
|
|
|
|
while True:
|
|
# Drain all pending events and deduplicate layer_changed
|
|
# notifications (keep only latest version per layer).
|
|
events: list[dict] = []
|
|
try:
|
|
# Wait up to 1s for the first event
|
|
ev = await asyncio.wait_for(queue.get(), timeout=1.0)
|
|
events.append(ev)
|
|
# Drain any more that queued while we waited
|
|
while not queue.empty():
|
|
try:
|
|
events.append(queue.get_nowait())
|
|
except asyncio.QueueEmpty:
|
|
break
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
|
|
# Separate layer_changed events (dedup) from other events
|
|
layer_latest: dict[str, dict] = {}
|
|
for ev in events:
|
|
if ev.get("event") == "layer_changed":
|
|
layer_latest[ev["data"]["layer"]] = ev["data"]
|
|
else:
|
|
yield _sse_format(ev["event"], ev["data"])
|
|
|
|
# Emit one batched layer_changed event per cycle
|
|
if layer_latest:
|
|
yield _sse_format("layer_changed", {"layers": layer_latest})
|
|
|
|
# Poll channel for tasks (same as WS push loop)
|
|
tasks = channel.poll_tasks()
|
|
for task in tasks:
|
|
yield _sse_format("task", task)
|
|
|
|
# Heartbeat to keep connection alive through Tor/proxies
|
|
now = time.time()
|
|
if now - last_heartbeat >= heartbeat_interval:
|
|
yield _sse_format("heartbeat", {
|
|
"ts": now,
|
|
"layer_versions": get_layer_versions(),
|
|
})
|
|
last_heartbeat = now
|
|
|
|
# Check if client disconnected
|
|
if await request.is_disconnected():
|
|
break
|
|
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
unregister_layer_change_callback(_on_layer_change)
|
|
async with _sse_queues_lock:
|
|
if queue in _sse_queues:
|
|
_sse_queues.remove(queue)
|
|
|
|
return StreamingResponse(
|
|
event_stream(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no", # Disable nginx buffering if proxied
|
|
},
|
|
)
|
|
|
|
|
|
def _sse_format(event: str, data: Any) -> str:
|
|
"""Format a single SSE event."""
|
|
import json as _j
|
|
payload = _j.dumps(data, default=str)
|
|
return f"event: {event}\ndata: {payload}\n\n"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket Channel — persistent bidirectional connection
|
|
# ---------------------------------------------------------------------------
|
|
# Replaces polling: one Tor circuit, always warm, instant push.
|
|
#
|
|
# Auth: HMAC signature passed as query params on the upgrade request:
|
|
# ws://host/api/ai/channel/ws?ts=...&nonce=...&sig=...
|
|
#
|
|
# Protocol (JSON messages over WS):
|
|
# Agent → SB: {"type": "command", "cmd": "get_summary", "args": {}}
|
|
# SB → Agent: {"type": "result", "command_id": "...", "result": {...}}
|
|
# SB → Agent: {"type": "task", "task": {...}} (pushed instantly)
|
|
# SB → Agent: {"type": "alert", "alert": {...}} (watchdog hits)
|
|
# Agent → SB: {"type": "ping"}
|
|
# SB → Agent: {"type": "pong"}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
from fastapi import WebSocket, WebSocketDisconnect
|
|
import json as _json_mod
|
|
|
|
# Track connected WebSocket clients for push delivery
|
|
_ws_clients: list[WebSocket] = []
|
|
_ws_clients_lock = asyncio.Lock()
|
|
|
|
|
|
async def _verify_ws_hmac(ws: WebSocket) -> bool:
|
|
"""Verify HMAC signature from WebSocket query params.
|
|
|
|
The agent signs: METHOD|path|ts|nonce|body_digest
|
|
For WS upgrade: GET|/api/ai/channel/ws|ts|nonce|sha256("")
|
|
"""
|
|
import hashlib as _hl
|
|
import hmac as _hm
|
|
|
|
params = ws.query_params
|
|
ts_str = params.get("ts", "")
|
|
nonce = params.get("nonce", "")
|
|
sig = params.get("sig", "")
|
|
|
|
if not ts_str or not nonce or not sig:
|
|
return False
|
|
|
|
try:
|
|
ts = int(ts_str)
|
|
except ValueError:
|
|
return False
|
|
|
|
# Timestamp within 60 seconds
|
|
if abs(time.time() - ts) > 60:
|
|
return False
|
|
|
|
from services.config import get_settings
|
|
secret = str(get_settings().OPENCLAW_HMAC_SECRET or "").strip()
|
|
if not secret:
|
|
return False
|
|
|
|
# Same signature format as HTTP HMAC: GET|path|ts|nonce|sha256("")
|
|
body_digest = _hl.sha256(b"").hexdigest()
|
|
message = f"GET|/api/ai/channel/ws|{ts_str}|{nonce}|{body_digest}"
|
|
expected = _hm.new(
|
|
secret.encode("utf-8"),
|
|
message.encode("utf-8"),
|
|
_hl.sha256,
|
|
).hexdigest()
|
|
|
|
return _hm.compare_digest(sig, expected)
|
|
|
|
|
|
async def _ws_push_loop(ws: WebSocket):
|
|
"""Background coroutine that checks for tasks/alerts and pushes them."""
|
|
from services.openclaw_channel import channel
|
|
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(1) # Check every second (near-instant delivery)
|
|
|
|
# Poll for tasks (watchdog alerts, operator tasks)
|
|
tasks = channel.poll_tasks()
|
|
for task in tasks:
|
|
try:
|
|
await ws.send_json({"type": "task", "task": task})
|
|
except Exception:
|
|
return # Connection closed
|
|
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception:
|
|
await asyncio.sleep(3)
|
|
|
|
|
|
@router.websocket("/api/ai/channel/ws")
|
|
async def channel_websocket(ws: WebSocket):
|
|
"""Persistent bidirectional WebSocket channel for OpenClaw agents.
|
|
|
|
One connection = one Tor circuit kept warm. No polling overhead.
|
|
Commands execute immediately, tasks/alerts are pushed in real-time.
|
|
"""
|
|
# Auth: check HMAC on upgrade, or allow local connections
|
|
host = (ws.client.host or "").lower() if ws.client else ""
|
|
is_local = host in ("127.0.0.1", "::1", "localhost")
|
|
|
|
if not is_local and not await _verify_ws_hmac(ws):
|
|
# Must accept before sending close with custom code
|
|
await ws.accept()
|
|
await ws.close(code=4001, reason="HMAC authentication failed")
|
|
return
|
|
|
|
await ws.accept()
|
|
|
|
# Register this client for push delivery
|
|
async with _ws_clients_lock:
|
|
_ws_clients.append(ws)
|
|
|
|
# Start background push loop
|
|
push_task = asyncio.create_task(_ws_push_loop(ws))
|
|
|
|
from services.openclaw_channel import channel
|
|
from services.config import get_settings
|
|
|
|
access_tier = str(get_settings().OPENCLAW_ACCESS_TIER or "restricted").strip().lower()
|
|
|
|
# Send welcome message
|
|
try:
|
|
await ws.send_json({
|
|
"type": "connected",
|
|
"access_tier": access_tier,
|
|
"message": "WebSocket channel active. Send commands as JSON.",
|
|
})
|
|
except Exception:
|
|
push_task.cancel()
|
|
return
|
|
|
|
try:
|
|
while True:
|
|
raw = await ws.receive_text()
|
|
try:
|
|
msg = _json_mod.loads(raw)
|
|
except _json_mod.JSONDecodeError:
|
|
await ws.send_json({"type": "error", "detail": "invalid JSON"})
|
|
continue
|
|
|
|
msg_type = str(msg.get("type", "")).strip().lower()
|
|
|
|
if msg_type == "ping":
|
|
await ws.send_json({"type": "pong", "ts": time.time()})
|
|
continue
|
|
|
|
if msg_type == "command":
|
|
cmd = str(msg.get("cmd", "")).strip().lower()
|
|
args = msg.get("args") or {}
|
|
if not cmd:
|
|
await ws.send_json({"type": "error", "detail": "empty command"})
|
|
continue
|
|
|
|
# Execute command (same as HTTP channel)
|
|
result = channel.submit_command(cmd, args, access_tier)
|
|
await ws.send_json({
|
|
"type": "result",
|
|
"cmd": cmd,
|
|
"command_id": result.get("command_id"),
|
|
**result,
|
|
})
|
|
continue
|
|
|
|
await ws.send_json({
|
|
"type": "error",
|
|
"detail": f"unknown message type: {msg_type}. Use 'command' or 'ping'.",
|
|
})
|
|
|
|
except WebSocketDisconnect:
|
|
logger.info("OpenClaw WebSocket client disconnected")
|
|
except Exception as e:
|
|
logger.warning("OpenClaw WebSocket error: %s", e)
|
|
finally:
|
|
push_task.cancel()
|
|
async with _ws_clients_lock:
|
|
if ws in _ws_clients:
|
|
_ws_clients.remove(ws)
|
|
|
|
|
|
async def broadcast_to_agents(msg: dict[str, Any]):
|
|
"""Push a message to all connected WebSocket AND SSE agents.
|
|
|
|
Called by watchdog, correlation engine, or operator actions.
|
|
"""
|
|
# Push to WebSocket clients
|
|
async with _ws_clients_lock:
|
|
clients = list(_ws_clients)
|
|
|
|
dead: list[WebSocket] = []
|
|
for ws in clients:
|
|
try:
|
|
await ws.send_json(msg)
|
|
except Exception:
|
|
dead.append(ws)
|
|
|
|
if dead:
|
|
async with _ws_clients_lock:
|
|
for ws in dead:
|
|
if ws in _ws_clients:
|
|
_ws_clients.remove(ws)
|
|
|
|
# Push to SSE clients
|
|
event_type = msg.get("type", "alert")
|
|
await broadcast_to_sse_clients(event_type, msg)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Analysis Zones — OpenClaw-placed map overlays (delete from frontend)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.delete(
|
|
"/api/ai/analysis-zones/{zone_id}",
|
|
dependencies=[Depends(require_local_operator)],
|
|
)
|
|
@limiter.limit("30/minute")
|
|
async def delete_analysis_zone(request: Request, zone_id: str) -> dict:
|
|
"""Delete an analysis zone by ID (called from the map popup delete button)."""
|
|
from services.analysis_zone_store import delete_zone
|
|
|
|
removed = delete_zone(zone_id)
|
|
if not removed:
|
|
raise HTTPException(status_code=404, detail="Zone not found")
|
|
return {"ok": True, "removed": zone_id}
|
|
|
|
|
|
@router.get(
|
|
"/api/ai/analysis-zones",
|
|
dependencies=[Depends(require_openclaw_or_local)],
|
|
)
|
|
@limiter.limit("60/minute")
|
|
async def list_analysis_zones(request: Request) -> dict:
|
|
"""List all live analysis zones."""
|
|
from services.analysis_zone_store import list_zones
|
|
|
|
return {"ok": True, "zones": list_zones()}
|
|
|