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:
Teknium 2026-04-19 05:17:39 -07:00 committed by Teknium
parent de491fdf0e
commit ea0bd81b84
11 changed files with 222 additions and 331 deletions

2
cli.py
View file

@ -5287,7 +5287,7 @@ class HermesCLI:
print(" /cron list") print(" /cron list")
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]') 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> --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> --remove-skill blogwatcher")
print(" /cron edit <job_id> --clear-skills") print(" /cron edit <job_id> --clear-skills")
print(" /cron pause <job_id>") print(" /cron pause <job_id>")

View file

@ -7,7 +7,7 @@ license: MIT
metadata: metadata:
hermes: hermes:
tags: [telephony, phone, sms, mms, voice, twilio, bland.ai, vapi, calling, texting] 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 category: productivity
--- ---

View file

@ -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

View file

@ -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()

View file

@ -1,17 +1,20 @@
--- ---
name: maps name: maps
description: > description: >
Geocoding, reverse geocoding, nearby POI search (44 categories), Location intelligence — geocode a place, reverse-geocode coordinates,
distance/routing, turn-by-turn directions, timezone lookup, bounding box find nearby places (44 POI categories), driving/walking/cycling
search, and area info. Uses OpenStreetMap + Overpass + OSRM. Free, no API key. distance + time, turn-by-turn directions, timezone lookup, bounding
version: 1.1.0 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 author: Mibayy
license: MIT license: MIT
metadata: metadata:
hermes: 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 category: productivity
requires_toolsets: [terminal] requires_toolsets: [terminal]
supersedes: [find-nearby]
--- ---
# Maps Skill # 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. 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 ## When to Use
- User wants coordinates for a place name - User sends a Telegram location pin (latitude/longitude in the message) → `nearby`
- User has coordinates and wants the address - User wants coordinates for a place name → `search`
- User asks for nearby restaurants, hospitals, pharmacies, hotels, etc. - User has coordinates and wants the address → `reverse`
- User wants driving/walking/cycling distance or travel time - User asks for nearby restaurants, hospitals, pharmacies, hotels, etc. → `nearby`
- User wants turn-by-turn directions between two places - User wants driving/walking/cycling distance or travel time → `distance`
- User wants timezone information for a location - User wants turn-by-turn directions between two places → `directions`
- User wants to search for POIs within a geographic area - User wants timezone information for a location → `timezone`
- User wants to search for POIs within a geographic area → `area` + `bbox`
## Prerequisites ## Prerequisites
Python 3.8+ (stdlib only — no pip installs needed). 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 ## Commands
@ -63,9 +71,16 @@ Returns: full address breakdown (street, city, state, country, postcode).
### nearby — Find places by category ### nearby — Find places by category
```bash ```bash
# By coordinates (from a Telegram location pin, for example)
python3 $MAPS nearby 48.8584 2.2945 restaurant --limit 10 python3 $MAPS nearby 48.8584 2.2945 restaurant --limit 10
python3 $MAPS nearby 40.7128 -74.0060 hospital --radius 2000 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, 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, convenience_store, bakery, bookshop, laundry, car_wash, car_rental,
bicycle_rental, taxi, veterinary, zoo, playground, stadium, nightclub. 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 ### distance — Travel distance and time
```bash ```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 Finds POIs within a geographic rectangle. Use `area` first to get the
bounding box coordinates for a named place. 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 ## Workflow Examples
**"Find Italian restaurants near the Colosseum":** **"Find Italian restaurants near the Colosseum":**
1. `search "Colosseum Rome"` → get lat/lon 1. `nearby --near "Colosseum Rome" --category restaurant --radius 500`
2. `nearby LAT LON 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?":** **"How do I walk from hotel to conference center?":**
1. `directions "Hotel Name" --to "Conference Center" --mode walking` 1. `directions "Hotel Name" --to "Conference Center" --mode walking`
@ -140,14 +180,19 @@ bounding box coordinates for a named place.
## Pitfalls ## Pitfalls
- Nominatim ToS: max 1 req/s (handled automatically by the script) - 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 - 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) - `distance` and `directions` use `--to` flag for the destination (not positional)
- If a zip code alone gives ambiguous results globally, include country/state
## Verification ## Verification
```bash ```bash
python3 ~/.hermes/skills/maps/scripts/maps_client.py search "Statue of Liberty" python3 ~/.hermes/skills/maps/scripts/maps_client.py search "Statue of Liberty"
# Should return lat ~40.689, lon ~-74.044 # 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
``` ```

View file

@ -34,7 +34,14 @@ DATA_SOURCE = "OpenStreetMap/Nominatim"
NOMINATIM_SEARCH = "https://nominatim.openstreetmap.org/search" NOMINATIM_SEARCH = "https://nominatim.openstreetmap.org/search"
NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse" 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" OSRM_BASE = "https://router.project-osrm.org/route/v1"
TIMEAPI_BASE = "https://timeapi.io/api/timezone/coordinate" 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}") 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 # Geo math
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -379,6 +410,9 @@ def parse_overpass_elements(elements, ref_lat=None, ref_lon=None):
"lon": el_lon, "lon": el_lon,
"osm_type": el.get("type", ""), "osm_type": el.get("type", ""),
"osm_id": el.get("id", ""), "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": { "tags": {
k: v for k, v in tags.items() k: v for k, v in tags.items()
if k not in ("name", "name:en", 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: if ref_lat is not None and ref_lon is not None:
dist_m = haversine_m(ref_lat, ref_lon, el_lat, el_lon) dist_m = haversine_m(ref_lat, ref_lon, el_lat, el_lon)
place["distance_m"] = round(dist_m, 1) 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) places.append(place)
@ -499,47 +551,84 @@ def cmd_reverse(args):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def cmd_nearby(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: try:
lat = float(args.lat) lat = float(args.lat)
lon = float(args.lon) lon = float(args.lon)
except ValueError: except (TypeError, ValueError):
error_exit("LAT and LON must be numeric values.") error_exit("Provide numeric LAT and LON, or use --near \"<address>\".")
category = args.category.lower() # Categories: support both legacy single positional ``category`` and the
if category not in CATEGORY_TAGS: # 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( 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)}" f"Valid categories: {', '.join(VALID_CATEGORIES)}"
) )
radius = int(args.radius) radius = int(args.radius)
limit = int(args.limit) limit = int(args.limit)
if radius <= 0: if radius <= 0:
error_exit("Radius must be a positive integer (metres).") error_exit("Radius must be a positive integer (metres).")
if limit <= 0: if limit <= 0:
error_exit("Limit must be a positive integer.") 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] tag_key, tag_val = CATEGORY_TAGS[category]
religion = RELIGION_FILTER.get(category) religion = RELIGION_FILTER.get(category)
query = build_overpass_nearby(tag_key, tag_val, lat, lon, radius, limit, query = build_overpass_nearby(tag_key, tag_val, lat, lon, radius, limit,
religion=religion) religion=religion)
raw = overpass_query(query)
post_data = "data=" + urllib.parse.quote(query)
raw = http_post(OVERPASS_API, post_data)
elements = raw.get("elements", []) 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 # Sort merged by distance when we have ref lat/lon, then cap at ``limit``.
for p in places: places = sorted(
p["category"] = category merged.values(),
key=lambda p: p.get("distance_m", float("inf")),
)[:limit]
print_json({ print_json({
"center_lat": lat, "center_lat": lat,
"center_lon": lon, "center_lon": lon,
"category": category, "categories": categories,
"radius_m": radius, "radius_m": radius,
"count": len(places), "count": len(places),
"results": places, "results": places,
@ -861,8 +950,7 @@ def cmd_bbox(args):
query = build_overpass_bbox(tag_key, tag_val, south, west, north, east, query = build_overpass_bbox(tag_key, tag_val, south, west, north, east,
limit, religion=religion) limit, religion=religion)
post_data = "data=" + urllib.parse.quote(query) raw = overpass_query(query)
raw = http_post(OVERPASS_API, post_data)
elements = raw.get("elements", []) elements = raw.get("elements", [])
@ -998,15 +1086,33 @@ def build_parser():
help="Find nearby places of a given category.", help="Find nearby places of a given category.",
description=( description=(
"Find points of interest near a location using the Overpass API.\n" "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)}" f"Categories: {', '.join(VALID_CATEGORIES)}"
), ),
formatter_class=argparse.RawDescriptionHelpFormatter, 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( p_nearby.add_argument(
"category", "lat", nargs="?", default=None,
help="POI category (use --help to see full list).", 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( p_nearby.add_argument(
"--radius", "-r", "--radius", "-r",

View file

@ -1024,7 +1024,7 @@ class TestRunJobSkillBacked:
"id": "multi-skill-job", "id": "multi-skill-job",
"name": "multi skill test", "name": "multi skill test",
"prompt": "Combine the results.", "prompt": "Combine the results.",
"skills": ["blogwatcher", "find-nearby"], "skills": ["blogwatcher", "maps"],
} }
fake_db = MagicMock() fake_db = MagicMock()
@ -1057,12 +1057,12 @@ class TestRunJobSkillBacked:
assert error is None assert error is None
assert final_response == "ok" assert final_response == "ok"
assert skill_view_mock.call_count == 2 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] 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 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 assert "Combine the results." in prompt_arg

View file

@ -54,12 +54,12 @@ class TestCronCommandLifecycle:
deliver=None, deliver=None,
repeat=None, repeat=None,
skill=None, skill=None,
skills=["find-nearby", "blogwatcher"], skills=["maps", "blogwatcher"],
clear_skills=False, clear_skills=False,
) )
) )
updated = get_job(job["id"]) updated = get_job(job["id"])
assert updated["skills"] == ["find-nearby", "blogwatcher"] assert updated["skills"] == ["maps", "blogwatcher"]
assert updated["name"] == "Edited Job" assert updated["name"] == "Edited Job"
assert updated["prompt"] == "Revised prompt" assert updated["prompt"] == "Revised prompt"
assert updated["schedule_display"] == "every 120m" assert updated["schedule_display"] == "every 120m"
@ -95,7 +95,7 @@ class TestCronCommandLifecycle:
deliver=None, deliver=None,
repeat=None, repeat=None,
skill=None, skill=None,
skills=["blogwatcher", "find-nearby"], skills=["blogwatcher", "maps"],
) )
) )
out = capsys.readouterr().out out = capsys.readouterr().out
@ -103,5 +103,5 @@ class TestCronCommandLifecycle:
jobs = list_jobs() jobs = list_jobs()
assert len(jobs) == 1 assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"] assert jobs[0]["skills"] == ["blogwatcher", "maps"]
assert jobs[0]["name"] == "Skill combo" assert jobs[0]["name"] == "Skill combo"

View file

@ -192,23 +192,23 @@ class TestUnifiedCronjobTool:
result = json.loads( result = json.loads(
cronjob( cronjob(
action="create", action="create",
skills=["blogwatcher", "find-nearby"], skills=["blogwatcher", "maps"],
prompt="Use both skills and combine the result.", prompt="Use both skills and combine the result.",
schedule="every 1h", schedule="every 1h",
name="Combo job", name="Combo job",
) )
) )
assert result["success"] is True assert result["success"] is True
assert result["skills"] == ["blogwatcher", "find-nearby"] assert result["skills"] == ["blogwatcher", "maps"]
listing = json.loads(cronjob(action="list")) 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): def test_multi_skill_default_name_prefers_prompt_when_present(self):
result = json.loads( result = json.loads(
cronjob( cronjob(
action="create", action="create",
skills=["blogwatcher", "find-nearby"], skills=["blogwatcher", "maps"],
prompt="Use both skills and combine the result.", prompt="Use both skills and combine the result.",
schedule="every 1h", schedule="every 1h",
) )
@ -220,7 +220,7 @@ class TestUnifiedCronjobTool:
created = json.loads( created = json.loads(
cronjob( cronjob(
action="create", action="create",
skills=["blogwatcher", "find-nearby"], skills=["blogwatcher", "maps"],
prompt="Use both skills and combine the result.", prompt="Use both skills and combine the result.",
schedule="every 1h", schedule="every 1h",
) )

View file

@ -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-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` | | `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 ## mcp
Skills for working with MCP (Model Context Protocol) servers, tools, and integrations. 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` | | `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` | | `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` | | `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` | | `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` | | `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` |

View file

@ -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 30m "Remind me to check the build"
/cron add "every 2h" "Check server status" /cron add "every 2h" "Check server status"
/cron add "every 1h" "Summarize new feed items" --skill blogwatcher /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 ### 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" "Summarize new feed items" --skill blogwatcher
hermes cron create "every 1h" "Use both skills and combine the result" \ hermes cron create "every 1h" "Use both skills and combine the result" \
--skill blogwatcher \ --skill blogwatcher \
--skill find-nearby \ --skill maps \
--name "Skill combo" --name "Skill combo"
``` ```
@ -77,7 +77,7 @@ Skills are loaded in order. The prompt becomes the task instruction layered on t
```python ```python
cronjob( cronjob(
action="create", 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.", prompt="Look for new local events and interesting nearby places, then combine them into one short brief.",
schedule="every 6h", schedule="every 6h",
name="Local brief", name="Local brief",
@ -95,7 +95,7 @@ You do not need to delete and recreate jobs just to change them.
```bash ```bash
/cron edit <job_id> --schedule "every 4h" /cron edit <job_id> --schedule "every 4h"
/cron edit <job_id> --prompt "Use the revised task" /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> --remove-skill blogwatcher
/cron edit <job_id> --clear-skills /cron edit <job_id> --clear-skills
``` ```
@ -105,8 +105,8 @@ You do not need to delete and recreate jobs just to change them.
```bash ```bash
hermes cron edit <job_id> --schedule "every 4h" hermes cron edit <job_id> --schedule "every 4h"
hermes cron edit <job_id> --prompt "Use the revised task" 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> --skill blogwatcher --skill maps
hermes cron edit <job_id> --add-skill find-nearby hermes cron edit <job_id> --add-skill maps
hermes cron edit <job_id> --remove-skill blogwatcher hermes cron edit <job_id> --remove-skill blogwatcher
hermes cron edit <job_id> --clear-skills hermes cron edit <job_id> --clear-skills
``` ```