@curless/hotel-storefront 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/__tests__/card-contract.test.js +13 -0
  2. package/dist/__tests__/card-contract.test.js.map +1 -1
  3. package/dist/__tests__/data.test.js +28 -3
  4. package/dist/__tests__/data.test.js.map +1 -1
  5. package/dist/__tests__/demo-hotels.test.js +25 -11
  6. package/dist/__tests__/demo-hotels.test.js.map +1 -1
  7. package/dist/__tests__/present.test.d.ts +2 -0
  8. package/dist/__tests__/present.test.d.ts.map +1 -0
  9. package/dist/__tests__/present.test.js +140 -0
  10. package/dist/__tests__/present.test.js.map +1 -0
  11. package/dist/__tests__/server.test.js +63 -4
  12. package/dist/__tests__/server.test.js.map +1 -1
  13. package/dist/__tests__/tools.test.js +129 -0
  14. package/dist/__tests__/tools.test.js.map +1 -1
  15. package/dist/backend.d.ts +30 -6
  16. package/dist/backend.d.ts.map +1 -1
  17. package/dist/card.generated.d.ts +1 -1
  18. package/dist/card.generated.js +2 -2
  19. package/dist/card.generated.js.map +1 -1
  20. package/dist/config.d.ts +11 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +7 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/data.d.ts.map +1 -1
  25. package/dist/data.js +88 -13
  26. package/dist/data.js.map +1 -1
  27. package/dist/demo-hotels.d.ts +24 -1
  28. package/dist/demo-hotels.d.ts.map +1 -1
  29. package/dist/demo-hotels.js +136 -13
  30. package/dist/demo-hotels.js.map +1 -1
  31. package/dist/fence.d.ts +9 -3
  32. package/dist/fence.d.ts.map +1 -1
  33. package/dist/fence.js +0 -0
  34. package/dist/fence.js.map +1 -1
  35. package/dist/index.js +0 -0
  36. package/dist/members.d.ts +14 -0
  37. package/dist/members.d.ts.map +1 -0
  38. package/dist/members.js +56 -0
  39. package/dist/members.js.map +1 -0
  40. package/dist/present.d.ts +129 -0
  41. package/dist/present.d.ts.map +1 -0
  42. package/dist/present.js +188 -0
  43. package/dist/present.js.map +1 -0
  44. package/dist/server.d.ts +27 -10
  45. package/dist/server.d.ts.map +1 -1
  46. package/dist/server.js +27 -7
  47. package/dist/server.js.map +1 -1
  48. package/dist/surface.d.ts.map +1 -1
  49. package/dist/surface.js +27 -2
  50. package/dist/surface.js.map +1 -1
  51. package/dist/tools.d.ts +174 -1
  52. package/dist/tools.d.ts.map +1 -1
  53. package/dist/tools.js +464 -50
  54. package/dist/tools.js.map +1 -1
  55. package/dist/types.d.ts +57 -1
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/vocab.d.ts +1 -1
  58. package/dist/vocab.d.ts.map +1 -1
  59. package/dist/vocab.js +1 -1
  60. package/dist/vocab.js.map +1 -1
  61. package/package.json +11 -9
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isDay } from './dates.js';
2
2
  import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
3
- import { formatMoney } from './money.js';
3
+ import { formatMoney, multiplyMoney } from './money.js';
4
+ import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_PROS, buildItinerary, compareHotels, } from './present.js';
4
5
  import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, tripKey, } from './trip.js';
5
6
  import { DEMO_NOTE } from './vocab.js';
6
7
  // The eleven things a guest can do: find hotels, look at one on dates, see what
@@ -83,7 +84,7 @@ export const buildTools = (n, v) => {
83
84
  },
84
85
  {
85
86
  name: n.getHotel,
86
- description: `One ${v.noun} in full: description, amenities, address, check-in and check-out times. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
87
+ description: `One ${v.noun} in full: description, amenities, address, check-in and check-out times. Given \`checkIn\` and \`checkOut\` it also returns the room types that can be booked for those nights, each with its nightly rate, the stay total including taxes and fees, whether it can be cancelled free and until when, and how many rooms are left. \`rooms\` leaves out the types that have fewer than that many. Without dates it returns the ${v.noun} only, because a room has no price until the nights are known.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
87
88
  annotations: reads(`View one ${v.brand} ${v.noun}`, open),
88
89
  inputSchema: {
89
90
  type: 'object',
@@ -93,6 +94,10 @@ export const buildTools = (n, v) => {
93
94
  description: `The ${v.noun}'s name or id${v.hints.example ? `, e.g. "${v.hints.example}"` : ''}`,
94
95
  },
95
96
  ...stayProps,
97
+ rooms: {
98
+ type: 'number',
99
+ description: `How many rooms of one type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
100
+ },
96
101
  },
97
102
  required: ['hotel'],
98
103
  },
@@ -225,6 +230,101 @@ export const buildTools = (n, v) => {
225
230
  required: ['booking'],
226
231
  },
227
232
  },
233
+ {
234
+ name: n.compare,
235
+ // ⚠️ NO PRICES IN, NO PRICES OUT OF THE MODEL. The arguments carry ids and
236
+ // prose; every figure on the card is read off the hotel's own record. A
237
+ // model that could send a price could one day send the wrong one, onto a
238
+ // card that looks official.
239
+ description: `Puts two to four ${v.nounPlural} side by side as one card. Each column's price, rate, cancellation terms, breakfast and rooms left come from the ${v.noun}'s own record; the arguments carry only the ids, one line on who each suits, and its pros and cons. With \`checkIn\`/\`checkOut\` the columns carry each ${v.noun}'s cheapest room for those nights; without them, a from-price.`,
240
+ annotations: reads(`Compare ${v.brand} ${v.nounPlural}`, open),
241
+ inputSchema: {
242
+ type: 'object',
243
+ properties: {
244
+ entries: {
245
+ type: 'array',
246
+ minItems: 2,
247
+ maxItems: MAX_COMPARED,
248
+ description: `The ${v.nounPlural} to set beside each other, in the order they should appear`,
249
+ items: {
250
+ type: 'object',
251
+ properties: {
252
+ hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
253
+ bestFor: {
254
+ type: 'string',
255
+ description: 'One line on who this one suits, in your own words',
256
+ },
257
+ pros: { type: 'array', items: { type: 'string' }, maxItems: MAX_PROS },
258
+ cons: { type: 'array', items: { type: 'string' }, maxItems: MAX_CONS },
259
+ },
260
+ required: ['hotel'],
261
+ },
262
+ },
263
+ recommended: { type: 'string', description: `The one ${v.noun} to mark as the pick` },
264
+ title: { type: 'string', description: 'A heading for the card' },
265
+ ...stayProps,
266
+ },
267
+ required: ['entries'],
268
+ },
269
+ },
270
+ {
271
+ name: n.itinerary,
272
+ description: `A day-by-day plan as one card: each day carries its own label, an optional note, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records. Each item is marked with whether it is in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
273
+ annotations: reads(`Lay out a ${v.brand} itinerary`, open),
274
+ inputSchema: {
275
+ type: 'object',
276
+ properties: {
277
+ title: { type: 'string', description: 'What to call the trip' },
278
+ days: {
279
+ type: 'array',
280
+ minItems: 1,
281
+ maxItems: MAX_DAYS,
282
+ items: {
283
+ type: 'object',
284
+ properties: {
285
+ label: { type: 'string', description: 'e.g. "Day 1 — Arrive in Hong Kong"' },
286
+ note: {
287
+ type: 'string',
288
+ description: 'One or two lines on the day, in your own words',
289
+ },
290
+ items: {
291
+ type: 'array',
292
+ items: { type: 'string' },
293
+ maxItems: MAX_ITEMS_PER_DAY,
294
+ description: `${v.nounPlural} and add-ons for that day, by name or id`,
295
+ },
296
+ },
297
+ required: ['label'],
298
+ },
299
+ },
300
+ ...stayProps,
301
+ },
302
+ required: ['title', 'days'],
303
+ },
304
+ },
305
+ {
306
+ name: n.signIn,
307
+ // ⚠️ A MEMBERSHIP NUMBER AND A SURNAME. No password appears in this
308
+ // schema because none exists: the connector has nowhere to put one, and
309
+ // a description that mentioned passwords would invite a model to ask for
310
+ // one in a chat transcript.
311
+ description: `Signs a ${v.brand} loyalty member in for this conversation with their membership number and the surname on it, and answers with their tier, their point balance and what the tier takes off the public rate. Room rates and trip totals are then quoted at the member rate until they are signed out.`,
312
+ annotations: edits(`Sign in to ${v.brand}`, open),
313
+ inputSchema: {
314
+ type: 'object',
315
+ properties: {
316
+ number: { type: 'string', description: 'The membership number, as it is printed' },
317
+ surname: { type: 'string', description: 'The surname on that membership' },
318
+ },
319
+ required: ['number', 'surname'],
320
+ },
321
+ },
322
+ {
323
+ name: n.signOut,
324
+ description: 'Ends the membership sign-in for this conversation. Rates go back to the public ones; the trip and any bookings are left as they are.',
325
+ annotations: edits(`Sign out of ${v.brand}`, open),
326
+ inputSchema: { type: 'object', properties: {} },
327
+ },
228
328
  {
229
329
  name: n.policies,
230
330
  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.`,
@@ -269,6 +369,10 @@ const TRIP_SCHEMA = {
269
369
  subtotal: STR,
270
370
  taxesAndFees: STR,
271
371
  total: STR,
372
+ /** Who is signed in, on every payload. */
373
+ member: OBJ,
374
+ /** What the tier took off THIS trip, and what it earns. */
375
+ memberSaving: OBJ,
272
376
  days: ARR,
273
377
  dropped: ARR,
274
378
  },
@@ -302,7 +406,9 @@ export const buildOutputSchemas = (n) => ({
302
406
  demo: BOOL,
303
407
  demoNote: STR,
304
408
  hotels: ARR,
409
+ /** How many MATCHED. `shown` is how many are in `hotels`. */
305
410
  total: { type: 'number' },
411
+ shown: { type: 'number' },
306
412
  cities: ARR,
307
413
  query: STR,
308
414
  },
@@ -322,6 +428,8 @@ export const buildOutputSchemas = (n) => ({
322
428
  rooms: ARR,
323
429
  /** True when no dates were given: the card asks for them instead of showing rooms. */
324
430
  needsDates: BOOL,
431
+ /** How many rooms of one type the list was filtered to. */
432
+ rooms_wanted: { type: 'number' },
325
433
  noRooms: STR,
326
434
  extras: ARR,
327
435
  policies: ARR,
@@ -347,7 +455,7 @@ export const buildOutputSchemas = (n) => ({
347
455
  [n.requestBooking]: {
348
456
  type: 'object',
349
457
  properties: {
350
- view: { enum: ['booking', 'empty-trip', 'error'] },
458
+ view: { enum: ['booking', 'needs-guest', 'empty-trip', 'error'] },
351
459
  brand: STR,
352
460
  toolNames: OBJ,
353
461
  demo: BOOL,
@@ -375,6 +483,56 @@ export const buildOutputSchemas = (n) => ({
375
483
  },
376
484
  [n.previewCancellation]: CANCEL_SCHEMA,
377
485
  [n.cancelBooking]: CANCEL_SCHEMA,
486
+ [n.compare]: {
487
+ type: 'object',
488
+ properties: {
489
+ view: { enum: ['comparison', 'nothing-to-compare'] },
490
+ brand: STR,
491
+ toolNames: OBJ,
492
+ demo: BOOL,
493
+ demoNote: STR,
494
+ title: STR,
495
+ stay: OBJ,
496
+ columns: ARR,
497
+ dropped: ARR,
498
+ },
499
+ required: ['view'],
500
+ },
501
+ [n.itinerary]: {
502
+ type: 'object',
503
+ properties: {
504
+ view: { enum: ['itinerary', 'empty-itinerary'] },
505
+ brand: STR,
506
+ toolNames: OBJ,
507
+ demo: BOOL,
508
+ demoNote: STR,
509
+ title: STR,
510
+ stay: OBJ,
511
+ days: ARR,
512
+ /** How many of the planned items are actually in the trip. Computed, never written. */
513
+ inTrip: { type: 'number' },
514
+ planned: { type: 'number' },
515
+ dropped: ARR,
516
+ },
517
+ required: ['view'],
518
+ },
519
+ [n.signIn]: {
520
+ type: 'object',
521
+ properties: {
522
+ view: { enum: ['member', 'member-not-found'] },
523
+ brand: STR,
524
+ toolNames: OBJ,
525
+ demo: BOOL,
526
+ demoNote: STR,
527
+ member: OBJ,
528
+ },
529
+ required: ['view'],
530
+ },
531
+ [n.signOut]: {
532
+ type: 'object',
533
+ properties: { view: { const: 'signed-out' }, brand: STR, toolNames: OBJ },
534
+ required: ['view'],
535
+ },
378
536
  [n.policies]: {
379
537
  type: 'object',
380
538
  properties: {
@@ -401,13 +559,30 @@ const asStay = (args) => {
401
559
  const guests = typeof args.guests === 'number' ? Math.floor(args.guests) : 2;
402
560
  return { checkIn, checkOut, guests };
403
561
  };
404
- /** What every payload carries, so the card can draw a header and say whose data this is. */
405
- const common = (deps) => ({
562
+ /**
563
+ * What every payload carries, so the card can draw a header and say whose data
564
+ * this is.
565
+ *
566
+ * ⚠️ THE MEMBER BELONGS HERE, not on the sign-in view alone. A member rate is a
567
+ * fact about EVERY price on the card, so the tier has to travel with every
568
+ * answer - otherwise the header says "sign in" over a list of discounted rates,
569
+ * which is what it did the first time (2026-09-18). The name and the number are
570
+ * left out: the chip needs a tier, and a payload that repeats somebody's
571
+ * membership number on every call is carrying it further than it has to go.
572
+ */
573
+ const memberBadge = (deps, key) => {
574
+ const m = deps.members.read(key);
575
+ return m
576
+ ? { member: { programme: m.programme, tier: { name: m.tier.name, discount: m.tier.discount } } }
577
+ : {};
578
+ };
579
+ const common = (deps, key) => ({
406
580
  brand: deps.vocab.brand,
407
581
  ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
408
582
  toolNames: deps.tools,
409
583
  vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
410
584
  ...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
585
+ ...(key ? memberBadge(deps, key) : {}),
411
586
  });
412
587
  /** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
413
588
  const quoteForCard = (quote) => ({
@@ -431,6 +606,12 @@ const quoteForCard = (quote) => ({
431
606
  subtotal: formatMoney(quote.subtotal),
432
607
  taxesAndFees: formatMoney(quote.taxesAndFees),
433
608
  total: formatMoney(quote.total),
609
+ // ⚠️ NOT `member`: that key means "who is signed in" on every payload, and
610
+ // the quote's block is a different shape (a tier NAME, a saving, points).
611
+ // Two shapes on one key is how a card reads `member.tier.name` off a string.
612
+ ...(quote.member
613
+ ? { memberSaving: { ...quote.member, saved: formatMoney(quote.member.saved) } }
614
+ : {}),
434
615
  ...(quote.stay ? { stay: quote.stay } : {}),
435
616
  ...(quote.dropped ? { dropped: quote.dropped } : {}),
436
617
  });
@@ -450,16 +631,46 @@ const tripText = (quote, demo) => {
450
631
  .join('\n');
451
632
  };
452
633
  const bookingLine = (b) => `${b.confirmationCode} · ${b.hotelName} · ${b.stay.checkIn} to ${b.stay.checkOut} · ${b.status}`;
453
- const identity = (ctx, args) => {
634
+ const identity = (deps, ctx, key, args = {}) => {
454
635
  const email = str(args.email);
455
- return email ? { sessionId: ctx.session, email } : { sessionId: ctx.session };
636
+ const member = deps.members.read(key);
637
+ return {
638
+ sessionId: ctx.session,
639
+ ...(email ? { email } : {}),
640
+ // ⚠️ THE MEMBER BUYS A RATE, NOT A LOOKUP. `listBookings` and the
641
+ // cancellation tools still key on the conversation and on the email the
642
+ // booking was made with - a membership number plus a surname is what a desk
643
+ // checks, not authentication, and it must never become a way to read
644
+ // somebody else's booking.
645
+ ...(member ? { member } : {}),
646
+ };
456
647
  };
457
- /** The trip, priced, as both views need it. */
458
- const currentTrip = async (deps, key) => {
648
+ /**
649
+ * Why this hotel cannot join the trip that is already there - or null.
650
+ *
651
+ * One trip, one currency. Not a limitation to route around: converting HKD to
652
+ * CNY means picking a rate, and a rate we picked is a number the guest would
653
+ * read off a card as if the hotel had said it.
654
+ */
655
+ const currencyClash = async (deps, key, hotel) => {
459
656
  const lines = deps.trips.read(key);
657
+ if (lines.length === 0)
658
+ return null;
659
+ const theirs = hotel.fromNightly?.currency;
660
+ if (!theirs)
661
+ return null;
460
662
  const quote = await deps.backend.quote({ lines });
663
+ const ours = quote.lines.length ? quote.total.currency : undefined;
664
+ if (!ours || ours === theirs)
665
+ return null;
666
+ return `This trip is priced in ${ours} and ${hotel.name} charges in ${theirs}. No exchange rate is invented here - book those nights as their own trip, or clear this one first.`;
667
+ };
668
+ /** The trip, priced, as both views need it. */
669
+ const currentTrip = async (deps, key, who) => {
670
+ const lines = deps.trips.read(key);
671
+ const quote = await deps.backend.quote({ lines }, who);
461
672
  return {
462
- structured: { view: 'trip', ...common(deps), ...quoteForCard(quote), count: lines.length },
673
+ structured: { view: 'trip', ...common(deps, key), ...quoteForCard(quote), count: lines.length },
463
674
  text: tripText(quote, deps.vocab.demo),
464
675
  };
465
676
  };
@@ -469,15 +680,19 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
469
680
  const key = ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand);
470
681
  if (name === t.listHotels) {
471
682
  const query = str(args.query);
472
- const hotels = await deps.backend.searchHotels({
683
+ const { hotels, total } = await deps.backend.searchHotels({
473
684
  query: query || undefined,
474
685
  city: str(args.city) || undefined,
475
686
  limit: typeof args.limit === 'number' ? args.limit : undefined,
476
687
  });
477
688
  const cities = [...new Set(hotels.map((h) => h.city))];
689
+ // How many were left behind. The group has ninety-odd properties, so an
690
+ // unnarrowed browse is a page of a much longer list, and neither the card
691
+ // nor the model may present the page as the whole answer.
692
+ const more = Math.max(0, total - hotels.length);
478
693
  if (hotels.length === 0) {
479
694
  return {
480
- structured: { view: 'no-hotels', ...common(deps), hotels: [], total: 0, query },
695
+ structured: { view: 'no-hotels', ...common(deps, key), hotels: [], total: 0, query },
481
696
  text: `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
482
697
  };
483
698
  }
@@ -491,22 +706,28 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
491
706
  return {
492
707
  structured: {
493
708
  view: 'hotels',
494
- ...common(deps),
709
+ ...common(deps, key),
495
710
  hotels: forCard,
496
- total: hotels.length,
711
+ total,
712
+ shown: hotels.length,
497
713
  cities,
498
714
  query,
499
715
  },
500
- text: `${hotels.length} ${deps.vocab.brand} ${hotels.length === 1 ? deps.vocab.noun : deps.vocab.nounPlural}${where} — tap one to see rooms.`,
716
+ text: [
717
+ `${total} ${deps.vocab.brand} ${total === 1 ? deps.vocab.noun : deps.vocab.nounPlural}${where}`,
718
+ more > 0
719
+ ? `— the ${hotels.length} below, and ${more} more. Narrow by city or country, or ask for a bigger \`limit\`.`
720
+ : '— tap one to see rooms.',
721
+ ].join(' '),
501
722
  };
502
723
  }
503
724
  if (name === t.getHotel) {
504
725
  const wanted = str(args.hotel);
505
726
  const stay = asStay(args);
506
- const detail = await deps.backend.getHotel(wanted, stay);
727
+ const detail = await deps.backend.getHotel(wanted, stay, identity(deps, ctx, key));
507
728
  if (!detail) {
508
729
  return {
509
- structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
730
+ structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
510
731
  text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name. ${t.listHotels} lists them.`,
511
732
  };
512
733
  }
@@ -515,7 +736,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
515
736
  const policies = await deps.backend.policies(h.id);
516
737
  const extras = await deps.backend.extrasFor(h.id, stay);
517
738
  const base = {
518
- ...common(deps),
739
+ ...common(deps, key),
519
740
  hotel: {
520
741
  ...h,
521
742
  summary: description.text,
@@ -542,17 +763,48 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
542
763
  text: detail.noRooms ?? 'Nothing is available for those nights.',
543
764
  };
544
765
  }
545
- const rooms = detail.rooms.map((r) => ({
766
+ // ⚠️ THE ROOM COUNT FILTERS THE LIST, it does not just ride along. A guest
767
+ // who needs two rooms and is shown a type with one left finds out when the
768
+ // trip drops the line, which is later and worse than not offering it.
769
+ const roomsWanted = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(Number(args.rooms) || 1)));
770
+ const enough = detail.rooms.filter((r) => r.roomsLeft >= roomsWanted);
771
+ if (enough.length === 0) {
772
+ return {
773
+ structured: {
774
+ view: 'no-rooms',
775
+ ...base,
776
+ stay,
777
+ rooms_wanted: roomsWanted,
778
+ noRooms: `No room type here has ${roomsWanted} left for those nights.`,
779
+ },
780
+ text: `No room type at ${h.name} has ${roomsWanted} left for ${stay.checkIn} to ${stay.checkOut}.`,
781
+ };
782
+ }
783
+ const rooms = enough.map((r) => ({
546
784
  ...r,
547
785
  nightly: formatMoney(r.nightly),
548
786
  stayTotal: formatMoney(r.stayTotal),
549
787
  taxesAndFees: formatMoney(r.taxesAndFees),
788
+ // ⚠️ THE NUMBER THE GUEST IS LOOKING AT HAS TO BE THE ONE THEY ASKED FOR.
789
+ // With two rooms picked, the card was still showing one room's total and
790
+ // nothing said so - robin changed the count and watched the price not
791
+ // move (2026-09-18). Multiplied HERE, in minor units, because the card
792
+ // holds formatted strings and must never do arithmetic on money.
793
+ ...(roomsWanted > 1
794
+ ? { roomsTotal: formatMoney(multiplyMoney(r.stayTotal, roomsWanted)) }
795
+ : {}),
796
+ // ⚠️ THE PUBLIC RATE IS MONEY TOO. It went out as a raw
797
+ // `{ minor, currency }` and the card would have drawn "[object Object]"
798
+ // through the middle with a line - caught by the test that asserted the
799
+ // string, not by anyone looking at it.
800
+ ...(r.publicNightly ? { publicNightly: formatMoney(r.publicNightly) } : {}),
801
+ ...(r.publicStayTotal ? { publicStayTotal: formatMoney(r.publicStayTotal) } : {}),
550
802
  }));
551
803
  return {
552
- structured: { view: 'hotel', ...base, stay, rooms },
804
+ structured: { view: 'hotel', ...base, stay, rooms, rooms_wanted: roomsWanted },
553
805
  text: [
554
- `**${h.name}** · ${detail.rooms.length} room ${detail.rooms.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests.`,
555
- ...detail.rooms.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${r.refundable ? `, free to cancel${r.freeCancellationUntil ? ` until ${r.freeCancellationUntil.slice(0, 10)}` : ''}` : ', non-refundable'}`),
806
+ `**${h.name}** · ${enough.length} room ${enough.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}.`,
807
+ ...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'}`),
556
808
  deps.vocab.demo ? DEMO_NOTE : '',
557
809
  ]
558
810
  .filter(Boolean)
@@ -564,7 +816,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
564
816
  const detail = await deps.backend.getHotel(wanted);
565
817
  if (!detail) {
566
818
  return {
567
- structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
819
+ structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
568
820
  text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
569
821
  };
570
822
  }
@@ -572,7 +824,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
572
824
  return {
573
825
  structured: {
574
826
  view: 'extras',
575
- ...common(deps),
827
+ ...common(deps, key),
576
828
  hotel: detail.hotel,
577
829
  extras: extras.map((e) => ({
578
830
  ...e,
@@ -593,7 +845,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
593
845
  const detail = await deps.backend.getHotel(wanted);
594
846
  if (!detail) {
595
847
  return {
596
- structured: { view: 'error', ...common(deps), error: 'hotel-not-found', query: wanted },
848
+ structured: {
849
+ view: 'error',
850
+ ...common(deps, key),
851
+ error: 'hotel-not-found',
852
+ query: wanted,
853
+ },
597
854
  text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
598
855
  };
599
856
  }
@@ -604,10 +861,27 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
604
861
  const extra = extras.find((e) => e.id === extraRef || e.name.toLowerCase() === extraRef.toLowerCase());
605
862
  if (!extra) {
606
863
  return {
607
- structured: { view: 'error', ...common(deps), error: 'no-such-extra', query: extraRef },
864
+ structured: {
865
+ view: 'error',
866
+ ...common(deps, key),
867
+ error: 'no-such-extra',
868
+ query: extraRef,
869
+ },
608
870
  text: `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`,
609
871
  };
610
872
  }
873
+ const clash = await currencyClash(deps, key, detail.hotel);
874
+ if (clash) {
875
+ return {
876
+ structured: {
877
+ view: 'error',
878
+ ...common(deps, key),
879
+ error: 'mixed-currency',
880
+ detail: clash,
881
+ },
882
+ text: clash,
883
+ };
884
+ }
611
885
  const line = {
612
886
  kind: 'extra',
613
887
  hotelId,
@@ -616,12 +890,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
616
890
  ...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
617
891
  };
618
892
  deps.trips.set(key, line);
619
- return currentTrip(deps, key);
893
+ return currentTrip(deps, key, identity(deps, ctx, key));
620
894
  }
621
895
  const roomRef = str(args.room);
622
896
  if (!roomRef) {
623
897
  return {
624
- structured: { view: 'error', ...common(deps), error: 'no-room-named' },
898
+ structured: { view: 'error', ...common(deps, key), error: 'no-room-named' },
625
899
  text: `Say which room type, or which add-on. ${t.getHotel} with the dates lists the room types.`,
626
900
  };
627
901
  }
@@ -631,19 +905,24 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
631
905
  // desk. The trip stays as it was and the answer asks.
632
906
  if (!stay) {
633
907
  return {
634
- structured: { view: 'needs-dates', ...common(deps), hotel: detail.hotel, room: roomRef },
908
+ structured: {
909
+ view: 'needs-dates',
910
+ ...common(deps, key),
911
+ hotel: detail.hotel,
912
+ room: roomRef,
913
+ },
635
914
  text: 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
636
915
  };
637
916
  }
638
917
  // The backend owns the calendar - it knows each hotel's own timezone, and a
639
918
  // second opinion computed from this process's clock would disagree with it
640
919
  // for eight hours a day.
641
- const dated = await deps.backend.getHotel(hotelId, stay);
920
+ const dated = await deps.backend.getHotel(hotelId, stay, identity(deps, ctx, key));
642
921
  if (!dated?.rooms) {
643
922
  return {
644
923
  structured: {
645
924
  view: 'needs-dates',
646
- ...common(deps),
925
+ ...common(deps, key),
647
926
  hotel: detail.hotel,
648
927
  room: roomRef,
649
928
  why: dated?.noRooms,
@@ -656,7 +935,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
656
935
  return {
657
936
  structured: {
658
937
  view: 'error',
659
- ...common(deps),
938
+ ...common(deps, key),
660
939
  error: 'no-such-room',
661
940
  query: roomRef,
662
941
  rooms: dated.rooms.map((r) => ({ id: r.id, name: r.name })),
@@ -664,6 +943,24 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
664
943
  text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => r.name).join(', ')}.`,
665
944
  };
666
945
  }
946
+ // ⚠️ A LINE THAT WILL NEVER PRICE DOES NOT GO IN. The quote drops a hotel
947
+ // billing in another currency - no exchange rate is invented here - but
948
+ // until now the line still went into the trip, so the guest carried
949
+ // something that was silently dropped from every total from then on
950
+ // (2026-09-18). Refused here, with the trip left exactly as it was; the
951
+ // quote's own drop stays as the backstop for a hotel whose currency moves.
952
+ const wrongCurrency = await currencyClash(deps, key, detail.hotel);
953
+ if (wrongCurrency) {
954
+ return {
955
+ structured: {
956
+ view: 'error',
957
+ ...common(deps, key),
958
+ error: 'mixed-currency',
959
+ detail: wrongCurrency,
960
+ },
961
+ text: wrongCurrency,
962
+ };
963
+ }
667
964
  deps.trips.set(key, {
668
965
  kind: 'room',
669
966
  hotelId,
@@ -671,14 +968,14 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
671
968
  stay,
672
969
  rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
673
970
  });
674
- return currentTrip(deps, key);
971
+ return currentTrip(deps, key, identity(deps, ctx, key));
675
972
  }
676
973
  if (name === t.viewTrip)
677
- return currentTrip(deps, key);
974
+ return currentTrip(deps, key, identity(deps, ctx, key));
678
975
  if (name === t.clearTrip) {
679
976
  deps.trips.clear(key);
680
977
  return {
681
- structured: { view: 'trip', ...common(deps), lines: [], count: 0, total: null },
978
+ structured: { view: 'trip', ...common(deps, key), lines: [], count: 0, total: null },
682
979
  text: 'The trip is empty.',
683
980
  };
684
981
  }
@@ -686,11 +983,18 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
686
983
  const lines = deps.trips.read(key);
687
984
  if (lines.length === 0) {
688
985
  return {
689
- structured: { view: 'empty-trip', ...common(deps) },
986
+ structured: { view: 'empty-trip', ...common(deps, key) },
690
987
  text: `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`,
691
988
  };
692
989
  }
693
990
  const guest = { name: str(args.name), email: str(args.email) };
991
+ const missing = [!guest.name && 'a name', !guest.email.includes('@') && 'an email'].filter((x) => typeof x === 'string');
992
+ if (missing.length) {
993
+ return {
994
+ structured: { view: 'needs-guest', ...common(deps, key), missing },
995
+ text: `The booking needs ${missing.join(' and ')}. Ask the guest for ${missing.length > 1 ? 'them' : 'it'} - do not use a name or email you know from anywhere else.`,
996
+ };
997
+ }
694
998
  try {
695
999
  const booking = await deps.backend.requestBooking({ lines }, {
696
1000
  ...guest,
@@ -701,7 +1005,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
701
1005
  return {
702
1006
  structured: {
703
1007
  view: 'booking',
704
- ...common(deps),
1008
+ ...common(deps, key),
705
1009
  booking: { ...booking, quote: quoteForCard(booking.quote) },
706
1010
  paid: false,
707
1011
  holdUntil: booking.holdUntil,
@@ -720,7 +1024,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
720
1024
  return {
721
1025
  structured: {
722
1026
  view: 'error',
723
- ...common(deps),
1027
+ ...common(deps, key),
724
1028
  error: 'not-bookable',
725
1029
  detail: err.message,
726
1030
  },
@@ -729,11 +1033,11 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
729
1033
  }
730
1034
  }
731
1035
  if (name === t.listBookings) {
732
- const bookings = await deps.backend.listBookings(identity(ctx, args), typeof args.limit === 'number' ? args.limit : undefined);
1036
+ const bookings = await deps.backend.listBookings(identity(deps, ctx, key, args), typeof args.limit === 'number' ? args.limit : undefined);
733
1037
  return {
734
1038
  structured: {
735
1039
  view: 'bookings',
736
- ...common(deps),
1040
+ ...common(deps, key),
737
1041
  bookings: bookings.map((b) => ({ ...b, quote: quoteForCard(b.quote) })),
738
1042
  count: bookings.length,
739
1043
  },
@@ -744,18 +1048,18 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
744
1048
  }
745
1049
  if (name === t.previewCancellation || name === t.cancelBooking) {
746
1050
  const ref = str(args.booking);
747
- const who = identity(ctx, args);
1051
+ const who = identity(deps, ctx, key, args);
748
1052
  const preview = await deps.backend.previewCancellation(ref, who);
749
1053
  if (!preview) {
750
1054
  return {
751
- structured: { view: 'booking-not-found', ...common(deps), query: ref },
1055
+ structured: { view: 'booking-not-found', ...common(deps, key), query: ref },
752
1056
  text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
753
1057
  };
754
1058
  }
755
1059
  const asPreview = {
756
1060
  structured: {
757
1061
  view: 'cancellation-preview',
758
- ...common(deps),
1062
+ ...common(deps, key),
759
1063
  booking: { id: preview.bookingId, confirmationCode: preview.confirmationCode },
760
1064
  fee: formatMoney(preview.fee),
761
1065
  refund: formatMoney(preview.refund),
@@ -787,7 +1091,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
787
1091
  return {
788
1092
  structured: {
789
1093
  view: 'cancelled',
790
- ...common(deps),
1094
+ ...common(deps, key),
791
1095
  booking: { ...cancelled, quote: quoteForCard(cancelled.quote) },
792
1096
  fee: formatMoney(preview.fee),
793
1097
  refund: formatMoney(preview.refund),
@@ -797,12 +1101,122 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
797
1101
  text: `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
798
1102
  };
799
1103
  }
1104
+ if (name === t.compare) {
1105
+ const asks = Array.isArray(args.entries) ? args.entries : [];
1106
+ const comparison = await compareHotels(deps.backend, asks.map((e) => ({
1107
+ hotel: str(e.hotel),
1108
+ bestFor: typeof e.bestFor === 'string' ? e.bestFor : undefined,
1109
+ pros: e.pros,
1110
+ cons: e.cons,
1111
+ })), {
1112
+ stay: asStay(args),
1113
+ recommended: str(args.recommended) || undefined,
1114
+ title: str(args.title) || undefined,
1115
+ });
1116
+ if (comparison.columns.length === 0) {
1117
+ return {
1118
+ structured: {
1119
+ view: 'nothing-to-compare',
1120
+ ...common(deps, key),
1121
+ dropped: comparison.dropped,
1122
+ },
1123
+ text: `None of those are ${deps.vocab.brand} ${deps.vocab.nounPlural}: ${comparison.dropped.map((d) => d.name).join(', ')}.`,
1124
+ };
1125
+ }
1126
+ return {
1127
+ structured: {
1128
+ view: 'comparison',
1129
+ ...common(deps, key),
1130
+ ...comparison,
1131
+ columns: comparison.columns.map((c) => ({
1132
+ ...c,
1133
+ hotel: { ...c.hotel, fromNightly: undefined },
1134
+ })),
1135
+ },
1136
+ text: [
1137
+ // The card carries the table; the text carries only what the model
1138
+ // could not see before it asked - what was dropped, and why.
1139
+ `${comparison.columns.length} side by side${comparison.stay ? ` for ${comparison.stay.checkIn} to ${comparison.stay.checkOut}` : ''}.`,
1140
+ ...comparison.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
1141
+ deps.vocab.demo ? DEMO_NOTE : '',
1142
+ ]
1143
+ .filter(Boolean)
1144
+ .join('\n'),
1145
+ };
1146
+ }
1147
+ if (name === t.itinerary) {
1148
+ const days = Array.isArray(args.days) ? args.days : [];
1149
+ const plan = await buildItinerary(deps.backend, days.map((d) => ({
1150
+ label: str(d.label),
1151
+ note: typeof d.note === 'string' ? d.note : undefined,
1152
+ items: d.items ?? [],
1153
+ })), { title: str(args.title), stay: asStay(args), trip: deps.trips.read(key) });
1154
+ if (plan.days.length === 0) {
1155
+ return {
1156
+ structured: { view: 'empty-itinerary', ...common(deps, key) },
1157
+ text: 'An itinerary needs at least one day with a label.',
1158
+ };
1159
+ }
1160
+ // ⚠️ THE SENTENCE THE PLAN MUST NOT BE ALLOWED TO SKIP. A four-day plan
1161
+ // with one room actually added looks exactly like a booking unless
1162
+ // somebody says otherwise, and this counts it rather than describing it.
1163
+ const gap = plan.planned === 0
1164
+ ? 'Nothing on this plan is in the trip yet.'
1165
+ : plan.inTrip === plan.planned
1166
+ ? `All ${plan.planned} are in the trip.`
1167
+ : `${plan.inTrip} of ${plan.planned} are in the trip; the rest are a plan, not a booking.`;
1168
+ return {
1169
+ structured: { view: 'itinerary', ...common(deps, key), ...plan, gap },
1170
+ text: [
1171
+ `**${plan.title}** · ${plan.days.length} ${plan.days.length === 1 ? 'day' : 'days'}. ${gap}`,
1172
+ ...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
1173
+ deps.vocab.demo ? DEMO_NOTE : '',
1174
+ ]
1175
+ .filter(Boolean)
1176
+ .join('\n'),
1177
+ };
1178
+ }
1179
+ if (name === t.signIn) {
1180
+ const number = str(args.number);
1181
+ const surname = str(args.surname);
1182
+ const member = await deps.backend.signInMember(number, surname);
1183
+ if (!member) {
1184
+ // ⚠️ NO TIER FOR A NUMBER THAT DID NOT MATCH. Being generous here - "close
1185
+ // enough, have the discount" - is inventing a membership, and the guest
1186
+ // would find out at the desk.
1187
+ return {
1188
+ structured: { view: 'member-not-found', ...common(deps, key), query: number },
1189
+ text: `No ${deps.vocab.brand} membership matches that number and surname. Check both as they are printed; nothing else is needed - this connector never asks for a password.`,
1190
+ };
1191
+ }
1192
+ deps.members.set(key, member);
1193
+ return {
1194
+ structured: { view: 'member', ...common(deps, key), member },
1195
+ text: [
1196
+ `Signed in as ${member.name} · ${member.programme} ${member.tier.name} · ${member.points.toLocaleString('en-GB')} points.`,
1197
+ `Rates now show the ${member.tier.name} member rate, ${Math.round(member.tier.discount * 100)}% off the public one.`,
1198
+ deps.vocab.demo ? DEMO_NOTE : '',
1199
+ ]
1200
+ .filter(Boolean)
1201
+ .join(' '),
1202
+ };
1203
+ }
1204
+ if (name === t.signOut) {
1205
+ const was = deps.members.read(key);
1206
+ deps.members.clear(key);
1207
+ return {
1208
+ structured: { view: 'signed-out', ...common(deps, key) },
1209
+ text: was
1210
+ ? `Signed out of ${was.programme}. Rates are the public ones again; the trip and any bookings are untouched.`
1211
+ : 'Nobody was signed in.',
1212
+ };
1213
+ }
800
1214
  if (name === t.policies) {
801
1215
  const wanted = str(args.hotel);
802
1216
  const detail = await deps.backend.getHotel(wanted);
803
1217
  if (!detail) {
804
1218
  return {
805
- structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
1219
+ structured: { view: 'hotel-not-found', ...common(deps, key), query: wanted },
806
1220
  text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
807
1221
  };
808
1222
  }
@@ -814,7 +1228,7 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
814
1228
  return {
815
1229
  structured: {
816
1230
  view: 'policy-missing',
817
- ...common(deps),
1231
+ ...common(deps, key),
818
1232
  hotel: detail.hotel,
819
1233
  topic,
820
1234
  topics: policies.map((p) => ({ topic: p.topic, title: p.title })),
@@ -825,11 +1239,11 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
825
1239
  text: `${detail.hotel.name} does not publish anything about that here.${detail.hotel.url ? ` Its own page: ${detail.hotel.url}` : ''}`,
826
1240
  };
827
1241
  }
828
- const fenced = hotelText(one.title, detail.hotel.name, one.text, POLICY_MAX);
1242
+ const fenced = hotelText(one.title, detail.hotel.name, one.text, POLICY_MAX, deps.vocab.demo);
829
1243
  return {
830
1244
  structured: {
831
1245
  view: 'policy',
832
- ...common(deps),
1246
+ ...common(deps, key),
833
1247
  hotel: detail.hotel,
834
1248
  topic: one.topic,
835
1249
  title: one.title,
@@ -840,12 +1254,12 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
840
1254
  };
841
1255
  }
842
1256
  const all = policies
843
- .map((p) => hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX).fenced)
1257
+ .map((p) => hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX, deps.vocab.demo).fenced)
844
1258
  .join('\n\n');
845
1259
  return {
846
1260
  structured: {
847
1261
  view: 'policies',
848
- ...common(deps),
1262
+ ...common(deps, key),
849
1263
  hotel: detail.hotel,
850
1264
  topics: policies.map((p) => ({
851
1265
  topic: p.topic,