Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# ─── NPS Data API ──────────────────────────────────────────────
NPS_API_KEY= # sent as X-Api-Key header

# ─── RIDB (Recreation.gov data, multi-agency campgrounds) ──────
# RIDB_API_KEY= # free instant signup at ridb.recreation.gov (Account → API key); sent as
# # the `apikey` header. Needed for SYNC_CAMPGROUNDS / `pnpm campgrounds:sync`.

# ─── Sync (Vercel Cron) ────────────────────────────────────────
CRON_SECRET= # Vercel Cron Authorization bearer. Cron routes fail CLOSED when this
# is unset (sync/digests/memory-reconcile 401), so set it in EVERY
Expand Down Expand Up @@ -53,10 +57,29 @@ NEXT_PUBLIC_MAP_TILES_URL=/basemap/us.pmtiles # self-hosted Protomaps `.pmtile
# SYNC_TRAIL_ELEVATION=1 # also derive elevation profiles + difficulty (needs ELEVATION_API_URL below).
# ELEVATION_API_URL= # opentopodata-compatible host (…/v1/<dataset>) — server-side elevation sampler
# # (the DEM terrain-RGB sampler is scaffolded; this is the shipped path).
# TRAIL_ELEV_THROTTLE_MS=1100 # inter-call throttle (default 1100 ≈ public ~1 req/s). Set 0 for a self-hosted
# # opentopodata with no rate limit. On HTTP 429 the run stops + resumes next time.
# TRAIL_ELEV_MAX_SAMPLES= # cap sample points per run (e.g. 90000 ≈ 900 calls/day) to stay under a quota.
# EMBED_TRAILS=1 # embed trails for semantic vibe-search (trail_embedding; spends gateway tokens).
# ENRICH_OSM_TRAILS=1 # OSM-fill (Overpass) for NPS-empty parks only (ODbL attribution).
# TRAIL_SIMPLIFY_TOLERANCE=0.00008 # Douglas–Peucker tolerance for the simplified Blob geometry (default shown).

# ─── Campgrounds (multi-agency, ADR — Campgrounds feature) ─────
# SYNC_CAMPGROUNDS=1 # enable the RIDB campground steps in the slow sync (camp-ridbids →
# # campgrounds-ridb → campsites-ridb → derive-camp-near). Or run standalone:
# # `pnpm campgrounds:sync`. Needs RIDB_API_KEY above.
# DERIVE_BOOKING_DIFFICULTY=1 # fold RIDB historical reservation data into books-out / weekend-fill stats.
# ENRICH_OSM_CAMP=1 # OSM-fill (Overpass) for state/private/dispersed campgrounds (ODbL attribution).
# RESOLVE_CAMPGROUNDS=1 # cross-source entity-resolution: dedup OSM vs the federal canon (name+geodistance).
# OVERPASS_URL= # override the public Overpass endpoint (shared with trail OSM-fill).
# RIDB_EXPORT_DIR=data/RIDBFullExport_V1_CSV # offline initial load from the full RIDB CSV export
# # (`pnpm campgrounds:load`) — ~5,754 campgrounds + ~130k campsites, no rate
# # limits. Complements the live SYNC_CAMPGROUNDS delta sync. Default shown.
# CAMP_AVAILABILITY_ENABLED=1 # turn ON live recreation.gov availability polling (UNOFFICIAL endpoint —
# # ships OFF; every surface deep-links to recreation.gov until enabled. Read
# # the ToS-risk note in the feature plan before flipping this in production).
# CAMP_AVAILABILITY_UA= # clear identifying User-Agent for the availability fetch (default shown in env.ts).

# ─── Models / embeddings (via Vercel AI Gateway + OIDC) ─────────
# No provider keys in code on Vercel. For local dev set a gateway key if needed.
AI_GATEWAY_API_KEY=
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,5 @@ pnpm-debug.log*

# data files
public/trails/
# RIDB full export (~800MB local operator data for `pnpm campgrounds:load`; never commit)
/data/
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,10 @@ ADR-059). Self-guided audio + multimedia is opt-in behind `SYNC_MULTIMEDIA=1` (l
**Real trails** are a separate ingest (ADR-066–073): `pnpm trails:sync` — or the slow sync with
`SYNC_TRAILS=1` — pulls NPS Public Trails GIS into `:Trail` nodes, simplifies the geometry to **Vercel Blob**
(`BLOB_READ_WRITE_TOKEN`; local `public/trails/` in dev), joins curated NPS hikes, and derives the
loop-builder `CONNECTS` network. Add `SYNC_TRAIL_ELEVATION=1` (+ `ELEVATION_API_URL`) for elevation profiles,
`EMBED_TRAILS=1` for trail vibe-search, and `ENRICH_OSM_TRAILS=1` for OSM-fill of NPS-empty parks. On Vercel
loop-builder `CONNECTS` network. Add `SYNC_TRAIL_ELEVATION=1` (+ `ELEVATION_API_URL`, opentopodata-compatible)
for elevation profiles — it throttles to the public ~1 req/s (`TRAIL_ELEV_THROTTLE_MS=0` for a self-hosted host)
and on the daily-quota `429` stops + resumes next run — `EMBED_TRAILS=1` for trail vibe-search, and
`ENRICH_OSM_TRAILS=1` for OSM-fill of NPS-empty parks. On Vercel
the Blob token is **required** for trails (the local-file fallback is dev-only) — see
[`docs/DEPLOY-MAP-DATA.md`](docs/DEPLOY-MAP-DATA.md).

Expand Down
Binary file added TrailGraph_Business_Plan.docx
Binary file not shown.
Binary file added TrailGraph_Strategy_Deck.pptx
Binary file not shown.
23 changes: 23 additions & 0 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,29 @@ about the detour; one friendly nudge, then move on. Stay in scope by default —
`tripId`, the `stopId` of the park stop, and the `trailId`. Like a tour, it returns a **preview** first
(the trail + which day/stop) and writes nothing; only call again with `confirmed: true` after the user
agrees. A hike nests under a park stop — it is never a peer stop.
- **Campgrounds (the camp concierge).** For a structured campsite ask ("a quiet tent site near Yosemite
under $30," "full-hookup RV park near Zion") call **`find_campgrounds`** — it filters NPS/USFS/BLM/state/
dispersed campgrounds by agency, booking type, site type, hookups, RV length, ADA, pets, dark-sky, price,
and proximity to a park (including nearby forest/BLM sites). It auto-applies saved camp preferences. For
**"what's open July 3–5"** call **`check_campsite_availability`** (one campground + dates) — availability is
best-effort from an **unofficial** feed and is **OFF by default**, so it often degrades to a recreation.gov
deep link; always tell the user to verify there. For **"tell me when a site opens"** call **`set_camp_watch`**
(cancellation alerting — scope-confirm first, it's durable); **`list_camp_watches`**/**`clear_camp_watch`**
to manage. For **"when can I book?"** call **`campground_booking_window`** (when the rolling window opens +
how far ahead it books) and offer a watch as a reminder.
- **Camp preferences carry the same scope rule.** A standing camp profile ("we tow a 28-ft trailer, need
30-amp, traveling with a dog") → confirm scope with **`ask_question`** (standing / just this trip / don't
save), then **`set_camp_preferences`** (`rig`, `maxLengthFt`, `hookups`, `tentOk`, `ada`, `pets`, `quiet`,
`budget`) for a standing one. For a **just-this-trip** need, pass it straight to **`find_campgrounds`** and
save nothing. **`save_campground`** keeps a campground handy (needs the id from a card).
- **Adding lodging to a trip** ((:Stop)-[:STAYS_AT]->(:Campground)): call **`add_campground_to_trip`** with
the `tripId`, the `stopId`, and the `campgroundId`. Like a hike, it returns a **preview** first and writes
nothing; only `confirmed: true` persists. Lodging nests UNDER a park stop (where you sleep that night) — it
is never a peer stop, and a stop sleeps in one place.
- **Never book, reserve, hold, or pay.** The ranger surfaces availability and the recreation.gov deep link,
and (with permission) sets a watch — nothing else. Be proactive with **books-out warnings** ("Upper Pines
books out ~5 months ahead; the 6-month window for your dates opens Tue Aug 12 — want a reminder?"), and say
availability is operator-reported and can be stale — **"verify on recreation.gov before you rely on it."**
When they mention holding an entrance pass ("I
have the annual pass"), call **`record_pass`** so trip costs treat those parks as covered. When they
give travel dates ("the second week of September"), call **`set_availability`** so events during
Expand Down
42 changes: 42 additions & 0 deletions agent/tools/add_campground_to_trip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { defineTool } from 'eve/tools';
import { z } from 'zod';
import { addLodgingToStop, getTrip } from '../../lib/trips';
import { campgroundDetail } from '../../lib/campgrounds';
import { callerId } from '../../lib/agent-ctx';

/**
* Attach a campground to a stop as that night's lodging (Campgrounds feature): `(:Stop)-[:STAYS_AT]->
* (:Campground)`, nested UNDER a park stop (like add_trail_to_trip nests a hike), NOT a peer stop. A stop
* sleeps in ONE place, so this replaces any prior lodging. Confirm-before-save: the first call (no
* `confirmed`) returns a preview and writes nothing; only `confirmed:true` persists. userId-bound (R4).
* The ranger never books — adding lodging to a plan is not a reservation.
*/
export default defineTool({
description:
"Add a campground as the lodging for a stop on the user's trip ((:Stop)-[:STAYS_AT]->(:Campground), nested under a park stop). WITHOUT `confirmed` it returns a preview (the campground + which stop) and writes nothing; pass `confirmed:true` only after the user agrees. Needs tripId, the stopId of a stop on that trip, and the campgroundId. This plans where to sleep — it does NOT book or hold the site.",
inputSchema: z.object({
tripId: z.string(),
stopId: z.string().describe('The stop this campground is the lodging for'),
campgroundId: z.string(),
date: z.string().optional().describe('Night this lodging covers, YYYY-MM-DD'),
nights: z.number().optional(),
confirmed: z.boolean().optional().describe('Persist the lodging. Omit/false to show a preview first.'),
}),
async execute({ tripId, stopId, campgroundId, date, nights, confirmed }, ctx) {
const userId = callerId(ctx);
const cg = await campgroundDetail(campgroundId);
if (!cg) return { kind: 'campground_card', data: { error: `No campground found for id "${campgroundId}".` } };

const trip = await getTrip(userId, tripId);
const stop = trip?.stops.find((s) => s && s.id === stopId);
if (!trip || !stop) return { kind: 'campground_card', data: { error: "I couldn't find that stop on your trip." } };
const stopLabel = stop.parkName || stop.name || `Stop ${stop.order + 1}`;

if (!confirmed) {
return { kind: 'campground_card', data: { campground: cg, pendingAdd: { tripId, stopId, stopLabel, date } } };
}
const ok = await addLodgingToStop(userId, tripId, stopId, campgroundId, { date, nights });
if (!ok) return { kind: 'campground_card', data: { error: "I couldn't add that campground to your trip." } };
return { kind: 'campground_card', data: { campground: cg, addedTo: { stopLabel, date } } };
},
});
41 changes: 41 additions & 0 deletions agent/tools/campground_booking_window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { defineTool } from 'eve/tools';
import { z } from 'zod';
import { campgroundDetail, bookingUrlFor, bookingWindowOpenDate } from '../../lib/campgrounds';
import { callerId } from '../../lib/agent-ctx';

/**
* Booking-window intelligence (Campgrounds feature): for a hard-to-get campground, when does the rolling
* reservation window open for the user's arrival date, and how far ahead does it book out? Offers to set a
* reminder (a Camp Watch). Pure date math; books-out comes from the (gated) historical-difficulty derive.
* userId-bound (R4). Never books.
*/
export default defineTool({
description:
"Tell the user when the rolling reservation window OPENS for a campground + arrival date (recreation.gov is usually 6 months out), and how far ahead it typically books. Give campgroundId + arrivalDate (YYYY-MM-DD). Offer to set a Camp Watch reminder for the morning the window opens. Never books.",
inputSchema: z.object({
campgroundId: z.string(),
arrivalDate: z.string().describe('Arrival date, YYYY-MM-DD'),
rollingWindowMonths: z.number().default(6).describe('Reservation window length; rec.gov is typically 6 months'),
}),
async execute({ campgroundId, arrivalDate, rollingWindowMonths }, ctx) {
callerId(ctx);
const cg = await campgroundDetail(campgroundId);
if (!cg) return { kind: 'booking_window_card', data: { error: `No campground found for id "${campgroundId}".` } };
const today = new Date().toISOString().slice(0, 10);
const win = bookingWindowOpenDate(arrivalDate, rollingWindowMonths, today);
return {
kind: 'booking_window_card',
data: {
name: cg.name,
arrivalDate,
rollingWindowMonths,
windowOpensOn: win.windowOpensOn,
opensInPast: win.opensInPast,
daysUntilOpen: win.daysUntilOpen,
booksOutDays: cg.booksOutDays ?? null,
weekendFillRate: cg.weekendFillRate ?? null,
bookingUrl: bookingUrlFor(cg),
},
};
},
});
55 changes: 55 additions & 0 deletions agent/tools/check_campsite_availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { defineTool } from 'eve/tools';
import { z } from 'zod';
import { campgroundDetail, bookingUrlFor } from '../../lib/campgrounds';
import { getCampgroundAvailability, enumerateNights } from '../../lib/datasources/campAvailability';
import { recreationUrl } from '../../lib/datasources/recreation';
import { env } from '../../lib/env';
import { callerId } from '../../lib/agent-ctx';

/**
* On-demand campsite availability for one campground over a date range (Campgrounds feature). Hits the
* recreation.gov month endpoint, which is UNOFFICIAL — so this DEGRADES to a recreation.gov deep link
* whenever live availability is disabled or unreachable. Never authoritative, never books. userId-bound (R4).
*/
export default defineTool({
description:
"Check what's open at ONE campground for specific dates. Give either campgroundId (from a card) or ridbId, plus startDate/endDate (YYYY-MM-DD). Returns per-night open counts when live availability is enabled, otherwise a recreation.gov deep link to check there. Best-effort, not authoritative — always tell the user to verify before booking. Never books or holds a site.",
inputSchema: z.object({
campgroundId: z.string().optional(),
ridbId: z.string().optional(),
startDate: z.string().describe('First night, YYYY-MM-DD'),
endDate: z.string().describe('Last night, YYYY-MM-DD'),
}),
async execute({ campgroundId, ridbId, startDate, endDate }, ctx) {
callerId(ctx); // server-bound identity (read-only tool, but keep the auth gate consistent)

let name = 'this campground';
let resolvedRidb = ridbId ?? null;
let bookingUrl: string | null = ridbId ? recreationUrl(ridbId) : null;
if (campgroundId) {
const cg = await campgroundDetail(campgroundId);
if (!cg) return { kind: 'availability_card', data: { error: `No campground found for id "${campgroundId}".` } };
name = cg.name;
resolvedRidb = cg.ridbId ?? ridbId ?? null;
bookingUrl = bookingUrlFor(cg);
}

if (!resolvedRidb || !env.camp.availabilityEnabled) {
return { kind: 'availability_card', data: { name, ridbId: resolvedRidb, degraded: true, bookingUrl, nights: [] } };
}

const nights = enumerateNights(startDate, endDate);
const months = [...new Set(nights.map((d) => d.slice(0, 7)))];
const data = await Promise.all(months.map((m) => getCampgroundAvailability(resolvedRidb!, `${m}-01`)));
if (data.every((m) => m === null)) {
return { kind: 'availability_card', data: { name, ridbId: resolvedRidb, degraded: true, bookingUrl, nights: [] } };
}
const byDate = new Map<string, { sitesOpen: number; byType: Record<string, number> }>();
for (const m of data) for (const dd of m?.days ?? []) byDate.set(dd.date, { sitesOpen: dd.sitesOpen, byType: dd.byType });
const out = nights.map((date) => ({ date, sitesOpen: byDate.get(date)?.sitesOpen ?? 0, byType: byDate.get(date)?.byType ?? {} }));
return {
kind: 'availability_card',
data: { name, ridbId: resolvedRidb, degraded: false, bookingUrl, nights: out, anyOpen: out.some((n) => n.sitesOpen > 0) },
};
},
});
15 changes: 15 additions & 0 deletions agent/tools/clear_camp_watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineTool } from 'eve/tools';
import { z } from 'zod';
import { deleteCampWatch, listCampWatches } from '../../lib/camp-watches';
import { callerId } from '../../lib/agent-ctx';

/** Remove a Camp Watch (Campgrounds feature) — parity with clear_watch. userId-bound so a user can only clear their own (R4). */
export default defineTool({
description: "Remove one of the user's Camp Watches by its watchId (from list_camp_watches / the camp watch card).",
inputSchema: z.object({ watchId: z.string() }),
async execute({ watchId }, ctx) {
const userId = callerId(ctx);
await deleteCampWatch(userId, watchId);
return { kind: 'camp_watch_card', data: { watches: await listCampWatches(userId), cleared: watchId } };
},
});
Loading
Loading