@loamly/tracker 2.1.0 → 2.4.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.
@@ -25,633 +25,584 @@ var Loamly = (() => {
25
25
  loamly: () => loamly
26
26
  });
27
27
 
28
- // src/config.ts
29
- var VERSION = "2.1.0";
28
+ // src/behavioral/form-tracker.ts
30
29
  var DEFAULT_CONFIG = {
31
- apiHost: "https://app.loamly.ai",
32
- endpoints: {
33
- visit: "/api/ingest/visit",
34
- behavioral: "/api/ingest/behavioral",
35
- session: "/api/ingest/session",
36
- resolve: "/api/tracker/resolve",
37
- health: "/api/tracker/health",
38
- ping: "/api/tracker/ping"
39
- },
40
- pingInterval: 3e4,
41
- // 30 seconds
42
- batchSize: 10,
43
- batchTimeout: 5e3,
44
- sessionTimeout: 18e5,
45
- // 30 minutes
46
- maxTextLength: 100,
47
- timeSpentThresholdMs: 5e3
48
- // Only send time_spent when delta >= 5 seconds
49
- };
50
- var AI_PLATFORMS = {
51
- "chatgpt.com": "chatgpt",
52
- "chat.openai.com": "chatgpt",
53
- "claude.ai": "claude",
54
- "perplexity.ai": "perplexity",
55
- "bard.google.com": "bard",
56
- "gemini.google.com": "gemini",
57
- "copilot.microsoft.com": "copilot",
58
- "github.com/copilot": "github-copilot",
59
- "you.com": "you",
60
- "phind.com": "phind",
61
- "poe.com": "poe"
30
+ sensitiveFields: [
31
+ "password",
32
+ "pwd",
33
+ "pass",
34
+ "credit",
35
+ "card",
36
+ "cvv",
37
+ "cvc",
38
+ "ssn",
39
+ "social",
40
+ "secret",
41
+ "token",
42
+ "key",
43
+ "pin",
44
+ "security",
45
+ "answer"
46
+ ],
47
+ trackableFields: [
48
+ "email",
49
+ "name",
50
+ "phone",
51
+ "company",
52
+ "first",
53
+ "last",
54
+ "city",
55
+ "country",
56
+ "domain",
57
+ "website",
58
+ "url",
59
+ "organization"
60
+ ],
61
+ thankYouPatterns: [
62
+ /thank[-_]?you/i,
63
+ /success/i,
64
+ /confirmation/i,
65
+ /submitted/i,
66
+ /complete/i
67
+ ],
68
+ // LOA-482: Enable field value capture by default
69
+ captureFieldValues: true,
70
+ maxFieldValueLength: 200
62
71
  };
63
-
64
- // src/detection/navigation-timing.ts
65
- function detectNavigationType() {
66
- try {
67
- const entries = performance.getEntriesByType("navigation");
68
- if (!entries || entries.length === 0) {
69
- return { nav_type: "unknown", confidence: 0, signals: ["no_timing_data"] };
70
- }
71
- const nav = entries[0];
72
- const signals = [];
73
- let pasteScore = 0;
74
- const fetchStartDelta = nav.fetchStart - nav.startTime;
75
- if (fetchStartDelta < 5) {
76
- pasteScore += 0.25;
77
- signals.push("instant_fetch_start");
78
- } else if (fetchStartDelta < 20) {
79
- pasteScore += 0.15;
80
- signals.push("fast_fetch_start");
81
- }
82
- const dnsTime = nav.domainLookupEnd - nav.domainLookupStart;
83
- if (dnsTime === 0) {
84
- pasteScore += 0.15;
85
- signals.push("no_dns_lookup");
86
- }
87
- const connectTime = nav.connectEnd - nav.connectStart;
88
- if (connectTime === 0) {
89
- pasteScore += 0.15;
90
- signals.push("no_tcp_connect");
91
- }
92
- if (nav.redirectCount === 0) {
93
- pasteScore += 0.1;
94
- signals.push("no_redirects");
95
- }
96
- const timingVariance = calculateTimingVariance(nav);
97
- if (timingVariance < 10) {
98
- pasteScore += 0.15;
99
- signals.push("uniform_timing");
100
- }
101
- if (!document.referrer || document.referrer === "") {
102
- pasteScore += 0.1;
103
- signals.push("no_referrer");
104
- }
105
- const confidence = Math.min(pasteScore, 1);
106
- const nav_type = pasteScore >= 0.5 ? "likely_paste" : "likely_click";
107
- return {
108
- nav_type,
109
- confidence: Math.round(confidence * 1e3) / 1e3,
110
- signals
111
- };
112
- } catch {
113
- return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
114
- }
115
- }
116
- function calculateTimingVariance(nav) {
117
- const timings = [
118
- nav.fetchStart - nav.startTime,
119
- nav.domainLookupEnd - nav.domainLookupStart,
120
- nav.connectEnd - nav.connectStart,
121
- nav.responseStart - nav.requestStart
122
- ].filter((t) => t >= 0);
123
- if (timings.length === 0) return 100;
124
- const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
125
- const variance = timings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / timings.length;
126
- return Math.sqrt(variance);
127
- }
128
-
129
- // src/detection/referrer.ts
130
- function detectAIFromReferrer(referrer) {
131
- if (!referrer) {
132
- return null;
133
- }
134
- try {
135
- const url = new URL(referrer);
136
- const hostname = url.hostname.toLowerCase();
137
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
138
- if (hostname.includes(pattern) || referrer.includes(pattern)) {
139
- return {
140
- isAI: true,
141
- platform,
142
- confidence: 0.95,
143
- // High confidence when referrer matches
144
- method: "referrer"
145
- };
72
+ var FormTracker = class {
73
+ constructor(config2 = {}) {
74
+ this.formStartTimes = /* @__PURE__ */ new Map();
75
+ this.interactedForms = /* @__PURE__ */ new Set();
76
+ this.mutationObserver = null;
77
+ this.handleFocusIn = (e) => {
78
+ const target = e.target;
79
+ if (!this.isFormField(target)) return;
80
+ const form = target.closest("form");
81
+ const formId = this.getFormId(form || target);
82
+ if (!this.formStartTimes.has(formId)) {
83
+ this.formStartTimes.set(formId, Date.now());
84
+ this.interactedForms.add(formId);
85
+ this.emitEvent({
86
+ event_type: "form_start",
87
+ form_id: formId,
88
+ form_type: this.detectFormType(form || target)
89
+ });
146
90
  }
147
- }
148
- return null;
149
- } catch {
150
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
151
- if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
152
- return {
153
- isAI: true,
154
- platform,
155
- confidence: 0.85,
156
- method: "referrer"
157
- };
91
+ const fieldName = this.getFieldName(target);
92
+ if (fieldName && !this.isSensitiveField(fieldName)) {
93
+ this.emitEvent({
94
+ event_type: "form_field",
95
+ form_id: formId,
96
+ form_type: this.detectFormType(form || target),
97
+ field_name: this.sanitizeFieldName(fieldName),
98
+ field_type: target.type || target.tagName.toLowerCase()
99
+ });
158
100
  }
159
- }
160
- return null;
161
- }
162
- }
163
- function detectAIFromUTM(url) {
164
- try {
165
- const params = new URL(url).searchParams;
166
- const utmSource = params.get("utm_source")?.toLowerCase();
167
- if (utmSource) {
168
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
169
- if (utmSource.includes(pattern.split(".")[0])) {
170
- return {
171
- isAI: true,
172
- platform,
173
- confidence: 0.99,
174
- // Very high confidence from explicit UTM
175
- method: "referrer"
176
- };
101
+ };
102
+ this.handleSubmit = (e) => {
103
+ const form = e.target;
104
+ if (!form || form.tagName !== "FORM") return;
105
+ const formId = this.getFormId(form);
106
+ const startTime = this.formStartTimes.get(formId);
107
+ const { fields, emailSubmitted } = this.captureFormFields(form);
108
+ this.emitEvent({
109
+ event_type: "form_submit",
110
+ form_id: formId,
111
+ form_type: this.detectFormType(form),
112
+ time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
113
+ is_conversion: true,
114
+ submit_source: "submit",
115
+ fields: fields.length > 0 ? fields : void 0,
116
+ email_submitted: emailSubmitted
117
+ });
118
+ };
119
+ this.handleClick = (e) => {
120
+ const target = e.target;
121
+ if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
122
+ const form = target.closest("form");
123
+ if (form && form.classList.contains("hs-form")) {
124
+ const formId = this.getFormId(form);
125
+ const startTime = this.formStartTimes.get(formId);
126
+ const { fields, emailSubmitted } = this.captureFormFields(form);
127
+ this.emitEvent({
128
+ event_type: "form_submit",
129
+ form_id: formId,
130
+ form_type: "hubspot",
131
+ time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
132
+ is_conversion: true,
133
+ submit_source: "click",
134
+ fields: fields.length > 0 ? fields : void 0,
135
+ email_submitted: emailSubmitted
136
+ });
177
137
  }
178
138
  }
179
- if (utmSource.includes("ai") || utmSource.includes("llm") || utmSource.includes("chatbot")) {
180
- return {
181
- isAI: true,
182
- platform: utmSource,
183
- confidence: 0.9,
184
- method: "referrer"
185
- };
139
+ if (target.closest('[data-qa="submit-button"]')) {
140
+ this.emitEvent({
141
+ event_type: "form_submit",
142
+ form_id: "typeform_embed",
143
+ form_type: "typeform",
144
+ is_conversion: true,
145
+ submit_source: "click"
146
+ });
186
147
  }
187
- }
188
- return null;
189
- } catch {
190
- return null;
191
- }
192
- }
193
-
194
- // src/detection/behavioral-classifier.ts
195
- var NAIVE_BAYES_WEIGHTS = {
196
- human: {
197
- time_to_first_click_delayed: 0.85,
198
- time_to_first_click_normal: 0.75,
199
- time_to_first_click_fast: 0.5,
200
- time_to_first_click_immediate: 0.25,
201
- scroll_speed_variable: 0.8,
202
- scroll_speed_erratic: 0.7,
203
- scroll_speed_uniform: 0.35,
204
- scroll_speed_none: 0.45,
205
- nav_timing_click: 0.75,
206
- nav_timing_unknown: 0.55,
207
- nav_timing_paste: 0.35,
208
- has_referrer: 0.7,
209
- no_referrer: 0.45,
210
- homepage_landing: 0.65,
211
- deep_landing: 0.5,
212
- mouse_movement_curved: 0.9,
213
- mouse_movement_linear: 0.3,
214
- mouse_movement_none: 0.4,
215
- form_fill_normal: 0.85,
216
- form_fill_fast: 0.6,
217
- form_fill_instant: 0.2,
218
- focus_blur_normal: 0.75,
219
- focus_blur_rapid: 0.45
220
- },
221
- ai_influenced: {
222
- time_to_first_click_immediate: 0.75,
223
- time_to_first_click_fast: 0.55,
224
- time_to_first_click_normal: 0.4,
225
- time_to_first_click_delayed: 0.35,
226
- scroll_speed_none: 0.55,
227
- scroll_speed_uniform: 0.7,
228
- scroll_speed_variable: 0.35,
229
- scroll_speed_erratic: 0.4,
230
- nav_timing_paste: 0.75,
231
- nav_timing_unknown: 0.5,
232
- nav_timing_click: 0.35,
233
- no_referrer: 0.65,
234
- has_referrer: 0.4,
235
- deep_landing: 0.6,
236
- homepage_landing: 0.45,
237
- mouse_movement_none: 0.6,
238
- mouse_movement_linear: 0.75,
239
- mouse_movement_curved: 0.25,
240
- form_fill_instant: 0.8,
241
- form_fill_fast: 0.55,
242
- form_fill_normal: 0.3,
243
- focus_blur_rapid: 0.6,
244
- focus_blur_normal: 0.4
245
- }
246
- };
247
- var PRIORS = {
248
- human: 0.85,
249
- ai_influenced: 0.15
250
- };
251
- var DEFAULT_WEIGHT = 0.5;
252
- var BehavioralClassifier = class {
253
- /**
254
- * Create a new classifier
255
- * @param minSessionTimeMs Minimum session time before classification (default: 10s)
256
- */
257
- constructor(minSessionTimeMs = 1e4) {
258
- this.classified = false;
259
- this.result = null;
260
- this.onClassify = null;
261
- this.minSessionTime = minSessionTimeMs;
262
- this.data = {
263
- firstClickTime: null,
264
- scrollEvents: [],
265
- mouseEvents: [],
266
- formEvents: [],
267
- focusBlurEvents: [],
268
- startTime: Date.now()
148
+ };
149
+ this.config = {
150
+ ...DEFAULT_CONFIG,
151
+ ...config2,
152
+ sensitiveFields: [
153
+ ...DEFAULT_CONFIG.sensitiveFields,
154
+ ...config2.sensitiveFields || []
155
+ ]
269
156
  };
270
157
  }
271
158
  /**
272
- * Set callback for when classification completes
273
- */
274
- setOnClassify(callback) {
275
- this.onClassify = callback;
276
- }
277
- /**
278
- * Record a click event
279
- */
280
- recordClick() {
281
- if (this.data.firstClickTime === null) {
282
- this.data.firstClickTime = Date.now();
283
- }
284
- this.checkAndClassify();
285
- }
286
- /**
287
- * Record a scroll event
159
+ * Start tracking forms
288
160
  */
289
- recordScroll(position) {
290
- this.data.scrollEvents.push({ time: Date.now(), position });
291
- if (this.data.scrollEvents.length > 50) {
292
- this.data.scrollEvents = this.data.scrollEvents.slice(-50);
293
- }
294
- this.checkAndClassify();
161
+ start() {
162
+ document.addEventListener("focusin", this.handleFocusIn, { passive: true });
163
+ document.addEventListener("submit", this.handleSubmit);
164
+ document.addEventListener("click", this.handleClick, { passive: true });
165
+ this.startMutationObserver();
166
+ this.checkThankYouPage();
167
+ this.scanForEmbeddedForms();
295
168
  }
296
169
  /**
297
- * Record mouse movement
170
+ * Stop tracking
298
171
  */
299
- recordMouse(x, y) {
300
- this.data.mouseEvents.push({ time: Date.now(), x, y });
301
- if (this.data.mouseEvents.length > 100) {
302
- this.data.mouseEvents = this.data.mouseEvents.slice(-100);
303
- }
304
- this.checkAndClassify();
172
+ stop() {
173
+ document.removeEventListener("focusin", this.handleFocusIn);
174
+ document.removeEventListener("submit", this.handleSubmit);
175
+ document.removeEventListener("click", this.handleClick);
176
+ this.mutationObserver?.disconnect();
305
177
  }
306
178
  /**
307
- * Record form field interaction start
179
+ * Get forms that had interaction
308
180
  */
309
- recordFormStart(fieldId) {
310
- const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
311
- if (!existing) {
312
- this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
313
- }
181
+ getInteractedForms() {
182
+ return Array.from(this.interactedForms);
314
183
  }
315
184
  /**
316
- * Record form field interaction end
185
+ * LOA-482: Capture form field values with privacy-safe sanitization
186
+ * - Never captures sensitive fields (password, credit card, etc.)
187
+ * - Truncates values to maxFieldValueLength
188
+ * - Extracts email if found
317
189
  */
318
- recordFormEnd(fieldId) {
319
- const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
320
- if (event) {
321
- event.endTime = Date.now();
190
+ captureFormFields(form) {
191
+ const fields = [];
192
+ let emailSubmitted;
193
+ if (!this.config.captureFieldValues) {
194
+ return { fields, emailSubmitted };
322
195
  }
323
- this.checkAndClassify();
324
- }
325
- /**
326
- * Record focus/blur event
327
- */
328
- recordFocusBlur(type) {
329
- this.data.focusBlurEvents.push({ type, time: Date.now() });
330
- if (this.data.focusBlurEvents.length > 20) {
331
- this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
196
+ try {
197
+ const formData = new FormData(form);
198
+ for (const [name, value] of formData.entries()) {
199
+ if (this.isSensitiveField(name)) {
200
+ continue;
201
+ }
202
+ const input = form.elements.namedItem(name);
203
+ const inputType = input?.type || "text";
204
+ if (inputType === "file" || value instanceof File) {
205
+ continue;
206
+ }
207
+ const stringValue = String(value);
208
+ if (this.isEmailField(name, stringValue)) {
209
+ emailSubmitted = stringValue.substring(0, 254);
210
+ }
211
+ const truncatedValue = stringValue.length > this.config.maxFieldValueLength ? stringValue.substring(0, this.config.maxFieldValueLength) + "..." : stringValue;
212
+ fields.push({
213
+ name: this.sanitizeFieldName(name),
214
+ type: inputType,
215
+ value: truncatedValue
216
+ });
217
+ }
218
+ } catch (err) {
219
+ console.warn("[Loamly] Failed to capture form fields:", err);
332
220
  }
221
+ return { fields, emailSubmitted };
333
222
  }
334
223
  /**
335
- * Check if we have enough data and classify
224
+ * Check if a field contains an email
336
225
  */
337
- checkAndClassify() {
338
- if (this.classified) return;
339
- const sessionDuration = Date.now() - this.data.startTime;
340
- if (sessionDuration < this.minSessionTime) return;
341
- const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
342
- if (!hasData) return;
343
- this.classify();
226
+ isEmailField(fieldName, value) {
227
+ const lowerName = fieldName.toLowerCase();
228
+ const isEmailName = lowerName.includes("email") || lowerName === "e-mail";
229
+ const isEmailValue = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
230
+ return isEmailName && isEmailValue;
344
231
  }
345
- /**
346
- * Force classification (for beforeunload)
347
- */
348
- forceClassify() {
349
- if (this.classified) return this.result;
350
- return this.classify();
232
+ startMutationObserver() {
233
+ this.mutationObserver = new MutationObserver((mutations) => {
234
+ for (const mutation of mutations) {
235
+ for (const node of mutation.addedNodes) {
236
+ if (node instanceof HTMLElement) {
237
+ if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
238
+ this.trackEmbeddedForm(node, "hubspot");
239
+ }
240
+ if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
241
+ this.trackEmbeddedForm(node, "typeform");
242
+ }
243
+ if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
244
+ this.trackEmbeddedForm(node, "jotform");
245
+ }
246
+ if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
247
+ this.trackEmbeddedForm(node, "gravity");
248
+ }
249
+ }
250
+ }
251
+ }
252
+ });
253
+ this.mutationObserver.observe(document.body, {
254
+ childList: true,
255
+ subtree: true
256
+ });
351
257
  }
352
- /**
353
- * Perform classification
354
- */
355
- classify() {
356
- const sessionDuration = Date.now() - this.data.startTime;
357
- const signals = this.extractSignals();
358
- let humanLogProb = Math.log(PRIORS.human);
359
- let aiLogProb = Math.log(PRIORS.ai_influenced);
360
- for (const signal of signals) {
361
- const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
362
- const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
363
- humanLogProb += Math.log(humanWeight);
364
- aiLogProb += Math.log(aiWeight);
365
- }
366
- const maxLog = Math.max(humanLogProb, aiLogProb);
367
- const humanExp = Math.exp(humanLogProb - maxLog);
368
- const aiExp = Math.exp(aiLogProb - maxLog);
369
- const total = humanExp + aiExp;
370
- const humanProbability = humanExp / total;
371
- const aiProbability = aiExp / total;
372
- let classification;
373
- let confidence;
374
- if (humanProbability > 0.6) {
375
- classification = "human";
376
- confidence = humanProbability;
377
- } else if (aiProbability > 0.6) {
378
- classification = "ai_influenced";
379
- confidence = aiProbability;
380
- } else {
381
- classification = "uncertain";
382
- confidence = Math.max(humanProbability, aiProbability);
383
- }
384
- this.result = {
385
- classification,
386
- humanProbability,
387
- aiProbability,
388
- confidence,
389
- signals,
390
- timestamp: Date.now(),
391
- sessionDurationMs: sessionDuration
392
- };
393
- this.classified = true;
394
- if (this.onClassify) {
395
- this.onClassify(this.result);
396
- }
397
- return this.result;
258
+ scanForEmbeddedForms() {
259
+ document.querySelectorAll(".hs-form").forEach((form) => {
260
+ this.trackEmbeddedForm(form, "hubspot");
261
+ });
262
+ document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
263
+ this.trackEmbeddedForm(form, "typeform");
264
+ });
265
+ document.querySelectorAll(".jotform-form").forEach((form) => {
266
+ this.trackEmbeddedForm(form, "jotform");
267
+ });
268
+ document.querySelectorAll(".gform_wrapper").forEach((form) => {
269
+ this.trackEmbeddedForm(form, "gravity");
270
+ });
398
271
  }
399
- /**
400
- * Extract behavioral signals from collected data
401
- */
402
- extractSignals() {
403
- const signals = [];
404
- if (this.data.firstClickTime !== null) {
405
- const timeToClick = this.data.firstClickTime - this.data.startTime;
406
- if (timeToClick < 500) {
407
- signals.push("time_to_first_click_immediate");
408
- } else if (timeToClick < 2e3) {
409
- signals.push("time_to_first_click_fast");
410
- } else if (timeToClick < 1e4) {
411
- signals.push("time_to_first_click_normal");
412
- } else {
413
- signals.push("time_to_first_click_delayed");
414
- }
415
- }
416
- if (this.data.scrollEvents.length === 0) {
417
- signals.push("scroll_speed_none");
418
- } else if (this.data.scrollEvents.length >= 3) {
419
- const scrollDeltas = [];
420
- for (let i = 1; i < this.data.scrollEvents.length; i++) {
421
- const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
422
- scrollDeltas.push(delta);
423
- }
424
- const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
425
- const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
426
- const stdDev = Math.sqrt(variance);
427
- const cv = mean > 0 ? stdDev / mean : 0;
428
- if (cv < 0.2) {
429
- signals.push("scroll_speed_uniform");
430
- } else if (cv < 0.6) {
431
- signals.push("scroll_speed_variable");
432
- } else {
433
- signals.push("scroll_speed_erratic");
434
- }
435
- }
436
- if (this.data.mouseEvents.length === 0) {
437
- signals.push("mouse_movement_none");
438
- } else if (this.data.mouseEvents.length >= 10) {
439
- const n = Math.min(this.data.mouseEvents.length, 20);
440
- const recentMouse = this.data.mouseEvents.slice(-n);
441
- let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
442
- for (const event of recentMouse) {
443
- sumX += event.x;
444
- sumY += event.y;
445
- sumXY += event.x * event.y;
446
- sumX2 += event.x * event.x;
447
- }
448
- const denominator = n * sumX2 - sumX * sumX;
449
- const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
450
- const intercept = (sumY - slope * sumX) / n;
451
- let ssRes = 0, ssTot = 0;
452
- const yMean = sumY / n;
453
- for (const event of recentMouse) {
454
- const yPred = slope * event.x + intercept;
455
- ssRes += Math.pow(event.y - yPred, 2);
456
- ssTot += Math.pow(event.y - yMean, 2);
457
- }
458
- const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
459
- if (r2 > 0.95) {
460
- signals.push("mouse_movement_linear");
461
- } else {
462
- signals.push("mouse_movement_curved");
272
+ trackEmbeddedForm(element, type) {
273
+ const formId = `${type}_${this.getFormId(element)}`;
274
+ element.addEventListener("focusin", () => {
275
+ if (!this.formStartTimes.has(formId)) {
276
+ this.formStartTimes.set(formId, Date.now());
277
+ this.interactedForms.add(formId);
278
+ this.emitEvent({
279
+ event_type: "form_start",
280
+ form_id: formId,
281
+ form_type: type
282
+ });
463
283
  }
464
- }
465
- const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
466
- if (completedForms.length > 0) {
467
- const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
468
- if (avgFillTime < 100) {
469
- signals.push("form_fill_instant");
470
- } else if (avgFillTime < 500) {
471
- signals.push("form_fill_fast");
472
- } else {
473
- signals.push("form_fill_normal");
284
+ }, { passive: true });
285
+ }
286
+ checkThankYouPage() {
287
+ const url = window.location.href.toLowerCase();
288
+ const title = document.title.toLowerCase();
289
+ for (const pattern of this.config.thankYouPatterns) {
290
+ if (pattern.test(url) || pattern.test(title)) {
291
+ this.emitEvent({
292
+ event_type: "form_success",
293
+ form_id: "page_conversion",
294
+ form_type: "unknown",
295
+ is_conversion: true,
296
+ submit_source: "thank_you"
297
+ });
298
+ break;
474
299
  }
475
300
  }
476
- if (this.data.focusBlurEvents.length >= 4) {
477
- const recentFB = this.data.focusBlurEvents.slice(-10);
478
- const intervals = [];
479
- for (let i = 1; i < recentFB.length; i++) {
480
- intervals.push(recentFB[i].time - recentFB[i - 1].time);
481
- }
482
- const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
483
- if (avgInterval < 1e3) {
484
- signals.push("focus_blur_rapid");
485
- } else {
486
- signals.push("focus_blur_normal");
487
- }
301
+ }
302
+ isFormField(element) {
303
+ const tagName = element.tagName;
304
+ return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
305
+ }
306
+ getFormId(element) {
307
+ if (!element) return "unknown";
308
+ return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
309
+ }
310
+ getFieldName(input) {
311
+ return input.name || input.id || input.getAttribute("data-name") || "";
312
+ }
313
+ isSensitiveField(fieldName) {
314
+ const lowerName = fieldName.toLowerCase();
315
+ return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
316
+ }
317
+ sanitizeFieldName(fieldName) {
318
+ return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
319
+ }
320
+ detectFormType(element) {
321
+ if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
322
+ return "hubspot";
488
323
  }
489
- return signals;
324
+ if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
325
+ return "typeform";
326
+ }
327
+ if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
328
+ return "jotform";
329
+ }
330
+ if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
331
+ return "gravity";
332
+ }
333
+ if (element.tagName === "FORM") {
334
+ return "native";
335
+ }
336
+ return "unknown";
337
+ }
338
+ emitEvent(event) {
339
+ this.config.onFormEvent?.(event);
340
+ }
341
+ };
342
+
343
+ // src/behavioral/scroll-tracker.ts
344
+ var DEFAULT_CHUNKS = [30, 60, 90, 100];
345
+ var ScrollTracker = class {
346
+ constructor(config2 = {}) {
347
+ this.maxDepth = 0;
348
+ this.reportedChunks = /* @__PURE__ */ new Set();
349
+ this.ticking = false;
350
+ this.isVisible = true;
351
+ this.handleScroll = () => {
352
+ if (!this.ticking && this.isVisible) {
353
+ requestAnimationFrame(() => {
354
+ this.checkScrollDepth();
355
+ this.ticking = false;
356
+ });
357
+ this.ticking = true;
358
+ }
359
+ };
360
+ this.handleVisibility = () => {
361
+ this.isVisible = document.visibilityState === "visible";
362
+ };
363
+ this.config = {
364
+ chunks: DEFAULT_CHUNKS,
365
+ ...config2
366
+ };
367
+ this.startTime = Date.now();
490
368
  }
491
369
  /**
492
- * Add context signals (set by tracker from external data)
370
+ * Start tracking scroll depth
493
371
  */
494
- addContextSignal(_signal) {
372
+ start() {
373
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
374
+ document.addEventListener("visibilitychange", this.handleVisibility);
375
+ this.checkScrollDepth();
495
376
  }
496
377
  /**
497
- * Get current result (null if not yet classified)
378
+ * Stop tracking
498
379
  */
499
- getResult() {
500
- return this.result;
380
+ stop() {
381
+ window.removeEventListener("scroll", this.handleScroll);
382
+ document.removeEventListener("visibilitychange", this.handleVisibility);
501
383
  }
502
384
  /**
503
- * Check if classification has been performed
385
+ * Get current max scroll depth
504
386
  */
505
- hasClassified() {
506
- return this.classified;
507
- }
508
- };
509
-
510
- // src/detection/focus-blur.ts
511
- var FocusBlurAnalyzer = class {
512
- constructor() {
513
- this.sequence = [];
514
- this.firstInteractionTime = null;
515
- this.analyzed = false;
516
- this.result = null;
517
- this.pageLoadTime = performance.now();
387
+ getMaxDepth() {
388
+ return this.maxDepth;
518
389
  }
519
390
  /**
520
- * Initialize event tracking
521
- * Must be called after DOM is ready
391
+ * Get reported chunks
522
392
  */
523
- initTracking() {
524
- document.addEventListener("focus", (e) => {
525
- this.recordEvent("focus", e.target);
526
- }, true);
527
- document.addEventListener("blur", (e) => {
528
- this.recordEvent("blur", e.target);
529
- }, true);
530
- window.addEventListener("focus", () => {
531
- this.recordEvent("window_focus", null);
532
- });
533
- window.addEventListener("blur", () => {
534
- this.recordEvent("window_blur", null);
535
- });
536
- const recordFirstInteraction = () => {
537
- if (this.firstInteractionTime === null) {
538
- this.firstInteractionTime = performance.now();
539
- }
540
- };
541
- document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
542
- document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
393
+ getReportedChunks() {
394
+ return Array.from(this.reportedChunks).sort((a, b) => a - b);
543
395
  }
544
396
  /**
545
- * Record a focus/blur event
397
+ * Get final scroll event (for unload)
546
398
  */
547
- recordEvent(type, target) {
548
- const event = {
549
- type,
550
- target: target?.tagName || "WINDOW",
551
- timestamp: performance.now()
399
+ getFinalEvent() {
400
+ const docHeight = document.documentElement.scrollHeight;
401
+ const viewportHeight = window.innerHeight;
402
+ return {
403
+ depth: this.maxDepth,
404
+ chunk: this.getChunkForDepth(this.maxDepth),
405
+ time_to_reach_ms: Date.now() - this.startTime,
406
+ total_height: docHeight,
407
+ viewport_height: viewportHeight
552
408
  };
553
- this.sequence.push(event);
554
- if (this.sequence.length > 20) {
555
- this.sequence = this.sequence.slice(-20);
556
- }
557
409
  }
558
- /**
559
- * Analyze the focus/blur sequence for paste patterns
560
- */
561
- analyze() {
562
- if (this.analyzed && this.result) {
563
- return this.result;
564
- }
565
- const signals = [];
566
- let confidence = 0;
567
- const earlyEvents = this.sequence.filter((e) => e.timestamp < this.pageLoadTime + 500);
568
- const hasEarlyWindowFocus = earlyEvents.some((e) => e.type === "window_focus");
569
- if (hasEarlyWindowFocus) {
570
- signals.push("early_window_focus");
571
- confidence += 0.15;
572
- }
573
- const hasEarlyBodyFocus = earlyEvents.some(
574
- (e) => e.type === "focus" && e.target === "BODY"
575
- );
576
- if (hasEarlyBodyFocus) {
577
- signals.push("early_body_focus");
578
- confidence += 0.15;
579
- }
580
- const hasLinkFocus = this.sequence.some(
581
- (e) => e.type === "focus" && e.target === "A"
582
- );
583
- if (!hasLinkFocus) {
584
- signals.push("no_link_focus");
585
- confidence += 0.1;
410
+ checkScrollDepth() {
411
+ const scrollY = window.scrollY;
412
+ const viewportHeight = window.innerHeight;
413
+ const docHeight = document.documentElement.scrollHeight;
414
+ if (docHeight <= viewportHeight) {
415
+ this.updateDepth(100);
416
+ return;
586
417
  }
587
- const firstFocus = this.sequence.find((e) => e.type === "focus");
588
- if (firstFocus && (firstFocus.target === "BODY" || firstFocus.target === "HTML")) {
589
- signals.push("first_focus_body");
590
- confidence += 0.1;
418
+ const scrollableHeight = docHeight - viewportHeight;
419
+ const currentDepth = Math.min(100, Math.round(scrollY / scrollableHeight * 100));
420
+ this.updateDepth(currentDepth);
421
+ }
422
+ updateDepth(depth) {
423
+ if (depth <= this.maxDepth) return;
424
+ this.maxDepth = depth;
425
+ this.config.onDepthChange?.(depth);
426
+ for (const chunk of this.config.chunks) {
427
+ if (depth >= chunk && !this.reportedChunks.has(chunk)) {
428
+ this.reportedChunks.add(chunk);
429
+ this.reportChunk(chunk);
430
+ }
591
431
  }
592
- const windowEvents = this.sequence.filter(
593
- (e) => e.type === "window_focus" || e.type === "window_blur"
594
- );
595
- if (windowEvents.length <= 2) {
596
- signals.push("minimal_window_switches");
597
- confidence += 0.05;
432
+ }
433
+ reportChunk(chunk) {
434
+ const docHeight = document.documentElement.scrollHeight;
435
+ const viewportHeight = window.innerHeight;
436
+ const event = {
437
+ depth: this.maxDepth,
438
+ chunk,
439
+ time_to_reach_ms: Date.now() - this.startTime,
440
+ total_height: docHeight,
441
+ viewport_height: viewportHeight
442
+ };
443
+ this.config.onChunkReached?.(event);
444
+ }
445
+ getChunkForDepth(depth) {
446
+ const chunks = this.config.chunks.sort((a, b) => b - a);
447
+ for (const chunk of chunks) {
448
+ if (depth >= chunk) return chunk;
598
449
  }
599
- if (this.firstInteractionTime !== null) {
600
- const timeToInteraction = this.firstInteractionTime - this.pageLoadTime;
601
- if (timeToInteraction > 3e3) {
602
- signals.push("delayed_first_interaction");
603
- confidence += 0.1;
450
+ return 0;
451
+ }
452
+ };
453
+
454
+ // src/behavioral/time-tracker.ts
455
+ var DEFAULT_CONFIG2 = {
456
+ idleThresholdMs: 3e4,
457
+ // 30 seconds
458
+ updateIntervalMs: 5e3
459
+ // 5 seconds
460
+ };
461
+ var TimeTracker = class {
462
+ constructor(config2 = {}) {
463
+ this.activeTime = 0;
464
+ this.idleTime = 0;
465
+ this.isVisible = true;
466
+ this.isIdle = false;
467
+ this.updateInterval = null;
468
+ this.idleCheckInterval = null;
469
+ this.handleVisibility = () => {
470
+ const wasVisible = this.isVisible;
471
+ this.isVisible = document.visibilityState === "visible";
472
+ if (wasVisible && !this.isVisible) {
473
+ this.updateTimes();
474
+ } else if (!wasVisible && this.isVisible) {
475
+ this.lastUpdateTime = Date.now();
476
+ this.lastActivityTime = Date.now();
604
477
  }
605
- }
606
- confidence = Math.min(confidence, 0.65);
607
- let navType;
608
- if (confidence >= 0.35) {
609
- navType = "likely_paste";
610
- } else if (signals.length === 0) {
611
- navType = "unknown";
612
- } else {
613
- navType = "likely_click";
614
- }
615
- this.result = {
616
- nav_type: navType,
617
- confidence,
618
- signals,
619
- sequence: this.sequence.slice(-10),
620
- time_to_first_interaction_ms: this.firstInteractionTime ? Math.round(this.firstInteractionTime - this.pageLoadTime) : null
621
478
  };
622
- this.analyzed = true;
623
- return this.result;
479
+ this.handleActivity = () => {
480
+ const now = Date.now();
481
+ if (this.isIdle) {
482
+ this.isIdle = false;
483
+ }
484
+ this.lastActivityTime = now;
485
+ };
486
+ this.config = { ...DEFAULT_CONFIG2, ...config2 };
487
+ this.startTime = Date.now();
488
+ this.lastActivityTime = this.startTime;
489
+ this.lastUpdateTime = this.startTime;
624
490
  }
625
491
  /**
626
- * Get current result (analyze if not done)
492
+ * Start tracking time
627
493
  */
628
- getResult() {
629
- return this.analyze();
494
+ start() {
495
+ document.addEventListener("visibilitychange", this.handleVisibility);
496
+ const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
497
+ activityEvents.forEach((event) => {
498
+ document.addEventListener(event, this.handleActivity, { passive: true });
499
+ });
500
+ this.updateInterval = setInterval(() => {
501
+ this.update();
502
+ }, this.config.updateIntervalMs);
503
+ this.idleCheckInterval = setInterval(() => {
504
+ this.checkIdle();
505
+ }, 1e3);
630
506
  }
631
507
  /**
632
- * Check if analysis has been performed
508
+ * Stop tracking
633
509
  */
634
- hasAnalyzed() {
635
- return this.analyzed;
510
+ stop() {
511
+ document.removeEventListener("visibilitychange", this.handleVisibility);
512
+ const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
513
+ activityEvents.forEach((event) => {
514
+ document.removeEventListener(event, this.handleActivity);
515
+ });
516
+ if (this.updateInterval) {
517
+ clearInterval(this.updateInterval);
518
+ this.updateInterval = null;
519
+ }
520
+ if (this.idleCheckInterval) {
521
+ clearInterval(this.idleCheckInterval);
522
+ this.idleCheckInterval = null;
523
+ }
636
524
  }
637
525
  /**
638
- * Get the raw sequence for debugging
526
+ * Get current time metrics
639
527
  */
640
- getSequence() {
641
- return [...this.sequence];
528
+ getMetrics() {
529
+ this.updateTimes();
530
+ return {
531
+ active_time_ms: this.activeTime,
532
+ total_time_ms: Date.now() - this.startTime,
533
+ idle_time_ms: this.idleTime,
534
+ is_engaged: !this.isIdle && this.isVisible
535
+ };
642
536
  }
643
537
  /**
644
- * Reset the analyzer
538
+ * Get final metrics (for unload)
645
539
  */
646
- reset() {
647
- this.sequence = [];
648
- this.pageLoadTime = performance.now();
649
- this.firstInteractionTime = null;
650
- this.analyzed = false;
651
- this.result = null;
540
+ getFinalMetrics() {
541
+ this.updateTimes();
542
+ return this.getMetrics();
543
+ }
544
+ checkIdle() {
545
+ const now = Date.now();
546
+ const timeSinceActivity = now - this.lastActivityTime;
547
+ if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
548
+ this.isIdle = true;
549
+ }
550
+ }
551
+ updateTimes() {
552
+ const now = Date.now();
553
+ const elapsed = now - this.lastUpdateTime;
554
+ if (this.isVisible) {
555
+ if (this.isIdle) {
556
+ this.idleTime += elapsed;
557
+ } else {
558
+ this.activeTime += elapsed;
559
+ }
560
+ }
561
+ this.lastUpdateTime = now;
562
+ }
563
+ update() {
564
+ if (!this.isVisible) return;
565
+ this.updateTimes();
566
+ this.config.onUpdate?.(this.getMetrics());
652
567
  }
653
568
  };
654
569
 
570
+ // src/config.ts
571
+ var VERSION = "2.4.0";
572
+ var DEFAULT_CONFIG3 = {
573
+ apiHost: "https://app.loamly.ai",
574
+ endpoints: {
575
+ visit: "/api/ingest/visit",
576
+ behavioral: "/api/ingest/behavioral",
577
+ session: "/api/ingest/session",
578
+ resolve: "/api/tracker/resolve",
579
+ health: "/api/tracker/health",
580
+ ping: "/api/tracker/ping"
581
+ },
582
+ pingInterval: 3e4,
583
+ // 30 seconds
584
+ batchSize: 10,
585
+ batchTimeout: 5e3,
586
+ sessionTimeout: 18e5,
587
+ // 30 minutes
588
+ maxTextLength: 100,
589
+ timeSpentThresholdMs: 5e3
590
+ // Only send time_spent when delta >= 5 seconds
591
+ };
592
+ var AI_PLATFORMS = {
593
+ "chatgpt.com": "chatgpt",
594
+ "chat.openai.com": "chatgpt",
595
+ "claude.ai": "claude",
596
+ "perplexity.ai": "perplexity",
597
+ "bard.google.com": "bard",
598
+ "gemini.google.com": "gemini",
599
+ "copilot.microsoft.com": "copilot",
600
+ "github.com/copilot": "github-copilot",
601
+ "you.com": "you",
602
+ "phind.com": "phind",
603
+ "poe.com": "poe"
604
+ };
605
+
655
606
  // src/detection/agentic-browser.ts
656
607
  var CometDetector = class {
657
608
  constructor() {
@@ -825,743 +776,894 @@ var Loamly = (() => {
825
776
  signals.push(`teleporting_clicks:${mousePatterns.teleportingClicks}`);
826
777
  probability = Math.max(probability, 0.78);
827
778
  }
828
- return {
829
- cometDOMDetected: this.cometDetector.isDetected(),
830
- cdpDetected: this.cdpDetector.isDetected(),
831
- mousePatterns,
832
- agenticProbability: probability,
833
- signals
834
- };
779
+ return {
780
+ cometDOMDetected: this.cometDetector.isDetected(),
781
+ cdpDetected: this.cdpDetector.isDetected(),
782
+ mousePatterns,
783
+ agenticProbability: probability,
784
+ signals
785
+ };
786
+ }
787
+ /**
788
+ * Cleanup resources
789
+ */
790
+ destroy() {
791
+ this.cometDetector.destroy();
792
+ this.mouseAnalyzer.destroy();
793
+ }
794
+ };
795
+
796
+ // src/detection/behavioral-classifier.ts
797
+ var NAIVE_BAYES_WEIGHTS = {
798
+ human: {
799
+ time_to_first_click_delayed: 0.85,
800
+ time_to_first_click_normal: 0.75,
801
+ time_to_first_click_fast: 0.5,
802
+ time_to_first_click_immediate: 0.25,
803
+ scroll_speed_variable: 0.8,
804
+ scroll_speed_erratic: 0.7,
805
+ scroll_speed_uniform: 0.35,
806
+ scroll_speed_none: 0.45,
807
+ nav_timing_click: 0.75,
808
+ nav_timing_unknown: 0.55,
809
+ nav_timing_paste: 0.35,
810
+ has_referrer: 0.7,
811
+ no_referrer: 0.45,
812
+ homepage_landing: 0.65,
813
+ deep_landing: 0.5,
814
+ mouse_movement_curved: 0.9,
815
+ mouse_movement_linear: 0.3,
816
+ mouse_movement_none: 0.4,
817
+ form_fill_normal: 0.85,
818
+ form_fill_fast: 0.6,
819
+ form_fill_instant: 0.2,
820
+ focus_blur_normal: 0.75,
821
+ focus_blur_rapid: 0.45
822
+ },
823
+ ai_influenced: {
824
+ time_to_first_click_immediate: 0.75,
825
+ time_to_first_click_fast: 0.55,
826
+ time_to_first_click_normal: 0.4,
827
+ time_to_first_click_delayed: 0.35,
828
+ scroll_speed_none: 0.55,
829
+ scroll_speed_uniform: 0.7,
830
+ scroll_speed_variable: 0.35,
831
+ scroll_speed_erratic: 0.4,
832
+ nav_timing_paste: 0.75,
833
+ nav_timing_unknown: 0.5,
834
+ nav_timing_click: 0.35,
835
+ no_referrer: 0.65,
836
+ has_referrer: 0.4,
837
+ deep_landing: 0.6,
838
+ homepage_landing: 0.45,
839
+ mouse_movement_none: 0.6,
840
+ mouse_movement_linear: 0.75,
841
+ mouse_movement_curved: 0.25,
842
+ form_fill_instant: 0.8,
843
+ form_fill_fast: 0.55,
844
+ form_fill_normal: 0.3,
845
+ focus_blur_rapid: 0.6,
846
+ focus_blur_normal: 0.4
847
+ }
848
+ };
849
+ var PRIORS = {
850
+ human: 0.85,
851
+ ai_influenced: 0.15
852
+ };
853
+ var DEFAULT_WEIGHT = 0.5;
854
+ var BehavioralClassifier = class {
855
+ /**
856
+ * Create a new classifier
857
+ * @param minSessionTimeMs Minimum session time before classification (default: 10s)
858
+ */
859
+ constructor(minSessionTimeMs = 1e4) {
860
+ this.classified = false;
861
+ this.result = null;
862
+ this.onClassify = null;
863
+ this.minSessionTime = minSessionTimeMs;
864
+ this.data = {
865
+ firstClickTime: null,
866
+ scrollEvents: [],
867
+ mouseEvents: [],
868
+ formEvents: [],
869
+ focusBlurEvents: [],
870
+ startTime: Date.now()
871
+ };
872
+ }
873
+ /**
874
+ * Set callback for when classification completes
875
+ */
876
+ setOnClassify(callback) {
877
+ this.onClassify = callback;
878
+ }
879
+ /**
880
+ * Record a click event
881
+ */
882
+ recordClick() {
883
+ if (this.data.firstClickTime === null) {
884
+ this.data.firstClickTime = Date.now();
885
+ }
886
+ this.checkAndClassify();
835
887
  }
836
888
  /**
837
- * Cleanup resources
889
+ * Record a scroll event
838
890
  */
839
- destroy() {
840
- this.cometDetector.destroy();
841
- this.mouseAnalyzer.destroy();
891
+ recordScroll(position) {
892
+ this.data.scrollEvents.push({ time: Date.now(), position });
893
+ if (this.data.scrollEvents.length > 50) {
894
+ this.data.scrollEvents = this.data.scrollEvents.slice(-50);
895
+ }
896
+ this.checkAndClassify();
842
897
  }
843
- };
844
-
845
- // src/infrastructure/event-queue.ts
846
- var DEFAULT_QUEUE_CONFIG = {
847
- batchSize: DEFAULT_CONFIG.batchSize,
848
- batchTimeout: DEFAULT_CONFIG.batchTimeout,
849
- maxRetries: 3,
850
- retryDelayMs: 1e3,
851
- storageKey: "_loamly_queue"
852
- };
853
- var EventQueue = class {
854
- constructor(endpoint2, config2 = {}) {
855
- this.queue = [];
856
- this.batchTimer = null;
857
- this.isFlushing = false;
858
- this.endpoint = endpoint2;
859
- this.config = { ...DEFAULT_QUEUE_CONFIG, ...config2 };
860
- this.loadFromStorage();
898
+ /**
899
+ * Record mouse movement
900
+ */
901
+ recordMouse(x, y) {
902
+ this.data.mouseEvents.push({ time: Date.now(), x, y });
903
+ if (this.data.mouseEvents.length > 100) {
904
+ this.data.mouseEvents = this.data.mouseEvents.slice(-100);
905
+ }
906
+ this.checkAndClassify();
861
907
  }
862
908
  /**
863
- * Add event to queue
909
+ * Record form field interaction start
864
910
  */
865
- push(type, payload) {
866
- const event = {
867
- id: this.generateId(),
868
- type,
869
- payload,
870
- timestamp: Date.now(),
871
- retries: 0
872
- };
873
- this.queue.push(event);
874
- this.saveToStorage();
875
- this.scheduleBatch();
911
+ recordFormStart(fieldId) {
912
+ const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
913
+ if (!existing) {
914
+ this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
915
+ }
876
916
  }
877
917
  /**
878
- * Force flush all events immediately
918
+ * Record form field interaction end
879
919
  */
880
- async flush() {
881
- if (this.isFlushing || this.queue.length === 0) return;
882
- this.isFlushing = true;
883
- this.clearBatchTimer();
884
- try {
885
- const events = [...this.queue];
886
- this.queue = [];
887
- await this.sendBatch(events);
888
- } finally {
889
- this.isFlushing = false;
890
- this.saveToStorage();
920
+ recordFormEnd(fieldId) {
921
+ const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
922
+ if (event) {
923
+ event.endTime = Date.now();
891
924
  }
925
+ this.checkAndClassify();
892
926
  }
893
927
  /**
894
- * Flush using sendBeacon (for unload events)
928
+ * Record focus/blur event
895
929
  */
896
- flushBeacon() {
897
- if (this.queue.length === 0) return true;
898
- const events = this.queue.map((e) => ({
899
- type: e.type,
900
- ...e.payload,
901
- _queue_id: e.id,
902
- _queue_timestamp: e.timestamp
903
- }));
904
- const success = navigator.sendBeacon?.(
905
- this.endpoint,
906
- JSON.stringify({ events, beacon: true })
907
- ) ?? false;
908
- if (success) {
909
- this.queue = [];
910
- this.clearStorage();
930
+ recordFocusBlur(type) {
931
+ this.data.focusBlurEvents.push({ type, time: Date.now() });
932
+ if (this.data.focusBlurEvents.length > 20) {
933
+ this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
911
934
  }
912
- return success;
913
935
  }
914
936
  /**
915
- * Get current queue length
937
+ * Check if we have enough data and classify
916
938
  */
917
- get length() {
918
- return this.queue.length;
939
+ checkAndClassify() {
940
+ if (this.classified) return;
941
+ const sessionDuration = Date.now() - this.data.startTime;
942
+ if (sessionDuration < this.minSessionTime) return;
943
+ const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
944
+ if (!hasData) return;
945
+ this.classify();
919
946
  }
920
- scheduleBatch() {
921
- if (this.batchTimer) return;
922
- if (this.queue.length >= this.config.batchSize) {
923
- this.flush();
924
- return;
925
- }
926
- this.batchTimer = setTimeout(() => {
927
- this.batchTimer = null;
928
- this.flush();
929
- }, this.config.batchTimeout);
947
+ /**
948
+ * Force classification (for beforeunload)
949
+ */
950
+ forceClassify() {
951
+ if (this.classified) return this.result;
952
+ return this.classify();
930
953
  }
931
- clearBatchTimer() {
932
- if (this.batchTimer) {
933
- clearTimeout(this.batchTimer);
934
- this.batchTimer = null;
954
+ /**
955
+ * Perform classification
956
+ */
957
+ classify() {
958
+ const sessionDuration = Date.now() - this.data.startTime;
959
+ const signals = this.extractSignals();
960
+ let humanLogProb = Math.log(PRIORS.human);
961
+ let aiLogProb = Math.log(PRIORS.ai_influenced);
962
+ for (const signal of signals) {
963
+ const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
964
+ const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
965
+ humanLogProb += Math.log(humanWeight);
966
+ aiLogProb += Math.log(aiWeight);
935
967
  }
936
- }
937
- async sendBatch(events) {
938
- if (events.length === 0) return;
939
- const payload = {
940
- events: events.map((e) => ({
941
- type: e.type,
942
- ...e.payload,
943
- _queue_id: e.id,
944
- _queue_timestamp: e.timestamp
945
- })),
946
- batch: true
968
+ const maxLog = Math.max(humanLogProb, aiLogProb);
969
+ const humanExp = Math.exp(humanLogProb - maxLog);
970
+ const aiExp = Math.exp(aiLogProb - maxLog);
971
+ const total = humanExp + aiExp;
972
+ const humanProbability = humanExp / total;
973
+ const aiProbability = aiExp / total;
974
+ let classification;
975
+ let confidence;
976
+ if (humanProbability > 0.6) {
977
+ classification = "human";
978
+ confidence = humanProbability;
979
+ } else if (aiProbability > 0.6) {
980
+ classification = "ai_influenced";
981
+ confidence = aiProbability;
982
+ } else {
983
+ classification = "uncertain";
984
+ confidence = Math.max(humanProbability, aiProbability);
985
+ }
986
+ this.result = {
987
+ classification,
988
+ humanProbability,
989
+ aiProbability,
990
+ confidence,
991
+ signals,
992
+ timestamp: Date.now(),
993
+ sessionDurationMs: sessionDuration
947
994
  };
948
- try {
949
- const response = await fetch(this.endpoint, {
950
- method: "POST",
951
- headers: { "Content-Type": "application/json" },
952
- body: JSON.stringify(payload)
953
- });
954
- if (!response.ok) {
955
- throw new Error(`HTTP ${response.status}`);
995
+ this.classified = true;
996
+ if (this.onClassify) {
997
+ this.onClassify(this.result);
998
+ }
999
+ return this.result;
1000
+ }
1001
+ /**
1002
+ * Extract behavioral signals from collected data
1003
+ */
1004
+ extractSignals() {
1005
+ const signals = [];
1006
+ if (this.data.firstClickTime !== null) {
1007
+ const timeToClick = this.data.firstClickTime - this.data.startTime;
1008
+ if (timeToClick < 500) {
1009
+ signals.push("time_to_first_click_immediate");
1010
+ } else if (timeToClick < 2e3) {
1011
+ signals.push("time_to_first_click_fast");
1012
+ } else if (timeToClick < 1e4) {
1013
+ signals.push("time_to_first_click_normal");
1014
+ } else {
1015
+ signals.push("time_to_first_click_delayed");
956
1016
  }
957
- } catch (error) {
958
- for (const event of events) {
959
- if (event.retries < this.config.maxRetries) {
960
- event.retries++;
961
- this.queue.push(event);
962
- }
1017
+ }
1018
+ if (this.data.scrollEvents.length === 0) {
1019
+ signals.push("scroll_speed_none");
1020
+ } else if (this.data.scrollEvents.length >= 3) {
1021
+ const scrollDeltas = [];
1022
+ for (let i = 1; i < this.data.scrollEvents.length; i++) {
1023
+ const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
1024
+ scrollDeltas.push(delta);
963
1025
  }
964
- if (this.queue.length > 0) {
965
- const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1);
966
- setTimeout(() => this.flush(), delay);
1026
+ const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
1027
+ const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
1028
+ const stdDev = Math.sqrt(variance);
1029
+ const cv = mean > 0 ? stdDev / mean : 0;
1030
+ if (cv < 0.2) {
1031
+ signals.push("scroll_speed_uniform");
1032
+ } else if (cv < 0.6) {
1033
+ signals.push("scroll_speed_variable");
1034
+ } else {
1035
+ signals.push("scroll_speed_erratic");
967
1036
  }
968
1037
  }
969
- }
970
- loadFromStorage() {
971
- try {
972
- const stored = localStorage.getItem(this.config.storageKey);
973
- if (stored) {
974
- const parsed = JSON.parse(stored);
975
- if (Array.isArray(parsed)) {
976
- const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
977
- this.queue = parsed.filter((e) => e.timestamp > cutoff);
978
- }
1038
+ if (this.data.mouseEvents.length === 0) {
1039
+ signals.push("mouse_movement_none");
1040
+ } else if (this.data.mouseEvents.length >= 10) {
1041
+ const n = Math.min(this.data.mouseEvents.length, 20);
1042
+ const recentMouse = this.data.mouseEvents.slice(-n);
1043
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
1044
+ for (const event of recentMouse) {
1045
+ sumX += event.x;
1046
+ sumY += event.y;
1047
+ sumXY += event.x * event.y;
1048
+ sumX2 += event.x * event.x;
979
1049
  }
980
- } catch {
981
- }
982
- }
983
- saveToStorage() {
984
- try {
985
- if (this.queue.length > 0) {
986
- localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1050
+ const denominator = n * sumX2 - sumX * sumX;
1051
+ const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
1052
+ const intercept = (sumY - slope * sumX) / n;
1053
+ let ssRes = 0, ssTot = 0;
1054
+ const yMean = sumY / n;
1055
+ for (const event of recentMouse) {
1056
+ const yPred = slope * event.x + intercept;
1057
+ ssRes += Math.pow(event.y - yPred, 2);
1058
+ ssTot += Math.pow(event.y - yMean, 2);
1059
+ }
1060
+ const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
1061
+ if (r2 > 0.95) {
1062
+ signals.push("mouse_movement_linear");
987
1063
  } else {
988
- this.clearStorage();
1064
+ signals.push("mouse_movement_curved");
989
1065
  }
990
- } catch {
991
- }
992
- }
993
- clearStorage() {
994
- try {
995
- localStorage.removeItem(this.config.storageKey);
996
- } catch {
997
1066
  }
998
- }
999
- generateId() {
1000
- return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1001
- }
1002
- };
1003
-
1004
- // src/infrastructure/ping.ts
1005
- var PingService = class {
1006
- constructor(sessionId2, visitorId2, version, config2 = {}) {
1007
- this.intervalId = null;
1008
- this.isVisible = true;
1009
- this.currentScrollDepth = 0;
1010
- this.ping = async () => {
1011
- const data = this.getData();
1012
- this.config.onPing?.(data);
1013
- if (this.config.endpoint) {
1014
- try {
1015
- await fetch(this.config.endpoint, {
1016
- method: "POST",
1017
- headers: { "Content-Type": "application/json" },
1018
- body: JSON.stringify(data)
1019
- });
1020
- } catch {
1021
- }
1067
+ const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
1068
+ if (completedForms.length > 0) {
1069
+ const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
1070
+ if (avgFillTime < 100) {
1071
+ signals.push("form_fill_instant");
1072
+ } else if (avgFillTime < 500) {
1073
+ signals.push("form_fill_fast");
1074
+ } else {
1075
+ signals.push("form_fill_normal");
1022
1076
  }
1023
- };
1024
- this.handleVisibilityChange = () => {
1025
- this.isVisible = document.visibilityState === "visible";
1026
- };
1027
- this.handleScroll = () => {
1028
- const scrollPercent = Math.round(
1029
- (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
1030
- );
1031
- if (scrollPercent > this.currentScrollDepth) {
1032
- this.currentScrollDepth = Math.min(scrollPercent, 100);
1077
+ }
1078
+ if (this.data.focusBlurEvents.length >= 4) {
1079
+ const recentFB = this.data.focusBlurEvents.slice(-10);
1080
+ const intervals = [];
1081
+ for (let i = 1; i < recentFB.length; i++) {
1082
+ intervals.push(recentFB[i].time - recentFB[i - 1].time);
1033
1083
  }
1034
- };
1035
- this.sessionId = sessionId2;
1036
- this.visitorId = visitorId2;
1037
- this.version = version;
1038
- this.pageLoadTime = Date.now();
1039
- this.config = {
1040
- interval: DEFAULT_CONFIG.pingInterval,
1041
- endpoint: "",
1042
- ...config2
1043
- };
1044
- document.addEventListener("visibilitychange", this.handleVisibilityChange);
1045
- window.addEventListener("scroll", this.handleScroll, { passive: true });
1046
- }
1047
- /**
1048
- * Start the ping service
1049
- */
1050
- start() {
1051
- if (this.intervalId) return;
1052
- this.intervalId = setInterval(() => {
1053
- if (this.isVisible) {
1054
- this.ping();
1084
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
1085
+ if (avgInterval < 1e3) {
1086
+ signals.push("focus_blur_rapid");
1087
+ } else {
1088
+ signals.push("focus_blur_normal");
1055
1089
  }
1056
- }, this.config.interval);
1057
- this.ping();
1090
+ }
1091
+ return signals;
1058
1092
  }
1059
1093
  /**
1060
- * Stop the ping service
1094
+ * Add context signals (set by tracker from external data)
1061
1095
  */
1062
- stop() {
1063
- if (this.intervalId) {
1064
- clearInterval(this.intervalId);
1065
- this.intervalId = null;
1066
- }
1067
- document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1068
- window.removeEventListener("scroll", this.handleScroll);
1096
+ addContextSignal(_signal) {
1069
1097
  }
1070
1098
  /**
1071
- * Update scroll depth (called by external scroll tracker)
1099
+ * Get current result (null if not yet classified)
1072
1100
  */
1073
- updateScrollDepth(depth) {
1074
- if (depth > this.currentScrollDepth) {
1075
- this.currentScrollDepth = depth;
1076
- }
1101
+ getResult() {
1102
+ return this.result;
1077
1103
  }
1078
1104
  /**
1079
- * Get current ping data
1105
+ * Check if classification has been performed
1080
1106
  */
1081
- getData() {
1082
- return {
1083
- session_id: this.sessionId,
1084
- visitor_id: this.visitorId,
1085
- url: window.location.href,
1086
- time_on_page_ms: Date.now() - this.pageLoadTime,
1087
- scroll_depth: this.currentScrollDepth,
1088
- is_active: this.isVisible,
1089
- tracker_version: this.version
1090
- };
1107
+ hasClassified() {
1108
+ return this.classified;
1091
1109
  }
1092
1110
  };
1093
1111
 
1094
- // src/behavioral/scroll-tracker.ts
1095
- var DEFAULT_CHUNKS = [30, 60, 90, 100];
1096
- var ScrollTracker = class {
1097
- constructor(config2 = {}) {
1098
- this.maxDepth = 0;
1099
- this.reportedChunks = /* @__PURE__ */ new Set();
1100
- this.ticking = false;
1101
- this.isVisible = true;
1102
- this.handleScroll = () => {
1103
- if (!this.ticking && this.isVisible) {
1104
- requestAnimationFrame(() => {
1105
- this.checkScrollDepth();
1106
- this.ticking = false;
1107
- });
1108
- this.ticking = true;
1112
+ // src/detection/focus-blur.ts
1113
+ var FocusBlurAnalyzer = class {
1114
+ constructor() {
1115
+ this.sequence = [];
1116
+ this.firstInteractionTime = null;
1117
+ this.analyzed = false;
1118
+ this.result = null;
1119
+ this.pageLoadTime = performance.now();
1120
+ }
1121
+ /**
1122
+ * Initialize event tracking
1123
+ * Must be called after DOM is ready
1124
+ */
1125
+ initTracking() {
1126
+ document.addEventListener("focus", (e) => {
1127
+ this.recordEvent("focus", e.target);
1128
+ }, true);
1129
+ document.addEventListener("blur", (e) => {
1130
+ this.recordEvent("blur", e.target);
1131
+ }, true);
1132
+ window.addEventListener("focus", () => {
1133
+ this.recordEvent("window_focus", null);
1134
+ });
1135
+ window.addEventListener("blur", () => {
1136
+ this.recordEvent("window_blur", null);
1137
+ });
1138
+ const recordFirstInteraction = () => {
1139
+ if (this.firstInteractionTime === null) {
1140
+ this.firstInteractionTime = performance.now();
1109
1141
  }
1110
1142
  };
1111
- this.handleVisibility = () => {
1112
- this.isVisible = document.visibilityState === "visible";
1113
- };
1114
- this.config = {
1115
- chunks: DEFAULT_CHUNKS,
1116
- ...config2
1143
+ document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
1144
+ document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
1145
+ }
1146
+ /**
1147
+ * Record a focus/blur event
1148
+ */
1149
+ recordEvent(type, target) {
1150
+ const event = {
1151
+ type,
1152
+ target: target?.tagName || "WINDOW",
1153
+ timestamp: performance.now()
1117
1154
  };
1118
- this.startTime = Date.now();
1155
+ this.sequence.push(event);
1156
+ if (this.sequence.length > 20) {
1157
+ this.sequence = this.sequence.slice(-20);
1158
+ }
1119
1159
  }
1120
1160
  /**
1121
- * Start tracking scroll depth
1161
+ * Analyze the focus/blur sequence for paste patterns
1122
1162
  */
1123
- start() {
1124
- window.addEventListener("scroll", this.handleScroll, { passive: true });
1125
- document.addEventListener("visibilitychange", this.handleVisibility);
1126
- this.checkScrollDepth();
1163
+ analyze() {
1164
+ if (this.analyzed && this.result) {
1165
+ return this.result;
1166
+ }
1167
+ const signals = [];
1168
+ let confidence = 0;
1169
+ const earlyEvents = this.sequence.filter((e) => e.timestamp < this.pageLoadTime + 500);
1170
+ const hasEarlyWindowFocus = earlyEvents.some((e) => e.type === "window_focus");
1171
+ if (hasEarlyWindowFocus) {
1172
+ signals.push("early_window_focus");
1173
+ confidence += 0.15;
1174
+ }
1175
+ const hasEarlyBodyFocus = earlyEvents.some(
1176
+ (e) => e.type === "focus" && e.target === "BODY"
1177
+ );
1178
+ if (hasEarlyBodyFocus) {
1179
+ signals.push("early_body_focus");
1180
+ confidence += 0.15;
1181
+ }
1182
+ const hasLinkFocus = this.sequence.some(
1183
+ (e) => e.type === "focus" && e.target === "A"
1184
+ );
1185
+ if (!hasLinkFocus) {
1186
+ signals.push("no_link_focus");
1187
+ confidence += 0.1;
1188
+ }
1189
+ const firstFocus = this.sequence.find((e) => e.type === "focus");
1190
+ if (firstFocus && (firstFocus.target === "BODY" || firstFocus.target === "HTML")) {
1191
+ signals.push("first_focus_body");
1192
+ confidence += 0.1;
1193
+ }
1194
+ const windowEvents = this.sequence.filter(
1195
+ (e) => e.type === "window_focus" || e.type === "window_blur"
1196
+ );
1197
+ if (windowEvents.length <= 2) {
1198
+ signals.push("minimal_window_switches");
1199
+ confidence += 0.05;
1200
+ }
1201
+ if (this.firstInteractionTime !== null) {
1202
+ const timeToInteraction = this.firstInteractionTime - this.pageLoadTime;
1203
+ if (timeToInteraction > 3e3) {
1204
+ signals.push("delayed_first_interaction");
1205
+ confidence += 0.1;
1206
+ }
1207
+ }
1208
+ confidence = Math.min(confidence, 0.65);
1209
+ let navType;
1210
+ if (confidence >= 0.35) {
1211
+ navType = "likely_paste";
1212
+ } else if (signals.length === 0) {
1213
+ navType = "unknown";
1214
+ } else {
1215
+ navType = "likely_click";
1216
+ }
1217
+ this.result = {
1218
+ nav_type: navType,
1219
+ confidence,
1220
+ signals,
1221
+ sequence: this.sequence.slice(-10),
1222
+ time_to_first_interaction_ms: this.firstInteractionTime ? Math.round(this.firstInteractionTime - this.pageLoadTime) : null
1223
+ };
1224
+ this.analyzed = true;
1225
+ return this.result;
1127
1226
  }
1128
1227
  /**
1129
- * Stop tracking
1228
+ * Get current result (analyze if not done)
1130
1229
  */
1131
- stop() {
1132
- window.removeEventListener("scroll", this.handleScroll);
1133
- document.removeEventListener("visibilitychange", this.handleVisibility);
1230
+ getResult() {
1231
+ return this.analyze();
1134
1232
  }
1135
1233
  /**
1136
- * Get current max scroll depth
1234
+ * Check if analysis has been performed
1137
1235
  */
1138
- getMaxDepth() {
1139
- return this.maxDepth;
1236
+ hasAnalyzed() {
1237
+ return this.analyzed;
1140
1238
  }
1141
1239
  /**
1142
- * Get reported chunks
1240
+ * Get the raw sequence for debugging
1143
1241
  */
1144
- getReportedChunks() {
1145
- return Array.from(this.reportedChunks).sort((a, b) => a - b);
1242
+ getSequence() {
1243
+ return [...this.sequence];
1146
1244
  }
1147
1245
  /**
1148
- * Get final scroll event (for unload)
1246
+ * Reset the analyzer
1149
1247
  */
1150
- getFinalEvent() {
1151
- const docHeight = document.documentElement.scrollHeight;
1152
- const viewportHeight = window.innerHeight;
1248
+ reset() {
1249
+ this.sequence = [];
1250
+ this.pageLoadTime = performance.now();
1251
+ this.firstInteractionTime = null;
1252
+ this.analyzed = false;
1253
+ this.result = null;
1254
+ }
1255
+ };
1256
+
1257
+ // src/detection/navigation-timing.ts
1258
+ function detectNavigationType() {
1259
+ try {
1260
+ const entries = performance.getEntriesByType("navigation");
1261
+ if (!entries || entries.length === 0) {
1262
+ return { nav_type: "unknown", confidence: 0, signals: ["no_timing_data"] };
1263
+ }
1264
+ const nav = entries[0];
1265
+ const signals = [];
1266
+ let pasteScore = 0;
1267
+ const fetchStartDelta = nav.fetchStart - nav.startTime;
1268
+ if (fetchStartDelta < 5) {
1269
+ pasteScore += 0.25;
1270
+ signals.push("instant_fetch_start");
1271
+ } else if (fetchStartDelta < 20) {
1272
+ pasteScore += 0.15;
1273
+ signals.push("fast_fetch_start");
1274
+ }
1275
+ const dnsTime = nav.domainLookupEnd - nav.domainLookupStart;
1276
+ if (dnsTime === 0) {
1277
+ pasteScore += 0.15;
1278
+ signals.push("no_dns_lookup");
1279
+ }
1280
+ const connectTime = nav.connectEnd - nav.connectStart;
1281
+ if (connectTime === 0) {
1282
+ pasteScore += 0.15;
1283
+ signals.push("no_tcp_connect");
1284
+ }
1285
+ if (nav.redirectCount === 0) {
1286
+ pasteScore += 0.1;
1287
+ signals.push("no_redirects");
1288
+ }
1289
+ const timingVariance = calculateTimingVariance(nav);
1290
+ if (timingVariance < 10) {
1291
+ pasteScore += 0.15;
1292
+ signals.push("uniform_timing");
1293
+ }
1294
+ if (!document.referrer || document.referrer === "") {
1295
+ pasteScore += 0.1;
1296
+ signals.push("no_referrer");
1297
+ }
1298
+ const confidence = Math.min(pasteScore, 1);
1299
+ const nav_type = pasteScore >= 0.5 ? "likely_paste" : "likely_click";
1153
1300
  return {
1154
- depth: this.maxDepth,
1155
- chunk: this.getChunkForDepth(this.maxDepth),
1156
- time_to_reach_ms: Date.now() - this.startTime,
1157
- total_height: docHeight,
1158
- viewport_height: viewportHeight
1301
+ nav_type,
1302
+ confidence: Math.round(confidence * 1e3) / 1e3,
1303
+ signals
1159
1304
  };
1305
+ } catch {
1306
+ return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
1160
1307
  }
1161
- checkScrollDepth() {
1162
- const scrollY = window.scrollY;
1163
- const viewportHeight = window.innerHeight;
1164
- const docHeight = document.documentElement.scrollHeight;
1165
- if (docHeight <= viewportHeight) {
1166
- this.updateDepth(100);
1167
- return;
1168
- }
1169
- const scrollableHeight = docHeight - viewportHeight;
1170
- const currentDepth = Math.min(100, Math.round(scrollY / scrollableHeight * 100));
1171
- this.updateDepth(currentDepth);
1308
+ }
1309
+ function calculateTimingVariance(nav) {
1310
+ const timings = [
1311
+ nav.fetchStart - nav.startTime,
1312
+ nav.domainLookupEnd - nav.domainLookupStart,
1313
+ nav.connectEnd - nav.connectStart,
1314
+ nav.responseStart - nav.requestStart
1315
+ ].filter((t) => t >= 0);
1316
+ if (timings.length === 0) return 100;
1317
+ const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
1318
+ const variance = timings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / timings.length;
1319
+ return Math.sqrt(variance);
1320
+ }
1321
+
1322
+ // src/detection/referrer.ts
1323
+ function detectAIFromReferrer(referrer) {
1324
+ if (!referrer) {
1325
+ return null;
1172
1326
  }
1173
- updateDepth(depth) {
1174
- if (depth <= this.maxDepth) return;
1175
- this.maxDepth = depth;
1176
- this.config.onDepthChange?.(depth);
1177
- for (const chunk of this.config.chunks) {
1178
- if (depth >= chunk && !this.reportedChunks.has(chunk)) {
1179
- this.reportedChunks.add(chunk);
1180
- this.reportChunk(chunk);
1327
+ try {
1328
+ const url = new URL(referrer);
1329
+ const hostname = url.hostname.toLowerCase();
1330
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1331
+ if (hostname.includes(pattern) || referrer.includes(pattern)) {
1332
+ return {
1333
+ isAI: true,
1334
+ platform,
1335
+ confidence: 0.95,
1336
+ // High confidence when referrer matches
1337
+ method: "referrer"
1338
+ };
1181
1339
  }
1182
1340
  }
1341
+ return null;
1342
+ } catch {
1343
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1344
+ if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
1345
+ return {
1346
+ isAI: true,
1347
+ platform,
1348
+ confidence: 0.85,
1349
+ method: "referrer"
1350
+ };
1351
+ }
1352
+ }
1353
+ return null;
1183
1354
  }
1184
- reportChunk(chunk) {
1185
- const docHeight = document.documentElement.scrollHeight;
1186
- const viewportHeight = window.innerHeight;
1187
- const event = {
1188
- depth: this.maxDepth,
1189
- chunk,
1190
- time_to_reach_ms: Date.now() - this.startTime,
1191
- total_height: docHeight,
1192
- viewport_height: viewportHeight
1193
- };
1194
- this.config.onChunkReached?.(event);
1195
- }
1196
- getChunkForDepth(depth) {
1197
- const chunks = this.config.chunks.sort((a, b) => b - a);
1198
- for (const chunk of chunks) {
1199
- if (depth >= chunk) return chunk;
1355
+ }
1356
+ function detectAIFromUTM(url) {
1357
+ try {
1358
+ const params = new URL(url).searchParams;
1359
+ const utmSource = params.get("utm_source")?.toLowerCase();
1360
+ if (utmSource) {
1361
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1362
+ if (utmSource.includes(pattern.split(".")[0])) {
1363
+ return {
1364
+ isAI: true,
1365
+ platform,
1366
+ confidence: 0.99,
1367
+ // Very high confidence from explicit UTM
1368
+ method: "referrer"
1369
+ };
1370
+ }
1371
+ }
1372
+ if (utmSource.includes("ai") || utmSource.includes("llm") || utmSource.includes("chatbot")) {
1373
+ return {
1374
+ isAI: true,
1375
+ platform: utmSource,
1376
+ confidence: 0.9,
1377
+ method: "referrer"
1378
+ };
1379
+ }
1200
1380
  }
1201
- return 0;
1381
+ return null;
1382
+ } catch {
1383
+ return null;
1202
1384
  }
1203
- };
1385
+ }
1204
1386
 
1205
- // src/behavioral/time-tracker.ts
1206
- var DEFAULT_CONFIG2 = {
1207
- idleThresholdMs: 3e4,
1208
- // 30 seconds
1209
- updateIntervalMs: 5e3
1210
- // 5 seconds
1387
+ // src/infrastructure/event-queue.ts
1388
+ var DEFAULT_QUEUE_CONFIG = {
1389
+ batchSize: DEFAULT_CONFIG3.batchSize,
1390
+ batchTimeout: DEFAULT_CONFIG3.batchTimeout,
1391
+ maxRetries: 3,
1392
+ retryDelayMs: 1e3,
1393
+ storageKey: "_loamly_queue"
1211
1394
  };
1212
- var TimeTracker = class {
1213
- constructor(config2 = {}) {
1214
- this.activeTime = 0;
1215
- this.idleTime = 0;
1216
- this.isVisible = true;
1217
- this.isIdle = false;
1218
- this.updateInterval = null;
1219
- this.idleCheckInterval = null;
1220
- this.handleVisibility = () => {
1221
- const wasVisible = this.isVisible;
1222
- this.isVisible = document.visibilityState === "visible";
1223
- if (wasVisible && !this.isVisible) {
1224
- this.updateTimes();
1225
- } else if (!wasVisible && this.isVisible) {
1226
- this.lastUpdateTime = Date.now();
1227
- this.lastActivityTime = Date.now();
1228
- }
1229
- };
1230
- this.handleActivity = () => {
1231
- const now = Date.now();
1232
- if (this.isIdle) {
1233
- this.isIdle = false;
1234
- }
1235
- this.lastActivityTime = now;
1236
- };
1237
- this.config = { ...DEFAULT_CONFIG2, ...config2 };
1238
- this.startTime = Date.now();
1239
- this.lastActivityTime = this.startTime;
1240
- this.lastUpdateTime = this.startTime;
1395
+ var EventQueue = class {
1396
+ constructor(endpoint2, config2 = {}) {
1397
+ this.queue = [];
1398
+ this.batchTimer = null;
1399
+ this.isFlushing = false;
1400
+ this.endpoint = endpoint2;
1401
+ this.config = { ...DEFAULT_QUEUE_CONFIG, ...config2 };
1402
+ this.loadFromStorage();
1241
1403
  }
1242
1404
  /**
1243
- * Start tracking time
1405
+ * Add event to queue
1244
1406
  */
1245
- start() {
1246
- document.addEventListener("visibilitychange", this.handleVisibility);
1247
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1248
- activityEvents.forEach((event) => {
1249
- document.addEventListener(event, this.handleActivity, { passive: true });
1250
- });
1251
- this.updateInterval = setInterval(() => {
1252
- this.update();
1253
- }, this.config.updateIntervalMs);
1254
- this.idleCheckInterval = setInterval(() => {
1255
- this.checkIdle();
1256
- }, 1e3);
1407
+ push(type, payload, headers) {
1408
+ const event = {
1409
+ id: this.generateId(),
1410
+ type,
1411
+ payload,
1412
+ headers,
1413
+ timestamp: Date.now(),
1414
+ retries: 0
1415
+ };
1416
+ this.queue.push(event);
1417
+ this.saveToStorage();
1418
+ this.scheduleBatch();
1257
1419
  }
1258
1420
  /**
1259
- * Stop tracking
1421
+ * Force flush all events immediately
1260
1422
  */
1261
- stop() {
1262
- document.removeEventListener("visibilitychange", this.handleVisibility);
1263
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1264
- activityEvents.forEach((event) => {
1265
- document.removeEventListener(event, this.handleActivity);
1266
- });
1267
- if (this.updateInterval) {
1268
- clearInterval(this.updateInterval);
1269
- this.updateInterval = null;
1270
- }
1271
- if (this.idleCheckInterval) {
1272
- clearInterval(this.idleCheckInterval);
1273
- this.idleCheckInterval = null;
1423
+ async flush() {
1424
+ if (this.isFlushing || this.queue.length === 0) return;
1425
+ this.isFlushing = true;
1426
+ this.clearBatchTimer();
1427
+ try {
1428
+ const events = [...this.queue];
1429
+ this.queue = [];
1430
+ await this.sendBatch(events);
1431
+ } finally {
1432
+ this.isFlushing = false;
1433
+ this.saveToStorage();
1274
1434
  }
1275
1435
  }
1276
1436
  /**
1277
- * Get current time metrics
1437
+ * Flush using sendBeacon (for unload events)
1278
1438
  */
1279
- getMetrics() {
1280
- this.updateTimes();
1281
- return {
1282
- active_time_ms: this.activeTime,
1283
- total_time_ms: Date.now() - this.startTime,
1284
- idle_time_ms: this.idleTime,
1285
- is_engaged: !this.isIdle && this.isVisible
1286
- };
1439
+ flushBeacon() {
1440
+ if (this.queue.length === 0) return true;
1441
+ const baseUrl = this.config.apiKey ? `${this.endpoint}?api_key=${encodeURIComponent(this.config.apiKey)}` : this.endpoint;
1442
+ let allSent = true;
1443
+ for (const event of this.queue) {
1444
+ const payload = {
1445
+ ...event.payload,
1446
+ _queue_id: event.id,
1447
+ _queue_timestamp: event.timestamp
1448
+ };
1449
+ const success = navigator.sendBeacon?.(baseUrl, JSON.stringify(payload)) ?? false;
1450
+ if (!success) {
1451
+ allSent = false;
1452
+ break;
1453
+ }
1454
+ }
1455
+ if (allSent) {
1456
+ this.queue = [];
1457
+ this.clearStorage();
1458
+ }
1459
+ return allSent;
1287
1460
  }
1288
1461
  /**
1289
- * Get final metrics (for unload)
1462
+ * Get current queue length
1290
1463
  */
1291
- getFinalMetrics() {
1292
- this.updateTimes();
1293
- return this.getMetrics();
1464
+ get length() {
1465
+ return this.queue.length;
1294
1466
  }
1295
- checkIdle() {
1296
- const now = Date.now();
1297
- const timeSinceActivity = now - this.lastActivityTime;
1298
- if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
1299
- this.isIdle = true;
1467
+ scheduleBatch() {
1468
+ if (this.batchTimer) return;
1469
+ if (this.queue.length >= this.config.batchSize) {
1470
+ this.flush();
1471
+ return;
1300
1472
  }
1473
+ this.batchTimer = setTimeout(() => {
1474
+ this.batchTimer = null;
1475
+ this.flush();
1476
+ }, this.config.batchTimeout);
1301
1477
  }
1302
- updateTimes() {
1303
- const now = Date.now();
1304
- const elapsed = now - this.lastUpdateTime;
1305
- if (this.isVisible) {
1306
- if (this.isIdle) {
1307
- this.idleTime += elapsed;
1478
+ clearBatchTimer() {
1479
+ if (this.batchTimer) {
1480
+ clearTimeout(this.batchTimer);
1481
+ this.batchTimer = null;
1482
+ }
1483
+ }
1484
+ async sendBatch(events) {
1485
+ if (events.length === 0) return;
1486
+ try {
1487
+ const results = await Promise.allSettled(
1488
+ events.map(async (event) => {
1489
+ const response = await fetch(this.endpoint, {
1490
+ method: "POST",
1491
+ headers: {
1492
+ "Content-Type": "application/json",
1493
+ ...event.headers || {}
1494
+ },
1495
+ body: JSON.stringify({
1496
+ ...event.payload,
1497
+ _queue_id: event.id,
1498
+ _queue_timestamp: event.timestamp
1499
+ })
1500
+ });
1501
+ if (!response.ok) {
1502
+ throw new Error(`HTTP ${response.status}`);
1503
+ }
1504
+ })
1505
+ );
1506
+ const failedEvents = events.filter((_, index) => results[index]?.status === "rejected");
1507
+ if (failedEvents.length > 0) {
1508
+ for (const event of failedEvents) {
1509
+ if (event.retries < this.config.maxRetries) {
1510
+ event.retries++;
1511
+ this.queue.push(event);
1512
+ }
1513
+ }
1514
+ const delay = this.config.retryDelayMs * Math.pow(2, failedEvents[0].retries - 1);
1515
+ setTimeout(() => this.flush(), delay);
1516
+ }
1517
+ return;
1518
+ } catch (error) {
1519
+ for (const event of events) {
1520
+ if (event.retries < this.config.maxRetries) {
1521
+ event.retries++;
1522
+ this.queue.push(event);
1523
+ }
1524
+ }
1525
+ if (this.queue.length > 0) {
1526
+ const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1);
1527
+ setTimeout(() => this.flush(), delay);
1528
+ }
1529
+ }
1530
+ }
1531
+ loadFromStorage() {
1532
+ try {
1533
+ const stored = localStorage.getItem(this.config.storageKey);
1534
+ if (stored) {
1535
+ const parsed = JSON.parse(stored);
1536
+ if (Array.isArray(parsed)) {
1537
+ const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
1538
+ this.queue = parsed.filter((e) => e.timestamp > cutoff);
1539
+ }
1540
+ }
1541
+ } catch {
1542
+ }
1543
+ }
1544
+ saveToStorage() {
1545
+ try {
1546
+ if (this.queue.length > 0) {
1547
+ localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1308
1548
  } else {
1309
- this.activeTime += elapsed;
1549
+ this.clearStorage();
1310
1550
  }
1551
+ } catch {
1311
1552
  }
1312
- this.lastUpdateTime = now;
1313
1553
  }
1314
- update() {
1315
- if (!this.isVisible) return;
1316
- this.updateTimes();
1317
- this.config.onUpdate?.(this.getMetrics());
1554
+ clearStorage() {
1555
+ try {
1556
+ localStorage.removeItem(this.config.storageKey);
1557
+ } catch {
1558
+ }
1559
+ }
1560
+ generateId() {
1561
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1318
1562
  }
1319
1563
  };
1320
1564
 
1321
- // src/behavioral/form-tracker.ts
1322
- var DEFAULT_CONFIG3 = {
1323
- sensitiveFields: [
1324
- "password",
1325
- "pwd",
1326
- "pass",
1327
- "credit",
1328
- "card",
1329
- "cvv",
1330
- "cvc",
1331
- "ssn",
1332
- "social",
1333
- "secret",
1334
- "token",
1335
- "key"
1336
- ],
1337
- trackableFields: [
1338
- "email",
1339
- "name",
1340
- "phone",
1341
- "company",
1342
- "first",
1343
- "last",
1344
- "city",
1345
- "country"
1346
- ],
1347
- thankYouPatterns: [
1348
- /thank[-_]?you/i,
1349
- /success/i,
1350
- /confirmation/i,
1351
- /submitted/i,
1352
- /complete/i
1353
- ]
1354
- };
1355
- var FormTracker = class {
1356
- constructor(config2 = {}) {
1357
- this.formStartTimes = /* @__PURE__ */ new Map();
1358
- this.interactedForms = /* @__PURE__ */ new Set();
1359
- this.mutationObserver = null;
1360
- this.handleFocusIn = (e) => {
1361
- const target = e.target;
1362
- if (!this.isFormField(target)) return;
1363
- const form = target.closest("form");
1364
- const formId = this.getFormId(form || target);
1365
- if (!this.formStartTimes.has(formId)) {
1366
- this.formStartTimes.set(formId, Date.now());
1367
- this.interactedForms.add(formId);
1368
- this.emitEvent({
1369
- event_type: "form_start",
1370
- form_id: formId,
1371
- form_type: this.detectFormType(form || target)
1372
- });
1373
- }
1374
- const fieldName = this.getFieldName(target);
1375
- if (fieldName && !this.isSensitiveField(fieldName)) {
1376
- this.emitEvent({
1377
- event_type: "form_field",
1378
- form_id: formId,
1379
- form_type: this.detectFormType(form || target),
1380
- field_name: this.sanitizeFieldName(fieldName),
1381
- field_type: target.type || target.tagName.toLowerCase()
1382
- });
1565
+ // src/infrastructure/ping.ts
1566
+ var PingService = class {
1567
+ constructor(sessionId2, visitorId2, version, config2 = {}) {
1568
+ this.intervalId = null;
1569
+ this.isVisible = true;
1570
+ this.isFocused = true;
1571
+ this.currentScrollDepth = 0;
1572
+ this.ping = async () => {
1573
+ const data = this.getData();
1574
+ this.config.onPing?.(data);
1575
+ if (this.config.endpoint) {
1576
+ try {
1577
+ await fetch(this.config.endpoint, {
1578
+ method: "POST",
1579
+ headers: { "Content-Type": "application/json" },
1580
+ body: JSON.stringify(data)
1581
+ });
1582
+ } catch {
1583
+ }
1383
1584
  }
1384
1585
  };
1385
- this.handleSubmit = (e) => {
1386
- const form = e.target;
1387
- if (!form || form.tagName !== "FORM") return;
1388
- const formId = this.getFormId(form);
1389
- const startTime = this.formStartTimes.get(formId);
1390
- this.emitEvent({
1391
- event_type: "form_submit",
1392
- form_id: formId,
1393
- form_type: this.detectFormType(form),
1394
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1395
- is_conversion: true
1396
- });
1586
+ this.handleVisibilityChange = () => {
1587
+ this.isVisible = document.visibilityState === "visible";
1397
1588
  };
1398
- this.handleClick = (e) => {
1399
- const target = e.target;
1400
- if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
1401
- const form = target.closest("form");
1402
- if (form && form.classList.contains("hs-form")) {
1403
- const formId = this.getFormId(form);
1404
- const startTime = this.formStartTimes.get(formId);
1405
- this.emitEvent({
1406
- event_type: "form_submit",
1407
- form_id: formId,
1408
- form_type: "hubspot",
1409
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1410
- is_conversion: true
1411
- });
1412
- }
1589
+ this.handleFocusChange = () => {
1590
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1591
+ if (this.intervalId && this.isVisible && this.isFocused) {
1592
+ this.ping();
1413
1593
  }
1414
- if (target.closest('[data-qa="submit-button"]')) {
1415
- this.emitEvent({
1416
- event_type: "form_submit",
1417
- form_id: "typeform_embed",
1418
- form_type: "typeform",
1419
- is_conversion: true
1420
- });
1594
+ };
1595
+ this.handleScroll = () => {
1596
+ const scrollPercent = Math.round(
1597
+ (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
1598
+ );
1599
+ if (scrollPercent > this.currentScrollDepth) {
1600
+ this.currentScrollDepth = Math.min(scrollPercent, 100);
1421
1601
  }
1422
1602
  };
1603
+ this.sessionId = sessionId2;
1604
+ this.visitorId = visitorId2;
1605
+ this.version = version;
1606
+ this.pageLoadTime = Date.now();
1607
+ this.isVisible = document.visibilityState === "visible";
1608
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1423
1609
  this.config = {
1424
- ...DEFAULT_CONFIG3,
1425
- ...config2,
1426
- sensitiveFields: [
1427
- ...DEFAULT_CONFIG3.sensitiveFields,
1428
- ...config2.sensitiveFields || []
1429
- ]
1610
+ interval: DEFAULT_CONFIG3.pingInterval,
1611
+ endpoint: "",
1612
+ ...config2
1430
1613
  };
1614
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
1615
+ window.addEventListener("focus", this.handleFocusChange);
1616
+ window.addEventListener("blur", this.handleFocusChange);
1617
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
1431
1618
  }
1432
1619
  /**
1433
- * Start tracking forms
1620
+ * Start the ping service
1434
1621
  */
1435
1622
  start() {
1436
- document.addEventListener("focusin", this.handleFocusIn, { passive: true });
1437
- document.addEventListener("submit", this.handleSubmit);
1438
- document.addEventListener("click", this.handleClick, { passive: true });
1439
- this.startMutationObserver();
1440
- this.checkThankYouPage();
1441
- this.scanForEmbeddedForms();
1623
+ if (this.intervalId) return;
1624
+ this.intervalId = setInterval(() => {
1625
+ if (this.isVisible && this.isFocused) {
1626
+ this.ping();
1627
+ }
1628
+ }, this.config.interval);
1629
+ if (this.isVisible && this.isFocused) {
1630
+ this.ping();
1631
+ }
1442
1632
  }
1443
1633
  /**
1444
- * Stop tracking
1634
+ * Stop the ping service
1445
1635
  */
1446
1636
  stop() {
1447
- document.removeEventListener("focusin", this.handleFocusIn);
1448
- document.removeEventListener("submit", this.handleSubmit);
1449
- document.removeEventListener("click", this.handleClick);
1450
- this.mutationObserver?.disconnect();
1637
+ if (this.intervalId) {
1638
+ clearInterval(this.intervalId);
1639
+ this.intervalId = null;
1640
+ }
1641
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1642
+ window.removeEventListener("focus", this.handleFocusChange);
1643
+ window.removeEventListener("blur", this.handleFocusChange);
1644
+ window.removeEventListener("scroll", this.handleScroll);
1451
1645
  }
1452
1646
  /**
1453
- * Get forms that had interaction
1647
+ * Update scroll depth (called by external scroll tracker)
1454
1648
  */
1455
- getInteractedForms() {
1456
- return Array.from(this.interactedForms);
1457
- }
1458
- startMutationObserver() {
1459
- this.mutationObserver = new MutationObserver((mutations) => {
1460
- for (const mutation of mutations) {
1461
- for (const node of mutation.addedNodes) {
1462
- if (node instanceof HTMLElement) {
1463
- if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
1464
- this.trackEmbeddedForm(node, "hubspot");
1465
- }
1466
- if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
1467
- this.trackEmbeddedForm(node, "typeform");
1468
- }
1469
- if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
1470
- this.trackEmbeddedForm(node, "jotform");
1471
- }
1472
- if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
1473
- this.trackEmbeddedForm(node, "gravity");
1474
- }
1475
- }
1476
- }
1477
- }
1478
- });
1479
- this.mutationObserver.observe(document.body, {
1480
- childList: true,
1481
- subtree: true
1482
- });
1483
- }
1484
- scanForEmbeddedForms() {
1485
- document.querySelectorAll(".hs-form").forEach((form) => {
1486
- this.trackEmbeddedForm(form, "hubspot");
1487
- });
1488
- document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
1489
- this.trackEmbeddedForm(form, "typeform");
1490
- });
1491
- document.querySelectorAll(".jotform-form").forEach((form) => {
1492
- this.trackEmbeddedForm(form, "jotform");
1493
- });
1494
- document.querySelectorAll(".gform_wrapper").forEach((form) => {
1495
- this.trackEmbeddedForm(form, "gravity");
1496
- });
1497
- }
1498
- trackEmbeddedForm(element, type) {
1499
- const formId = `${type}_${this.getFormId(element)}`;
1500
- element.addEventListener("focusin", () => {
1501
- if (!this.formStartTimes.has(formId)) {
1502
- this.formStartTimes.set(formId, Date.now());
1503
- this.interactedForms.add(formId);
1504
- this.emitEvent({
1505
- event_type: "form_start",
1506
- form_id: formId,
1507
- form_type: type
1508
- });
1509
- }
1510
- }, { passive: true });
1511
- }
1512
- checkThankYouPage() {
1513
- const url = window.location.href.toLowerCase();
1514
- const title = document.title.toLowerCase();
1515
- for (const pattern of this.config.thankYouPatterns) {
1516
- if (pattern.test(url) || pattern.test(title)) {
1517
- this.emitEvent({
1518
- event_type: "form_success",
1519
- form_id: "page_conversion",
1520
- form_type: "unknown",
1521
- is_conversion: true
1522
- });
1523
- break;
1524
- }
1525
- }
1526
- }
1527
- isFormField(element) {
1528
- const tagName = element.tagName;
1529
- return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
1530
- }
1531
- getFormId(element) {
1532
- if (!element) return "unknown";
1533
- return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
1534
- }
1535
- getFieldName(input) {
1536
- return input.name || input.id || input.getAttribute("data-name") || "";
1537
- }
1538
- isSensitiveField(fieldName) {
1539
- const lowerName = fieldName.toLowerCase();
1540
- return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
1541
- }
1542
- sanitizeFieldName(fieldName) {
1543
- return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
1544
- }
1545
- detectFormType(element) {
1546
- if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
1547
- return "hubspot";
1548
- }
1549
- if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
1550
- return "typeform";
1551
- }
1552
- if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
1553
- return "jotform";
1554
- }
1555
- if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
1556
- return "gravity";
1557
- }
1558
- if (element.tagName === "FORM") {
1559
- return "native";
1649
+ updateScrollDepth(depth) {
1650
+ if (depth > this.currentScrollDepth) {
1651
+ this.currentScrollDepth = depth;
1560
1652
  }
1561
- return "unknown";
1562
1653
  }
1563
- emitEvent(event) {
1564
- this.config.onFormEvent?.(event);
1654
+ /**
1655
+ * Get current ping data
1656
+ */
1657
+ getData() {
1658
+ return {
1659
+ session_id: this.sessionId,
1660
+ visitor_id: this.visitorId,
1661
+ url: window.location.href,
1662
+ time_on_page_ms: Date.now() - this.pageLoadTime,
1663
+ scroll_depth: this.currentScrollDepth,
1664
+ is_active: this.isVisible && this.isFocused,
1665
+ tracker_version: this.version
1666
+ };
1565
1667
  }
1566
1668
  };
1567
1669
 
@@ -1737,13 +1839,15 @@ var Loamly = (() => {
1737
1839
  }
1738
1840
 
1739
1841
  // src/core.ts
1740
- var config = { apiHost: DEFAULT_CONFIG.apiHost };
1842
+ var config = { apiHost: DEFAULT_CONFIG3.apiHost };
1741
1843
  var initialized = false;
1742
1844
  var debugMode = false;
1743
1845
  var visitorId = null;
1744
1846
  var sessionId = null;
1847
+ var workspaceId = null;
1745
1848
  var navigationTiming = null;
1746
1849
  var aiDetection = null;
1850
+ var pageStartTime = null;
1747
1851
  var behavioralClassifier = null;
1748
1852
  var behavioralMLResult = null;
1749
1853
  var focusBlurAnalyzer = null;
@@ -1763,6 +1867,28 @@ var Loamly = (() => {
1763
1867
  function endpoint(path) {
1764
1868
  return `${config.apiHost}${path}`;
1765
1869
  }
1870
+ function buildHeaders(idempotencyKey) {
1871
+ const headers = {
1872
+ "Content-Type": "application/json"
1873
+ };
1874
+ if (config.apiKey) {
1875
+ headers["X-Loamly-Api-Key"] = config.apiKey;
1876
+ }
1877
+ if (idempotencyKey) {
1878
+ headers["X-Idempotency-Key"] = idempotencyKey;
1879
+ }
1880
+ return headers;
1881
+ }
1882
+ function buildBeaconUrl(path) {
1883
+ if (!config.apiKey) return path;
1884
+ const url = new URL(path, config.apiHost);
1885
+ url.searchParams.set("api_key", config.apiKey);
1886
+ return url.toString();
1887
+ }
1888
+ function buildIdempotencyKey(prefix) {
1889
+ const base = sessionId || visitorId || "unknown";
1890
+ return `${prefix}:${base}:${Date.now()}`;
1891
+ }
1766
1892
  function init(userConfig = {}) {
1767
1893
  if (initialized) {
1768
1894
  log("Already initialized");
@@ -1771,9 +1897,13 @@ var Loamly = (() => {
1771
1897
  config = {
1772
1898
  ...config,
1773
1899
  ...userConfig,
1774
- apiHost: userConfig.apiHost || DEFAULT_CONFIG.apiHost
1900
+ apiHost: userConfig.apiHost || DEFAULT_CONFIG3.apiHost
1775
1901
  };
1902
+ workspaceId = userConfig.workspaceId ?? null;
1776
1903
  debugMode = userConfig.debug ?? false;
1904
+ if (config.apiKey && !workspaceId) {
1905
+ log("Workspace ID missing. Behavioral events require workspaceId.");
1906
+ }
1777
1907
  const features = {
1778
1908
  scroll: true,
1779
1909
  time: true,
@@ -1791,13 +1921,11 @@ var Loamly = (() => {
1791
1921
  log("Features:", features);
1792
1922
  visitorId = getVisitorId();
1793
1923
  log("Visitor ID:", visitorId);
1794
- const session = getSessionId();
1795
- sessionId = session.sessionId;
1796
- log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
1797
1924
  if (features.eventQueue) {
1798
- eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1799
- batchSize: DEFAULT_CONFIG.batchSize,
1800
- batchTimeout: DEFAULT_CONFIG.batchTimeout
1925
+ eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG3.endpoints.behavioral), {
1926
+ batchSize: DEFAULT_CONFIG3.batchSize,
1927
+ batchTimeout: DEFAULT_CONFIG3.batchTimeout,
1928
+ apiKey: config.apiKey
1801
1929
  });
1802
1930
  }
1803
1931
  navigationTiming = detectNavigationType();
@@ -1807,37 +1935,40 @@ var Loamly = (() => {
1807
1935
  log("AI detected:", aiDetection);
1808
1936
  }
1809
1937
  initialized = true;
1810
- if (!userConfig.disableAutoPageview) {
1811
- pageview();
1812
- }
1813
- if (!userConfig.disableBehavioral) {
1814
- setupAdvancedBehavioralTracking(features);
1815
- }
1816
- if (features.behavioralML) {
1817
- behavioralClassifier = new BehavioralClassifier(1e4);
1818
- behavioralClassifier.setOnClassify(handleBehavioralClassification);
1819
- setupBehavioralMLTracking();
1820
- }
1821
- if (features.focusBlur) {
1822
- focusBlurAnalyzer = new FocusBlurAnalyzer();
1823
- focusBlurAnalyzer.initTracking();
1824
- setTimeout(() => {
1825
- if (focusBlurAnalyzer) {
1826
- handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
1827
- }
1828
- }, 5e3);
1829
- }
1830
1938
  if (features.agentic) {
1831
1939
  agenticAnalyzer = new AgenticBrowserAnalyzer();
1832
1940
  agenticAnalyzer.init();
1833
1941
  }
1834
- if (features.ping && visitorId && sessionId) {
1835
- pingService = new PingService(sessionId, visitorId, VERSION, {
1836
- interval: DEFAULT_CONFIG.pingInterval,
1837
- endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping)
1838
- });
1839
- pingService.start();
1840
- }
1942
+ void initializeSession().finally(() => {
1943
+ void registerServiceWorker();
1944
+ if (!userConfig.disableAutoPageview) {
1945
+ pageview();
1946
+ }
1947
+ if (!userConfig.disableBehavioral) {
1948
+ setupAdvancedBehavioralTracking(features);
1949
+ }
1950
+ if (features.behavioralML) {
1951
+ behavioralClassifier = new BehavioralClassifier(1e4);
1952
+ behavioralClassifier.setOnClassify(handleBehavioralClassification);
1953
+ setupBehavioralMLTracking();
1954
+ }
1955
+ if (features.focusBlur) {
1956
+ focusBlurAnalyzer = new FocusBlurAnalyzer();
1957
+ focusBlurAnalyzer.initTracking();
1958
+ setTimeout(() => {
1959
+ if (focusBlurAnalyzer) {
1960
+ handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
1961
+ }
1962
+ }, 5e3);
1963
+ }
1964
+ if (features.ping && visitorId && sessionId) {
1965
+ pingService = new PingService(sessionId, visitorId, VERSION, {
1966
+ interval: DEFAULT_CONFIG3.pingInterval,
1967
+ endpoint: endpoint(DEFAULT_CONFIG3.endpoints.ping)
1968
+ });
1969
+ pingService.start();
1970
+ }
1971
+ });
1841
1972
  spaRouter = new SPARouter({
1842
1973
  onNavigate: handleSPANavigation
1843
1974
  });
@@ -1846,6 +1977,78 @@ var Loamly = (() => {
1846
1977
  reportHealth("initialized");
1847
1978
  log("Initialization complete");
1848
1979
  }
1980
+ async function registerServiceWorker() {
1981
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
1982
+ if (!config.apiKey || !workspaceId) return;
1983
+ try {
1984
+ const swUrl = new URL("/tracker/loamly-sw.js", window.location.origin);
1985
+ swUrl.searchParams.set("workspace_id", workspaceId);
1986
+ swUrl.searchParams.set("api_key", config.apiKey);
1987
+ const registration = await navigator.serviceWorker.register(swUrl.toString(), { scope: "/" });
1988
+ registration.addEventListener("updatefound", () => {
1989
+ const installing = registration.installing;
1990
+ installing?.addEventListener("statechange", () => {
1991
+ if (installing.state === "activated") {
1992
+ installing.postMessage({ type: "SKIP_WAITING" });
1993
+ }
1994
+ });
1995
+ });
1996
+ setInterval(() => {
1997
+ registration.update().catch(() => {
1998
+ });
1999
+ }, 24 * 60 * 60 * 1e3);
2000
+ } catch {
2001
+ }
2002
+ }
2003
+ async function initializeSession() {
2004
+ const now = Date.now();
2005
+ pageStartTime = now;
2006
+ try {
2007
+ const storedSession = sessionStorage.getItem("loamly_session");
2008
+ const storedStart = sessionStorage.getItem("loamly_start");
2009
+ const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG3.sessionTimeout;
2010
+ if (storedSession && storedStart) {
2011
+ const startTime = parseInt(storedStart, 10);
2012
+ const elapsed = now - startTime;
2013
+ if (elapsed > 0 && elapsed < sessionTimeout) {
2014
+ sessionId = storedSession;
2015
+ log("Session ID:", sessionId, "(existing)");
2016
+ return;
2017
+ }
2018
+ }
2019
+ } catch {
2020
+ }
2021
+ if (config.apiKey && workspaceId && visitorId) {
2022
+ try {
2023
+ const response = await safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.session), {
2024
+ method: "POST",
2025
+ headers: buildHeaders(),
2026
+ body: JSON.stringify({
2027
+ workspace_id: workspaceId,
2028
+ visitor_id: visitorId
2029
+ })
2030
+ });
2031
+ if (response?.ok) {
2032
+ const data = await response.json();
2033
+ sessionId = data.session_id || sessionId;
2034
+ const startTime = data.start_time || now;
2035
+ if (sessionId) {
2036
+ try {
2037
+ sessionStorage.setItem("loamly_session", sessionId);
2038
+ sessionStorage.setItem("loamly_start", String(startTime));
2039
+ } catch {
2040
+ }
2041
+ log("Session ID:", sessionId, "(server)");
2042
+ return;
2043
+ }
2044
+ }
2045
+ } catch {
2046
+ }
2047
+ }
2048
+ const session = getSessionId();
2049
+ sessionId = session.sessionId;
2050
+ log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
2051
+ }
1849
2052
  function setupAdvancedBehavioralTracking(features) {
1850
2053
  if (features.scroll) {
1851
2054
  scrollTracker = new ScrollTracker({
@@ -1853,8 +2056,8 @@ var Loamly = (() => {
1853
2056
  onChunkReached: (event) => {
1854
2057
  log("Scroll chunk:", event.chunk);
1855
2058
  queueEvent("scroll_depth", {
1856
- depth: event.depth,
1857
- chunk: event.chunk,
2059
+ scroll_depth: Math.round(event.depth / 100 * 100) / 100,
2060
+ milestone: Math.round(event.chunk / 100 * 100) / 100,
1858
2061
  time_to_reach_ms: event.time_to_reach_ms
1859
2062
  });
1860
2063
  }
@@ -1866,12 +2069,10 @@ var Loamly = (() => {
1866
2069
  updateIntervalMs: 1e4,
1867
2070
  // Report every 10 seconds
1868
2071
  onUpdate: (event) => {
1869
- if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
2072
+ if (event.active_time_ms >= DEFAULT_CONFIG3.timeSpentThresholdMs) {
1870
2073
  queueEvent("time_spent", {
1871
- active_time_ms: event.active_time_ms,
1872
- total_time_ms: event.total_time_ms,
1873
- idle_time_ms: event.idle_time_ms,
1874
- is_engaged: event.is_engaged
2074
+ visible_time_ms: event.total_time_ms,
2075
+ page_start_time: pageStartTime || Date.now()
1875
2076
  });
1876
2077
  }
1877
2078
  }
@@ -1882,13 +2083,22 @@ var Loamly = (() => {
1882
2083
  formTracker = new FormTracker({
1883
2084
  onFormEvent: (event) => {
1884
2085
  log("Form event:", event.event_type, event.form_id);
1885
- queueEvent(event.event_type, {
2086
+ const isSubmitEvent = event.event_type === "form_submit";
2087
+ const isSuccessEvent = event.event_type === "form_success";
2088
+ const normalizedEventType = isSubmitEvent || isSuccessEvent ? "form_submit" : "form_focus";
2089
+ const submitSource = event.submit_source || (isSuccessEvent ? "thank_you" : isSubmitEvent ? "submit" : null);
2090
+ queueEvent(normalizedEventType, {
1886
2091
  form_id: event.form_id,
1887
- form_type: event.form_type,
1888
- field_name: event.field_name,
1889
- field_type: event.field_type,
1890
- time_to_submit_ms: event.time_to_submit_ms,
1891
- is_conversion: event.is_conversion
2092
+ form_provider: event.form_type || "unknown",
2093
+ form_field_type: event.field_type || null,
2094
+ form_field_name: event.field_name || null,
2095
+ form_event_type: event.event_type,
2096
+ submit_source: submitSource,
2097
+ is_inferred: isSuccessEvent,
2098
+ time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1e3) : null,
2099
+ // LOA-482: Include captured form field values
2100
+ fields: event.fields || null,
2101
+ email_submitted: event.email_submitted || null
1892
2102
  });
1893
2103
  }
1894
2104
  });
@@ -1909,7 +2119,7 @@ var Loamly = (() => {
1909
2119
  if (link && link.href) {
1910
2120
  const isExternal = link.hostname !== window.location.hostname;
1911
2121
  queueEvent("click", {
1912
- element: "link",
2122
+ element_type: "link",
1913
2123
  href: truncateText(link.href, 200),
1914
2124
  text: truncateText(link.textContent || "", 100),
1915
2125
  is_external: isExternal
@@ -1919,15 +2129,32 @@ var Loamly = (() => {
1919
2129
  }
1920
2130
  function queueEvent(eventType, data) {
1921
2131
  if (!eventQueue) return;
1922
- eventQueue.push(eventType, {
2132
+ if (!config.apiKey) {
2133
+ log("Missing apiKey, behavioral event skipped:", eventType);
2134
+ return;
2135
+ }
2136
+ if (!workspaceId) {
2137
+ log("Missing workspaceId, behavioral event skipped:", eventType);
2138
+ return;
2139
+ }
2140
+ if (!sessionId) {
2141
+ log("Missing sessionId, behavioral event skipped:", eventType);
2142
+ return;
2143
+ }
2144
+ const idempotencyKey = buildIdempotencyKey(eventType);
2145
+ const payload = {
1923
2146
  visitor_id: visitorId,
1924
2147
  session_id: sessionId,
1925
2148
  event_type: eventType,
1926
- ...data,
1927
- url: window.location.href,
2149
+ event_data: data,
2150
+ page_url: window.location.href,
2151
+ page_path: window.location.pathname,
1928
2152
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1929
- tracker_version: VERSION
1930
- });
2153
+ tracker_version: VERSION,
2154
+ idempotency_key: idempotencyKey
2155
+ };
2156
+ payload.workspace_id = workspaceId;
2157
+ eventQueue.push(eventType, payload, buildHeaders(idempotencyKey));
1931
2158
  }
1932
2159
  function handleSPANavigation(event) {
1933
2160
  log("SPA navigation:", event.navigation_type, event.to_url);
@@ -1955,34 +2182,42 @@ var Loamly = (() => {
1955
2182
  }
1956
2183
  function setupUnloadHandlers() {
1957
2184
  const handleUnload = () => {
2185
+ if (!workspaceId || !config.apiKey || !sessionId) return;
1958
2186
  const scrollEvent = scrollTracker?.getFinalEvent();
1959
2187
  if (scrollEvent) {
1960
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
2188
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2189
+ workspace_id: workspaceId,
1961
2190
  visitor_id: visitorId,
1962
2191
  session_id: sessionId,
1963
2192
  event_type: "scroll_depth_final",
1964
- data: scrollEvent,
1965
- url: window.location.href
2193
+ event_data: {
2194
+ scroll_depth: Math.round(scrollEvent.depth / 100 * 100) / 100,
2195
+ milestone: Math.round(scrollEvent.chunk / 100 * 100) / 100,
2196
+ time_to_reach_ms: scrollEvent.time_to_reach_ms
2197
+ },
2198
+ page_url: window.location.href,
2199
+ page_path: window.location.pathname,
2200
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2201
+ tracker_version: VERSION,
2202
+ idempotency_key: buildIdempotencyKey("scroll_depth_final")
1966
2203
  });
1967
2204
  }
1968
2205
  const timeEvent = timeTracker?.getFinalMetrics();
1969
2206
  if (timeEvent) {
1970
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1971
- visitor_id: visitorId,
1972
- session_id: sessionId,
1973
- event_type: "time_spent_final",
1974
- data: timeEvent,
1975
- url: window.location.href
1976
- });
1977
- }
1978
- const agenticResult = agenticAnalyzer?.getResult();
1979
- if (agenticResult && agenticResult.agenticProbability > 0) {
1980
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
2207
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2208
+ workspace_id: workspaceId,
1981
2209
  visitor_id: visitorId,
1982
2210
  session_id: sessionId,
1983
- event_type: "agentic_detection",
1984
- data: agenticResult,
1985
- url: window.location.href
2211
+ event_type: "time_spent",
2212
+ event_data: {
2213
+ visible_time_ms: timeEvent.total_time_ms,
2214
+ page_start_time: pageStartTime || Date.now()
2215
+ },
2216
+ page_url: window.location.href,
2217
+ page_path: window.location.pathname,
2218
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2219
+ tracker_version: VERSION,
2220
+ idempotency_key: buildIdempotencyKey("time_spent")
1986
2221
  });
1987
2222
  }
1988
2223
  eventQueue?.flushBeacon();
@@ -2005,30 +2240,55 @@ var Loamly = (() => {
2005
2240
  log("Not initialized, call init() first");
2006
2241
  return;
2007
2242
  }
2243
+ if (!config.apiKey) {
2244
+ log("Missing apiKey, pageview skipped");
2245
+ return;
2246
+ }
2008
2247
  const url = customUrl || window.location.href;
2248
+ const utmParams = extractUTMParams(url);
2249
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2250
+ const idempotencyKey = buildIdempotencyKey("visit");
2251
+ const agenticResult = agenticAnalyzer?.getResult();
2252
+ const pagePath = (() => {
2253
+ try {
2254
+ return new URL(url).pathname;
2255
+ } catch {
2256
+ return window.location.pathname;
2257
+ }
2258
+ })();
2009
2259
  const payload = {
2010
2260
  visitor_id: visitorId,
2011
2261
  session_id: sessionId,
2012
- url,
2262
+ page_url: url,
2263
+ page_path: pagePath,
2013
2264
  referrer: document.referrer || null,
2014
2265
  title: document.title || null,
2015
- utm_source: extractUTMParams(url).utm_source || null,
2016
- utm_medium: extractUTMParams(url).utm_medium || null,
2017
- utm_campaign: extractUTMParams(url).utm_campaign || null,
2266
+ utm_source: utmParams.utm_source || null,
2267
+ utm_medium: utmParams.utm_medium || null,
2268
+ utm_campaign: utmParams.utm_campaign || null,
2269
+ utm_term: utmParams.utm_term || null,
2270
+ utm_content: utmParams.utm_content || null,
2018
2271
  user_agent: navigator.userAgent,
2019
2272
  screen_width: window.screen?.width,
2020
2273
  screen_height: window.screen?.height,
2021
2274
  language: navigator.language,
2022
2275
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
2023
2276
  tracker_version: VERSION,
2277
+ event_type: "pageview",
2278
+ event_data: null,
2279
+ timestamp,
2024
2280
  navigation_timing: navigationTiming,
2025
2281
  ai_platform: aiDetection?.platform || null,
2026
- is_ai_referrer: aiDetection?.isAI || false
2282
+ is_ai_referrer: aiDetection?.isAI || false,
2283
+ agentic_detection: agenticResult || null
2027
2284
  };
2285
+ if (workspaceId) {
2286
+ payload.workspace_id = workspaceId;
2287
+ }
2028
2288
  log("Pageview:", payload);
2029
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
2289
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.visit), {
2030
2290
  method: "POST",
2031
- headers: { "Content-Type": "application/json" },
2291
+ headers: buildHeaders(idempotencyKey),
2032
2292
  body: JSON.stringify(payload)
2033
2293
  });
2034
2294
  }
@@ -2037,6 +2297,11 @@ var Loamly = (() => {
2037
2297
  log("Not initialized, call init() first");
2038
2298
  return;
2039
2299
  }
2300
+ if (!config.apiKey) {
2301
+ log("Missing apiKey, event skipped:", eventName);
2302
+ return;
2303
+ }
2304
+ const idempotencyKey = buildIdempotencyKey(`event:${eventName}`);
2040
2305
  const payload = {
2041
2306
  visitor_id: visitorId,
2042
2307
  session_id: sessionId,
@@ -2045,14 +2310,19 @@ var Loamly = (() => {
2045
2310
  properties: options.properties || {},
2046
2311
  revenue: options.revenue,
2047
2312
  currency: options.currency || "USD",
2048
- url: window.location.href,
2313
+ page_url: window.location.href,
2314
+ referrer: document.referrer || null,
2049
2315
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2050
- tracker_version: VERSION
2316
+ tracker_version: VERSION,
2317
+ idempotency_key: idempotencyKey
2051
2318
  };
2319
+ if (workspaceId) {
2320
+ payload.workspace_id = workspaceId;
2321
+ }
2052
2322
  log("Event:", eventName, payload);
2053
2323
  safeFetch(endpoint("/api/ingest/event"), {
2054
2324
  method: "POST",
2055
- headers: { "Content-Type": "application/json" },
2325
+ headers: buildHeaders(idempotencyKey),
2056
2326
  body: JSON.stringify(payload)
2057
2327
  });
2058
2328
  }
@@ -2064,17 +2334,26 @@ var Loamly = (() => {
2064
2334
  log("Not initialized, call init() first");
2065
2335
  return;
2066
2336
  }
2337
+ if (!config.apiKey) {
2338
+ log("Missing apiKey, identify skipped");
2339
+ return;
2340
+ }
2067
2341
  log("Identify:", userId, traits);
2342
+ const idempotencyKey = buildIdempotencyKey("identify");
2068
2343
  const payload = {
2069
2344
  visitor_id: visitorId,
2070
2345
  session_id: sessionId,
2071
2346
  user_id: userId,
2072
2347
  traits,
2073
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2348
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2349
+ idempotency_key: idempotencyKey
2074
2350
  };
2351
+ if (workspaceId) {
2352
+ payload.workspace_id = workspaceId;
2353
+ }
2075
2354
  safeFetch(endpoint("/api/ingest/identify"), {
2076
2355
  method: "POST",
2077
- headers: { "Content-Type": "application/json" },
2356
+ headers: buildHeaders(idempotencyKey),
2078
2357
  body: JSON.stringify(payload)
2079
2358
  });
2080
2359
  }
@@ -2205,14 +2484,15 @@ var Loamly = (() => {
2205
2484
  return initialized;
2206
2485
  }
2207
2486
  function reportHealth(status, errorMessage) {
2208
- if (!config.apiKey) return;
2209
2487
  try {
2210
2488
  const healthData = {
2211
- workspace_id: config.apiKey,
2489
+ workspace_id: workspaceId,
2490
+ visitor_id: visitorId,
2491
+ session_id: sessionId,
2212
2492
  status,
2213
2493
  error_message: errorMessage || null,
2214
- version: VERSION,
2215
- url: typeof window !== "undefined" ? window.location.href : null,
2494
+ tracker_version: VERSION,
2495
+ page_url: typeof window !== "undefined" ? window.location.href : null,
2216
2496
  user_agent: typeof navigator !== "undefined" ? navigator.userAgent : null,
2217
2497
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2218
2498
  features: {
@@ -2227,7 +2507,7 @@ var Loamly = (() => {
2227
2507
  event_queue: !!eventQueue
2228
2508
  }
2229
2509
  };
2230
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.health), {
2510
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.health), {
2231
2511
  method: "POST",
2232
2512
  headers: { "Content-Type": "application/json" },
2233
2513
  body: JSON.stringify(healthData)
@@ -2271,14 +2551,28 @@ var Loamly = (() => {
2271
2551
  debugMode = enabled;
2272
2552
  log("Debug mode:", enabled ? "enabled" : "disabled");
2273
2553
  }
2554
+ function trackBehavioral(eventType, eventData) {
2555
+ if (!initialized) {
2556
+ log("Not initialized, trackBehavioral skipped:", eventType);
2557
+ return;
2558
+ }
2559
+ queueEvent(eventType, eventData);
2560
+ }
2561
+ function getCurrentWorkspaceId() {
2562
+ return workspaceId;
2563
+ }
2274
2564
  var loamly = {
2275
2565
  init,
2276
2566
  pageview,
2277
2567
  track,
2568
+ trackBehavioral,
2569
+ // NEW: For secondary modules/plugins
2278
2570
  conversion,
2279
2571
  identify,
2280
2572
  getSessionId: getCurrentSessionId,
2281
2573
  getVisitorId: getCurrentVisitorId,
2574
+ getWorkspaceId: getCurrentWorkspaceId,
2575
+ // NEW: For debugging/introspection
2282
2576
  getAIDetection: getAIDetectionResult,
2283
2577
  getNavigationTiming: getNavigationTimingResult,
2284
2578
  getBehavioralML: getBehavioralMLResult,
@@ -2308,16 +2602,17 @@ var Loamly = (() => {
2308
2602
  }
2309
2603
  async function resolveWorkspaceConfig(domain) {
2310
2604
  try {
2311
- const response = await fetch(`${DEFAULT_CONFIG.apiHost}${DEFAULT_CONFIG.endpoints.resolve}?domain=${encodeURIComponent(domain)}`);
2605
+ const response = await fetch(`${DEFAULT_CONFIG3.apiHost}${DEFAULT_CONFIG3.endpoints.resolve}?d=${encodeURIComponent(domain)}`);
2312
2606
  if (!response.ok) {
2313
2607
  console.warn("[Loamly] Failed to resolve workspace for domain:", domain);
2314
2608
  return null;
2315
2609
  }
2316
2610
  const data = await response.json();
2317
- if (data.workspace_id) {
2611
+ if (data.workspace_id && data.public_key) {
2318
2612
  return {
2319
- apiKey: data.workspace_api_key,
2320
- apiHost: DEFAULT_CONFIG.apiHost
2613
+ apiKey: data.public_key,
2614
+ workspaceId: data.workspace_id,
2615
+ apiHost: DEFAULT_CONFIG3.apiHost
2321
2616
  };
2322
2617
  }
2323
2618
  return null;
@@ -2334,6 +2629,9 @@ var Loamly = (() => {
2334
2629
  if (script.dataset.apiKey) {
2335
2630
  config2.apiKey = script.dataset.apiKey;
2336
2631
  }
2632
+ if (script.dataset.workspaceId) {
2633
+ config2.workspaceId = script.dataset.workspaceId;
2634
+ }
2337
2635
  if (script.dataset.apiHost) {
2338
2636
  config2.apiHost = script.dataset.apiHost;
2339
2637
  }
@@ -2346,7 +2644,7 @@ var Loamly = (() => {
2346
2644
  if (script.dataset.disableBehavioral === "true") {
2347
2645
  config2.disableBehavioral = true;
2348
2646
  }
2349
- if (config2.apiKey) {
2647
+ if (config2.apiKey || config2.workspaceId) {
2350
2648
  return config2;
2351
2649
  }
2352
2650
  }
@@ -2367,6 +2665,16 @@ var Loamly = (() => {
2367
2665
  loamly.init(dataConfig);
2368
2666
  return;
2369
2667
  }
2668
+ const urlParams = new URLSearchParams(window.location.search);
2669
+ const apiKeyParam = urlParams.get("api_key");
2670
+ const workspaceIdParam = urlParams.get("workspace_id");
2671
+ if (apiKeyParam || workspaceIdParam) {
2672
+ loamly.init({
2673
+ apiKey: apiKeyParam || void 0,
2674
+ workspaceId: workspaceIdParam || void 0
2675
+ });
2676
+ return;
2677
+ }
2370
2678
  const currentDomain = window.location.hostname;
2371
2679
  if (currentDomain && currentDomain !== "localhost") {
2372
2680
  const resolvedConfig = await resolveWorkspaceConfig(currentDomain);