@curless/hotel-storefront 0.51.4 → 0.51.6

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.
package/dist/tools.js CHANGED
@@ -167,7 +167,7 @@ const forCapabilities = (tools, n, v, caps) => {
167
167
  if (t.name === n.viewTrip) {
168
168
  out = {
169
169
  ...out,
170
- description: 'The trip as it stands: every room line with its own total, the taxes and fees, and the all-in total, laid out day by day on the card.',
170
+ description: 'The trip as it stands: every room line with its own total, the taxes and fees, and the all-in total, laid out day by day (on the card, in an app that draws one).',
171
171
  };
172
172
  }
173
173
  if (t.name === n.lookUp) {
@@ -362,7 +362,7 @@ const allTools = (n, v) => {
362
362
  // description says it too, as what the card is, never as an order: the
363
363
  // directory rule (tool-descriptions.test.ts) holds, and "first" is one of
364
364
  // its words, hence "the other way round".
365
- description: `${upFirst(catalogue(v))}, shown as a card for a guest to look through and pick one ${v.noun} to book. It holds no plan of days: a day-by-day plan (行程, 安排, an itinerary) is a card of its own, laid out by the days, with a ${v.noun} for each part of the trip read up as text. Asked with no place - or a country of many cities - it answers with the places to choose from: regions, countries and cities, each with how many ${v.nounPlural} it has; \`all\` lists the ${v.nounPlural} themselves. \`city\` (a city or a country) or \`query\` answers with the ${v.nounPlural}: a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price, and a city usually holds several. With \`checkIn\` and \`checkOut\` every ${v.noun} is priced for those nights - its cheapest room that takes the party, \`rooms\` of it, for the whole stay with taxes - or marked as having no room for them. \`sort\` and \`maxNightly\` order and cap by that price, or by the from-price without dates, where the matches share one currency. \`brand\`, \`facilities\`, \`freeCancellation\`, \`breakfast\` and \`bed\` narrow the list, and the answer counts what each would leave. A long list comes a page at a time: the answer says how many more there are and the \`offset\` of the next page. Words or a place that match nothing are widened once, and the answer names what it widened to.${v.hints.search ? ` ${v.hints.search}` : ''}`,
365
+ description: `${upFirst(catalogue(v))}, shown to a guest - as a card, in an app that draws one - to look through and pick one ${v.noun} to book. It holds no plan of days: a day-by-day plan (行程, 安排, an itinerary) is an answer of its own, laid out by the days, with a ${v.noun} for each part of the trip read up as text. Asked with no place - or a country of many cities - it answers with the places to choose from: regions, countries and cities, each with how many ${v.nounPlural} it has; \`all\` lists the ${v.nounPlural} themselves. \`city\` (a city or a country) or \`query\` answers with the ${v.nounPlural}: a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price, and a city usually holds several. With \`checkIn\` and \`checkOut\` every ${v.noun} is priced for those nights - its cheapest room that takes the party, \`rooms\` of it, for the whole stay with taxes - or marked as having no room for them. \`sort\` and \`maxNightly\` order and cap by that price, or by the from-price without dates, where the matches share one currency. \`brand\`, \`facilities\`, \`freeCancellation\`, \`breakfast\` and \`bed\` narrow the list, and the answer counts what each would leave. A long list comes a page at a time: the answer says how many more there are and the \`offset\` of the next page. Words or a place that match nothing are widened once, and the answer names what it widened to.${v.hints.search ? ` ${v.hints.search}` : ''}`,
366
366
  annotations: reads(`Browse ${catalogue(v)}`, open),
367
367
  inputSchema: {
368
368
  type: 'object',
@@ -441,7 +441,7 @@ const allTools = (n, v) => {
441
441
  },
442
442
  {
443
443
  name: n.getHotel,
444
- description: `One ${v.noun} in full: description, amenities, address, and check-in and check-out times where the ${v.noun} publishes them. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, one line per type at its lowest rate: the nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rates it has. \`rooms\` leaves out the types that have fewer than that many left. \`bed\`, \`breakfast\`, \`freeCancellation\`, \`window\`, \`payment\` and \`family\` keep only the rates that meet all of those given; the card still holds every room, with the given filters selected. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known. The ${v.noun} is drawn as a card for the guest to look at.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
444
+ description: `One ${v.noun} in full: description, amenities, address, and check-in and check-out times where the ${v.noun} publishes them. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, one line per type at its lowest rate: the nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rates it has. \`rooms\` leaves out the types that have fewer than that many left. \`bed\`, \`breakfast\`, \`freeCancellation\`, \`window\`, \`payment\` and \`family\` keep only the rates that meet all of those given; a card, in an app that draws one, still holds every room, with the given filters selected. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known. The ${v.noun} is shown to the guest - as a card, in an app that draws one.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
445
445
  annotations: reads(`View one ${catalogue(v, true)}`, open),
446
446
  inputSchema: {
447
447
  type: 'object',
@@ -521,7 +521,7 @@ const allTools = (n, v) => {
521
521
  },
522
522
  {
523
523
  name: n.listExtras,
524
- description: `What one ${v.noun} offers beside the room: airport transfers, dinners, guided experiences, spa and family add-ons, each with its price and whether it is charged per stay, per person or per night, drawn as a card for the guest to look at.`,
524
+ description: `What one ${v.noun} offers beside the room: airport transfers, dinners, guided experiences, spa and family add-ons, each with its price and whether it is charged per stay, per person or per night, shown to the guest - as a card, in an app that draws one.`,
525
525
  annotations: reads(v.market ? `Add-ons on ${v.brand}` : `${v.brand} add-ons`, open),
526
526
  inputSchema: {
527
527
  type: 'object',
@@ -574,7 +574,7 @@ const allTools = (n, v) => {
574
574
  },
575
575
  {
576
576
  name: n.viewTrip,
577
- description: 'The trip as it stands: every room line and add-on with its own total, the taxes and fees, and the all-in total, laid out day by day on the card.',
577
+ description: 'The trip as it stands: every room line and add-on with its own total, the taxes and fees, and the all-in total, laid out day by day (on the card, in an app that draws one).',
578
578
  annotations: reads(`View the ${v.brand} trip`, open),
579
579
  inputSchema: { type: 'object', properties: {} },
580
580
  },
@@ -771,7 +771,7 @@ const allTools = (n, v) => {
771
771
  // prose; every figure on the card is read off the hotel's own record. A
772
772
  // model that could send a price could one day send the wrong one, onto a
773
773
  // card that looks official.
774
- description: `Puts two to four ${v.nounPlural} side by side as one card. Each column's price, rate, cancellation terms, breakfast and rooms left come from the ${v.noun}'s own record; the arguments carry only the ids, one line on who each suits, and its pros and cons. With \`checkIn\`/\`checkOut\` the columns carry each ${v.noun}'s cheapest room for those nights; without them, a from-price.`,
774
+ description: `Puts two to four ${v.nounPlural} side by side - as one card, in an app that draws one. Each column's price, rate, cancellation terms, breakfast and rooms left come from the ${v.noun}'s own record; the arguments carry only the ids, one line on who each suits, and its pros and cons. With \`checkIn\`/\`checkOut\` the columns carry each ${v.noun}'s cheapest room for those nights; without them, a from-price.`,
775
775
  annotations: reads(`Compare ${catalogue(v)}`, open),
776
776
  inputSchema: {
777
777
  type: 'object',
@@ -796,7 +796,7 @@ const allTools = (n, v) => {
796
796
  },
797
797
  },
798
798
  recommended: { type: 'string', description: `The one ${v.noun} to mark as the pick` },
799
- title: { type: 'string', description: 'A heading for the card' },
799
+ title: { type: 'string', description: 'A heading for the plan' },
800
800
  ...stayProps,
801
801
  },
802
802
  required: ['entries'],
@@ -804,7 +804,7 @@ const allTools = (n, v) => {
804
804
  },
805
805
  {
806
806
  name: n.itinerary,
807
- description: `A day-by-day plan (行程) as one card - the card for a trip of several days, or for days a guest asked to have planned (a city with a span of dates and 行程 is one): the days are laid out by where they are spent, and each part of the trip has a ${v.noun} near those days, chosen from the ${v.nounPlural} read up as text. A week in a big city is usually two or three stays: its far-out sights fall on neighbouring days, and those nights have a ${v.noun} near them, with the reason in that day's \`why\` (Beijing's Summer Palace, Yuanmingyuan, Fragrant Hills and the Peking and Tsinghua campuses - 颐和园, 圆明园, 香山, 北大清华 - are days side by side in the north-west); a day out of town, such as the Great Wall, is a day of its own, there and back; one ${v.noun} for the whole week suits days that are all within about 45 minutes of it. Nothing here knows a sight's opening days: each day is drawn as it is given, with its date (many museums close on Mondays, the Palace Museum - 故宫 - among them). Each day carries its own label, an optional note, the moments of the day in order, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records - a ${v.noun} with its photograph and, given dates, the price of its nights. Each stay is marked with whether it is booked, in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
807
+ description: `A day-by-day plan (行程) - one card, in an app that draws one - for a trip of several days, or for days a guest asked to have planned (a city with a span of dates and 行程 is one): the days are laid out by where they are spent, and each part of the trip has a ${v.noun} near those days, chosen from the ${v.nounPlural} read up as text. A week in a big city is usually two or three stays: its far-out sights fall on neighbouring days, and those nights have a ${v.noun} near them, with the reason in that day's \`why\` (Beijing's Summer Palace, Yuanmingyuan, Fragrant Hills and the Peking and Tsinghua campuses - 颐和园, 圆明园, 香山, 北大清华 - are days side by side in the north-west); a day out of town, such as the Great Wall, is a day of its own, there and back; one ${v.noun} for the whole week suits days that are all within about 45 minutes of it. Nothing here knows a sight's opening days: each day is laid out as it is given, with its date (many museums close on Mondays, the Palace Museum - 故宫 - among them). Each day carries its own label, an optional note, the moments of the day in order, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records - a ${v.noun} with its photograph and, given dates, the price of its nights. Each stay is marked with whether it is booked, in the trip or only in the plan, and the answer says how many of each. Nothing is added to the trip.`,
808
808
  annotations: reads(v.market ? `Lay out an itinerary on ${v.brand}` : `Lay out a ${v.brand} itinerary`, open),
809
809
  inputSchema: {
810
810
  type: 'object',
@@ -819,7 +819,7 @@ const allTools = (n, v) => {
819
819
  properties: {
820
820
  label: {
821
821
  type: 'string',
822
- description: `e.g. "Arrive${places[0] ? ` in ${places[0]}` : ''}" - the card prints each day's date beside it, so leave the date out`,
822
+ description: `e.g. "Arrive${places[0] ? ` in ${places[0]}` : ''}" - each day's date is printed beside it, so leave the date out`,
823
823
  },
824
824
  note: {
825
825
  type: 'string',
@@ -827,7 +827,7 @@ const allTools = (n, v) => {
827
827
  },
828
828
  why: {
829
829
  type: 'string',
830
- description: `Why the ${v.noun} named on this day suits the days of that stay - near what, good for what - one sentence, in your own words, with no lead-in such as "My pick:" - the card has its own`,
830
+ description: `Why the ${v.noun} named on this day suits the days of that stay - near what, good for what - one sentence, in your own words, with no lead-in such as "My pick:" - the plan has its own`,
831
831
  },
832
832
  items: {
833
833
  type: 'array',
@@ -1976,6 +1976,24 @@ const langOf = (deps, _key) => deps.lang;
1976
1976
  */
1977
1977
  const freeUntilText = (r) => r.freeCancellationLocal ??
1978
1978
  `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
1979
+ /** Any Han character: a name already in Chinese needs none beside it. */
1980
+ const HAN = /\p{Script=Han}/u;
1981
+ /**
1982
+ * A name as the words to a client that draws no card say it: the plain name,
1983
+ * then the group's own Chinese name in () where it has one the plain name is
1984
+ * not.
1985
+ *
1986
+ * ⚠️ THE GROUP'S NAME, NOT THE MODEL'S (2026-11-02 audit, P1-1). The Chinese
1987
+ * names were only in the structure, and a model that is handed the text alone
1988
+ * told a Chinese guest 「浦东凯里酒店」 for 上海浦东嘉里大酒店 - a name nobody
1989
+ * can find the hotel by. A card host is told the plain name as ever: its card
1990
+ * says the Chinese.
1991
+ */
1992
+ const withZh = (name, names, noCards) => {
1993
+ const plain = hotelName(name);
1994
+ const zh = noCards && names?.zh ? hotelName(names.zh) : '';
1995
+ return zh && zh !== plain && !HAN.test(plain) ? `${plain}(${zh})` : plain;
1996
+ };
1979
1997
  /**
1980
1998
  * What a signed-in member's prices are, said once at the top of a hotel's
1981
1999
  * rooms - with the cheapest one's public and member figures, so "member rate"
@@ -2163,11 +2181,11 @@ const memberTripLines = (q) => {
2163
2181
  : []),
2164
2182
  ].filter(Boolean);
2165
2183
  };
2166
- const tripText = (quote, demo) => {
2184
+ const tripText = (quote, demo, noCards) => {
2167
2185
  if (quote.lines.length === 0)
2168
2186
  return 'The trip is empty.';
2169
2187
  const lines = quote.lines.map((l) => l.kind === 'room'
2170
- ? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.hourly ? stayWords(l.stay, l.hourly) : `${stayWords(l.stay)} (${l.nights} ${l.nights === 1 ? 'night' : 'nights'})`} — ${formatMoney(l.total)}`
2188
+ ? `- ${l.quantity ?? 1}× ${withZh(l.title, l.titleNames, noCards)} at ${withZh(l.hotelName, l.hotelNames, noCards)}, ${l.hourly ? stayWords(l.stay, l.hourly) : `${stayWords(l.stay)} (${l.nights} ${l.nights === 1 ? 'night' : 'nights'})`} — ${formatMoney(l.total)}`
2171
2189
  : `- ${l.ordered ?? l.quantity}× ${l.title}${l.ordered !== undefined && l.quantity !== undefined && l.quantity !== l.ordered ? ` (${l.quantity} in all, over the stay)` : ''} — ${formatMoney(l.total)}`);
2172
2190
  return [
2173
2191
  ...lines,
@@ -2390,9 +2408,17 @@ const offerCount = (rooms) => {
2390
2408
  : `${rooms.length} rates on ${types} room ${types === 1 ? 'type' : 'types'}`;
2391
2409
  };
2392
2410
  /** What one room costs, as the text says it: nights, or the hours of a room by the hour. */
2393
- const priceWords = (r) => r.hourly
2411
+ const priceWords = (r, noCards) => r.hourly
2394
2412
  ? `${formatMoney(r.stayTotal)} for ${r.hourly.hours} hours incl. taxes and fees, starting between ${r.hourly.earliestStart} and ${r.hourly.latestStart} today and out by ${r.hourly.latestEnd}`
2395
- : `${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
2413
+ : `${formatMoney(r.nightly)} ${nightWords(noCards)}, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
2414
+ /**
2415
+ * "a night" - and to a client that draws no card, "a night before taxes".
2416
+ *
2417
+ * ⚠️ THE NIGHTLY RATE HAS NO TAX IN IT, the stay total has (2026-11-02 audit,
2418
+ * P3-1): read side by side with "incl. taxes and fees", a model told the guest
2419
+ * "含税费 … 每晚 ¥1,668". The card says 每晚价不含税 under its prices (0.50.1).
2420
+ */
2421
+ const nightWords = (noCards) => (noCards ? 'a night before taxes' : 'a night');
2396
2422
  const LABEL_WORDS = {
2397
2423
  'room-only': 'room only',
2398
2424
  breakfast: 'breakfast',
@@ -2449,9 +2475,9 @@ const roomFactsWords = (r, rate = false) => {
2449
2475
  return ` (${facts})`;
2450
2476
  };
2451
2477
  /** The cheapest price a rate quotes, as the text says it: "from" a night, or the hours of a block. */
2452
- const fromWords = (r) => r.hourly
2478
+ const fromWords = (r, noCards) => r.hourly
2453
2479
  ? `${formatMoney(r.stayTotal)} for ${r.hourly.hours} hours incl. taxes and fees`
2454
- : `${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
2480
+ : `${formatMoney(r.nightly)} ${nightWords(noCards)}, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
2455
2481
  /**
2456
2482
  * A hotel's rooms for the nights, one line per ROOM TYPE.
2457
2483
  *
@@ -2464,7 +2490,13 @@ const fromWords = (r) => r.hourly
2464
2490
  */
2465
2491
  const roomTypeLines = (rooms, roomsWanted,
2466
2492
  /** The terms already applied: what they say is true of every rate here, so it is not counted again. */
2467
- terms) => {
2493
+ terms,
2494
+ /**
2495
+ * To a client that draws no card, each line also says the id the next call
2496
+ * takes and the group's Chinese name (2026-11-02 audit, P1-1, P1-4): the
2497
+ * card is what books a room for a card host.
2498
+ */
2499
+ noCards) => {
2468
2500
  const forRooms = (r) => roomsWanted > 1
2469
2501
  ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)`
2470
2502
  : '';
@@ -2472,7 +2504,9 @@ terms) => {
2472
2504
  const cheapest = type.rooms.reduce((a, b) => (b.stayTotal.minor < a.stayTotal.minor ? b : a));
2473
2505
  const n = type.rooms.length;
2474
2506
  if (n === 1)
2475
- return `- ${cheapest.name}${labelWords(cheapest)}${roomFactsWords(cheapest, true)} — ${priceWords(cheapest)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`;
2507
+ return noCards
2508
+ ? `- ${withZh(cheapest.name, cheapest.names, true)}${labelWords(cheapest)}${roomFactsWords(cheapest, true)}, room \`${cheapest.id}\` — ${priceWords(cheapest, true)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`
2509
+ : `- ${cheapest.name}${labelWords(cheapest)}${roomFactsWords(cheapest, true)} — ${priceWords(cheapest)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`;
2476
2510
  const some = (k, all, part) => k === n ? all : k > 0 ? `${k} of ${n} ${part}` : '';
2477
2511
  const also = [
2478
2512
  terms?.breakfast
@@ -2482,7 +2516,9 @@ terms) => {
2482
2516
  ? ''
2483
2517
  : some(type.rooms.filter((r) => facetsOf(r).freeCancellation).length, 'all are free to cancel', 'are free to cancel'),
2484
2518
  ].filter(Boolean);
2485
- return `- ${type.name}${roomFactsWords(cheapest)} — ${n} rates from ${fromWords(cheapest)}${forRooms(cheapest)}. Cheapest: "${cheapest.name}"${labelWords(cheapest)}, ${cancelWords(cheapest)}.${also.length ? ` ${also.join('; ')}.` : ''}`;
2519
+ return noCards
2520
+ ? `- ${withZh(type.name, cheapest.roomType?.names, true)}${roomFactsWords(cheapest)}, room type \`${type.id}\` — ${n} rates from ${fromWords(cheapest, true)}${forRooms(cheapest)}. Cheapest: "${withZh(cheapest.name, cheapest.names, true)}", room \`${cheapest.id}\`${labelWords(cheapest)}, ${cancelWords(cheapest)}.${also.length ? ` ${also.join('; ')}.` : ''}`
2521
+ : `- ${type.name}${roomFactsWords(cheapest)} — ${n} rates from ${fromWords(cheapest)}${forRooms(cheapest)}. Cheapest: "${cheapest.name}"${labelWords(cheapest)}, ${cancelWords(cheapest)}.${also.length ? ` ${also.join('; ')}.` : ''}`;
2486
2522
  });
2487
2523
  };
2488
2524
  /**
@@ -2495,13 +2531,16 @@ const roomsText = (hotelName,
2495
2531
  /** "3 room types for 2026-11-04 to 2026-11-06, 2 guests" - what the rooms are, counted. */
2496
2532
  counted,
2497
2533
  /** "2026-11-04 to 2026-11-06" - when, for the sentence that says none fit. */
2498
- when, enough, roomsWanted, terms) => {
2534
+ when, enough, roomsWanted, terms, noCards) => {
2499
2535
  // A type with several rates is priced from its cheapest: say how to reach another.
2500
2536
  const lines = (rs) => [
2501
- ...roomTypeLines(rs, roomsWanted, terms),
2537
+ ...roomTypeLines(rs, roomsWanted, terms, noCards),
2502
2538
  ...(byRoomType(rs).some((t) => t.rooms.length > 1)
2503
2539
  ? [
2504
- 'A type with several rates is priced from its cheapest one; bed, breakfast, freeCancellation, window, payment and family narrow to the others (the card lists every rate).',
2540
+ noCards
2541
+ ? // ⚠️ NO CARD LISTS THE OTHERS (2026-11-02 audit, P2-1).
2542
+ 'A type with several rates is priced from its cheapest one; bed, breakfast, freeCancellation, window, payment and family narrow to the others - asked again with them, this answer lists those rates.'
2543
+ : 'A type with several rates is priced from its cheapest one; bed, breakfast, freeCancellation, window, payment and family narrow to the others (the card lists every rate).',
2505
2544
  ]
2506
2545
  : []),
2507
2546
  ];
@@ -2563,14 +2602,36 @@ const paymentWords = (b) => {
2563
2602
  return `${formatMoney(p.refundAmount)} of ${formatMoney(p.amount)} ${p.status === 'refunded' ? 'refunded' : 'going back'} - the rest was the fee`;
2564
2603
  return ((p.method === 'unionpay' ? UNIONPAY_WORDS[p.status] : undefined) ?? PAYMENT_WORDS[p.status]);
2565
2604
  };
2566
- const bookingLine = (b) => {
2605
+ /**
2606
+ * What a booking holds and what cancelling it costs, in a line - to a client
2607
+ * that draws no card, where the words are all the guest has of it.
2608
+ *
2609
+ * ⚠️ A CODE, A HOTEL AND A DATE WERE ALL IT SAID (2026-11-02 audit, P3-3): in
2610
+ * the same conversation a model fills in the room from what came before, but
2611
+ * a booking looked up later by its code had no room and no terms beside it.
2612
+ */
2613
+ const heldRoomsWords = (b) => b.quote.lines
2614
+ .filter((l) => l.kind === 'room')
2615
+ .map((l) => `${(l.quantity ?? 1) > 1 ? `${l.quantity}× ` : ''}${withZh(l.title, l.titleNames, true)}${b.status === 'cancelled' || b.status === 'expired' || b.status === 'failed'
2616
+ ? ''
2617
+ : l.freeCancellationUntil
2618
+ ? `, free to cancel until ${freeUntilText(l)}`
2619
+ : l.refundable === false
2620
+ ? ', non-refundable'
2621
+ : l.refundable
2622
+ ? ', past its free cancellation - cancelling has a fee'
2623
+ : ''}`)
2624
+ .join('; ');
2625
+ const bookingLine = (b, noCards) => {
2567
2626
  const kinds = b.requests?.kinds ?? [];
2627
+ const held = noCards ? heldRoomsWords(b) : '';
2568
2628
  return [
2569
- `${b.confirmationCode} · ${b.hotelName} · ${stayWords(b.stay, b.hourly)} · ${
2629
+ `${b.confirmationCode} · ${withZh(b.hotelName, b.hotelNames, noCards)} · ${stayWords(b.stay, b.hourly)} · ${
2570
2630
  // ⚠️ SAID AS WHAT IT IS - see BookingStatus. Neither booked nor failed.
2571
2631
  b.status === 'unconfirmed'
2572
2632
  ? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
2573
2633
  : b.status}`,
2634
+ held ? ` · ${held}` : '',
2574
2635
  kinds.length ? ` · asked for ${kinds.map(bare).join(', ')}` : '',
2575
2636
  b.quote.coupon
2576
2637
  ? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
@@ -2946,7 +3007,7 @@ const needsRoomIn = (t, lines, parts) => {
2946
3007
  * `store`) the picked room's slot. `key` stays the conversation's trip key: it
2947
3008
  * is the one `common` reads, so a header's trip count never counts a pick.
2948
3009
  */
2949
- const currentTrip = async (deps, key, who, store = key, coupon) => {
3010
+ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
2950
3011
  await refreshMember(deps, key);
2951
3012
  const member = deps.members.read(key);
2952
3013
  const lines = deps.trips.read(store);
@@ -2977,6 +3038,11 @@ const currentTrip = async (deps, key, who, store = key, coupon) => {
2977
3038
  ]
2978
3039
  : [])));
2979
3040
  const overlapping = overlaps.length ? { overlaps } : {};
3041
+ // ⚠️ WHAT COMES NEXT, IN WORDS (2026-11-02 audit, P2-4): the card has a
3042
+ // Book button under the trip, and the words had nothing in its place.
3043
+ const nextStep = ctx?.noCards && parts.some((p) => p.lines.some((l) => l.kind === 'room'))
3044
+ ? `Nothing in the trip is booked yet. To book it, ${deps.tools.requestBooking} takes the guest's own name and email - ask for them if the guest has not typed them in this conversation.`
3045
+ : '';
2980
3046
  if (parts.length === 1 && only) {
2981
3047
  return {
2982
3048
  structured: {
@@ -2987,11 +3053,13 @@ const currentTrip = async (deps, key, who, store = key, coupon) => {
2987
3053
  ...stamp,
2988
3054
  ...overlapping,
2989
3055
  count: lines.length,
3056
+ ...(nextStep ? { nextStep } : {}),
2990
3057
  },
2991
3058
  text: [
2992
- tripText(only, false),
3059
+ tripText(only, false, ctx?.noCards),
2993
3060
  ...overlaps.map((o) => o.said),
2994
3061
  orphans.line,
3062
+ nextStep,
2995
3063
  deps.vocab.demo && lines.length ? noteFor(deps.vocab) : '',
2996
3064
  ]
2997
3065
  .filter(Boolean)
@@ -3031,12 +3099,14 @@ const currentTrip = async (deps, key, who, store = key, coupon) => {
3031
3099
  ...stamp,
3032
3100
  ...overlapping,
3033
3101
  count: lines.length,
3102
+ ...(nextStep ? { nextStep } : {}),
3034
3103
  },
3035
3104
  text: [
3036
3105
  `This trip is in ${parts.length} currencies, priced separately and not converted - each hotel is booked and paid in its own.`,
3037
- ...parts.map((q) => tripText(q, false)),
3106
+ ...parts.map((q) => tripText(q, false, ctx?.noCards)),
3038
3107
  ...overlaps.map((o) => o.said),
3039
3108
  orphans.line,
3109
+ nextStep,
3040
3110
  deps.vocab.demo ? noteFor(deps.vocab) : '',
3041
3111
  ]
3042
3112
  .filter(Boolean)
@@ -3364,20 +3434,42 @@ export const COMPARISON_NOT_DRAWN = "Nothing was drawn: this app shows no cards,
3364
3434
  * the card - so a client that draws none had a count to tell the guest.
3365
3435
  */
3366
3436
  export const LIST_NOT_DRAWN = "Nothing was drawn: this app shows no cards, so the guest has seen none of these hotels. Tell them in your reply - each one's name, area and price as given here:";
3367
- /** One hotel of a list as a line, for a client that shows no cards. */
3437
+ /**
3438
+ * One hotel of a list as a line, for a client that shows no cards.
3439
+ *
3440
+ * ⚠️ WHETHER IT CAN BE BOOKED HERE IS ON THE LINE (2026-11-02 audit, P1-5): a
3441
+ * Huazhu hotel whose only rate tonight is paid in advance was listed as
3442
+ * "¥783.02 for the stay", with the sentence that such rates are not booked
3443
+ * here said once at the top - a model reading the line could tell the guest it
3444
+ * can be booked. Each priced line says how its rate is paid and what it costs
3445
+ * to cancel, as the card's tile does.
3446
+ */
3368
3447
  const listedInWords = (h) => {
3369
3448
  const where = [h.area, h.city]
3370
3449
  .filter((x) => !!x)
3371
3450
  .map((x) => hotelName(x))
3372
3451
  .join(', ');
3373
- const price = h.forStay
3374
- ? `${formatMoney(h.forStay.total)} for the stay incl. taxes and fees, ${hotelName(h.forStay.room)}${h.forStay.rooms > 1 ? ` × ${h.forStay.rooms}` : ''}`
3452
+ const f = h.forStay;
3453
+ const terms = f
3454
+ ? [
3455
+ f.refundable === false ? 'non-refundable' : f.freeCancellation ? 'free to cancel' : '',
3456
+ f.payment === 'prepaid'
3457
+ ? f.notSoldHere
3458
+ ? 'paid in advance - not booked here'
3459
+ : 'paid in advance'
3460
+ : f.payment === 'at-hotel'
3461
+ ? 'paid at the hotel'
3462
+ : '',
3463
+ ].filter(Boolean)
3464
+ : [];
3465
+ const price = f
3466
+ ? `${formatMoney(f.total)} for the stay incl. taxes and fees, ${withZh(f.room, f.roomNames, true)}${f.rooms > 1 ? ` × ${f.rooms}` : ''}${terms.length ? ` [${terms.join(', ')}]` : ''}`
3375
3467
  : h.noRoomsForStay
3376
3468
  ? 'no room for those nights'
3377
3469
  : h.fromNightly
3378
- ? `from ${formatMoney(h.fromNightly)} a night`
3470
+ ? `from ${formatMoney(h.fromNightly)} ${nightWords(true)}`
3379
3471
  : '';
3380
- return `- ${hotelName(h.name)} (\`${h.id}\`)${where ? ` · ${where}` : ''}${price ? `: ${price}` : ''}`;
3472
+ return `- ${withZh(h.name, h.names?.name, true)} (\`${h.id}\`)${where ? ` · ${where}` : ''}${price ? `: ${price}` : ''}`;
3381
3473
  };
3382
3474
  /**
3383
3475
  * One column of a comparison as a line, for a client that shows no cards:
@@ -3399,7 +3491,7 @@ const comparedInWords = (c) => {
3399
3491
  const p = c.price;
3400
3492
  const priced = p
3401
3493
  ? [
3402
- `${hotelName(p.room)} at ${p.nightly} a night, ${p.stayTotal} for the stay incl. taxes and fees`,
3494
+ `${withZh(p.room, p.roomNames, true)} at ${p.nightly} ${nightWords(true)}, ${p.stayTotal} for the stay incl. taxes and fees`,
3403
3495
  p.freeCancellationUntil
3404
3496
  ? `free to cancel until ${freeUntilText(p)}`
3405
3497
  : p.refundable
@@ -3417,7 +3509,7 @@ const comparedInWords = (c) => {
3417
3509
  : c.from
3418
3510
  ? `from ${c.from} a night; rooms are priced once there are dates`
3419
3511
  : 'not priced without dates';
3420
- return `- ${hotelName(h.name)} (\`${h.id}\`)${about ? ` · ${about}` : ''}: ${priced}`;
3512
+ return `- ${withZh(h.name, h.names?.name, true)} (\`${h.id}\`)${about ? ` · ${about}` : ''}: ${priced}`;
3421
3513
  };
3422
3514
  /** An area's names as a day might say them: "Haidian", "海淀区" and "海淀". */
3423
3515
  const areaWords = (h) => {
@@ -3709,7 +3801,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3709
3801
  : !!asked && l.stay.checkIn === asked.checkIn && l.stay.checkOut === asked.checkOut));
3710
3802
  if (held) {
3711
3803
  deps.trips.set(store, unset(held));
3712
- return currentTrip(deps, key, who, store, couponOf(deps, args));
3804
+ return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
3713
3805
  }
3714
3806
  }
3715
3807
  const today = (startTime || !asStay(args)
@@ -3769,7 +3861,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3769
3861
  hours: hourlyRoom.hourly.hours,
3770
3862
  },
3771
3863
  });
3772
- return currentTrip(deps, key, who, store, couponOf(deps, args));
3864
+ return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
3773
3865
  }
3774
3866
  const stay = asStay(args);
3775
3867
  // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
@@ -3915,7 +4007,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3915
4007
  stay,
3916
4008
  rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3917
4009
  });
3918
- const now = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
4010
+ const now = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args), ctx);
3919
4011
  // Which rate was taken, in the structure too and in the same words - see
3920
4012
  // the itinerary's `nudges` (2026-10-27 audit, F11).
3921
4013
  return took
@@ -4209,7 +4301,8 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4209
4301
  ? `${booked.length} bookings, one for each stay - each hotel keeps its own nights under its own code, and each is ${holderOf(deps, first.booking.supplier).caps.changes ? 'changed or cancelled' : 'cancelled'} on its own.`
4210
4302
  : '',
4211
4303
  ...booked.flatMap(({ booking, asked }) => [
4212
- `**${confirming(deps, booking) ? 'Being confirmed' : booking.confirmationCode}** · ${booking.hotelName} · ${stayWords(booking.stay, booking.hourly)}`,
4304
+ `**${confirming(deps, booking) ? 'Being confirmed' : booking.confirmationCode}** · ${withZh(booking.hotelName, booking.hotelNames, ctx.noCards)} · ${stayWords(booking.stay, booking.hourly)}`,
4305
+ ctx.noCards ? heldRoomsWords(booking) : '',
4213
4306
  bookedLine(deps, booking),
4214
4307
  ...asked,
4215
4308
  ]),
@@ -4509,7 +4602,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4509
4602
  };
4510
4603
  const priced = await answerTool(deps, t.listHotels, { ...args, ...tonight }, ctx);
4511
4604
  // In both, in the same words: the model reads the structure.
4512
- const stayDefault = `No dates were asked for, so this opens on tonight by default: ${tonight.checkIn} to ${tonight.checkOut} (one night, on the hotels' own date), ${tonight.guests} ${tonight.guests === 1 ? 'guest' : 'guests'}, one room - every price here is for that night. Say so: the prices shown are tonight's, and the guest can change the dates on the card or name others. Do not ask for dates before showing them.`;
4605
+ const stayDefault = `No dates were asked for, so this opens on tonight by default: ${tonight.checkIn} to ${tonight.checkOut} (one night, on the hotels' own date), ${tonight.guests} ${tonight.guests === 1 ? 'guest' : 'guests'}, one room - every price here is for that night. Say so: the prices shown are tonight's, and the guest can ${ctx.noCards ? 'name other dates' : 'change the dates on the card or name others'}. Do not ask for dates before showing them.`;
4513
4606
  // Right after what is here - "every one, and no others" stays the first thing said.
4514
4607
  const inAll = String(priced.structured.inAll ?? '');
4515
4608
  return {
@@ -4570,6 +4663,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4570
4663
  // "the most are in" three of forty cities left the other thirty-seven
4571
4664
  // for a guest to guess at.
4572
4665
  const every = (xs) => xs.map((x) => `${x.name} (${x.count})`).join(', ');
4666
+ // ⚠️ AND TO A CLIENT THAT DRAWS NO CARD, EVERY CITY (2026-11-02 audit,
4667
+ // P3-2): told to name the places, a model had "China (54)" and nothing
4668
+ // to name. A country of one city named like it says no more.
4669
+ const everyWithCities = (xs) => xs
4670
+ .map((c) => c.cities.length > 1 || (c.cities[0] && c.cities[0].name !== c.name)
4671
+ ? `${c.name} (${c.count}: ${c.cities.map((x) => `${x.name} ${x.count}`).join(', ')})`
4672
+ : `${c.name} (${c.count})`)
4673
+ .join(', ');
4573
4674
  const n = deps.vocab.nounPlural;
4574
4675
  if (namedCountry) {
4575
4676
  const [only] = countries;
@@ -4607,7 +4708,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4607
4708
  ...(ask.stay ? { stay: ask.stay } : {}),
4608
4709
  ...(today ? { today } : {}),
4609
4710
  },
4610
- text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} ${countries.length === 1 ? 'country' : 'countries'}, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${every(r.countries)}`).join('; ')}. A city or a country lists its ${n}. ${forPlans}`,
4711
+ text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} ${countries.length === 1 ? 'country' : 'countries'}, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${ctx.noCards ? everyWithCities(r.countries) : every(r.countries)}`).join('; ')}. A city or a country lists its ${n}. ${forPlans}`,
4611
4712
  };
4612
4713
  }
4613
4714
  if (hotels.length === 0) {
@@ -4877,7 +4978,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4877
4978
  activeFilters: activeKeysOf(roomTermsOf(args)),
4878
4979
  },
4879
4980
  text: [
4880
- `**${h.name}** · ${[h.area, h.city].filter(Boolean).join(', ')}`,
4981
+ `**${ctx.noCards ? withZh(h.name, h.names?.name, true) : h.name}** · ${[h.area, h.city].filter(Boolean).join(', ')}`,
4881
4982
  description.fenced,
4882
4983
  'Rooms and prices need the dates - which nights is the guest staying?',
4883
4984
  ]
@@ -4990,9 +5091,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4990
5091
  },
4991
5092
  text: [
4992
5093
  memberRates?.said ?? '',
4993
- ...roomsText(h.name, byTheHour
5094
+ ...roomsText(ctx.noCards ? withZh(h.name, h.names?.name, true) : h.name, byTheHour
4994
5095
  ? (rs) => `${rs.length} ${rs.length === 1 ? 'room' : 'rooms'} by the hour for today, ${stay.checkIn}, ${stay.guests} guests`
4995
- : (rs) => `${offerCount(rs)} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}`, byTheHour ? `today, ${stay.checkIn}` : `${stay.checkIn} to ${stay.checkOut}`, enough, roomsWanted, terms),
5096
+ : (rs) => `${offerCount(rs)} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}`, byTheHour ? `today, ${stay.checkIn}` : `${stay.checkIn} to ${stay.checkOut}`, enough, roomsWanted, terms, ctx.noCards),
4996
5097
  // The other arrivals are priced on the cheapest room of any kind.
4997
5098
  terms && flex.text
4998
5099
  ? `${flex.text} (That is the cheapest room of any kind, not only one with ${termWords(terms)}.)`
@@ -5079,7 +5180,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5079
5180
  const extras = await deps.backend.extrasFor(h.id, stay);
5080
5181
  const rooms = (detail.rooms ?? []).filter((r) => r.roomsLeft >= roomsWanted);
5081
5182
  blocks.push([
5082
- `### ${hotelName(h.name)} · id \`${h.id}\``,
5183
+ `### ${withZh(h.name, h.names?.name, ctx.noCards)} · id \`${h.id}\``,
5083
5184
  [
5084
5185
  [h.area, h.city, h.country].filter(Boolean).join(', '),
5085
5186
  h.brand,
@@ -5090,12 +5191,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5090
5191
  about.fenced,
5091
5192
  !stay
5092
5193
  ? h.fromNightly
5093
- ? `From ${formatMoney(h.fromNightly)} a night; rooms are priced once there are dates.`
5194
+ ? `From ${formatMoney(h.fromNightly)} ${nightWords(ctx.noCards)}; rooms are priced once there are dates.`
5094
5195
  : ''
5095
5196
  : rooms.length
5096
5197
  ? [
5097
5198
  `Rooms for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}:`,
5098
- ...rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`),
5199
+ // ⚠️ ONE LINE A ROOM TYPE, WITH ITS ID, to a client that
5200
+ // draws no card (2026-11-02 audit, P1-4, P3-4): every rate of
5201
+ // four Atour hotels was 16,213 characters, and not one room
5202
+ // id - which is what the trip takes. As the hotel's own
5203
+ // answer says them.
5204
+ ...(ctx.noCards
5205
+ ? roomTypeLines(rooms, roomsWanted, undefined, true)
5206
+ : rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.freeCancellationUntil ? `, free to cancel until ${freeUntilText(r)}` : r.refundable ? '' : ', non-refundable'}`)),
5099
5207
  ].join('\n')
5100
5208
  : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
5101
5209
  extras.length
@@ -5127,10 +5235,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5127
5235
  // "Draw the answer now as ONE card", an instruction in a tool result.
5128
5236
  // The rule itself lives in the server instructions; what stays here is
5129
5237
  // what each next call would draw, which is what kept it to one card.
5130
- `These ids are what ${t.itinerary} (a plan or days - 行程, recommended or arranged) and ${t.compare} (which ${deps.vocab.noun} to stay at) take; each of those draws one card. ${listAnd(
5238
+ `These ids are what ${t.itinerary} (a plan or days - 行程, recommended or arranged) and ${t.compare} (which ${deps.vocab.noun} to stay at) take${ctx.noCards ? '' : '; each of those draws one card'}. ${listAnd(
5131
5239
  // Only tools this deployment offers: a name it withholds is one the
5132
5240
  // model would try (2026-10-05 audit, P2 - Huazhu has no add-ons).
5133
- [t.listHotels, t.getHotel, t.listExtras].filter((n) => !deps.offered || deps.offered.has(n)))} would each draw a card of their own.`,
5241
+ [t.listHotels, t.getHotel, t.listExtras].filter((n) => !deps.offered || deps.offered.has(n)))} ${
5242
+ // ⚠️ NOTHING IS DRAWN WHERE NO CARD IS (2026-11-02 audit, P2-1).
5243
+ ctx.noCards
5244
+ ? 'answer with what the guest is told, for a guest who asked to see a list, a hotel or its add-ons.'
5245
+ : 'would each draw a card of their own.'}`,
5134
5246
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5135
5247
  ]
5136
5248
  .filter(Boolean)
@@ -5214,7 +5326,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5214
5326
  },
5215
5327
  text: extras.length
5216
5328
  ? extras
5217
- .map((e) => `- ${e.name} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
5329
+ .map((e) => `- ${e.name}${ctx.noCards ? ` (\`${e.id}\`)` : ''} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
5218
5330
  .concat(deps.vocab.demo ? [noteFor(deps.vocab)] : [])
5219
5331
  .join('\n')
5220
5332
  : `${detail.hotel.name} has no add-ons listed here.`,
@@ -5251,7 +5363,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5251
5363
  ...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
5252
5364
  };
5253
5365
  deps.trips.set(key, line);
5254
- return currentTrip(deps, key, identity(deps, ctx, key));
5366
+ return currentTrip(deps, key, identity(deps, ctx, key), key, undefined, ctx);
5255
5367
  }
5256
5368
  return putRoomLine(deps, t, ctx, key, key, args, detail);
5257
5369
  }
@@ -5273,7 +5385,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5273
5385
  return direct(await putRoomLine(deps, t, ctx, key, slot, args, found.detail));
5274
5386
  }
5275
5387
  if (name === t.viewTrip)
5276
- return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args));
5388
+ return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args), ctx);
5277
5389
  if (name === t.clearTrip) {
5278
5390
  deps.trips.clear(key);
5279
5391
  return {
@@ -5327,7 +5439,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5327
5439
  text: [
5328
5440
  missed ? missLine : '',
5329
5441
  bookings.length
5330
- ? bookings.map(bookingLine).join('\n')
5442
+ ? bookings.map((b) => bookingLine(b, ctx.noCards)).join('\n')
5331
5443
  : code
5332
5444
  ? ''
5333
5445
  : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
@@ -5890,8 +6002,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5890
6002
  .flatMap((day) => day.items)
5891
6003
  .filter((x) => x.kind === 'hotel' && (x.night ?? 1) === 1 && x.checkIn && x.roomId);
5892
6004
  // Each stay's rate says its terms where they bind - see `ItineraryItem.refundable`.
6005
+ // With no card, until when it is free to cancel too: the reply is told to
6006
+ // give each stay's terms (2026-11-02 audit, P2-3).
5893
6007
  const termsOf = (x) => [
5894
- x.refundable === false ? 'non-refundable' : '',
6008
+ x.refundable === false
6009
+ ? 'non-refundable'
6010
+ : ctx.noCards && x.freeCancellationUntil
6011
+ ? `free to cancel until ${freeUntilText(x)}`
6012
+ : '',
5895
6013
  x.payment === 'prepaid'
5896
6014
  ? x.notSoldHere
5897
6015
  ? 'paid in advance - not booked here'
@@ -5902,7 +6020,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5902
6020
  .join(', ');
5903
6021
  // With no card, each stay's own price too: the card shows it beside the
5904
6022
  // stay, and the reply is now the only place the guest reads it.
5905
- const stays = stayItems.map((x, i) => `Stay ${i + 1}: ${hotelName(x.name)} (\`${x.id}\`), room \`${x.roomId}\`${x.roomName ? ` (${hotelName(x.roomName)}${termsOf(x) ? `; ${termsOf(x)}` : ''})` : termsOf(x) ? ` (${termsOf(x)})` : ''}, ${x.checkIn} to ${x.checkOut}${x.nights ? ` (${x.nights} ${x.nights === 1 ? 'night' : 'nights'})` : ''}${ctx.noCards && x.stayTotal ? `, ${x.stayTotal} for the stay incl. taxes and fees` : ''}${x.booked ? ` - booked, ${x.booked}` : x.inTrip ? ' - in the trip' : ''}`);
6023
+ const stays = stayItems.map((x, i) => `Stay ${i + 1}: ${withZh(x.name, x.names?.name, ctx.noCards)} (\`${x.id}\`), room \`${x.roomId}\`${x.roomName ? ` (${withZh(x.roomName, x.roomNames, ctx.noCards)}${termsOf(x) ? `; ${termsOf(x)}` : ''})` : termsOf(x) ? ` (${termsOf(x)})` : ''}, ${x.checkIn} to ${x.checkOut}${x.nights ? ` (${x.nights} ${x.nights === 1 ? 'night' : 'nights'})` : ''}${ctx.noCards && x.stayTotal ? `, ${x.stayTotal} for the stay incl. taxes and fees` : ''}${x.booked ? ` - booked, ${x.booked}` : x.inTrip ? ' - in the trip' : ''}`);
5906
6024
  // ⚠️ IN BOTH, IN THE SAME WORDS (2026-10-27 audit, F11): a host that hands
5907
6025
  // the model `structuredContent` rather than the text never saw what the
5908
6026
  // text said of a long stay. The model's part of the answer is the same on