@curless/hotel-storefront 0.13.1 → 0.14.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.
package/dist/tools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX } from './backend.js';
1
+ import { LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX } from './backend.js';
2
2
  import { isDay, nights as nightsBetween } from './dates.js';
3
3
  import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
4
4
  import { formatMoney, money, multiplyMoney, toMinor } from './money.js';
@@ -124,8 +124,11 @@ export const buildTools = (n, v) => {
124
124
  properties: {
125
125
  hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
126
126
  ...stayProps,
127
+ booking: {
128
+ type: 'string',
129
+ 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`,
130
+ },
127
131
  },
128
- required: ['hotel'],
129
132
  },
130
133
  },
131
134
  {
@@ -195,6 +198,10 @@ export const buildTools = (n, v) => {
195
198
  type: 'string',
196
199
  description: `Anything else for the desk, in the guest's own words, up to ${REQUEST_NOTE_MAX} characters`,
197
200
  },
201
+ rememberRequests: {
202
+ type: 'boolean',
203
+ 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',
204
+ },
198
205
  },
199
206
  required: ['name', 'email'],
200
207
  },
@@ -254,7 +261,7 @@ export const buildTools = (n, v) => {
254
261
  },
255
262
  {
256
263
  name: n.previewChange,
257
- description: `What moving a booking to other nights, another room type or a different number of rooms would cost: the stay it holds now, the stay it would hold, and the difference between them. The new nights are priced at what those nights cost today, not at the rate the booking was made on. A rate that cannot be moved, a ${v.noun} that has no room left for the new nights and a booking that is already gone are each said in those words. Nothing is changed.`,
264
+ description: `What moving a booking to other nights, another room type or a different number of rooms - or putting an add-on on it, taking one off or changing how many - would cost: what it holds now, what it would hold, and the difference between them. The new nights are priced at what those nights cost today, not at the rate the booking was made on. A rate that cannot be moved, a ${v.noun} that has no room left for the new nights and a booking that is already gone are each said in those words. Nothing is changed.`,
258
265
  annotations: reads('Preview a booking change', open),
259
266
  inputSchema: {
260
267
  type: 'object',
@@ -276,6 +283,14 @@ export const buildTools = (n, v) => {
276
283
  type: 'string',
277
284
  description: 'A different room type at the same hotel, by id or name',
278
285
  },
286
+ extra: {
287
+ type: 'string',
288
+ description: `An add-on at this ${v.noun}, by id or name, to put on the booking, take off or change - with \`quantity\``,
289
+ },
290
+ quantity: {
291
+ type: 'number',
292
+ description: `How many of that add-on the booking should hold, 0 to ${MAX_EXTRA_QUANTITY}; 0 takes it off`,
293
+ },
279
294
  email: {
280
295
  type: 'string',
281
296
  description: 'The email the booking was made with, when it was made elsewhere',
@@ -286,7 +301,7 @@ export const buildTools = (n, v) => {
286
301
  },
287
302
  {
288
303
  name: n.changeBooking,
289
- description: 'Moves a booking to other nights, another room type or a different number of rooms. Called without `confirmed` it returns the same preview - the two stays and the difference - and changes nothing; it changes only when `confirmed` is true. The confirmation code stays the same.',
304
+ description: 'Moves a booking to other nights, another room type or a different number of rooms, or sets one of its add-ons (`extra` and `quantity`; 0 takes it off). Called without `confirmed` it returns the same preview - the two stays and the difference - and changes nothing; it changes only when `confirmed` is true. The confirmation code stays the same.',
290
305
  annotations: edits('Change a booking', open),
291
306
  inputSchema: {
292
307
  type: 'object',
@@ -308,6 +323,14 @@ export const buildTools = (n, v) => {
308
323
  type: 'string',
309
324
  description: 'A different room type at the same hotel, by id or name',
310
325
  },
326
+ extra: {
327
+ type: 'string',
328
+ description: `An add-on at this ${v.noun}, by id or name, to put on the booking, take off or change - with \`quantity\``,
329
+ },
330
+ quantity: {
331
+ type: 'number',
332
+ description: `How many of that add-on the booking should hold, 0 to ${MAX_EXTRA_QUANTITY}; 0 takes it off`,
333
+ },
311
334
  email: {
312
335
  type: 'string',
313
336
  description: 'The email the booking was made with, when it was made elsewhere',
@@ -415,6 +438,18 @@ export const buildTools = (n, v) => {
415
438
  annotations: edits(`Sign out of ${v.brand}`, open),
416
439
  inputSchema: { type: 'object', properties: {} },
417
440
  },
441
+ {
442
+ name: n.forgetPreferences,
443
+ 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.`,
444
+ annotations: {
445
+ title: 'Forget my saved preferences',
446
+ readOnlyHint: false,
447
+ destructiveHint: true,
448
+ idempotentHint: true,
449
+ openWorldHint: open,
450
+ },
451
+ inputSchema: { type: 'object', properties: {} },
452
+ },
418
453
  {
419
454
  name: n.policies,
420
455
  description: `What one ${v.noun} publishes about cancellation, check-in and check-out, children and extra beds, pets, deposits and accessibility. With \`topic\` it returns that topic; without, all of them.`,
@@ -666,6 +701,16 @@ export const buildOutputSchemas = (n) => ({
666
701
  properties: { view: { const: 'signed-out' }, brand: STR, toolNames: OBJ },
667
702
  required: ['view'],
668
703
  },
704
+ [n.forgetPreferences]: {
705
+ type: 'object',
706
+ properties: {
707
+ view: { enum: ['member', 'no-member'] },
708
+ brand: STR,
709
+ toolNames: OBJ,
710
+ member: OBJ,
711
+ },
712
+ required: ['view'],
713
+ },
669
714
  [n.policies]: {
670
715
  type: 'object',
671
716
  properties: {
@@ -705,9 +750,19 @@ const asStay = (args) => {
705
750
  */
706
751
  const memberBadge = (deps, key) => {
707
752
  const m = deps.members.read(key);
753
+ // ⚠️ `signedIn` ON EVERY ANSWER, false included. The card cleared its tier
754
+ // chip only on the sign-out view, so after signing out in the chat - or a
755
+ // sign-in that lapsed - older cards kept "Gold · 5% off" over public prices.
708
756
  return m
709
- ? { member: { programme: m.programme, tier: { name: m.tier.name, discount: m.tier.discount } } }
710
- : {};
757
+ ? {
758
+ signedIn: true,
759
+ member: {
760
+ programme: m.programme,
761
+ tier: { name: m.tier.name, discount: m.tier.discount },
762
+ ...(m.preferences?.length ? { preferences: m.preferences } : {}),
763
+ },
764
+ }
765
+ : { signedIn: false };
711
766
  };
712
767
  const common = (deps, key) => ({
713
768
  brand: deps.vocab.brand,
@@ -912,6 +967,26 @@ const widen = (ask) => {
912
967
  }
913
968
  return undefined;
914
969
  };
970
+ /** What a change would do, in one sentence - the nights, and the add-on when it sets one. */
971
+ const changeSentence = (p) => {
972
+ const moves = !!p.to &&
973
+ (p.to.stay.checkIn !== p.from.stay.checkIn ||
974
+ p.to.stay.checkOut !== p.from.stay.checkOut ||
975
+ p.to.rooms !== p.from.rooms ||
976
+ p.to.roomName !== p.from.roomName);
977
+ const a = p.addOn;
978
+ const addOn = !a
979
+ ? ''
980
+ : a.to === 0
981
+ ? `take ${a.name} off`
982
+ : a.from === 0
983
+ ? `add ${a.to}× ${a.name}`
984
+ : `change ${a.name} from ${a.from} to ${a.to}`;
985
+ const nights = moves
986
+ ? `move to ${p.to?.stay.checkIn} - ${p.to?.stay.checkOut}${p.to && p.to.rooms > 1 ? `, ${p.to.rooms} rooms` : ''}`
987
+ : '';
988
+ return `${p.confirmationCode} would ${[nights, addOn].filter(Boolean).join(' and ')}.`;
989
+ };
915
990
  /**
916
991
  * How much of a plan is real, in the one sentence the model is told to repeat:
917
992
  * what is booked, what is in the trip, and that the rest is only a plan.
@@ -1030,35 +1105,132 @@ const identity = (deps, ctx, key, args = {}) => {
1030
1105
  };
1031
1106
  };
1032
1107
  /**
1033
- * Why this hotel cannot join the trip that is already there - or null.
1108
+ * The trip priced one currency at a time, in the order the currencies first
1109
+ * appear in it.
1034
1110
  *
1035
- * One trip, one currency. Not a limitation to route around: converting HKD to
1036
- * CNY means picking a rate, and a rate we picked is a number the guest would
1037
- * read off a card as if the hotel had said it.
1111
+ * ⚠️ TWO CURRENCIES ARE TWO TOTALS (robin, 2026-09-28: 「4 ok」). A Hong Kong -
1112
+ * Shanghai trip used to be refused at the second hotel, because one total would
1113
+ * have needed an exchange rate and a rate we picked is a number a guest would
1114
+ * read as the hotel's. Now it is two quotes, drawn side by side and never
1115
+ * added up; each hotel is booked, and paid, in its own.
1038
1116
  */
1039
- const currencyClash = async (deps, key, hotel) => {
1040
- const lines = deps.trips.read(key);
1041
- if (lines.length === 0)
1042
- return null;
1043
- const theirs = hotel.fromNightly?.currency;
1044
- if (!theirs)
1045
- return null;
1046
- const quote = await deps.backend.quote({ lines });
1047
- const ours = quote.lines.length ? quote.total.currency : undefined;
1048
- if (!ours || ours === theirs)
1049
- return null;
1050
- return `This trip is priced in ${ours} and ${hotel.name} charges in ${theirs}. No exchange rate is invented here, so one trip is one currency: request this trip first - a booked trip is empty again - and then put ${hotel.name} in it, or clear this one.`;
1117
+ const quoteParts = async (deps, lines, who) => {
1118
+ const byCurrency = new Map();
1119
+ for (const l of lines) {
1120
+ const c = (await deps.backend.getHotel(l.hotelId))?.hotel.currency ?? '';
1121
+ byCurrency.set(c, [...(byCurrency.get(c) ?? []), l]);
1122
+ }
1123
+ if (byCurrency.size === 0)
1124
+ return [await deps.backend.quote({ lines: [] }, who)];
1125
+ return Promise.all([...byCurrency.values()].map((g) => deps.backend.quote({ lines: g }, who)));
1051
1126
  };
1052
1127
  /** The trip, priced, as both views need it. */
1053
1128
  const currentTrip = async (deps, key, who) => {
1054
1129
  const lines = deps.trips.read(key);
1055
- const quote = await deps.backend.quote({ lines }, who);
1130
+ const parts = await quoteParts(deps, lines, who);
1131
+ const [only] = parts;
1132
+ if (parts.length === 1 && only) {
1133
+ return {
1134
+ structured: {
1135
+ view: 'trip',
1136
+ ...common(deps, key),
1137
+ ...quoteForCard(only),
1138
+ count: lines.length,
1139
+ },
1140
+ text: tripText(only, deps.vocab.demo),
1141
+ };
1142
+ }
1143
+ const shaped = parts.map(quoteForCard);
1056
1144
  return {
1057
- structured: { view: 'trip', ...common(deps, key), ...quoteForCard(quote), count: lines.length },
1058
- text: tripText(quote, deps.vocab.demo),
1145
+ structured: {
1146
+ view: 'trip',
1147
+ ...common(deps, key),
1148
+ lines: shaped.flatMap((p) => p.lines),
1149
+ // One block per currency, each with its own taxes and total. `total` is
1150
+ // the totals side by side for anything that reads one string - a list,
1151
+ // never a sum.
1152
+ groups: shaped.map((p, i) => ({ ...p, currency: parts[i]?.total.currency })),
1153
+ currencies: parts.map((p) => p.total.currency),
1154
+ total: shaped.map((p) => p.total).join(' + '),
1155
+ ...(shaped.some((p) => p.dropped) ? { dropped: shaped.flatMap((p) => p.dropped ?? []) } : {}),
1156
+ count: lines.length,
1157
+ },
1158
+ text: [
1159
+ `This trip is in ${parts.length} currencies, priced separately and not converted - each hotel is booked and paid in its own.`,
1160
+ ...parts.map((q) => tripText(q, false)),
1161
+ deps.vocab.demo ? DEMO_NOTE : '',
1162
+ ]
1163
+ .filter(Boolean)
1164
+ .join('\n\n'),
1059
1165
  };
1060
1166
  };
1167
+ /**
1168
+ * What the guest might say next, as a few chips under the card.
1169
+ *
1170
+ * ⚠️ WRITTEN HERE, FROM WHAT IS ON SCREEN - not by the model. Each is a
1171
+ * sentence the GUEST would say, sent to the chat as theirs when pressed, so
1172
+ * it names what it is about ("the cancellation policy at the Island
1173
+ * Shangri-La") rather than pointing at the screen: the same words have to make
1174
+ * sense in a transcript. Only what this answer can actually lead to - no
1175
+ * "compare" beside a city with one hotel, no transfer beside a booking that
1176
+ * has one - and three at most, so they read as a hint and not a menu
1177
+ * (robin, 2026-09-28: 「建议按钮」).
1178
+ */
1179
+ const suggestionsFor = async (deps, d) => {
1180
+ const view = String(d.view ?? '');
1181
+ const out = [];
1182
+ const hotel = d.hotel;
1183
+ if (view === 'hotel' && hotel && Array.isArray(d.rooms) && d.rooms.length) {
1184
+ if ((await deps.backend.extrasFor(hotel.id)).length)
1185
+ out.push(`What add-ons does ${hotel.name} offer?`);
1186
+ out.push(`What is the cancellation policy at ${hotel.name}?`);
1187
+ const nearby = await deps.backend.searchHotels({ city: hotel.city, limit: 2 });
1188
+ if (nearby.total > 1)
1189
+ out.push(`Compare ${hotel.name} with the other ${hotel.city} hotels`);
1190
+ }
1191
+ // Not across currencies: that sort is refused there (see `sort`), and a chip
1192
+ // for it would ask for something the next answer says it will not do.
1193
+ if (view === 'hotels' &&
1194
+ Array.isArray(d.hotels) &&
1195
+ d.hotels.length > 1 &&
1196
+ d.sort !== 'price-low' &&
1197
+ !d.currencies)
1198
+ out.push('Show the cheapest first');
1199
+ if (view === 'comparison') {
1200
+ const pick = (d.columns ?? []).find((c) => c.recommended);
1201
+ if (pick) {
1202
+ out.push(`Show me the rooms at ${pick.hotel.name}`);
1203
+ out.push(`Plan the days at ${pick.hotel.name}`);
1204
+ }
1205
+ }
1206
+ if (view === 'itinerary' && Number(d.planned) > Number(d.inTrip) + Number(d.booked ?? 0))
1207
+ out.push('Put the stays on this plan in the trip');
1208
+ if (view === 'booking') {
1209
+ const all = (Array.isArray(d.bookings) ? d.bookings : [d.booking]);
1210
+ for (const b of all.slice(0, 2)) {
1211
+ if (!b?.hotelId)
1212
+ continue;
1213
+ const transfer = (await deps.backend.extrasFor(b.hotelId, b.stay)).find((e) => e.kind === 'transfer');
1214
+ const has = b.quote?.lines?.some((l) => l.extraRef === transfer?.id);
1215
+ // The add-on's own name, quoted: "Airport transfer, one way" unquoted
1216
+ // ran on into the code as if the transfer went TO the booking.
1217
+ if (transfer && !has)
1218
+ out.push(`Add "${transfer.name}" to booking ${b.confirmationCode}`);
1219
+ }
1220
+ const first = all[0];
1221
+ if (first?.hotelName)
1222
+ out.push(`What time is check-in at ${first.hotelName}?`);
1223
+ }
1224
+ return out.slice(0, 3);
1225
+ };
1061
1226
  export const handleTool = async (deps, name, rawArgs, ctx) => {
1227
+ const answer = await answerTool(deps, name, rawArgs, ctx);
1228
+ const suggestions = await suggestionsFor(deps, answer.structured).catch(() => []);
1229
+ return suggestions.length
1230
+ ? { ...answer, structured: { ...answer.structured, suggestions } }
1231
+ : answer;
1232
+ };
1233
+ const answerTool = async (deps, name, rawArgs, ctx) => {
1062
1234
  const args = (rawArgs ?? {});
1063
1235
  const t = deps.tools;
1064
1236
  const key = ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand);
@@ -1324,7 +1496,22 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1324
1496
  };
1325
1497
  }
1326
1498
  if (name === t.listExtras) {
1327
- const wanted = str(args.hotel);
1499
+ // ⚠️ AN ADD-ON FOR A BOOKING ALREADY MADE (robin, 2026-09-28). Add-ons
1500
+ // could only go in the trip before the request; once booked, an airport
1501
+ // transfer had nowhere to go. Asked with a booking, this lists that
1502
+ // hotel's add-ons for that booking's nights, with what it already holds.
1503
+ const code = str(args.booking);
1504
+ const booked = code
1505
+ ? (await deps.backend.listBookings(identity(deps, ctx, key, args), 50)).find((b) => (b.confirmationCode === code || b.id === code) &&
1506
+ (b.status === 'requested' || b.status === 'confirmed'))
1507
+ : undefined;
1508
+ if (code && !booked) {
1509
+ return {
1510
+ structured: { view: 'booking-not-found', ...common(deps, key), query: code },
1511
+ text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
1512
+ };
1513
+ }
1514
+ const wanted = booked ? booked.hotelId : str(args.hotel);
1328
1515
  const detail = await deps.backend.getHotel(wanted);
1329
1516
  if (!detail) {
1330
1517
  return {
@@ -1332,12 +1519,22 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1332
1519
  text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
1333
1520
  };
1334
1521
  }
1335
- const extras = await deps.backend.extrasFor(detail.hotel.id, asStay(args));
1522
+ const extras = await deps.backend.extrasFor(detail.hotel.id, booked?.stay ?? asStay(args));
1523
+ const held = Object.fromEntries((booked?.quote.lines ?? []).flatMap((l) => l.kind === 'extra' && l.extraRef ? [[l.extraRef, l.ordered ?? l.quantity ?? 1]] : []));
1336
1524
  return {
1337
1525
  structured: {
1338
1526
  view: 'extras',
1339
1527
  ...common(deps, key),
1340
1528
  hotel: detail.hotel,
1529
+ ...(booked
1530
+ ? {
1531
+ forBooking: {
1532
+ confirmationCode: booked.confirmationCode,
1533
+ stay: booked.stay,
1534
+ held,
1535
+ },
1536
+ }
1537
+ : {}),
1341
1538
  extras: extras.map((e) => ({
1342
1539
  ...e,
1343
1540
  description: hotelText('add-on', detail.hotel.name, e.description, DESCRIPTION_MAX).text,
@@ -1346,7 +1543,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1346
1543
  },
1347
1544
  text: extras.length
1348
1545
  ? extras
1349
- .map((e) => `- ${e.name} — ${formatMoney(e.price)} per ${e.per}`)
1546
+ .map((e) => `- ${e.name} — ${formatMoney(e.price)} per ${e.per}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
1350
1547
  .concat(deps.vocab.demo ? [DEMO_NOTE] : [])
1351
1548
  .join('\n')
1352
1549
  : `${detail.hotel.name} has no add-ons listed here.`,
@@ -1382,18 +1579,6 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1382
1579
  text: `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`,
1383
1580
  };
1384
1581
  }
1385
- const clash = await currencyClash(deps, key, detail.hotel);
1386
- if (clash) {
1387
- return {
1388
- structured: {
1389
- view: 'error',
1390
- ...common(deps, key),
1391
- error: 'mixed-currency',
1392
- detail: clash,
1393
- },
1394
- text: clash,
1395
- };
1396
- }
1397
1582
  const line = {
1398
1583
  kind: 'extra',
1399
1584
  hotelId,
@@ -1455,24 +1640,6 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1455
1640
  text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => r.name).join(', ')}.`,
1456
1641
  };
1457
1642
  }
1458
- // ⚠️ A LINE THAT WILL NEVER PRICE DOES NOT GO IN. The quote drops a hotel
1459
- // billing in another currency - no exchange rate is invented here - but
1460
- // until now the line still went into the trip, so the guest carried
1461
- // something that was silently dropped from every total from then on
1462
- // (2026-09-18). Refused here, with the trip left exactly as it was; the
1463
- // quote's own drop stays as the backstop for a hotel whose currency moves.
1464
- const wrongCurrency = await currencyClash(deps, key, detail.hotel);
1465
- if (wrongCurrency) {
1466
- return {
1467
- structured: {
1468
- view: 'error',
1469
- ...common(deps, key),
1470
- error: 'mixed-currency',
1471
- detail: wrongCurrency,
1472
- },
1473
- text: wrongCurrency,
1474
- };
1475
- }
1476
1643
  deps.trips.set(key, {
1477
1644
  kind: 'room',
1478
1645
  hotelId,
@@ -1524,6 +1691,33 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1524
1691
  // because a booking is found by the address it was made with.
1525
1692
  { ...identity(deps, ctx, key), email: guest.email }, requests);
1526
1693
  deps.trips.clear(key);
1694
+ // ⚠️ REMEMBERED ONLY WHEN ASKED - see `rememberPreferences`. Said either
1695
+ // way, so neither the guest nor the model assumes something was kept.
1696
+ const signedIn = deps.members.read(key);
1697
+ let remembered;
1698
+ if (args.rememberRequests === true) {
1699
+ const lasting = (requests?.kinds ?? []).filter((k) => LASTING_REQUESTS.includes(k));
1700
+ if (!signedIn) {
1701
+ remembered = {
1702
+ kept: [],
1703
+ line: 'Nothing was remembered: saved preferences belong to a membership, and nobody is signed in.',
1704
+ };
1705
+ }
1706
+ else if (!lasting.length) {
1707
+ remembered = {
1708
+ kept: signedIn.preferences ?? [],
1709
+ line: 'Nothing new was remembered: an early check-in, a late arrival and a note are for one stay, not every stay.',
1710
+ };
1711
+ }
1712
+ else {
1713
+ const kept = await deps.backend.rememberPreferences(signedIn, lasting);
1714
+ deps.members.set(key, { ...signedIn, preferences: kept });
1715
+ remembered = {
1716
+ kept,
1717
+ line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
1718
+ };
1719
+ }
1720
+ }
1527
1721
  // The hotel's own check-in hour, so an early-check-in request is answered
1528
1722
  // with the time it is early against - London opens at 16:00 and Hong Kong
1529
1723
  // at 15:00, and a generic "check-in is in the afternoon" is not an answer.
@@ -1558,6 +1752,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1558
1752
  }
1559
1753
  : {}),
1560
1754
  paid: false,
1755
+ ...(remembered ? { remembered: remembered.kept } : {}),
1561
1756
  holdUntil: first.booking.holdUntil,
1562
1757
  total: formatMoney(first.booking.quote.total),
1563
1758
  ...(first.asked.length ? { requested: first.asked } : {}),
@@ -1574,6 +1769,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1574
1769
  `${formatMoney(booking.quote.total)} all in. **Nothing has been charged** - the rooms are held until ${booking.holdUntil?.slice(0, 16).replace('T', ' ')} UTC, and ${booking.guest.name} presents a card at check-in.`,
1575
1770
  ...asked,
1576
1771
  ]),
1772
+ remembered?.line ?? '',
1577
1773
  deps.vocab.demo ? DEMO_NOTE : '',
1578
1774
  ]
1579
1775
  .filter(Boolean)
@@ -1614,6 +1810,8 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1614
1810
  ...(str(args.checkOut) ? { checkOut: str(args.checkOut) } : {}),
1615
1811
  ...(typeof args.rooms === 'number' ? { rooms: args.rooms } : {}),
1616
1812
  ...(str(args.room) ? { room: str(args.room) } : {}),
1813
+ ...(str(args.extra) ? { extra: str(args.extra) } : {}),
1814
+ ...(typeof args.quantity === 'number' ? { quantity: args.quantity } : {}),
1617
1815
  };
1618
1816
  const preview = await deps.backend.previewChange(ref, change, who);
1619
1817
  if (!preview) {
@@ -1647,12 +1845,13 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1647
1845
  ...(diff ? { difference: diff, refundDue } : {}),
1648
1846
  changeable: preview.changeable,
1649
1847
  ...(preview.roomsLeft === undefined ? {} : { roomsLeft: preview.roomsLeft }),
1848
+ ...(preview.addOn ? { addOn: preview.addOn } : {}),
1650
1849
  rule: preview.rule,
1651
1850
  toolToConfirm: t.changeBooking,
1652
1851
  },
1653
1852
  text: preview.changeable
1654
1853
  ? [
1655
- `${preview.confirmationCode} would move to ${preview.to?.stay.checkIn} - ${preview.to?.stay.checkOut}${preview.to && preview.to.rooms > 1 ? `, ${preview.to.rooms} rooms` : ''}.`,
1854
+ changeSentence(preview),
1656
1855
  diff && preview.difference?.minor !== 0
1657
1856
  ? refundDue
1658
1857
  ? `That is ${diff} less than it holds now.`
@@ -1683,6 +1882,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1683
1882
  from: stayOf(preview.from),
1684
1883
  ...(preview.to ? { to: stayOf(preview.to) } : {}),
1685
1884
  ...(diff ? { difference: diff, refundDue } : {}),
1885
+ ...(preview.addOn ? { addOn: preview.addOn } : {}),
1686
1886
  changeable: false,
1687
1887
  rule: preview.rule,
1688
1888
  },
@@ -1856,6 +2056,9 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1856
2056
  text: [
1857
2057
  `Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
1858
2058
  `Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
2059
+ member.preferences?.length
2060
+ ? `Saved preferences on this membership: ${member.preferences.map((k) => REQUEST_WORDS[k]).join(', ')}. They are ticked on the booking form; mention them, and ask before booking with them.`
2061
+ : '',
1859
2062
  deps.vocab.demo ? DEMO_NOTE : '',
1860
2063
  ]
1861
2064
  .filter(Boolean)
@@ -1872,6 +2075,22 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
1872
2075
  : 'Nobody was signed in.',
1873
2076
  };
1874
2077
  }
2078
+ if (name === t.forgetPreferences) {
2079
+ const m = deps.members.read(key);
2080
+ if (!m) {
2081
+ return {
2082
+ structured: { view: 'no-member', ...common(deps, key) },
2083
+ text: `Nobody is signed in, so there is nothing saved to forget. ${t.signIn} signs a member in.`,
2084
+ };
2085
+ }
2086
+ await deps.backend.rememberPreferences(m, []);
2087
+ const { preferences: _gone, ...member } = m;
2088
+ deps.members.set(key, member);
2089
+ return {
2090
+ structured: { view: 'member', ...common(deps, key), member },
2091
+ text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
2092
+ };
2093
+ }
1875
2094
  if (name === t.policies) {
1876
2095
  const wanted = str(args.hotel);
1877
2096
  const detail = await deps.backend.getHotel(wanted);