@curless/hotel-storefront 0.50.2 → 0.50.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js CHANGED
@@ -5,6 +5,7 @@ import { DESCRIPTION_MAX, POLICY_MAX, hotelName, hotelText } from './fence.js';
5
5
  import { clockOf, minutesOf, startProblem, stayWords, windowWords } from './hourly.js';
6
6
  import { amountsIn, formatMoney, keepingAmounts, money, multiplyMoney, toMinor } from './money.js';
7
7
  import { namedExactly, roomNamesOf, roomTypeNamed, withTraditional } from './names.js';
8
+ import { overlapsOf } from './overlap.js';
8
9
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, candidatesFor, compareHotels, namesMoreThanOne, } from './present.js';
9
10
  import { regionOf } from './regions.js';
10
11
  import { activeKeysOf, byRoomType, facetsOf, keyWords, matchesTerms, optionsOf, roomTermsOf, termWords, } from './room-filters.js';
@@ -96,13 +97,17 @@ export const buildTools = (n, v, caps = FULL_CAPABILITIES) => forCapabilities(al
96
97
  * The list tool where the deployment is one hotel in all (2026-10-15, Huazhu's
97
98
  * test system): asked with no place it answers with that hotel's page, not
98
99
  * with places to choose from - and says so, or the model offers to look in
99
- * another city (three smoke runs did). Never in a market.
100
+ * another city (three smoke runs did). Where it is a few (`HOME_LIST_MAX`,
101
+ * Huazhu's production, 2026-10-30), asked with no place it lists all of them.
102
+ * Never in a market.
100
103
  */
101
- export const oneHotelListing = (t, brand) => {
104
+ export const homeListing = (t, brand, count) => {
102
105
  const v = brand.vocab;
103
- if (t.name !== brand.tools.listHotels || v.market)
106
+ if (t.name !== brand.tools.listHotels || v.market || count < 1 || count > HOME_LIST_MAX)
104
107
  return t;
105
- const one = `This deployment has ONE ${v.noun} in all: asked with no place, it answers with that ${v.noun}'s own page, and there is no other ${v.noun} or city to list or search. `;
108
+ const one = count === 1
109
+ ? `This deployment has ONE ${v.noun} in all: asked with no place, it answers with that ${v.noun}'s own page, and there is no other ${v.noun} or city to list or search. `
110
+ : `This deployment has ${count} ${v.nounPlural} in all: asked with no place, it lists every one of them, and there is no other ${v.noun} or city to list or search. `;
106
111
  const places = /Asked with no place[\s\S]*?themselves\. /;
107
112
  return {
108
113
  ...t,
@@ -1161,6 +1166,12 @@ const HOTEL_PAGE = {
1161
1166
  * none - and never in what the assistant is told.
1162
1167
  */
1163
1168
  soldOut: ARR,
1169
+ /**
1170
+ * Where some rates are listed but not booked here (`Room.notSoldHere`, paid
1171
+ * in advance on a deployment that takes no payment): what that means and
1172
+ * what to offer - the same sentence as the text.
1173
+ */
1174
+ notSoldHere: STR,
1164
1175
  };
1165
1176
  /**
1166
1177
  * What request_booking answers - and book_picked_room, which books the same way
@@ -1209,6 +1220,8 @@ const BOOKING_SCHEMA = {
1209
1220
  stamp: STR,
1210
1221
  /** The trip changed after the card that sent the form was drawn, so nothing was booked (`stampOf`). */
1211
1222
  tripChanged: BOOL,
1223
+ /** Two stays at two hotels on the same nights, each with what the text says of it (`overlapsOf`). */
1224
+ overlaps: ARR,
1212
1225
  },
1213
1226
  required: ['view'],
1214
1227
  };
@@ -1245,6 +1258,10 @@ export const buildOutputSchemas = (n) => ({
1245
1258
  widened: OBJ,
1246
1259
  /** The deployment's only hotel, answered with its page - see HOTEL_PAGE. */
1247
1260
  only: BOOL,
1261
+ /** A deployment of a few, answered with the list of all of them (`HOME_LIST_MAX`). */
1262
+ home: BOOL,
1263
+ /** With `only` or `home`: every hotel there is, and that there are no others - as the text says it. */
1264
+ inAll: STR,
1248
1265
  /** Only where the client shows no cards: nothing was drawn - see `LIST_NOT_DRAWN`. */
1249
1266
  notDrawn: STR,
1250
1267
  ...HOTEL_PAGE,
@@ -1392,6 +1409,8 @@ export const buildOutputSchemas = (n) => ({
1392
1409
  staysTotal: STR,
1393
1410
  /** What the text says of a long stay at one hotel, in the same words. */
1394
1411
  nudges: STRS,
1412
+ /** Two stays at two hotels on the same nights, each with what the text says of it (`overlapsOf`). */
1413
+ overlaps: ARR,
1395
1414
  /** Only for a client that shows no cards: that nothing was drawn - `PLAN_NOT_DRAWN`. */
1396
1415
  notDrawn: STR,
1397
1416
  },
@@ -2160,10 +2179,22 @@ const LABEL_WORDS = {
2160
2179
  const labelWords = (r) => {
2161
2180
  const words = [
2162
2181
  ...(r.labels ?? []).map((l) => LABEL_WORDS[l]),
2163
- ...(r.payment === 'prepaid' ? ['paid in advance'] : []),
2182
+ ...(r.payment === 'prepaid'
2183
+ ? [r.notSoldHere ? 'paid in advance - not booked here' : 'paid in advance']
2184
+ : []),
2164
2185
  ];
2165
2186
  return words.length ? ` [${words.join(', ')}]` : '';
2166
2187
  };
2188
+ /**
2189
+ * What is said of rates listed but not booked here (`Room.notSoldHere`) - in
2190
+ * the text and, word for word, in the structure (`notSoldHere`): the model reads
2191
+ * the structure (2026-10-28). Listed and marked as the group sells them; the
2192
+ * card says no more than 预付 on them (robin, 2026-10-30), so the assistant is
2193
+ * the one who knows - and says it when the guest wants one.
2194
+ */
2195
+ export const notSoldHereWords = (group) => `Rates marked paid in advance are listed as ${group} sells them, but a stay paid in advance cannot be booked here yet: this connector books rates paid at the hotel. If the guest wants one, say so plainly - nothing is booked or charged - and offer a rate paid at the hotel, other dates, or booking it with ${group} directly.`;
2196
+ /** The group a hotel is with, by name - the deployment's own brand outside a market. */
2197
+ const groupNameOf = (deps, hotelId) => holderOf(deps, deps.backend.supplierOf?.(hotelId)).name;
2167
2198
  /** What a rate says about cancelling, as the text puts it. */
2168
2199
  const cancelWords = (r) => r.freeCancellationUntil
2169
2200
  ? `free to cancel until ${freeUntilText(r)}`
@@ -2370,7 +2401,13 @@ const dateFlex = (dates, stay) => {
2370
2401
  dates: dates.map((d) => ({
2371
2402
  checkIn: d.checkIn,
2372
2403
  checkOut: d.checkOut,
2373
- ...(d.from ? { from: formatMoney(d.from) } : { soldOut: true }),
2404
+ // "Sold out" only when it is (D2, robin 2026-10-30): a day with rooms
2405
+ // that cannot be booked here says why instead.
2406
+ ...(d.from
2407
+ ? { from: formatMoney(d.from) }
2408
+ : !d.why || d.why === 'sold-out'
2409
+ ? { soldOut: true }
2410
+ : { noRoomsCode: d.why }),
2374
2411
  ...(d.chosen ? { chosen: true } : {}),
2375
2412
  // The chosen date cannot match: `beatsBy` is a price strictly below it.
2376
2413
  ...(beatsBy !== undefined && d.from?.minor === beatsBy ? { best: true } : {}),
@@ -2665,6 +2702,19 @@ const currentTrip = async (deps, key, who, store = key) => {
2665
2702
  const groupOf = deps.backend.supplierOf
2666
2703
  ? (id) => deps.backend.supplierOf?.(id)
2667
2704
  : undefined;
2705
+ // Two hotels on the same nights - said, never refused (overlap.ts). In
2706
+ // both, in the same words.
2707
+ const overlaps = overlapsOf(parts.flatMap((p) => p.lines.flatMap((l) => l.kind === 'room' && l.stay && !l.hourly
2708
+ ? [
2709
+ {
2710
+ hotelId: l.hotelId,
2711
+ name: hotelName(l.hotelName),
2712
+ checkIn: l.stay.checkIn,
2713
+ checkOut: l.stay.checkOut,
2714
+ },
2715
+ ]
2716
+ : [])));
2717
+ const overlapping = overlaps.length ? { overlaps } : {};
2668
2718
  if (parts.length === 1 && only) {
2669
2719
  return {
2670
2720
  structured: {
@@ -2673,10 +2723,12 @@ const currentTrip = async (deps, key, who, store = key) => {
2673
2723
  ...quoteForCard(only, groupOf),
2674
2724
  ...needs,
2675
2725
  ...stamp,
2726
+ ...overlapping,
2676
2727
  count: lines.length,
2677
2728
  },
2678
2729
  text: [
2679
2730
  tripText(only, false),
2731
+ ...overlaps.map((o) => o.said),
2680
2732
  orphans.line,
2681
2733
  deps.vocab.demo && lines.length ? noteFor(deps.vocab) : '',
2682
2734
  ]
@@ -2715,11 +2767,13 @@ const currentTrip = async (deps, key, who, store = key) => {
2715
2767
  ...needs,
2716
2768
  ...(shaped.some((p) => p.dropped) ? { dropped: shaped.flatMap((p) => p.dropped ?? []) } : {}),
2717
2769
  ...stamp,
2770
+ ...overlapping,
2718
2771
  count: lines.length,
2719
2772
  },
2720
2773
  text: [
2721
2774
  `This trip is in ${parts.length} currencies, priced separately and not converted - each hotel is booked and paid in its own.`,
2722
2775
  ...parts.map((q) => tripText(q, false)),
2776
+ ...overlaps.map((o) => o.said),
2723
2777
  orphans.line,
2724
2778
  deps.vocab.demo ? noteFor(deps.vocab) : '',
2725
2779
  ]
@@ -2824,6 +2878,17 @@ dated = false) => {
2824
2878
  * read better as hotels, grouped by city.
2825
2879
  */
2826
2880
  export const CITY_PICKER_MIN = 4;
2881
+ /**
2882
+ * Up to how many hotels a whole deployment is a list rather than places to
2883
+ * choose from. Huazhu's production had two, in two cities, and "where to?"
2884
+ * over two city chips and "browse all 2" had the model list them all again -
2885
+ * two cards for one question (robin, 2026-10-30). Six is what the card shows
2886
+ * whole at every width, with no "Next" (its inline page is five rows on a
2887
+ * phone and lets two more in - see the card's `pageSize`): a place picker
2888
+ * over that many saves no one a tap. A group of nine in four cities still
2889
+ * chooses a city first.
2890
+ */
2891
+ export const HOME_LIST_MAX = 6;
2827
2892
  const suggestionsFor = async (deps, d) => {
2828
2893
  const view = String(d.view ?? '');
2829
2894
  const out = [];
@@ -3078,7 +3143,11 @@ const comparedInWords = (c) => {
3078
3143
  : p.refundable
3079
3144
  ? 'no free cancellation'
3080
3145
  : 'non-refundable',
3081
- p.payment === 'prepaid' ? 'paid in advance' : '',
3146
+ p.payment === 'prepaid'
3147
+ ? p.notSoldHere
3148
+ ? 'paid in advance - not booked here'
3149
+ : 'paid in advance'
3150
+ : '',
3082
3151
  p.breakfast ? 'breakfast included' : 'no breakfast',
3083
3152
  ]
3084
3153
  .filter(Boolean)
@@ -3493,7 +3562,10 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3493
3562
  let took = '';
3494
3563
  const type = room ? undefined : roomTypeNamed(dated.rooms, roomRef);
3495
3564
  if (type) {
3496
- const fits = type.rooms.filter((r) => r.sleeps >= stay.guests && r.roomsLeft >= wanted);
3565
+ const fitting = type.rooms.filter((r) => r.sleeps >= stay.guests && r.roomsLeft >= wanted);
3566
+ // A rate booked here before one only shown (`notSoldHere`), where the type has both.
3567
+ const sold = fitting.filter((r) => !r.notSoldHere);
3568
+ const fits = sold.length ? sold : fitting;
3497
3569
  const [first, ...rest] = fits;
3498
3570
  const cheapest = first
3499
3571
  ? rest.reduce((a, b) => (b.stayTotal.minor < a.stayTotal.minor ? b : a), first)
@@ -3535,6 +3607,45 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3535
3607
  text: `${type ? `${detail.hotel.name} has no ${type.name} that sleeps ${stay.guests} and has ${wanted === 1 ? 'a room' : `${wanted} rooms`} left for those nights.` : `${detail.hotel.name} has no room type by that name for those nights.`} It has: ${dated.rooms.map((r) => `${r.name} (${han && r.names?.zh ? `${r.names.zh}, ` : ''}\`${r.id}\`)`).join(', ')}. Call again with the id.`,
3536
3608
  };
3537
3609
  }
3610
+ // ⚠️ LISTED, NOT BOOKED HERE (robin, 2026-10-30): a rate paid in advance
3611
+ // where this deployment takes no payment goes in no trip and no pick. The
3612
+ // press on its Reserve - or the assistant asking for it - is answered with
3613
+ // what is true and what is left: nothing booked, nothing charged; the rates
3614
+ // here paid at the hotel, a press each; other dates; or the group itself.
3615
+ // Never a review that fails at Book, after the guest typed their name.
3616
+ if (room.notSoldHere) {
3617
+ const open = dated.rooms.filter((r) => !r.notSoldHere && r.sleeps >= stay.guests && r.roomsLeft >= wanted);
3618
+ const group = groupNameOf(deps, hotelId);
3619
+ const said = [
3620
+ `Nothing was booked or charged: ${room.name} is paid in advance, and a stay paid in advance cannot be booked here yet - this connector books rates paid at the hotel.`,
3621
+ open.length
3622
+ ? `Paid at the hotel at ${detail.hotel.name} for those nights: ${open.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
3623
+ : `No rate at ${detail.hotel.name} for those nights is paid at the hotel.`,
3624
+ `Other dates, or booking this rate with ${group} directly, are the other ways on.`,
3625
+ ].join(' ');
3626
+ return {
3627
+ structured: {
3628
+ view: 'error',
3629
+ ...common(deps, key),
3630
+ error: 'prepaid-not-here',
3631
+ room: { id: room.id, name: room.name, ...(room.names ? { names: room.names } : {}) },
3632
+ rooms: open.map((r) => ({
3633
+ id: r.id,
3634
+ name: r.name,
3635
+ ...(r.names ? { names: r.names } : {}),
3636
+ })),
3637
+ hotel: {
3638
+ id: detail.hotel.id,
3639
+ name: detail.hotel.name,
3640
+ ...(detail.hotel.names ? { names: detail.hotel.names } : {}),
3641
+ },
3642
+ stay,
3643
+ group,
3644
+ said,
3645
+ },
3646
+ text: said,
3647
+ };
3648
+ }
3538
3649
  deps.trips.set(store, {
3539
3650
  kind: 'room',
3540
3651
  hotelId,
@@ -4091,17 +4202,33 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4091
4202
  const [sole] = hotels;
4092
4203
  if (choosing && total === 1 && sole && !deps.backend.suppliers) {
4093
4204
  const page = await answerTool(deps, t.getHotel, { hotel: sole.id, ...stayArgs }, ctx);
4205
+ // ⚠️ NOT "THE ONLY ONE HERE": read as "in this list", it had the
4206
+ // assistant ask which city to list the rest in (2026-10-15 smoke run).
4207
+ // And not "tell me another city": the second run said there was one
4208
+ // hotel, then offered to look in another city (2026-10-15).
4209
+ const inAll = `This connector has one ${branded(deps.vocab, deps.vocab.noun)} in all, ${sole.name}${sole.city ? ` in ${sole.city}` : ''}. There is no other hotel and no other city here - nothing else to search for or to offer.`;
4094
4210
  return {
4095
4211
  ...page,
4096
- structured: { ...page.structured, only: true },
4097
- // ⚠️ NOT "THE ONLY ONE HERE": read as "in this list", it had the
4098
- // assistant ask which city to list the rest in (2026-10-15 smoke run).
4099
- // And not "tell me another city": the second run said there was one
4100
- // hotel, then offered to look in another city (2026-10-15).
4101
- text: `This connector has one ${branded(deps.vocab, deps.vocab.noun)} in all, ${sole.name}${sole.city ? ` in ${sole.city}` : ''}. There is no other hotel and no other city here - nothing else to search for or to offer. Its page:\n\n${page.text}`,
4212
+ // In both, in the same words: a host may hand its model this rather
4213
+ // than the text.
4214
+ structured: { ...page.structured, only: true, inAll },
4215
+ text: `${inAll} Its page:\n\n${page.text}`,
4102
4216
  };
4103
4217
  }
4104
- if (choosing && facets) {
4218
+ // ⚠️ A FEW HOTELS ARE NO CHOICE OF PLACES EITHER (robin, 2026-10-30):
4219
+ // Huazhu's production had two, in two cities, and its home was "where to?"
4220
+ // over two city chips and "browse all 2" - the model then listed them
4221
+ // again, a second card for the same question. Where the whole deployment
4222
+ // fits on one page - never a market, whose home is its groups - the home
4223
+ // is the list of all of them, marked `home` so the card keeps the home's
4224
+ // own ways in on it.
4225
+ const homeList = !!choosing &&
4226
+ !asked.city &&
4227
+ !deps.backend.suppliers &&
4228
+ total > 1 &&
4229
+ total <= HOME_LIST_MAX &&
4230
+ hotels.length === total;
4231
+ if (choosing && facets && !homeList) {
4105
4232
  const countries = facets.countries.map((c) => ({
4106
4233
  name: c.name,
4107
4234
  count: c.count,
@@ -4151,7 +4278,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4151
4278
  ...(ask.stay ? { stay: ask.stay } : {}),
4152
4279
  ...(today ? { today } : {}),
4153
4280
  },
4154
- text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} countries, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${every(r.countries)}`).join('; ')}. A city or a country lists its ${n}. ${forPlans}`,
4281
+ text: `${total} ${branded(deps.vocab, n)} in ${facets.cities.length} cities across ${countries.length} ${countries.length === 1 ? 'country' : 'countries'}, to choose from by region, country and city - ${regions.map((r) => `${r.name}: ${every(r.countries)}`).join('; ')}. A city or a country lists its ${n}. ${forPlans}`,
4155
4282
  };
4156
4283
  }
4157
4284
  if (hotels.length === 0) {
@@ -4286,10 +4413,25 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4286
4413
  : ''}.`
4287
4414
  : '',
4288
4415
  ].filter(Boolean);
4416
+ // A price on the list that is not booked here (`notSoldHere`): said as on
4417
+ // the hotel's page, in both - see `notSoldHereWords`.
4418
+ const notSoldOn = hotels.find((h) => h.forStay?.notSoldHere);
4419
+ const notSold = notSoldOn ? notSoldHereWords(groupNameOf(deps, notSoldOn.id)) : '';
4420
+ if (notSold)
4421
+ priceNotes.push(notSold);
4422
+ // Every one by name and city, and that there are no others - or, told
4423
+ // only "2 hotels", the model asked for the list again to learn which.
4424
+ const inAll = homeList
4425
+ ? `This connector has ${total} ${branded(deps.vocab, deps.vocab.nounPlural)} in all, and this answer lists every one: ${hotels
4426
+ .map((h) => `${hotelName(h.name)}${h.city ? ` in ${h.city}` : ''}`)
4427
+ .join('; ')}. There is no other ${deps.vocab.noun} and no other city here - nothing else to list, search for or offer.`
4428
+ : '';
4289
4429
  return {
4290
4430
  structured: {
4291
4431
  view: 'hotels',
4292
4432
  ...common(deps, key),
4433
+ // The home, as a list - see `homeList`. The same sentence as the text.
4434
+ ...(homeList ? { home: true, inAll } : {}),
4293
4435
  hotels: forCard,
4294
4436
  total,
4295
4437
  shown: hotels.length,
@@ -4311,9 +4453,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4311
4453
  // In both, like a plan's: a host with no card may hand its model this
4312
4454
  // rather than the text.
4313
4455
  ...(ctx.noCards ? { notDrawn: LIST_NOT_DRAWN } : {}),
4456
+ ...(notSold ? { notSoldHere: notSold } : {}),
4314
4457
  },
4315
4458
  text: [
4316
- `${total} ${branded(deps.vocab, total === 1 ? deps.vocab.noun : deps.vocab.nounPlural)}${where}.`,
4459
+ inAll ||
4460
+ `${total} ${branded(deps.vocab, total === 1 ? deps.vocab.noun : deps.vocab.nounPlural)}${where}.`,
4317
4461
  // ⚠️ NO "BELOW", NO "TAP". This sentence is read out by a voice
4318
4462
  // assistant as often as it is drawn next to a card, and a guest whose
4319
4463
  // host has no card at all cannot tap the one it tells them to tap. The
@@ -4445,6 +4589,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4445
4589
  // card needs no call to the server.
4446
4590
  const terms = roomTermsOf(args);
4447
4591
  const soldOut = soldOutTypesOf(detail, enough, roomsWanted);
4592
+ const notSold = enough.some((r) => r.notSoldHere)
4593
+ ? notSoldHereWords(groupNameOf(deps, h.id))
4594
+ : '';
4448
4595
  const rooms = enough.map((r) => ({
4449
4596
  ...r,
4450
4597
  facets: facetsOf(r),
@@ -4484,6 +4631,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4484
4631
  activeFilters: activeKeysOf(terms),
4485
4632
  // Types with no room to book for these nights, said and not left out.
4486
4633
  ...(byTheHour || !soldOut.length ? {} : { soldOut }),
4634
+ ...(notSold ? { notSoldHere: notSold } : {}),
4487
4635
  ...flex.structured,
4488
4636
  },
4489
4637
  text: [
@@ -4494,6 +4642,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4494
4642
  terms && flex.text
4495
4643
  ? `${flex.text} (That is the cheapest room of any kind, not only one with ${termWords(terms)}.)`
4496
4644
  : flex.text,
4645
+ notSold,
4497
4646
  deps.vocab.demo ? noteFor(deps.vocab) : '',
4498
4647
  ]
4499
4648
  .filter(Boolean)
@@ -5376,7 +5525,11 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5376
5525
  // Each stay's rate says its terms where they bind - see `ItineraryItem.refundable`.
5377
5526
  const termsOf = (x) => [
5378
5527
  x.refundable === false ? 'non-refundable' : '',
5379
- x.payment === 'prepaid' ? 'paid in advance' : '',
5528
+ x.payment === 'prepaid'
5529
+ ? x.notSoldHere
5530
+ ? 'paid in advance - not booked here'
5531
+ : 'paid in advance'
5532
+ : '',
5380
5533
  ]
5381
5534
  .filter(Boolean)
5382
5535
  .join(', ');
@@ -5388,6 +5541,10 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5388
5541
  // text said of a long stay. The model's part of the answer is the same on
5389
5542
  // either side.
5390
5543
  const nudges = stays.length ? await longStayNudges(deps, stayItems, plan.days) : [];
5544
+ // Two hotels on the same nights of the plan - said, never refused (overlap.ts).
5545
+ const overlaps = overlapsOf(stayItems.flatMap((x) => x.checkIn && x.checkOut
5546
+ ? [{ hotelId: x.id, name: hotelName(x.name), checkIn: x.checkIn, checkOut: x.checkOut }]
5547
+ : []));
5391
5548
  return {
5392
5549
  structured: {
5393
5550
  view: 'itinerary',
@@ -5395,6 +5552,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5395
5552
  ...plan,
5396
5553
  gap,
5397
5554
  ...(nudges.length ? { nudges } : {}),
5555
+ ...(overlaps.length ? { overlaps } : {}),
5398
5556
  // In both, like the nudges: a host with no card may hand its model
5399
5557
  // this rather than the text.
5400
5558
  ...(ctx.noCards ? { notDrawn: PLAN_NOT_DRAWN } : {}),
@@ -5409,6 +5567,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5409
5567
  ? [`Together, the stays not in the trip or booked yet: ${plan.staysTotal}.`]
5410
5568
  : []),
5411
5569
  ...nudges,
5570
+ ...overlaps.map((o) => o.said),
5412
5571
  ]
5413
5572
  : []),
5414
5573
  ...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),