@voyagier/cli
Advanced tools
| import { deriveFlightDetail } from "../flight-format.js"; | ||
| import { deriveHotelFacts } from "../hotel-format.js"; | ||
| import { formatPrice } from "../format.js"; | ||
| export function parseClockMinutes(value) { | ||
| if (typeof value !== "string") | ||
| return null; | ||
| const m = value.trim().match(/^(\d{1,2}):(\d{2})$/); | ||
| if (!m) | ||
| return null; | ||
| const h = Number(m[1]); | ||
| const min = Number(m[2]); | ||
| if (h > 23 || min > 59) | ||
| return null; | ||
| return h * 60 + min; | ||
| } | ||
| export function minutesToClock(mins) { | ||
| const h = Math.floor(mins / 60); | ||
| const m = mins % 60; | ||
| return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; | ||
| } | ||
| export function compactMoney(n) { | ||
| return Number.isInteger(n) ? `$${n.toLocaleString("en-US")}` : formatPrice(n); | ||
| } | ||
| export function parseDurationMinutes(duration) { | ||
| if (!duration) | ||
| return Infinity; | ||
| const match = duration.match(/(\d+)h\s*(\d+)?m?/); | ||
| if (match) | ||
| return parseInt(match[1], 10) * 60 + parseInt(match[2] ?? "0", 10); | ||
| const minOnly = duration.match(/(\d+)\s*m/); | ||
| if (minOnly) | ||
| return parseInt(minOnly[1], 10); | ||
| return Infinity; | ||
| } | ||
| export function stopCount(bookingData) { | ||
| if (!bookingData) | ||
| return null; | ||
| if (typeof bookingData.stops === "number") | ||
| return bookingData.stops; | ||
| const segments = bookingData.segments; | ||
| if (Array.isArray(segments)) | ||
| return Math.max(0, segments.length - 1); | ||
| const derived = deriveFlightDetail(bookingData)?.stopCount; | ||
| return typeof derived === "number" ? derived : null; | ||
| } | ||
| function collectAirlines(opt) { | ||
| const out = []; | ||
| const add = (code) => { | ||
| if (!code) | ||
| return; | ||
| const up = code.toUpperCase(); | ||
| if (!out.includes(up)) | ||
| out.push(up); | ||
| }; | ||
| const outbound = deriveFlightDetail(opt.bookingData, 0); | ||
| const ret = deriveFlightDetail(opt.bookingData, 1); | ||
| outbound?.carriers.forEach(add); | ||
| ret?.carriers.forEach(add); | ||
| if (opt.airline && /^[A-Za-z0-9]{2}$/.test(opt.airline.trim())) | ||
| add(opt.airline.trim()); | ||
| return out; | ||
| } | ||
| export function flightFacts(opt) { | ||
| const outbound = deriveFlightDetail(opt.bookingData, 0); | ||
| const ret = deriveFlightDetail(opt.bookingData, 1); | ||
| return { | ||
| price: typeof opt.price === "number" ? opt.price : null, | ||
| durationMin: parseDurationMinutes(opt.duration), | ||
| durationLabel: opt.duration ?? null, | ||
| stops: stopCount(opt.bookingData), | ||
| departMin: parseClockMinutes(outbound?.departureTime), | ||
| departLabel: outbound?.departureTime ?? null, | ||
| arriveMin: parseClockMinutes(outbound?.arrivalTime), | ||
| arriveLabel: outbound?.arrivalTime ?? null, | ||
| returnDepartMin: parseClockMinutes(ret?.departureTime), | ||
| returnDepartLabel: ret?.departureTime ?? null, | ||
| airlines: collectAirlines(opt), | ||
| }; | ||
| } | ||
| function extremum(all, pick, dir) { | ||
| const vals = all.map(pick).filter((v) => v != null); | ||
| if (!vals.length) | ||
| return null; | ||
| return dir === "max" ? Math.max(...vals) : Math.min(...vals); | ||
| } | ||
| function buildFlightFilters(filters) { | ||
| const active = []; | ||
| if (filters.departAfter != null) { | ||
| const t = filters.departAfter; | ||
| active.push({ | ||
| key: "depart-after", | ||
| pred: (f) => f.departMin != null && f.departMin >= t, | ||
| describe: (all) => { | ||
| const latest = extremum(all, (f) => f.departMin, "max"); | ||
| return latest == null | ||
| ? `no options carry a departure time to filter with --depart-after ${minutesToClock(t)}` | ||
| : `no options depart after ${minutesToClock(t)}; latest departure is ${minutesToClock(latest)}`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.departBefore != null) { | ||
| const t = filters.departBefore; | ||
| active.push({ | ||
| key: "depart-before", | ||
| pred: (f) => f.departMin != null && f.departMin < t, | ||
| describe: (all) => { | ||
| const earliest = extremum(all, (f) => f.departMin, "min"); | ||
| return earliest == null | ||
| ? `no options carry a departure time to filter with --depart-before ${minutesToClock(t)}` | ||
| : `no options depart before ${minutesToClock(t)}; earliest departure is ${minutesToClock(earliest)}`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.arriveBy != null) { | ||
| const t = filters.arriveBy; | ||
| active.push({ | ||
| key: "arrive-by", | ||
| pred: (f) => f.arriveMin != null && f.arriveMin <= t, | ||
| describe: (all) => { | ||
| const earliest = extremum(all, (f) => f.arriveMin, "min"); | ||
| return earliest == null | ||
| ? `no options carry an arrival time to filter with --arrive-by ${minutesToClock(t)}` | ||
| : `no options arrive by ${minutesToClock(t)}; earliest arrival is ${minutesToClock(earliest)}`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.returnDepartAfter != null) { | ||
| const t = filters.returnDepartAfter; | ||
| active.push({ | ||
| key: "return-depart-after", | ||
| pred: (f) => f.returnDepartMin != null && f.returnDepartMin >= t, | ||
| describe: (all) => { | ||
| const latest = extremum(all, (f) => f.returnDepartMin, "max"); | ||
| return latest == null | ||
| ? `no options carry return-leg times to filter with --return-depart-after ${minutesToClock(t)}` | ||
| : `no return legs depart after ${minutesToClock(t)}; latest return departure is ${minutesToClock(latest)}`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.returnDepartBefore != null) { | ||
| const t = filters.returnDepartBefore; | ||
| active.push({ | ||
| key: "return-depart-before", | ||
| pred: (f) => f.returnDepartMin != null && f.returnDepartMin < t, | ||
| describe: (all) => { | ||
| const earliest = extremum(all, (f) => f.returnDepartMin, "min"); | ||
| return earliest == null | ||
| ? `no options carry return-leg times to filter with --return-depart-before ${minutesToClock(t)}` | ||
| : `no return legs depart before ${minutesToClock(t)}; earliest return departure is ${minutesToClock(earliest)}`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.airlines && filters.airlines.length) { | ||
| const want = filters.airlines.map((a) => a.toUpperCase()); | ||
| active.push({ | ||
| key: "airline", | ||
| pred: (f) => f.airlines.some((a) => want.includes(a)), | ||
| describe: (all) => { | ||
| const present = [...new Set(all.flatMap((f) => f.airlines))].sort(); | ||
| return present.length | ||
| ? `no options on ${want.join(", ")}; airlines available: ${present.join(", ")}` | ||
| : `no options on ${want.join(", ")}; no airline data available to filter on`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.maxStops != null) { | ||
| const max = filters.maxStops; | ||
| active.push({ | ||
| key: "max-stops", | ||
| pred: (f) => f.stops != null && f.stops <= max, | ||
| describe: (all) => { | ||
| const fewest = extremum(all, (f) => f.stops, "min"); | ||
| if (max === 0) { | ||
| return fewest == null | ||
| ? `no options carry stop data to filter with --nonstop` | ||
| : `no nonstop options; fewest stops is ${fewest}`; | ||
| } | ||
| return fewest == null | ||
| ? `no options carry stop data to filter with --max-stops ${max}` | ||
| : `no options with ${max} stop${max === 1 ? "" : "s"} or fewer; fewest is ${fewest}`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.maxPrice != null) { | ||
| const max = filters.maxPrice; | ||
| active.push({ | ||
| key: "max-price", | ||
| pred: (f) => f.price != null && f.price <= max, | ||
| describe: (all) => { | ||
| const cheapest = extremum(all, (f) => f.price, "min"); | ||
| return cheapest == null | ||
| ? `no options carry a price to filter with --max-price ${compactMoney(max)}` | ||
| : `no options at or below ${compactMoney(max)}; cheapest is ${compactMoney(cheapest)}`; | ||
| }, | ||
| }); | ||
| } | ||
| return active; | ||
| } | ||
| function applyFilters(items, active) { | ||
| let kept = items; | ||
| for (const af of active) | ||
| kept = kept.filter((k) => af.pred(k.f)); | ||
| const keptOpts = kept.map((k) => k.opt); | ||
| if (keptOpts.length > 0 || items.length === 0 || active.length === 0) { | ||
| return { kept: keptOpts, zero: null }; | ||
| } | ||
| const allFacts = items.map((k) => k.f); | ||
| const sole = active.filter((af) => allFacts.filter((f) => af.pred(f)).length === 0); | ||
| const combination = sole.length === 0; | ||
| const culprits = combination ? active : sole; | ||
| return { | ||
| kept: keptOpts, | ||
| zero: { | ||
| eliminatedBy: culprits.map((c) => c.key), | ||
| detail: culprits.map((c) => ({ filter: c.key, message: c.describe(allFacts) })), | ||
| inputCount: items.length, | ||
| combination, | ||
| }, | ||
| }; | ||
| } | ||
| export function filterFlights(options, filters) { | ||
| const items = options.map((opt) => ({ opt, f: flightFacts(opt) })); | ||
| return applyFilters(items, buildFlightFilters(filters)); | ||
| } | ||
| function hotelFactsRow(opt) { | ||
| const facts = deriveHotelFacts(opt.bookingData); | ||
| return { | ||
| price: typeof opt.price === "number" ? opt.price : null, | ||
| rating: facts?.rating ?? null, | ||
| amenities: facts?.amenities ?? [], | ||
| }; | ||
| } | ||
| function buildHotelFilters(filters) { | ||
| const active = []; | ||
| if (filters.minRating != null) { | ||
| const min = filters.minRating; | ||
| active.push({ | ||
| key: "min-rating", | ||
| pred: (f) => f.rating != null && f.rating >= min, | ||
| describe: (all) => { | ||
| const highest = extremum(all, (f) => f.rating, "max"); | ||
| return highest == null | ||
| ? `no hotels carry a rating to filter with --min-rating ${min}` | ||
| : `no hotels rated ${min} or higher; highest rating is ${highest}`; | ||
| }, | ||
| }); | ||
| } | ||
| if (filters.maxTotal != null) { | ||
| const max = filters.maxTotal; | ||
| active.push({ | ||
| key: "max-total", | ||
| pred: (f) => f.price != null && f.price <= max, | ||
| describe: (all) => { | ||
| const cheapest = extremum(all, (f) => f.price, "min"); | ||
| return cheapest == null | ||
| ? `no hotels carry a total to filter with --max-total ${compactMoney(max)}` | ||
| : `no hotels at or below ${compactMoney(max)} total; cheapest is ${compactMoney(cheapest)}`; | ||
| }, | ||
| }); | ||
| } | ||
| return active; | ||
| } | ||
| export function filterHotels(options, filters) { | ||
| const items = options.map((opt) => ({ opt, f: hotelFactsRow(opt) })); | ||
| return applyFilters(items, buildHotelFilters(filters)); | ||
| } | ||
| export function flightCallouts(options) { | ||
| const out = {}; | ||
| let bestPrice = Infinity; | ||
| let bestDuration = Infinity; | ||
| let bestDepart = Infinity; | ||
| options.forEach((opt, i) => { | ||
| const f = flightFacts(opt); | ||
| if (f.price != null && f.price < bestPrice) { | ||
| bestPrice = f.price; | ||
| out.cheapest = { index: i + 1, price: f.price }; | ||
| } | ||
| if (f.durationMin < bestDuration) { | ||
| bestDuration = f.durationMin; | ||
| out.fastest = { index: i + 1, durationLabel: f.durationLabel }; | ||
| } | ||
| if (f.departMin != null && f.departMin < bestDepart) { | ||
| bestDepart = f.departMin; | ||
| out.earliest = { index: i + 1, departLabel: f.departLabel }; | ||
| } | ||
| }); | ||
| return out; | ||
| } | ||
| export function flightCalloutLine(options) { | ||
| const c = flightCallouts(options); | ||
| const parts = []; | ||
| if (c.cheapest) | ||
| parts.push(`Cheapest: #${c.cheapest.index} (${compactMoney(c.cheapest.price)})`); | ||
| if (c.fastest && c.fastest.durationLabel) | ||
| parts.push(`Fastest: #${c.fastest.index} (${c.fastest.durationLabel})`); | ||
| if (c.earliest) | ||
| parts.push(`Earliest: #${c.earliest.index} (${c.earliest.departLabel})`); | ||
| return parts.join(" · "); | ||
| } | ||
| export function hotelCallouts(options) { | ||
| const out = {}; | ||
| let bestPrice = Infinity; | ||
| let bestRating = -Infinity; | ||
| options.forEach((opt, i) => { | ||
| const f = hotelFactsRow(opt); | ||
| if (f.price != null && f.price < bestPrice) { | ||
| bestPrice = f.price; | ||
| out.cheapest = { index: i + 1, price: f.price }; | ||
| } | ||
| if (f.rating != null && f.rating > bestRating) { | ||
| bestRating = f.rating; | ||
| out.highestRated = { index: i + 1, rating: f.rating }; | ||
| } | ||
| }); | ||
| return out; | ||
| } | ||
| export function hotelCalloutLine(options) { | ||
| const c = hotelCallouts(options); | ||
| const parts = []; | ||
| if (c.cheapest) | ||
| parts.push(`Cheapest: #${c.cheapest.index} (${compactMoney(c.cheapest.price)})`); | ||
| if (c.highestRated) | ||
| parts.push(`Highest rated: #${c.highestRated.index} (⭐${c.highestRated.rating})`); | ||
| return parts.join(" · "); | ||
| } | ||
| export function flightFacets(options) { | ||
| const facts = options.map(flightFacts); | ||
| const out = {}; | ||
| const prices = facts.map((f) => f.price).filter((p) => p != null); | ||
| if (prices.length) | ||
| out.priceRange = { min: Math.min(...prices), max: Math.max(...prices) }; | ||
| const airlines = {}; | ||
| for (const f of facts) | ||
| for (const a of f.airlines) | ||
| airlines[a] = (airlines[a] ?? 0) + 1; | ||
| if (Object.keys(airlines).length) | ||
| out.airlines = sortCounts(airlines); | ||
| const stops = {}; | ||
| let nonstop = 0; | ||
| for (const f of facts) { | ||
| if (f.stops == null) | ||
| continue; | ||
| stops[String(f.stops)] = (stops[String(f.stops)] ?? 0) + 1; | ||
| if (f.stops === 0) | ||
| nonstop++; | ||
| } | ||
| if (Object.keys(stops).length) { | ||
| out.nonstop = nonstop; | ||
| out.stops = Object.fromEntries(Object.keys(stops).map(Number).sort((a, b) => a - b).map((k) => [String(k), stops[String(k)]])); | ||
| } | ||
| const departs = facts.map((f) => f.departMin).filter((m) => m != null); | ||
| if (departs.length) { | ||
| out.earliestDeparture = minutesToClock(Math.min(...departs)); | ||
| out.latestDeparture = minutesToClock(Math.max(...departs)); | ||
| } | ||
| return out; | ||
| } | ||
| const TOP_AMENITIES = 6; | ||
| export function hotelFacets(options) { | ||
| const facts = options.map(hotelFactsRow); | ||
| const out = {}; | ||
| const prices = facts.map((f) => f.price).filter((p) => p != null); | ||
| if (prices.length) | ||
| out.priceRange = { min: Math.min(...prices), max: Math.max(...prices) }; | ||
| const ratings = facts.map((f) => f.rating).filter((r) => r != null); | ||
| if (ratings.length) | ||
| out.ratingRange = { min: Math.min(...ratings), max: Math.max(...ratings) }; | ||
| const amenities = {}; | ||
| for (const f of facts) | ||
| for (const a of f.amenities) | ||
| amenities[a] = (amenities[a] ?? 0) + 1; | ||
| if (Object.keys(amenities).length) { | ||
| out.amenities = Object.fromEntries(Object.entries(amenities) | ||
| .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) | ||
| .slice(0, TOP_AMENITIES)); | ||
| } | ||
| return out; | ||
| } | ||
| function sortCounts(counts) { | ||
| return Object.fromEntries(Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))); | ||
| } |
| function str(value) { | ||
| return typeof value === "string" && value.length > 0 ? value : null; | ||
| } | ||
| export function wallClockTime(value) { | ||
| if (typeof value !== "string") | ||
| return null; | ||
| const iso = value.match(/T(\d{2}):(\d{2})/); | ||
| if (iso) | ||
| return `${iso[1]}:${iso[2]}`; | ||
| const bare = value.match(/^(\d{1,2}):(\d{2})/); | ||
| if (bare) | ||
| return `${bare[1].padStart(2, "0")}:${bare[2]}`; | ||
| return null; | ||
| } | ||
| function legsOf(bookingData, segmentIndex = 0) { | ||
| const flights = Array.isArray(bookingData.flights) ? bookingData.flights : null; | ||
| const seg = flights && flights[segmentIndex] && typeof flights[segmentIndex] === "object" | ||
| ? flights[segmentIndex] | ||
| : null; | ||
| const raw = seg && Array.isArray(seg.flightLegs) | ||
| ? seg.flightLegs | ||
| : segmentIndex === 0 && Array.isArray(bookingData.flightLegs) | ||
| ? bookingData.flightLegs | ||
| : []; | ||
| return raw.filter((l) => !!l && typeof l === "object"); | ||
| } | ||
| export function deriveFlightDetail(bookingData, segmentIndex = 0) { | ||
| if (!bookingData || typeof bookingData !== "object") | ||
| return null; | ||
| const legs = legsOf(bookingData, segmentIndex); | ||
| if (legs.length === 0) | ||
| return null; | ||
| const firstLeg = legs[0]; | ||
| const lastLeg = legs[legs.length - 1]; | ||
| const carrier = str(firstLeg.carrier); | ||
| const numberValue = str(firstLeg.flightNumber) ?? | ||
| (typeof firstLeg.flightNumber === "number" ? String(firstLeg.flightNumber) : null); | ||
| const flightNumber = carrier && numberValue ? `${carrier} ${numberValue}` : null; | ||
| const connections = []; | ||
| for (let i = 0; i < legs.length - 1; i++) { | ||
| const code = str(legs[i].destination); | ||
| if (code) | ||
| connections.push(code); | ||
| } | ||
| const carriers = []; | ||
| for (const leg of legs) { | ||
| const c = str(leg.carrier); | ||
| if (c && !carriers.includes(c)) | ||
| carriers.push(c); | ||
| } | ||
| return { | ||
| flightNumber, | ||
| origin: str(firstLeg.origin), | ||
| destination: str(lastLeg.destination), | ||
| departureTime: wallClockTime(firstLeg.departureTime), | ||
| arrivalTime: wallClockTime(lastLeg.arrivalTime), | ||
| stopCount: legs.length - 1, | ||
| connections, | ||
| carriers, | ||
| }; | ||
| } | ||
| export function flightStopsLabel(detail) { | ||
| if (detail.stopCount == null) | ||
| return ""; | ||
| if (detail.stopCount <= 0) | ||
| return "nonstop"; | ||
| const base = `${detail.stopCount} stop${detail.stopCount === 1 ? "" : "s"}`; | ||
| return detail.connections.length ? `${base}, ${detail.connections.join(", ")}` : base; | ||
| } | ||
| export function flightRouteLabel(detail) { | ||
| const from = [detail.origin, detail.departureTime].filter(Boolean).join(" "); | ||
| const to = [detail.destination, detail.arrivalTime].filter(Boolean).join(" "); | ||
| const route = from && to ? `${from} → ${to}` : from || to; | ||
| if (!route) | ||
| return ""; | ||
| const stops = flightStopsLabel(detail); | ||
| return stops ? `${route} (${stops})` : route; | ||
| } | ||
| export function flightProjectionFields(bookingData) { | ||
| const detail = deriveFlightDetail(bookingData); | ||
| if (!detail) | ||
| return {}; | ||
| const out = {}; | ||
| if (detail.flightNumber) | ||
| out.flightNumber = detail.flightNumber; | ||
| if (detail.origin) | ||
| out.origin = detail.origin; | ||
| if (detail.destination) | ||
| out.destination = detail.destination; | ||
| if (detail.departureTime) | ||
| out.departureTime = detail.departureTime; | ||
| if (detail.arrivalTime) | ||
| out.arrivalTime = detail.arrivalTime; | ||
| if (detail.stopCount != null) | ||
| out.stops = detail.stopCount; | ||
| if (detail.connections.length) | ||
| out.connections = detail.connections; | ||
| return out; | ||
| } |
+8
-4
@@ -252,3 +252,3 @@ # Voyagier CLI — Agent Reference | ||
| // plans list: { "items": [...], "total": 12, "page": 1, "limit": 20 } (each item carries url/clientUrl/advisorUrl) | ||
| // search flights: { "tripPlanId": "...", "selectionId": "...", "optionCount": N, "topOptions": [≤10 summaries], "url": "...", "clientUrl": "...", "advisorUrl": "..." } (--full swaps topOptions for the complete options[] dump) | ||
| // search flights: { "tripPlanId": "...", "selectionId": "...", "optionCount": N, "topOptions": [≤10 summaries], "callouts": { cheapest/fastest/earliest }, "facets": { priceRange, airlines, nonstop, stops, earliest/latestDeparture }, "url": "...", "clientUrl": "...", "advisorUrl": "..." } (--full swaps topOptions for the complete options[] dump and omits facets; callouts index the post-filter/sort list; when a --filter drops everything: "filteredToZero": { eliminatedBy, detail, inputCount }) | ||
| // select: { "ok": true, "success": true, "type": "option_selected", ... } | ||
@@ -348,2 +348,4 @@ // selection-options: { "selectionId": "...", "status": "...", "optionCount": N, "options": [...] } | ||
| At session start, `doctor` reports the installed CLI version. If a command or tool documented here is missing from your installed surface, the installed CLI (and its MCP server) is older than these docs — upgrade with `npm i -g @voyagier/cli@latest` before relying on the missing capability. | ||
| Schema-drift verdicts are classified: drift confined to **peripheral** surfaces (places / comments / booking-record reads) reports `WARN` with an explicit "safe to proceed" — the core compose/close loop (plan → search → select → travellers → quote → book) is unaffected, so keep going. `FAIL` on the schema check means a CORE operation drifted (named in `details.coreDrifted`) — expect the corresponding command to break, and prefer upgrading the CLI before continuing. | ||
@@ -431,2 +433,4 @@ | ||
| **Verify routing after selecting flights.** Once the flight legs are picked, run `voyagier itinerary <planId>` and confirm the per-leg routing (layovers/stops) and times match what you tell the user — a compact search/option summary can hide connections. Do this before describing the trip or booking. | ||
| ### Travellers (Style B JSON) | ||
@@ -472,4 +476,4 @@ ```bash | ||
| ```bash | ||
| voyagier search flights --plan <id> --from <iata> --to <iata> --date <YYYY-MM-DD> [--return <YYYY-MM-DD>] [--goal <goalId>] [--max-stops <n>] [--sort price|duration|stops] [--full] --json | ||
| voyagier search hotels --plan <id> --location <city> --checkin <date> --checkout <date> [--goal <goalId>] [--guests <n>] [--replace] [--full] --json | ||
| voyagier search flights --plan <id> --from <iata> --to <iata> --date <YYYY-MM-DD> [--return <YYYY-MM-DD>] [--goal <goalId>] [--max-stops <n>] [--nonstop] [--depart-after <HH:MM>] [--depart-before <HH:MM>] [--arrive-by <HH:MM>] [--return-depart-after <HH:MM>] [--return-depart-before <HH:MM>] [--airline <code>] [--max-price <n>] [--sort price|duration|stops] [--full] --json | ||
| voyagier search hotels --plan <id> --location <city> --checkin <date> --checkout <date> [--goal <goalId>] [--guests <n>] [--min-rating <n>] [--max-total <n>] [--replace] [--full] --json | ||
| voyagier search activities --plan <id> --destination <city> [--date <date>] [--query <q>] [--goal <goalId>] [--replace] [--full] --json | ||
@@ -487,3 +491,3 @@ voyagier search airports "<query>" --json | ||
| `selection-options` reports a status; `--wait` polls with backoff and returns once the status is **terminal** — `READY`, `NO_RESULTS`, `AWAITING_INPUT`, or `FETCH_ERROR` (only `FETCHING` keeps polling). `--goal <goalId>` targets a specific goal (default: the first Flight/Hotel/Activity goal on the plan). `--max-stops` and `--sort` are client-side presentation filters over the returned options. | ||
| `selection-options` reports a status; `--wait` polls with backoff and returns once the status is **terminal** — `READY`, `NO_RESULTS`, `AWAITING_INPUT`, or `FETCH_ERROR` (only `FETCHING` keeps polling). `--goal <goalId>` targets a specific goal (default: the first Flight/Hotel/Activity goal on the plan). The refinement flags (`--max-stops`/`--nonstop`/`--depart-after`/`--depart-before`/`--arrive-by`/`--return-depart-*`/`--airline`/`--max-price`; hotels: `--min-rating`/`--max-total`) and `--sort` are client-side presentation filters over the ALREADY-returned options — they never re-query or re-rank server-side. They compose (AND) and run before the display limit. Times are compared as stored wall-clock (no timezone math); `--depart-after`/`--arrive-by`/`--max-*`/`--min-rating` are inclusive, `--depart-before`/`--return-depart-before` exclusive. When a filter drops everything, the response names which filter(s) and the nearest miss (`filteredToZero` in `--json`) — loosen and retry rather than assuming no inventory. | ||
@@ -490,0 +494,0 @@ **Prices are party totals.** Every search option's price is the total for the searched traveller group, NOT per-person — do not multiply by traveller count. (Hotel search prices are additionally whole-STAY totals, not nightly.) `book --dry-run` / `quote` are the chargeable truth. |
+18
-4
| import { formatPrice } from "./utils.js"; | ||
| import { hotelStayLabel } from "./hotel-format.js"; | ||
| import { hotelStayLabel, deriveHotelFacts } from "./hotel-format.js"; | ||
| import { deriveFlightDetail, flightRouteLabel } from "./flight-format.js"; | ||
| export function agentFlightOptions(options) { | ||
@@ -8,5 +9,10 @@ if (options.length === 0) | ||
| .map((opt, i) => { | ||
| const detail = deriveFlightDetail(opt.bookingData); | ||
| const parts = []; | ||
| if (opt.airline) | ||
| parts.push(opt.airline); | ||
| const lead = detail?.flightNumber ?? opt.airline; | ||
| if (lead) | ||
| parts.push(lead); | ||
| const route = detail ? flightRouteLabel(detail) : ""; | ||
| if (route) | ||
| parts.push(route); | ||
| if (opt.duration) | ||
@@ -25,4 +31,12 @@ parts.push(opt.duration); | ||
| .map((opt, i) => { | ||
| const facts = deriveHotelFacts(opt.bookingData); | ||
| const parts = [opt.name]; | ||
| if (facts?.rating != null) | ||
| parts.push(`⭐${facts.rating}`); | ||
| if (facts?.amenities.length) | ||
| parts.push(facts.amenities.join(", ")); | ||
| const label = hotelStayLabel(opt.price, opt.bookingData); | ||
| return `${i + 1}. ${opt.name}${label ? ` · ${label}` : ""}`; | ||
| if (label) | ||
| parts.push(label); | ||
| return `${i + 1}. ${parts.join(" · ")}`; | ||
| }) | ||
@@ -29,0 +43,0 @@ .join("\n"); |
+159
-36
@@ -12,3 +12,4 @@ import { printPlanFooter } from "../plan-footer.js"; | ||
| import { agentFlightOptions, agentHotelOptions, agentActivityOptions } from "../agent-output.js"; | ||
| import { deriveHotelStay } from "../hotel-format.js"; | ||
| import { deriveHotelStay, hotelFactsFields } from "../hotel-format.js"; | ||
| import { flightProjectionFields } from "../flight-format.js"; | ||
| import { searchAirports } from "../data/airports.js"; | ||
@@ -21,2 +22,3 @@ import { findMetroArea } from "../data/metro-areas.js"; | ||
| import { scaffoldPlan, generateTripTitle } from "./scaffold.js"; | ||
| import { parseClockMinutes, parseDurationMinutes, stopCount, filterFlights, filterHotels, flightCallouts, flightCalloutLine, flightFacets, hotelCallouts, hotelCalloutLine, hotelFacets, } from "./search-refine.js"; | ||
| async function resolveDateOpt(current, opts, question, command) { | ||
@@ -97,22 +99,5 @@ if (current) | ||
| } | ||
| function parseDurationMinutes(duration) { | ||
| if (!duration) | ||
| return Infinity; | ||
| const match = duration.match(/(\d+)h\s*(\d+)?m?/); | ||
| if (match) | ||
| return parseInt(match[1], 10) * 60 + (parseInt(match[2] ?? "0", 10)); | ||
| const minOnly = duration.match(/(\d+)\s*m/); | ||
| if (minOnly) | ||
| return parseInt(minOnly[1], 10); | ||
| return Infinity; | ||
| } | ||
| function parseStops(bookingData) { | ||
| if (!bookingData) | ||
| return Infinity; | ||
| if (typeof bookingData.stops === "number") | ||
| return bookingData.stops; | ||
| const segments = bookingData.segments; | ||
| if (segments) | ||
| return Math.max(0, segments.length - 1); | ||
| return Infinity; | ||
| const c = stopCount(bookingData); | ||
| return c == null ? Infinity : c; | ||
| } | ||
@@ -168,3 +153,3 @@ function sortOptions(options, sortBy) { | ||
| const TOP_OPTIONS = 10; | ||
| function searchJsonBody(base, options, topOptions, full, refineHint) { | ||
| function searchJsonBody(base, options, topOptions, full, refineHint, facets) { | ||
| if (full) { | ||
@@ -177,2 +162,3 @@ return { ...base, optionCount: options.length, options: options.map((opt, i) => ({ index: i + 1, ...opt })) }; | ||
| topOptions: topOptions.slice(0, TOP_OPTIONS), | ||
| ...(facets && Object.keys(facets).length ? { facets } : {}), | ||
| ...(options.length > TOP_OPTIONS | ||
@@ -183,2 +169,96 @@ ? { note: `Showing top ${TOP_OPTIONS} of ${options.length} options — re-run with --full for the complete dump (large: includes raw provider bookingData), or refine with ${refineHint}.` } | ||
| } | ||
| function parseNonNegativeNumber(value, flag) { | ||
| if (value === undefined) | ||
| return undefined; | ||
| const n = Number(value); | ||
| if (!Number.isFinite(n) || n < 0) { | ||
| throw new CliError(CliErrorCode.VALIDATION, `${flag} must be a non-negative number (got "${value}").`); | ||
| } | ||
| return n; | ||
| } | ||
| function parseTimeFlag(value, flag) { | ||
| if (value === undefined) | ||
| return undefined; | ||
| const mins = parseClockMinutes(value); | ||
| if (mins == null) { | ||
| throw new CliError(CliErrorCode.VALIDATION, `${flag} must be a 24-hour HH:MM time (got "${value}").`); | ||
| } | ||
| return mins; | ||
| } | ||
| function parseFlightFilters(opts) { | ||
| let maxStops; | ||
| if (opts.maxStops !== undefined) { | ||
| const n = Number(opts.maxStops); | ||
| if (!Number.isInteger(n) || n < 0) { | ||
| throw new CliError(CliErrorCode.VALIDATION, `--max-stops must be a non-negative integer (got "${opts.maxStops}").`); | ||
| } | ||
| maxStops = n; | ||
| } | ||
| if (opts.nonstop) | ||
| maxStops = maxStops === undefined ? 0 : Math.min(maxStops, 0); | ||
| const airlines = Array.isArray(opts.airline) | ||
| ? opts.airline.map((a) => { | ||
| const code = String(a).trim().toUpperCase(); | ||
| if (!/^[A-Z0-9]{2}$/.test(code)) { | ||
| throw new CliError(CliErrorCode.VALIDATION, `--airline must be a 2-character carrier IATA code (got "${a}").`); | ||
| } | ||
| return code; | ||
| }) | ||
| : undefined; | ||
| return { | ||
| departAfter: parseTimeFlag(opts.departAfter, "--depart-after"), | ||
| departBefore: parseTimeFlag(opts.departBefore, "--depart-before"), | ||
| arriveBy: parseTimeFlag(opts.arriveBy, "--arrive-by"), | ||
| returnDepartAfter: parseTimeFlag(opts.returnDepartAfter, "--return-depart-after"), | ||
| returnDepartBefore: parseTimeFlag(opts.returnDepartBefore, "--return-depart-before"), | ||
| ...(airlines && airlines.length ? { airlines } : {}), | ||
| maxStops, | ||
| maxPrice: parseNonNegativeNumber(opts.maxPrice, "--max-price"), | ||
| }; | ||
| } | ||
| function parseHotelFilters(opts) { | ||
| return { | ||
| minRating: parseNonNegativeNumber(opts.minRating, "--min-rating"), | ||
| maxTotal: parseNonNegativeNumber(opts.maxTotal, "--max-total"), | ||
| }; | ||
| } | ||
| function filteredToZeroJson(zero) { | ||
| return { | ||
| filteredToZero: { | ||
| eliminatedBy: zero.eliminatedBy, | ||
| inputCount: zero.inputCount, | ||
| combination: zero.combination, | ||
| detail: zero.detail, | ||
| }, | ||
| }; | ||
| } | ||
| function filteredToZeroLines(zero) { | ||
| const lines = []; | ||
| const lead = zero.combination | ||
| ? `All ${zero.inputCount} option${zero.inputCount === 1 ? "" : "s"} were filtered out by the combination of active filters:` | ||
| : `All ${zero.inputCount} option${zero.inputCount === 1 ? "" : "s"} were filtered out:`; | ||
| lines.push(lead); | ||
| for (const d of zero.detail) | ||
| lines.push(` • ${d.message}`); | ||
| lines.push("Loosen or drop a filter and search again."); | ||
| return lines; | ||
| } | ||
| function flightCalloutsJson(c) { | ||
| const out = {}; | ||
| if (c.cheapest) | ||
| out.cheapest = c.cheapest; | ||
| if (c.fastest && c.fastest.durationLabel) | ||
| out.fastest = { index: c.fastest.index, duration: c.fastest.durationLabel }; | ||
| if (c.earliest) | ||
| out.earliest = { index: c.earliest.index, departure: c.earliest.departLabel }; | ||
| return Object.keys(out).length ? { callouts: out } : {}; | ||
| } | ||
| function hotelCalloutsJson(c) { | ||
| const out = {}; | ||
| if (c.cheapest) | ||
| out.cheapest = c.cheapest; | ||
| if (c.highestRated) | ||
| out.highestRated = c.highestRated; | ||
| return Object.keys(out).length ? { callouts: out } : {}; | ||
| } | ||
| const SEARCH_WAIT_TIMEOUT_MS = 90_000; | ||
@@ -301,2 +381,10 @@ const WAIT_HEARTBEAT_MS = 10_000; | ||
| .option("--max-stops <n>", "Maximum number of stops") | ||
| .option("--nonstop", "Only nonstop flights (sugar for --max-stops 0)") | ||
| .option("--depart-after <HH:MM>", "Outbound departs at or after this wall-clock time") | ||
| .option("--depart-before <HH:MM>", "Outbound departs before this wall-clock time") | ||
| .option("--arrive-by <HH:MM>", "Outbound arrives at or before this wall-clock time") | ||
| .option("--return-depart-after <HH:MM>", "Return leg departs at or after this time (round trips)") | ||
| .option("--return-depart-before <HH:MM>", "Return leg departs before this time (round trips)") | ||
| .option("--airline <code>", "Filter by carrier IATA code (repeatable)", (value, acc) => [...acc, value], []) | ||
| .option("--max-price <n>", "Only options at or below this price") | ||
| .option("--sort <field>", "Sort by: price, duration, stops, default", "default") | ||
@@ -337,2 +425,3 @@ .option("--full", "Include ALL options with raw provider data in the output (large; default shows top summaries)") | ||
| } | ||
| const flightFilters = parseFlightFilters(opts); | ||
| const { tripPlanId, scaffolded } = await resolvePlanForSearch(opts, { | ||
@@ -427,11 +516,5 @@ title: generateTripTitle({ to: destination, depart: opts.date }), | ||
| const sortBy = (opts.sort ?? "default"); | ||
| let filtered = [...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder); | ||
| if (opts.maxStops !== undefined) { | ||
| const maxStops = Number(opts.maxStops); | ||
| if (!Number.isInteger(maxStops) || maxStops < 0) { | ||
| throw new CliError(CliErrorCode.VALIDATION, `--max-stops must be a non-negative integer (got "${opts.maxStops}").`); | ||
| } | ||
| filtered = filtered.filter((o) => parseStops(o.bookingData) <= maxStops); | ||
| } | ||
| const options = sortOptions(filtered, sortBy); | ||
| const prefiltered = [...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder); | ||
| const { kept, zero: filteredToZero } = filterFlights(prefiltered, flightFilters); | ||
| const options = sortOptions(kept, sortBy); | ||
| const searchResults = options.map((opt, i) => ({ | ||
@@ -442,2 +525,3 @@ index: i + 1, | ||
| summary: buildFlightSummary(opt, origin, destination), | ||
| ...flightProjectionFields(opt.bookingData), | ||
| })); | ||
@@ -464,3 +548,5 @@ saveSearchState({ | ||
| ...reuseEnvelopeFields(reuse), | ||
| }, options, searchResults, opts.full, "--sort/--max-stops"), null, 2) + "\n"); | ||
| ...flightCalloutsJson(flightCallouts(options)), | ||
| ...(filteredToZero ? filteredToZeroJson(filteredToZero) : {}), | ||
| }, options, searchResults, opts.full, "--sort/--max-stops/--nonstop/--depart-after/--depart-before/--arrive-by/--return-depart-after/--return-depart-before/--airline/--max-price", flightFacets(options)), null, 2) + "\n"); | ||
| return; | ||
@@ -476,3 +562,7 @@ } | ||
| lines.push(`> ⚠ ${w}`); | ||
| if (options.length === 0) { | ||
| if (filteredToZero) { | ||
| for (const l of filteredToZeroLines(filteredToZero)) | ||
| lines.push(l); | ||
| } | ||
| else if (options.length === 0) { | ||
| lines.push("_No options yet — the search is still fetching inventory._"); | ||
@@ -483,2 +573,5 @@ lines.push(""); | ||
| else { | ||
| const callout = flightCalloutLine(options); | ||
| if (callout) | ||
| lines.push(`_${callout}_`); | ||
| const shown = opts.full ? options : options.slice(0, TOP_OPTIONS); | ||
@@ -502,2 +595,7 @@ lines.push(agentFlightOptions(shown)); | ||
| writeReuseWarnings(reuse.warnings); | ||
| if (filteredToZero) { | ||
| for (const l of filteredToZeroLines(filteredToZero)) | ||
| process.stderr.write(chalk.yellow(l + "\n")); | ||
| return; | ||
| } | ||
| if (options.length === 0) { | ||
@@ -510,2 +608,5 @@ process.stderr.write(chalk.dim("No options yet — the search is still fetching inventory.\n")); | ||
| console.log(chalk.bold(`\n${options.length} flight option${options.length > 1 ? "s" : ""} found${sortLabel}:\n`)); | ||
| const calloutLine = flightCalloutLine(options); | ||
| if (calloutLine) | ||
| console.log(chalk.dim(calloutLine)); | ||
| console.log(formatFlights(options)); | ||
@@ -533,2 +634,4 @@ await printPlanFooter(tripPlanId); | ||
| .option("--guests <n>", "Number of adult guests", "1") | ||
| .option("--min-rating <n>", "Only hotels rated at or above this") | ||
| .option("--max-total <n>", "Only hotels with a stay total at or below this") | ||
| .option("--sort <field>", "Sort by: price, default", "default") | ||
@@ -551,2 +654,3 @@ .option("--full", "Include ALL options with raw provider data in the output (large; default shows top summaries)") | ||
| warnPastDate(opts.checkout, "--checkout"); | ||
| const hotelFilters = parseHotelFilters(opts); | ||
| const quietHotel = !!(opts.json || opts.agent); | ||
@@ -655,5 +759,6 @@ const { tripPlanId, scaffolded } = await resolvePlanForSearch(opts, { | ||
| const sortBy = (opts.sort ?? "default"); | ||
| const { kept: keptHotels, zero: filteredToZero } = filterHotels([...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder), hotelFilters); | ||
| const options = sortBy === "price" | ||
| ? [...fetchedOptions].sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity)) | ||
| : [...fetchedOptions].sort((a, b) => a.sortOrder - b.sortOrder); | ||
| ? [...keptHotels].sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity)) | ||
| : keptHotels; | ||
| const searchResults = options.map((opt, i) => { | ||
@@ -665,2 +770,3 @@ const stay = deriveHotelStay(opt.price, opt.bookingData); | ||
| summary: buildHotelSummary(opt), | ||
| ...hotelFactsFields(opt.bookingData), | ||
| ...(stay | ||
@@ -691,3 +797,5 @@ ? { | ||
| ...reuseEnvelopeFields(reuse), | ||
| }, options, searchResults, opts.full, "--sort"), null, 2) + "\n"); | ||
| ...hotelCalloutsJson(hotelCallouts(options)), | ||
| ...(filteredToZero ? filteredToZeroJson(filteredToZero) : {}), | ||
| }, options, searchResults, opts.full, "--sort/--min-rating/--max-total", hotelFacets(options)), null, 2) + "\n"); | ||
| return; | ||
@@ -703,3 +811,7 @@ } | ||
| lines.push(`> ⚠ ${w}`); | ||
| if (options.length === 0) { | ||
| if (filteredToZero) { | ||
| for (const l of filteredToZeroLines(filteredToZero)) | ||
| lines.push(l); | ||
| } | ||
| else if (options.length === 0) { | ||
| lines.push("_No options yet — the search is still fetching inventory._"); | ||
@@ -710,2 +822,5 @@ lines.push(""); | ||
| else { | ||
| const callout = hotelCalloutLine(options); | ||
| if (callout) | ||
| lines.push(`_${callout}_`); | ||
| const shown = opts.full ? options : options.slice(0, TOP_OPTIONS); | ||
@@ -725,2 +840,7 @@ lines.push(agentHotelOptions(shown)); | ||
| writeReuseWarnings(reuse.warnings); | ||
| if (filteredToZero) { | ||
| for (const l of filteredToZeroLines(filteredToZero)) | ||
| process.stderr.write(chalk.yellow(l + "\n")); | ||
| return; | ||
| } | ||
| if (options.length === 0) { | ||
@@ -747,2 +867,5 @@ const loc = opts.location; | ||
| console.log(chalk.bold(`\n${options.length} hotel option${options.length > 1 ? "s" : ""} found${sortLabel}:\n`)); | ||
| const hotelCallout = hotelCalloutLine(options); | ||
| if (hotelCallout) | ||
| console.log(chalk.dim(hotelCallout)); | ||
| console.log(formatHotels(options)); | ||
@@ -749,0 +872,0 @@ await printPlanFooter(tripPlanId); |
+15
-5
| import chalk from "chalk"; | ||
| import { formatPrice } from "./utils.js"; | ||
| import { hotelStayLabel } from "./hotel-format.js"; | ||
| import { hotelStayLabel, deriveHotelFacts } from "./hotel-format.js"; | ||
| import { deriveFlightDetail, flightRouteLabel } from "./flight-format.js"; | ||
| function extractRoute(opt) { | ||
@@ -21,9 +22,13 @@ if (opt.bookingData && typeof opt.bookingData === "object") { | ||
| const idx = chalk.bold.cyan(`[${i + 1}]`); | ||
| const airline = opt.airline ? chalk.white(opt.airline) : ""; | ||
| const route = chalk.white(overrideRoute | ||
| const detail = deriveFlightDetail(opt.bookingData); | ||
| const lead = detail?.flightNumber ?? opt.airline; | ||
| const airline = lead ? chalk.white(lead) : ""; | ||
| const detailRoute = detail ? flightRouteLabel(detail) : ""; | ||
| const route = chalk.white(detailRoute || (overrideRoute | ||
| ? `${overrideRoute.origin} to ${overrideRoute.destination}` | ||
| : extractRoute(opt)); | ||
| : extractRoute(opt))); | ||
| const price = opt.price != null ? chalk.green(formatPrice(opt.price)) : ""; | ||
| const duration = opt.duration ? chalk.dim(opt.duration) : ""; | ||
| const time = opt.time ? chalk.dim(opt.time) : ""; | ||
| const hasLegTime = Boolean(detail?.departureTime || detail?.arrivalTime); | ||
| const time = opt.time && !hasLegTime ? chalk.dim(opt.time) : ""; | ||
| const parts = [airline, route].filter(Boolean); | ||
@@ -45,4 +50,9 @@ const details = [price, duration].filter(Boolean).join(" · "); | ||
| const name = chalk.white(opt.name); | ||
| const facts = deriveHotelFacts(opt.bookingData); | ||
| const label = hotelStayLabel(opt.price, opt.bookingData); | ||
| let line = ` 🏨 ${idx} ${name}`; | ||
| if (facts?.rating != null) | ||
| line += ` · ${chalk.yellow(`⭐${facts.rating}`)}`; | ||
| if (facts?.amenities.length) | ||
| line += ` · ${chalk.dim(facts.amenities.join(", "))}`; | ||
| if (label) | ||
@@ -49,0 +59,0 @@ line += ` · ${chalk.green(label)}`; |
+29
-0
@@ -41,2 +41,31 @@ import { formatPrice } from "./format.js"; | ||
| } | ||
| const MAX_AMENITIES = 3; | ||
| export function deriveHotelFacts(bookingData) { | ||
| if (!bookingData || typeof bookingData !== "object") | ||
| return null; | ||
| const bd = bookingData; | ||
| const raw = typeof bd.rating === "number" ? bd.rating : typeof bd.starRating === "number" ? bd.starRating : null; | ||
| const rating = raw == null || Number.isNaN(raw) || raw <= 0 | ||
| ? null | ||
| : Number.isInteger(raw) | ||
| ? raw | ||
| : Math.round(raw * 10) / 10; | ||
| const amenities = Array.isArray(bd.amenities) | ||
| ? bd.amenities.filter((a) => typeof a === "string" && a.length > 0).slice(0, MAX_AMENITIES) | ||
| : []; | ||
| if (rating == null && amenities.length === 0) | ||
| return null; | ||
| return { rating, amenities }; | ||
| } | ||
| export function hotelFactsFields(bookingData) { | ||
| const facts = deriveHotelFacts(bookingData); | ||
| if (!facts) | ||
| return {}; | ||
| const out = {}; | ||
| if (facts.rating != null) | ||
| out.rating = facts.rating; | ||
| if (facts.amenities.length) | ||
| out.amenities = facts.amenities; | ||
| return out; | ||
| } | ||
| export function deriveRoomStay(optionData) { | ||
@@ -43,0 +72,0 @@ if (!optionData || typeof optionData !== "object") |
@@ -10,2 +10,4 @@ import { readFileSync } from "fs"; | ||
| "", | ||
| "Visibility tools verify the real state: travellers_list (discover traveller ids + missing checkout fields), itinerary (the actual composed trip — per-leg routing and times — after selecting flights/hotels), and bookings_list (booking records + status after a checkout, before telling a user their trip is secured).", | ||
| "", | ||
| "search is ASYNC: a search may return optionCount 0 while inventory is still fetching in the background. When that happens, poll get_selection_options (wait defaults to true — it polls to a terminal status) before select_option.", | ||
@@ -12,0 +14,0 @@ "", |
+45
-1
@@ -47,2 +47,5 @@ import { z } from "zod"; | ||
| const args = ["travellers", "add", "--plan", i.plan_id, "--first", i.first, "--last", i.last, "--type", i.type ?? "Adult"]; | ||
| opt(args, "--gender", i.gender); | ||
| opt(args, "--dob", i.dob); | ||
| opt(args, "--email", i.email); | ||
| for (const p of i.frequent_flyer ?? []) | ||
@@ -77,2 +80,5 @@ args.push("--frequent-flyer", p); | ||
| } | ||
| export function buildTravellersListArgs(i) { | ||
| return ["travellers", "list", "--plan", i.plan_id, "--json"]; | ||
| } | ||
| export function buildGoalAddArgs(i) { | ||
@@ -123,2 +129,5 @@ const args = ["plans", "goal-add", i.plan_id, "--type", i.type]; | ||
| } | ||
| export function buildItineraryArgs(i) { | ||
| return ["itinerary", i.plan_id, "--json"]; | ||
| } | ||
| export function buildPlanStatusArgs(i) { | ||
@@ -156,2 +165,5 @@ return ["plan-status", i.plan_id, "--json"]; | ||
| } | ||
| export function buildBookingsListArgs(i) { | ||
| return ["bookings", "list", "--plan", i.plan_id, "--json"]; | ||
| } | ||
| export function buildAgentDocsArgs() { | ||
@@ -205,3 +217,3 @@ return ["agent-docs"]; | ||
| name: "add_traveller", | ||
| description: "Add a traveller to a trip plan. Travellers are required before search. Gender and date of birth are required at flight checkout; passport data hard-gates international reservations (set those via the CLI travellers update later). Loyalty programs are applied at checkout best-effort — a booking never fails because of them.", | ||
| description: "Add a traveller to a trip plan. Travellers are required before search. Gender and date of birth are required at flight checkout and passport data hard-gates international reservations — set them with the travellers_update tool (or pass gender/dob here) once you have them. Loyalty programs are applied at checkout best-effort — a booking never fails because of them.", | ||
| timeoutMs: T.short, | ||
@@ -213,2 +225,5 @@ inputSchema: { | ||
| type: z.string().optional().describe("Traveller type: Adult | Child | Infant. Default Adult."), | ||
| gender: z.string().optional().describe("Gender: M | F | X (or Male | Female | Unspecified). Required at flight checkout."), | ||
| dob: z.string().optional().describe("Date of birth (YYYY-MM-DD). Required at flight checkout."), | ||
| email: z.string().optional().describe("Email address."), | ||
| frequent_flyer: z.array(z.string()).optional().describe('Frequent-flyer programs as "AIRLINE:NUMBER", e.g. ["DL:1234567"]. Member number exactly as the airline issued it.'), | ||
@@ -244,2 +259,11 @@ hotel_loyalty: z.array(z.string()).optional().describe('Hotel loyalty programs as "CHAIN:NUMBER", e.g. ["HI:12345678"]. Member number is digits only — do NOT include the chain code prefix.'), | ||
| defineTool({ | ||
| name: "travellers_list", | ||
| description: "List the travellers on a plan. Use to discover traveller ids and to see which checkout-required fields are still missing (gender, date of birth, passport) — travellers may have been created outside this session, so never assume the roster. Pair with travellers_update to fill any gaps.", | ||
| timeoutMs: T.short, | ||
| inputSchema: { | ||
| plan_id: z.string().describe("Trip plan id."), | ||
| }, | ||
| buildArgs: (i) => buildTravellersListArgs(i), | ||
| }), | ||
| defineTool({ | ||
| name: "goal_add", | ||
@@ -332,2 +356,12 @@ description: "Add a goal to a trip plan (no item/selection). A goal defines a slot the plan needs decided — e.g. an Activity goal is required before search_activities has anything to search against. type is a SelectionType (Activity, Flight, Hotel, HotelRoom, …), validated by the CLI. Returns the created goal; traveller assignment (if requested) is best-effort and surfaced in the result.", | ||
| defineTool({ | ||
| name: "itinerary", | ||
| description: "Show the computed itinerary for a plan (the actual composed trip, sourced from the platform's tripPlanEvents): time-sorted events with per-leg routing, times, and locations. Use this after selecting flights or hotels to verify the real composed trip — per-leg routing (layovers/stops), times, and hotel check-in/out — before describing the trip to a user or booking. A compact option summary can hide connections; the itinerary is the ground truth. Returns the standard envelope: events are under data.events (with data.total and data.dayRange), alongside planContext." + | ||
| INJECTION_NOTE, | ||
| timeoutMs: T.short, | ||
| inputSchema: { | ||
| plan_id: z.string().describe("Trip plan id."), | ||
| }, | ||
| buildArgs: (i) => buildItineraryArgs(i), | ||
| }), | ||
| defineTool({ | ||
| name: "plan_status", | ||
@@ -386,2 +420,12 @@ description: "ONE call answering 'what's left before this plan can book?'. Switch on data.readiness: BOOKED | READY_TO_BOOK | BLOCKED (act on data.blockers[]/nextSteps[]) | IN_PROGRESS (system is working — poll, don't act). book_dry_run is the checkout truth on any contradiction.", | ||
| defineTool({ | ||
| name: "bookings_list", | ||
| description: "List booking records for a plan with their status (Pending/Confirmed/Failed/Cancelled). Check it after any book call and before telling a user their trip is secured — a created checkout is not yet a confirmed booking. Booking-record amounts are raw CENTS (amountCents)." + | ||
| INJECTION_NOTE, | ||
| timeoutMs: T.short, | ||
| inputSchema: { | ||
| plan_id: z.string().describe("Trip plan id."), | ||
| }, | ||
| buildArgs: (i) => buildBookingsListArgs(i), | ||
| }), | ||
| defineTool({ | ||
| name: "agent_docs", | ||
@@ -388,0 +432,0 @@ description: "Print the full Voyagier agent reference (AGENT.md) as markdown — the canonical integration guide for the compose/close loop, error codes, and quirks.", |
+26
-1
@@ -5,3 +5,4 @@ import chalk from "chalk"; | ||
| import { formatPrice } from "./format.js"; | ||
| import { hotelStayLabel } from "./hotel-format.js"; | ||
| import { hotelStayLabel, deriveHotelFacts } from "./hotel-format.js"; | ||
| import { deriveFlightDetail, flightRouteLabel } from "./flight-format.js"; | ||
| export { formatPrice, cents } from "./format.js"; | ||
@@ -24,2 +25,21 @@ export function maskLoyaltyValue(value) { | ||
| export function buildFlightSummary(opt, origin, destination) { | ||
| const detail = deriveFlightDetail(opt.bookingData); | ||
| if (detail) { | ||
| const parts = []; | ||
| const lead = detail.flightNumber ?? opt.airline; | ||
| if (lead) | ||
| parts.push(lead); | ||
| const route = flightRouteLabel(detail); | ||
| if (route) | ||
| parts.push(route); | ||
| else if (origin && destination) | ||
| parts.push(`${origin}→${destination}`); | ||
| else | ||
| parts.push(opt.name); | ||
| if (opt.duration) | ||
| parts.push(opt.duration); | ||
| if (opt.price != null) | ||
| parts.push(formatPrice(opt.price)); | ||
| return parts.join(" · "); | ||
| } | ||
| const parts = []; | ||
@@ -40,2 +60,7 @@ if (origin && destination) | ||
| const parts = [opt.name]; | ||
| const facts = deriveHotelFacts(opt.bookingData); | ||
| if (facts?.rating != null) | ||
| parts.push(`⭐${facts.rating}`); | ||
| if (facts?.amenities.length) | ||
| parts.push(facts.amenities.join(", ")); | ||
| const label = hotelStayLabel(opt.price, opt.bookingData); | ||
@@ -42,0 +67,0 @@ if (label) |
+1
-1
| { | ||
| "name": "@voyagier/cli", | ||
| "version": "2.17.0", | ||
| "version": "2.18.0", | ||
| "mcpName": "com.voyagier/cli", | ||
@@ -5,0 +5,0 @@ "description": "Agent-ready travel CLI — search, plan, quote, and book real trips (flights, hotels, activities) against the Voyagier platform. Built for AI agents: --json everywhere, uniform error codes, price-gated checkout, printable agent reference (voyagier agent-docs).", |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1278957
2.66%75
2.74%20280
3.73%