@curless/hotel-storefront 0.21.1 → 0.22.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: {
@@ -1976,6 +2024,112 @@ const answerTool = async (deps, name, rawArgs, ctx) => {
1976
2024
  .join('\n'),
1977
2025
  };
1978
2026
  }
2027
+ if (name === t.lookUp) {
2028
+ const stay = asStay(args);
2029
+ const who = identity(deps, ctx, key);
2030
+ const roomsWanted = Math.max(1, Math.min(MAX_ROOMS_PER_LINE, Math.floor(Number(args.rooms) || 1)));
2031
+ const asked = args[deps.vocab.nounPlural];
2032
+ const named = (Array.isArray(asked) ? asked : [])
2033
+ .map((x) => str(x))
2034
+ .filter(Boolean)
2035
+ .slice(0, LOOK_UP_MAX);
2036
+ const city = str(args.city);
2037
+ const ids = [];
2038
+ const dropped = [];
2039
+ let total = 0;
2040
+ if (named.length) {
2041
+ for (const ref of named) {
2042
+ const found = await deps.backend.getHotel(ref);
2043
+ if (found)
2044
+ ids.push(found.hotel.id);
2045
+ else
2046
+ dropped.push(ref);
2047
+ }
2048
+ total = ids.length;
2049
+ }
2050
+ else if (city) {
2051
+ const found = await deps.backend.searchHotels({ city, limit: LOOK_UP_MAX }, who);
2052
+ ids.push(...found.hotels.map((h) => h.id));
2053
+ total = found.total;
2054
+ }
2055
+ const unique = [...new Set(ids)];
2056
+ if (unique.length === 0) {
2057
+ return {
2058
+ structured: {
2059
+ view: 'nothing-found',
2060
+ ...common(deps, key),
2061
+ ...(dropped.length ? { dropped } : {}),
2062
+ },
2063
+ text: city
2064
+ ? `No ${deps.vocab.brand} ${deps.vocab.nounPlural} in "${city}". ${t.listHotels} with no place shows the places there are.`
2065
+ : named.length
2066
+ ? `No ${deps.vocab.brand} ${deps.vocab.noun} answers to ${dropped.map((d) => `"${d}"`).join(', ')}.`
2067
+ : `Name a \`city\`, or up to ${LOOK_UP_MAX} ${deps.vocab.nounPlural} in \`${deps.vocab.nounPlural}\`.`,
2068
+ };
2069
+ }
2070
+ const blocks = [];
2071
+ const read = [];
2072
+ for (const id of unique) {
2073
+ const detail = await deps.backend.getHotel(id, stay, who);
2074
+ if (!detail)
2075
+ continue;
2076
+ const h = detail.hotel;
2077
+ read.push({ id: h.id, name: h.name });
2078
+ const about = hotelText('description', h.name, [h.summary, h.amenities.length ? `Has: ${h.amenities.join(', ')}.` : '']
2079
+ .filter(Boolean)
2080
+ .join('\n'), DESCRIPTION_MAX, deps.vocab.demo);
2081
+ const extras = await deps.backend.extrasFor(h.id, stay);
2082
+ const rooms = (detail.rooms ?? []).filter((r) => r.roomsLeft >= roomsWanted);
2083
+ blocks.push([
2084
+ `### ${h.name} · id \`${h.id}\``,
2085
+ [
2086
+ [h.area, h.city, h.country].filter(Boolean).join(', '),
2087
+ h.brand,
2088
+ h.rating ? `${h.rating.value}/${h.rating.scaleMax} from ${h.rating.count} reviews` : '',
2089
+ ]
2090
+ .filter(Boolean)
2091
+ .join(' · '),
2092
+ about.fenced,
2093
+ !stay
2094
+ ? h.fromNightly
2095
+ ? `From ${formatMoney(h.fromNightly)} a night; rooms are priced once there are dates.`
2096
+ : ''
2097
+ : rooms.length
2098
+ ? [
2099
+ `Rooms for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests${roomsWanted > 1 ? `, ${roomsWanted} rooms` : ''}:`,
2100
+ ...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'}`),
2101
+ ].join('\n')
2102
+ : (detail.noRooms ?? `No room type here has ${roomsWanted} left for those nights.`),
2103
+ extras.length
2104
+ ? `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('; ')}`
2105
+ : '',
2106
+ ]
2107
+ .filter(Boolean)
2108
+ .join('\n'));
2109
+ }
2110
+ return {
2111
+ structured: {
2112
+ view: 'look-up',
2113
+ ...common(deps, key),
2114
+ hotels: read,
2115
+ ...(stay ? { stay } : {}),
2116
+ total,
2117
+ ...(dropped.length ? { dropped } : {}),
2118
+ },
2119
+ text: [
2120
+ `Read ${read.length}${total > read.length ? ` of ${total}` : ''} ${read.length === 1 ? deps.vocab.noun : deps.vocab.nounPlural}. Nothing was shown to the guest.`,
2121
+ ...blocks,
2122
+ dropped.length ? `Not found: ${dropped.map((d) => `"${d}"`).join(', ')}.` : '',
2123
+ total > read.length
2124
+ ? `${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'}.`
2125
+ : '',
2126
+ `If the answer is a comparison, a recommendation or a plan, draw it now as ONE ${t.compare} or ${t.itinerary} call with these ids - not with ${t.listHotels}, ${t.getHotel} or ${t.listExtras}, which each draw another card.`,
2127
+ deps.vocab.demo ? DEMO_NOTE : '',
2128
+ ]
2129
+ .filter(Boolean)
2130
+ .join('\n\n'),
2131
+ };
2132
+ }
1979
2133
  if (name === t.listExtras) {
1980
2134
  // ⚠️ AN ADD-ON FOR A BOOKING ALREADY MADE (robin, 2026-09-28). Add-ons
1981
2135
  // could only go in the trip before the request; once booked, an airport