@curless/hotel-storefront 0.21.1 → 0.23.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
@@ -61,6 +61,8 @@ const cancels = (title, openWorld) => ({
61
61
  openWorldHint: openWorld,
62
62
  });
63
63
  const DATE = (what) => ({ type: 'string', description: `${what}, as YYYY-MM-DD` });
64
+ /** How many ${nounPlural} one look-up reads - a city's worth, and what a comparison can hold and then some. */
65
+ export const LOOK_UP_MAX = 6;
64
66
  export const buildTools = (n, v) => {
65
67
  const open = !v.demo;
66
68
  const stayProps = {
@@ -132,7 +134,7 @@ export const buildTools = (n, v) => {
132
134
  },
133
135
  {
134
136
  name: n.getHotel,
135
- 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}` : ''}`,
137
+ 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. The ${v.noun} is drawn as a card for the guest to look at.${v.hints.hotel ? ` ${v.hints.hotel}` : ''}`,
136
138
  annotations: reads(`View one ${v.brand} ${v.noun}`, open),
137
139
  inputSchema: {
138
140
  type: 'object',
@@ -150,9 +152,39 @@ export const buildTools = (n, v) => {
150
152
  required: ['hotel'],
151
153
  },
152
154
  },
155
+ {
156
+ name: n.lookUp,
157
+ // ⚠️ THE ONE READING TOOL THAT DRAWS NOTHING. Every other one renders a
158
+ // card on every call, so a model reading up before it answered - list
159
+ // the city, open four hotels, then compare - put six cards in front of
160
+ // the guest for one question (robin, 2026-09-29, Opus in Claude.ai). The
161
+ // reference apps do the same split: search without a card, present with
162
+ // one.
163
+ 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. The ids are the ones a comparison and a plan take.`,
164
+ annotations: reads(`Look up ${v.brand} ${v.nounPlural}`, open),
165
+ inputSchema: {
166
+ type: 'object',
167
+ properties: {
168
+ city: {
169
+ type: 'string',
170
+ description: `A city or a country: its ${v.nounPlural}, up to ${LOOK_UP_MAX}`,
171
+ },
172
+ [v.nounPlural]: {
173
+ type: 'array',
174
+ items: { type: 'string' },
175
+ description: `Up to ${LOOK_UP_MAX} ${v.nounPlural} by name or id; these are read when both are given`,
176
+ },
177
+ ...stayProps,
178
+ rooms: {
179
+ type: 'number',
180
+ description: `How many rooms of one type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1`,
181
+ },
182
+ },
183
+ },
184
+ },
153
185
  {
154
186
  name: n.listExtras,
155
- description: `What one ${v.noun} offers beside the room: airport transfers, dinners, guided experiences, spa and family add-ons, each with its price and whether it is charged per stay, per person or per night.`,
187
+ description: `What one ${v.noun} offers beside the room: airport transfers, dinners, guided experiences, spa and family add-ons, each with its price and whether it is charged per stay, per person or per night, drawn as a card for the guest to look at.`,
156
188
  annotations: reads(`${v.brand} add-ons`, open),
157
189
  inputSchema: {
158
190
  type: 'object',
@@ -771,6 +803,22 @@ export const buildOutputSchemas = (n) => ({
771
803
  },
772
804
  required: ['view'],
773
805
  },
806
+ // What was read, by id - the substance is the text, which is what the model
807
+ // plans from, and a second copy of it here would be read twice.
808
+ [n.lookUp]: {
809
+ type: 'object',
810
+ properties: {
811
+ view: { enum: ['look-up', 'nothing-found'] },
812
+ brand: STR,
813
+ toolNames: OBJ,
814
+ demo: BOOL,
815
+ hotels: ARR,
816
+ stay: OBJ,
817
+ total: { type: 'number' },
818
+ dropped: ARR,
819
+ },
820
+ required: ['view'],
821
+ },
774
822
  [n.policies]: {
775
823
  type: 'object',
776
824
  properties: {
@@ -1445,11 +1493,40 @@ const suggestionsFor = async (deps, d) => {
1445
1493
  });
1446
1494
  }
1447
1495
  }
1448
- if (view === 'itinerary' && Number(d.planned) > Number(d.inTrip) + Number(d.booked ?? 0))
1449
- out.push({
1450
- label: 'Put the stays on this plan in the trip',
1451
- say: 'Put the stays on this plan in the trip',
1452
- });
1496
+ // ⚠️ THE CARD PUTS THE STAYS IN ITSELF (robin, 2026-09-29: 「改成卡片直接加进行程
1497
+ // 吧」). This chip used to send "Put the stays on this plan in the trip" to the
1498
+ // chat, and the host held it under a caution banner - it cannot tell a
1499
+ // card's message from an injected one - for the guest to send on. Now it is
1500
+ // the card's own calls: each stay not yet in the trip or booked, on the room
1501
+ // the plan priced it on, for the plan's own nights. The trip is editable and
1502
+ // nothing is booked by it.
1503
+ if (view === 'itinerary') {
1504
+ const stays = (d.days ?? [])
1505
+ .flatMap((day) => day.items)
1506
+ .filter((x) => x.kind === 'hotel' &&
1507
+ (x.night ?? 1) === 1 &&
1508
+ x.roomId &&
1509
+ x.checkIn &&
1510
+ x.checkOut &&
1511
+ !x.inTrip &&
1512
+ !x.booked);
1513
+ if (stays.length)
1514
+ out.push({
1515
+ label: stays.length === 1
1516
+ ? `Put ${stays[0]?.name} in the trip`
1517
+ : `Put these ${stays.length} stays in the trip`,
1518
+ asks: stays.map((x) => ({
1519
+ tool: 'addToTrip',
1520
+ args: {
1521
+ hotel: x.id,
1522
+ room: x.roomId,
1523
+ checkIn: x.checkIn,
1524
+ checkOut: x.checkOut,
1525
+ guests: stay?.guests ?? 2,
1526
+ },
1527
+ })),
1528
+ });
1529
+ }
1453
1530
  if (view === 'booking') {
1454
1531
  const all = (Array.isArray(d.bookings) ? d.bookings : [d.booking]);
1455
1532
  for (const b of all.slice(0, 2)) {
@@ -1976,6 +2053,112 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1976
2053
  .join('\n'),
1977
2054
  };
1978
2055
  }
2056
+ if (name === t.lookUp) {
2057
+ const stay = asStay(args);
2058
+ const who = identity(deps, ctx, key);
2059
+ const roomsWanted = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(Number(args.rooms) || 1)));
2060
+ const asked = args[deps.vocab.nounPlural];
2061
+ const named = (Array.isArray(asked) ? asked : [])
2062
+ .map((x) => str(x))
2063
+ .filter(Boolean)
2064
+ .slice(0, LOOK_UP_MAX);
2065
+ const city = str(args.city);
2066
+ const ids = [];
2067
+ const dropped = [];
2068
+ let total = 0;
2069
+ if (named.length) {
2070
+ for (const ref of named) {
2071
+ const found = await deps.backend.getHotel(ref);
2072
+ if (found)
2073
+ ids.push(found.hotel.id);
2074
+ else
2075
+ dropped.push(ref);
2076
+ }
2077
+ total = ids.length;
2078
+ }
2079
+ else if (city) {
2080
+ const found = await deps.backend.searchHotels({ city, limit: LOOK_UP_MAX }, who);
2081
+ ids.push(...found.hotels.map((h) => h.id));
2082
+ total = found.total;
2083
+ }
2084
+ const unique = [...new Set(ids)];
2085
+ if (unique.length === 0) {
2086
+ return {
2087
+ structured: {
2088
+ view: 'nothing-found',
2089
+ ...common(deps, key),
2090
+ ...(dropped.length ? { dropped } : {}),
2091
+ },
2092
+ text: city
2093
+ ? `No ${deps.vocab.brand} ${deps.vocab.nounPlural} in "${city}". ${t.listHotels} with no place shows the places there are.`
2094
+ : named.length
2095
+ ? `No ${deps.vocab.brand} ${deps.vocab.noun} answers to ${dropped.map((d) => `"${d}"`).join(', ')}.`
2096
+ : `Name a \`city\`, or up to ${LOOK_UP_MAX} ${deps.vocab.nounPlural} in \`${deps.vocab.nounPlural}\`.`,
2097
+ };
2098
+ }
2099
+ const blocks = [];
2100
+ const read = [];
2101
+ for (const id of unique) {
2102
+ const detail = await deps.backend.getHotel(id, stay, who);
2103
+ if (!detail)
2104
+ continue;
2105
+ const h = detail.hotel;
2106
+ read.push({ id: h.id, name: h.name });
2107
+ const about = hotelText('description', h.name, [h.summary, h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
2108
+ .filter(Boolean)
2109
+ .join('\n'), DESCRIPTION_MAX, deps.vocab.demo);
2110
+ const extras = await deps.backend.extrasFor(h.id, stay);
2111
+ const rooms = (detail.rooms ?? []).filter((r) => r.roomsLeft >= roomsWanted);
2112
+ blocks.push([
2113
+ `### ${h.name} · id \`${h.id}\``,
2114
+ [
2115
+ [h.area, h.city, h.country].filter(Boolean).join(', '),
2116
+ h.brand,
2117
+ h.rating ? `${h.rating.value}/${h.rating.scaleMax} from ${h.rating.count} reviews` : '',
2118
+ ]
2119
+ .filter(Boolean)
2120
+ .join(' · '),
2121
+ about.fenced,
2122
+ !stay
2123
+ ? h.fromNightly
2124
+ ? `From ${formatMoney(h.fromNightly)} a night; rooms are priced once there are dates.`
2125
+ : ''
2126
+ : rooms.length
2127
+ ? [
2128
+ `Rooms for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}:`,
2129
+ ...rooms.map((r) => `- ${r.name} · sleeps ${r.sleeps}${r.breakfast ? ' · breakfast' : ''} · ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${r.freeCancellationUntil ? `, free to cancel until ${r.freeCancellationUntil.slice(0, 10)}` : r.refundable ? '' : ', non-refundable'}`),
2130
+ ].join('\n')
2131
+ : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
2132
+ extras.length
2133
+ ? `Add-ons: ${extras.map((e) => `${e.name} (id \`${e.id}\`, ${formatMoney(e.price)} per ${e.per}${e.when && e.when !== 'any' ? `, on ${e.when}` : ''})`).join('; ')}`
2134
+ : '',
2135
+ ]
2136
+ .filter(Boolean)
2137
+ .join('\n'));
2138
+ }
2139
+ return {
2140
+ structured: {
2141
+ view: 'look-up',
2142
+ ...common(deps, key),
2143
+ hotels: read,
2144
+ ...(stay ? { stay } : {}),
2145
+ total,
2146
+ ...(dropped.length ? { dropped } : {}),
2147
+ },
2148
+ text: [
2149
+ `Read ${read.length}${total > read.length ? ` of ${total}` : ''} ${read.length === 1 ? deps.vocab.noun : deps.vocab.nounPlural}. Nothing was shown to the guest.`,
2150
+ ...blocks,
2151
+ dropped.length ? `Not found: ${dropped.map((d) => `"${d}"`).join(', ')}.` : '',
2152
+ total > read.length
2153
+ ? `${total - read.length} more ${total - read.length === 1 ? 'is' : 'are'} there; name ${total - read.length === 1 ? 'it' : 'them'} in \`${deps.vocab.nounPlural}\` to read ${total - read.length === 1 ? 'it' : 'them'}.`
2154
+ : '',
2155
+ `Draw the answer now as ONE card with these ids: a plan or days the guest asked for (行程 - recommended or arranged) is ${t.itinerary}; which ${deps.vocab.noun} to stay at is ${t.compare}. Not ${t.listHotels}, ${t.getHotel} or ${t.listExtras}, which each draw another card.`,
2156
+ deps.vocab.demo ? DEMO_NOTE : '',
2157
+ ]
2158
+ .filter(Boolean)
2159
+ .join('\n\n'),
2160
+ };
2161
+ }
1979
2162
  if (name === t.listExtras) {
1980
2163
  // ⚠️ AN ADD-ON FOR A BOOKING ALREADY MADE (robin, 2026-09-28). Add-ons
1981
2164
  // could only go in the trip before the request; once booked, an airport