@curless/hotel-storefront 0.1.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.
Files changed (98) hide show
  1. package/README.md +63 -0
  2. package/dist/__tests__/card-contract.test.d.ts +2 -0
  3. package/dist/__tests__/card-contract.test.d.ts.map +1 -0
  4. package/dist/__tests__/card-contract.test.js +64 -0
  5. package/dist/__tests__/card-contract.test.js.map +1 -0
  6. package/dist/__tests__/data.test.d.ts +2 -0
  7. package/dist/__tests__/data.test.d.ts.map +1 -0
  8. package/dist/__tests__/data.test.js +89 -0
  9. package/dist/__tests__/data.test.js.map +1 -0
  10. package/dist/__tests__/dates.test.d.ts +2 -0
  11. package/dist/__tests__/dates.test.d.ts.map +1 -0
  12. package/dist/__tests__/dates.test.js +71 -0
  13. package/dist/__tests__/dates.test.js.map +1 -0
  14. package/dist/__tests__/demo-hotels.test.d.ts +2 -0
  15. package/dist/__tests__/demo-hotels.test.d.ts.map +1 -0
  16. package/dist/__tests__/demo-hotels.test.js +240 -0
  17. package/dist/__tests__/demo-hotels.test.js.map +1 -0
  18. package/dist/__tests__/server.test.d.ts +2 -0
  19. package/dist/__tests__/server.test.d.ts.map +1 -0
  20. package/dist/__tests__/server.test.js +209 -0
  21. package/dist/__tests__/server.test.js.map +1 -0
  22. package/dist/__tests__/tool-descriptions.test.d.ts +2 -0
  23. package/dist/__tests__/tool-descriptions.test.d.ts.map +1 -0
  24. package/dist/__tests__/tool-descriptions.test.js +139 -0
  25. package/dist/__tests__/tool-descriptions.test.js.map +1 -0
  26. package/dist/__tests__/tools.test.d.ts +2 -0
  27. package/dist/__tests__/tools.test.d.ts.map +1 -0
  28. package/dist/__tests__/tools.test.js +226 -0
  29. package/dist/__tests__/tools.test.js.map +1 -0
  30. package/dist/backend.d.ts +43 -0
  31. package/dist/backend.d.ts.map +1 -0
  32. package/dist/backend.js +2 -0
  33. package/dist/backend.js.map +1 -0
  34. package/dist/card.generated.d.ts +4 -0
  35. package/dist/card.generated.d.ts.map +1 -0
  36. package/dist/card.generated.js +8 -0
  37. package/dist/card.generated.js.map +1 -0
  38. package/dist/config.d.ts +122 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/config.js +42 -0
  41. package/dist/config.js.map +1 -0
  42. package/dist/data.d.ts +5 -0
  43. package/dist/data.d.ts.map +1 -0
  44. package/dist/data.js +183 -0
  45. package/dist/data.js.map +1 -0
  46. package/dist/dates.d.ts +32 -0
  47. package/dist/dates.d.ts.map +1 -0
  48. package/dist/dates.js +75 -0
  49. package/dist/dates.js.map +1 -0
  50. package/dist/demo-hotels.d.ts +96 -0
  51. package/dist/demo-hotels.d.ts.map +1 -0
  52. package/dist/demo-hotels.js +446 -0
  53. package/dist/demo-hotels.js.map +1 -0
  54. package/dist/fence.d.ts +24 -0
  55. package/dist/fence.d.ts.map +1 -0
  56. package/dist/fence.js +0 -0
  57. package/dist/fence.js.map +1 -0
  58. package/dist/index.d.ts +3 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +23 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/legal.d.ts +5 -0
  63. package/dist/legal.d.ts.map +1 -0
  64. package/dist/legal.js +111 -0
  65. package/dist/legal.js.map +1 -0
  66. package/dist/money.d.ts +13 -0
  67. package/dist/money.d.ts.map +1 -0
  68. package/dist/money.js +46 -0
  69. package/dist/money.js.map +1 -0
  70. package/dist/server.d.ts +85 -0
  71. package/dist/server.d.ts.map +1 -0
  72. package/dist/server.js +275 -0
  73. package/dist/server.js.map +1 -0
  74. package/dist/surface.d.ts +52 -0
  75. package/dist/surface.d.ts.map +1 -0
  76. package/dist/surface.js +99 -0
  77. package/dist/surface.js.map +1 -0
  78. package/dist/tools.d.ts +336 -0
  79. package/dist/tools.d.ts.map +1 -0
  80. package/dist/tools.js +862 -0
  81. package/dist/tools.js.map +1 -0
  82. package/dist/trip-ids.d.ts +16 -0
  83. package/dist/trip-ids.d.ts.map +1 -0
  84. package/dist/trip-ids.js +77 -0
  85. package/dist/trip-ids.js.map +1 -0
  86. package/dist/trip.d.ts +59 -0
  87. package/dist/trip.d.ts.map +1 -0
  88. package/dist/trip.js +149 -0
  89. package/dist/trip.js.map +1 -0
  90. package/dist/types.d.ts +193 -0
  91. package/dist/types.d.ts.map +1 -0
  92. package/dist/types.js +13 -0
  93. package/dist/types.js.map +1 -0
  94. package/dist/vocab.d.ts +33 -0
  95. package/dist/vocab.d.ts.map +1 -0
  96. package/dist/vocab.js +15 -0
  97. package/dist/vocab.js.map +1 -0
  98. package/package.json +44 -0
package/dist/tools.js ADDED
@@ -0,0 +1,862 @@
1
+ import { isDay } from './dates.js';
2
+ import { DESCRIPTION_MAX, POLICY_MAX, hotelText } from './fence.js';
3
+ import { formatMoney } from './money.js';
4
+ import { MAX_EXTRA_QUANTITY, MAX_ROOMS_PER_LINE, clampCount, tripKey, } from './trip.js';
5
+ import { DEMO_NOTE } from './vocab.js';
6
+ // The eleven things a guest can do: find hotels, look at one on dates, see what
7
+ // else it offers, build a trip, ask for the rooms, and deal with a booking they
8
+ // already have.
9
+ //
10
+ // ⚠️ TOOL DESCRIPTIONS SAY WHAT A TOOL DOES AND NOTHING ELSE. No "always call
11
+ // X first", no "do not use web search", no rules about the model's behaviour.
12
+ // That is not style: the directory listings both attest that "tool descriptions
13
+ // contain no instructions about model behavior, other tools, or external
14
+ // instruction sources", and the shopping connector had to move nine
15
+ // descriptions' worth of guidance into the server instructions on 2026-09-17 to
16
+ // make that attestation true. This package starts there. Behaviour lives in
17
+ // surface.ts, and `tool-descriptions.test.ts` holds the line.
18
+ /**
19
+ * Annotations, every hint stated, and each one true of the code.
20
+ *
21
+ * OpenAI's review rejected the shopping app's v1.0.0 over exactly this - an
22
+ * absent `idempotentHint` reads as null, and `openWorldHint: false` was a claim
23
+ * the code could not support. So: reads are reads, the trip tools write OUR
24
+ * state and say so, and `openWorldHint` follows where the rooms actually come
25
+ * from - false while this serves a local demonstration file, true once a
26
+ * deployment points at the group's own booking system, which answers with
27
+ * whatever it says at that moment.
28
+ */
29
+ const reads = (title, openWorld) => ({
30
+ title,
31
+ readOnlyHint: true,
32
+ destructiveHint: false,
33
+ idempotentHint: true,
34
+ openWorldHint: openWorld,
35
+ });
36
+ /** Changes the trip we hold for this conversation. Nothing at the hotel moves. */
37
+ const edits = (title, openWorld) => ({
38
+ title,
39
+ readOnlyHint: false,
40
+ destructiveHint: false,
41
+ idempotentHint: true,
42
+ openWorldHint: openWorld,
43
+ });
44
+ /** Asks the hotel to hold rooms. Takes no money, and undoing it is what the cancel tools are for. */
45
+ const books = (title, openWorld) => ({
46
+ title,
47
+ readOnlyHint: false,
48
+ destructiveHint: false,
49
+ idempotentHint: false,
50
+ openWorldHint: openWorld,
51
+ });
52
+ const cancels = (title, openWorld) => ({
53
+ title,
54
+ readOnlyHint: false,
55
+ destructiveHint: true,
56
+ idempotentHint: true,
57
+ openWorldHint: openWorld,
58
+ });
59
+ const DATE = (what) => ({ type: 'string', description: `${what}, as YYYY-MM-DD` });
60
+ export const buildTools = (n, v) => {
61
+ const open = !v.demo;
62
+ const stayProps = {
63
+ checkIn: DATE('The arrival date'),
64
+ checkOut: DATE('The departure date - the night before it is the last night'),
65
+ guests: { type: 'number', description: 'People in the room. Default 2' },
66
+ };
67
+ return [
68
+ {
69
+ name: n.listHotels,
70
+ description: `${v.brand} ${v.nounPlural}, shown as a card with a picture, the city and area, a rating where the ${v.noun} publishes one, and a from-price for each. \`city\` or \`query\` narrows the list; a city usually holds several ${v.nounPlural}, and all of them are returned. Dates are not needed here and no stay is priced.${v.hints.search ? ` ${v.hints.search}` : ''}`,
71
+ annotations: reads(`Browse ${v.brand} ${v.nounPlural}`, open),
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ query: {
76
+ type: 'string',
77
+ description: "Free text over the name, city, area, description and amenities - the guest's own words",
78
+ },
79
+ city: { type: 'string', description: 'One city, e.g. "Hong Kong"' },
80
+ limit: { type: 'number', description: 'Default 20' },
81
+ },
82
+ },
83
+ },
84
+ {
85
+ name: n.getHotel,
86
+ 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. 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}` : ''}`,
87
+ annotations: reads(`View one ${v.brand} ${v.noun}`, open),
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {
91
+ hotel: {
92
+ type: 'string',
93
+ description: `The ${v.noun}'s name or id${v.hints.example ? `, e.g. "${v.hints.example}"` : ''}`,
94
+ },
95
+ ...stayProps,
96
+ },
97
+ required: ['hotel'],
98
+ },
99
+ },
100
+ {
101
+ name: n.listExtras,
102
+ 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.`,
103
+ annotations: reads(`${v.brand} add-ons`, open),
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
108
+ ...stayProps,
109
+ },
110
+ required: ['hotel'],
111
+ },
112
+ },
113
+ {
114
+ name: n.addToTrip,
115
+ description: `Puts room nights or an add-on into the trip and returns the whole trip, priced. A room line needs the ${v.noun}, the room type and the dates; \`rooms\` books more than one of that room type. An add-on line needs the ${v.noun} and the add-on. A line is replaced, not stacked: sending the same room and dates again sets that line's room count, and 0 removes it.`,
116
+ annotations: edits(`Add to the ${v.brand} trip`, open),
117
+ inputSchema: {
118
+ type: 'object',
119
+ properties: {
120
+ hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
121
+ room: {
122
+ type: 'string',
123
+ description: 'The room type id or name, as the hotel returned it',
124
+ },
125
+ extra: {
126
+ type: 'string',
127
+ description: 'The add-on id or name. A line names either this or `room`',
128
+ },
129
+ ...stayProps,
130
+ rooms: {
131
+ type: 'number',
132
+ description: `How many of that room type, 1 to ${MAX_ROOMS_PER_LINE}. Default 1; 0 removes the line`,
133
+ },
134
+ quantity: {
135
+ type: 'number',
136
+ description: `For an add-on: how many, 1 to ${MAX_EXTRA_QUANTITY}. Default 1; 0 removes it`,
137
+ },
138
+ },
139
+ required: ['hotel'],
140
+ },
141
+ },
142
+ {
143
+ name: n.viewTrip,
144
+ description: 'The trip as it stands: every room line and add-on with its own total, the taxes and fees, and the all-in total, laid out day by day on the card.',
145
+ annotations: reads(`View the ${v.brand} trip`, open),
146
+ inputSchema: { type: 'object', properties: {} },
147
+ },
148
+ {
149
+ name: n.clearTrip,
150
+ description: 'Empties the trip.',
151
+ annotations: edits(`Clear the ${v.brand} trip`, open),
152
+ inputSchema: { type: 'object', properties: {} },
153
+ },
154
+ {
155
+ name: n.requestBooking,
156
+ description: `Asks ${v.brand} to hold the trip's rooms for a named guest and returns a confirmation code and the moment the hold lapses. No payment is taken and no card is collected; the rooms are held, not paid for.`,
157
+ annotations: books(`Request a ${v.brand} booking`, open),
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ name: {
162
+ type: 'string',
163
+ description: "The guest's full name, as it should appear on the booking",
164
+ },
165
+ email: { type: 'string', description: 'The email the confirmation goes to' },
166
+ phone: { type: 'string', description: 'Optional' },
167
+ notes: {
168
+ type: 'string',
169
+ description: 'Anything the hotel should know - a late arrival, a high floor',
170
+ },
171
+ },
172
+ required: ['name', 'email'],
173
+ },
174
+ },
175
+ {
176
+ name: n.listBookings,
177
+ description: 'The bookings made in this conversation, newest to oldest, with their status, dates and confirmation codes. Given an email it also finds bookings made with that email.',
178
+ annotations: reads(`My ${v.brand} bookings`, open),
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ email: { type: 'string', description: 'The email a booking was made with' },
183
+ limit: { type: 'number', description: 'Default 10' },
184
+ },
185
+ },
186
+ },
187
+ {
188
+ name: n.previewCancellation,
189
+ description: 'What cancelling a booking would cost and what would be refunded, with the rule it was worked out from and the free-cancellation deadline where the rate has one. Nothing is cancelled.',
190
+ annotations: reads('Preview a cancellation', open),
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ booking: { type: 'string', description: 'The confirmation code or booking id' },
195
+ email: {
196
+ type: 'string',
197
+ description: 'The email the booking was made with, when it was made elsewhere',
198
+ },
199
+ },
200
+ required: ['booking'],
201
+ },
202
+ },
203
+ {
204
+ name: n.cancelBooking,
205
+ // No other tool is named here, and that is not a stylistic choice: the
206
+ // directory attestation covers descriptions that instruct about other
207
+ // tools. The preview it returns is the same payload the preview tool
208
+ // returns, which is a fact about THIS tool's answer.
209
+ description: 'Cancels a booking. Called without `confirmed` it returns the cancellation preview - the fee, the refund and the rule behind them - and cancels nothing; it cancels only when `confirmed` is true.',
210
+ annotations: cancels('Cancel a booking', open),
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ booking: { type: 'string', description: 'The confirmation code or booking id' },
215
+ confirmed: {
216
+ type: 'boolean',
217
+ description: 'True cancels. Anything else returns the preview',
218
+ },
219
+ reason: { type: 'string', description: 'Optional, kept on the booking' },
220
+ email: {
221
+ type: 'string',
222
+ description: 'The email the booking was made with, when it was made elsewhere',
223
+ },
224
+ },
225
+ required: ['booking'],
226
+ },
227
+ },
228
+ {
229
+ name: n.policies,
230
+ description: `What one ${v.noun} publishes about cancellation, check-in and check-out, children and extra beds, pets, deposits and accessibility. With \`topic\` it returns that topic; without, all of them.`,
231
+ annotations: reads(`${v.brand} policies`, open),
232
+ inputSchema: {
233
+ type: 'object',
234
+ properties: {
235
+ hotel: { type: 'string', description: `The ${v.noun}'s name or id` },
236
+ topic: {
237
+ type: 'string',
238
+ enum: ['cancellation', 'check-in', 'children', 'pets', 'deposit', 'accessibility'],
239
+ },
240
+ },
241
+ required: ['hotel'],
242
+ },
243
+ },
244
+ ];
245
+ };
246
+ // The shape of each tool's `structuredContent`, so a reader knows what it is
247
+ // holding without inferring it from one example.
248
+ //
249
+ // `view` is the discriminator and is always present - that is the part a reader
250
+ // must be able to rely on, so it is the part declared exactly. Item internals
251
+ // are left open on purpose: a schema that promises fields the payload sometimes
252
+ // omits is a claim that is sometimes false, and the reader trusting it is the
253
+ // model deciding what to tell a guest.
254
+ const OBJ = { type: 'object' };
255
+ const ARR = { type: 'array', items: { type: 'object' } };
256
+ const STR = { type: 'string' };
257
+ const BOOL = { type: 'boolean' };
258
+ const TRIP_SCHEMA = {
259
+ type: 'object',
260
+ properties: {
261
+ view: { enum: ['trip', 'needs-dates', 'error'] },
262
+ brand: STR,
263
+ toolNames: OBJ,
264
+ vocab: OBJ,
265
+ demo: BOOL,
266
+ demoNote: STR,
267
+ lines: ARR,
268
+ stay: OBJ,
269
+ subtotal: STR,
270
+ taxesAndFees: STR,
271
+ total: STR,
272
+ days: ARR,
273
+ dropped: ARR,
274
+ },
275
+ required: ['view'],
276
+ };
277
+ const CANCEL_SCHEMA = {
278
+ type: 'object',
279
+ properties: {
280
+ view: { enum: ['cancellation-preview', 'cancelled', 'booking-not-found'] },
281
+ brand: STR,
282
+ toolNames: OBJ,
283
+ demo: BOOL,
284
+ demoNote: STR,
285
+ booking: OBJ,
286
+ fee: STR,
287
+ refund: STR,
288
+ rule: STR,
289
+ freeUntil: STR,
290
+ cancellable: BOOL,
291
+ },
292
+ required: ['view'],
293
+ };
294
+ export const buildOutputSchemas = (n) => ({
295
+ [n.listHotels]: {
296
+ type: 'object',
297
+ properties: {
298
+ view: { enum: ['hotels', 'no-hotels'] },
299
+ brand: STR,
300
+ toolNames: OBJ,
301
+ vocab: OBJ,
302
+ demo: BOOL,
303
+ demoNote: STR,
304
+ hotels: ARR,
305
+ total: { type: 'number' },
306
+ cities: ARR,
307
+ query: STR,
308
+ },
309
+ required: ['view'],
310
+ },
311
+ [n.getHotel]: {
312
+ type: 'object',
313
+ properties: {
314
+ view: { enum: ['hotel', 'no-rooms', 'hotel-not-found'] },
315
+ brand: STR,
316
+ toolNames: OBJ,
317
+ vocab: OBJ,
318
+ demo: BOOL,
319
+ demoNote: STR,
320
+ hotel: OBJ,
321
+ stay: OBJ,
322
+ rooms: ARR,
323
+ /** True when no dates were given: the card asks for them instead of showing rooms. */
324
+ needsDates: BOOL,
325
+ noRooms: STR,
326
+ extras: ARR,
327
+ policies: ARR,
328
+ },
329
+ required: ['view'],
330
+ },
331
+ [n.listExtras]: {
332
+ type: 'object',
333
+ properties: {
334
+ view: { enum: ['extras', 'hotel-not-found'] },
335
+ brand: STR,
336
+ toolNames: OBJ,
337
+ demo: BOOL,
338
+ demoNote: STR,
339
+ hotel: OBJ,
340
+ extras: ARR,
341
+ },
342
+ required: ['view'],
343
+ },
344
+ [n.addToTrip]: TRIP_SCHEMA,
345
+ [n.viewTrip]: TRIP_SCHEMA,
346
+ [n.clearTrip]: TRIP_SCHEMA,
347
+ [n.requestBooking]: {
348
+ type: 'object',
349
+ properties: {
350
+ view: { enum: ['booking', 'empty-trip', 'error'] },
351
+ brand: STR,
352
+ toolNames: OBJ,
353
+ demo: BOOL,
354
+ demoNote: STR,
355
+ booking: OBJ,
356
+ /** Always false here: this connector holds rooms and takes no money. */
357
+ paid: BOOL,
358
+ holdUntil: STR,
359
+ total: STR,
360
+ },
361
+ required: ['view'],
362
+ },
363
+ [n.listBookings]: {
364
+ type: 'object',
365
+ properties: {
366
+ view: { const: 'bookings' },
367
+ brand: STR,
368
+ toolNames: OBJ,
369
+ demo: BOOL,
370
+ demoNote: STR,
371
+ bookings: ARR,
372
+ count: { type: 'number' },
373
+ },
374
+ required: ['view'],
375
+ },
376
+ [n.previewCancellation]: CANCEL_SCHEMA,
377
+ [n.cancelBooking]: CANCEL_SCHEMA,
378
+ [n.policies]: {
379
+ type: 'object',
380
+ properties: {
381
+ view: { enum: ['policy', 'policies', 'policy-missing', 'hotel-not-found'] },
382
+ brand: STR,
383
+ toolNames: OBJ,
384
+ demo: BOOL,
385
+ hotel: OBJ,
386
+ topic: STR,
387
+ title: STR,
388
+ text: STR,
389
+ topics: ARR,
390
+ url: STR,
391
+ },
392
+ required: ['view'],
393
+ },
394
+ });
395
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
396
+ const asStay = (args) => {
397
+ const checkIn = str(args.checkIn);
398
+ const checkOut = str(args.checkOut);
399
+ if (!checkIn && !checkOut)
400
+ return undefined;
401
+ const guests = typeof args.guests === 'number' ? Math.floor(args.guests) : 2;
402
+ return { checkIn, checkOut, guests };
403
+ };
404
+ /** What every payload carries, so the card can draw a header and say whose data this is. */
405
+ const common = (deps) => ({
406
+ brand: deps.vocab.brand,
407
+ ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
408
+ toolNames: deps.tools,
409
+ vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
410
+ ...(deps.vocab.demo ? { demo: true, demoNote: DEMO_NOTE } : { demo: false }),
411
+ });
412
+ /** One line of a trip, flattened for the card. Prices are strings: the card never does arithmetic. */
413
+ const quoteForCard = (quote) => ({
414
+ lines: quote.lines.map((l) => ({
415
+ kind: l.kind,
416
+ hotelId: l.hotelId,
417
+ hotelName: l.hotelName,
418
+ roomRef: l.roomRef,
419
+ extraRef: l.extraRef,
420
+ title: l.title,
421
+ detail: l.detail,
422
+ stay: l.stay,
423
+ nights: l.nights,
424
+ quantity: l.quantity,
425
+ unit: formatMoney(l.unit),
426
+ total: formatMoney(l.total),
427
+ refundable: l.refundable,
428
+ freeCancellationUntil: l.freeCancellationUntil,
429
+ day: l.day,
430
+ })),
431
+ subtotal: formatMoney(quote.subtotal),
432
+ taxesAndFees: formatMoney(quote.taxesAndFees),
433
+ total: formatMoney(quote.total),
434
+ ...(quote.stay ? { stay: quote.stay } : {}),
435
+ ...(quote.dropped ? { dropped: quote.dropped } : {}),
436
+ });
437
+ const tripText = (quote, demo) => {
438
+ if (quote.lines.length === 0)
439
+ return 'The trip is empty.';
440
+ const lines = quote.lines.map((l) => l.kind === 'room'
441
+ ? `- ${l.quantity ?? 1}× ${l.title} at ${l.hotelName}, ${l.stay?.checkIn} to ${l.stay?.checkOut} (${l.nights} nights) — ${formatMoney(l.total)}`
442
+ : `- ${l.quantity}× ${l.title} — ${formatMoney(l.total)}`);
443
+ return [
444
+ ...lines,
445
+ `Taxes and fees ${formatMoney(quote.taxesAndFees)} · **Total ${formatMoney(quote.total)}**`,
446
+ ...(quote.dropped ?? []).map((d) => `Left out: ${d.title} — ${d.why}`),
447
+ demo ? DEMO_NOTE : '',
448
+ ]
449
+ .filter(Boolean)
450
+ .join('\n');
451
+ };
452
+ const bookingLine = (b) => `${b.confirmationCode} · ${b.hotelName} · ${b.stay.checkIn} to ${b.stay.checkOut} · ${b.status}`;
453
+ const identity = (ctx, args) => {
454
+ const email = str(args.email);
455
+ return email ? { sessionId: ctx.session, email } : { sessionId: ctx.session };
456
+ };
457
+ /** The trip, priced, as both views need it. */
458
+ const currentTrip = async (deps, key) => {
459
+ const lines = deps.trips.read(key);
460
+ const quote = await deps.backend.quote({ lines });
461
+ return {
462
+ structured: { view: 'trip', ...common(deps), ...quoteForCard(quote), count: lines.length },
463
+ text: tripText(quote, deps.vocab.demo),
464
+ };
465
+ };
466
+ export const handleTool = async (deps, name, rawArgs, ctx) => {
467
+ const args = (rawArgs ?? {});
468
+ const t = deps.tools;
469
+ const key = ctx.tripKey ?? tripKey(ctx.session, deps.vocab.brand);
470
+ if (name === t.listHotels) {
471
+ const query = str(args.query);
472
+ const hotels = await deps.backend.searchHotels({
473
+ query: query || undefined,
474
+ city: str(args.city) || undefined,
475
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
476
+ });
477
+ const cities = [...new Set(hotels.map((h) => h.city))];
478
+ if (hotels.length === 0) {
479
+ return {
480
+ structured: { view: 'no-hotels', ...common(deps), hotels: [], total: 0, query },
481
+ text: `No ${deps.vocab.brand} ${deps.vocab.nounPlural} match that.`,
482
+ };
483
+ }
484
+ const forCard = hotels.map((h) => ({
485
+ ...h,
486
+ summary: hotelText('description', h.name, h.summary, DESCRIPTION_MAX).text,
487
+ fromNightly: h.fromNightly ? formatMoney(h.fromNightly) : undefined,
488
+ }));
489
+ // The whole shortlist, never one pick: a city is not a hotel.
490
+ const where = cities.length === 1 ? ` in ${cities[0]}` : '';
491
+ return {
492
+ structured: {
493
+ view: 'hotels',
494
+ ...common(deps),
495
+ hotels: forCard,
496
+ total: hotels.length,
497
+ cities,
498
+ query,
499
+ },
500
+ text: `${hotels.length} ${deps.vocab.brand} ${hotels.length === 1 ? deps.vocab.noun : deps.vocab.nounPlural}${where} — tap one to see rooms.`,
501
+ };
502
+ }
503
+ if (name === t.getHotel) {
504
+ const wanted = str(args.hotel);
505
+ const stay = asStay(args);
506
+ const detail = await deps.backend.getHotel(wanted, stay);
507
+ if (!detail) {
508
+ return {
509
+ structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
510
+ text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name. ${t.listHotels} lists them.`,
511
+ };
512
+ }
513
+ const h = detail.hotel;
514
+ const description = hotelText('description', h.name, h.summary, DESCRIPTION_MAX);
515
+ const policies = await deps.backend.policies(h.id);
516
+ const extras = await deps.backend.extrasFor(h.id, stay);
517
+ const base = {
518
+ ...common(deps),
519
+ hotel: {
520
+ ...h,
521
+ summary: description.text,
522
+ fromNightly: h.fromNightly ? formatMoney(h.fromNightly) : undefined,
523
+ },
524
+ extras: extras.map((e) => ({ ...e, price: formatMoney(e.price) })),
525
+ policies: policies.map((p) => ({ topic: p.topic, title: p.title })),
526
+ };
527
+ if (!stay) {
528
+ return {
529
+ structured: { view: 'hotel', ...base, needsDates: true },
530
+ text: [
531
+ `**${h.name}** · ${[h.area, h.city].filter(Boolean).join(', ')}`,
532
+ description.fenced,
533
+ 'Rooms and prices need the dates - which nights is the guest staying?',
534
+ ]
535
+ .filter(Boolean)
536
+ .join('\n\n'),
537
+ };
538
+ }
539
+ if (detail.noRooms || !detail.rooms) {
540
+ return {
541
+ structured: { view: 'no-rooms', ...base, stay, noRooms: detail.noRooms },
542
+ text: detail.noRooms ?? 'Nothing is available for those nights.',
543
+ };
544
+ }
545
+ const rooms = detail.rooms.map((r) => ({
546
+ ...r,
547
+ nightly: formatMoney(r.nightly),
548
+ stayTotal: formatMoney(r.stayTotal),
549
+ taxesAndFees: formatMoney(r.taxesAndFees),
550
+ }));
551
+ return {
552
+ structured: { view: 'hotel', ...base, stay, rooms },
553
+ text: [
554
+ `**${h.name}** · ${detail.rooms.length} room ${detail.rooms.length === 1 ? 'type' : 'types'} for ${stay.checkIn} to ${stay.checkOut}, ${stay.guests} guests.`,
555
+ ...detail.rooms.map((r) => `- ${r.name} — ${formatMoney(r.nightly)} a night, ${formatMoney(r.stayTotal)} for the stay incl. taxes and fees${r.refundable ? `, free to cancel${r.freeCancellationUntil ? ` until ${r.freeCancellationUntil.slice(0, 10)}` : ''}` : ', non-refundable'}`),
556
+ deps.vocab.demo ? DEMO_NOTE : '',
557
+ ]
558
+ .filter(Boolean)
559
+ .join('\n'),
560
+ };
561
+ }
562
+ if (name === t.listExtras) {
563
+ const wanted = str(args.hotel);
564
+ const detail = await deps.backend.getHotel(wanted);
565
+ if (!detail) {
566
+ return {
567
+ structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
568
+ text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
569
+ };
570
+ }
571
+ const extras = await deps.backend.extrasFor(detail.hotel.id, asStay(args));
572
+ return {
573
+ structured: {
574
+ view: 'extras',
575
+ ...common(deps),
576
+ hotel: detail.hotel,
577
+ extras: extras.map((e) => ({
578
+ ...e,
579
+ description: hotelText('add-on', detail.hotel.name, e.description, DESCRIPTION_MAX).text,
580
+ price: formatMoney(e.price),
581
+ })),
582
+ },
583
+ text: extras.length
584
+ ? extras
585
+ .map((e) => `- ${e.name} — ${formatMoney(e.price)} per ${e.per}`)
586
+ .concat(deps.vocab.demo ? [DEMO_NOTE] : [])
587
+ .join('\n')
588
+ : `${detail.hotel.name} has no add-ons listed here.`,
589
+ };
590
+ }
591
+ if (name === t.addToTrip) {
592
+ const wanted = str(args.hotel);
593
+ const detail = await deps.backend.getHotel(wanted);
594
+ if (!detail) {
595
+ return {
596
+ structured: { view: 'error', ...common(deps), error: 'hotel-not-found', query: wanted },
597
+ text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
598
+ };
599
+ }
600
+ const hotelId = detail.hotel.id;
601
+ const extraRef = str(args.extra);
602
+ if (extraRef) {
603
+ const extras = await deps.backend.extrasFor(hotelId);
604
+ const extra = extras.find((e) => e.id === extraRef || e.name.toLowerCase() === extraRef.toLowerCase());
605
+ if (!extra) {
606
+ return {
607
+ structured: { view: 'error', ...common(deps), error: 'no-such-extra', query: extraRef },
608
+ text: `${detail.hotel.name} does not offer that. ${t.listExtras} lists what it has.`,
609
+ };
610
+ }
611
+ const line = {
612
+ kind: 'extra',
613
+ hotelId,
614
+ extraId: extra.id,
615
+ quantity: clampCount(args.quantity ?? 1, MAX_EXTRA_QUANTITY),
616
+ ...(isDay(str(args.day)) ? { day: str(args.day) } : {}),
617
+ };
618
+ deps.trips.set(key, line);
619
+ return currentTrip(deps, key);
620
+ }
621
+ const roomRef = str(args.room);
622
+ if (!roomRef) {
623
+ return {
624
+ structured: { view: 'error', ...common(deps), error: 'no-room-named' },
625
+ text: `Say which room type, or which add-on. ${t.getHotel} with the dates lists the room types.`,
626
+ };
627
+ }
628
+ const stay = asStay(args);
629
+ // ⚠️ NO DATES, NO LINE. Guessing "tonight" or "this weekend" here is how a
630
+ // connector books someone the wrong nights, and the guest finds out at the
631
+ // desk. The trip stays as it was and the answer asks.
632
+ if (!stay) {
633
+ return {
634
+ structured: { view: 'needs-dates', ...common(deps), hotel: detail.hotel, room: roomRef },
635
+ text: 'Which nights? A room needs a check-in and a check-out date before it can go into the trip.',
636
+ };
637
+ }
638
+ // The backend owns the calendar - it knows each hotel's own timezone, and a
639
+ // second opinion computed from this process's clock would disagree with it
640
+ // for eight hours a day.
641
+ const dated = await deps.backend.getHotel(hotelId, stay);
642
+ if (!dated?.rooms) {
643
+ return {
644
+ structured: {
645
+ view: 'needs-dates',
646
+ ...common(deps),
647
+ hotel: detail.hotel,
648
+ room: roomRef,
649
+ why: dated?.noRooms,
650
+ },
651
+ text: dated?.noRooms ?? 'Those nights cannot be booked here.',
652
+ };
653
+ }
654
+ const room = dated.rooms.find((r) => r.id === roomRef || r.name.toLowerCase() === roomRef.toLowerCase());
655
+ if (!room) {
656
+ return {
657
+ structured: {
658
+ view: 'error',
659
+ ...common(deps),
660
+ error: 'no-such-room',
661
+ query: roomRef,
662
+ rooms: dated.rooms.map((r) => ({ id: r.id, name: r.name })),
663
+ },
664
+ text: `${detail.hotel.name} has no room type by that name for those nights. It has: ${dated.rooms.map((r) => r.name).join(', ')}.`,
665
+ };
666
+ }
667
+ deps.trips.set(key, {
668
+ kind: 'room',
669
+ hotelId,
670
+ roomId: room.id,
671
+ stay,
672
+ rooms: clampCount(args.rooms ?? 1, MAX_ROOMS_PER_LINE),
673
+ });
674
+ return currentTrip(deps, key);
675
+ }
676
+ if (name === t.viewTrip)
677
+ return currentTrip(deps, key);
678
+ if (name === t.clearTrip) {
679
+ deps.trips.clear(key);
680
+ return {
681
+ structured: { view: 'trip', ...common(deps), lines: [], count: 0, total: null },
682
+ text: 'The trip is empty.',
683
+ };
684
+ }
685
+ if (name === t.requestBooking) {
686
+ const lines = deps.trips.read(key);
687
+ if (lines.length === 0) {
688
+ return {
689
+ structured: { view: 'empty-trip', ...common(deps) },
690
+ text: `There is nothing in the trip yet. ${t.addToTrip} puts room nights in it.`,
691
+ };
692
+ }
693
+ const guest = { name: str(args.name), email: str(args.email) };
694
+ try {
695
+ const booking = await deps.backend.requestBooking({ lines }, {
696
+ ...guest,
697
+ ...(str(args.phone) ? { phone: str(args.phone) } : {}),
698
+ ...(str(args.notes) ? { notes: str(args.notes) } : {}),
699
+ }, { sessionId: ctx.session, email: guest.email });
700
+ deps.trips.clear(key);
701
+ return {
702
+ structured: {
703
+ view: 'booking',
704
+ ...common(deps),
705
+ booking: { ...booking, quote: quoteForCard(booking.quote) },
706
+ paid: false,
707
+ holdUntil: booking.holdUntil,
708
+ total: formatMoney(booking.quote.total),
709
+ },
710
+ text: [
711
+ `**${booking.confirmationCode}** · ${booking.hotelName} · ${booking.stay.checkIn} to ${booking.stay.checkOut}`,
712
+ `${formatMoney(booking.quote.total)} all in. **Nothing has been charged** - the rooms are held until ${booking.holdUntil?.slice(0, 16).replace('T', ' ')} UTC, and ${booking.guest.name} presents a card at check-in.`,
713
+ deps.vocab.demo ? DEMO_NOTE : '',
714
+ ]
715
+ .filter(Boolean)
716
+ .join('\n'),
717
+ };
718
+ }
719
+ catch (err) {
720
+ return {
721
+ structured: {
722
+ view: 'error',
723
+ ...common(deps),
724
+ error: 'not-bookable',
725
+ detail: err.message,
726
+ },
727
+ text: err.message,
728
+ };
729
+ }
730
+ }
731
+ if (name === t.listBookings) {
732
+ const bookings = await deps.backend.listBookings(identity(ctx, args), typeof args.limit === 'number' ? args.limit : undefined);
733
+ return {
734
+ structured: {
735
+ view: 'bookings',
736
+ ...common(deps),
737
+ bookings: bookings.map((b) => ({ ...b, quote: quoteForCard(b.quote) })),
738
+ count: bookings.length,
739
+ },
740
+ text: bookings.length
741
+ ? bookings.map(bookingLine).join('\n')
742
+ : 'No bookings here. A booking made in another conversation can be found with the email it was made with.',
743
+ };
744
+ }
745
+ if (name === t.previewCancellation || name === t.cancelBooking) {
746
+ const ref = str(args.booking);
747
+ const who = identity(ctx, args);
748
+ const preview = await deps.backend.previewCancellation(ref, who);
749
+ if (!preview) {
750
+ return {
751
+ structured: { view: 'booking-not-found', ...common(deps), query: ref },
752
+ text: 'No booking here matches that code. A booking made in another conversation needs the email it was made with.',
753
+ };
754
+ }
755
+ const asPreview = {
756
+ structured: {
757
+ view: 'cancellation-preview',
758
+ ...common(deps),
759
+ booking: { id: preview.bookingId, confirmationCode: preview.confirmationCode },
760
+ fee: formatMoney(preview.fee),
761
+ refund: formatMoney(preview.refund),
762
+ rule: preview.rule,
763
+ freeUntil: preview.freeUntil,
764
+ cancellable: preview.cancellable,
765
+ toolToConfirm: t.cancelBooking,
766
+ },
767
+ text: [
768
+ `Cancelling ${preview.confirmationCode} would cost ${formatMoney(preview.fee)}${preview.fee.minor === 0 ? ' - nothing' : ''}.`,
769
+ preview.rule,
770
+ preview.cancellable
771
+ ? `Say the word and it is cancelled (${t.cancelBooking} with \`confirmed\`).`
772
+ : '',
773
+ ]
774
+ .filter(Boolean)
775
+ .join(' '),
776
+ };
777
+ // ⚠️ THE PREVIEW IS NOT ADVISORY. Cancelling without one is how a guest
778
+ // loses a night's rate they were never told about, so the cancel tool
779
+ // returns this same preview and does nothing until it is told again.
780
+ if (name === t.previewCancellation || args.confirmed !== true)
781
+ return asPreview;
782
+ if (!preview.cancellable)
783
+ return asPreview;
784
+ const cancelled = await deps.backend.cancelBooking(ref, who, str(args.reason) || undefined);
785
+ if (!cancelled)
786
+ return asPreview;
787
+ return {
788
+ structured: {
789
+ view: 'cancelled',
790
+ ...common(deps),
791
+ booking: { ...cancelled, quote: quoteForCard(cancelled.quote) },
792
+ fee: formatMoney(preview.fee),
793
+ refund: formatMoney(preview.refund),
794
+ rule: preview.rule,
795
+ cancellable: false,
796
+ },
797
+ text: `${cancelled.confirmationCode} is cancelled. ${preview.fee.minor === 0 ? 'No fee.' : `The hotel charges ${formatMoney(preview.fee)}.`}`,
798
+ };
799
+ }
800
+ if (name === t.policies) {
801
+ const wanted = str(args.hotel);
802
+ const detail = await deps.backend.getHotel(wanted);
803
+ if (!detail) {
804
+ return {
805
+ structured: { view: 'hotel-not-found', ...common(deps), query: wanted },
806
+ text: `No ${deps.vocab.brand} ${deps.vocab.noun} by that name.`,
807
+ };
808
+ }
809
+ const policies = await deps.backend.policies(detail.hotel.id);
810
+ const topic = str(args.topic);
811
+ if (topic) {
812
+ const one = policies.find((p) => p.topic === topic);
813
+ if (!one) {
814
+ return {
815
+ structured: {
816
+ view: 'policy-missing',
817
+ ...common(deps),
818
+ hotel: detail.hotel,
819
+ topic,
820
+ topics: policies.map((p) => ({ topic: p.topic, title: p.title })),
821
+ url: detail.hotel.url,
822
+ },
823
+ // Never fill in what hotels usually do - that is how a guest is told
824
+ // about a free window this one does not offer.
825
+ text: `${detail.hotel.name} does not publish anything about that here.${detail.hotel.url ? ` Its own page: ${detail.hotel.url}` : ''}`,
826
+ };
827
+ }
828
+ const fenced = hotelText(one.title, detail.hotel.name, one.text, POLICY_MAX);
829
+ return {
830
+ structured: {
831
+ view: 'policy',
832
+ ...common(deps),
833
+ hotel: detail.hotel,
834
+ topic: one.topic,
835
+ title: one.title,
836
+ text: fenced.text,
837
+ url: one.url,
838
+ },
839
+ text: `**${one.title} · ${detail.hotel.name}**\n\n${fenced.fenced}`,
840
+ };
841
+ }
842
+ const all = policies
843
+ .map((p) => hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX).fenced)
844
+ .join('\n\n');
845
+ return {
846
+ structured: {
847
+ view: 'policies',
848
+ ...common(deps),
849
+ hotel: detail.hotel,
850
+ topics: policies.map((p) => ({
851
+ topic: p.topic,
852
+ title: p.title,
853
+ text: hotelText(p.title, detail.hotel.name, p.text, POLICY_MAX).text,
854
+ })),
855
+ url: detail.hotel.url,
856
+ },
857
+ text: all || `${detail.hotel.name} publishes no policies here.`,
858
+ };
859
+ }
860
+ return { structured: { view: 'error', error: 'unknown-tool' }, text: `Unknown tool: ${name}` };
861
+ };
862
+ //# sourceMappingURL=tools.js.map