@curless/hotel-storefront 0.51.5 → 0.52.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 (49) hide show
  1. package/dist/aggregate/hotels.d.ts +18 -1
  2. package/dist/aggregate/hotels.d.ts.map +1 -1
  3. package/dist/aggregate/hotels.js +38 -7
  4. package/dist/aggregate/hotels.js.map +1 -1
  5. package/dist/aggregate/make.d.ts.map +1 -1
  6. package/dist/aggregate/make.js +2 -0
  7. package/dist/aggregate/make.js.map +1 -1
  8. package/dist/backend.d.ts +26 -0
  9. package/dist/backend.d.ts.map +1 -1
  10. package/dist/backend.js.map +1 -1
  11. package/dist/card.generated.d.ts +1 -1
  12. package/dist/card.generated.js +2 -2
  13. package/dist/card.generated.js.map +1 -1
  14. package/dist/config.d.ts +27 -1
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +43 -0
  17. package/dist/config.js.map +1 -1
  18. package/dist/money.d.ts +1 -0
  19. package/dist/money.d.ts.map +1 -1
  20. package/dist/money.js +1 -1
  21. package/dist/money.js.map +1 -1
  22. package/dist/present.d.ts +8 -0
  23. package/dist/present.d.ts.map +1 -1
  24. package/dist/present.js +5 -0
  25. package/dist/present.js.map +1 -1
  26. package/dist/reference.d.ts +34 -0
  27. package/dist/reference.d.ts.map +1 -0
  28. package/dist/reference.js +117 -0
  29. package/dist/reference.js.map +1 -0
  30. package/dist/server.d.ts +2 -2
  31. package/dist/server.js +2 -2
  32. package/dist/supplier.d.ts.map +1 -1
  33. package/dist/supplier.js +2 -1
  34. package/dist/supplier.js.map +1 -1
  35. package/dist/surface.d.ts +7 -5
  36. package/dist/surface.d.ts.map +1 -1
  37. package/dist/surface.js +39 -13
  38. package/dist/surface.js.map +1 -1
  39. package/dist/tools.d.ts +9 -9
  40. package/dist/tools.d.ts.map +1 -1
  41. package/dist/tools.js +256 -56
  42. package/dist/tools.js.map +1 -1
  43. package/dist/types.d.ts +15 -0
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/vocab.d.ts +1 -1
  46. package/dist/vocab.d.ts.map +1 -1
  47. package/dist/vocab.js +18 -3
  48. package/dist/vocab.js.map +1 -1
  49. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -7,6 +7,7 @@ import { amountsIn, formatMoney, keepingAmounts, money, multiplyMoney, toMinor }
7
7
  import { namedExactly, roomNamesOf, roomTypeNamed, withTraditional } from './names.js';
8
8
  import { overlapsOf } from './overlap.js';
9
9
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, candidatesFor, compareHotels, namesMoreThanOne, } from './present.js';
10
+ import { withReference } from './reference.js';
10
11
  import { regionOf } from './regions.js';
11
12
  import { activeKeysOf, byRoomType, facetsOf, keyWords, matchesTerms, optionsOf, roomTermsOf, termWords, } from './room-filters.js';
12
13
  import { SAME_BOOKING_MS } from './same-booking.js';
@@ -167,7 +168,7 @@ const forCapabilities = (tools, n, v, caps) => {
167
168
  if (t.name === n.viewTrip) {
168
169
  out = {
169
170
  ...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.',
171
+ 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
172
  };
172
173
  }
173
174
  if (t.name === n.lookUp) {
@@ -362,7 +363,7 @@ const allTools = (n, v) => {
362
363
  // description says it too, as what the card is, never as an order: the
363
364
  // directory rule (tool-descriptions.test.ts) holds, and "first" is one of
364
365
  // 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}` : ''}`,
366
+ 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
367
  annotations: reads(`Browse ${catalogue(v)}`, open),
367
368
  inputSchema: {
368
369
  type: 'object',
@@ -441,7 +442,7 @@ const allTools = (n, v) => {
441
442
  },
442
443
  {
443
444
  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}` : ''}`,
445
+ 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
446
  annotations: reads(`View one ${catalogue(v, true)}`, open),
446
447
  inputSchema: {
447
448
  type: 'object',
@@ -521,7 +522,7 @@ const allTools = (n, v) => {
521
522
  },
522
523
  {
523
524
  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.`,
525
+ 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
526
  annotations: reads(v.market ? `Add-ons on ${v.brand}` : `${v.brand} add-ons`, open),
526
527
  inputSchema: {
527
528
  type: 'object',
@@ -574,7 +575,7 @@ const allTools = (n, v) => {
574
575
  },
575
576
  {
576
577
  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.',
578
+ 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
579
  annotations: reads(`View the ${v.brand} trip`, open),
579
580
  inputSchema: { type: 'object', properties: {} },
580
581
  },
@@ -771,7 +772,7 @@ const allTools = (n, v) => {
771
772
  // prose; every figure on the card is read off the hotel's own record. A
772
773
  // model that could send a price could one day send the wrong one, onto a
773
774
  // 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.`,
775
+ 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
776
  annotations: reads(`Compare ${catalogue(v)}`, open),
776
777
  inputSchema: {
777
778
  type: 'object',
@@ -796,7 +797,7 @@ const allTools = (n, v) => {
796
797
  },
797
798
  },
798
799
  recommended: { type: 'string', description: `The one ${v.noun} to mark as the pick` },
799
- title: { type: 'string', description: 'A heading for the card' },
800
+ title: { type: 'string', description: 'A heading for the plan' },
800
801
  ...stayProps,
801
802
  },
802
803
  required: ['entries'],
@@ -804,7 +805,7 @@ const allTools = (n, v) => {
804
805
  },
805
806
  {
806
807
  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.`,
808
+ 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
809
  annotations: reads(v.market ? `Lay out an itinerary on ${v.brand}` : `Lay out a ${v.brand} itinerary`, open),
809
810
  inputSchema: {
810
811
  type: 'object',
@@ -819,7 +820,7 @@ const allTools = (n, v) => {
819
820
  properties: {
820
821
  label: {
821
822
  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`,
823
+ description: `e.g. "Arrive${places[0] ? ` in ${places[0]}` : ''}" - each day's date is printed beside it, so leave the date out`,
823
824
  },
824
825
  note: {
825
826
  type: 'string',
@@ -827,7 +828,7 @@ const allTools = (n, v) => {
827
828
  },
828
829
  why: {
829
830
  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`,
831
+ 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
832
  },
832
833
  items: {
833
834
  type: 'array',
@@ -1337,6 +1338,10 @@ export const buildOutputSchemas = (n) => ({
1337
1338
  stayDefault: STR,
1338
1339
  /** Only where the client shows no cards: nothing was drawn - see `LIST_NOT_DRAWN`. */
1339
1340
  notDrawn: STR,
1341
+ /** In a market: the groups that did not answer in time - none of their hotels is in this answer (`degradedOf`). */
1342
+ degraded: ARR,
1343
+ /** What `degraded` means, as the text says it: not that those groups have none here. */
1344
+ degradedSaid: STR,
1340
1345
  ...HOTEL_PAGE,
1341
1346
  },
1342
1347
  required: ['view'],
@@ -1535,6 +1540,9 @@ export const buildOutputSchemas = (n) => ({
1535
1540
  dropped: STRS,
1536
1541
  /** For a name that fits more than one hotel, the ones it fits - each with \`for\`, the name. */
1537
1542
  candidates: ARR,
1543
+ /** A \`city\` read in a market: the groups that did not answer in time (\`degradedOf\`). */
1544
+ degraded: ARR,
1545
+ degradedSaid: STR,
1538
1546
  },
1539
1547
  required: ['view'],
1540
1548
  },
@@ -1976,6 +1984,24 @@ const langOf = (deps, _key) => deps.lang;
1976
1984
  */
1977
1985
  const freeUntilText = (r) => r.freeCancellationLocal ??
1978
1986
  `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
1987
+ /** Any Han character: a name already in Chinese needs none beside it. */
1988
+ const HAN = /\p{Script=Han}/u;
1989
+ /**
1990
+ * A name as the words to a client that draws no card say it: the plain name,
1991
+ * then the group's own Chinese name in () where it has one the plain name is
1992
+ * not.
1993
+ *
1994
+ * ⚠️ THE GROUP'S NAME, NOT THE MODEL'S (2026-11-02 audit, P1-1). The Chinese
1995
+ * names were only in the structure, and a model that is handed the text alone
1996
+ * told a Chinese guest 「浦东凯里酒店」 for 上海浦东嘉里大酒店 - a name nobody
1997
+ * can find the hotel by. A card host is told the plain name as ever: its card
1998
+ * says the Chinese.
1999
+ */
2000
+ const withZh = (name, names, noCards) => {
2001
+ const plain = hotelName(name);
2002
+ const zh = noCards && names?.zh ? hotelName(names.zh) : '';
2003
+ return zh && zh !== plain && !HAN.test(plain) ? `${plain}(${zh})` : plain;
2004
+ };
1979
2005
  /**
1980
2006
  * What a signed-in member's prices are, said once at the top of a hotel's
1981
2007
  * rooms - with the cheapest one's public and member figures, so "member rate"
@@ -2163,11 +2189,11 @@ const memberTripLines = (q) => {
2163
2189
  : []),
2164
2190
  ].filter(Boolean);
2165
2191
  };
2166
- const tripText = (quote, demo) => {
2192
+ const tripText = (quote, demo, noCards) => {
2167
2193
  if (quote.lines.length === 0)
2168
2194
  return 'The trip is empty.';
2169
2195
  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)}`
2196
+ ? `- ${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
2197
  : `- ${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
2198
  return [
2173
2199
  ...lines,
@@ -2390,9 +2416,17 @@ const offerCount = (rooms) => {
2390
2416
  : `${rooms.length} rates on ${types} room ${types === 1 ? 'type' : 'types'}`;
2391
2417
  };
2392
2418
  /** 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
2419
+ const priceWords = (r, noCards) => r.hourly
2394
2420
  ? `${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`;
2421
+ : `${formatMoney(r.nightly)} ${nightWords(noCards)}, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
2422
+ /**
2423
+ * "a night" - and to a client that draws no card, "a night before taxes".
2424
+ *
2425
+ * ⚠️ THE NIGHTLY RATE HAS NO TAX IN IT, the stay total has (2026-11-02 audit,
2426
+ * P3-1): read side by side with "incl. taxes and fees", a model told the guest
2427
+ * "含税费 … 每晚 ¥1,668". The card says 每晚价不含税 under its prices (0.50.1).
2428
+ */
2429
+ const nightWords = (noCards) => (noCards ? 'a night before taxes' : 'a night');
2396
2430
  const LABEL_WORDS = {
2397
2431
  'room-only': 'room only',
2398
2432
  breakfast: 'breakfast',
@@ -2449,9 +2483,9 @@ const roomFactsWords = (r, rate = false) => {
2449
2483
  return ` (${facts})`;
2450
2484
  };
2451
2485
  /** 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
2486
+ const fromWords = (r, noCards) => r.hourly
2453
2487
  ? `${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`;
2488
+ : `${formatMoney(r.nightly)} ${nightWords(noCards)}, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees`;
2455
2489
  /**
2456
2490
  * A hotel's rooms for the nights, one line per ROOM TYPE.
2457
2491
  *
@@ -2464,7 +2498,13 @@ const fromWords = (r) => r.hourly
2464
2498
  */
2465
2499
  const roomTypeLines = (rooms, roomsWanted,
2466
2500
  /** The terms already applied: what they say is true of every rate here, so it is not counted again. */
2467
- terms) => {
2501
+ terms,
2502
+ /**
2503
+ * To a client that draws no card, each line also says the id the next call
2504
+ * takes and the group's Chinese name (2026-11-02 audit, P1-1, P1-4): the
2505
+ * card is what books a room for a card host.
2506
+ */
2507
+ noCards) => {
2468
2508
  const forRooms = (r) => roomsWanted > 1
2469
2509
  ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)`
2470
2510
  : '';
@@ -2472,7 +2512,9 @@ terms) => {
2472
2512
  const cheapest = type.rooms.reduce((a, b) => (b.stayTotal.minor < a.stayTotal.minor ? b : a));
2473
2513
  const n = type.rooms.length;
2474
2514
  if (n === 1)
2475
- return `- ${cheapest.name}${labelWords(cheapest)}${roomFactsWords(cheapest, true)} — ${priceWords(cheapest)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`;
2515
+ return noCards
2516
+ ? `- ${withZh(cheapest.name, cheapest.names, true)}${labelWords(cheapest)}${roomFactsWords(cheapest, true)}, room \`${cheapest.id}\` — ${priceWords(cheapest, true)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`
2517
+ : `- ${cheapest.name}${labelWords(cheapest)}${roomFactsWords(cheapest, true)} — ${priceWords(cheapest)}${forRooms(cheapest)}, ${cancelWords(cheapest)}`;
2476
2518
  const some = (k, all, part) => k === n ? all : k > 0 ? `${k} of ${n} ${part}` : '';
2477
2519
  const also = [
2478
2520
  terms?.breakfast
@@ -2482,7 +2524,9 @@ terms) => {
2482
2524
  ? ''
2483
2525
  : some(type.rooms.filter((r) => facetsOf(r).freeCancellation).length, 'all are free to cancel', 'are free to cancel'),
2484
2526
  ].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('; ')}.` : ''}`;
2527
+ return noCards
2528
+ ? `- ${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('; ')}.` : ''}`
2529
+ : `- ${type.name}${roomFactsWords(cheapest)} — ${n} rates from ${fromWords(cheapest)}${forRooms(cheapest)}. Cheapest: "${cheapest.name}"${labelWords(cheapest)}, ${cancelWords(cheapest)}.${also.length ? ` ${also.join('; ')}.` : ''}`;
2486
2530
  });
2487
2531
  };
2488
2532
  /**
@@ -2495,13 +2539,16 @@ const roomsText = (hotelName,
2495
2539
  /** "3 room types for 2026-11-04 to 2026-11-06, 2 guests" - what the rooms are, counted. */
2496
2540
  counted,
2497
2541
  /** "2026-11-04 to 2026-11-06" - when, for the sentence that says none fit. */
2498
- when, enough, roomsWanted, terms) => {
2542
+ when, enough, roomsWanted, terms, noCards) => {
2499
2543
  // A type with several rates is priced from its cheapest: say how to reach another.
2500
2544
  const lines = (rs) => [
2501
- ...roomTypeLines(rs, roomsWanted, terms),
2545
+ ...roomTypeLines(rs, roomsWanted, terms, noCards),
2502
2546
  ...(byRoomType(rs).some((t) => t.rooms.length > 1)
2503
2547
  ? [
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).',
2548
+ noCards
2549
+ ? // ⚠️ NO CARD LISTS THE OTHERS (2026-11-02 audit, P2-1).
2550
+ '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.'
2551
+ : '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
2552
  ]
2506
2553
  : []),
2507
2554
  ];
@@ -2563,14 +2610,36 @@ const paymentWords = (b) => {
2563
2610
  return `${formatMoney(p.refundAmount)} of ${formatMoney(p.amount)} ${p.status === 'refunded' ? 'refunded' : 'going back'} - the rest was the fee`;
2564
2611
  return ((p.method === 'unionpay' ? UNIONPAY_WORDS[p.status] : undefined) ?? PAYMENT_WORDS[p.status]);
2565
2612
  };
2566
- const bookingLine = (b) => {
2613
+ /**
2614
+ * What a booking holds and what cancelling it costs, in a line - to a client
2615
+ * that draws no card, where the words are all the guest has of it.
2616
+ *
2617
+ * ⚠️ A CODE, A HOTEL AND A DATE WERE ALL IT SAID (2026-11-02 audit, P3-3): in
2618
+ * the same conversation a model fills in the room from what came before, but
2619
+ * a booking looked up later by its code had no room and no terms beside it.
2620
+ */
2621
+ const heldRoomsWords = (b) => b.quote.lines
2622
+ .filter((l) => l.kind === 'room')
2623
+ .map((l) => `${(l.quantity ?? 1) > 1 ? `${l.quantity}× ` : ''}${withZh(l.title, l.titleNames, true)}${b.status === 'cancelled' || b.status === 'expired' || b.status === 'failed'
2624
+ ? ''
2625
+ : l.freeCancellationUntil
2626
+ ? `, free to cancel until ${freeUntilText(l)}`
2627
+ : l.refundable === false
2628
+ ? ', non-refundable'
2629
+ : l.refundable
2630
+ ? ', past its free cancellation - cancelling has a fee'
2631
+ : ''}`)
2632
+ .join('; ');
2633
+ const bookingLine = (b, noCards) => {
2567
2634
  const kinds = b.requests?.kinds ?? [];
2635
+ const held = noCards ? heldRoomsWords(b) : '';
2568
2636
  return [
2569
- `${b.confirmationCode} · ${b.hotelName} · ${stayWords(b.stay, b.hourly)} · ${
2637
+ `${b.confirmationCode} · ${withZh(b.hotelName, b.hotelNames, noCards)} · ${stayWords(b.stay, b.hourly)} · ${
2570
2638
  // ⚠️ SAID AS WHAT IT IS - see BookingStatus. Neither booked nor failed.
2571
2639
  b.status === 'unconfirmed'
2572
2640
  ? 'unconfirmed: whether it was made is not known yet, and a person is confirming it'
2573
2641
  : b.status}`,
2642
+ held ? ` · ${held}` : '',
2574
2643
  kinds.length ? ` · asked for ${kinds.map(bare).join(', ')}` : '',
2575
2644
  b.quote.coupon
2576
2645
  ? ` · coupon ${couponName(b.quote.coupon)} −${formatMoney(b.quote.coupon.off)}${b.couponReturned === true
@@ -2772,6 +2841,30 @@ const remember = (deps, k, amount) => {
2772
2841
  };
2773
2842
  /** "a, b and c" - "a and b" - "a". */
2774
2843
  const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}` : (xs[0] ?? '');
2844
+ /**
2845
+ * The groups of a market that did not answer a search, as the sentence the
2846
+ * model is told and the fields the card draws its own line from - the same
2847
+ * list in both (`HotelSearchResult.degraded`).
2848
+ *
2849
+ * ⚠️ "DID NOT ANSWER" IS NOT "HAS NONE". A list without Atour's hotels, from
2850
+ * an Atour that timed out, reads to a guest as a city where Atour has no
2851
+ * hotel. The sentence says the first and denies the second, in the text and in
2852
+ * `structuredContent` alike - a host with no card has only the words.
2853
+ */
2854
+ const degradedOf = (deps, answer) => {
2855
+ const down = answer.degraded ?? [];
2856
+ if (down.length === 0)
2857
+ return { structured: {}, said: '' };
2858
+ const one = down.length === 1;
2859
+ const said = `${listAnd(down.map((d) => d.name))} did not answer in time, so none of ${one ? 'its' : 'their'} ${deps.vocab.nounPlural} is in this list - this does not mean ${one ? 'it has' : 'they have'} none here; asking again in a moment may bring them.`;
2860
+ return {
2861
+ structured: {
2862
+ degraded: down.map((d) => ({ supplier: d.supplier, name: d.name })),
2863
+ degradedSaid: said,
2864
+ },
2865
+ said,
2866
+ };
2867
+ };
2775
2868
  /** The counts a call can carry, and what each may be. */
2776
2869
  const COUNTS = ['guests', 'rooms', 'quantity'];
2777
2870
  /** "2" is 2: a host that sends a count as a string has not asked for something else. */
@@ -2941,12 +3034,31 @@ const needsRoomIn = (t, lines, parts) => {
2941
3034
  .join('\n'),
2942
3035
  };
2943
3036
  };
3037
+ /**
3038
+ * What a booking of these stays takes of the guest, in the words of the
3039
+ * sentence that asks for it: "name and email", and a phone number where a group
3040
+ * needs one - for all of them, or, in a market where only some of the trip's
3041
+ * groups do, for the stays with those.
3042
+ */
3043
+ const guestDetailsFor = (deps, rooms) => {
3044
+ if (!deps.backend.suppliers)
3045
+ return (deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone
3046
+ ? 'name, email and phone number'
3047
+ : 'name and email';
3048
+ const groups = [...new Set(rooms.map((l) => deps.backend.supplierOf?.(l.hotelId)))];
3049
+ const needing = groups.filter((id) => !!holderOf(deps, id).caps.requiresPhone);
3050
+ if (needing.length === 0)
3051
+ return 'name and email';
3052
+ if (needing.length === groups.length)
3053
+ return 'name, email and phone number';
3054
+ return `name and email, and a phone number for the stays with ${listAnd(needing.map((id) => holderOf(deps, id).name))}`;
3055
+ };
2944
3056
  /**
2945
3057
  * The lines in one slot of the trip store, priced - the trip itself, or (with
2946
3058
  * `store`) the picked room's slot. `key` stays the conversation's trip key: it
2947
3059
  * is the one `common` reads, so a header's trip count never counts a pick.
2948
3060
  */
2949
- const currentTrip = async (deps, key, who, store = key, coupon) => {
3061
+ const currentTrip = async (deps, key, who, store = key, coupon, ctx) => {
2950
3062
  await refreshMember(deps, key);
2951
3063
  const member = deps.members.read(key);
2952
3064
  const lines = deps.trips.read(store);
@@ -2977,6 +3089,17 @@ const currentTrip = async (deps, key, who, store = key, coupon) => {
2977
3089
  ]
2978
3090
  : [])));
2979
3091
  const overlapping = overlaps.length ? { overlaps } : {};
3092
+ // ⚠️ WHAT COMES NEXT, IN WORDS (2026-11-02 audit, P2-4): the card has a
3093
+ // Book button under the trip, and the words had nothing in its place.
3094
+ //
3095
+ // ⚠️ AND A PHONE NUMBER WHERE THE TRIP'S GROUP NEEDS ONE (the 0.52.0 gap): the
3096
+ // card's form asks for it, and this sentence - the whole of the card for a
3097
+ // client that draws none - said only "name and email", so the first booking
3098
+ // came back "needs a phone number". Whose need it is is the trip's groups',
3099
+ // as `requestBooking` judges it.
3100
+ const nextStep = ctx?.noCards && parts.some((p) => p.lines.some((l) => l.kind === 'room'))
3101
+ ? `Nothing in the trip is booked yet. To book it, ${deps.tools.requestBooking} takes the guest's own ${guestDetailsFor(deps, lines.filter((l) => l.kind === 'room'))} - ask for them if the guest has not typed them in this conversation.`
3102
+ : '';
2980
3103
  if (parts.length === 1 && only) {
2981
3104
  return {
2982
3105
  structured: {
@@ -2987,11 +3110,13 @@ const currentTrip = async (deps, key, who, store = key, coupon) => {
2987
3110
  ...stamp,
2988
3111
  ...overlapping,
2989
3112
  count: lines.length,
3113
+ ...(nextStep ? { nextStep } : {}),
2990
3114
  },
2991
3115
  text: [
2992
- tripText(only, false),
3116
+ tripText(only, false, ctx?.noCards),
2993
3117
  ...overlaps.map((o) => o.said),
2994
3118
  orphans.line,
3119
+ nextStep,
2995
3120
  deps.vocab.demo && lines.length ? noteFor(deps.vocab) : '',
2996
3121
  ]
2997
3122
  .filter(Boolean)
@@ -3031,12 +3156,14 @@ const currentTrip = async (deps, key, who, store = key, coupon) => {
3031
3156
  ...stamp,
3032
3157
  ...overlapping,
3033
3158
  count: lines.length,
3159
+ ...(nextStep ? { nextStep } : {}),
3034
3160
  },
3035
3161
  text: [
3036
3162
  `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)),
3163
+ ...parts.map((q) => tripText(q, false, ctx?.noCards)),
3038
3164
  ...overlaps.map((o) => o.said),
3039
3165
  orphans.line,
3166
+ nextStep,
3040
3167
  deps.vocab.demo ? noteFor(deps.vocab) : '',
3041
3168
  ]
3042
3169
  .filter(Boolean)
@@ -3319,6 +3446,14 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
3319
3446
  // a card from before - its minor units and currency, for the card to write
3320
3447
  // in its reader's language. The card does no sums: every figure is ours.
3321
3448
  const amounts = amountsIn(named, written);
3449
+ // ⚠️ AND, WHERE THE DEPLOYMENT HAS REFERENCE RATES, A SMALL "≈" BESIDE EACH
3450
+ // AMOUNT IN ANOTHER CURRENCY (reference.ts): the hotel's own currency is what
3451
+ // is quoted and charged, and the figure is only ever beside it.
3452
+ if (deps.backend.reference)
3453
+ return {
3454
+ ...answer,
3455
+ ...withReference(deps.backend.reference, { structured: named, text: answer.text }, amounts, written),
3456
+ };
3322
3457
  const structured = Object.keys(amounts).length ? { ...named, amounts } : named;
3323
3458
  return { ...answer, structured };
3324
3459
  };
@@ -3364,20 +3499,42 @@ export const COMPARISON_NOT_DRAWN = "Nothing was drawn: this app shows no cards,
3364
3499
  * the card - so a client that draws none had a count to tell the guest.
3365
3500
  */
3366
3501
  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. */
3502
+ /**
3503
+ * One hotel of a list as a line, for a client that shows no cards.
3504
+ *
3505
+ * ⚠️ WHETHER IT CAN BE BOOKED HERE IS ON THE LINE (2026-11-02 audit, P1-5): a
3506
+ * Huazhu hotel whose only rate tonight is paid in advance was listed as
3507
+ * "¥783.02 for the stay", with the sentence that such rates are not booked
3508
+ * here said once at the top - a model reading the line could tell the guest it
3509
+ * can be booked. Each priced line says how its rate is paid and what it costs
3510
+ * to cancel, as the card's tile does.
3511
+ */
3368
3512
  const listedInWords = (h) => {
3369
3513
  const where = [h.area, h.city]
3370
3514
  .filter((x) => !!x)
3371
3515
  .map((x) => hotelName(x))
3372
3516
  .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}` : ''}`
3517
+ const f = h.forStay;
3518
+ const terms = f
3519
+ ? [
3520
+ f.refundable === false ? 'non-refundable' : f.freeCancellation ? 'free to cancel' : '',
3521
+ f.payment === 'prepaid'
3522
+ ? f.notSoldHere
3523
+ ? 'paid in advance - not booked here'
3524
+ : 'paid in advance'
3525
+ : f.payment === 'at-hotel'
3526
+ ? 'paid at the hotel'
3527
+ : '',
3528
+ ].filter(Boolean)
3529
+ : [];
3530
+ const price = f
3531
+ ? `${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
3532
  : h.noRoomsForStay
3376
3533
  ? 'no room for those nights'
3377
3534
  : h.fromNightly
3378
- ? `from ${formatMoney(h.fromNightly)} a night`
3535
+ ? `from ${formatMoney(h.fromNightly)} ${nightWords(true)}`
3379
3536
  : '';
3380
- return `- ${hotelName(h.name)} (\`${h.id}\`)${where ? ` · ${where}` : ''}${price ? `: ${price}` : ''}`;
3537
+ return `- ${withZh(h.name, h.names?.name, true)} (\`${h.id}\`)${where ? ` · ${where}` : ''}${price ? `: ${price}` : ''}`;
3381
3538
  };
3382
3539
  /**
3383
3540
  * One column of a comparison as a line, for a client that shows no cards:
@@ -3399,7 +3556,7 @@ const comparedInWords = (c) => {
3399
3556
  const p = c.price;
3400
3557
  const priced = p
3401
3558
  ? [
3402
- `${hotelName(p.room)} at ${p.nightly} a night, ${p.stayTotal} for the stay incl. taxes and fees`,
3559
+ `${withZh(p.room, p.roomNames, true)} at ${p.nightly} ${nightWords(true)}, ${p.stayTotal} for the stay incl. taxes and fees`,
3403
3560
  p.freeCancellationUntil
3404
3561
  ? `free to cancel until ${freeUntilText(p)}`
3405
3562
  : p.refundable
@@ -3417,7 +3574,7 @@ const comparedInWords = (c) => {
3417
3574
  : c.from
3418
3575
  ? `from ${c.from} a night; rooms are priced once there are dates`
3419
3576
  : 'not priced without dates';
3420
- return `- ${hotelName(h.name)} (\`${h.id}\`)${about ? ` · ${about}` : ''}: ${priced}`;
3577
+ return `- ${withZh(h.name, h.names?.name, true)} (\`${h.id}\`)${about ? ` · ${about}` : ''}: ${priced}`;
3421
3578
  };
3422
3579
  /** An area's names as a day might say them: "Haidian", "海淀区" and "海淀". */
3423
3580
  const areaWords = (h) => {
@@ -3709,7 +3866,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3709
3866
  : !!asked && l.stay.checkIn === asked.checkIn && l.stay.checkOut === asked.checkOut));
3710
3867
  if (held) {
3711
3868
  deps.trips.set(store, unset(held));
3712
- return currentTrip(deps, key, who, store, couponOf(deps, args));
3869
+ return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
3713
3870
  }
3714
3871
  }
3715
3872
  const today = (startTime || !asStay(args)
@@ -3769,7 +3926,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3769
3926
  hours: hourlyRoom.hourly.hours,
3770
3927
  },
3771
3928
  });
3772
- return currentTrip(deps, key, who, store, couponOf(deps, args));
3929
+ return currentTrip(deps, key, who, store, couponOf(deps, args), ctx);
3773
3930
  }
3774
3931
  const stay = asStay(args);
3775
3932
  // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
@@ -3915,7 +4072,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3915
4072
  stay,
3916
4073
  rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3917
4074
  });
3918
- const now = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args));
4075
+ const now = await currentTrip(deps, key, identity(deps, ctx, key), store, couponOf(deps, args), ctx);
3919
4076
  // Which rate was taken, in the structure too and in the same words - see
3920
4077
  // the itinerary's `nudges` (2026-10-27 audit, F11).
3921
4078
  return took
@@ -4209,7 +4366,8 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
4209
4366
  ? `${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
4367
  : '',
4211
4368
  ...booked.flatMap(({ booking, asked }) => [
4212
- `**${confirming(deps, booking) ? 'Being confirmed' : booking.confirmationCode}** · ${booking.hotelName} · ${stayWords(booking.stay, booking.hourly)}`,
4369
+ `**${confirming(deps, booking) ? 'Being confirmed' : booking.confirmationCode}** · ${withZh(booking.hotelName, booking.hotelNames, ctx.noCards)} · ${stayWords(booking.stay, booking.hourly)}`,
4370
+ ctx.noCards ? heldRoomsWords(booking) : '',
4213
4371
  bookedLine(deps, booking),
4214
4372
  ...asked,
4215
4373
  ]),
@@ -4509,7 +4667,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4509
4667
  };
4510
4668
  const priced = await answerTool(deps, t.listHotels, { ...args, ...tonight }, ctx);
4511
4669
  // 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.`;
4670
+ 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
4671
  // Right after what is here - "every one, and no others" stays the first thing said.
4514
4672
  const inAll = String(priced.structured.inAll ?? '');
4515
4673
  return {
@@ -4570,7 +4728,16 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4570
4728
  // "the most are in" three of forty cities left the other thirty-seven
4571
4729
  // for a guest to guess at.
4572
4730
  const every = (xs) => xs.map((x) => `${x.name} (${x.count})`).join(', ');
4731
+ // ⚠️ AND TO A CLIENT THAT DRAWS NO CARD, EVERY CITY (2026-11-02 audit,
4732
+ // P3-2): told to name the places, a model had "China (54)" and nothing
4733
+ // to name. A country of one city named like it says no more.
4734
+ const everyWithCities = (xs) => xs
4735
+ .map((c) => c.cities.length > 1 || (c.cities[0] && c.cities[0].name !== c.name)
4736
+ ? `${c.name} (${c.count}: ${c.cities.map((x) => `${x.name} ${x.count}`).join(', ')})`
4737
+ : `${c.name} (${c.count})`)
4738
+ .join(', ');
4573
4739
  const n = deps.vocab.nounPlural;
4740
+ const degraded = degradedOf(deps, answer);
4574
4741
  if (namedCountry) {
4575
4742
  const [only] = countries;
4576
4743
  return {
@@ -4582,8 +4749,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4582
4749
  ask: { city: asked.city, ...stayArgs },
4583
4750
  ...(ask.stay ? { stay: ask.stay } : {}),
4584
4751
  ...(today ? { today } : {}),
4752
+ ...degraded.structured,
4585
4753
  },
4586
- text: `${total} ${branded(deps.vocab, n)} in ${only?.name}, in ${only?.cities.length} cities to choose from: ${every(only?.cities ?? [])}. A city lists its ${n}; \`all\` lists every one in ${only?.name}. ${forPlans}`,
4754
+ text: `${total} ${branded(deps.vocab, n)} in ${only?.name}, in ${only?.cities.length} cities to choose from: ${every(only?.cities ?? [])}. A city lists its ${n}; \`all\` lists every one in ${only?.name}. ${degraded.said ? `${degraded.said} ` : ''}${forPlans}`,
4587
4755
  };
4588
4756
  }
4589
4757
  const regions = [
@@ -4606,11 +4774,13 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4606
4774
  ask: { ...stayArgs },
4607
4775
  ...(ask.stay ? { stay: ask.stay } : {}),
4608
4776
  ...(today ? { today } : {}),
4777
+ ...degraded.structured,
4609
4778
  },
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}`,
4779
+ 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}. ${degraded.said ? `${degraded.said} ` : ''}${forPlans}`,
4611
4780
  };
4612
4781
  }
4613
4782
  if (hotels.length === 0) {
4783
+ const degraded = degradedOf(deps, answer);
4614
4784
  const nearest = (answer.nearest ?? []).map((h) => ({
4615
4785
  ...h,
4616
4786
  summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
@@ -4658,6 +4828,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4658
4828
  ...(nearest.length ? { nearest, overBudget: answer.overBudget } : {}),
4659
4829
  ...(ask.stay ? { stay: ask.stay } : {}),
4660
4830
  ...(widened?.found ? { widened } : {}),
4831
+ // Nothing found while a group did not answer is not "none here".
4832
+ ...degraded.structured,
4661
4833
  // Why it is empty, in the words the model is told, so the card says
4662
4834
  // the same thing rather than guessing at it.
4663
4835
  why,
@@ -4675,6 +4847,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4675
4847
  text: [
4676
4848
  widened?.found ? `Nothing matched ${widened.from}, so this looked in ${widened.to}.` : '',
4677
4849
  why,
4850
+ degraded.said,
4678
4851
  ]
4679
4852
  .filter(Boolean)
4680
4853
  .join(' '),
@@ -4750,6 +4923,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4750
4923
  priceNotes.push(notSold);
4751
4924
  // Every one by name and city, and that there are no others - or, told
4752
4925
  // only "2 hotels", the model asked for the list again to learn which.
4926
+ const degraded = degradedOf(deps, answer);
4753
4927
  const inAll = homeList
4754
4928
  ? `This connector has ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} in all, and this answer lists every one: ${hotels
4755
4929
  .map((h) => `${hotelName(h.name)}${h.city ? ` in ${h.city}` : ''}`)
@@ -4802,6 +4976,8 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4802
4976
  ...(answer.overBudget ? { overBudget: answer.overBudget } : {}),
4803
4977
  ...(widened?.found ? { widened } : {}),
4804
4978
  ...(answer.currencies.length > 1 ? { currencies: answer.currencies } : {}),
4979
+ // A group that did not answer, in both: the card draws a line from it.
4980
+ ...degraded.structured,
4805
4981
  // In both, like a plan's: a host with no card may hand its model this
4806
4982
  // rather than the text.
4807
4983
  ...(ctx.noCards ? { notDrawn: LIST_NOT_DRAWN } : {}),
@@ -4819,6 +4995,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4819
4995
  : '',
4820
4996
  ...priceNotes,
4821
4997
  widened?.found ? `Nothing matched ${widened.from}, so this is ${widened.to}.` : '',
4998
+ degraded.said,
4822
4999
  ctx.noCards && hotels.length
4823
5000
  ? `\n${LIST_NOT_DRAWN}\n${hotels.map(listedInWords).join('\n')}\n`
4824
5001
  : '',
@@ -4877,7 +5054,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4877
5054
  activeFilters: activeKeysOf(roomTermsOf(args)),
4878
5055
  },
4879
5056
  text: [
4880
- `**${h.name}** · ${[h.area, h.city].filter(Boolean).join(', ')}`,
5057
+ `**${ctx.noCards ? withZh(h.name, h.names?.name, true) : h.name}** · ${[h.area, h.city].filter(Boolean).join(', ')}`,
4881
5058
  description.fenced,
4882
5059
  'Rooms and prices need the dates - which nights is the guest staying?',
4883
5060
  ]
@@ -4990,9 +5167,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4990
5167
  },
4991
5168
  text: [
4992
5169
  memberRates?.said ?? '',
4993
- ...roomsText(h.name, byTheHour
5170
+ ...roomsText(ctx.noCards ? withZh(h.name, h.names?.name, true) : h.name, byTheHour
4994
5171
  ? (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),
5172
+ : (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
5173
  // The other arrivals are priced on the cheapest room of any kind.
4997
5174
  terms && flex.text
4998
5175
  ? `${flex.text} (That is the cheapest room of any kind, not only one with ${termWords(terms)}.)`
@@ -5019,6 +5196,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5019
5196
  /** For each name that fits more than one, which ones - see `namesLike`. */
5020
5197
  const candidates = [];
5021
5198
  let total = 0;
5199
+ let degradedFound = {};
5022
5200
  if (named.length) {
5023
5201
  for (const ref of named) {
5024
5202
  const found = await deps.backend.getHotel(ref);
@@ -5038,7 +5216,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5038
5216
  const found = await deps.backend.searchHotels({ city, limit: LOOK_UP_MAX }, who);
5039
5217
  ids.push(...found.hotels.map((h) => h.id));
5040
5218
  total = found.total;
5219
+ degradedFound = found;
5041
5220
  }
5221
+ const degraded = degradedOf(deps, degradedFound);
5042
5222
  const unique = [...new Set(ids)];
5043
5223
  // A name that fits more than one is said with the ones it fits; the rest are not found.
5044
5224
  const notFound = dropped.filter((d) => !candidates.some((c) => c.for === d));
@@ -5050,9 +5230,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5050
5230
  ...common(deps, key),
5051
5231
  ...(dropped.length ? { dropped } : {}),
5052
5232
  ...(candidates.length ? { candidates } : {}),
5233
+ ...degraded.structured,
5053
5234
  },
5054
5235
  text: city
5055
- ? `No ${branded(deps.vocab, deps.vocab.nounPlural)} in "${city}". ${t.listHotels} with no place shows the places there are.`
5236
+ ? `No ${branded(deps.vocab, deps.vocab.nounPlural)} in "${city}". ${degraded.said ? `${degraded.said} ` : ''}${t.listHotels} with no place shows the places there are.`
5056
5237
  : named.length
5057
5238
  ? [
5058
5239
  notFound.length
@@ -5079,7 +5260,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5079
5260
  const extras = await deps.backend.extrasFor(h.id, stay);
5080
5261
  const rooms = (detail.rooms ?? []).filter((r) => r.roomsLeft >= roomsWanted);
5081
5262
  blocks.push([
5082
- `### ${hotelName(h.name)} · id \`${h.id}\``,
5263
+ `### ${withZh(h.name, h.names?.name, ctx.noCards)} · id \`${h.id}\``,
5083
5264
  [
5084
5265
  [h.area, h.city, h.country].filter(Boolean).join(', '),
5085
5266
  h.brand,
@@ -5090,12 +5271,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5090
5271
  about.fenced,
5091
5272
  !stay
5092
5273
  ? h.fromNightly
5093
- ? `From ${formatMoney(h.fromNightly)} a night; rooms are priced once there are dates.`
5274
+ ? `From ${formatMoney(h.fromNightly)} ${nightWords(ctx.noCards)}; rooms are priced once there are dates.`
5094
5275
  : ''
5095
5276
  : rooms.length
5096
5277
  ? [
5097
5278
  `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'}`),
5279
+ // ⚠️ ONE LINE A ROOM TYPE, WITH ITS ID, to a client that
5280
+ // draws no card (2026-11-02 audit, P1-4, P3-4): every rate of
5281
+ // four Atour hotels was 16,213 characters, and not one room
5282
+ // id - which is what the trip takes. As the hotel's own
5283
+ // answer says them.
5284
+ ...(ctx.noCards
5285
+ ? roomTypeLines(rooms, roomsWanted, undefined, true)
5286
+ : 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
5287
  ].join('\n')
5100
5288
  : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
5101
5289
  extras.length
@@ -5114,9 +5302,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5114
5302
  total,
5115
5303
  ...(dropped.length ? { dropped } : {}),
5116
5304
  ...(candidates.length ? { candidates } : {}),
5305
+ ...degraded.structured,
5117
5306
  },
5118
5307
  text: [
5119
5308
  `Read ${read.length}${total > read.length ? ` of ${total}` : ''} ${read.length === 1 ? deps.vocab.noun : deps.vocab.nounPlural}. Nothing was shown to the guest.`,
5309
+ degraded.said,
5120
5310
  ...blocks,
5121
5311
  notFound.length ? `Not found: ${notFound.map((d) => `"${d}"`).join(', ')}.` : '',
5122
5312
  ...fitsMany,
@@ -5127,10 +5317,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5127
5317
  // "Draw the answer now as ONE card", an instruction in a tool result.
5128
5318
  // The rule itself lives in the server instructions; what stays here is
5129
5319
  // 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(
5320
+ `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
5321
  // Only tools this deployment offers: a name it withholds is one the
5132
5322
  // 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.`,
5323
+ [t.listHotels, t.getHotel, t.listExtras].filter((n) => !deps.offered || deps.offered.has(n)))} ${
5324
+ // ⚠️ NOTHING IS DRAWN WHERE NO CARD IS (2026-11-02 audit, P2-1).
5325
+ ctx.noCards
5326
+ ? 'answer with what the guest is told, for a guest who asked to see a list, a hotel or its add-ons.'
5327
+ : 'would each draw a card of their own.'}`,
5134
5328
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5135
5329
  ]
5136
5330
  .filter(Boolean)
@@ -5214,7 +5408,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5214
5408
  },
5215
5409
  text: extras.length
5216
5410
  ? extras
5217
- .map((e) => `- ${e.name} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
5411
+ .map((e) => `- ${e.name}${ctx.noCards ? ` (\`${e.id}\`)` : ''} — ${formatMoney(e.price)} ${unitWords(e.per)}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
5218
5412
  .concat(deps.vocab.demo ? [noteFor(deps.vocab)] : [])
5219
5413
  .join('\n')
5220
5414
  : `${detail.hotel.name} has no add-ons listed here.`,
@@ -5251,7 +5445,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5251
5445
  ...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
5252
5446
  };
5253
5447
  deps.trips.set(key, line);
5254
- return currentTrip(deps, key, identity(deps, ctx, key));
5448
+ return currentTrip(deps, key, identity(deps, ctx, key), key, undefined, ctx);
5255
5449
  }
5256
5450
  return putRoomLine(deps, t, ctx, key, key, args, detail);
5257
5451
  }
@@ -5273,7 +5467,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5273
5467
  return direct(await putRoomLine(deps, t, ctx, key, slot, args, found.detail));
5274
5468
  }
5275
5469
  if (name === t.viewTrip)
5276
- return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args));
5470
+ return currentTrip(deps, key, identity(deps, ctx, key), key, couponOf(deps, args), ctx);
5277
5471
  if (name === t.clearTrip) {
5278
5472
  deps.trips.clear(key);
5279
5473
  return {
@@ -5327,7 +5521,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5327
5521
  text: [
5328
5522
  missed ? missLine : '',
5329
5523
  bookings.length
5330
- ? bookings.map(bookingLine).join('\n')
5524
+ ? bookings.map((b) => bookingLine(b, ctx.noCards)).join('\n')
5331
5525
  : code
5332
5526
  ? ''
5333
5527
  : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
@@ -5890,8 +6084,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5890
6084
  .flatMap((day) => day.items)
5891
6085
  .filter((x) => x.kind === 'hotel' && (x.night ?? 1) === 1 && x.checkIn && x.roomId);
5892
6086
  // Each stay's rate says its terms where they bind - see `ItineraryItem.refundable`.
6087
+ // With no card, until when it is free to cancel too: the reply is told to
6088
+ // give each stay's terms (2026-11-02 audit, P2-3).
5893
6089
  const termsOf = (x) => [
5894
- x.refundable === false ? 'non-refundable' : '',
6090
+ x.refundable === false
6091
+ ? 'non-refundable'
6092
+ : ctx.noCards && x.freeCancellationUntil
6093
+ ? `free to cancel until ${freeUntilText(x)}`
6094
+ : '',
5895
6095
  x.payment === 'prepaid'
5896
6096
  ? x.notSoldHere
5897
6097
  ? 'paid in advance - not booked here'
@@ -5902,7 +6102,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5902
6102
  .join(', ');
5903
6103
  // With no card, each stay's own price too: the card shows it beside the
5904
6104
  // 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' : ''}`);
6105
+ 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
6106
  // ⚠️ IN BOTH, IN THE SAME WORDS (2026-10-27 audit, F11): a host that hands
5907
6107
  // the model `structuredContent` rather than the text never saw what the
5908
6108
  // text said of a long stay. The model's part of the answer is the same on