@zerocost/sdk 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -138,6 +138,142 @@ var TrackModule = class {
138
138
  }
139
139
  };
140
140
 
141
+ // src/core/widget-render.ts
142
+ var SDK_WIDGET_REFRESH_MS = 2e4;
143
+ function escapeHtml(value) {
144
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
145
+ }
146
+ function resolveTheme(theme) {
147
+ if (theme === "light" || theme === "dark") {
148
+ return theme;
149
+ }
150
+ if (typeof window !== "undefined" && window.matchMedia) {
151
+ return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
152
+ }
153
+ return "dark";
154
+ }
155
+ function getPalette(theme) {
156
+ const mode = resolveTheme(theme);
157
+ if (mode === "light") {
158
+ return {
159
+ bg: "#ffffff",
160
+ surface: "#f8f8f8",
161
+ surfaceStrong: "#efefef",
162
+ border: "#dddddd",
163
+ text: "#111111",
164
+ textMuted: "#666666",
165
+ textFaint: "#8a8a8a",
166
+ accentBg: "#111111",
167
+ accentText: "#ffffff",
168
+ badgeBg: "#f1f1f1"
169
+ };
170
+ }
171
+ return {
172
+ bg: "#0a0a0a",
173
+ surface: "#111111",
174
+ surfaceStrong: "#181818",
175
+ border: "#2b2b2b",
176
+ text: "#ffffff",
177
+ textMuted: "#a3a3a3",
178
+ textFaint: "#737373",
179
+ accentBg: "#ffffff",
180
+ accentText: "#111111",
181
+ badgeBg: "#171717"
182
+ };
183
+ }
184
+ function normalizeFormat(format) {
185
+ if (format === "floating-video") return "video-widget";
186
+ if (format === "sidebar-display") return "sponsored-card";
187
+ return format;
188
+ }
189
+ function renderVideoWidget(ad, theme) {
190
+ const palette = getPalette(theme);
191
+ const media = ad.video_url ? `<video src="${escapeHtml(ad.video_url)}" autoplay muted loop playsinline preload="auto" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></video>` : ad.image_url ? `<img src="${escapeHtml(ad.image_url)}" alt="${escapeHtml(ad.title)}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;" />` : `<div style="position:absolute;inset:0;background:linear-gradient(135deg,#1f2937 0%,#111827 40%,#030712 100%);"></div>`;
192
+ return `
193
+ <div style="position:relative;width:200px;aspect-ratio:9/16;border-radius:16px;overflow:hidden;background:${palette.bg};border:1px solid rgba(255,255,255,0.12);box-shadow:0 20px 60px rgba(0,0,0,0.45);font-family:Space Grotesk,system-ui,sans-serif;">
194
+ ${media}
195
+ <div style="position:absolute;top:10px;right:10px;display:flex;gap:8px;z-index:3;">
196
+ <button type="button" data-zc-close style="width:22px;height:22px;border:none;border-radius:999px;background:rgba(0,0,0,0.52);color:#fff;font-size:12px;cursor:pointer;">x</button>
197
+ </div>
198
+ <div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0.04) 0%,rgba(0,0,0,0.12) 35%,rgba(0,0,0,0.85) 100%);"></div>
199
+ <div style="position:absolute;left:0;right:0;bottom:0;padding:14px;z-index:2;">
200
+ <div style="display:inline-flex;align-items:center;padding:4px 7px;border-radius:999px;background:rgba(17,17,17,0.72);border:1px solid rgba(255,255,255,0.08);color:#d4d4d4;font-size:9px;font-weight:700;letter-spacing:0.14em;text-transform:uppercase;">Sponsored</div>
201
+ <div style="margin-top:10px;color:#fff;font-size:14px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
202
+ ${ad.description ? `<div style="margin-top:6px;color:rgba(255,255,255,0.78);font-size:11px;line-height:1.35;">${escapeHtml(ad.description)}</div>` : ""}
203
+ <a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="margin-top:12px;display:block;width:100%;padding:9px 10px;border-radius:10px;background:#ffffff;color:#111111;text-align:center;text-decoration:none;font-size:11px;font-weight:700;">${escapeHtml(ad.cta_text || "Learn More")}</a>
204
+ </div>
205
+ </div>
206
+ `;
207
+ }
208
+ function renderTooltipWidget(ad, theme) {
209
+ const palette = getPalette(theme);
210
+ return `
211
+ <div style="width:320px;max-width:100%;padding:14px 15px;border-radius:14px;background:${palette.surface};border:1px solid ${palette.border};box-shadow:0 18px 48px rgba(0,0,0,0.28);font-family:Space Grotesk,system-ui,sans-serif;">
212
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
213
+ <div style="display:inline-flex;align-items:center;gap:6px;color:${palette.textFaint};font-size:10px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">
214
+ <span style="width:6px;height:6px;border-radius:999px;background:${palette.text};display:inline-block;"></span>
215
+ Sponsored
216
+ </div>
217
+ <button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};font-size:12px;cursor:pointer;padding:0;">x</button>
218
+ </div>
219
+ <div style="margin-top:10px;color:${palette.text};font-size:13px;line-height:1.55;">
220
+ ${escapeHtml(ad.description || ad.title)} <a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="color:${palette.text};font-weight:700;text-decoration:underline;text-underline-offset:2px;">${escapeHtml(ad.cta_text || "Learn More")}</a>
221
+ </div>
222
+ </div>
223
+ `;
224
+ }
225
+ function renderSponsoredCard(ad, theme) {
226
+ const palette = getPalette(theme);
227
+ const media = ad.image_url ? `<img src="${escapeHtml(ad.image_url)}" alt="${escapeHtml(ad.title)}" style="display:block;width:100%;height:92px;object-fit:cover;" />` : `<div style="width:100%;height:92px;background:linear-gradient(135deg,#1f2937 0%,#111827 40%,#030712 100%);"></div>`;
228
+ return `
229
+ <div style="width:176px;border-radius:16px;overflow:hidden;background:${palette.surface};border:1px solid ${palette.border};box-shadow:0 18px 52px rgba(0,0,0,0.32);font-family:Space Grotesk,system-ui,sans-serif;">
230
+ ${media}
231
+ <div style="padding:12px;">
232
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
233
+ <div style="display:inline-flex;align-items:center;padding:4px 6px;border-radius:999px;background:${palette.badgeBg};color:${palette.textFaint};font-size:9px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">Sponsored</div>
234
+ <button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};font-size:12px;cursor:pointer;padding:0;">x</button>
235
+ </div>
236
+ <div style="margin-top:10px;color:${palette.text};font-size:13px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
237
+ ${ad.description ? `<div style="margin-top:6px;color:${palette.textMuted};font-size:11px;line-height:1.35;">${escapeHtml(ad.description)}</div>` : ""}
238
+ <a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="margin-top:12px;display:block;width:100%;padding:8px 10px;border-radius:10px;border:1px solid ${palette.border};background:${palette.surfaceStrong};color:${palette.text};text-align:center;text-decoration:none;font-size:11px;font-weight:700;">${escapeHtml(ad.cta_text || "Learn More")}</a>
239
+ </div>
240
+ </div>
241
+ `;
242
+ }
243
+ function renderInlineText(ad, theme) {
244
+ const palette = getPalette(theme);
245
+ const media = ad.image_url ? `<img src="${escapeHtml(ad.image_url)}" alt="${escapeHtml(ad.title)}" style="width:34px;height:34px;border-radius:10px;object-fit:cover;flex-shrink:0;" />` : `<div style="width:34px;height:34px;border-radius:10px;background:${palette.accentBg};color:${palette.accentText};display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;flex-shrink:0;">Ad</div>`;
246
+ return `
247
+ <div style="margin:10px 0;border-radius:14px;overflow:hidden;background:${palette.surface};border:1px solid ${palette.border};font-family:Space Grotesk,system-ui,sans-serif;">
248
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:9px 12px;border-bottom:1px solid ${palette.border};background:${palette.surfaceStrong};">
249
+ <span style="color:${palette.textFaint};font-size:9px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">Sponsored</span>
250
+ <button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};font-size:12px;cursor:pointer;padding:0;">x</button>
251
+ </div>
252
+ <div style="padding:12px;display:flex;gap:10px;align-items:flex-start;">
253
+ ${media}
254
+ <div style="min-width:0;flex:1;">
255
+ <div style="color:${palette.text};font-size:13px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
256
+ ${ad.description ? `<div style="margin-top:5px;color:${palette.textMuted};font-size:11px;line-height:1.45;">${escapeHtml(ad.description)}</div>` : ""}
257
+ <a href="${escapeHtml(ad.landing_url)}" target="_blank" rel="noreferrer noopener" data-zc-cta style="margin-top:9px;display:inline-block;padding:7px 10px;border-radius:9px;background:${palette.accentBg};color:${palette.accentText};text-decoration:none;font-size:10px;font-weight:700;">${escapeHtml(ad.cta_text || "Learn More")}</a>
258
+ </div>
259
+ </div>
260
+ </div>
261
+ `;
262
+ }
263
+ function renderWidgetMarkup(ad, options) {
264
+ const format = normalizeFormat(options.format);
265
+ if (format === "tooltip-ad") {
266
+ return renderTooltipWidget(ad, options.theme);
267
+ }
268
+ if (format === "sponsored-card") {
269
+ return renderSponsoredCard(ad, options.theme);
270
+ }
271
+ if (format === "inline-text") {
272
+ return renderInlineText(ad, options.theme);
273
+ }
274
+ return renderVideoWidget(ad, options.theme);
275
+ }
276
+
141
277
  // src/modules/widget.ts
142
278
  var POSITION_STYLES = {
143
279
  "bottom-right": "position:fixed;bottom:24px;right:24px;z-index:9999;",
@@ -152,6 +288,17 @@ var POSITION_STYLES = {
152
288
  };
153
289
  var FORMAT_PRIORITY = ["video-widget", "tooltip-ad", "sponsored-card", "sidebar-display", "inline-text"];
154
290
  var AUTO_SLOT_ID = "zerocost-auto-slot";
291
+ var CHAT_CONTAINER_SELECTORS = [
292
+ "[data-zerocost-chat]",
293
+ "[data-zc-chat]",
294
+ "[data-chat]",
295
+ "[data-chat-stream]",
296
+ "[data-conversation]",
297
+ "[data-ai-chat]",
298
+ '[role="log"]',
299
+ '[aria-label*="chat" i]',
300
+ '[aria-label*="conversation" i]'
301
+ ];
155
302
  var WidgetModule = class {
156
303
  constructor(client) {
157
304
  this.client = client;
@@ -159,15 +306,17 @@ var WidgetModule = class {
159
306
  mounted = /* @__PURE__ */ new Map();
160
307
  async autoInjectWithConfig(display, widget) {
161
308
  try {
162
- const selected = this.resolveSelectedWidget(display, widget);
309
+ const selected = this.resolveSelectedWidgets(display, widget);
163
310
  this.clearAutoInjectedSlots();
164
- if (!selected || !selected.enabled) {
311
+ if (selected.length === 0) {
165
312
  this.client.log("No enabled widget format found. Skipping injection.");
166
313
  return;
167
314
  }
168
- this.client.log(`Auto-inject: rendering configured format "${selected.format}" only.`);
169
- await this.mountSingleFormat(selected);
170
- this.client.log("\u2713 Single ad slot injected.");
315
+ for (const config of selected) {
316
+ this.client.log(`Auto-inject: rendering configured format "${config.format}".`);
317
+ await this.mountSingleFormat(config);
318
+ }
319
+ this.client.log("Auto-inject completed.");
171
320
  } catch (err) {
172
321
  this.client.log(`Widget autoInject error: ${err}`);
173
322
  }
@@ -181,34 +330,65 @@ var WidgetModule = class {
181
330
  this.client.log(`Widget autoInject error: ${err}`);
182
331
  }
183
332
  }
184
- resolveSelectedWidget(display, widget) {
185
- if (widget?.enabled && widget?.format) {
186
- return {
333
+ resolveSelectedWidgets(display, widget) {
334
+ const configs = this.normalizeConfigs(display);
335
+ const selected = [];
336
+ if (widget?.enabled && widget?.format && widget.format !== "inline-text") {
337
+ selected.push({
187
338
  format: widget.format,
188
339
  position: widget.position || "bottom-right",
189
340
  theme: widget.theme || "dark",
190
341
  autoplay: widget.autoplay ?? widget.format === "video-widget",
191
342
  enabled: true
192
- };
193
- }
194
- const configs = this.normalizeConfigs(display);
195
- for (const fmt of FORMAT_PRIORITY) {
196
- const cfg = configs[fmt];
197
- if (cfg?.enabled) {
198
- return {
199
- format: fmt,
200
- position: cfg.position,
201
- theme: cfg.theme,
202
- autoplay: cfg.autoplay,
203
- enabled: true
204
- };
343
+ });
344
+ } else {
345
+ for (const format of FORMAT_PRIORITY) {
346
+ if (format === "inline-text") continue;
347
+ const config = configs[format];
348
+ if (config?.enabled) {
349
+ selected.push({
350
+ format,
351
+ position: config.position,
352
+ theme: config.theme,
353
+ autoplay: config.autoplay,
354
+ enabled: true
355
+ });
356
+ break;
357
+ }
205
358
  }
206
359
  }
207
- return null;
360
+ const inlineConfig = configs["inline-text"];
361
+ if (widget?.enabled && widget?.format === "inline-text") {
362
+ selected.push({
363
+ format: "inline-text",
364
+ position: widget.position || inlineConfig?.position || "after-paragraph-1",
365
+ theme: widget.theme || inlineConfig?.theme || "dark",
366
+ autoplay: false,
367
+ enabled: true
368
+ });
369
+ } else if (inlineConfig?.enabled) {
370
+ selected.push({
371
+ format: "inline-text",
372
+ position: inlineConfig.position,
373
+ theme: inlineConfig.theme,
374
+ autoplay: false,
375
+ enabled: true
376
+ });
377
+ }
378
+ return selected;
208
379
  }
209
380
  normalizeConfigs(display) {
210
- if (display && typeof display === "object" && "video-widget" in display) {
211
- return display;
381
+ if (display && typeof display === "object" && ("video-widget" in display || "floating-video" in display)) {
382
+ const source = display;
383
+ const videoConfig = source["video-widget"] || source["floating-video"];
384
+ const sponsoredCardConfig = source["sponsored-card"] || source["sidebar-display"];
385
+ return {
386
+ "video-widget": videoConfig || { position: "bottom-right", theme: "dark", autoplay: true, enabled: true },
387
+ "tooltip-ad": source["tooltip-ad"] || { position: "bottom-right", theme: "dark", autoplay: false, enabled: false },
388
+ "sponsored-card": sponsoredCardConfig || { position: "bottom-right", theme: "dark", autoplay: false, enabled: false },
389
+ "sidebar-display": sponsoredCardConfig || { position: "bottom-right", theme: "dark", autoplay: false, enabled: false },
390
+ "inline-text": source["inline-text"] || { position: "after-paragraph-1", theme: "dark", autoplay: false, enabled: false }
391
+ };
212
392
  }
213
393
  const pos = display?.position || "bottom-right";
214
394
  const theme = display?.theme || "dark";
@@ -228,31 +408,32 @@ var WidgetModule = class {
228
408
  return;
229
409
  }
230
410
  if (!isInline) {
231
- let el = document.getElementById(AUTO_SLOT_ID);
232
- if (!el) {
233
- el = document.createElement("div");
234
- el.id = AUTO_SLOT_ID;
235
- document.body.appendChild(el);
411
+ let element = document.getElementById(AUTO_SLOT_ID);
412
+ if (!element) {
413
+ element = document.createElement("div");
414
+ element.id = AUTO_SLOT_ID;
415
+ document.body.appendChild(element);
236
416
  }
237
417
  const posStyle = POSITION_STYLES[config.position] || POSITION_STYLES["bottom-right"];
238
- const maxW = config.format === "video-widget" ? "max-width:200px;" : config.format === "sponsored-card" || config.format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
239
- el.setAttribute("style", `${posStyle}${maxW}`);
240
- el.setAttribute("data-zerocost", "");
241
- el.setAttribute("data-format", config.format);
418
+ const maxW = config.format === "video-widget" ? "max-width:200px;" : "max-width:176px;";
419
+ element.setAttribute("style", `${posStyle}${maxW}`);
420
+ element.setAttribute("data-zerocost", "");
421
+ element.setAttribute("data-format", config.format);
242
422
  }
243
423
  await this.mount(targetElementId, {
244
424
  format: config.format,
245
- refreshInterval: 0,
246
- // Config polling handles re-rendering
425
+ refreshInterval: SDK_WIDGET_REFRESH_MS / 1e3,
247
426
  theme: config.theme,
248
427
  autoplay: config.autoplay,
249
428
  position: config.position
250
429
  });
251
430
  }
252
431
  ensureInlineTarget(position) {
432
+ const chatContainer = this.findChatContainer();
433
+ if (!chatContainer) return null;
253
434
  const paragraphMatch = /after-paragraph-(\d+)/.exec(position || "");
254
435
  const index = paragraphMatch ? Number(paragraphMatch[1]) : 1;
255
- const paragraphs = Array.from(document.querySelectorAll("p"));
436
+ const paragraphs = Array.from(chatContainer.querySelectorAll("p"));
256
437
  const anchor = paragraphs[Math.max(0, Math.min(paragraphs.length - 1, index - 1))];
257
438
  if (!anchor) return null;
258
439
  const existing = document.getElementById(AUTO_SLOT_ID);
@@ -269,11 +450,22 @@ var WidgetModule = class {
269
450
  }
270
451
  return inlineId;
271
452
  }
453
+ findChatContainer() {
454
+ for (const selector of CHAT_CONTAINER_SELECTORS) {
455
+ const container = document.querySelector(selector);
456
+ if (container) return container;
457
+ }
458
+ const semanticContainers = Array.from(document.querySelectorAll("section, main, div, article"));
459
+ return semanticContainers.find((node) => {
460
+ const marker = `${node.id} ${node.className || ""}`.toLowerCase();
461
+ return /chat|conversation|assistant|messages|thread/.test(marker);
462
+ }) || null;
463
+ }
272
464
  async mount(targetElementId, options = {}) {
273
- const el = document.getElementById(targetElementId);
274
- if (!el) return;
465
+ const element = document.getElementById(targetElementId);
466
+ if (!element) return;
275
467
  if (this.mounted.has(targetElementId)) this.unmount(targetElementId);
276
- const refreshMs = (options.refreshInterval ?? 30) * 1e3;
468
+ const refreshMs = (options.refreshInterval ?? SDK_WIDGET_REFRESH_MS / 1e3) * 1e3;
277
469
  const theme = options.theme || "dark";
278
470
  const format = options.format || "video-widget";
279
471
  const autoplay = options.autoplay ?? format === "video-widget";
@@ -287,15 +479,15 @@ var WidgetModule = class {
287
479
  };
288
480
  const data = await this.client.request("/serve-widget", body);
289
481
  const ad = data.ad;
290
- if (!ad || !data.html) {
482
+ if (!ad) {
291
483
  this.client.log(`No ad inventory available for configured format "${format}".`);
292
- el.innerHTML = "";
484
+ element.innerHTML = "";
293
485
  return;
294
486
  }
295
- el.innerHTML = data.html;
296
- el.setAttribute("data-zerocost-ad-id", ad.id);
297
- this.ensureVideoPlayback(el);
298
- const ctas = el.querySelectorAll("[data-zc-cta]");
487
+ element.innerHTML = renderWidgetMarkup(ad, { format, theme });
488
+ element.setAttribute("data-zerocost-ad-id", ad.id);
489
+ this.ensureVideoPlayback(element);
490
+ const ctas = element.querySelectorAll("[data-zc-cta]");
299
491
  ctas.forEach((cta) => {
300
492
  cta.addEventListener("click", () => {
301
493
  this.client.request("/track-event", {
@@ -305,11 +497,11 @@ var WidgetModule = class {
305
497
  });
306
498
  });
307
499
  });
308
- const closeBtn = el.querySelector("[data-zc-close]");
500
+ const closeBtn = element.querySelector("[data-zc-close]");
309
501
  if (closeBtn) {
310
- closeBtn.addEventListener("click", (e) => {
311
- e.preventDefault();
312
- e.stopPropagation();
502
+ closeBtn.addEventListener("click", (event) => {
503
+ event.preventDefault();
504
+ event.stopPropagation();
313
505
  this.unmount(targetElementId);
314
506
  });
315
507
  }
@@ -346,8 +538,8 @@ var WidgetModule = class {
346
538
  unmount(targetElementId) {
347
539
  const slot = this.mounted.get(targetElementId);
348
540
  if (slot?.interval) clearInterval(slot.interval);
349
- const el = document.getElementById(targetElementId);
350
- if (el) el.remove();
541
+ const element = document.getElementById(targetElementId);
542
+ if (element) element.remove();
351
543
  this.mounted.delete(targetElementId);
352
544
  }
353
545
  unmountAll() {
@@ -366,59 +558,99 @@ var LLMDataModule = class {
366
558
  clickHandler = null;
367
559
  errorHandler = null;
368
560
  fetchOriginal = null;
369
- /**
370
- * Start capturing LLM training data based on server config.
371
- */
561
+ mutationObserver = null;
562
+ conversationScanTimer = null;
563
+ isSampledSession = false;
564
+ observedConversations = /* @__PURE__ */ new Map();
565
+ conversationCounter = 0;
372
566
  start(config) {
567
+ this.stop();
373
568
  this.config = config;
374
569
  this.client.log(`LLMData: started (sample=${config.sampleRate}%)`);
375
- if (Math.random() * 100 > config.sampleRate) {
570
+ this.isSampledSession = Math.random() * 100 <= config.sampleRate;
571
+ if (!this.isSampledSession) {
376
572
  this.client.log("LLMData: session not sampled, skipping");
377
573
  return;
378
574
  }
379
575
  if (config.uiInteractions) this.captureUIInteractions();
380
- if (config.textPrompts) this.interceptPrompts();
576
+ if (config.textPrompts) {
577
+ this.interceptPrompts();
578
+ this.captureConversationSurfaces();
579
+ this.scheduleConversationScan();
580
+ }
381
581
  if (config.apiErrors) this.captureAPIErrors();
382
582
  this.flushInterval = setInterval(() => this.flush(), 1e4);
383
583
  }
384
584
  stop() {
385
- if (this.flushInterval) clearInterval(this.flushInterval);
585
+ if (this.flushInterval) {
586
+ clearInterval(this.flushInterval);
587
+ this.flushInterval = null;
588
+ }
386
589
  if (this.clickHandler) {
387
590
  document.removeEventListener("click", this.clickHandler, true);
591
+ this.clickHandler = null;
388
592
  }
389
593
  if (this.errorHandler) {
390
594
  window.removeEventListener("error", this.errorHandler);
595
+ this.errorHandler = null;
391
596
  }
392
597
  if (this.fetchOriginal) {
393
598
  window.fetch = this.fetchOriginal;
599
+ this.fetchOriginal = null;
394
600
  }
395
- this.flush();
601
+ if (this.mutationObserver) {
602
+ this.mutationObserver.disconnect();
603
+ this.mutationObserver = null;
604
+ }
605
+ if (this.conversationScanTimer) {
606
+ clearTimeout(this.conversationScanTimer);
607
+ this.conversationScanTimer = null;
608
+ }
609
+ this.observedConversations.clear();
610
+ this.isSampledSession = false;
611
+ void this.flush();
396
612
  this.config = null;
397
613
  }
398
- /**
399
- * Manually track an LLM prompt/response pair (for startups that want to
400
- * explicitly send their AI interactions).
401
- */
402
614
  trackPrompt(prompt, response, meta) {
403
- if (!this.config?.textPrompts) return;
615
+ if (!this.canCapture("textPrompts")) return;
404
616
  this.pushEvent("llm_prompt", {
405
617
  prompt: this.scrub(prompt),
406
618
  response: response ? this.scrub(response) : void 0,
407
- ...meta
619
+ source: "manual",
620
+ ...this.sanitizeMeta(meta)
621
+ });
622
+ }
623
+ trackConversation(messages, meta) {
624
+ if (!this.canCapture("textPrompts")) return;
625
+ const cleanedMessages = messages.map((message) => ({
626
+ role: this.normalizeRole(message.role),
627
+ content: this.scrub(message.content || ""),
628
+ ...message.name ? { name: this.scrub(message.name) } : {}
629
+ })).filter((message) => message.content);
630
+ if (cleanedMessages.length === 0) return;
631
+ const sanitizedMeta = this.sanitizeMeta(meta);
632
+ const conversationId = this.getConversationId(sanitizedMeta);
633
+ cleanedMessages.forEach((message, index) => {
634
+ this.pushConversationMessage(message, {
635
+ conversationId,
636
+ turnIndex: index,
637
+ source: "manual",
638
+ ...sanitizedMeta
639
+ });
640
+ });
641
+ this.pushConversationSummary(conversationId, cleanedMessages, {
642
+ source: "manual",
643
+ ...sanitizedMeta
408
644
  });
409
645
  }
410
- /**
411
- * Manually track an API error.
412
- */
413
646
  trackError(endpoint, status, message) {
414
- if (!this.config?.apiErrors) return;
647
+ if (!this.canCapture("apiErrors")) return;
415
648
  this.pushEvent("api_error", {
416
649
  endpoint,
417
650
  status,
418
651
  message: message ? this.scrub(message) : void 0
419
652
  });
420
653
  }
421
- // ── Private ──
422
654
  captureUIInteractions() {
423
655
  this.clickHandler = (e) => {
424
656
  const target = e.target;
@@ -442,44 +674,136 @@ var LLMDataModule = class {
442
674
  }
443
675
  interceptPrompts() {
444
676
  this.fetchOriginal = window.fetch;
445
- const self = this;
446
677
  const origFetch = window.fetch;
678
+ const self = this;
447
679
  window.fetch = async function(input, init) {
448
680
  const fetchInput = input instanceof URL ? input.toString() : input;
449
681
  const url = typeof fetchInput === "string" ? fetchInput : fetchInput.url;
450
682
  const isLLM = /\/(chat|completions|generate|predict|inference|ask)/i.test(url);
451
- if (!isLLM) return origFetch.call(window, fetchInput, init);
683
+ if (!isLLM) {
684
+ return origFetch.call(window, fetchInput, init);
685
+ }
686
+ const requestMeta = self.extractRequestMeta(url, init);
687
+ if (requestMeta.messages.length > 0) {
688
+ requestMeta.messages.forEach((message, index) => {
689
+ self.pushConversationMessage(message, {
690
+ conversationId: requestMeta.conversationId,
691
+ turnIndex: index,
692
+ source: "network-request",
693
+ endpoint: requestMeta.endpoint,
694
+ requestId: requestMeta.requestId,
695
+ ...requestMeta.meta
696
+ });
697
+ });
698
+ }
452
699
  try {
453
700
  const res = await origFetch.call(window, fetchInput, init);
454
701
  const clone = res.clone();
455
- let reqBody;
456
- if (init?.body && typeof init.body === "string") {
457
- try {
458
- const parsed = JSON.parse(init.body);
459
- reqBody = JSON.stringify(parsed.messages || parsed.prompt || "").slice(0, 500);
460
- } catch {
461
- reqBody = void 0;
462
- }
463
- }
464
702
  clone.text().then((text) => {
703
+ const responseMessages = self.extractResponseMessages(text);
704
+ if (responseMessages.length > 0) {
705
+ responseMessages.forEach((message, index) => {
706
+ self.pushConversationMessage(message, {
707
+ conversationId: requestMeta.conversationId,
708
+ turnIndex: requestMeta.messages.length + index,
709
+ source: "network-response",
710
+ endpoint: requestMeta.endpoint,
711
+ requestId: requestMeta.requestId,
712
+ status: res.status,
713
+ ...requestMeta.meta
714
+ });
715
+ });
716
+ self.pushConversationSummary(
717
+ requestMeta.conversationId,
718
+ [...requestMeta.messages, ...responseMessages],
719
+ {
720
+ source: "network",
721
+ endpoint: requestMeta.endpoint,
722
+ requestId: requestMeta.requestId,
723
+ status: res.status,
724
+ ...requestMeta.meta
725
+ }
726
+ );
727
+ }
465
728
  self.pushEvent("llm_prompt", {
466
- endpoint: new URL(url).pathname,
467
- request: reqBody ? self.scrub(reqBody) : void 0,
468
- response: self.scrub(text.slice(0, 500)),
469
- status: res.status
729
+ endpoint: requestMeta.endpoint,
730
+ conversationId: requestMeta.conversationId,
731
+ request: requestMeta.promptPreview,
732
+ response: self.scrub(text.slice(0, 1200)),
733
+ status: res.status,
734
+ source: "network",
735
+ requestId: requestMeta.requestId,
736
+ ...requestMeta.meta
470
737
  });
471
738
  }).catch(() => {
472
739
  });
473
740
  return res;
474
- } catch (err) {
741
+ } catch (error) {
742
+ const message = error instanceof Error ? error.message : String(error);
475
743
  self.pushEvent("api_error", {
476
- endpoint: new URL(url).pathname,
477
- message: self.scrub(err.message)
744
+ endpoint: requestMeta.endpoint,
745
+ message: self.scrub(message),
746
+ requestId: requestMeta.requestId
478
747
  });
479
- throw err;
748
+ throw error;
480
749
  }
481
750
  };
482
751
  }
752
+ captureConversationSurfaces() {
753
+ if (typeof MutationObserver === "undefined" || typeof document === "undefined") return;
754
+ this.mutationObserver = new MutationObserver(() => this.scheduleConversationScan());
755
+ this.mutationObserver.observe(document.body, {
756
+ childList: true,
757
+ subtree: true,
758
+ characterData: true
759
+ });
760
+ }
761
+ scheduleConversationScan() {
762
+ if (this.conversationScanTimer) {
763
+ clearTimeout(this.conversationScanTimer);
764
+ }
765
+ this.conversationScanTimer = setTimeout(() => {
766
+ this.conversationScanTimer = null;
767
+ this.scanConversationSurfaces();
768
+ }, 300);
769
+ }
770
+ scanConversationSurfaces() {
771
+ const containers = this.findConversationContainers();
772
+ if (containers.length === 0) return;
773
+ containers.forEach((container) => {
774
+ const snapshot = this.buildConversationSnapshot(container);
775
+ if (!snapshot || snapshot.messages.length === 0) return;
776
+ const existing = this.observedConversations.get(snapshot.conversationId);
777
+ const newMessages = snapshot.messages.map((message, index) => ({
778
+ message,
779
+ index,
780
+ fingerprint: this.getMessageFingerprint(snapshot.conversationId, message, index)
781
+ })).filter(({ fingerprint }) => !existing?.messageFingerprints.has(fingerprint));
782
+ if (newMessages.length === 0 && existing?.signature === snapshot.signature) {
783
+ return;
784
+ }
785
+ newMessages.forEach(({ message, index, fingerprint }) => {
786
+ this.pushConversationMessage(message, {
787
+ conversationId: snapshot.conversationId,
788
+ turnIndex: index,
789
+ source: "dom",
790
+ conversationTitle: snapshot.title,
791
+ pageTitle: document.title,
792
+ path: location.pathname
793
+ });
794
+ snapshot.messageFingerprints.add(fingerprint);
795
+ });
796
+ if (!existing || existing.signature !== snapshot.signature) {
797
+ this.pushConversationSummary(snapshot.conversationId, snapshot.messages, {
798
+ source: "dom",
799
+ conversationTitle: snapshot.title,
800
+ pageTitle: document.title,
801
+ path: location.pathname
802
+ });
803
+ }
804
+ this.observedConversations.set(snapshot.conversationId, snapshot);
805
+ });
806
+ }
483
807
  captureAPIErrors() {
484
808
  this.errorHandler = (e) => {
485
809
  this.pushEvent("api_error", {
@@ -491,9 +815,37 @@ var LLMDataModule = class {
491
815
  };
492
816
  window.addEventListener("error", this.errorHandler);
493
817
  }
818
+ pushConversationMessage(message, meta) {
819
+ const content = this.scrub(message.content || "");
820
+ if (!content) return;
821
+ this.pushEvent("llm_message", {
822
+ role: this.normalizeRole(message.role),
823
+ content,
824
+ ...message.name ? { name: this.scrub(message.name) } : {},
825
+ ...meta
826
+ });
827
+ }
828
+ pushConversationSummary(conversationId, messages, meta) {
829
+ const preview = messages.map((message) => ({
830
+ role: this.normalizeRole(message.role),
831
+ content: this.scrub(message.content || ""),
832
+ ...message.name ? { name: this.scrub(message.name) } : {}
833
+ })).filter((message) => message.content).slice(0, 12);
834
+ if (preview.length === 0) return;
835
+ this.pushEvent("llm_conversation", {
836
+ conversationId,
837
+ messageCount: preview.length,
838
+ roles: Array.from(new Set(preview.map((message) => message.role))),
839
+ preview,
840
+ ...meta
841
+ });
842
+ }
494
843
  pushEvent(type, data) {
844
+ if (!this.isSampledSession) return;
495
845
  this.buffer.push({ type, data, timestamp: Date.now() });
496
- if (this.buffer.length >= 50) this.flush();
846
+ if (this.buffer.length >= 50) {
847
+ void this.flush();
848
+ }
497
849
  }
498
850
  async flush() {
499
851
  if (this.buffer.length === 0) return;
@@ -506,25 +858,286 @@ var LLMDataModule = class {
506
858
  this.buffer.unshift(...events);
507
859
  }
508
860
  }
509
- /** Basic PII scrubbing */
510
861
  scrub(text) {
511
- return text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[EMAIL]").replace(/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, "[PHONE]").replace(/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, "[CARD]").replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN]").replace(/\b(sk|pk|api|key|secret|token)[-_]?[a-zA-Z0-9]{16,}\b/gi, "[API_KEY]");
862
+ return text.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[EMAIL]").replace(/\+?\d[\d\s().-]{7,}\d/g, "[PHONE]").replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN]").replace(/\b(?:\d[ -]*?){13,19}\b/g, "[CARD]").replace(/\b(sk|pk|api|key|secret|token)[-_]?[a-zA-Z0-9]{16,}\b/gi, "[API_KEY]").replace(/\b[A-F0-9]{32,}\b/gi, "[TOKEN]").replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[IP]");
512
863
  }
513
864
  getPath(el) {
514
865
  const parts = [];
515
- let cur = el;
516
- while (cur && parts.length < 5) {
517
- let seg = cur.tagName?.toLowerCase() || "";
518
- if (cur.id) seg += `#${cur.id}`;
519
- else if (cur.className && typeof cur.className === "string") {
520
- const cls = cur.className.split(" ")[0];
521
- if (cls) seg += `.${cls}`;
866
+ let current = el;
867
+ while (current && parts.length < 5) {
868
+ let segment = current.tagName?.toLowerCase() || "";
869
+ if (current.id) {
870
+ segment += `#${current.id}`;
871
+ } else if (typeof current.className === "string" && current.className) {
872
+ const cls = current.className.split(" ")[0];
873
+ if (cls) segment += `.${cls}`;
522
874
  }
523
- parts.unshift(seg);
524
- cur = cur.parentElement;
875
+ parts.unshift(segment);
876
+ current = current.parentElement;
525
877
  }
526
878
  return parts.join(" > ");
527
879
  }
880
+ canCapture(setting) {
881
+ return Boolean(this.config?.[setting] && this.isSampledSession);
882
+ }
883
+ sanitizeMeta(meta) {
884
+ if (!meta) return {};
885
+ return Object.fromEntries(
886
+ Object.entries(meta).map(([key, value]) => {
887
+ if (typeof value === "string") return [key, this.scrub(value)];
888
+ if (Array.isArray(value)) {
889
+ return [key, value.map((item) => typeof item === "string" ? this.scrub(item) : item)];
890
+ }
891
+ return [key, value];
892
+ })
893
+ );
894
+ }
895
+ extractRequestMeta(url, init) {
896
+ const endpoint = this.safePathname(url);
897
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
898
+ const parsed = this.parseRequestBody(init?.body);
899
+ const conversationId = this.getConversationId(parsed.meta);
900
+ return {
901
+ endpoint,
902
+ requestId,
903
+ conversationId,
904
+ messages: parsed.messages,
905
+ promptPreview: parsed.messages.map((message) => `[${message.role}] ${message.content}`).join("\n").slice(0, 1200),
906
+ meta: parsed.meta
907
+ };
908
+ }
909
+ parseRequestBody(body) {
910
+ if (!body || typeof body !== "string") {
911
+ return { messages: [], meta: {} };
912
+ }
913
+ try {
914
+ const parsed = JSON.parse(body);
915
+ const messages = this.extractMessagesFromUnknown(parsed);
916
+ const meta = {};
917
+ const keys = ["model", "conversationId", "conversation_id", "threadId", "thread_id", "sessionId", "session_id", "topic", "domain", "workflow"];
918
+ keys.forEach((key) => {
919
+ const value = parsed[key];
920
+ if (typeof value === "string" && value.trim()) {
921
+ meta[key] = this.scrub(value.trim().slice(0, 120));
922
+ }
923
+ });
924
+ return { messages, meta };
925
+ } catch {
926
+ return {
927
+ messages: [{ role: "user", content: this.scrub(body.slice(0, 2e3)) }],
928
+ meta: {}
929
+ };
930
+ }
931
+ }
932
+ extractMessagesFromUnknown(value) {
933
+ if (!value || typeof value !== "object") return [];
934
+ const record = value;
935
+ if (Array.isArray(record.messages)) {
936
+ return record.messages.map((item) => this.normalizeMessage(item)).filter((item) => Boolean(item?.content));
937
+ }
938
+ if (typeof record.prompt === "string") {
939
+ return [{ role: "user", content: this.scrub(record.prompt.slice(0, 2e3)) }];
940
+ }
941
+ if (Array.isArray(record.input)) {
942
+ return record.input.map((item) => this.normalizeMessage(item)).filter((item) => Boolean(item?.content));
943
+ }
944
+ if (typeof record.input === "string") {
945
+ return [{ role: "user", content: this.scrub(record.input.slice(0, 2e3)) }];
946
+ }
947
+ return [];
948
+ }
949
+ normalizeMessage(input) {
950
+ if (typeof input === "string") {
951
+ const content2 = this.scrub(input.slice(0, 2e3));
952
+ return content2 ? { role: "user", content: content2 } : null;
953
+ }
954
+ if (!input || typeof input !== "object") return null;
955
+ const record = input;
956
+ const role = this.normalizeRole(record.role);
957
+ const content = this.extractContent(record.content ?? record.text ?? record.message);
958
+ const name = typeof record.name === "string" ? this.scrub(record.name.slice(0, 120)) : void 0;
959
+ if (!content) return null;
960
+ return { role, content, ...name ? { name } : {} };
961
+ }
962
+ extractContent(value) {
963
+ if (typeof value === "string") return this.scrub(value.slice(0, 4e3));
964
+ if (Array.isArray(value)) {
965
+ return value.map((item) => {
966
+ if (typeof item === "string") return this.scrub(item);
967
+ if (item && typeof item === "object") {
968
+ const record = item;
969
+ if (typeof record.text === "string") return this.scrub(record.text);
970
+ }
971
+ return "";
972
+ }).filter(Boolean).join("\n").slice(0, 4e3);
973
+ }
974
+ return "";
975
+ }
976
+ extractResponseMessages(text) {
977
+ const trimmed = text.trim();
978
+ if (!trimmed) return [];
979
+ try {
980
+ const parsed = JSON.parse(trimmed);
981
+ const messages = this.extractAssistantMessages(parsed);
982
+ if (messages.length > 0) return messages;
983
+ } catch {
984
+ }
985
+ return [{ role: "assistant", content: this.scrub(trimmed.slice(0, 4e3)) }];
986
+ }
987
+ extractAssistantMessages(value) {
988
+ if (!value || typeof value !== "object") return [];
989
+ const record = value;
990
+ const direct = this.normalizeMessage(record.message ?? record.output ?? record.response ?? record.answer);
991
+ if (direct) {
992
+ return [{ ...direct, role: direct.role === "user" ? "assistant" : direct.role }];
993
+ }
994
+ if (Array.isArray(record.choices)) {
995
+ return record.choices.map((choice) => {
996
+ if (!choice || typeof choice !== "object") return null;
997
+ const payload = choice;
998
+ const normalized = this.normalizeMessage(payload.message ?? payload.delta ?? payload.text);
999
+ if (!normalized) return null;
1000
+ return {
1001
+ ...normalized,
1002
+ role: normalized.role === "user" ? "assistant" : normalized.role
1003
+ };
1004
+ }).filter((item) => Boolean(item && item.content));
1005
+ }
1006
+ if (Array.isArray(record.messages)) {
1007
+ return record.messages.map((item) => this.normalizeMessage(item)).filter((item) => Boolean(item && item.content)).map((item) => ({
1008
+ ...item,
1009
+ role: item.role === "user" ? "assistant" : item.role
1010
+ }));
1011
+ }
1012
+ return [];
1013
+ }
1014
+ findConversationContainers() {
1015
+ const selectors = [
1016
+ "[data-chat-thread]",
1017
+ "[data-conversation]",
1018
+ '[data-testid*="chat" i]',
1019
+ '[data-testid*="conversation" i]',
1020
+ '[aria-label*="chat" i]',
1021
+ '[aria-label*="conversation" i]',
1022
+ '[role="log"]',
1023
+ '[role="feed"]',
1024
+ "main"
1025
+ ];
1026
+ const found = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))).filter((element) => this.looksLikeConversationContainer(element));
1027
+ return Array.from(new Set(found)).slice(0, 4);
1028
+ }
1029
+ looksLikeConversationContainer(element) {
1030
+ const marker = [
1031
+ element.dataset.chatThread,
1032
+ element.dataset.conversation,
1033
+ element.getAttribute("aria-label"),
1034
+ element.getAttribute("data-testid"),
1035
+ typeof element.className === "string" ? element.className : "",
1036
+ element.id
1037
+ ].filter(Boolean).join(" ").toLowerCase();
1038
+ if (/chat|conversation|assistant|thread|messages/.test(marker)) {
1039
+ return true;
1040
+ }
1041
+ const messageNodes = element.querySelectorAll('[data-message-author-role], [data-role], [role="article"], article, [data-testid*="message" i]');
1042
+ return messageNodes.length >= 2;
1043
+ }
1044
+ buildConversationSnapshot(container) {
1045
+ const messageNodes = Array.from(
1046
+ container.querySelectorAll('[data-message-author-role], [data-role], [role="article"], article, [data-testid*="message" i], [class*="message"], [class*="chat"]')
1047
+ ).filter((node) => this.isUsableMessageNode(node)).slice(0, 80);
1048
+ const messages = messageNodes.map((node) => this.messageFromNode(node)).filter((message) => Boolean(message?.content));
1049
+ if (messages.length < 2) return null;
1050
+ const title = this.extractConversationTitle(container);
1051
+ const conversationId = this.getConversationId({ conversationTitle: title, path: location.pathname }, container);
1052
+ const signature = messages.map((message) => `${message.role}:${message.content}`).join("|").slice(0, 6e3);
1053
+ return {
1054
+ conversationId,
1055
+ signature,
1056
+ messageFingerprints: new Set(messages.map((message, index) => this.getMessageFingerprint(conversationId, message, index))),
1057
+ messages,
1058
+ title
1059
+ };
1060
+ }
1061
+ isUsableMessageNode(node) {
1062
+ const text = node.innerText?.trim() || "";
1063
+ if (!text || text.length < 2) return false;
1064
+ const marker = `${typeof node.className === "string" ? node.className : ""} ${node.getAttribute("data-testid") || ""} ${node.getAttribute("aria-label") || ""}`.toLowerCase();
1065
+ if (/input|textarea|composer|toolbar|button|copy code/.test(marker)) {
1066
+ return false;
1067
+ }
1068
+ return true;
1069
+ }
1070
+ messageFromNode(node) {
1071
+ const text = this.scrub((node.innerText || "").trim().slice(0, 4e3));
1072
+ if (!text) return null;
1073
+ return {
1074
+ role: this.detectRole(node, text),
1075
+ content: text
1076
+ };
1077
+ }
1078
+ detectRole(node, text) {
1079
+ const marker = [
1080
+ node.dataset.messageAuthorRole,
1081
+ node.dataset.role,
1082
+ node.getAttribute("data-role"),
1083
+ node.getAttribute("aria-label"),
1084
+ typeof node.className === "string" ? node.className : "",
1085
+ node.id,
1086
+ node.closest("[data-message-author-role]")?.getAttribute("data-message-author-role")
1087
+ ].filter(Boolean).join(" ").toLowerCase();
1088
+ if (/assistant|bot|ai|model|gpt|claude|copilot/.test(marker)) return "assistant";
1089
+ if (/user|human|prompt|customer|visitor/.test(marker)) return "user";
1090
+ if (/system/.test(marker)) return "system";
1091
+ if (/tool|function/.test(marker)) return "tool";
1092
+ const alignment = window.getComputedStyle(node).textAlign;
1093
+ if (alignment === "right") return "user";
1094
+ const lower = text.toLowerCase();
1095
+ if (lower.startsWith("you:")) return "user";
1096
+ if (lower.startsWith("assistant:") || lower.startsWith("ai:")) return "assistant";
1097
+ return "unknown";
1098
+ }
1099
+ extractConversationTitle(container) {
1100
+ const heading = container.querySelector('h1, h2, h3, [data-testid*="title" i], [class*="title"]');
1101
+ return this.scrub(heading?.innerText?.trim().slice(0, 160) || document.title || "conversation");
1102
+ }
1103
+ getConversationId(meta, container) {
1104
+ const explicit = [
1105
+ meta?.conversationId,
1106
+ meta?.conversation_id,
1107
+ meta?.threadId,
1108
+ meta?.thread_id,
1109
+ meta?.sessionId,
1110
+ meta?.session_id
1111
+ ].find((value) => typeof value === "string" && value.trim().length > 0);
1112
+ if (explicit) return this.scrub(explicit).slice(0, 120);
1113
+ if (container) {
1114
+ const existing = container.dataset.zcConversationId;
1115
+ if (existing) return existing;
1116
+ const generated = `conv-${location.pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root"}-${++this.conversationCounter}`;
1117
+ container.dataset.zcConversationId = generated;
1118
+ return generated;
1119
+ }
1120
+ return `conv-${location.pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root"}-${Date.now()}`;
1121
+ }
1122
+ getMessageFingerprint(conversationId, message, index) {
1123
+ return `${conversationId}:${index}:${message.role}:${message.content.slice(0, 240)}`;
1124
+ }
1125
+ safePathname(url) {
1126
+ try {
1127
+ return new URL(url, window.location.origin).pathname;
1128
+ } catch {
1129
+ return url;
1130
+ }
1131
+ }
1132
+ normalizeRole(value) {
1133
+ if (typeof value !== "string") return "unknown";
1134
+ const normalized = value.toLowerCase();
1135
+ if (normalized.includes("assistant") || normalized.includes("bot") || normalized.includes("ai") || normalized.includes("model")) return "assistant";
1136
+ if (normalized.includes("user") || normalized.includes("human")) return "user";
1137
+ if (normalized.includes("system")) return "system";
1138
+ if (normalized.includes("tool") || normalized.includes("function")) return "tool";
1139
+ return "unknown";
1140
+ }
528
1141
  };
529
1142
 
530
1143
  // src/modules/recording.ts
@@ -691,7 +1304,9 @@ var RecordingModule = class {
691
1304
  };
692
1305
 
693
1306
  // src/index.ts
694
- var CONFIG_POLL_INTERVAL = 5e3;
1307
+ var CONFIG_CACHE_PREFIX = "zerocost-sdk-config:";
1308
+ var CONFIG_SYNC_DEBOUNCE_MS = 750;
1309
+ var CONFIG_STALE_AFTER_MS = 3e4;
695
1310
  var ZerocostSDK = class {
696
1311
  core;
697
1312
  ads;
@@ -699,8 +1314,11 @@ var ZerocostSDK = class {
699
1314
  widget;
700
1315
  data;
701
1316
  recording;
702
- configPollTimer = null;
703
1317
  lastConfigHash = "";
1318
+ lastDataCollectionHash = "";
1319
+ configSyncInFlight = null;
1320
+ lastConfigSyncAt = 0;
1321
+ cleanupConfigListeners = [];
704
1322
  constructor(config) {
705
1323
  this.core = new ZerocostClient(config);
706
1324
  this.ads = new AdsModule(this.core);
@@ -709,89 +1327,192 @@ var ZerocostSDK = class {
709
1327
  this.data = new LLMDataModule(this.core);
710
1328
  this.recording = new RecordingModule(this.core);
711
1329
  }
712
- /**
713
- * Initialize the SDK. Automatically:
714
- * 1. Fetches display preferences and injects ad slots into the DOM
715
- * 2. Starts LLM data collection if enabled
716
- * 3. Starts UX session recording if enabled
717
- * 4. Polls for config changes every 5s — instant ad format switching
718
- *
719
- * No custom components needed — ads render automatically.
720
- * Enable `debug: true` in config to see detailed logs.
721
- */
722
1330
  async init() {
723
1331
  this.core.init();
724
1332
  if (typeof document === "undefined") {
725
- this.core.log("Running in non-browser environment \u2014 skipping DOM injection.");
1333
+ this.core.log("Running in non-browser environment; skipping DOM injection.");
726
1334
  return;
727
1335
  }
728
- const isIframe = window !== window.top;
729
- if (isIframe) {
730
- this.core.log("Running inside an iframe. Ads will still render if permissions allow.");
1336
+ if (window !== window.top) {
1337
+ this.core.log("Running inside an iframe. Ads render if permissions allow.");
1338
+ }
1339
+ this.core.log("Initializing Zerocost SDK.");
1340
+ const cachedConfig = this.readCachedConfig();
1341
+ if (cachedConfig) {
1342
+ this.lastConfigHash = this.configToHash(cachedConfig);
1343
+ this.syncDataCollection(cachedConfig.dataCollection);
1344
+ await this.widget.autoInjectWithConfig(cachedConfig.display, cachedConfig.widget);
1345
+ this.core.log("Applied cached config immediately.");
731
1346
  }
732
- this.core.log("Initializing... Ads will be auto-injected into the DOM. No custom component required.");
1347
+ this.startConfigSync();
733
1348
  try {
734
- const config = await this.fetchConfig();
735
- this.lastConfigHash = this.configToHash(config);
736
- this.applyConfig(config);
737
- this.core.log("\u2713 SDK fully initialized. Ads are rendering automatically.");
738
- this.startConfigPolling();
1349
+ await this.refreshConfig({ force: true, reason: "init" });
1350
+ this.core.log("SDK fully initialized. Ads are rendering automatically.");
739
1351
  } catch (err) {
740
- this.core.log(`Init error: ${err}. Attempting fallback ad injection...`);
741
- this.widget.autoInject();
1352
+ this.core.log(`Init error: ${err}. Attempting fallback ad injection.`);
1353
+ await this.widget.autoInject();
742
1354
  }
743
1355
  }
1356
+ async refreshConfig(options = {}) {
1357
+ const now = Date.now();
1358
+ if (!options.force && now - this.lastConfigSyncAt < CONFIG_SYNC_DEBOUNCE_MS) {
1359
+ return this.configSyncInFlight ?? Promise.resolve();
1360
+ }
1361
+ if (this.configSyncInFlight) {
1362
+ return this.configSyncInFlight;
1363
+ }
1364
+ this.configSyncInFlight = (async () => {
1365
+ try {
1366
+ const config = await this.fetchConfig();
1367
+ const nextHash = this.configToHash(config);
1368
+ const hasDisplayChanged = nextHash !== this.lastConfigHash;
1369
+ this.lastConfigHash = nextHash;
1370
+ this.lastConfigSyncAt = Date.now();
1371
+ this.writeCachedConfig(config);
1372
+ if (hasDisplayChanged) {
1373
+ this.core.log(`Config change detected${options.reason ? ` via ${options.reason}` : ""}. Updating ad formats immediately.`);
1374
+ await this.widget.autoInjectWithConfig(config.display, config.widget);
1375
+ }
1376
+ this.syncDataCollection(config.dataCollection);
1377
+ } catch (err) {
1378
+ this.core.log(`Config sync failed${options.reason ? ` during ${options.reason}` : ""}; keeping current placements.`);
1379
+ throw err;
1380
+ } finally {
1381
+ this.configSyncInFlight = null;
1382
+ }
1383
+ })();
1384
+ return this.configSyncInFlight;
1385
+ }
744
1386
  async fetchConfig() {
745
1387
  return this.core.request("/get-placements");
746
1388
  }
747
- applyConfig(config) {
748
- const { display, widget, dataCollection } = config;
749
- this.widget.autoInjectWithConfig(display, widget);
1389
+ configToHash(config) {
1390
+ try {
1391
+ return JSON.stringify({ display: config.display, widget: config.widget });
1392
+ } catch {
1393
+ return "";
1394
+ }
1395
+ }
1396
+ dataCollectionToHash(dataCollection) {
1397
+ try {
1398
+ return JSON.stringify(dataCollection || {});
1399
+ } catch {
1400
+ return "";
1401
+ }
1402
+ }
1403
+ syncDataCollection(dataCollection) {
1404
+ const nextHash = this.dataCollectionToHash(dataCollection);
1405
+ if (nextHash === this.lastDataCollectionHash) return;
1406
+ this.data.stop();
1407
+ this.recording.stop();
750
1408
  if (dataCollection?.llm) {
751
1409
  this.data.start(dataCollection.llm);
752
1410
  }
753
1411
  if (dataCollection?.recording) {
754
1412
  this.recording.start(dataCollection.recording);
755
1413
  }
1414
+ this.lastDataCollectionHash = nextHash;
756
1415
  }
757
- configToHash(config) {
1416
+ getConfigCacheKey() {
1417
+ return `${CONFIG_CACHE_PREFIX}${this.core.getConfig().appId}`;
1418
+ }
1419
+ readCachedConfig() {
1420
+ if (typeof window === "undefined") return null;
758
1421
  try {
759
- return JSON.stringify({ d: config.display, w: config.widget });
1422
+ const raw = window.localStorage.getItem(this.getConfigCacheKey());
1423
+ return raw ? JSON.parse(raw) : null;
760
1424
  } catch {
761
- return "";
1425
+ return null;
762
1426
  }
763
1427
  }
764
- startConfigPolling() {
765
- if (this.configPollTimer) return;
766
- this.configPollTimer = setInterval(async () => {
767
- try {
768
- const config = await this.fetchConfig();
769
- const hash = this.configToHash(config);
770
- if (this.lastConfigHash && hash !== this.lastConfigHash) {
771
- this.core.log("\u26A1 Config change detected \u2014 switching ad format instantly.");
772
- this.widget.unmountAll();
773
- this.applyConfig(config);
774
- }
775
- this.lastConfigHash = hash;
776
- } catch {
1428
+ writeCachedConfig(config) {
1429
+ if (typeof window === "undefined") return;
1430
+ try {
1431
+ window.localStorage.setItem(this.getConfigCacheKey(), JSON.stringify(config));
1432
+ } catch {
1433
+ this.core.log("Failed to persist cached config.");
1434
+ }
1435
+ }
1436
+ startConfigSync() {
1437
+ if (typeof window === "undefined") return;
1438
+ const syncIfVisible = () => {
1439
+ if (document.visibilityState === "visible") {
1440
+ this.refreshConfig({ reason: "visibility" }).catch(() => {
1441
+ });
777
1442
  }
778
- }, CONFIG_POLL_INTERVAL);
1443
+ };
1444
+ const syncIfStale = (reason) => {
1445
+ if (Date.now() - this.lastConfigSyncAt >= CONFIG_STALE_AFTER_MS) {
1446
+ this.refreshConfig({ reason }).catch(() => {
1447
+ });
1448
+ }
1449
+ };
1450
+ const onVisibilityChange = () => syncIfVisible();
1451
+ const onFocus = () => syncIfStale("focus");
1452
+ const onPageShow = () => syncIfStale("pageshow");
1453
+ const onOnline = () => this.refreshConfig({ force: true, reason: "online" }).catch(() => {
1454
+ });
1455
+ const onNavigation = () => syncIfStale("navigation");
1456
+ document.addEventListener("visibilitychange", onVisibilityChange);
1457
+ window.addEventListener("focus", onFocus);
1458
+ window.addEventListener("pageshow", onPageShow);
1459
+ window.addEventListener("online", onOnline);
1460
+ window.addEventListener("popstate", onNavigation);
1461
+ window.addEventListener("hashchange", onNavigation);
1462
+ window.addEventListener("zerocost:navigation", onNavigation);
1463
+ const restoreHistoryPatch = this.patchHistory(onNavigation);
1464
+ this.cleanupConfigListeners.push(
1465
+ () => document.removeEventListener("visibilitychange", onVisibilityChange),
1466
+ () => window.removeEventListener("focus", onFocus),
1467
+ () => window.removeEventListener("pageshow", onPageShow),
1468
+ () => window.removeEventListener("online", onOnline),
1469
+ () => window.removeEventListener("popstate", onNavigation),
1470
+ () => window.removeEventListener("hashchange", onNavigation),
1471
+ () => window.removeEventListener("zerocost:navigation", onNavigation),
1472
+ restoreHistoryPatch
1473
+ );
779
1474
  }
780
- /**
781
- * Tear down all modules.
782
- */
783
- destroy() {
784
- if (this.configPollTimer) {
785
- clearInterval(this.configPollTimer);
786
- this.configPollTimer = null;
1475
+ patchHistory(onNavigation) {
1476
+ if (typeof window === "undefined") return () => {
1477
+ };
1478
+ const historyRef = window.history;
1479
+ if (historyRef.__zerocostPatched) {
1480
+ return () => {
1481
+ };
787
1482
  }
1483
+ const originalPushState = historyRef.pushState.bind(window.history);
1484
+ const originalReplaceState = historyRef.replaceState.bind(window.history);
1485
+ historyRef.__zerocostPatched = true;
1486
+ historyRef.__zerocostPushState = originalPushState;
1487
+ historyRef.__zerocostReplaceState = originalReplaceState;
1488
+ historyRef.pushState = ((...args) => {
1489
+ const result = originalPushState(...args);
1490
+ window.dispatchEvent(new Event("zerocost:navigation"));
1491
+ onNavigation();
1492
+ return result;
1493
+ });
1494
+ historyRef.replaceState = ((...args) => {
1495
+ const result = originalReplaceState(...args);
1496
+ window.dispatchEvent(new Event("zerocost:navigation"));
1497
+ onNavigation();
1498
+ return result;
1499
+ });
1500
+ return () => {
1501
+ if (!historyRef.__zerocostPatched) return;
1502
+ historyRef.pushState = historyRef.__zerocostPushState || historyRef.pushState;
1503
+ historyRef.replaceState = historyRef.__zerocostReplaceState || historyRef.replaceState;
1504
+ delete historyRef.__zerocostPatched;
1505
+ delete historyRef.__zerocostPushState;
1506
+ delete historyRef.__zerocostReplaceState;
1507
+ };
1508
+ }
1509
+ destroy() {
1510
+ this.cleanupConfigListeners.forEach((cleanup) => cleanup());
1511
+ this.cleanupConfigListeners = [];
788
1512
  this.widget.unmountAll();
789
1513
  this.data.stop();
790
1514
  this.recording.stop();
791
1515
  }
792
- /**
793
- * Validate the configured API key against the server.
794
- */
795
1516
  async validateKey() {
796
1517
  try {
797
1518
  const result = await this.core.request("/validate-key");