@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.js CHANGED
@@ -109,6 +109,142 @@ var TrackModule = class {
109
109
  }
110
110
  };
111
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
+
112
248
  // src/modules/widget.ts
113
249
  var POSITION_STYLES = {
114
250
  "bottom-right": "position:fixed;bottom:24px;right:24px;z-index:9999;",
@@ -123,6 +259,17 @@ var POSITION_STYLES = {
123
259
  };
124
260
  var FORMAT_PRIORITY = ["video-widget", "tooltip-ad", "sponsored-card", "sidebar-display", "inline-text"];
125
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
+ ];
126
273
  var WidgetModule = class {
127
274
  constructor(client) {
128
275
  this.client = client;
@@ -130,15 +277,17 @@ var WidgetModule = class {
130
277
  mounted = /* @__PURE__ */ new Map();
131
278
  async autoInjectWithConfig(display, widget) {
132
279
  try {
133
- const selected = this.resolveSelectedWidget(display, widget);
280
+ const selected = this.resolveSelectedWidgets(display, widget);
134
281
  this.clearAutoInjectedSlots();
135
- if (!selected || !selected.enabled) {
282
+ if (selected.length === 0) {
136
283
  this.client.log("No enabled widget format found. Skipping injection.");
137
284
  return;
138
285
  }
139
- this.client.log(`Auto-inject: rendering configured format "${selected.format}" only.`);
140
- await this.mountSingleFormat(selected);
141
- 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.");
142
291
  } catch (err) {
143
292
  this.client.log(`Widget autoInject error: ${err}`);
144
293
  }
@@ -152,34 +301,65 @@ var WidgetModule = class {
152
301
  this.client.log(`Widget autoInject error: ${err}`);
153
302
  }
154
303
  }
155
- resolveSelectedWidget(display, widget) {
156
- if (widget?.enabled && widget?.format) {
157
- 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({
158
309
  format: widget.format,
159
310
  position: widget.position || "bottom-right",
160
311
  theme: widget.theme || "dark",
161
312
  autoplay: widget.autoplay ?? widget.format === "video-widget",
162
313
  enabled: true
163
- };
164
- }
165
- const configs = this.normalizeConfigs(display);
166
- for (const fmt of FORMAT_PRIORITY) {
167
- const cfg = configs[fmt];
168
- if (cfg?.enabled) {
169
- return {
170
- format: fmt,
171
- position: cfg.position,
172
- theme: cfg.theme,
173
- autoplay: cfg.autoplay,
174
- enabled: true
175
- };
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
+ }
176
329
  }
177
330
  }
178
- 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;
179
350
  }
180
351
  normalizeConfigs(display) {
181
- if (display && typeof display === "object" && "video-widget" in display) {
182
- 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
+ };
183
363
  }
184
364
  const pos = display?.position || "bottom-right";
185
365
  const theme = display?.theme || "dark";
@@ -199,31 +379,32 @@ var WidgetModule = class {
199
379
  return;
200
380
  }
201
381
  if (!isInline) {
202
- let el = document.getElementById(AUTO_SLOT_ID);
203
- if (!el) {
204
- el = document.createElement("div");
205
- el.id = AUTO_SLOT_ID;
206
- 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);
207
387
  }
208
388
  const posStyle = POSITION_STYLES[config.position] || POSITION_STYLES["bottom-right"];
209
- const maxW = config.format === "video-widget" ? "max-width:200px;" : config.format === "sponsored-card" || config.format === "sidebar-display" ? "max-width:176px;" : "max-width:320px;";
210
- el.setAttribute("style", `${posStyle}${maxW}`);
211
- el.setAttribute("data-zerocost", "");
212
- 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);
213
393
  }
214
394
  await this.mount(targetElementId, {
215
395
  format: config.format,
216
- refreshInterval: 0,
217
- // Config polling handles re-rendering
396
+ refreshInterval: SDK_WIDGET_REFRESH_MS / 1e3,
218
397
  theme: config.theme,
219
398
  autoplay: config.autoplay,
220
399
  position: config.position
221
400
  });
222
401
  }
223
402
  ensureInlineTarget(position) {
403
+ const chatContainer = this.findChatContainer();
404
+ if (!chatContainer) return null;
224
405
  const paragraphMatch = /after-paragraph-(\d+)/.exec(position || "");
225
406
  const index = paragraphMatch ? Number(paragraphMatch[1]) : 1;
226
- const paragraphs = Array.from(document.querySelectorAll("p"));
407
+ const paragraphs = Array.from(chatContainer.querySelectorAll("p"));
227
408
  const anchor = paragraphs[Math.max(0, Math.min(paragraphs.length - 1, index - 1))];
228
409
  if (!anchor) return null;
229
410
  const existing = document.getElementById(AUTO_SLOT_ID);
@@ -240,11 +421,22 @@ var WidgetModule = class {
240
421
  }
241
422
  return inlineId;
242
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
+ }
243
435
  async mount(targetElementId, options = {}) {
244
- const el = document.getElementById(targetElementId);
245
- if (!el) return;
436
+ const element = document.getElementById(targetElementId);
437
+ if (!element) return;
246
438
  if (this.mounted.has(targetElementId)) this.unmount(targetElementId);
247
- const refreshMs = (options.refreshInterval ?? 30) * 1e3;
439
+ const refreshMs = (options.refreshInterval ?? SDK_WIDGET_REFRESH_MS / 1e3) * 1e3;
248
440
  const theme = options.theme || "dark";
249
441
  const format = options.format || "video-widget";
250
442
  const autoplay = options.autoplay ?? format === "video-widget";
@@ -258,15 +450,15 @@ var WidgetModule = class {
258
450
  };
259
451
  const data = await this.client.request("/serve-widget", body);
260
452
  const ad = data.ad;
261
- if (!ad || !data.html) {
453
+ if (!ad) {
262
454
  this.client.log(`No ad inventory available for configured format "${format}".`);
263
- el.innerHTML = "";
455
+ element.innerHTML = "";
264
456
  return;
265
457
  }
266
- el.innerHTML = data.html;
267
- el.setAttribute("data-zerocost-ad-id", ad.id);
268
- this.ensureVideoPlayback(el);
269
- 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]");
270
462
  ctas.forEach((cta) => {
271
463
  cta.addEventListener("click", () => {
272
464
  this.client.request("/track-event", {
@@ -276,11 +468,11 @@ var WidgetModule = class {
276
468
  });
277
469
  });
278
470
  });
279
- const closeBtn = el.querySelector("[data-zc-close]");
471
+ const closeBtn = element.querySelector("[data-zc-close]");
280
472
  if (closeBtn) {
281
- closeBtn.addEventListener("click", (e) => {
282
- e.preventDefault();
283
- e.stopPropagation();
473
+ closeBtn.addEventListener("click", (event) => {
474
+ event.preventDefault();
475
+ event.stopPropagation();
284
476
  this.unmount(targetElementId);
285
477
  });
286
478
  }
@@ -317,8 +509,8 @@ var WidgetModule = class {
317
509
  unmount(targetElementId) {
318
510
  const slot = this.mounted.get(targetElementId);
319
511
  if (slot?.interval) clearInterval(slot.interval);
320
- const el = document.getElementById(targetElementId);
321
- if (el) el.remove();
512
+ const element = document.getElementById(targetElementId);
513
+ if (element) element.remove();
322
514
  this.mounted.delete(targetElementId);
323
515
  }
324
516
  unmountAll() {
@@ -337,59 +529,99 @@ var LLMDataModule = class {
337
529
  clickHandler = null;
338
530
  errorHandler = null;
339
531
  fetchOriginal = null;
340
- /**
341
- * Start capturing LLM training data based on server config.
342
- */
532
+ mutationObserver = null;
533
+ conversationScanTimer = null;
534
+ isSampledSession = false;
535
+ observedConversations = /* @__PURE__ */ new Map();
536
+ conversationCounter = 0;
343
537
  start(config) {
538
+ this.stop();
344
539
  this.config = config;
345
540
  this.client.log(`LLMData: started (sample=${config.sampleRate}%)`);
346
- if (Math.random() * 100 > config.sampleRate) {
541
+ this.isSampledSession = Math.random() * 100 <= config.sampleRate;
542
+ if (!this.isSampledSession) {
347
543
  this.client.log("LLMData: session not sampled, skipping");
348
544
  return;
349
545
  }
350
546
  if (config.uiInteractions) this.captureUIInteractions();
351
- if (config.textPrompts) this.interceptPrompts();
547
+ if (config.textPrompts) {
548
+ this.interceptPrompts();
549
+ this.captureConversationSurfaces();
550
+ this.scheduleConversationScan();
551
+ }
352
552
  if (config.apiErrors) this.captureAPIErrors();
353
553
  this.flushInterval = setInterval(() => this.flush(), 1e4);
354
554
  }
355
555
  stop() {
356
- if (this.flushInterval) clearInterval(this.flushInterval);
556
+ if (this.flushInterval) {
557
+ clearInterval(this.flushInterval);
558
+ this.flushInterval = null;
559
+ }
357
560
  if (this.clickHandler) {
358
561
  document.removeEventListener("click", this.clickHandler, true);
562
+ this.clickHandler = null;
359
563
  }
360
564
  if (this.errorHandler) {
361
565
  window.removeEventListener("error", this.errorHandler);
566
+ this.errorHandler = null;
362
567
  }
363
568
  if (this.fetchOriginal) {
364
569
  window.fetch = this.fetchOriginal;
570
+ this.fetchOriginal = null;
365
571
  }
366
- 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();
367
583
  this.config = null;
368
584
  }
369
- /**
370
- * Manually track an LLM prompt/response pair (for startups that want to
371
- * explicitly send their AI interactions).
372
- */
373
585
  trackPrompt(prompt, response, meta) {
374
- if (!this.config?.textPrompts) return;
586
+ if (!this.canCapture("textPrompts")) return;
375
587
  this.pushEvent("llm_prompt", {
376
588
  prompt: this.scrub(prompt),
377
589
  response: response ? this.scrub(response) : void 0,
378
- ...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
379
615
  });
380
616
  }
381
- /**
382
- * Manually track an API error.
383
- */
384
617
  trackError(endpoint, status, message) {
385
- if (!this.config?.apiErrors) return;
618
+ if (!this.canCapture("apiErrors")) return;
386
619
  this.pushEvent("api_error", {
387
620
  endpoint,
388
621
  status,
389
622
  message: message ? this.scrub(message) : void 0
390
623
  });
391
624
  }
392
- // ── Private ──
393
625
  captureUIInteractions() {
394
626
  this.clickHandler = (e) => {
395
627
  const target = e.target;
@@ -413,44 +645,136 @@ var LLMDataModule = class {
413
645
  }
414
646
  interceptPrompts() {
415
647
  this.fetchOriginal = window.fetch;
416
- const self = this;
417
648
  const origFetch = window.fetch;
649
+ const self = this;
418
650
  window.fetch = async function(input, init) {
419
651
  const fetchInput = input instanceof URL ? input.toString() : input;
420
652
  const url = typeof fetchInput === "string" ? fetchInput : fetchInput.url;
421
653
  const isLLM = /\/(chat|completions|generate|predict|inference|ask)/i.test(url);
422
- 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
+ }
423
670
  try {
424
671
  const res = await origFetch.call(window, fetchInput, init);
425
672
  const clone = res.clone();
426
- let reqBody;
427
- if (init?.body && typeof init.body === "string") {
428
- try {
429
- const parsed = JSON.parse(init.body);
430
- reqBody = JSON.stringify(parsed.messages || parsed.prompt || "").slice(0, 500);
431
- } catch {
432
- reqBody = void 0;
433
- }
434
- }
435
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
+ }
436
699
  self.pushEvent("llm_prompt", {
437
- endpoint: new URL(url).pathname,
438
- request: reqBody ? self.scrub(reqBody) : void 0,
439
- response: self.scrub(text.slice(0, 500)),
440
- 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
441
708
  });
442
709
  }).catch(() => {
443
710
  });
444
711
  return res;
445
- } catch (err) {
712
+ } catch (error) {
713
+ const message = error instanceof Error ? error.message : String(error);
446
714
  self.pushEvent("api_error", {
447
- endpoint: new URL(url).pathname,
448
- message: self.scrub(err.message)
715
+ endpoint: requestMeta.endpoint,
716
+ message: self.scrub(message),
717
+ requestId: requestMeta.requestId
449
718
  });
450
- throw err;
719
+ throw error;
451
720
  }
452
721
  };
453
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
+ }
454
778
  captureAPIErrors() {
455
779
  this.errorHandler = (e) => {
456
780
  this.pushEvent("api_error", {
@@ -462,9 +786,37 @@ var LLMDataModule = class {
462
786
  };
463
787
  window.addEventListener("error", this.errorHandler);
464
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
+ }
465
814
  pushEvent(type, data) {
815
+ if (!this.isSampledSession) return;
466
816
  this.buffer.push({ type, data, timestamp: Date.now() });
467
- if (this.buffer.length >= 50) this.flush();
817
+ if (this.buffer.length >= 50) {
818
+ void this.flush();
819
+ }
468
820
  }
469
821
  async flush() {
470
822
  if (this.buffer.length === 0) return;
@@ -477,25 +829,286 @@ var LLMDataModule = class {
477
829
  this.buffer.unshift(...events);
478
830
  }
479
831
  }
480
- /** Basic PII scrubbing */
481
832
  scrub(text) {
482
- 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]");
483
834
  }
484
835
  getPath(el) {
485
836
  const parts = [];
486
- let cur = el;
487
- while (cur && parts.length < 5) {
488
- let seg = cur.tagName?.toLowerCase() || "";
489
- if (cur.id) seg += `#${cur.id}`;
490
- else if (cur.className && typeof cur.className === "string") {
491
- const cls = cur.className.split(" ")[0];
492
- 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}`;
493
845
  }
494
- parts.unshift(seg);
495
- cur = cur.parentElement;
846
+ parts.unshift(segment);
847
+ current = current.parentElement;
496
848
  }
497
849
  return parts.join(" > ");
498
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
+ }
499
1112
  };
500
1113
 
501
1114
  // src/modules/recording.ts
@@ -661,8 +1274,611 @@ var RecordingModule = class {
661
1274
  }
662
1275
  };
663
1276
 
1277
+ // src/core/consent-ui.ts
1278
+ var STYLE_ID = "zerocost-consent-styles";
1279
+ function injectStyles(theme) {
1280
+ if (document.getElementById(STYLE_ID)) return;
1281
+ const darkVars = `
1282
+ --zc-bg: #111111;
1283
+ --zc-surface: #1a1a1a;
1284
+ --zc-border: #2a2a2a;
1285
+ --zc-text: #ffffff;
1286
+ --zc-text-secondary: #999999;
1287
+ --zc-accent: #ffffff;
1288
+ --zc-accent-bg: #ffffff;
1289
+ --zc-accent-fg: #000000;
1290
+ --zc-toggle-off-bg: #333333;
1291
+ --zc-toggle-on-bg: #00e599;
1292
+ --zc-toggle-knob: #ffffff;
1293
+ --zc-backdrop: rgba(0,0,0,0.65);
1294
+ --zc-link: #888888;
1295
+ --zc-link-hover: #cccccc;
1296
+ `;
1297
+ const lightVars = `
1298
+ --zc-bg: #ffffff;
1299
+ --zc-surface: #f5f5f5;
1300
+ --zc-border: #e0e0e0;
1301
+ --zc-text: #111111;
1302
+ --zc-text-secondary: #666666;
1303
+ --zc-accent: #111111;
1304
+ --zc-accent-bg: #111111;
1305
+ --zc-accent-fg: #ffffff;
1306
+ --zc-toggle-off-bg: #cccccc;
1307
+ --zc-toggle-on-bg: #00c77d;
1308
+ --zc-toggle-knob: #ffffff;
1309
+ --zc-backdrop: rgba(0,0,0,0.45);
1310
+ --zc-link: #666666;
1311
+ --zc-link-hover: #111111;
1312
+ `;
1313
+ let themeRule;
1314
+ if (theme === "dark") {
1315
+ themeRule = `.zc-consent-root { ${darkVars} }`;
1316
+ } else if (theme === "light") {
1317
+ themeRule = `.zc-consent-root { ${lightVars} }`;
1318
+ } else {
1319
+ themeRule = `
1320
+ .zc-consent-root { ${lightVars} }
1321
+ @media (prefers-color-scheme: dark) {
1322
+ .zc-consent-root { ${darkVars} }
1323
+ }
1324
+ `;
1325
+ }
1326
+ const css = `
1327
+ ${themeRule}
1328
+
1329
+ .zc-consent-root * {
1330
+ box-sizing: border-box;
1331
+ margin: 0;
1332
+ padding: 0;
1333
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif;
1334
+ }
1335
+
1336
+ .zc-consent-backdrop {
1337
+ position: fixed;
1338
+ inset: 0;
1339
+ z-index: 999999;
1340
+ background: var(--zc-backdrop);
1341
+ display: flex;
1342
+ align-items: center;
1343
+ justify-content: center;
1344
+ animation: zc-fade-in 200ms ease;
1345
+ }
1346
+
1347
+ @keyframes zc-fade-in {
1348
+ from { opacity: 0; }
1349
+ to { opacity: 1; }
1350
+ }
1351
+
1352
+ @keyframes zc-slide-up {
1353
+ from { transform: translateY(100%); }
1354
+ to { transform: translateY(0); }
1355
+ }
1356
+
1357
+ .zc-consent-card {
1358
+ background: var(--zc-bg);
1359
+ border: 1px solid var(--zc-border);
1360
+ border-radius: 16px;
1361
+ width: 100%;
1362
+ max-width: 460px;
1363
+ max-height: 90vh;
1364
+ overflow-y: auto;
1365
+ padding: 28px 24px 24px;
1366
+ animation: zc-fade-in 200ms ease;
1367
+ }
1368
+
1369
+ /* Mobile: bottom-sheet style */
1370
+ @media (max-width: 640px) {
1371
+ .zc-consent-backdrop {
1372
+ align-items: flex-end;
1373
+ }
1374
+ .zc-consent-card {
1375
+ border-radius: 20px 20px 0 0;
1376
+ max-width: 100%;
1377
+ animation: zc-slide-up 200ms ease;
1378
+ }
1379
+ }
1380
+
1381
+ /* Scrollbar */
1382
+ .zc-consent-card::-webkit-scrollbar { width: 4px; }
1383
+ .zc-consent-card::-webkit-scrollbar-thumb { background: var(--zc-border); border-radius: 4px; }
1384
+
1385
+ .zc-consent-header {
1386
+ display: flex;
1387
+ align-items: center;
1388
+ gap: 10px;
1389
+ margin-bottom: 6px;
1390
+ }
1391
+
1392
+ .zc-consent-logo {
1393
+ width: 28px;
1394
+ height: 28px;
1395
+ border-radius: 6px;
1396
+ background: var(--zc-accent-bg);
1397
+ display: flex;
1398
+ align-items: center;
1399
+ justify-content: center;
1400
+ flex-shrink: 0;
1401
+ }
1402
+
1403
+ .zc-consent-logo svg {
1404
+ width: 16px;
1405
+ height: 16px;
1406
+ }
1407
+
1408
+ .zc-consent-title {
1409
+ font-size: 16px;
1410
+ font-weight: 700;
1411
+ color: var(--zc-text);
1412
+ letter-spacing: -0.02em;
1413
+ }
1414
+
1415
+ .zc-consent-subtitle {
1416
+ font-size: 13px;
1417
+ color: var(--zc-text-secondary);
1418
+ line-height: 1.5;
1419
+ margin-bottom: 20px;
1420
+ }
1421
+
1422
+ .zc-consent-toggles {
1423
+ display: flex;
1424
+ flex-direction: column;
1425
+ gap: 10px;
1426
+ margin-bottom: 20px;
1427
+ }
1428
+
1429
+ .zc-consent-toggle-card {
1430
+ background: var(--zc-surface);
1431
+ border: 1px solid var(--zc-border);
1432
+ border-radius: 12px;
1433
+ padding: 14px 16px;
1434
+ }
1435
+
1436
+ .zc-consent-toggle-row {
1437
+ display: flex;
1438
+ align-items: center;
1439
+ justify-content: space-between;
1440
+ margin-bottom: 6px;
1441
+ }
1442
+
1443
+ .zc-consent-toggle-label {
1444
+ font-size: 14px;
1445
+ font-weight: 600;
1446
+ color: var(--zc-text);
1447
+ }
1448
+
1449
+ .zc-consent-toggle-desc {
1450
+ font-size: 12px;
1451
+ color: var(--zc-text-secondary);
1452
+ line-height: 1.5;
1453
+ margin-bottom: 4px;
1454
+ }
1455
+
1456
+ .zc-consent-learn-more {
1457
+ font-size: 11px;
1458
+ color: var(--zc-link);
1459
+ text-decoration: none;
1460
+ cursor: pointer;
1461
+ transition: color 150ms;
1462
+ }
1463
+
1464
+ .zc-consent-learn-more:hover {
1465
+ color: var(--zc-link-hover);
1466
+ }
1467
+
1468
+ /* Toggle switch */
1469
+ .zc-toggle {
1470
+ position: relative;
1471
+ width: 40px;
1472
+ height: 22px;
1473
+ flex-shrink: 0;
1474
+ cursor: pointer;
1475
+ }
1476
+
1477
+ .zc-toggle input {
1478
+ opacity: 0;
1479
+ width: 0;
1480
+ height: 0;
1481
+ position: absolute;
1482
+ }
1483
+
1484
+ .zc-toggle-track {
1485
+ position: absolute;
1486
+ inset: 0;
1487
+ background: var(--zc-toggle-off-bg);
1488
+ border-radius: 11px;
1489
+ transition: background 200ms ease;
1490
+ }
1491
+
1492
+ .zc-toggle input:checked + .zc-toggle-track {
1493
+ background: var(--zc-toggle-on-bg);
1494
+ }
1495
+
1496
+ .zc-toggle-knob {
1497
+ position: absolute;
1498
+ top: 2px;
1499
+ left: 2px;
1500
+ width: 18px;
1501
+ height: 18px;
1502
+ background: var(--zc-toggle-knob);
1503
+ border-radius: 50%;
1504
+ transition: transform 200ms ease;
1505
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
1506
+ }
1507
+
1508
+ .zc-toggle input:checked ~ .zc-toggle-knob {
1509
+ transform: translateX(18px);
1510
+ }
1511
+
1512
+ /* Footer */
1513
+ .zc-consent-footer {
1514
+ display: flex;
1515
+ flex-wrap: wrap;
1516
+ gap: 4px 12px;
1517
+ justify-content: center;
1518
+ margin-bottom: 16px;
1519
+ }
1520
+
1521
+ .zc-consent-footer a {
1522
+ font-size: 11px;
1523
+ color: var(--zc-link);
1524
+ text-decoration: none;
1525
+ transition: color 150ms;
1526
+ }
1527
+
1528
+ .zc-consent-footer a:hover {
1529
+ color: var(--zc-link-hover);
1530
+ }
1531
+
1532
+ .zc-consent-footer-sep {
1533
+ font-size: 11px;
1534
+ color: var(--zc-link);
1535
+ opacity: 0.5;
1536
+ }
1537
+
1538
+ /* Confirm button */
1539
+ .zc-consent-confirm {
1540
+ display: block;
1541
+ width: 100%;
1542
+ padding: 12px;
1543
+ font-size: 14px;
1544
+ font-weight: 600;
1545
+ border: none;
1546
+ border-radius: 10px;
1547
+ cursor: pointer;
1548
+ background: var(--zc-accent-bg);
1549
+ color: var(--zc-accent-fg);
1550
+ letter-spacing: -0.01em;
1551
+ transition: opacity 150ms;
1552
+ }
1553
+
1554
+ .zc-consent-confirm:hover {
1555
+ opacity: 0.88;
1556
+ }
1557
+
1558
+ .zc-consent-confirm:active {
1559
+ opacity: 0.75;
1560
+ }
1561
+ `;
1562
+ const style = document.createElement("style");
1563
+ style.id = STYLE_ID;
1564
+ style.textContent = css;
1565
+ document.head.appendChild(style);
1566
+ }
1567
+ function createToggle(id, checked) {
1568
+ const label = document.createElement("label");
1569
+ label.className = "zc-toggle";
1570
+ const input = document.createElement("input");
1571
+ input.type = "checkbox";
1572
+ input.checked = checked;
1573
+ input.id = id;
1574
+ const track = document.createElement("span");
1575
+ track.className = "zc-toggle-track";
1576
+ const knob = document.createElement("span");
1577
+ knob.className = "zc-toggle-knob";
1578
+ label.appendChild(input);
1579
+ label.appendChild(track);
1580
+ label.appendChild(knob);
1581
+ return label;
1582
+ }
1583
+ function createToggleCard(toggleId, title, description, learnMoreUrl, defaultOn) {
1584
+ const card = document.createElement("div");
1585
+ card.className = "zc-consent-toggle-card";
1586
+ const row = document.createElement("div");
1587
+ row.className = "zc-consent-toggle-row";
1588
+ const labelSpan = document.createElement("span");
1589
+ labelSpan.className = "zc-consent-toggle-label";
1590
+ labelSpan.textContent = title;
1591
+ const toggle = createToggle(toggleId, defaultOn);
1592
+ row.appendChild(labelSpan);
1593
+ row.appendChild(toggle);
1594
+ card.appendChild(row);
1595
+ const desc = document.createElement("div");
1596
+ desc.className = "zc-consent-toggle-desc";
1597
+ desc.textContent = description;
1598
+ card.appendChild(desc);
1599
+ const link = document.createElement("a");
1600
+ link.className = "zc-consent-learn-more";
1601
+ link.href = learnMoreUrl;
1602
+ link.target = "_blank";
1603
+ link.rel = "noopener noreferrer";
1604
+ link.textContent = "Learn more \u2197";
1605
+ card.appendChild(link);
1606
+ return card;
1607
+ }
1608
+ function showConsentUI(options) {
1609
+ return new Promise((resolve) => {
1610
+ const { appName, theme, privacyPolicyUrl } = options;
1611
+ const defaults = options.defaults ?? { ads: true, usageData: false, aiInteractions: false };
1612
+ injectStyles(theme);
1613
+ const root = document.createElement("div");
1614
+ root.className = "zc-consent-root";
1615
+ const backdrop = document.createElement("div");
1616
+ backdrop.className = "zc-consent-backdrop";
1617
+ const blockEscape = (e) => {
1618
+ if (e.key === "Escape") {
1619
+ e.preventDefault();
1620
+ e.stopPropagation();
1621
+ }
1622
+ };
1623
+ document.addEventListener("keydown", blockEscape, true);
1624
+ const card = document.createElement("div");
1625
+ card.className = "zc-consent-card";
1626
+ const header = document.createElement("div");
1627
+ header.className = "zc-consent-header";
1628
+ const logo = document.createElement("div");
1629
+ logo.className = "zc-consent-logo";
1630
+ 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>`;
1631
+ const title = document.createElement("div");
1632
+ title.className = "zc-consent-title";
1633
+ title.textContent = `${appName || "This app"} uses Zerocost`;
1634
+ header.appendChild(logo);
1635
+ header.appendChild(title);
1636
+ card.appendChild(header);
1637
+ const subtitle = document.createElement("div");
1638
+ subtitle.className = "zc-consent-subtitle";
1639
+ subtitle.textContent = "Manage your preferences below. You can update these anytime.";
1640
+ card.appendChild(subtitle);
1641
+ const toggles = document.createElement("div");
1642
+ toggles.className = "zc-consent-toggles";
1643
+ const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
1644
+ toggles.appendChild(createToggleCard(
1645
+ "zc-toggle-ads",
1646
+ "Ads",
1647
+ "Contextual, non-intrusive ads. No cookies or browsing history used.",
1648
+ `${baseUrl}/consent/ads`,
1649
+ defaults.ads
1650
+ ));
1651
+ toggles.appendChild(createToggleCard(
1652
+ "zc-toggle-usage",
1653
+ "Usage data",
1654
+ "Anonymized usage patterns. No personal information is shared.",
1655
+ `${baseUrl}/consent/usage-data`,
1656
+ defaults.usageData
1657
+ ));
1658
+ toggles.appendChild(createToggleCard(
1659
+ "zc-toggle-ai",
1660
+ "AI interactions",
1661
+ "Anonymized conversation data used for AI research.",
1662
+ `${baseUrl}/consent/ai-interactions`,
1663
+ defaults.aiInteractions
1664
+ ));
1665
+ card.appendChild(toggles);
1666
+ const footer = document.createElement("div");
1667
+ footer.className = "zc-consent-footer";
1668
+ const ppLink = document.createElement("a");
1669
+ ppLink.href = privacyPolicyUrl || `${baseUrl}/privacy`;
1670
+ ppLink.target = "_blank";
1671
+ ppLink.rel = "noopener noreferrer";
1672
+ ppLink.textContent = "Privacy Policy";
1673
+ footer.appendChild(ppLink);
1674
+ const sep1 = document.createElement("span");
1675
+ sep1.className = "zc-consent-footer-sep";
1676
+ sep1.textContent = "\xB7";
1677
+ footer.appendChild(sep1);
1678
+ const termsLink = document.createElement("a");
1679
+ termsLink.href = `${baseUrl}/terms`;
1680
+ termsLink.target = "_blank";
1681
+ termsLink.rel = "noopener noreferrer";
1682
+ termsLink.textContent = "Terms";
1683
+ footer.appendChild(termsLink);
1684
+ const sep2 = document.createElement("span");
1685
+ sep2.className = "zc-consent-footer-sep";
1686
+ sep2.textContent = "\xB7";
1687
+ footer.appendChild(sep2);
1688
+ const dnsLink = document.createElement("a");
1689
+ dnsLink.href = `${baseUrl}/do-not-sell`;
1690
+ dnsLink.target = "_blank";
1691
+ dnsLink.rel = "noopener noreferrer";
1692
+ dnsLink.textContent = "Do Not Sell My Data";
1693
+ footer.appendChild(dnsLink);
1694
+ card.appendChild(footer);
1695
+ const confirmBtn = document.createElement("button");
1696
+ confirmBtn.className = "zc-consent-confirm";
1697
+ confirmBtn.textContent = "Confirm";
1698
+ confirmBtn.addEventListener("click", () => {
1699
+ const ads = document.getElementById("zc-toggle-ads")?.checked ?? defaults.ads;
1700
+ const usageData = document.getElementById("zc-toggle-usage")?.checked ?? defaults.usageData;
1701
+ const aiInteractions = document.getElementById("zc-toggle-ai")?.checked ?? defaults.aiInteractions;
1702
+ document.removeEventListener("keydown", blockEscape, true);
1703
+ root.remove();
1704
+ resolve({ ads, usageData, aiInteractions });
1705
+ });
1706
+ card.appendChild(confirmBtn);
1707
+ backdrop.appendChild(card);
1708
+ root.appendChild(backdrop);
1709
+ document.body.appendChild(root);
1710
+ });
1711
+ }
1712
+ function removeConsentUI() {
1713
+ document.querySelector(".zc-consent-root")?.remove();
1714
+ }
1715
+
1716
+ // src/core/consent.ts
1717
+ var CONSENT_VERSION = "1.1";
1718
+ var CONSENT_STORAGE_PREFIX = "zerocost-consent:";
1719
+ var TWELVE_MONTHS_MS = 365 * 24 * 60 * 60 * 1e3;
1720
+ var ConsentManager = class {
1721
+ record = null;
1722
+ needsReset = false;
1723
+ client;
1724
+ consentConfig;
1725
+ appName;
1726
+ theme;
1727
+ constructor(client, opts) {
1728
+ this.client = client;
1729
+ this.consentConfig = opts.consent ?? {};
1730
+ this.appName = opts.appName ?? "";
1731
+ this.theme = opts.theme ?? "dark";
1732
+ this.hydrateFromStorage();
1733
+ }
1734
+ // ── Public API (per spec §6.3) ───────────────────────────────────
1735
+ /** Returns the current consent record, or null if none exists. */
1736
+ get() {
1737
+ return this.record;
1738
+ }
1739
+ /** Programmatically open the consent popup (e.g. from app settings). */
1740
+ async open() {
1741
+ removeConsentUI();
1742
+ await this.promptAndWait();
1743
+ }
1744
+ /** Clear consent — prompt will re-fire on next init(). */
1745
+ reset() {
1746
+ this.record = null;
1747
+ this.needsReset = true;
1748
+ this.clearStorage();
1749
+ this.client.log("Consent reset. Prompt will re-fire on next init().");
1750
+ }
1751
+ /** Restore a previously saved record (skip re-prompting if valid). */
1752
+ restore(record) {
1753
+ if (this.isValid(record)) {
1754
+ this.record = record;
1755
+ this.writeStorage(record);
1756
+ this.client.log("Consent restored from saved record.");
1757
+ } else {
1758
+ this.client.log("Restored record invalid (version/expiry). Will re-prompt.");
1759
+ }
1760
+ }
1761
+ /** Check whether a specific feature is consented. */
1762
+ has(feature) {
1763
+ if (!this.record) return false;
1764
+ return !!this.record[feature];
1765
+ }
1766
+ // ── Internal (used by ZerocostSDK.init) ──────────────────────────
1767
+ /** Should the consent prompt be shown? */
1768
+ shouldPrompt() {
1769
+ if (this.needsReset) return true;
1770
+ if (!this.record) return true;
1771
+ if (!this.isValid(this.record)) return true;
1772
+ return false;
1773
+ }
1774
+ /** Show the consent popup, wait for confirmation, store record. */
1775
+ async promptAndWait() {
1776
+ this.needsReset = false;
1777
+ const result = await showConsentUI({
1778
+ appName: this.appName,
1779
+ theme: this.theme,
1780
+ privacyPolicyUrl: this.consentConfig.privacyPolicyUrl,
1781
+ defaults: this.record ? { ads: this.record.ads, usageData: this.record.usageData, aiInteractions: this.record.aiInteractions } : void 0
1782
+ });
1783
+ const userId = this.getOrCreateUserId();
1784
+ const record = {
1785
+ userId,
1786
+ appId: this.client.getConfig().appId,
1787
+ ads: result.ads,
1788
+ usageData: result.usageData,
1789
+ aiInteractions: result.aiInteractions,
1790
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1791
+ version: CONSENT_VERSION,
1792
+ method: "confirmed",
1793
+ ipRegion: "OTHER"
1794
+ // server can enrich via IP
1795
+ };
1796
+ this.record = record;
1797
+ this.writeStorage(record);
1798
+ if (this.consentConfig.onConsentChange) {
1799
+ try {
1800
+ this.consentConfig.onConsentChange(record);
1801
+ } catch (err) {
1802
+ this.client.log(`onConsentChange callback error: ${err}`);
1803
+ }
1804
+ }
1805
+ this.submitToServer(record);
1806
+ this.client.log("Consent confirmed.", record);
1807
+ }
1808
+ // ── Helpers ──────────────────────────────────────────────────────
1809
+ isValid(record) {
1810
+ if (record.version !== CONSENT_VERSION) return false;
1811
+ const age = Date.now() - new Date(record.timestamp).getTime();
1812
+ if (age > TWELVE_MONTHS_MS) return false;
1813
+ return true;
1814
+ }
1815
+ storageKey() {
1816
+ return `${CONSENT_STORAGE_PREFIX}${this.client.getConfig().appId}`;
1817
+ }
1818
+ hydrateFromStorage() {
1819
+ if (typeof window === "undefined") return;
1820
+ try {
1821
+ const raw = localStorage.getItem(this.storageKey());
1822
+ if (!raw) return;
1823
+ const parsed = JSON.parse(raw);
1824
+ if (this.isValid(parsed)) {
1825
+ this.record = parsed;
1826
+ }
1827
+ } catch {
1828
+ }
1829
+ }
1830
+ writeStorage(record) {
1831
+ if (typeof window === "undefined") return;
1832
+ try {
1833
+ localStorage.setItem(this.storageKey(), JSON.stringify(record));
1834
+ } catch {
1835
+ this.client.log("Failed to write consent to localStorage.");
1836
+ }
1837
+ }
1838
+ clearStorage() {
1839
+ if (typeof window === "undefined") return;
1840
+ try {
1841
+ localStorage.removeItem(this.storageKey());
1842
+ } catch {
1843
+ }
1844
+ }
1845
+ getOrCreateUserId() {
1846
+ const key = "zerocost-user-id";
1847
+ if (typeof window === "undefined") return this.generateUUID();
1848
+ let id = localStorage.getItem(key);
1849
+ if (!id) {
1850
+ id = this.generateUUID();
1851
+ try {
1852
+ localStorage.setItem(key, id);
1853
+ } catch {
1854
+ }
1855
+ }
1856
+ return id;
1857
+ }
1858
+ generateUUID() {
1859
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
1860
+ return crypto.randomUUID();
1861
+ }
1862
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
1863
+ const r = Math.random() * 16 | 0;
1864
+ const v = c === "x" ? r : r & 3 | 8;
1865
+ return v.toString(16);
1866
+ });
1867
+ }
1868
+ async submitToServer(record) {
1869
+ try {
1870
+ await this.client.request("/consent/submit", record);
1871
+ this.client.log("Consent record submitted to server.");
1872
+ } catch (err) {
1873
+ this.client.log(`Failed to submit consent to server: ${err}`);
1874
+ }
1875
+ }
1876
+ };
1877
+
664
1878
  // src/index.ts
665
- var CONFIG_POLL_INTERVAL = 5e3;
1879
+ var CONFIG_CACHE_PREFIX = "zerocost-sdk-config:";
1880
+ var CONFIG_SYNC_DEBOUNCE_MS = 750;
1881
+ var CONFIG_STALE_AFTER_MS = 3e4;
666
1882
  var ZerocostSDK = class {
667
1883
  core;
668
1884
  ads;
@@ -670,8 +1886,12 @@ var ZerocostSDK = class {
670
1886
  widget;
671
1887
  data;
672
1888
  recording;
673
- configPollTimer = null;
1889
+ consent;
674
1890
  lastConfigHash = "";
1891
+ lastDataCollectionHash = "";
1892
+ configSyncInFlight = null;
1893
+ lastConfigSyncAt = 0;
1894
+ cleanupConfigListeners = [];
675
1895
  constructor(config) {
676
1896
  this.core = new ZerocostClient(config);
677
1897
  this.ads = new AdsModule(this.core);
@@ -679,90 +1899,206 @@ var ZerocostSDK = class {
679
1899
  this.widget = new WidgetModule(this.core);
680
1900
  this.data = new LLMDataModule(this.core);
681
1901
  this.recording = new RecordingModule(this.core);
1902
+ this.consent = new ConsentManager(this.core, {
1903
+ appName: config.appName,
1904
+ theme: config.theme,
1905
+ consent: config.consent
1906
+ });
682
1907
  }
683
- /**
684
- * Initialize the SDK. Automatically:
685
- * 1. Fetches display preferences and injects ad slots into the DOM
686
- * 2. Starts LLM data collection if enabled
687
- * 3. Starts UX session recording if enabled
688
- * 4. Polls for config changes every 5s — instant ad format switching
689
- *
690
- * No custom components needed — ads render automatically.
691
- * Enable `debug: true` in config to see detailed logs.
692
- */
693
1908
  async init() {
694
1909
  this.core.init();
695
1910
  if (typeof document === "undefined") {
696
- this.core.log("Running in non-browser environment \u2014 skipping DOM injection.");
1911
+ this.core.log("Running in non-browser environment; skipping DOM injection.");
1912
+ return;
1913
+ }
1914
+ if (window !== window.top) {
1915
+ this.core.log("Running inside an iframe. Ads render if permissions allow.");
1916
+ }
1917
+ this.core.log("Initializing Zerocost SDK.");
1918
+ if (this.consent.shouldPrompt()) {
1919
+ this.core.log("Consent required \u2014 showing prompt.");
1920
+ await this.consent.promptAndWait();
1921
+ }
1922
+ if (!this.consent.has("ads")) {
1923
+ this.core.log("Ads consent not granted \u2014 skipping ad injection.");
697
1924
  return;
698
1925
  }
699
- const isIframe = window !== window.top;
700
- if (isIframe) {
701
- this.core.log("Running inside an iframe. Ads will still render if permissions allow.");
1926
+ const cachedConfig = this.readCachedConfig();
1927
+ if (cachedConfig) {
1928
+ this.lastConfigHash = this.configToHash(cachedConfig);
1929
+ this.syncDataCollection(cachedConfig.dataCollection);
1930
+ await this.widget.autoInjectWithConfig(cachedConfig.display, cachedConfig.widget);
1931
+ this.core.log("Applied cached config immediately.");
702
1932
  }
703
- this.core.log("Initializing... Ads will be auto-injected into the DOM. No custom component required.");
1933
+ this.startConfigSync();
704
1934
  try {
705
- const config = await this.fetchConfig();
706
- this.lastConfigHash = this.configToHash(config);
707
- this.applyConfig(config);
708
- this.core.log("\u2713 SDK fully initialized. Ads are rendering automatically.");
709
- this.startConfigPolling();
1935
+ await this.refreshConfig({ force: true, reason: "init" });
1936
+ this.core.log("SDK fully initialized. Ads are rendering automatically.");
710
1937
  } catch (err) {
711
- this.core.log(`Init error: ${err}. Attempting fallback ad injection...`);
712
- this.widget.autoInject();
1938
+ this.core.log(`Init error: ${err}. Attempting fallback ad injection.`);
1939
+ await this.widget.autoInject();
1940
+ }
1941
+ }
1942
+ async refreshConfig(options = {}) {
1943
+ const now = Date.now();
1944
+ if (!options.force && now - this.lastConfigSyncAt < CONFIG_SYNC_DEBOUNCE_MS) {
1945
+ return this.configSyncInFlight ?? Promise.resolve();
713
1946
  }
1947
+ if (this.configSyncInFlight) {
1948
+ return this.configSyncInFlight;
1949
+ }
1950
+ this.configSyncInFlight = (async () => {
1951
+ try {
1952
+ const config = await this.fetchConfig();
1953
+ const nextHash = this.configToHash(config);
1954
+ const hasDisplayChanged = nextHash !== this.lastConfigHash;
1955
+ this.lastConfigHash = nextHash;
1956
+ this.lastConfigSyncAt = Date.now();
1957
+ this.writeCachedConfig(config);
1958
+ if (hasDisplayChanged) {
1959
+ this.core.log(`Config change detected${options.reason ? ` via ${options.reason}` : ""}. Updating ad formats immediately.`);
1960
+ await this.widget.autoInjectWithConfig(config.display, config.widget);
1961
+ }
1962
+ this.syncDataCollection(config.dataCollection);
1963
+ } catch (err) {
1964
+ this.core.log(`Config sync failed${options.reason ? ` during ${options.reason}` : ""}; keeping current placements.`);
1965
+ throw err;
1966
+ } finally {
1967
+ this.configSyncInFlight = null;
1968
+ }
1969
+ })();
1970
+ return this.configSyncInFlight;
714
1971
  }
715
1972
  async fetchConfig() {
716
1973
  return this.core.request("/get-placements");
717
1974
  }
718
- applyConfig(config) {
719
- const { display, widget, dataCollection } = config;
720
- this.widget.autoInjectWithConfig(display, widget);
721
- if (dataCollection?.llm) {
1975
+ configToHash(config) {
1976
+ try {
1977
+ return JSON.stringify({ display: config.display, widget: config.widget });
1978
+ } catch {
1979
+ return "";
1980
+ }
1981
+ }
1982
+ dataCollectionToHash(dataCollection) {
1983
+ try {
1984
+ return JSON.stringify(dataCollection || {});
1985
+ } catch {
1986
+ return "";
1987
+ }
1988
+ }
1989
+ syncDataCollection(dataCollection) {
1990
+ const nextHash = this.dataCollectionToHash(dataCollection);
1991
+ if (nextHash === this.lastDataCollectionHash) return;
1992
+ this.data.stop();
1993
+ this.recording.stop();
1994
+ if (dataCollection?.llm && this.consent.has("usageData")) {
722
1995
  this.data.start(dataCollection.llm);
723
1996
  }
724
- if (dataCollection?.recording) {
1997
+ if (dataCollection?.recording && this.consent.has("aiInteractions")) {
725
1998
  this.recording.start(dataCollection.recording);
726
1999
  }
2000
+ this.lastDataCollectionHash = nextHash;
727
2001
  }
728
- configToHash(config) {
2002
+ getConfigCacheKey() {
2003
+ return `${CONFIG_CACHE_PREFIX}${this.core.getConfig().appId}`;
2004
+ }
2005
+ readCachedConfig() {
2006
+ if (typeof window === "undefined") return null;
729
2007
  try {
730
- return JSON.stringify({ d: config.display, w: config.widget });
2008
+ const raw = window.localStorage.getItem(this.getConfigCacheKey());
2009
+ return raw ? JSON.parse(raw) : null;
731
2010
  } catch {
732
- return "";
2011
+ return null;
733
2012
  }
734
2013
  }
735
- startConfigPolling() {
736
- if (this.configPollTimer) return;
737
- this.configPollTimer = setInterval(async () => {
738
- try {
739
- const config = await this.fetchConfig();
740
- const hash = this.configToHash(config);
741
- if (this.lastConfigHash && hash !== this.lastConfigHash) {
742
- this.core.log("\u26A1 Config change detected \u2014 switching ad format instantly.");
743
- this.widget.unmountAll();
744
- this.applyConfig(config);
745
- }
746
- this.lastConfigHash = hash;
747
- } catch {
2014
+ writeCachedConfig(config) {
2015
+ if (typeof window === "undefined") return;
2016
+ try {
2017
+ window.localStorage.setItem(this.getConfigCacheKey(), JSON.stringify(config));
2018
+ } catch {
2019
+ this.core.log("Failed to persist cached config.");
2020
+ }
2021
+ }
2022
+ startConfigSync() {
2023
+ if (typeof window === "undefined") return;
2024
+ const syncIfVisible = () => {
2025
+ if (document.visibilityState === "visible") {
2026
+ this.refreshConfig({ reason: "visibility" }).catch(() => {
2027
+ });
748
2028
  }
749
- }, CONFIG_POLL_INTERVAL);
2029
+ };
2030
+ const syncIfStale = (reason) => {
2031
+ if (Date.now() - this.lastConfigSyncAt >= CONFIG_STALE_AFTER_MS) {
2032
+ this.refreshConfig({ reason }).catch(() => {
2033
+ });
2034
+ }
2035
+ };
2036
+ const onVisibilityChange = () => syncIfVisible();
2037
+ const onFocus = () => syncIfStale("focus");
2038
+ const onPageShow = () => syncIfStale("pageshow");
2039
+ const onOnline = () => this.refreshConfig({ force: true, reason: "online" }).catch(() => {
2040
+ });
2041
+ const onNavigation = () => syncIfStale("navigation");
2042
+ document.addEventListener("visibilitychange", onVisibilityChange);
2043
+ window.addEventListener("focus", onFocus);
2044
+ window.addEventListener("pageshow", onPageShow);
2045
+ window.addEventListener("online", onOnline);
2046
+ window.addEventListener("popstate", onNavigation);
2047
+ window.addEventListener("hashchange", onNavigation);
2048
+ window.addEventListener("zerocost:navigation", onNavigation);
2049
+ const restoreHistoryPatch = this.patchHistory(onNavigation);
2050
+ this.cleanupConfigListeners.push(
2051
+ () => document.removeEventListener("visibilitychange", onVisibilityChange),
2052
+ () => window.removeEventListener("focus", onFocus),
2053
+ () => window.removeEventListener("pageshow", onPageShow),
2054
+ () => window.removeEventListener("online", onOnline),
2055
+ () => window.removeEventListener("popstate", onNavigation),
2056
+ () => window.removeEventListener("hashchange", onNavigation),
2057
+ () => window.removeEventListener("zerocost:navigation", onNavigation),
2058
+ restoreHistoryPatch
2059
+ );
750
2060
  }
751
- /**
752
- * Tear down all modules.
753
- */
754
- destroy() {
755
- if (this.configPollTimer) {
756
- clearInterval(this.configPollTimer);
757
- this.configPollTimer = null;
2061
+ patchHistory(onNavigation) {
2062
+ if (typeof window === "undefined") return () => {
2063
+ };
2064
+ const historyRef = window.history;
2065
+ if (historyRef.__zerocostPatched) {
2066
+ return () => {
2067
+ };
758
2068
  }
2069
+ const originalPushState = historyRef.pushState.bind(window.history);
2070
+ const originalReplaceState = historyRef.replaceState.bind(window.history);
2071
+ historyRef.__zerocostPatched = true;
2072
+ historyRef.__zerocostPushState = originalPushState;
2073
+ historyRef.__zerocostReplaceState = originalReplaceState;
2074
+ historyRef.pushState = ((...args) => {
2075
+ const result = originalPushState(...args);
2076
+ window.dispatchEvent(new Event("zerocost:navigation"));
2077
+ onNavigation();
2078
+ return result;
2079
+ });
2080
+ historyRef.replaceState = ((...args) => {
2081
+ const result = originalReplaceState(...args);
2082
+ window.dispatchEvent(new Event("zerocost:navigation"));
2083
+ onNavigation();
2084
+ return result;
2085
+ });
2086
+ return () => {
2087
+ if (!historyRef.__zerocostPatched) return;
2088
+ historyRef.pushState = historyRef.__zerocostPushState || historyRef.pushState;
2089
+ historyRef.replaceState = historyRef.__zerocostReplaceState || historyRef.replaceState;
2090
+ delete historyRef.__zerocostPatched;
2091
+ delete historyRef.__zerocostPushState;
2092
+ delete historyRef.__zerocostReplaceState;
2093
+ };
2094
+ }
2095
+ destroy() {
2096
+ this.cleanupConfigListeners.forEach((cleanup) => cleanup());
2097
+ this.cleanupConfigListeners = [];
759
2098
  this.widget.unmountAll();
760
2099
  this.data.stop();
761
2100
  this.recording.stop();
762
2101
  }
763
- /**
764
- * Validate the configured API key against the server.
765
- */
766
2102
  async validateKey() {
767
2103
  try {
768
2104
  const result = await this.core.request("/validate-key");
@@ -773,6 +2109,7 @@ var ZerocostSDK = class {
773
2109
  }
774
2110
  };
775
2111
  export {
2112
+ ConsentManager,
776
2113
  LLMDataModule,
777
2114
  RecordingModule,
778
2115
  ZerocostClient,