@zerocost/sdk 0.11.0 → 0.13.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
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ ConsentManager: () => ConsentManager,
23
24
  LLMDataModule: () => LLMDataModule,
24
25
  RecordingModule: () => RecordingModule,
25
26
  ZerocostClient: () => ZerocostClient,
@@ -138,6 +139,142 @@ var TrackModule = class {
138
139
  }
139
140
  };
140
141
 
142
+ // src/core/widget-render.ts
143
+ var SDK_WIDGET_REFRESH_MS = 2e4;
144
+ function escapeHtml(value) {
145
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
146
+ }
147
+ function resolveTheme(theme) {
148
+ if (theme === "light" || theme === "dark") {
149
+ return theme;
150
+ }
151
+ if (typeof window !== "undefined" && window.matchMedia) {
152
+ return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
153
+ }
154
+ return "dark";
155
+ }
156
+ function getPalette(theme) {
157
+ const mode = resolveTheme(theme);
158
+ if (mode === "light") {
159
+ return {
160
+ bg: "#ffffff",
161
+ surface: "#f8f8f8",
162
+ surfaceStrong: "#efefef",
163
+ border: "#dddddd",
164
+ text: "#111111",
165
+ textMuted: "#666666",
166
+ textFaint: "#8a8a8a",
167
+ accentBg: "#111111",
168
+ accentText: "#ffffff",
169
+ badgeBg: "#f1f1f1"
170
+ };
171
+ }
172
+ return {
173
+ bg: "#0a0a0a",
174
+ surface: "#111111",
175
+ surfaceStrong: "#181818",
176
+ border: "#2b2b2b",
177
+ text: "#ffffff",
178
+ textMuted: "#a3a3a3",
179
+ textFaint: "#737373",
180
+ accentBg: "#ffffff",
181
+ accentText: "#111111",
182
+ badgeBg: "#171717"
183
+ };
184
+ }
185
+ function normalizeFormat(format) {
186
+ if (format === "floating-video") return "video-widget";
187
+ if (format === "sidebar-display") return "sponsored-card";
188
+ return format;
189
+ }
190
+ function renderVideoWidget(ad, theme) {
191
+ const palette = getPalette(theme);
192
+ 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>`;
193
+ return `
194
+ <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;">
195
+ ${media}
196
+ <div style="position:absolute;top:10px;right:10px;display:flex;gap:8px;z-index:3;">
197
+ <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>
198
+ </div>
199
+ <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>
200
+ <div style="position:absolute;left:0;right:0;bottom:0;padding:14px;z-index:2;">
201
+ <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>
202
+ <div style="margin-top:10px;color:#fff;font-size:14px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
203
+ ${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>` : ""}
204
+ <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>
205
+ </div>
206
+ </div>
207
+ `;
208
+ }
209
+ function renderTooltipWidget(ad, theme) {
210
+ const palette = getPalette(theme);
211
+ return `
212
+ <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;">
213
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
214
+ <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;">
215
+ <span style="width:6px;height:6px;border-radius:999px;background:${palette.text};display:inline-block;"></span>
216
+ Sponsored
217
+ </div>
218
+ <button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};font-size:12px;cursor:pointer;padding:0;">x</button>
219
+ </div>
220
+ <div style="margin-top:10px;color:${palette.text};font-size:13px;line-height:1.55;">
221
+ ${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>
222
+ </div>
223
+ </div>
224
+ `;
225
+ }
226
+ function renderSponsoredCard(ad, theme) {
227
+ const palette = getPalette(theme);
228
+ 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>`;
229
+ return `
230
+ <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;">
231
+ ${media}
232
+ <div style="padding:12px;">
233
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
234
+ <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>
235
+ <button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};font-size:12px;cursor:pointer;padding:0;">x</button>
236
+ </div>
237
+ <div style="margin-top:10px;color:${palette.text};font-size:13px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
238
+ ${ad.description ? `<div style="margin-top:6px;color:${palette.textMuted};font-size:11px;line-height:1.35;">${escapeHtml(ad.description)}</div>` : ""}
239
+ <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>
240
+ </div>
241
+ </div>
242
+ `;
243
+ }
244
+ function renderInlineText(ad, theme) {
245
+ const palette = getPalette(theme);
246
+ 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>`;
247
+ return `
248
+ <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;">
249
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:9px 12px;border-bottom:1px solid ${palette.border};background:${palette.surfaceStrong};">
250
+ <span style="color:${palette.textFaint};font-size:9px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;">Sponsored</span>
251
+ <button type="button" data-zc-close style="background:none;border:none;color:${palette.textFaint};font-size:12px;cursor:pointer;padding:0;">x</button>
252
+ </div>
253
+ <div style="padding:12px;display:flex;gap:10px;align-items:flex-start;">
254
+ ${media}
255
+ <div style="min-width:0;flex:1;">
256
+ <div style="color:${palette.text};font-size:13px;font-weight:700;line-height:1.2;">${escapeHtml(ad.title)}</div>
257
+ ${ad.description ? `<div style="margin-top:5px;color:${palette.textMuted};font-size:11px;line-height:1.45;">${escapeHtml(ad.description)}</div>` : ""}
258
+ <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>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ `;
263
+ }
264
+ function renderWidgetMarkup(ad, options) {
265
+ const format = normalizeFormat(options.format);
266
+ if (format === "tooltip-ad") {
267
+ return renderTooltipWidget(ad, options.theme);
268
+ }
269
+ if (format === "sponsored-card") {
270
+ return renderSponsoredCard(ad, options.theme);
271
+ }
272
+ if (format === "inline-text") {
273
+ return renderInlineText(ad, options.theme);
274
+ }
275
+ return renderVideoWidget(ad, options.theme);
276
+ }
277
+
141
278
  // src/modules/widget.ts
142
279
  var POSITION_STYLES = {
143
280
  "bottom-right": "position:fixed;bottom:24px;right:24px;z-index:9999;",
@@ -152,6 +289,17 @@ var POSITION_STYLES = {
152
289
  };
153
290
  var FORMAT_PRIORITY = ["video-widget", "tooltip-ad", "sponsored-card", "sidebar-display", "inline-text"];
154
291
  var AUTO_SLOT_ID = "zerocost-auto-slot";
292
+ var CHAT_CONTAINER_SELECTORS = [
293
+ "[data-zerocost-chat]",
294
+ "[data-zc-chat]",
295
+ "[data-chat]",
296
+ "[data-chat-stream]",
297
+ "[data-conversation]",
298
+ "[data-ai-chat]",
299
+ '[role="log"]',
300
+ '[aria-label*="chat" i]',
301
+ '[aria-label*="conversation" i]'
302
+ ];
155
303
  var WidgetModule = class {
156
304
  constructor(client) {
157
305
  this.client = client;
@@ -159,15 +307,17 @@ var WidgetModule = class {
159
307
  mounted = /* @__PURE__ */ new Map();
160
308
  async autoInjectWithConfig(display, widget) {
161
309
  try {
162
- const selected = this.resolveSelectedWidget(display, widget);
310
+ const selected = this.resolveSelectedWidgets(display, widget);
163
311
  this.clearAutoInjectedSlots();
164
- if (!selected || !selected.enabled) {
312
+ if (selected.length === 0) {
165
313
  this.client.log("No enabled widget format found. Skipping injection.");
166
314
  return;
167
315
  }
168
- this.client.log(`Auto-inject: rendering configured format "${selected.format}" only.`);
169
- await this.mountSingleFormat(selected);
170
- this.client.log("\u2713 Single ad slot injected.");
316
+ for (const config of selected) {
317
+ this.client.log(`Auto-inject: rendering configured format "${config.format}".`);
318
+ await this.mountSingleFormat(config);
319
+ }
320
+ this.client.log("Auto-inject completed.");
171
321
  } catch (err) {
172
322
  this.client.log(`Widget autoInject error: ${err}`);
173
323
  }
@@ -181,34 +331,65 @@ var WidgetModule = class {
181
331
  this.client.log(`Widget autoInject error: ${err}`);
182
332
  }
183
333
  }
184
- resolveSelectedWidget(display, widget) {
185
- if (widget?.enabled && widget?.format) {
186
- return {
334
+ resolveSelectedWidgets(display, widget) {
335
+ const configs = this.normalizeConfigs(display);
336
+ const selected = [];
337
+ if (widget?.enabled && widget?.format && widget.format !== "inline-text") {
338
+ selected.push({
187
339
  format: widget.format,
188
340
  position: widget.position || "bottom-right",
189
341
  theme: widget.theme || "dark",
190
342
  autoplay: widget.autoplay ?? widget.format === "video-widget",
191
343
  enabled: true
192
- };
193
- }
194
- const configs = this.normalizeConfigs(display);
195
- for (const fmt of FORMAT_PRIORITY) {
196
- const cfg = configs[fmt];
197
- if (cfg?.enabled) {
198
- return {
199
- format: fmt,
200
- position: cfg.position,
201
- theme: cfg.theme,
202
- autoplay: cfg.autoplay,
203
- enabled: true
204
- };
344
+ });
345
+ } else {
346
+ for (const format of FORMAT_PRIORITY) {
347
+ if (format === "inline-text") continue;
348
+ const config = configs[format];
349
+ if (config?.enabled) {
350
+ selected.push({
351
+ format,
352
+ position: config.position,
353
+ theme: config.theme,
354
+ autoplay: config.autoplay,
355
+ enabled: true
356
+ });
357
+ break;
358
+ }
205
359
  }
206
360
  }
207
- return null;
361
+ const inlineConfig = configs["inline-text"];
362
+ if (widget?.enabled && widget?.format === "inline-text") {
363
+ selected.push({
364
+ format: "inline-text",
365
+ position: widget.position || inlineConfig?.position || "after-paragraph-1",
366
+ theme: widget.theme || inlineConfig?.theme || "dark",
367
+ autoplay: false,
368
+ enabled: true
369
+ });
370
+ } else if (inlineConfig?.enabled) {
371
+ selected.push({
372
+ format: "inline-text",
373
+ position: inlineConfig.position,
374
+ theme: inlineConfig.theme,
375
+ autoplay: false,
376
+ enabled: true
377
+ });
378
+ }
379
+ return selected;
208
380
  }
209
381
  normalizeConfigs(display) {
210
- if (display && typeof display === "object" && "video-widget" in display) {
211
- return display;
382
+ if (display && typeof display === "object" && ("video-widget" in display || "floating-video" in display)) {
383
+ const source = display;
384
+ const videoConfig = source["video-widget"] || source["floating-video"];
385
+ const sponsoredCardConfig = source["sponsored-card"] || source["sidebar-display"];
386
+ return {
387
+ "video-widget": videoConfig || { position: "bottom-right", theme: "dark", autoplay: true, enabled: true },
388
+ "tooltip-ad": source["tooltip-ad"] || { position: "bottom-right", theme: "dark", autoplay: false, enabled: false },
389
+ "sponsored-card": sponsoredCardConfig || { position: "bottom-right", theme: "dark", autoplay: false, enabled: false },
390
+ "sidebar-display": sponsoredCardConfig || { position: "bottom-right", theme: "dark", autoplay: false, enabled: false },
391
+ "inline-text": source["inline-text"] || { position: "after-paragraph-1", theme: "dark", autoplay: false, enabled: false }
392
+ };
212
393
  }
213
394
  const pos = display?.position || "bottom-right";
214
395
  const theme = display?.theme || "dark";
@@ -228,31 +409,32 @@ var WidgetModule = class {
228
409
  return;
229
410
  }
230
411
  if (!isInline) {
231
- let el = document.getElementById(AUTO_SLOT_ID);
232
- if (!el) {
233
- el = document.createElement("div");
234
- el.id = AUTO_SLOT_ID;
235
- document.body.appendChild(el);
412
+ let element = document.getElementById(AUTO_SLOT_ID);
413
+ if (!element) {
414
+ element = document.createElement("div");
415
+ element.id = AUTO_SLOT_ID;
416
+ document.body.appendChild(element);
236
417
  }
237
418
  const posStyle = POSITION_STYLES[config.position] || POSITION_STYLES["bottom-right"];
238
- const maxW = config.format === "video-widget" ? "max-width:200px;" : config.format === "sponsored-card" || config.format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
239
- el.setAttribute("style", `${posStyle}${maxW}`);
240
- el.setAttribute("data-zerocost", "");
241
- el.setAttribute("data-format", config.format);
419
+ const maxW = config.format === "video-widget" ? "max-width:200px;" : "max-width:176px;";
420
+ element.setAttribute("style", `${posStyle}${maxW}`);
421
+ element.setAttribute("data-zerocost", "");
422
+ element.setAttribute("data-format", config.format);
242
423
  }
243
424
  await this.mount(targetElementId, {
244
425
  format: config.format,
245
- refreshInterval: 0,
246
- // Config polling handles re-rendering
426
+ refreshInterval: SDK_WIDGET_REFRESH_MS / 1e3,
247
427
  theme: config.theme,
248
428
  autoplay: config.autoplay,
249
429
  position: config.position
250
430
  });
251
431
  }
252
432
  ensureInlineTarget(position) {
433
+ const chatContainer = this.findChatContainer();
434
+ if (!chatContainer) return null;
253
435
  const paragraphMatch = /after-paragraph-(\d+)/.exec(position || "");
254
436
  const index = paragraphMatch ? Number(paragraphMatch[1]) : 1;
255
- const paragraphs = Array.from(document.querySelectorAll("p"));
437
+ const paragraphs = Array.from(chatContainer.querySelectorAll("p"));
256
438
  const anchor = paragraphs[Math.max(0, Math.min(paragraphs.length - 1, index - 1))];
257
439
  if (!anchor) return null;
258
440
  const existing = document.getElementById(AUTO_SLOT_ID);
@@ -269,11 +451,22 @@ var WidgetModule = class {
269
451
  }
270
452
  return inlineId;
271
453
  }
454
+ findChatContainer() {
455
+ for (const selector of CHAT_CONTAINER_SELECTORS) {
456
+ const container = document.querySelector(selector);
457
+ if (container) return container;
458
+ }
459
+ const semanticContainers = Array.from(document.querySelectorAll("section, main, div, article"));
460
+ return semanticContainers.find((node) => {
461
+ const marker = `${node.id} ${node.className || ""}`.toLowerCase();
462
+ return /chat|conversation|assistant|messages|thread/.test(marker);
463
+ }) || null;
464
+ }
272
465
  async mount(targetElementId, options = {}) {
273
- const el = document.getElementById(targetElementId);
274
- if (!el) return;
466
+ const element = document.getElementById(targetElementId);
467
+ if (!element) return;
275
468
  if (this.mounted.has(targetElementId)) this.unmount(targetElementId);
276
- const refreshMs = (options.refreshInterval ?? 30) * 1e3;
469
+ const refreshMs = (options.refreshInterval ?? SDK_WIDGET_REFRESH_MS / 1e3) * 1e3;
277
470
  const theme = options.theme || "dark";
278
471
  const format = options.format || "video-widget";
279
472
  const autoplay = options.autoplay ?? format === "video-widget";
@@ -287,15 +480,15 @@ var WidgetModule = class {
287
480
  };
288
481
  const data = await this.client.request("/serve-widget", body);
289
482
  const ad = data.ad;
290
- if (!ad || !data.html) {
483
+ if (!ad) {
291
484
  this.client.log(`No ad inventory available for configured format "${format}".`);
292
- el.innerHTML = "";
485
+ element.innerHTML = "";
293
486
  return;
294
487
  }
295
- el.innerHTML = data.html;
296
- el.setAttribute("data-zerocost-ad-id", ad.id);
297
- this.ensureVideoPlayback(el);
298
- const ctas = el.querySelectorAll("[data-zc-cta]");
488
+ element.innerHTML = renderWidgetMarkup(ad, { format, theme });
489
+ element.setAttribute("data-zerocost-ad-id", ad.id);
490
+ this.ensureVideoPlayback(element);
491
+ const ctas = element.querySelectorAll("[data-zc-cta]");
299
492
  ctas.forEach((cta) => {
300
493
  cta.addEventListener("click", () => {
301
494
  this.client.request("/track-event", {
@@ -305,11 +498,11 @@ var WidgetModule = class {
305
498
  });
306
499
  });
307
500
  });
308
- const closeBtn = el.querySelector("[data-zc-close]");
501
+ const closeBtn = element.querySelector("[data-zc-close]");
309
502
  if (closeBtn) {
310
- closeBtn.addEventListener("click", (e) => {
311
- e.preventDefault();
312
- e.stopPropagation();
503
+ closeBtn.addEventListener("click", (event) => {
504
+ event.preventDefault();
505
+ event.stopPropagation();
313
506
  this.unmount(targetElementId);
314
507
  });
315
508
  }
@@ -346,8 +539,8 @@ var WidgetModule = class {
346
539
  unmount(targetElementId) {
347
540
  const slot = this.mounted.get(targetElementId);
348
541
  if (slot?.interval) clearInterval(slot.interval);
349
- const el = document.getElementById(targetElementId);
350
- if (el) el.remove();
542
+ const element = document.getElementById(targetElementId);
543
+ if (element) element.remove();
351
544
  this.mounted.delete(targetElementId);
352
545
  }
353
546
  unmountAll() {
@@ -366,59 +559,99 @@ var LLMDataModule = class {
366
559
  clickHandler = null;
367
560
  errorHandler = null;
368
561
  fetchOriginal = null;
369
- /**
370
- * Start capturing LLM training data based on server config.
371
- */
562
+ mutationObserver = null;
563
+ conversationScanTimer = null;
564
+ isSampledSession = false;
565
+ observedConversations = /* @__PURE__ */ new Map();
566
+ conversationCounter = 0;
372
567
  start(config) {
568
+ this.stop();
373
569
  this.config = config;
374
570
  this.client.log(`LLMData: started (sample=${config.sampleRate}%)`);
375
- if (Math.random() * 100 > config.sampleRate) {
571
+ this.isSampledSession = Math.random() * 100 <= config.sampleRate;
572
+ if (!this.isSampledSession) {
376
573
  this.client.log("LLMData: session not sampled, skipping");
377
574
  return;
378
575
  }
379
576
  if (config.uiInteractions) this.captureUIInteractions();
380
- if (config.textPrompts) this.interceptPrompts();
577
+ if (config.textPrompts) {
578
+ this.interceptPrompts();
579
+ this.captureConversationSurfaces();
580
+ this.scheduleConversationScan();
581
+ }
381
582
  if (config.apiErrors) this.captureAPIErrors();
382
583
  this.flushInterval = setInterval(() => this.flush(), 1e4);
383
584
  }
384
585
  stop() {
385
- if (this.flushInterval) clearInterval(this.flushInterval);
586
+ if (this.flushInterval) {
587
+ clearInterval(this.flushInterval);
588
+ this.flushInterval = null;
589
+ }
386
590
  if (this.clickHandler) {
387
591
  document.removeEventListener("click", this.clickHandler, true);
592
+ this.clickHandler = null;
388
593
  }
389
594
  if (this.errorHandler) {
390
595
  window.removeEventListener("error", this.errorHandler);
596
+ this.errorHandler = null;
391
597
  }
392
598
  if (this.fetchOriginal) {
393
599
  window.fetch = this.fetchOriginal;
600
+ this.fetchOriginal = null;
394
601
  }
395
- this.flush();
602
+ if (this.mutationObserver) {
603
+ this.mutationObserver.disconnect();
604
+ this.mutationObserver = null;
605
+ }
606
+ if (this.conversationScanTimer) {
607
+ clearTimeout(this.conversationScanTimer);
608
+ this.conversationScanTimer = null;
609
+ }
610
+ this.observedConversations.clear();
611
+ this.isSampledSession = false;
612
+ void this.flush();
396
613
  this.config = null;
397
614
  }
398
- /**
399
- * Manually track an LLM prompt/response pair (for startups that want to
400
- * explicitly send their AI interactions).
401
- */
402
615
  trackPrompt(prompt, response, meta) {
403
- if (!this.config?.textPrompts) return;
616
+ if (!this.canCapture("textPrompts")) return;
404
617
  this.pushEvent("llm_prompt", {
405
618
  prompt: this.scrub(prompt),
406
619
  response: response ? this.scrub(response) : void 0,
407
- ...meta
620
+ source: "manual",
621
+ ...this.sanitizeMeta(meta)
622
+ });
623
+ }
624
+ trackConversation(messages, meta) {
625
+ if (!this.canCapture("textPrompts")) return;
626
+ const cleanedMessages = messages.map((message) => ({
627
+ role: this.normalizeRole(message.role),
628
+ content: this.scrub(message.content || ""),
629
+ ...message.name ? { name: this.scrub(message.name) } : {}
630
+ })).filter((message) => message.content);
631
+ if (cleanedMessages.length === 0) return;
632
+ const sanitizedMeta = this.sanitizeMeta(meta);
633
+ const conversationId = this.getConversationId(sanitizedMeta);
634
+ cleanedMessages.forEach((message, index) => {
635
+ this.pushConversationMessage(message, {
636
+ conversationId,
637
+ turnIndex: index,
638
+ source: "manual",
639
+ ...sanitizedMeta
640
+ });
641
+ });
642
+ this.pushConversationSummary(conversationId, cleanedMessages, {
643
+ source: "manual",
644
+ ...sanitizedMeta
408
645
  });
409
646
  }
410
- /**
411
- * Manually track an API error.
412
- */
413
647
  trackError(endpoint, status, message) {
414
- if (!this.config?.apiErrors) return;
648
+ if (!this.canCapture("apiErrors")) return;
415
649
  this.pushEvent("api_error", {
416
650
  endpoint,
417
651
  status,
418
652
  message: message ? this.scrub(message) : void 0
419
653
  });
420
654
  }
421
- // ── Private ──
422
655
  captureUIInteractions() {
423
656
  this.clickHandler = (e) => {
424
657
  const target = e.target;
@@ -442,44 +675,136 @@ var LLMDataModule = class {
442
675
  }
443
676
  interceptPrompts() {
444
677
  this.fetchOriginal = window.fetch;
445
- const self = this;
446
678
  const origFetch = window.fetch;
679
+ const self = this;
447
680
  window.fetch = async function(input, init) {
448
681
  const fetchInput = input instanceof URL ? input.toString() : input;
449
682
  const url = typeof fetchInput === "string" ? fetchInput : fetchInput.url;
450
683
  const isLLM = /\/(chat|completions|generate|predict|inference|ask)/i.test(url);
451
- if (!isLLM) return origFetch.call(window, fetchInput, init);
684
+ if (!isLLM) {
685
+ return origFetch.call(window, fetchInput, init);
686
+ }
687
+ const requestMeta = self.extractRequestMeta(url, init);
688
+ if (requestMeta.messages.length > 0) {
689
+ requestMeta.messages.forEach((message, index) => {
690
+ self.pushConversationMessage(message, {
691
+ conversationId: requestMeta.conversationId,
692
+ turnIndex: index,
693
+ source: "network-request",
694
+ endpoint: requestMeta.endpoint,
695
+ requestId: requestMeta.requestId,
696
+ ...requestMeta.meta
697
+ });
698
+ });
699
+ }
452
700
  try {
453
701
  const res = await origFetch.call(window, fetchInput, init);
454
702
  const clone = res.clone();
455
- let reqBody;
456
- if (init?.body && typeof init.body === "string") {
457
- try {
458
- const parsed = JSON.parse(init.body);
459
- reqBody = JSON.stringify(parsed.messages || parsed.prompt || "").slice(0, 500);
460
- } catch {
461
- reqBody = void 0;
462
- }
463
- }
464
703
  clone.text().then((text) => {
704
+ const responseMessages = self.extractResponseMessages(text);
705
+ if (responseMessages.length > 0) {
706
+ responseMessages.forEach((message, index) => {
707
+ self.pushConversationMessage(message, {
708
+ conversationId: requestMeta.conversationId,
709
+ turnIndex: requestMeta.messages.length + index,
710
+ source: "network-response",
711
+ endpoint: requestMeta.endpoint,
712
+ requestId: requestMeta.requestId,
713
+ status: res.status,
714
+ ...requestMeta.meta
715
+ });
716
+ });
717
+ self.pushConversationSummary(
718
+ requestMeta.conversationId,
719
+ [...requestMeta.messages, ...responseMessages],
720
+ {
721
+ source: "network",
722
+ endpoint: requestMeta.endpoint,
723
+ requestId: requestMeta.requestId,
724
+ status: res.status,
725
+ ...requestMeta.meta
726
+ }
727
+ );
728
+ }
465
729
  self.pushEvent("llm_prompt", {
466
- endpoint: new URL(url).pathname,
467
- request: reqBody ? self.scrub(reqBody) : void 0,
468
- response: self.scrub(text.slice(0, 500)),
469
- status: res.status
730
+ endpoint: requestMeta.endpoint,
731
+ conversationId: requestMeta.conversationId,
732
+ request: requestMeta.promptPreview,
733
+ response: self.scrub(text.slice(0, 1200)),
734
+ status: res.status,
735
+ source: "network",
736
+ requestId: requestMeta.requestId,
737
+ ...requestMeta.meta
470
738
  });
471
739
  }).catch(() => {
472
740
  });
473
741
  return res;
474
- } catch (err) {
742
+ } catch (error) {
743
+ const message = error instanceof Error ? error.message : String(error);
475
744
  self.pushEvent("api_error", {
476
- endpoint: new URL(url).pathname,
477
- message: self.scrub(err.message)
745
+ endpoint: requestMeta.endpoint,
746
+ message: self.scrub(message),
747
+ requestId: requestMeta.requestId
478
748
  });
479
- throw err;
749
+ throw error;
480
750
  }
481
751
  };
482
752
  }
753
+ captureConversationSurfaces() {
754
+ if (typeof MutationObserver === "undefined" || typeof document === "undefined") return;
755
+ this.mutationObserver = new MutationObserver(() => this.scheduleConversationScan());
756
+ this.mutationObserver.observe(document.body, {
757
+ childList: true,
758
+ subtree: true,
759
+ characterData: true
760
+ });
761
+ }
762
+ scheduleConversationScan() {
763
+ if (this.conversationScanTimer) {
764
+ clearTimeout(this.conversationScanTimer);
765
+ }
766
+ this.conversationScanTimer = setTimeout(() => {
767
+ this.conversationScanTimer = null;
768
+ this.scanConversationSurfaces();
769
+ }, 300);
770
+ }
771
+ scanConversationSurfaces() {
772
+ const containers = this.findConversationContainers();
773
+ if (containers.length === 0) return;
774
+ containers.forEach((container) => {
775
+ const snapshot = this.buildConversationSnapshot(container);
776
+ if (!snapshot || snapshot.messages.length === 0) return;
777
+ const existing = this.observedConversations.get(snapshot.conversationId);
778
+ const newMessages = snapshot.messages.map((message, index) => ({
779
+ message,
780
+ index,
781
+ fingerprint: this.getMessageFingerprint(snapshot.conversationId, message, index)
782
+ })).filter(({ fingerprint }) => !existing?.messageFingerprints.has(fingerprint));
783
+ if (newMessages.length === 0 && existing?.signature === snapshot.signature) {
784
+ return;
785
+ }
786
+ newMessages.forEach(({ message, index, fingerprint }) => {
787
+ this.pushConversationMessage(message, {
788
+ conversationId: snapshot.conversationId,
789
+ turnIndex: index,
790
+ source: "dom",
791
+ conversationTitle: snapshot.title,
792
+ pageTitle: document.title,
793
+ path: location.pathname
794
+ });
795
+ snapshot.messageFingerprints.add(fingerprint);
796
+ });
797
+ if (!existing || existing.signature !== snapshot.signature) {
798
+ this.pushConversationSummary(snapshot.conversationId, snapshot.messages, {
799
+ source: "dom",
800
+ conversationTitle: snapshot.title,
801
+ pageTitle: document.title,
802
+ path: location.pathname
803
+ });
804
+ }
805
+ this.observedConversations.set(snapshot.conversationId, snapshot);
806
+ });
807
+ }
483
808
  captureAPIErrors() {
484
809
  this.errorHandler = (e) => {
485
810
  this.pushEvent("api_error", {
@@ -491,9 +816,37 @@ var LLMDataModule = class {
491
816
  };
492
817
  window.addEventListener("error", this.errorHandler);
493
818
  }
819
+ pushConversationMessage(message, meta) {
820
+ const content = this.scrub(message.content || "");
821
+ if (!content) return;
822
+ this.pushEvent("llm_message", {
823
+ role: this.normalizeRole(message.role),
824
+ content,
825
+ ...message.name ? { name: this.scrub(message.name) } : {},
826
+ ...meta
827
+ });
828
+ }
829
+ pushConversationSummary(conversationId, messages, meta) {
830
+ const preview = messages.map((message) => ({
831
+ role: this.normalizeRole(message.role),
832
+ content: this.scrub(message.content || ""),
833
+ ...message.name ? { name: this.scrub(message.name) } : {}
834
+ })).filter((message) => message.content).slice(0, 12);
835
+ if (preview.length === 0) return;
836
+ this.pushEvent("llm_conversation", {
837
+ conversationId,
838
+ messageCount: preview.length,
839
+ roles: Array.from(new Set(preview.map((message) => message.role))),
840
+ preview,
841
+ ...meta
842
+ });
843
+ }
494
844
  pushEvent(type, data) {
845
+ if (!this.isSampledSession) return;
495
846
  this.buffer.push({ type, data, timestamp: Date.now() });
496
- if (this.buffer.length >= 50) this.flush();
847
+ if (this.buffer.length >= 50) {
848
+ void this.flush();
849
+ }
497
850
  }
498
851
  async flush() {
499
852
  if (this.buffer.length === 0) return;
@@ -506,25 +859,286 @@ var LLMDataModule = class {
506
859
  this.buffer.unshift(...events);
507
860
  }
508
861
  }
509
- /** Basic PII scrubbing */
510
862
  scrub(text) {
511
- return text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[EMAIL]").replace(/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, "[PHONE]").replace(/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, "[CARD]").replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN]").replace(/\b(sk|pk|api|key|secret|token)[-_]?[a-zA-Z0-9]{16,}\b/gi, "[API_KEY]");
863
+ return text.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "[EMAIL]").replace(/\+?\d[\d\s().-]{7,}\d/g, "[PHONE]").replace(/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN]").replace(/\b(?:\d[ -]*?){13,19}\b/g, "[CARD]").replace(/\b(sk|pk|api|key|secret|token)[-_]?[a-zA-Z0-9]{16,}\b/gi, "[API_KEY]").replace(/\b[A-F0-9]{32,}\b/gi, "[TOKEN]").replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[IP]");
512
864
  }
513
865
  getPath(el) {
514
866
  const parts = [];
515
- let cur = el;
516
- while (cur && parts.length < 5) {
517
- let seg = cur.tagName?.toLowerCase() || "";
518
- if (cur.id) seg += `#${cur.id}`;
519
- else if (cur.className && typeof cur.className === "string") {
520
- const cls = cur.className.split(" ")[0];
521
- if (cls) seg += `.${cls}`;
867
+ let current = el;
868
+ while (current && parts.length < 5) {
869
+ let segment = current.tagName?.toLowerCase() || "";
870
+ if (current.id) {
871
+ segment += `#${current.id}`;
872
+ } else if (typeof current.className === "string" && current.className) {
873
+ const cls = current.className.split(" ")[0];
874
+ if (cls) segment += `.${cls}`;
522
875
  }
523
- parts.unshift(seg);
524
- cur = cur.parentElement;
876
+ parts.unshift(segment);
877
+ current = current.parentElement;
525
878
  }
526
879
  return parts.join(" > ");
527
880
  }
881
+ canCapture(setting) {
882
+ return Boolean(this.config?.[setting] && this.isSampledSession);
883
+ }
884
+ sanitizeMeta(meta) {
885
+ if (!meta) return {};
886
+ return Object.fromEntries(
887
+ Object.entries(meta).map(([key, value]) => {
888
+ if (typeof value === "string") return [key, this.scrub(value)];
889
+ if (Array.isArray(value)) {
890
+ return [key, value.map((item) => typeof item === "string" ? this.scrub(item) : item)];
891
+ }
892
+ return [key, value];
893
+ })
894
+ );
895
+ }
896
+ extractRequestMeta(url, init) {
897
+ const endpoint = this.safePathname(url);
898
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
899
+ const parsed = this.parseRequestBody(init?.body);
900
+ const conversationId = this.getConversationId(parsed.meta);
901
+ return {
902
+ endpoint,
903
+ requestId,
904
+ conversationId,
905
+ messages: parsed.messages,
906
+ promptPreview: parsed.messages.map((message) => `[${message.role}] ${message.content}`).join("\n").slice(0, 1200),
907
+ meta: parsed.meta
908
+ };
909
+ }
910
+ parseRequestBody(body) {
911
+ if (!body || typeof body !== "string") {
912
+ return { messages: [], meta: {} };
913
+ }
914
+ try {
915
+ const parsed = JSON.parse(body);
916
+ const messages = this.extractMessagesFromUnknown(parsed);
917
+ const meta = {};
918
+ const keys = ["model", "conversationId", "conversation_id", "threadId", "thread_id", "sessionId", "session_id", "topic", "domain", "workflow"];
919
+ keys.forEach((key) => {
920
+ const value = parsed[key];
921
+ if (typeof value === "string" && value.trim()) {
922
+ meta[key] = this.scrub(value.trim().slice(0, 120));
923
+ }
924
+ });
925
+ return { messages, meta };
926
+ } catch {
927
+ return {
928
+ messages: [{ role: "user", content: this.scrub(body.slice(0, 2e3)) }],
929
+ meta: {}
930
+ };
931
+ }
932
+ }
933
+ extractMessagesFromUnknown(value) {
934
+ if (!value || typeof value !== "object") return [];
935
+ const record = value;
936
+ if (Array.isArray(record.messages)) {
937
+ return record.messages.map((item) => this.normalizeMessage(item)).filter((item) => Boolean(item?.content));
938
+ }
939
+ if (typeof record.prompt === "string") {
940
+ return [{ role: "user", content: this.scrub(record.prompt.slice(0, 2e3)) }];
941
+ }
942
+ if (Array.isArray(record.input)) {
943
+ return record.input.map((item) => this.normalizeMessage(item)).filter((item) => Boolean(item?.content));
944
+ }
945
+ if (typeof record.input === "string") {
946
+ return [{ role: "user", content: this.scrub(record.input.slice(0, 2e3)) }];
947
+ }
948
+ return [];
949
+ }
950
+ normalizeMessage(input) {
951
+ if (typeof input === "string") {
952
+ const content2 = this.scrub(input.slice(0, 2e3));
953
+ return content2 ? { role: "user", content: content2 } : null;
954
+ }
955
+ if (!input || typeof input !== "object") return null;
956
+ const record = input;
957
+ const role = this.normalizeRole(record.role);
958
+ const content = this.extractContent(record.content ?? record.text ?? record.message);
959
+ const name = typeof record.name === "string" ? this.scrub(record.name.slice(0, 120)) : void 0;
960
+ if (!content) return null;
961
+ return { role, content, ...name ? { name } : {} };
962
+ }
963
+ extractContent(value) {
964
+ if (typeof value === "string") return this.scrub(value.slice(0, 4e3));
965
+ if (Array.isArray(value)) {
966
+ return value.map((item) => {
967
+ if (typeof item === "string") return this.scrub(item);
968
+ if (item && typeof item === "object") {
969
+ const record = item;
970
+ if (typeof record.text === "string") return this.scrub(record.text);
971
+ }
972
+ return "";
973
+ }).filter(Boolean).join("\n").slice(0, 4e3);
974
+ }
975
+ return "";
976
+ }
977
+ extractResponseMessages(text) {
978
+ const trimmed = text.trim();
979
+ if (!trimmed) return [];
980
+ try {
981
+ const parsed = JSON.parse(trimmed);
982
+ const messages = this.extractAssistantMessages(parsed);
983
+ if (messages.length > 0) return messages;
984
+ } catch {
985
+ }
986
+ return [{ role: "assistant", content: this.scrub(trimmed.slice(0, 4e3)) }];
987
+ }
988
+ extractAssistantMessages(value) {
989
+ if (!value || typeof value !== "object") return [];
990
+ const record = value;
991
+ const direct = this.normalizeMessage(record.message ?? record.output ?? record.response ?? record.answer);
992
+ if (direct) {
993
+ return [{ ...direct, role: direct.role === "user" ? "assistant" : direct.role }];
994
+ }
995
+ if (Array.isArray(record.choices)) {
996
+ return record.choices.map((choice) => {
997
+ if (!choice || typeof choice !== "object") return null;
998
+ const payload = choice;
999
+ const normalized = this.normalizeMessage(payload.message ?? payload.delta ?? payload.text);
1000
+ if (!normalized) return null;
1001
+ return {
1002
+ ...normalized,
1003
+ role: normalized.role === "user" ? "assistant" : normalized.role
1004
+ };
1005
+ }).filter((item) => Boolean(item && item.content));
1006
+ }
1007
+ if (Array.isArray(record.messages)) {
1008
+ return record.messages.map((item) => this.normalizeMessage(item)).filter((item) => Boolean(item && item.content)).map((item) => ({
1009
+ ...item,
1010
+ role: item.role === "user" ? "assistant" : item.role
1011
+ }));
1012
+ }
1013
+ return [];
1014
+ }
1015
+ findConversationContainers() {
1016
+ const selectors = [
1017
+ "[data-chat-thread]",
1018
+ "[data-conversation]",
1019
+ '[data-testid*="chat" i]',
1020
+ '[data-testid*="conversation" i]',
1021
+ '[aria-label*="chat" i]',
1022
+ '[aria-label*="conversation" i]',
1023
+ '[role="log"]',
1024
+ '[role="feed"]',
1025
+ "main"
1026
+ ];
1027
+ const found = selectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))).filter((element) => this.looksLikeConversationContainer(element));
1028
+ return Array.from(new Set(found)).slice(0, 4);
1029
+ }
1030
+ looksLikeConversationContainer(element) {
1031
+ const marker = [
1032
+ element.dataset.chatThread,
1033
+ element.dataset.conversation,
1034
+ element.getAttribute("aria-label"),
1035
+ element.getAttribute("data-testid"),
1036
+ typeof element.className === "string" ? element.className : "",
1037
+ element.id
1038
+ ].filter(Boolean).join(" ").toLowerCase();
1039
+ if (/chat|conversation|assistant|thread|messages/.test(marker)) {
1040
+ return true;
1041
+ }
1042
+ const messageNodes = element.querySelectorAll('[data-message-author-role], [data-role], [role="article"], article, [data-testid*="message" i]');
1043
+ return messageNodes.length >= 2;
1044
+ }
1045
+ buildConversationSnapshot(container) {
1046
+ const messageNodes = Array.from(
1047
+ container.querySelectorAll('[data-message-author-role], [data-role], [role="article"], article, [data-testid*="message" i], [class*="message"], [class*="chat"]')
1048
+ ).filter((node) => this.isUsableMessageNode(node)).slice(0, 80);
1049
+ const messages = messageNodes.map((node) => this.messageFromNode(node)).filter((message) => Boolean(message?.content));
1050
+ if (messages.length < 2) return null;
1051
+ const title = this.extractConversationTitle(container);
1052
+ const conversationId = this.getConversationId({ conversationTitle: title, path: location.pathname }, container);
1053
+ const signature = messages.map((message) => `${message.role}:${message.content}`).join("|").slice(0, 6e3);
1054
+ return {
1055
+ conversationId,
1056
+ signature,
1057
+ messageFingerprints: new Set(messages.map((message, index) => this.getMessageFingerprint(conversationId, message, index))),
1058
+ messages,
1059
+ title
1060
+ };
1061
+ }
1062
+ isUsableMessageNode(node) {
1063
+ const text = node.innerText?.trim() || "";
1064
+ if (!text || text.length < 2) return false;
1065
+ const marker = `${typeof node.className === "string" ? node.className : ""} ${node.getAttribute("data-testid") || ""} ${node.getAttribute("aria-label") || ""}`.toLowerCase();
1066
+ if (/input|textarea|composer|toolbar|button|copy code/.test(marker)) {
1067
+ return false;
1068
+ }
1069
+ return true;
1070
+ }
1071
+ messageFromNode(node) {
1072
+ const text = this.scrub((node.innerText || "").trim().slice(0, 4e3));
1073
+ if (!text) return null;
1074
+ return {
1075
+ role: this.detectRole(node, text),
1076
+ content: text
1077
+ };
1078
+ }
1079
+ detectRole(node, text) {
1080
+ const marker = [
1081
+ node.dataset.messageAuthorRole,
1082
+ node.dataset.role,
1083
+ node.getAttribute("data-role"),
1084
+ node.getAttribute("aria-label"),
1085
+ typeof node.className === "string" ? node.className : "",
1086
+ node.id,
1087
+ node.closest("[data-message-author-role]")?.getAttribute("data-message-author-role")
1088
+ ].filter(Boolean).join(" ").toLowerCase();
1089
+ if (/assistant|bot|ai|model|gpt|claude|copilot/.test(marker)) return "assistant";
1090
+ if (/user|human|prompt|customer|visitor/.test(marker)) return "user";
1091
+ if (/system/.test(marker)) return "system";
1092
+ if (/tool|function/.test(marker)) return "tool";
1093
+ const alignment = window.getComputedStyle(node).textAlign;
1094
+ if (alignment === "right") return "user";
1095
+ const lower = text.toLowerCase();
1096
+ if (lower.startsWith("you:")) return "user";
1097
+ if (lower.startsWith("assistant:") || lower.startsWith("ai:")) return "assistant";
1098
+ return "unknown";
1099
+ }
1100
+ extractConversationTitle(container) {
1101
+ const heading = container.querySelector('h1, h2, h3, [data-testid*="title" i], [class*="title"]');
1102
+ return this.scrub(heading?.innerText?.trim().slice(0, 160) || document.title || "conversation");
1103
+ }
1104
+ getConversationId(meta, container) {
1105
+ const explicit = [
1106
+ meta?.conversationId,
1107
+ meta?.conversation_id,
1108
+ meta?.threadId,
1109
+ meta?.thread_id,
1110
+ meta?.sessionId,
1111
+ meta?.session_id
1112
+ ].find((value) => typeof value === "string" && value.trim().length > 0);
1113
+ if (explicit) return this.scrub(explicit).slice(0, 120);
1114
+ if (container) {
1115
+ const existing = container.dataset.zcConversationId;
1116
+ if (existing) return existing;
1117
+ const generated = `conv-${location.pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root"}-${++this.conversationCounter}`;
1118
+ container.dataset.zcConversationId = generated;
1119
+ return generated;
1120
+ }
1121
+ return `conv-${location.pathname.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "") || "root"}-${Date.now()}`;
1122
+ }
1123
+ getMessageFingerprint(conversationId, message, index) {
1124
+ return `${conversationId}:${index}:${message.role}:${message.content.slice(0, 240)}`;
1125
+ }
1126
+ safePathname(url) {
1127
+ try {
1128
+ return new URL(url, window.location.origin).pathname;
1129
+ } catch {
1130
+ return url;
1131
+ }
1132
+ }
1133
+ normalizeRole(value) {
1134
+ if (typeof value !== "string") return "unknown";
1135
+ const normalized = value.toLowerCase();
1136
+ if (normalized.includes("assistant") || normalized.includes("bot") || normalized.includes("ai") || normalized.includes("model")) return "assistant";
1137
+ if (normalized.includes("user") || normalized.includes("human")) return "user";
1138
+ if (normalized.includes("system")) return "system";
1139
+ if (normalized.includes("tool") || normalized.includes("function")) return "tool";
1140
+ return "unknown";
1141
+ }
528
1142
  };
529
1143
 
530
1144
  // src/modules/recording.ts
@@ -690,8 +1304,611 @@ var RecordingModule = class {
690
1304
  }
691
1305
  };
692
1306
 
1307
+ // src/core/consent-ui.ts
1308
+ var STYLE_ID = "zerocost-consent-styles";
1309
+ function injectStyles(theme) {
1310
+ if (document.getElementById(STYLE_ID)) return;
1311
+ const darkVars = `
1312
+ --zc-bg: #111111;
1313
+ --zc-surface: #1a1a1a;
1314
+ --zc-border: #2a2a2a;
1315
+ --zc-text: #ffffff;
1316
+ --zc-text-secondary: #999999;
1317
+ --zc-accent: #ffffff;
1318
+ --zc-accent-bg: #ffffff;
1319
+ --zc-accent-fg: #000000;
1320
+ --zc-toggle-off-bg: #333333;
1321
+ --zc-toggle-on-bg: #00e599;
1322
+ --zc-toggle-knob: #ffffff;
1323
+ --zc-backdrop: rgba(0,0,0,0.65);
1324
+ --zc-link: #888888;
1325
+ --zc-link-hover: #cccccc;
1326
+ `;
1327
+ const lightVars = `
1328
+ --zc-bg: #ffffff;
1329
+ --zc-surface: #f5f5f5;
1330
+ --zc-border: #e0e0e0;
1331
+ --zc-text: #111111;
1332
+ --zc-text-secondary: #666666;
1333
+ --zc-accent: #111111;
1334
+ --zc-accent-bg: #111111;
1335
+ --zc-accent-fg: #ffffff;
1336
+ --zc-toggle-off-bg: #cccccc;
1337
+ --zc-toggle-on-bg: #00c77d;
1338
+ --zc-toggle-knob: #ffffff;
1339
+ --zc-backdrop: rgba(0,0,0,0.45);
1340
+ --zc-link: #666666;
1341
+ --zc-link-hover: #111111;
1342
+ `;
1343
+ let themeRule;
1344
+ if (theme === "dark") {
1345
+ themeRule = `.zc-consent-root { ${darkVars} }`;
1346
+ } else if (theme === "light") {
1347
+ themeRule = `.zc-consent-root { ${lightVars} }`;
1348
+ } else {
1349
+ themeRule = `
1350
+ .zc-consent-root { ${lightVars} }
1351
+ @media (prefers-color-scheme: dark) {
1352
+ .zc-consent-root { ${darkVars} }
1353
+ }
1354
+ `;
1355
+ }
1356
+ const css = `
1357
+ ${themeRule}
1358
+
1359
+ .zc-consent-root * {
1360
+ box-sizing: border-box;
1361
+ margin: 0;
1362
+ padding: 0;
1363
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif;
1364
+ }
1365
+
1366
+ .zc-consent-backdrop {
1367
+ position: fixed;
1368
+ inset: 0;
1369
+ z-index: 999999;
1370
+ background: var(--zc-backdrop);
1371
+ display: flex;
1372
+ align-items: center;
1373
+ justify-content: center;
1374
+ animation: zc-fade-in 200ms ease;
1375
+ }
1376
+
1377
+ @keyframes zc-fade-in {
1378
+ from { opacity: 0; }
1379
+ to { opacity: 1; }
1380
+ }
1381
+
1382
+ @keyframes zc-slide-up {
1383
+ from { transform: translateY(100%); }
1384
+ to { transform: translateY(0); }
1385
+ }
1386
+
1387
+ .zc-consent-card {
1388
+ background: var(--zc-bg);
1389
+ border: 1px solid var(--zc-border);
1390
+ border-radius: 16px;
1391
+ width: 100%;
1392
+ max-width: 460px;
1393
+ max-height: 90vh;
1394
+ overflow-y: auto;
1395
+ padding: 28px 24px 24px;
1396
+ animation: zc-fade-in 200ms ease;
1397
+ }
1398
+
1399
+ /* Mobile: bottom-sheet style */
1400
+ @media (max-width: 640px) {
1401
+ .zc-consent-backdrop {
1402
+ align-items: flex-end;
1403
+ }
1404
+ .zc-consent-card {
1405
+ border-radius: 20px 20px 0 0;
1406
+ max-width: 100%;
1407
+ animation: zc-slide-up 200ms ease;
1408
+ }
1409
+ }
1410
+
1411
+ /* Scrollbar */
1412
+ .zc-consent-card::-webkit-scrollbar { width: 4px; }
1413
+ .zc-consent-card::-webkit-scrollbar-thumb { background: var(--zc-border); border-radius: 4px; }
1414
+
1415
+ .zc-consent-header {
1416
+ display: flex;
1417
+ align-items: center;
1418
+ gap: 10px;
1419
+ margin-bottom: 6px;
1420
+ }
1421
+
1422
+ .zc-consent-logo {
1423
+ width: 28px;
1424
+ height: 28px;
1425
+ border-radius: 6px;
1426
+ background: var(--zc-accent-bg);
1427
+ display: flex;
1428
+ align-items: center;
1429
+ justify-content: center;
1430
+ flex-shrink: 0;
1431
+ }
1432
+
1433
+ .zc-consent-logo svg {
1434
+ width: 16px;
1435
+ height: 16px;
1436
+ }
1437
+
1438
+ .zc-consent-title {
1439
+ font-size: 16px;
1440
+ font-weight: 700;
1441
+ color: var(--zc-text);
1442
+ letter-spacing: -0.02em;
1443
+ }
1444
+
1445
+ .zc-consent-subtitle {
1446
+ font-size: 13px;
1447
+ color: var(--zc-text-secondary);
1448
+ line-height: 1.5;
1449
+ margin-bottom: 20px;
1450
+ }
1451
+
1452
+ .zc-consent-toggles {
1453
+ display: flex;
1454
+ flex-direction: column;
1455
+ gap: 10px;
1456
+ margin-bottom: 20px;
1457
+ }
1458
+
1459
+ .zc-consent-toggle-card {
1460
+ background: var(--zc-surface);
1461
+ border: 1px solid var(--zc-border);
1462
+ border-radius: 12px;
1463
+ padding: 14px 16px;
1464
+ }
1465
+
1466
+ .zc-consent-toggle-row {
1467
+ display: flex;
1468
+ align-items: center;
1469
+ justify-content: space-between;
1470
+ margin-bottom: 6px;
1471
+ }
1472
+
1473
+ .zc-consent-toggle-label {
1474
+ font-size: 14px;
1475
+ font-weight: 600;
1476
+ color: var(--zc-text);
1477
+ }
1478
+
1479
+ .zc-consent-toggle-desc {
1480
+ font-size: 12px;
1481
+ color: var(--zc-text-secondary);
1482
+ line-height: 1.5;
1483
+ margin-bottom: 4px;
1484
+ }
1485
+
1486
+ .zc-consent-learn-more {
1487
+ font-size: 11px;
1488
+ color: var(--zc-link);
1489
+ text-decoration: none;
1490
+ cursor: pointer;
1491
+ transition: color 150ms;
1492
+ }
1493
+
1494
+ .zc-consent-learn-more:hover {
1495
+ color: var(--zc-link-hover);
1496
+ }
1497
+
1498
+ /* Toggle switch */
1499
+ .zc-toggle {
1500
+ position: relative;
1501
+ width: 40px;
1502
+ height: 22px;
1503
+ flex-shrink: 0;
1504
+ cursor: pointer;
1505
+ }
1506
+
1507
+ .zc-toggle input {
1508
+ opacity: 0;
1509
+ width: 0;
1510
+ height: 0;
1511
+ position: absolute;
1512
+ }
1513
+
1514
+ .zc-toggle-track {
1515
+ position: absolute;
1516
+ inset: 0;
1517
+ background: var(--zc-toggle-off-bg);
1518
+ border-radius: 11px;
1519
+ transition: background 200ms ease;
1520
+ }
1521
+
1522
+ .zc-toggle input:checked + .zc-toggle-track {
1523
+ background: var(--zc-toggle-on-bg);
1524
+ }
1525
+
1526
+ .zc-toggle-knob {
1527
+ position: absolute;
1528
+ top: 2px;
1529
+ left: 2px;
1530
+ width: 18px;
1531
+ height: 18px;
1532
+ background: var(--zc-toggle-knob);
1533
+ border-radius: 50%;
1534
+ transition: transform 200ms ease;
1535
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
1536
+ }
1537
+
1538
+ .zc-toggle input:checked ~ .zc-toggle-knob {
1539
+ transform: translateX(18px);
1540
+ }
1541
+
1542
+ /* Footer */
1543
+ .zc-consent-footer {
1544
+ display: flex;
1545
+ flex-wrap: wrap;
1546
+ gap: 4px 12px;
1547
+ justify-content: center;
1548
+ margin-bottom: 16px;
1549
+ }
1550
+
1551
+ .zc-consent-footer a {
1552
+ font-size: 11px;
1553
+ color: var(--zc-link);
1554
+ text-decoration: none;
1555
+ transition: color 150ms;
1556
+ }
1557
+
1558
+ .zc-consent-footer a:hover {
1559
+ color: var(--zc-link-hover);
1560
+ }
1561
+
1562
+ .zc-consent-footer-sep {
1563
+ font-size: 11px;
1564
+ color: var(--zc-link);
1565
+ opacity: 0.5;
1566
+ }
1567
+
1568
+ /* Confirm button */
1569
+ .zc-consent-confirm {
1570
+ display: block;
1571
+ width: 100%;
1572
+ padding: 12px;
1573
+ font-size: 14px;
1574
+ font-weight: 600;
1575
+ border: none;
1576
+ border-radius: 10px;
1577
+ cursor: pointer;
1578
+ background: var(--zc-accent-bg);
1579
+ color: var(--zc-accent-fg);
1580
+ letter-spacing: -0.01em;
1581
+ transition: opacity 150ms;
1582
+ }
1583
+
1584
+ .zc-consent-confirm:hover {
1585
+ opacity: 0.88;
1586
+ }
1587
+
1588
+ .zc-consent-confirm:active {
1589
+ opacity: 0.75;
1590
+ }
1591
+ `;
1592
+ const style = document.createElement("style");
1593
+ style.id = STYLE_ID;
1594
+ style.textContent = css;
1595
+ document.head.appendChild(style);
1596
+ }
1597
+ function createToggle(id, checked) {
1598
+ const label = document.createElement("label");
1599
+ label.className = "zc-toggle";
1600
+ const input = document.createElement("input");
1601
+ input.type = "checkbox";
1602
+ input.checked = checked;
1603
+ input.id = id;
1604
+ const track = document.createElement("span");
1605
+ track.className = "zc-toggle-track";
1606
+ const knob = document.createElement("span");
1607
+ knob.className = "zc-toggle-knob";
1608
+ label.appendChild(input);
1609
+ label.appendChild(track);
1610
+ label.appendChild(knob);
1611
+ return label;
1612
+ }
1613
+ function createToggleCard(toggleId, title, description, learnMoreUrl, defaultOn) {
1614
+ const card = document.createElement("div");
1615
+ card.className = "zc-consent-toggle-card";
1616
+ const row = document.createElement("div");
1617
+ row.className = "zc-consent-toggle-row";
1618
+ const labelSpan = document.createElement("span");
1619
+ labelSpan.className = "zc-consent-toggle-label";
1620
+ labelSpan.textContent = title;
1621
+ const toggle = createToggle(toggleId, defaultOn);
1622
+ row.appendChild(labelSpan);
1623
+ row.appendChild(toggle);
1624
+ card.appendChild(row);
1625
+ const desc = document.createElement("div");
1626
+ desc.className = "zc-consent-toggle-desc";
1627
+ desc.textContent = description;
1628
+ card.appendChild(desc);
1629
+ const link = document.createElement("a");
1630
+ link.className = "zc-consent-learn-more";
1631
+ link.href = learnMoreUrl;
1632
+ link.target = "_blank";
1633
+ link.rel = "noopener noreferrer";
1634
+ link.textContent = "Learn more \u2197";
1635
+ card.appendChild(link);
1636
+ return card;
1637
+ }
1638
+ function showConsentUI(options) {
1639
+ return new Promise((resolve) => {
1640
+ const { appName, theme, privacyPolicyUrl } = options;
1641
+ const defaults = options.defaults ?? { ads: true, usageData: false, aiInteractions: false };
1642
+ injectStyles(theme);
1643
+ const root = document.createElement("div");
1644
+ root.className = "zc-consent-root";
1645
+ const backdrop = document.createElement("div");
1646
+ backdrop.className = "zc-consent-backdrop";
1647
+ const blockEscape = (e) => {
1648
+ if (e.key === "Escape") {
1649
+ e.preventDefault();
1650
+ e.stopPropagation();
1651
+ }
1652
+ };
1653
+ document.addEventListener("keydown", blockEscape, true);
1654
+ const card = document.createElement("div");
1655
+ card.className = "zc-consent-card";
1656
+ const header = document.createElement("div");
1657
+ header.className = "zc-consent-header";
1658
+ const logo = document.createElement("div");
1659
+ logo.className = "zc-consent-logo";
1660
+ logo.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--zc-accent-fg)"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>`;
1661
+ const title = document.createElement("div");
1662
+ title.className = "zc-consent-title";
1663
+ title.textContent = `${appName || "This app"} uses Zerocost`;
1664
+ header.appendChild(logo);
1665
+ header.appendChild(title);
1666
+ card.appendChild(header);
1667
+ const subtitle = document.createElement("div");
1668
+ subtitle.className = "zc-consent-subtitle";
1669
+ subtitle.textContent = "Manage your preferences below. You can update these anytime.";
1670
+ card.appendChild(subtitle);
1671
+ const toggles = document.createElement("div");
1672
+ toggles.className = "zc-consent-toggles";
1673
+ const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
1674
+ toggles.appendChild(createToggleCard(
1675
+ "zc-toggle-ads",
1676
+ "Ads",
1677
+ "Contextual, non-intrusive ads. No cookies or browsing history used.",
1678
+ `${baseUrl}/consent/ads`,
1679
+ defaults.ads
1680
+ ));
1681
+ toggles.appendChild(createToggleCard(
1682
+ "zc-toggle-usage",
1683
+ "Usage data",
1684
+ "Anonymized usage patterns. No personal information is shared.",
1685
+ `${baseUrl}/consent/usage-data`,
1686
+ defaults.usageData
1687
+ ));
1688
+ toggles.appendChild(createToggleCard(
1689
+ "zc-toggle-ai",
1690
+ "AI interactions",
1691
+ "Anonymized conversation data used for AI research.",
1692
+ `${baseUrl}/consent/ai-interactions`,
1693
+ defaults.aiInteractions
1694
+ ));
1695
+ card.appendChild(toggles);
1696
+ const footer = document.createElement("div");
1697
+ footer.className = "zc-consent-footer";
1698
+ const ppLink = document.createElement("a");
1699
+ ppLink.href = privacyPolicyUrl || `${baseUrl}/privacy`;
1700
+ ppLink.target = "_blank";
1701
+ ppLink.rel = "noopener noreferrer";
1702
+ ppLink.textContent = "Privacy Policy";
1703
+ footer.appendChild(ppLink);
1704
+ const sep1 = document.createElement("span");
1705
+ sep1.className = "zc-consent-footer-sep";
1706
+ sep1.textContent = "\xB7";
1707
+ footer.appendChild(sep1);
1708
+ const termsLink = document.createElement("a");
1709
+ termsLink.href = `${baseUrl}/terms`;
1710
+ termsLink.target = "_blank";
1711
+ termsLink.rel = "noopener noreferrer";
1712
+ termsLink.textContent = "Terms";
1713
+ footer.appendChild(termsLink);
1714
+ const sep2 = document.createElement("span");
1715
+ sep2.className = "zc-consent-footer-sep";
1716
+ sep2.textContent = "\xB7";
1717
+ footer.appendChild(sep2);
1718
+ const dnsLink = document.createElement("a");
1719
+ dnsLink.href = `${baseUrl}/do-not-sell`;
1720
+ dnsLink.target = "_blank";
1721
+ dnsLink.rel = "noopener noreferrer";
1722
+ dnsLink.textContent = "Do Not Sell My Data";
1723
+ footer.appendChild(dnsLink);
1724
+ card.appendChild(footer);
1725
+ const confirmBtn = document.createElement("button");
1726
+ confirmBtn.className = "zc-consent-confirm";
1727
+ confirmBtn.textContent = "Confirm";
1728
+ confirmBtn.addEventListener("click", () => {
1729
+ const ads = document.getElementById("zc-toggle-ads")?.checked ?? defaults.ads;
1730
+ const usageData = document.getElementById("zc-toggle-usage")?.checked ?? defaults.usageData;
1731
+ const aiInteractions = document.getElementById("zc-toggle-ai")?.checked ?? defaults.aiInteractions;
1732
+ document.removeEventListener("keydown", blockEscape, true);
1733
+ root.remove();
1734
+ resolve({ ads, usageData, aiInteractions });
1735
+ });
1736
+ card.appendChild(confirmBtn);
1737
+ backdrop.appendChild(card);
1738
+ root.appendChild(backdrop);
1739
+ document.body.appendChild(root);
1740
+ });
1741
+ }
1742
+ function removeConsentUI() {
1743
+ document.querySelector(".zc-consent-root")?.remove();
1744
+ }
1745
+
1746
+ // src/core/consent.ts
1747
+ var CONSENT_VERSION = "1.1";
1748
+ var CONSENT_STORAGE_PREFIX = "zerocost-consent:";
1749
+ var TWELVE_MONTHS_MS = 365 * 24 * 60 * 60 * 1e3;
1750
+ var ConsentManager = class {
1751
+ record = null;
1752
+ needsReset = false;
1753
+ client;
1754
+ consentConfig;
1755
+ appName;
1756
+ theme;
1757
+ constructor(client, opts) {
1758
+ this.client = client;
1759
+ this.consentConfig = opts.consent ?? {};
1760
+ this.appName = opts.appName ?? "";
1761
+ this.theme = opts.theme ?? "dark";
1762
+ this.hydrateFromStorage();
1763
+ }
1764
+ // ── Public API (per spec §6.3) ───────────────────────────────────
1765
+ /** Returns the current consent record, or null if none exists. */
1766
+ get() {
1767
+ return this.record;
1768
+ }
1769
+ /** Programmatically open the consent popup (e.g. from app settings). */
1770
+ async open() {
1771
+ removeConsentUI();
1772
+ await this.promptAndWait();
1773
+ }
1774
+ /** Clear consent — prompt will re-fire on next init(). */
1775
+ reset() {
1776
+ this.record = null;
1777
+ this.needsReset = true;
1778
+ this.clearStorage();
1779
+ this.client.log("Consent reset. Prompt will re-fire on next init().");
1780
+ }
1781
+ /** Restore a previously saved record (skip re-prompting if valid). */
1782
+ restore(record) {
1783
+ if (this.isValid(record)) {
1784
+ this.record = record;
1785
+ this.writeStorage(record);
1786
+ this.client.log("Consent restored from saved record.");
1787
+ } else {
1788
+ this.client.log("Restored record invalid (version/expiry). Will re-prompt.");
1789
+ }
1790
+ }
1791
+ /** Check whether a specific feature is consented. */
1792
+ has(feature) {
1793
+ if (!this.record) return false;
1794
+ return !!this.record[feature];
1795
+ }
1796
+ // ── Internal (used by ZerocostSDK.init) ──────────────────────────
1797
+ /** Should the consent prompt be shown? */
1798
+ shouldPrompt() {
1799
+ if (this.needsReset) return true;
1800
+ if (!this.record) return true;
1801
+ if (!this.isValid(this.record)) return true;
1802
+ return false;
1803
+ }
1804
+ /** Show the consent popup, wait for confirmation, store record. */
1805
+ async promptAndWait() {
1806
+ this.needsReset = false;
1807
+ const result = await showConsentUI({
1808
+ appName: this.appName,
1809
+ theme: this.theme,
1810
+ privacyPolicyUrl: this.consentConfig.privacyPolicyUrl,
1811
+ defaults: this.record ? { ads: this.record.ads, usageData: this.record.usageData, aiInteractions: this.record.aiInteractions } : void 0
1812
+ });
1813
+ const userId = this.getOrCreateUserId();
1814
+ const record = {
1815
+ userId,
1816
+ appId: this.client.getConfig().appId,
1817
+ ads: result.ads,
1818
+ usageData: result.usageData,
1819
+ aiInteractions: result.aiInteractions,
1820
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1821
+ version: CONSENT_VERSION,
1822
+ method: "confirmed",
1823
+ ipRegion: "OTHER"
1824
+ // server can enrich via IP
1825
+ };
1826
+ this.record = record;
1827
+ this.writeStorage(record);
1828
+ if (this.consentConfig.onConsentChange) {
1829
+ try {
1830
+ this.consentConfig.onConsentChange(record);
1831
+ } catch (err) {
1832
+ this.client.log(`onConsentChange callback error: ${err}`);
1833
+ }
1834
+ }
1835
+ this.submitToServer(record);
1836
+ this.client.log("Consent confirmed.", record);
1837
+ }
1838
+ // ── Helpers ──────────────────────────────────────────────────────
1839
+ isValid(record) {
1840
+ if (record.version !== CONSENT_VERSION) return false;
1841
+ const age = Date.now() - new Date(record.timestamp).getTime();
1842
+ if (age > TWELVE_MONTHS_MS) return false;
1843
+ return true;
1844
+ }
1845
+ storageKey() {
1846
+ return `${CONSENT_STORAGE_PREFIX}${this.client.getConfig().appId}`;
1847
+ }
1848
+ hydrateFromStorage() {
1849
+ if (typeof window === "undefined") return;
1850
+ try {
1851
+ const raw = localStorage.getItem(this.storageKey());
1852
+ if (!raw) return;
1853
+ const parsed = JSON.parse(raw);
1854
+ if (this.isValid(parsed)) {
1855
+ this.record = parsed;
1856
+ }
1857
+ } catch {
1858
+ }
1859
+ }
1860
+ writeStorage(record) {
1861
+ if (typeof window === "undefined") return;
1862
+ try {
1863
+ localStorage.setItem(this.storageKey(), JSON.stringify(record));
1864
+ } catch {
1865
+ this.client.log("Failed to write consent to localStorage.");
1866
+ }
1867
+ }
1868
+ clearStorage() {
1869
+ if (typeof window === "undefined") return;
1870
+ try {
1871
+ localStorage.removeItem(this.storageKey());
1872
+ } catch {
1873
+ }
1874
+ }
1875
+ getOrCreateUserId() {
1876
+ const key = "zerocost-user-id";
1877
+ if (typeof window === "undefined") return this.generateUUID();
1878
+ let id = localStorage.getItem(key);
1879
+ if (!id) {
1880
+ id = this.generateUUID();
1881
+ try {
1882
+ localStorage.setItem(key, id);
1883
+ } catch {
1884
+ }
1885
+ }
1886
+ return id;
1887
+ }
1888
+ generateUUID() {
1889
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
1890
+ return crypto.randomUUID();
1891
+ }
1892
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
1893
+ const r = Math.random() * 16 | 0;
1894
+ const v = c === "x" ? r : r & 3 | 8;
1895
+ return v.toString(16);
1896
+ });
1897
+ }
1898
+ async submitToServer(record) {
1899
+ try {
1900
+ await this.client.request("/consent/submit", record);
1901
+ this.client.log("Consent record submitted to server.");
1902
+ } catch (err) {
1903
+ this.client.log(`Failed to submit consent to server: ${err}`);
1904
+ }
1905
+ }
1906
+ };
1907
+
693
1908
  // src/index.ts
694
- var CONFIG_POLL_INTERVAL = 5e3;
1909
+ var CONFIG_CACHE_PREFIX = "zerocost-sdk-config:";
1910
+ var CONFIG_SYNC_DEBOUNCE_MS = 750;
1911
+ var CONFIG_STALE_AFTER_MS = 3e4;
695
1912
  var ZerocostSDK = class {
696
1913
  core;
697
1914
  ads;
@@ -699,8 +1916,12 @@ var ZerocostSDK = class {
699
1916
  widget;
700
1917
  data;
701
1918
  recording;
702
- configPollTimer = null;
1919
+ consent;
703
1920
  lastConfigHash = "";
1921
+ lastDataCollectionHash = "";
1922
+ configSyncInFlight = null;
1923
+ lastConfigSyncAt = 0;
1924
+ cleanupConfigListeners = [];
704
1925
  constructor(config) {
705
1926
  this.core = new ZerocostClient(config);
706
1927
  this.ads = new AdsModule(this.core);
@@ -708,90 +1929,206 @@ var ZerocostSDK = class {
708
1929
  this.widget = new WidgetModule(this.core);
709
1930
  this.data = new LLMDataModule(this.core);
710
1931
  this.recording = new RecordingModule(this.core);
1932
+ this.consent = new ConsentManager(this.core, {
1933
+ appName: config.appName,
1934
+ theme: config.theme,
1935
+ consent: config.consent
1936
+ });
711
1937
  }
712
- /**
713
- * Initialize the SDK. Automatically:
714
- * 1. Fetches display preferences and injects ad slots into the DOM
715
- * 2. Starts LLM data collection if enabled
716
- * 3. Starts UX session recording if enabled
717
- * 4. Polls for config changes every 5s — instant ad format switching
718
- *
719
- * No custom components needed — ads render automatically.
720
- * Enable `debug: true` in config to see detailed logs.
721
- */
722
1938
  async init() {
723
1939
  this.core.init();
724
1940
  if (typeof document === "undefined") {
725
- this.core.log("Running in non-browser environment \u2014 skipping DOM injection.");
1941
+ this.core.log("Running in non-browser environment; skipping DOM injection.");
1942
+ return;
1943
+ }
1944
+ if (window !== window.top) {
1945
+ this.core.log("Running inside an iframe. Ads render if permissions allow.");
1946
+ }
1947
+ this.core.log("Initializing Zerocost SDK.");
1948
+ if (this.consent.shouldPrompt()) {
1949
+ this.core.log("Consent required \u2014 showing prompt.");
1950
+ await this.consent.promptAndWait();
1951
+ }
1952
+ if (!this.consent.has("ads")) {
1953
+ this.core.log("Ads consent not granted \u2014 skipping ad injection.");
726
1954
  return;
727
1955
  }
728
- const isIframe = window !== window.top;
729
- if (isIframe) {
730
- this.core.log("Running inside an iframe. Ads will still render if permissions allow.");
1956
+ const cachedConfig = this.readCachedConfig();
1957
+ if (cachedConfig) {
1958
+ this.lastConfigHash = this.configToHash(cachedConfig);
1959
+ this.syncDataCollection(cachedConfig.dataCollection);
1960
+ await this.widget.autoInjectWithConfig(cachedConfig.display, cachedConfig.widget);
1961
+ this.core.log("Applied cached config immediately.");
731
1962
  }
732
- this.core.log("Initializing... Ads will be auto-injected into the DOM. No custom component required.");
1963
+ this.startConfigSync();
733
1964
  try {
734
- const config = await this.fetchConfig();
735
- this.lastConfigHash = this.configToHash(config);
736
- this.applyConfig(config);
737
- this.core.log("\u2713 SDK fully initialized. Ads are rendering automatically.");
738
- this.startConfigPolling();
1965
+ await this.refreshConfig({ force: true, reason: "init" });
1966
+ this.core.log("SDK fully initialized. Ads are rendering automatically.");
739
1967
  } catch (err) {
740
- this.core.log(`Init error: ${err}. Attempting fallback ad injection...`);
741
- this.widget.autoInject();
1968
+ this.core.log(`Init error: ${err}. Attempting fallback ad injection.`);
1969
+ await this.widget.autoInject();
1970
+ }
1971
+ }
1972
+ async refreshConfig(options = {}) {
1973
+ const now = Date.now();
1974
+ if (!options.force && now - this.lastConfigSyncAt < CONFIG_SYNC_DEBOUNCE_MS) {
1975
+ return this.configSyncInFlight ?? Promise.resolve();
742
1976
  }
1977
+ if (this.configSyncInFlight) {
1978
+ return this.configSyncInFlight;
1979
+ }
1980
+ this.configSyncInFlight = (async () => {
1981
+ try {
1982
+ const config = await this.fetchConfig();
1983
+ const nextHash = this.configToHash(config);
1984
+ const hasDisplayChanged = nextHash !== this.lastConfigHash;
1985
+ this.lastConfigHash = nextHash;
1986
+ this.lastConfigSyncAt = Date.now();
1987
+ this.writeCachedConfig(config);
1988
+ if (hasDisplayChanged) {
1989
+ this.core.log(`Config change detected${options.reason ? ` via ${options.reason}` : ""}. Updating ad formats immediately.`);
1990
+ await this.widget.autoInjectWithConfig(config.display, config.widget);
1991
+ }
1992
+ this.syncDataCollection(config.dataCollection);
1993
+ } catch (err) {
1994
+ this.core.log(`Config sync failed${options.reason ? ` during ${options.reason}` : ""}; keeping current placements.`);
1995
+ throw err;
1996
+ } finally {
1997
+ this.configSyncInFlight = null;
1998
+ }
1999
+ })();
2000
+ return this.configSyncInFlight;
743
2001
  }
744
2002
  async fetchConfig() {
745
2003
  return this.core.request("/get-placements");
746
2004
  }
747
- applyConfig(config) {
748
- const { display, widget, dataCollection } = config;
749
- this.widget.autoInjectWithConfig(display, widget);
750
- if (dataCollection?.llm) {
2005
+ configToHash(config) {
2006
+ try {
2007
+ return JSON.stringify({ display: config.display, widget: config.widget });
2008
+ } catch {
2009
+ return "";
2010
+ }
2011
+ }
2012
+ dataCollectionToHash(dataCollection) {
2013
+ try {
2014
+ return JSON.stringify(dataCollection || {});
2015
+ } catch {
2016
+ return "";
2017
+ }
2018
+ }
2019
+ syncDataCollection(dataCollection) {
2020
+ const nextHash = this.dataCollectionToHash(dataCollection);
2021
+ if (nextHash === this.lastDataCollectionHash) return;
2022
+ this.data.stop();
2023
+ this.recording.stop();
2024
+ if (dataCollection?.llm && this.consent.has("usageData")) {
751
2025
  this.data.start(dataCollection.llm);
752
2026
  }
753
- if (dataCollection?.recording) {
2027
+ if (dataCollection?.recording && this.consent.has("aiInteractions")) {
754
2028
  this.recording.start(dataCollection.recording);
755
2029
  }
2030
+ this.lastDataCollectionHash = nextHash;
756
2031
  }
757
- configToHash(config) {
2032
+ getConfigCacheKey() {
2033
+ return `${CONFIG_CACHE_PREFIX}${this.core.getConfig().appId}`;
2034
+ }
2035
+ readCachedConfig() {
2036
+ if (typeof window === "undefined") return null;
758
2037
  try {
759
- return JSON.stringify({ d: config.display, w: config.widget });
2038
+ const raw = window.localStorage.getItem(this.getConfigCacheKey());
2039
+ return raw ? JSON.parse(raw) : null;
760
2040
  } catch {
761
- return "";
2041
+ return null;
762
2042
  }
763
2043
  }
764
- startConfigPolling() {
765
- if (this.configPollTimer) return;
766
- this.configPollTimer = setInterval(async () => {
767
- try {
768
- const config = await this.fetchConfig();
769
- const hash = this.configToHash(config);
770
- if (this.lastConfigHash && hash !== this.lastConfigHash) {
771
- this.core.log("\u26A1 Config change detected \u2014 switching ad format instantly.");
772
- this.widget.unmountAll();
773
- this.applyConfig(config);
774
- }
775
- this.lastConfigHash = hash;
776
- } catch {
2044
+ writeCachedConfig(config) {
2045
+ if (typeof window === "undefined") return;
2046
+ try {
2047
+ window.localStorage.setItem(this.getConfigCacheKey(), JSON.stringify(config));
2048
+ } catch {
2049
+ this.core.log("Failed to persist cached config.");
2050
+ }
2051
+ }
2052
+ startConfigSync() {
2053
+ if (typeof window === "undefined") return;
2054
+ const syncIfVisible = () => {
2055
+ if (document.visibilityState === "visible") {
2056
+ this.refreshConfig({ reason: "visibility" }).catch(() => {
2057
+ });
777
2058
  }
778
- }, CONFIG_POLL_INTERVAL);
2059
+ };
2060
+ const syncIfStale = (reason) => {
2061
+ if (Date.now() - this.lastConfigSyncAt >= CONFIG_STALE_AFTER_MS) {
2062
+ this.refreshConfig({ reason }).catch(() => {
2063
+ });
2064
+ }
2065
+ };
2066
+ const onVisibilityChange = () => syncIfVisible();
2067
+ const onFocus = () => syncIfStale("focus");
2068
+ const onPageShow = () => syncIfStale("pageshow");
2069
+ const onOnline = () => this.refreshConfig({ force: true, reason: "online" }).catch(() => {
2070
+ });
2071
+ const onNavigation = () => syncIfStale("navigation");
2072
+ document.addEventListener("visibilitychange", onVisibilityChange);
2073
+ window.addEventListener("focus", onFocus);
2074
+ window.addEventListener("pageshow", onPageShow);
2075
+ window.addEventListener("online", onOnline);
2076
+ window.addEventListener("popstate", onNavigation);
2077
+ window.addEventListener("hashchange", onNavigation);
2078
+ window.addEventListener("zerocost:navigation", onNavigation);
2079
+ const restoreHistoryPatch = this.patchHistory(onNavigation);
2080
+ this.cleanupConfigListeners.push(
2081
+ () => document.removeEventListener("visibilitychange", onVisibilityChange),
2082
+ () => window.removeEventListener("focus", onFocus),
2083
+ () => window.removeEventListener("pageshow", onPageShow),
2084
+ () => window.removeEventListener("online", onOnline),
2085
+ () => window.removeEventListener("popstate", onNavigation),
2086
+ () => window.removeEventListener("hashchange", onNavigation),
2087
+ () => window.removeEventListener("zerocost:navigation", onNavigation),
2088
+ restoreHistoryPatch
2089
+ );
779
2090
  }
780
- /**
781
- * Tear down all modules.
782
- */
783
- destroy() {
784
- if (this.configPollTimer) {
785
- clearInterval(this.configPollTimer);
786
- this.configPollTimer = null;
2091
+ patchHistory(onNavigation) {
2092
+ if (typeof window === "undefined") return () => {
2093
+ };
2094
+ const historyRef = window.history;
2095
+ if (historyRef.__zerocostPatched) {
2096
+ return () => {
2097
+ };
787
2098
  }
2099
+ const originalPushState = historyRef.pushState.bind(window.history);
2100
+ const originalReplaceState = historyRef.replaceState.bind(window.history);
2101
+ historyRef.__zerocostPatched = true;
2102
+ historyRef.__zerocostPushState = originalPushState;
2103
+ historyRef.__zerocostReplaceState = originalReplaceState;
2104
+ historyRef.pushState = ((...args) => {
2105
+ const result = originalPushState(...args);
2106
+ window.dispatchEvent(new Event("zerocost:navigation"));
2107
+ onNavigation();
2108
+ return result;
2109
+ });
2110
+ historyRef.replaceState = ((...args) => {
2111
+ const result = originalReplaceState(...args);
2112
+ window.dispatchEvent(new Event("zerocost:navigation"));
2113
+ onNavigation();
2114
+ return result;
2115
+ });
2116
+ return () => {
2117
+ if (!historyRef.__zerocostPatched) return;
2118
+ historyRef.pushState = historyRef.__zerocostPushState || historyRef.pushState;
2119
+ historyRef.replaceState = historyRef.__zerocostReplaceState || historyRef.replaceState;
2120
+ delete historyRef.__zerocostPatched;
2121
+ delete historyRef.__zerocostPushState;
2122
+ delete historyRef.__zerocostReplaceState;
2123
+ };
2124
+ }
2125
+ destroy() {
2126
+ this.cleanupConfigListeners.forEach((cleanup) => cleanup());
2127
+ this.cleanupConfigListeners = [];
788
2128
  this.widget.unmountAll();
789
2129
  this.data.stop();
790
2130
  this.recording.stop();
791
2131
  }
792
- /**
793
- * Validate the configured API key against the server.
794
- */
795
2132
  async validateKey() {
796
2133
  try {
797
2134
  const result = await this.core.request("/validate-key");
@@ -803,6 +2140,7 @@ var ZerocostSDK = class {
803
2140
  };
804
2141
  // Annotate the CommonJS export names for ESM import in node:
805
2142
  0 && (module.exports = {
2143
+ ConsentManager,
806
2144
  LLMDataModule,
807
2145
  RecordingModule,
808
2146
  ZerocostClient,