@fortrip/agent-contract 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +18 -13
  2. package/dist/index.d.ts +2625 -268
  3. package/dist/index.js +2272 -532
  4. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -55,10 +55,10 @@ function passesLuhn(digits) {
55
55
  }
56
56
 
57
57
  // src/commands.ts
58
- import { z as z3 } from "zod";
58
+ import { z as z6 } from "zod";
59
59
 
60
60
  // src/inputs.ts
61
- import { z as z2 } from "zod";
61
+ import { z as z5 } from "zod";
62
62
 
63
63
  // src/dates.ts
64
64
  var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -79,243 +79,391 @@ function addIsoDateDays(date, days) {
79
79
  return value.toISOString().slice(0, 10);
80
80
  }
81
81
 
82
- // src/fields.ts
82
+ // src/conventions/ids.ts
83
83
  import { z } from "zod";
84
+ function prefixed(prefix, name) {
85
+ const error = `${name} must be ${prefix}_ followed by 20 letters or digits`;
86
+ return z.string({ error }).regex(new RegExp(`^${prefix}_[0-9A-Za-z]{20}$`), { error });
87
+ }
88
+ var hotelId = prefixed("htl", "hotelId");
89
+ var offerId = prefixed("off", "offerId");
90
+ var orderId = prefixed("ord", "orderId");
91
+ var placeId = z.string({ error: "placeId must be plc_ followed by 1 to 12 digits" }).regex(/^plc_\d{1,12}$/, {
92
+ error: "placeId must be plc_ followed by 1 to 12 digits"
93
+ });
94
+
95
+ // src/conventions/money.ts
96
+ import { z as z2 } from "zod";
97
+ var amountError = "amount must be a decimal string such as 132.40";
98
+ var currencyError = "currency must be a 3-letter uppercase ISO 4217 code";
99
+ var money = z2.looseObject({
100
+ amount: z2.string({ error: amountError }).regex(/^\d+(\.\d+)?$/, {
101
+ error: amountError
102
+ }),
103
+ currency: z2.string({ error: currencyError }).regex(/^[A-Z]{3}$/, {
104
+ error: currencyError
105
+ })
106
+ });
107
+ var usdMoney = z2.strictObject(
108
+ {
109
+ amount: z2.string({ error: amountError }).regex(/^\d+(\.\d+)?$/, { error: amountError }).describe("Decimal string such as 132.40, never a number."),
110
+ currency: z2.literal("USD", { error: "currency must be USD" }).describe("Always USD in a request.")
111
+ },
112
+ {
113
+ error: (issue) => issue.code === "unrecognized_keys" ? `Unknown field(s): ${issue.keys.join(", ")}` : "Expected a Money object"
114
+ }
115
+ );
116
+ function formatMoney(m) {
117
+ return `${m.currency} ${m.amount}`;
118
+ }
119
+
120
+ // src/conventions/time.ts
121
+ import { z as z3 } from "zod";
122
+ var instant = z3.string({ error: "must be an RFC 3339 UTC instant such as 2026-10-23T08:00:00Z" }).regex(/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?Z$/, {
123
+ error: "must be an RFC 3339 UTC instant such as 2026-10-23T08:00:00Z"
124
+ }).refine((s) => isIsoDate(s.slice(0, 10)), { error: "must be a real instant" });
125
+ var localDate = z3.string({ error: "must be a real yyyy-MM-dd calendar date" }).refine(isIsoDate, { error: "must be a real yyyy-MM-dd calendar date" });
126
+ var localTime = z3.string({ error: "must be a local time such as 14:00" }).regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/, {
127
+ error: "must be a local time such as 14:00"
128
+ });
129
+ function isIanaZone(s) {
130
+ if (!/^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/.test(s)) return false;
131
+ try {
132
+ new Intl.DateTimeFormat("en", { timeZone: s });
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+ var timeZone = z3.string({ error: "must be an IANA time zone such as Asia/Shanghai" }).refine(isIanaZone, { error: "must be an IANA time zone such as Asia/Shanghai" });
139
+
140
+ // src/fields.ts
141
+ import { z as z4 } from "zod";
84
142
  function strictInput(shape) {
85
- return z.strictObject(shape, {
143
+ return z4.strictObject(shape, {
86
144
  error: (issue) => issue.code === "unrecognized_keys" ? `Unknown argument(s): ${issue.keys.join(", ")}` : "Expected a JSON object"
87
145
  });
88
146
  }
89
147
  function wholeNumber(name, min, max) {
90
148
  const error = `${name} must be a whole number from ${min} to ${max}`;
91
- return z.number({ error }).int({ error }).min(min, { error }).max(max, { error });
92
- }
93
- function positiveId(name) {
94
- const error = `${name} must be a positive whole number`;
95
- return z.number({ error }).int({ error }).positive({ error });
96
- }
97
- function isoDate(name) {
98
- const error = `${name} must be a real yyyy-MM-dd calendar date`;
99
- return z.string({ error }).refine(isIsoDate, { error });
149
+ return z4.number({ error }).int({ error }).min(min, { error }).max(max, { error });
100
150
  }
101
151
  function nonEmpty(name) {
102
152
  const error = `${name} must be a non-empty string`;
103
- return z.string({ error }).min(1, { error });
153
+ return z4.string({ error }).min(1, { error });
104
154
  }
105
155
  function text(name, min, max) {
106
156
  const error = `${name} must be text of ${min} to ${max} characters`;
107
- return z.string({ error }).min(min, { error }).max(max, { error });
157
+ return z4.string({ error }).min(min, { error }).max(max, { error });
108
158
  }
109
159
  function oneOf(name, values) {
110
- return z.enum(values, {
160
+ return z4.enum(values, {
111
161
  error: `${name} must be one of: ${values.join(", ")}`
112
162
  });
113
163
  }
114
164
  function flag(name) {
115
- return z.boolean({ error: `${name} must be true or false` });
116
- }
117
- function childrenAges(max) {
118
- const error = `childrenAges must list each child's age (0 to 17) in one room, at most ${max}`;
119
- return z.array(wholeNumber("Each child's age", 0, 17), { error }).max(max, { error });
165
+ return z4.boolean({ error: `${name} must be true or false` });
120
166
  }
121
167
 
122
168
  // src/limits.ts
123
169
  var LIMITS = {
124
170
  rooms: 8,
125
171
  adultsPerRoom: 8,
126
- childrenPerRoom: 8,
172
+ childrenPerRoom: 4,
127
173
  childAgeMax: 17,
128
- nights: 15,
129
- ratesHotels: 15,
130
- facilities: 10,
131
- queryLength: 128,
132
- nearRadiusKm: 200,
133
- page: 100,
134
- pageSize: 50,
174
+ nightsFortrip: 30,
175
+ nightsSupplier: 15,
176
+ offersHotels: 15,
177
+ offersPerHotelDefault: 5,
178
+ offersPerHotelMax: 20,
179
+ pageSizeDefault: 20,
180
+ pageSizeMax: 50,
181
+ textLength: 128,
182
+ radiusMetersMax: 8e4,
183
+ radiusMetersDefault: 8e3,
184
+ amenityFilters: 9,
135
185
  guestName: 60,
136
- contactName: 120,
186
+ contactFirstName: 59,
187
+ contactLastName: 59,
137
188
  contactPhoneMin: 3,
138
189
  contactPhoneMax: 40,
139
190
  specialRequests: 500,
140
- maxRoomsPerHotel: 200
191
+ poolLimit: 3e3,
192
+ candidatesShown: 8,
193
+ candidatesCounted: 200,
194
+ localitiesShown: 8
141
195
  };
142
- var BOARD_CODES = [
143
- "RO",
144
- "BB",
145
- "HB",
146
- "FB",
147
- "BL",
148
- "BD",
149
- "HBBL",
150
- "HBLD",
151
- "AI"
196
+ var DISPLAY_CURRENCIES = ["USD", "CNY"];
197
+ var SUPPORT_EMAIL = "info@fortrip.ai";
198
+
199
+ // src/vocabulary.ts
200
+ var FILTERABLE_AMENITIES = [
201
+ "parking",
202
+ "wifi",
203
+ "laundry",
204
+ "restaurant",
205
+ "room_service",
206
+ "bar",
207
+ "non_smoking",
208
+ "business_center",
209
+ "pool"
210
+ ];
211
+ var AMENITY_CODES = [
212
+ ...FILTERABLE_AMENITIES,
213
+ "airport_shuttle",
214
+ "spa",
215
+ "gym",
216
+ "accessible",
217
+ "ev_charging",
218
+ "air_conditioning",
219
+ "pets_allowed",
220
+ "kitchen",
221
+ "other"
152
222
  ];
153
- var CURRENCIES = ["CNY", "USD"];
154
223
  var SEARCH_SORTS = [
155
- "recommended",
156
- "price-asc",
157
- "price-desc",
158
- "star-desc",
159
- "distance"
224
+ "distance",
225
+ "relevance",
226
+ "stars_desc",
227
+ "price_asc",
228
+ "price_desc"
160
229
  ];
161
- var SUPPORT_EMAIL = "info@fortrip.ai";
162
230
 
163
231
  // src/inputs.ts
164
- var FACILITY_NAME_MAX = 64;
165
- var MAX_HOTEL_ID = 2147483647;
166
- var DEFAULT_MAX_ROOMS_PER_HOTEL = 10;
167
- var stayDescriptions = {
168
- checkIn: "Check-in date, yyyy-MM-dd.",
169
- checkOut: `Check-out date, yyyy-MM-dd. Stays are 1 to ${LIMITS.nights} nights.`
170
- };
171
- var displayCurrency = oneOf("currency", CURRENCIES).default("CNY").describe(
172
- "Currency to show prices in. Prices are converted from the supplier's at Stripe's exchange rate; the response's `fx` says how."
232
+ var locale = oneOf("locale", ["en", "zh"]).describe(
233
+ "Language for names and text in the response: en or zh."
173
234
  );
174
- function bounded(name, min, max) {
175
- const error = `${name} must be a number from ${min} to ${max}`;
176
- return z2.number({ error }).min(min, { error }).max(max, { error });
177
- }
178
- var queryError = `query must be text of 1 to ${LIMITS.queryLength} characters`;
179
- var facilitiesError = `facilities must list 1 to ${LIMITS.facilities} catalogue facility names (1 to ${FACILITY_NAME_MAX} characters each)`;
180
- var radiusError = `near.radiusKm must be more than 0, at most ${LIMITS.nearRadiusKm}`;
181
- var searchHotelsInput = strictInput({
182
- query: z2.string({ error: queryError }).trim().min(1, { error: queryError }).max(LIMITS.queryLength, { error: queryError }).optional().describe(
183
- "Hotel name, brand or city to search for. Provide at least one of `query`, `cityCode` or `near`."
184
- ),
185
- cityCode: positiveId("cityCode").optional().describe("Catalogue city code, as returned in `hotels[].cityCode`."),
186
- near: strictInput({
187
- lat: bounded("near.lat", -90, 90).describe("Latitude."),
188
- lng: bounded("near.lng", -180, 180).describe("Longitude."),
189
- radiusKm: z2.number({ error: radiusError }).positive({ error: radiusError }).max(LIMITS.nearRadiusKm, { error: radiusError }).describe("Radius in kilometres.")
190
- }).optional().describe("Search around a point."),
191
- starMin: bounded("starMin", 0, 5).optional().describe("Lowest star rating to include."),
192
- starMax: bounded("starMax", 0, 5).optional().describe("Highest star rating to include."),
193
- facilities: z2.array(
194
- z2.string({ error: facilitiesError }).trim().min(1, { error: facilitiesError }).max(FACILITY_NAME_MAX, { error: facilitiesError }),
195
- { error: facilitiesError }
196
- ).min(1, { error: facilitiesError }).max(LIMITS.facilities, { error: facilitiesError }).transform((names) => [...new Set(names)]).optional().describe(
197
- "Facilities every hotel must have. Names must match the catalogue exactly (case-insensitive), e.g. `Swimming pool`, `Parking`."
198
- ),
199
- checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
200
- checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
201
- currency: displayCurrency,
202
- priceMin: z2.number({ error: "priceMin must be a number, 0 or more" }).nonnegative({ error: "priceMin must be a number, 0 or more" }).optional().describe(
203
- "Lowest average nightly price, in `currency`. Applied to indicative prices."
235
+ var displayCurrency = oneOf("displayCurrency", [
236
+ "USD",
237
+ "CNY"
238
+ ]).describe(
239
+ "Currency to show prices in, USD or CNY. It never changes what is charged: payment is in USD."
240
+ );
241
+ var cursor = nonEmpty("cursor");
242
+ var nightsError = (name) => `${name} must be after the check-in date and at most ${LIMITS.nightsFortrip} nights later`;
243
+ var stay = strictInput({
244
+ checkIn: localDate.describe(
245
+ "Check-in date in the hotel's calendar, yyyy-MM-dd."
204
246
  ),
205
- priceMax: z2.number({ error: "priceMax must be a number above 0" }).positive({ error: "priceMax must be a number above 0" }).optional().describe("Highest average nightly price, in `currency`."),
206
- sortBy: oneOf("sortBy", SEARCH_SORTS).default("recommended").describe(
207
- "Result order. `recommended` sorts by distance with `near`, by relevance with `query`, otherwise by catalogue weight. `distance` requires `near`."
247
+ checkOut: localDate.describe(
248
+ `Check-out date in the hotel's calendar, yyyy-MM-dd. Stays are 1 to ${LIMITS.nightsFortrip} nights.`
208
249
  ),
209
- page: wholeNumber("page", 1, LIMITS.page).default(1).describe("Page number."),
210
- pageSize: wholeNumber("pageSize", 1, LIMITS.pageSize).default(20).describe("Hotels per page.")
211
- }).superRefine((input, ctx) => {
212
- if (!input.query && input.cityCode === void 0 && !input.near) {
213
- ctx.addIssue({
214
- code: "custom",
215
- path: ["query"],
216
- message: "Provide query, cityCode or near"
217
- });
218
- }
219
- if (input.sortBy === "distance" && !input.near) {
220
- ctx.addIssue({
221
- code: "custom",
222
- path: ["sortBy"],
223
- message: "distance sort requires near"
224
- });
225
- }
226
- if (input.starMin !== void 0 && input.starMax !== void 0 && input.starMin > input.starMax) {
227
- ctx.addIssue({
228
- code: "custom",
229
- path: ["starMax"],
230
- message: "starMax must be >= starMin"
231
- });
232
- }
233
- if (input.priceMin !== void 0 && input.priceMax !== void 0 && input.priceMin > input.priceMax) {
234
- ctx.addIssue({
235
- code: "custom",
236
- path: ["priceMax"],
237
- message: "priceMax must be >= priceMin"
238
- });
239
- }
240
- if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
241
- const earliest = addIsoDateDays((/* @__PURE__ */ new Date()).toISOString().slice(0, 10), -1);
242
- if (input.checkIn < earliest) {
243
- ctx.addIssue({
244
- code: "custom",
245
- path: ["checkIn"],
246
- message: "checkIn must not be in the past"
247
- });
248
- }
249
- const nights = countIsoDateDays(input.checkIn, input.checkOut);
250
- if (nights < 1) {
251
- ctx.addIssue({
252
- code: "custom",
253
- path: ["checkOut"],
254
- message: "checkOut must be after checkIn"
255
- });
256
- } else if (nights > LIMITS.nights) {
250
+ rooms: z5.array(
251
+ strictInput({
252
+ adults: wholeNumber("adults", 1, LIMITS.adultsPerRoom).describe(
253
+ "Adults in this room."
254
+ ),
255
+ childrenAges: z5.array(wholeNumber("Each child's age", 0, LIMITS.childAgeMax), {
256
+ error: `childrenAges must list each child's age (0 to ${LIMITS.childAgeMax}) in this room, at most ${LIMITS.childrenPerRoom}`
257
+ }).max(LIMITS.childrenPerRoom, {
258
+ error: `childrenAges must list each child's age (0 to ${LIMITS.childAgeMax}) in this room, at most ${LIMITS.childrenPerRoom}`
259
+ }).describe(
260
+ "Age of each child in this room, 0 to 17. An empty list means no children."
261
+ )
262
+ }),
263
+ { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }
264
+ ).min(1, { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }).max(LIMITS.rooms, {
265
+ error: `rooms must list 1 to ${LIMITS.rooms} rooms`
266
+ }).describe(
267
+ `The rooms wanted, 1 to ${LIMITS.rooms}, each with its own party.`
268
+ )
269
+ }).superRefine((value, ctx) => {
270
+ if (!isIsoDate(value.checkIn) || !isIsoDate(value.checkOut)) return;
271
+ const nights = countIsoDateDays(value.checkIn, value.checkOut);
272
+ if (nights < 1 || nights > LIMITS.nightsFortrip) {
257
273
  ctx.addIssue({
258
274
  code: "custom",
259
275
  path: ["checkOut"],
260
- message: `search supports at most ${LIMITS.nights} nights`
276
+ message: nightsError("checkOut")
261
277
  });
262
278
  }
263
279
  });
264
- var hotelIdError = "hotelId must be a positive whole number";
265
- var getHotelInput = z2.object(
280
+ var countryCode = z5.string({ error: "countryCode must be an ISO 3166-1 alpha-2 code" }).regex(/^[A-Z]{2}$/, {
281
+ error: "countryCode must be 2 uppercase letters (ISO 3166-1 alpha-2)"
282
+ });
283
+ var radiusMeters = wholeNumber("radiusMeters", 1, LIMITS.radiusMetersMax).optional().describe(
284
+ `Search radius around the destination in meters, 1 to ${LIMITS.radiusMetersMax}. Left out, the service picks one.`
285
+ );
286
+ function bounded(name, min, max) {
287
+ const error = `${name} must be a number from ${min} to ${max}`;
288
+ return z5.number({ error }).min(min, { error }).max(max, { error });
289
+ }
290
+ var KINDS = ["place_name", "place", "point", "country", "locality"];
291
+ var destination = z5.discriminatedUnion(
292
+ "kind",
293
+ [
294
+ strictInput({
295
+ kind: z5.literal("place_name", { error: "kind must be place_name" }).describe("A place named in words, with its country."),
296
+ name: text("name", 1, LIMITS.textLength).describe(
297
+ "The place name, in any language, e.g. Tokyo or \u4E1C\u4EAC."
298
+ ),
299
+ countryCode: countryCode.describe(
300
+ "ISO 3166-1 alpha-2 country the place is in, e.g. JP."
301
+ ),
302
+ radiusMeters
303
+ }),
304
+ strictInput({
305
+ kind: z5.literal("place", { error: "kind must be place" }).describe("A place id from an earlier response."),
306
+ placeId: placeId.describe("placeId from an earlier response."),
307
+ radiusMeters
308
+ }),
309
+ strictInput({
310
+ kind: z5.literal("point", { error: "kind must be point" }).describe("A point on the map."),
311
+ location: strictInput({
312
+ lat: bounded("lat", -90, 90).describe("Latitude, WGS84."),
313
+ lng: bounded("lng", -180, 180).describe("Longitude, WGS84.")
314
+ }).describe("The point, WGS84."),
315
+ radiusMeters
316
+ }),
317
+ strictInput({
318
+ kind: z5.literal("country", { error: "kind must be country" }).describe("A whole country."),
319
+ countryCode: countryCode.describe("ISO 3166-1 alpha-2 country, e.g. JP.")
320
+ }),
321
+ strictInput({
322
+ kind: z5.literal("locality", { error: "kind must be locality" }).describe("A locality (hub) listed in an earlier search response."),
323
+ placeId: placeId.describe("placeId of the locality."),
324
+ radiusMeters
325
+ })
326
+ ],
266
327
  {
267
- hotelId: z2.coerce.number({ error: hotelIdError }).int({ error: hotelIdError }).positive({ error: hotelIdError }).max(MAX_HOTEL_ID, { error: hotelIdError }).describe("hotelId from search_hotels"),
268
- includeRooms: flag("includeRooms").optional().describe("Also return the catalogue room list (adds a few KB)")
269
- },
270
- { error: "Expected a JSON object" }
328
+ error: `destination.kind must be one of: ${KINDS.join(", ")}`
329
+ }
271
330
  );
272
- var hotelIdsError = `hotelIds must list 1 to ${LIMITS.ratesHotels} hotelIds`;
273
- var boardCodesError = `boardCodes must list meal codes from: ${BOARD_CODES.join(", ")}`;
274
- var getRatesInput = strictInput({
275
- hotelIds: z2.array(positiveId("Each hotelId"), { error: hotelIdsError }).min(1, { error: hotelIdsError }).max(LIMITS.ratesHotels, { error: hotelIdsError }).describe("Hotels to price. Each call takes about 15 seconds."),
276
- checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
277
- checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
278
- rooms: wholeNumber("rooms", 1, LIMITS.rooms).describe(
279
- "Number of rooms; every room has the same occupancy"
280
- ),
281
- adults: wholeNumber("adults (per room)", 1, LIMITS.adultsPerRoom).describe(
282
- "Adults per room, not in total (2 rooms for 4 adults = rooms 2, adults 2)"
283
- ),
284
- childrenAges: childrenAges(LIMITS.childrenPerRoom).optional().describe("Age of each child in one room (0-17), per room"),
285
- nationality: z2.string({ error: "nationality must be a 2-letter ISO 3166-1 code" }).regex(/^[A-Z]{2}$/, {
286
- error: "nationality must be a 2-letter ISO 3166-1 code, upper case"
331
+ var textQuery = z5.string({
332
+ error: `text must be text of 1 to ${LIMITS.textLength} characters`
333
+ }).trim().min(1, {
334
+ error: `text must be text of 1 to ${LIMITS.textLength} characters`
335
+ }).max(LIMITS.textLength, {
336
+ error: `text must be text of 1 to ${LIMITS.textLength} characters`
337
+ });
338
+ var starsError = "must be from 1 to 5 in steps of 0.5";
339
+ function starBound(name) {
340
+ return z5.number({ error: `${name} ${starsError}` }).min(1, { error: `${name} ${starsError}` }).max(5, { error: `${name} ${starsError}` }).refine((value) => Number.isInteger(value * 2), {
341
+ error: `${name} ${starsError}`
342
+ });
343
+ }
344
+ var amenitiesError = `amenities must list 1 to ${LIMITS.amenityFilters} codes from: ${AMENITY_CODES.join(", ")}`;
345
+ var searchFilters = strictInput({
346
+ stars: strictInput({
347
+ min: starBound("stars.min").optional().describe("Lowest star rating, 1 to 5 in steps of 0.5."),
348
+ max: starBound("stars.max").optional().describe("Highest star rating, 1 to 5 in steps of 0.5.")
349
+ }).superRefine((value, ctx) => {
350
+ if (value.min !== void 0 && value.max !== void 0 && value.min > value.max) {
351
+ ctx.addIssue({
352
+ code: "custom",
353
+ path: ["max"],
354
+ message: "stars.max must be at least stars.min"
355
+ });
356
+ }
287
357
  }).optional().describe(
288
- "Guest nationality, ISO 3166-1 alpha-2 in upper case, e.g. `CN`. Some rates are restricted by nationality; booking doesn't send it, so such a rate may change or be refused at booking"
358
+ "Star rating range. Unrated hotels are left out whenever it is set."
289
359
  ),
290
- currency: displayCurrency,
291
- // One hotel can return thousands of rate plans; these keep a response
292
- // small enough for an agent's context.
293
- bookableOnly: flag("bookableOnly").default(true).describe("Keep only rates that are bookable on every night."),
294
- boardCodes: z2.array(oneOf("Each boardCode", BOARD_CODES), { error: boardCodesError }).min(1, { error: boardCodesError }).optional().describe(
295
- "Keep only these meal plans: `RO` room only, `BB` breakfast, `HB` half board, `FB` full board, `AI` all inclusive, and so on."
360
+ amenities: z5.array(oneOf("Each amenity", AMENITY_CODES), { error: amenitiesError }).min(1, { error: amenitiesError }).max(LIMITS.amenityFilters, { error: amenitiesError }).optional().describe(
361
+ "Amenities every hotel must list. Only some codes can filter; asking for one that cannot is answered with the list that can."
362
+ ),
363
+ price: strictInput({
364
+ maxPerRoomNight: usdMoney.describe(
365
+ "Highest price per room per night, in USD."
366
+ )
367
+ }).optional().describe("Price limit. Needs indicative pricing, so a stay.")
368
+ }).optional().describe("Filters every hotel in the answer must pass.");
369
+ var NEXT_PAGE_KEYS = /* @__PURE__ */ new Set(["cursor", "limit"]);
370
+ function createSearchHotelsInput(now = Date.now) {
371
+ return strictInput({
372
+ destination: destination.optional().describe("Where to search. A first page needs this or `text`."),
373
+ text: textQuery.optional().describe(
374
+ `Hotel name, brand or area to match, 1 to ${LIMITS.textLength} characters. A first page needs this or \`destination\`.`
375
+ ),
376
+ stay: stay.optional().describe(
377
+ "Dates and party. Needed for indicative prices. Search uses only the dates when the rooms differ."
378
+ ),
379
+ pricing: oneOf("pricing", ["indicative", "none"]).optional().describe(
380
+ "`indicative` adds an approximate price per hotel and needs a stay; `none` skips prices. Default: indicative with a stay, otherwise none."
381
+ ),
382
+ filters: searchFilters,
383
+ sort: oneOf("sort", SEARCH_SORTS).optional().describe(
384
+ "Result order. `distance` needs a destination, `relevance` needs `text`, `price_asc` and `price_desc` need indicative pricing. Default: relevance with `text`, otherwise distance."
385
+ ),
386
+ limit: wholeNumber("limit", 1, LIMITS.pageSizeMax).optional().describe(
387
+ `Hotels per page, 1 to ${LIMITS.pageSizeMax}. Default ${LIMITS.pageSizeDefault}.`
388
+ ),
389
+ cursor: cursor.optional().describe(
390
+ "`nextCursor` from the previous response. A next page is the cursor and, optionally, `limit`; nothing else."
391
+ ),
392
+ locale: locale.optional(),
393
+ displayCurrency: displayCurrency.optional()
394
+ }).superRefine((input, ctx) => {
395
+ const issue = (path, message) => ctx.addIssue({ code: "custom", path, message });
396
+ if (input.cursor !== void 0) {
397
+ for (const [key, value] of Object.entries(input)) {
398
+ if (value !== void 0 && !NEXT_PAGE_KEYS.has(key)) {
399
+ issue(
400
+ [key],
401
+ `${key} cannot be sent with cursor: a next page is the cursor and limit only`
402
+ );
403
+ }
404
+ }
405
+ return;
406
+ }
407
+ if (input.destination === void 0 && input.text === void 0) {
408
+ issue(["destination"], "Provide a destination or text");
409
+ }
410
+ if (input.sort === "distance" && input.destination === void 0) {
411
+ issue(["sort"], "sort distance needs a destination");
412
+ }
413
+ if (input.sort === "relevance" && input.text === void 0) {
414
+ issue(["sort"], "sort relevance needs text");
415
+ }
416
+ const indicative = (input.pricing ?? (input.stay ? "indicative" : "none")) === "indicative";
417
+ if (input.pricing === "indicative" && input.stay === void 0) {
418
+ issue(["stay"], "pricing indicative needs a stay");
419
+ }
420
+ if (!indicative) {
421
+ const reason = input.pricing === "none" ? 'pricing is "none"' : "there is no stay";
422
+ if (input.sort === "price_asc" || input.sort === "price_desc") {
423
+ issue(
424
+ ["sort"],
425
+ `${input.sort} needs indicative pricing, but ${reason}`
426
+ );
427
+ }
428
+ if (input.filters?.price !== void 0) {
429
+ issue(
430
+ ["filters", "price"],
431
+ `filters.price needs indicative pricing, but ${reason}`
432
+ );
433
+ }
434
+ }
435
+ const checkIn = input.stay?.checkIn;
436
+ if (checkIn !== void 0 && isIsoDate(checkIn)) {
437
+ const earliest = new Date(now() - 12 * 36e5).toISOString().slice(0, 10);
438
+ if (checkIn < earliest) {
439
+ issue(["stay", "checkIn"], "stay.checkIn must not be in the past");
440
+ }
441
+ }
442
+ });
443
+ }
444
+ var searchHotelsInput = createSearchHotelsInput();
445
+ var getHotelInput = strictInput({
446
+ hotelId: hotelId.describe("hotelId from a search response."),
447
+ include: oneOf("include", ["rooms"]).optional().describe("`rooms` adds the room list."),
448
+ locale: locale.optional()
449
+ });
450
+ var hotelIdsError = `hotelIds must list 1 to ${LIMITS.offersHotels} distinct hotelIds`;
451
+ var hotelOffersInput = strictInput({
452
+ hotelIds: z5.array(hotelId, { error: hotelIdsError }).min(1, { error: hotelIdsError }).max(LIMITS.offersHotels, { error: hotelIdsError }).refine((ids) => new Set(ids).size === ids.length, {
453
+ error: hotelIdsError
454
+ }).describe(`Hotels to price, 1 to ${LIMITS.offersHotels}, no repeats.`),
455
+ stay: stay.describe(
456
+ "Dates and party. One party per call: every room must be the same."
296
457
  ),
297
- freeCancellation: flag("freeCancellation").optional().describe(
298
- "Keep only rates that can still be cancelled free of charge right now."
458
+ filters: strictInput({
459
+ requireBreakfast: flag("requireBreakfast").optional().describe("Keep only offers that include breakfast."),
460
+ requireFreeCancellation: flag("requireFreeCancellation").optional().describe("Keep only offers that can still be cancelled free of charge.")
461
+ }).optional().describe("Filters every offer must pass."),
462
+ limitPerHotel: wholeNumber("limitPerHotel", 1, LIMITS.offersPerHotelMax).default(LIMITS.offersPerHotelDefault).describe(
463
+ `Offers per hotel, 1 to ${LIMITS.offersPerHotelMax}. Default ${LIMITS.offersPerHotelDefault}.`
299
464
  ),
300
- maxRoomsPerHotel: wholeNumber("maxRoomsPerHotel", 1, LIMITS.maxRoomsPerHotel).default(DEFAULT_MAX_ROOMS_PER_HOTEL).describe(
301
- "Return the N cheapest rooms per hotel. The MCP tool defaults to 5, the CLI to 10."
302
- )
303
- }).superRefine((input, ctx) => {
304
- if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
305
- const nights = countIsoDateDays(input.checkIn, input.checkOut);
306
- if (nights < 1) {
307
- ctx.addIssue({
308
- code: "custom",
309
- path: ["checkOut"],
310
- message: "checkOut must be after checkIn"
311
- });
312
- } else if (nights > LIMITS.nights) {
313
- ctx.addIssue({
314
- code: "custom",
315
- path: ["checkOut"],
316
- message: `rates support at most ${LIMITS.nights} nights`
317
- });
318
- }
465
+ locale: locale.optional(),
466
+ displayCurrency: displayCurrency.optional()
319
467
  });
320
468
  function cardFreeText(name, min, max, minDigits) {
321
469
  return text(name, min, max).refine(
@@ -334,271 +482,322 @@ function guestName(name) {
334
482
  { error: GUEST_NAME_MESSAGE }
335
483
  );
336
484
  }
485
+ function contactName(name, max) {
486
+ return z5.string({ error: `${name} must be text of 1 to ${max} characters` }).refine((value) => !containsCardNumber(value), {
487
+ error: CARD_NUMBER_MESSAGE,
488
+ abort: true
489
+ }).refine(
490
+ (value) => {
491
+ const length = normalizeGuestName(value).length;
492
+ return length >= 1 && length <= max;
493
+ },
494
+ { error: `${name} must be text of 1 to ${max} characters` }
495
+ );
496
+ }
497
+ var emailError = "email must be an email address";
337
498
  var guestsError = "guests must list at least one guest per room";
338
- var roomNoError = `roomNo must be a whole number from 1 to ${LIMITS.rooms}`;
339
- var contactEmailError = "contactEmail must be an email address";
340
- var createBookingInput = strictInput({
341
- hotelId: positiveId("hotelId").describe("Hotel id."),
342
- roomId: nonEmpty("roomId").describe("roomId from get_hotel_rates"),
343
- ratePlanCode: nonEmpty("ratePlanCode").describe(
344
- "ratePlanCode from get_hotel_rates"
345
- ),
346
- checkIn: isoDate("checkIn").describe(stayDescriptions.checkIn),
347
- checkOut: isoDate("checkOut").describe(stayDescriptions.checkOut),
348
- rooms: wholeNumber("rooms", 1, LIMITS.rooms).describe(
349
- "Number of rooms, as in get_hotel_rates; every room has the same occupancy"
350
- ),
351
- adults: wholeNumber("adults (per room)", 1, LIMITS.adultsPerRoom).describe(
352
- "Adults per room, not in total, as in get_hotel_rates (2 rooms for 4 adults = rooms 2, adults 2)"
353
- ),
354
- childrenAges: childrenAges(LIMITS.childrenPerRoom).default([]).describe(
355
- "Age of each child in one room (0-17), per room, as in get_hotel_rates"
356
- ),
357
- guests: z2.array(
499
+ var maxGuestsPerRoom = LIMITS.adultsPerRoom + LIMITS.childrenPerRoom;
500
+ var createOrderInput = strictInput({
501
+ contact: strictInput({
502
+ firstName: contactName("firstName", LIMITS.contactFirstName).describe(
503
+ `Contact person's first name, at most ${LIMITS.contactFirstName} characters.`
504
+ ),
505
+ lastName: contactName("lastName", LIMITS.contactLastName).describe(
506
+ `Contact person's last name, at most ${LIMITS.contactLastName} characters.`
507
+ ),
508
+ email: z5.string({ error: emailError }).trim().pipe(z5.email({ error: emailError })).describe("Contact email; the confirmation is sent here."),
509
+ // Only cards of 16+ digits: a phone number has at most 15 (E.164).
510
+ phone: cardFreeText(
511
+ "phone",
512
+ LIMITS.contactPhoneMin,
513
+ LIMITS.contactPhoneMax,
514
+ 16
515
+ ).describe("Contact phone number, with country code.")
516
+ }).describe("Who the hotel and the confirmation reach."),
517
+ items: z5.array(
358
518
  strictInput({
359
- firstName: guestName("firstName").describe("First name."),
360
- lastName: guestName("lastName").describe("Last name."),
361
- // A numeric string ("1") booked before; the action takes either.
362
- roomNo: z2.coerce.number({ error: roomNoError }).int({ error: roomNoError }).min(1, { error: roomNoError }).max(LIMITS.rooms, { error: roomNoError }).describe("Room the guest stays in, starting at 1.")
519
+ offerId: offerId.describe("offerId from an offers response."),
520
+ hotel: strictInput({
521
+ rooms: z5.array(
522
+ strictInput({
523
+ guests: z5.array(
524
+ strictInput({
525
+ firstName: guestName("firstName").describe(
526
+ "Guest's first name, in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
527
+ ),
528
+ lastName: guestName("lastName").describe(
529
+ "Guest's last name, in Latin letters as on the passport."
530
+ )
531
+ }),
532
+ { error: guestsError }
533
+ ).min(1, { error: guestsError }).max(maxGuestsPerRoom, {
534
+ error: `guests must list at most ${maxGuestsPerRoom} guests per room`
535
+ }).describe(
536
+ "Guests staying in this room: at least one, no more than the room sleeps."
537
+ )
538
+ }),
539
+ { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }
540
+ ).min(1, { error: `rooms must list 1 to ${LIMITS.rooms} rooms` }).max(LIMITS.rooms, {
541
+ error: `rooms must list 1 to ${LIMITS.rooms} rooms`
542
+ }).describe("One entry per room of the offer, in order."),
543
+ specialRequests: cardFreeText(
544
+ "specialRequests",
545
+ 0,
546
+ LIMITS.specialRequests
547
+ ).optional().describe("Requests for the hotel, in English. Not guaranteed.")
548
+ }).describe("What the hotel needs to know.")
363
549
  }),
364
- { error: guestsError }
365
- ).min(1, { error: guestsError }).describe(
366
- "At least one guest per room, no more than the room sleeps. Names in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
367
- ),
368
- contactName: cardFreeText("contactName", 1, LIMITS.contactName).describe(
369
- "Contact person for the booking."
550
+ { error: "items must list exactly one offer" }
551
+ ).min(1, { error: "items must list exactly one offer" }).max(1, { error: "items must list exactly one offer" }).describe("The offer to book. Exactly one in v1.")
552
+ });
553
+ var getOrderInput = strictInput({
554
+ orderId: orderId.describe("orderId from create order or list orders."),
555
+ locale: locale.optional()
556
+ });
557
+ var listLimitError = `limit must be a whole number from 1 to ${LIMITS.pageSizeMax}`;
558
+ var listOrdersInput = strictInput({
559
+ cursor: cursor.optional().describe("`nextCursor` from the previous page of orders."),
560
+ limit: z5.coerce.number({ error: listLimitError }).int({ error: listLimitError }).min(1, { error: listLimitError }).max(LIMITS.pageSizeMax, { error: listLimitError }).optional().describe(
561
+ `Orders per page, 1 to ${LIMITS.pageSizeMax}. Default ${LIMITS.pageSizeDefault}.`
370
562
  ),
371
- // Only cards of 16+ digits: a phone number has at most 15 (E.164), so
372
- // its digits aren't mistaken for a card.
373
- contactPhone: cardFreeText(
374
- "contactPhone",
375
- LIMITS.contactPhoneMin,
376
- LIMITS.contactPhoneMax,
377
- 16
378
- ).describe("Contact phone number."),
379
- // Blank means none, as it did before: the booking sends no email then.
380
- contactEmail: z2.string({ error: contactEmailError }).trim().refine((value) => value === "" || z2.email().safeParse(value).success, {
381
- error: contactEmailError
382
- }).optional().describe("Contact email."),
383
- specialRequests: cardFreeText("specialRequests", 0, LIMITS.specialRequests).optional().describe("Requests for the hotel, in English. Not guaranteed.")
384
- }).superRefine((input, ctx) => {
385
- if (!isIsoDate(input.checkIn) || !isIsoDate(input.checkOut)) return;
386
- const nights = countIsoDateDays(input.checkIn, input.checkOut);
387
- if (nights < 1) {
388
- ctx.addIssue({
389
- code: "custom",
390
- path: ["checkOut"],
391
- message: "checkOut must be after checkIn"
392
- });
393
- } else if (nights > LIMITS.nights) {
394
- ctx.addIssue({
395
- code: "custom",
396
- path: ["checkOut"],
397
- message: `A booking covers at most ${LIMITS.nights} nights; book a longer stay as separate bookings`
398
- });
399
- }
563
+ locale: locale.optional()
400
564
  });
401
565
 
402
566
  // src/commands.ts
403
- function fieldDescription(json, field) {
567
+ function fieldDescription(json, field, sets = {}) {
404
568
  let node = json;
569
+ const path = [];
405
570
  for (const part of field.split(".")) {
406
- node = (node?.properties ?? node?.items?.properties)?.[part];
571
+ if (node?.type === "array") node = node.items;
572
+ const at = path.join(".");
573
+ const variants = node?.oneOf ?? node?.anyOf ?? (node ? [node] : []);
574
+ node = variants.find(
575
+ (variant) => variant.properties?.[part] && Object.entries(sets).every(
576
+ ([key, value]) => !key.startsWith(`${at}.`) || variant.properties?.[key.slice(at.length + 1)]?.const === value
577
+ )
578
+ )?.properties?.[part];
579
+ path.push(part);
407
580
  }
408
581
  if (!node?.description) throw new Error(`No description for field ${field}`);
409
582
  return node.description;
410
583
  }
411
584
  function flagsOf(input, defs) {
412
- const json = z3.toJSONSchema(input, { io: "input" });
585
+ const json = z6.toJSONSchema(input, { io: "input" });
413
586
  return defs.map((def) => ({
414
587
  ...def,
415
- description: def.description ?? fieldDescription(json, def.field)
588
+ description: def.description ?? fieldDescription(json, def.field, def.sets)
416
589
  }));
417
590
  }
418
- var ID = "<id>";
591
+ function positional(input, name, description) {
592
+ const json = z6.toJSONSchema(input, { io: "input" });
593
+ return {
594
+ name,
595
+ field: name,
596
+ description: description ?? fieldDescription(json, name)
597
+ };
598
+ }
419
599
  var COUNT = "<n>";
420
600
  var DATE = "YYYY-MM-DD";
601
+ var ROOM = "<adults[:age,\u2026]>";
602
+ var LOCALES = "en|zh";
603
+ var kind = (value) => ({ "destination.kind": value });
604
+ var localeFlag = {
605
+ flag: "locale",
606
+ field: "locale",
607
+ type: "string",
608
+ placeholder: LOCALES
609
+ };
610
+ var displayCurrencyFlag = {
611
+ flag: "display-currency",
612
+ field: "displayCurrency",
613
+ type: "string",
614
+ placeholder: DISPLAY_CURRENCIES.join("|")
615
+ };
616
+ function stayFlags(roomDescription) {
617
+ return [
618
+ { flag: "check-in", field: "stay.checkIn", type: "string", placeholder: DATE },
619
+ { flag: "check-out", field: "stay.checkOut", type: "string", placeholder: DATE },
620
+ {
621
+ flag: "room",
622
+ field: "stay.rooms",
623
+ type: "string",
624
+ multiple: true,
625
+ placeholder: ROOM,
626
+ description: roomDescription
627
+ }
628
+ ];
629
+ }
630
+ var ROOM_FORMAT = "as `adults` or `adults:age,age` with each child's age, e.g. `2` or `2:6,9`";
421
631
  var searchFlags = flagsOf(searchHotelsInput, [
422
632
  {
423
- flag: "query",
424
- short: "q",
425
- field: "query",
633
+ flag: "place-name",
634
+ field: "destination.name",
426
635
  type: "string",
427
- placeholder: "<text>",
428
- description: "Hotel name, brand or city to search for. Provide at least one of `--query`, `--city-code` or `--near`."
636
+ sets: kind("place_name"),
637
+ placeholder: "<name>",
638
+ description: "Search a place by its name, in any language (e.g. Tokyo or \u4E1C\u4EAC), with its country in `--country`."
429
639
  },
430
- { flag: "city-code", field: "cityCode", type: "id", placeholder: COUNT },
431
640
  {
432
- flag: "near",
433
- field: "near",
641
+ flag: "country",
642
+ field: "destination.countryCode",
434
643
  type: "string",
435
- placeholder: "<lat,lng,km>",
436
- description: `Search around a point, as \`lat,lng,radiusKm\` (radius up to ${LIMITS.nearRadiusKm}), e.g. \`13.75,100.5,5\`.`
644
+ sets: kind("place_name"),
645
+ placeholder: "<CC>",
646
+ description: "The country of `--place-name`, ISO 3166-1 alpha-2, e.g. JP."
437
647
  },
438
- { flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
439
- { flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
440
- { flag: "star-min", field: "starMin", type: "number", placeholder: COUNT },
441
- { flag: "star-max", field: "starMax", type: "number", placeholder: COUNT },
442
648
  {
443
- flag: "facility",
444
- field: "facilities",
649
+ flag: "place",
650
+ field: "destination.placeId",
445
651
  type: "string",
446
- multiple: true,
447
- placeholder: "<name>"
652
+ sets: kind("place"),
653
+ placeholder: "<plc_>",
654
+ description: "Search a place by its placeId from an earlier answer."
448
655
  },
449
- { flag: "price-min", field: "priceMin", type: "number", placeholder: COUNT },
450
- { flag: "price-max", field: "priceMax", type: "number", placeholder: COUNT },
451
656
  {
452
- flag: "sort",
453
- field: "sortBy",
657
+ flag: "locality",
658
+ field: "destination.placeId",
454
659
  type: "string",
455
- placeholder: SEARCH_SORTS.join("|")
660
+ sets: kind("locality"),
661
+ placeholder: "<plc_>",
662
+ description: "Search a locality by its placeId, as an earlier search listed it under localities."
456
663
  },
457
- { flag: "page", field: "page", type: "number", placeholder: COUNT },
458
- { flag: "page-size", field: "pageSize", type: "number", placeholder: COUNT },
459
664
  {
460
- flag: "currency",
461
- field: "currency",
665
+ flag: "point",
666
+ field: "destination.location",
462
667
  type: "string",
463
- placeholder: CURRENCIES.join("|")
464
- }
465
- ]);
466
- var hotelFlags = flagsOf(getHotelInput, [
467
- { flag: "with-rooms", field: "includeRooms", type: "boolean" }
468
- ]);
469
- var ratesFlags = flagsOf(getRatesInput, [
470
- {
471
- flag: "hotel",
472
- field: "hotelIds",
473
- type: "id",
474
- multiple: true,
475
- placeholder: ID,
476
- description: `Hotels to price, by id from \`hotels search\`: repeat the flag or comma-separate, up to ${LIMITS.ratesHotels}. Each call takes about 15 seconds.`
668
+ sets: kind("point"),
669
+ placeholder: "<lat,lng>",
670
+ description: "Search around a point, as `lat,lng` (WGS84), e.g. `13.75,100.5`."
477
671
  },
478
- { flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
479
- { flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
480
- { flag: "rooms", field: "rooms", type: "number", placeholder: COUNT },
481
672
  {
482
- flag: "adults",
483
- field: "adults",
484
- type: "number",
485
- placeholder: COUNT,
486
- description: "Adults per room, not in total (2 rooms for 4 adults = `--rooms 2 --adults 2`)"
673
+ flag: "country-only",
674
+ field: "destination.countryCode",
675
+ type: "string",
676
+ sets: kind("country"),
677
+ placeholder: "<CC>",
678
+ description: "Search a whole country, ISO 3166-1 alpha-2, e.g. JP."
487
679
  },
488
680
  {
489
- flag: "child-age",
490
- field: "childrenAges",
681
+ flag: "radius-meters",
682
+ field: "destination.radiusMeters",
491
683
  type: "number",
492
- multiple: true,
493
- placeholder: "<age>"
684
+ placeholder: COUNT
494
685
  },
495
686
  {
496
- flag: "nationality",
497
- field: "nationality",
687
+ flag: "text",
688
+ field: "text",
498
689
  type: "string",
499
- placeholder: "<CC>"
690
+ placeholder: "<text>",
691
+ description: `Hotel name, brand or area to match, 1 to ${LIMITS.textLength} characters. A first page needs this or a destination.`
500
692
  },
693
+ ...stayFlags(
694
+ `A room, ${ROOM_FORMAT}. Repeat for each room, up to ${LIMITS.rooms}; rooms may differ.`
695
+ ),
501
696
  {
502
- flag: "board",
503
- field: "boardCodes",
697
+ flag: "pricing",
698
+ field: "pricing",
504
699
  type: "string",
505
- multiple: true,
506
- placeholder: BOARD_CODES.join("|")
507
- },
508
- { flag: "free-cancellation", field: "freeCancellation", type: "boolean" },
509
- {
510
- flag: "include-unbookable",
511
- field: "bookableOnly",
512
- type: "boolean",
513
- description: "Also keep rates that are not bookable on every night (by default only bookable rates are returned)."
700
+ placeholder: "indicative|none"
514
701
  },
702
+ { flag: "stars-min", field: "filters.stars.min", type: "number", placeholder: COUNT },
703
+ { flag: "stars-max", field: "filters.stars.max", type: "number", placeholder: COUNT },
515
704
  {
516
- flag: "max-rooms",
517
- field: "maxRoomsPerHotel",
518
- type: "number",
519
- placeholder: COUNT,
520
- description: "Return the N cheapest rooms per hotel. Default 10."
705
+ flag: "amenity",
706
+ field: "filters.amenities",
707
+ type: "string",
708
+ multiple: true,
709
+ placeholder: FILTERABLE_AMENITIES.join("|"),
710
+ description: `An amenity every hotel must list; repeat for each, up to ${LIMITS.amenityFilters}.`
521
711
  },
522
712
  {
523
- flag: "currency",
524
- field: "currency",
713
+ flag: "price-max-per-room-night",
714
+ field: "filters.price.maxPerRoomNight",
525
715
  type: "string",
526
- placeholder: CURRENCIES.join("|")
527
- }
528
- ]);
529
- var bookFlags = flagsOf(createBookingInput, [
530
- { flag: "hotel", field: "hotelId", type: "id", placeholder: ID },
716
+ placeholder: "<USD decimal>",
717
+ description: "Highest indicative price per room per night, in USD, e.g. `150` or `149.50`. Needs a stay."
718
+ },
531
719
  {
532
- flag: "room-id",
533
- field: "roomId",
720
+ flag: "sort",
721
+ field: "sort",
534
722
  type: "string",
535
- placeholder: ID,
536
- description: "`roomId` from `hotels rates`"
723
+ placeholder: SEARCH_SORTS.join("|")
537
724
  },
725
+ { flag: "limit", field: "limit", type: "number", placeholder: COUNT },
538
726
  {
539
- flag: "rate-plan",
540
- field: "ratePlanCode",
727
+ flag: "cursor",
728
+ field: "cursor",
541
729
  type: "string",
542
- placeholder: "<code>",
543
- description: "`ratePlanCode` from `hotels rates`"
730
+ placeholder: "<cursor>",
731
+ description: "`nextCursor` from the previous page. A next page takes only `--cursor` and `--limit`."
544
732
  },
545
- { flag: "check-in", field: "checkIn", type: "string", placeholder: DATE },
546
- { flag: "check-out", field: "checkOut", type: "string", placeholder: DATE },
733
+ localeFlag,
734
+ displayCurrencyFlag
735
+ ]);
736
+ var hotelFlags = flagsOf(getHotelInput, [
547
737
  {
548
738
  flag: "rooms",
549
- field: "rooms",
550
- type: "number",
551
- placeholder: COUNT,
552
- description: "Number of rooms, as in `hotels rates`; every room has the same occupancy"
553
- },
554
- {
555
- flag: "adults",
556
- field: "adults",
557
- type: "number",
558
- placeholder: COUNT,
559
- description: "Adults per room, not in total, as in `hotels rates` (2 rooms for 4 adults = `--rooms 2 --adults 2`)"
560
- },
561
- {
562
- flag: "child-age",
563
- field: "childrenAges",
564
- type: "number",
565
- multiple: true,
566
- placeholder: "<age>",
567
- description: "Age of each child in one room (0-17), per room, as in `hotels rates`"
739
+ field: "include",
740
+ type: "boolean",
741
+ value: "rooms",
742
+ description: "Add the hotel's rooms."
568
743
  },
744
+ localeFlag
745
+ ]);
746
+ var offersFlags = flagsOf(hotelOffersInput, [
569
747
  {
570
- flag: "guest",
571
- field: "guests",
748
+ flag: "hotel",
749
+ field: "hotelIds",
572
750
  type: "string",
573
751
  multiple: true,
574
- placeholder: '"First Last[:roomNo]"',
575
- description: 'A guest, as "First Last" (room 1) or "First Last:roomNo", e.g. "Mary Ann Lee:2": the last word is the last name, the rest the first name. Repeat for each guest. At least one guest per room, no more than the room sleeps. Names in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller).'
752
+ placeholder: "<htl_>",
753
+ description: `A hotel to price, by hotelId from \`hotels search\`; repeat for each, up to ${LIMITS.offersHotels}.`
576
754
  },
755
+ ...stayFlags(
756
+ `A room, ${ROOM_FORMAT}. Repeat for each room, up to ${LIMITS.rooms}; every room must be the same.`
757
+ ),
758
+ { flag: "require-breakfast", field: "filters.requireBreakfast", type: "boolean" },
577
759
  {
578
- flag: "contact-name",
579
- field: "contactName",
580
- type: "string",
581
- placeholder: "<name>"
760
+ flag: "require-free-cancellation",
761
+ field: "filters.requireFreeCancellation",
762
+ type: "boolean"
582
763
  },
764
+ { flag: "limit-per-hotel", field: "limitPerHotel", type: "number", placeholder: COUNT },
765
+ localeFlag,
766
+ displayCurrencyFlag
767
+ ]);
768
+ var bookFlags = flagsOf(createOrderInput, [
583
769
  {
584
- flag: "contact-phone",
585
- field: "contactPhone",
770
+ flag: "offer",
771
+ field: "items.offerId",
586
772
  type: "string",
587
- placeholder: "<phone>"
773
+ placeholder: "<off_>",
774
+ description: "The offer to order, by offerId from `hotels offers`."
588
775
  },
589
776
  {
590
- flag: "contact-email",
591
- field: "contactEmail",
777
+ flag: "guest",
778
+ field: "items.hotel.rooms.guests",
592
779
  type: "string",
593
- placeholder: "<email>"
780
+ multiple: true,
781
+ placeholder: "<room>:<first>:<last>",
782
+ description: "A guest, as `room:first:last`, e.g. `1:John:Smith` for room 1. Repeat for each guest: at least one per room of the offer, no more than the room sleeps. Names in Latin letters as on the passport; transliterate accents (M\xFCller \u2192 Mueller)."
594
783
  },
784
+ { flag: "first-name", field: "contact.firstName", type: "string", placeholder: "<name>" },
785
+ { flag: "last-name", field: "contact.lastName", type: "string", placeholder: "<name>" },
786
+ { flag: "email", field: "contact.email", type: "string", placeholder: "<email>" },
787
+ { flag: "phone", field: "contact.phone", type: "string", placeholder: "<phone>" },
595
788
  {
596
789
  flag: "special-requests",
597
- field: "specialRequests",
790
+ field: "items.hotel.specialRequests",
598
791
  type: "string",
599
792
  placeholder: "<english text>"
600
793
  }
601
794
  ]);
795
+ var orderFlags = flagsOf(getOrderInput, [localeFlag]);
796
+ var ordersListFlags = flagsOf(listOrdersInput, [
797
+ { flag: "cursor", field: "cursor", type: "string", placeholder: "<cursor>" },
798
+ { flag: "limit", field: "limit", type: "number", placeholder: COUNT },
799
+ localeFlag
800
+ ]);
602
801
  var BODY_OPTION = {
603
802
  flag: "body",
604
803
  type: "string",
@@ -606,13 +805,14 @@ var BODY_OPTION = {
606
805
  description: "The raw request body as a JSON object, or `@file.json` to read it from a file. Flags override its fields."
607
806
  };
608
807
  var COMMAND_GROUPS = [
609
- { name: "hotels", summary: "Search, price and book hotels" }
808
+ { name: "hotels", summary: "Search hotels, get offers and order one" },
809
+ { name: "orders", summary: "Your orders and their status" }
610
810
  ];
611
811
  var COMMANDS = [
612
812
  {
613
813
  group: "hotels",
614
814
  name: "search",
615
- summary: "Find hotels with indicative prices",
815
+ summary: "Find hotels in a place, near a point or by name, with indicative prices",
616
816
  tool: "search_hotels",
617
817
  flags: searchFlags,
618
818
  options: [BODY_OPTION]
@@ -620,27 +820,42 @@ var COMMANDS = [
620
820
  {
621
821
  group: "hotels",
622
822
  name: "get",
623
- summary: "Static hotel detail: description, policies, facilities and photos",
823
+ summary: "Hotel details: description, policies, amenities and rooms",
624
824
  tool: "get_hotel",
625
825
  flags: hotelFlags,
626
- positionals: ["hotelId"]
826
+ positionals: [positional(getHotelInput, "hotelId")]
627
827
  },
628
828
  {
629
829
  group: "hotels",
630
- name: "rates",
631
- summary: `Live rooms and rates for up to ${LIMITS.ratesHotels} hotels`,
632
- tool: "get_hotel_rates",
633
- flags: ratesFlags,
830
+ name: "offers",
831
+ summary: `Live bookable offers for up to ${LIMITS.offersHotels} hotels`,
832
+ tool: "get_hotel_offers",
833
+ flags: offersFlags,
634
834
  options: [BODY_OPTION]
635
835
  },
636
836
  {
637
837
  group: "hotels",
638
838
  name: "book",
639
- summary: "Create an order and get a payment link",
839
+ summary: "Order an offer: an unpaid order with a payment link",
640
840
  tool: "book_hotel",
641
841
  flags: bookFlags,
642
842
  options: [BODY_OPTION]
643
843
  },
844
+ {
845
+ group: "orders",
846
+ name: "get",
847
+ summary: "Show an order and its status",
848
+ tool: "get_order",
849
+ flags: orderFlags,
850
+ positionals: [positional(getOrderInput, "orderId")]
851
+ },
852
+ {
853
+ group: "orders",
854
+ name: "list",
855
+ summary: "List your orders, newest first",
856
+ tool: "list_orders",
857
+ flags: ordersListFlags
858
+ },
644
859
  {
645
860
  group: null,
646
861
  name: "login",
@@ -675,7 +890,14 @@ var COMMANDS = [
675
890
  summary: "Print the guide for AI agents (Markdown)",
676
891
  tool: "get_planning_guide",
677
892
  flags: [],
678
- positionals: ["topic"]
893
+ positionals: [
894
+ {
895
+ name: "topic",
896
+ field: null,
897
+ optional: true,
898
+ description: "`planning` for the planning guide; left out, the main guide."
899
+ }
900
+ ]
679
901
  },
680
902
  {
681
903
  group: null,
@@ -700,22 +922,139 @@ var GLOBAL_FLAGS = [
700
922
  short: "v",
701
923
  type: "boolean",
702
924
  description: "Print the CLI version."
925
+ },
926
+ {
927
+ flag: "json",
928
+ type: "boolean",
929
+ description: "Print the answer as its JSON on one line instead of text; an error prints its JSON envelope, on stderr."
703
930
  }
704
931
  ];
705
932
 
933
+ // src/conventions/open.ts
934
+ import { z as z7 } from "zod";
935
+ var OPEN = /* @__PURE__ */ Symbol("fortrip.open");
936
+ function mark(schema, kind2) {
937
+ schema._zod.def[OPEN] = kind2;
938
+ }
939
+ function openObject(shape) {
940
+ return z7.looseObject(shape);
941
+ }
942
+ function openEnum(values) {
943
+ const schema = z7.union([
944
+ z7.enum(values),
945
+ z7.string()
946
+ ]);
947
+ mark(schema, "enum");
948
+ return schema;
949
+ }
950
+ function openUnion(discriminator, branches) {
951
+ const known = /* @__PURE__ */ new Set();
952
+ for (const branch of branches) {
953
+ const field = branch.shape[discriminator];
954
+ const values = field?._zod.values;
955
+ if (!values) {
956
+ throw new Error(`openUnion: branch has no literal "${discriminator}"`);
957
+ }
958
+ for (const v of values) known.add(v);
959
+ }
960
+ const fallback = z7.looseObject({
961
+ [discriminator]: z7.string().refine((v) => !known.has(v), { error: "malformed value for a known type" })
962
+ });
963
+ const schema = z7.union([...branches, fallback]);
964
+ mark(schema, "union");
965
+ return schema;
966
+ }
967
+ function parseOpen(schema, value) {
968
+ return schema.parse(value);
969
+ }
970
+ function strictOutput(schema) {
971
+ return strictify(schema);
972
+ }
973
+ var LEAVES = /* @__PURE__ */ new Set([
974
+ "string",
975
+ "number",
976
+ "boolean",
977
+ "bigint",
978
+ "null",
979
+ "undefined",
980
+ "literal",
981
+ "enum",
982
+ "date",
983
+ "nan",
984
+ "never",
985
+ "template_literal",
986
+ "transform"
987
+ ]);
988
+ var WRAPPERS = {
989
+ optional: "innerType",
990
+ nullable: "innerType",
991
+ default: "innerType",
992
+ prefault: "innerType",
993
+ nonoptional: "innerType",
994
+ readonly: "innerType",
995
+ array: "element"
996
+ };
997
+ function strictify(schema) {
998
+ const def = schema._zod.def;
999
+ const kind2 = def[OPEN];
1000
+ const rebuild = (changes) => {
1001
+ const next = { ...def, ...changes };
1002
+ delete next[OPEN];
1003
+ return schema.clone(next);
1004
+ };
1005
+ if (kind2 === "enum") return strictify(def.options[0]);
1006
+ if (kind2 === "union") {
1007
+ return rebuild({ options: def.options.slice(0, -1).map(strictify) });
1008
+ }
1009
+ const inner = WRAPPERS[def.type];
1010
+ if (inner) return rebuild({ [inner]: strictify(def[inner]) });
1011
+ switch (def.type) {
1012
+ case "object": {
1013
+ const shape = def.shape;
1014
+ return rebuild({
1015
+ shape: Object.fromEntries(
1016
+ Object.entries(shape).map(([k, v]) => [k, strictify(v)])
1017
+ ),
1018
+ catchall: z7.never()
1019
+ });
1020
+ }
1021
+ case "union":
1022
+ return rebuild({ options: def.options.map(strictify) });
1023
+ case "tuple":
1024
+ return rebuild({
1025
+ items: def.items.map(strictify),
1026
+ rest: def.rest ? strictify(def.rest) : def.rest
1027
+ });
1028
+ case "record":
1029
+ return rebuild({ valueType: strictify(def.valueType) });
1030
+ case "pipe":
1031
+ return rebuild({
1032
+ in: strictify(def.in),
1033
+ out: strictify(def.out)
1034
+ });
1035
+ case "lazy": {
1036
+ const getter = def.getter;
1037
+ return rebuild({ getter: () => strictify(getter()) });
1038
+ }
1039
+ default:
1040
+ if (LEAVES.has(def.type)) return schema;
1041
+ throw new Error(`strictOutput: cannot close a "${def.type}" schema`);
1042
+ }
1043
+ }
1044
+
706
1045
  // src/guide-data.generated.ts
707
1046
  var FRAGMENTS = {
708
- "booking": '{{#if claude-skill cli}}\n## Booking\n\n{{/if}}\n- Call `{{tool:hotels book}}` only for the rate the user picked{{#if claude-skill cli}}, with exactly the hotel, `{{arg:roomId}}`, `{{arg:ratePlanCode}}`, dates, `{{arg:rooms}}`, `{{arg:adults}}` and `{{arg:childrenAges}}` used in `{{tool:hotels rates}}`{{/if}}.\n- Guest names: Latin letters as on the passport (M\xFCller \u2192 Mueller), first and last name 1\u2013{{limit:guestName}} characters each{{#if cli}}, as `--guest "First Last"` (the last word is the last name; `"First Last:2"` puts the guest in room 2){{/if}}.{{#if claude-skill cli}} At least one guest per room, no more than the room sleeps.{{/if}}\n{{#if claude-skill cli}}\n- A contact name (up to {{limit:contactName}} characters) and phone ({{limit:contactPhoneMin}}\u2013{{limit:contactPhoneMax}} characters) are required; an email is optional. Special requests must be in English and aren\'t guaranteed.\n- One booking covers at most {{limit:nights}} nights; book a longer stay as separate bookings.\n{{/if}}\n- The `{{arg:nationality}}` given to `{{tool:hotels rates}}` isn\'t sent with the booking: a rate restricted by nationality may change or be refused.\n- `{{tool:hotels book}}` can take up to about two minutes{{#if cli}}: allow the command at least 3 minutes{{/if}}. If it times out, {{#if cli}}run it again with identical flags{{/if}}{{#if mcp claude-skill}}call it again with the same details{{/if}}: it resumes the same order{{#if claude-skill cli}}, with the same or a refreshed payment link{{/if}}.\n{{#if cli}}\n- `{{tool:hotels book}}` takes only these fields: a `--body` with any other field (such as `nationality`) is rejected with exit 2.\n{{/if}}\n',
709
- "errors": "{{#if claude-skill cli}}\n## Errors\n\n{{#if claude-skill}}A tool error{{/if}}{{#if cli}}An error on stderr{{/if}} carries `error` (what went wrong) and `status`, and most carry a `reason`:\n\n| `reason` | Do this |\n|---|---|\n| `already_booked` (409) | This stay is already paid or completed: show `orderUrl` and don't book again |\n| `price_or_terms_changed` (409) | The rate changed; `changes` says how. Show the user what changed and re-run `{{tool:hotels rates}}`; if they still want it, call `{{tool:hotels book}}` again and confirm the new price and terms before giving the link |\n| `rate_unavailable` (422) | The rate can no longer be booked: re-run `{{tool:hotels rates}}` and let the user choose again |\n| `supplier_unavailable` (503 or 504) | Wait `retryAfterSeconds` and retry once with the same details; if it fails again, tell the user |\n| `refused` (422) | The booking was refused (a guest name, too many attempts, bookings paused): read `error` and explain it to the user |\n{{#if claude-skill}}\n\nWithout a `reason`:\n\n- **429**: wait `retryAfterSeconds`, then retry once.\n- **503** with `retryable: true` is a brief failure on Fortrip's side (for example, the same booking started twice at once): wait `retryAfterSeconds`, then retry once with the same details.\n- **500** and **502** are generic failures: read `error` and tell the user instead of guessing.\n{{/if}}\n{{/if}}\n",
710
- "meals": "{{#if claude-skill cli}}\n## Presenting a rate\n\nGive the user, for each option:\n\n- the meal plan: `boardCode` (RO room only, BB breakfast, HB breakfast + dinner, FB breakfast + lunch + dinner, BL lunch, BD dinner, HBBL breakfast + lunch, HBLD lunch + dinner, AI all inclusive) with `boardCount`, how many guests per room the meal plan covers. Fewer than the adults per room means not everyone gets the meals (BB with `boardCount` 1 for 2 adults is breakfast for 1 of 2): say who is covered;\n- cancellation: `refundable` and `freeCancelUntil`;\n- confirmation: `isInstantConfirm`;\n- any `fees`: taxes paid at the hotel, in their own currency.\n{{/if}}\n",
711
- "occupancy": "{{#if claude-skill cli}}\n## Occupancy\n\n{{/if}}\n- `{{tool:hotels rates}}` and `{{tool:hotels book}}` both require `{{arg:rooms}}` and `{{arg:adults}}`. `{{arg:adults}}` and `{{arg:childrenAges}}` are **per room**, the same in every room: 2 rooms for 4 adults is {{#if cli}}`--rooms 2 --adults 2`{{/if}}{{#if mcp claude-skill}}`rooms: 2, adults: 2`{{/if}}.\n{{#if claude-skill cli}}\n- If the party doesn't divide evenly across the rooms (3 adults in 2 rooms), ask the user whether to price every room for the larger number or to price and book each occupancy as its own stay.\n- Ask each child's age; `{{tool:hotels rates}}` and `{{tool:hotels book}}` need it.\n- Limits: 1\u2013{{limit:rooms}} rooms, 1\u2013{{limit:adultsPerRoom}} adults and 0\u2013{{limit:childrenPerRoom}} children (aged 0\u2013{{limit:childAgeMax}}) per room, 1\u2013{{limit:nights}} nights, up to {{limit:ratesHotels}} hotels per `{{tool:hotels rates}}` call.\n{{/if}}\n",
712
- "order": '{{#if claude-skill cli}}\n## Workflow\n\n1. **Search** with `{{tool:hotels search}}` for candidate hotels and indicative prices. It needs `{{arg:checkIn}}` and `{{arg:checkOut}}`.\n{{#if cli}}\n\n ```bash\n {{tool:hotels search}} --query "Bangkok" --check-in 2026-11-10 --check-out 2026-11-12 \\\n --star-min 4 --sort price-asc --page-size 10\n ```\n\n{{/if}}\n2. **Details** (optional) with `{{tool:hotels get}}`, when the user asks about a hotel or a stated preference needs evidence (a pool, a policy, room size). For a details-only question, call it directly when you know the hotel\'s id; otherwise find the hotel with `{{tool:hotels search}}` using the user\'s dates (ask for them if they gave none).\n{{#if cli}}\n\n ```bash\n {{tool:hotels get}} 266000 [--with-rooms]\n ```\n\n{{/if}}\n3. **Rates** with `{{tool:hotels rates}}` for a few shortlisted hotels, using the dates and occupancy the user will book. It takes about 15 seconds. A hotel is available only once this returns a live option for it.\n{{#if cli}}\n\n ```bash\n {{tool:hotels rates}} --hotel 266000 --hotel 266001 --check-in 2026-11-10 \\\n --check-out 2026-11-12 --rooms 1 --adults 2 [--child-age 6] \\\n [--nationality CN] [--board BB] [--free-cancellation] [--currency USD]\n ```\n\n{{/if}}\n4. **Book** with `{{tool:hotels book}}`, once the user has picked one rate and given the guest details (see Booking).\n{{#if cli}}\n\n ```bash\n {{tool:hotels book}} --hotel 266000 --room-id 9191658 --rate-plan "<ratePlanCode>" \\\n --check-in 2026-11-10 --check-out 2026-11-12 --rooms 1 --adults 2 \\\n --guest "John Smith" --contact-name "John Smith" \\\n --contact-phone "+8613800000000" [--contact-email john@example.com]\n ```\n\n{{/if}}\n5. **Confirm, then give the payment link**, as in "Before giving the payment link" below.\n\n## Search\n\n{{/if}}\n- Search prices come from a minimum-price feed, not live rates: quote `{{tool:hotels rates}}` prices, and never call a search result available or bookable.{{#if claude-skill cli}} When you show search prices, say they are indicative minimums, not quotes.{{/if}}\n- A search that matches too many hotels fails with `matched` and `maxMatches`: narrow it{{#if claude-skill cli}} (a city, an area, stars or a price range){{/if}}.\n{{#if claude-skill cli}}\n- Use `{{arg:near}}` for "near X"{{#if cli}} (`lat,lng,radiusKm`){{/if}}, and `{{arg:cityCode}}` when you know the city\'s code.\n- Facility names in `{{arg:facilities}}` must match the catalogue exactly (`Swimming pool`, `Parking`); near-synonyms match nothing.\n{{/if}}\n',
713
- "payment-link": "{{#if claude-skill cli}}\n## Before giving the payment link\n\n`{{tool:hotels book}}` returns `paymentUrl`, `statusUrl`, `orderUrl`, `amount`, `currency`, `terms` and `stay`. Nothing is charged until the user pays.\n\n1. Show the `stay` (hotel, room, dates, nights, rooms, adults and children's ages per room), the `amount` and `currency` to be charged, and every item in `terms`: meal plan (`boardCode` with `boardCount`), cancellation (`refundable`, `freeCancelUntil`, the `cancellation` ladder), confirmation (`isInstantConfirm`, `confirmByMins`) and `payAtHotelFees`.\n2. End that reply by asking the user to confirm, and wait for a clear yes. Urgency is not consent.\n3. Then give `paymentUrl` exactly as returned: don't shorten, rebuild or re-encode it. It opens a Fortrip page that recaps the booking and continues to payment on Stripe.\n\n{{/if}}\n{{#if mcp}}\n- Before you give `paymentUrl`, show the user the `stay`, `amount`, `currency` and every item in `terms` that `{{tool:hotels book}}` returned, end that reply by asking the user to confirm, and wait for a clear yes. Then give `paymentUrl` exactly as returned, to this user only: anyone holding it can pay.\n{{/if}}\n- If `amount` or `terms` is null, don't give the link: call `{{tool:hotels book}}` again with the same details, and if either is still null, tell the user to contact {{support}}.\n{{#if claude-skill cli}}\n- `paymentUrl` works like a payment credential: anyone holding it can pay. Give it to this user only, never in shared channels, documents, logs or tickets. It expires about 3 hours 45 minutes after it was created; for a fresh one, call `{{tool:hotels book}}` again with the same details.\n- `statusUrl` is the same page: after payment it shows the booking's confirmation progress, without signing in. `orderUrl` is the user's signed-in order page.\n{{/if}}\n- Never ask for, accept or pass on payment card details; payment happens only on the payment page.{{#if claude-skill cli}} A card number in a guest name, the contact name or the special requests is rejected{{#if cli}} with exit 2{{/if}}.{{/if}}\n",
714
- "planning": "# Planning a hotel stay\n\nRead this when the request is more than one hotel for fixed dates: flexible dates, a budget, split stays, several destinations, or \"find me the best\". The rules in {{#if mcp}}the server instructions{{/if}}{{#if claude-skill}}SKILL.md{{/if}}{{#if cli}}`{{tool:guide}}`{{/if}} still apply (call order, occupancy per room, confirming the terms `{{tool:hotels book}}` returns before giving the payment link); this covers what to ask, how to compare and how to present.{{#if cli}} As there, `fortrip` is short for `npx -y fortrip@latest`.{{/if}}\n\n## What to collect\n\nKeep track of: dates (or the flexible window), nights, destination(s), rooms and the guests in each room, room constraints (beds, connecting rooms, accessibility), budget, and what the user said matters most (cheapest, most flexible, closest to X).\n\nOccupancy is per room and the same in every room, so if the party doesn't split evenly across the rooms, ask how it splits.\n\nAsk only for what changes which hotels qualify or how they rank. If children come along, ask each child's age; `{{tool:hotels rates}}` and `{{tool:hotels book}}` need it. Don't ask for trip type or style: infer it from the conversation or leave it general.\n\n## Evidence\n\n- A `{{tool:hotels search}}` price is an indicative minimum, not a quote. Never call a search result available or bookable.\n- Use `{{tool:hotels get}}` only when a stated preference needs evidence (a pool, a policy, room size).\n- A hotel is available only once `{{tool:hotels rates}}` returned a live option for it.\n- Anything {{#if mcp claude-skill}}the tools{{/if}}{{#if cli}}the CLI{{/if}} didn't return, such as reviews, style, price trends or exchange rates other than `fx`, is unknown. Say so instead of filling it in.\n\n## Ranking\n\n1. Drop options that break a hard constraint (dates, guests, budget cap, required facility). Compare a budget in the unit the user gave it: per night, per room or for the whole stay.\n2. Keep only options with a live rate.\n3. Order by the user's stated main goal. Goals they didn't state don't count.\n4. Use other preferences only to break ties.\n\nUse the cheapest comparable live room as the baseline unless the user fixed the room type. Show at most three plans, each with its price difference from the baseline and what it trades off (meal plan, cancellation, location, instant confirmation). Keep hotel names as returned and dates in the user's notation.\n\nFor a split stay or several destinations, price each leg with its own `{{tool:hotels rates}}` call and add up the legs. Each leg is a separate `{{tool:hotels book}}` call, with its own confirmation and its own payment link.\n\n## Stop and say so\n\n- `{{tool:hotels rates}}` covers up to {{limit:ratesHotels}} hotels per call and takes about 15 seconds. If the request needs more hotels or date combinations than a few calls can cover, narrow it with the user, or present the result as a comparison of the hotels you priced, not the best overall.\n- Urgency is not consent. Call `{{tool:hotels book}}` only for the rate the user picked, and give its payment link only after the user confirms the amount, currency, stay and terms that `{{tool:hotels book}}` returned.\n",
715
- "prices": "{{#if claude-skill cli}}\n## Prices and currency\n\n{{/if}}\n- In `{{tool:hotels rates}}`, `totalPrice` is the whole stay for all rooms, and `dailyPrices` are per room per night.{{#if claude-skill cli}} To compare with a per-night or per-room budget, divide the total by the nights and rooms, or use `dailyPrices`.{{/if}}\n- Pass `{{arg:currency}}`{{#if claude-skill cli}} to `{{tool:hotels search}}` and `{{tool:hotels rates}}`{{/if}}: `CNY` (the default) or `USD`, to suit the user's language or location.{{#if cli}} Without `--currency`, the CLI uses the currency in `--body`, then `FORTRIP_CURRENCY`, then `CNY`.{{/if}}{{#if claude-skill cli}} Prices are converted at Stripe's exchange rate, or a fixed fallback rate when Stripe is unavailable; `fx` says which, and is `null` when nothing needed converting.{{/if}}\n{{#if claude-skill cli}}\n- `{{arg:priceMin}}` and `{{arg:priceMax}}` on `{{tool:hotels search}}` bound the indicative nightly price, in that currency.\n{{/if}}\n- The charge is the `amount` in the `currency` that `{{tool:hotels book}}` returns (currently USD). A converted price is a reference: never present it as the charge.{{#if claude-skill cli}} Stripe's page may also show the charge in the user's local currency, at Stripe's rate. Fees paid at the hotel are separate, in their own currency.{{/if}}\n",
716
- "scope": "{{#if claude-skill cli}}\n# Fortrip hotel stays\n\n{{/if}}\nFortrip: hotel search, live rates and booking payment links.{{#if claude-skill cli}} Not for flights, activities, or changing or cancelling an existing booking.{{/if}}\n\n{{#if mcp}}\nCall order: `{{tool:hotels search}}` \u2192 (optional) `{{tool:hotels get}}` \u2192 `{{tool:hotels rates}}` \u2192 `{{tool:hotels book}}`. `{{tool:guide}}` returns Fortrip's guidance for flexible dates, budgets, split stays, several destinations or \"find the best\" requests.\n{{/if}}\n{{#if claude-skill}}\nThe tools are on Fortrip's MCP server, named `fortrip` in this plugin (a Fortrip connector added on claude.ai can show under its own name). They're written `fortrip:<tool>` below:\n{{/if}}\n{{#if cli}}\nThe `fortrip` CLI (npm package `fortrip`) calls Fortrip as the signed-in user and prints JSON:\n{{/if}}\n{{#if claude-skill cli}}\n\n| {{#if claude-skill}}Tool{{/if}}{{#if cli}}Command{{/if}} | Returns |\n|---|---|\n| `{{tool:hotels search}}` | Hotels by name, brand or city (`{{arg:query}}`), `{{arg:cityCode}}`, or a point and radius (`{{arg:near}}`), with indicative prices |\n| `{{tool:hotels get}}` | One hotel's address, check-in and check-out times, policies, facilities and photos; no prices |\n| `{{tool:hotels rates}}` | Live rooms, meal plans, cancellation terms and prices for up to {{limit:ratesHotels}} hotels |\n| `{{tool:hotels book}}` | An unpaid booking for one rate, with its terms and a payment link |\n\nFor flexible dates, a budget, split stays, several destinations or \"find me the best\", {{#if claude-skill}}read [references/planning.md](references/planning.md){{/if}}{{#if cli}}also run `{{tool:guide}} planning`{{/if}} before searching.\n{{/if}}\n",
717
- "setup": "{{#if cli}}\n## Setup\n\nRun every command as `npx -y fortrip@latest <command>` (Node.js 22 or newer). The examples below shorten that to `fortrip`; `fortrip <command> --help` lists a command's flags. `{{tool:hotels search}}`, `{{tool:hotels rates}}` and `{{tool:hotels book}}` also take `--body '<json>'` (or `--body @file.json`), the raw request body; flags override its fields.\n\nWhen the user first asks about hotels, run `{{tool:whoami}}` before asking follow-up questions, so the sign-in can happen while they answer.\n\n{{/if}}\n{{#if claude-skill cli}}\n## Sign-in\n\n{{/if}}\nSign-in happens only in the browser. Never ask the user to paste a token.\n{{#if claude-skill}}\n\nIf none of these tools are available, Fortrip isn't connected: tell the user to add the Fortrip connector or plugin and sign in.\n{{/if}}\n{{#if mcp claude-skill}}\nIf a tool says the user isn't signed in, ask them to reconnect Fortrip and sign in again.\n{{/if}}\n{{#if cli}}\n\nIf a command exits with code **3** and stderr has `loginUrl`, show it to the user as a link together with `userCode`, e.g. \"Sign in to Fortrip: <loginUrl> (code <userCode>), then tell me when you're done.\" When they confirm, rerun the same command; it finishes the sign-in itself. Don't run `{{tool:login}}`: it waits for up to 10 minutes. Commands that exit 3 together share one `loginUrl`; show it once. If the rerun gives a different `loginUrl`, show that one.\n\nWhen Fortrip no longer accepts the saved sign-in (it was revoked, or it expired and couldn't be renewed), the command also exits 3 with a `loginUrl`; handle it the same way. The saved sign-in is kept until the user approves the new link.\n\nIn CI or a sandbox without a browser, the user can set `FORTRIP_TOKEN` instead. Exit 3 with `tokenSource: \"FORTRIP_TOKEN\"` means Fortrip didn't accept that token: tell the user to replace or unset it.\n\n## Exit codes\n\n| Code | Meaning | What to do |\n|---|---|---|\n| 0 | Success; JSON on stdout | Continue |\n| 1 | Any other error: an error from Fortrip (see Errors), a network error or a timeout | Read `error` on stderr. If `retryable` is true, wait `retryAfterSeconds` (or a few seconds) and retry once |\n| 2 | Invalid arguments | Fix the flags; stderr `issues` lists each bad field with a message |\n| 3 | Not signed in | Show `loginUrl` to the user, then rerun (above) |\n| 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |\n\nOutput on stdout or stderr can carry `warning` (with `recoveryFile` when a sign-in couldn't be saved): pass it on to the user.\n{{/if}}\n",
718
- "support": "{{#if claude-skill cli}}\n## Changes and support\n\n{{/if}}\n{{#if mcp claude-skill}}These tools{{/if}}{{#if cli}}The CLI{{/if}} can't change or cancel a booking: for changes, cancellations, refunds or other problems with a booking, the user emails {{support}} with the booking's `orderUrl` (never `paymentUrl` or `statusUrl`).\n"
1047
+ "booking": "{{#if claude-skill cli}}\n## Ordering\n\n{{/if}}\n- Call `{{tool:hotels book}}` only for the offer the user picked, with at least one guest per room{{#if claude-skill cli}}, named as the user gave them{{/if}}; never invent guests.{{#if claude-skill cli}} Room 1 is the offer's first room, and a room takes no more guests than it sleeps.{{/if}}\n- Guest names: Latin letters as on the passport (M\xFCller \u2192 Mueller), first and last name 1\u2013{{limit:guestName}} characters each{{#if cli}}, as `--guest 1:John:Smith` (room 1, first name, last name){{/if}}.\n{{#if claude-skill cli}}\n- The contact's first and last name (up to {{limit:contactFirstName}} characters each), email and phone (with its country code) are all required. Special requests must be in English and aren't guaranteed.\n{{/if}}\n- `{{tool:hotels book}}` can take up to about two minutes{{#if cli}}: allow the command at least 3 minutes{{/if}}. If it times out, {{#if cli}}run it again with identical flags{{/if}}{{#if mcp claude-skill}}call it again with the same details{{/if}}: it returns the same order.\n{{#if mcp}}\n- On `offer_changed`, show the user every change and ask again before ordering the new offer.\n{{/if}}\n{{#if cli}}\n- `{{tool:hotels book}}` takes only these fields: a `--body` with any other field (such as `nationality`) is rejected with exit 2.\n{{/if}}\n",
1048
+ "errors": "{{#if claude-skill cli}}\n## Errors\n\n{{#if claude-skill}}A tool error{{/if}}{{#if cli}}An error on stderr{{/if}} carries `code`, `message` and `retryable`, and `retryAfterSeconds` when waiting helps; many carry `details`. Search errors are under Search. Ordering errors:\n\n| `code` | Do this |\n|---|---|\n| `offer_changed` (409) | The price or terms changed since the offer: `changes` lists every change (`worse` says which are worse for the user) and `offer` is the new offer. Show the user every change and ask again; if they still want it, order the new offer, and confirm the new order before giving its link |\n| `offer_expired` (409) | Get offers again and let the user choose again |\n| `offer_unavailable` (422) | The offer is sold out or was refused: get offers again and let the user choose again |\n| `order_in_progress` (409) | The same order is still being created: wait `retryAfterSeconds`, then {{#if claude-skill}}call again with the same details{{/if}}{{#if cli}}run the command again with identical flags{{/if}} |\n| `offer_already_ordered` (409) | This offer was already ordered with other details: show that order (`orderId`) instead. An offer is ordered once |\n| `order_exists` (409) | This stay already has an order (`orderId`, `status`): show it instead of ordering again. This also blocks ordering the same offer, dates and rooms again for another party, ordering a stay again after it was cancelled, ordering while the user's own website checkout for the stay is open (up to 3 hours 45 minutes) and ordering again a stay whose earlier order failed at the hotel. For those, the user emails {{support}} |\n| `order_attempts_exhausted` (409) | This order can't be tried again: the user emails {{support}} with its `reference` |\n| `order_removed` (410) | The order was never completed and no payment is held: get offers again and order the new offer (the order keeps its `orderId`) |\n| `stay_unsupported` (422) | `mixed_occupancy`: order once per party; `max_nights`: at most `max` nights per order |\n| `stay_in_past` (422) | Check-in has passed in the hotel's time zone: ask for new dates |\n| `ordering_paused` (503) | Ordering is paused on Fortrip's side: tell the user, and try again only after `retryAfterSeconds` (10 minutes or more) |\n\nAny code:\n\n- `invalid_request` (400): `details.issues` lists each bad field; fix them. For amenities, `details.filterable` lists those that can filter.\n- `cursor_invalid` (400): the page is gone; search again from the first page.\n- `not_found` (404): no such hotel, or no such order for this user.\n- `rate_limited` (429): wait `retryAfterSeconds`, then retry once.\n- `service_unavailable` (503) and `supplier_unavailable` (503 or 504): wait `retryAfterSeconds`, then retry once with the same details; if it fails again, tell the user.\n- Any other code: read `message`. If `retryable` is true, wait `retryAfterSeconds` and retry once; otherwise tell the user instead of guessing.\n{{/if}}\n",
1049
+ "meals": '{{#if claude-skill cli}}\n## Presenting an offer\n\nGive the user, for each offer:\n\n- the room (`room.name`, beds, size) and the total, as above;\n- the meals: `meals.included` (breakfast, lunch, dinner) or `allInclusive`, with `guestsCoveredPerRoom`. Fewer than the guests per room means not everyone gets the meals (breakfast for 1 with 2 adults in the room): say who is covered. `unspecifiedMainMeals` counts main meals the hotel didn\'t name. With `known: false`, the meals are unknown: say so, and don\'t call it room only;\n- cancellation: `status` (`free`, `partial_penalty`, `non_refundable` or `unknown`), `freeCancellationEndsAt` (null with `free`: free until check-in) and the `penalties`. Times are in UTC: also give them in the hotel\'s `timeZone`;\n- confirmation: `mode` `instant` or `on_request` (within `withinMinutes`);\n- fees payable at the property (`payAtProperty.items`), in the property\'s currency. `disclosure: "incomplete"` means there can be fees not listed: say so.\n\nAn offer expires at `expiresAt`, about 30 minutes after it was made; after that, get offers again.\n\n"Not listed" is not "no": `listed: false` (amenities, policies, rooms), `null` and `unknown` mean the source didn\'t say. Tell the user it\'s unknown instead of turning it into "no".\n{{/if}}\n{{#if mcp}}\n- "Not listed" or "unknown" is not "no".\n{{/if}}\n',
1050
+ "occupancy": "{{#if claude-skill cli}}\n## Rooms and guests\n\n- {{#if mcp claude-skill}}`stay.rooms` lists each room's party as `{ adults, childrenAges }`: two rooms of 2 adults, one with a child of 6, is `[{ adults: 2, childrenAges: [6] }, { adults: 2, childrenAges: [] }]`{{/if}}{{#if cli}}`--room <adults[:age,\u2026]>`, once per room, gives each room's party: two rooms of 2 adults, one with a child of 6, is `--room 2:6 --room 2`{{/if}}.\n- Ask each child's age; offers need it.\n- If the party doesn't split evenly across the rooms (3 adults in 2 rooms), ask the user how it splits.\n{{/if}}\n- Offers and orders take one party per call, every room the same: for different parties, order once per party.{{#if claude-skill cli}} `{{tool:hotels offers}}` answers `stay_unsupported` (`mixed_occupancy`) for mixed rooms; `{{tool:hotels search}}` accepts them but prices only from the dates, with the note `party_split_required`.{{/if}}\n{{#if claude-skill cli}}\n- Limits: 1\u2013{{limit:rooms}} rooms, 1\u2013{{limit:adultsPerRoom}} adults and 0\u2013{{limit:childrenPerRoom}} children (aged 0\u2013{{limit:childAgeMax}}) per room, 1\u2013{{limit:nightsSupplier}} nights, up to {{limit:offersHotels}} hotels per `{{tool:hotels offers}}` call. A longer stay answers `stay_unsupported` (`max_nights`): ask the user whether to split it into consecutive stays, each its own order.\n{{/if}}\n",
1051
+ "order": '{{#if claude-skill cli}}\n## Workflow\n\n1. **Search** with `{{tool:hotels search}}` for candidate hotels: a destination, `{{arg:text}}` or both, and the stay\'s dates and rooms for indicative prices.\n{{#if cli}}\n\n ```bash\n {{tool:hotels search}} --place-name Bangkok --country TH --check-in 2026-11-10 \\\n --check-out 2026-11-12 --room 2 --stars-min 4 --sort price_asc --limit 10\n ```\n\n{{/if}}\n2. **Details** (optional) with `{{tool:hotels get}}`, when the user asks about a hotel or a stated preference needs evidence (a pool, a policy, room size). For a details-only question, call it directly when you know the hotel\'s `hotelId`; otherwise find the hotel with `{{tool:hotels search}}`.\n{{#if cli}}\n\n ```bash\n {{tool:hotels get}} <hotelId> [--rooms]\n ```\n\n{{/if}}\n3. **Offers** with `{{tool:hotels offers}}` for a few shortlisted hotels, with the dates and rooms the user will order. It takes about 15 seconds. A hotel is available only once this returns an offer for it.\n{{#if cli}}\n\n ```bash\n {{tool:hotels offers}} --hotel <hotelId> --hotel <hotelId> --check-in 2026-11-10 \\\n --check-out 2026-11-12 --room 2 --room 2 [--require-free-cancellation]\n ```\n\n{{/if}}\n4. **Order** with `{{tool:hotels book}}`, once the user has picked one offer and given the guests and the contact (see Ordering).\n{{#if cli}}\n\n ```bash\n {{tool:hotels book}} --offer <offerId> --guest 1:John:Smith --guest 2:Mary:Smith \\\n --first-name John --last-name Smith --email john@example.com \\\n --phone "+8613800000000"\n ```\n\n{{/if}}\n5. **Confirm, then give the payment link**, as in "Before giving the payment link" below.\n6. **After payment**, check the order with `{{tool:orders get}}` (see After payment).\n\n## Search\n\n### Where\n\n- **A place by name:** {{#if mcp claude-skill}}`{ kind: "place_name", name, countryCode }`{{/if}}{{#if cli}}`--place-name <name> --country <CC>`{{/if}}, in any language (Kyoto or \u4EAC\u90FD). The country is required: ask the user when it isn\'t clear. Names match exactly, ignoring only case and spacing: accents, hyphens, apostrophes and periods count ("K\xF6ln" or "Cologne", not "Koln"; "St. Moritz", not "St Moritz").\n- If several places bear the name, the answer is `destination_ambiguous` with up to {{limit:candidatesShown}} candidates and their hotel counts: ask the user which one they mean, then search it by its `placeId`; never pick one yourself.\n- `destination_not_found` with `no_match` means no place of that name in that country: check the spelling and accents, or try another name for the place. Its `nameAsText` counts the hotels that a `{{arg:text}}` search for the name would find.\n- **A place from an earlier answer:** {{#if mcp claude-skill}}`{ kind: "place", placeId }`{{/if}}{{#if cli}}`--place <placeId>`{{/if}}.\n- **Near a point:** {{#if mcp claude-skill}}`{ kind: "point", location: { lat, lng } }`{{/if}}{{#if cli}}`--point <lat,lng>`{{/if}}, for "near X" when you know where X is.\n- **A whole country:** {{#if mcp claude-skill}}`{ kind: "country", countryCode }`{{/if}}{{#if cli}}`--country-only <CC>`{{/if}}, as for the Maldives.\n- Countries, regions (states, provinces), prefecture-level cities in China and main islands cover their whole area. Towns, neighbourhoods, districts, small islands and points cover a circle of {{limit:radiusMetersDefault}} meters; `{{arg:destination.radiusMeters}}` sets another, up to {{limit:radiusMetersMax}} meters, and narrows an area to a circle around its center.\n- **Localities:** an area search lists its `localities`, the towns its hotels belong to, each with a `placeId`, its hotel `count` and the places it `includes` (Patong is part of Phuket). To search one, pass it as {{#if mcp claude-skill}}`{ kind: "locality", placeId }`{{/if}}{{#if cli}}`--locality <placeId>`{{/if}}. A town that isn\'t a locality answers `destination_not_found` with `not_a_locality` and the `hub` it belongs to: search the hub, or the town itself with {{#if mcp claude-skill}}`kind: "place"`{{/if}}{{#if cli}}`--place`{{/if}} for a circle around it.\n- A price sort or price filter over more than {{limit:poolLimit}} hotels (in an area, or for text alone) answers `search_too_broad` with `matched` and the top `localities`: show them and ask the user which they prefer, unless the request already names one of them; search that one by its `placeId` as a locality, never by its name.\n\n### Text\n\n`{{arg:text}}` matches exact words in hotel names and addresses, in Chinese or English:\n\n- Every word must match as written: plurals, accents, spacing and abbreviations count ("hostel" and "hostels", "St Regis" and "Saint Regis" find different hotels). Hyphens and apostrophes don\'t (Ritz-Carlton finds Ritz Carlton).\n- In Chinese, matching follows word segmentation, so part of a name can match unrelated hotels (\u5E0C\u5C14 is not \u5E0C\u5C14\u987F).\n- Use only distinctive words: a hotel or brand name, a district or a landmark. Not "hotel", "near", "cheap" or "best".\n- Typos return nothing: "Shangahi" finds no hotel.\n- Place names belong in the destination: "Maldives" as text finds a hotel named Maldives in China.\n\n### Filters\n\n- Stars run from 1 to 5 in half steps{{#if mcp claude-skill}} (`filters.stars`){{/if}}. "4 stars" as {{#if mcp claude-skill}}`{ min: 4, max: 4 }`{{/if}}{{#if cli}}`--stars-min 4 --stars-max 4`{{/if}} leaves out 4.5: unless the user means exactly 4, use {{#if mcp claude-skill}}`{ min: 4 }`{{/if}}{{#if cli}}`--stars-min 4`{{/if}} or ask. Unrated hotels are left out whenever stars are filtered.\n- `{{arg:filters.amenities}}` takes only the amenities that can filter (such as pool, parking, wifi); others, such as gym or spa, show in `{{tool:hotels get}}` but can\'t filter. A hotel matches only when it lists the amenity, and about half the hotels list no amenities at all (`coverage.excludedNoFacilityData` counts those left out): "not listed" is not "absent".\n- `{{arg:filters.price.maxPerRoomNight}}` bounds the indicative price per room per night, in USD; hotels without one are left out and counted.\n\n### Results\n\n- `notes` explain the answer (a limited pool, a circle around an island, more hotels just outside it): pass on what matters to the user.\n- `coverage` has the counts: `matched` hotels, how many were priced and how many have a price.\n- A next page is {{#if mcp claude-skill}}`cursor` set to `nextCursor` and, optionally, `limit`{{/if}}{{#if cli}}`--cursor <nextCursor>` and, optionally, `--limit`{{/if}}; nothing else.\n{{/if}}\n{{#if mcp}}\n- A place name needs its `countryCode` (ask the user if unclear) and matches exactly (accents and periods count). On `destination_ambiguous`, ask the user which they mean, then search its `placeId`; never pick one yourself.\n- On `search_too_broad`, show its `localities` and ask the user which, unless they already named one; search it as `{ kind: "locality", placeId }`, never by name.\n- `text` matches whole words of hotel names and addresses: use a distinctive name, brand or landmark. A typo finds nothing.\n{{/if}}\n',
1052
+ "payment-link": "{{#if claude-skill cli}}\n## Before giving the payment link\n\n`{{tool:hotels book}}` answers with an unpaid order: `status` `awaiting_payment`, its `reference` (the support number), `price.total`, the stay and its terms, and `payment.url`, a Fortrip page that recaps the order and continues to payment on Stripe. Nothing is charged until the user pays.\n\n1. Show the order's total and currency, the stay (hotel, room, dates, rooms and guests per room) and every term: meals, cancellation (status, deadline, penalties) and fees payable at the property, unknowns included.\n2. End that reply by asking the user to confirm, and wait for a clear yes. Urgency is not consent.\n3. Then give `payment.url` exactly as returned: don't shorten, rebuild or re-encode it.\n\n{{/if}}\n{{#if mcp}}\n- Before you give `payment.url`, show the user the order's total and currency, the stay and every term, end that reply by asking the user to confirm, and wait for a clear yes. Then give `payment.url` exactly as returned, to this user only: anyone holding it can pay.\n{{/if}}\n{{#if claude-skill cli}}\n- `payment.url` works like a payment credential: anyone holding it can pay. Give it to this user only, never in shared channels, documents, logs or tickets. With `requiresSignIn: true` (an order started on the Fortrip website), the user signs in to pay.\n- The link expires at `payment.expiresAt`; the order is then `expired`. To order the stay again, get offers again, and confirm the new order with the user before giving its link.\n- An order `awaiting_payment` whose `payment.url` is null had its link unreadable this time: read it again with `{{tool:orders get}}`.\n{{/if}}\n- Never ask for, accept or pass on payment card details; payment happens only on the payment page.{{#if claude-skill cli}} A card number in a guest name, the contact or the special requests is rejected{{#if cli}} with exit 2{{/if}}.{{/if}}\n{{#if claude-skill cli}}\n\n## After payment\n\nCheck the order with `{{tool:orders get}}`:\n\n- `confirming` while the hotel confirms, then `confirmed` with the hotel's `confirmationNumber`.\n- `failed`: no stay was booked; `payment.status` says whether the payment was released or refunded.\n- `needs_attention`: Fortrip is looking into it; the user can email {{support}} with the `reference`.\n- `manageUrl` is the user's own page for the order.\n{{/if}}\n",
1053
+ "planning": "# Planning a hotel stay\n\nRead this when the request is more than one hotel for fixed dates: flexible dates, a budget, split stays, several destinations, or \"find me the best\". The rules in {{#if mcp}}the server instructions{{/if}}{{#if claude-skill}}SKILL.md{{/if}}{{#if cli}}`{{tool:guide}}`{{/if}} still apply (call order, destinations, one party per offer and order, confirming the order `{{tool:hotels book}}` returns before giving the payment link); this covers what to ask, how to compare and how to present.{{#if cli}} As there, `fortrip` is short for `npx -y fortrip@latest`.{{/if}}\n\n## What to collect\n\nKeep track of: dates (or the flexible window), nights, destination(s) with their country, rooms and the guests in each room, room constraints (beds, connecting rooms, accessibility), budget, and what the user said matters most (cheapest, most flexible, closest to X).\n\nOffers and orders take one party per call, every room the same, so if the party doesn't split evenly across the rooms, ask how it splits. Rooms for different parties are priced and ordered separately, each order with its own payment link.\n\nAsk only for what changes which hotels qualify or how they rank. \"4 stars\" as {{#if mcp claude-skill}}`{ min: 4, max: 4 }`{{/if}}{{#if cli}}`--stars-min 4 --stars-max 4`{{/if}} leaves out 4.5: unless the user means exactly 4, use {{#if mcp claude-skill}}`{ min: 4 }`{{/if}}{{#if cli}}`--stars-min 4`{{/if}} or ask. If children come along, ask each child's age; `{{tool:hotels offers}}` needs it. Don't ask for trip type or style: infer it from the conversation or leave it general. When a place name is ambiguous, ask which place the user means.\n\n## Evidence\n\n- A `{{tool:hotels search}}` price is an indicative estimate per room per night, not a quote. Never call a search result available or bookable, and once you have offers, compare offers only. A hotel without a search price can still have offers.\n- Use `{{tool:hotels get}}` only when a stated preference needs evidence (a pool, a policy, room size). An amenity or policy it doesn't list is unknown, not absent.\n- A hotel is available only once `{{tool:hotels offers}}` returned an offer for it.\n- Anything {{#if mcp claude-skill}}the tools{{/if}}{{#if cli}}the CLI{{/if}} didn't return, such as reviews, style, price trends or exchange rates other than `approx`, is unknown. Say so instead of filling it in.\n\n## Ranking\n\n1. Drop options that break a hard constraint (dates, guests, budget cap, required facility). Compare a budget in the unit the user gave it: per night, per room or for the whole stay. An offer's `price.total` covers the whole stay and every room, in USD.\n2. Keep only options with a live offer.\n3. Order by the user's stated main goal. Goals they didn't state don't count.\n4. Use other preferences only to break ties.\n\nUse the cheapest comparable offer as the baseline unless the user fixed the room type. Show at most three plans, each with its price difference from the baseline and what it trades off (meals, cancellation, location, confirmation). Keep hotel names as returned and dates in the user's notation.\n\nFor a split stay or several destinations, price each leg with its own `{{tool:hotels offers}}` call and add up the legs. Each leg is a separate `{{tool:hotels book}}` call, with its own confirmation and its own payment link.\n\n## Stop and say so\n\n- `{{tool:hotels offers}}` covers up to {{limit:offersHotels}} hotels per call and takes about 15 seconds, and its offers expire after about 30 minutes. If the request needs more hotels or date combinations than a few calls can cover, narrow it with the user, or present the result as a comparison of the hotels you priced, not the best overall.\n- When a search is too broad to price, show its localities and ask the user which they prefer, unless the request already names one of them; never pick one yourself.\n- Urgency is not consent. Call `{{tool:hotels book}}` only for the offer the user picked, and give its payment link only after the user confirms the total, currency, stay and terms of the order `{{tool:hotels book}}` returned.\n",
1054
+ "prices": "{{#if claude-skill cli}}\n## Prices and currency\n\n- A search price (`price.fromPerRoomNight`) is indicative and not bookable: the lowest price of one unit (a room or, at a hostel, possibly a bed) per night, for any party and often any date, and it can be well above or below the offers. Call it an estimate, quote `{{tool:hotels offers}}` prices, and never call a search result available or bookable. `price: null` means there is no indicative price, not that the hotel is full; about half the hotels have none for given dates.\n{{/if}}\n{{#if mcp}}\n- Search prices are indicative, not bookable: quote `{{tool:hotels offers}}` prices.\n{{/if}}\n- {{#if claude-skill cli}}In an offer or order, {{/if}}`price.total` is the whole stay for all rooms and is what is charged, in USD for offers and the orders made from them.{{#if claude-skill cli}} `perNightAllRooms` is the total per night; to compare with a per-room budget, also divide by the rooms.{{/if}} Stripe's page may let the user pay in their card's local currency at Stripe's rate (Adaptive Pricing){{#if claude-skill cli}}: say so plainly, and that the USD total is the price{{/if}}. `approx`{{#if claude-skill cli}}, in `{{arg:displayCurrency}}`, with its `rateDate`,{{/if}} is an estimate, never the charge.\n{{#if claude-skill cli}}\n- `{{arg:displayCurrency}}`{{#if cli}} (or `FORTRIP_DISPLAY_CURRENCY`){{/if}} is `USD` or `CNY`: `CNY` adds `approx` in yuan to suit a user who thinks in it. It never changes the charge.\n- Fees payable at the property (`payAtProperty`) are separate, in the property's own currency, not converted.\n- After payment, `payment.localCharge` is what the card paid in its own currency. Orders made on the Fortrip website can be charged in another currency: `payment.chargeCurrency` says which.\n{{/if}}\n",
1055
+ "scope": "{{#if claude-skill cli}}\n# Fortrip hotel stays\n\n{{/if}}\nFortrip: hotel search, live offers and orders with a payment link.{{#if claude-skill cli}} Not for flights, activities, or changing or cancelling an existing order.{{/if}}\n\n{{#if mcp}}\nCall order: `{{tool:hotels search}}` \u2192 (optional) `{{tool:hotels get}}` \u2192 `{{tool:hotels offers}}` \u2192 `{{tool:hotels book}}`; after the user pays, `{{tool:orders get}}`; `{{tool:orders list}}` lists their orders. `{{tool:guide}}` returns Fortrip's guidance for flexible dates, budgets or several destinations.\n{{/if}}\n{{#if claude-skill}}\nThe tools are on Fortrip's MCP server, named `fortrip` in this plugin (a Fortrip connector added on claude.ai can show under its own name). They're written `fortrip:<tool>` below:\n{{/if}}\n{{#if cli}}\nThe `fortrip` CLI (npm package `fortrip`) calls Fortrip as the signed-in user and prints each answer as compact text (`--json` prints its JSON):\n{{/if}}\n{{#if claude-skill cli}}\n\n| {{#if claude-skill}}Tool{{/if}}{{#if cli}}Command{{/if}} | Returns |\n|---|---|\n| `{{tool:hotels search}}` | Hotels in a place, near a point or matching a name (`{{arg:text}}`), with indicative prices |\n| `{{tool:hotels get}}` | One hotel's address, check-in and check-out times, description, policies, amenities and rooms; no prices |\n| `{{tool:hotels offers}}` | Live offers (room, meals, total in USD, cancellation terms) for up to {{limit:offersHotels}} hotels |\n| `{{tool:hotels book}}` | An unpaid order for one offer, with its terms and a payment link |\n| `{{tool:orders get}}` | One order and its status, including whether the hotel confirmed it |\n| `{{tool:orders list}}` | The user's orders, newest first, including those made on the Fortrip website |\n\nFor flexible dates, a budget, split stays, several destinations or \"find me the best\", {{#if claude-skill}}read [references/planning.md](references/planning.md){{/if}}{{#if cli}}also run `{{tool:guide}} planning`{{/if}} before searching.\n{{/if}}\n",
1056
+ "setup": "{{#if cli}}\n## Setup\n\nRun every command as `npx -y fortrip@latest <command>` (Node.js 22 or newer). The examples below shorten that to `fortrip`; `fortrip <command> --help` lists a command's flags. `{{tool:hotels search}}`, `{{tool:hotels offers}}` and `{{tool:hotels book}}` also take `--body '<json>'` (or `--body @file.json`), the raw request body; flags override its fields. Add `--json` to any command that calls Fortrip to get its answer as one line of JSON instead of text.\n\nWhen the user first asks about hotels, run `{{tool:whoami}}` before asking follow-up questions, so the sign-in can happen while they answer.\n\n{{/if}}\n{{#if claude-skill cli}}\n## Sign-in\n\n{{/if}}\nSign-in happens only in the browser. Never ask the user to paste a token.\n{{#if claude-skill}}\n\nIf none of these tools are available, Fortrip isn't connected: tell the user to add the Fortrip connector or plugin and sign in.\n{{/if}}\n{{#if mcp claude-skill}}\nIf a tool says the user isn't signed in, ask them to reconnect Fortrip.\n{{/if}}\n{{#if cli}}\n\nIf a command exits with code **3** and stderr has `loginUrl`, show it to the user as a link together with `userCode`, e.g. \"Sign in to Fortrip: <loginUrl> (code <userCode>), then tell me when you're done.\" When they confirm, rerun the same command; it finishes the sign-in itself. Don't run `{{tool:login}}`: it waits for up to 10 minutes. Commands that exit 3 together share one `loginUrl`; show it once. If the rerun gives a different `loginUrl`, show that one.\n\nWhen Fortrip no longer accepts the saved sign-in (it was revoked, or it expired and couldn't be renewed), the command also exits 3 with a `loginUrl`; handle it the same way. The saved sign-in is kept until the user approves the new link.\n\nIn CI or a sandbox without a browser, the user can set `FORTRIP_TOKEN` instead. Exit 3 with `tokenSource: \"FORTRIP_TOKEN\"` means Fortrip didn't accept that token: tell the user to replace or unset it.\n\n## Exit codes\n\n| Code | Meaning | What to do |\n|---|---|---|\n| 0 | Success; the answer on stdout | Continue |\n| 1 | Any other error: an error from Fortrip (see Errors), a network error or a timeout | Read the error on stderr. If `retryable` is true, wait `retryAfterSeconds` (or a few seconds) and retry once |\n| 2 | Invalid arguments, or `invalid_request` from Fortrip | Fix the flags; the error lists each bad field with a message |\n| 3 | Not signed in | Show `loginUrl` to the user, then rerun (above) |\n| 4 | Rate limited | Wait `retryAfterSeconds`, then retry once |\n\nOutput on stdout or stderr can carry `warning` (with `recoveryFile` when a sign-in couldn't be saved): pass it on to the user.\n{{/if}}\n",
1057
+ "support": "{{#if claude-skill cli}}\n## Changes and support\n\n{{/if}}\n{{#if mcp claude-skill}}These tools{{/if}}{{#if cli}}The CLI{{/if}} can't change or cancel an order: for changes, cancellations, refunds or other problems, the user emails {{support}} with the order's `reference` (never `payment.url`).\n"
719
1058
  };
720
1059
 
721
1060
  // src/guide.ts
@@ -783,13 +1122,13 @@ function select(text2, surface) {
783
1122
  }
784
1123
  continue;
785
1124
  }
786
- const names = tag[1].trim().split(/\s+/);
787
- for (const name of names) {
1125
+ const names2 = tag[1].trim().split(/\s+/);
1126
+ for (const name of names2) {
788
1127
  if (!SURFACES.includes(name)) {
789
1128
  throw new Error(`Unknown guide surface "${name}" in ${tag[0]}`);
790
1129
  }
791
1130
  }
792
- open.push(names.includes(surface));
1131
+ open.push(names2.includes(surface));
793
1132
  }
794
1133
  if (open.length > 0) throw new Error("Guide has {{#if}} without {{/if}}");
795
1134
  return out + tagged.slice(from);
@@ -797,18 +1136,18 @@ function select(text2, surface) {
797
1136
  function resolve(token, surface, shown) {
798
1137
  const body = token.slice(2, -2);
799
1138
  if (body === "support") return SUPPORT_EMAIL;
800
- const [kind, name, ...rest] = body.split(":");
1139
+ const [kind2, name, ...rest] = body.split(":");
801
1140
  if (name !== void 0 && rest.length === 0) {
802
- if (kind === "limit" && Object.hasOwn(LIMITS, name)) {
1141
+ if (kind2 === "limit" && Object.hasOwn(LIMITS, name)) {
803
1142
  return String(LIMITS[name]);
804
1143
  }
805
- if (kind === "tool") {
1144
+ if (kind2 === "tool") {
806
1145
  const spec = COMMANDS.find(
807
1146
  (command) => commandInvocation(command) === `fortrip ${name}`
808
1147
  );
809
1148
  if (spec) return shown ? toolName(spec, surface, token) : token;
810
1149
  }
811
- if (kind === "arg") {
1150
+ if (kind2 === "arg") {
812
1151
  const flags = new Set(
813
1152
  COMMANDS.flatMap((command) => command.flags).filter((flag3) => flag3.field === name).map((flag3) => flag3.flag)
814
1153
  );
@@ -828,170 +1167,1514 @@ function toolName(spec, surface, token) {
828
1167
  return surface === "mcp" ? spec.tool : `fortrip:${spec.tool}`;
829
1168
  }
830
1169
 
831
- // src/outputs.ts
832
- import { z as z4 } from "zod";
833
- var bookingResult = z4.object({
834
- agentOrderId: z4.string(),
835
- paymentUrl: z4.string().describe(
836
- "Fortrip page that recaps the booking and continues to payment. Anyone holding it can pay"
1170
+ // src/outputs/account.ts
1171
+ import { z as z8 } from "zod";
1172
+ var meResult = openObject({
1173
+ userId: z8.string(),
1174
+ email: z8.string().nullable(),
1175
+ name: z8.string().nullable()
1176
+ });
1177
+ var authConfig = openObject({
1178
+ issuer: z8.string(),
1179
+ clientId: z8.string(),
1180
+ scopes: z8.string(),
1181
+ deviceAuthorizationEndpoint: z8.string(),
1182
+ tokenEndpoint: z8.string(),
1183
+ revocationEndpoint: z8.string()
1184
+ });
1185
+
1186
+ // src/outputs/common.ts
1187
+ import { z as z9 } from "zod";
1188
+ var names = openObject({
1189
+ en: z9.string().nullable(),
1190
+ zh: z9.string().nullable()
1191
+ });
1192
+ var latLng = openObject({ lat: z9.number(), lng: z9.number() });
1193
+ var LOCALES2 = ["en", "zh"];
1194
+ var knownLocaleText = openObject({
1195
+ text: z9.string(),
1196
+ locale: openEnum(LOCALES2)
1197
+ });
1198
+ var localizedText = openObject({
1199
+ text: z9.string(),
1200
+ locale: openEnum(LOCALES2).nullable()
1201
+ });
1202
+ var stayEcho = openObject({
1203
+ checkIn: localDate,
1204
+ checkOut: localDate,
1205
+ rooms: z9.array(
1206
+ openObject({
1207
+ adults: z9.number().int(),
1208
+ childrenAges: z9.array(z9.number().int())
1209
+ })
1210
+ ).min(1)
1211
+ });
1212
+
1213
+ // src/outputs/error-details.ts
1214
+ import { z as z15 } from "zod";
1215
+
1216
+ // src/errors.ts
1217
+ var ERROR_CODES = {
1218
+ invalid_request: { status: [400], retryable: false },
1219
+ cursor_invalid: { status: [400], retryable: false },
1220
+ unauthenticated: { status: [401], retryable: false },
1221
+ forbidden: { status: [403], retryable: false },
1222
+ not_found: { status: [404], retryable: false },
1223
+ order_removed: { status: [410], retryable: false },
1224
+ offer_expired: { status: [409], retryable: false },
1225
+ offer_changed: { status: [409], retryable: false },
1226
+ offer_already_ordered: { status: [409], retryable: false },
1227
+ order_exists: { status: [409], retryable: false },
1228
+ order_in_progress: { status: [409], retryable: true },
1229
+ order_attempts_exhausted: { status: [409], retryable: false },
1230
+ destination_ambiguous: { status: [422], retryable: false },
1231
+ destination_not_found: { status: [422], retryable: false },
1232
+ search_too_broad: { status: [422], retryable: false },
1233
+ stay_unsupported: { status: [422], retryable: false },
1234
+ stay_in_past: { status: [422], retryable: false },
1235
+ offer_unavailable: { status: [422], retryable: false },
1236
+ rate_limited: { status: [429], retryable: true },
1237
+ ordering_paused: { status: [503], retryable: true },
1238
+ service_unavailable: { status: [503], retryable: true },
1239
+ supplier_unavailable: { status: [503, 504], retryable: true },
1240
+ internal: { status: [500], retryable: false }
1241
+ };
1242
+
1243
+ // src/outputs/offer.ts
1244
+ import { z as z13 } from "zod";
1245
+
1246
+ // src/outputs/hotel.ts
1247
+ import { z as z12 } from "zod";
1248
+
1249
+ // src/outputs/search.ts
1250
+ import { z as z11 } from "zod";
1251
+
1252
+ // src/outputs/place.ts
1253
+ import { z as z10 } from "zod";
1254
+ var PLACE_KINDS = [
1255
+ "country",
1256
+ "region",
1257
+ "district",
1258
+ "settlement",
1259
+ "neighbourhood",
1260
+ "island"
1261
+ ];
1262
+ var placeRef = openObject({
1263
+ placeId,
1264
+ name: z10.string(),
1265
+ names
1266
+ });
1267
+ var place = openObject({
1268
+ placeId,
1269
+ name: z10.string(),
1270
+ names,
1271
+ kind: openEnum(PLACE_KINDS),
1272
+ scope: openEnum(["area", "radius"]),
1273
+ cityLike: z10.boolean().nullable(),
1274
+ population: z10.number().int().nullable(),
1275
+ region: placeRef.nullable(),
1276
+ countryCode: z10.string(),
1277
+ location: latLng
1278
+ });
1279
+ var CENTER_BASES = [
1280
+ "seat",
1281
+ "capital",
1282
+ "busiest_settlement",
1283
+ "hub",
1284
+ "point"
1285
+ ];
1286
+ var center = openObject({
1287
+ location: latLng,
1288
+ place: placeRef.nullable(),
1289
+ basis: openEnum(CENTER_BASES)
1290
+ });
1291
+
1292
+ // src/outputs/search.ts
1293
+ var SEARCH_NOTE_CODES = [
1294
+ "indicative_prices",
1295
+ "prices_unavailable",
1296
+ "pool_limited",
1297
+ "amenities_listed_only",
1298
+ "place_resolved_dominant",
1299
+ "area_absorbed",
1300
+ "broad_area",
1301
+ "island_radius",
1302
+ "area_retired",
1303
+ "more_outside_radius",
1304
+ "more_elsewhere",
1305
+ "few_hotels_in_scope",
1306
+ "party_split_required",
1307
+ "no_results",
1308
+ "locality_available"
1309
+ ];
1310
+ var indicativePrice = openObject({
1311
+ kind: openEnum(["indicative"]),
1312
+ basis: openEnum(["lowest_unit"]),
1313
+ fromPerRoomNight: money,
1314
+ fromPerRoomStay: money,
1315
+ approx: openObject({
1316
+ fromPerRoomNight: money,
1317
+ fromPerRoomStay: money,
1318
+ rateDate: localDate
1319
+ }).nullable()
1320
+ });
1321
+ var hotelSummary = openObject({
1322
+ hotelId,
1323
+ name: z11.string(),
1324
+ names,
1325
+ stars: z11.number().nullable(),
1326
+ address: openObject({
1327
+ line: knownLocaleText.nullable(),
1328
+ countryCode: z11.string().nullable(),
1329
+ region: placeRef.nullable()
1330
+ }),
1331
+ location: latLng.nullable(),
1332
+ timeZone: z11.string().nullable(),
1333
+ distanceMeters: z11.number().nullable(),
1334
+ price: indicativePrice.nullable()
1335
+ });
1336
+ var localities = openObject({
1337
+ items: z11.array(
1338
+ openObject({
1339
+ place: placeRef,
1340
+ count: z11.number().int(),
1341
+ total: z11.number().int(),
1342
+ includes: z11.array(
1343
+ openObject({ place: placeRef, members: z11.number().int() })
1344
+ )
1345
+ })
837
1346
  ),
838
- amount: z4.number().nullable().describe("What the user will be charged, in currency"),
839
- currency: z4.string(),
840
- orderUrl: z4.string().describe("The signed-in order page"),
841
- statusUrl: z4.string().optional().describe("The booking status page; the same page as paymentUrl"),
842
- stay: z4.object({
843
- hotelId: z4.number(),
844
- hotelName: z4.string().nullable(),
845
- roomName: z4.string().nullable(),
846
- checkIn: z4.string(),
847
- checkOut: z4.string(),
848
- nights: z4.number(),
849
- rooms: z4.number(),
850
- adultsPerRoom: z4.number(),
851
- childrenAgesPerRoom: z4.array(z4.number())
852
- }).describe("The stay as booked; every room has the same occupancy"),
853
- terms: z4.object({
854
- boardCode: z4.string(),
855
- boardCount: z4.number(),
856
- refundable: z4.boolean(),
857
- freeCancelUntil: z4.string().nullable(),
858
- cancellation: z4.array(z4.object({ amount: z4.number(), from: z4.string() })),
859
- isInstantConfirm: z4.boolean(),
860
- confirmByMins: z4.number().nullable(),
861
- payAtHotelFees: z4.array(
862
- z4.object({
863
- type: z4.string(),
864
- isMandatory: z4.boolean(),
865
- amount: z4.number(),
866
- currency: z4.string()
1347
+ unassigned: z11.number().int(),
1348
+ more: z11.number().int()
1349
+ });
1350
+ var radiusSource = openEnum(["default", "requested"]);
1351
+ var destination2 = openUnion("kind", [
1352
+ openObject({
1353
+ kind: z11.literal("place"),
1354
+ place,
1355
+ resolution: openEnum([
1356
+ "only_match",
1357
+ "dominant",
1358
+ "by_id",
1359
+ "by_country_code",
1360
+ "by_locality"
1361
+ ]),
1362
+ otherMatches: z11.number().int(),
1363
+ scope: openEnum(["area", "radius", "locality"]),
1364
+ center,
1365
+ radiusMeters: z11.number().nullable(),
1366
+ radiusSource: radiusSource.nullable()
1367
+ }),
1368
+ openObject({
1369
+ kind: z11.literal("point"),
1370
+ center,
1371
+ scope: openEnum(["radius"]),
1372
+ radiusMeters: z11.number(),
1373
+ radiusSource
1374
+ })
1375
+ ]);
1376
+ var count = z11.number().int();
1377
+ var searchResult = openObject({
1378
+ destination: destination2.nullable(),
1379
+ text: openObject({
1380
+ value: z11.string(),
1381
+ match: openEnum(["exact"])
1382
+ }).nullable(),
1383
+ stay: stayEcho.nullable(),
1384
+ pricing: openObject({
1385
+ mode: openEnum(["indicative", "none"]),
1386
+ scope: openEnum(["page", "pool"]).nullable(),
1387
+ status: openEnum(["ok", "unavailable"]).nullable()
1388
+ }),
1389
+ sort: openEnum([
1390
+ "distance",
1391
+ "relevance",
1392
+ "stars_desc",
1393
+ "price_asc",
1394
+ "price_desc"
1395
+ ]),
1396
+ coverage: openObject({
1397
+ matched: count,
1398
+ priced: count.nullable(),
1399
+ withPrice: count.nullable(),
1400
+ poolRadiusMeters: z11.number().nullable(),
1401
+ excludedNoPrice: count.nullable(),
1402
+ excludedNoFacilityData: count.nullable(),
1403
+ withinMaxRadius: count.nullable(),
1404
+ textAnywhere: count.nullable(),
1405
+ localities: localities.nullable(),
1406
+ nameAsText: count.nullable()
1407
+ }),
1408
+ data: z11.array(hotelSummary),
1409
+ nextCursor: z11.string().nullable(),
1410
+ notes: z11.array(
1411
+ openObject({ code: openEnum(SEARCH_NOTE_CODES), message: z11.string() })
1412
+ )
1413
+ });
1414
+
1415
+ // src/outputs/hotel.ts
1416
+ var POLICY_TOPICS = [
1417
+ "check_in",
1418
+ "important_notices",
1419
+ "booking",
1420
+ "booking_tips",
1421
+ "check_in_out",
1422
+ "check_in_tips",
1423
+ "reception",
1424
+ "deposit",
1425
+ "meal",
1426
+ "extra_bed",
1427
+ "pets",
1428
+ "surroundings",
1429
+ "meetings",
1430
+ "non_smoking",
1431
+ "other"
1432
+ ];
1433
+ var roomFacts = {
1434
+ beds: localizedText.nullable(),
1435
+ areaSqm: openObject({ min: z12.number(), max: z12.number() }).nullable(),
1436
+ maxOccupancy: z12.number().int().nullable(),
1437
+ window: openEnum(["yes", "no", "partial"]).nullable()
1438
+ };
1439
+ var timeRange = openObject({
1440
+ from: localTime.nullable(),
1441
+ until: localTime.nullable()
1442
+ });
1443
+ var hotel = openObject({
1444
+ hotelId,
1445
+ name: z12.string(),
1446
+ names,
1447
+ bookable: z12.boolean(),
1448
+ stars: z12.number().nullable(),
1449
+ address: hotelSummary.shape.address,
1450
+ location: latLng.nullable(),
1451
+ timeZone: z12.string().nullable(),
1452
+ description: knownLocaleText.nullable(),
1453
+ checkInTimes: timeRange,
1454
+ checkOutTimes: timeRange,
1455
+ policies: openObject({
1456
+ listed: z12.boolean(),
1457
+ items: z12.array(
1458
+ openObject({
1459
+ topic: openEnum(POLICY_TOPICS),
1460
+ text: z12.string(),
1461
+ locale: openEnum(["en", "zh"]).nullable()
867
1462
  })
868
1463
  )
869
- }).nullable()
1464
+ }),
1465
+ amenities: openObject({
1466
+ listed: z12.boolean(),
1467
+ items: z12.array(
1468
+ openObject({ code: openEnum(AMENITY_CODES), name: knownLocaleText })
1469
+ )
1470
+ }),
1471
+ breakfastServed: z12.boolean().nullable(),
1472
+ deposit: openObject({
1473
+ required: z12.boolean().nullable(),
1474
+ amount: money.nullable()
1475
+ }),
1476
+ contact: openObject({
1477
+ phone: z12.string().nullable(),
1478
+ website: z12.string().nullable()
1479
+ }),
1480
+ rooms: openObject({
1481
+ listed: z12.boolean(),
1482
+ items: z12.array(openObject({ name: localizedText, ...roomFacts }))
1483
+ }).optional()
1484
+ });
1485
+
1486
+ // src/outputs/offer.ts
1487
+ var meals = openObject({
1488
+ known: z13.boolean(),
1489
+ included: z13.array(openEnum(["breakfast", "lunch", "dinner"])).nullable(),
1490
+ unspecifiedMainMeals: z13.number().int().nullable(),
1491
+ allInclusive: z13.boolean().nullable(),
1492
+ guestsCoveredPerRoom: z13.number().int().nullable(),
1493
+ label: localizedText.nullable()
1494
+ });
1495
+ var payAtProperty = openObject({
1496
+ disclosure: openEnum(["complete", "incomplete"]),
1497
+ items: z13.array(
1498
+ openObject({
1499
+ label: localizedText.nullable(),
1500
+ amount: money.nullable(),
1501
+ mandatory: z13.boolean(),
1502
+ basis: openEnum([
1503
+ "per_stay",
1504
+ "per_night",
1505
+ "per_person",
1506
+ "per_person_per_night",
1507
+ "unknown"
1508
+ ]),
1509
+ collectedAt: openEnum(["property", "unknown"])
1510
+ })
1511
+ )
1512
+ });
1513
+ var cancellation = openObject({
1514
+ status: openEnum(["free", "partial_penalty", "non_refundable", "unknown"]),
1515
+ asOf: instant,
1516
+ freeCancellationEndsAt: instant.nullable(),
1517
+ penalties: z13.array(
1518
+ openObject({ startsAt: instant.nullable(), amount: money })
1519
+ ),
1520
+ timeZone: z13.string().nullable()
1521
+ });
1522
+ var confirmation = openObject({
1523
+ mode: openEnum(["instant", "on_request", "unknown"]),
1524
+ withinMinutes: z13.number().int().nullable()
1525
+ });
1526
+ var offer = openObject({
1527
+ offerId,
1528
+ type: openEnum(["hotel"]),
1529
+ expiresAt: instant,
1530
+ hotel: openObject({
1531
+ hotelId,
1532
+ room: openObject({ name: localizedText.nullable(), ...roomFacts }),
1533
+ roomCount: z13.number().int(),
1534
+ meals
1535
+ }),
1536
+ price: openObject({
1537
+ total: money,
1538
+ perNightAllRooms: money,
1539
+ approx: openObject({
1540
+ total: money,
1541
+ perNightAllRooms: money,
1542
+ rateDate: localDate
1543
+ }).nullable(),
1544
+ payAtProperty
1545
+ }),
1546
+ cancellation,
1547
+ confirmation
1548
+ });
1549
+ var omitted = openObject({
1550
+ byNationality: z13.number().int(),
1551
+ byFilters: z13.number().int(),
1552
+ dominated: z13.number().int(),
1553
+ unreadable: z13.number().int(),
1554
+ byLimit: z13.number().int()
1555
+ });
1556
+ var UNAVAILABLE_REASONS = [
1557
+ "sold_out",
1558
+ "no_matching_offers",
1559
+ "nationality_restricted",
1560
+ "offers_unreadable",
1561
+ "not_found",
1562
+ "supplier_unavailable",
1563
+ "stay_in_past"
1564
+ ];
1565
+ var hotelOffersResult = openObject({
1566
+ stay: stayEcho,
1567
+ hotels: z13.array(
1568
+ openObject({
1569
+ hotel: openObject({ hotelId, name: z13.string(), names }),
1570
+ offers: z13.array(offer),
1571
+ omitted
1572
+ })
1573
+ ),
1574
+ unavailable: z13.array(
1575
+ openObject({
1576
+ hotelId,
1577
+ reason: openEnum(UNAVAILABLE_REASONS),
1578
+ omitted: omitted.nullable()
1579
+ })
1580
+ )
870
1581
  });
871
- var meResult = z4.object({
872
- userId: z4.string(),
873
- email: z4.string().nullable(),
874
- name: z4.string().nullable()
1582
+
1583
+ // src/outputs/order.ts
1584
+ import { z as z14 } from "zod";
1585
+ var ORDER_STATUSES = [
1586
+ "awaiting_payment",
1587
+ "expired",
1588
+ "confirming",
1589
+ "confirmed",
1590
+ "failed",
1591
+ "cancelling",
1592
+ "cancelled",
1593
+ "needs_attention"
1594
+ ];
1595
+ var PAYMENT_STATUSES = [
1596
+ "unpaid",
1597
+ "authorized",
1598
+ "paid",
1599
+ "released",
1600
+ "refund_pending",
1601
+ "refunded"
1602
+ ];
1603
+ var orderReference = z14.string().regex(/^FT-[2-9A-HJ-NP-Z]{6}$/);
1604
+ var hotelItem = openObject({
1605
+ type: z14.literal("hotel"),
1606
+ hotel: openObject({
1607
+ hotelId: hotelId.nullable(),
1608
+ name: z14.string(),
1609
+ names,
1610
+ checkIn: localDate,
1611
+ checkOut: localDate,
1612
+ timeZone: z14.string().nullable(),
1613
+ rooms: stayEcho.shape.rooms,
1614
+ room: openObject({
1615
+ name: localizedText.nullable(),
1616
+ beds: localizedText.nullable()
1617
+ }),
1618
+ meals,
1619
+ cancellation,
1620
+ payAtProperty,
1621
+ confirmationNumber: z14.string().nullable()
1622
+ })
1623
+ });
1624
+ var order = openObject({
1625
+ orderId,
1626
+ reference: orderReference,
1627
+ status: openEnum(ORDER_STATUSES),
1628
+ createdAt: instant,
1629
+ updatedAt: instant,
1630
+ items: z14.array(openUnion("type", [hotelItem])).min(1),
1631
+ price: openObject({ total: money }),
1632
+ payment: openObject({
1633
+ status: openEnum(PAYMENT_STATUSES),
1634
+ url: z14.string().nullable(),
1635
+ requiresSignIn: z14.boolean(),
1636
+ expiresAt: instant.nullable(),
1637
+ chargeCurrency: z14.string(),
1638
+ localCurrencyAtCheckout: z14.boolean(),
1639
+ localCharge: money.nullable()
1640
+ }),
1641
+ manageUrl: z14.string()
875
1642
  });
876
- var authConfig = z4.object({
877
- issuer: z4.string(),
878
- clientId: z4.string(),
879
- scopes: z4.string(),
880
- deviceAuthorizationEndpoint: z4.string(),
881
- tokenEndpoint: z4.string(),
882
- revocationEndpoint: z4.string()
1643
+ var orderList = openObject({
1644
+ data: z14.array(order),
1645
+ nextCursor: z14.string().nullable()
883
1646
  });
1647
+ function change(field, value) {
1648
+ return openObject({
1649
+ field: z14.literal(field),
1650
+ previous: value,
1651
+ current: value,
1652
+ worse: z14.boolean()
1653
+ });
1654
+ }
1655
+ var offerChange = openUnion("field", [
1656
+ change("price", money),
1657
+ change("meals", meals),
1658
+ change("cancellation", cancellation),
1659
+ change("confirmation", confirmation),
1660
+ change("pay_at_property", payAtProperty)
1661
+ ]);
1662
+
1663
+ // src/outputs/error-details.ts
1664
+ var count2 = z15.number().int();
1665
+ var ERROR_DETAILS = {
1666
+ invalid_request: openObject({
1667
+ issues: z15.array(openObject({ pointer: z15.string(), message: z15.string() })),
1668
+ filterable: z15.array(openEnum(AMENITY_CODES)).optional(),
1669
+ share: openObject({
1670
+ code: openEnum(AMENITY_CODES),
1671
+ listedShare: z15.number().min(0).max(1)
1672
+ }).optional()
1673
+ }),
1674
+ cursor_invalid: openObject({
1675
+ reason: openEnum(["expired", "mismatch", "malformed"])
1676
+ }),
1677
+ unauthenticated: null,
1678
+ forbidden: null,
1679
+ not_found: null,
1680
+ order_removed: openObject({ orderId, reference: orderReference }),
1681
+ offer_expired: openObject({ offerId, expiredAt: instant }),
1682
+ offer_changed: openObject({ changes: z15.array(offerChange).min(1), offer }),
1683
+ offer_already_ordered: openObject({ orderId }),
1684
+ order_exists: openObject({ orderId, status: openEnum(ORDER_STATUSES) }),
1685
+ order_in_progress: openObject({ orderId }),
1686
+ order_attempts_exhausted: openObject({ orderId }),
1687
+ destination_ambiguous: openObject({
1688
+ name: z15.string(),
1689
+ countryCode: z15.string(),
1690
+ candidates: z15.array(
1691
+ openObject({
1692
+ ...place.shape,
1693
+ hotels: openObject({
1694
+ scope: openEnum(["area", "radius"]),
1695
+ radiusMeters: z15.number().nullable(),
1696
+ count: count2
1697
+ })
1698
+ })
1699
+ ),
1700
+ candidatesTotal: count2,
1701
+ nameAsText: count2.nullable()
1702
+ }),
1703
+ destination_not_found: openUnion("reason", [
1704
+ openObject({
1705
+ reason: z15.literal("no_match"),
1706
+ name: z15.string(),
1707
+ countryCode: z15.string(),
1708
+ nameAsText: count2.nullable()
1709
+ }),
1710
+ openObject({ reason: z15.literal("unknown_id"), placeId }),
1711
+ openObject({
1712
+ reason: z15.literal("not_a_locality"),
1713
+ placeId,
1714
+ hub: placeRef.nullable(),
1715
+ hubTotal: count2.nullable()
1716
+ })
1717
+ ]),
1718
+ search_too_broad: openObject({
1719
+ matched: count2,
1720
+ poolLimit: count2,
1721
+ localities,
1722
+ /** The searched area's center, as a search echoes it; null for text only.
1723
+ * Optional for clients; the server must send it (`SERVER_DETAILS`). */
1724
+ center: center.nullable().optional()
1725
+ }),
1726
+ stay_unsupported: openUnion("reason", [
1727
+ openObject({ reason: z15.literal("mixed_occupancy") }),
1728
+ openObject({ reason: z15.literal("max_nights"), max: count2 })
1729
+ ]),
1730
+ stay_in_past: openObject({
1731
+ checkIn: localDate,
1732
+ timeZone: z15.string().nullable()
1733
+ }),
1734
+ offer_unavailable: openObject({
1735
+ offerId,
1736
+ reason: openEnum(["sold_out", "refused"])
1737
+ }),
1738
+ rate_limited: openObject({
1739
+ scope: openEnum(["search", "offers", "hotel", "orders_read", "orders_create"])
1740
+ }),
1741
+ ordering_paused: null,
1742
+ service_unavailable: null,
1743
+ supplier_unavailable: openObject({
1744
+ outcome: openEnum(["not_started", "unknown"])
1745
+ }),
1746
+ internal: null
1747
+ };
1748
+ var SERVER_DETAILS = {
1749
+ search_too_broad: ERROR_DETAILS.search_too_broad.extend({ center: center.nullable() })
1750
+ };
1751
+ function envelopeSchema(detailsRequired) {
1752
+ return openObject({
1753
+ error: openUnion(
1754
+ "code",
1755
+ Object.keys(ERROR_CODES).map((code) => {
1756
+ const details = detailsRequired ? SERVER_DETAILS[code] ?? ERROR_DETAILS[code] : ERROR_DETAILS[code];
1757
+ return openObject({
1758
+ code: z15.literal(code),
1759
+ message: z15.string(),
1760
+ param: z15.string().optional(),
1761
+ retryable: z15.boolean(),
1762
+ retryAfterSeconds: z15.number().int().optional(),
1763
+ ...details ? { details: detailsRequired ? details : details.optional() } : {},
1764
+ requestId: z15.string()
1765
+ });
1766
+ })
1767
+ )
1768
+ });
1769
+ }
1770
+ var errorEnvelope = envelopeSchema(false);
1771
+ function strictErrorEnvelope() {
1772
+ return strictOutput(envelopeSchema(true));
1773
+ }
884
1774
 
885
1775
  // src/paths.ts
1776
+ var PUBLIC_ORIGINS = {
1777
+ api: "https://api.fortrip.ai",
1778
+ mcp: "https://mcp.fortrip.ai"
1779
+ };
886
1780
  var API = {
887
- me: "/api/agent/v1/me",
888
- authConfig: "/api/agent/v1/auth/config",
889
- search: "/api/agent/v1/hotels/search",
890
- rates: "/api/agent/v1/hotels/rates",
891
- bookings: "/api/agent/v1/hotels/bookings",
892
- mcp: "/api/mcp",
893
- hotel(id, includeRooms) {
894
- return `/api/agent/v1/hotels/${id}${includeRooms ? "?include=rooms" : ""}`;
1781
+ me: "/v1/me",
1782
+ authConfig: "/v1/auth/config",
1783
+ hotelSearch: "/v1/hotels/search",
1784
+ hotelOffers: "/v1/hotels/offers",
1785
+ orders: "/v1/orders",
1786
+ hotel(id, opts) {
1787
+ const query = [];
1788
+ if (opts?.rooms) query.push("include=rooms");
1789
+ if (opts?.locale) query.push(`locale=${opts.locale}`);
1790
+ return `/v1/hotels/${encodeURIComponent(id)}${query.length ? `?${query.join("&")}` : ""}`;
1791
+ },
1792
+ order(id, opts) {
1793
+ return `/v1/orders/${encodeURIComponent(id)}${opts?.locale ? `?locale=${opts.locale}` : ""}`;
895
1794
  }
896
1795
  };
897
-
898
- // src/reasons.ts
899
- var reasonStatuses = {
900
- already_booked: [409],
901
- price_or_terms_changed: [409],
902
- rate_unavailable: [422],
903
- refused: [422],
904
- supplier_unavailable: [503, 504]
905
- };
906
- var ERROR_REASONS = reasonStatuses;
1796
+ var MCP_PATH = "/";
907
1797
 
908
1798
  // src/tools.ts
909
- import { z as z5 } from "zod";
910
- var MCP_MAX_ROOMS_PER_HOTEL = 5;
911
- var MCP_RATES_INPUT = getRatesInput.safeExtend({
912
- maxRoomsPerHotel: wholeNumber("maxRoomsPerHotel", 1, LIMITS.maxRoomsPerHotel).default(MCP_MAX_ROOMS_PER_HOTEL).describe(
913
- `Return the N cheapest rooms per hotel. Defaults to ${MCP_MAX_ROOMS_PER_HOTEL}.`
914
- )
915
- });
916
- var BOOKING_NEXT_STEP = "Show the user amount, currency, the stay (hotel, room, dates, rooms, guests per room) and every item in terms (meals, cancellation, confirmation, pay-at-hotel fees), then end your reply by asking them to confirm. Only after they say yes, give them paymentUrl exactly as returned.";
917
- var MCP_BOOKING_RESULT = bookingResult.extend({
918
- nextStep: z5.string()
919
- });
920
- var MEAL_CODES = "RO room only; BB breakfast; HB breakfast + dinner; FB breakfast + lunch + dinner; BL lunch; BD dinner; HBBL breakfast + lunch; HBLD lunch + dinner; AI all inclusive";
1799
+ import { z as z16 } from "zod";
1800
+ var ORDER_NEXT_STEP = "Before you give the user an order's payment.url, show them its total and currency, the stay (hotel, room, dates, rooms and guests per room) and every term (meals, cancellation, fees payable at the property), then end your reply by asking them to confirm, and wait for a clear yes. Only after they say yes, give them payment.url exactly as returned.";
1801
+ var nextStep = z16.string().optional().describe(
1802
+ "What to show and ask before giving a payment.url. Present when the answer holds a non-null payment.url, and only then."
1803
+ );
1804
+ var MCP_OUTPUT = {
1805
+ book_hotel: order.extend({ nextStep }),
1806
+ get_order: order.extend({ nextStep }),
1807
+ list_orders: orderList.extend({ nextStep })
1808
+ };
1809
+ var READ = {
1810
+ readOnlyHint: true,
1811
+ idempotentHint: true,
1812
+ openWorldHint: true,
1813
+ destructiveHint: false
1814
+ };
1815
+ var READ_OWN = { ...READ, openWorldHint: false };
921
1816
  var TOOLS = [
922
1817
  {
923
1818
  name: "get_planning_guide",
924
1819
  title: "Get hotel planning guide",
925
1820
  description: `Returns Fortrip's planning guide for a request that is more than one hotel for fixed dates (flexible dates, a budget, split stays, several destinations, "find me the best"): what to ask the user, which results count as evidence, how to rank and how to present options.`,
926
- annotations: {
927
- readOnlyHint: true,
928
- idempotentHint: true,
929
- openWorldHint: false,
930
- destructiveHint: false
931
- },
1821
+ annotations: READ_OWN,
932
1822
  input: null,
933
1823
  output: null
934
1824
  },
935
1825
  {
936
1826
  name: "search_hotels",
937
1827
  title: "Search hotels",
938
- description: "Find hotels by name/brand/city text (query), catalogue cityCode or coordinates (near), with per-night prices for the dates in currency (CNY by default, or USD). Those prices come from the supplier's minimum-price feed, not live rates; get_hotel_rates returns the bookable price. facilities must match catalogue names exactly. Refused (422) when more than 30,000 hotels match: narrow the search.",
939
- annotations: {
940
- readOnlyHint: true,
941
- idempotentHint: true,
942
- openWorldHint: true,
943
- destructiveHint: false
944
- },
1828
+ description: 'Find hotels by destination and/or text, with an indicative per-room-night price when a stay is given. destination is a place_name with its countryCode, a place or locality by placeId from an earlier answer, a point, or a country. A name several places bear answers destination_ambiguous with candidates: ask the user, then search the chosen placeId. text matches hotel names, brands and areas word for word. Indicative prices are estimates and not bookable: get_hotel_offers returns what the user pays. A search too broad to answer is refused with search_too_broad and its localities: pass one back as { kind: "locality", placeId }, never by name. The answer states the scope, the counts and notes; a next page is the cursor and, optionally, limit.',
1829
+ annotations: READ,
945
1830
  input: searchHotelsInput,
946
- output: null
1831
+ output: searchResult
947
1832
  },
948
1833
  {
949
1834
  name: "get_hotel",
950
1835
  title: "Get hotel details",
951
- description: "Static catalogue facts for one hotel: address, contact, intro, check-in/out times, policies, facilities and photos. No prices or availability.",
952
- annotations: {
953
- readOnlyHint: true,
954
- idempotentHint: true,
955
- openWorldHint: true,
956
- destructiveHint: false
957
- },
1836
+ description: `One hotel's catalogue facts: names, address, location, time zone, check-in and check-out times, the full description, every policy and the amenities, and its rooms with include: "rooms". "Not listed" is not "no". No prices or availability: get_hotel_offers has those.`,
1837
+ annotations: READ,
958
1838
  input: getHotelInput,
959
- output: null
1839
+ output: hotel
960
1840
  },
961
1841
  {
962
- name: "get_hotel_rates",
963
- title: "Get live hotel rates",
964
- description: `Live rooms and rate plans for up to ${LIMITS.ratesHotels} hotels, prices in currency (CNY by default, or USD) at Stripe's exchange rate. rooms, adults and childrenAges are per room: every room has the same occupancy. Each room carries its catalogue facts (static) and the cheapest rate per meal plan \xD7 free-cancellation variant, with roomId and ratePlanCode for book_hotel. boardCode is the meal plan (${MEAL_CODES}); boardCount is how many guests per room it covers: fewer than adults per room means the meals don't cover everyone (BB with boardCount 1 for 2 adults is breakfast for 1 of 2). Takes about 15 seconds; query a few hotels at a time. Returns the ${MCP_MAX_ROOMS_PER_HOTEL} cheapest rooms per hotel unless maxRoomsPerHotel says otherwise.`,
965
- annotations: {
966
- readOnlyHint: true,
967
- idempotentHint: true,
968
- openWorldHint: true,
969
- destructiveHint: false
970
- },
971
- input: MCP_RATES_INPUT,
972
- output: null
1842
+ name: "get_hotel_offers",
1843
+ title: "Get live hotel offers",
1844
+ description: `Live, bookable offers for up to ${LIMITS.offersHotels} hotels and one stay. Every room in stay.rooms must have the same party: for different parties, call once per party. Each offer has the room, the meals included, price.total in USD for the whole stay and every room (what is charged), fees payable at the property, the cancellation terms with times in UTC and the hotel's time zone, the confirmation mode, and an offerId for book_hotel that expires at expiresAt. Hotels with nothing to offer are listed under unavailable with the reason. Up to limitPerHotel offers per hotel, default ${LIMITS.offersPerHotelDefault}, cheapest first.`,
1845
+ annotations: READ,
1846
+ input: hotelOffersInput,
1847
+ output: hotelOffersResult
973
1848
  },
974
1849
  {
975
1850
  name: "book_hotel",
976
- title: "Create hotel booking payment link",
977
- description: `Create (or resume) a booking for one rate from get_hotel_rates, with the same rooms, adults and childrenAges (per room). Returns the amount and currency the user will be charged, the accepted terms (meal plan, cancellation, confirmation, pay-at-hotel fees), the stay as booked, and paymentUrl: a Fortrip page that recaps the booking and continues to payment. Nothing is charged until the user pays there; anyone holding paymentUrl can pay. The same user and stay always returns the same order; a stay that is already paid returns 409. This tool can't change or cancel a booking: for changes, cancellations or refunds the user emails ${SUPPORT_EMAIL} with the booking's orderUrl. Can take up to about two minutes; if the call times out, call it again with the same details \u2014 it resumes the same order.`,
1851
+ title: "Create hotel order and payment link",
1852
+ description: `Order one offer from get_hotel_offers. The answer is an unpaid order with a payment link: status awaiting_payment, the total, the stay and its terms, and payment.url, a Fortrip page where the user pays. Nothing is charged until the user pays there; anyone holding the link can pay. Send the contact and, for each room of the offer, at least one guest. If the price or terms changed since the offer, the answer is offer_changed with every change and a new offer: show the changes and ask the user again before ordering it. Sending the same details again returns the same order. This tool can't change or cancel an order: for changes, cancellations or refunds the user emails ${SUPPORT_EMAIL} with the order's reference.`,
978
1853
  annotations: {
979
1854
  readOnlyHint: false,
980
- idempotentHint: true,
1855
+ idempotentHint: false,
981
1856
  openWorldHint: true,
982
1857
  destructiveHint: false
983
1858
  },
984
- input: createBookingInput,
985
- output: MCP_BOOKING_RESULT
1859
+ input: createOrderInput,
1860
+ output: MCP_OUTPUT.book_hotel
1861
+ },
1862
+ {
1863
+ name: "get_order",
1864
+ title: "Get order",
1865
+ description: "One of the user's orders by orderId: its status, the payment status and, while it awaits payment, payment.url; the stay and its terms; and the hotel's confirmation number once there is one. Check it after the user pays. An order awaiting payment whose payment.url is null had its link unreadable this time: read it again.",
1866
+ annotations: READ_OWN,
1867
+ input: getOrderInput,
1868
+ output: MCP_OUTPUT.get_order
1869
+ },
1870
+ {
1871
+ name: "list_orders",
1872
+ title: "List orders",
1873
+ description: "The user's hotel orders, newest first, including those made on the Fortrip website. Pass nextCursor back as cursor for the next page.",
1874
+ annotations: READ_OWN,
1875
+ input: listOrdersInput,
1876
+ output: MCP_OUTPUT.list_orders
986
1877
  }
987
1878
  ];
988
1879
 
1880
+ // src/render/format.ts
1881
+ function num(n) {
1882
+ return n.toLocaleString("en-US");
1883
+ }
1884
+ function km(meters) {
1885
+ return `${(meters / 1e3).toFixed(1)} km`;
1886
+ }
1887
+ function money2(m) {
1888
+ return `${m.currency} ${m.amount}`;
1889
+ }
1890
+ function plural(n, one, many = `${one}s`) {
1891
+ return `${num(n)} ${n === 1 ? one : many}`;
1892
+ }
1893
+ function named(name, names2) {
1894
+ const other = [names2.en, names2.zh].find((n) => n && n !== name);
1895
+ return other ? `${name} (${other})` : name;
1896
+ }
1897
+ function label(words, value) {
1898
+ return Object.hasOwn(words, value) ? words[value] : value;
1899
+ }
1900
+ function stayLine(checkIn, checkOut) {
1901
+ const nights = countIsoDateDays(checkIn, checkOut);
1902
+ return `${checkIn} \u2192 ${checkOut} (${plural(nights, "night")})`;
1903
+ }
1904
+ function partyOf(room) {
1905
+ const adults = plural(room.adults, "adult");
1906
+ const n = room.childrenAges.length;
1907
+ if (n === 0) return adults;
1908
+ const ages = room.childrenAges.join(", ");
1909
+ return `${adults} and ${plural(n, "child", "children")} (${n === 1 ? "age" : "ages"} ${ages})`;
1910
+ }
1911
+ function party(rooms) {
1912
+ const parties = rooms.map(partyOf);
1913
+ if (parties.length === 1) return `1 room: ${parties[0]}`;
1914
+ if (parties.every((p) => p === parties[0])) {
1915
+ return `${parties.length} rooms, each ${parties[0]}`;
1916
+ }
1917
+ return `${parties.length} rooms: ${parties.map((p, i) => `room ${i + 1} ${p}`).join("; ")}`;
1918
+ }
1919
+ function when(instant2, timeZone2) {
1920
+ const utc = `${instant2.slice(0, 10)} ${instant2.slice(11, 16)} UTC`;
1921
+ if (!timeZone2) return utc;
1922
+ try {
1923
+ const parts = new Intl.DateTimeFormat("en-CA", {
1924
+ timeZone: timeZone2,
1925
+ year: "numeric",
1926
+ month: "2-digit",
1927
+ day: "2-digit",
1928
+ hour: "2-digit",
1929
+ minute: "2-digit",
1930
+ hourCycle: "h23"
1931
+ }).formatToParts(new Date(instant2));
1932
+ const part = (type) => parts.find((p) => p.type === type)?.value;
1933
+ return `${utc} (${part("year")}-${part("month")}-${part("day")} ${part("hour")}:${part("minute")} ${timeZone2})`;
1934
+ } catch {
1935
+ return utc;
1936
+ }
1937
+ }
1938
+ function raw(value) {
1939
+ try {
1940
+ return JSON.stringify(value) ?? "no value";
1941
+ } catch {
1942
+ return String(value);
1943
+ }
1944
+ }
1945
+ var WINDOWS = {
1946
+ yes: "window",
1947
+ no: "no window",
1948
+ partial: "window in some rooms"
1949
+ };
1950
+ function roomFacts2(r) {
1951
+ const area = r.areaSqm ? r.areaSqm.min === r.areaSqm.max ? `${r.areaSqm.min} m\xB2` : `${r.areaSqm.min}\u2013${r.areaSqm.max} m\xB2` : "size not stated";
1952
+ return [
1953
+ r.beds ? `beds: ${r.beds.text}` : "beds not listed",
1954
+ area,
1955
+ r.maxOccupancy === null ? "max occupancy not listed" : `max ${plural(r.maxOccupancy, "guest")}`,
1956
+ r.window === null ? "window not listed" : label(WINDOWS, r.window)
1957
+ ];
1958
+ }
1959
+
1960
+ // src/render/hotel.ts
1961
+ function times(what, t) {
1962
+ if (t.from === null && t.until === null) return `${what}: not listed`;
1963
+ return `${what} from ${t.from ?? "not listed"}, until ${t.until ?? "not listed"}`;
1964
+ }
1965
+ function deposit(d) {
1966
+ if (d.required === null) return "Deposit: not stated";
1967
+ if (!d.required) return "Deposit: not required";
1968
+ return `Deposit: required, ${d.amount ? money2(d.amount) : "amount not stated"}`;
1969
+ }
1970
+ function roomLine(r) {
1971
+ return [`- ${r.name.text}`, ...roomFacts2(r)].join(" \xB7 ");
1972
+ }
1973
+ function renderHotel(h) {
1974
+ const region = h.address.region;
1975
+ const lines = [
1976
+ [
1977
+ named(h.name, h.names),
1978
+ h.stars === null ? "stars not listed" : `${h.stars}\u2605`,
1979
+ h.hotelId,
1980
+ h.bookable ? "" : "no longer bookable (it has left the catalogue)"
1981
+ ].filter(Boolean).join(" \xB7 "),
1982
+ [
1983
+ `Address: ${h.address.line ? h.address.line.text : "not listed"}`,
1984
+ region ? `${region.name} ${region.placeId}` : "",
1985
+ h.address.countryCode ?? "",
1986
+ h.location ? `${h.location.lat}, ${h.location.lng}` : "location not listed",
1987
+ `time zone ${h.timeZone ?? "not listed"}`
1988
+ ].filter(Boolean).join(" \xB7 "),
1989
+ `${times("Check-in", h.checkInTimes)} \xB7 ${times("Check-out", h.checkOutTimes)} (local time)`,
1990
+ `Breakfast served: ${h.breakfastServed === null ? "not stated" : h.breakfastServed ? "yes" : "no"}`,
1991
+ deposit(h.deposit),
1992
+ `Phone: ${h.contact.phone ?? "not listed"} \xB7 Website: ${h.contact.website ?? "not listed"}`
1993
+ ];
1994
+ if (!h.amenities.listed) {
1995
+ lines.push('Amenities: not listed (unknown, not "none")');
1996
+ } else if (h.amenities.items.length === 0) {
1997
+ lines.push("Amenities: none listed");
1998
+ } else {
1999
+ const items = h.amenities.items.map((a) => `${a.name.text} (${a.code})`);
2000
+ lines.push(`Amenities listed (others may exist): ${items.join(", ")}`);
2001
+ }
2002
+ if (!h.policies.listed) {
2003
+ lines.push("Policies: not listed");
2004
+ } else if (h.policies.items.length === 0) {
2005
+ lines.push("Policies: none listed");
2006
+ } else {
2007
+ lines.push("Policies:", ...h.policies.items.map((p) => `- ${p.topic}: ${p.text}`));
2008
+ }
2009
+ if (h.rooms) {
2010
+ if (!h.rooms.listed) lines.push("Rooms: not listed");
2011
+ else if (h.rooms.items.length === 0) lines.push("Rooms: none listed");
2012
+ else lines.push("Rooms:", ...h.rooms.items.map(roomLine));
2013
+ }
2014
+ if (h.description === null) {
2015
+ lines.push("Description: not listed");
2016
+ } else {
2017
+ lines.push(`Description (${h.description.locale}):`, h.description.text);
2018
+ }
2019
+ return lines.join("\n");
2020
+ }
2021
+
2022
+ // src/render/offers.ts
2023
+ var MEALS_UNKNOWN = "meals not stated by the supplier \u2014 not known to be room only, may include breakfast; ask or check at booking";
2024
+ function mealsText(m) {
2025
+ if (!m.known) return MEALS_UNKNOWN;
2026
+ if (m.included === null) return "meals not stated";
2027
+ let base;
2028
+ if (m.allInclusive) {
2029
+ base = "all inclusive";
2030
+ } else {
2031
+ const extra = m.unspecifiedMainMeals;
2032
+ const parts = [...m.included];
2033
+ if (extra !== null && extra > 0) parts.push(`${plural(extra, "more main meal")} (not named)`);
2034
+ if (parts.length === 0 && extra === 0) return "room only";
2035
+ base = parts.length > 0 ? parts.join(" + ") : "no meals named";
2036
+ if (extra === null) base += "; other main meals not stated";
2037
+ }
2038
+ return m.guestsCoveredPerRoom === null ? `${base}, guests covered not stated` : `${base} for ${plural(m.guestsCoveredPerRoom, "guest")} per room`;
2039
+ }
2040
+ function mealsWithLabel(m) {
2041
+ const text2 = mealsText(m);
2042
+ return m.label ? `${text2} (labelled "${m.label.text}")` : text2;
2043
+ }
2044
+ function cancellationText(c) {
2045
+ if (c.status === "unknown") return "cancellation terms unknown";
2046
+ const steps = c.penalties.map(
2047
+ (p) => `${money2(p.amount)} from ${p.startsAt === null ? "booking" : when(p.startsAt, c.timeZone)}`
2048
+ );
2049
+ if (c.status === "free") {
2050
+ const head2 = c.freeCancellationEndsAt === null ? "free cancellation until check-in" : `free cancellation until ${when(c.freeCancellationEndsAt, c.timeZone)}`;
2051
+ return steps.length ? `${head2}, then ${steps.join("; ")}` : head2;
2052
+ }
2053
+ const head = label(
2054
+ { partial_penalty: "partial penalty now", non_refundable: "non-refundable" },
2055
+ c.status
2056
+ );
2057
+ return steps.length ? `${head}: ${steps.join("; ")}` : head;
2058
+ }
2059
+ var BASES = {
2060
+ per_stay: "per stay",
2061
+ per_night: "per night",
2062
+ per_person: "per person",
2063
+ per_person_per_night: "per person per night",
2064
+ unknown: "basis unknown"
2065
+ };
2066
+ function feesText(p) {
2067
+ const items = p.items.map(
2068
+ (i) => [
2069
+ `${i.label ? i.label.text : "unnamed fee"} ${i.amount ? money2(i.amount) : "amount not stated"}`,
2070
+ label(BASES, i.basis),
2071
+ i.mandatory ? "mandatory" : "optional",
2072
+ i.collectedAt === "property" ? "at the property" : i.collectedAt === "unknown" ? "collection not stated (could be prepaid or at the property)" : `collected: ${i.collectedAt}`
2073
+ ].filter(Boolean).join(", ")
2074
+ );
2075
+ const listed = `fees not in the total: ${items.join("; ")}`;
2076
+ if (p.disclosure === "complete") {
2077
+ return items.length ? listed : "nothing to pay at the property";
2078
+ }
2079
+ const caveat = p.disclosure === "incomplete" ? "fees not fully disclosed (amounts or how they are collected may be missing)" : `fees not in the total: disclosure ${p.disclosure}`;
2080
+ return items.length ? `${listed}; ${caveat}` : caveat;
2081
+ }
2082
+ function confirmationText(c) {
2083
+ if (c.mode === "instant") return "instant confirmation";
2084
+ if (c.mode === "on_request") {
2085
+ return c.withinMinutes === null ? "confirmation on request" : `confirmation on request, within ${c.withinMinutes} min`;
2086
+ }
2087
+ if (c.mode === "unknown") return "confirmation mode unknown";
2088
+ return `confirmation ${c.mode}`;
2089
+ }
2090
+ function roomText(r) {
2091
+ return [r.name ? r.name.text : "room name not listed", ...roomFacts2(r)].join(", ");
2092
+ }
2093
+ function offerLine(o) {
2094
+ const p = o.price;
2095
+ let price = `${money2(p.total)} total (${money2(p.perNightAllRooms)} per night, all rooms)`;
2096
+ if (p.approx) {
2097
+ price += ` \u2248 ${money2(p.approx.total)} (${money2(p.approx.perNightAllRooms)} per night), exchange rate of ${p.approx.rateDate}`;
2098
+ }
2099
+ return [
2100
+ `${o.offerId}${o.type === "hotel" ? "" : ` (${o.type})`}`,
2101
+ `${plural(o.hotel.roomCount, "room")}: ${roomText(o.hotel.room)}`,
2102
+ price,
2103
+ mealsWithLabel(o.hotel.meals),
2104
+ cancellationText(o.cancellation),
2105
+ confirmationText(o.confirmation),
2106
+ feesText(p.payAtProperty),
2107
+ `offer expires ${when(o.expiresAt, null)}`
2108
+ ].join(" \xB7 ");
2109
+ }
2110
+ var OMITTED = {
2111
+ byNationality: "restricted by nationality",
2112
+ byFilters: "removed by the filters",
2113
+ dominated: "beaten by another offer for the same room",
2114
+ unreadable: "unreadable"
2115
+ };
2116
+ function limitText(o) {
2117
+ const n = o.byLimit;
2118
+ return typeof n === "number" && n > 0 ? `${plural(n, "more offer")} not shown (limitPerHotel); a filtered request (requireBreakfast / requireFreeCancellation) or a higher limitPerHotel may show them \u2014 don't conclude they don't exist` : "";
2119
+ }
2120
+ function omittedText(o) {
2121
+ return Object.entries(OMITTED).filter(([key]) => typeof o[key] === "number" && o[key] > 0).map(([key, words]) => `${o[key]} ${words}`).join(", ");
2122
+ }
2123
+ var UNAVAILABLE = {
2124
+ sold_out: "sold out",
2125
+ no_matching_offers: "no offer matches the filters",
2126
+ nationality_restricted: "restricted by nationality",
2127
+ offers_unreadable: "offers unreadable",
2128
+ not_found: "hotel not found",
2129
+ supplier_unavailable: "offers could not be fetched this time",
2130
+ stay_in_past: "check-in is already past"
2131
+ };
2132
+ function renderOffers(r) {
2133
+ const lines = [
2134
+ `Live offers \xB7 ${stayLine(r.stay.checkIn, r.stay.checkOut)} \xB7 ${party(r.stay.rooms)}`,
2135
+ "total = the whole stay and every room, the amount charged. Fees not in the total are in the property's currency, not converted. Times are UTC, then the hotel's time zone."
2136
+ ];
2137
+ if (r.hotels.length === 0) lines.push("", "No hotel has offers for this stay.");
2138
+ for (const h of r.hotels) {
2139
+ lines.push("", `${named(h.hotel.name, h.hotel.names)} \xB7 ${h.hotel.hotelId} \xB7 ${plural(h.offers.length, "offer")}`);
2140
+ h.offers.forEach((o, i) => lines.push(`${i + 1}. ${offerLine(o)}`));
2141
+ const omitted2 = omittedText(h.omitted);
2142
+ if (omitted2) lines.push(`Not shown: ${omitted2}.`);
2143
+ const limited = limitText(h.omitted);
2144
+ if (limited) lines.push(`${limited}.`);
2145
+ }
2146
+ if (r.unavailable.length > 0) {
2147
+ lines.push("", "Unavailable:");
2148
+ for (const u of r.unavailable) {
2149
+ const omitted2 = u.omitted ? [omittedText(u.omitted), limitText(u.omitted)].filter(Boolean).join("; ") : "";
2150
+ lines.push(`- ${u.hotelId}: ${label(UNAVAILABLE, u.reason)}${omitted2 ? ` (${omitted2})` : ""}`);
2151
+ }
2152
+ }
2153
+ return lines.join("\n");
2154
+ }
2155
+
2156
+ // src/render/order.ts
2157
+ var ORDER_STATUS_WORDS = {
2158
+ awaiting_payment: "awaiting payment",
2159
+ expired: "expired (not paid in time)",
2160
+ confirming: "confirming with the hotel",
2161
+ confirmed: "confirmed",
2162
+ failed: "failed",
2163
+ cancelling: "cancelling",
2164
+ cancelled: "cancelled",
2165
+ needs_attention: "needs attention from Fortrip"
2166
+ };
2167
+ var PAYMENT_WORDS = {
2168
+ unpaid: "unpaid",
2169
+ authorized: "authorized (held, not yet charged)",
2170
+ paid: "paid",
2171
+ released: "released (the hold was let go)",
2172
+ refund_pending: "refund pending",
2173
+ refunded: "refunded"
2174
+ };
2175
+ function itemLines(item) {
2176
+ if (item.type !== "hotel") {
2177
+ return [`Item of type ${item.type}: not shown in this text. ${raw(item)}`];
2178
+ }
2179
+ const h = item.hotel;
2180
+ return [
2181
+ [
2182
+ `Hotel: ${named(h.name, h.names)}`,
2183
+ h.hotelId ?? "not in the hotel catalogue",
2184
+ stayLine(h.checkIn, h.checkOut),
2185
+ party(h.rooms),
2186
+ `time zone ${h.timeZone ?? "not listed"}`
2187
+ ].join(" \xB7 "),
2188
+ `Room: ${h.room.name ? h.room.name.text : "room name not listed"} \xB7 ${h.room.beds ? `beds: ${h.room.beds.text}` : "beds not listed"}`,
2189
+ `Terms: ${mealsWithLabel(h.meals)} \xB7 ${cancellationText(h.cancellation)} \xB7 ${feesText(h.payAtProperty)}`,
2190
+ `Hotel confirmation number: ${h.confirmationNumber ?? "none yet"}`
2191
+ ];
2192
+ }
2193
+ function paymentLines(o) {
2194
+ const p = o.payment;
2195
+ const lines = [
2196
+ [
2197
+ `Total: ${money2(o.price.total)}`,
2198
+ `payment ${label(PAYMENT_WORDS, p.status)}`,
2199
+ `charged in ${p.chargeCurrency}${p.localCurrencyAtCheckout ? " (the user's card may pay in its local currency at checkout)" : ""}`,
2200
+ p.localCharge ? `card charged ${money2(p.localCharge)}` : ""
2201
+ ].filter(Boolean).join(" \xB7 ")
2202
+ ];
2203
+ if (p.url !== null) {
2204
+ const about = [
2205
+ p.expiresAt === null ? "" : `expires ${when(p.expiresAt, null)}`,
2206
+ p.requiresSignIn ? "the user signs in to Fortrip to pay" : "no sign-in needed"
2207
+ ].filter(Boolean);
2208
+ lines.push(`Payment link (${about.join("; ")}): ${p.url}`);
2209
+ } else if (o.status === "awaiting_payment") {
2210
+ lines.push("Payment link: could not be read this time; read this order again for it.");
2211
+ }
2212
+ lines.push(`Manage: ${o.manageUrl}`);
2213
+ return lines;
2214
+ }
2215
+ function orderLines(o) {
2216
+ return [
2217
+ `Order ${o.orderId} \xB7 reference ${o.reference} \xB7 ${label(ORDER_STATUS_WORDS, o.status)} \xB7 created ${when(o.createdAt, null)} \xB7 updated ${when(o.updatedAt, null)}`,
2218
+ ...o.items.flatMap(itemLines),
2219
+ ...paymentLines(o)
2220
+ ];
2221
+ }
2222
+ function renderOrder(o) {
2223
+ return orderLines(o).join("\n");
2224
+ }
2225
+ function renderOrderList(list) {
2226
+ const lines = [
2227
+ list.data.length === 0 ? "No orders on this page." : `Orders, newest first: ${list.data.length} on this page.`
2228
+ ];
2229
+ for (const o of list.data) lines.push("", ...orderLines(o));
2230
+ lines.push(
2231
+ "",
2232
+ list.nextCursor === null ? "Last page." : `Next page: call again with { cursor: "${list.nextCursor}" }.`
2233
+ );
2234
+ return lines.join("\n");
2235
+ }
2236
+
2237
+ // src/render/search.ts
2238
+ var SORTS = {
2239
+ distance: "nearest first",
2240
+ relevance: "best text match first",
2241
+ stars_desc: "most stars first",
2242
+ price_asc: "cheapest first",
2243
+ price_desc: "dearest first"
2244
+ };
2245
+ var CENTER_BASES2 = {
2246
+ seat: "the seat",
2247
+ capital: "the capital",
2248
+ busiest_settlement: "the settlement with the most hotels",
2249
+ hub: "the locality's hub",
2250
+ point: "its location"
2251
+ };
2252
+ function radiusWords(meters, source) {
2253
+ return `within ${km(meters)}${source === "default" ? ", the default radius" : ""}`;
2254
+ }
2255
+ function placeHeader(d, matched) {
2256
+ const p = d.place;
2257
+ const scope = d.scope === "locality" ? "locality" : d.scope === "area" ? d.radiusMeters === null ? p.kind : `${p.kind}, within ${km(d.radiusMeters)} of its center` : d.scope === "radius" ? d.radiusMeters === null ? `${p.kind}, radius not stated` : `${p.kind}, ${radiusWords(d.radiusMeters, d.radiusSource)}` : `${p.kind}, scope ${d.scope}`;
2258
+ const where = [p.region && `${p.region.name} ${p.region.placeId}`, p.countryCode].filter(Boolean).join(", ");
2259
+ const other = [p.names.en, p.names.zh].find((n) => n && n !== p.name);
2260
+ return [
2261
+ `${p.name} (${scope})`,
2262
+ plural(matched, "hotel"),
2263
+ p.placeId,
2264
+ where,
2265
+ other ? `also named ${other}` : ""
2266
+ ].filter(Boolean).join(" \xB7 ");
2267
+ }
2268
+ function header(r) {
2269
+ const d = r.destination;
2270
+ const matched = r.coverage.matched;
2271
+ let head;
2272
+ if (d === null) {
2273
+ head = `${r.text ? `Text "${r.text.value}"` : "All hotels"} \xB7 ${plural(matched, "hotel")}`;
2274
+ } else if (d.kind === "place") {
2275
+ head = placeHeader(d, matched);
2276
+ } else if (d.kind === "point") {
2277
+ const p = d;
2278
+ const { lat, lng } = p.center.location;
2279
+ head = `Point ${lat}, ${lng} (${radiusWords(p.radiusMeters, p.radiusSource)}) \xB7 ${plural(matched, "hotel")}`;
2280
+ } else {
2281
+ head = `Destination of kind ${d.kind} \xB7 ${plural(matched, "hotel")}`;
2282
+ }
2283
+ if (d !== null && r.text) head += ` \xB7 text "${r.text.value}" (exact words)`;
2284
+ return head;
2285
+ }
2286
+ function centerLine(d) {
2287
+ if (d === null || d.kind !== "place" && d.kind !== "point") return null;
2288
+ const c = d.center;
2289
+ const from = c.place ? `${c.place.name} ${c.place.placeId}` : `${c.location.lat}, ${c.location.lng}`;
2290
+ return `Distances from ${from} (${label(CENTER_BASES2, c.basis)}).`;
2291
+ }
2292
+ function pricingWords(r) {
2293
+ const { mode, scope, status } = r.pricing;
2294
+ if (mode === "none") return "no prices (no stay given)";
2295
+ if (mode === "indicative" && status === "unavailable") {
2296
+ return "indicative prices unavailable this time";
2297
+ }
2298
+ if (mode === "indicative") {
2299
+ return "indicative prices per room-night: estimates, not bookable (only offers are)";
2300
+ }
2301
+ return `prices ${[mode, scope, status].filter(Boolean).join(", ")}`;
2302
+ }
2303
+ function countsLine(r) {
2304
+ const c = r.coverage;
2305
+ const country = r.destination?.kind === "place" ? r.destination.place.countryCode : "the country";
2306
+ const parts = [`${num(c.matched)} matched`];
2307
+ if (c.priced !== null) parts.push(`${num(c.priced)} priced`);
2308
+ if (c.withPrice !== null) parts.push(`${num(c.withPrice)} with a price`);
2309
+ else if (r.pricing.mode === "indicative") parts.push("with a price: unknown");
2310
+ if (c.excludedNoPrice !== null) {
2311
+ parts.push(`${num(c.excludedNoPrice)} left out for having no indicative price`);
2312
+ }
2313
+ if (c.excludedNoFacilityData !== null) {
2314
+ parts.push(`${num(c.excludedNoFacilityData)} left out for listing no facilities`);
2315
+ }
2316
+ if (c.withinMaxRadius !== null) parts.push(`${num(c.withinMaxRadius)} within 80 km`);
2317
+ if (c.textAnywhere !== null) {
2318
+ parts.push(`${num(c.textAnywhere)} match the text anywhere in ${country}`);
2319
+ }
2320
+ if (c.nameAsText !== null) parts.push(`${num(c.nameAsText)} have the name as text`);
2321
+ return `Counts: ${parts.join(", ")}.`;
2322
+ }
2323
+ function localitiesLines(loc, areaName) {
2324
+ const lines = [
2325
+ 'Localities: pass one back as destination { kind: "locality", placeId }, never the name. The number is its matching hotels; "incl." lists places inside it, with all their hotels regardless of filters ("hotels overall").'
2326
+ ];
2327
+ for (const item of loc.items) {
2328
+ let line = `- ${item.place.name} ${item.place.placeId} ${num(item.count)}`;
2329
+ if (item.total !== item.count) line += ` here, ${num(item.total)} as a locality`;
2330
+ const sameName = areaName !== null && [item.place.name, item.place.names.en, item.place.names.zh].some(
2331
+ (n) => n?.toLowerCase() === areaName.toLowerCase()
2332
+ );
2333
+ if (sameName) line += ` \u2014 the locality, not the whole of ${areaName}`;
2334
+ if (item.includes.length > 0) {
2335
+ const inc = item.includes.map(
2336
+ (i) => `${i.place.name} ${i.place.placeId}, ${num(i.members)} hotels overall`
2337
+ );
2338
+ line += ` (incl. ${inc.join("; ")})`;
2339
+ }
2340
+ lines.push(line);
2341
+ }
2342
+ if (loc.unassigned > 0) {
2343
+ lines.push(`${plural(loc.unassigned, "hotel")} in this search belong to no locality.`);
2344
+ }
2345
+ if (loc.more > 0) {
2346
+ lines.push(`${num(loc.more)} more localities; name one with place_name.`);
2347
+ }
2348
+ return lines;
2349
+ }
2350
+ function priceWords(h, status) {
2351
+ const p = h.price;
2352
+ if (p === null) {
2353
+ return status === "ok" ? "no indicative price" : "price unavailable this time";
2354
+ }
2355
+ let text2 = `from ${money2(p.fromPerRoomNight)}/room-night (${money2(p.fromPerRoomStay)} per room for the stay)`;
2356
+ if (p.approx) {
2357
+ text2 += ` \u2248 ${money2(p.approx.fromPerRoomNight)}/room-night (${money2(p.approx.fromPerRoomStay)}), exchange rate of ${p.approx.rateDate}`;
2358
+ }
2359
+ if (p.kind !== "indicative" || p.basis !== "lowest_unit") {
2360
+ text2 += ` [${p.kind}, ${p.basis}]`;
2361
+ }
2362
+ return text2;
2363
+ }
2364
+ function hotelLine(h, i, r) {
2365
+ const where = [h.address.region?.name, h.address.countryCode].filter(Boolean).join(", ");
2366
+ return [
2367
+ `${i + 1}. ${named(h.name, h.names)}`,
2368
+ h.stars === null ? "stars not listed" : `${h.stars}\u2605`,
2369
+ r.destination === null ? "" : h.distanceMeters === null ? "distance unknown" : km(h.distanceMeters),
2370
+ r.pricing.mode === "none" ? "" : priceWords(h, r.pricing.status),
2371
+ h.address.line ? h.address.line.text : "address not listed",
2372
+ where,
2373
+ h.hotelId
2374
+ ].filter(Boolean).join(" \xB7 ");
2375
+ }
2376
+ function renderSearch(r) {
2377
+ const lines = [header(r)];
2378
+ lines.push(
2379
+ [
2380
+ r.stay ? `Stay ${stayLine(r.stay.checkIn, r.stay.checkOut)}, ${party(r.stay.rooms)}` : "No stay",
2381
+ pricingWords(r),
2382
+ `sort: ${label(SORTS, r.sort)}`
2383
+ ].join(" \xB7 ")
2384
+ );
2385
+ const center2 = centerLine(r.destination);
2386
+ if (center2) lines.push(center2);
2387
+ lines.push(countsLine(r));
2388
+ const pool = r.coverage.poolRadiusMeters;
2389
+ if (pool !== null) {
2390
+ const d = r.destination;
2391
+ const known = d !== null && (d.kind === "place" || d.kind === "point") ? d : null;
2392
+ const around = known?.center.place?.name ?? (known?.kind === "place" ? known.place.name : "the center");
2393
+ const priced = r.coverage.priced === null ? "" : ` ${num(r.coverage.priced)}`;
2394
+ lines.push(
2395
+ `Only the nearest${priced} matches were priced: "cheapest" here means cheapest within ${km(pool)} of ${around}.`
2396
+ );
2397
+ }
2398
+ if (r.coverage.localities) {
2399
+ const areaName = r.destination?.kind === "place" ? r.destination.place.name : null;
2400
+ lines.push("", ...localitiesLines(r.coverage.localities, areaName));
2401
+ }
2402
+ lines.push("");
2403
+ if (r.data.length === 0) lines.push("No hotels on this page.");
2404
+ r.data.forEach((h, i) => lines.push(hotelLine(h, i, r)));
2405
+ if (r.notes.length > 0) {
2406
+ lines.push("", "Notes:", ...r.notes.map((n) => `- ${n.code}: ${n.message}`));
2407
+ }
2408
+ lines.push(
2409
+ "",
2410
+ r.nextCursor === null ? "Last page." : `Next page: call again with only the cursor and, optionally, limit: { cursor: "${r.nextCursor}" }.`
2411
+ );
2412
+ return lines.join("\n");
2413
+ }
2414
+
2415
+ // src/render/errors.ts
2416
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2417
+ function changeLine(c) {
2418
+ const worse = c.worse === true ? " (worse)" : c.worse === false ? " (not worse)" : "";
2419
+ const pair = (show) => `${show(c.previous)} \u2192 ${show(c.current)}`;
2420
+ switch (c.field) {
2421
+ case "price":
2422
+ return `- price: ${pair(money2)}${worse}`;
2423
+ case "meals":
2424
+ return `- meals: ${pair(mealsText)}${worse}`;
2425
+ case "cancellation":
2426
+ return `- cancellation: ${pair(cancellationText)}${worse}`;
2427
+ case "confirmation":
2428
+ return `- confirmation: ${pair(confirmationText)}${worse}`;
2429
+ case "pay_at_property":
2430
+ return `- fees not in the total: ${pair(feesText)}${worse}`;
2431
+ default:
2432
+ return `- ${c.field}: ${raw(c.previous)} \u2192 ${raw(c.current)}${worse}`;
2433
+ }
2434
+ }
2435
+ function tooBroad(d) {
2436
+ const lines = [
2437
+ 'Next: show the user these localities and ask which they prefer, unless their request already names one of them; then search again with destination { kind: "locality", placeId: "<id>" } and the same text, filters, stay and sort.',
2438
+ ...localitiesLines(d.localities, null)
2439
+ ];
2440
+ const top = d.localities.items[0];
2441
+ if (top && top.total > d.poolLimit && d.center?.place?.placeId === top.place.placeId) {
2442
+ lines.push(
2443
+ `Or: ${top.place.name}, the area's center, alone has ${num(top.total)} matching hotels: the same destination with radiusMeters (up to ${LIMITS.radiusMetersMax}) prices nearly the same hotels (the nearest ${num(d.poolLimit)} around ${top.place.name}).`
2444
+ );
2445
+ }
2446
+ return lines;
2447
+ }
2448
+ function ambiguous(d) {
2449
+ const shown = d.candidatesTotal > d.candidates.length ? `${d.candidates.length} of ${d.candidatesTotal} shown` : `${d.candidatesTotal}`;
2450
+ const lines = [
2451
+ `Places named "${d.name}" in ${d.countryCode} (${shown}). Ask the user which one, then search with destination { kind: "place", placeId: "<id>" }:`
2452
+ ];
2453
+ for (const c of d.candidates) {
2454
+ const other = [c.names.en, c.names.zh].find((n) => n && n !== c.name);
2455
+ const where = [c.region && `${c.region.name} ${c.region.placeId}`, c.countryCode].filter(Boolean).join(", ");
2456
+ const hotels = c.hotels.scope === "area" ? `${plural(c.hotels.count, "hotel")} in the area` : c.hotels.radiusMeters === null ? plural(c.hotels.count, "hotel") : `${plural(c.hotels.count, "hotel")} within ${km(c.hotels.radiusMeters)}`;
2457
+ lines.push(
2458
+ [
2459
+ `- ${c.name}${other ? ` (${other})` : ""} ${c.placeId}`,
2460
+ `${c.kind} in ${where}`,
2461
+ c.population === null ? "population not recorded" : `population ${num(c.population)}`,
2462
+ hotels
2463
+ ].join(" \xB7 ")
2464
+ );
2465
+ }
2466
+ if (d.nameAsText !== null) {
2467
+ lines.push(`${plural(d.nameAsText, "hotel")} have "${d.name}" in their name or address: it can also be searched as text.`);
2468
+ }
2469
+ return lines;
2470
+ }
2471
+ function notFound(d) {
2472
+ switch (d.reason) {
2473
+ case "no_match": {
2474
+ const m = d;
2475
+ const text2 = m.nameAsText === null ? "" : ` ${plural(m.nameAsText, "hotel")} have "${m.name}" in their name or address: it can be searched as text.`;
2476
+ return [`No place named "${m.name}" in ${m.countryCode}.${text2}`];
2477
+ }
2478
+ case "unknown_id":
2479
+ return [`Unknown placeId ${d.placeId}.`];
2480
+ case "not_a_locality": {
2481
+ const n = d;
2482
+ const town = `{ kind: "place", placeId: "${n.placeId}" }`;
2483
+ if (n.hub === null) {
2484
+ return [`${n.placeId} belongs to no locality: search ${town} for an 8 km circle around it.`];
2485
+ }
2486
+ const total = n.hubTotal === null ? "" : ` (${plural(n.hubTotal, "matching hotel")})`;
2487
+ return [
2488
+ `${n.placeId} is not a locality. Its hotels belong to the locality ${n.hub.name} ${n.hub.placeId}${total}.`,
2489
+ `Search { kind: "locality", placeId: "${n.hub.placeId}" } for those, or ${town} for an 8 km circle around the town itself.`
2490
+ ];
2491
+ }
2492
+ default:
2493
+ return [`Details: ${raw(d)}`];
2494
+ }
2495
+ }
2496
+ function detailLines(e) {
2497
+ if (!("details" in e) || e.details === void 0) return [];
2498
+ switch (e.code) {
2499
+ case "invalid_request": {
2500
+ const d = e.details;
2501
+ const lines = d.issues.map((i) => `- ${i.pointer}: ${i.message}`);
2502
+ if (d.filterable) lines.push(`Filterable amenities: ${d.filterable.join(", ")}.`);
2503
+ if (d.share) {
2504
+ lines.push(
2505
+ `${d.share.code} is listed by ${(d.share.listedShare * 100).toFixed(1)} % of hotels that list any facility.`
2506
+ );
2507
+ }
2508
+ return lines;
2509
+ }
2510
+ case "cursor_invalid":
2511
+ return [`The cursor is ${e.details.reason}: search again without a cursor.`];
2512
+ case "order_removed":
2513
+ return [`Order ${e.details.orderId}, reference ${e.details.reference}.`];
2514
+ case "offer_expired":
2515
+ return [`Offer ${e.details.offerId} expired at ${when(e.details.expiredAt, null)}: get fresh offers.`];
2516
+ case "offer_changed":
2517
+ return [
2518
+ "Changes:",
2519
+ ...e.details.changes.map(changeLine),
2520
+ "New offer, held for ordering:",
2521
+ offerLine(e.details.offer),
2522
+ "Show the user these changes and ask again before ordering the new offer."
2523
+ ];
2524
+ case "offer_already_ordered":
2525
+ case "order_in_progress":
2526
+ case "order_attempts_exhausted":
2527
+ return [`Order ${e.details.orderId}.`];
2528
+ case "order_exists":
2529
+ return [`Order ${e.details.orderId}, ${label(ORDER_STATUS_WORDS, e.details.status)}.`];
2530
+ case "destination_ambiguous":
2531
+ return ambiguous(e.details);
2532
+ case "destination_not_found":
2533
+ return notFound(e.details);
2534
+ case "search_too_broad":
2535
+ return tooBroad(e.details);
2536
+ case "stay_unsupported":
2537
+ return e.details.reason === "mixed_occupancy" ? ["Every room must have the same party: ask once per party."] : e.details.reason === "max_nights" ? [`At most ${plural(e.details.max, "night")} per stay.`] : [`Details: ${raw(e.details)}`];
2538
+ case "stay_in_past":
2539
+ return [`Check-in ${e.details.checkIn} is already past${e.details.timeZone ? ` in ${e.details.timeZone}` : ""}.`];
2540
+ case "offer_unavailable":
2541
+ return [
2542
+ `Offer ${e.details.offerId}: ${label({ sold_out: "sold out", refused: "can no longer be booked" }, e.details.reason)}. Get fresh offers.`
2543
+ ];
2544
+ case "rate_limited":
2545
+ return [`Limit reached for: ${e.details.scope}.`];
2546
+ case "supplier_unavailable":
2547
+ return [
2548
+ label(
2549
+ {
2550
+ not_started: "Nothing was sent to the hotel's supplier.",
2551
+ unknown: "The request was sent and timed out: its outcome is unknown."
2552
+ },
2553
+ e.details.outcome
2554
+ )
2555
+ ];
2556
+ default:
2557
+ return [`Details: ${raw(e.details)}`];
2558
+ }
2559
+ }
2560
+ function retryLine(retryable, after) {
2561
+ if (retryable === true) {
2562
+ return typeof after === "number" ? `Retryable: retry after ${after} s.` : "Retryable.";
2563
+ }
2564
+ return retryable === false ? "Not retryable." : "Retryable: unknown.";
2565
+ }
2566
+ function renderError(envelope, status) {
2567
+ const http = status === void 0 ? "" : ` (HTTP ${status})`;
2568
+ let known = null;
2569
+ let body = null;
2570
+ try {
2571
+ const parsed = parseOpen(errorEnvelope, envelope).error;
2572
+ body = parsed;
2573
+ if (Object.hasOwn(ERROR_CODES, parsed.code)) known = parsed;
2574
+ } catch {
2575
+ if (isObject(envelope) && isObject(envelope.error)) body = envelope.error;
2576
+ }
2577
+ if (body === null) {
2578
+ return `Error${http}: the answer carried no readable error.
2579
+ Retryable: unknown.`;
2580
+ }
2581
+ const code = typeof body.code === "string" ? body.code : "unknown";
2582
+ const lines = [
2583
+ `Error ${code}${http}${typeof body.message === "string" ? `: ${body.message}` : ""}`,
2584
+ retryLine(body.retryable, body.retryAfterSeconds)
2585
+ ];
2586
+ if (typeof body.param === "string") lines.push(`Field: ${body.param}`);
2587
+ if (known) lines.push(...detailLines(known));
2588
+ else if (body.details !== void 0) lines.push(`Details: ${raw(body.details)}`);
2589
+ if (typeof body.requestId === "string") lines.push(`Request id: ${body.requestId}`);
2590
+ return lines.join("\n");
2591
+ }
2592
+
2593
+ // src/render/index.ts
2594
+ function renderToolResult(tool, data) {
2595
+ const schema = TOOLS.find((t) => t.name === tool)?.output;
2596
+ let parsed;
2597
+ try {
2598
+ if (!schema) throw new Error(`no output schema for ${tool}`);
2599
+ parsed = parseOpen(schema, data);
2600
+ } catch {
2601
+ const text2 = `This ${tool} result is not in the expected shape; as JSON:
2602
+ ${raw(data)}`;
2603
+ return ORDER_TOOLS.has(tool) && holdsPaymentUrl(data) ? `${text2}
2604
+
2605
+ ${ORDER_NEXT_STEP}` : text2;
2606
+ }
2607
+ switch (tool) {
2608
+ case "search_hotels":
2609
+ return renderSearch(parsed);
2610
+ case "get_hotel":
2611
+ return renderHotel(parsed);
2612
+ case "get_hotel_offers":
2613
+ return renderOffers(parsed);
2614
+ case "book_hotel":
2615
+ case "get_order": {
2616
+ const order2 = parsed;
2617
+ return withConsent(renderOrder(order2), [order2]);
2618
+ }
2619
+ case "list_orders": {
2620
+ const list = parsed;
2621
+ return withConsent(renderOrderList(list), list.data);
2622
+ }
2623
+ }
2624
+ }
2625
+ var ORDER_TOOLS = /* @__PURE__ */ new Set(["book_hotel", "get_order", "list_orders"]);
2626
+ function holdsPaymentUrl(value, seen = /* @__PURE__ */ new Set()) {
2627
+ if (typeof value !== "object" || value === null || seen.has(value)) return false;
2628
+ seen.add(value);
2629
+ const payment = value.payment;
2630
+ if (typeof payment === "object" && payment !== null && typeof payment.url === "string" && payment.url !== "") {
2631
+ return true;
2632
+ }
2633
+ return Object.values(value).some((v) => holdsPaymentUrl(v, seen));
2634
+ }
2635
+ function withConsent(text2, orders) {
2636
+ return orders.some((o) => o.payment.url !== null) ? `${text2}
2637
+
2638
+ ${ORDER_NEXT_STEP}` : text2;
2639
+ }
2640
+
2641
+ // src/conventions/unknown-is-not-no.ts
2642
+ var RESULT_LIST = "A result list: the API built it, so an empty list means none, not unknown";
2643
+ var REQUEST_ECHO = "An echo of the request: it says what the caller sent";
2644
+ var API_DECIDES = "A flag the API itself decides, so it is never unknown";
2645
+ var THE_FLAG = "The flag that says whether the source stated its siblings";
2646
+ var UNKNOWN_IS_NOT_NO_EXEMPTIONS = {
2647
+ data: `${RESULT_LIST} (a page of hotels or orders).`,
2648
+ notes: `${RESULT_LIST} (the notes the search added).`,
2649
+ offers: `${RESULT_LIST} (the offers left after selection).`,
2650
+ items: `${RESULT_LIST} (an order's items, a listed set's entries).`,
2651
+ issues: `${RESULT_LIST} (what was wrong with the request).`,
2652
+ changes: `${RESULT_LIST} (every change between the offer and the verify).`,
2653
+ hotels: `${RESULT_LIST} (the hotels that have offers).`,
2654
+ unavailable: `${RESULT_LIST} (the hotels without offers, each with a reason).`,
2655
+ candidates: `${RESULT_LIST} (the places a name could mean).`,
2656
+ "localities.items": `${RESULT_LIST} (the top locality hubs of the scope).`,
2657
+ "localities.items.includes": `${RESULT_LIST} (other places among a hub's members).`,
2658
+ filterable: `${RESULT_LIST} (the amenity codes a search can filter by).`,
2659
+ rooms: `${REQUEST_ECHO} (the rooms of the stay).`,
2660
+ childrenAges: `${REQUEST_ECHO} (each child's age; empty means no children).`,
2661
+ retryable: `${API_DECIDES} (whether retrying can help).`,
2662
+ mandatory: `${API_DECIDES} (whether a fee is charged whatever the guest chooses).`,
2663
+ worse: `${API_DECIDES} (whether a change is worse for the guest).`,
2664
+ requiresSignIn: `${API_DECIDES} (whether the payment link needs a website sign-in).`,
2665
+ localCurrencyAtCheckout: `${API_DECIDES} (whether checkout may charge in the card's currency).`,
2666
+ bookable: `${API_DECIDES} (whether the hotel is still in the index).`,
2667
+ cityLike: `${API_DECIDES} (frozen per gazetteer release; null where not computed).`,
2668
+ known: `${THE_FLAG} (meals).`,
2669
+ listed: `${THE_FLAG} (policies, amenities, rooms).`
2670
+ };
2671
+
989
2672
  // package.json
990
2673
  var package_default = {
991
2674
  name: "@fortrip/agent-contract",
992
- version: "2.0.0",
2675
+ version: "3.0.0",
993
2676
  license: "Apache-2.0",
994
- description: "The contract of Fortrip's agent API (/api/agent/v1): limits, paths, input schemas and field rules.",
2677
+ description: "The contract of Fortrip's agent API (https://api.fortrip.ai/v1) and its MCP server: limits, paths, request and response schemas, error codes and field rules.",
995
2678
  type: "module",
996
2679
  repository: {
997
2680
  type: "git",
@@ -1038,47 +2721,104 @@ var package_default = {
1038
2721
  // src/version.ts
1039
2722
  var CONTRACT_VERSION = package_default.version;
1040
2723
  export {
2724
+ AMENITY_CODES,
1041
2725
  API,
1042
- BOARD_CODES,
1043
- BOOKING_NEXT_STEP,
1044
2726
  CARD_NUMBER_MESSAGE,
2727
+ CENTER_BASES,
1045
2728
  COMMANDS,
1046
2729
  COMMAND_GROUPS,
1047
2730
  CONTRACT_VERSION,
1048
- CURRENCIES,
1049
- ERROR_REASONS,
2731
+ DISPLAY_CURRENCIES,
2732
+ ERROR_CODES,
2733
+ ERROR_DETAILS,
2734
+ FILTERABLE_AMENITIES,
1050
2735
  GLOBAL_FLAGS,
1051
2736
  GUEST_NAME_MESSAGE,
1052
2737
  GUEST_NAME_PATTERN,
1053
2738
  GUEST_NAME_TITLE,
1054
2739
  LIMITS,
1055
- MCP_BOOKING_RESULT,
1056
- MCP_RATES_INPUT,
2740
+ MCP_OUTPUT,
2741
+ MCP_PATH,
2742
+ ORDER_NEXT_STEP,
2743
+ ORDER_STATUSES,
2744
+ PAYMENT_STATUSES,
2745
+ PLACE_KINDS,
2746
+ POLICY_TOPICS,
2747
+ PUBLIC_ORIGINS,
2748
+ SEARCH_NOTE_CODES,
1057
2749
  SEARCH_SORTS,
1058
2750
  SUPPORT_EMAIL,
1059
2751
  TOOLS,
2752
+ UNAVAILABLE_REASONS,
2753
+ UNKNOWN_IS_NOT_NO_EXEMPTIONS,
1060
2754
  addIsoDateDays,
1061
2755
  authConfig,
1062
- bookingResult,
1063
- childrenAges,
2756
+ cancellation,
2757
+ center,
1064
2758
  commandInvocation,
2759
+ confirmation,
1065
2760
  containsCardNumber,
1066
2761
  countIsoDateDays,
1067
- createBookingInput,
2762
+ createOrderInput,
2763
+ createSearchHotelsInput,
2764
+ destination,
2765
+ errorEnvelope,
1068
2766
  flag,
2767
+ formatMoney,
1069
2768
  getHotelInput,
1070
- getRatesInput,
2769
+ getOrderInput,
1071
2770
  guestNameRe,
2771
+ hotel,
2772
+ hotelId,
2773
+ hotelOffersInput,
2774
+ hotelOffersResult,
2775
+ hotelSummary,
2776
+ indicativePrice,
2777
+ instant,
1072
2778
  isIsoDate,
1073
- isoDate,
2779
+ knownLocaleText,
2780
+ latLng,
2781
+ listOrdersInput,
2782
+ localDate,
2783
+ localTime,
2784
+ localities,
2785
+ localizedText,
1074
2786
  meResult,
2787
+ meals,
2788
+ money,
2789
+ names,
1075
2790
  nonEmpty,
1076
2791
  normalizeGuestName,
2792
+ offer,
2793
+ offerChange,
2794
+ offerId,
2795
+ omitted,
1077
2796
  oneOf,
1078
- positiveId,
2797
+ openEnum,
2798
+ openObject,
2799
+ openUnion,
2800
+ order,
2801
+ orderId,
2802
+ orderList,
2803
+ orderReference,
2804
+ parseOpen,
2805
+ payAtProperty,
2806
+ place,
2807
+ placeId,
2808
+ placeRef,
2809
+ renderError,
1079
2810
  renderGuide,
2811
+ renderToolResult,
2812
+ roomFacts,
1080
2813
  searchHotelsInput,
2814
+ searchResult,
2815
+ stay,
2816
+ stayEcho,
2817
+ strictErrorEnvelope,
1081
2818
  strictInput,
2819
+ strictOutput,
1082
2820
  text,
2821
+ timeZone,
2822
+ usdMoney,
1083
2823
  wholeNumber
1084
2824
  };