@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/__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 +278 -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,132 @@ 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
|
+
// 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
|
-
|
|
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
|
-
|
|
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);
|