@curless/hotel-storefront 0.47.4 → 0.47.6

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
@@ -4,7 +4,7 @@ import { FACILITIES } from './facilities.js';
4
4
  import { DESCRIPTION_MAX, POLICY_MAX, guestText, 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
- import { namedExactly, roomNamesOf, withTraditional } from './names.js';
7
+ import { namedExactly, roomNamesOf, roomTypeNamed, withTraditional } from './names.js';
8
8
  import { MAX_COMPARED, MAX_CONS, MAX_DAYS, MAX_ITEMS_PER_DAY, MAX_MOMENTS, MAX_PROS, buildItinerary, candidatesFor, compareHotels, namesMoreThanOne, } from './present.js';
9
9
  import { regionOf } from './regions.js';
10
10
  import { activeKeysOf, byRoomType, facetsOf, keyWords, matchesTerms, optionsOf, roomTermsOf, termWords, } from './room-filters.js';
@@ -747,12 +747,16 @@ const allTools = (n, v) => {
747
747
  properties: {
748
748
  label: {
749
749
  type: 'string',
750
- description: `e.g. "Day 1 — Arrive${places[0] ? ` in ${places[0]}` : ''}"`,
750
+ description: `e.g. "Arrive${places[0] ? ` in ${places[0]}` : ''}" - the card prints each day's date beside it, so leave the date out`,
751
751
  },
752
752
  note: {
753
753
  type: 'string',
754
754
  description: 'One or two lines on the day, in your own words',
755
755
  },
756
+ why: {
757
+ type: 'string',
758
+ description: `Why the ${v.noun} named on this day suits the days of that stay - near what, good for what - one sentence, in your own words, with no lead-in such as "My pick:" - the card has its own`,
759
+ },
756
760
  items: {
757
761
  type: 'array',
758
762
  items: { type: 'string' },
@@ -1321,7 +1325,21 @@ export const buildOutputSchemas = (n) => ({
1321
1325
  demoNote: STR,
1322
1326
  title: STR,
1323
1327
  stay: OBJ,
1324
- days: ARR,
1328
+ days: {
1329
+ type: 'array',
1330
+ items: {
1331
+ type: 'object',
1332
+ properties: {
1333
+ label: STR,
1334
+ note: STR,
1335
+ /** Why the hotel this day names suits the days of that stay - the model's sentence. */
1336
+ why: STR,
1337
+ date: STR,
1338
+ plan: ARR,
1339
+ items: ARR,
1340
+ },
1341
+ },
1342
+ },
1325
1343
  /** How many of the planned items are actually in the trip. Computed, never written. */
1326
1344
  inTrip: { type: 'number' },
1327
1345
  planned: { type: 'number' },
@@ -2925,6 +2943,58 @@ export const handleTool = async (deps, name, rawArgs, ctx) => {
2925
2943
  const fitsManyOf = (deps, dropped) => dropped.flatMap((d) => d.candidates?.length
2926
2944
  ? [`"${d.name}" ${namesMoreThanOne(d.candidates, branded(deps.vocab, deps.vocab.noun))}`]
2927
2945
  : []);
2946
+ /** A stay this long, at one hotel, is worth asking whether the days stay in one area. */
2947
+ const LONG_STAY_NIGHTS = 4;
2948
+ /** The other areas a long stay's answer names - one hotel each. */
2949
+ const OTHER_AREAS_MAX = 4;
2950
+ /**
2951
+ * For each priced stay of four nights or more, the other areas of its city that
2952
+ * have a hotel - for the model, in the tool's text, never on the card or in the
2953
+ * schema.
2954
+ *
2955
+ * ⚠️ A WEEK AT ONE HOTEL WAS THE PLAN, AND THE MODEL NEVER SAW WHAT ELSE THE
2956
+ * CITY HAS (the owner, 2026-10-26, on the live Atour connector: both Beijing
2957
+ * weeks, all six nights at one hotel). The instructions say a long stay in a big
2958
+ * city usually spans areas; this is the answer saying it at the moment the plan
2959
+ * has just been drawn that way, with the hotels of the other areas by id, so the
2960
+ * model can plan the stay in parts - or offer the move - without a second
2961
+ * look-up. Nothing where the city has no other area, or the backend cannot say:
2962
+ * a nudge that guesses is worse than none.
2963
+ */
2964
+ const longStayNudges = async (deps, stays) => {
2965
+ // The areas the plan already uses are not "other" for any of its stays.
2966
+ const used = new Set(stays.map((x) => x.area?.toLowerCase()).filter(Boolean));
2967
+ const out = [];
2968
+ for (const x of stays) {
2969
+ if ((x.nights ?? 0) < LONG_STAY_NIGHTS || !x.area || !x.city)
2970
+ continue;
2971
+ let hotels;
2972
+ try {
2973
+ hotels = (await deps.backend.searchHotels({ city: x.city, limit: MAX_PAGE })).hotels;
2974
+ }
2975
+ catch {
2976
+ // The plan is already drawn; a city that cannot be read just gets no nudge.
2977
+ continue;
2978
+ }
2979
+ const byArea = new Map();
2980
+ for (const h of hotels) {
2981
+ const area = h.area?.trim();
2982
+ if (!area || h.city !== x.city || h.id === x.id)
2983
+ continue;
2984
+ const lower = area.toLowerCase();
2985
+ if (used.has(lower) || byArea.has(lower))
2986
+ continue;
2987
+ byArea.set(lower, h);
2988
+ }
2989
+ if (byArea.size === 0)
2990
+ continue;
2991
+ const others = [...byArea.values()]
2992
+ .slice(0, OTHER_AREAS_MAX)
2993
+ .map((h) => `${h.area} - ${h.name} (\`${h.id}\`)`);
2994
+ out.push(`${stays.length === 1 ? `All ${x.nights} nights are` : `${x.nights} nights are`} at ${x.name} (${x.area}). ${x.city} also has: ${others.join('; ')}. In your reply, name the planned days that are far from ${x.area} and offer moving those nights to the hotel here nearest them, with the reason in one sentence - or say why every day suits ${x.area}. The plan on screen stays as it is; draw it again only if the guest asks.`);
2995
+ }
2996
+ return out;
2997
+ };
2928
2998
  /** The hotel a call named - or the answer that says there is none, or several by that name. */
2929
2999
  const hotelNamed = async (deps, key, wanted) => {
2930
3000
  const detail = await deps.backend.getHotel(wanted);
@@ -2996,7 +3066,7 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
2996
3066
  })),
2997
3067
  },
2998
3068
  text: sold.length
2999
- ? `${detail.hotel.name} has no room by the hour by that name today. It has: ${sold.map((r) => `${r.name} (\`${r.id}\`, ${windowWords(r.hourly)})`).join('; ')}.`
3069
+ ? `${detail.hotel.name} has no room by the hour by that name today. It has: ${sold.map((r) => `${r.name} (\`${r.id}\`, ${windowWords(r.hourly)})`).join('; ')}. Call again with the id.`
3000
3070
  : ((await today)?.noRooms ?? `${detail.hotel.name} has no rooms by the hour today.`),
3001
3071
  };
3002
3072
  }
@@ -3085,12 +3155,37 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3085
3155
  text: dated?.noRooms ?? 'Those nights cannot be booked here.',
3086
3156
  };
3087
3157
  }
3088
- // ⚠️ BY THE OFFER'S WHOLE NAME, in any language it has: a guest who read
3158
+ // ⚠️ BY THE ID, THE OFFER'S WHOLE NAME, OR A ROOM TYPE'S BARE NAME - in
3159
+ // that order, and in any language the name has: a guest who read
3089
3160
  // 「豪华大床房 · 双早 · 不可取消」 on the card says that, and it is the same offer
3090
3161
  // as "Deluxe King Room · Breakfast for 2 · Non-refundable". Never a part of
3091
- // it, and never a room type's bare name when it has several rates.
3092
- const room = dated.rooms.find((r) => r.id === roomRef) ?? namedExactly(dated.rooms, roomRef);
3162
+ // a name. A type's bare name ("Deluxe King Room") used to be no room at all
3163
+ // when the type has several rates, and a model that planned a trip by the
3164
+ // type's name was answered "no such room" three times over (the owner,
3165
+ // 2026-10-26, Atour). It now takes the type's CHEAPEST rate that sleeps the
3166
+ // stay's guests and has rooms left - and the answer says which rate that
3167
+ // was and that there are others, so the guest is told what they got. Two
3168
+ // types answering to one name, or none, is still no room.
3169
+ const wanted = Math.max(1, clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE));
3170
+ let room = dated.rooms.find((r) => r.id === roomRef) ?? namedExactly(dated.rooms, roomRef);
3171
+ let took = '';
3172
+ const type = room ? undefined : roomTypeNamed(dated.rooms, roomRef);
3173
+ if (type) {
3174
+ const fits = type.rooms.filter((r) => r.sleeps >= stay.guests && r.roomsLeft >= wanted);
3175
+ const [first, ...rest] = fits;
3176
+ const cheapest = first
3177
+ ? rest.reduce((a, b) => (b.stayTotal.minor < a.stayTotal.minor ? b : a), first)
3178
+ : undefined;
3179
+ if (cheapest) {
3180
+ room = cheapest;
3181
+ const others = fits.filter((r) => r !== cheapest);
3182
+ took = `Took ${cheapest.name} (\`${cheapest.id}\`) - ${others.length
3183
+ ? `the lowest of ${type.name}'s ${fits.length} rates for these nights; the others: ${others.map((r) => `${r.name} (\`${r.id}\`)`).join(', ')}.`
3184
+ : `${type.name}'s only rate for these nights.`}`;
3185
+ }
3186
+ }
3093
3187
  if (!room) {
3188
+ const han = /\p{Script=Han}/u.test(roomRef);
3094
3189
  return {
3095
3190
  structured: {
3096
3191
  view: 'error',
@@ -3104,10 +3199,18 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3104
3199
  })),
3105
3200
  // The hotel and the nights, so the card can take the guest straight
3106
3201
  // back to its rooms rather than to the start (round-2 jury).
3107
- hotel: { id: detail.hotel.id, name: detail.hotel.name },
3202
+ hotel: {
3203
+ id: detail.hotel.id,
3204
+ name: detail.hotel.name,
3205
+ // The card says the hotel in the guest's language (「没能放进行程:…」).
3206
+ ...(detail.hotel.names ? { names: detail.hotel.names } : {}),
3207
+ },
3108
3208
  stay,
3109
3209
  },
3110
- text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => (/\p{Script=Han}/u.test(roomRef) && r.names?.zh ? `${r.name} (${r.names.zh})` : r.name)).join(', ')}.`,
3210
+ // ⚠️ EVERY OFFER WITH ITS ID: the model that was told "no such room" by
3211
+ // a list of names it had just tried to match by name tried again by name.
3212
+ // The ids are what it can say, and it says them once.
3213
+ 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.`,
3111
3214
  };
3112
3215
  }
3113
3216
  deps.trips.set(store, {
@@ -3117,7 +3220,8 @@ const putRoomLine = async (deps, t, ctx, key, store, args, detail) => {
3117
3220
  stay,
3118
3221
  rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
3119
3222
  });
3120
- return currentTrip(deps, key, identity(deps, ctx, key), store);
3223
+ const now = await currentTrip(deps, key, identity(deps, ctx, key), store);
3224
+ return took ? { ...now, text: `${took}\n${now.text}` } : now;
3121
3225
  };
3122
3226
  /**
3123
3227
  * Books the rooms in one slot of the trip store for a named guest - THE ONE
@@ -4844,6 +4948,7 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4844
4948
  const plan = await buildItinerary(deps.backend, days.map((d) => ({
4845
4949
  label: str(d.label),
4846
4950
  note: typeof d.note === 'string' ? d.note : undefined,
4951
+ why: typeof d.why === 'string' ? d.why : undefined,
4847
4952
  items: d.items ?? [],
4848
4953
  ...(Array.isArray(d.plan) ? { plan: d.plan } : {}),
4849
4954
  })), {
@@ -4866,15 +4971,28 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4866
4971
  // Each stay as the trip takes it, for a guest who asks in the chat to put
4867
4972
  // the plan in the trip: without the room, the model opened the hotel to
4868
4973
  // find one - another card - before it could add anything.
4869
- const stays = plan.days
4974
+ //
4975
+ // ⚠️ BY ID, NEVER BY NAME. A model asked "put the stays on this plan in the
4976
+ // trip" added three rooms by the names it remembered and was answered "no
4977
+ // such room" three times, a whole error card each (the owner, 2026-10-26,
4978
+ // Atour). Each priced stay is listed with the hotel id, the room id and the
4979
+ // nights, which is exactly what add_to_trip takes.
4980
+ const stayItems = plan.days
4870
4981
  .flatMap((day) => day.items)
4871
- .filter((x) => x.kind === 'hotel' && (x.night ?? 1) === 1 && x.checkIn && x.roomId)
4872
- .map((x) => `${x.name} (\`${x.id}\`) ${x.checkIn} to ${x.checkOut}, room \`${x.roomId}\`${x.booked ? ` - booked, ${x.booked}` : x.inTrip ? ' - in the trip' : ''}`);
4982
+ .filter((x) => x.kind === 'hotel' && (x.night ?? 1) === 1 && x.checkIn && x.roomId);
4983
+ const stays = stayItems.map((x, i) => `Stay ${i + 1}: ${x.name} (\`${x.id}\`), room \`${x.roomId}\`${x.roomName ? ` (${x.roomName})` : ''}, ${x.checkIn} to ${x.checkOut}${x.nights ? ` (${x.nights} ${x.nights === 1 ? 'night' : 'nights'})` : ''}${x.booked ? ` - booked, ${x.booked}` : x.inTrip ? ' - in the trip' : ''}`);
4873
4984
  return {
4874
4985
  structured: { view: 'itinerary', ...common(deps, key), ...plan, gap },
4875
4986
  text: [
4876
4987
  `**${plan.title}** · ${plan.days.length} ${plan.days.length === 1 ? 'day' : 'days'}. ${gap}`,
4877
- stays.length ? `Stays, priced on: ${stays.join('; ')}.` : '',
4988
+ ...(stays.length
4989
+ ? [
4990
+ `Priced stays (${plan.stay?.guests ?? 2} guests; ${t.addToTrip} takes each one's hotel id, room id and dates):`,
4991
+ ...stays,
4992
+ // ⚠️ IN THE TEXT ONLY - not in `structured`, not in the schema.
4993
+ ...(await longStayNudges(deps, stayItems)),
4994
+ ]
4995
+ : []),
4878
4996
  ...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
4879
4997
  ...fitsManyOf(deps, plan.dropped),
4880
4998
  deps.vocab.demo ? noteFor(deps.vocab) : '',