From a844ad9c2ff82c535f015fe58467ed81df68d9e3 Mon Sep 17 00:00:00 2001 From: Justin Crowe Date: Tue, 7 Jul 2026 15:03:26 -0400 Subject: [PATCH 1/3] Add meridian: 5-assistant hotel + airline concierge squad Native-Vapi build: every business tool is a code tool hitting Supabase REST directly (no webhook hosting), squad routing via assistantName transfers, distinct Deepgram persona voices, PSTN-tuned barge-in, TTS-hygiene helpers, 15 mock-conversation evals, and an offline 40+ case tool audit. Includes an optional standalone outbound-disruption assistant and campaign script. Co-Authored-By: Claude Fable 5 --- README.md | 1 + projects/meridian/.env.example | 29 + projects/meridian/README.md | 88 + projects/meridian/api/index.ts | 14 + .../meridian/assets/hotel-knowledge-base.txt | 191 ++ projects/meridian/package-lock.json | 1809 +++++++++++++++++ projects/meridian/package.json | 44 + projects/meridian/src/config.ts | 49 + projects/meridian/src/db/client.ts | 43 + projects/meridian/src/db/migrate.ts | 36 + projects/meridian/src/db/schema.sql | 81 + projects/meridian/src/db/seed.ts | 63 + projects/meridian/src/routes/webhook.ts | 166 ++ projects/meridian/src/server.ts | 24 + projects/meridian/src/test/audit-tools.ts | 114 ++ .../meridian/src/test/check-connections.ts | 78 + projects/meridian/src/test/flight-tools.ts | 40 + .../meridian/src/test/simulate-webhook.ts | 53 + .../meridian/src/test/synth-reservation.ts | 40 + projects/meridian/src/test/verify-config.ts | 115 ++ .../meridian/src/tools/lookup_reservation.ts | 92 + projects/meridian/src/utils/print.ts | 9 + .../meridian/src/vapi/assistants/concierge.ts | 62 + .../src/vapi/assistants/flight-triage.ts | 98 + .../src/vapi/assistants/hotel-concierge.ts | 186 ++ .../vapi/assistants/outbound-disruption.ts | 147 ++ .../meridian/src/vapi/assistants/rebooking.ts | 116 ++ .../src/vapi/assistants/upsell-recovery.ts | 121 ++ projects/meridian/src/vapi/campaign.ts | 180 ++ projects/meridian/src/vapi/flight-code.ts | 262 +++ projects/meridian/src/vapi/lookup-code.ts | 109 + projects/meridian/src/vapi/recovery-code.ts | 39 + .../meridian/src/vapi/service-request-code.ts | 131 ++ projects/meridian/src/vapi/simulations.ts | 458 +++++ projects/meridian/src/vapi/speaking.ts | 34 + projects/meridian/src/vapi/squad.ts | 124 ++ projects/meridian/src/vapi/upload-kb.ts | 32 + projects/meridian/tsconfig.json | 14 + 38 files changed, 5292 insertions(+) create mode 100644 projects/meridian/.env.example create mode 100644 projects/meridian/README.md create mode 100644 projects/meridian/api/index.ts create mode 100644 projects/meridian/assets/hotel-knowledge-base.txt create mode 100644 projects/meridian/package-lock.json create mode 100644 projects/meridian/package.json create mode 100644 projects/meridian/src/config.ts create mode 100644 projects/meridian/src/db/client.ts create mode 100644 projects/meridian/src/db/migrate.ts create mode 100644 projects/meridian/src/db/schema.sql create mode 100644 projects/meridian/src/db/seed.ts create mode 100644 projects/meridian/src/routes/webhook.ts create mode 100644 projects/meridian/src/server.ts create mode 100644 projects/meridian/src/test/audit-tools.ts create mode 100644 projects/meridian/src/test/check-connections.ts create mode 100644 projects/meridian/src/test/flight-tools.ts create mode 100644 projects/meridian/src/test/simulate-webhook.ts create mode 100644 projects/meridian/src/test/synth-reservation.ts create mode 100644 projects/meridian/src/test/verify-config.ts create mode 100644 projects/meridian/src/tools/lookup_reservation.ts create mode 100644 projects/meridian/src/utils/print.ts create mode 100644 projects/meridian/src/vapi/assistants/concierge.ts create mode 100644 projects/meridian/src/vapi/assistants/flight-triage.ts create mode 100644 projects/meridian/src/vapi/assistants/hotel-concierge.ts create mode 100644 projects/meridian/src/vapi/assistants/outbound-disruption.ts create mode 100644 projects/meridian/src/vapi/assistants/rebooking.ts create mode 100644 projects/meridian/src/vapi/assistants/upsell-recovery.ts create mode 100644 projects/meridian/src/vapi/campaign.ts create mode 100644 projects/meridian/src/vapi/flight-code.ts create mode 100644 projects/meridian/src/vapi/lookup-code.ts create mode 100644 projects/meridian/src/vapi/recovery-code.ts create mode 100644 projects/meridian/src/vapi/service-request-code.ts create mode 100644 projects/meridian/src/vapi/simulations.ts create mode 100644 projects/meridian/src/vapi/speaking.ts create mode 100644 projects/meridian/src/vapi/squad.ts create mode 100644 projects/meridian/src/vapi/upload-kb.ts create mode 100644 projects/meridian/tsconfig.json diff --git a/README.md b/README.md index 583381d..c85b3f3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A running list of projects, each self-contained in its own folder under `project | Project | What it is | Built by | |---|---|---| +| [meridian](projects/meridian) | A 5-assistant hotel + airline concierge squad built with only native Vapi primitives (code tools, transfers, evals), zero hosting required | [Justin Crowe](https://github.com/justincrowe-hub) | ## Browsing / running a project diff --git a/projects/meridian/.env.example b/projects/meridian/.env.example new file mode 100644 index 0000000..1fae0df --- /dev/null +++ b/projects/meridian/.env.example @@ -0,0 +1,29 @@ +# Copy to .env and fill in your own values. Never commit real keys. + +# ── Required ───────────────────────────────────────────────────────────────── +# Vapi dashboard → Org Settings → API Keys (use a test/sandbox key) +VAPI_API_KEY= + +# Supabase → Project Settings → API. The code tools carry these to Vapi's +# runtime so reservations/service requests persist with zero hosting. +SUPABASE_URL= +SUPABASE_SERVICE_ROLE_KEY= + +# Supabase → Project Settings → Database → Connection string (URI). +# Only needed once, for `npm run migrate`. +SUPABASE_DB_URL= + +# ── Optional ───────────────────────────────────────────────────────────────── +# File id printed by `npm run kb:upload` (uploads assets/hotel-knowledge-base.txt +# to your Vapi org). Unset = hotel assistant is created without the KB tool. +HOTEL_KB_FILE_ID= + +# Callback number the outbound assistant offers if a member declines, written +# the way it should be read aloud. Unset = a generic "call us back" line. +CALLBACK_NUMBER= + +# Only for the optional self-hosted webhook server (any string you invent). +VAPI_WEBHOOK_SECRET= + +# Only for the optional PostHog call-analytics sink in the webhook server. +POSTHOG_API_KEY= diff --git a/projects/meridian/README.md b/projects/meridian/README.md new file mode 100644 index 0000000..9c232b5 --- /dev/null +++ b/projects/meridian/README.md @@ -0,0 +1,88 @@ +# Meridian: a 5-assistant Vapi concierge squad + +> 🧪 **This is a showcase demo, not an officially supported Vapi product.** Built by Justin Crowe (GTM at Vapi) to explore how far a multi-assistant squad can go using only native Vapi primitives: code tools, query tools, transfers, evals, and structured-data analysis. No third-party integrations, no self-hosted webhook required. It is meant to inspire, not to be production ready. + +## What it does + +Meridian is a fictional membership travel brand (think closed-loop hotel + airline concierge). Call one number and a squad of five assistants handles the whole journey: + +| Assistant | Voice | Role | +|---|---|---| +| Concierge "Aria" | asteria | Front door. Greets, identifies the member, routes to hotel or flight. | +| Hotel Concierge "Jack" | orion | Reservation lookup, service requests, hotel knowledge base. | +| Flight Triage "Maya" | luna | Flight status. Escalates cancellations and 2h+ delays to Rebooking. | +| Rebooking "Marcus" | arcas | Finds alternative flights, confirms the rebook. | +| Upsell & Recovery "Sophie" | stella | Room upgrades (priced, pending on folio) and travel-credit requests. | + +A sixth, standalone assistant ("Outbound Disruption") proactively calls members when their flight is cancelled or badly delayed, rebooks them in one call, and files a travel-credit request. + +## How it works + +- **Every business tool is a Vapi code tool** that calls the Supabase REST API directly, so the tools run on Vapi's infrastructure. There is no webhook, tunnel, or server to host, yet data persists: a new caller gets a randomly invented reservation that is written to Supabase and returned consistently on every later call. +- **Routing lives in the squad**, not in prompts: members declare `transferCall` tools with `assistantName` destinations, and the squad wires `assistantDestinations` with silent, rolling-history transfers so context carries across handoffs. +- **Distinct persona voices** per member (Deepgram), with `membersOverrides` used only for uniform settings (transcriber, barge-in). Pinning a voice in `membersOverrides` would silently collapse every member to one voice. +- **Barge-in is tuned for PSTN**: a shared `stopSpeakingPlan` (`numWords: 10`, the API maximum, plus `acknowledgementPhrases` / `interruptionPhrases`) so background noise and backchannels never interrupt, but "stop" or "wait" always does. +- **TTS hygiene everywhere**: every string a tool returns for speech is spoken words only. Flight numbers, times, dates, and reference codes are spelled out so the voice never reads raw codes or ISO timestamps. +- **Evals**: 15 `chat.mockConversation` simulations cover routing, tool behavior, and guardrails (for example: Sophie must treat an upgrade ask as an upgrade, never lead with credits, and never quote a credit dollar amount). +- An optional Express webhook server (`src/server.ts`) adds end-of-call analytics (call logs to Supabase, events to PostHog). The voice demo works fully without it. + +## Setup + +### Prerequisites + +- Node 20+ +- A Vapi account and API key (use a test key) +- A free [Supabase](https://supabase.com) project (gives the code tools a hosted place to persist reservations) + +### Steps + +1. `cp .env.example .env` and fill in `VAPI_API_KEY`, `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, and `SUPABASE_DB_URL` +2. `npm install` +3. `npm run migrate`: applies `src/db/schema.sql` (5 tables) to your Supabase project +4. Optional: `npm run kb:upload`: uploads `assets/hotel-knowledge-base.txt` to your Vapi org; put the printed id in `.env` as `HOTEL_KB_FILE_ID` so Jack gets the knowledge-base tool +5. Create the five assistants (order does not matter; names are the routing contract, so leave them as-is): + ``` + npm run assistant:concierge + npm run assistant:hotel + npm run assistant:triage + npm run assistant:rebooking + npm run assistant:upsell + ``` +6. `npm run squad`: wires them into the Meridian Concierge Squad +7. In the Vapi dashboard, point a phone number at the squad, then call it + +Re-running an assistant script creates a new assistant. To update one in place, pass its id: `ASSISTANT_ID= npm run assistant:hotel`. Keep assistant names unique in your org or the squad script will refuse to wire (by design). + +### Outbound disruption campaign (optional) + +``` +npm run assistant:outbound +npm run seed # adds a demo member +TEST_PHONE=+1XXXXXXXXXX FLIGHT_NUMBER=UA482 npm run campaign +``` + +`TEST_PHONE` overrides the member's phone so the call goes to you. You also need a Vapi phone number to place the call from. + +## Verify and test + +| Command | What it checks | Needs | +|---|---|---| +| `npm run typecheck` | TypeScript across the project | nothing | +| `npm run check` | env vars + Supabase/Vapi connectivity | .env | +| `npm run test:synth` | executes the exact shipped lookup code against your Supabase | .env | +| `npm run test:flight` | flight status/rebook/compensation code tools | .env | +| `npm run test:audit` | all 8 code-tool bodies across a 40+ case matrix, plus a TTS-hygiene scan of every spoken message | .env | +| `npm run test:verify` | live squad config: membership, distinct voices, barge-in, transferCall wiring (read-only) | assistants + squad created | +| `npm run simulations` | upserts the 15 evals (`RUN=1` to also execute them) | assistants created | + +## Known limitations + +- Flight status is **synthesized** (weighted toward disruptions so the demo is interesting). No real flight-data provider is wired in. +- Free-tier Supabase projects pause after about a week of inactivity; unpause in the Supabase dashboard if lookups start failing with DNS errors. +- Eval runs (`RUN=1 npm run simulations`) have been observed to sit in `queued` on some orgs; the audit suite (`npm run test:audit`) covers the same tool behavior offline. +- Voices are Deepgram-specific; swapping providers means re-tuning the barge-in plan. +- Built and tested on macOS with Node 20/24. + +## Built by + +Justin Crowe ([justincrowe-hub](https://github.com/justincrowe-hub)), GTM at Vapi. diff --git a/projects/meridian/api/index.ts b/projects/meridian/api/index.ts new file mode 100644 index 0000000..383bc56 --- /dev/null +++ b/projects/meridian/api/index.ts @@ -0,0 +1,14 @@ +/** + * Vercel serverless entry point — wraps the Meridian Express app. + * All routes (/webhook, /health) are handled by the Express router. + * Env vars come from Vercel's environment (not .env files). + */ +import express from "express"; +import { webhookRouter } from "../src/routes/webhook.js"; + +const app = express(); +app.use(express.json({ limit: "5mb" })); +app.use(webhookRouter); +app.get("/health", (_req, res) => res.json({ ok: true, service: "meridian" })); + +export default app; diff --git a/projects/meridian/assets/hotel-knowledge-base.txt b/projects/meridian/assets/hotel-knowledge-base.txt new file mode 100644 index 0000000..da0596a --- /dev/null +++ b/projects/meridian/assets/hotel-knowledge-base.txt @@ -0,0 +1,191 @@ +MERIDIAN GRAND HOTEL — KNOWLEDGE BASE +Last updated: 2025 + +================================================== +PROPERTY OVERVIEW +================================================== +Property name: Meridian Grand Hotel +Address: 1200 Harbor Boulevard, Miami, FL 33101 +Main phone: +1 (305) 555-0100 +Check-in time: 4:00 PM +Late check-out: Available until 2:00 PM, subject to availability, $50 fee. Complimentary for Gold and Platinum members. +Check-out time: 11:00 AM +Early check-in: Available from 12:00 PM, subject to availability, $75 fee. Complimentary for Platinum members. + +================================================== +ROOM TYPES +================================================== +Standard King — 1 king bed, city view, 380 sq ft +Deluxe King — 1 king bed, ocean view, 420 sq ft +Standard Double — 2 queen beds, city view, 400 sq ft +Deluxe Double — 2 queen beds, ocean view, 440 sq ft +Junior Suite — 1 king bed, separate living area, ocean view, 650 sq ft +Executive Suite — 1 king bed, full living room, ocean view, 950 sq ft +Presidential Suite — 2 bedrooms, full kitchen, wraparound terrace, 1800 sq ft + +All rooms include: free WiFi, 65-inch smart TV, Nespresso machine, minibar, in-room safe, blackout curtains. + +================================================== +AMENITIES & HOURS +================================================== + +POOL +Rooftop pool: Open daily 7:00 AM – 10:00 PM +Heated lap pool: Open daily 6:00 AM – 9:00 PM +Pool towels available at the pool deck, no charge. +Cabana rentals: $150/day, reservations recommended, call front desk. + +FITNESS CENTER +Location: Level 3 +Hours: Open 24 hours, 7 days a week +Equipment: Peloton bikes, treadmills, free weights, cable machines, stretching area +Personal training: Available by appointment, $120/session, call front desk to book. + +SPA — MERIDIAN WELLNESS +Location: Level 2 +Hours: Monday–Friday 9:00 AM – 8:00 PM, Saturday–Sunday 8:00 AM – 9:00 PM +Services: Swedish massage, deep tissue massage, hot stone massage, facials, couples treatments +50-minute massage: $180 +80-minute massage: $240 +Facial (60 min): $160 +Couples suite: $420 for 80-minute session +Booking: Guests should call the front desk or visit Level 2 directly. 24-hour cancellation policy. + +RESTAURANTS & BARS + +Harbor Kitchen (all-day dining) +Location: Level 1, lobby level +Hours: Breakfast 6:30 AM – 11:00 AM, Lunch 12:00 PM – 3:00 PM, Dinner 6:00 PM – 10:30 PM +Cuisine: Contemporary American +Dress code: Smart casual +Reservations: Recommended for dinner, walk-ins welcome for breakfast and lunch + +Azul Rooftop Bar +Location: Level 22 +Hours: Sunday–Thursday 4:00 PM – 12:00 AM, Friday–Saturday 4:00 PM – 2:00 AM +Cuisine: Small plates, craft cocktails +Dress code: Smart casual, no flip flops or beachwear after 6:00 PM +Reservations: Not required but recommended on weekends + +The Lobby Lounge +Location: Level 1 +Hours: Daily 11:00 AM – 1:00 AM +Cuisine: Light bites, coffee, cocktails, afternoon tea (2:00 PM – 5:00 PM daily) +No reservations needed + +Room service: Available 24 hours. 20-minute delivery guarantee or the order is complimentary. + +BUSINESS CENTER +Location: Level 2 +Hours: Open 24 hours +Services: Printing, scanning, fax, private meeting pods (bookable at front desk, $50/hour) + +================================================== +SERVICES +================================================== + +VALET & PARKING +Valet parking: $55/night, available 24 hours +Self-parking garage: $35/night, accessed via Harbor Street entrance +Electric vehicle charging: 4 stalls available in self-parking, first-come first-served, no extra charge + +CONCIERGE SERVICES +Hours: Daily 7:00 AM – 11:00 PM +Services: Restaurant reservations, show tickets, transportation, tours, dry cleaning coordination + +DRY CLEANING & LAUNDRY +Same-day service if items received before 9:00 AM +Next-day service for items received after 9:00 AM +Pickup: Call housekeeping or leave bag on door handle with request card + +HOUSEKEEPING +Standard service: Daily between 9:00 AM – 4:00 PM +Turn-down service: Available on request, 6:00 PM – 9:00 PM +Extra towels, pillows, toiletries: Call housekeeping, delivered within 20 minutes +Do not disturb: Hang card on door or press DND button on room phone + +TRANSPORTATION +Airport shuttle: $35 per person, runs every 90 minutes 5:00 AM – 11:00 PM, book at front desk +Taxi/rideshare: Doorman can assist, pickup zone is Harbor Boulevard entrance +Car rental desk: Located in lobby, open daily 8:00 AM – 6:00 PM + +PET POLICY +Pets allowed: Dogs and cats only, max 2 pets per room +Pet fee: $75 per stay (non-refundable) +Max weight: 50 lbs per pet +Pet-friendly rooms: Ground floor rooms only, must request at booking +Dog walking service: Available through concierge, $25 per 30-minute walk + +================================================== +LOYALTY PROGRAM — MERIDIAN REWARDS +================================================== +Standard: Base earn rate, 10 points per $1 spent +Silver: 25+ nights/year, 12 points per $1, free room upgrade when available +Gold: 50+ nights/year, 15 points per $1, complimentary late checkout, free breakfast daily +Platinum: 75+ nights/year, 20 points per $1, complimentary early check-in and late checkout, suite upgrades when available, dedicated phone line + +Points redemption: 1,000 points = $10 credit toward room rate or hotel services +Points never expire for members with activity in past 24 months + +================================================== +POLICIES +================================================== + +CANCELLATION +Standard rate: Free cancellation up to 48 hours before check-in +Non-refundable rate: No refund after booking +Group bookings (5+ rooms): 7-day cancellation policy + +SMOKING +Property is entirely non-smoking including all rooms, balconies, and rooftop areas +Smoking permitted in designated outdoor area only — located at Harbor Street side entrance +Violation fee: $350 deep cleaning charge + +NOISE & QUIET HOURS +Quiet hours: 10:00 PM – 8:00 AM +Noise complaints: Call front desk, security will respond within 10 minutes + +VISITORS +Guests may have visitors in common areas at any time +Visitors in guest rooms: Permitted until 11:00 PM, must be registered at front desk +Overnight visitors: $50/night, must be added to reservation + +ACCESSIBILITY +Accessible rooms available on every floor, roll-in showers available on request +Hearing loop available at front desk +Service animals welcome at no charge +Accessible parking: 6 spaces on Level 1 of self-parking garage + +================================================== +FREQUENTLY ASKED QUESTIONS +================================================== + +Q: Can I get a room with a guaranteed ocean view? +A: Deluxe King, Deluxe Double, Junior Suite, Executive Suite, and Presidential Suite all have ocean views. Standard rooms have city views. We cannot guarantee a specific floor but can note your preference. + +Q: Is breakfast included? +A: Breakfast is not included in standard rates. Gold and Platinum members receive complimentary breakfast at Harbor Kitchen for up to 2 guests per room. A breakfast package can be added at booking for $38 per person per day. + +Q: What is the WiFi password? +A: WiFi is complimentary. Network name: MeridianGuest. Password is provided on your key card envelope at check-in. + +Q: Can I store luggage before check-in or after check-out? +A: Yes, luggage storage is complimentary and available at the bell desk 24 hours a day. + +Q: Is there a fee to use the pool or fitness center? +A: Both are complimentary for all registered guests. + +Q: Do you have a gift shop? +A: Yes, located in the lobby, open daily 7:00 AM – 10:00 PM. Stocks sundries, snacks, beverages, and Meridian-branded items. + +Q: Can I request a specific room or floor? +A: We accept room preferences and do our best to accommodate, but cannot guarantee specific rooms. Platinum members receive priority room assignments. + +Q: What time is the pool bar open? +A: The rooftop Azul Bar overlooks the pool and opens at 4:00 PM daily. Non-alcoholic beverages and light snacks can be ordered poolside from 10:00 AM through a QR code menu. + +Q: Is the hotel family-friendly? +A: Yes. We offer rollaway beds ($35/night), cribs (complimentary, request in advance), and a kids menu at Harbor Kitchen. Children under 12 stay free when sharing a room with parents. + +Q: Do you have EV charging? +A: Yes, 4 EV charging stalls are available in the self-parking garage at no extra charge, first-come first-served. \ No newline at end of file diff --git a/projects/meridian/package-lock.json b/projects/meridian/package-lock.json new file mode 100644 index 0000000..5d303ba --- /dev/null +++ b/projects/meridian/package-lock.json @@ -0,0 +1,1809 @@ +{ + "name": "meridian", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meridian", + "version": "1.0.0", + "dependencies": { + "@supabase/supabase-js": "^2.45.0", + "@vapi-ai/server-sdk": "^1.2.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "pg": "^8.12.0", + "posthog-node": "^5.38.4" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.0", + "tsx": "^4.15.7", + "typescript": "^5.5.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@posthog/core": { + "version": "1.39.6", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.6.tgz", + "integrity": "sha512-o6ajIwN5zXoNP0D4H/QPmOyibNTUkSyOR6ya7AG5U2ywXx4awo72L2KnCoiZPQM5x/bXv6jPBdimH8M18Ax0aw==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.392.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.392.1", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.1.tgz", + "integrity": "sha512-Qg6Gl7/1vlr8+gPtBi5gwnLgAgiyFoKOVmTvTtDcvya9cpTwZfna7rQmkGQ4B63CunUYNNbOlqcwiUwUDyTK6w==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.110.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.1.tgz", + "integrity": "sha512-8EJUoRoqzAYWkieHrLCrGrxs3cBbRB94xK8GzHe0aSLlSa4mt1enYLCiMgLiKJOF8GNYeX6PLTBNef0XAhILgw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.1.tgz", + "integrity": "sha512-Pm9HD9Qth6zQO0aOGZ3DxYb6QlCzrkTkP6eUEOvhTlbvcvYRDSCZXIJ5dkDLpqxD+gdrXfWDFOpU9BpRKYjsIQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.1.tgz", + "integrity": "sha512-BILNhS40oGlP2bn/VJjh/1hCyrWNMmpGZGcCrPvqd5R3s8gf8Bd5qyKoHZdMwTKynGCuJGjagRXa069abhibMw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.1.tgz", + "integrity": "sha512-JmWCpMoe2iJPNrXIdHqIvf2Mie6NfSuWHS9Ve6YCmLkn1mNl3vrK3a67IukJ5lPjgmAzB+jFagyMz2oSIX+Xnw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.1.tgz", + "integrity": "sha512-UqJGzhFMiIRBe8vT7a6ZtrN2U5YufH4Tk9nyc9KKfa2TUtV11BkSg/v3lyy/c4A8SbekVH3UywTF0s42zpF4zw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.1.tgz", + "integrity": "sha512-MiJh0THZgyAvTWm91FOLknjH5dYjXRKOdxpuUsyTVZVXIf7FxCShzevIm4KGCubHRU2n25Gyt8PhTXIFzcD9FA==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.1", + "@supabase/functions-js": "2.110.1", + "@supabase/postgrest-js": "2.110.1", + "@supabase/realtime-js": "2.110.1", + "@supabase/storage-js": "2.110.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@vapi-ai/server-sdk": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@vapi-ai/server-sdk/-/server-sdk-1.2.0.tgz", + "integrity": "sha512-0Ys/MEs+JoxmKQ/bTzMHUCJvzEWu+/XcFiN2m/GjTqVB2Uwv8Vi91D1/YGbRUH+q1aCBSH3g+yIOFroDDlJF1A==", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/posthog-node": { + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.40.0.tgz", + "integrity": "sha512-DrLfHuauO0W6qruF80iqr5JdmLysef74XzOB4eh36oRLRhxCySLraTqsi2Pj161LZnp9/JNdRDxwT8ei8VK2YA==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.39.6" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/projects/meridian/package.json b/projects/meridian/package.json new file mode 100644 index 0000000..9b85cdd --- /dev/null +++ b/projects/meridian/package.json @@ -0,0 +1,44 @@ +{ + "name": "meridian", + "version": "1.0.0", + "private": true, + "description": "Meridian — a 5-assistant Vapi squad: hotel + airline membership concierge, built native-Vapi (code tools, no webhook required)", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "check": "tsx src/test/check-connections.ts", + "migrate": "tsx src/db/migrate.ts", + "seed": "tsx src/db/seed.ts", + "kb:upload": "tsx src/vapi/upload-kb.ts", + "assistant:concierge": "tsx src/vapi/assistants/concierge.ts", + "assistant:hotel": "tsx src/vapi/assistants/hotel-concierge.ts", + "assistant:triage": "tsx src/vapi/assistants/flight-triage.ts", + "assistant:rebooking": "tsx src/vapi/assistants/rebooking.ts", + "assistant:upsell": "tsx src/vapi/assistants/upsell-recovery.ts", + "assistant:outbound": "tsx src/vapi/assistants/outbound-disruption.ts", + "squad": "tsx src/vapi/squad.ts", + "campaign": "tsx src/vapi/campaign.ts", + "simulations": "tsx src/vapi/simulations.ts", + "server": "tsx src/server.ts", + "test:synth": "tsx src/test/synth-reservation.ts", + "test:flight": "tsx src/test/flight-tools.ts", + "test:audit": "tsx src/test/audit-tools.ts", + "test:verify": "tsx src/test/verify-config.ts", + "test:webhook": "tsx src/test/simulate-webhook.ts" + }, + "dependencies": { + "@supabase/supabase-js": "^2.45.0", + "@vapi-ai/server-sdk": "^1.2.0", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "pg": "^8.12.0", + "posthog-node": "^5.38.4" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.0", + "tsx": "^4.15.7", + "typescript": "^5.5.2" + } +} diff --git a/projects/meridian/src/config.ts b/projects/meridian/src/config.ts new file mode 100644 index 0000000..1c6073b --- /dev/null +++ b/projects/meridian/src/config.ts @@ -0,0 +1,49 @@ +/** + * Meridian environment + shared Vapi client. + * + * Loads meridian/.env — copy .env.example to .env and fill in your values. + * Only VAPI_API_KEY is required for the core demo (assistants + squad); + * the rest enable persistence, the optional webhook server, and analytics. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import { VapiClient } from "@vapi-ai/server-sdk"; + +const here = path.dirname(fileURLToPath(import.meta.url)); // meridian/src +dotenv.config({ path: path.resolve(here, "../.env") }); // meridian/.env + +export function req(key: string): string { + const v = process.env[key]?.trim(); + if (!v) throw new Error(`Missing env var: ${key} — add it to meridian/.env`); + return v; +} +function opt(key: string): string | undefined { + return process.env[key]?.trim() || undefined; +} + +/** Accept a pasted REST endpoint (…/rest/v1/) and reduce it to the base project URL. */ +function normalizeSupabaseUrl(u?: string): string | undefined { + if (!u) return undefined; + return u.replace(/\/rest\/v1\/?$/, "").replace(/\/+$/, ""); +} + +export const env = { + vapiApiKey: req("VAPI_API_KEY"), + /** Shared secret for the optional webhook server (any string you invent). */ + webhookSecret: opt("VAPI_WEBHOOK_SECRET"), + webhookPort: Number(process.env.MERIDIAN_PORT ?? 3100), + serverUrl: opt("SERVER_URL"), + supabaseUrl: normalizeSupabaseUrl(opt("SUPABASE_URL")), + supabaseServiceKey: opt("SUPABASE_SERVICE_ROLE_KEY"), + supabaseDbUrl: opt("SUPABASE_DB_URL"), + posthogApiKey: opt("POSTHOG_API_KEY"), + /** + * File id of assets/hotel-knowledge-base.txt after uploading it to YOUR + * Vapi org (`npm run kb:upload` prints it). If unset, the hotel assistant + * is created without the knowledge-base query tool. + */ + hotelKbFileId: opt("HOTEL_KB_FILE_ID"), +}; + +export const vapi = new VapiClient({ token: env.vapiApiKey }); diff --git a/projects/meridian/src/db/client.ts b/projects/meridian/src/db/client.ts new file mode 100644 index 0000000..8df898b --- /dev/null +++ b/projects/meridian/src/db/client.ts @@ -0,0 +1,43 @@ +/** Supabase service-role client + row types. Server-side only (bypasses RLS). */ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { env } from "../config.js"; + +let _client: SupabaseClient | undefined; + +/** Lazily construct the client so the server can boot even before creds are set. */ +export function supabase(): SupabaseClient { + if (!_client) { + if (!env.supabaseUrl || !env.supabaseServiceKey) { + throw new Error("Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in meridian/.env"); + } + // env.supabaseUrl is normalized (any pasted /rest/v1 suffix stripped) so supabase-js + // builds /rest/v1/ correctly. + _client = createClient(env.supabaseUrl, env.supabaseServiceKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + } + return _client; +} + +export interface Guest { + id: string; + name: string; + email: string | null; + phone: string | null; + loyalty_tier: "standard" | "silver" | "gold" | "platinum" | null; + loyalty_points: number; + travel_credit_balance: number; +} + +export interface Reservation { + id: string; + confirmation_number: string; + guest_id: string | null; + property_name: string | null; + room_number: string | null; + room_type: string | null; + check_in: string | null; + check_out: string | null; + status: string; + special_requests: string | null; +} diff --git a/projects/meridian/src/db/migrate.ts b/projects/meridian/src/db/migrate.ts new file mode 100644 index 0000000..9e2c515 --- /dev/null +++ b/projects/meridian/src/db/migrate.ts @@ -0,0 +1,36 @@ +/** + * Applies db/schema.sql to your Supabase Postgres. + * Requires SUPABASE_DB_URL (Supabase → Project Settings → Database → Connection string → URI). + * + * npm run migrate + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import pg from "pg"; +import { env } from "../config.js"; +import { header } from "../utils/print.js"; + +header("Meridian — apply schema"); + +if (!env.supabaseDbUrl) { + throw new Error( + "Set SUPABASE_DB_URL in meridian/.env (Supabase → Project Settings → Database → Connection string → URI)." + ); +} + +const here = path.dirname(fileURLToPath(import.meta.url)); +const sql = readFileSync(path.resolve(here, "schema.sql"), "utf8"); + +const client = new pg.Client({ + connectionString: env.supabaseDbUrl, + ssl: { rejectUnauthorized: false }, // Supabase requires SSL +}); + +await client.connect(); +try { + await client.query(sql); + console.log("✓ Schema applied: guests, reservations, flight_bookings, service_requests, call_logs."); +} finally { + await client.end(); +} diff --git a/projects/meridian/src/db/schema.sql b/projects/meridian/src/db/schema.sql new file mode 100644 index 0000000..926cbd6 --- /dev/null +++ b/projects/meridian/src/db/schema.sql @@ -0,0 +1,81 @@ +-- Meridian schema — guests, reservations, flight bookings, service requests, call logs. +-- Re-runnable: every statement uses "if not exists". +create extension if not exists pgcrypto; + +create table if not exists guests ( + id uuid primary key default gen_random_uuid(), + name text not null, + email text, + phone text, + loyalty_tier text check (loyalty_tier in ('standard', 'silver', 'gold', 'platinum')), + loyalty_points integer default 0, + travel_credit_balance numeric(10,2) default 0, + created_at timestamptz default now() +); + +create table if not exists reservations ( + id uuid primary key default gen_random_uuid(), + confirmation_number text unique not null, + guest_id uuid references guests(id), + property_name text, + room_number text, + room_type text, + check_in date, + check_out date, + status text default 'active', + special_requests text, + created_at timestamptz default now() +); + +create table if not exists flight_bookings ( + id uuid primary key default gen_random_uuid(), + guest_id uuid references guests(id), + flight_number text not null, + origin text, + destination text, + departure_time timestamptz, + arrival_time timestamptz, + seat text, + cabin_class text default 'economy', + booking_reference text unique, + status text default 'active', + created_at timestamptz default now() +); + +create table if not exists service_requests ( + id uuid primary key default gen_random_uuid(), + reservation_id uuid references reservations(id), + guest_id uuid references guests(id), + request_type text, + description text, + status text default 'pending', + created_at timestamptz default now() +); + +create table if not exists call_logs ( + id uuid primary key default gen_random_uuid(), + vapi_call_id text unique, + guest_id uuid references guests(id), + assistant_name text, + call_type text, + resolution text, + transcript text, + upsell_offered boolean default false, + upsell_converted boolean default false, + duration_seconds integer, + created_at timestamptz default now() +); + +-- Lookup helpers for lookup_reservation (by name or confirmation number). +create index if not exists idx_guests_name_lower on guests (lower(name)); +create index if not exists idx_reservations_conf on reservations (confirmation_number); +create index if not exists idx_reservations_guest on reservations (guest_id); + +-- PostgREST API roles need explicit table privileges: tables created over a direct +-- postgres connection don't inherit Supabase's default grants. RLS stays OFF — all +-- access is server-side via the secret / service_role key. +grant usage on schema public to anon, authenticated, service_role; +grant all on all tables in schema public to anon, authenticated, service_role; +grant all on all sequences in schema public to anon, authenticated, service_role; +alter default privileges in schema public grant all on tables to anon, authenticated, service_role; +alter default privileges in schema public grant all on sequences to anon, authenticated, service_role; diff --git a/projects/meridian/src/db/seed.ts b/projects/meridian/src/db/seed.ts new file mode 100644 index 0000000..41dacd0 --- /dev/null +++ b/projects/meridian/src/db/seed.ts @@ -0,0 +1,63 @@ +/** + * Seeds one demo guest + reservation so Phase 1 lookups return a real hit. + * Idempotent: guest is found-or-created by email; reservation upserts on + * confirmation_number (which is unique in the schema). + * + * npm run seed + */ +import { supabase } from "./client.js"; +import { header, pretty } from "../utils/print.js"; + +header("Meridian — seed demo data"); +const db = supabase(); + +const DEMO_EMAIL = "jordan.rivera@example.com"; + +// guests.email is not unique in the schema, so find-or-create manually. +let { data: guest, error: findErr } = await db + .from("guests") + .select("*") + .eq("email", DEMO_EMAIL) + .maybeSingle(); +if (findErr) throw new Error(findErr.message); + +if (!guest) { + const ins = await db + .from("guests") + .insert({ + name: "Jordan Rivera", + email: DEMO_EMAIL, + phone: "+14155550142", + loyalty_tier: "gold", + loyalty_points: 24500, + travel_credit_balance: 0, + }) + .select() + .single(); + if (ins.error) throw new Error(ins.error.message); + guest = ins.data; +} + +// reservations.confirmation_number is unique → safe to upsert. +const { error: rErr } = await db.from("reservations").upsert( + { + confirmation_number: "MGH12345", + guest_id: guest.id, + property_name: "Meridian Grand Hotel", + room_number: "1204", + room_type: "King Suite", + check_in: "2026-06-22", + check_out: "2026-06-25", + status: "active", + special_requests: "High floor, late checkout requested", + }, + { onConflict: "confirmation_number" } +); +if (rErr) throw new Error(rErr.message); + +pretty("Seeded", { + guest: guest.name, + tier: guest.loyalty_tier, + confirmation: "MGH12345", + room: "1204 (King Suite)", +}); diff --git a/projects/meridian/src/routes/webhook.ts b/projects/meridian/src/routes/webhook.ts new file mode 100644 index 0000000..00434ef --- /dev/null +++ b/projects/meridian/src/routes/webhook.ts @@ -0,0 +1,166 @@ +/** + * Vapi webhook router. + * - Verifies the shared secret (x-vapi-secret header) on every request. + * - Dispatches `tool-calls` to handlers in the registry below. + * - Acks lifecycle events (call_logs wiring lands in Phase 4). + * + * Vapi resolves the webhook URL in this order: + * tool.server.url → assistant.server.url → phoneNumber.server.url → org.server.url + * Meridian wires it at the PHONE-NUMBER level (the Demo Line's serverUrl, routed to the + * Concierge Squad) — no assistant sets server. Code-tool calls run on Vapi infra and do NOT + * hit this handler; the standalone Outbound assistant has no server wiring either. + */ +import { Router, type Request, type Response } from "express"; +import { PostHog } from "posthog-node"; +import { env } from "../config.js"; +import { supabase } from "../db/client.js"; +import { lookupReservation } from "../tools/lookup_reservation.js"; + + +type ToolCtx = { callId?: string }; +type ToolHandler = (args: Record, ctx: ToolCtx) => unknown | Promise; + +const toolHandlers: Record = { + lookup_reservation: (args) => lookupReservation(args), + // handle_service_request, get_flight_status, find_alternative_flights, ... land in later phases. +}; + +/** Vapi sends tool-call arguments as a JSON string or an already-parsed object. */ +function parseArgs(raw: unknown): Record { + if (raw == null) return {}; + if (typeof raw === "object") return raw as Record; + if (typeof raw === "string") { + try { + return JSON.parse(raw); + } catch { + return {}; + } + } + return {}; +} + +export const webhookRouter = Router(); + +webhookRouter.post("/webhook", async (req: Request, res: Response) => { + // ── Native Vapi auth: shared secret echoed in the x-vapi-secret header ── + const provided = req.header("x-vapi-secret"); + // Fail closed: with no VAPI_WEBHOOK_SECRET configured, nothing authenticates. + if (!env.webhookSecret || !provided || provided !== env.webhookSecret) { + return res.status(401).json({ error: "unauthorized" }); + } + + const message = (req.body?.message ?? {}) as Record; + const type = message.type as string | undefined; + const callId = message.call?.id as string | undefined; + const ts = new Date().toISOString().slice(11, 19); + console.log(`[${ts}] ${type ?? "unknown"}${callId ? ` call=${callId.slice(0, 8)}` : ""}`); + + switch (type) { + case "tool-calls": { + const toolCallList = (message.toolCallList ?? []) as Array<{ + id: string; + function?: { name?: string; arguments?: unknown }; + }>; + + const results = await Promise.all( + toolCallList.map(async (tc) => { + const name = tc.function?.name; + const args = parseArgs(tc.function?.arguments); + const handler = name ? toolHandlers[name] : undefined; + if (!handler) { + return { toolCallId: tc.id, error: `Tool "${name}" not found` }; + } + try { + const output = await handler(args, { callId }); + console.log(` → ${name}(${JSON.stringify(args)}) → ok`); + // Vapi expects `result` as a string; stringify structured output. + return { toolCallId: tc.id, result: JSON.stringify(output) }; + } catch (err: any) { + console.error(`tool "${name}" failed:`, err?.message ?? err); + return { toolCallId: tc.id, error: `Tool "${name}" failed: ${err?.message ?? "unknown error"}` }; + } + }) + ); + return res.json({ results }); + } + + case "end-of-call-report": { + // Analytics sink: it must NEVER throw out of the handler, or Vapi sees a + // timeout and retries. Everything below is wrapped so we always 200. + try { + if (env.supabaseUrl && env.supabaseServiceKey) { + const call = message.call as Record | undefined; + const artifact = message.artifact as Record | undefined; + const analysis = message.analysis as Record | undefined; + const structured = (analysis?.structuredData ?? {}) as Record; + + const startMs = call?.startedAt ? new Date(call.startedAt).getTime() : null; + const endMs = call?.endedAt ? new Date(call.endedAt).getTime() : null; + const durationSeconds = startMs && endMs ? Math.round((endMs - startMs) / 1000) : null; + + const guestName: string | null = structured.guestName ?? structured.memberName ?? null; + let guestId: string | null = null; + if (guestName) { + const { data } = await supabase().from("guests").select("id").ilike("name", guestName).limit(1); + guestId = data?.[0]?.id ?? null; + } + + // Squad calls populate call.squad, not call.assistant — best-effort the active member, + // then fall back to the squad name so attribution is never silently null. + const assistantName: string | null = + call?.assistant?.name ?? (message as any)?.assistant?.name ?? call?.squad?.name ?? null; + const callType = assistantName === "Meridian — Outbound Disruption" ? "outbound" : "inbound"; + + // Members emit different field names; read them all (the analysis model fills only its own member's keys). + const upsellOffered: boolean = structured.upgradeOffered ?? structured.upsellOffered ?? false; + const upsellConverted: boolean = structured.upgradeRequested ?? structured.upsellConverted ?? false; + const creditRequested: boolean = + structured.creditRequestSubmitted ?? structured.creditFollowUpRaised ?? structured.creditRequestRaised ?? false; + + await supabase().from("call_logs").upsert( + { + vapi_call_id: call?.id ?? null, + guest_id: guestId, + assistant_name: assistantName, + call_type: callType, + resolution: call?.endedReason ?? null, + transcript: artifact?.transcript ?? null, + upsell_offered: upsellOffered, + upsell_converted: upsellConverted, + duration_seconds: durationSeconds, + }, + { onConflict: "vapi_call_id" } + ); + + if (env.posthogApiKey && call?.id) { + const resolvedEnded = call?.endedReason ?? "unknown"; + const deflected = ["customer-ended-call", "assistant-ended-call"].includes(resolvedEnded); + const ph = new PostHog(env.posthogApiKey, { host: "https://us.i.posthog.com" }); + ph.capture({ + distinctId: call.id, + event: "meridian_call_completed", + properties: { + assistant_name: assistantName, + call_type: callType, + resolution: resolvedEnded, + deflected, + upsell_offered: upsellOffered, + upsell_converted: upsellConverted, + credit_requested: creditRequested, + duration_seconds: durationSeconds, + guest_id: guestId, + }, + }); + await ph.shutdown(); + } + } + } catch (err: any) { + console.error("end-of-call-report handling failed (ignored):", err?.message ?? err); + } + return res.sendStatus(200); + } + + default: + return res.sendStatus(200); + } +}); diff --git a/projects/meridian/src/server.ts b/projects/meridian/src/server.ts new file mode 100644 index 0000000..71531c8 --- /dev/null +++ b/projects/meridian/src/server.ts @@ -0,0 +1,24 @@ +/** + * Meridian webhook server — the single endpoint Vapi calls for tool execution + * and call lifecycle events. + * + * npm run server # terminal 1 (:3100) + * ./bin/cloudflared tunnel --url http://localhost:3100 # terminal 2 → public URL + */ +import express from "express"; +import { env } from "./config.js"; +import { webhookRouter } from "./routes/webhook.js"; +import { header } from "./utils/print.js"; + +const app = express(); +// Vapi end-of-call reports (transcript + messages) can exceed Express's 100kb default. +app.use(express.json({ limit: "5mb" })); +app.use(webhookRouter); +app.get("/health", (_req, res) => res.json({ ok: true, service: "meridian" })); + +app.listen(env.webhookPort, () => { + header("Meridian webhook server"); + console.log(` POST http://localhost:${env.webhookPort}/webhook (x-vapi-secret required)`); + console.log(` GET http://localhost:${env.webhookPort}/health`); + console.log(`\n Expose publicly: ./bin/cloudflared tunnel --url http://localhost:${env.webhookPort}`); +}); diff --git a/projects/meridian/src/test/audit-tools.ts b/projects/meridian/src/test/audit-tools.ts new file mode 100644 index 0000000..3a3db91 --- /dev/null +++ b/projects/meridian/src/test/audit-tools.ts @@ -0,0 +1,114 @@ +/** + * Exhaustive runtime audit of every Meridian Code-tool body. + * Executes the EXACT shipped code against live Supabase across an input matrix, + * then scans every spoken `message` for TTS hazards and every return for framing + * violations. Prints a JSON report. Run: npx tsx meridian/src/test/audit-tools.ts + */ +import { env } from "../config.js"; +import { + GET_FLIGHT_STATUS_CODE, FIND_ALTERNATIVES_CODE, CONFIRM_REBOOK_CODE, COMPENSATION_CODE, +} from "../vapi/flight-code.js"; +import { SERVICE_REQUEST_CODE, ROOM_UPGRADE_CODE } from "../vapi/service-request-code.js"; +import { ISSUE_TRAVEL_CREDIT_CODE } from "../vapi/recovery-code.js"; +import { LOOKUP_CODE } from "../vapi/lookup-code.js"; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as any; +if (!env.supabaseUrl || !env.supabaseServiceKey) throw new Error("Supabase env missing"); +const toolEnv = { SUPABASE_URL: env.supabaseUrl, SUPABASE_SERVICE_ROLE_KEY: env.supabaseServiceKey }; + +const fns: Record = { + get_flight_status: new AsyncFunction("args", "env", GET_FLIGHT_STATUS_CODE), + find_alternative_flights: new AsyncFunction("args", "env", FIND_ALTERNATIVES_CODE), + confirm_rebook: new AsyncFunction("args", "env", CONFIRM_REBOOK_CODE), + compensation_engine: new AsyncFunction("args", "env", COMPENSATION_CODE), + handle_service_request: new AsyncFunction("args", "env", SERVICE_REQUEST_CODE), + request_room_upgrade: new AsyncFunction("args", "env", ROOM_UPGRADE_CODE), + issue_travel_credit: new AsyncFunction("args", "env", ISSUE_TRAVEL_CREDIT_CODE), + lookup_reservation: new AsyncFunction("args", "env", LOOKUP_CODE), +}; + +// ── TTS-hazard scanner ──────────────────────────────────────────────────────── +// A spoken string must contain no raw multi-digit run, no colon time, no ISO +// date, no $, and no hyphen between word-chars (heard as "minus"). +function scanSpoken(s: string): string[] { + const hits: string[] = []; + if (typeof s !== "string" || !s) return hits; + if (/\d\d/.test(s)) hits.push(`multi-digit run: "${(s.match(/\d[\d:]*\d/) || [])[0]}"`); + if (/\b\d{1,2}:\d{2}\b/.test(s)) hits.push("colon time"); + if (/\d{4}-\d{2}-\d{2}/.test(s)) hits.push("ISO date"); + if (/\$\s*\d/.test(s)) hits.push("dollar amount"); + if (/[A-Za-z]-[A-Za-z]|\d-\d/.test(s)) hits.push(`hyphen-as-minus: "${(s.match(/\S*-\S*/) || [])[0]}"`); + return hits; +} + +type Case = { tool: string; label: string; args: any; expect?: (r: any) => string[] }; +const cases: Case[] = [ + // lookup_reservation + { tool: "lookup_reservation", label: "new name (creates)", args: { name: "Audit Persona Alpha" } }, + { tool: "lookup_reservation", label: "same name (persists)", args: { name: "Audit Persona Alpha" } }, + { tool: "lookup_reservation", label: "unknown conf#", args: { confirmationNumber: "ZZ999" } }, + { tool: "lookup_reservation", label: "empty args", args: {} }, + // handle_service_request + { tool: "handle_service_request", label: "towels by name", args: { name: "Audit Persona Alpha", requestType: "extra towels", description: "two bath towels" } }, + { tool: "handle_service_request", label: "missing type (defaults)", args: { name: "Audit Persona Alpha" } }, + { tool: "handle_service_request", label: "no identifier", args: { requestType: "late checkout" } }, + // get_flight_status — many to hit all branches + tricky hours + bare-digit + oversized regressions + ...["UA482", "AA1011", "UA12", "DL2034", "DL-2034", "Delta 500", "AS7", "B6321", "WN1599", "NK88", "F9404", "QQ9999", + "1234", "482", "4825", "UA10000", "12345"].map((f) => ({ + tool: "get_flight_status", label: `status ${f}`, args: { flightNumber: f, name: "Audit Persona Alpha" }, + })), + { tool: "get_flight_status", label: "missing flightNumber", args: { name: "Audit Persona Alpha" } }, + // find_alternative_flights + { tool: "find_alternative_flights", label: "with route", args: { origin: "JFK", destination: "LAX" } }, + { tool: "find_alternative_flights", label: "no route", args: {} }, + // confirm_rebook + { tool: "confirm_rebook", label: "by ref + flight", args: { bookingReference: "BK929441", newFlightNumber: "DL2034" } }, + { tool: "confirm_rebook", label: "5-char ref (synthetic ok)", args: { bookingReference: "ABCDE", newFlightNumber: "UA777" } }, + { tool: "confirm_rebook", label: "by name", args: { name: "Audit Persona Alpha", newFlightNumber: "AA100" } }, + { tool: "confirm_rebook", label: "NOTHING to change", args: {} }, + { tool: "confirm_rebook", label: "seat only", args: { name: "Audit Persona Alpha", seat: "14C" } }, + { tool: "confirm_rebook", label: "non-ISO departure (must not throw)", args: { bookingReference: "BK929441", newFlightNumber: "DL2034", newDepartureTime: "nine forty AM" } }, + { tool: "confirm_rebook", label: "bare-digit flight by ref", args: { bookingReference: "BK929441", newFlightNumber: "1234" } }, + // compensation_engine — assert NO dollar field, NO $ in message + { tool: "compensation_engine", label: "cancelled gold", args: { cancelled: true, loyaltyTier: "gold" }, + expect: (r) => (r.voucherUSD !== undefined ? ["leaks voucherUSD to model"] : []) }, + { tool: "compensation_engine", label: "180min platinum", args: { delayMinutes: 180, loyaltyTier: "platinum" }, + expect: (r) => (r.currency !== undefined ? ["leaks currency to model"] : []) }, + { tool: "compensation_engine", label: "on-time standard", args: { delayMinutes: 0, loyaltyTier: "standard" } }, + // request_room_upgrade — offer must quote price; accept must be pending + { tool: "request_room_upgrade", label: "offer (no accept)", args: { name: "Audit Persona Alpha" }, + expect: (r) => (/confirmed|charged|done/i.test(r.message || "") ? ["offer claims confirmed/charged"] : []) }, + { tool: "request_room_upgrade", label: "accept (pending)", args: { name: "Audit Persona Alpha", accept: true }, + expect: (r) => (/\bconfirmed\b|charged now\.?$/i.test(r.message || "") && !/folio/i.test(r.message || "") ? ["accept not pending-framed"] : []) }, + // issue_travel_credit — pending only, no $ + { tool: "issue_travel_credit", label: "with name", args: { name: "Audit Persona Alpha", reason: "flight cancellation" }, + expect: (r) => (r.amountUSD !== undefined || r.newBalanceUSD !== undefined ? ["leaks amount/balance"] : []) }, + { tool: "issue_travel_credit", label: "missing name", args: {} }, +]; + +(async () => { + const report: any[] = []; + for (const c of cases) { + const entry: any = { tool: c.tool, label: c.label }; + try { + const r = await fns[c.tool](c.args, toolEnv); + entry.ok = true; + entry.message = r?.message ?? null; + const leaks = scanSpoken(r?.message ?? ""); + if (leaks.length) entry.ttsLeaks = leaks; + const framing = c.expect ? c.expect(r) : []; + if (framing.length) entry.framing = framing; + // surface a couple key return fields + entry.keys = Object.keys(r || {}); + } catch (e: any) { + entry.ok = false; + entry.error = String(e?.message ?? e); + } + report.push(entry); + } + const problems = report.filter((e) => !e.ok || e.ttsLeaks || e.framing); + console.log(JSON.stringify({ total: report.length, problems: problems.length, report }, null, 2)); + console.log(`\n=== ${problems.length} PROBLEM(S) of ${report.length} cases ===`); + for (const p of problems) console.log(` ✗ ${p.tool} / ${p.label}: ${p.error || JSON.stringify(p.ttsLeaks || p.framing)}`); + if (!problems.length) console.log(" ✓ all clean"); +})(); diff --git a/projects/meridian/src/test/check-connections.ts b/projects/meridian/src/test/check-connections.ts new file mode 100644 index 0000000..ae7a684 --- /dev/null +++ b/projects/meridian/src/test/check-connections.ts @@ -0,0 +1,78 @@ +/** + * Preflight — confirms every credential Meridian needs is present and live, + * without printing secret values. Run before migrate/seed or any phase. + * + * npm run check + */ +import pg from "pg"; +import { env } from "../config.js"; +import { header } from "../utils/print.js"; + +header("Meridian — preflight connection check"); + +function mask(v?: string) { + return v ? `${v.slice(0, 4)}…${v.slice(-4)} (len ${v.length})` : "MISSING"; +} + +/** Identify what's actually in the key field, without printing the secret. */ +function classifyKey(v?: string): string { + if (!v) return "MISSING"; + if (v.startsWith("eyJ")) return "JWT (anon or service_role) — correct shape ✓"; + if (v.startsWith("sb_secret_")) return "new secret key — correct shape ✓"; + if (v.startsWith("sb_publishable_")) return "PUBLISHABLE key ✗ (client-side — you need the SECRET / service_role)"; + if (/^postgres(ql)?:\/\//.test(v) || v.toLowerCase().startsWith("postgres")) return "Postgres connection string ✗ (wrong field — that belongs in SUPABASE_DB_URL)"; + if (v.startsWith("http")) return "a URL ✗ (wrong field)"; + return "unrecognized format ✗"; +} + +console.log("Env presence:"); +console.log(" VAPI_API_KEY :", env.vapiApiKey ? "set" : "MISSING"); +console.log(" VAPI_WEBHOOK_SECRET :", env.webhookSecret ? "set" : "MISSING"); +console.log(" SUPABASE_URL :", env.supabaseUrl ?? "MISSING"); +console.log(" SUPABASE_SERVICE_ROLE :", mask(env.supabaseServiceKey)); +console.log(" SERVICE_ROLE type :", classifyKey(env.supabaseServiceKey)); +console.log(" SUPABASE_DB_URL :", env.supabaseDbUrl ? "set" : "MISSING"); + +let ok = true; + +// 1) Supabase REST — used by seed.ts + lookup_reservation (supabase-js) +if (env.supabaseUrl && env.supabaseServiceKey) { + try { + const r = await fetch(`${env.supabaseUrl}/rest/v1/`, { + headers: { apikey: env.supabaseServiceKey, Authorization: `Bearer ${env.supabaseServiceKey}` }, + }); + console.log(`\nSupabase REST → HTTP ${r.status} ${r.ok ? "✓ URL + key valid" : "✗ check URL/key"}`); + if (!r.ok) ok = false; + } catch (e: any) { + console.log("\nSupabase REST → ✗", e?.message); + ok = false; + } +} else { + console.log("\nSupabase REST → skipped (URL/key missing)"); + ok = false; +} + +// 2) Postgres — used by migrate.ts (pg) +if (env.supabaseDbUrl) { + const c = new pg.Client({ connectionString: env.supabaseDbUrl, ssl: { rejectUnauthorized: false } }); + try { + await c.connect(); + const r = await c.query("select current_database() as db, current_user as usr"); + console.log("Postgres → ✓ connected:", r.rows[0]); + } catch (e: any) { + console.log("Postgres → ✗", e?.message); + ok = false; + } finally { + try { + await c.end(); + } catch { + /* ignore */ + } + } +} else { + console.log("Postgres → skipped (DB URL missing)"); + ok = false; +} + +console.log(`\n${ok ? "✓ All systems go — ready to migrate + seed." : "✗ Fix the items marked ✗ above."}`); +process.exit(ok ? 0 : 1); diff --git a/projects/meridian/src/test/flight-tools.ts b/projects/meridian/src/test/flight-tools.ts new file mode 100644 index 0000000..79f9796 --- /dev/null +++ b/projects/meridian/src/test/flight-tools.ts @@ -0,0 +1,40 @@ +/** + * Live integration test for the airline Code tools — executes the EXACT code + * shipped to Vapi against real Supabase (status → alternatives → rebook) plus + * the pure compensation calc. + * + * npm run test:flight + */ +import { env } from "../config.js"; +import { GET_FLIGHT_STATUS_CODE, FIND_ALTERNATIVES_CODE, CONFIRM_REBOOK_CODE, COMPENSATION_CODE } from "../vapi/flight-code.js"; +import { header, pretty } from "../utils/print.js"; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as any; +if (!env.supabaseUrl || !env.supabaseServiceKey) throw new Error("Supabase env missing — fill meridian/.env"); +const toolEnv = { SUPABASE_URL: env.supabaseUrl, SUPABASE_SERVICE_ROLE_KEY: env.supabaseServiceKey }; + +const getStatus = new AsyncFunction("args", "env", GET_FLIGHT_STATUS_CODE); +const findAlt = new AsyncFunction("args", "env", FIND_ALTERNATIVES_CODE); +const confirmRebook = new AsyncFunction("args", "env", CONFIRM_REBOOK_CODE); +const compensation = new AsyncFunction("args", "env", COMPENSATION_CODE); + +header("Airline Code tools — live integration"); + +const st = await getStatus({ flightNumber: "UA482", name: "Justin Crowe" }, toolEnv); +pretty("get_flight_status UA482 / Justin Crowe", st); + +const alts = await findAlt({ origin: st.origin, destination: st.destination }, toolEnv); +pretty("find_alternative_flights", alts); + +const chosen = alts.options[0]; +const rb = await confirmRebook( + { bookingReference: st.bookingReference, newFlightNumber: chosen.flightNumber, newDepartureTime: chosen.departureTime }, + toolEnv +); +pretty(`confirm_rebook → ${chosen.flightNumber}`, rb); + +// Pure calc — show a real disruption voucher (gold, 3h delay) regardless of UA482's roll. +const comp = await compensation({ delayMinutes: 180, loyaltyTier: "gold" }, toolEnv); +pretty("compensation_engine (gold, 180 min)", comp); +const compCx = await compensation({ cancelled: true, loyaltyTier: "platinum" }, toolEnv); +pretty("compensation_engine (platinum, cancelled)", compCx); diff --git a/projects/meridian/src/test/simulate-webhook.ts b/projects/meridian/src/test/simulate-webhook.ts new file mode 100644 index 0000000..3659ee3 --- /dev/null +++ b/projects/meridian/src/test/simulate-webhook.ts @@ -0,0 +1,53 @@ +/** + * Phase 1 check — POSTs a fake Vapi `tool-calls` webhook for lookup_reservation to + * the local server, exactly as Vapi would (including the x-vapi-secret header). + * Also confirms a request WITHOUT the secret is rejected (401). + * + * npm run server # terminal 1 + * npm run test:webhook -- MGH12345 # terminal 2 (optional conf# arg) + */ +import { env, req as reqEnv } from "../config.js"; +import { header, pretty } from "../utils/print.js"; + +const base = `http://localhost:${env.webhookPort}`; +const secret = reqEnv("VAPI_WEBHOOK_SECRET"); +const conf = process.argv[2] ?? "MGH12345"; + +function toolCallsPayload(args: Record) { + return { + message: { + type: "tool-calls", + call: { id: "sim-call-001" }, + toolCallList: [ + { + id: "toolcall_sim_1", + type: "function", + function: { name: "lookup_reservation", arguments: JSON.stringify(args) }, + }, + ], + }, + }; +} + +header("Simulate: lookup_reservation tool-call"); + +// 1) Negative: missing secret must be rejected. +const noAuth = await fetch(`${base}/webhook`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(toolCallsPayload({ confirmationNumber: conf })), +}); +console.log(`no-secret request → HTTP ${noAuth.status} (expect 401)`); + +// 2) Authenticated call. +const res = await fetch(`${base}/webhook`, { + method: "POST", + headers: { "content-type": "application/json", "x-vapi-secret": secret }, + body: JSON.stringify(toolCallsPayload({ confirmationNumber: conf })), +}); +const json = (await res.json().catch(() => ({}))) as any; +pretty(`authenticated request → HTTP ${res.status}`, json); + +// Decode the tool result so you can read what Aria would receive. +const raw = json?.results?.[0]?.result; +if (raw) pretty("decoded tool result", JSON.parse(raw)); diff --git a/projects/meridian/src/test/synth-reservation.ts b/projects/meridian/src/test/synth-reservation.ts new file mode 100644 index 0000000..e1863f9 --- /dev/null +++ b/projects/meridian/src/test/synth-reservation.ts @@ -0,0 +1,40 @@ +/** + * Live integration test for the standalone code tools — executes the EXACT code + * shipped to Vapi (lookup-code.ts / service-request-code.ts) against your real + * Supabase. The bodies use `await`, so they're reconstructed as async functions. + * + * npm run test:synth + */ +import { env } from "../config.js"; +import { LOOKUP_CODE } from "../vapi/lookup-code.js"; +import { SERVICE_REQUEST_CODE } from "../vapi/service-request-code.js"; +import { header, pretty } from "../utils/print.js"; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as any; +const lookup = new AsyncFunction("args", "env", LOOKUP_CODE); +const serviceRequest = new AsyncFunction("args", "env", SERVICE_REQUEST_CODE); + +if (!env.supabaseUrl || !env.supabaseServiceKey) throw new Error("Supabase env missing — fill meridian/.env"); +const toolEnv = { SUPABASE_URL: env.supabaseUrl, SUPABASE_SERVICE_ROLE_KEY: env.supabaseServiceKey }; + +header("Standalone code tools — live Supabase integration"); + +async function lookupCheck(label: string, args: Record) { + const a = await lookup(args, toolEnv); + const b = await lookup(args, toolEnv); // second call must match → persisted + const same = JSON.stringify(a) === JSON.stringify(b); + pretty(`${label} [persists: ${same ? "✓" : "✗"}]`, a); + return a; +} + +const justin = await lookupCheck('name "Justin Crowe"', { name: "Justin Crowe" }); +await lookupCheck('name "Mallory Gray"', { name: "Mallory Gray" }); +await lookupCheck('conf "MGH12345"', { confirmationNumber: "MGH12345" }); + +header("handle_service_request"); +const sr = await serviceRequest( + { name: "Justin Crowe", requestType: "extra towels", description: "two extra bath towels" }, + toolEnv +); +pretty("service request →", sr); +console.log(`\nattached to room ${justin?.reservation?.roomNumber} | reference ${sr?.reference}`); diff --git a/projects/meridian/src/test/verify-config.ts b/projects/meridian/src/test/verify-config.ts new file mode 100644 index 0000000..1771bf0 --- /dev/null +++ b/projects/meridian/src/test/verify-config.ts @@ -0,0 +1,115 @@ +/** + * Live config verification — checks phone routing, squad membership, DISTINCT + * per-member voices, uniform Deepgram transcriber + barge-in, transferCall + * presence, that no duplicate assistant names exist, and that the squad does + * NOT pin a single voice via membersOverrides (which would collapse all members + * to one voice). + * + * npm run test:verify + */ +import { vapi } from "../config.js"; +import { header } from "../utils/print.js"; + +header("Meridian — live config verification"); + +const SQUAD_NAME = "Meridian Concierge Squad"; + +// Squad members resolved by NAME (ids change on recreate; names are the contract). +const MEMBER_NAMES = [ + "Meridian — Concierge", + "Meridian — Hotel Concierge (Jack)", + "Meridian — Flight Triage", + "Meridian — Rebooking", + "Meridian — Upsell & Recovery", +]; + +let failures = 0; +function ok(label: string) { console.log(` ✓ ${label}`); } +function fail(label: string) { console.log(` ✗ ${label}`); failures++; } + +// ── 0. Resolve the squad by name ────────────────────────────────────────────── +const squads = await vapi.squads.list({ limit: 100 }); +const squadMatch = squads.find((s) => s.name === SQUAD_NAME); +if (!squadMatch) { + console.log(`✗ Squad "${SQUAD_NAME}" not found — run \`npm run squad\` first.`); + process.exit(1); +} +const SQUAD_ID = squadMatch.id; + +// ── 1. Phone number → squad (skipped if no phone number points at the squad) ── +console.log(`\n── Phone number ──`); +const phones = await vapi.phoneNumbers.list(); +const phone = phones.find((p) => (p as unknown as Record)["squadId"] === SQUAD_ID); +if (phone) ok(`${(phone as { number?: string }).number ?? phone.id} → ${SQUAD_NAME}`); +else console.log(` – no phone number routes to the squad yet (attach one in the dashboard to take live calls)`); + +// ── 2. No duplicate assistant names ─────────────────────────────────────────── +const allAssistants = await vapi.assistants.list({ limit: 100 }); +console.log(`\n── Duplicate-name check ──`); +const byName = new Map(); +for (const a of allAssistants) { + if (!a.name?.startsWith("Meridian")) continue; + byName.set(a.name, [...(byName.get(a.name) ?? []), a.id]); +} +for (const [name, ids] of byName) { + if (ids.length > 1) fail(`duplicate name "${name}" → ${ids.join(", ")} (delete the stale one)`); +} +if (![...byName.values()].some((ids) => ids.length > 1)) ok("all Meridian assistant names are unique"); + +// ── 3. Squad membership (by name) ───────────────────────────────────────────── +const squad = await vapi.squads.get({ id: SQUAD_ID }); +console.log(`\n── Squad: "${squad.name}" (${squad.members.length} members) ──`); +const idToName = new Map(allAssistants.map((a) => [a.id, a.name ?? ""])); +const memberNames = new Set(squad.members.map((m) => idToName.get((m as { assistantId: string }).assistantId) ?? "?")); +for (const name of MEMBER_NAMES) { + memberNames.has(name) ? ok(name) : fail(`"${name}" missing from squad (members: ${[...memberNames].join(", ")})`); +} + +// ── 4. Squad must NOT pin a single voice ────────────────────────────────────── +console.log(`\n── membersOverrides ──`); +const mo = (squad as Record).membersOverrides ?? {}; +if (mo.voice) fail(`membersOverrides.voice=${JSON.stringify(mo.voice)} pins ONE voice across all members — remove it`); +else ok("membersOverrides has no voice pin (members keep distinct voices)"); +const moWords = mo.stopSpeakingPlan?.numWords ?? 0; +moWords >= 5 ? ok(`membersOverrides.stopSpeakingPlan.numWords=${moWords}`) : fail(`membersOverrides barge-in numWords=${moWords} (want ≥5)`); + +// ── 5. Per-assistant: voice, transcriber, barge-in, transferCall ────────────── +const meridian = allAssistants.filter((a) => a.name?.startsWith("Meridian —")); +console.log(`\n── Assistant configs (${meridian.length} found) ──`); +const memberVoices: Record = {}; +for (const a of meridian) { + const name = a.name ?? a.id; + const voice = a.voice as { provider?: string; voiceId?: string } | undefined; + const transcriber = a.transcriber as { provider?: string } | undefined; + const ssp = a.stopSpeakingPlan as { numWords?: number } | undefined; + const tools = ((a.model as { tools?: Array<{ type: string }> } | undefined)?.tools) ?? []; + const hasTransfer = tools.some((t) => t.type === "transferCall"); + const isOutbound = name === "Meridian — Outbound Disruption"; + + const voiceOk = voice?.provider === "deepgram" && !!voice?.voiceId; + const transcriberOk = transcriber?.provider === "deepgram"; + const sspOk = (ssp?.numWords ?? 0) >= 5; + const transferOk = isOutbound ? !hasTransfer : hasTransfer; + + if (voiceOk && transcriberOk && sspOk && transferOk) ok(`${name} [${voice?.voiceId}]`); + else console.log(` ✗ ${name}`); + if (!voiceOk) fail(` voice: ${voice?.provider}/${voice?.voiceId} (want deepgram/)`); + if (!transcriberOk) fail(` transcriber: ${transcriber?.provider ?? "none"} (want deepgram)`); + if (!sspOk) fail(` stopSpeakingPlan.numWords=${ssp?.numWords ?? "none"} (want ≥5)`); + if (!transferOk) fail(` transferCall ${hasTransfer ? "present" : "absent"} (${isOutbound ? "outbound should NOT have it" : "should be present"})`); + + if (MEMBER_NAMES.includes(name) && voice?.voiceId) memberVoices[name] = voice.voiceId; +} + +// ── 6. The five squad members must have DISTINCT voices ─────────────────────── +console.log(`\n── Distinct member voices ──`); +const voiceList = Object.values(memberVoices); +const uniqueVoices = new Set(voiceList); +if (voiceList.length === MEMBER_NAMES.length && uniqueVoices.size === voiceList.length) { + ok(`5 distinct voices: ${MEMBER_NAMES.map((n) => `${n.replace("Meridian — ", "")}=${memberVoices[n]}`).join(", ")}`); +} else { + fail(`voices not all distinct: ${JSON.stringify(memberVoices)}`); +} + +console.log(`\n${failures === 0 ? "✓ All checks passed — config looks correct." : `✗ ${failures} check(s) failed — see above.`}`); +process.exit(failures === 0 ? 0 : 1); diff --git a/projects/meridian/src/tools/lookup_reservation.ts b/projects/meridian/src/tools/lookup_reservation.ts new file mode 100644 index 0000000..828ebc5 --- /dev/null +++ b/projects/meridian/src/tools/lookup_reservation.ts @@ -0,0 +1,92 @@ +/** + * lookup_reservation — find a guest's reservation by confirmation number + * (preferred, unique) or by name. Returns a verbal-friendly profile for Aria. + */ +import { supabase, type Guest, type Reservation } from "../db/client.js"; + +export interface LookupReservationArgs { + name?: string; + confirmationNumber?: string; +} + +type ReservationRow = Reservation & { guests?: Guest | null }; + +function shape(reservation: ReservationRow, guest: Guest | null | undefined) { + return { + found: true as const, + guest: guest + ? { + firstName: guest.name?.split(" ")[0] ?? guest.name, + fullName: guest.name, + loyaltyTier: guest.loyalty_tier ?? "standard", + loyaltyPoints: guest.loyalty_points ?? 0, + } + : null, + reservation: { + confirmationNumber: reservation.confirmation_number, + property: reservation.property_name, + roomNumber: reservation.room_number, + roomType: reservation.room_type, + checkIn: reservation.check_in, + checkOut: reservation.check_out, + status: reservation.status, + specialRequests: reservation.special_requests, + }, + }; +} + +export async function lookupReservation(args: LookupReservationArgs) { + const db = supabase(); + const conf = args.confirmationNumber?.trim(); + const name = args.name?.trim(); + + // 1) Confirmation number is unique — try it first. + if (conf) { + const { data, error } = await db + .from("reservations") + .select("*, guests(*)") + .ilike("confirmation_number", conf) + .maybeSingle(); + if (error) throw new Error(error.message); + if (data) return shape(data as ReservationRow, (data as ReservationRow).guests ?? null); + } + + // 2) Fall back to name → newest reservation for that guest. + if (name) { + const { data: guests, error: gErr } = await db.from("guests").select("*").ilike("name", name).limit(3); + if (gErr) throw new Error(gErr.message); + + if (guests && guests.length > 1) { + return { + found: false as const, + reason: "multiple_matches", + message: + "I'm seeing more than one guest under that name. Could you share your confirmation number so I pull up the right reservation?", + }; + } + if (guests && guests.length === 1) { + const guest = guests[0] as Guest; + const { data: r, error: rErr } = await db + .from("reservations") + .select("*") + .eq("guest_id", guest.id) + .order("check_in", { ascending: false }) + .limit(1) + .maybeSingle(); + if (rErr) throw new Error(rErr.message); + if (r) return shape(r as ReservationRow, guest); + return { + found: false as const, + reason: "guest_without_reservation", + message: `I found your profile but no active reservation. Want me to take down a new request?`, + }; + } + } + + return { + found: false as const, + reason: "no_match", + message: + "I couldn't find a reservation under that name or confirmation number. Could you spell the last name, or read me the confirmation number?", + }; +} diff --git a/projects/meridian/src/utils/print.ts b/projects/meridian/src/utils/print.ts new file mode 100644 index 0000000..f6ecf10 --- /dev/null +++ b/projects/meridian/src/utils/print.ts @@ -0,0 +1,9 @@ +export function pretty(label: string, data: unknown) { + console.log(`\n── ${label} ──`); + console.log(JSON.stringify(data, null, 2)); +} + +export function header(title: string) { + const line = "─".repeat(title.length + 4); + console.log(`\n${line}\n ${title}\n${line}`); +} diff --git a/projects/meridian/src/vapi/assistants/concierge.ts b/projects/meridian/src/vapi/assistants/concierge.ts new file mode 100644 index 0000000..ac9e26a --- /dev/null +++ b/projects/meridian/src/vapi/assistants/concierge.ts @@ -0,0 +1,62 @@ +/** + * Meridian — Concierge (squad front door). Thin router: greets neutrally, + * figures out hotel vs flight intent, and transfers. No business/lookup tools — + * identification happens in the specialist it routes to — but it DOES carry the + * required transferCall tool (with assistantName destinations); removing it + * breaks all routing. Routing destinations also live in the squad member config + * (see ../squad.ts). + * + * Run: npm run assistant:concierge (ASSISTANT_ID= to upsert) + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { vapi } from "../../config.js"; +import { STOP_SPEAKING_PLAN } from "../speaking.js"; +import { header, pretty } from "../../utils/print.js"; + +header("Meridian — create/update Concierge (front door)"); + +const SYSTEM_PROMPT = `# Identity +You are Aria, the Meridian member concierge — the first voice a member hears. Meridian is a premium travel membership: one concierge for a member's whole trip, hotel and air. Warm and natural — 1 to 2 sentences. + +# Your only job: identify what they need, then call the transfer function. +- FLIGHT — status, delay, cancellation, rebooking, a flight seat or cabin upgrade, or any flight change → transfer to flight. +- HOTEL — reservation, room, amenities, hours, policies, housekeeping, in-room request, room/suite upgrade, or check-in → transfer to hotel. +- BOTH — if they say "both" (or describe a flight AND a hotel matter), say you'll start them with the flight team and they can be handed to the hotel team next, then transfer to flight. + +# Transfer rules — these are mandatory +- A one-word answer is enough intent: if they say just "flight", transfer to flight; if just "hotel", transfer to hotel. Do NOT re-ask or confirm — transfer right away. +- Call the transfer function the moment intent is clear. Do NOT say goodbye, ask "anything else?", or end the call yourself. +- If they say "transfer me to flight" or "connect me to hotel" — transfer immediately. +- Callers often say their name first. Acknowledge warmly, then transfer if you know what they need. +- If they say only "upgrade" without specifying, ask ONCE: "Is that a room upgrade at the hotel, or a flight seat upgrade?" then route accordingly. +- If intent is still unclear after one reply, ask ONCE: "Is this about your flight, your hotel, or both?" Never ask it twice. +- Never look anything up or collect details — the specialist handles all of that.`; + +const config: Vapi.CreateAssistantDto = { + name: "Meridian — Concierge", + model: { + provider: "openai", + model: "gpt-4o", + temperature: 0.3, + messages: [{ role: "system", content: SYSTEM_PROMPT }], + tools: [ + { + type: "transferCall", + destinations: [ + { type: "assistant", assistantName: "Meridian — Hotel Concierge (Jack)", description: "Anything about the HOTEL STAY: a reservation, check-in or check-out time, late checkout, amenities and hours (pool, spa, gym, restaurant), Wi-Fi, parking or valet, pet policy, housekeeping, towels, room service, a room or suite upgrade for the hotel stay, directions, or changing/cancelling a hotel reservation." }, + { type: "assistant", assistantName: "Meridian — Flight Triage", description: "Anything about a FLIGHT: flight status, a delay, a cancellation, 'my flight or trip was cancelled', changing or rebooking a flight, or a flight seat or cabin upgrade." }, + ], + }, + ], + }, + voice: { provider: "deepgram", voiceId: "asteria" }, + transcriber: { provider: "deepgram", model: "nova-2" }, + firstMessage: "Thanks for calling Meridian, this is Aria. How can I help you today — is it about your flight, your hotel, or both?", + startSpeakingPlan: { waitSeconds: 0.6 }, + stopSpeakingPlan: STOP_SPEAKING_PLAN, +}; + +const existingId = process.env.ASSISTANT_ID; +const assistant = existingId ? await vapi.assistants.update({ id: existingId, ...config }) : await vapi.assistants.create(config); +pretty(existingId ? "Updated assistant" : "Created assistant", { id: assistant.id, name: assistant.name }); +console.log(`\nUpsert: ASSISTANT_ID=${assistant.id} npm run assistant:concierge`); diff --git a/projects/meridian/src/vapi/assistants/flight-triage.ts b/projects/meridian/src/vapi/assistants/flight-triage.ts new file mode 100644 index 0000000..5b8a355 --- /dev/null +++ b/projects/meridian/src/vapi/assistants/flight-triage.ts @@ -0,0 +1,98 @@ +/** + * Meridian — Flight Triage. Standalone (Code tool → Supabase REST; no webhook). + * Identifies the caller + flight, pulls status, and flags when rebooking is + * warranted. The triage → rebooking hand-off is wired in Phase 4 (squad). + * + * Run: npm run assistant:triage (ASSISTANT_ID= to upsert) + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { env, vapi } from "../../config.js"; +import { GET_FLIGHT_STATUS_CODE } from "../flight-code.js"; +import { STOP_SPEAKING_PLAN } from "../speaking.js"; +import { header, pretty } from "../../utils/print.js"; + +header("Meridian — create/update Flight Triage"); + +if (!env.supabaseUrl || !env.supabaseServiceKey) { + throw new Error("Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in meridian/.env."); +} +const supabaseEnv = [ + { name: "SUPABASE_URL", value: env.supabaseUrl }, + { name: "SUPABASE_SERVICE_ROLE_KEY", value: env.supabaseServiceKey }, +]; + +const SYSTEM_PROMPT = `# Identity +You are Maya, the Meridian flight specialist. Calm, fast, reassuring. 2 sentences or fewer. If they already gave their name earlier in the call, use it — don't ask again. + +# Decide what they need +- STATUS ("is my flight on time?", a delay question): ask for the flight number if you don't have it, call get_flight_status, and read back its 'message' field. Route to the flight-changes specialist ONLY when the tool returns needsRebooking = true (cancellation, or a delay of two hours or more) — don't decide from the spoken hours. +- A CHANGE, REBOOK, or SEAT/CABIN UPGRADE ("change my flight", "different flight", "upgrade my seat"): you don't need to run a status check — hand off to the flight-changes specialist to handle it. + +# Scope — you can only check status and transfer +- You do NOT rebook, quote prices or compensation, or issue credits. For any of those, transfer to the flight-changes specialist immediately. +- For baggage or flight check-in, explain those are handled at the airport counter or in the airline app and can't be processed on this call — don't attempt them. +- Never call any tool other than get_flight_status. Never attempt a tool you weren't given. + +# Tool rules +- Call get_flight_status one at a time; say "let me check that flight" while you wait. +- Read back the tool's 'message' field verbatim — it is already spoken-word formatted. Never read the raw flightNumber code, minute counts, or any timestamp field aloud. Never invent flight options. + +# Close +- If the caller came in with BOTH a flight and a hotel matter, once the flight is resolved proactively offer to connect them to the hotel team and transfer to the hotel concierge — don't just ask "anything else?". +- Otherwise, only ask "Is there anything else I can help with?" if you have fully resolved the issue without a transfer. If you are routing to a specialist, call the transfer function immediately — do not ask first.`; + +const tools: Vapi.CreateAssistantDtoModel["tools"] = [ + { + type: "transferCall", + destinations: [ + { type: "assistant", assistantName: "Meridian — Rebooking", description: "Flight is cancelled or delayed two hours or more and the caller needs to be rebooked." }, + { type: "assistant", assistantName: "Meridian — Hotel Concierge (Jack)", description: "Caller shifts to a HOTEL matter: a reservation, room, amenities, or a service request." }, + ], + }, + { + type: "code", + function: { + name: "get_flight_status", + description: + "Get a flight's current status (on-time / delayed / cancelled), delay length, route, and whether rebooking is warranted. Optionally pass the caller's name.", + parameters: { + type: "object", + properties: { + flightNumber: { type: "string", description: "The flight number, e.g. UA482." }, + name: { type: "string", description: "Caller's full name, if given." }, + }, + required: ["flightNumber"], + }, + }, + environmentVariables: supabaseEnv, + code: GET_FLIGHT_STATUS_CODE, + }, +]; + +const config: Vapi.CreateAssistantDto = { + name: "Meridian — Flight Triage", + model: { provider: "openai", model: "gpt-4o", temperature: 0.3, messages: [{ role: "system", content: SYSTEM_PROMPT }], tools }, + voice: { provider: "deepgram", voiceId: "luna" }, + transcriber: { provider: "deepgram", model: "nova-2" }, + firstMessage: "Hi, this is Maya at the Meridian flight desk. Can I get your name and your flight number?", + analysisPlan: { + structuredDataPlan: { + enabled: true, + schema: { + type: "object", + properties: { + guestName: { type: "string", description: "The caller's full name." }, + flightNumber: { type: "string", description: "The flight number." }, + disruptionType: { type: "string", description: "on-time, minor-delay, major-delay, or cancelled." }, + }, + }, + }, + }, + startSpeakingPlan: { waitSeconds: 0.6, transcriptionEndpointingPlan: { onNumberSeconds: 2 } }, + stopSpeakingPlan: STOP_SPEAKING_PLAN, +}; + +const existingId = process.env.ASSISTANT_ID; +const assistant = existingId ? await vapi.assistants.update({ id: existingId, ...config }) : await vapi.assistants.create(config); +pretty(existingId ? "Updated assistant" : "Created assistant", { id: assistant.id, name: assistant.name, tools: tools.map((t) => ("function" in t && t.function ? t.function.name : t.type)) }); +console.log(`\nUpsert: ASSISTANT_ID=${assistant.id} npm run assistant:triage`); diff --git a/projects/meridian/src/vapi/assistants/hotel-concierge.ts b/projects/meridian/src/vapi/assistants/hotel-concierge.ts new file mode 100644 index 0000000..082ebf0 --- /dev/null +++ b/projects/meridian/src/vapi/assistants/hotel-concierge.ts @@ -0,0 +1,186 @@ +/** + * Meridian — Hotel Concierge ("Jack") — STANDALONE build (Phase 2). + * + * Zero hosting on your side: every tool runs on Vapi's infrastructure. + * - lookup_reservation : Code tool → Supabase REST. Returns a persisted + * reservation for a known guest/conf#, or invents + persists a RANDOM one + * for a new name (so any caller gets a consistent record thereafter). + * - handle_service_request : Code tool → Supabase REST. Logs a service request + * (towels, late checkout, restaurant booking, …) against the reservation. + * - hotel-knowledge-base : query tool (Vapi/Google) over the uploaded file. + * + * Supabase is hosted, so this stays standalone — no webhook, tunnel, or laptop. + * Run: npm run assistant:hotel (ASSISTANT_ID= to upsert) + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { env, vapi } from "../../config.js"; +import { LOOKUP_CODE } from "../lookup-code.js"; +import { SERVICE_REQUEST_CODE } from "../service-request-code.js"; +import { STOP_SPEAKING_PLAN } from "../speaking.js"; +import { header, pretty } from "../../utils/print.js"; + +header("Meridian — create/update Hotel Concierge (Jack) [standalone, Phase 2]"); + +if (!env.supabaseUrl || !env.supabaseServiceKey) { + throw new Error( + "Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in meridian/.env — the code tools read them as Vapi env vars." + ); +} +const supabaseEnv = [ + { name: "SUPABASE_URL", value: env.supabaseUrl }, + { name: "SUPABASE_SERVICE_ROLE_KEY", value: env.supabaseServiceKey }, +]; + +const SYSTEM_PROMPT = `# Identity +You are Jack, a warm and efficient hotel concierge for Meridian Grand Hotel. Keep every reply to 2 sentences or fewer. + +# On every call +1. On your FIRST turn, introduce yourself by name — "this is Jack, the hotel concierge". After a squad transfer the handoff line has already named you, so don't re-introduce yourself — just continue. +2. If the caller's name or confirmation number is already in the conversation, use it to call lookup_reservation without re-asking. Only ask for a name or confirmation number if you don't have one yet. Then greet the guest by first name. +3. Help with what they need: a service request, an amenity or policy question, or reservation details. + +# Transfers +- If the caller raises a FLIGHT matter (status, delay, cancellation, rebooking, a seat or cabin upgrade on a flight), transfer to Flight Triage immediately. +- If the caller is checking in today or wants a hotel ROOM upgrade, transfer to Upsell & Recovery immediately. +- Transfer the moment intent is clear — do not ask "anything else?" first. + +# Service requests +When a guest wants something for their room or stay — extra towels, late checkout, housekeeping, a restaurant booking, transportation — call handle_service_request with a short requestType (e.g. "extra towels", "late checkout", "restaurant booking") and a one-line description. Pass their confirmation number or name so it attaches to the reservation, then confirm warmly using the request type and room it returns. (Don't read the internal reference code aloud.) + +# Knowledge base +Use the knowledge base for amenity hours, room types, hotel services, loyalty tiers, policies, and FAQs. Always query it before saying you don't know something — never guess hours, prices, or policies. + +# Out of scope +You only handle the current guest's stay at Meridian Grand Hotel. For anything outside that — searching for other properties, making new reservations, pricing quotes for future stays — politely explain you can only assist with an existing reservation here, and suggest they visit meridianhotels.com or call the reservations line. Never attempt a tool call you weren't given. + +# Tool rules +- Call one tool at a time; say a short filler like "Let me take care of that" while you wait. +- State only what a tool or the knowledge base returns — don't invent details. +- Read confirmation numbers and room numbers back one character/digit at a time (e.g. room "one seven oh two"). +- Speak dates and times as natural words ("June twenty fifth", "three in the afternoon"), never as digits, colons, hyphens, or ISO format like 2026-06-25. + +# Close +If you've fully helped and no transfer is needed, ask if there's anything else. If a transfer is warranted, transfer immediately instead — don't ask first.`; + +const tools: NonNullable = [ + { + type: "transferCall", + destinations: [ + { type: "assistant", assistantName: "Meridian — Flight Triage", description: "Caller shifts to a FLIGHT matter: flight status, a delay, a cancellation, rebooking, a seat or cabin upgrade on a flight, or any other flight-related request." }, + { type: "assistant", assistantName: "Meridian — Upsell & Recovery", description: "Guest is checking in today or wants a hotel room upgrade (not flight-related)." }, + ], + }, + { + type: "code", + function: { + name: "lookup_reservation", + description: + "Look up a guest's reservation by confirmation number (preferred) or full name. Returns guest profile, loyalty tier, room, and stay dates. A new name gets a freshly assigned reservation that persists.", + parameters: { + type: "object", + properties: { + confirmationNumber: { type: "string", description: "Booking confirmation number, if the caller has it." }, + name: { type: "string", description: "Caller's full name, if there's no confirmation number." }, + }, + }, + }, + environmentVariables: supabaseEnv, + code: LOOKUP_CODE, + }, + { + type: "code", + function: { + name: "handle_service_request", + description: + "Log a guest service request (extra towels, late checkout, housekeeping, restaurant booking, transportation, etc.) against their reservation. Returns a confirmation reference.", + parameters: { + type: "object", + properties: { + requestType: { type: "string", description: "Short label, e.g. 'extra towels', 'late checkout', 'restaurant booking'." }, + description: { type: "string", description: "One-line detail of the request." }, + confirmationNumber: { type: "string", description: "The guest's confirmation number, if known." }, + name: { type: "string", description: "The guest's full name, if there's no confirmation number." }, + }, + required: ["requestType"], + }, + }, + environmentVariables: supabaseEnv, + code: SERVICE_REQUEST_CODE, + }, + // KB query tool only when assets/hotel-knowledge-base.txt has been uploaded + // to this org (`npm run kb:upload`) and HOTEL_KB_FILE_ID is set in .env. + ...(env.hotelKbFileId + ? [ + { + type: "query" as const, + knowledgeBases: [ + { + name: "hotel-knowledge-base", + provider: "google" as const, + model: "gemini-2.5-flash" as const, + description: + "Meridian Grand Hotel: property overview & hours, room types & amenities, pool/spa/fitness/restaurant details, services (valet, housekeeping, dry cleaning, transportation, pet policy), loyalty tiers (standard/silver/gold/platinum), and policies (cancellation, smoking, noise, visitor, accessibility) plus FAQs.", + fileIds: [env.hotelKbFileId], + }, + ], + }, + ] + : []), +]; + +if (!env.hotelKbFileId) { + console.log( + "Note: HOTEL_KB_FILE_ID not set — creating Jack WITHOUT the knowledge-base tool.\n" + + "Run `npm run kb:upload` and add the printed id to .env to enable it." + ); +} + +const config: Vapi.CreateAssistantDto = { + name: "Meridian — Hotel Concierge (Jack)", + model: { + provider: "openai", + model: "gpt-4o", + temperature: 0.3, + messages: [{ role: "system", content: SYSTEM_PROMPT }], + tools, + }, + voice: { provider: "deepgram", voiceId: "orion" }, + transcriber: { provider: "deepgram", model: "nova-2" }, + firstMessage: + "Thanks for calling Meridian Grand Hotel, this is Jack. May I have your name or confirmation number to pull up your reservation?", + // Extract call-level structured data → call.analysis.structuredData (gate item). + analysisPlan: { + structuredDataPlan: { + enabled: true, + schema: { + type: "object", + properties: { + guestName: { type: "string", description: "The caller's full name." }, + requestType: { + type: "string", + description: "The service request type, if any (towels, late checkout, restaurant booking, etc.).", + }, + roomNumber: { type: "string", description: "The guest's room number." }, + }, + }, + }, + }, + startSpeakingPlan: { + waitSeconds: 0.6, + transcriptionEndpointingPlan: { onNumberSeconds: 2 }, + }, + stopSpeakingPlan: STOP_SPEAKING_PLAN, +}; + +const existingId = process.env.ASSISTANT_ID; +const assistant = existingId + ? await vapi.assistants.update({ id: existingId, ...config }) + : await vapi.assistants.create(config); + +pretty(existingId ? "Updated assistant" : "Created assistant", { + id: assistant.id, + name: assistant.name, + tools: tools.map((t) => ("function" in t && t.function ? t.function.name : t.type)), +}); +console.log("\nStandalone — no webhook/tunnel. Upsert in place next time:"); +console.log(` ASSISTANT_ID=${assistant.id} npm run assistant:hotel`); diff --git a/projects/meridian/src/vapi/assistants/outbound-disruption.ts b/projects/meridian/src/vapi/assistants/outbound-disruption.ts new file mode 100644 index 0000000..edaf2e6 --- /dev/null +++ b/projects/meridian/src/vapi/assistants/outbound-disruption.ts @@ -0,0 +1,147 @@ +/** + * Meridian — Outbound Disruption. Standalone (Code tools → Supabase REST; no webhook). + * Proactively calls members when a flight is cancelled or delayed 2h+. + * Fully self-contained: finds alternatives, confirms the rebook, and mentions the travel + * credit — no transfer needed, which keeps the outbound flow to a single call. + * + * Run: npm run assistant:outbound (ASSISTANT_ID= to upsert) + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { env, vapi } from "../../config.js"; +import { FIND_ALTERNATIVES_CODE, CONFIRM_REBOOK_CODE, COMPENSATION_CODE } from "../flight-code.js"; +import { STOP_SPEAKING_PLAN } from "../speaking.js"; +import { header, pretty } from "../../utils/print.js"; + +header("Meridian — create/update Outbound Disruption"); + +if (!env.supabaseUrl || !env.supabaseServiceKey) { + throw new Error("Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in meridian/.env."); +} +const supabaseEnv = [ + { name: "SUPABASE_URL", value: env.supabaseUrl }, + { name: "SUPABASE_SERVICE_ROLE_KEY", value: env.supabaseServiceKey }, +]; + +// Spoken if the member declines/is busy. Set CALLBACK_NUMBER to your demo line +// (spell it the way it should be READ ALOUD, e.g. "nine seven zero, ..."). +const CALLBACK_LINE = process.env.CALLBACK_NUMBER + ? `give them the callback number (${process.env.CALLBACK_NUMBER})` + : `let them know they can call the Meridian member line back anytime`; + +const SYSTEM_PROMPT = `# Identity +You are Meridian's outbound disruption specialist. You placed this call — the member did not call you. +Your opening message already told them which flight was disrupted and why you're calling. Be brief. + +# Your flow +1. Confirm they want to rebook. If they decline or are busy, ${CALLBACK_LINE} and close warmly. +2. If yes, call find_alternative_flights with the origin and destination from your opening message. + Read at most 3 options with departure times. Never invent flight numbers. +3. When they choose a flight, call confirm_rebook with their name and the chosen flight number. + State only what the tool returns — never invent confirmation numbers. Read the tool's message as given; never read a raw flight code or ISO time aloud. +4. Call compensation_engine with the disruption type and their loyalty tier (standard if unknown). + Tell them only that a travel-credit request has been noted for review — never quote a dollar amount or say it's been applied. + +# Rules +- One tool at a time. Short filler while you wait ("Let me pull those up..."). +- Never take a card number. Never quote a credit dollar amount — credits are requests the team reviews. +- 1–2 sentences per turn maximum. +- If they are already at the airport or have been rebooked, acknowledge and close warmly.`; + +const tools: Vapi.CreateAssistantDtoModel["tools"] = [ + { + type: "code", + function: { + name: "find_alternative_flights", + description: "Find up to 3 alternative flights for a disrupted route. Pass origin and destination airport codes.", + parameters: { + type: "object", + properties: { + origin: { type: "string", description: "Origin airport code, e.g. LAX." }, + destination: { type: "string", description: "Destination airport code, e.g. MIA." }, + }, + }, + }, + code: FIND_ALTERNATIVES_CODE, + }, + { + type: "code", + function: { + name: "confirm_rebook", + description: "Rebook the member onto a new flight. Pass their name and the chosen flight number.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "The member's full name." }, + bookingReference: { type: "string", description: "Booking reference if known, e.g. BK929441." }, + newFlightNumber: { type: "string", description: "The chosen flight number, e.g. UA891." }, + newDepartureTime: { type: "string", description: "Optional. Only an exact ISO timestamp; omit otherwise — never pass a spoken time." }, + seat: { type: "string", description: "Seat assignment, if changing." }, + }, + required: ["name", "newFlightNumber"], + }, + }, + environmentVariables: supabaseEnv, + code: CONFIRM_REBOOK_CODE, + }, + { + type: "code", + function: { + name: "compensation_engine", + description: "Calculate the travel-credit voucher owed for this disruption.", + parameters: { + type: "object", + properties: { + cancelled: { type: "boolean", description: "True if the flight was cancelled." }, + disruptionType: { type: "string", description: "on-time, minor-delay, major-delay, or cancelled." }, + delayMinutes: { type: "number", description: "Delay length in minutes (for a delay)." }, + delayHours: { type: "number", description: "Delay length in hours (alternative to delayMinutes)." }, + loyaltyTier: { type: "string", description: "standard, silver, gold, or platinum." }, + }, + }, + }, + code: COMPENSATION_CODE, + }, +]; + +const config: Vapi.CreateAssistantDto = { + name: "Meridian — Outbound Disruption", + model: { + provider: "openai", + model: "gpt-4o", + temperature: 0.3, + messages: [{ role: "system", content: SYSTEM_PROMPT }], + tools, + }, + voice: { provider: "deepgram", voiceId: "asteria" }, + transcriber: { provider: "deepgram", model: "nova-2" }, + // Generic fallback — campaign script overrides firstMessage per customer. + firstMessage: "Hi, this is Aria from Meridian — I'm calling about a disruption on your upcoming flight. Can I help get you rebooked?", + firstMessageMode: "assistant-speaks-first", + startSpeakingPlan: { waitSeconds: 0.6 }, + stopSpeakingPlan: STOP_SPEAKING_PLAN, + analysisPlan: { + structuredDataPlan: { + enabled: true, + schema: { + type: "object", + properties: { + memberName: { type: "string", description: "The member's full name." }, + rebookAccepted: { type: "boolean", description: "Whether the member accepted the rebooking offer." }, + rebookedFlight: { type: "string", description: "The new flight number, if rebooked." }, + creditRequestRaised: { type: "boolean", description: "Whether a travel-credit request was noted for review." }, + }, + }, + }, + }, +}; + +const existingId = process.env.ASSISTANT_ID; +const assistant = existingId + ? await vapi.assistants.update({ id: existingId, ...config }) + : await vapi.assistants.create(config); +pretty(existingId ? "Updated assistant" : "Created assistant", { + id: assistant.id, + name: assistant.name, + tools: tools.map((t) => ("function" in t && t.function ? t.function.name : t.type)), +}); +console.log(`\nUpsert: ASSISTANT_ID=${assistant.id} npm run assistant:outbound`); diff --git a/projects/meridian/src/vapi/assistants/rebooking.ts b/projects/meridian/src/vapi/assistants/rebooking.ts new file mode 100644 index 0000000..0ba1785 --- /dev/null +++ b/projects/meridian/src/vapi/assistants/rebooking.ts @@ -0,0 +1,116 @@ +/** + * Meridian — Rebooking specialist. Standalone (Code tools; confirm_rebook → + * Supabase REST; no webhook). Presents alternatives and confirms the rebook + * (persists). Travel credits are NOT handled here — they hand off to member care. + * + * Run: npm run assistant:rebooking (ASSISTANT_ID= to upsert) + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { env, vapi } from "../../config.js"; +import { FIND_ALTERNATIVES_CODE, CONFIRM_REBOOK_CODE } from "../flight-code.js"; +import { STOP_SPEAKING_PLAN } from "../speaking.js"; +import { header, pretty } from "../../utils/print.js"; + +header("Meridian — create/update Rebooking specialist"); + +if (!env.supabaseUrl || !env.supabaseServiceKey) { + throw new Error("Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in meridian/.env."); +} +const supabaseEnv = [ + { name: "SUPABASE_URL", value: env.supabaseUrl }, + { name: "SUPABASE_SERVICE_ROLE_KEY", value: env.supabaseServiceKey }, +]; + +const SYSTEM_PROMPT = `# Identity +You are Marcus, a Meridian rebooking specialist. Warm, efficient, solution-focused. Keep replies to 2 sentences or fewer. + +# Flow +1. The caller's flight was disrupted. Confirm their name (and booking reference if they have it). +2. Call find_alternative_flights for their route. Read at most 3 options with departure times — never more than 3. +3. When they choose one, call confirm_rebook with their booking reference (or name) plus the chosen flight number, then confirm the new flight clearly. +4. After a cancellation or a delay of two hours or more, the caller may be owed a travel credit — but you do NOT handle credits. Let them know member care will follow up, and transfer to Upsell & Recovery. + +# Scope — flights only +- You rebook disrupted flights and change seats. You do NOT issue travel credits or quote compensation amounts — for those, transfer to member care. Paid cabin upgrades are arranged at the airport or online, not on this call. +- Never call a tool that isn't listed for you. Never attempt a tool you weren't given. + +# Tool rules +- Call one tool at a time; say a short filler like "Let me find your options" while you wait. +- State only what the tools return — never invent flights, times, or credit amounts. +- Speak any time aloud as words ("three forty PM"), never as 9:47 or an ISO timestamp. The flight options already come back spoken-word formatted — read them as given. + +# Close +Ask if there's anything else you can help with.`; + +const tools: Vapi.CreateAssistantDtoModel["tools"] = [ + { + type: "transferCall", + destinations: [ + { type: "assistant", assistantName: "Meridian — Upsell & Recovery", description: "Rebooking is confirmed after a cancellation or two-hour-plus delay — hand off to member care for post-disruption follow-up." }, + { type: "assistant", assistantName: "Meridian — Flight Triage", description: "Caller wants to re-check flight status before deciding." }, + ], + }, + { + type: "code", + function: { + name: "find_alternative_flights", + description: "Find up to 3 alternative flights for a route. Pass origin and destination airport codes if known.", + parameters: { + type: "object", + properties: { + origin: { type: "string", description: "Origin airport code, e.g. LAX." }, + destination: { type: "string", description: "Destination airport code, e.g. MIA." }, + }, + }, + }, + code: FIND_ALTERNATIVES_CODE, + }, + { + type: "code", + function: { + name: "confirm_rebook", + description: "Rebook the guest's disrupted flight onto a new flight (and optionally change their seat). Pass booking reference (or name) and the chosen flight number. Does NOT handle paid cabin upgrades or travel credits.", + parameters: { + type: "object", + properties: { + bookingReference: { type: "string", description: "The booking reference, e.g. BK929441." }, + name: { type: "string", description: "The guest's full name, if no booking reference." }, + newFlightNumber: { type: "string", description: "The chosen flight number." }, + newDepartureTime: { type: "string", description: "Optional. Only an exact ISO timestamp; omit otherwise — never pass a spoken time, and never read it aloud." }, + seat: { type: "string", description: "Seat assignment to change to, e.g. 14A." }, + }, + required: ["newFlightNumber"], + }, + }, + environmentVariables: supabaseEnv, + code: CONFIRM_REBOOK_CODE, + }, +]; + +const config: Vapi.CreateAssistantDto = { + name: "Meridian — Rebooking", + model: { provider: "openai", model: "gpt-4o", temperature: 0.3, messages: [{ role: "system", content: SYSTEM_PROMPT }], tools }, + voice: { provider: "deepgram", voiceId: "arcas" }, + transcriber: { provider: "deepgram", model: "nova-2" }, + firstMessage: "Hi, this is Marcus, Meridian's rebooking specialist — I'll get you sorted. Can I get your name or booking reference?", + analysisPlan: { + structuredDataPlan: { + enabled: true, + schema: { + type: "object", + properties: { + guestName: { type: "string", description: "The caller's full name." }, + rebookedFlight: { type: "string", description: "The new flight number, if rebooked." }, + creditFollowUpRaised: { type: "boolean", description: "Whether the caller was told member care will follow up on a travel credit." }, + }, + }, + }, + }, + startSpeakingPlan: { waitSeconds: 0.6, transcriptionEndpointingPlan: { onNumberSeconds: 2 } }, + stopSpeakingPlan: STOP_SPEAKING_PLAN, +}; + +const existingId = process.env.ASSISTANT_ID; +const assistant = existingId ? await vapi.assistants.update({ id: existingId, ...config }) : await vapi.assistants.create(config); +pretty(existingId ? "Updated assistant" : "Created assistant", { id: assistant.id, name: assistant.name, tools: tools.map((t) => ("function" in t && t.function ? t.function.name : t.type)) }); +console.log(`\nUpsert: ASSISTANT_ID=${assistant.id} npm run assistant:rebooking`); diff --git a/projects/meridian/src/vapi/assistants/upsell-recovery.ts b/projects/meridian/src/vapi/assistants/upsell-recovery.ts new file mode 100644 index 0000000..ad922f4 --- /dev/null +++ b/projects/meridian/src/vapi/assistants/upsell-recovery.ts @@ -0,0 +1,121 @@ +/** + * Meridian — Upsell & Recovery. Standalone (Code tools → Supabase REST). + * Two jobs: room-upgrade upsell on check-in, and travel-credit recovery after a + * flight disruption. In the squad, Hotel Concierge and Rebooking hand off here. + * + * Run: npm run assistant:upsell (ASSISTANT_ID= to upsert) + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { env, vapi } from "../../config.js"; +import { ROOM_UPGRADE_CODE } from "../service-request-code.js"; +import { ISSUE_TRAVEL_CREDIT_CODE } from "../recovery-code.js"; +import { STOP_SPEAKING_PLAN } from "../speaking.js"; +import { header, pretty } from "../../utils/print.js"; + +header("Meridian — create/update Upsell & Recovery"); + +if (!env.supabaseUrl || !env.supabaseServiceKey) { + throw new Error("Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in meridian/.env."); +} +const supabaseEnv = [ + { name: "SUPABASE_URL", value: env.supabaseUrl }, + { name: "SUPABASE_SERVICE_ROLE_KEY", value: env.supabaseServiceKey }, +]; + +const SYSTEM_PROMPT = `# Identity +You are Sophie, a Meridian member-care specialist for upgrades and service recovery. Warm, generous within reason, concise — 2 sentences or fewer. Introduce yourself by name on your first turn. + +# Intent first — decide before you act +Figure out which job this is BEFORE doing anything: +- A room upgrade or a check-in is ALWAYS the upgrade branch. Never mention or offer a travel credit for it. +- Only the recovery branch may mention a travel credit, and ONLY when the caller has a CONFIRMED flight cancellation or a delay of two hours or more. If there's no confirmed disruption, do not bring up a credit at all. + +# A) Upgrade (check-in or "nicer room") +1. Call request_room_upgrade (accept omitted) to fetch one offer; read the room and rate back exactly as the tool's message states them — the rate is already spoken-word formatted, so never restate it as digits or with a dollar sign. +2. If they ask the cost, state the price BEFORE proceeding — answer the question, don't skip to confirming. +3. Only when they clearly say yes, call request_room_upgrade again with accept true. Then tell them it's been REQUESTED and goes on their folio at check-in — never that it's "confirmed", "done", or charged. Never take a card number. + +# B) Recovery (post-disruption only) +1. Confirm the disruption (cancellation, or a delay of two hours or more). If the conversation already shows one — e.g. you were just transferred from rebooking — acknowledge it and proceed without re-asking what happened. If there's no confirmed disruption, don't proceed. +2. Call issue_travel_credit with the guest's name and a short reason. +3. Tell the guest only that the request has been submitted for team review. Never speak a dollar amount or balance, and never say the credit was applied, approved, or added. + +# Tool rules +- Call one tool at a time; short filler while you wait. Never take a card number. +- State only what the tools return — never invent prices, amounts, or balances. + +# Close +Ask if there's anything else you can help with.`; + +const tools: Vapi.CreateAssistantDtoModel["tools"] = [ + { + type: "transferCall", + destinations: [ + { type: "assistant", assistantName: "Meridian — Hotel Concierge (Jack)", description: "Caller shifts back to a HOTEL-stay matter." }, + { type: "assistant", assistantName: "Meridian — Flight Triage", description: "Caller shifts to a FLIGHT matter." }, + ], + }, + { + type: "code", + function: { + name: "request_room_upgrade", + description: "Offer one room upgrade with its nightly rate (call with accept omitted to fetch the offer so you can quote the price). Call again with accept=true ONLY after the guest agrees — this submits a PENDING upgrade request added to the folio at check-in; it does not charge anything.", + parameters: { + type: "object", + properties: { + confirmationNumber: { type: "string", description: "Confirmation number, if known." }, + name: { type: "string", description: "Guest's full name." }, + accept: { type: "boolean", description: "true only once the guest has agreed to the quoted upgrade; omit/false to just fetch the offer." }, + }, + }, + }, + environmentVariables: supabaseEnv, + code: ROOM_UPGRADE_CODE, + }, + { + type: "code", + function: { + name: "issue_travel_credit", + description: "Submit a PENDING travel-credit request for team review after a confirmed flight disruption. Does not apply a credit or touch any balance, and returns no dollar amount. Pass the guest's name and a short reason.", + parameters: { + type: "object", + properties: { + name: { type: "string", description: "The guest's full name." }, + reason: { type: "string", description: "Short reason, e.g. 'flight cancellation' or 'three-hour delay'." }, + }, + required: ["name"], + }, + }, + environmentVariables: supabaseEnv, + code: ISSUE_TRAVEL_CREDIT_CODE, + }, +]; + +const config: Vapi.CreateAssistantDto = { + name: "Meridian — Upsell & Recovery", + model: { provider: "openai", model: "gpt-4o", temperature: 0.3, messages: [{ role: "system", content: SYSTEM_PROMPT }], tools }, + voice: { provider: "deepgram", voiceId: "stella" }, + transcriber: { provider: "deepgram", model: "nova-2" }, + firstMessage: "Hi, this is Sophie with Meridian member care — how can I help you today?", + analysisPlan: { + structuredDataPlan: { + enabled: true, + schema: { + type: "object", + properties: { + guestName: { type: "string", description: "The caller's full name." }, + upgradeOffered: { type: "boolean", description: "True once a room-upgrade offer with a price was presented to the guest, regardless of whether they accepted." }, + upgradeRequested: { type: "boolean", description: "True only if the guest accepted and a room-upgrade request was submitted." }, + creditRequestSubmitted: { type: "boolean", description: "Whether a travel-credit request was submitted for team review." }, + }, + }, + }, + }, + startSpeakingPlan: { waitSeconds: 0.6, transcriptionEndpointingPlan: { onNumberSeconds: 2 } }, + stopSpeakingPlan: STOP_SPEAKING_PLAN, +}; + +const existingId = process.env.ASSISTANT_ID; +const assistant = existingId ? await vapi.assistants.update({ id: existingId, ...config }) : await vapi.assistants.create(config); +pretty(existingId ? "Updated assistant" : "Created assistant", { id: assistant.id, name: assistant.name, tools: tools.map((t) => ("function" in t && t.function ? t.function.name : t.type)) }); +console.log(`\nUpsert: ASSISTANT_ID=${assistant.id} npm run assistant:upsell`); diff --git a/projects/meridian/src/vapi/campaign.ts b/projects/meridian/src/vapi/campaign.ts new file mode 100644 index 0000000..c9afd2b --- /dev/null +++ b/projects/meridian/src/vapi/campaign.ts @@ -0,0 +1,180 @@ +/** + * Meridian — Outbound Disruption Campaign. + * Proactively calls members affected by a flight disruption. + * Looks up affected members from Supabase, builds personalized opening messages, + * and places outbound calls in one batch via vapi.calls.create. + * + * Note: Vapi's /campaign API requires a Twilio/Vonage/Telnyx number. + * This script uses vapi.calls.create with a `customers` array, which works + * with Vapi-provisioned numbers and achieves the same proactive outbound effect. + * + * Required env: + * FLIGHT_NUMBER e.g. UA482 + * PHONE_NUMBER_ID Vapi phone number ID (Dashboard → Phone Numbers → copy ID) + * OR omit — it will auto-pick the first number in your org. + * + * Optional env: + * DISRUPTION_TYPE cancelled | major-delay (default: cancelled) + * DELAY_MINUTES delay in minutes for major-delay, e.g. 180 + * ORIGIN origin airport code override, e.g. LAX + * DESTINATION destination airport code override, e.g. MIA + * TEST_PHONE E164 number to add as a test call (for demo/dry-run) + * + * npm run campaign + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { vapi, env } from "../config.js"; +import { supabase } from "../db/client.js"; +import { header, pretty } from "../utils/print.js"; + +header("Meridian — Outbound Disruption Campaign"); + +if (!env.supabaseUrl || !env.supabaseServiceKey) { + throw new Error("Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in meridian/.env."); +} + +// ── Config from env ──────────────────────────────────────────────────────────── + +const FLIGHT_NUMBER = (process.env.FLIGHT_NUMBER ?? "").toUpperCase().trim(); +if (!FLIGHT_NUMBER) throw new Error("Set FLIGHT_NUMBER env var, e.g. FLIGHT_NUMBER=UA482 npm run campaign"); + +const DISRUPTION_TYPE = ((process.env.DISRUPTION_TYPE ?? "cancelled").toLowerCase()) as "cancelled" | "major-delay"; +const DELAY_MINUTES = Number(process.env.DELAY_MINUTES ?? 0); +const ORIGIN = (process.env.ORIGIN ?? "").toUpperCase() || null; +const DESTINATION = (process.env.DESTINATION ?? "").toUpperCase() || null; +const TEST_PHONE = process.env.TEST_PHONE?.trim() || null; + +// ── Resolve the outbound assistant ──────────────────────────────────────────── + +const allAssistants = await vapi.assistants.list({ limit: 100 }); +const outboundAssistant = allAssistants.find((a) => a.name === "Meridian — Outbound Disruption"); +if (!outboundAssistant) { + throw new Error( + `Assistant "Meridian — Outbound Disruption" not found.\nCreate it first: npm run assistant:outbound` + ); +} + +// ── Resolve the outbound phone number ───────────────────────────────────────── + +let phoneNumberId = process.env.PHONE_NUMBER_ID?.trim() ?? ""; +if (!phoneNumberId) { + const phoneNumbers = await vapi.phoneNumbers.list({ limit: 20 }); + const first = phoneNumbers[0]; + if (!first) { + throw new Error("No phone numbers found in your Vapi org.\nAdd one at dashboard.vapi.ai/phone-numbers, then set PHONE_NUMBER_ID=."); + } + phoneNumberId = first.id; + const display = (first as { number?: string }).number ?? first.id; + console.log(`Auto-selected phone number: ${display} (set PHONE_NUMBER_ID to pin a specific one)\n`); +} + +// ── Look up affected members from Supabase ───────────────────────────────────── + +const db = supabase(); +type BookingWithGuest = { + booking_reference: string | null; + origin: string | null; + destination: string | null; + cabin_class: string | null; + guests: { + id: string; + name: string; + phone: string | null; + loyalty_tier: string | null; + } | null; +}; + +const { data: bookings, error: dbErr } = await db + .from("flight_bookings") + .select("booking_reference, origin, destination, cabin_class, guests(id, name, phone, loyalty_tier)") + .eq("flight_number", FLIGHT_NUMBER) + .eq("status", "active") + .not("guest_id", "is", null); + +if (dbErr) throw new Error(`Supabase query failed: ${dbErr.message}`); +const rows = (bookings as unknown as BookingWithGuest[]) ?? []; +const withPhone = rows.filter((r) => r.guests?.phone); +const skipped = rows.length - withPhone.length; + +// ── Build per-customer opening messages ──────────────────────────────────────── + +function buildFirstMessage(name: string, origin: string | null, dest: string | null): string { + const from = origin ? ` from ${origin}` : ""; + const to = dest ? ` to ${dest}` : ""; + const route = `${FLIGHT_NUMBER}${from}${to}`; + + if (DISRUPTION_TYPE === "cancelled") { + return `Hi ${name}, this is Aria from Meridian — I'm calling because your flight ${route} has been cancelled. We'd like to get you rebooked right now. Can I find you the next available option?`; + } + const delayHrs = DELAY_MINUTES ? Math.round(DELAY_MINUTES / 60) : 2; + const hrs = `${delayHrs} hour${delayHrs === 1 ? "" : "s"}`; + return `Hi ${name}, this is Aria from Meridian — your flight ${route} is delayed by about ${hrs}. We can move you to an earlier option if you'd prefer. Can I pull up some alternatives?`; +} + +const customers: Vapi.CreateCustomerDto[] = withPhone.map((r) => { + const g = r.guests!; + const o = ORIGIN ?? r.origin; + const d = DESTINATION ?? r.destination; + return { + number: g.phone!, + name: g.name, + assistantOverrides: { + firstMessage: buildFirstMessage(g.name, o, d), + }, + }; +}); + +// Optional test customer (a real phone you own, for demo/dry-run) +if (TEST_PHONE) { + customers.push({ + number: TEST_PHONE, + name: "Test", + assistantOverrides: { + firstMessage: buildFirstMessage("there", ORIGIN, DESTINATION), + }, + }); +} + +pretty("Members resolved", { + onFlight: rows.length, + withPhone: withPhone.length, + skippedNoPhone: skipped, + testCustomer: TEST_PHONE ? 1 : 0, + totalCalls: customers.length, +}); + +if (customers.length === 0) { + console.log(`\nNo callable members found for flight ${FLIGHT_NUMBER}.`); + console.log(`Options:`); + console.log(` • Seed a member: npm run seed (adds Jordan Rivera with phone +14155550142)`); + console.log(` • Dry-run now: TEST_PHONE=+1xxxxxxxxxx FLIGHT_NUMBER=${FLIGHT_NUMBER} npm run campaign`); + process.exit(0); +} + +// ── Place outbound calls ─────────────────────────────────────────────────────── +// +// Vapi's /campaign API requires a Twilio/Vonage/Telnyx number, not Vapi-provisioned. +// calls.create with a `customers` array works with any number and achieves the same +// proactive outbound blast effect. + +const batchName = `Meridian — Disruption: ${FLIGHT_NUMBER} ${DISRUPTION_TYPE}`; + +const result = await vapi.calls.create({ + name: batchName, + assistantId: outboundAssistant.id, + phoneNumberId, + customers, +}); + +// calls.create returns Call (single customer) or CallBatchResponse (customers array). +const batchResult = result as { results?: { id: string }[]; id?: string }; +const callIds = batchResult.results ? batchResult.results.map((c) => c.id) : [batchResult.id]; + +pretty("Outbound calls placed", { + batchName, + customers: customers.length, + assistant: outboundAssistant.name, + callIds, +}); + +console.log(`\nMonitor calls at: https://dashboard.vapi.ai/calls`); diff --git a/projects/meridian/src/vapi/flight-code.ts b/projects/meridian/src/vapi/flight-code.ts new file mode 100644 index 0000000..1a1dac0 --- /dev/null +++ b/projects/meridian/src/vapi/flight-code.ts @@ -0,0 +1,262 @@ +/** + * Vapi **Code tool** bodies for the airline side (run on Vapi infra; `args`/`env` + * in scope; async; end with `return`). Supabase-backed tools talk to Supabase + * REST directly, so everything stays standalone (no webhook). + * + * get_flight_status → synth a realistic status + persist a flight_booking + * find_alternative_flights → synth 3 alternative options (no DB) + * confirm_rebook → PATCH the flight_booking to the chosen flight + * compensation_engine → pure calc: delay length × loyalty-tier multiplier + * + * Verified end-to-end by src/test/flight-tools.ts. + */ + +export const GET_FLIGHT_STATUS_CODE = ` +const base = env.SUPABASE_URL + "/rest/v1"; +const KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const H = { apikey: KEY, Authorization: "Bearer " + KEY, "Content-Type": "application/json" }; +async function q(path) { const r = await fetch(base + path, { headers: H }); return r.ok ? await r.json() : []; } +async function insert(t, row) { const r = await fetch(base + "/" + t, { method: "POST", headers: Object.assign({ Prefer: "return=representation" }, H), body: JSON.stringify(row) }); const j = await r.json(); return Array.isArray(j) ? j[0] : j; } +function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return Math.abs(h); } +function pick(a, seed) { return a[seed % a.length]; } +function pad(n, w) { return String(n).padStart(w, "0"); } +function fmt(d) { + const h = d.getUTCHours(); const m = d.getUTCMinutes(); + const ap = h >= 12 ? "PM" : "AM"; const h12 = h % 12 || 12; + const sm = ["","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; + const tm = ["","","twenty","thirty","forty","fifty"]; + const mw = m === 0 ? "" : m < 10 ? "oh " + sm[m] : m < 20 ? sm[m] : m % 10 === 0 ? tm[Math.floor(m/10)] : tm[Math.floor(m/10)] + " " + sm[m%10]; + return sm[h12] + (mw ? " " + mw : "") + " " + ap; +} +function fmtFlightNum(n) { + const sm = ["","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; + const tm = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]; + function two(x) { if (!x) return ""; return x < 20 ? sm[x] : (x % 10 === 0 ? tm[Math.floor(x/10)] : tm[Math.floor(x/10)] + " " + sm[x%10]); } + if (!Number.isInteger(n) || n < 0 || n > 9999) return String(n).split("").map(function (c) { return (c >= "0" && c <= "9") ? (sm[+c] || "zero") : c; }).join(" ").trim(); + if (n < 100) return two(n); + if (n < 1000) { const lo2 = n % 100; return sm[Math.floor(n/100)] + " " + (lo2 === 0 ? "hundred" : lo2 < 10 ? "oh " + sm[lo2] : two(lo2)); } + const hi = Math.floor(n/100); const lo = n % 100; + return two(hi) + " " + (lo === 0 ? "hundred" : lo < 10 ? "oh " + sm[lo] : two(lo)); +} +function fmtFlight(fn) { + const map = {UA:"United",AA:"American",DL:"Delta",AS:"Alaska",B6:"JetBlue",WN:"Southwest",F9:"Frontier",NK:"Spirit"}; + if (/^\\d+$/.test(fn)) return fmtFlightNum(parseInt(fn, 10)); // bare digits ("1234") → "twelve thirty four", not "12 flight..." + const m = fn.match(/^([A-Z0-9]{2})(\\d+)$/); + if (m) return (map[m[1]] || m[1]) + " flight " + fmtFlightNum(parseInt(m[2], 10)); + // Non-canonical input: never read a raw multi-digit run — spell any digits as words. + return fn.replace(/\\d+/g, function (d) { return " " + fmtFlightNum(parseInt(d, 10)) + " "; }).replace(/ +/g, " ").trim(); +} +const CITY = {JFK:"New York",LAX:"Los Angeles",ORD:"Chicago",SFO:"San Francisco",ATL:"Atlanta",DFW:"Dallas",SEA:"Seattle",MIA:"Miami",BOS:"Boston",DEN:"Denver"}; +function city(code) { return CITY[code] || code; } +function delayWords(mins) { + const h = Math.floor(mins / 60); const m = mins % 60; + const hw = ["zero","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve"]; + const sm = ["","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; + const tm = ["","","twenty","thirty","forty","fifty"]; + const minWords = m < 20 ? sm[m] : (m % 10 === 0 ? tm[Math.floor(m/10)] : tm[Math.floor(m/10)] + " " + sm[m%10]); + if (h === 0) return minWords + (m === 1 ? " minute" : " minutes"); + const hp = (hw[h] || h) + (h === 1 ? " hour" : " hours"); + if (m === 0) return "about " + hp; + if (m === 30) return "about " + (hw[h] || h) + " and a half hours"; + return "about " + hp + " and " + minWords + " minutes"; +} + +const fn = String(args.flightNumber || "").toUpperCase().replace(/[^A-Z0-9]/g, ""); +if (!fn) return { found: false, message: "What's your flight number?" }; +const name = args.name ? String(args.name).trim() : ""; + +let guest = null; +if (name) { + const g = await q("/guests?name=ilike." + encodeURIComponent(name) + "&select=*"); + guest = g[0] || await insert("guests", { name: name, loyalty_tier: pick(["standard", "silver", "gold", "platinum"], hash(name)), loyalty_points: (hash(name) % 90000) + 1000 }); +} + +const airports = ["JFK", "LAX", "ORD", "SFO", "ATL", "DFW", "SEA", "MIA", "BOS", "DEN"]; +const h = hash(fn); +const origin = pick(airports, h); +let destination = pick(airports, Math.floor(h / 3)); +if (destination === origin) destination = pick(airports, Math.floor(h / 3) + 1); + +const roll = h % 100; +let status = "on-time", delayMinutes = 0, disruptionType = "on-time"; +if (roll < 35) { status = "cancelled"; disruptionType = "cancelled"; } +else if (roll < 75) { status = "delayed"; delayMinutes = 60 + (h % 300); disruptionType = delayMinutes >= 120 ? "major-delay" : "minor-delay"; } +const needsRebooking = status === "cancelled" || delayMinutes >= 120; + +const dep = new Date(); dep.setHours(8 + (h % 12), h % 60, 0, 0); +const arr = new Date(dep.getTime() + (90 + (h % 240)) * 60000); +const estDep = status === "delayed" ? new Date(dep.getTime() + delayMinutes * 60000) : dep; + +let booking = null; +if (guest) { const b = await q("/flight_bookings?guest_id=eq." + guest.id + "&flight_number=eq." + encodeURIComponent(fn) + "&limit=1"); booking = b[0] || null; } +if (!booking) { const b2 = await q("/flight_bookings?flight_number=eq." + encodeURIComponent(fn) + "&limit=1"); booking = b2[0] || null; } +if (!booking) { + booking = await insert("flight_bookings", { + guest_id: guest ? guest.id : null, + flight_number: fn, origin: origin, destination: destination, + departure_time: dep.toISOString(), arrival_time: arr.toISOString(), + seat: String(1 + (h % 40)) + pick(["A", "B", "C", "D", "E", "F"], h), + cabin_class: pick(["economy", "economy", "economy", "business", "first"], Math.floor(h / 5)), + booking_reference: "BK" + pad(h % 1000000, 6), status: status, + }); +} +// Insert can be rejected (e.g. a booking_reference unique collision); never return an empty ref. +if (!booking || !booking.booking_reference) { + booking = { booking_reference: "BK" + pad(h % 1000000, 6), cabin_class: pick(["economy", "economy", "economy", "business", "first"], Math.floor(h / 5)) }; +} + +return { + flightNumber: fn, status: status, delayMinutes: delayMinutes, disruptionType: disruptionType, needsRebooking: needsRebooking, + origin: origin, destination: destination, scheduledDeparture: dep.toISOString(), estimatedDeparture: estDep.toISOString(), + bookingReference: booking.booking_reference, cabinClass: booking.cabin_class, + message: status === "cancelled" ? (fmtFlight(fn) + " from " + city(origin) + " to " + city(destination) + " has been cancelled.") + : status === "delayed" ? (fmtFlight(fn) + " is delayed " + delayWords(delayMinutes) + ", now departing around " + fmt(estDep) + ".") + : (fmtFlight(fn) + " from " + city(origin) + " to " + city(destination) + " is on time."), +}; +`; + +export const FIND_ALTERNATIVES_CODE = ` +// Seed the options off the route so a re-call (e.g. after a false barge-in mid-read) returns the +// SAME three flights — the caller can still pick "option two" after an interruption. +function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return Math.abs(h); } +let __seed = hash(String(args.origin || "X").toUpperCase() + "|" + String(args.destination || "Y").toUpperCase() + "|MERIDIAN") || 1; +function rnd() { __seed = (Math.imul(__seed, 1103515245) + 12345) & 0x7fffffff; return __seed / 0x7fffffff; } +function ri(min, max) { return Math.floor(rnd() * (max - min + 1)) + min; } +function pick(a) { return a[Math.floor(rnd() * a.length)]; } +function fmt(d) { + const h = d.getUTCHours(); const m = d.getUTCMinutes(); + const ap = h >= 12 ? "PM" : "AM"; const h12 = h % 12 || 12; + const sm = ["","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; + const tm = ["","","twenty","thirty","forty","fifty"]; + const mw = m === 0 ? "" : m < 10 ? "oh " + sm[m] : m < 20 ? sm[m] : m % 10 === 0 ? tm[Math.floor(m/10)] : tm[Math.floor(m/10)] + " " + sm[m%10]; + return sm[h12] + (mw ? " " + mw : "") + " " + ap; +} +function fmtFlightNum(n) { + const sm = ["","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; + const tm = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]; + function two(x) { if (!x) return ""; return x < 20 ? sm[x] : (x % 10 === 0 ? tm[Math.floor(x/10)] : tm[Math.floor(x/10)] + " " + sm[x%10]); } + if (!Number.isInteger(n) || n < 0 || n > 9999) return String(n).split("").map(function (c) { return (c >= "0" && c <= "9") ? (sm[+c] || "zero") : c; }).join(" ").trim(); + if (n < 100) return two(n); + if (n < 1000) { const lo2 = n % 100; return sm[Math.floor(n/100)] + " " + (lo2 === 0 ? "hundred" : lo2 < 10 ? "oh " + sm[lo2] : two(lo2)); } + const hi = Math.floor(n/100); const lo = n % 100; + return two(hi) + " " + (lo === 0 ? "hundred" : lo < 10 ? "oh " + sm[lo] : two(lo)); +} +function fmtFlight(fn) { + const map = {UA:"United",AA:"American",DL:"Delta",AS:"Alaska",B6:"JetBlue",WN:"Southwest",F9:"Frontier",NK:"Spirit"}; + if (/^\\d+$/.test(fn)) return fmtFlightNum(parseInt(fn, 10)); // bare digits ("1234") → "twelve thirty four", not "12 flight..." + const m = fn.match(/^([A-Z0-9]{2})(\\d+)$/); + if (m) return (map[m[1]] || m[1]) + " flight " + fmtFlightNum(parseInt(m[2], 10)); + // Non-canonical input: never read a raw multi-digit run — spell any digits as words. + return fn.replace(/\\d+/g, function (d) { return " " + fmtFlightNum(parseInt(d, 10)) + " "; }).replace(/ +/g, " ").trim(); +} +const carriers = ["UA", "AA", "DL", "AS", "B6", "WN"]; +const origin = args.origin ? String(args.origin).toUpperCase() : ""; +const destination = args.destination ? String(args.destination).toUpperCase() : ""; +const now = new Date(); +const options = []; +for (let i = 0; i < 3; i++) { + const dep = new Date(now.getTime() + (ri(2, 9) + i * 2) * 3600000); + const arr = new Date(dep.getTime() + (90 + ri(0, 240)) * 60000); + options.push({ + option: i + 1, + flightNumber: pick(carriers) + ri(100, 2999), + origin: origin || null, destination: destination || null, + departureTime: fmt(dep), arrivalTime: fmt(arr), + seatsAvailable: ri(2, 30), cabinClass: pick(["economy", "economy", "business"]), + }); +} +return { options: options, message: "Here are three options. Option one: " + fmtFlight(options[0].flightNumber) + " departing " + options[0].departureTime + ". Option two: " + fmtFlight(options[1].flightNumber) + " departing " + options[1].departureTime + ". Option three: " + fmtFlight(options[2].flightNumber) + " departing " + options[2].departureTime + ". Which would you like?" }; +`; + +export const CONFIRM_REBOOK_CODE = ` +const base = env.SUPABASE_URL + "/rest/v1"; +const KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const H = { apikey: KEY, Authorization: "Bearer " + KEY, "Content-Type": "application/json" }; +async function q(path) { const r = await fetch(base + path, { headers: H }); return r.ok ? await r.json() : []; } +async function patch(path, row) { const r = await fetch(base + path, { method: "PATCH", headers: Object.assign({ Prefer: "return=representation" }, H), body: JSON.stringify(row) }); const j = await r.json(); return Array.isArray(j) ? j[0] : j; } +function fmtFlightNum(n) { + const sm = ["","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; + const tm = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]; + function two(x) { if (!x) return ""; return x < 20 ? sm[x] : (x % 10 === 0 ? tm[Math.floor(x/10)] : tm[Math.floor(x/10)] + " " + sm[x%10]); } + if (!Number.isInteger(n) || n < 0 || n > 9999) return String(n).split("").map(function (c) { return (c >= "0" && c <= "9") ? (sm[+c] || "zero") : c; }).join(" ").trim(); + if (n < 100) return two(n); + if (n < 1000) { const lo2 = n % 100; return sm[Math.floor(n/100)] + " " + (lo2 === 0 ? "hundred" : lo2 < 10 ? "oh " + sm[lo2] : two(lo2)); } + const hi = Math.floor(n/100); const lo = n % 100; + return two(hi) + " " + (lo === 0 ? "hundred" : lo < 10 ? "oh " + sm[lo] : two(lo)); +} +function fmtFlight(fn) { + const map = {UA:"United",AA:"American",DL:"Delta",AS:"Alaska",B6:"JetBlue",WN:"Southwest",F9:"Frontier",NK:"Spirit"}; + if (/^\\d+$/.test(fn)) return fmtFlightNum(parseInt(fn, 10)); // bare digits ("1234") → "twelve thirty four", not "12 flight..." + const m = fn.match(/^([A-Z0-9]{2})(\\d+)$/); + if (m) return (map[m[1]] || m[1]) + " flight " + fmtFlightNum(parseInt(m[2], 10)); + // Non-canonical input: never read a raw multi-digit run — spell any digits as words. + return fn.replace(/\\d+/g, function (d) { return " " + fmtFlightNum(parseInt(d, 10)) + " "; }).replace(/ +/g, " ").trim(); +} + +const ref = args.bookingReference ? String(args.bookingReference).trim().toUpperCase() : ""; +const name = args.name ? String(args.name).trim() : ""; +const newFlight = args.newFlightNumber ? String(args.newFlightNumber).toUpperCase().replace(/[^A-Z0-9]/g, "") : ""; +const seat = args.seat ? String(args.seat).toUpperCase().split(" ").join("") : ""; +if (!newFlight && !seat) return { changed: false, message: "What would you like to change — a different flight, or your seat?" }; + +let booking = null; +if (ref) { const b = await q("/flight_bookings?booking_reference=ilike." + encodeURIComponent(ref) + "&limit=1"); booking = b[0] || null; } +if (!booking && name) { const g = await q("/guests?name=ilike." + encodeURIComponent(name) + "&select=id"); if (g[0]) { const b = await q("/flight_bookings?guest_id=eq." + g[0].id + "&order=created_at.desc&limit=1"); booking = b[0] || null; } } +if (!booking) { + // Never fabricate a confirmation out of thin air: if the caller gave NO identifier, ask for one. + if (!ref && !name) return { changed: false, message: "I couldn't find a booking to change — can I get your name or booking reference?" }; + // They gave a reference/name (demo accepts any): confirm the change against it. + const parts = []; if (newFlight) parts.push("onto " + fmtFlight(newFlight)); if (seat) parts.push("with your new seat"); + return { + changed: true, bookingReference: ref || name, flightNumber: newFlight || null, seat: seat || null, + message: "Done — you're rebooked" + (parts.length ? " " + parts.join(" ") : "") + ". Your booking reference is unchanged, and a confirmation is on its way.", + }; +} + +const upd = { status: "active" }; +if (newFlight) upd.flight_number = newFlight; +// Only persist a departure time if it is a genuine ISO timestamp. The model often supplies a +// spoken-word time ("nine forty AM"), which would 400 the timestamptz column and, because the +// error object replaces the row, blow up fmtFlight(updated.flight_number) below. +if (args.newDepartureTime && /^\\d{4}-\\d{2}-\\d{2}T/.test(String(args.newDepartureTime)) && !isNaN(Date.parse(args.newDepartureTime))) { + upd.departure_time = new Date(args.newDepartureTime).toISOString(); +} +if (seat) upd.seat = seat; +const updated = await patch("/flight_bookings?id=eq." + booking.id, upd); + +// PATCH may still return an error object instead of the row — fall back to known values so we +// never read a field off undefined or speak an empty confirmation. +const okFlight = (updated && updated.flight_number) ? updated.flight_number : (newFlight || booking.flight_number); +const okRef = (updated && updated.booking_reference) ? updated.booking_reference : (booking.booking_reference || ref || name); +const parts = []; +if (newFlight) parts.push("onto " + fmtFlight(okFlight)); +if (seat) parts.push("with your new seat"); +return { + changed: true, bookingReference: okRef, flightNumber: okFlight || null, + seat: (updated && updated.seat) || seat || null, cabinClass: (updated && updated.cabin_class) || null, + message: "Done — you're rebooked" + (parts.length ? " " + parts.join(" ") : "") + ". Your booking reference is unchanged.", +}; +`; + +export const COMPENSATION_CODE = ` +const tierMult = { standard: 1.0, silver: 1.25, gold: 1.5, platinum: 2.0 }; +const tier = args.loyaltyTier ? String(args.loyaltyTier).toLowerCase() : "standard"; +const mult = tierMult[tier] || 1.0; +const cancelled = args.cancelled === true || String(args.disruptionType || "").toLowerCase() === "cancelled"; +let delayMinutes = Number(args.delayMinutes || 0); +if (!delayMinutes && args.delayHours) delayMinutes = Number(args.delayHours) * 60; + +let base = cancelled ? 200 : Math.round((delayMinutes / 60) * 40); +let voucher = Math.round(base * mult); +if (voucher > 500) voucher = 500; +if (voucher < 0) voucher = 0; +const eligible = voucher > 0; +// Return NO dollar figure or raw minute count to the model — the credit is a pending request, +// sized by the back office, never spoken. Only an eligibility flag + tier reach the model. +return { + eligible: eligible, tier: tier, + message: eligible + ? "I've noted a travel-credit request for this disruption; our team will review it and follow up." + : "There's no eligible disruption here, so there's nothing to submit.", +}; +`; diff --git a/projects/meridian/src/vapi/lookup-code.ts b/projects/meridian/src/vapi/lookup-code.ts new file mode 100644 index 0000000..7160124 --- /dev/null +++ b/projects/meridian/src/vapi/lookup-code.ts @@ -0,0 +1,109 @@ +/** + * lookup_reservation — Vapi **Code tool** body (runs on Vapi infra; `args`/`env` + * in scope; async; end with `return`). Talks to Supabase REST directly, so the + * assistant stays standalone (no webhook) yet persistent. + * + * Behavior: + * - confirmation number → returns that reservation (with guest). + * - known guest name → returns their persisted reservation. + * - NEW name → invents a RANDOM reservation, assigns a confirmation + * number, writes guest + reservation to Supabase, and + * returns it. Same name next time → same record. + * + * env: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY (set on the tool). + * Verified end-to-end by src/test/synth-reservation.ts. + */ +export const LOOKUP_CODE = ` +const base = env.SUPABASE_URL + "/rest/v1"; +const KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const H = { apikey: KEY, Authorization: "Bearer " + KEY, "Content-Type": "application/json" }; + +async function q(path) { const r = await fetch(base + path, { headers: H }); return r.ok ? await r.json() : []; } +async function insert(table, row) { + const r = await fetch(base + "/" + table, { + method: "POST", + headers: Object.assign({ Prefer: "return=representation" }, H), + body: JSON.stringify(row), + }); + const j = await r.json(); + return Array.isArray(j) ? j[0] : j; +} +function ri(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function rp(arr) { return arr[Math.floor(Math.random() * arr.length)]; } +function pad(n, w) { return String(n).padStart(w, "0"); } +function fmt(d) { return d.toISOString().slice(0, 10); } +// Spoken-word date so TTS never reads an ISO string like "2026-06-25". +function spokenDate(iso) { + if (!iso) return null; + const m = String(iso).match(/^(\\d{4})-(\\d{2})-(\\d{2})/); + if (!m) return iso; + const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + const ord = ["","first","second","third","fourth","fifth","sixth","seventh","eighth","ninth","tenth","eleventh","twelfth","thirteenth","fourteenth","fifteenth","sixteenth","seventeenth","eighteenth","nineteenth","twentieth","twenty first","twenty second","twenty third","twenty fourth","twenty fifth","twenty sixth","twenty seventh","twenty eighth","twenty ninth","thirtieth","thirty first"]; + const day = parseInt(m[3], 10); + return months[parseInt(m[2], 10) - 1] + " " + (ord[day] || day); +} + +function shape(guest, resv) { + guest = guest || {}; + return { + found: true, + guest: { + firstName: (guest.name || "Guest").split(" ")[0], + fullName: guest.name || "Valued Guest", + loyaltyTier: guest.loyalty_tier || "standard", + // loyaltyPoints intentionally omitted: a raw 5-digit balance would be read aloud as a + // mangled numeral. Loyalty is surfaced as the tier (a word) only. + }, + reservation: { + confirmationNumber: resv.confirmation_number, + property: resv.property_name, + roomNumber: resv.room_number, + roomType: resv.room_type, + checkIn: spokenDate(resv.check_in), + checkOut: spokenDate(resv.check_out), + status: resv.status, + specialRequests: resv.special_requests, + }, + }; +} + +const conf = args.confirmationNumber ? String(args.confirmationNumber).trim().toUpperCase() : ""; +const name = args.name ? String(args.name).trim() : ""; + +if (conf) { + const rows = await q("/reservations?confirmation_number=eq." + encodeURIComponent(conf) + "&select=*,guests(*)"); + if (rows[0]) return shape(rows[0].guests, rows[0]); +} + +if (name) { + const guests = await q("/guests?name=ilike." + encodeURIComponent(name) + "&select=*"); + let guest = guests[0]; + if (guest) { + const resv = await q("/reservations?guest_id=eq." + guest.id + "&order=check_in.desc&limit=1&select=*"); + if (resv[0]) return shape(guest, resv[0]); + } else { + guest = await insert("guests", { + name: name, + loyalty_tier: rp(["standard", "silver", "gold", "platinum"]), + loyalty_points: ri(500, 95000), + }); + } + const today = new Date(); + const co = new Date(today); + co.setDate(co.getDate() + ri(1, 5)); + const resv = await insert("reservations", { + confirmation_number: "MGH" + pad(ri(0, 999999), 6), + guest_id: guest.id, + property_name: "Meridian Grand Hotel", + room_number: String(ri(2, 20)) + pad(ri(1, 40), 2), + room_type: rp(["Standard Queen", "Deluxe King", "King Suite", "Ocean-View Suite", "Garden Double"]), + check_in: fmt(today), + check_out: fmt(co), + status: "active", + special_requests: rp(["High floor", "Late checkout requested", "Extra towels on arrival", "Quiet room", "Early check-in"]), + }); + return shape(guest, resv); +} + +return { found: false, message: "Could you share your name or confirmation number so I can pull up your reservation?" }; +`; diff --git a/projects/meridian/src/vapi/recovery-code.ts b/projects/meridian/src/vapi/recovery-code.ts new file mode 100644 index 0000000..364f00b --- /dev/null +++ b/projects/meridian/src/vapi/recovery-code.ts @@ -0,0 +1,39 @@ +/** + * issue_travel_credit (registered tool name) — Vapi **Code tool** body (Vapi infra; async). + * Despite the legacy name, it only SUBMITS a pending request — it does not issue/apply anything. + * Logs a PENDING travel-credit request for team review. It does NOT apply a + * credit, touch any balance, or speak/return a dollar amount — the spoken line + * and the system of record must agree: a request was submitted, nothing granted. + * Standalone (no webhook); writes to the existing service_requests table. + * + * args: name (required), reason (optional). + * env: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY. + */ +export const ISSUE_TRAVEL_CREDIT_CODE = ` +const base = env.SUPABASE_URL + "/rest/v1"; +const KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const H = { apikey: KEY, Authorization: "Bearer " + KEY, "Content-Type": "application/json" }; +async function q(path) { const r = await fetch(base + path, { headers: H }); return r.ok ? await r.json() : []; } +async function insert(t, row) { const r = await fetch(base + "/" + t, { method: "POST", headers: Object.assign({ Prefer: "return=representation" }, H), body: JSON.stringify(row) }); const j = await r.json(); return Array.isArray(j) ? j[0] : j; } + +const name = args.name ? String(args.name).trim() : ""; +const reason = args.reason ? String(args.reason).trim() : "flight disruption"; +if (!name) return { submitted: false, message: "Whose account is this for — can I get your name?" }; + +// Link to the guest if we can find them; submit the request either way. +const guests = await q("/guests?name=ilike." + encodeURIComponent(name) + "&select=id"); +const guestId = guests[0] ? guests[0].id : null; + +await insert("service_requests", { + reservation_id: null, + guest_id: guestId, + request_type: "travel credit", + description: "Travel-credit request — " + reason + " (submitted by phone, pending team review).", + status: "pending_review", +}); + +return { + submitted: true, status: "pending_review", reason: reason, + message: "I've submitted a travel credit request for your account. Our team will review it and be in touch as soon as possible.", +}; +`; diff --git a/projects/meridian/src/vapi/service-request-code.ts b/projects/meridian/src/vapi/service-request-code.ts new file mode 100644 index 0000000..7b034ed --- /dev/null +++ b/projects/meridian/src/vapi/service-request-code.ts @@ -0,0 +1,131 @@ +/** + * handle_service_request — Vapi **Code tool** body (runs on Vapi infra; async). + * Logs a guest service request to Supabase against their reservation, so the + * assistant stays standalone (no webhook) yet persistent. + * + * args: requestType (required), description, confirmationNumber, name. + * env: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY. + */ +export const SERVICE_REQUEST_CODE = ` +const base = env.SUPABASE_URL + "/rest/v1"; +const KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const H = { apikey: KEY, Authorization: "Bearer " + KEY, "Content-Type": "application/json" }; + +async function q(path) { const r = await fetch(base + path, { headers: H }); return r.ok ? await r.json() : []; } +async function insert(table, row) { + const r = await fetch(base + "/" + table, { + method: "POST", + headers: Object.assign({ Prefer: "return=representation" }, H), + body: JSON.stringify(row), + }); + const j = await r.json(); + return Array.isArray(j) ? j[0] : j; +} + +const conf = args.confirmationNumber ? String(args.confirmationNumber).trim().toUpperCase() : ""; +const name = args.name ? String(args.name).trim() : ""; +const requestType = args.requestType ? String(args.requestType).trim() : "general request"; +const description = args.description ? String(args.description).trim() : requestType; + +let resv = null; +let guest = null; +if (conf) { + const rows = await q("/reservations?confirmation_number=eq." + encodeURIComponent(conf) + "&select=*,guests(*)"); + if (rows[0]) { resv = rows[0]; guest = rows[0].guests; } +} +if (!resv && name) { + const guests = await q("/guests?name=ilike." + encodeURIComponent(name) + "&select=*"); + if (guests[0]) { + guest = guests[0]; + const r = await q("/reservations?guest_id=eq." + guests[0].id + "&order=check_in.desc&limit=1&select=*"); + resv = r[0] || null; + } +} + +const created = await insert("service_requests", { + reservation_id: resv ? resv.id : null, + guest_id: guest ? guest.id : null, + request_type: requestType, + description: description, + status: "pending", +}); + +const ref = "SR-" + (created && created.id ? String(created.id).slice(0, 8).toUpperCase() : String(Math.floor(Math.random() * 100000))); +const room = resv ? resv.room_number : null; +// Speak the room number digit by digit so TTS never reads "1702" as a multi-digit numeral. +function spokenDigits(s) { + const w = { "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four", "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine" }; + return String(s).split("").map((c) => w[c] || c).join(" "); +} +return { + logged: true, + reference: ref, + requestType: requestType, + room: room, + message: "I've logged your " + requestType + (room ? " for room " + spokenDigits(room) : "") + " — our team will take care of it shortly.", +}; +`; + +/** + * request_room_upgrade — Vapi **Code tool** body (Vapi infra; async). + * Deterministically offers ONE room upgrade with a real nightly rate (so the + * agent can quote a price when asked), then on accept=true logs a PENDING + * service_request — never an instant "confirmed" upgrade, never a card on the phone. + * + * args: confirmationNumber?, name?, accept? (boolean). + * env: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY. + */ +export const ROOM_UPGRADE_CODE = ` +const base = env.SUPABASE_URL + "/rest/v1"; +const KEY = env.SUPABASE_SERVICE_ROLE_KEY; +const H = { apikey: KEY, Authorization: "Bearer " + KEY, "Content-Type": "application/json" }; +async function q(path) { const r = await fetch(base + path, { headers: H }); return r.ok ? await r.json() : []; } +async function insert(t, row) { const r = await fetch(base + "/" + t, { method: "POST", headers: Object.assign({ Prefer: "return=representation" }, H), body: JSON.stringify(row) }); const j = await r.json(); return Array.isArray(j) ? j[0] : j; } +function hash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return Math.abs(h); } +function words(n) { + const sm = ["zero","one","two","three","four","five","six","seven","eight","nine","ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen","seventeen","eighteen","nineteen"]; + const tm = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]; + if (n < 20) return sm[n]; + if (n < 100) return tm[Math.floor(n/10)] + (n % 10 ? " " + sm[n%10] : ""); + const h = Math.floor(n/100), r = n % 100; + return sm[h] + " hundred" + (r ? " " + words(r) : ""); +} + +const conf = args.confirmationNumber ? String(args.confirmationNumber).trim().toUpperCase() : ""; +const name = args.name ? String(args.name).trim() : ""; +const accept = args.accept === true; + +// Locate the reservation (for room + linkage); fine if not found — still make an offer. +let resv = null, guest = null; +if (conf) { const rows = await q("/reservations?confirmation_number=eq." + encodeURIComponent(conf) + "&select=*,guests(*)"); if (rows[0]) { resv = rows[0]; guest = rows[0].guests; } } +if (!resv && name) { const gs = await q("/guests?name=ilike." + encodeURIComponent(name) + "&select=*"); if (gs[0]) { guest = gs[0]; const r = await q("/reservations?guest_id=eq." + gs[0].id + "&order=check_in.desc&limit=1&select=*"); resv = r[0] || null; } } + +// Deterministic offer keyed to the guest so the quote is stable across the call. +const seed = hash(conf || name || (resv && resv.confirmation_number) || "MERIDIAN"); +const tiers = ["a Deluxe Room", "a Junior Suite", "an Executive Suite", "an Ocean-View Suite"]; +const toRoom = tiers[seed % tiers.length]; +const nightlyRate = 75 + (seed % 18) * 15; // 75–330, multiples of 15 +const rateWords = words(nightlyRate) + " dollars a night"; + +if (!accept) { + // No raw nightlyRateUSD in the return — only the spoken-word rate in the message, so the model + // can never read a bare numeral. The dollar figure lives in the DB description row only. + return { + offered: true, toRoom: toRoom, + message: "I can offer you an upgrade to " + toRoom + " for " + rateWords + ". Would you like me to put in the request?", + }; +} + +const created = await insert("service_requests", { + reservation_id: resv ? resv.id : null, + guest_id: guest ? guest.id : null, + request_type: "room upgrade", + description: "Upgrade to " + toRoom + " at $" + nightlyRate + "/night (requested by phone; folio at check-in).", + status: "pending", +}); + +return { + submitted: true, status: "pending", toRoom: toRoom, + message: "I've put in the request for " + toRoom + " at " + rateWords + ". The team confirms availability and it goes on your folio at check-in; nothing's charged now.", +}; +`; diff --git a/projects/meridian/src/vapi/simulations.ts b/projects/meridian/src/vapi/simulations.ts new file mode 100644 index 0000000..2c58a96 --- /dev/null +++ b/projects/meridian/src/vapi/simulations.ts @@ -0,0 +1,458 @@ +/** + * Meridian — Phase 5 Simulation Suite. + * + * Creates (or upserts) 10 Vapi Evals covering the full Meridian squad flow. + * Pass RUN=1 to also execute all evals and print a pass/fail summary. + * + * npm run simulations # create/update evals + * RUN=1 npm run simulations # create + run all + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { vapi } from "../config.js"; +import { header, pretty } from "../utils/print.js"; + +header("Meridian — Phase 5 Simulation Suite"); + +// Resolve assistant ids by name from the live org (create the assistants first — see README). +const allAssistants = await vapi.assistants.list({ limit: 100 }); +function idByName(name: string): string { + const matches = allAssistants.filter((a) => a.name === name); + if (matches.length === 0) throw new Error(`Assistant not found: "${name}" — create the assistants first (see README).`); + if (matches.length > 1) throw new Error(`Ambiguous: ${matches.length} assistants named "${name}" — delete the stale duplicate(s).`); + return matches[0].id; +} +const IDS = { + concierge: idByName("Meridian — Concierge"), + hotel: idByName("Meridian — Hotel Concierge (Jack)"), + triage: idByName("Meridian — Flight Triage"), + rebooking: idByName("Meridian — Rebooking"), + upsell: idByName("Meridian — Upsell & Recovery"), +}; + +// ── Judge helpers ───────────────────────────────────────────────────────────── + +function aiJudge(criteria: string): Vapi.ChatEvalAssistantMessageEvaluationJudgePlan { + return { + type: "ai", + model: { + provider: "openai", + model: "gpt-4o-mini", + temperature: 0, + maxTokens: 50, + messages: [ + { + role: "system", + content: `You are evaluating an AI voice assistant. ${criteria}\n\nConversation so far:\n{{messages}}\n\nRespond with exactly "pass" or "fail".`, + }, + ], + } as Vapi.AssistantMessageJudgePlanAiModel, + }; +} + +// ── Message helpers ─────────────────────────────────────────────────────────── + +type Msg = Vapi.CreateEvalDtoMessagesItem; + +const u = (content: string): Msg => + ({ role: "user", content }) as Vapi.ChatEvalUserMessageMock; + +const a = (content: string, toolCalls?: Vapi.ChatEvalAssistantMessageMockToolCall[]): Msg => + ({ role: "assistant", content, toolCalls }) as Vapi.ChatEvalAssistantMessageMock; + +const t = (content: string): Msg => + ({ role: "tool", content }) as Vapi.ChatEvalToolResponseMessageMock; + +const check = (criteria: string, exitOnFailure = false): Msg => + ({ + role: "assistant", + judgePlan: aiJudge(criteria), + continuePlan: exitOnFailure ? { exitOnFailureEnabled: true } : undefined, + }) as Vapi.ChatEvalAssistantMessageEvaluation; + +// ── Scenario definitions ────────────────────────────────────────────────────── + +type Spec = { + eval: Vapi.CreateEvalDto; + target: Vapi.CreateEvalRunDtoTarget; +}; + +const SCENARIOS: Spec[] = [ + + // 01 — Concierge routes hotel request + { + eval: { + name: "01-hotel-routing", + description: "Caller states a hotel need on the first turn. Concierge must call transferCall to Hotel Concierge.", + type: "chat.mockConversation", + messages: [ + u("Hi, I need help with my hotel reservation."), + check( + "The assistant should call the transferCall function to route to the Hotel Concierge. Pass if transferCall was called to a hotel destination. Fail if no transfer was made or if the assistant asked 'hotel or flight?'.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.concierge }, + }, + + // 02 — Concierge routes flight request + { + eval: { + name: "02-flight-routing", + description: "Caller states a flight need. Concierge must call transferCall to Flight Triage.", + type: "chat.mockConversation", + messages: [ + u("I need to check on my flight status."), + check( + "The assistant should call transferCall to route to the Flight Triage desk. Pass if transferCall was called to a flight destination. Fail if no transfer was made.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.concierge }, + }, + + // 03 — Explicit transfer request is immediate + { + eval: { + name: "03-explicit-transfer-immediate", + description: "Caller explicitly says 'transfer me to the flight desk'. Concierge must comply on the first turn without asking questions.", + type: "chat.mockConversation", + messages: [ + u("Transfer me to the flight desk, please."), + check( + "The caller explicitly requested a transfer. The assistant must call transferCall immediately with no clarifying questions. Pass if transferCall was called on the first turn. Fail if the assistant asked any questions or added commentary.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.concierge }, + }, + + // 04 — Name-first greeting doesn't trigger double-ask + { + eval: { + name: "04-name-first-no-double-ask", + description: "Caller gives their name first, then states hotel need. Concierge must NOT ask 'hotel or flight?' again.", + type: "chat.mockConversation", + messages: [ + u("Hi, my name is Sarah."), + a("Hi Sarah! How can I help you today?"), + u("I need help with my hotel stay."), + check( + "The caller has already stated they need hotel help. The assistant should call transferCall to Hotel Concierge without asking 'Is this about your hotel stay or a flight?' or any similar question. Pass if transferCall was called to a hotel destination without re-asking. Fail if the assistant re-asked the routing question." + ), + ], + }, + target: { type: "assistant", assistantId: IDS.concierge }, + }, + + // 05 — Hotel: reservation lookup by name + { + eval: { + name: "05-hotel-lookup-by-name", + description: "Hotel concierge calls lookup_reservation when caller gives their name.", + type: "chat.mockConversation", + messages: [ + u("My name is Emily Chen."), + check( + "The hotel concierge should call the lookup_reservation tool with the caller's name. Pass if lookup_reservation was called (with any arguments). Fail if the tool was not called or the assistant asked for a confirmation number first." + ), + ], + }, + target: { type: "assistant", assistantId: IDS.hotel }, + }, + + // 06 — Hotel: service request (towels) + { + eval: { + name: "06-hotel-service-request-towels", + description: "Hotel concierge calls handle_service_request when guest asks for extra towels.", + type: "chat.mockConversation", + messages: [ + u("Hi, I'm Emily Chen, room 412."), + a("Hello Emily! Let me look up your reservation.", [ + { name: "lookup_reservation", arguments: { name: "Emily Chen" } }, + ]), + t(JSON.stringify({ name: "Emily Chen", room: "412", confirmationNumber: "MC123456", checkIn: "2026-06-23", checkOut: "2026-06-26", loyaltyTier: "silver" })), + a("Welcome back, Emily! I have your reservation at Room 412. How can I help you today?"), + u("I need extra towels please."), + check( + "The guest requested extra towels. The hotel concierge should call handle_service_request with a requestType like 'extra towels'. Pass if handle_service_request was called. Fail if the tool was not called.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.hotel }, + }, + + // 07 — Hotel: knowledge base query (pool hours) + { + eval: { + name: "07-hotel-kb-amenity-hours", + description: "Hotel concierge queries the knowledge base when asked about amenity hours.", + type: "chat.mockConversation", + messages: [ + u("Hi I'm David Lee."), + a("Hello David! Let me pull up your reservation.", [ + { name: "lookup_reservation", arguments: { name: "David Lee" } }, + ]), + t(JSON.stringify({ name: "David Lee", room: "208", confirmationNumber: "MC789012", loyaltyTier: "standard" })), + a("Hi David! I have your reservation at Room 208. What can I help you with?"), + u("What time does the pool close tonight?"), + check( + "The guest asked about pool hours. The hotel concierge should query the hotel-knowledge-base tool (a query tool) rather than answering from memory. Pass if a knowledge base query tool was called. Fail if the assistant answered without calling the knowledge base tool." + ), + ], + }, + target: { type: "assistant", assistantId: IDS.hotel }, + }, + + // 08 — Flight triage: status check + { + eval: { + name: "08-flight-status-check", + description: "Flight triage calls get_flight_status when caller asks about a specific flight.", + type: "chat.mockConversation", + messages: [ + u("Can you check if flight UA482 is on time?"), + check( + "The caller asked about flight UA482. The flight triage assistant should call get_flight_status with flightNumber 'UA482'. Pass if get_flight_status was called. Fail if the tool was not called or was called with a different flight number.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.triage }, + }, + + // 09 — Flight triage → rebooking on cancellation + { + eval: { + name: "09-flight-cancelled-routes-to-rebooking", + description: "Flight triage calls transferCall to rebooking after confirming a cancellation.", + type: "chat.mockConversation", + messages: [ + u("Hi I'm Marcus Lee, I'm on flight UA482."), + a("Let me check that flight for you, Marcus.", [ + { name: "get_flight_status", arguments: { flightNumber: "UA482", name: "Marcus Lee" } }, + ]), + t(JSON.stringify({ flightNumber: "UA482", status: "cancelled", delayMinutes: null, recommendRebook: true, origin: "LAX", destination: "MIA" })), + a("I'm sorry Marcus — flight UA482 has been cancelled. I'll get you on the next available flight right away."), + check( + "The flight was cancelled. The flight triage assistant should call transferCall to route the caller to the Rebooking specialist. Pass if transferCall was called to a rebooking destination. Fail if no transfer was made.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.triage }, + }, + + // 10 — Cabin upgrade routes to flight, not hotel + { + eval: { + name: "10-cabin-upgrade-routes-to-flight", + description: "Caller asks for a cabin upgrade. Concierge must route to flight (not hotel or upsell).", + type: "chat.mockConversation", + messages: [ + u("I'd like to upgrade my seat to business class on my upcoming flight."), + check( + "The caller wants a flight cabin upgrade. The concierge should call transferCall to the Flight Triage desk, not to the Hotel Concierge and not to Upsell & Recovery. Pass if transferCall was called to a flight-related destination. Fail if the transfer went to hotel, upsell, or if no transfer was made.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.concierge }, + }, + + // 11 — Concierge routes on a bare one-word "flight" answer + { + eval: { + name: "11-oneword-flight-routes", + description: "After the open greeting, the caller answers only 'flight'. Concierge must transfer to Flight Triage without re-asking.", + type: "chat.mockConversation", + messages: [ + a("Thanks for calling Meridian, this is Aria. How can I help you today — is it about your flight, your hotel, or both?"), + u("Flight."), + check( + "The caller answered 'flight'. The concierge must treat this one-word answer as clear intent and call transferCall to the Flight Triage desk. Pass if transferCall was called to a flight destination. Fail if the assistant re-asked, asked for clarification, or made no transfer.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.concierge }, + }, + + // 12 — Concierge handles "both" by starting with flight + { + eval: { + name: "12-both-routes-to-flight", + description: "Caller says they have both a flight and hotel issue. Concierge must transfer to Flight Triage (the agreed first hop).", + type: "chat.mockConversation", + messages: [ + a("Thanks for calling Meridian, this is Aria. How can I help you today — is it about your flight, your hotel, or both?"), + u("Honestly both — my flight got delayed and I also need to sort out my hotel room."), + check( + "The caller has BOTH a flight and a hotel matter. The concierge should acknowledge and transfer to the Flight Triage desk first (not the hotel). Pass if transferCall was called to a flight destination. Fail if it transferred to hotel first or made no transfer.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.concierge }, + }, + + // 13 — Member care: a ROOM UPGRADE must NOT trigger a travel credit + { + eval: { + name: "13-upgrade-not-credit", + description: "Caller asks for a room upgrade. Sophie must use request_room_upgrade and must NOT offer or issue a travel credit.", + type: "chat.mockConversation", + messages: [ + u("Hi, I'm checking in later today and I'd love to upgrade to a nicer room."), + check( + "This is a ROOM UPGRADE request, not a disruption. The member-care assistant should call request_room_upgrade. It must NOT call issue_travel_credit and must NOT offer or mention a travel credit. Pass only if request_room_upgrade was called and no travel credit was offered or issued. Fail if a travel credit was mentioned, offered, or issued.", + true + ), + ], + }, + target: { type: "assistant", assistantId: IDS.upsell }, + }, + + // 14 — Member care: answer the upgrade COST, don't auto-confirm + { + eval: { + name: "14-upgrade-price-not-confirmed", + description: "After an upgrade offer, the caller asks the cost. Sophie must restate the price and NOT claim the upgrade is confirmed.", + type: "chat.mockConversation", + messages: [ + u("I'd like to upgrade my room."), + a("Let me pull up an option for you.", [ + { name: "request_room_upgrade", arguments: { name: "Emily Chen" } }, + ]), + t(JSON.stringify({ offered: true, toRoom: "a Junior Suite", message: "I can offer you an upgrade to a Junior Suite for ninety dollars a night. Would you like me to put in the request?" })), + a("I can offer you an upgrade to a Junior Suite for ninety dollars a night. Would you like me to put in the request?"), + u("Wait, how much did you say it costs?"), + check( + "The guest asked the cost. The assistant should restate the nightly price (ninety dollars a night) in words and ask if they'd like to proceed. It must NOT claim the upgrade is confirmed, done, booked, or charged. Pass if it gives the price and does not claim confirmation. Fail if it claims the upgrade is confirmed/done or fails to give the price." + ), + ], + }, + target: { type: "assistant", assistantId: IDS.upsell }, + }, + + // 15 — Member care: travel credit is framed as a PENDING request (no $, not applied) + { + eval: { + name: "15-credit-is-pending-request", + description: "On a confirmed cancellation, Sophie submits a travel-credit request and frames it as pending review — never a dollar amount or 'applied'.", + type: "chat.mockConversation", + messages: [ + u("My flight was cancelled and I was told member care could help with a travel credit. I'm Emily Chen."), + a("I'm sorry about the cancellation, Emily — let me get that started.", [ + { name: "issue_travel_credit", arguments: { name: "Emily Chen", reason: "flight cancellation" } }, + ]), + t(JSON.stringify({ submitted: true, status: "pending_review", message: "I've submitted a travel credit request for your account. Our team will review it and be in touch as soon as possible." })), + check( + "The assistant should tell the guest the travel credit is a REQUEST submitted for team review. It must NOT state any dollar amount, must NOT state a balance, and must NOT say the credit was applied, approved, added, or granted. Pass only if it is framed as a pending request with no dollar amount and no applied/approved claim. Fail otherwise." + ), + ], + }, + target: { type: "assistant", assistantId: IDS.upsell }, + }, +]; + +// ── Upsert evals ────────────────────────────────────────────────────────────── + +const existing = (await vapi.eval.evalControllerGetPaginated({ limit: 100 })).results ?? []; +const byName = new Map(existing.map((e) => [e.name, e])); + +let created = 0; +let updated = 0; +const evalIds: { name: string; id: string }[] = []; + +for (const spec of SCENARIOS) { + const prev = byName.get(spec.eval.name!); + if (prev) { + await vapi.eval.evalControllerUpdate({ id: prev.id, ...spec.eval }); + evalIds.push({ name: spec.eval.name!, id: prev.id }); + updated++; + } else { + const created_ = await vapi.eval.evalControllerCreate(spec.eval); + evalIds.push({ name: spec.eval.name!, id: created_.id }); + created++; + } +} + +pretty("Eval suite ready", { created, updated, total: SCENARIOS.length }); +console.log("\nScenarios:"); +evalIds.forEach(({ name, id }) => console.log(` ${name} → ${id}`)); + +// ── Optional: run all ───────────────────────────────────────────────────────── + +if (process.env.RUN !== "1") { + console.log("\nTo run all scenarios: RUN=1 npm run simulations"); + process.exit(0); +} + +console.log("\nRunning all scenarios (parallel, then polling to completion)…"); + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// Fire every run, capturing its run id. +const runs = await Promise.all( + SCENARIOS.map(async (spec) => { + try { + const run = (await vapi.eval.evalControllerRun({ + eval: spec.eval, + target: spec.target as Vapi.CreateEvalRunDtoTarget, + type: "eval", + })) as Record; + // evalControllerRun returns { workflowId, evalRunId } — the run id is evalRunId. + return { name: spec.eval.name!, runId: (run?.evalRunId ?? run?.id) as string | undefined, error: undefined as string | undefined }; + } catch (err: unknown) { + return { name: spec.eval.name!, runId: undefined, error: err instanceof Error ? err.message : String(err) }; + } + }) +); + +// Poll each run until it ends (or we hit the per-run timeout). +const POLL_MS = 6000; +const MAX_MS = 360000; +async function resolveRun(name: string, runId?: string, startErr?: string) { + if (startErr || !runId) return { name, status: "error", detail: startErr ?? "no run id" }; + const deadline = Date.now() + MAX_MS; + while (Date.now() < deadline) { + await sleep(POLL_MS); + let run: Record; + try { + run = (await vapi.eval.evalControllerGetRun({ id: runId })) as Record; + } catch (err: unknown) { + return { name, status: "error", detail: err instanceof Error ? err.message : String(err) }; + } + if (run?.status === "ended") { + const rs = (run.results ?? []) as Array<{ status?: string }>; + const verdicts = rs.map((r) => r.status).filter(Boolean) as string[]; + const passed = verdicts.length > 0 && verdicts.every((s) => s === "pass"); + return { name, status: passed ? "pass" : "fail", detail: `${run.endedReason ?? ""} [${verdicts.join(",") || "no-result"}]` }; + } + if (run?.status === "error" || run?.status === "timeout" || run?.status === "aborted") { + return { name, status: "error", detail: `${run.status}: ${run.endedMessage ?? ""}` }; + } + } + return { name, status: "error", detail: "poll timeout" }; +} + +const results = await Promise.all(runs.map((r) => resolveRun(r.name, r.runId, r.error))); +results.sort((a, b) => a.name.localeCompare(b.name)); + +const pass = results.filter((r) => r.status === "pass").length; +const fail = results.filter((r) => r.status === "fail").length; +const errs = results.filter((r) => r.status === "error").length; + +console.log("\n── Simulation results ──"); +for (const r of results) { + const mark = r.status === "pass" ? "✓" : r.status === "fail" ? "✗" : "⚠"; + console.log(` ${mark} ${r.name.padEnd(34)} ${r.status.toUpperCase()}${r.detail ? " " + r.detail : ""}`); +} +console.log(`\n${pass}/${results.length} passed` + (fail ? `, ${fail} failed` : "") + (errs ? `, ${errs} errored` : "")); +console.log("Full traces: https://dashboard.vapi.ai/evals"); +process.exit(fail > 0 || errs > 0 ? 1 : 0); diff --git a/projects/meridian/src/vapi/speaking.ts b/projects/meridian/src/vapi/speaking.ts new file mode 100644 index 0000000..be1822c --- /dev/null +++ b/projects/meridian/src/vapi/speaking.ts @@ -0,0 +1,34 @@ +/** + * Shared barge-in (stop-speaking) config for every Meridian member. + * + * Centralized on purpose: + * - The squad's `membersOverrides.stopSpeakingPlan` OVERRIDES each member's own + * stopSpeakingPlan at call time, so the override and the per-assistant value + * must stay identical or behavior silently diverges between squad and standalone. + * - This has been re-tuned several times for PSTN false-interrupts; one source + * of truth stops it drifting. + * + * Strategy for the recurring "assistant starts a long read, stops, then repeats" + * bug (false barge-in from line echo or a quiet backchannel during a long read): + * - HIGH `numWords` so a short echo/backchannel can't reach the interrupt + * threshold mid-read; + * - `interruptionPhrases` so a caller who genuinely wants to cut in still can, + * instantly, by saying "wait" / "stop" / "actually" — we don't trade away + * real interruptibility for the higher threshold; + * - `acknowledgementPhrases` so common "okay / mm-hmm / yeah" never interrupt; + * - `backoffSeconds` slightly longer so any genuine stop resumes cleanly. + */ +import type { Vapi } from "@vapi-ai/server-sdk"; + +export const STOP_SPEAKING_PLAN: Vapi.StopSpeakingPlan = { + numWords: 10, // Vapi hard-caps numWords at 10; pair with interruptionPhrases for intentional cut-ins. + backoffSeconds: 2, + acknowledgementPhrases: [ + "okay", "ok", "uh huh", "mhm", "mm-hmm", "yeah", "yep", "yes", + "right", "sure", "got it", "i see", "cool", "alright", "perfect", "thanks", "thank you", + ], + interruptionPhrases: [ + "stop", "wait", "hold on", "hang on", "actually", "one second", "one moment", + "cancel that", "go back", "never mind", + ], +}; diff --git a/projects/meridian/src/vapi/squad.ts b/projects/meridian/src/vapi/squad.ts new file mode 100644 index 0000000..4c1c7ea --- /dev/null +++ b/projects/meridian/src/vapi/squad.ts @@ -0,0 +1,124 @@ +/** + * Meridian Concierge Squad — wires the five assistants into one call flow. + * + * Concierge (entry) ─▶ Hotel Concierge ⇄ Flight Triage ─▶ Rebooking ─▶ Upsell & Recovery + * + * Every member can reach the others for realistic topic pivots (a hotel caller + * who suddenly has a flight problem, etc.) — missing destinations caused a + * dropped call ("assistant-forwarded-call") when the model improvised a bad name. + * + * Routing lives here (member assistantDestinations), not in prompts. Transfers + * are silent + rolling-history so the identified-member context carries across. + * membersOverrides ONLY hardens barge-in (uniform numWords across members) — it must + * NOT set `voice`, or every member would speak in one voice and you'd lose the + * distinct personas. Each assistant declares its own Deepgram voice in its own file. + * + * Resolves members by name from the live org — create the 5 assistants first. + * Names must be UNIQUE in the org or idByName throws (a duplicate silently resolved + * the squad to a stale hotel agent once — see idByName guard below). + * npm run squad (creates, or updates the existing squad in place) + */ +import type { Vapi } from "@vapi-ai/server-sdk"; +import { vapi } from "../config.js"; +import { STOP_SPEAKING_PLAN } from "./speaking.js"; +import { header, pretty } from "../utils/print.js"; + +header("Meridian — create/update Concierge Squad"); + +const NAMES = { + concierge: "Meridian — Concierge", + hotel: "Meridian — Hotel Concierge (Jack)", + triage: "Meridian — Flight Triage", + rebooking: "Meridian — Rebooking", + upsell: "Meridian — Upsell & Recovery", +}; + +const all = await vapi.assistants.list({ limit: 100 }); +function idByName(name: string): string { + const matches = all.filter((x) => x.name === name); + if (matches.length === 0) throw new Error(`Assistant not found: "${name}" — create it first.`); + if (matches.length > 1) + throw new Error( + `Ambiguous: ${matches.length} assistants named "${name}" (${matches.map((m) => m.id).join(", ")}). ` + + `Delete the stale duplicate(s) so the name is unique before wiring the squad.` + ); + return matches[0].id; +} +const id = { + concierge: idByName(NAMES.concierge), + hotel: idByName(NAMES.hotel), + triage: idByName(NAMES.triage), + rebooking: idByName(NAMES.rebooking), + upsell: idByName(NAMES.upsell), +}; + +function handoff(msg: string): Vapi.AssistantOverrides { + return { firstMessageMode: "assistant-speaks-first", firstMessage: msg }; +} +function dest(name: string, description: string): Vapi.TransferDestinationAssistant { + return { type: "assistant", assistantName: name, description }; +} + +const members: Vapi.SquadMemberDto[] = [ + { + assistantId: id.concierge, + assistantDestinations: [ + dest(NAMES.hotel, "Anything about the HOTEL STAY: a reservation, check-in or check-out time, late checkout, amenities and hours (pool, spa, gym, restaurant), Wi-Fi, parking or valet, pet policy, housekeeping, towels, room service, a room upgrade, directions, or changing/cancelling a hotel reservation."), + dest(NAMES.triage, "Anything about a FLIGHT: flight status, a delay, a cancellation, 'my flight or trip was cancelled', changing or rebooking a flight, or a seat or cabin upgrade."), + ], + }, + { + assistantId: id.hotel, + assistantOverrides: handoff("This is Jack, your hotel specialist — happy to help with your stay. What do you need?"), + assistantDestinations: [ + dest(NAMES.triage, "Caller shifts to a FLIGHT matter: flight status, a delay, a cancellation, rebooking, a seat or cabin upgrade on a flight, or any other flight-related request."), + dest(NAMES.upsell, "Guest is checking in today or wants a hotel room upgrade (not flight-related)."), + ], + }, + { + assistantId: id.triage, + assistantOverrides: handoff("Hi, this is Maya at the Meridian flight desk. What's your flight number?"), + assistantDestinations: [ + dest(NAMES.rebooking, "Flight is cancelled or delayed two hours or more and the caller needs to be rebooked."), + dest(NAMES.hotel, "Caller shifts to a HOTEL matter: a reservation, room, amenities, or a service request."), + ], + }, + { + assistantId: id.rebooking, + assistantOverrides: handoff("This is Marcus, rebooking — I'll get you sorted. Do you have your booking reference handy?"), + assistantDestinations: [ + dest(NAMES.upsell, "Rebooking is confirmed after a cancellation or two-hour-plus delay — hand off to member care for post-disruption follow-up."), + dest(NAMES.triage, "Caller wants to re-check flight status before deciding."), + ], + }, + { + assistantId: id.upsell, + assistantOverrides: handoff("This is Sophie in member care — how can I help you today?"), + assistantDestinations: [ + dest(NAMES.hotel, "Caller shifts back to a HOTEL-stay matter."), + dest(NAMES.triage, "Caller shifts to a FLIGHT matter."), + ], + }, +]; + +const config: Vapi.CreateSquadDto = { + name: "Meridian Concierge Squad", + members, + membersOverrides: { + // NO `voice` here — each member keeps its own distinct Deepgram voice. + // Uniform Deepgram transcriber + barge-in across all members. This + // stopSpeakingPlan OVERRIDES each member's own, so it is the source of truth. + transcriber: { provider: "deepgram", model: "nova-2" }, + stopSpeakingPlan: STOP_SPEAKING_PLAN, + }, +}; + +const existing = (await vapi.squads.list({ limit: 100 })).find((s) => s.name === config.name); +const squad = existing ? await vapi.squads.update({ id: existing.id, ...config }) : await vapi.squads.create(config); + +pretty(existing ? "Updated squad (id preserved — phone stays pointed)" : "Created squad", { + id: squad.id, + name: squad.name, + members: members.length, +}); +console.log(`\nSQUAD_ID=${squad.id}`); diff --git a/projects/meridian/src/vapi/upload-kb.ts b/projects/meridian/src/vapi/upload-kb.ts new file mode 100644 index 0000000..c0dd331 --- /dev/null +++ b/projects/meridian/src/vapi/upload-kb.ts @@ -0,0 +1,32 @@ +/** + * Uploads assets/hotel-knowledge-base.txt to YOUR Vapi org and prints the + * file id. Put that id in .env as HOTEL_KB_FILE_ID, then (re)create the hotel + * assistant so it gets the knowledge-base query tool. + * + * npm run kb:upload + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { env } from "../config.js"; +import { header } from "../utils/print.js"; + +header("Meridian — upload hotel knowledge base"); + +const here = path.dirname(fileURLToPath(import.meta.url)); +const kbPath = path.resolve(here, "../../assets/hotel-knowledge-base.txt"); + +const form = new FormData(); +form.append("file", new Blob([fs.readFileSync(kbPath)], { type: "text/plain" }), "hotel-knowledge-base.txt"); + +const res = await fetch("https://api.vapi.ai/file", { + method: "POST", + headers: { Authorization: `Bearer ${env.vapiApiKey}` }, + body: form, +}); +if (!res.ok) throw new Error(`Upload failed: HTTP ${res.status} — ${await res.text()}`); +const file = (await res.json()) as { id: string; name: string }; + +console.log(`Uploaded "${file.name}" → file id: ${file.id}`); +console.log(`\nAdd to .env:\n HOTEL_KB_FILE_ID=${file.id}`); +console.log(`Then re-run: npm run assistant:hotel (ASSISTANT_ID= to upsert in place)`); diff --git a/projects/meridian/tsconfig.json b/projects/meridian/tsconfig.json new file mode 100644 index 0000000..5e92158 --- /dev/null +++ b/projects/meridian/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["src/**/*", "api/**/*"], + "exclude": ["node_modules", "dist"] +} From 6ab26f2e86e0e723a85c56f33b89570120c1229b Mon Sep 17 00:00:00 2001 From: Justin Crowe Date: Tue, 7 Jul 2026 15:14:39 -0400 Subject: [PATCH 2/3] Dehyphenate two spoken strings so the TTS-hygiene audit is fully green Co-Authored-By: Claude Fable 5 --- projects/meridian/src/vapi/flight-code.ts | 2 +- projects/meridian/src/vapi/service-request-code.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/meridian/src/vapi/flight-code.ts b/projects/meridian/src/vapi/flight-code.ts index 1a1dac0..f6ef115 100644 --- a/projects/meridian/src/vapi/flight-code.ts +++ b/projects/meridian/src/vapi/flight-code.ts @@ -256,7 +256,7 @@ const eligible = voucher > 0; return { eligible: eligible, tier: tier, message: eligible - ? "I've noted a travel-credit request for this disruption; our team will review it and follow up." + ? "I've noted a travel credit request for this disruption; our team will review it and follow up." : "There's no eligible disruption here, so there's nothing to submit.", }; `; diff --git a/projects/meridian/src/vapi/service-request-code.ts b/projects/meridian/src/vapi/service-request-code.ts index 7b034ed..d1348a7 100644 --- a/projects/meridian/src/vapi/service-request-code.ts +++ b/projects/meridian/src/vapi/service-request-code.ts @@ -126,6 +126,6 @@ const created = await insert("service_requests", { return { submitted: true, status: "pending", toRoom: toRoom, - message: "I've put in the request for " + toRoom + " at " + rateWords + ". The team confirms availability and it goes on your folio at check-in; nothing's charged now.", + message: "I've put in the request for " + toRoom + " at " + rateWords + ". The team confirms availability and it goes on your folio when you check in; nothing's charged now.", }; `; From b9c7f2369c6c046e76bb11b60292008a73f4a70e Mon Sep 17 00:00:00 2001 From: Amanda Martin <290842976+amanda-vapi@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:53:42 -0700 Subject: [PATCH 3/3] Update projects/meridian/src/db/schema.sql --- projects/meridian/src/db/schema.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/meridian/src/db/schema.sql b/projects/meridian/src/db/schema.sql index 926cbd6..e9a3816 100644 --- a/projects/meridian/src/db/schema.sql +++ b/projects/meridian/src/db/schema.sql @@ -74,6 +74,7 @@ create index if not exists idx_reservations_guest on reservations (guest_id); -- PostgREST API roles need explicit table privileges: tables created over a direct -- postgres connection don't inherit Supabase's default grants. RLS stays OFF — all -- access is server-side via the secret / service_role key. +-- refer to supabase documentation for best practices for RLS before considering this for a production deployment grant usage on schema public to anon, authenticated, service_role; grant all on all tables in schema public to anon, authenticated, service_role; grant all on all sequences in schema public to anon, authenticated, service_role;