@curless/hotel-storefront 0.13.1 → 0.14.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.
- package/dist/__tests__/demo-hotels.test.js +29 -5
- package/dist/__tests__/demo-hotels.test.js.map +1 -1
- package/dist/__tests__/server.test.js +1 -1
- package/dist/__tests__/server.test.js.map +1 -1
- package/dist/__tests__/tools.test.js +173 -19
- package/dist/__tests__/tools.test.js.map +1 -1
- package/dist/backend.d.ts +22 -13
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +7 -0
- package/dist/backend.js.map +1 -1
- package/dist/card.generated.d.ts +1 -1
- package/dist/card.generated.js +2 -2
- package/dist/card.generated.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/demo-hotels.d.ts.map +1 -1
- package/dist/demo-hotels.js +92 -20
- package/dist/demo-hotels.js.map +1 -1
- package/dist/legal.d.ts.map +1 -1
- package/dist/legal.js +12 -1
- package/dist/legal.js.map +1 -1
- package/dist/money.d.ts +11 -0
- package/dist/money.d.ts.map +1 -1
- package/dist/money.js +14 -0
- package/dist/money.js.map +1 -1
- package/dist/server.d.ts +8 -8
- package/dist/server.js +2 -2
- package/dist/surface.d.ts.map +1 -1
- package/dist/surface.js +8 -0
- package/dist/surface.js.map +1 -1
- package/dist/tools.d.ts +40 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +276 -59
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
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:
|
|
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
|
-
? {
|
|
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,130 @@ const identity = (deps, ctx, key, args = {}) => {
|
|
|
1030
1105
|
};
|
|
1031
1106
|
};
|
|
1032
1107
|
/**
|
|
1033
|
-
*
|
|
1108
|
+
* The trip priced one currency at a time, in the order the currencies first
|
|
1109
|
+
* appear in it.
|
|
1034
1110
|
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
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
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
|
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: {
|
|
1058
|
-
|
|
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
|
+
if (transfer && !has)
|
|
1216
|
+
out.push(`Add ${transfer.name.toLowerCase()} to ${b.confirmationCode}`);
|
|
1217
|
+
}
|
|
1218
|
+
const first = all[0];
|
|
1219
|
+
if (first?.hotelName)
|
|
1220
|
+
out.push(`What time is check-in at ${first.hotelName}?`);
|
|
1221
|
+
}
|
|
1222
|
+
return out.slice(0, 3);
|
|
1223
|
+
};
|
|
1061
1224
|
export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
1225
|
+
const answer = await answerTool(deps, name, rawArgs, ctx);
|
|
1226
|
+
const suggestions = await suggestionsFor(deps, answer.structured).catch(() => []);
|
|
1227
|
+
return suggestions.length
|
|
1228
|
+
? { ...answer, structured: { ...answer.structured, suggestions } }
|
|
1229
|
+
: answer;
|
|
1230
|
+
};
|
|
1231
|
+
const answerTool = async (deps, name, rawArgs, ctx) => {
|
|
1062
1232
|
const args = (rawArgs ?? {});
|
|
1063
1233
|
const t = deps.tools;
|
|
1064
1234
|
const key = ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand);
|
|
@@ -1324,7 +1494,22 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1324
1494
|
};
|
|
1325
1495
|
}
|
|
1326
1496
|
if (name === t.listExtras) {
|
|
1327
|
-
|
|
1497
|
+
// ⚠️ AN ADD-ON FOR A BOOKING ALREADY MADE (robin, 2026-09-28). Add-ons
|
|
1498
|
+
// could only go in the trip before the request; once booked, an airport
|
|
1499
|
+
// transfer had nowhere to go. Asked with a booking, this lists that
|
|
1500
|
+
// hotel's add-ons for that booking's nights, with what it already holds.
|
|
1501
|
+
const code = str(args.booking);
|
|
1502
|
+
const booked = code
|
|
1503
|
+
? (await deps.backend.listBookings(identity(deps, ctx, key, args), 50)).find((b) => (b.confirmationCode === code || b.id === code) &&
|
|
1504
|
+
(b.status === 'requested' || b.status === 'confirmed'))
|
|
1505
|
+
: undefined;
|
|
1506
|
+
if (code && !booked) {
|
|
1507
|
+
return {
|
|
1508
|
+
structured: { view: 'booking-not-found', ...common(deps, key), query: code },
|
|
1509
|
+
text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
const wanted = booked ? booked.hotelId : str(args.hotel);
|
|
1328
1513
|
const detail = await deps.backend.getHotel(wanted);
|
|
1329
1514
|
if (!detail) {
|
|
1330
1515
|
return {
|
|
@@ -1332,12 +1517,22 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1332
1517
|
text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
|
|
1333
1518
|
};
|
|
1334
1519
|
}
|
|
1335
|
-
const extras = await deps.backend.extrasFor(detail.hotel.id, asStay(args));
|
|
1520
|
+
const extras = await deps.backend.extrasFor(detail.hotel.id, booked?.stay ?? asStay(args));
|
|
1521
|
+
const held = Object.fromEntries((booked?.quote.lines ?? []).flatMap((l) => l.kind === 'extra' && l.extraRef ? [[l.extraRef, l.ordered ?? l.quantity ?? 1]] : []));
|
|
1336
1522
|
return {
|
|
1337
1523
|
structured: {
|
|
1338
1524
|
view: 'extras',
|
|
1339
1525
|
...common(deps, key),
|
|
1340
1526
|
hotel: detail.hotel,
|
|
1527
|
+
...(booked
|
|
1528
|
+
? {
|
|
1529
|
+
forBooking: {
|
|
1530
|
+
confirmationCode: booked.confirmationCode,
|
|
1531
|
+
stay: booked.stay,
|
|
1532
|
+
held,
|
|
1533
|
+
},
|
|
1534
|
+
}
|
|
1535
|
+
: {}),
|
|
1341
1536
|
extras: extras.map((e) => ({
|
|
1342
1537
|
...e,
|
|
1343
1538
|
description: hotelText('add-on', detail.hotel.name, e.description, DESCRIPTION_MAX).text,
|
|
@@ -1346,7 +1541,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1346
1541
|
},
|
|
1347
1542
|
text: extras.length
|
|
1348
1543
|
? extras
|
|
1349
|
-
.map((e) => `- ${e.name} — ${formatMoney(e.price)} per ${e.per}`)
|
|
1544
|
+
.map((e) => `- ${e.name} — ${formatMoney(e.price)} per ${e.per}${held[e.id] ? ` (${booked?.confirmationCode} holds ${held[e.id]})` : ''}`)
|
|
1350
1545
|
.concat(deps.vocab.demo ? [DEMO_NOTE] : [])
|
|
1351
1546
|
.join('\n')
|
|
1352
1547
|
: `${detail.hotel.name} has no add-ons listed here.`,
|
|
@@ -1382,18 +1577,6 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1382
1577
|
text: `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`,
|
|
1383
1578
|
};
|
|
1384
1579
|
}
|
|
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
1580
|
const line = {
|
|
1398
1581
|
kind: 'extra',
|
|
1399
1582
|
hotelId,
|
|
@@ -1455,24 +1638,6 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1455
1638
|
text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => r.name).join(', ')}.`,
|
|
1456
1639
|
};
|
|
1457
1640
|
}
|
|
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
1641
|
deps.trips.set(key, {
|
|
1477
1642
|
kind: 'room',
|
|
1478
1643
|
hotelId,
|
|
@@ -1524,6 +1689,33 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1524
1689
|
// because a booking is found by the address it was made with.
|
|
1525
1690
|
{ ...identity(deps, ctx, key), email: guest.email }, requests);
|
|
1526
1691
|
deps.trips.clear(key);
|
|
1692
|
+
// ⚠️ REMEMBERED ONLY WHEN ASKED - see `rememberPreferences`. Said either
|
|
1693
|
+
// way, so neither the guest nor the model assumes something was kept.
|
|
1694
|
+
const signedIn = deps.members.read(key);
|
|
1695
|
+
let remembered;
|
|
1696
|
+
if (args.rememberRequests === true) {
|
|
1697
|
+
const lasting = (requests?.kinds ?? []).filter((k) => LASTING_REQUESTS.includes(k));
|
|
1698
|
+
if (!signedIn) {
|
|
1699
|
+
remembered = {
|
|
1700
|
+
kept: [],
|
|
1701
|
+
line: 'Nothing was remembered: saved preferences belong to a membership, and nobody is signed in.',
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
else if (!lasting.length) {
|
|
1705
|
+
remembered = {
|
|
1706
|
+
kept: signedIn.preferences ?? [],
|
|
1707
|
+
line: 'Nothing new was remembered: an early check-in, a late arrival and a note are for one stay, not every stay.',
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
else {
|
|
1711
|
+
const kept = await deps.backend.rememberPreferences(signedIn, lasting);
|
|
1712
|
+
deps.members.set(key, { ...signedIn, preferences: kept });
|
|
1713
|
+
remembered = {
|
|
1714
|
+
kept,
|
|
1715
|
+
line: `Remembered on this ${signedIn.programme} membership for next time: ${kept.map((k) => REQUEST_WORDS[k]).join(', ')}. ${t.forgetPreferences} forgets them.`,
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1527
1719
|
// The hotel's own check-in hour, so an early-check-in request is answered
|
|
1528
1720
|
// with the time it is early against - London opens at 16:00 and Hong Kong
|
|
1529
1721
|
// at 15:00, and a generic "check-in is in the afternoon" is not an answer.
|
|
@@ -1558,6 +1750,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1558
1750
|
}
|
|
1559
1751
|
: {}),
|
|
1560
1752
|
paid: false,
|
|
1753
|
+
...(remembered ? { remembered: remembered.kept } : {}),
|
|
1561
1754
|
holdUntil: first.booking.holdUntil,
|
|
1562
1755
|
total: formatMoney(first.booking.quote.total),
|
|
1563
1756
|
...(first.asked.length ? { requested: first.asked } : {}),
|
|
@@ -1574,6 +1767,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1574
1767
|
`${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
1768
|
...asked,
|
|
1576
1769
|
]),
|
|
1770
|
+
remembered?.line ?? '',
|
|
1577
1771
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1578
1772
|
]
|
|
1579
1773
|
.filter(Boolean)
|
|
@@ -1614,6 +1808,8 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1614
1808
|
...(str(args.checkOut) ? { checkOut: str(args.checkOut) } : {}),
|
|
1615
1809
|
...(typeof args.rooms === 'number' ? { rooms: args.rooms } : {}),
|
|
1616
1810
|
...(str(args.room) ? { room: str(args.room) } : {}),
|
|
1811
|
+
...(str(args.extra) ? { extra: str(args.extra) } : {}),
|
|
1812
|
+
...(typeof args.quantity === 'number' ? { quantity: args.quantity } : {}),
|
|
1617
1813
|
};
|
|
1618
1814
|
const preview = await deps.backend.previewChange(ref, change, who);
|
|
1619
1815
|
if (!preview) {
|
|
@@ -1647,12 +1843,13 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1647
1843
|
...(diff ? { difference: diff, refundDue } : {}),
|
|
1648
1844
|
changeable: preview.changeable,
|
|
1649
1845
|
...(preview.roomsLeft === undefined ? {} : { roomsLeft: preview.roomsLeft }),
|
|
1846
|
+
...(preview.addOn ? { addOn: preview.addOn } : {}),
|
|
1650
1847
|
rule: preview.rule,
|
|
1651
1848
|
toolToConfirm: t.changeBooking,
|
|
1652
1849
|
},
|
|
1653
1850
|
text: preview.changeable
|
|
1654
1851
|
? [
|
|
1655
|
-
|
|
1852
|
+
changeSentence(preview),
|
|
1656
1853
|
diff && preview.difference?.minor !== 0
|
|
1657
1854
|
? refundDue
|
|
1658
1855
|
? `That is ${diff} less than it holds now.`
|
|
@@ -1683,6 +1880,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1683
1880
|
from: stayOf(preview.from),
|
|
1684
1881
|
...(preview.to ? { to: stayOf(preview.to) } : {}),
|
|
1685
1882
|
...(diff ? { difference: diff, refundDue } : {}),
|
|
1883
|
+
...(preview.addOn ? { addOn: preview.addOn } : {}),
|
|
1686
1884
|
changeable: false,
|
|
1687
1885
|
rule: preview.rule,
|
|
1688
1886
|
},
|
|
@@ -1856,6 +2054,9 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1856
2054
|
text: [
|
|
1857
2055
|
`Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
|
|
1858
2056
|
`Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
|
|
2057
|
+
member.preferences?.length
|
|
2058
|
+
? `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.`
|
|
2059
|
+
: '',
|
|
1859
2060
|
deps.vocab.demo ? DEMO_NOTE : '',
|
|
1860
2061
|
]
|
|
1861
2062
|
.filter(Boolean)
|
|
@@ -1872,6 +2073,22 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
|
|
|
1872
2073
|
: 'Nobody was signed in.',
|
|
1873
2074
|
};
|
|
1874
2075
|
}
|
|
2076
|
+
if (name === t.forgetPreferences) {
|
|
2077
|
+
const m = deps.members.read(key);
|
|
2078
|
+
if (!m) {
|
|
2079
|
+
return {
|
|
2080
|
+
structured: { view: 'no-member', ...common(deps, key) },
|
|
2081
|
+
text: `Nobody is signed in, so there is nothing saved to forget. ${t.signIn} signs a member in.`,
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
await deps.backend.rememberPreferences(m, []);
|
|
2085
|
+
const { preferences: _gone, ...member } = m;
|
|
2086
|
+
deps.members.set(key, member);
|
|
2087
|
+
return {
|
|
2088
|
+
structured: { view: 'member', ...common(deps, key), member },
|
|
2089
|
+
text: `Forgot the room preferences saved on ${m.programme} ${m.tier.name}. Bookings already made keep what they asked for.`,
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
1875
2092
|
if (name === t.policies) {
|
|
1876
2093
|
const wanted = str(args.hotel);
|
|
1877
2094
|
const detail = await deps.backend.getHotel(wanted);
|