@curless/hotel-storefront 0.18.3 → 0.19.1

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 (58) 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__/present.test.js +13 -0
  4. package/dist/__tests__/present.test.js.map +1 -1
  5. package/dist/__tests__/server.test.js +17 -3
  6. package/dist/__tests__/server.test.js.map +1 -1
  7. package/dist/__tests__/tools.test.js +298 -5
  8. package/dist/__tests__/tools.test.js.map +1 -1
  9. package/dist/backend.d.ts +12 -1
  10. package/dist/backend.d.ts.map +1 -1
  11. package/dist/backend.js +7 -1
  12. package/dist/backend.js.map +1 -1
  13. package/dist/card.generated.d.ts +1 -1
  14. package/dist/card.generated.js +2 -2
  15. package/dist/card.generated.js.map +1 -1
  16. package/dist/data.d.ts +2 -0
  17. package/dist/data.d.ts.map +1 -1
  18. package/dist/data.js +24 -7
  19. package/dist/data.js.map +1 -1
  20. package/dist/demo-hotels.d.ts +24 -2
  21. package/dist/demo-hotels.d.ts.map +1 -1
  22. package/dist/demo-hotels.js +319 -95
  23. package/dist/demo-hotels.js.map +1 -1
  24. package/dist/legal.d.ts +1 -1
  25. package/dist/legal.d.ts.map +1 -1
  26. package/dist/legal.js +31 -10
  27. package/dist/legal.js.map +1 -1
  28. package/dist/limits.d.ts +12 -0
  29. package/dist/limits.d.ts.map +1 -0
  30. package/dist/limits.js +47 -0
  31. package/dist/limits.js.map +1 -0
  32. package/dist/members.d.ts +9 -0
  33. package/dist/members.d.ts.map +1 -1
  34. package/dist/members.js +17 -0
  35. package/dist/members.js.map +1 -1
  36. package/dist/money.d.ts +6 -1
  37. package/dist/money.d.ts.map +1 -1
  38. package/dist/money.js +6 -1
  39. package/dist/money.js.map +1 -1
  40. package/dist/present.d.ts +2 -1
  41. package/dist/present.d.ts.map +1 -1
  42. package/dist/present.js +10 -6
  43. package/dist/present.js.map +1 -1
  44. package/dist/server.d.ts +8 -8
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +13 -4
  47. package/dist/server.js.map +1 -1
  48. package/dist/surface.d.ts +2 -2
  49. package/dist/surface.d.ts.map +1 -1
  50. package/dist/surface.js +2 -2
  51. package/dist/surface.js.map +1 -1
  52. package/dist/tools.d.ts +39 -13
  53. package/dist/tools.d.ts.map +1 -1
  54. package/dist/tools.js +391 -96
  55. package/dist/tools.js.map +1 -1
  56. package/dist/types.d.ts +12 -4
  57. package/dist/types.d.ts.map +1 -1
  58. 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.
@@ -1205,6 +1279,7 @@ const currentTrip = async (deps, key, who) => {
1205
1279
  };
1206
1280
  }
1207
1281
  const shaped = parts.map(quoteForCard);
1282
+ const bookable = parts.map((p) => p.lines.some((l) => l.kind === 'room'));
1208
1283
  return {
1209
1284
  structured: {
1210
1285
  view: 'trip',
@@ -1213,9 +1288,24 @@ const currentTrip = async (deps, key, who) => {
1213
1288
  // One block per currency, each with its own taxes and total. `total` is
1214
1289
  // the totals side by side for anything that reads one string - a list,
1215
1290
  // never a sum.
1216
- groups: shaped.map((p, i) => ({ ...p, currency: parts[i]?.total.currency })),
1291
+ //
1292
+ // ⚠️ A CURRENCY OF ADD-ONS ALONE IS NOT PART OF WHAT IS BOOKED. A
1293
+ // transfer at a Paris hotel with no Paris room made its own € block, and
1294
+ // the trip's total read "HK$8,588 · €347" for a request that refuses
1295
+ // the €347 (2026-09-28 audit). It is drawn, marked, and left out.
1296
+ groups: shaped.map((p, i) => ({
1297
+ ...p,
1298
+ currency: parts[i]?.total.currency,
1299
+ ...(bookable[i] ? {} : { goesWithAStay: true }),
1300
+ })),
1217
1301
  currencies: parts.map((p) => p.total.currency),
1218
- total: shaped.map((p) => p.total).join(' + '),
1302
+ // Side by side with a dot, not a plus: "HK$8,588 + ¥152,550" reads as
1303
+ // a sum nobody can do.
1304
+ total: (shaped.filter((_, i) => bookable[i]).length
1305
+ ? shaped.filter((_, i) => bookable[i])
1306
+ : shaped)
1307
+ .map((p) => p.total)
1308
+ .join(' · '),
1219
1309
  ...needs,
1220
1310
  ...(shaped.some((p) => p.dropped) ? { dropped: shaped.flatMap((p) => p.dropped ?? []) } : {}),
1221
1311
  count: lines.length,
@@ -1317,7 +1407,9 @@ const suggestionsFor = async (deps, d) => {
1317
1407
  const nearby = await deps.backend.searchHotels({ city: hotel.city, limit: 2 });
1318
1408
  if (nearby.total > 1)
1319
1409
  out.push({
1320
- label: `Compare with the other ${hotel.city} hotels`,
1410
+ // The label IS what is sent: a chip that sends more than it says is
1411
+ // a chip that says something else in the guest's name.
1412
+ label: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
1321
1413
  say: `Compare ${hotel.name} with the other ${hotel.city} hotels`,
1322
1414
  });
1323
1415
  }
@@ -1338,7 +1430,7 @@ const suggestionsFor = async (deps, d) => {
1338
1430
  }
1339
1431
  if (view === 'itinerary' && Number(d.planned) > Number(d.inTrip) + Number(d.booked ?? 0))
1340
1432
  out.push({
1341
- label: 'Put these stays in the trip',
1433
+ label: 'Put the stays on this plan in the trip',
1342
1434
  say: 'Put the stays on this plan in the trip',
1343
1435
  });
1344
1436
  if (view === 'booking') {
@@ -1395,11 +1487,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1395
1487
  sort,
1396
1488
  maxNightly: ceiling,
1397
1489
  stay: asStay(args),
1398
- rooms: typeof args.rooms === 'number' ? args.rooms : undefined,
1490
+ // Clamped here, so what is said and echoed is what was priced: "9 rooms"
1491
+ // was said over prices for five.
1492
+ rooms: typeof args.rooms === 'number' && Number.isFinite(args.rooms)
1493
+ ? Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(args.rooms)))
1494
+ : undefined,
1399
1495
  filters: filtersOf(args),
1400
1496
  };
1401
1497
  const who = identity(deps, ctx, key, args);
1402
1498
  let answer = await deps.backend.searchHotels(ask, who);
1499
+ // A brand as the data spells it, so its chip shows as ON and can be turned
1500
+ // off: `brand: 'kerry'` filtered, and then no chip matched it.
1501
+ const brandAsked = ask.filters?.brand?.toLowerCase();
1502
+ const brandNamed = answer.refine?.brands.find((b) => b.name.toLowerCase() === brandAsked)?.name;
1503
+ if (ask.filters && brandNamed)
1504
+ ask.filters = { ...ask.filters, brand: brandNamed };
1403
1505
  // The search the answer actually came from - the widened one, when it was
1404
1506
  // widened - so its next page is the next page of THAT.
1405
1507
  let asked = ask;
@@ -1419,20 +1521,29 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1419
1521
  // comment was written to forbid, committed by the code under it.
1420
1522
  // `overBudget` is how the seam says the place and the words did match.
1421
1523
  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) {
1524
+ // ⚠️ A FILTER OR A CEILING THAT EMPTIED THE LIST IS NOT A SEARCH THAT
1525
+ // FAILED either, for the same reason as the ceiling above: the place
1526
+ // matched. What matched the place and the words, before either:
1527
+ const placedCount = (a) => a.beforeFilters ?? a.beforeCeiling ?? a.total;
1528
+ if (placedCount(answer) === 0) {
1426
1529
  const wider = widen(ask);
1427
1530
  if (wider) {
1428
1531
  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) {
1532
+ // Found when the wider PLACE matched - a wider search emptied by the
1533
+ // same ceiling or filter is still the place, and "Hong Kong found
1534
+ // nothing either" was said of four hotels over the ceiling.
1535
+ widened = { from: wider.from, to: wider.to, found: placedCount(second) > 0 };
1536
+ if (widened.found) {
1431
1537
  answer = second;
1432
1538
  asked = { ...ask, ...wider.ask };
1433
1539
  }
1434
1540
  }
1435
1541
  }
1542
+ const filteredEmpty = !!asked.filters &&
1543
+ answer.total === 0 &&
1544
+ placedCount(answer) > 0 &&
1545
+ (answer.beforeCeiling ?? 0) === 0;
1546
+ const ceilingEmpty = answer.total === 0 && (answer.beforeCeiling ?? 0) > 0;
1436
1547
  // The ceiling again on this side of the seam. MAX_PAGE is part of the
1437
1548
  // backend contract, but "the next backend honours it" is not something the
1438
1549
  // card can be built on - and the cost of it being wrong is the whole group
@@ -1486,6 +1597,21 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1486
1597
  ...(ask.rooms && ask.rooms > 1 ? { rooms: ask.rooms } : {}),
1487
1598
  }
1488
1599
  : {};
1600
+ // ⚠️ THE SEARCH AS ASKED, `all` INCLUDED. The echo dropped `all`, so every
1601
+ // re-query the card made from a "Browse all" list - new dates, the default
1602
+ // order, clearing a filter - met no place, no words and no filter, and
1603
+ // came back as the destinations page (2026-09-28 audit). Built once, for
1604
+ // the list and for the list a filter or a ceiling emptied.
1605
+ const listAll = args.all === true;
1606
+ const echo = (applied) => ({
1607
+ ...(asked.query ? { query: asked.query } : {}),
1608
+ ...(asked.city ? { city: asked.city } : {}),
1609
+ ...(applied !== 'group' ? { sort: applied } : {}),
1610
+ ...(asked.maxNightly ? { maxNightly: asked.maxNightly } : {}),
1611
+ ...(asked.filters ?? {}),
1612
+ ...(listAll ? { all: true } : {}),
1613
+ ...stayArgs,
1614
+ });
1489
1615
  const namedCountry = !!asked.city &&
1490
1616
  facets?.countries.length === 1 &&
1491
1617
  facets.countries[0]?.name.toLowerCase() === asked.city.toLowerCase();
@@ -1495,8 +1621,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1495
1621
  sort === 'group' &&
1496
1622
  !asked.maxNightly &&
1497
1623
  !offset &&
1498
- args.all !== true &&
1624
+ !listAll &&
1625
+ // A widened search is a list with the miss said over it: as a page of
1626
+ // places, "helipad" matching nothing in China was never said at all.
1627
+ !widened &&
1499
1628
  (!asked.city || (namedCountry && facets.cities.length >= CITY_PICKER_MIN));
1629
+ // "Today" where the hotels are - earliest and latest - for a page that has
1630
+ // no one hotel's clock to fill its date bar from (see the card's
1631
+ // `todayRange`): the UTC date was yesterday in Asia before 8am.
1632
+ const days = answer.hotels
1633
+ .map((h) => h.todayThere)
1634
+ .filter((x) => !!x)
1635
+ .sort();
1636
+ const today = days.length ? { min: days[0], max: days.at(-1) } : undefined;
1500
1637
  if (choosing && facets) {
1501
1638
  const countries = facets.countries.map((c) => ({
1502
1639
  name: c.name,
@@ -1505,10 +1642,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1505
1642
  .filter((x) => x.country === c.name)
1506
1643
  .map((x) => ({ name: x.name, count: x.count })),
1507
1644
  }));
1508
- const top = (xs) => xs
1509
- .slice(0, 3)
1510
- .map((x) => `${x.name} (${x.count})`)
1511
- .join(', ');
1645
+ // Every place, counted: a host with no card has only this sentence, and
1646
+ // "the most are in" three of forty cities left the other thirty-seven
1647
+ // for a guest to guess at.
1648
+ const every = (xs) => xs.map((x) => `${x.name} (${x.count})`).join(', ');
1512
1649
  const n = deps.vocab.nounPlural;
1513
1650
  if (namedCountry) {
1514
1651
  const [only] = countries;
@@ -1520,8 +1657,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1520
1657
  scope: only,
1521
1658
  ask: { city: asked.city, ...stayArgs },
1522
1659
  ...(ask.stay ? { stay: ask.stay } : {}),
1660
+ ...(today ? { today } : {}),
1523
1661
  },
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.`,
1662
+ 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
1663
  };
1526
1664
  }
1527
1665
  const regions = [
@@ -1543,8 +1681,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1543
1681
  regions,
1544
1682
  ask: { ...stayArgs },
1545
1683
  ...(ask.stay ? { stay: ask.stay } : {}),
1684
+ ...(today ? { today } : {}),
1546
1685
  },
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)}.`,
1686
+ 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
1687
  };
1549
1688
  }
1550
1689
  if (hotels.length === 0) {
@@ -1552,9 +1691,39 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1552
1691
  ...h,
1553
1692
  summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
1554
1693
  fromNightly: h.fromNightly ? formatMoney(h.fromNightly) : undefined,
1694
+ ...(h.forStay
1695
+ ? {
1696
+ forStay: {
1697
+ ...h.forStay,
1698
+ total: formatMoney(h.forStay.total),
1699
+ nightly: formatMoney(h.forStay.nightly),
1700
+ },
1701
+ }
1702
+ : {}),
1555
1703
  }));
1556
- const cheapest = answer.nearest?.[0]?.fromNightly;
1704
+ const [first] = answer.nearest ?? [];
1705
+ // On named nights, a night of the stay that was priced - the list rate
1706
+ // was quoted beside a ceiling it was never held against.
1707
+ const cheapest = first?.forStay?.nightly ?? first?.fromNightly;
1557
1708
  const capCurrency = answer.currencies[0] ?? cheapest?.currency ?? 'HKD';
1709
+ const cap = formatMoney(money(toMinor(Number(ceiling), capCurrency), capCurrency));
1710
+ const pastTheEnd = answer.total > 0 && offset > 0;
1711
+ const why = pastTheEnd
1712
+ ? `That page is past the end: ${answer.total} match, so \`offset\` 0 to ${answer.total - 1} is where they are.`
1713
+ : filteredEmpty
1714
+ ? emptiedBy(asked.filters ?? {}, answer.beforeFilters ?? 0, deps.vocab.nounPlural)
1715
+ : ceilingEmpty && nearest.length
1716
+ ? `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` : ''}.`
1717
+ : ceilingEmpty
1718
+ ? `None of the ${answer.beforeCeiling} that match has a room for those nights.`
1719
+ : [
1720
+ `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
1721
+ // What was tried and also failed is worth a sentence: it tells a
1722
+ // guest the word itself is the problem, not the narrowing.
1723
+ widened ? `${sentence(widened.to)} found nothing either.` : '',
1724
+ ]
1725
+ .filter(Boolean)
1726
+ .join(' ');
1558
1727
  return {
1559
1728
  structured: {
1560
1729
  view: 'no-hotels',
@@ -1563,33 +1732,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1563
1732
  total: 0,
1564
1733
  query,
1565
1734
  ...(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.
1735
+ ...(ask.stay ? { stay: ask.stay } : {}),
1736
+ ...(widened?.found ? { widened } : {}),
1737
+ // Why it is empty, in the words the model is told, so the card says
1738
+ // the same thing rather than guessing at it.
1739
+ why,
1740
+ // The way back out of a filter that emptied it: the same search -
1741
+ // its order, its ceiling and its nights too - with the chips to take
1742
+ // one off. Taking one off dropped the sort and the ceiling.
1568
1743
  ...(filteredEmpty
1569
1744
  ? {
1570
- ask: {
1571
- ...(asked.query ? { query: asked.query } : {}),
1572
- ...(asked.city ? { city: asked.city } : {}),
1573
- ...(asked.filters ?? {}),
1574
- ...stayArgs,
1575
- },
1745
+ ask: echo(sort),
1576
1746
  ...(answer.refine ? { refine: refineFor(answer.refine, asked.filters, 0) } : {}),
1577
1747
  beforeFilters: answer.beforeFilters,
1578
1748
  }
1579
1749
  : {}),
1580
1750
  },
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(' '),
1751
+ text: [
1752
+ widened?.found ? `Nothing matched ${widened.from}, so this looked in ${widened.to}.` : '',
1753
+ why,
1754
+ ]
1755
+ .filter(Boolean)
1756
+ .join(' '),
1593
1757
  };
1594
1758
  }
1595
1759
  const forCard = hotels.map((h) => ({
@@ -1648,14 +1812,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1648
1812
  sort: applied,
1649
1813
  // The search as asked, so "show more" is this search's next page and
1650
1814
  // 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
- },
1815
+ ask: echo(applied),
1659
1816
  ...(ask.stay ? { stay: ask.stay } : {}),
1660
1817
  ...(answer.refine
1661
1818
  ? { refine: refineFor(answer.refine, asked.filters, total, !!ask.stay) }
@@ -1693,7 +1850,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1693
1850
  };
1694
1851
  }
1695
1852
  const h = detail.hotel;
1696
- const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX);
1853
+ const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX, deps.vocab.demo);
1697
1854
  const policies = await deps.backend.policies(h.id);
1698
1855
  const extras = await deps.backend.extrasFor(h.id, stay);
1699
1856
  const base = {
@@ -1794,7 +1951,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1794
1951
  },
1795
1952
  text: [
1796
1953
  `**${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'}`),
1954
+ ...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
1955
  flex.text,
1799
1956
  deps.vocab.demo ? DEMO_NOTE : '',
1800
1957
  ]
@@ -1808,14 +1965,35 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1808
1965
  // transfer had nowhere to go. Asked with a booking, this lists that
1809
1966
  // hotel's add-ons for that booking's nights, with what it already holds.
1810
1967
  const code = str(args.booking);
1811
- const found = code
1812
- ? await deps.backend.findBooking(code, identity(deps, ctx, key, args))
1813
- : null;
1968
+ const who = identity(deps, ctx, key, args);
1969
+ const { found, tooMany } = code
1970
+ ? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
1971
+ : { found: null, tooMany: false };
1814
1972
  const booked = found && (found.status === 'requested' || found.status === 'confirmed') ? found : undefined;
1973
+ if (code && found && !booked) {
1974
+ // Found, and over: saying "no booking matches" about a booking the guest
1975
+ // is looking at sends them hunting for a code they have right.
1976
+ return {
1977
+ structured: {
1978
+ view: 'error',
1979
+ ...common(deps, key),
1980
+ error: 'booking-ended',
1981
+ detail: `${found.confirmationCode} is ${found.status}, so nothing can be added to it.`,
1982
+ },
1983
+ 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.`,
1984
+ };
1985
+ }
1815
1986
  if (code && !booked) {
1816
1987
  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.',
1988
+ structured: {
1989
+ view: 'booking-not-found',
1990
+ ...common(deps, key),
1991
+ query: code,
1992
+ ...(tooMany ? { tooMany: true } : {}),
1993
+ },
1994
+ text: tooMany
1995
+ ? TOO_MANY_TRIES
1996
+ : 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
1819
1997
  };
1820
1998
  }
1821
1999
  const wanted = booked ? booked.hotelId : str(args.hotel);
@@ -2017,8 +2195,17 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2017
2195
  };
2018
2196
  }
2019
2197
  else {
2020
- const kept = await deps.backend.rememberPreferences(signedIn, lasting);
2198
+ // ADDED TO what is kept, not in place of it: a quiet room remembered
2199
+ // this time silently dropped the king bed remembered last time. A new
2200
+ // request does replace its opposite - twin beds over a king bed.
2201
+ const clash = (k) => lasting.some((n) => OPPOSITES.some((pair) => pair.includes(n) && pair.includes(k)));
2202
+ const merged = [
2203
+ ...(signedIn.preferences ?? []).filter((k) => !lasting.includes(k) && !clash(k)),
2204
+ ...lasting,
2205
+ ];
2206
+ const kept = await deps.backend.rememberPreferences(signedIn, merged);
2021
2207
  deps.members.set(key, { ...signedIn, preferences: kept });
2208
+ deps.members.setPreferences(signedIn.number, kept);
2022
2209
  remembered = {
2023
2210
  kept,
2024
2211
  line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
@@ -2055,6 +2242,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2055
2242
  quote: quoteForCard(booking.quote),
2056
2243
  total: formatMoney(booking.quote.total),
2057
2244
  ...(asked.length ? { requested: asked } : {}),
2245
+ // Each its own warning colour - it rode on the first alone.
2246
+ ...(booking.requests?.kinds.includes('accessible-room')
2247
+ ? { accessibility: true }
2248
+ : {}),
2058
2249
  })),
2059
2250
  }
2060
2251
  : {}),
@@ -2100,8 +2291,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2100
2291
  const here = await deps.backend.listBookings(who, typeof args.limit === 'number' ? args.limit : undefined);
2101
2292
  // Elsewhere, only with the code AND the email - see `listBookings`.
2102
2293
  const code = str(args.booking);
2103
- const there = code && str(args.email) ? await deps.backend.findBooking(code, who) : null;
2294
+ const { found: there, tooMany } = code && str(args.email)
2295
+ ? await guarded(deps, key, who, () => deps.backend.findBooking(code, who))
2296
+ : { found: null, tooMany: false };
2104
2297
  const bookings = there && !here.some((b) => b.id === there.id) ? [there, ...here] : here;
2298
+ // ⚠️ A MISS IS SAID EVEN WHEN THERE IS SOMETHING TO LIST. With two
2299
+ // bookings in the conversation, a wrong code answered "2 bookings" and
2300
+ // nothing else - read as "found it" (2026-09-28 audit).
2301
+ const missed = code && !there;
2302
+ const missLine = tooMany
2303
+ ? TOO_MANY_TRIES
2304
+ : code && !str(args.email)
2305
+ ? 'A booking made elsewhere needs the email it was made with as well as its code.'
2306
+ : 'No booking matches that confirmation code and email. Both are needed, as they are on the confirmation.';
2105
2307
  return {
2106
2308
  structured: {
2107
2309
  view: 'bookings',
@@ -2110,13 +2312,19 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2110
2312
  count: bookings.length,
2111
2313
  // What was looked for, so a card can say THAT did not match rather
2112
2314
  // than "nothing in this conversation".
2113
- ...(code && !there ? { notFound: code } : {}),
2315
+ ...(missed ? { notFound: code, notFoundLine: missLine } : {}),
2316
+ ...(tooMany ? { tooMany: true } : {}),
2114
2317
  },
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.',
2318
+ text: [
2319
+ missed ? missLine : '',
2320
+ bookings.length
2321
+ ? bookings.map(bookingLine).join('\n')
2322
+ : code
2323
+ ? ''
2324
+ : 'No bookings in this conversation. A booking made elsewhere is found with its confirmation code and the email it was made with.',
2325
+ ]
2326
+ .filter(Boolean)
2327
+ .join('\n'),
2120
2328
  };
2121
2329
  }
2122
2330
  if (name === t.previewChange || name === t.changeBooking) {
@@ -2130,11 +2338,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2130
2338
  ...(str(args.extra) ? { extra: str(args.extra) } : {}),
2131
2339
  ...(typeof args.quantity === 'number' ? { quantity: args.quantity } : {}),
2132
2340
  };
2133
- const preview = await deps.backend.previewChange(ref, change, who);
2341
+ const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewChange(ref, change, who));
2134
2342
  if (!preview) {
2135
2343
  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.',
2344
+ structured: {
2345
+ view: 'booking-not-found',
2346
+ ...common(deps, key),
2347
+ query: ref,
2348
+ ...(tooMany ? { tooMany: true } : {}),
2349
+ },
2350
+ text: tooMany
2351
+ ? TOO_MANY_TRIES
2352
+ : 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
2138
2353
  };
2139
2354
  }
2140
2355
  const stayOf = (x) => ({
@@ -2176,16 +2391,48 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2176
2391
  : 'The total is unchanged.',
2177
2392
  preview.rule,
2178
2393
  `Say the word and it moves (${t.changeBooking} with \`confirmed\`).`,
2179
- ].join(' ')
2394
+ deps.vocab.demo ? DEMO_NOTE : '',
2395
+ ]
2396
+ .filter(Boolean)
2397
+ .join(' ')
2180
2398
  : preview.rule,
2181
2399
  };
2182
2400
  // ⚠️ THE PREVIEW IS NOT ADVISORY, for the same reason the cancellation's is
2183
2401
  // not: a change re-prices the stay, and a guest who never saw the new total
2184
- // finds it at the desk.
2402
+ // finds it at the desk. And it confirms what was SHOWN - see
2403
+ // `shownPreviews`.
2404
+ // Keyed by what the change COMES TO, not how it was asked: the card sends
2405
+ // its confirmation from the preview's own nights and rooms, which need not
2406
+ // be the words the preview was asked in.
2407
+ const shownKey = [
2408
+ key,
2409
+ 'change',
2410
+ preview.bookingId,
2411
+ preview.to?.stay.checkIn,
2412
+ preview.to?.stay.checkOut,
2413
+ preview.to?.rooms,
2414
+ preview.to?.roomName,
2415
+ preview.addOn ? `${preview.addOn.id}:${preview.addOn.to}` : '',
2416
+ ].join('|');
2417
+ const wasShown = previewsOf(deps).get(shownKey);
2418
+ const newTotal = preview.to?.total.minor;
2419
+ if (newTotal !== undefined)
2420
+ remember(deps, shownKey, newTotal);
2185
2421
  if (name === t.previewChange || args.confirmed !== true)
2186
2422
  return asPreview;
2187
2423
  if (!preview.changeable)
2188
2424
  return asPreview;
2425
+ if (wasShown === undefined || wasShown !== newTotal) {
2426
+ return {
2427
+ ...asPreview,
2428
+ text: [
2429
+ wasShown === undefined
2430
+ ? 'Nothing has changed yet: this is what the change would do, for the guest to see first.'
2431
+ : `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.`,
2432
+ asPreview.text,
2433
+ ].join(' '),
2434
+ };
2435
+ }
2189
2436
  try {
2190
2437
  const changed = await deps.backend.changeBooking(ref, change, who);
2191
2438
  if (!changed)
@@ -2219,11 +2466,18 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2219
2466
  if (name === t.previewCancellation || name === t.cancelBooking) {
2220
2467
  const ref = str(args.booking);
2221
2468
  const who = identity(deps, ctx, key, args);
2222
- const preview = await deps.backend.previewCancellation(ref, who);
2469
+ const { found: preview, tooMany } = await guarded(deps, key, who, () => deps.backend.previewCancellation(ref, who));
2223
2470
  if (!preview) {
2224
2471
  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.',
2472
+ structured: {
2473
+ view: 'booking-not-found',
2474
+ ...common(deps, key),
2475
+ query: ref,
2476
+ ...(tooMany ? { tooMany: true } : {}),
2477
+ },
2478
+ text: tooMany
2479
+ ? TOO_MANY_TRIES
2480
+ : 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
2227
2481
  };
2228
2482
  }
2229
2483
  const asPreview = {
@@ -2244,17 +2498,33 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2244
2498
  preview.cancellable
2245
2499
  ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
2246
2500
  : '',
2501
+ deps.vocab.demo ? DEMO_NOTE : '',
2247
2502
  ]
2248
2503
  .filter(Boolean)
2249
2504
  .join(' '),
2250
2505
  };
2251
2506
  // ⚠️ THE PREVIEW IS NOT ADVISORY. Cancelling without one is how a guest
2252
2507
  // 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.
2508
+ // returns this same preview and does nothing until it is told again - and
2509
+ // told again about the fee that was SHOWN (`shownPreviews`).
2510
+ const shownKey = `${key}|cancel|${preview.bookingId}`;
2511
+ const wasShown = previewsOf(deps).get(shownKey);
2512
+ remember(deps, shownKey, preview.fee.minor);
2254
2513
  if (name === t.previewCancellation || args.confirmed !== true)
2255
2514
  return asPreview;
2256
2515
  if (!preview.cancellable)
2257
2516
  return asPreview;
2517
+ if (wasShown === undefined || wasShown !== preview.fee.minor) {
2518
+ return {
2519
+ ...asPreview,
2520
+ text: [
2521
+ wasShown === undefined
2522
+ ? 'Nothing is cancelled yet: this is what cancelling would cost, for the guest to see first.'
2523
+ : `The fee has changed since it was shown - it is now ${formatMoney(preview.fee)}. Nothing is cancelled yet.`,
2524
+ asPreview.text,
2525
+ ].join(' '),
2526
+ };
2527
+ }
2258
2528
  const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
2259
2529
  if (!cancelled)
2260
2530
  return asPreview;
@@ -2268,7 +2538,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2268
2538
  rule: preview.rule,
2269
2539
  cancellable: false,
2270
2540
  },
2271
- text: `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
2541
+ text: [
2542
+ `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
2543
+ deps.vocab.demo ? DEMO_NOTE : '',
2544
+ ]
2545
+ .filter(Boolean)
2546
+ .join(' '),
2272
2547
  };
2273
2548
  }
2274
2549
  if (name === t.compare) {
@@ -2282,6 +2557,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2282
2557
  stay: asStay(args),
2283
2558
  recommended: str(args.recommended) || undefined,
2284
2559
  title: str(args.title) || undefined,
2560
+ // The member's rate, like the list and the hotel beside it: without
2561
+ // it the comparison quoted HK$5,424 where both said HK$4,610.
2562
+ who: identity(deps, ctx, key),
2285
2563
  });
2286
2564
  if (comparison.columns.length === 0) {
2287
2565
  return {
@@ -2357,7 +2635,23 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2357
2635
  if (name === t.signIn) {
2358
2636
  const number = str(args.number);
2359
2637
  const surname = str(args.surname);
2638
+ // Counted like a booking lookup - see limits.ts. A number and a surname
2639
+ // read a tier, a point balance and saved preferences.
2640
+ const tries = [`signin|${key}`, `member|${number.replace(/\s+/g, '').toUpperCase()}`];
2641
+ if (deps.misses?.blocked(tries)) {
2642
+ return {
2643
+ structured: {
2644
+ view: 'member-not-found',
2645
+ ...common(deps, key),
2646
+ query: number,
2647
+ tooMany: true,
2648
+ },
2649
+ 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.',
2650
+ };
2651
+ }
2360
2652
  const member = await deps.backend.signInMember(number, surname);
2653
+ if (!member)
2654
+ deps.misses?.miss(tries);
2361
2655
  if (!member) {
2362
2656
  // ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
2363
2657
  // enough, have the discount" - is inventing a membership, and the guest
@@ -2403,6 +2697,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
2403
2697
  await deps.backend.rememberPreferences(m, []);
2404
2698
  const { preferences: _gone, ...member } = m;
2405
2699
  deps.members.set(key, member);
2700
+ deps.members.setPreferences(m.number, []);
2406
2701
  return {
2407
2702
  structured: { view: 'member', ...common(deps, key), member },
2408
2703
  text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,