@curless/hotel-storefront 0.27.7 → 0.27.9

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 (76) hide show
  1. package/README.md +1 -1
  2. package/dist/aggregate/hotels.d.ts +45 -0
  3. package/dist/aggregate/hotels.d.ts.map +1 -0
  4. package/dist/aggregate/hotels.js +633 -0
  5. package/dist/aggregate/hotels.js.map +1 -0
  6. package/dist/aggregate/ids.d.ts +36 -0
  7. package/dist/aggregate/ids.d.ts.map +1 -0
  8. package/dist/aggregate/ids.js +64 -0
  9. package/dist/aggregate/ids.js.map +1 -0
  10. package/dist/aggregate/make.d.ts +12 -0
  11. package/dist/aggregate/make.d.ts.map +1 -0
  12. package/dist/aggregate/make.js +96 -0
  13. package/dist/aggregate/make.js.map +1 -0
  14. package/dist/aggregate/paging.d.ts +35 -0
  15. package/dist/aggregate/paging.d.ts.map +1 -0
  16. package/dist/aggregate/paging.js +94 -0
  17. package/dist/aggregate/paging.js.map +1 -0
  18. package/dist/backend.d.ts +41 -0
  19. package/dist/backend.d.ts.map +1 -1
  20. package/dist/backend.js.map +1 -1
  21. package/dist/card.generated.d.ts +2 -2
  22. package/dist/card.generated.d.ts.map +1 -1
  23. package/dist/card.generated.js +3 -3
  24. package/dist/card.generated.js.map +1 -1
  25. package/dist/config.d.ts +31 -2
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js.map +1 -1
  28. package/dist/demo-hotels.d.ts +6 -0
  29. package/dist/demo-hotels.d.ts.map +1 -1
  30. package/dist/demo-hotels.js +16 -6
  31. package/dist/demo-hotels.js.map +1 -1
  32. package/dist/huazhu/backend.d.ts.map +1 -1
  33. package/dist/huazhu/backend.js +2 -0
  34. package/dist/huazhu/backend.js.map +1 -1
  35. package/dist/huazhu/make.d.ts +4 -0
  36. package/dist/huazhu/make.d.ts.map +1 -0
  37. package/dist/huazhu/make.js +105 -0
  38. package/dist/huazhu/make.js.map +1 -0
  39. package/dist/index.js +0 -0
  40. package/dist/legal.d.ts.map +1 -1
  41. package/dist/legal.js +136 -51
  42. package/dist/legal.js.map +1 -1
  43. package/dist/lib.d.ts +2 -1
  44. package/dist/lib.d.ts.map +1 -1
  45. package/dist/lib.js +2 -0
  46. package/dist/lib.js.map +1 -1
  47. package/dist/listing.d.ts +10 -0
  48. package/dist/listing.d.ts.map +1 -0
  49. package/dist/listing.js +43 -0
  50. package/dist/listing.js.map +1 -0
  51. package/dist/server.d.ts +10 -22
  52. package/dist/server.d.ts.map +1 -1
  53. package/dist/server.js +40 -153
  54. package/dist/server.js.map +1 -1
  55. package/dist/state.d.ts +8 -0
  56. package/dist/state.d.ts.map +1 -1
  57. package/dist/state.js +18 -1
  58. package/dist/state.js.map +1 -1
  59. package/dist/supplier.d.ts +50 -0
  60. package/dist/supplier.d.ts.map +1 -0
  61. package/dist/supplier.js +57 -0
  62. package/dist/supplier.js.map +1 -0
  63. package/dist/surface.d.ts.map +1 -1
  64. package/dist/surface.js +37 -21
  65. package/dist/surface.js.map +1 -1
  66. package/dist/tools.d.ts +29 -23
  67. package/dist/tools.d.ts.map +1 -1
  68. package/dist/tools.js +208 -69
  69. package/dist/tools.js.map +1 -1
  70. package/dist/types.d.ts +19 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/vocab.d.ts +20 -0
  73. package/dist/vocab.d.ts.map +1 -1
  74. package/dist/vocab.js +22 -1
  75. package/dist/vocab.js.map +1 -1
  76. package/package.json +12 -10
package/dist/tools.js CHANGED
@@ -6,7 +6,7 @@ import { formatMoney, money, multiplyMoney, toMinor } from './money.js';
6
6
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, compareHotels, } from './present.js';
7
7
  import { regionOf } from './regions.js';
8
8
  import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, TRIP_TTL_MS, clampCount, shownOf, tripKey, } from './trip.js';
9
- import { DEMO_NOTE } from './vocab.js';
9
+ import { DEMO_NOTE, branded, catalogue, upFirst } from './vocab.js';
10
10
  // The eleven things a guest can do: find hotels, look at one on dates, see what
11
11
  // else it offers, build a trip, ask for the rooms, and deal with a booking they
12
12
  // already have.
@@ -151,7 +151,8 @@ const forCapabilities = (tools, n, v, caps) => {
151
151
  }
152
152
  if (!caps.members)
153
153
  out = { ...out, inputSchema: without(out.inputSchema, 'rememberRequests') };
154
- if (caps.booking === 'pay-at-hotel') {
154
+ // A market's suppliers book differently - its own description says so.
155
+ if (caps.booking === 'pay-at-hotel' && !v.market) {
155
156
  out = {
156
157
  ...out,
157
158
  // ⚠️ A SANDBOX BOOKS NOTHING REAL, and its description says so
@@ -182,8 +183,8 @@ const allTools = (n, v) => {
182
183
  return [
183
184
  {
184
185
  name: n.listHotels,
185
- description: `${v.brand} ${v.nounPlural}, shown as a card. 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\` and \`breakfast\` 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}` : ''}`,
186
- annotations: reads(`Browse ${v.brand} ${v.nounPlural}`, open),
186
+ description: `${upFirst(catalogue(v))}, shown as a card. 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\` and \`breakfast\` 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}` : ''}`,
187
+ annotations: reads(`Browse ${catalogue(v)}`, open),
187
188
  inputSchema: {
188
189
  type: 'object',
189
190
  properties: {
@@ -223,6 +224,14 @@ const allTools = (n, v) => {
223
224
  type: 'string',
224
225
  description: "One of the group's brands, as the list's brand counts name it",
225
226
  },
227
+ ...(v.market
228
+ ? {
229
+ group: {
230
+ type: 'string',
231
+ description: "One hotel group's hotels only, by the group's name",
232
+ },
233
+ }
234
+ : {}),
226
235
  facilities: {
227
236
  type: 'array',
228
237
  items: { type: 'string', enum: FACILITIES.map((x) => x.key) },
@@ -246,7 +255,7 @@ const allTools = (n, v) => {
246
255
  {
247
256
  name: n.getHotel,
248
257
  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, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. \`rooms\` leaves out the types that have fewer than that many. 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}` : ''}`,
249
- annotations: reads(`View one ${v.brand} ${v.noun}`, open),
258
+ annotations: reads(`View one ${catalogue(v, true)}`, open),
250
259
  inputSchema: {
251
260
  type: 'object',
252
261
  properties: {
@@ -274,7 +283,7 @@ const allTools = (n, v) => {
274
283
  // reference apps do the same split: search without a card, present with
275
284
  // one.
276
285
  description: `Reads up to ${LOOK_UP_MAX} ${v.nounPlural} - a \`city\`'s (or a country's), or the ones named in \`${v.nounPlural}\` - and returns them as text, with nothing drawn and nothing shown to the guest: each one's id, area, rating, description and amenities, its add-ons with their ids and prices, and, given \`checkIn\` and \`checkOut\`, its rooms priced for those nights with taxes in. The ids are the ones a comparison and a plan take.`,
277
- annotations: reads(`Look up ${v.brand} ${v.nounPlural}`, open),
286
+ annotations: reads(`Look up ${catalogue(v)}`, open),
278
287
  inputSchema: {
279
288
  type: 'object',
280
289
  properties: {
@@ -300,7 +309,7 @@ const allTools = (n, v) => {
300
309
  {
301
310
  name: n.listExtras,
302
311
  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.`,
303
- annotations: reads(`${v.brand} add-ons`, open),
312
+ annotations: reads(v.market ? `Add-ons on ${v.brand}` : `${v.brand} add-ons`, open),
304
313
  inputSchema: {
305
314
  type: 'object',
306
315
  properties: {
@@ -367,10 +376,15 @@ const allTools = (n, v) => {
367
376
  // ⚠️ A DEMONSTRATION BOOKING IS SAID AS ONE (2026-10-05 audit, P2):
368
377
  // "asks Shangri-La to hold the rooms" read as a real hold, and a guest
369
378
  // could take the code to the real hotel.
370
- description: v.demo
371
- ? `Makes a demonstration booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the demonstration hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
372
- : `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
373
- annotations: books(`Request a ${v.brand} booking`, open),
379
+ description: v.market
380
+ ? // ⚠️ THE GROUP HOLDS THE BOOKING, NOT THE MARKET - and what a booking
381
+ // makes is the group's: a hold, a test booking, or a reservation paid
382
+ // at the hotel. The answer says which, stay by stay.
383
+ `Books the trip's rooms for a named guest with the hotel group each stay belongs to, one group at a time: a trip holding two groups' hotels is booked group by group. What a booking makes is that group's - a demonstration hold that lapses on its own, a test booking in a simulated system, or the hotel's own reservation paid at the hotel - and the answer says which for every stay. No payment is taken here and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests.`
384
+ : v.demo
385
+ ? `Makes a demonstration booking request for the trip's rooms, for a named guest: it returns a confirmation code and the moment the demonstration hold lapses on its own. ${v.brand} does not receive it and no real room is held. No payment is taken and no card is collected. Preferences - a bed type, a floor, a late arrival - ride along on the request.`
386
+ : `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for. Preferences - a bed type, a floor, a late arrival - ride along on the booking as requests the desk tries to meet.`,
387
+ annotations: books(v.market ? `Request a booking on ${v.brand}` : `Request a ${v.brand} booking`, open),
374
388
  inputSchema: {
375
389
  type: 'object',
376
390
  properties: {
@@ -397,6 +411,16 @@ const allTools = (n, v) => {
397
411
  type: 'boolean',
398
412
  description: 'True when the guest asked for these room preferences to be remembered for their next stays. Needs a signed-in member; what is kept is the lasting kind - a bed type, a floor, a quiet or connecting room; an accessible room is asked for on each stay and is not kept',
399
413
  },
414
+ // A booking is made with one group at a time; a trip holding two
415
+ // groups' hotels is booked group by group (Marketplace, §6-1).
416
+ ...(v.market
417
+ ? {
418
+ group: {
419
+ type: 'string',
420
+ description: "The hotel group to book now, by name, when the trip holds more than one group's hotels. Its stays are booked and the others stay in the trip",
421
+ },
422
+ }
423
+ : {}),
400
424
  },
401
425
  required: ['name', 'email'],
402
426
  },
@@ -557,7 +581,7 @@ const allTools = (n, v) => {
557
581
  // model that could send a price could one day send the wrong one, onto a
558
582
  // card that looks official.
559
583
  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.`,
560
- annotations: reads(`Compare ${v.brand} ${v.nounPlural}`, open),
584
+ annotations: reads(`Compare ${catalogue(v)}`, open),
561
585
  inputSchema: {
562
586
  type: 'object',
563
587
  properties: {
@@ -590,7 +614,7 @@ const allTools = (n, v) => {
590
614
  {
591
615
  name: n.itinerary,
592
616
  description: `A day-by-day plan as one card: 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.`,
593
- annotations: reads(`Lay out a ${v.brand} itinerary`, open),
617
+ annotations: reads(v.market ? `Lay out an itinerary on ${v.brand}` : `Lay out a ${v.brand} itinerary`, open),
594
618
  inputSchema: {
595
619
  type: 'object',
596
620
  properties: {
@@ -680,7 +704,7 @@ const allTools = (n, v) => {
680
704
  {
681
705
  name: n.policies,
682
706
  description: `What one ${v.noun} publishes about cancellation, check-in and check-out, children and extra beds, pets, deposits and accessibility. With \`topic\` it returns that topic; without, all of them.`,
683
- annotations: reads(`${v.brand} policies`, open),
707
+ annotations: reads(v.market ? `${upFirst(v.noun)} policies on ${v.brand}` : `${v.brand} policies`, open),
684
708
  inputSchema: {
685
709
  type: 'object',
686
710
  properties: {
@@ -1046,6 +1070,40 @@ const memberBadge = (deps, key) => {
1046
1070
  }
1047
1071
  : { signedIn: false };
1048
1072
  };
1073
+ /** A market's group by its id or its name, as a guest or a model would say it. */
1074
+ const groupNamed = (deps, said) => {
1075
+ const want = said.trim().toLowerCase();
1076
+ return deps.backend
1077
+ .suppliers?.()
1078
+ .find((x) => x.id.toLowerCase() === want || x.name.toLowerCase() === want);
1079
+ };
1080
+ /** The same trip line at a count of nothing - which takes it out (`TripStore.set`). */
1081
+ const unset = (l) => l.kind === 'extra' ? { ...l, quantity: 0 } : { ...l, rooms: 0 };
1082
+ /**
1083
+ * On a group's own connector, the connector itself. In a market, the supplier
1084
+ * named on the booking or the hotel.
1085
+ *
1086
+ * ⚠️ NEVER THE MARKET. "Asks Otaless to hold the rooms" or "Otaless's own
1087
+ * reservation" would send a guest to a desk that has never heard of Otaless,
1088
+ * and a market holding a demonstration beside a group's real system must not
1089
+ * call either by the other's terms (robin, 2026-10-07).
1090
+ */
1091
+ const holderOf = (deps, supplier) => {
1092
+ const s = supplier ? deps.backend.suppliers?.().find((x) => x.id === supplier) : undefined;
1093
+ if (s)
1094
+ return {
1095
+ name: s.name,
1096
+ caps: s.capabilities,
1097
+ demo: s.mode !== 'live',
1098
+ sandbox: s.mode === 'sandbox',
1099
+ };
1100
+ return {
1101
+ name: deps.vocab.brand,
1102
+ caps: deps.backend.capabilities ?? FULL_CAPABILITIES,
1103
+ demo: deps.vocab.demo,
1104
+ sandbox: !!deps.vocab.sandbox,
1105
+ };
1106
+ };
1049
1107
  /**
1050
1108
  * What was made, in one sentence - which depends on what asking MAKES here.
1051
1109
  *
@@ -1058,47 +1116,56 @@ const memberBadge = (deps, key) => {
1058
1116
  */
1059
1117
  const bookedLine = (deps, b) => {
1060
1118
  const total = formatMoney(b.quote.total);
1061
- if ((deps.backend.capabilities ?? FULL_CAPABILITIES).booking === 'pay-at-hotel') {
1062
- if (deps.vocab.sandbox)
1063
- return `${total} in the simulated system. **This is a test booking** - not a ${deps.vocab.brand} reservation: no room is held and nothing is paid, now or at any hotel.`;
1119
+ const h = holderOf(deps, b.supplier);
1120
+ if (h.caps.booking === 'pay-at-hotel') {
1121
+ if (h.sandbox)
1122
+ return `${total} in the simulated system. **This is a test booking** - not a ${h.name} reservation: no room is held and nothing is paid, now or at any hotel.`;
1064
1123
  return b.status === 'requested'
1065
- ? `${total} all in, to be paid at the hotel. ${deps.vocab.brand} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
1066
- : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${deps.vocab.brand}'s own reservation, kept until it is cancelled, and ${b.guest.name} pays at check-in.`;
1124
+ ? `${total} all in, to be paid at the hotel. ${h.name} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
1125
+ : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${h.name}'s own reservation, kept until it is cancelled, and ${b.guest.name} pays at check-in.`;
1067
1126
  }
1068
1127
  const lapses = b.holdUntilLocal
1069
1128
  ? `${b.holdUntilLocal} hotel time`
1070
1129
  : `${b.holdUntil?.slice(0, 16).replace('T', ' ')} UTC`;
1071
1130
  // ⚠️ NOT "PRESENTS A CARD AT CHECK-IN" (2026-10-05 audit, P2): a demo
1072
1131
  // booking has no check-in, and saying so sent a guest towards a real desk.
1073
- if (deps.vocab.demo)
1074
- return `${total} all in. **${demoBookingWords(deps)}** Nothing has been charged and no card is needed. The demonstration hold lapses on its own at ${lapses}.`;
1132
+ if (h.demo)
1133
+ return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. The demonstration hold lapses on its own at ${lapses}.`;
1075
1134
  return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and ${b.guest.name} presents a card at check-in.`;
1076
1135
  };
1077
1136
  /** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
1078
- const demoBookingWords = (deps) => `This is a demonstration booking: ${deps.vocab.brand} has not received it and is not holding any room.`;
1137
+ const demoBookingWords = (h) => `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
1079
1138
  /**
1080
1139
  * Said on every answer about a demonstration booking - made, changed or
1081
1140
  * cancelled - and on nothing else: a hold of the demo's that a guest took for
1082
1141
  * the hotel's would send them to a desk with a code nobody there has.
1083
1142
  */
1084
- const demoBookingNote = (deps) => deps.vocab.demo && (deps.backend.capabilities ?? FULL_CAPABILITIES).booking === 'hold'
1085
- ? demoBookingWords(deps)
1086
- : '';
1087
- const common = (deps, key) => ({
1088
- brand: deps.vocab.brand,
1089
- ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
1090
- toolNames: deps.offered
1091
- ? Object.fromEntries(Object.entries(deps.tools).filter(([, name]) => deps.offered?.has(name)))
1092
- : deps.tools,
1093
- bookingMode: (deps.backend.capabilities ?? FULL_CAPABILITIES).booking,
1094
- ...((deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone
1095
- ? { phoneRequired: true }
1096
- : {}),
1097
- ...(deps.vocab.sandbox ? { sandbox: true } : {}),
1098
- vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
1099
- ...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
1100
- ...(key ? memberBadge(deps, key) : {}),
1101
- });
1143
+ const demoBookingNote = (deps, supplier) => {
1144
+ const h = holderOf(deps, supplier);
1145
+ return h.demo && h.caps.booking === 'hold' ? demoBookingWords(h) : '';
1146
+ };
1147
+ const common = (deps, key) => {
1148
+ const caps = deps.backend.capabilities ?? FULL_CAPABILITIES;
1149
+ return {
1150
+ brand: deps.vocab.brand,
1151
+ ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
1152
+ toolNames: deps.offered
1153
+ ? Object.fromEntries(Object.entries(deps.tools).filter(([, name]) => deps.offered?.has(name)))
1154
+ : deps.tools,
1155
+ bookingMode: caps.booking,
1156
+ ...(caps.requiresPhone ? { phoneRequired: true } : {}),
1157
+ ...(deps.vocab.sandbox ? { sandbox: true } : {}),
1158
+ ...(caps.programme ? { programme: caps.programme } : {}),
1159
+ ...(caps.codePrefix ? { codePrefix: caps.codePrefix } : {}),
1160
+ // A market: the card draws each hotel and booking as its group's.
1161
+ ...(deps.backend.suppliers
1162
+ ? { market: { name: deps.vocab.brand, suppliers: deps.backend.suppliers() } }
1163
+ : {}),
1164
+ vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
1165
+ ...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
1166
+ ...(key ? memberBadge(deps, key) : {}),
1167
+ };
1168
+ };
1102
1169
  /**
1103
1170
  * When a room stops being free to cancel, the way a guest reads it: the
1104
1171
  * hotel's own clock when the backend knows it, else the instant in UTC - never
@@ -1107,10 +1174,12 @@ const common = (deps, key) => ({
1107
1174
  const freeUntilText = (r) => r.freeCancellationLocal ??
1108
1175
  `${(r.freeCancellationUntil ?? '').slice(0, 16).replace('T', ' ')} UTC`;
1109
1176
  /** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
1110
- const quoteForCard = (quote) => ({
1177
+ const quoteForCard = (quote, groupOf) => ({
1111
1178
  lines: quote.lines.map((l) => ({
1112
1179
  kind: l.kind,
1113
1180
  hotelId: l.hotelId,
1181
+ // In a market: whose stay this is, so the card books it with that group.
1182
+ ...(groupOf?.(l.hotelId) ? { supplier: groupOf(l.hotelId) } : {}),
1114
1183
  hotelName: l.hotelName,
1115
1184
  roomRef: l.roomRef,
1116
1185
  extraRef: l.extraRef,
@@ -1145,7 +1214,7 @@ const tripText = (quote, demo) => {
1145
1214
  if (quote.lines.length === 0)
1146
1215
  return 'The trip is empty.';
1147
1216
  const lines = quote.lines.map((l) => l.kind === 'room'
1148
- ? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.stay?.checkIn} to ${l.stay?.checkOut} (${l.nights} nights) — ${formatMoney(l.total)}`
1217
+ ? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.stay?.checkIn} to ${l.stay?.checkOut} (${l.nights} ${l.nights === 1 ? 'night' : 'nights'}) — ${formatMoney(l.total)}`
1149
1218
  : `- ${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)}`);
1150
1219
  return [
1151
1220
  ...lines,
@@ -1490,6 +1559,8 @@ const remember = (deps, k, amount) => {
1490
1559
  m.delete(oldest);
1491
1560
  }
1492
1561
  };
1562
+ /** "a, b and c" - "a and b" - "a". */
1563
+ const listAnd = (xs) => xs.length > 1 ? `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}` : (xs[0] ?? '');
1493
1564
  /** The counts a call can carry, and what each may be. */
1494
1565
  const COUNTS = ['guests', 'rooms', 'quantity'];
1495
1566
  /** "2" is 2: a host that sends a count as a string has not asked for something else. */
@@ -1639,12 +1710,15 @@ const currentTrip = async (deps, key, who) => {
1639
1710
  const [only] = parts;
1640
1711
  const orphans = needsRoomIn(deps.tools, lines, parts);
1641
1712
  const needs = orphans.needsRoom.length ? { needsRoom: orphans.needsRoom } : {};
1713
+ const groupOf = deps.backend.supplierOf
1714
+ ? (id) => deps.backend.supplierOf?.(id)
1715
+ : undefined;
1642
1716
  if (parts.length === 1 && only) {
1643
1717
  return {
1644
1718
  structured: {
1645
1719
  view: 'trip',
1646
1720
  ...common(deps, key),
1647
- ...quoteForCard(only),
1721
+ ...quoteForCard(only, groupOf),
1648
1722
  ...needs,
1649
1723
  count: lines.length,
1650
1724
  },
@@ -1653,7 +1727,7 @@ const currentTrip = async (deps, key, who) => {
1653
1727
  .join('\n'),
1654
1728
  };
1655
1729
  }
1656
- const shaped = parts.map(quoteForCard);
1730
+ const shaped = parts.map((p) => quoteForCard(p, groupOf));
1657
1731
  const bookable = parts.map((p) => p.lines.some((l) => l.kind === 'room'));
1658
1732
  return {
1659
1733
  structured: {
@@ -1703,6 +1777,7 @@ const filtersOf = (args) => {
1703
1777
  : [];
1704
1778
  const f = {
1705
1779
  ...(str(args.brand) ? { brand: str(args.brand) } : {}),
1780
+ ...(str(args.group) ? { supplier: str(args.group) } : {}),
1706
1781
  ...(facilities.length ? { facilities } : {}),
1707
1782
  ...(args.freeCancellation === true ? { freeCancellation: true } : {}),
1708
1783
  ...(args.breakfast === true ? { breakfast: true } : {}),
@@ -1746,7 +1821,16 @@ const refineFor = (r, on, total,
1746
1821
  dated = false) => {
1747
1822
  const narrows = (count, active) => active || (count > 0 && count < total);
1748
1823
  const brands = r.brands.filter((b) => narrows(b.count, b.name === on?.brand));
1824
+ // In a market: the groups, by id - the card names them from `market`.
1825
+ const groups = (r.suppliers ?? []).filter((x) => narrows(x.count, x.id === on?.supplier));
1749
1826
  return {
1827
+ ...(r.suppliers
1828
+ ? {
1829
+ groups: groups.length > 1 || on?.supplier
1830
+ ? groups.map((x) => ({ ...x, on: x.id === on?.supplier }))
1831
+ : [],
1832
+ }
1833
+ : {}),
1750
1834
  brands: brands.length > 1 || on?.brand ? brands.map((b) => ({ ...b, on: b.name === on?.brand })) : [],
1751
1835
  facilities: r.facilities
1752
1836
  .filter((x) => narrows(x.count, !!on?.facilities?.includes(x.key)))
@@ -1943,6 +2027,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1943
2027
  : undefined,
1944
2028
  filters: filtersOf(args),
1945
2029
  };
2030
+ // A group as a guest names it, to the id the market files it under.
2031
+ const named = ask.filters?.supplier ? groupNamed(deps, ask.filters.supplier) : undefined;
2032
+ if (ask.filters?.supplier && named)
2033
+ ask.filters = { ...ask.filters, supplier: named.id };
1946
2034
  const who = identity(deps, ctx, key, args);
1947
2035
  let answer = await deps.backend.searchHotels(ask, who);
1948
2036
  // A brand as the data spells it, so its chip shows as ON and can be turned
@@ -2108,7 +2196,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2108
2196
  ...(ask.stay ? { stay: ask.stay } : {}),
2109
2197
  ...(today ? { today } : {}),
2110
2198
  },
2111
- text: `${total} ${deps.vocab.brand} ${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}.`,
2199
+ 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}.`,
2112
2200
  };
2113
2201
  }
2114
2202
  const regions = [
@@ -2132,7 +2220,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2132
2220
  ...(ask.stay ? { stay: ask.stay } : {}),
2133
2221
  ...(today ? { today } : {}),
2134
2222
  },
2135
- text: `${total} ${deps.vocab.brand} ${n} in ${facets.cities.length} cities across ${countries.length} 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}.`,
2223
+ text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} 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}.`,
2136
2224
  };
2137
2225
  }
2138
2226
  if (hotels.length === 0) {
@@ -2166,7 +2254,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2166
2254
  : ceilingEmpty
2167
2255
  ? `None of the ${answer.beforeCeiling} that match has a room for those nights.`
2168
2256
  : [
2169
- `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
2257
+ `No ${branded(deps.vocab, deps.vocab.nounPlural)} match that.`,
2170
2258
  // What was tried and also failed is worth a sentence: it tells a
2171
2259
  // guest the word itself is the problem, not the narrowing.
2172
2260
  widened ? `${sentence(widened.to)} found nothing either.` : '',
@@ -2278,7 +2366,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2278
2366
  ...(answer.currencies.length > 1 ? { currencies: answer.currencies } : {}),
2279
2367
  },
2280
2368
  text: [
2281
- `${total} ${deps.vocab.brand} ${total === 1 ? deps.vocab.noun : deps.vocab.nounPlural}${where}.`,
2369
+ `${total} ${branded(deps.vocab, total === 1 ? deps.vocab.noun : deps.vocab.nounPlural)}${where}.`,
2282
2370
  // ⚠️ NO "BELOW", NO "TAP". This sentence is read out by a voice
2283
2371
  // assistant as often as it is drawn next to a card, and a guest whose
2284
2372
  // host has no card at all cannot tap the one it tells them to tap. The
@@ -2301,7 +2389,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2301
2389
  if (!detail) {
2302
2390
  return {
2303
2391
  structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
2304
- text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name. ${t.listHotels} lists them.`,
2392
+ text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name. ${t.listHotels} lists them.`,
2305
2393
  };
2306
2394
  }
2307
2395
  const h = detail.hotel;
@@ -2458,9 +2546,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2458
2546
  ...(dropped.length ? { dropped } : {}),
2459
2547
  },
2460
2548
  text: city
2461
- ? `No ${deps.vocab.brand} ${deps.vocab.nounPlural} in "${city}". ${t.listHotels} with no place shows the places there are.`
2549
+ ? `No ${branded(deps.vocab, deps.vocab.nounPlural)} in "${city}". ${t.listHotels} with no place shows the places there are.`
2462
2550
  : named.length
2463
- ? `No ${deps.vocab.brand} ${deps.vocab.noun} answers to ${dropped.map((d) => `"${d}"`).join(', ')}.`
2551
+ ? `No ${branded(deps.vocab, deps.vocab.noun)} answers to ${dropped.map((d) => `"${d}"`).join(', ')}.`
2464
2552
  : `Name a \`city\`, or up to ${LOOK_UP_MAX} ${deps.vocab.nounPlural} in \`${deps.vocab.nounPlural}\`.`,
2465
2553
  };
2466
2554
  }
@@ -2520,7 +2608,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2520
2608
  total > read.length
2521
2609
  ? `${total - read.length} more ${total - read.length === 1 ? 'is' : 'are'} there; name ${total - read.length === 1 ? 'it' : 'them'} in \`${deps.vocab.nounPlural}\` to read ${total - read.length === 1 ? 'it' : 'them'}.`
2522
2610
  : '',
2523
- `Draw the answer now as ONE card with these ids: a plan or days the guest asked for (行程 - recommended or arranged) is ${t.itinerary}; which ${deps.vocab.noun} to stay at is ${t.compare}. Not ${t.listHotels}, ${t.getHotel} or ${t.listExtras}, which each draw another card.`,
2611
+ // ⚠️ WHAT THE IDS ARE FOR, SAID AS A FACT (2026-10-05 audit, P3): this was
2612
+ // "Draw the answer now as ONE card", an instruction in a tool result.
2613
+ // The rule itself lives in the server instructions; what stays here is
2614
+ // what each next call would draw, which is what kept it to one card.
2615
+ `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(
2616
+ // Only tools this deployment offers: a name it withholds is one the
2617
+ // model would try (2026-10-05 audit, P2 - Huazhu has no add-ons).
2618
+ [t.listHotels, t.getHotel, t.listExtras].filter((n) => !deps.offered || deps.offered.has(n)))} would each draw a card of their own.`,
2524
2619
  deps.vocab.demo ? DEMO_NOTE : '',
2525
2620
  ]
2526
2621
  .filter(Boolean)
@@ -2569,7 +2664,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2569
2664
  if (!detail) {
2570
2665
  return {
2571
2666
  structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
2572
- text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
2667
+ text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
2573
2668
  };
2574
2669
  }
2575
2670
  const extras = await deps.backend.extrasFor(detail.hotel.id, booked?.stay ?? asStay(args));
@@ -2613,7 +2708,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2613
2708
  error: 'hotel-not-found',
2614
2709
  query: wanted,
2615
2710
  },
2616
- text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
2711
+ text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
2617
2712
  };
2618
2713
  }
2619
2714
  const hotelId = detail.hotel.id;
@@ -2629,9 +2724,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2629
2724
  error: 'no-such-extra',
2630
2725
  query: extraRef,
2631
2726
  },
2632
- text: (deps.backend.capabilities ?? FULL_CAPABILITIES).extras
2727
+ text: holderOf(deps, detail.hotel.supplier).caps.extras
2633
2728
  ? `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`
2634
- : `${deps.vocab.brand} ${deps.vocab.nounPlural} sell rooms only here - there are no add-ons.`,
2729
+ : `${holderOf(deps, detail.hotel.supplier).name} ${deps.vocab.nounPlural} sell rooms only here - there are no add-ons.`,
2635
2730
  };
2636
2731
  }
2637
2732
  const line = {
@@ -2714,17 +2809,39 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2714
2809
  };
2715
2810
  }
2716
2811
  if (name === t.requestBooking) {
2717
- const lines = deps.trips.read(key);
2718
- if (lines.length === 0) {
2812
+ const inTrip = deps.trips.read(key);
2813
+ if (inTrip.length === 0) {
2719
2814
  return {
2720
2815
  structured: { view: 'empty-trip', ...common(deps, key) },
2721
2816
  text: `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`,
2722
2817
  };
2723
2818
  }
2819
+ // ⚠️ ONE GROUP AT A TIME, in a market: a booking cannot be all-or-nothing
2820
+ // across two groups' systems (Marketplace, §6-1). `group` books one
2821
+ // group's stays and leaves the rest in the trip.
2822
+ const asked = deps.backend.suppliers ? str(args.group) : '';
2823
+ const group = asked ? groupNamed(deps, asked) : undefined;
2824
+ const groupOf = (l) => deps.backend.supplierOf?.(l.hotelId);
2825
+ const lines = group ? inTrip.filter((l) => groupOf(l) === group.id) : inTrip;
2826
+ if (asked && (!group || lines.length === 0)) {
2827
+ const there = [...new Set(inTrip.map(groupOf))]
2828
+ .map((id) => holderOf(deps, id).name)
2829
+ .filter(Boolean);
2830
+ const words = group
2831
+ ? `Nothing in the trip is with ${group.name}. It holds stays with ${listAnd(there)}.`
2832
+ : `No hotel group here is called "${asked}". The trip holds stays with ${listAnd(there)}.`;
2833
+ return {
2834
+ structured: { view: 'error', ...common(deps, key), error: 'no-such-group', detail: words },
2835
+ text: words,
2836
+ };
2837
+ }
2724
2838
  const guest = { name: str(args.name), email: str(args.email) };
2725
2839
  // A phone where the backend needs one: a booking paid at the hotel and
2726
2840
  // guaranteed by nobody, which the hotel has to be able to follow up.
2727
- const phoneNeeded = !!(deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone;
2841
+ // In a market, the trip's own groups decide - not whichever group needs most.
2842
+ const phoneNeeded = deps.backend.suppliers
2843
+ ? lines.some((l) => !!holderOf(deps, deps.backend.supplierOf?.(l.hotelId)).caps.requiresPhone)
2844
+ : !!(deps.backend.capabilities ?? FULL_CAPABILITIES).requiresPhone;
2728
2845
  const phone = str(args.phone);
2729
2846
  const missing = [
2730
2847
  !guest.name && 'a name',
@@ -2759,7 +2876,16 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2759
2876
  // only place an Identity is built; the guest's email still overrides,
2760
2877
  // because a booking is found by the address it was made with.
2761
2878
  { ...identity(deps, ctx, key), email: guest.email }, requests);
2762
- deps.trips.clear(key);
2879
+ if (lines.length === inTrip.length)
2880
+ deps.trips.clear(key);
2881
+ else
2882
+ for (const l of lines)
2883
+ deps.trips.set(key, unset(l));
2884
+ // What is left is priced again, and that price is what the next
2885
+ // group's booking is held to (ShownQuote).
2886
+ const rest = lines.length === inTrip.length
2887
+ ? undefined
2888
+ : await currentTrip(deps, key, identity(deps, ctx, key));
2763
2889
  // ⚠️ REMEMBERED ONLY WHEN ASKED - see `rememberPreferences`. Said either
2764
2890
  // way, so neither the guest nor the model assumes something was kept.
2765
2891
  const signedIn = deps.members.read(key);
@@ -2844,7 +2970,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2844
2970
  },
2845
2971
  text: [
2846
2972
  several
2847
- ? `${booked.length} bookings, one for each stay - each hotel keeps its own nights under its own code, and each is ${(deps.backend.capabilities ?? FULL_CAPABILITIES).changes ? 'changed or cancelled' : 'cancelled'} on its own.`
2973
+ ? `${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.`
2848
2974
  : '',
2849
2975
  ...booked.flatMap(({ booking, asked }) => [
2850
2976
  `**${booking.confirmationCode}** · ${booking.hotelName} · ${booking.stay.checkIn} to ${booking.stay.checkOut}`,
@@ -2852,6 +2978,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2852
2978
  ...asked,
2853
2979
  ]),
2854
2980
  remembered?.line ?? '',
2981
+ rest ? `The trip's other stays are still in it, not booked:\n${rest.text}` : '',
2855
2982
  deps.vocab.demo ? DEMO_NOTE : '',
2856
2983
  ]
2857
2984
  .filter(Boolean)
@@ -2867,6 +2994,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2867
2994
  const again = await currentTrip(deps, key, identity(deps, ctx, key));
2868
2995
  return { ...again, text: [words, again.text].join('\n\n') };
2869
2996
  }
2997
+ if (err.code === 'mixed-supplier') {
2998
+ const again = await currentTrip(deps, key, identity(deps, ctx, key));
2999
+ return {
3000
+ ...again,
3001
+ text: [
3002
+ words,
3003
+ `${t.requestBooking} with \`group\` books one group's stays and leaves the others in the trip.`,
3004
+ again.text,
3005
+ ].join('\n\n'),
3006
+ };
3007
+ }
2870
3008
  return {
2871
3009
  structured: {
2872
3010
  view: 'error',
@@ -2983,7 +3121,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2983
3121
  : 'The total is unchanged.',
2984
3122
  preview.rule,
2985
3123
  `Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
2986
- demoBookingNote(deps),
3124
+ demoBookingNote(deps, preview.supplier),
2987
3125
  deps.vocab.demo ? DEMO_NOTE : '',
2988
3126
  ]
2989
3127
  .filter(Boolean)
@@ -3046,7 +3184,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3046
3184
  text: [
3047
3185
  `${changed.confirmationCode} now holds ${changed.stay.checkIn} to ${changed.stay.checkOut}, ${formatMoney(changed.quote.total)} all in.`,
3048
3186
  'The confirmation code has not changed. **Nothing has been charged.**',
3049
- demoBookingNote(deps),
3187
+ demoBookingNote(deps, changed.supplier),
3050
3188
  deps.vocab.demo ? DEMO_NOTE : '',
3051
3189
  ]
3052
3190
  .filter(Boolean)
@@ -3094,7 +3232,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3094
3232
  preview.cancellable
3095
3233
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
3096
3234
  : '',
3097
- demoBookingNote(deps),
3235
+ demoBookingNote(deps, preview.supplier),
3098
3236
  deps.vocab.demo ? DEMO_NOTE : '',
3099
3237
  ]
3100
3238
  .filter(Boolean)
@@ -3130,6 +3268,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3130
3268
  // the rate's terms, and where the two differ the hotel's is the one that
3131
3269
  // will be charged.
3132
3270
  const charged = cancelled.cancellationFee ?? preview.fee;
3271
+ const h = holderOf(deps, cancelled.supplier);
3133
3272
  return {
3134
3273
  structured: {
3135
3274
  view: 'cancelled',
@@ -3140,14 +3279,14 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3140
3279
  cancellable: false,
3141
3280
  },
3142
3281
  text: [
3143
- deps.vocab.sandbox
3282
+ h.sandbox
3144
3283
  ? // A test booking cancelled in the simulation: no hotel, no charge (2026-10-05 audit, P2).
3145
3284
  `${cancelled.confirmationCode} is cancelled in the simulated system. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this one is a test, and nothing is charged.`}`
3146
- : deps.vocab.demo
3285
+ : h.demo
3147
3286
  ? // Nobody charges a demonstration anything (2026-10-05 audit, P2).
3148
3287
  `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `A real booking on this rate would be charged ${formatMoney(charged)}; this is a demonstration, and nothing is charged.`}`
3149
3288
  : `${cancelled.confirmationCode} is cancelled. ${charged.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(charged)}.`}`,
3150
- demoBookingNote(deps),
3289
+ demoBookingNote(deps, cancelled.supplier),
3151
3290
  deps.vocab.demo ? DEMO_NOTE : '',
3152
3291
  ]
3153
3292
  .filter(Boolean)
@@ -3176,7 +3315,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3176
3315
  ...common(deps, key),
3177
3316
  dropped: comparison.dropped,
3178
3317
  },
3179
- text: `None of those are ${deps.vocab.brand} ${deps.vocab.nounPlural}: ${comparison.dropped.map((d) => d.name).join(', ')}.`,
3318
+ text: `None of those are ${catalogue(deps.vocab)}: ${comparison.dropped.map((d) => d.name).join(', ')}.`,
3180
3319
  };
3181
3320
  }
3182
3321
  return {
@@ -3332,7 +3471,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
3332
3471
  if (!detail) {
3333
3472
  return {
3334
3473
  structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
3335
- text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
3474
+ text: `No ${branded(deps.vocab, deps.vocab.noun)} by that name.`,
3336
3475
  };
3337
3476
  }
3338
3477
  const policies = await deps.backend.policies(detail.hotel.id);