@zerocost/sdk 0.10.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
@@ -65,14 +65,18 @@ var ZerocostClient = class {
65
65
  }
66
66
  async request(path, body) {
67
67
  const url = `${this.baseUrl}${path}`;
68
- this.log(`\u2192 ${url}`, body);
68
+ const payload = {
69
+ ...body || {},
70
+ app_id: this.config.appId
71
+ };
72
+ this.log(`\u2192 ${url}`, payload);
69
73
  const res = await fetch(url, {
70
74
  method: "POST",
71
75
  headers: {
72
76
  "Content-Type": "application/json",
73
77
  "x-api-key": this.config.apiKey
74
78
  },
75
- body: body ? JSON.stringify(body) : void 0
79
+ body: JSON.stringify(payload)
76
80
  });
77
81
  const data = await res.json();
78
82
  if (!res.ok) {
@@ -134,6 +138,142 @@ var TrackModule = class {
134
138
  }
135
139
  };
136
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
+
137
277
  // src/modules/widget.ts
138
278
  var POSITION_STYLES = {
139
279
  "bottom-right": "position:fixed;bottom:24px;right:24px;z-index:9999;",
@@ -148,6 +288,17 @@ var POSITION_STYLES = {
148
288
  };
149
289
  var FORMAT_PRIORITY = ["video-widget", "tooltip-ad", "sponsored-card", "sidebar-display", "inline-text"];
150
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
+ ];
151
302
  var WidgetModule = class {
152
303
  constructor(client) {
153
304
  this.client = client;
@@ -155,15 +306,17 @@ var WidgetModule = class {
155
306
  mounted = /* @__PURE__ */ new Map();
156
307
  async autoInjectWithConfig(display, widget) {
157
308
  try {
158
- const selected = this.resolveSelectedWidget(display, widget);
309
+ const selected = this.resolveSelectedWidgets(display, widget);
159
310
  this.clearAutoInjectedSlots();
160
- if (!selected || !selected.enabled) {
311
+ if (selected.length === 0) {
161
312
  this.client.log("No enabled widget format found. Skipping injection.");
162
313
  return;
163
314
  }
164
- this.client.log(`Auto-inject: rendering configured format "${selected.format}" only.`);
165
- await this.mountSingleFormat(selected);
166
- 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.");
167
320
  } catch (err) {
168
321
  this.client.log(`Widget autoInject error: ${err}`);
169
322
  }
@@ -177,34 +330,65 @@ var WidgetModule = class {
177
330
  this.client.log(`Widget autoInject error: ${err}`);
178
331
  }
179
332
  }
180
- resolveSelectedWidget(display, widget) {
181
- if (widget?.enabled && widget?.format) {
182
- 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({
183
338
  format: widget.format,
184
339
  position: widget.position || "bottom-right",
185
340
  theme: widget.theme || "dark",
186
341
  autoplay: widget.autoplay ?? widget.format === "video-widget",
187
342
  enabled: true
188
- };
189
- }
190
- const configs = this.normalizeConfigs(display);
191
- for (const fmt of FORMAT_PRIORITY) {
192
- const cfg = configs[fmt];
193
- if (cfg?.enabled) {
194
- return {
195
- format: fmt,
196
- position: cfg.position,
197
- theme: cfg.theme,
198
- autoplay: cfg.autoplay,
199
- enabled: true
200
- };
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
+ }
201
358
  }
202
359
  }
203
- 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;
204
379
  }
205
380
  normalizeConfigs(display) {
206
- if (display && typeof display === "object" && "video-widget" in display) {
207
- 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
+ };
208
392
  }
209
393
  const pos = display?.position || "bottom-right";
210
394
  const theme = display?.theme || "dark";
@@ -224,31 +408,32 @@ var WidgetModule = class {
224
408
  return;
225
409
  }
226
410
  if (!isInline) {
227
- let el = document.getElementById(AUTO_SLOT_ID);
228
- if (!el) {
229
- el = document.createElement("div");
230
- el.id = AUTO_SLOT_ID;
231
- 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);
232
416
  }
233
417
  const posStyle = POSITION_STYLES[config.position] || POSITION_STYLES["bottom-right"];
234
- const maxW = config.format === "video-widget" ? "max-width:200px;" : config.format === "sponsored-card" || config.format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
235
- el.setAttribute("style", `${posStyle}${maxW}`);
236
- el.setAttribute("data-zerocost", "");
237
- 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);
238
422
  }
239
423
  await this.mount(targetElementId, {
240
424
  format: config.format,
241
- refreshInterval: 0,
242
- // Config polling handles re-rendering
425
+ refreshInterval: SDK_WIDGET_REFRESH_MS / 1e3,
243
426
  theme: config.theme,
244
427
  autoplay: config.autoplay,
245
428
  position: config.position
246
429
  });
247
430
  }
248
431
  ensureInlineTarget(position) {
432
+ const chatContainer = this.findChatContainer();
433
+ if (!chatContainer) return null;
249
434
  const paragraphMatch = /after-paragraph-(\d+)/.exec(position || "");
250
435
  const index = paragraphMatch ? Number(paragraphMatch[1]) : 1;
251
- const paragraphs = Array.from(document.querySelectorAll("p"));
436
+ const paragraphs = Array.from(chatContainer.querySelectorAll("p"));
252
437
  const anchor = paragraphs[Math.max(0, Math.min(paragraphs.length - 1, index - 1))];
253
438
  if (!anchor) return null;
254
439
  const existing = document.getElementById(AUTO_SLOT_ID);
@@ -265,11 +450,22 @@ var WidgetModule = class {
265
450
  }
266
451
  return inlineId;
267
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
+ }
268
464
  async mount(targetElementId, options = {}) {
269
- const el = document.getElementById(targetElementId);
270
- if (!el) return;
465
+ const element = document.getElementById(targetElementId);
466
+ if (!element) return;
271
467
  if (this.mounted.has(targetElementId)) this.unmount(targetElementId);
272
- const refreshMs = (options.refreshInterval ?? 30) * 1e3;
468
+ const refreshMs = (options.refreshInterval ?? SDK_WIDGET_REFRESH_MS / 1e3) * 1e3;
273
469
  const theme = options.theme || "dark";
274
470
  const format = options.format || "video-widget";
275
471
  const autoplay = options.autoplay ?? format === "video-widget";
@@ -283,15 +479,15 @@ var WidgetModule = class {
283
479
  };
284
480
  const data = await this.client.request("/serve-widget", body);
285
481
  const ad = data.ad;
286
- if (!ad || !data.html) {
482
+ if (!ad) {
287
483
  this.client.log(`No ad inventory available for configured format "${format}".`);
288
- el.innerHTML = "";
484
+ element.innerHTML = "";
289
485
  return;
290
486
  }
291
- el.innerHTML = data.html;
292
- el.setAttribute("data-zerocost-ad-id", ad.id);
293
- this.ensureVideoPlayback(el);
294
- 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]");
295
491
  ctas.forEach((cta) => {
296
492
  cta.addEventListener("click", () => {
297
493
  this.client.request("/track-event", {
@@ -301,11 +497,11 @@ var WidgetModule = class {
301
497
  });
302
498
  });
303
499
  });
304
- const closeBtn = el.querySelector("[data-zc-close]");
500
+ const closeBtn = element.querySelector("[data-zc-close]");
305
501
  if (closeBtn) {
306
- closeBtn.addEventListener("click", (e) => {
307
- e.preventDefault();
308
- e.stopPropagation();
502
+ closeBtn.addEventListener("click", (event) => {
503
+ event.preventDefault();
504
+ event.stopPropagation();
309
505
  this.unmount(targetElementId);
310
506
  });
311
507
  }
@@ -342,8 +538,8 @@ var WidgetModule = class {
342
538
  unmount(targetElementId) {
343
539
  const slot = this.mounted.get(targetElementId);
344
540
  if (slot?.interval) clearInterval(slot.interval);
345
- const el = document.getElementById(targetElementId);
346
- if (el) el.remove();
541
+ const element = document.getElementById(targetElementId);
542
+ if (element) element.remove();
347
543
  this.mounted.delete(targetElementId);
348
544
  }
349
545
  unmountAll() {
@@ -362,59 +558,99 @@ var LLMDataModule = class {
362
558
  clickHandler = null;
363
559
  errorHandler = null;
364
560
  fetchOriginal = null;
365
- /**
366
- * Start capturing LLM training data based on server config.
367
- */
561
+ mutationObserver = null;
562
+ conversationScanTimer = null;
563
+ isSampledSession = false;
564
+ observedConversations = /* @__PURE__ */ new Map();
565
+ conversationCounter = 0;
368
566
  start(config) {
567
+ this.stop();
369
568
  this.config = config;
370
569
  this.client.log(`LLMData: started (sample=${config.sampleRate}%)`);
371
- if (Math.random() * 100 > config.sampleRate) {
570
+ this.isSampledSession = Math.random() * 100 <= config.sampleRate;
571
+ if (!this.isSampledSession) {
372
572
  this.client.log("LLMData: session not sampled, skipping");
373
573
  return;
374
574
  }
375
575
  if (config.uiInteractions) this.captureUIInteractions();
376
- if (config.textPrompts) this.interceptPrompts();
576
+ if (config.textPrompts) {
577
+ this.interceptPrompts();
578
+ this.captureConversationSurfaces();
579
+ this.scheduleConversationScan();
580
+ }
377
581
  if (config.apiErrors) this.captureAPIErrors();
378
582
  this.flushInterval = setInterval(() => this.flush(), 1e4);
379
583
  }
380
584
  stop() {
381
- if (this.flushInterval) clearInterval(this.flushInterval);
585
+ if (this.flushInterval) {
586
+ clearInterval(this.flushInterval);
587
+ this.flushInterval = null;
588
+ }
382
589
  if (this.clickHandler) {
383
590
  document.removeEventListener("click", this.clickHandler, true);
591
+ this.clickHandler = null;
384
592
  }
385
593
  if (this.errorHandler) {
386
594
  window.removeEventListener("error", this.errorHandler);
595
+ this.errorHandler = null;
387
596
  }
388
597
  if (this.fetchOriginal) {
389
598
  window.fetch = this.fetchOriginal;
599
+ this.fetchOriginal = null;
390
600
  }
391
- 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();
392
612
  this.config = null;
393
613
  }
394
- /**
395
- * Manually track an LLM prompt/response pair (for startups that want to
396
- * explicitly send their AI interactions).
397
- */
398
614
  trackPrompt(prompt, response, meta) {
399
- if (!this.config?.textPrompts) return;
615
+ if (!this.canCapture("textPrompts")) return;
400
616
  this.pushEvent("llm_prompt", {
401
617
  prompt: this.scrub(prompt),
402
618
  response: response ? this.scrub(response) : void 0,
403
- ...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
404
644
  });
405
645
  }
406
- /**
407
- * Manually track an API error.
408
- */
409
646
  trackError(endpoint, status, message) {
410
- if (!this.config?.apiErrors) return;
647
+ if (!this.canCapture("apiErrors")) return;
411
648
  this.pushEvent("api_error", {
412
649
  endpoint,
413
650
  status,
414
651
  message: message ? this.scrub(message) : void 0
415
652
  });
416
653
  }
417
- // ── Private ──
418
654
  captureUIInteractions() {
419
655
  this.clickHandler = (e) => {
420
656
  const target = e.target;
@@ -438,44 +674,136 @@ var LLMDataModule = class {
438
674
  }
439
675
  interceptPrompts() {
440
676
  this.fetchOriginal = window.fetch;
441
- const self = this;
442
677
  const origFetch = window.fetch;
678
+ const self = this;
443
679
  window.fetch = async function(input, init) {
444
680
  const fetchInput = input instanceof URL ? input.toString() : input;
445
681
  const url = typeof fetchInput === "string" ? fetchInput : fetchInput.url;
446
682
  const isLLM = /\/(chat|completions|generate|predict|inference|ask)/i.test(url);
447
- 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
+ }
448
699
  try {
449
700
  const res = await origFetch.call(window, fetchInput, init);
450
701
  const clone = res.clone();
451
- let reqBody;
452
- if (init?.body && typeof init.body === "string") {
453
- try {
454
- const parsed = JSON.parse(init.body);
455
- reqBody = JSON.stringify(parsed.messages || parsed.prompt || "").slice(0, 500);
456
- } catch {
457
- reqBody = void 0;
458
- }
459
- }
460
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
+ }
461
728
  self.pushEvent("llm_prompt", {
462
- endpoint: new URL(url).pathname,
463
- request: reqBody ? self.scrub(reqBody) : void 0,
464
- response: self.scrub(text.slice(0, 500)),
465
- 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
466
737
  });
467
738
  }).catch(() => {
468
739
  });
469
740
  return res;
470
- } catch (err) {
741
+ } catch (error) {
742
+ const message = error instanceof Error ? error.message : String(error);
471
743
  self.pushEvent("api_error", {
472
- endpoint: new URL(url).pathname,
473
- message: self.scrub(err.message)
744
+ endpoint: requestMeta.endpoint,
745
+ message: self.scrub(message),
746
+ requestId: requestMeta.requestId
474
747
  });
475
- throw err;
748
+ throw error;
476
749
  }
477
750
  };
478
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
+ }
479
807
  captureAPIErrors() {
480
808
  this.errorHandler = (e) => {
481
809
  this.pushEvent("api_error", {
@@ -487,9 +815,37 @@ var LLMDataModule = class {
487
815
  };
488
816
  window.addEventListener("error", this.errorHandler);
489
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
+ }
490
843
  pushEvent(type, data) {
844
+ if (!this.isSampledSession) return;
491
845
  this.buffer.push({ type, data, timestamp: Date.now() });
492
- if (this.buffer.length >= 50) this.flush();
846
+ if (this.buffer.length >= 50) {
847
+ void this.flush();
848
+ }
493
849
  }
494
850
  async flush() {
495
851
  if (this.buffer.length === 0) return;
@@ -502,25 +858,286 @@ var LLMDataModule = class {
502
858
  this.buffer.unshift(...events);
503
859
  }
504
860
  }
505
- /** Basic PII scrubbing */
506
861
  scrub(text) {
507
- 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]");
508
863
  }
509
864
  getPath(el) {
510
865
  const parts = [];
511
- let cur = el;
512
- while (cur && parts.length < 5) {
513
- let seg = cur.tagName?.toLowerCase() || "";
514
- if (cur.id) seg += `#${cur.id}`;
515
- else if (cur.className && typeof cur.className === "string") {
516
- const cls = cur.className.split(" ")[0];
517
- 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}`;
518
874
  }
519
- parts.unshift(seg);
520
- cur = cur.parentElement;
875
+ parts.unshift(segment);
876
+ current = current.parentElement;
521
877
  }
522
878
  return parts.join(" > ");
523
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
+ }
524
1141
  };
525
1142
 
526
1143
  // src/modules/recording.ts
@@ -687,7 +1304,9 @@ var RecordingModule = class {
687
1304
  };
688
1305
 
689
1306
  // src/index.ts
690
- 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;
691
1310
  var ZerocostSDK = class {
692
1311
  core;
693
1312
  ads;
@@ -695,8 +1314,11 @@ var ZerocostSDK = class {
695
1314
  widget;
696
1315
  data;
697
1316
  recording;
698
- configPollTimer = null;
699
1317
  lastConfigHash = "";
1318
+ lastDataCollectionHash = "";
1319
+ configSyncInFlight = null;
1320
+ lastConfigSyncAt = 0;
1321
+ cleanupConfigListeners = [];
700
1322
  constructor(config) {
701
1323
  this.core = new ZerocostClient(config);
702
1324
  this.ads = new AdsModule(this.core);
@@ -705,89 +1327,192 @@ var ZerocostSDK = class {
705
1327
  this.data = new LLMDataModule(this.core);
706
1328
  this.recording = new RecordingModule(this.core);
707
1329
  }
708
- /**
709
- * Initialize the SDK. Automatically:
710
- * 1. Fetches display preferences and injects ad slots into the DOM
711
- * 2. Starts LLM data collection if enabled
712
- * 3. Starts UX session recording if enabled
713
- * 4. Polls for config changes every 5s — instant ad format switching
714
- *
715
- * No custom components needed — ads render automatically.
716
- * Enable `debug: true` in config to see detailed logs.
717
- */
718
1330
  async init() {
719
1331
  this.core.init();
720
1332
  if (typeof document === "undefined") {
721
- this.core.log("Running in non-browser environment \u2014 skipping DOM injection.");
1333
+ this.core.log("Running in non-browser environment; skipping DOM injection.");
722
1334
  return;
723
1335
  }
724
- const isIframe = window !== window.top;
725
- if (isIframe) {
726
- 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.");
727
1338
  }
728
- this.core.log("Initializing... Ads will be auto-injected into the DOM. No custom component required.");
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.");
1346
+ }
1347
+ this.startConfigSync();
729
1348
  try {
730
- const config = await this.fetchConfig();
731
- this.lastConfigHash = this.configToHash(config);
732
- this.applyConfig(config);
733
- this.core.log("\u2713 SDK fully initialized. Ads are rendering automatically.");
734
- this.startConfigPolling();
1349
+ await this.refreshConfig({ force: true, reason: "init" });
1350
+ this.core.log("SDK fully initialized. Ads are rendering automatically.");
735
1351
  } catch (err) {
736
- this.core.log(`Init error: ${err}. Attempting fallback ad injection...`);
737
- this.widget.autoInject();
1352
+ this.core.log(`Init error: ${err}. Attempting fallback ad injection.`);
1353
+ await this.widget.autoInject();
1354
+ }
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();
738
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;
739
1385
  }
740
1386
  async fetchConfig() {
741
1387
  return this.core.request("/get-placements");
742
1388
  }
743
- applyConfig(config) {
744
- const { display, widget, dataCollection } = config;
745
- 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();
746
1408
  if (dataCollection?.llm) {
747
1409
  this.data.start(dataCollection.llm);
748
1410
  }
749
1411
  if (dataCollection?.recording) {
750
1412
  this.recording.start(dataCollection.recording);
751
1413
  }
1414
+ this.lastDataCollectionHash = nextHash;
752
1415
  }
753
- configToHash(config) {
1416
+ getConfigCacheKey() {
1417
+ return `${CONFIG_CACHE_PREFIX}${this.core.getConfig().appId}`;
1418
+ }
1419
+ readCachedConfig() {
1420
+ if (typeof window === "undefined") return null;
754
1421
  try {
755
- return JSON.stringify({ d: config.display, w: config.widget });
1422
+ const raw = window.localStorage.getItem(this.getConfigCacheKey());
1423
+ return raw ? JSON.parse(raw) : null;
756
1424
  } catch {
757
- return "";
1425
+ return null;
758
1426
  }
759
1427
  }
760
- startConfigPolling() {
761
- if (this.configPollTimer) return;
762
- this.configPollTimer = setInterval(async () => {
763
- try {
764
- const config = await this.fetchConfig();
765
- const hash = this.configToHash(config);
766
- if (this.lastConfigHash && hash !== this.lastConfigHash) {
767
- this.core.log("\u26A1 Config change detected \u2014 switching ad format instantly.");
768
- this.widget.unmountAll();
769
- this.applyConfig(config);
770
- }
771
- this.lastConfigHash = hash;
772
- } 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
+ });
773
1442
  }
774
- }, 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
+ );
775
1474
  }
776
- /**
777
- * Tear down all modules.
778
- */
779
- destroy() {
780
- if (this.configPollTimer) {
781
- clearInterval(this.configPollTimer);
782
- 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
+ };
783
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 = [];
784
1512
  this.widget.unmountAll();
785
1513
  this.data.stop();
786
1514
  this.recording.stop();
787
1515
  }
788
- /**
789
- * Validate the configured API key against the server.
790
- */
791
1516
  async validateKey() {
792
1517
  try {
793
1518
  const result = await this.core.request("/validate-key");