1/** 2 * LVL LTD â human shopping assistant (static embed) 3 * Catalog-grounded via /api/shop-assist. Session-only memory. 4 * Proactive: max 1 interruption / session on multi-browse or outline dwell. 5 * 6 * P0: skip mount on dense interactive surfaces (marketplace grid, AR/trek 7 * heavy pages) â Skill guide chrome + sessionStorage work competes with 8 * 100+ card paint and can lock the main thread. Opt-in with ?sa=1. 9 */ 10(function (global) { 11 "use strict"; 12 13 if (global.__LVL_SHOP_ASSIST_MOUNTED__) return; 14 15 function isDenseSurface() { 16 try { 17 var path = (location.pathname || "").toLowerCase(); 18 var q = location.search || ""; 19 if (/[?&]sa=1(?:&|$)/.test(q)) return false; // explicit opt-in 20 // Marketplace inventory (SSR + interactive grid) 21 if (path.indexOf("/hub/marketplace") !== -1) return true; 22 // Dense AR / trek map shells on apex mirrors 23 if (path.indexOf("/trek/ar") !== -1 || path === "/ar" || path.indexOf("/ar/") === 0) 24 return true; 25 // Explicit kill switch 26 if (/[?&]sa=0(?:&|$)/.test(q)) return true; 27 if (document.documentElement.getAttribute("data-lvl-no-sa") === "1") return true; 28 if (document.body && document.body.getAttribute("data-lvl-no-sa") === "1") return true; 29 } catch (_) { 30 /* */ 31 } 32 return false; 33 } 34 35 if (isDenseSurface()) { 36 global.__LVL_SHOP_ASSIST_MOUNTED__ = true; // prevent re-entry if script double-loads 37 global.__LVL_SHOP_ASSIST_SKIPPED__ = "dense_surface"; 38 return; 39 } 40 41 global.__LVL_SHOP_ASSIST_MOUNTED__ = true; 42 43 const API = "/api/shop-assist"; 44 const SESS_KEY = "lvl_shop_assist_session_v1"; 45 const PRO_KEY = "lvl_shop_assist_proactive_v1"; 46 const UI_KEY = "lvl_shop_assist_ui_v1"; 47 48 function loadJSON(key, fallback) { 49 try { 50 const r = sessionStorage.getItem(key); 51 return r ? JSON.parse(r) : fallback; 52 } catch { 53 return fallback; 54 } 55 } 56 function saveJSON(key, val) { 57 try { 58 sessionStorage.setItem(key, JSON.stringify(val)); 59 } catch { 60 /* */ 61 } 62 } 63 64 function emptySession() { 65 return { 66 id: "sess_" + Date.now().toString(36), 67 messages: [], 68 lastSkillIds: [], 69 }; 70 } 71 72 function defaultPro() { 73 return { 74 skillPageViews: [], 75 outlineDwellMs: {}, 76 unlockAttempted: false, 77 proactiveSent: false, 78 }; 79 } 80 81 function skillIdFromLocation() { 82 try { 83 const u = new URL(location.href); 84 if (u.searchParams.get("buy")) return u.searchParams.get("buy"); 85 const m1 = u.pathname.match(/\/skills\/([^/]+)/); 86 if (m1) return decodeURIComponent(m1[1]); 87 const m2 = u.pathname.match(/\/agents\/([^/]+)/); 88 if (m2 && !["data", "feed", "crew", "docs"].includes(m2[1])) 89 return decodeURIComponent(m2[1]); 90 } catch { 91 /* */ 92 } 93 return null; 94 } 95 96 function trackBrowse() { 97 const pro = loadJSON(PRO_KEY, defaultPro()); 98 const id = skillIdFromLocation(); 99 if (id && pro.skillPageViews.indexOf(id) === -1) { 100 pro.skillPageViews.push(id); 101 pro.skillPageViews = pro.skillPageViews.slice(-20); 102 saveJSON(PRO_KEY, pro); 103 } 104 if (/pay=1|unlock/i.test(location.search)) { 105 pro.unlockAttempted = true; 106 saveJSON(PRO_KEY, pro); 107 } 108 // Outline dwell â single timer, capped (never leave unbounded setIntervals) 109 if (id && /outline/i.test(location.pathname + location.href) && !global.__LVL_SA_DWELL__) { 110 global.__LVL_SA_DWELL__ = true; 111 var ticks = 0; 112 var iv = setInterval(function () { 113 ticks += 1; 114 if (ticks > 36) { 115 // ~3 minutes max 116 clearInterval(iv); 117 return; 118 } 119 const p = loadJSON(PRO_KEY, defaultPro()); 120 p.outlineDwellMs[id] = (p.outlineDwellMs[id] || 0) + 5000; 121 saveJSON(PRO_KEY, p); 122 }, 5000); 123 } 124 } 125 126 function evaluateProactive() { 127 const s = loadJSON(PRO_KEY, defaultPro()); 128 if (s.proactiveSent || s.unlockAttempted) return null; 129 if (s.skillPageViews.length >= 3) { 130 const last = s.skillPageViews[s.skillPageViews.length - 1]; 131 return { 132 message: 133 "You've looked at " + 134 s.skillPageViews.length + 135 ' skills (including â' + 136 last + 137 "â) without unlocking. Is the wallet/USDC step the blocker, or are you still comparing free outlines?", 138 }; 139 } 140 for (const id in s.outlineDwellMs) { 141 if (s.outlineDwellMs[id] >= 45000) { 142 return { 143 message: 144 'You\'ve spent a while on the free outline for â' + 145 id + 146 ".â What's holding you back â unclear fit, price, or MetaMask/USDC steps?", 147 }; 148 } 149 } 150 return null; 151 } 152 153 function el(tag, cls, html) { 154 const n = document.createElement(tag); 155 if (cls) n.className = cls; 156 if (html != null) n.innerHTML = html; 157 return n; 158 } 159 160 function mount() { 161 trackBrowse(); 162 163 const root = el("div", "lvl-sa-root"); 164 root.setAttribute("data-lvl-shopping-assistant", "1"); 165 const fab = el("button", "lvl-sa-fab"); 166 fab.type = "button"; 167 fab.textContent = "Skill guide"; 168 fab.setAttribute("aria-label", "Open skill shopping guide"); 169 170 const panel = el("section", "lvl-sa-panel"); 171 panel.setAttribute("aria-label", "LVL skill shopping assistant"); 172 panel.hidden = true; 173 174 panel.innerHTML = 175 '<header class="lvl-sa-header">' + 176 '<button type="button" class="lvl-sa-header-main" aria-label="Expand skill guide">' + 177 '<div><strong>Skill guide</strong>' + 178 '<div class="lvl-sa-sub">Humans only · live catalog · free outline first</div></div>' + 179 '<span class="lvl-sa-min-hint" hidden>Click to expand</span>' + 180 "</button>" + 181 '<div class="lvl-sa-header-actions">' + 182 '<button type="button" class="lvl-sa-min" aria-label="Minimize chat" title="Minimize">â</button>' + 183 '<button type="button" class="lvl-sa-x" aria-label="Close chat" title="Close">Ã</button>' + 184 "</div></header>" + 185 '<div class="lvl-sa-msgs" id="lvl-sa-msgs"></div>' + 186 '<form class="lvl-sa-form" id="lvl-sa-form">' + 187 '<input id="lvl-sa-input" placeholder="What does your agent need?" aria-label="Message" autocomplete="off" />' + 188 '<button type="submit" id="lvl-sa-send">Send</button></form>'; 189 190 root.appendChild(fab); 191 root.appendChild(panel); 192 document.body.appendChild(root); 193 194 const msgs = panel.querySelector("#lvl-sa-msgs"); 195 const form = panel.querySelector("#lvl-sa-form"); 196 const input = panel.querySelector("#lvl-sa-input"); 197 const sendBtn = panel.querySelector("#lvl-sa-send"); 198 const minBtn = panel.querySelector(".lvl-sa-min"); 199 const closeBtn = panel.querySelector(".lvl-sa-x"); 200 const headerMain = panel.querySelector(".lvl-sa-header-main"); 201 const minHint = panel.querySelector(".lvl-sa-min-hint"); 202 let busy = false;
203 let session = loadJSON(SESS_KEY, emptySession()); 204 /** @type {"closed"|"open"|"minimized"} */ 205 let uiState = loadJSON(UI_KEY, { state: "closed" }).state || "closed"; 206 207 function persistUi(state) { 208 uiState = state; 209 saveJSON(UI_KEY, { state: state }); 210 } 211 212 function ensureMessages() { 213 if (!msgs.childNodes.length) restore(); 214 } 215 216 function setOpen() { 217 panel.hidden = false; 218 fab.hidden = true; 219 panel.classList.remove("is-minimized"); 220 minBtn.setAttribute("aria-label", "Minimize chat"); 221 minBtn.title = "Minimize"; 222 minBtn.textContent = "â"; 223 if (minHint) minHint.hidden = true; 224 headerMain.setAttribute("aria-label", "Skill guide"); 225 panel.setAttribute("aria-expanded", "true"); 226 ensureMessages(); 227 persistUi("open"); 228 try { 229 input.focus(); 230 } catch { 231 /* */ 232 } 233 } 234 235 function setMinimized() { 236 panel.hidden = false; 237 fab.hidden = true; 238 panel.classList.add("is-minimized"); 239 minBtn.setAttribute("aria-label", "Expand chat"); 240 minBtn.title = "Expand"; 241 minBtn.textContent = "â¢"; 242 if (minHint) minHint.hidden = false; 243 headerMain.setAttribute("aria-label", "Expand skill guide"); 244 panel.setAttribute("aria-expanded", "false"); 245 ensureMessages(); 246 persistUi("minimized"); 247 } 248 249 function setClosed() { 250 panel.hidden = true; 251 fab.hidden = false; 252 panel.classList.remove("is-minimized"); 253 panel.setAttribute("aria-expanded", "false"); 254 if (minHint) minHint.hidden = true; 255 persistUi("closed"); 256 } 257 258 function renderWelcome() { 259 if (session.messages && session.messages.length) return; 260 const b = el( 261 "div", 262 "lvl-sa-bubble-bot", 263 "Tell me what you need for your agent (e.g. ânegotiate purchasesâ or âcheapest way to tryâ). Iâll match <strong>real catalog skills</strong> and always point at free outlines before payment." 264 ); 265 msgs.appendChild(b); 266 } 267 268 function appendBubble(role, text, proactive) { 269 const b = el( 270 "div", 271 role === "user" ? "lvl-sa-bubble-user" : "lvl-sa-bubble-bot" 272 ); 273 if (proactive) { 274 b.appendChild(el("div", "lvl-sa-proactive", "Proactive · once this visit")); 275 } 276 const body = document.createElement("div"); 277 body.textContent = text; 278 b.appendChild(body); 279 msgs.appendChild(b); 280 msgs.scrollTop = msgs.scrollHeight; 281 } 282 283 function appendCards(skills) { 284 if (!skills || !skills.length) return; 285 const wrap = el("div", "lvl-sa-cards"); 286 skills.forEach(function (s) { 287 const c = el("article", "lvl-sa-card"); 288 c.innerHTML = 289 "<strong></strong><div class='lvl-sa-price'></div><p class='lvl-sa-sum'></p>" + 290 (s.tradeoff_note ? "<p class='lvl-sa-trade'></p>" : "") + 291 "<a target='_blank' rel='noopener'>Free outline first</a> · <a>Marketplace</a>"; 292 c.querySelector("strong").textContent = s.name; 293 c.querySelector(".lvl-sa-price").textContent = s.price_label || ""; 294 c.querySelector(".lvl-sa-sum").textContent = s.summary || ""; 295 const trade = c.querySelector(".lvl-sa-trade"); 296 if (trade) trade.textContent = s.tradeoff_note || ""; 297 const links = c.querySelectorAll("a"); 298 links[0].href = s.outline_url; 299 links[1].href = s.marketplace_url; 300 wrap.appendChild(c); 301 }); 302 msgs.appendChild(wrap); 303 msgs.scrollTop = msgs.scrollHeight; 304 } 305 306 function restore() { 307 (session.messages || []).forEach(function (m) { 308 appendBubble(m.role, m.content, m.meta && m.meta.proactive); 309 }); 310 renderWelcome(); 311 } 312 313 fab.onclick = function () { 314 setOpen(); 315 }; 316 closeBtn.onclick = function (e) { 317 e.stopPropagation(); 318 setClosed(); 319 }; 320 minBtn.onclick = function (e) { 321 e.stopPropagation(); 322 if (panel.classList.contains("is-minimized")) setOpen(); 323 else setMinimized(); 324 }; 325 headerMain.onclick = function () { 326 if (panel.classList.contains("is-minimized")) setOpen(); 327 };
328 // Never restore full-open panel on load â it covers marketplace cards. 329 // Only restore minimized chrome (compact bar) if user left it that way. 330 // Fresh visits and "open" sessions start closed (FAB only). 331 if (uiState === "minimized") setMinimized(); 332 else setClosed(); 333 334 form.onsubmit = function (e) { 335 e.preventDefault(); 336 const text = (input.value || "").trim(); 337 if (!text || busy) return; 338 busy = true; 339 sendBtn.disabled = true; 340 input.value = ""; 341 appendBubble("user", text); 342 session.messages.push({ 343 role: "user", 344 content: text, 345 at: new Date().toISOString(), 346 }); 347 saveJSON(SESS_KEY, session); 348 349 fetch(API, { 350 method: "POST", 351 headers: { "Content-Type": "application/json" }, 352 body: JSON.stringify({ 353 message: text, 354 session_id: session.id, 355 history: session.messages.slice(-12), 356 page_url: location.href, 357 }), 358 }) 359 .then(function (r) { 360 return r.json(); 361 }) 362 .then(function (data) { 363 const reply = 364 (data && data.reply) || 365 "Could not load catalog matches â try the marketplace."; 366 appendBubble("assistant", reply); 367 session.messages.push({ 368 role: "assistant", 369 content: reply, 370 at: new Date().toISOString(), 371 meta: { skill_ids: (data.skills || []).map(function (s) { return s.id; }) }, 372 }); 373 saveJSON(SESS_KEY, session); 374 appendCards(data.skills || []); 375 }) 376 .catch(function () { 377 appendBubble( 378 "assistant", 379 "Network error. Browse https://lvlltd.com/hub/marketplace or retry." 380 ); 381 }) 382 .finally(function () { 383 busy = false; 384 sendBtn.disabled = false; 385 }); 386 }; 387 388 // Proactive once â minimized bar only (never full panel over commerce grid) 389 setTimeout(function () { 390 try { 391 const hit = evaluateProactive(); 392 if (!hit) return; 393 const pro = loadJSON(PRO_KEY, defaultPro()); 394 pro.proactiveSent = true; 395 saveJSON(PRO_KEY, pro); 396 ensureMessages(); 397 appendBubble("assistant", hit.message, true); 398 session.messages.push({ 399 role: "assistant", 400 content: hit.message, 401 at: new Date().toISOString(), 402 meta: { proactive: true }, 403 }); 404 saveJSON(SESS_KEY, session); 405 setMinimized(); 406 // Pulse FAB if still closed somehow 407 try { 408 fab.classList.add("lvl-sa-fab-pulse"); 409 setTimeout(function () { 410 fab.classList.remove("lvl-sa-fab-pulse"); 411 }, 4000); 412 } catch (e) { 413 /* */ 414 } 415 } catch (e) { 416 console.warn("shop-assist proactive", e); 417 } 418 }, 2800); 419 } 420 421 if (document.readyState === "loading") { 422 document.addEventListener("DOMContentLoaded", mount); 423 } else { 424 mount(); 425 } 426})(typeof window !== "undefined" ? window : globalThis);
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.