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