@curless/hotel-storefront 0.47.8 → 0.48.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.
package/dist/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_PAGE, FULL_CAPABILITIES, LASTING_REQUESTS, MAX_PAGE, REQUEST_KINDS, REQUEST_NOTE_MAX, guestTooLong, unitWords, } from './backend.js';
2
2
  import { MAX_GUESTS, isDay, nights as nightsBetween } from './dates.js';
3
3
  import { FACILITIES } from './facilities.js';
4
- import { DESCRIPTION_MAX, POLICY_MAX, guestText, hotelText } from './fence.js';
4
+ import { DESCRIPTION_MAX, POLICY_MAX, 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';
@@ -431,7 +431,7 @@ const allTools = (n, v) => {
431
431
  // the guest for one question (robin, 2026-09-29, Opus in Claude.ai). The
432
432
  // reference apps do the same split: search without a card, present with
433
433
  // one.
434
- description: `Reads up to ${LOOK_UP_MAX} ${v.nounPlural} - a \`city\`'s (or a country's), or the ones named in \`${v.nounPlural}\` - and returns them as text, with nothing drawn and nothing shown to the guest: each one's id, area, rating, description and amenities, its add-ons with their ids and prices, and, given \`checkIn\` and \`checkOut\`, its rooms priced for those nights with taxes in. It is how the ${v.nounPlural} of a day-by-day plan are chosen - for each part of the trip, those near where its days are spent; a city with a span of dates and 行程 is such a plan, also right after a list of ${v.nounPlural} - and the ids are the ones a comparison and a plan take.`,
434
+ description: `Reads up to ${LOOK_UP_MAX} ${v.nounPlural} - a \`city\`'s (or a country's), or the ones named in \`${v.nounPlural}\` - and returns them as text, with nothing drawn and nothing shown to the guest: each one's id, area, rating, description and amenities, its add-ons with their ids and prices, and, given \`checkIn\` and \`checkOut\`, its rooms priced for those nights with taxes in. It is how the ${v.nounPlural} of a day-by-day plan are chosen - for each part of the trip, those near where its days are spent (a week in a big city has two or three parts, its far-out days side by side with a ${v.noun} near them); a city with a span of dates and 行程 is such a plan, also right after a list of ${v.nounPlural} - and the ids are the ones a comparison and a plan take.`,
435
435
  annotations: reads(`Look up ${catalogue(v)}`, open),
436
436
  inputSchema: {
437
437
  type: 'object',
@@ -740,7 +740,7 @@ const allTools = (n, v) => {
740
740
  },
741
741
  {
742
742
  name: n.itinerary,
743
- description: `A day-by-day plan (行程) as one card - the card for a trip of several days, or for a guest who asks to have their days planned (a city with a span of dates and 行程 is one, also right after a list of ${v.nounPlural}): the days are laid out by where they are spent, and each part of the trip has a ${v.noun} near those days, chosen from the ${v.nounPlural} read up as text. A week in a big city is usually two or three stays: days more than about 45 minutes each way from the rest (a palace or park on the city's edge, a day out of town) have a ${v.noun} near them, with the reason in that day's \`why\`. Each day is checked against that place's closing days for its date (many museums close on Mondays). Each day carries its own label, an optional note, the moments of the day in order, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records - a ${v.noun} with its photograph and, given dates, the price of its nights. Each stay is marked with whether it is booked, in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
743
+ description: `A day-by-day plan (行程) as one card - the card for a trip of several days, or for a guest who asks to have their days planned (a city with a span of dates and 行程 is one, also right after a list of ${v.nounPlural}): the days are laid out by where they are spent, and each part of the trip has a ${v.noun} near those days, chosen from the ${v.nounPlural} read up as text. A week in a big city is usually two or three stays: its far-out sights fall on neighbouring days, and those nights have a ${v.noun} near them, with the reason in that day's \`why\` (Beijing's Summer Palace, Yuanmingyuan, Fragrant Hills and the Peking and Tsinghua campuses - 颐和园, 圆明园, 香山, 北大清华 - are days side by side in the north-west); a day out of town, such as the Great Wall, is a day of its own, there and back. One ${v.noun} for the whole week is the plan when every day is within about 45 minutes of it, with the stay in two parts as the other way the reply names. Nothing here knows a sight's opening days: each day is drawn as it is given, with its date (many museums close on Mondays, the Palace Museum - 故宫 - among them). Each day carries its own label, an optional note, the moments of the day in order, and the ${v.nounPlural} and add-ons attached to it, with their details filled in from their own records - a ${v.noun} with its photograph and, given dates, the price of its nights. Each stay is marked with whether it is booked, in the trip or only in the plan, and the card says how many of each. Nothing is added to the trip.`,
744
744
  annotations: reads(v.market ? `Lay out an itinerary on ${v.brand}` : `Lay out a ${v.brand} itinerary`, open),
745
745
  inputSchema: {
746
746
  type: 'object',
@@ -910,6 +910,20 @@ const allTools = (n, v) => {
910
910
  type: 'object',
911
911
  properties: {
912
912
  ...guestProps,
913
+ // The room the form shows, as pick_room returned it - see `pickChanged`.
914
+ hotel: { type: 'string', description: `The ${v.noun} id on the form` },
915
+ room: { type: 'string', description: 'The room type id on the form' },
916
+ rooms: {
917
+ type: 'integer',
918
+ minimum: 1,
919
+ maximum: MAX_ROOMS_PER_LINE,
920
+ description: 'How many of that room type the form shows',
921
+ },
922
+ ...stayProps,
923
+ startTime: {
924
+ type: 'string',
925
+ description: 'For a room by the hour: the start the form shows, HH:MM',
926
+ },
913
927
  },
914
928
  required: ['name', 'email'],
915
929
  },
@@ -1177,6 +1191,13 @@ const BOOKING_SCHEMA = {
1177
1191
  * 2026-10-14).
1178
1192
  */
1179
1193
  failed: ARR,
1194
+ /**
1195
+ * The same stays for the same guest asked for again within minutes: the
1196
+ * booking made then is the answer, and nothing new was booked.
1197
+ */
1198
+ repeat: BOOL,
1199
+ /** A form whose room is no longer the one picked: the pick that would put it back (`pickChanged`). */
1200
+ again: OBJ,
1180
1201
  },
1181
1202
  required: ['view'],
1182
1203
  };
@@ -1729,7 +1750,7 @@ const bookedLine = (deps, b) => {
1729
1750
  return `${total} in ${h.name}'s test system. **This is a test booking** - it holds no room for anyone, nothing is paid, now or at any hotel, and nobody needs to go there.`;
1730
1751
  return b.status === 'requested'
1731
1752
  ? `${total} all in, to be paid at the hotel. ${h.name} has not confirmed this booking yet - that takes up to eight minutes, and ${deps.tools.listBookings} shows when it has. Nothing has been charged.`
1732
- : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${h.name}'s own reservation, kept until it is cancelled, and ${b.guest.name} pays at check-in.`;
1753
+ : `${total} all in, paid at the hotel. **Nothing has been charged here** - this is ${h.name}'s own reservation, kept until it is cancelled, and the guest pays at check-in.`;
1733
1754
  }
1734
1755
  const lapses = b.holdUntilLocal
1735
1756
  ? `${b.holdUntilLocal} hotel time`
@@ -1738,7 +1759,7 @@ const bookedLine = (deps, b) => {
1738
1759
  // booking has no check-in, and saying so sent a guest towards a real desk.
1739
1760
  if (h.demo)
1740
1761
  return `${total} all in. **${demoBookingWords(h)}** Nothing has been charged and no card is needed. The demonstration hold lapses on its own at ${lapses}.`;
1741
- return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and ${b.guest.name} presents a card at check-in.`;
1762
+ return `${total} all in. **Nothing has been charged** - the rooms are held until ${lapses}, and the guest presents a card at check-in.`;
1742
1763
  };
1743
1764
  /** What a demonstration booking is, in one sentence - see `demoBookingNote`. */
1744
1765
  const demoBookingWords = (h) => `This is a demonstration booking: ${h.name} has not received it and is not holding any room.`;
@@ -1941,6 +1962,8 @@ const readRequests = (args) => {
1941
1962
  dropped,
1942
1963
  };
1943
1964
  };
1965
+ /** Said of a booking that carries the guest's note - see `requestLines`. */
1966
+ export const NOTE_PASSED = 'A note from the guest was passed to the hotel.';
1944
1967
  /**
1945
1968
  * The sentences a booking's requests earn, in the order a guest reads them.
1946
1969
  *
@@ -1962,8 +1985,12 @@ const requestLines = (b, dropped, checkInFrom) => {
1962
1985
  : REQUEST_WORDS[k]);
1963
1986
  out.push(`Asked for on the booking: ${words.join(', ')}. The desk meets what it can on the day; none of it is held.`);
1964
1987
  }
1988
+ // ⚠️ THAT THERE IS A NOTE, NOT WHAT IT SAYS (2026-10-27 audit, F3): the
1989
+ // guest's own words are theirs, typed on the card's form - the card shows
1990
+ // them from `_meta` (server.ts, `guestsApart`), and the model is told only
1991
+ // that one went to the hotel.
1965
1992
  if (r.note)
1966
- out.push(`Passed to the hotel:\n${guestText('note for the hotel', r.note, REQUEST_NOTE_MAX)}`);
1993
+ out.push(NOTE_PASSED);
1967
1994
  if (r.kinds.includes('accessible-room')) {
1968
1995
  out.push(`An accessible room is a room type, and this booking does not hold one - ${b.hotelName} confirms accessible rooms directly, quoting ${b.confirmationCode}.`);
1969
1996
  }
@@ -2955,6 +2982,41 @@ const fitsManyOf = (deps, dropped) => dropped.flatMap((d) => d.candidates?.lengt
2955
2982
  const LONG_STAY_NIGHTS = 4;
2956
2983
  /** The other areas a long stay's answer names - one hotel each. */
2957
2984
  const OTHER_AREAS_MAX = 4;
2985
+ /**
2986
+ * Every plan's answer ends with this: the card is the plan, and a correction
2987
+ * found after drawing it is said, not drawn.
2988
+ *
2989
+ * ⚠️ ONE PLAN, ONE CARD - SAID WHERE THE MODEL READS IT LAST (the owner,
2990
+ * 2026-10-26, live 0.47.8): the plan put the Palace Museum on a Monday, the
2991
+ * model noticed after drawing it and drew the whole plan again - two plan
2992
+ * cards in one answer, "以第二张卡片为准". The instructions and the description
2993
+ * say to check closing days before drawing; this was said only in the answer
2994
+ * of a long stay at one hotel. It is in every plan's answer now.
2995
+ */
2996
+ export const PLAN_DRAWN_ONCE = 'The plan on the card is this one. Something to change that you notice now - a sight closed on its day, a day too far from its hotel - goes in your reply, not in another plan: the plan is drawn again only when the guest asks for the change.';
2997
+ /** An area's names as a day might say them: "Haidian", "海淀区" and "海淀". */
2998
+ const areaWords = (h) => {
2999
+ const out = new Set();
3000
+ for (const name of [h.area, ...Object.values(h.names?.area ?? {})]) {
3001
+ const n = name?.trim();
3002
+ if (!n)
3003
+ continue;
3004
+ out.add(n);
3005
+ const short = n.replace(/\s+district$/i, '').replace(/区$/, '');
3006
+ if (short.length >= 2)
3007
+ out.add(short);
3008
+ }
3009
+ return [...out];
3010
+ };
3011
+ /** Whether a day's own words - label, note, moments - name one of these. */
3012
+ const dayNames = (day, words) => {
3013
+ const said = [day.label, day.note, ...(day.plan ?? []).flatMap((m) => [m.what, m.note])]
3014
+ .filter(Boolean)
3015
+ .join(' ');
3016
+ return words.some((w) => /^[\x20-\x7e]+$/.test(w)
3017
+ ? new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(said)
3018
+ : said.includes(w));
3019
+ };
2958
3020
  /**
2959
3021
  * For each priced stay of four nights or more, the other areas of its city that
2960
3022
  * have a hotel - for the model, in the tool's text, never on the card or in the
@@ -2968,13 +3030,21 @@ const OTHER_AREAS_MAX = 4;
2968
3030
  * model can plan the stay in parts - or offer the move - without a second
2969
3031
  * look-up. Nothing where the city has no other area, or the backend cannot say:
2970
3032
  * a nudge that guesses is worse than none.
3033
+ *
3034
+ * ⚠️ AND A WHOLE TRIP AT ONE HOTEL SAYS WHEN THAT FITS (0.47.9, after live
3035
+ * 0.47.8: six nights in Dongcheng, "only one day is far, not worth a move").
3036
+ * One hotel throughout is the plan only when every day is within about 45
3037
+ * minutes of it; otherwise the reply names the split as the other way. A day
3038
+ * whose own words name another area's hotel is named here - also for a trip
3039
+ * shorter than four nights - so the model does not have to find it again.
2971
3040
  */
2972
- const longStayNudges = async (deps, stays) => {
3041
+ const longStayNudges = async (deps, stays, days) => {
2973
3042
  // The areas the plan already uses are not "other" for any of its stays.
2974
3043
  const used = new Set(stays.map((x) => x.area?.toLowerCase()).filter(Boolean));
2975
3044
  const out = [];
3045
+ const whole = stays.length === 1;
2976
3046
  for (const x of stays) {
2977
- if ((x.nights ?? 0) < LONG_STAY_NIGHTS || !x.area || !x.city)
3047
+ if (!x.area || !x.city || (x.nights ?? 0) < (whole ? 2 : LONG_STAY_NIGHTS))
2978
3048
  continue;
2979
3049
  let hotels;
2980
3050
  try {
@@ -2996,10 +3066,29 @@ const longStayNudges = async (deps, stays) => {
2996
3066
  }
2997
3067
  if (byArea.size === 0)
2998
3068
  continue;
3069
+ // Only for a whole trip at one hotel: which of its days name another area.
3070
+ const elsewhere = whole
3071
+ ? days.flatMap((day, i) => {
3072
+ const h = [...byArea.values()].find((o) => dayNames(day, areaWords(o)));
3073
+ return h
3074
+ ? [
3075
+ `Day ${i + 1}${day.date ? ` (${day.date})` : ''} names ${h.area}, where ${h.name} (\`${h.id}\`) is.`,
3076
+ ]
3077
+ : [];
3078
+ })
3079
+ : [];
3080
+ if ((x.nights ?? 0) < LONG_STAY_NIGHTS && elsewhere.length === 0)
3081
+ continue;
2999
3082
  const others = [...byArea.values()]
3000
3083
  .slice(0, OTHER_AREAS_MAX)
3001
3084
  .map((h) => `${h.area} - ${h.name} (\`${h.id}\`)`);
3002
- 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.`);
3085
+ out.push(whole
3086
+ ? [
3087
+ `All ${x.nights} nights are at ${x.name} (${x.area}). ${x.city} also has: ${others.join('; ')}.`,
3088
+ ...elsewhere,
3089
+ `One hotel for the whole trip fits only when every day is within about 45 minutes of it. In your reply, name the days spent farther out than that and add one sentence on the other way - "it could also be two stays: ..." - those nights at the hotel here nearest them, and why; or say in one sentence why every day is within reach of ${x.area}.`,
3090
+ ].join(' ')
3091
+ : `${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}.`);
3003
3092
  }
3004
3093
  return out;
3005
3094
  };
@@ -3029,6 +3118,58 @@ const direct = (a) => ({
3029
3118
  ...a,
3030
3119
  structured: { ...a.structured, direct: true },
3031
3120
  });
3121
+ /**
3122
+ * The booking form's room, when it is NOT the room picked now: the refusal to
3123
+ * book it. Undefined when the form books what it shows.
3124
+ *
3125
+ * ⚠️ THE PICK IS THE CONVERSATION'S, THE FORM IS ONE CARD'S (2026-10-27 audit,
3126
+ * F1). Two cards of one conversation share the slot (`pickedKey`): Reserve on
3127
+ * the second replaced the first's room, and the first card's form then booked
3128
+ * the second's room under its own picture of another. The form says which room
3129
+ * it shows; one that is not the slot's - or a slot emptied since, by a booking
3130
+ * or "Don't book" on the other card - books nothing, and `again` is the pick
3131
+ * that would put the form's room back.
3132
+ *
3133
+ * A form that names no room is a card from before 0.48.0, booked as before.
3134
+ */
3135
+ const pickChanged = (deps, key, args) => {
3136
+ const room = str(args.room);
3137
+ if (!room)
3138
+ return undefined;
3139
+ const l = deps.trips.read(pickedKey(key)).find((x) => x.kind === 'room');
3140
+ const said = (v) => v !== undefined && v !== null && v !== '';
3141
+ const same = (asked, held) => !said(asked) || String(asked) === String(held);
3142
+ if (l?.kind === 'room' &&
3143
+ str(args.hotel) === l.hotelId &&
3144
+ room === l.roomId &&
3145
+ same(args.rooms, l.rooms ?? 1) &&
3146
+ same(args.guests, l.stay.guests) &&
3147
+ (l.hourly
3148
+ ? same(args.startTime, l.hourly.start)
3149
+ : same(args.checkIn, l.stay.checkIn) && same(args.checkOut, l.stay.checkOut)))
3150
+ return undefined;
3151
+ const words = l
3152
+ ? 'Nothing was booked: the booking form was for another room than the one picked now - Reserve was pressed on another card of this conversation since. The guest presses Reserve on the room they want again.'
3153
+ : 'Nothing was booked: the room on the booking form is no longer picked - another card of this conversation booked a room or let it go since. The guest presses Reserve on the room they want again.';
3154
+ return {
3155
+ structured: {
3156
+ view: 'error',
3157
+ ...common(deps, key),
3158
+ error: 'pick-changed',
3159
+ detail: words,
3160
+ again: {
3161
+ hotel: str(args.hotel),
3162
+ room,
3163
+ rooms: said(args.rooms) ? Number(args.rooms) : 1,
3164
+ ...(said(args.guests) ? { guests: Number(args.guests) } : {}),
3165
+ ...(said(args.startTime)
3166
+ ? { startTime: str(args.startTime) }
3167
+ : { checkIn: str(args.checkIn), checkOut: str(args.checkOut) }),
3168
+ },
3169
+ },
3170
+ text: words,
3171
+ };
3172
+ };
3032
3173
  /**
3033
3174
  * A room line, validated and priced against the hotel's calendar and put into
3034
3175
  * one slot of the trip store - THE ONE ROOM-LINE CODE, for `add_to_trip` (`store`
@@ -3297,7 +3438,7 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3297
3438
  const { requests, dropped } = readRequests(args);
3298
3439
  try {
3299
3440
  const shown = deps.trips.shown(store);
3300
- const { booked: made, failed } = await deps.backend.requestBooking({ lines, ...(shown ? { shown } : {}) }, {
3441
+ const { booked: made, failed, repeat, } = await deps.backend.requestBooking({ lines, ...(shown ? { shown } : {}) }, {
3301
3442
  ...guest,
3302
3443
  ...(str(args.phone) ? { phone: str(args.phone) } : {}),
3303
3444
  ...(requests?.note ? { notes: requests.note } : {}),
@@ -3473,8 +3614,14 @@ const bookTripLines = async (deps, t, ctx, key, store, args, nothingThere) => {
3473
3614
  ? { accessibility: true }
3474
3615
  : {}),
3475
3616
  ...(notBooked.length ? { failed: notBooked } : {}),
3617
+ ...(repeat ? { repeat: true } : {}),
3476
3618
  },
3477
3619
  text: [
3620
+ // ⚠️ THE SAME BOOKING ASKED FOR AGAIN IS SAID TO BE THAT, never a new
3621
+ // one (2026-10-27 audit, F2): it was answered as if just booked.
3622
+ repeat
3623
+ ? `NOTHING NEW WAS BOOKED: the same ${booked.length > 1 ? 'stays' : 'stay'} for the same guest ${booked.length > 1 ? 'were' : 'was'} asked for a few minutes ago, and ${booked.length > 1 ? 'these are the bookings' : 'this is the booking'} made then. A booking for someone else is made under their name.`
3624
+ : '',
3478
3625
  // ⚠️ BOOKED IS CONFIRMED: a stay still being confirmed is said as
3479
3626
  // that, and not counted (总线程, 2026-10-14).
3480
3627
  notBooked.length || (booked.length > 1 && waiting)
@@ -4432,6 +4579,9 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
4432
4579
  // The card's own: the room the guest picked with Reserve, booked exactly as
4433
4580
  // a trip is - and the trip left as it is.
4434
4581
  if (name === t.bookPicked) {
4582
+ const changed = pickChanged(deps, key, args);
4583
+ if (changed)
4584
+ return direct(changed);
4435
4585
  return direct(await bookTripLines(deps, t, ctx, key, pickedKey(key), args, 'Nothing is picked: the guest presses Reserve on a room first.'));
4436
4586
  }
4437
4587
  if (name === t.listBookings) {
@@ -5006,11 +5156,12 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
5006
5156
  `Priced stays (${plan.stay?.guests ?? 2} guests; ${t.addToTrip} takes each one's hotel id, room id and dates):`,
5007
5157
  ...stays,
5008
5158
  // ⚠️ IN THE TEXT ONLY - not in `structured`, not in the schema.
5009
- ...(await longStayNudges(deps, stayItems)),
5159
+ ...(await longStayNudges(deps, stayItems, plan.days)),
5010
5160
  ]
5011
5161
  : []),
5012
5162
  ...plan.dropped.map((d) => `Left out: ${d.name} — ${d.why}`),
5013
5163
  ...fitsManyOf(deps, plan.dropped),
5164
+ PLAN_DRAWN_ONCE,
5014
5165
  deps.vocab.demo ? noteFor(deps.vocab) : '',
5015
5166
  ]
5016
5167
  .filter(Boolean)