@zerocost/sdk 0.18.0 → 0.19.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.
@@ -0,0 +1,674 @@
1
+ export class LLMDataModule {
2
+ client;
3
+ config = null;
4
+ buffer = [];
5
+ flushInterval = null;
6
+ clickHandler = null;
7
+ errorHandler = null;
8
+ fetchOriginal = null;
9
+ mutationObserver = null;
10
+ conversationScanTimer = null;
11
+ isSampledSession = false;
12
+ observedConversations = new Map();
13
+ conversationCounter = 0;
14
+ constructor(client) {
15
+ this.client = client;
16
+ }
17
+ start(config) {
18
+ this.stop();
19
+ this.config = config;
20
+ this.client.log(`LLMData: started (sample=${config.sampleRate}%)`);
21
+ this.isSampledSession = Math.random() * 100 <= config.sampleRate;
22
+ if (!this.isSampledSession) {
23
+ this.client.log('LLMData: session not sampled, skipping');
24
+ return;
25
+ }
26
+ if (config.uiInteractions)
27
+ this.captureUIInteractions();
28
+ if (config.textPrompts) {
29
+ this.interceptPrompts();
30
+ this.captureConversationSurfaces();
31
+ this.scheduleConversationScan();
32
+ }
33
+ if (config.apiErrors)
34
+ this.captureAPIErrors();
35
+ this.flushInterval = setInterval(() => this.flush(), 10_000);
36
+ }
37
+ stop() {
38
+ if (this.flushInterval) {
39
+ clearInterval(this.flushInterval);
40
+ this.flushInterval = null;
41
+ }
42
+ if (this.clickHandler) {
43
+ document.removeEventListener('click', this.clickHandler, true);
44
+ this.clickHandler = null;
45
+ }
46
+ if (this.errorHandler) {
47
+ window.removeEventListener('error', this.errorHandler);
48
+ this.errorHandler = null;
49
+ }
50
+ if (this.fetchOriginal) {
51
+ window.fetch = this.fetchOriginal;
52
+ this.fetchOriginal = null;
53
+ }
54
+ if (this.mutationObserver) {
55
+ this.mutationObserver.disconnect();
56
+ this.mutationObserver = null;
57
+ }
58
+ if (this.conversationScanTimer) {
59
+ clearTimeout(this.conversationScanTimer);
60
+ this.conversationScanTimer = null;
61
+ }
62
+ this.observedConversations.clear();
63
+ this.isSampledSession = false;
64
+ void this.flush();
65
+ this.config = null;
66
+ }
67
+ trackPrompt(prompt, response, meta) {
68
+ if (!this.canCapture('textPrompts'))
69
+ return;
70
+ this.pushEvent('llm_prompt', {
71
+ prompt: this.scrub(prompt),
72
+ response: response ? this.scrub(response) : undefined,
73
+ source: 'manual',
74
+ ...this.sanitizeMeta(meta),
75
+ });
76
+ }
77
+ trackConversation(messages, meta) {
78
+ if (!this.canCapture('textPrompts'))
79
+ return;
80
+ const cleanedMessages = messages
81
+ .map((message) => ({
82
+ role: this.normalizeRole(message.role),
83
+ content: this.scrub(message.content || ''),
84
+ ...(message.name ? { name: this.scrub(message.name) } : {}),
85
+ }))
86
+ .filter((message) => message.content);
87
+ if (cleanedMessages.length === 0)
88
+ return;
89
+ const sanitizedMeta = this.sanitizeMeta(meta);
90
+ const conversationId = this.getConversationId(sanitizedMeta);
91
+ cleanedMessages.forEach((message, index) => {
92
+ this.pushConversationMessage(message, {
93
+ conversationId,
94
+ turnIndex: index,
95
+ source: 'manual',
96
+ ...sanitizedMeta,
97
+ });
98
+ });
99
+ this.pushConversationSummary(conversationId, cleanedMessages, {
100
+ source: 'manual',
101
+ ...sanitizedMeta,
102
+ });
103
+ }
104
+ trackError(endpoint, status, message) {
105
+ if (!this.canCapture('apiErrors'))
106
+ return;
107
+ this.pushEvent('api_error', {
108
+ endpoint,
109
+ status,
110
+ message: message ? this.scrub(message) : undefined,
111
+ });
112
+ }
113
+ captureUIInteractions() {
114
+ this.clickHandler = (e) => {
115
+ const target = e.target;
116
+ if (!target)
117
+ return;
118
+ const tag = target.tagName?.toLowerCase();
119
+ const text = target.textContent?.slice(0, 50)?.trim() || '';
120
+ const role = target.getAttribute('role');
121
+ const ariaLabel = target.getAttribute('aria-label');
122
+ const path = this.getPath(target);
123
+ this.pushEvent('ui_interaction', {
124
+ action: 'click',
125
+ tag,
126
+ text: this.scrub(text),
127
+ role,
128
+ ariaLabel,
129
+ path,
130
+ url: location.pathname,
131
+ });
132
+ };
133
+ document.addEventListener('click', this.clickHandler, true);
134
+ }
135
+ interceptPrompts() {
136
+ this.fetchOriginal = window.fetch;
137
+ const origFetch = window.fetch;
138
+ const self = this;
139
+ window.fetch = async function (input, init) {
140
+ const fetchInput = input instanceof URL ? input.toString() : input;
141
+ const url = typeof fetchInput === 'string' ? fetchInput : fetchInput.url;
142
+ const isLLM = /\/(chat|completions|generate|predict|inference|ask)/i.test(url);
143
+ if (!isLLM) {
144
+ return origFetch.call(window, fetchInput, init);
145
+ }
146
+ const requestMeta = self.extractRequestMeta(url, init);
147
+ if (requestMeta.messages.length > 0) {
148
+ requestMeta.messages.forEach((message, index) => {
149
+ self.pushConversationMessage(message, {
150
+ conversationId: requestMeta.conversationId,
151
+ turnIndex: index,
152
+ source: 'network-request',
153
+ endpoint: requestMeta.endpoint,
154
+ requestId: requestMeta.requestId,
155
+ ...requestMeta.meta,
156
+ });
157
+ });
158
+ }
159
+ try {
160
+ const res = await origFetch.call(window, fetchInput, init);
161
+ const clone = res.clone();
162
+ clone.text().then((text) => {
163
+ const responseMessages = self.extractResponseMessages(text);
164
+ if (responseMessages.length > 0) {
165
+ responseMessages.forEach((message, index) => {
166
+ self.pushConversationMessage(message, {
167
+ conversationId: requestMeta.conversationId,
168
+ turnIndex: requestMeta.messages.length + index,
169
+ source: 'network-response',
170
+ endpoint: requestMeta.endpoint,
171
+ requestId: requestMeta.requestId,
172
+ status: res.status,
173
+ ...requestMeta.meta,
174
+ });
175
+ });
176
+ self.pushConversationSummary(requestMeta.conversationId, [...requestMeta.messages, ...responseMessages], {
177
+ source: 'network',
178
+ endpoint: requestMeta.endpoint,
179
+ requestId: requestMeta.requestId,
180
+ status: res.status,
181
+ ...requestMeta.meta,
182
+ });
183
+ }
184
+ self.pushEvent('llm_prompt', {
185
+ endpoint: requestMeta.endpoint,
186
+ conversationId: requestMeta.conversationId,
187
+ request: requestMeta.promptPreview,
188
+ response: self.scrub(text.slice(0, 1200)),
189
+ status: res.status,
190
+ source: 'network',
191
+ requestId: requestMeta.requestId,
192
+ ...requestMeta.meta,
193
+ });
194
+ }).catch(() => { });
195
+ return res;
196
+ }
197
+ catch (error) {
198
+ const message = error instanceof Error ? error.message : String(error);
199
+ self.pushEvent('api_error', {
200
+ endpoint: requestMeta.endpoint,
201
+ message: self.scrub(message),
202
+ requestId: requestMeta.requestId,
203
+ });
204
+ throw error;
205
+ }
206
+ };
207
+ }
208
+ captureConversationSurfaces() {
209
+ if (typeof MutationObserver === 'undefined' || typeof document === 'undefined')
210
+ return;
211
+ this.mutationObserver = new MutationObserver(() => this.scheduleConversationScan());
212
+ this.mutationObserver.observe(document.body, {
213
+ childList: true,
214
+ subtree: true,
215
+ characterData: true,
216
+ });
217
+ }
218
+ scheduleConversationScan() {
219
+ if (this.conversationScanTimer) {
220
+ clearTimeout(this.conversationScanTimer);
221
+ }
222
+ this.conversationScanTimer = setTimeout(() => {
223
+ this.conversationScanTimer = null;
224
+ this.scanConversationSurfaces();
225
+ }, 300);
226
+ }
227
+ scanConversationSurfaces() {
228
+ const containers = this.findConversationContainers();
229
+ if (containers.length === 0)
230
+ return;
231
+ containers.forEach((container) => {
232
+ const snapshot = this.buildConversationSnapshot(container);
233
+ if (!snapshot || snapshot.messages.length === 0)
234
+ return;
235
+ const existing = this.observedConversations.get(snapshot.conversationId);
236
+ const newMessages = snapshot.messages
237
+ .map((message, index) => ({
238
+ message,
239
+ index,
240
+ fingerprint: this.getMessageFingerprint(snapshot.conversationId, message, index),
241
+ }))
242
+ .filter(({ fingerprint }) => !existing?.messageFingerprints.has(fingerprint));
243
+ if (newMessages.length === 0 && existing?.signature === snapshot.signature) {
244
+ return;
245
+ }
246
+ newMessages.forEach(({ message, index, fingerprint }) => {
247
+ this.pushConversationMessage(message, {
248
+ conversationId: snapshot.conversationId,
249
+ turnIndex: index,
250
+ source: 'dom',
251
+ conversationTitle: snapshot.title,
252
+ pageTitle: document.title,
253
+ path: location.pathname,
254
+ });
255
+ snapshot.messageFingerprints.add(fingerprint);
256
+ });
257
+ if (!existing || existing.signature !== snapshot.signature) {
258
+ this.pushConversationSummary(snapshot.conversationId, snapshot.messages, {
259
+ source: 'dom',
260
+ conversationTitle: snapshot.title,
261
+ pageTitle: document.title,
262
+ path: location.pathname,
263
+ });
264
+ }
265
+ this.observedConversations.set(snapshot.conversationId, snapshot);
266
+ });
267
+ }
268
+ captureAPIErrors() {
269
+ this.errorHandler = (e) => {
270
+ this.pushEvent('api_error', {
271
+ message: this.scrub(e.message || ''),
272
+ filename: e.filename,
273
+ line: e.lineno,
274
+ url: location.pathname,
275
+ });
276
+ };
277
+ window.addEventListener('error', this.errorHandler);
278
+ }
279
+ pushConversationMessage(message, meta) {
280
+ const content = this.scrub(message.content || '');
281
+ if (!content)
282
+ return;
283
+ this.pushEvent('llm_message', {
284
+ role: this.normalizeRole(message.role),
285
+ content,
286
+ ...(message.name ? { name: this.scrub(message.name) } : {}),
287
+ ...meta,
288
+ });
289
+ }
290
+ pushConversationSummary(conversationId, messages, meta) {
291
+ const preview = messages
292
+ .map((message) => ({
293
+ role: this.normalizeRole(message.role),
294
+ content: this.scrub(message.content || ''),
295
+ ...(message.name ? { name: this.scrub(message.name) } : {}),
296
+ }))
297
+ .filter((message) => message.content)
298
+ .slice(0, 12);
299
+ if (preview.length === 0)
300
+ return;
301
+ this.pushEvent('llm_conversation', {
302
+ conversationId,
303
+ messageCount: preview.length,
304
+ roles: Array.from(new Set(preview.map((message) => message.role))),
305
+ preview,
306
+ ...meta,
307
+ });
308
+ }
309
+ pushEvent(type, data) {
310
+ if (!this.isSampledSession)
311
+ return;
312
+ this.buffer.push({ type, data, timestamp: Date.now() });
313
+ if (this.buffer.length >= 50) {
314
+ void this.flush();
315
+ }
316
+ }
317
+ async flush() {
318
+ if (this.buffer.length === 0)
319
+ return;
320
+ const events = [...this.buffer];
321
+ this.buffer = [];
322
+ try {
323
+ await this.client.request('/ingest-data', { type: 'llm', events });
324
+ }
325
+ catch (err) {
326
+ this.client.log(`LLMData flush error: ${err}`);
327
+ this.buffer.unshift(...events);
328
+ }
329
+ }
330
+ scrub(text) {
331
+ return text
332
+ .replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[EMAIL]')
333
+ .replace(/\+?\d[\d\s().-]{7,}\d/g, '[PHONE]')
334
+ .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]')
335
+ .replace(/\b(?:\d[ -]*?){13,19}\b/g, '[CARD]')
336
+ .replace(/\b(sk|pk|api|key|secret|token)[-_]?[a-zA-Z0-9]{16,}\b/gi, '[API_KEY]')
337
+ .replace(/\b[A-F0-9]{32,}\b/gi, '[TOKEN]')
338
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP]');
339
+ }
340
+ getPath(el) {
341
+ const parts = [];
342
+ let current = el;
343
+ while (current && parts.length < 5) {
344
+ let segment = current.tagName?.toLowerCase() || '';
345
+ if (current.id) {
346
+ segment += `#${current.id}`;
347
+ }
348
+ else if (typeof current.className === 'string' && current.className) {
349
+ const cls = current.className.split(' ')[0];
350
+ if (cls)
351
+ segment += `.${cls}`;
352
+ }
353
+ parts.unshift(segment);
354
+ current = current.parentElement;
355
+ }
356
+ return parts.join(' > ');
357
+ }
358
+ canCapture(setting) {
359
+ return Boolean(this.config?.[setting] && this.isSampledSession);
360
+ }
361
+ sanitizeMeta(meta) {
362
+ if (!meta)
363
+ return {};
364
+ return Object.fromEntries(Object.entries(meta).map(([key, value]) => {
365
+ if (typeof value === 'string')
366
+ return [key, this.scrub(value)];
367
+ if (Array.isArray(value)) {
368
+ return [key, value.map((item) => typeof item === 'string' ? this.scrub(item) : item)];
369
+ }
370
+ return [key, value];
371
+ }));
372
+ }
373
+ extractRequestMeta(url, init) {
374
+ const endpoint = this.safePathname(url);
375
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
376
+ const parsed = this.parseRequestBody(init?.body);
377
+ const conversationId = this.getConversationId(parsed.meta);
378
+ return {
379
+ endpoint,
380
+ requestId,
381
+ conversationId,
382
+ messages: parsed.messages,
383
+ promptPreview: parsed.messages.map((message) => `[${message.role}] ${message.content}`).join('\n').slice(0, 1200),
384
+ meta: parsed.meta,
385
+ };
386
+ }
387
+ parseRequestBody(body) {
388
+ if (!body || typeof body !== 'string') {
389
+ return { messages: [], meta: {} };
390
+ }
391
+ try {
392
+ const parsed = JSON.parse(body);
393
+ const messages = this.extractMessagesFromUnknown(parsed);
394
+ const meta = {};
395
+ const keys = ['model', 'conversationId', 'conversation_id', 'threadId', 'thread_id', 'sessionId', 'session_id', 'topic', 'domain', 'workflow'];
396
+ keys.forEach((key) => {
397
+ const value = parsed[key];
398
+ if (typeof value === 'string' && value.trim()) {
399
+ meta[key] = this.scrub(value.trim().slice(0, 120));
400
+ }
401
+ });
402
+ return { messages, meta };
403
+ }
404
+ catch {
405
+ return {
406
+ messages: [{ role: 'user', content: this.scrub(body.slice(0, 2000)) }],
407
+ meta: {},
408
+ };
409
+ }
410
+ }
411
+ extractMessagesFromUnknown(value) {
412
+ if (!value || typeof value !== 'object')
413
+ return [];
414
+ const record = value;
415
+ if (Array.isArray(record.messages)) {
416
+ return record.messages
417
+ .map((item) => this.normalizeMessage(item))
418
+ .filter((item) => Boolean(item?.content));
419
+ }
420
+ if (typeof record.prompt === 'string') {
421
+ return [{ role: 'user', content: this.scrub(record.prompt.slice(0, 2000)) }];
422
+ }
423
+ if (Array.isArray(record.input)) {
424
+ return record.input
425
+ .map((item) => this.normalizeMessage(item))
426
+ .filter((item) => Boolean(item?.content));
427
+ }
428
+ if (typeof record.input === 'string') {
429
+ return [{ role: 'user', content: this.scrub(record.input.slice(0, 2000)) }];
430
+ }
431
+ return [];
432
+ }
433
+ normalizeMessage(input) {
434
+ if (typeof input === 'string') {
435
+ const content = this.scrub(input.slice(0, 2000));
436
+ return content ? { role: 'user', content } : null;
437
+ }
438
+ if (!input || typeof input !== 'object')
439
+ return null;
440
+ const record = input;
441
+ const role = this.normalizeRole(record.role);
442
+ const content = this.extractContent(record.content ?? record.text ?? record.message);
443
+ const name = typeof record.name === 'string' ? this.scrub(record.name.slice(0, 120)) : undefined;
444
+ if (!content)
445
+ return null;
446
+ return { role, content, ...(name ? { name } : {}) };
447
+ }
448
+ extractContent(value) {
449
+ if (typeof value === 'string')
450
+ return this.scrub(value.slice(0, 4000));
451
+ if (Array.isArray(value)) {
452
+ return value
453
+ .map((item) => {
454
+ if (typeof item === 'string')
455
+ return this.scrub(item);
456
+ if (item && typeof item === 'object') {
457
+ const record = item;
458
+ if (typeof record.text === 'string')
459
+ return this.scrub(record.text);
460
+ }
461
+ return '';
462
+ })
463
+ .filter(Boolean)
464
+ .join('\n')
465
+ .slice(0, 4000);
466
+ }
467
+ return '';
468
+ }
469
+ extractResponseMessages(text) {
470
+ const trimmed = text.trim();
471
+ if (!trimmed)
472
+ return [];
473
+ try {
474
+ const parsed = JSON.parse(trimmed);
475
+ const messages = this.extractAssistantMessages(parsed);
476
+ if (messages.length > 0)
477
+ return messages;
478
+ }
479
+ catch {
480
+ // Fall through to raw text handling.
481
+ }
482
+ return [{ role: 'assistant', content: this.scrub(trimmed.slice(0, 4000)) }];
483
+ }
484
+ extractAssistantMessages(value) {
485
+ if (!value || typeof value !== 'object')
486
+ return [];
487
+ const record = value;
488
+ const direct = this.normalizeMessage(record.message ?? record.output ?? record.response ?? record.answer);
489
+ if (direct) {
490
+ return [{ ...direct, role: direct.role === 'user' ? 'assistant' : direct.role }];
491
+ }
492
+ if (Array.isArray(record.choices)) {
493
+ return record.choices
494
+ .map((choice) => {
495
+ if (!choice || typeof choice !== 'object')
496
+ return null;
497
+ const payload = choice;
498
+ const normalized = this.normalizeMessage(payload.message ?? payload.delta ?? payload.text);
499
+ if (!normalized)
500
+ return null;
501
+ return {
502
+ ...normalized,
503
+ role: (normalized.role === 'user' ? 'assistant' : normalized.role),
504
+ };
505
+ })
506
+ .filter((item) => Boolean(item && item.content));
507
+ }
508
+ if (Array.isArray(record.messages)) {
509
+ return record.messages
510
+ .map((item) => this.normalizeMessage(item))
511
+ .filter((item) => Boolean(item && item.content))
512
+ .map((item) => ({
513
+ ...item,
514
+ role: (item.role === 'user' ? 'assistant' : item.role),
515
+ }));
516
+ }
517
+ return [];
518
+ }
519
+ findConversationContainers() {
520
+ const selectors = [
521
+ '[data-chat-thread]',
522
+ '[data-conversation]',
523
+ '[data-testid*="chat" i]',
524
+ '[data-testid*="conversation" i]',
525
+ '[aria-label*="chat" i]',
526
+ '[aria-label*="conversation" i]',
527
+ '[role="log"]',
528
+ '[role="feed"]',
529
+ 'main',
530
+ ];
531
+ const found = selectors
532
+ .flatMap((selector) => Array.from(document.querySelectorAll(selector)))
533
+ .filter((element) => this.looksLikeConversationContainer(element));
534
+ return Array.from(new Set(found)).slice(0, 4);
535
+ }
536
+ looksLikeConversationContainer(element) {
537
+ const marker = [
538
+ element.dataset.chatThread,
539
+ element.dataset.conversation,
540
+ element.getAttribute('aria-label'),
541
+ element.getAttribute('data-testid'),
542
+ typeof element.className === 'string' ? element.className : '',
543
+ element.id,
544
+ ]
545
+ .filter(Boolean)
546
+ .join(' ')
547
+ .toLowerCase();
548
+ if (/chat|conversation|assistant|thread|messages/.test(marker)) {
549
+ return true;
550
+ }
551
+ const messageNodes = element.querySelectorAll('[data-message-author-role], [data-role], [role="article"], article, [data-testid*="message" i]');
552
+ return messageNodes.length >= 2;
553
+ }
554
+ buildConversationSnapshot(container) {
555
+ const messageNodes = Array.from(container.querySelectorAll('[data-message-author-role], [data-role], [role="article"], article, [data-testid*="message" i], [class*="message"], [class*="chat"]'))
556
+ .filter((node) => this.isUsableMessageNode(node))
557
+ .slice(0, 80);
558
+ const messages = messageNodes
559
+ .map((node) => this.messageFromNode(node))
560
+ .filter((message) => Boolean(message?.content));
561
+ if (messages.length < 2)
562
+ return null;
563
+ const title = this.extractConversationTitle(container);
564
+ const conversationId = this.getConversationId({ conversationTitle: title, path: location.pathname }, container);
565
+ const signature = messages.map((message) => `${message.role}:${message.content}`).join('|').slice(0, 6000);
566
+ return {
567
+ conversationId,
568
+ signature,
569
+ messageFingerprints: new Set(messages.map((message, index) => this.getMessageFingerprint(conversationId, message, index))),
570
+ messages,
571
+ title,
572
+ };
573
+ }
574
+ isUsableMessageNode(node) {
575
+ const text = node.innerText?.trim() || '';
576
+ if (!text || text.length < 2)
577
+ return false;
578
+ const marker = `${typeof node.className === 'string' ? node.className : ''} ${node.getAttribute('data-testid') || ''} ${node.getAttribute('aria-label') || ''}`.toLowerCase();
579
+ if (/input|textarea|composer|toolbar|button|copy code/.test(marker)) {
580
+ return false;
581
+ }
582
+ return true;
583
+ }
584
+ messageFromNode(node) {
585
+ const text = this.scrub((node.innerText || '').trim().slice(0, 4000));
586
+ if (!text)
587
+ return null;
588
+ return {
589
+ role: this.detectRole(node, text),
590
+ content: text,
591
+ };
592
+ }
593
+ detectRole(node, text) {
594
+ const marker = [
595
+ node.dataset.messageAuthorRole,
596
+ node.dataset.role,
597
+ node.getAttribute('data-role'),
598
+ node.getAttribute('aria-label'),
599
+ typeof node.className === 'string' ? node.className : '',
600
+ node.id,
601
+ node.closest('[data-message-author-role]')?.getAttribute('data-message-author-role'),
602
+ ]
603
+ .filter(Boolean)
604
+ .join(' ')
605
+ .toLowerCase();
606
+ if (/assistant|bot|ai|model|gpt|claude|copilot/.test(marker))
607
+ return 'assistant';
608
+ if (/user|human|prompt|customer|visitor/.test(marker))
609
+ return 'user';
610
+ if (/system/.test(marker))
611
+ return 'system';
612
+ if (/tool|function/.test(marker))
613
+ return 'tool';
614
+ const alignment = window.getComputedStyle(node).textAlign;
615
+ if (alignment === 'right')
616
+ return 'user';
617
+ const lower = text.toLowerCase();
618
+ if (lower.startsWith('you:'))
619
+ return 'user';
620
+ if (lower.startsWith('assistant:') || lower.startsWith('ai:'))
621
+ return 'assistant';
622
+ return 'unknown';
623
+ }
624
+ extractConversationTitle(container) {
625
+ const heading = container.querySelector('h1, h2, h3, [data-testid*="title" i], [class*="title"]');
626
+ return this.scrub(heading?.innerText?.trim().slice(0, 160) || document.title || 'conversation');
627
+ }
628
+ getConversationId(meta, container) {
629
+ const explicit = [
630
+ meta?.conversationId,
631
+ meta?.conversation_id,
632
+ meta?.threadId,
633
+ meta?.thread_id,
634
+ meta?.sessionId,
635
+ meta?.session_id,
636
+ ].find((value) => typeof value === 'string' && value.trim().length > 0);
637
+ if (explicit)
638
+ return this.scrub(explicit).slice(0, 120);
639
+ if (container) {
640
+ const existing = container.dataset.zcConversationId;
641
+ if (existing)
642
+ return existing;
643
+ const generated = `conv-${location.pathname.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'root'}-${++this.conversationCounter}`;
644
+ container.dataset.zcConversationId = generated;
645
+ return generated;
646
+ }
647
+ return `conv-${location.pathname.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'root'}-${Date.now()}`;
648
+ }
649
+ getMessageFingerprint(conversationId, message, index) {
650
+ return `${conversationId}:${index}:${message.role}:${message.content.slice(0, 240)}`;
651
+ }
652
+ safePathname(url) {
653
+ try {
654
+ return new URL(url, window.location.origin).pathname;
655
+ }
656
+ catch {
657
+ return url;
658
+ }
659
+ }
660
+ normalizeRole(value) {
661
+ if (typeof value !== 'string')
662
+ return 'unknown';
663
+ const normalized = value.toLowerCase();
664
+ if (normalized.includes('assistant') || normalized.includes('bot') || normalized.includes('ai') || normalized.includes('model'))
665
+ return 'assistant';
666
+ if (normalized.includes('user') || normalized.includes('human'))
667
+ return 'user';
668
+ if (normalized.includes('system'))
669
+ return 'system';
670
+ if (normalized.includes('tool') || normalized.includes('function'))
671
+ return 'tool';
672
+ return 'unknown';
673
+ }
674
+ }