@loamly/tracker 2.1.1 → 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.1";
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()
269
- };
270
- }
271
- /**
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
288
- */
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();
295
- }
296
- /**
297
- * Record mouse movement
298
- */
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();
305
- }
306
- /**
307
- * Record form field interaction start
308
- */
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
- }
314
- }
315
- /**
316
- * Record form field interaction end
317
- */
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();
322
- }
323
- this.checkAndClassify();
148
+ };
149
+ this.config = {
150
+ ...DEFAULT_CONFIG,
151
+ ...config2,
152
+ sensitiveFields: [
153
+ ...DEFAULT_CONFIG.sensitiveFields,
154
+ ...config2.sensitiveFields || []
155
+ ]
156
+ };
324
157
  }
325
158
  /**
326
- * Record focus/blur event
159
+ * Start tracking forms
327
160
  */
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);
332
- }
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();
333
168
  }
334
169
  /**
335
- * Check if we have enough data and classify
170
+ * Stop tracking
336
171
  */
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();
172
+ stop() {
173
+ document.removeEventListener("focusin", this.handleFocusIn);
174
+ document.removeEventListener("submit", this.handleSubmit);
175
+ document.removeEventListener("click", this.handleClick);
176
+ this.mutationObserver?.disconnect();
344
177
  }
345
178
  /**
346
- * Force classification (for beforeunload)
179
+ * Get forms that had interaction
347
180
  */
348
- forceClassify() {
349
- if (this.classified) return this.result;
350
- return this.classify();
181
+ getInteractedForms() {
182
+ return Array.from(this.interactedForms);
351
183
  }
352
184
  /**
353
- * Perform classification
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
354
189
  */
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);
190
+ captureFormFields(form) {
191
+ const fields = [];
192
+ let emailSubmitted;
193
+ if (!this.config.captureFieldValues) {
194
+ return { fields, emailSubmitted };
383
195
  }
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);
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);
396
220
  }
397
- return this.result;
221
+ return { fields, emailSubmitted };
398
222
  }
399
223
  /**
400
- * Extract behavioral signals from collected data
224
+ * Check if a field contains an email
401
225
  */
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");
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;
231
+ }
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
+ }
414
251
  }
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);
252
+ });
253
+ this.mutationObserver.observe(document.body, {
254
+ childList: true,
255
+ subtree: true
256
+ });
257
+ }
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
+ });
271
+ }
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
+ });
423
283
  }
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");
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;
434
299
  }
435
300
  }
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");
463
- }
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";
464
323
  }
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");
474
- }
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";
475
332
  }
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
- }
333
+ if (element.tagName === "FORM") {
334
+ return "native";
488
335
  }
489
- return signals;
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;
586
- }
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;
591
- }
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;
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;
598
417
  }
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;
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);
604
430
  }
605
431
  }
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";
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;
614
449
  }
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
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();
477
+ }
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() {
@@ -834,772 +785,885 @@ var Loamly = (() => {
834
785
  };
835
786
  }
836
787
  /**
837
- * Cleanup resources
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();
887
+ }
888
+ /**
889
+ * Record a scroll event
890
+ */
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();
897
+ }
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();
907
+ }
908
+ /**
909
+ * Record form field interaction start
910
+ */
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
+ }
916
+ }
917
+ /**
918
+ * Record form field interaction end
919
+ */
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();
924
+ }
925
+ this.checkAndClassify();
926
+ }
927
+ /**
928
+ * Record focus/blur event
838
929
  */
839
- destroy() {
840
- this.cometDetector.destroy();
841
- this.mouseAnalyzer.destroy();
842
- }
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();
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);
934
+ }
861
935
  }
862
936
  /**
863
- * Add event to queue
937
+ * Check if we have enough data and classify
864
938
  */
865
- push(type, payload, headers) {
866
- const event = {
867
- id: this.generateId(),
868
- type,
869
- payload,
870
- headers,
871
- timestamp: Date.now(),
872
- retries: 0
873
- };
874
- this.queue.push(event);
875
- this.saveToStorage();
876
- this.scheduleBatch();
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();
877
946
  }
878
947
  /**
879
- * Force flush all events immediately
948
+ * Force classification (for beforeunload)
880
949
  */
881
- async flush() {
882
- if (this.isFlushing || this.queue.length === 0) return;
883
- this.isFlushing = true;
884
- this.clearBatchTimer();
885
- try {
886
- const events = [...this.queue];
887
- this.queue = [];
888
- await this.sendBatch(events);
889
- } finally {
890
- this.isFlushing = false;
891
- this.saveToStorage();
892
- }
950
+ forceClassify() {
951
+ if (this.classified) return this.result;
952
+ return this.classify();
893
953
  }
894
954
  /**
895
- * Flush using sendBeacon (for unload events)
955
+ * Perform classification
896
956
  */
897
- flushBeacon() {
898
- if (this.queue.length === 0) return true;
899
- const baseUrl = this.config.apiKey ? `${this.endpoint}?api_key=${encodeURIComponent(this.config.apiKey)}` : this.endpoint;
900
- let allSent = true;
901
- for (const event of this.queue) {
902
- const payload = {
903
- ...event.payload,
904
- _queue_id: event.id,
905
- _queue_timestamp: event.timestamp
906
- };
907
- const success = navigator.sendBeacon?.(baseUrl, JSON.stringify(payload)) ?? false;
908
- if (!success) {
909
- allSent = false;
910
- break;
911
- }
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);
912
967
  }
913
- if (allSent) {
914
- this.queue = [];
915
- this.clearStorage();
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);
916
985
  }
917
- return allSent;
986
+ this.result = {
987
+ classification,
988
+ humanProbability,
989
+ aiProbability,
990
+ confidence,
991
+ signals,
992
+ timestamp: Date.now(),
993
+ sessionDurationMs: sessionDuration
994
+ };
995
+ this.classified = true;
996
+ if (this.onClassify) {
997
+ this.onClassify(this.result);
998
+ }
999
+ return this.result;
918
1000
  }
919
1001
  /**
920
- * Get current queue length
1002
+ * Extract behavioral signals from collected data
921
1003
  */
922
- get length() {
923
- return this.queue.length;
924
- }
925
- scheduleBatch() {
926
- if (this.batchTimer) return;
927
- if (this.queue.length >= this.config.batchSize) {
928
- this.flush();
929
- return;
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");
1016
+ }
930
1017
  }
931
- this.batchTimer = setTimeout(() => {
932
- this.batchTimer = null;
933
- this.flush();
934
- }, this.config.batchTimeout);
935
- }
936
- clearBatchTimer() {
937
- if (this.batchTimer) {
938
- clearTimeout(this.batchTimer);
939
- this.batchTimer = null;
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);
1025
+ }
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");
1036
+ }
940
1037
  }
941
- }
942
- async sendBatch(events) {
943
- if (events.length === 0) return;
944
- try {
945
- const results = await Promise.allSettled(
946
- events.map(async (event) => {
947
- const response = await fetch(this.endpoint, {
948
- method: "POST",
949
- headers: {
950
- "Content-Type": "application/json",
951
- ...event.headers || {}
952
- },
953
- body: JSON.stringify({
954
- ...event.payload,
955
- _queue_id: event.id,
956
- _queue_timestamp: event.timestamp
957
- })
958
- });
959
- if (!response.ok) {
960
- throw new Error(`HTTP ${response.status}`);
961
- }
962
- })
963
- );
964
- const failedEvents = events.filter((_, index) => results[index]?.status === "rejected");
965
- if (failedEvents.length > 0) {
966
- for (const event of failedEvents) {
967
- if (event.retries < this.config.maxRetries) {
968
- event.retries++;
969
- this.queue.push(event);
970
- }
971
- }
972
- const delay = this.config.retryDelayMs * Math.pow(2, failedEvents[0].retries - 1);
973
- setTimeout(() => this.flush(), delay);
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;
974
1049
  }
975
- return;
976
- } catch (error) {
977
- for (const event of events) {
978
- if (event.retries < this.config.maxRetries) {
979
- event.retries++;
980
- this.queue.push(event);
981
- }
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);
982
1059
  }
983
- if (this.queue.length > 0) {
984
- const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1);
985
- setTimeout(() => this.flush(), delay);
1060
+ const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
1061
+ if (r2 > 0.95) {
1062
+ signals.push("mouse_movement_linear");
1063
+ } else {
1064
+ signals.push("mouse_movement_curved");
1065
+ }
1066
+ }
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");
986
1076
  }
987
1077
  }
988
- }
989
- loadFromStorage() {
990
- try {
991
- const stored = localStorage.getItem(this.config.storageKey);
992
- if (stored) {
993
- const parsed = JSON.parse(stored);
994
- if (Array.isArray(parsed)) {
995
- const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
996
- this.queue = parsed.filter((e) => e.timestamp > cutoff);
997
- }
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);
998
1083
  }
999
- } catch {
1000
- }
1001
- }
1002
- saveToStorage() {
1003
- try {
1004
- if (this.queue.length > 0) {
1005
- localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1084
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
1085
+ if (avgInterval < 1e3) {
1086
+ signals.push("focus_blur_rapid");
1006
1087
  } else {
1007
- this.clearStorage();
1088
+ signals.push("focus_blur_normal");
1008
1089
  }
1009
- } catch {
1010
1090
  }
1091
+ return signals;
1011
1092
  }
1012
- clearStorage() {
1013
- try {
1014
- localStorage.removeItem(this.config.storageKey);
1015
- } catch {
1016
- }
1093
+ /**
1094
+ * Add context signals (set by tracker from external data)
1095
+ */
1096
+ addContextSignal(_signal) {
1017
1097
  }
1018
- generateId() {
1019
- return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1098
+ /**
1099
+ * Get current result (null if not yet classified)
1100
+ */
1101
+ getResult() {
1102
+ return this.result;
1103
+ }
1104
+ /**
1105
+ * Check if classification has been performed
1106
+ */
1107
+ hasClassified() {
1108
+ return this.classified;
1020
1109
  }
1021
1110
  };
1022
1111
 
1023
- // src/infrastructure/ping.ts
1024
- var PingService = class {
1025
- constructor(sessionId2, visitorId2, version, config2 = {}) {
1026
- this.intervalId = null;
1027
- this.isVisible = true;
1028
- this.isFocused = true;
1029
- this.currentScrollDepth = 0;
1030
- this.ping = async () => {
1031
- const data = this.getData();
1032
- this.config.onPing?.(data);
1033
- if (this.config.endpoint) {
1034
- try {
1035
- await fetch(this.config.endpoint, {
1036
- method: "POST",
1037
- headers: { "Content-Type": "application/json" },
1038
- body: JSON.stringify(data)
1039
- });
1040
- } catch {
1041
- }
1042
- }
1043
- };
1044
- this.handleVisibilityChange = () => {
1045
- this.isVisible = document.visibilityState === "visible";
1046
- };
1047
- this.handleFocusChange = () => {
1048
- this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1049
- if (this.intervalId && this.isVisible && this.isFocused) {
1050
- this.ping();
1051
- }
1052
- };
1053
- this.handleScroll = () => {
1054
- const scrollPercent = Math.round(
1055
- (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
1056
- );
1057
- if (scrollPercent > this.currentScrollDepth) {
1058
- this.currentScrollDepth = Math.min(scrollPercent, 100);
1059
- }
1060
- };
1061
- this.sessionId = sessionId2;
1062
- this.visitorId = visitorId2;
1063
- this.version = version;
1064
- this.pageLoadTime = Date.now();
1065
- this.isVisible = document.visibilityState === "visible";
1066
- this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1067
- this.config = {
1068
- interval: DEFAULT_CONFIG.pingInterval,
1069
- endpoint: "",
1070
- ...config2
1071
- };
1072
- document.addEventListener("visibilitychange", this.handleVisibilityChange);
1073
- window.addEventListener("focus", this.handleFocusChange);
1074
- window.addEventListener("blur", this.handleFocusChange);
1075
- window.addEventListener("scroll", this.handleScroll, { passive: 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();
1076
1120
  }
1077
1121
  /**
1078
- * Start the ping service
1122
+ * Initialize event tracking
1123
+ * Must be called after DOM is ready
1079
1124
  */
1080
- start() {
1081
- if (this.intervalId) return;
1082
- this.intervalId = setInterval(() => {
1083
- if (this.isVisible && this.isFocused) {
1084
- this.ping();
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();
1085
1141
  }
1086
- }, this.config.interval);
1087
- if (this.isVisible && this.isFocused) {
1088
- this.ping();
1089
- }
1142
+ };
1143
+ document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
1144
+ document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
1090
1145
  }
1091
1146
  /**
1092
- * Stop the ping service
1147
+ * Record a focus/blur event
1093
1148
  */
1094
- stop() {
1095
- if (this.intervalId) {
1096
- clearInterval(this.intervalId);
1097
- this.intervalId = null;
1149
+ recordEvent(type, target) {
1150
+ const event = {
1151
+ type,
1152
+ target: target?.tagName || "WINDOW",
1153
+ timestamp: performance.now()
1154
+ };
1155
+ this.sequence.push(event);
1156
+ if (this.sequence.length > 20) {
1157
+ this.sequence = this.sequence.slice(-20);
1098
1158
  }
1099
- document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1100
- window.removeEventListener("focus", this.handleFocusChange);
1101
- window.removeEventListener("blur", this.handleFocusChange);
1102
- window.removeEventListener("scroll", this.handleScroll);
1103
1159
  }
1104
1160
  /**
1105
- * Update scroll depth (called by external scroll tracker)
1161
+ * Analyze the focus/blur sequence for paste patterns
1106
1162
  */
1107
- updateScrollDepth(depth) {
1108
- if (depth > this.currentScrollDepth) {
1109
- this.currentScrollDepth = depth;
1163
+ analyze() {
1164
+ if (this.analyzed && this.result) {
1165
+ return this.result;
1110
1166
  }
1111
- }
1112
- /**
1113
- * Get current ping data
1114
- */
1115
- getData() {
1116
- return {
1117
- session_id: this.sessionId,
1118
- visitor_id: this.visitorId,
1119
- url: window.location.href,
1120
- time_on_page_ms: Date.now() - this.pageLoadTime,
1121
- scroll_depth: this.currentScrollDepth,
1122
- is_active: this.isVisible && this.isFocused,
1123
- tracker_version: this.version
1124
- };
1125
- }
1126
- };
1127
-
1128
- // src/behavioral/scroll-tracker.ts
1129
- var DEFAULT_CHUNKS = [30, 60, 90, 100];
1130
- var ScrollTracker = class {
1131
- constructor(config2 = {}) {
1132
- this.maxDepth = 0;
1133
- this.reportedChunks = /* @__PURE__ */ new Set();
1134
- this.ticking = false;
1135
- this.isVisible = true;
1136
- this.handleScroll = () => {
1137
- if (!this.ticking && this.isVisible) {
1138
- requestAnimationFrame(() => {
1139
- this.checkScrollDepth();
1140
- this.ticking = false;
1141
- });
1142
- this.ticking = true;
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;
1143
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
1144
1223
  };
1145
- this.handleVisibility = () => {
1146
- this.isVisible = document.visibilityState === "visible";
1147
- };
1148
- this.config = {
1149
- chunks: DEFAULT_CHUNKS,
1150
- ...config2
1151
- };
1152
- this.startTime = Date.now();
1224
+ this.analyzed = true;
1225
+ return this.result;
1153
1226
  }
1154
1227
  /**
1155
- * Start tracking scroll depth
1228
+ * Get current result (analyze if not done)
1156
1229
  */
1157
- start() {
1158
- window.addEventListener("scroll", this.handleScroll, { passive: true });
1159
- document.addEventListener("visibilitychange", this.handleVisibility);
1160
- this.checkScrollDepth();
1230
+ getResult() {
1231
+ return this.analyze();
1161
1232
  }
1162
1233
  /**
1163
- * Stop tracking
1234
+ * Check if analysis has been performed
1164
1235
  */
1165
- stop() {
1166
- window.removeEventListener("scroll", this.handleScroll);
1167
- document.removeEventListener("visibilitychange", this.handleVisibility);
1236
+ hasAnalyzed() {
1237
+ return this.analyzed;
1168
1238
  }
1169
1239
  /**
1170
- * Get current max scroll depth
1240
+ * Get the raw sequence for debugging
1171
1241
  */
1172
- getMaxDepth() {
1173
- return this.maxDepth;
1242
+ getSequence() {
1243
+ return [...this.sequence];
1174
1244
  }
1175
1245
  /**
1176
- * Get reported chunks
1246
+ * Reset the analyzer
1177
1247
  */
1178
- getReportedChunks() {
1179
- return Array.from(this.reportedChunks).sort((a, b) => a - b);
1248
+ reset() {
1249
+ this.sequence = [];
1250
+ this.pageLoadTime = performance.now();
1251
+ this.firstInteractionTime = null;
1252
+ this.analyzed = false;
1253
+ this.result = null;
1180
1254
  }
1181
- /**
1182
- * Get final scroll event (for unload)
1183
- */
1184
- getFinalEvent() {
1185
- const docHeight = document.documentElement.scrollHeight;
1186
- const viewportHeight = window.innerHeight;
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";
1187
1300
  return {
1188
- depth: this.maxDepth,
1189
- chunk: this.getChunkForDepth(this.maxDepth),
1190
- time_to_reach_ms: Date.now() - this.startTime,
1191
- total_height: docHeight,
1192
- viewport_height: viewportHeight
1301
+ nav_type,
1302
+ confidence: Math.round(confidence * 1e3) / 1e3,
1303
+ signals
1193
1304
  };
1305
+ } catch {
1306
+ return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
1194
1307
  }
1195
- checkScrollDepth() {
1196
- const scrollY = window.scrollY;
1197
- const viewportHeight = window.innerHeight;
1198
- const docHeight = document.documentElement.scrollHeight;
1199
- if (docHeight <= viewportHeight) {
1200
- this.updateDepth(100);
1201
- return;
1202
- }
1203
- const scrollableHeight = docHeight - viewportHeight;
1204
- const currentDepth = Math.min(100, Math.round(scrollY / scrollableHeight * 100));
1205
- 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;
1206
1326
  }
1207
- updateDepth(depth) {
1208
- if (depth <= this.maxDepth) return;
1209
- this.maxDepth = depth;
1210
- this.config.onDepthChange?.(depth);
1211
- for (const chunk of this.config.chunks) {
1212
- if (depth >= chunk && !this.reportedChunks.has(chunk)) {
1213
- this.reportedChunks.add(chunk);
1214
- 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
+ };
1215
1339
  }
1216
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;
1217
1354
  }
1218
- reportChunk(chunk) {
1219
- const docHeight = document.documentElement.scrollHeight;
1220
- const viewportHeight = window.innerHeight;
1221
- const event = {
1222
- depth: this.maxDepth,
1223
- chunk,
1224
- time_to_reach_ms: Date.now() - this.startTime,
1225
- total_height: docHeight,
1226
- viewport_height: viewportHeight
1227
- };
1228
- this.config.onChunkReached?.(event);
1229
- }
1230
- getChunkForDepth(depth) {
1231
- const chunks = this.config.chunks.sort((a, b) => b - a);
1232
- for (const chunk of chunks) {
1233
- 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
+ }
1234
1380
  }
1235
- return 0;
1381
+ return null;
1382
+ } catch {
1383
+ return null;
1236
1384
  }
1237
- };
1385
+ }
1238
1386
 
1239
- // src/behavioral/time-tracker.ts
1240
- var DEFAULT_CONFIG2 = {
1241
- idleThresholdMs: 3e4,
1242
- // 30 seconds
1243
- updateIntervalMs: 5e3
1244
- // 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"
1245
1394
  };
1246
- var TimeTracker = class {
1247
- constructor(config2 = {}) {
1248
- this.activeTime = 0;
1249
- this.idleTime = 0;
1250
- this.isVisible = true;
1251
- this.isIdle = false;
1252
- this.updateInterval = null;
1253
- this.idleCheckInterval = null;
1254
- this.handleVisibility = () => {
1255
- const wasVisible = this.isVisible;
1256
- this.isVisible = document.visibilityState === "visible";
1257
- if (wasVisible && !this.isVisible) {
1258
- this.updateTimes();
1259
- } else if (!wasVisible && this.isVisible) {
1260
- this.lastUpdateTime = Date.now();
1261
- this.lastActivityTime = Date.now();
1262
- }
1263
- };
1264
- this.handleActivity = () => {
1265
- const now = Date.now();
1266
- if (this.isIdle) {
1267
- this.isIdle = false;
1268
- }
1269
- this.lastActivityTime = now;
1270
- };
1271
- this.config = { ...DEFAULT_CONFIG2, ...config2 };
1272
- this.startTime = Date.now();
1273
- this.lastActivityTime = this.startTime;
1274
- 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();
1275
1403
  }
1276
1404
  /**
1277
- * Start tracking time
1405
+ * Add event to queue
1278
1406
  */
1279
- start() {
1280
- document.addEventListener("visibilitychange", this.handleVisibility);
1281
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1282
- activityEvents.forEach((event) => {
1283
- document.addEventListener(event, this.handleActivity, { passive: true });
1284
- });
1285
- this.updateInterval = setInterval(() => {
1286
- this.update();
1287
- }, this.config.updateIntervalMs);
1288
- this.idleCheckInterval = setInterval(() => {
1289
- this.checkIdle();
1290
- }, 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();
1291
1419
  }
1292
1420
  /**
1293
- * Stop tracking
1421
+ * Force flush all events immediately
1294
1422
  */
1295
- stop() {
1296
- document.removeEventListener("visibilitychange", this.handleVisibility);
1297
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1298
- activityEvents.forEach((event) => {
1299
- document.removeEventListener(event, this.handleActivity);
1300
- });
1301
- if (this.updateInterval) {
1302
- clearInterval(this.updateInterval);
1303
- this.updateInterval = null;
1304
- }
1305
- if (this.idleCheckInterval) {
1306
- clearInterval(this.idleCheckInterval);
1307
- 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();
1308
1434
  }
1309
1435
  }
1310
1436
  /**
1311
- * Get current time metrics
1437
+ * Flush using sendBeacon (for unload events)
1312
1438
  */
1313
- getMetrics() {
1314
- this.updateTimes();
1315
- return {
1316
- active_time_ms: this.activeTime,
1317
- total_time_ms: Date.now() - this.startTime,
1318
- idle_time_ms: this.idleTime,
1319
- is_engaged: !this.isIdle && this.isVisible
1320
- };
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;
1321
1460
  }
1322
1461
  /**
1323
- * Get final metrics (for unload)
1462
+ * Get current queue length
1324
1463
  */
1325
- getFinalMetrics() {
1326
- this.updateTimes();
1327
- return this.getMetrics();
1464
+ get length() {
1465
+ return this.queue.length;
1328
1466
  }
1329
- checkIdle() {
1330
- const now = Date.now();
1331
- const timeSinceActivity = now - this.lastActivityTime;
1332
- if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
1333
- this.isIdle = true;
1467
+ scheduleBatch() {
1468
+ if (this.batchTimer) return;
1469
+ if (this.queue.length >= this.config.batchSize) {
1470
+ this.flush();
1471
+ return;
1334
1472
  }
1473
+ this.batchTimer = setTimeout(() => {
1474
+ this.batchTimer = null;
1475
+ this.flush();
1476
+ }, this.config.batchTimeout);
1335
1477
  }
1336
- updateTimes() {
1337
- const now = Date.now();
1338
- const elapsed = now - this.lastUpdateTime;
1339
- if (this.isVisible) {
1340
- if (this.isIdle) {
1341
- this.idleTime += elapsed;
1342
- } else {
1343
- this.activeTime += 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);
1344
1528
  }
1345
1529
  }
1346
- this.lastUpdateTime = now;
1347
1530
  }
1348
- update() {
1349
- if (!this.isVisible) return;
1350
- this.updateTimes();
1351
- this.config.onUpdate?.(this.getMetrics());
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
+ }
1352
1543
  }
1353
- };
1354
-
1355
- // src/behavioral/form-tracker.ts
1356
- var DEFAULT_CONFIG3 = {
1357
- sensitiveFields: [
1358
- "password",
1359
- "pwd",
1360
- "pass",
1361
- "credit",
1362
- "card",
1363
- "cvv",
1364
- "cvc",
1365
- "ssn",
1366
- "social",
1367
- "secret",
1368
- "token",
1369
- "key"
1370
- ],
1371
- trackableFields: [
1372
- "email",
1373
- "name",
1374
- "phone",
1375
- "company",
1376
- "first",
1377
- "last",
1378
- "city",
1379
- "country"
1380
- ],
1381
- thankYouPatterns: [
1382
- /thank[-_]?you/i,
1383
- /success/i,
1384
- /confirmation/i,
1385
- /submitted/i,
1386
- /complete/i
1387
- ]
1388
- };
1389
- var FormTracker = class {
1390
- constructor(config2 = {}) {
1391
- this.formStartTimes = /* @__PURE__ */ new Map();
1392
- this.interactedForms = /* @__PURE__ */ new Set();
1393
- this.mutationObserver = null;
1394
- this.handleFocusIn = (e) => {
1395
- const target = e.target;
1396
- if (!this.isFormField(target)) return;
1397
- const form = target.closest("form");
1398
- const formId = this.getFormId(form || target);
1399
- if (!this.formStartTimes.has(formId)) {
1400
- this.formStartTimes.set(formId, Date.now());
1401
- this.interactedForms.add(formId);
1402
- this.emitEvent({
1403
- event_type: "form_start",
1404
- form_id: formId,
1405
- form_type: this.detectFormType(form || target)
1406
- });
1544
+ saveToStorage() {
1545
+ try {
1546
+ if (this.queue.length > 0) {
1547
+ localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1548
+ } else {
1549
+ this.clearStorage();
1407
1550
  }
1408
- const fieldName = this.getFieldName(target);
1409
- if (fieldName && !this.isSensitiveField(fieldName)) {
1410
- this.emitEvent({
1411
- event_type: "form_field",
1412
- form_id: formId,
1413
- form_type: this.detectFormType(form || target),
1414
- field_name: this.sanitizeFieldName(fieldName),
1415
- field_type: target.type || target.tagName.toLowerCase()
1416
- });
1551
+ } catch {
1552
+ }
1553
+ }
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)}`;
1562
+ }
1563
+ };
1564
+
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
+ }
1417
1584
  }
1418
1585
  };
1419
- this.handleSubmit = (e) => {
1420
- const form = e.target;
1421
- if (!form || form.tagName !== "FORM") return;
1422
- const formId = this.getFormId(form);
1423
- const startTime = this.formStartTimes.get(formId);
1424
- this.emitEvent({
1425
- event_type: "form_submit",
1426
- form_id: formId,
1427
- form_type: this.detectFormType(form),
1428
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1429
- is_conversion: true,
1430
- submit_source: "submit"
1431
- });
1586
+ this.handleVisibilityChange = () => {
1587
+ this.isVisible = document.visibilityState === "visible";
1432
1588
  };
1433
- this.handleClick = (e) => {
1434
- const target = e.target;
1435
- if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
1436
- const form = target.closest("form");
1437
- if (form && form.classList.contains("hs-form")) {
1438
- const formId = this.getFormId(form);
1439
- const startTime = this.formStartTimes.get(formId);
1440
- this.emitEvent({
1441
- event_type: "form_submit",
1442
- form_id: formId,
1443
- form_type: "hubspot",
1444
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1445
- is_conversion: true,
1446
- submit_source: "click"
1447
- });
1448
- }
1589
+ this.handleFocusChange = () => {
1590
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1591
+ if (this.intervalId && this.isVisible && this.isFocused) {
1592
+ this.ping();
1449
1593
  }
1450
- if (target.closest('[data-qa="submit-button"]')) {
1451
- this.emitEvent({
1452
- event_type: "form_submit",
1453
- form_id: "typeform_embed",
1454
- form_type: "typeform",
1455
- is_conversion: true,
1456
- submit_source: "click"
1457
- });
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);
1458
1601
  }
1459
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;
1460
1609
  this.config = {
1461
- ...DEFAULT_CONFIG3,
1462
- ...config2,
1463
- sensitiveFields: [
1464
- ...DEFAULT_CONFIG3.sensitiveFields,
1465
- ...config2.sensitiveFields || []
1466
- ]
1610
+ interval: DEFAULT_CONFIG3.pingInterval,
1611
+ endpoint: "",
1612
+ ...config2
1467
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 });
1468
1618
  }
1469
1619
  /**
1470
- * Start tracking forms
1620
+ * Start the ping service
1471
1621
  */
1472
1622
  start() {
1473
- document.addEventListener("focusin", this.handleFocusIn, { passive: true });
1474
- document.addEventListener("submit", this.handleSubmit);
1475
- document.addEventListener("click", this.handleClick, { passive: true });
1476
- this.startMutationObserver();
1477
- this.checkThankYouPage();
1478
- 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
+ }
1479
1632
  }
1480
1633
  /**
1481
- * Stop tracking
1634
+ * Stop the ping service
1482
1635
  */
1483
1636
  stop() {
1484
- document.removeEventListener("focusin", this.handleFocusIn);
1485
- document.removeEventListener("submit", this.handleSubmit);
1486
- document.removeEventListener("click", this.handleClick);
1487
- 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);
1488
1645
  }
1489
1646
  /**
1490
- * Get forms that had interaction
1647
+ * Update scroll depth (called by external scroll tracker)
1491
1648
  */
1492
- getInteractedForms() {
1493
- return Array.from(this.interactedForms);
1494
- }
1495
- startMutationObserver() {
1496
- this.mutationObserver = new MutationObserver((mutations) => {
1497
- for (const mutation of mutations) {
1498
- for (const node of mutation.addedNodes) {
1499
- if (node instanceof HTMLElement) {
1500
- if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
1501
- this.trackEmbeddedForm(node, "hubspot");
1502
- }
1503
- if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
1504
- this.trackEmbeddedForm(node, "typeform");
1505
- }
1506
- if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
1507
- this.trackEmbeddedForm(node, "jotform");
1508
- }
1509
- if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
1510
- this.trackEmbeddedForm(node, "gravity");
1511
- }
1512
- }
1513
- }
1514
- }
1515
- });
1516
- this.mutationObserver.observe(document.body, {
1517
- childList: true,
1518
- subtree: true
1519
- });
1520
- }
1521
- scanForEmbeddedForms() {
1522
- document.querySelectorAll(".hs-form").forEach((form) => {
1523
- this.trackEmbeddedForm(form, "hubspot");
1524
- });
1525
- document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
1526
- this.trackEmbeddedForm(form, "typeform");
1527
- });
1528
- document.querySelectorAll(".jotform-form").forEach((form) => {
1529
- this.trackEmbeddedForm(form, "jotform");
1530
- });
1531
- document.querySelectorAll(".gform_wrapper").forEach((form) => {
1532
- this.trackEmbeddedForm(form, "gravity");
1533
- });
1534
- }
1535
- trackEmbeddedForm(element, type) {
1536
- const formId = `${type}_${this.getFormId(element)}`;
1537
- element.addEventListener("focusin", () => {
1538
- if (!this.formStartTimes.has(formId)) {
1539
- this.formStartTimes.set(formId, Date.now());
1540
- this.interactedForms.add(formId);
1541
- this.emitEvent({
1542
- event_type: "form_start",
1543
- form_id: formId,
1544
- form_type: type
1545
- });
1546
- }
1547
- }, { passive: true });
1548
- }
1549
- checkThankYouPage() {
1550
- const url = window.location.href.toLowerCase();
1551
- const title = document.title.toLowerCase();
1552
- for (const pattern of this.config.thankYouPatterns) {
1553
- if (pattern.test(url) || pattern.test(title)) {
1554
- this.emitEvent({
1555
- event_type: "form_success",
1556
- form_id: "page_conversion",
1557
- form_type: "unknown",
1558
- is_conversion: true,
1559
- submit_source: "thank_you"
1560
- });
1561
- break;
1562
- }
1563
- }
1564
- }
1565
- isFormField(element) {
1566
- const tagName = element.tagName;
1567
- return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
1568
- }
1569
- getFormId(element) {
1570
- if (!element) return "unknown";
1571
- return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
1572
- }
1573
- getFieldName(input) {
1574
- return input.name || input.id || input.getAttribute("data-name") || "";
1575
- }
1576
- isSensitiveField(fieldName) {
1577
- const lowerName = fieldName.toLowerCase();
1578
- return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
1579
- }
1580
- sanitizeFieldName(fieldName) {
1581
- return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
1582
- }
1583
- detectFormType(element) {
1584
- if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
1585
- return "hubspot";
1586
- }
1587
- if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
1588
- return "typeform";
1589
- }
1590
- if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
1591
- return "jotform";
1592
- }
1593
- if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
1594
- return "gravity";
1595
- }
1596
- if (element.tagName === "FORM") {
1597
- return "native";
1649
+ updateScrollDepth(depth) {
1650
+ if (depth > this.currentScrollDepth) {
1651
+ this.currentScrollDepth = depth;
1598
1652
  }
1599
- return "unknown";
1600
1653
  }
1601
- emitEvent(event) {
1602
- 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
+ };
1603
1667
  }
1604
1668
  };
1605
1669
 
@@ -1775,7 +1839,7 @@ var Loamly = (() => {
1775
1839
  }
1776
1840
 
1777
1841
  // src/core.ts
1778
- var config = { apiHost: DEFAULT_CONFIG.apiHost };
1842
+ var config = { apiHost: DEFAULT_CONFIG3.apiHost };
1779
1843
  var initialized = false;
1780
1844
  var debugMode = false;
1781
1845
  var visitorId = null;
@@ -1833,7 +1897,7 @@ var Loamly = (() => {
1833
1897
  config = {
1834
1898
  ...config,
1835
1899
  ...userConfig,
1836
- apiHost: userConfig.apiHost || DEFAULT_CONFIG.apiHost
1900
+ apiHost: userConfig.apiHost || DEFAULT_CONFIG3.apiHost
1837
1901
  };
1838
1902
  workspaceId = userConfig.workspaceId ?? null;
1839
1903
  debugMode = userConfig.debug ?? false;
@@ -1858,9 +1922,9 @@ var Loamly = (() => {
1858
1922
  visitorId = getVisitorId();
1859
1923
  log("Visitor ID:", visitorId);
1860
1924
  if (features.eventQueue) {
1861
- eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1862
- batchSize: DEFAULT_CONFIG.batchSize,
1863
- batchTimeout: DEFAULT_CONFIG.batchTimeout,
1925
+ eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG3.endpoints.behavioral), {
1926
+ batchSize: DEFAULT_CONFIG3.batchSize,
1927
+ batchTimeout: DEFAULT_CONFIG3.batchTimeout,
1864
1928
  apiKey: config.apiKey
1865
1929
  });
1866
1930
  }
@@ -1899,8 +1963,8 @@ var Loamly = (() => {
1899
1963
  }
1900
1964
  if (features.ping && visitorId && sessionId) {
1901
1965
  pingService = new PingService(sessionId, visitorId, VERSION, {
1902
- interval: DEFAULT_CONFIG.pingInterval,
1903
- endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping)
1966
+ interval: DEFAULT_CONFIG3.pingInterval,
1967
+ endpoint: endpoint(DEFAULT_CONFIG3.endpoints.ping)
1904
1968
  });
1905
1969
  pingService.start();
1906
1970
  }
@@ -1942,7 +2006,7 @@ var Loamly = (() => {
1942
2006
  try {
1943
2007
  const storedSession = sessionStorage.getItem("loamly_session");
1944
2008
  const storedStart = sessionStorage.getItem("loamly_start");
1945
- const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG.sessionTimeout;
2009
+ const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG3.sessionTimeout;
1946
2010
  if (storedSession && storedStart) {
1947
2011
  const startTime = parseInt(storedStart, 10);
1948
2012
  const elapsed = now - startTime;
@@ -1956,7 +2020,7 @@ var Loamly = (() => {
1956
2020
  }
1957
2021
  if (config.apiKey && workspaceId && visitorId) {
1958
2022
  try {
1959
- const response = await safeFetch(endpoint(DEFAULT_CONFIG.endpoints.session), {
2023
+ const response = await safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.session), {
1960
2024
  method: "POST",
1961
2025
  headers: buildHeaders(),
1962
2026
  body: JSON.stringify({
@@ -2005,7 +2069,7 @@ var Loamly = (() => {
2005
2069
  updateIntervalMs: 1e4,
2006
2070
  // Report every 10 seconds
2007
2071
  onUpdate: (event) => {
2008
- if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
2072
+ if (event.active_time_ms >= DEFAULT_CONFIG3.timeSpentThresholdMs) {
2009
2073
  queueEvent("time_spent", {
2010
2074
  visible_time_ms: event.total_time_ms,
2011
2075
  page_start_time: pageStartTime || Date.now()
@@ -2031,7 +2095,10 @@ var Loamly = (() => {
2031
2095
  form_event_type: event.event_type,
2032
2096
  submit_source: submitSource,
2033
2097
  is_inferred: isSuccessEvent,
2034
- time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1e3) : null
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
2035
2102
  });
2036
2103
  }
2037
2104
  });
@@ -2118,7 +2185,7 @@ var Loamly = (() => {
2118
2185
  if (!workspaceId || !config.apiKey || !sessionId) return;
2119
2186
  const scrollEvent = scrollTracker?.getFinalEvent();
2120
2187
  if (scrollEvent) {
2121
- sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
2188
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2122
2189
  workspace_id: workspaceId,
2123
2190
  visitor_id: visitorId,
2124
2191
  session_id: sessionId,
@@ -2137,7 +2204,7 @@ var Loamly = (() => {
2137
2204
  }
2138
2205
  const timeEvent = timeTracker?.getFinalMetrics();
2139
2206
  if (timeEvent) {
2140
- sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
2207
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2141
2208
  workspace_id: workspaceId,
2142
2209
  visitor_id: visitorId,
2143
2210
  session_id: sessionId,
@@ -2219,7 +2286,7 @@ var Loamly = (() => {
2219
2286
  payload.workspace_id = workspaceId;
2220
2287
  }
2221
2288
  log("Pageview:", payload);
2222
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
2289
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.visit), {
2223
2290
  method: "POST",
2224
2291
  headers: buildHeaders(idempotencyKey),
2225
2292
  body: JSON.stringify(payload)
@@ -2440,7 +2507,7 @@ var Loamly = (() => {
2440
2507
  event_queue: !!eventQueue
2441
2508
  }
2442
2509
  };
2443
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.health), {
2510
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.health), {
2444
2511
  method: "POST",
2445
2512
  headers: { "Content-Type": "application/json" },
2446
2513
  body: JSON.stringify(healthData)
@@ -2484,14 +2551,28 @@ var Loamly = (() => {
2484
2551
  debugMode = enabled;
2485
2552
  log("Debug mode:", enabled ? "enabled" : "disabled");
2486
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
+ }
2487
2564
  var loamly = {
2488
2565
  init,
2489
2566
  pageview,
2490
2567
  track,
2568
+ trackBehavioral,
2569
+ // NEW: For secondary modules/plugins
2491
2570
  conversion,
2492
2571
  identify,
2493
2572
  getSessionId: getCurrentSessionId,
2494
2573
  getVisitorId: getCurrentVisitorId,
2574
+ getWorkspaceId: getCurrentWorkspaceId,
2575
+ // NEW: For debugging/introspection
2495
2576
  getAIDetection: getAIDetectionResult,
2496
2577
  getNavigationTiming: getNavigationTimingResult,
2497
2578
  getBehavioralML: getBehavioralMLResult,
@@ -2521,7 +2602,7 @@ var Loamly = (() => {
2521
2602
  }
2522
2603
  async function resolveWorkspaceConfig(domain) {
2523
2604
  try {
2524
- const response = await fetch(`${DEFAULT_CONFIG.apiHost}${DEFAULT_CONFIG.endpoints.resolve}?d=${encodeURIComponent(domain)}`);
2605
+ const response = await fetch(`${DEFAULT_CONFIG3.apiHost}${DEFAULT_CONFIG3.endpoints.resolve}?d=${encodeURIComponent(domain)}`);
2525
2606
  if (!response.ok) {
2526
2607
  console.warn("[Loamly] Failed to resolve workspace for domain:", domain);
2527
2608
  return null;
@@ -2531,7 +2612,7 @@ var Loamly = (() => {
2531
2612
  return {
2532
2613
  apiKey: data.public_key,
2533
2614
  workspaceId: data.workspace_id,
2534
- apiHost: DEFAULT_CONFIG.apiHost
2615
+ apiHost: DEFAULT_CONFIG3.apiHost
2535
2616
  };
2536
2617
  }
2537
2618
  return null;