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 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>")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
```
|
```
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue