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