@curless/hotel-storefront 0.18.2 → 0.19.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 (56) hide show
  1. package/dist/__tests__/demo-hotels.test.js +21 -2
  2. package/dist/__tests__/demo-hotels.test.js.map +1 -1
  3. package/dist/__tests__/server.test.js +17 -3
  4. package/dist/__tests__/server.test.js.map +1 -1
  5. package/dist/__tests__/tools.test.js +282 -5
  6. package/dist/__tests__/tools.test.js.map +1 -1
  7. package/dist/backend.d.ts +12 -1
  8. package/dist/backend.d.ts.map +1 -1
  9. package/dist/backend.js +7 -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/data.d.ts +2 -0
  15. package/dist/data.d.ts.map +1 -1
  16. package/dist/data.js +24 -7
  17. package/dist/data.js.map +1 -1
  18. package/dist/demo-hotels.d.ts +24 -2
  19. package/dist/demo-hotels.d.ts.map +1 -1
  20. package/dist/demo-hotels.js +313 -95
  21. package/dist/demo-hotels.js.map +1 -1
  22. package/dist/legal.d.ts +1 -1
  23. package/dist/legal.d.ts.map +1 -1
  24. package/dist/legal.js +31 -10
  25. package/dist/legal.js.map +1 -1
  26. package/dist/limits.d.ts +12 -0
  27. package/dist/limits.d.ts.map +1 -0
  28. package/dist/limits.js +47 -0
  29. package/dist/limits.js.map +1 -0
  30. package/dist/members.d.ts +9 -0
  31. package/dist/members.d.ts.map +1 -1
  32. package/dist/members.js +17 -0
  33. package/dist/members.js.map +1 -1
  34. package/dist/money.d.ts +6 -1
  35. package/dist/money.d.ts.map +1 -1
  36. package/dist/money.js +6 -1
  37. package/dist/money.js.map +1 -1
  38. package/dist/present.d.ts +2 -1
  39. package/dist/present.d.ts.map +1 -1
  40. package/dist/present.js +3 -2
  41. package/dist/present.js.map +1 -1
  42. package/dist/server.d.ts +8 -8
  43. package/dist/server.d.ts.map +1 -1
  44. package/dist/server.js +13 -4
  45. package/dist/server.js.map +1 -1
  46. package/dist/surface.d.ts +2 -2
  47. package/dist/surface.d.ts.map +1 -1
  48. package/dist/surface.js +2 -2
  49. package/dist/surface.js.map +1 -1
  50. package/dist/tools.d.ts +39 -13
  51. package/dist/tools.d.ts.map +1 -1
  52. package/dist/tools.js +376 -95
  53. package/dist/tools.js.map +1 -1
  54. package/dist/types.d.ts +12 -4
  55. package/dist/types.d.ts.map +1 -1
  56. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -71,7 +71,7 @@ export const buildTools = (n, v) => {
71
71
  return [
72
72
  {
73
73
  name: n.listHotels,
74
- description: `${v.brand} ${v.nounPlural}, shown as a card with a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price for each. \`city\` or \`query\` narrows the list; a city usually holds several ${v.nounPlural}, and all of them are returned. Unnarrowed it answers with one page of a longer list, ordered by how many ${v.nounPlural} the group has in each city - a country is ordered the same way - and says how many it left out and where the next page starts. \`sort\` and \`maxNightly\` order and cap by the from-price where the matches share one currency. Words or a place that match nothing are widened once, and the answer names what it widened to. Dates are not needed here and no stay is priced.${v.hints.search ? ` ${v.hints.search}` : ''}`,
74
+ 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}` : ''}`,
75
75
  annotations: reads(`Browse ${v.brand} ${v.nounPlural}`, open),
76
76
  inputSchema: {
77
77
  type: 'object',
@@ -80,15 +80,23 @@ export const buildTools = (n, v) => {
80
80
  type: 'string',
81
81
  description: "Free text over the name, city, area, description and amenities - the guest's own words",
82
82
  },
83
- city: { type: 'string', description: 'One city, e.g. "Hong Kong"' },
83
+ city: {
84
+ type: 'string',
85
+ description: 'One city or one country, e.g. "Hong Kong" or "Japan"',
86
+ },
87
+ ...stayProps,
88
+ rooms: {
89
+ type: 'number',
90
+ description: `With dates: how many rooms of one type the party needs, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
91
+ },
84
92
  sort: {
85
93
  type: 'string',
86
94
  enum: ['group', 'price-low', 'price-high', 'rating'],
87
- description: `Default \`group\`: the cities the group has most ${v.nounPlural} in lead the list. A price order holds only where the matches share one currency - across currencies it is dropped and the answer says which currencies they are`,
95
+ description: `Default \`group\`: the cities the group has most ${v.nounPlural} in lead the list. A price order - by the stay's price with dates, the from-price without - holds only where the matches share one currency; across currencies it is dropped and the answer says which currencies they are`,
88
96
  },
89
97
  maxNightly: {
90
98
  type: 'number',
91
- description: 'A ceiling on the from-price, in the currency the matches are quoted in. Across currencies it is dropped and the answer says so',
99
+ description: 'A ceiling on the price of a night - of the stay with dates, the from-price without - in the currency the matches are quoted in. Across currencies it is dropped and the answer says so',
92
100
  },
93
101
  limit: {
94
102
  type: 'number',
@@ -153,7 +161,11 @@ export const buildTools = (n, v) => {
153
161
  ...stayProps,
154
162
  booking: {
155
163
  type: 'string',
156
- description: `A booking's confirmation code. The answer is then that booking's ${v.noun}, its add-ons for those nights and how many of each the booking holds; ${n.previewChange} changes them`,
164
+ description: `A booking's confirmation code. The answer is then that booking's ${v.noun}, its add-ons for those nights and how many of each the booking holds`,
165
+ },
166
+ email: {
167
+ type: 'string',
168
+ description: 'The email the booking was made with, when it was made elsewhere',
157
169
  },
158
170
  },
159
171
  },
@@ -227,7 +239,7 @@ export const buildTools = (n, v) => {
227
239
  },
228
240
  rememberRequests: {
229
241
  type: 'boolean',
230
- 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, connecting or accessible room',
242
+ 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',
231
243
  },
232
244
  },
233
245
  required: ['name', 'email'],
@@ -471,7 +483,7 @@ export const buildTools = (n, v) => {
471
483
  },
472
484
  {
473
485
  name: n.forgetPreferences,
474
- description: `Forgets the room preferences saved on the signed-in ${v.brand} membership - a bed type, a floor, a quiet or accessible room. Bookings already made keep what they asked for.`,
486
+ description: `Forgets the room preferences saved on the signed-in ${v.brand} membership - a bed type, a floor, a quiet or connecting room. Bookings already made keep what they asked for.`,
475
487
  annotations: {
476
488
  title: 'Forget my saved preferences',
477
489
  readOnlyHint: false,
@@ -816,6 +828,10 @@ const quoteForCard = (quote) => ({
816
828
  stay: l.stay,
817
829
  nights: l.nights,
818
830
  quantity: l.quantity,
831
+ // How many were ORDERED, beside how many units that comes to: one extra
832
+ // bed over three nights is quantity 3 and ordered 1, and the card's
833
+ // stepper read 3 and sent 3 ± 1 as the new count (2026-09-28 audit).
834
+ ...(l.ordered !== undefined ? { ordered: l.ordered } : {}),
819
835
  unit: formatMoney(l.unit),
820
836
  total: formatMoney(l.total),
821
837
  refundable: l.refundable,
@@ -839,7 +855,7 @@ const tripText = (quote, demo) => {
839
855
  return 'The trip is empty.';
840
856
  const lines = quote.lines.map((l) => l.kind === 'room'
841
857
  ? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.stay?.checkIn} to ${l.stay?.checkOut} (${l.nights} nights) — ${formatMoney(l.total)}`
842
- : `- ${l.quantity}× ${l.title} — ${formatMoney(l.total)}`);
858
+ : `- ${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)}`);
843
859
  return [
844
860
  ...lines,
845
861
  `Taxes and fees ${formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
@@ -1000,23 +1016,30 @@ const widen = (ask) => {
1000
1016
  };
1001
1017
  /** What a change would do, in one sentence - the nights, and the add-on when it sets one. */
1002
1018
  const changeSentence = (p) => {
1003
- const moves = !!p.to &&
1004
- (p.to.stay.checkIn !== p.from.stay.checkIn ||
1005
- p.to.stay.checkOut !== p.from.stay.checkOut ||
1006
- p.to.rooms !== p.from.rooms ||
1007
- p.to.roomName !== p.from.roomName);
1019
+ // ⚠️ EACH THING THAT CHANGES, NAMED AS WHAT IT IS. Two rooms down to one
1020
+ // was said as "would move to" the nights it already held, and an add-on set
1021
+ // to the count it already had as "take it off" (2026-09-28 audit).
1022
+ const to = p.to;
1023
+ const rooms = (n) => `${n} room${n === 1 ? '' : 's'}`;
1008
1024
  const a = p.addOn;
1009
- const addOn = !a
1010
- ? ''
1011
- : a.to === 0
1012
- ? `take ${a.name} off`
1013
- : a.from === 0
1014
- ? `add ${a.to}× ${a.name}`
1015
- : `change ${a.name} from ${a.from} to ${a.to}`;
1016
- const nights = moves
1017
- ? `move to ${p.to?.stay.checkIn} - ${p.to?.stay.checkOut}${p.to && p.to.rooms > 1 ? `, ${p.to.rooms} rooms` : ''}`
1018
- : '';
1019
- return `${p.confirmationCode} would ${[nights, addOn].filter(Boolean).join(' and ')}.`;
1025
+ const parts = [
1026
+ to && (to.stay.checkIn !== p.from.stay.checkIn || to.stay.checkOut !== p.from.stay.checkOut)
1027
+ ? `move to ${to.stay.checkIn} - ${to.stay.checkOut}`
1028
+ : '',
1029
+ to && to.roomName !== p.from.roomName ? `change the room to ${to.roomName}` : '',
1030
+ to && to.rooms !== p.from.rooms ? `go from ${rooms(p.from.rooms)} to ${rooms(to.rooms)}` : '',
1031
+ !a || a.from === a.to
1032
+ ? ''
1033
+ : a.to === 0
1034
+ ? `take ${a.name} off`
1035
+ : a.from === 0
1036
+ ? `add ${a.to}× ${a.name}`
1037
+ : `change ${a.name} from ${a.from} to ${a.to}`,
1038
+ ].filter(Boolean);
1039
+ const said = parts.length > 1
1040
+ ? `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`
1041
+ : (parts[0] ?? 'change');
1042
+ return `${p.confirmationCode} would ${said}.`;
1020
1043
  };
1021
1044
  /**
1022
1045
  * How much of a plan is real, in the one sentence the model is told to repeat:
@@ -1135,6 +1158,57 @@ const identity = (deps, ctx, key, args = {}) => {
1135
1158
  ...(member ? { member } : {}),
1136
1159
  };
1137
1160
  };
1161
+ /**
1162
+ * What the last preview of each booking's cancellation or change showed, per
1163
+ * conversation.
1164
+ *
1165
+ * ⚠️ A CONFIRMATION CONFIRMS WHAT WAS SHOWN. A cancellation previewed at no
1166
+ * charge and confirmed after the free window closed was cancelled for a
1167
+ * night's rate nobody had been shown; a `confirmed` with no preview at all was
1168
+ * cancelled on the spot (2026-09-28 audit). Now a confirmation goes through
1169
+ * only when the fee - or the change's new total - is the one last shown in
1170
+ * this conversation; otherwise it answers with the preview again.
1171
+ */
1172
+ const shownPreviews = new WeakMap();
1173
+ const MAX_SHOWN = 5_000;
1174
+ const previewsOf = (deps) => {
1175
+ let m = shownPreviews.get(deps);
1176
+ if (!m) {
1177
+ m = new Map();
1178
+ shownPreviews.set(deps, m);
1179
+ }
1180
+ return m;
1181
+ };
1182
+ const remember = (deps, k, amount) => {
1183
+ const m = previewsOf(deps);
1184
+ m.delete(k);
1185
+ m.set(k, amount);
1186
+ while (m.size > MAX_SHOWN) {
1187
+ const oldest = m.keys().next().value;
1188
+ if (oldest === undefined)
1189
+ break;
1190
+ m.delete(oldest);
1191
+ }
1192
+ };
1193
+ /** Said when a lookup has missed too often - see limits.ts. */
1194
+ const TOO_MANY_TRIES = 'Too many codes and emails that did not match, so looking a booking up by email is paused for a quarter of an hour. Check the code and the email on the confirmation, then try again.';
1195
+ /**
1196
+ * A booking lookup, with any email in it counted as a guess.
1197
+ *
1198
+ * ⚠️ A CODE AND AN EMAIL ARE ONLY A PAIR WHEN SOMETHING COUNTS THE MISSES. A
1199
+ * miss that carried an email is counted against the conversation AND the
1200
+ * email; after a few, lookups by email wait (limits.ts). A lookup of this
1201
+ * conversation's own booking, with no email, is never counted and never waits.
1202
+ */
1203
+ const guarded = async (deps, key, who, run) => {
1204
+ const keys = who.email ? [`lookup|${key}`, `email|${who.email.trim().toLowerCase()}`] : [];
1205
+ if (keys.length && deps.misses?.blocked(keys))
1206
+ return { found: null, tooMany: true };
1207
+ const found = await run();
1208
+ if (!found && keys.length)
1209
+ deps.misses?.miss(keys);
1210
+ return { found, tooMany: false };
1211
+ };
1138
1212
  /**
1139
1213
  * The trip priced one currency at a time, in the order the currencies first
1140
1214
  * appear in it.
@@ -1215,7 +1289,9 @@ const currentTrip = async (deps, key, who) => {
1215
1289
  // never a sum.
1216
1290
  groups: shaped.map((p, i) => ({ ...p, currency: parts[i]?.total.currency })),
1217
1291
  currencies: parts.map((p) => p.total.currency),
1218
- total: shaped.map((p) => p.total).join(' + '),
1292
+ // Side by side with a dot, not a plus: "HK$8,588 + ¥152,550" reads as
1293
+ // a sum nobody can do.
1294
+ total: shaped.map((p) => p.total).join(' · '),
1219
1295
  ...needs,
1220
1296
  ...(shaped.some((p) => p.dropped) ? { dropped: shaped.flatMap((p) => p.dropped ?? []) } : {}),
1221
1297
  count: lines.length,
@@ -1317,7 +1393,9 @@ const suggestionsFor = async (deps, d) => {
1317
1393
  const nearby = await deps.backend.searchHotels({ city: hotel.city, limit: 2 });
1318
1394
  if (nearby.total > 1)
1319
1395
  out.push({
1320
- label: `Compare with the other ${hotel.city} hotels`,
1396
+ // The label IS what is sent: a chip that sends more than it says is
1397
+ // a chip that says something else in the guest's name.
1398
+ label: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
1321
1399
  say: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
1322
1400
  });
1323
1401
  }
@@ -1338,7 +1416,7 @@ const suggestionsFor = async (deps, d) => {
1338
1416
  }
1339
1417
  if (view === 'itinerary' && Number(d.planned) > Number(d.inTrip) + Number(d.booked ?? 0))
1340
1418
  out.push({
1341
- label: 'Put these stays in the trip',
1419
+ label: 'Put the stays on this plan in the trip',
1342
1420
  say: 'Put the stays on this plan in the trip',
1343
1421
  });
1344
1422
  if (view === 'booking') {
@@ -1395,11 +1473,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1395
1473
  sort,
1396
1474
  maxNightly: ceiling,
1397
1475
  stay: asStay(args),
1398
- rooms: typeof args.rooms === 'number' ? args.rooms : undefined,
1476
+ // Clamped here, so what is said and echoed is what was priced: "9 rooms"
1477
+ // was said over prices for five.
1478
+ rooms: typeof args.rooms === 'number' && Number.isFinite(args.rooms)
1479
+ ? Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(args.rooms)))
1480
+ : undefined,
1399
1481
  filters: filtersOf(args),
1400
1482
  };
1401
1483
  const who = identity(deps, ctx, key, args);
1402
1484
  let answer = await deps.backend.searchHotels(ask, who);
1485
+ // A brand as the data spells it, so its chip shows as ON and can be turned
1486
+ // off: `brand: 'kerry'` filtered, and then no chip matched it.
1487
+ const brandAsked = ask.filters?.brand?.toLowerCase();
1488
+ const brandNamed = answer.refine?.brands.find((b) => b.name.toLowerCase() === brandAsked)?.name;
1489
+ if (ask.filters && brandNamed)
1490
+ ask.filters = { ...ask.filters, brand: brandNamed };
1403
1491
  // The search the answer actually came from - the widened one, when it was
1404
1492
  // widened - so its next page is the next page of THAT.
1405
1493
  let asked = ask;
@@ -1419,20 +1507,29 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1419
1507
  // comment was written to forbid, committed by the code under it.
1420
1508
  // `overBudget` is how the seam says the place and the words did match.
1421
1509
  let widened;
1422
- // ⚠️ A FILTER THAT EMPTIED THE LIST IS NOT A SEARCH THAT FAILED either,
1423
- // for the same reason as the ceiling below: the place matched.
1424
- const filteredEmpty = !!ask.filters && (answer.beforeFilters ?? 0) > 0;
1425
- if (answer.total === 0 && !answer.overBudget && !filteredEmpty) {
1510
+ // ⚠️ A FILTER OR A CEILING THAT EMPTIED THE LIST IS NOT A SEARCH THAT
1511
+ // FAILED either, for the same reason as the ceiling above: the place
1512
+ // matched. What matched the place and the words, before either:
1513
+ const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
1514
+ if (placedCount(answer) === 0) {
1426
1515
  const wider = widen(ask);
1427
1516
  if (wider) {
1428
1517
  const second = await deps.backend.searchHotels(wider.ask, who);
1429
- widened = { from: wider.from, to: wider.to, found: second.total > 0 };
1430
- if (second.total > 0) {
1518
+ // Found when the wider PLACE matched - a wider search emptied by the
1519
+ // same ceiling or filter is still the place, and "Hong Kong found
1520
+ // nothing either" was said of four hotels over the ceiling.
1521
+ widened = { from: wider.from, to: wider.to, found: placedCount(second) > 0 };
1522
+ if (widened.found) {
1431
1523
  answer = second;
1432
1524
  asked = { ...ask, ...wider.ask };
1433
1525
  }
1434
1526
  }
1435
1527
  }
1528
+ const filteredEmpty = !!asked.filters &&
1529
+ answer.total === 0 &&
1530
+ placedCount(answer) > 0 &&
1531
+ (answer.beforeCeiling ?? 0) === 0;
1532
+ const ceilingEmpty = answer.total === 0 && (answer.beforeCeiling ?? 0) > 0;
1436
1533
  // The ceiling again on this side of the seam. MAX_PAGE is part of the
1437
1534
  // backend contract, but "the next backend honours it" is not something the
1438
1535
  // card can be built on - and the cost of it being wrong is the whole group
@@ -1486,6 +1583,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1486
1583
  ...(ask.rooms && ask.rooms > 1 ? { rooms: ask.rooms } : {}),
1487
1584
  }
1488
1585
  : {};
1586
+ // ⚠️ THE SEARCH AS ASKED, `all` INCLUDED. The echo dropped `all`, so every
1587
+ // re-query the card made from a "Browse all" list - new dates, the default
1588
+ // order, clearing a filter - met no place, no words and no filter, and
1589
+ // came back as the destinations page (2026-09-28 audit). Built once, for
1590
+ // the list and for the list a filter or a ceiling emptied.
1591
+ const listAll = args.all === true;
1592
+ const echo = (applied) => ({
1593
+ ...(asked.query ? { query: asked.query } : {}),
1594
+ ...(asked.city ? { city: asked.city } : {}),
1595
+ ...(applied !== 'group' ? { sort: applied } : {}),
1596
+ ...(asked.maxNightly ? { maxNightly: asked.maxNightly } : {}),
1597
+ ...(asked.filters ?? {}),
1598
+ ...(listAll ? { all: true } : {}),
1599
+ ...stayArgs,
1600
+ });
1489
1601
  const namedCountry = !!asked.city &&
1490
1602
  facets?.countries.length === 1 &&
1491
1603
  facets.countries[0]?.name.toLowerCase() === asked.city.toLowerCase();
@@ -1495,8 +1607,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1495
1607
  sort === 'group' &&
1496
1608
  !asked.maxNightly &&
1497
1609
  !offset &&
1498
- args.all !== true &&
1610
+ !listAll &&
1611
+ // A widened search is a list with the miss said over it: as a page of
1612
+ // places, "helipad" matching nothing in China was never said at all.
1613
+ !widened &&
1499
1614
  (!asked.city || (namedCountry && facets.cities.length >= CITY_PICKER_MIN));
1615
+ // "Today" where the hotels are - earliest and latest - for a page that has
1616
+ // no one hotel's clock to fill its date bar from (see the card's
1617
+ // `todayRange`): the UTC date was yesterday in Asia before 8am.
1618
+ const days = answer.hotels
1619
+ .map((h) => h.todayThere)
1620
+ .filter((x) => !!x)
1621
+ .sort();
1622
+ const today = days.length ? { min: days[0], max: days.at(-1) } : undefined;
1500
1623
  if (choosing && facets) {
1501
1624
  const countries = facets.countries.map((c) => ({
1502
1625
  name: c.name,
@@ -1505,10 +1628,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1505
1628
  .filter((x) => x.country === c.name)
1506
1629
  .map((x) => ({ name: x.name, count: x.count })),
1507
1630
  }));
1508
- const top = (xs) => xs
1509
- .slice(0, 3)
1510
- .map((x) => `${x.name} (${x.count})`)
1511
- .join(', ');
1631
+ // Every place, counted: a host with no card has only this sentence, and
1632
+ // "the most are in" three of forty cities left the other thirty-seven
1633
+ // for a guest to guess at.
1634
+ const every = (xs) => xs.map((x) => `${x.name} (${x.count})`).join(', ');
1512
1635
  const n = deps.vocab.nounPlural;
1513
1636
  if (namedCountry) {
1514
1637
  const [only] = countries;
@@ -1520,8 +1643,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1520
1643
  scope: only,
1521
1644
  ask: { city: asked.city, ...stayArgs },
1522
1645
  ...(ask.stay ? { stay: ask.stay } : {}),
1646
+ ...(today ? { today } : {}),
1523
1647
  },
1524
- text: `${total} ${deps.vocab.brand} ${n} in ${only?.name}, in ${only?.cities.length} cities - the card lists the cities to choose from; the most are in ${top(only?.cities ?? [])}. \`all\` lists the ${n} themselves.`,
1648
+ 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}.`,
1525
1649
  };
1526
1650
  }
1527
1651
  const regions = [
@@ -1543,8 +1667,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1543
1667
  regions,
1544
1668
  ask: { ...stayArgs },
1545
1669
  ...(ask.stay ? { stay: ask.stay } : {}),
1670
+ ...(today ? { today } : {}),
1546
1671
  },
1547
- text: `${total} ${deps.vocab.brand} ${n} in ${facets.cities.length} cities across ${countries.length} countries - the card sets them out by region, country and city for the guest to choose from; the most are in ${top(countries)}.`,
1672
+ 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}.`,
1548
1673
  };
1549
1674
  }
1550
1675
  if (hotels.length === 0) {
@@ -1552,9 +1677,39 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1552
1677
  ...h,
1553
1678
  summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
1554
1679
  fromNightly: h.fromNightly ? formatMoney(h.fromNightly) : undefined,
1680
+ ...(h.forStay
1681
+ ? {
1682
+ forStay: {
1683
+ ...h.forStay,
1684
+ total: formatMoney(h.forStay.total),
1685
+ nightly: formatMoney(h.forStay.nightly),
1686
+ },
1687
+ }
1688
+ : {}),
1555
1689
  }));
1556
- const cheapest = answer.nearest?.[0]?.fromNightly;
1690
+ const [first] = answer.nearest ?? [];
1691
+ // On named nights, a night of the stay that was priced - the list rate
1692
+ // was quoted beside a ceiling it was never held against.
1693
+ const cheapest = first?.forStay?.nightly ?? first?.fromNightly;
1557
1694
  const capCurrency = answer.currencies[0] ?? cheapest?.currency ?? 'HKD';
1695
+ const cap = formatMoney(money(toMinor(Number(ceiling), capCurrency), capCurrency));
1696
+ const pastTheEnd = answer.total > 0 && offset > 0;
1697
+ const why = pastTheEnd
1698
+ ? `That page is past the end: ${answer.total} match, so \`offset\` 0 to ${answer.total - 1} is where they are.`
1699
+ : filteredEmpty
1700
+ ? emptiedBy(asked.filters ?? {}, answer.beforeFilters ?? 0, deps.vocab.nounPlural)
1701
+ : ceilingEmpty && nearest.length
1702
+ ? `Nothing under ${cap} a night. The cheapest that matched is ${first?.name} at ${cheapest ? formatMoney(cheapest) : '-'}${answer.overBudget && answer.overBudget > 1 ? `, of ${answer.overBudget} over the ceiling` : ''}.`
1703
+ : ceilingEmpty
1704
+ ? `None of the ${answer.beforeCeiling} that match has a room for those nights.`
1705
+ : [
1706
+ `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
1707
+ // What was tried and also failed is worth a sentence: it tells a
1708
+ // guest the word itself is the problem, not the narrowing.
1709
+ widened ? `${sentence(widened.to)} found nothing either.` : '',
1710
+ ]
1711
+ .filter(Boolean)
1712
+ .join(' ');
1558
1713
  return {
1559
1714
  structured: {
1560
1715
  view: 'no-hotels',
@@ -1563,33 +1718,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1563
1718
  total: 0,
1564
1719
  query,
1565
1720
  ...(nearest.length ? { nearest, overBudget: answer.overBudget } : {}),
1566
- // The way back out of a filter that emptied it: the same search,
1567
- // with the chips to take one off.
1721
+ ...(ask.stay ? { stay: ask.stay } : {}),
1722
+ ...(widened?.found ? { widened } : {}),
1723
+ // Why it is empty, in the words the model is told, so the card says
1724
+ // the same thing rather than guessing at it.
1725
+ why,
1726
+ // The way back out of a filter that emptied it: the same search -
1727
+ // its order, its ceiling and its nights too - with the chips to take
1728
+ // one off. Taking one off dropped the sort and the ceiling.
1568
1729
  ...(filteredEmpty
1569
1730
  ? {
1570
- ask: {
1571
- ...(asked.query ? { query: asked.query } : {}),
1572
- ...(asked.city ? { city: asked.city } : {}),
1573
- ...(asked.filters ?? {}),
1574
- ...stayArgs,
1575
- },
1731
+ ask: echo(sort),
1576
1732
  ...(answer.refine ? { refine: refineFor(answer.refine, asked.filters, 0) } : {}),
1577
1733
  beforeFilters: answer.beforeFilters,
1578
1734
  }
1579
1735
  : {}),
1580
1736
  },
1581
- text: filteredEmpty
1582
- ? emptiedBy(asked.filters ?? {}, answer.beforeFilters ?? 0, deps.vocab.nounPlural)
1583
- : nearest.length
1584
- ? `Nothing under ${formatMoney(money(toMinor(Number(ceiling), capCurrency), capCurrency))} a night. The cheapest that matched is ${answer.nearest?.[0]?.name} at ${cheapest ? formatMoney(cheapest) : '-'}${answer.overBudget && answer.overBudget > 1 ? `, of ${answer.overBudget} over the ceiling` : ''}.`
1585
- : [
1586
- `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
1587
- // What was tried and also failed is worth a sentence: it tells a
1588
- // guest the word itself is the problem, not the narrowing.
1589
- widened ? `${sentence(widened.to)} found nothing either.` : '',
1590
- ]
1591
- .filter(Boolean)
1592
- .join(' '),
1737
+ text: [
1738
+ widened?.found ? `Nothing matched ${widened.from}, so this looked in ${widened.to}.` : '',
1739
+ why,
1740
+ ]
1741
+ .filter(Boolean)
1742
+ .join(' '),
1593
1743
  };
1594
1744
  }
1595
1745
  const forCard = hotels.map((h) => ({
@@ -1648,14 +1798,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1648
1798
  sort: applied,
1649
1799
  // The search as asked, so "show more" is this search's next page and
1650
1800
  // not a new one - and a place chip keeps the order the guest chose.
1651
- ask: {
1652
- ...(asked.query ? { query: asked.query } : {}),
1653
- ...(asked.city ? { city: asked.city } : {}),
1654
- ...(applied !== 'group' ? { sort: applied } : {}),
1655
- ...(asked.maxNightly ? { maxNightly: asked.maxNightly } : {}),
1656
- ...(asked.filters ?? {}),
1657
- ...stayArgs,
1658
- },
1801
+ ask: echo(applied),
1659
1802
  ...(ask.stay ? { stay: ask.stay } : {}),
1660
1803
  ...(answer.refine
1661
1804
  ? { refine: refineFor(answer.refine, asked.filters, total, !!ask.stay) }
@@ -1693,7 +1836,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1693
1836
  };
1694
1837
  }
1695
1838
  const h = detail.hotel;
1696
- const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX);
1839
+ const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX, deps.vocab.demo);
1697
1840
  const policies = await deps.backend.policies(h.id);
1698
1841
  const extras = await deps.backend.extrasFor(h.id, stay);
1699
1842
  const base = {
@@ -1794,7 +1937,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1794
1937
  },
1795
1938
  text: [
1796
1939
  `**${h.name}** · ${enough.length} room ${enough.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
1797
- ...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${roomsWanted > 1 ? ` (${formatMoney(multiplyMoney(r.stayTotal, roomsWanted))} for ${roomsWanted} rooms)` : ''}${r.refundable ? `, free to cancel${r.freeCancellationUntil ? ` until ${r.freeCancellationUntil.slice(0, 10)}` : ''}` : ', non-refundable'}`),
1940
+ ...enough.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${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 ${r.freeCancellationUntil.slice(0, 10)}` : r.refundable ? ', the free cancellation window has closed - cancelling costs a night' : ', non-refundable'}`),
1798
1941
  flex.text,
1799
1942
  deps.vocab.demo ? DEMO_NOTE : '',
1800
1943
  ]
@@ -1808,14 +1951,35 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1808
1951
  // transfer had nowhere to go. Asked with a booking, this lists that
1809
1952
  // hotel's add-ons for that booking's nights, with what it already holds.
1810
1953
  const code = str(args.booking);
1811
- const found = code
1812
- ? await deps.backend.findBooking(code, identity(deps, ctx, key, args))
1813
- : null;
1954
+ const who = identity(deps, ctx, key, args);
1955
+ const { found, tooMany } = code
1956
+ ? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
1957
+ : { found: null, tooMany: false };
1814
1958
  const booked = found && (found.status === 'requested' || found.status === 'confirmed') ? found : undefined;
1959
+ if (code && found && !booked) {
1960
+ // Found, and over: saying "no booking matches" about a booking the guest
1961
+ // is looking at sends them hunting for a code they have right.
1962
+ return {
1963
+ structured: {
1964
+ view: 'error',
1965
+ ...common(deps, key),
1966
+ error: 'booking-ended',
1967
+ detail: `${found.confirmationCode} is ${found.status}, so nothing can be added to it.`,
1968
+ },
1969
+ text: `${found.confirmationCode} is ${found.status}, so nothing can be added to it. An add-on goes on a booking that still holds its rooms.`,
1970
+ };
1971
+ }
1815
1972
  if (code && !booked) {
1816
1973
  return {
1817
- structured: { view: 'booking-not-found', ...common(deps, key), query: code },
1818
- text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
1974
+ structured: {
1975
+ view: 'booking-not-found',
1976
+ ...common(deps, key),
1977
+ query: code,
1978
+ ...(tooMany ? { tooMany: true } : {}),
1979
+ },
1980
+ text: tooMany
1981
+ ? TOO_MANY_TRIES
1982
+ : 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
1819
1983
  };
1820
1984
  }
1821
1985
  const wanted = booked ? booked.hotelId : str(args.hotel);
@@ -2017,8 +2181,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2017
2181
  };
2018
2182
  }
2019
2183
  else {
2020
- const kept = await deps.backend.rememberPreferences(signedIn, lasting);
2184
+ // ADDED TO what is kept, not in place of it: a quiet room remembered
2185
+ // this time silently dropped the king bed remembered last time. A new
2186
+ // request does replace its opposite - twin beds over a king bed.
2187
+ const clash = (k) => lasting.some((n) => OPPOSITES.some((pair) => pair.includes(n) && pair.includes(k)));
2188
+ const merged = [
2189
+ ...(signedIn.preferences ?? []).filter((k) => !lasting.includes(k) && !clash(k)),
2190
+ ...lasting,
2191
+ ];
2192
+ const kept = await deps.backend.rememberPreferences(signedIn, merged);
2021
2193
  deps.members.set(key, { ...signedIn, preferences: kept });
2194
+ deps.members.setPreferences(signedIn.number, kept);
2022
2195
  remembered = {
2023
2196
  kept,
2024
2197
  line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
@@ -2055,6 +2228,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2055
2228
  quote: quoteForCard(booking.quote),
2056
2229
  total: formatMoney(booking.quote.total),
2057
2230
  ...(asked.length ? { requested: asked } : {}),
2231
+ // Each its own warning colour - it rode on the first alone.
2232
+ ...(booking.requests?.kinds.includes('accessible-room')
2233
+ ? { accessibility: true }
2234
+ : {}),
2058
2235
  })),
2059
2236
  }
2060
2237
  : {}),
@@ -2100,8 +2277,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2100
2277
  const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
2101
2278
  // Elsewhere, only with the code AND the email - see `listBookings`.
2102
2279
  const code = str(args.booking);
2103
- const there = code && str(args.email) ? await deps.backend.findBooking(code, who) : null;
2280
+ const { found: there, tooMany } = code && str(args.email)
2281
+ ? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
2282
+ : { found: null, tooMany: false };
2104
2283
  const bookings = there && !here.some((b) => b.id === there.id) ? [there, ...here] : here;
2284
+ // ⚠️ A MISS IS SAID EVEN WHEN THERE IS SOMETHING TO LIST. With two
2285
+ // bookings in the conversation, a wrong code answered "2 bookings" and
2286
+ // nothing else - read as "found it" (2026-09-28 audit).
2287
+ const missed = code && !there;
2288
+ const missLine = tooMany
2289
+ ? TOO_MANY_TRIES
2290
+ : code && !str(args.email)
2291
+ ? 'A booking made elsewhere needs the email it was made with as well as its code.'
2292
+ : 'No booking matches that confirmation code and email. Both are needed, as they are on the confirmation.';
2105
2293
  return {
2106
2294
  structured: {
2107
2295
  view: 'bookings',
@@ -2110,13 +2298,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2110
2298
  count: bookings.length,
2111
2299
  // What was looked for, so a card can say THAT did not match rather
2112
2300
  // than "nothing in this conversation".
2113
- ...(code && !there ? { notFound: code } : {}),
2301
+ ...(missed ? { notFound: code, notFoundLine: missLine } : {}),
2302
+ ...(tooMany ? { tooMany: true } : {}),
2114
2303
  },
2115
- text: bookings.length
2116
- ? bookings.map(bookingLine).join('\n')
2117
- : code
2118
- ? 'No booking matches that confirmation code and email. Both are needed, as they are on the confirmation.'
2119
- : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
2304
+ text: [
2305
+ missed ? missLine : '',
2306
+ bookings.length
2307
+ ? bookings.map(bookingLine).join('\n')
2308
+ : code
2309
+ ? ''
2310
+ : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
2311
+ ]
2312
+ .filter(Boolean)
2313
+ .join('\n'),
2120
2314
  };
2121
2315
  }
2122
2316
  if (name === t.previewChange || name === t.changeBooking) {
@@ -2130,11 +2324,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2130
2324
  ...(str(args.extra) ? { extra: str(args.extra) } : {}),
2131
2325
  ...(typeof args.quantity === 'number' ? { quantity: args.quantity } : {}),
2132
2326
  };
2133
- const preview = await deps.backend.previewChange(ref, change, who);
2327
+ const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewChange(ref, change, who));
2134
2328
  if (!preview) {
2135
2329
  return {
2136
- structured: { view: 'booking-not-found', ...common(deps, key), query: ref },
2137
- text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
2330
+ structured: {
2331
+ view: 'booking-not-found',
2332
+ ...common(deps, key),
2333
+ query: ref,
2334
+ ...(tooMany ? { tooMany: true } : {}),
2335
+ },
2336
+ text: tooMany
2337
+ ? TOO_MANY_TRIES
2338
+ : 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
2138
2339
  };
2139
2340
  }
2140
2341
  const stayOf = (x) => ({
@@ -2176,16 +2377,48 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2176
2377
  : 'The total is unchanged.',
2177
2378
  preview.rule,
2178
2379
  `Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
2179
- ].join(' ')
2380
+ deps.vocab.demo ? DEMO_NOTE : '',
2381
+ ]
2382
+ .filter(Boolean)
2383
+ .join(' ')
2180
2384
  : preview.rule,
2181
2385
  };
2182
2386
  // ⚠️ THE PREVIEW IS NOT ADVISORY, for the same reason the cancellation's is
2183
2387
  // not: a change re-prices the stay, and a guest who never saw the new total
2184
- // finds it at the desk.
2388
+ // finds it at the desk. And it confirms what was SHOWN - see
2389
+ // `shownPreviews`.
2390
+ // Keyed by what the change COMES TO, not how it was asked: the card sends
2391
+ // its confirmation from the preview's own nights and rooms, which need not
2392
+ // be the words the preview was asked in.
2393
+ const shownKey = [
2394
+ key,
2395
+ 'change',
2396
+ preview.bookingId,
2397
+ preview.to?.stay.checkIn,
2398
+ preview.to?.stay.checkOut,
2399
+ preview.to?.rooms,
2400
+ preview.to?.roomName,
2401
+ preview.addOn ? `${preview.addOn.id}:${preview.addOn.to}` : '',
2402
+ ].join('|');
2403
+ const wasShown = previewsOf(deps).get(shownKey);
2404
+ const newTotal = preview.to?.total.minor;
2405
+ if (newTotal !== undefined)
2406
+ remember(deps, shownKey, newTotal);
2185
2407
  if (name === t.previewChange || args.confirmed !== true)
2186
2408
  return asPreview;
2187
2409
  if (!preview.changeable)
2188
2410
  return asPreview;
2411
+ if (wasShown === undefined || wasShown !== newTotal) {
2412
+ return {
2413
+ ...asPreview,
2414
+ text: [
2415
+ wasShown === undefined
2416
+ ? 'Nothing has changed yet: this is what the change would do, for the guest to see first.'
2417
+ : `The price of this change has moved since it was shown - it is now ${preview.to ? formatMoney(preview.to.total) : '-'} in all. Nothing has changed yet.`,
2418
+ asPreview.text,
2419
+ ].join(' '),
2420
+ };
2421
+ }
2189
2422
  try {
2190
2423
  const changed = await deps.backend.changeBooking(ref, change, who);
2191
2424
  if (!changed)
@@ -2219,11 +2452,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2219
2452
  if (name === t.previewCancellation || name === t.cancelBooking) {
2220
2453
  const ref = str(args.booking);
2221
2454
  const who = identity(deps, ctx, key, args);
2222
- const preview = await deps.backend.previewCancellation(ref, who);
2455
+ const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewCancellation(ref, who));
2223
2456
  if (!preview) {
2224
2457
  return {
2225
- structured: { view: 'booking-not-found', ...common(deps, key), query: ref },
2226
- text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
2458
+ structured: {
2459
+ view: 'booking-not-found',
2460
+ ...common(deps, key),
2461
+ query: ref,
2462
+ ...(tooMany ? { tooMany: true } : {}),
2463
+ },
2464
+ text: tooMany
2465
+ ? TOO_MANY_TRIES
2466
+ : 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
2227
2467
  };
2228
2468
  }
2229
2469
  const asPreview = {
@@ -2244,17 +2484,33 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2244
2484
  preview.cancellable
2245
2485
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
2246
2486
  : '',
2487
+ deps.vocab.demo ? DEMO_NOTE : '',
2247
2488
  ]
2248
2489
  .filter(Boolean)
2249
2490
  .join(' '),
2250
2491
  };
2251
2492
  // ⚠️ THE PREVIEW IS NOT ADVISORY. Cancelling without one is how a guest
2252
2493
  // loses a night's rate they were never told about, so the cancel tool
2253
- // returns this same preview and does nothing until it is told again.
2494
+ // returns this same preview and does nothing until it is told again - and
2495
+ // told again about the fee that was SHOWN (`shownPreviews`).
2496
+ const shownKey = `${key}|cancel|${preview.bookingId}`;
2497
+ const wasShown = previewsOf(deps).get(shownKey);
2498
+ remember(deps, shownKey, preview.fee.minor);
2254
2499
  if (name === t.previewCancellation || args.confirmed !== true)
2255
2500
  return asPreview;
2256
2501
  if (!preview.cancellable)
2257
2502
  return asPreview;
2503
+ if (wasShown === undefined || wasShown !== preview.fee.minor) {
2504
+ return {
2505
+ ...asPreview,
2506
+ text: [
2507
+ wasShown === undefined
2508
+ ? 'Nothing is cancelled yet: this is what cancelling would cost, for the guest to see first.'
2509
+ : `The fee has changed since it was shown - it is now ${formatMoney(preview.fee)}. Nothing is cancelled yet.`,
2510
+ asPreview.text,
2511
+ ].join(' '),
2512
+ };
2513
+ }
2258
2514
  const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
2259
2515
  if (!cancelled)
2260
2516
  return asPreview;
@@ -2268,7 +2524,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2268
2524
  rule: preview.rule,
2269
2525
  cancellable: false,
2270
2526
  },
2271
- text: `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
2527
+ text: [
2528
+ `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
2529
+ deps.vocab.demo ? DEMO_NOTE : '',
2530
+ ]
2531
+ .filter(Boolean)
2532
+ .join(' '),
2272
2533
  };
2273
2534
  }
2274
2535
  if (name === t.compare) {
@@ -2282,6 +2543,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2282
2543
  stay: asStay(args),
2283
2544
  recommended: str(args.recommended) || undefined,
2284
2545
  title: str(args.title) || undefined,
2546
+ // The member's rate, like the list and the hotel beside it: without
2547
+ // it the comparison quoted HK$5,424 where both said HK$4,610.
2548
+ who: identity(deps, ctx, key),
2285
2549
  });
2286
2550
  if (comparison.columns.length === 0) {
2287
2551
  return {
@@ -2357,7 +2621,23 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2357
2621
  if (name === t.signIn) {
2358
2622
  const number = str(args.number);
2359
2623
  const surname = str(args.surname);
2624
+ // Counted like a booking lookup - see limits.ts. A number and a surname
2625
+ // read a tier, a point balance and saved preferences.
2626
+ const tries = [`signin|${key}`, `member|${number.replace(/\s+/g, '').toUpperCase()}`];
2627
+ if (deps.misses?.blocked(tries)) {
2628
+ return {
2629
+ structured: {
2630
+ view: 'member-not-found',
2631
+ ...common(deps, key),
2632
+ query: number,
2633
+ tooMany: true,
2634
+ },
2635
+ text: 'Too many membership numbers and surnames that did not match, so signing in is paused for a quarter of an hour. Check both as they are printed on the card, then try again.',
2636
+ };
2637
+ }
2360
2638
  const member = await deps.backend.signInMember(number, surname);
2639
+ if (!member)
2640
+ deps.misses?.miss(tries);
2361
2641
  if (!member) {
2362
2642
  // ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
2363
2643
  // enough, have the discount" - is inventing a membership, and the guest
@@ -2403,6 +2683,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2403
2683
  await deps.backend.rememberPreferences(m, []);
2404
2684
  const { preferences: _gone, ...member } = m;
2405
2685
  deps.members.set(key, member);
2686
+ deps.members.setPreferences(m.number, []);
2406
2687
  return {
2407
2688
  structured: { view: 'member', ...common(deps, key), member },
2408
2689
  text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,