mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(skills): consolidate find-nearby into maps as a single location skill
find-nearby and the (new) maps optional skill both used OpenStreetMap's Overpass + Nominatim to answer the same question — 'what's near this location?' — so shipping both would be duplicate code for overlapping capability. Consolidate into one active-by-default skill at skills/productivity/maps/ that is a strict superset of find-nearby. Moves + deletions: - optional-skills/productivity/maps/ → skills/productivity/maps/ (active, no install step needed) - skills/leisure/find-nearby/ → DELETED (fully superseded) Upgrades to maps_client.py so it covers everything find-nearby did: - Overpass server failover — tries overpass-api.de then overpass.kumi.systems so a single-mirror outage doesn't break the skill (new overpass_query helper, used by both nearby and bbox) - nearby now accepts --near "<address>" as a shortcut that auto-geocodes, so one command replaces the old 'search → copy coords → nearby' chain - nearby now accepts --category (repeatable) for multi-type queries in one call (e.g. --category restaurant --category bar), results merged and deduped by (osm_type, osm_id), sorted by distance, capped at --limit - Each nearby result now includes maps_url (clickable Google Maps search link) and directions_url (Google Maps directions from the search point — only when a ref point is known) - Promoted commonly-useful OSM tags to top-level fields on each result: cuisine, hours (opening_hours), phone, website — instead of forcing callers to dig into the raw tags dict SKILL.md: - Version bumped 1.1.0 → 1.2.0, description rewritten to lead with capability surface - New 'Working With Telegram Location Pins' section replacing find-nearby's equivalent workflow - metadata.hermes.supersedes: [find-nearby] so tooling can flag any lingering references to the old skill External references updated: - optional-skills/productivity/telephony/SKILL.md — related_skills find-nearby → maps - website/docs/reference/skills-catalog.md — removed the (now-empty) 'leisure' section, added 'maps' row under productivity - website/docs/user-guide/features/cron.md — find-nearby example usages swapped to maps - tests/tools/test_cronjob_tools.py, tests/hermes_cli/test_cron.py, tests/cron/test_scheduler.py — fixture string values swapped - cli.py:5290 — /cron help-hint example swapped Not touched: - RELEASE_v0.2.0.md — historical record, left intact E2E-verified live (Nominatim + Overpass, one query each): - nearby --near "Times Square" --category restaurant --category bar → 3 results, sorted by distance, all with maps_url, directions_url, cuisine, phone, website where OSM had the tags All 111 targeted tests pass across tests/cron/, tests/tools/, tests/hermes_cli/.
This commit is contained in:
parent
de491fdf0e
commit
ea0bd81b84
11 changed files with 222 additions and 331 deletions
2
cli.py
2
cli.py
|
|
@ -5287,7 +5287,7 @@ class HermesCLI:
|
|||
print(" /cron list")
|
||||
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
|
||||
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
|
||||
print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
|
||||
print(" /cron edit <job_id> --skill blogwatcher --skill maps")
|
||||
print(" /cron edit <job_id> --remove-skill blogwatcher")
|
||||
print(" /cron edit <job_id> --clear-skills")
|
||||
print(" /cron pause <job_id>")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ license: MIT
|
|||
metadata:
|
||||
hermes:
|
||||
tags: [telephony, phone, sms, mms, voice, twilio, bland.ai, vapi, calling, texting]
|
||||
related_skills: [find-nearby, google-workspace, agentmail]
|
||||
related_skills: [maps, google-workspace, agentmail]
|
||||
category: productivity
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
---
|
||||
name: find-nearby
|
||||
description: Find nearby places (restaurants, cafes, bars, pharmacies, etc.) using OpenStreetMap. Works with coordinates, addresses, cities, zip codes, or Telegram location pins. No API keys needed.
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [location, maps, nearby, places, restaurants, local]
|
||||
related_skills: []
|
||||
---
|
||||
|
||||
# Find Nearby — Local Place Discovery
|
||||
|
||||
Find restaurants, cafes, bars, pharmacies, and other places near any location. Uses OpenStreetMap (free, no API keys). Works with:
|
||||
|
||||
- **Coordinates** from Telegram location pins (latitude/longitude in conversation)
|
||||
- **Addresses** ("near 123 Main St, Springfield")
|
||||
- **Cities** ("restaurants in downtown Austin")
|
||||
- **Zip codes** ("pharmacies near 90210")
|
||||
- **Landmarks** ("cafes near Times Square")
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# By coordinates (from Telegram location pin or user-provided)
|
||||
python3 SKILL_DIR/scripts/find_nearby.py --lat <LAT> --lon <LON> --type restaurant --radius 1500
|
||||
|
||||
# By address, city, or landmark (auto-geocoded)
|
||||
python3 SKILL_DIR/scripts/find_nearby.py --near "Times Square, New York" --type cafe
|
||||
|
||||
# Multiple place types
|
||||
python3 SKILL_DIR/scripts/find_nearby.py --near "downtown austin" --type restaurant --type bar --limit 10
|
||||
|
||||
# JSON output
|
||||
python3 SKILL_DIR/scripts/find_nearby.py --near "90210" --type pharmacy --json
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `--lat`, `--lon` | Exact coordinates | — |
|
||||
| `--near` | Address, city, zip, or landmark (geocoded) | — |
|
||||
| `--type` | Place type (repeatable for multiple) | restaurant |
|
||||
| `--radius` | Search radius in meters | 1500 |
|
||||
| `--limit` | Max results | 15 |
|
||||
| `--json` | Machine-readable JSON output | off |
|
||||
|
||||
### Common Place Types
|
||||
|
||||
`restaurant`, `cafe`, `bar`, `pub`, `fast_food`, `pharmacy`, `hospital`, `bank`, `atm`, `fuel`, `parking`, `supermarket`, `convenience`, `hotel`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Get the location.** Look for coordinates (`latitude: ... / longitude: ...`) from a Telegram pin, or ask the user for an address/city/zip.
|
||||
|
||||
2. **Ask for preferences** (only if not already stated): place type, how far they're willing to go, any specifics (cuisine, "open now", etc.).
|
||||
|
||||
3. **Run the script** with appropriate flags. Use `--json` if you need to process results programmatically.
|
||||
|
||||
4. **Present results** with names, distances, and Google Maps links. If the user asked about hours or "open now," check the `hours` field in results — if missing or unclear, verify with `web_search`.
|
||||
|
||||
5. **For directions**, use the `directions_url` from results, or construct: `https://www.google.com/maps/dir/?api=1&origin=<LAT>,<LON>&destination=<LAT>,<LON>`
|
||||
|
||||
## Tips
|
||||
|
||||
- If results are sparse, widen the radius (1500 → 3000m)
|
||||
- For "open now" requests: check the `hours` field in results, cross-reference with `web_search` for accuracy since OSM hours aren't always complete
|
||||
- Zip codes alone can be ambiguous globally — prompt the user for country/state if results look wrong
|
||||
- The script uses OpenStreetMap data which is community-maintained; coverage varies by region
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Find nearby places using OpenStreetMap (Overpass + Nominatim). No API keys needed.
|
||||
|
||||
Usage:
|
||||
# By coordinates
|
||||
python find_nearby.py --lat 36.17 --lon -115.14 --type restaurant --radius 1500
|
||||
|
||||
# By address/city/zip (auto-geocoded)
|
||||
python find_nearby.py --near "Times Square, New York" --type cafe --radius 1000
|
||||
python find_nearby.py --near "90210" --type pharmacy
|
||||
|
||||
# Multiple types
|
||||
python find_nearby.py --lat 36.17 --lon -115.14 --type restaurant --type bar
|
||||
|
||||
# JSON output for programmatic use
|
||||
python find_nearby.py --near "downtown las vegas" --type restaurant --json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
OVERPASS_URLS = [
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
]
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
USER_AGENT = "HermesAgent/1.0 (find-nearby skill)"
|
||||
TIMEOUT = 15
|
||||
|
||||
|
||||
def _http_get(url: str) -> Any:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def _http_post(url: str, data: str) -> Any:
|
||||
req = urllib.request.Request(
|
||||
url, data=data.encode(), headers={"User-Agent": USER_AGENT}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Distance in meters between two coordinates."""
|
||||
R = 6_371_000
|
||||
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def geocode(query: str) -> tuple[float, float]:
|
||||
"""Convert address/city/zip to coordinates via Nominatim."""
|
||||
params = urllib.parse.urlencode({"q": query, "format": "json", "limit": 1})
|
||||
results = _http_get(f"{NOMINATIM_URL}?{params}")
|
||||
if not results:
|
||||
print(f"Error: Could not geocode '{query}'. Try a more specific address.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return float(results[0]["lat"]), float(results[0]["lon"])
|
||||
|
||||
|
||||
def find_nearby(lat: float, lon: float, types: list[str], radius: int = 1500, limit: int = 15) -> list[dict]:
|
||||
"""Query Overpass for nearby amenities."""
|
||||
# Build Overpass QL query
|
||||
type_filters = "".join(
|
||||
f'nwr["amenity"="{t}"](around:{radius},{lat},{lon});' for t in types
|
||||
)
|
||||
query = f"[out:json][timeout:{TIMEOUT}];({type_filters});out center tags;"
|
||||
|
||||
# Try each Overpass server
|
||||
data = None
|
||||
for url in OVERPASS_URLS:
|
||||
try:
|
||||
data = _http_post(url, f"data={urllib.parse.quote(query)}")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Parse results
|
||||
places = []
|
||||
for el in data.get("elements", []):
|
||||
tags = el.get("tags", {})
|
||||
name = tags.get("name")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
# Get coordinates (nodes have lat/lon directly, ways/relations use center)
|
||||
plat = el.get("lat") or (el.get("center", {}) or {}).get("lat")
|
||||
plon = el.get("lon") or (el.get("center", {}) or {}).get("lon")
|
||||
if plat is None or plon is None:
|
||||
continue
|
||||
|
||||
dist = haversine(lat, lon, plat, plon)
|
||||
|
||||
place = {
|
||||
"name": name,
|
||||
"type": tags.get("amenity", ""),
|
||||
"distance_m": round(dist),
|
||||
"lat": plat,
|
||||
"lon": plon,
|
||||
"maps_url": f"https://www.google.com/maps/search/?api=1&query={plat},{plon}",
|
||||
"directions_url": f"https://www.google.com/maps/dir/?api=1&origin={lat},{lon}&destination={plat},{plon}",
|
||||
}
|
||||
|
||||
# Add useful optional fields
|
||||
if tags.get("cuisine"):
|
||||
place["cuisine"] = tags["cuisine"]
|
||||
if tags.get("opening_hours"):
|
||||
place["hours"] = tags["opening_hours"]
|
||||
if tags.get("phone"):
|
||||
place["phone"] = tags["phone"]
|
||||
if tags.get("website"):
|
||||
place["website"] = tags["website"]
|
||||
if tags.get("addr:street"):
|
||||
addr_parts = [tags.get("addr:housenumber", ""), tags.get("addr:street", "")]
|
||||
if tags.get("addr:city"):
|
||||
addr_parts.append(tags["addr:city"])
|
||||
place["address"] = " ".join(p for p in addr_parts if p)
|
||||
|
||||
places.append(place)
|
||||
|
||||
# Sort by distance, limit results
|
||||
places.sort(key=lambda p: p["distance_m"])
|
||||
return places[:limit]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Find nearby places via OpenStreetMap")
|
||||
parser.add_argument("--lat", type=float, help="Latitude")
|
||||
parser.add_argument("--lon", type=float, help="Longitude")
|
||||
parser.add_argument("--near", type=str, help="Address, city, or zip code (geocoded automatically)")
|
||||
parser.add_argument("--type", action="append", dest="types", default=[], help="Place type (restaurant, cafe, bar, pharmacy, etc.)")
|
||||
parser.add_argument("--radius", type=int, default=1500, help="Search radius in meters (default: 1500)")
|
||||
parser.add_argument("--limit", type=int, default=15, help="Max results (default: 15)")
|
||||
parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve coordinates
|
||||
if args.near:
|
||||
lat, lon = geocode(args.near)
|
||||
elif args.lat is not None and args.lon is not None:
|
||||
lat, lon = args.lat, args.lon
|
||||
else:
|
||||
print("Error: Provide --lat/--lon or --near", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not args.types:
|
||||
args.types = ["restaurant"]
|
||||
|
||||
places = find_nearby(lat, lon, args.types, args.radius, args.limit)
|
||||
|
||||
if args.json_output:
|
||||
print(json.dumps({"origin": {"lat": lat, "lon": lon}, "results": places, "count": len(places)}, indent=2))
|
||||
else:
|
||||
if not places:
|
||||
print(f"No {'/'.join(args.types)} found within {args.radius}m")
|
||||
return
|
||||
print(f"Found {len(places)} places within {args.radius}m:\n")
|
||||
for i, p in enumerate(places, 1):
|
||||
dist_str = f"{p['distance_m']}m" if p["distance_m"] < 1000 else f"{p['distance_m']/1000:.1f}km"
|
||||
print(f" {i}. {p['name']} ({p['type']}) — {dist_str}")
|
||||
if p.get("cuisine"):
|
||||
print(f" Cuisine: {p['cuisine']}")
|
||||
if p.get("hours"):
|
||||
print(f" Hours: {p['hours']}")
|
||||
if p.get("address"):
|
||||
print(f" Address: {p['address']}")
|
||||
print(f" Map: {p['maps_url']}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,17 +1,20 @@
|
|||
---
|
||||
name: maps
|
||||
description: >
|
||||
Geocoding, reverse geocoding, nearby POI search (44 categories),
|
||||
distance/routing, turn-by-turn directions, timezone lookup, bounding box
|
||||
search, and area info. Uses OpenStreetMap + Overpass + OSRM. Free, no API key.
|
||||
version: 1.1.0
|
||||
Location intelligence — geocode a place, reverse-geocode coordinates,
|
||||
find nearby places (44 POI categories), driving/walking/cycling
|
||||
distance + time, turn-by-turn directions, timezone lookup, bounding
|
||||
box + area for a named place, and POI search within a rectangle.
|
||||
Uses OpenStreetMap + Overpass + OSRM. Free, no API key.
|
||||
version: 1.2.0
|
||||
author: Mibayy
|
||||
license: MIT
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [maps, geocoding, places, routing, distance, directions, openstreetmap, nominatim, overpass, osrm]
|
||||
tags: [maps, geocoding, places, routing, distance, directions, nearby, location, openstreetmap, nominatim, overpass, osrm]
|
||||
category: productivity
|
||||
requires_toolsets: [terminal]
|
||||
supersedes: [find-nearby]
|
||||
---
|
||||
|
||||
# Maps Skill
|
||||
|
|
@ -21,21 +24,26 @@ categories, zero dependencies (Python stdlib only), no API key required.
|
|||
|
||||
Data sources: OpenStreetMap/Nominatim, Overpass API, OSRM, TimeAPI.io.
|
||||
|
||||
This skill supersedes the old `find-nearby` skill — all of find-nearby's
|
||||
functionality is covered by the `nearby` command below, with the same
|
||||
`--near "<place>"` shortcut and multi-category support.
|
||||
|
||||
## When to Use
|
||||
|
||||
- User wants coordinates for a place name
|
||||
- User has coordinates and wants the address
|
||||
- User asks for nearby restaurants, hospitals, pharmacies, hotels, etc.
|
||||
- User wants driving/walking/cycling distance or travel time
|
||||
- User wants turn-by-turn directions between two places
|
||||
- User wants timezone information for a location
|
||||
- User wants to search for POIs within a geographic area
|
||||
- User sends a Telegram location pin (latitude/longitude in the message) → `nearby`
|
||||
- User wants coordinates for a place name → `search`
|
||||
- User has coordinates and wants the address → `reverse`
|
||||
- User asks for nearby restaurants, hospitals, pharmacies, hotels, etc. → `nearby`
|
||||
- User wants driving/walking/cycling distance or travel time → `distance`
|
||||
- User wants turn-by-turn directions between two places → `directions`
|
||||
- User wants timezone information for a location → `timezone`
|
||||
- User wants to search for POIs within a geographic area → `area` + `bbox`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Python 3.8+ (stdlib only — no pip installs needed).
|
||||
|
||||
Script path after install: `~/.hermes/skills/maps/scripts/maps_client.py`
|
||||
Script path: `~/.hermes/skills/maps/scripts/maps_client.py`
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -63,9 +71,16 @@ Returns: full address breakdown (street, city, state, country, postcode).
|
|||
### nearby — Find places by category
|
||||
|
||||
```bash
|
||||
# By coordinates (from a Telegram location pin, for example)
|
||||
python3 $MAPS nearby 48.8584 2.2945 restaurant --limit 10
|
||||
python3 $MAPS nearby 40.7128 -74.0060 hospital --radius 2000
|
||||
python3 $MAPS nearby 51.5074 -0.1278 cafe --limit 5 --radius 300
|
||||
|
||||
# By address / city / zip / landmark — --near auto-geocodes
|
||||
python3 $MAPS nearby --near "Times Square, New York" --category cafe
|
||||
python3 $MAPS nearby --near "90210" --category pharmacy
|
||||
|
||||
# Multiple categories merged into one query
|
||||
python3 $MAPS nearby --near "downtown austin" --category restaurant --category bar --limit 10
|
||||
```
|
||||
|
||||
44 categories: restaurant, cafe, bar, hospital, pharmacy, hotel, supermarket,
|
||||
|
|
@ -75,6 +90,11 @@ synagogue, dentist, doctor, cinema, theatre, gym, swimming_pool, post_office,
|
|||
convenience_store, bakery, bookshop, laundry, car_wash, car_rental,
|
||||
bicycle_rental, taxi, veterinary, zoo, playground, stadium, nightclub.
|
||||
|
||||
Each result includes: `name`, `address`, `lat`/`lon`, `distance_m`,
|
||||
`maps_url` (clickable Google Maps link), `directions_url` (Google Maps
|
||||
directions from the search point), and promoted tags when available —
|
||||
`cuisine`, `hours` (opening_hours), `phone`, `website`.
|
||||
|
||||
### distance — Travel distance and time
|
||||
|
||||
```bash
|
||||
|
|
@ -124,11 +144,31 @@ python3 $MAPS bbox 40.75 -74.00 40.77 -73.98 restaurant --limit 20
|
|||
Finds POIs within a geographic rectangle. Use `area` first to get the
|
||||
bounding box coordinates for a named place.
|
||||
|
||||
## Working With Telegram Location Pins
|
||||
|
||||
When a user sends a location pin, the message contains `latitude:` and
|
||||
`longitude:` fields. Extract those and pass them straight to `nearby`:
|
||||
|
||||
```bash
|
||||
# User sent a pin at 36.17, -115.14 and asked "find cafes nearby"
|
||||
python3 $MAPS nearby 36.17 -115.14 cafe --radius 1500
|
||||
```
|
||||
|
||||
Present results as a numbered list with names, distances, and the
|
||||
`maps_url` field so the user gets a tap-to-open link in chat. For "open
|
||||
now?" questions, check the `hours` field; if missing or unclear, verify
|
||||
with `web_search` since OSM hours are community-maintained and not always
|
||||
current.
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
**"Find Italian restaurants near the Colosseum":**
|
||||
1. `search "Colosseum Rome"` → get lat/lon
|
||||
2. `nearby LAT LON restaurant --radius 500`
|
||||
1. `nearby --near "Colosseum Rome" --category restaurant --radius 500`
|
||||
— one command, auto-geocoded
|
||||
|
||||
**"What's near this location pin they sent?":**
|
||||
1. Extract lat/lon from the Telegram message
|
||||
2. `nearby LAT LON cafe --radius 1500`
|
||||
|
||||
**"How do I walk from hotel to conference center?":**
|
||||
1. `directions "Hotel Name" --to "Conference Center" --mode walking`
|
||||
|
|
@ -140,14 +180,19 @@ bounding box coordinates for a named place.
|
|||
## Pitfalls
|
||||
|
||||
- Nominatim ToS: max 1 req/s (handled automatically by the script)
|
||||
- `nearby` requires lat/lon — use `search` first to get coordinates
|
||||
- `nearby` requires lat/lon OR `--near "<address>"` — one of the two is needed
|
||||
- OSRM routing coverage is best for Europe and North America
|
||||
- Overpass API can be slow during peak hours (script retries automatically)
|
||||
- Overpass API can be slow during peak hours; the script automatically
|
||||
falls back between mirrors (overpass-api.de → overpass.kumi.systems)
|
||||
- `distance` and `directions` use `--to` flag for the destination (not positional)
|
||||
- If a zip code alone gives ambiguous results globally, include country/state
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
python3 ~/.hermes/skills/maps/scripts/maps_client.py search "Statue of Liberty"
|
||||
# Should return lat ~40.689, lon ~-74.044
|
||||
|
||||
python3 ~/.hermes/skills/maps/scripts/maps_client.py nearby --near "Times Square" --category restaurant --limit 3
|
||||
# Should return a list of restaurants within ~500m of Times Square
|
||||
```
|
||||
|
|
@ -34,7 +34,14 @@ DATA_SOURCE = "OpenStreetMap/Nominatim"
|
|||
|
||||
NOMINATIM_SEARCH = "https://nominatim.openstreetmap.org/search"
|
||||
NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse"
|
||||
OVERPASS_API = "https://overpass-api.de/api/interpreter"
|
||||
# Public Overpass endpoints. We try them in order so a single server
|
||||
# outage doesn't break the skill — kumi.systems is a well-known mirror.
|
||||
OVERPASS_URLS = [
|
||||
"https://overpass-api.de/api/interpreter",
|
||||
"https://overpass.kumi.systems/api/interpreter",
|
||||
]
|
||||
# Backward-compat alias for any caller that imports OVERPASS_API directly.
|
||||
OVERPASS_API = OVERPASS_URLS[0]
|
||||
OSRM_BASE = "https://router.project-osrm.org/route/v1"
|
||||
TIMEAPI_BASE = "https://timeapi.io/api/timezone/coordinate"
|
||||
|
||||
|
|
@ -246,6 +253,30 @@ def http_post(url, data_str, retries=MAX_RETRIES):
|
|||
error_exit(f"POST failed after {retries} attempts. Last error: {last_error}")
|
||||
|
||||
|
||||
def overpass_query(query):
|
||||
"""POST an Overpass QL query, trying each URL in OVERPASS_URLS in turn.
|
||||
|
||||
A single public Overpass mirror can be rate-limited or down; trying the
|
||||
next mirror before giving up turns a flaky outage into a retry. Returns
|
||||
parsed JSON. Falls through to error_exit if every mirror fails.
|
||||
"""
|
||||
post_data = "data=" + urllib.parse.quote(query)
|
||||
last_error = None
|
||||
for url in OVERPASS_URLS:
|
||||
try:
|
||||
return http_post(url, post_data, retries=1)
|
||||
except SystemExit:
|
||||
# error_exit inside http_post — keep trying the next mirror.
|
||||
last_error = f"mirror {url} exhausted retries"
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = f"{url}: {exc}"
|
||||
continue
|
||||
error_exit(
|
||||
f"All Overpass mirrors failed. Last error: {last_error or 'unknown'}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geo math
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -379,6 +410,9 @@ def parse_overpass_elements(elements, ref_lat=None, ref_lon=None):
|
|||
"lon": el_lon,
|
||||
"osm_type": el.get("type", ""),
|
||||
"osm_id": el.get("id", ""),
|
||||
# Clickable Google Maps link so the agent can render a tap-to-open
|
||||
# URL in chat without composing one downstream.
|
||||
"maps_url": f"https://www.google.com/maps/search/?api=1&query={el_lat},{el_lon}",
|
||||
"tags": {
|
||||
k: v for k, v in tags.items()
|
||||
if k not in ("name", "name:en",
|
||||
|
|
@ -386,9 +420,27 @@ def parse_overpass_elements(elements, ref_lat=None, ref_lon=None):
|
|||
},
|
||||
}
|
||||
|
||||
# Promote commonly-useful tags to top-level fields so agents can
|
||||
# reference them without digging into the raw ``tags`` dict.
|
||||
for src_key, dst_key in (
|
||||
("cuisine", "cuisine"),
|
||||
("opening_hours", "hours"),
|
||||
("phone", "phone"),
|
||||
("website", "website"),
|
||||
):
|
||||
val = tags.get(src_key)
|
||||
if val:
|
||||
place[dst_key] = val
|
||||
|
||||
if ref_lat is not None and ref_lon is not None:
|
||||
dist_m = haversine_m(ref_lat, ref_lon, el_lat, el_lon)
|
||||
place["distance_m"] = round(dist_m, 1)
|
||||
# With a reference point we can also hand back a directions URL.
|
||||
place["directions_url"] = (
|
||||
f"https://www.google.com/maps/dir/?api=1"
|
||||
f"&origin={ref_lat},{ref_lon}"
|
||||
f"&destination={el_lat},{el_lon}"
|
||||
)
|
||||
|
||||
places.append(place)
|
||||
|
||||
|
|
@ -499,47 +551,84 @@ def cmd_reverse(args):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cmd_nearby(args):
|
||||
"""Find nearby POIs using the Overpass API."""
|
||||
"""Find nearby POIs using the Overpass API.
|
||||
|
||||
Accepts either explicit coordinates (``lat``/``lon``) or a free-form
|
||||
address via ``--near`` (auto-geocoded through Nominatim). Supports
|
||||
multiple categories in one call — results are merged, deduplicated
|
||||
by ``osm_type+osm_id``, sorted by distance.
|
||||
"""
|
||||
# Resolve the center point. --near takes precedence if provided so the
|
||||
# agent can ask "cafes near Times Square" in one command without having
|
||||
# to geocode first.
|
||||
if getattr(args, "near", None):
|
||||
near_query = " ".join(args.near).strip() if isinstance(args.near, list) else str(args.near).strip()
|
||||
if not near_query:
|
||||
error_exit("--near must be a non-empty address or place name.")
|
||||
lat, lon, _ = geocode_single(near_query)
|
||||
else:
|
||||
try:
|
||||
lat = float(args.lat)
|
||||
lon = float(args.lon)
|
||||
except ValueError:
|
||||
error_exit("LAT and LON must be numeric values.")
|
||||
except (TypeError, ValueError):
|
||||
error_exit("Provide numeric LAT and LON, or use --near \"<address>\".")
|
||||
|
||||
category = args.category.lower()
|
||||
if category not in CATEGORY_TAGS:
|
||||
# Categories: support both legacy single positional ``category`` and the
|
||||
# new repeatable ``--category`` flag. Users can ask for multiple place
|
||||
# types in one query.
|
||||
categories = []
|
||||
if getattr(args, "category_list", None):
|
||||
categories.extend(args.category_list)
|
||||
if getattr(args, "category", None):
|
||||
categories.append(args.category)
|
||||
# Deduplicate, preserve order, lower-case.
|
||||
categories = list(dict.fromkeys(c.lower() for c in categories if c))
|
||||
if not categories:
|
||||
error_exit("Provide at least one category (positional or --category).")
|
||||
unknown = [c for c in categories if c not in CATEGORY_TAGS]
|
||||
if unknown:
|
||||
error_exit(
|
||||
f"Unknown category '{category}'. "
|
||||
f"Unknown categor{'ies' if len(unknown) > 1 else 'y'} "
|
||||
f"{', '.join(repr(c) for c in unknown)}. "
|
||||
f"Valid categories: {', '.join(VALID_CATEGORIES)}"
|
||||
)
|
||||
|
||||
radius = int(args.radius)
|
||||
limit = int(args.limit)
|
||||
|
||||
if radius <= 0:
|
||||
error_exit("Radius must be a positive integer (metres).")
|
||||
if limit <= 0:
|
||||
error_exit("Limit must be a positive integer.")
|
||||
|
||||
# Query each category against the Overpass fallback chain, merge results,
|
||||
# dedupe by OSM identity so POIs tagged under multiple categories don't
|
||||
# appear twice.
|
||||
merged = {}
|
||||
for category in categories:
|
||||
tag_key, tag_val = CATEGORY_TAGS[category]
|
||||
religion = RELIGION_FILTER.get(category)
|
||||
query = build_overpass_nearby(tag_key, tag_val, lat, lon, radius, limit,
|
||||
religion=religion)
|
||||
|
||||
post_data = "data=" + urllib.parse.quote(query)
|
||||
raw = http_post(OVERPASS_API, post_data)
|
||||
|
||||
raw = overpass_query(query)
|
||||
elements = raw.get("elements", [])
|
||||
places = parse_overpass_elements(elements, ref_lat=lat, ref_lon=lon)
|
||||
for place in parse_overpass_elements(elements, ref_lat=lat, ref_lon=lon):
|
||||
place["category"] = category
|
||||
key = (place.get("osm_type", ""), place.get("osm_id", ""))
|
||||
# Prefer the entry that actually has a distance_m attached (first
|
||||
# pass through the ref_lat/ref_lon branch), then first-seen wins.
|
||||
if key not in merged:
|
||||
merged[key] = place
|
||||
|
||||
# Add category to each result
|
||||
for p in places:
|
||||
p["category"] = category
|
||||
# Sort merged by distance when we have ref lat/lon, then cap at ``limit``.
|
||||
places = sorted(
|
||||
merged.values(),
|
||||
key=lambda p: p.get("distance_m", float("inf")),
|
||||
)[:limit]
|
||||
|
||||
print_json({
|
||||
"center_lat": lat,
|
||||
"center_lon": lon,
|
||||
"category": category,
|
||||
"categories": categories,
|
||||
"radius_m": radius,
|
||||
"count": len(places),
|
||||
"results": places,
|
||||
|
|
@ -861,8 +950,7 @@ def cmd_bbox(args):
|
|||
query = build_overpass_bbox(tag_key, tag_val, south, west, north, east,
|
||||
limit, religion=religion)
|
||||
|
||||
post_data = "data=" + urllib.parse.quote(query)
|
||||
raw = http_post(OVERPASS_API, post_data)
|
||||
raw = overpass_query(query)
|
||||
|
||||
elements = raw.get("elements", [])
|
||||
|
||||
|
|
@ -998,15 +1086,33 @@ def build_parser():
|
|||
help="Find nearby places of a given category.",
|
||||
description=(
|
||||
"Find points of interest near a location using the Overpass API.\n"
|
||||
"Provide either LAT/LON, or use --near \"<address>\" to auto-geocode.\n"
|
||||
"Categories can be specified positionally OR repeated via --category\n"
|
||||
"to merge multiple types in one query (e.g. --category bar --category cafe).\n"
|
||||
f"Categories: {', '.join(VALID_CATEGORIES)}"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
p_nearby.add_argument("lat", help="Center latitude (decimal degrees).")
|
||||
p_nearby.add_argument("lon", help="Center longitude (decimal degrees).")
|
||||
p_nearby.add_argument(
|
||||
"category",
|
||||
help="POI category (use --help to see full list).",
|
||||
"lat", nargs="?", default=None,
|
||||
help="Center latitude (decimal degrees). Omit if using --near.",
|
||||
)
|
||||
p_nearby.add_argument(
|
||||
"lon", nargs="?", default=None,
|
||||
help="Center longitude (decimal degrees). Omit if using --near.",
|
||||
)
|
||||
p_nearby.add_argument(
|
||||
"category", nargs="?", default=None,
|
||||
help="POI category (use --help for full list). Omit if using --category flags.",
|
||||
)
|
||||
p_nearby.add_argument(
|
||||
"--near", nargs="+", metavar="PLACE",
|
||||
help="Address, city, or landmark to search around (geocoded via Nominatim).",
|
||||
)
|
||||
p_nearby.add_argument(
|
||||
"--category", action="append", dest="category_list", default=[],
|
||||
metavar="CAT",
|
||||
help="POI category (repeatable — adds a type to the search).",
|
||||
)
|
||||
p_nearby.add_argument(
|
||||
"--radius", "-r",
|
||||
|
|
@ -1024,7 +1024,7 @@ class TestRunJobSkillBacked:
|
|||
"id": "multi-skill-job",
|
||||
"name": "multi skill test",
|
||||
"prompt": "Combine the results.",
|
||||
"skills": ["blogwatcher", "find-nearby"],
|
||||
"skills": ["blogwatcher", "maps"],
|
||||
}
|
||||
|
||||
fake_db = MagicMock()
|
||||
|
|
@ -1057,12 +1057,12 @@ class TestRunJobSkillBacked:
|
|||
assert error is None
|
||||
assert final_response == "ok"
|
||||
assert skill_view_mock.call_count == 2
|
||||
assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "find-nearby"]
|
||||
assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "maps"]
|
||||
|
||||
prompt_arg = mock_agent.run_conversation.call_args.args[0]
|
||||
assert prompt_arg.index("blogwatcher") < prompt_arg.index("find-nearby")
|
||||
assert prompt_arg.index("blogwatcher") < prompt_arg.index("maps")
|
||||
assert "Instructions for blogwatcher." in prompt_arg
|
||||
assert "Instructions for find-nearby." in prompt_arg
|
||||
assert "Instructions for maps." in prompt_arg
|
||||
assert "Combine the results." in prompt_arg
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -54,12 +54,12 @@ class TestCronCommandLifecycle:
|
|||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=["find-nearby", "blogwatcher"],
|
||||
skills=["maps", "blogwatcher"],
|
||||
clear_skills=False,
|
||||
)
|
||||
)
|
||||
updated = get_job(job["id"])
|
||||
assert updated["skills"] == ["find-nearby", "blogwatcher"]
|
||||
assert updated["skills"] == ["maps", "blogwatcher"]
|
||||
assert updated["name"] == "Edited Job"
|
||||
assert updated["prompt"] == "Revised prompt"
|
||||
assert updated["schedule_display"] == "every 120m"
|
||||
|
|
@ -95,7 +95,7 @@ class TestCronCommandLifecycle:
|
|||
deliver=None,
|
||||
repeat=None,
|
||||
skill=None,
|
||||
skills=["blogwatcher", "find-nearby"],
|
||||
skills=["blogwatcher", "maps"],
|
||||
)
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
|
|
@ -103,5 +103,5 @@ class TestCronCommandLifecycle:
|
|||
|
||||
jobs = list_jobs()
|
||||
assert len(jobs) == 1
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"]
|
||||
assert jobs[0]["skills"] == ["blogwatcher", "maps"]
|
||||
assert jobs[0]["name"] == "Skill combo"
|
||||
|
|
|
|||
|
|
@ -192,23 +192,23 @@ class TestUnifiedCronjobTool:
|
|||
result = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
skills=["blogwatcher", "find-nearby"],
|
||||
skills=["blogwatcher", "maps"],
|
||||
prompt="Use both skills and combine the result.",
|
||||
schedule="every 1h",
|
||||
name="Combo job",
|
||||
)
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["skills"] == ["blogwatcher", "find-nearby"]
|
||||
assert result["skills"] == ["blogwatcher", "maps"]
|
||||
|
||||
listing = json.loads(cronjob(action="list"))
|
||||
assert listing["jobs"][0]["skills"] == ["blogwatcher", "find-nearby"]
|
||||
assert listing["jobs"][0]["skills"] == ["blogwatcher", "maps"]
|
||||
|
||||
def test_multi_skill_default_name_prefers_prompt_when_present(self):
|
||||
result = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
skills=["blogwatcher", "find-nearby"],
|
||||
skills=["blogwatcher", "maps"],
|
||||
prompt="Use both skills and combine the result.",
|
||||
schedule="every 1h",
|
||||
)
|
||||
|
|
@ -220,7 +220,7 @@ class TestUnifiedCronjobTool:
|
|||
created = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
skills=["blogwatcher", "find-nearby"],
|
||||
skills=["blogwatcher", "maps"],
|
||||
prompt="Use both skills and combine the result.",
|
||||
schedule="every 1h",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -100,14 +100,6 @@ GitHub workflow skills for managing repositories, pull requests, code reviews, i
|
|||
| `github-pr-workflow` | Full pull request lifecycle — create branches, commit changes, open PRs, monitor CI status, auto-fix failures, and merge. Works with gh CLI or falls back to git + GitHub REST API via curl. | `github/github-pr-workflow` |
|
||||
| `github-repo-management` | Clone, create, fork, configure, and manage GitHub repositories. Manage remotes, secrets, releases, and workflows. Works with gh CLI or falls back to git + GitHub REST API via curl. | `github/github-repo-management` |
|
||||
|
||||
## leisure
|
||||
|
||||
Skills for discovery and everyday tasks.
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| `find-nearby` | Find nearby places (restaurants, cafes, bars, pharmacies, etc.) using OpenStreetMap. Works with coordinates, addresses, cities, zip codes, or Telegram location pins. No API keys needed. | `leisure/find-nearby` |
|
||||
|
||||
## mcp
|
||||
|
||||
Skills for working with MCP (Model Context Protocol) servers, tools, and integrations.
|
||||
|
|
@ -198,6 +190,7 @@ Skills for document creation, presentations, spreadsheets, and other productivit
|
|||
|-------|-------------|------|
|
||||
| `google-workspace` | Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses Hermes-managed OAuth2 setup, prefers the Google Workspace CLI (`gws`) when available for broader API coverage, and falls back to the Python client libraries otherwise. | `productivity/google-workspace` |
|
||||
| `linear` | Manage Linear issues, projects, and teams via the GraphQL API. Create, update, search, and organize issues. Uses API key auth (no OAuth needed). All operations via curl — no dependencies. | `productivity/linear` |
|
||||
| `maps` | Location intelligence — geocode, reverse-geocode, nearby POI search (44 categories, coordinates or address via `--near`), driving/walking/cycling distance + time, turn-by-turn directions, timezone, bounding box + area, POI search in a rectangle. Uses OpenStreetMap + Overpass + OSRM. No API key needed. Telegram location-pin friendly. | `productivity/maps` |
|
||||
| `nano-pdf` | Edit PDFs with natural-language instructions using the nano-pdf CLI. Modify text, fix typos, update titles, and make content changes to specific pages without manual editing. | `productivity/nano-pdf` |
|
||||
| `notion` | Notion API for creating and managing pages, databases, and blocks via curl. Search, create, update, and query Notion workspaces directly from the terminal. | `productivity/notion` |
|
||||
| `ocr-and-documents` | Extract text from PDFs and scanned documents. Use web_extract for remote URLs, pymupdf for local text-based PDFs, marker-pdf for OCR/scanned docs. For DOCX use python-docx, for PPTX see the powerpoint skill. | `productivity/ocr-and-documents` |
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron
|
|||
/cron add 30m "Remind me to check the build"
|
||||
/cron add "every 2h" "Check server status"
|
||||
/cron add "every 1h" "Summarize new feed items" --skill blogwatcher
|
||||
/cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill find-nearby
|
||||
/cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill maps
|
||||
```
|
||||
|
||||
### From the standalone CLI
|
||||
|
|
@ -40,7 +40,7 @@ hermes cron create "every 2h" "Check server status"
|
|||
hermes cron create "every 1h" "Summarize new feed items" --skill blogwatcher
|
||||
hermes cron create "every 1h" "Use both skills and combine the result" \
|
||||
--skill blogwatcher \
|
||||
--skill find-nearby \
|
||||
--skill maps \
|
||||
--name "Skill combo"
|
||||
```
|
||||
|
||||
|
|
@ -77,7 +77,7 @@ Skills are loaded in order. The prompt becomes the task instruction layered on t
|
|||
```python
|
||||
cronjob(
|
||||
action="create",
|
||||
skills=["blogwatcher", "find-nearby"],
|
||||
skills=["blogwatcher", "maps"],
|
||||
prompt="Look for new local events and interesting nearby places, then combine them into one short brief.",
|
||||
schedule="every 6h",
|
||||
name="Local brief",
|
||||
|
|
@ -95,7 +95,7 @@ You do not need to delete and recreate jobs just to change them.
|
|||
```bash
|
||||
/cron edit <job_id> --schedule "every 4h"
|
||||
/cron edit <job_id> --prompt "Use the revised task"
|
||||
/cron edit <job_id> --skill blogwatcher --skill find-nearby
|
||||
/cron edit <job_id> --skill blogwatcher --skill maps
|
||||
/cron edit <job_id> --remove-skill blogwatcher
|
||||
/cron edit <job_id> --clear-skills
|
||||
```
|
||||
|
|
@ -105,8 +105,8 @@ You do not need to delete and recreate jobs just to change them.
|
|||
```bash
|
||||
hermes cron edit <job_id> --schedule "every 4h"
|
||||
hermes cron edit <job_id> --prompt "Use the revised task"
|
||||
hermes cron edit <job_id> --skill blogwatcher --skill find-nearby
|
||||
hermes cron edit <job_id> --add-skill find-nearby
|
||||
hermes cron edit <job_id> --skill blogwatcher --skill maps
|
||||
hermes cron edit <job_id> --add-skill maps
|
||||
hermes cron edit <job_id> --remove-skill blogwatcher
|
||||
hermes cron edit <job_id> --clear-skills
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue