1// Destiny v4 for the website bubble (loaded by destiny-head.js only when 2// /api/destiny-engine says so). The conversation lives on the server; this file sends 3// the guest's words, runs the turn, and draws answers with the bubble's own functions. 4// It also shows Ozan's live-chat messages and swaps contact placeholders back to what 5// the guest typed (kept only in this tab). 6(function(){ 7 const SEED='destiny_v4_seed',SENT='destiny_v4_sent',SEEN='destiny_v4_owner_seen'; 8 const EMAIL=/[\w.+-]+@[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,}/g,PHONE=/(?<![\w@.])(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}(?![\w@])/g,TOKEN=/\[(email|phone) (\d{1,2})\]/g; 9 const hex=n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join(''); 10 const uuid=()=>crypto.randomUUID?crypto.randomUUID():hex(16); 11 // Rules 4 (testing): this tab was opened with ?rules=4 or ?voice=4. 12 const rules4=()=>{try{return sessionStorage.getItem('db_rules')==='4';}catch(e){return false;}}; 13 const quoteMode=()=>{try{const q=new URLSearchParams(location.search).get('quote');if(q==='1')sessionStorage.setItem('db_quote','1');if(q==='0')sessionStorage.removeItem('db_quote');return sessionStorage.getItem('db_quote')==='1';}catch{return false;}}; 14 const quickMode=()=>{try{const q=new URLSearchParams(location.search).get('quick');if(q==='2')sessionStorage.setItem('db_quick','2');if(q==='0')sessionStorage.removeItem('db_quick');return sessionStorage.getItem('db_quick')==='2';}catch{return false;}}; 15 let ownerScript=null; 16 function loadOwnerForm(){if(window.DestinyOwnerRequest)return Promise.resolve();return ownerScript??=new Promise((ok,no)=>{const el=document.createElement('script');el.src='/destiny-owner-request.js';el.onload=ok;el.onerror=()=>{ownerScript=null;no(Error('owner_request_unavailable'));};document.head.appendChild(el);});} 17 let quoteScript=null; 18 function loadQuoteForm(){if(window.DestinyQuoteForm)return Promise.resolve();return quoteScript??=new Promise((ok,no)=>{const el=document.createElement('script');el.src='/destiny-quote-form.js';el.onload=ok;el.onerror=()=>{quoteScript=null;no(Error('quote_unavailable'));};document.head.appendChild(el);});} 19 const sleep=ms=>new Promise(r=>setTimeout(r,ms)); 20 const store={get(k,d){try{return JSON.parse(sessionStorage.getItem(k))??d;}catch{return d;}},set(k,v){try{sessionStorage.setItem(k,JSON.stringify(v));}catch{}}}; 21 const e164=raw=>{const d=String(raw).replace(/\D/g,'');return d.length===10?'+1'+d:d.length===11&&d[0]==='1'?'+'+d:null;}; 22 // Same numbering as the server (contacts.js), rebuilt from what this guest typed. 23 function ownContacts(){ 24 const c={emails:[],phones:[]}; 25 for(const text of store.get(SENT,[])){ 26 String(text).replace(EMAIL,m=>{m=m.toLowerCase();if(!c.emails.includes(m))c.emails.push(m);return m;}); 27 String(text).replace(EMAIL,'').replace(PHONE,m=>{const p=e164(m);if(p&&!c.phones.includes(p))c.phones.push(p);return m;}); 28 } 29 return c; 30 } 31 const guestView=text=>{const c=ownContacts();return String(text||'').replace(TOKEN,(m,k,n)=>(k==='email'?c.emails:c.phones)[Number(n)-1]||(k==='email'?'your email ð':'your number ð±'));}; 32 const pageContext=()=>{ 33 const source=sessionStorage.getItem('db_source'); 34 if(source==='ticker'){try{const t=JSON.parse(sessionStorage.getItem('db_ticker')||'{}');return {source,unit:t.unit,arrival:t.arrival,departure:t.departure};}catch{return {source};}} 35 return source?{source}:null; 36 }; 37 // Destiny's text often already contains its links; add only the ones it left out. 38 const compose=a=>a.catalogVersion===2?a.reply:[a.reply,...(a.links||[]).filter(u=>!String(a.reply).includes(u))].join('\n\n'); 39 const friendly={private_memory_sensitive_input:'For your safety, please do not share c
39ard numbers or reservation codes in chat. Your email or phone number is fine ð'}; 40 41 // opts.page: the page context when the page knows it (the concierge page's pageSource). 42 window.DestinyV4={start(config,ui,opts={}){ 43 quoteMode();quickMode(); // Remember the explicit test switch even before the first typed turn. 44 const path=config.chatPath,voicePath=config.voicePath||'/api/destiny-private-voice';let clientId=uuid(),sequence=0,after=0,booted=null,ownerStatus='none',watching=null,fresh=false; 45 async function post(body,to=path){ 46 const r=await fetch(to,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),credentials:'same-origin'}); 47 const d=await r.json().catch(()=>({}));if(!r.ok){const e=Error(d.error||'request_failed');e.status=r.status;throw e;}return d; 48 } 49 // A streamed request: partial answer lines as they come, the last line is the result. 50 async function postStream(body,onLine,to=path){ 51 const r=await fetch(to,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body),credentials:'same-origin'}); 52 if(!r.ok||!r.body?.getReader){const d=await r.json().catch(()=>({}));if(!r.ok){const e=Error(d.error||'request_failed');e.status=r.status;throw e;}return d;} 53 const reader=r.body.getReader(),dec=new TextDecoder();let buf='',last=null; 54 for(;;){const {value,done}=await reader.read();if(value)buf+=dec.decode(value,{stream:!done}); 55 let at;while((at=buf.indexOf('\n'))>=0){const line=buf.slice(0,at).trim();buf=buf.slice(at+1);if(!line)continue;let o;try{o=JSON.parse(line);}catch{continue;}if(o.partial)onLine(o.partial);else last=o;} 56 if(done)break;} 57 if(buf.trim()){try{last=JSON.parse(buf.trim());}catch{}} 58 if(!last)throw Error('request_failed');if(last.error)throw Error(last.error);return last; 59 } 60 function seed(){let s=localStorage.getItem(SEED);if(!/^[a-f0-9]{64}$/.test(s||'')){s=hex(32);localStorage.setItem(SEED,s);}return s;} 61 function boot(){const page=opts.page||pageContext();return booted??=post({op:'bootstrap',bootstrapId:seed(),...(fresh?{fresh:true}:{}),...(page?{page}:{})}).then(s=>{fresh=false;owner(s.owner);return s;}).catch(e=>{booted=null;throw e;});} 62 // Ozan's live chat: notes and his messages, each shown once. 63 function owner(o){ 64 if(!o)return;ownerStatus=o.status; 65 const seen=store.get(SEEN,0),msgs=o.messages||[]; 66 msgs.slice(seen).forEach(m=>m.from==='ozan'?ui.addOzan(m.text):ui.addNote(m.text)); 67 store.set(SEEN,msgs.length); 68 if(['invited','active'].includes(ownerStatus))watch(); 69 } 70 function watch(){ 71 if(watching)return; 72 watching=setInterval(async()=>{try{const s=await post({op:'status',after});owner(s.owner);if(!['invited','active'].includes(ownerStatus)){clearInterval(watching);watching=null;}}catch{}},3000); 73 } 74 // One guest message. A retry reuses the same turnId and sequence, so the server treats 75 // it as the same message (never sent twice). 76 async function turn(text,turnId,seq,retry,voice,onPartial=null,gate=null){ 77 let stage='bootstrap'; 78 try{ 79 await boot(); 80 stage='accept'; 81 if(!retry)store.set(SENT,[...store.get(SENT,[]),text]); 82 // Spoken words go in as voice turns (same conversation, same brain; HQ marks them ð¤). 83 if(gate?.before)await gate.before.catch(()=>{}); // Voice 6: acceptances stay in order, processing does not wait 84 try{await post({op:'accept',text,turnId,clientId,clientSequence:seq,synthetic:true,...(rules4()||voice?.v>=4?{rules:4}:{}),...(voice?{voice}:quoteMode()?{quote:1}:{}),...(!voice&&config.quickVersion===2&&quickMode()?{quick:2,rules:4}:{})},voice?voicePath:path);}finally{gate?.accepted?.(turnId);} 85 // Fast path: run the turn right away and get its answer back in the same request. 86 stage='process'; 87 {let so_far='';const r=onPartial?await postStream({op:'process',after,stream:true},p=>{if(p.turnId!==turnId)return;if(p.done){try{onPartial(so_far,true);}catch{}}else if(typeof p.text==='string'){so_far+=p.text;
87try{onPartial(so_far);}catch{}}else if(p.stage){try{onPartial.onStage?.(p.stage);}catch{}}else if(p.timing){try{onPartial.onTiming?.(p.timing);}catch{}}}):await post({op:'process',after});owner(r.owner);let mine=null; 88 for(const a of r.answers||[]){after=Math.max(after,a.sequence);if(a.turnId===turnId)mine=a;} 89 if(mine)return mine;if(r.blocked)throw Error('paused');} 90 for(let i=0;i<400;i++){ 91 stage='status'; 92 const s=await post({op:'status',after});owner(s.owner); 93 let mine=null;for(const a of s.answers||[]){after=Math.max(after,a.sequence);if(a.turnId===turnId)mine=a;} 94 if(mine)return mine; 95 if(s.blocked)throw Error('paused'); 96 if(s.pending){stage='process';const r=await post({op:'process'});if(r.busy)await sleep(500);}else await sleep(400); 97 } 98 throw Error('timeout'); 99 }catch(e){e.stage??=stage;throw e;} 100 } 101 // Connection problems are counted in HQ (a code and the failing step only). 102 function report(kind,e){ 103 try{const page=(opts.page||pageContext())?.source||null; 104 fetch('/api/destiny-client-event',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json'},credentials:'same-origin', 105 body:JSON.stringify({kind,code:String(e?.message||'unknown'),stage:e?.stage||'unknown',page:typeof page==='string'?page.toLowerCase():null})}).catch(()=>{}); 106 }catch{} 107 } 108 const NO_RETRY=['paused','timeout','private_memory_sensitive_input','conversation_expired']; 109 async function showQuote(offer,turnId,host,onPrepared,isCurrent=()=>true,options={}){ 110 if(!host){host=document.createElement('div');host.className='db-msg bot';document.getElementById('db-messages')?.appendChild(host);} 111 const loading=document.createElement('p');loading.textContent='Loading quote form (test mode)â¦';host.appendChild(loading);
112 try{await loadQuoteForm();}catch(e){loading.textContent='The quote form could not load. Please try again.';throw e;} 113 loading.remove();if(!isCurrent()||host.isConnected===false)return; 114 let prefillEmail=options.prefillEmail||''; 115 if(!prefillEmail)try{await boot();prefillEmail=(await post({op:'quote_contact',turnId})).email||'';}catch{} 116 if(!isCurrent()||host.isConnected===false)return; 117 return window.DestinyQuoteForm.mount(host,{offer,turnId,onPrepared,prefillEmail,onFailed:options.onFailed,onDismiss:options.onDismiss,submit:async b=>{await boot();return post(b,'/api/destiny-quote');}}); 118 } 119 async function showOwnerRequest(request,turnId,host,onPrepared,isCurrent=()=>true,options={}){ 120 if(!host){host=document.createElement('div');host.className='db-msg bot';document.getElementById('db-messages')?.append(host);} 121 host.textContent='Loading request formâ¦';await loadOwnerForm();let prefillEmail=options.prefillEmail||''; 122 if(!prefillEmail)try{await boot();prefillEmail=(await post({op:'owner_request_contact',turnId})).email||'';}catch{} 123 if(!isCurrent()||host.isConnected===false)return; 124 return window.DestinyOwnerRequest.mount(host,{request,turnId,prefillEmail,isCurrent,onPrepared,onFailed:options.onFailed,onDismiss:options.onDismiss,submit:b=>post(b)}); 125 } 126 function showActions(a){if(a.catalogVersion!==2||!a.actions?.length)return;if(ui.addActions){ui.addActions(a.actions);return;}const host=document.createElement('div');host.className='db-msg bot';host.style.cssText='display:flex;gap:8px;flex-wrap:wrap';for(const item of a.actions.slice(0,3)){const link=document.createElement('a');link.href=item.url;link.textContent=item.label;link.rel='noopener noreferrer';if(item.url.startsWith('https:'))link.target='_blank';link.style.cssText='display:inline-block;padding:12px 16px;border-radius:24px;background:#076f73;color:white;text-decoration:none';host.append(link);}document.getElementById('db-messages')?.append(host);} 127 return { 128 showQuote,showOwnerRequest, 129 ownerActive:()=>ownerStatus==='active', 130 // Voice 5: load the brain's prompt cache when a call starts (no answer, nothing saved). 131 async warm(){await boot();return post({op:'warm'},voicePath);}, 132 // Voice 6: a sentence she already answered herself is closed with her words (the brain moves on). 133 // Voice 8: quick names the approved entry she spoke, so the server records its canonical copy. 134 close(turnId,reply,quick){return post({op:'close',turnId,reply:String(reply).slice(0,400),...(typeof quick==='string'?{quick}:{})},voicePath);}, 135 // Voice 8: a complete booking request from the call's fast helper (the server checks it). 136 fastBook(turnId,book){return post({op:'fast_book',turnId,book},voicePath);}, 137 // Voice 5: this browser's conversation already has turns (a returning caller). 138 async returning(){const s=await boot();return (s?.accepted||0)>0;}, 139 greeting:source=>config.greetings?.[source]||null, 140 // The bubble's first line (HQ Appearance). 141 opener:()=>config.opener||null, 142 // The chips on the welcome message (HQ Appearance). 143 welcomeChips:()=>Array.isArray(config.welcomeChips)?config.welcomeChips.slice(0,4).map(c=>({label:String(c.label),prompt:String(c.prompt)})):[], 144 // Past messages after a reload (the guest's own words when this tab has them). 145 async restore(){ 146 try{ 147 const s=await boot(),st=await post({op:'status',after:0}),sent=store.get(SENT,[]);let i=0; 148 for(const a of st.answers||[]){after=Math.max(after,a.sequence);ui.addU(a.question.includes('[')&&sent[i]?sent[i]:guestView(a.question));i++;if(a.reply)ui.addB(guestView(compose(a)));showActions(a);} 149 store.set(SEEN,0);owner(st.owner);return (st.answers||[]).length>0; 150 }catch{return false;} 151 }, 152 // opts.voice: how the spoken words were heard (the mic). Returns the answer (voice reads it). 153 async send(text,retried=false,opts={}){ 154 if(text==='__popup_open__'){ui.addB(config.greetings?.popup||"Who do I have the pleasure of welcoming to Destin today? ð");return;} 155 ui.showTyping(''); 156 const turnId=uuid(),seq=++sequence,typedAt=Date.now();let first=null; 157 try{ 158 let a;
159 try{a=await turn(text,turnId,seq,false,opts.voice,opts.onPartial||null,opts.gate||null);} 160 catch(e){ 161 // One quiet retry for a network blip or a mixed-up conversation ticket. 162 if(NO_RETRY.includes(e.message))throw e; 163 first=e;booted=null;await sleep(800); 164 a=await turn(text,turnId,seq,true,opts.voice); 165 report('recovered',first); 166 } 167 const wait=500-(Date.now()-typedAt);if(wait>0)await sleep(wait); 168 ui.rmTyping(); 169 if(a.reply)ui.addB(guestView(compose(a)),{type:true});showActions(a); 170 if(!opts.voice&&a.catalogVersion===2&&a.ownerRequest){if(ui.addOwnerRequest)ui.addOwnerRequest(a);else await showOwnerRequest(a.ownerRequest,a.turnId).catch(()=>ui.addNote?.('The request form could not load. Please contact Ozan directly.'));} 171 if(!opts.voice&"eMode()&&a.offerQuote){if(a.quoteInvite)ui.addB(a.quoteInvite);if(ui.addQuote)ui.addQuote(a);else await showQuote(a.offerQuote,a.turnId).catch(()=>{});} 172 // Loop breaker: a tap sends what Destiny understood as the guest's own words. 173 if(a.confirm?.text&&ui.addConfirm)ui.addConfirm({label:String(a.confirm.label||a.confirm.text),text:String(a.confirm.text)}); 174 // "Guests also asked" (never next to the confirm button). 175 else if(Array.isArray(a.chips)&&a.chips.length&&ui.addChips)ui.addChips(a.chips.slice(0,3).map(c=>({label:String(c.label),prompt:String(c.prompt)}))); 176 return a; 177 }catch(e){ 178 ui.rmTyping(); 179 if(e.message==='conversation_expired'&&!retried){this.reset();return this.send(text,true,opts);} 180 if(e.message!=='private_memory_sensitive_input')report('connect_failed',e); 181 ui.addB(friendly[e.message]||"Sorry, I couldn't connect. Please try again or reach Ozan at (972) 357-4262."); 182 } 183 }, 184 // A new conversation (the ticker starts one for its dates). 185 reset(){fresh=true;localStorage.removeItem(SEED);store.set(SENT,[]);store.set(SEEN,0);booted=null;after=0;clientId=uuid();sequence=0;ownerStatus='none';if(watching){clearInterval(watching);watching=null;}}, 186 }; 187 }}; 188})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.