@privacyscrubber/sdk 2.0.2

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,1711 @@
1
+ /**
2
+ * PrivacyScrubber Core Engine — Shared Module
3
+ * Pure JavaScript, zero DOM dependencies.
4
+ * Used by both the Chrome Extension and (via extraction) the main website.
5
+ *
6
+ * Zero-server rule: No fetch(), no XMLHttpRequest, no external calls.
7
+ * Airplane Mode Verified: works with no network after load.
8
+ */
9
+
10
+ // ─── Regex Rules ─────────────────────────────────────────────────────────────
11
+ (function() {
12
+ if (typeof window !== 'undefined' && window.PrivacyScrubberCore && window.PrivacyScrubberCore.isInitialized) return;
13
+
14
+ function getEngine() {
15
+ if (typeof window !== 'undefined' && window.PrivacyScrubberEngine) return window.PrivacyScrubberEngine;
16
+ if (typeof global !== 'undefined' && global.PrivacyScrubberEngine) return global.PrivacyScrubberEngine;
17
+ if (typeof require === 'function') {
18
+ try { return require('./ps-pii-engine.cjs'); } catch(e) {}
19
+ try { return require('./ps-pii-engine.js'); } catch(e) {}
20
+ try { return require('../chrome-extension/ps-pii-engine.js'); } catch(e) {}
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Runtime Initialization for dynamic rule synchronization.
27
+ * @param {Object} config - { regexes, profiles, names }
28
+ */
29
+ function init(config) {
30
+ if (!config) return;
31
+ const engine = getEngine();
32
+ if (engine) engine.init(config);
33
+ if (typeof window !== 'undefined' && window.PrivacyScrubberCore) {
34
+ window.PrivacyScrubberCore.isInitialized = true;
35
+ }
36
+ }
37
+
38
+ function stitchOrphanedNameLines(text, profile) {
39
+ if (!text || !text.includes('\n')) return text;
40
+ const prof = (profile || 'general').toLowerCase();
41
+ if (prof === 'medical') {
42
+ text = text.replace(/Patient Name:\s*\n+([A-Z][a-zA-Z]+\s[A-Z][a-zA-Z]+)/g, 'Patient Name: $1');
43
+ } else if (prof === 'legal') {
44
+ text = text.replace(/Defendant:\s*\n+([A-Z][a-zA-Z]+\s[A-Z][a-zA-Z]+)/g, 'Defendant: $1');
45
+ }
46
+ return text;
47
+ }
48
+
49
+ /**
50
+ * Protect PII from plain text.
51
+ *
52
+ * @param {string} text - Raw input text
53
+ * @param {Array<{label: string, pattern: string}>} [customRules=[]] - Optional PRO regex rules or exact text
54
+ * @returns {{ scrubbedText: string, tokenMap: Object, count: number }}
55
+ * scrubbedText: text with PII replaced by tokens like [NAME_1]
56
+ * tokenMap: { "[NAME_1]": "John Doe", ... } — for data reveal
57
+ * count: total number of items protected
58
+ * uniqueUnmasked: Set of unmasked values
59
+ */
60
+ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'General', existingSessionMap = {}, isPro = false, ignoreList = null) {
61
+ if (!text) return { scrubbedText: "", tokenMap: {}, count: 0, uniqueUnmasked: new Set(), trialMeta: null, executionMs: 0 };
62
+
63
+ const executionStartMs = (typeof performance !== 'undefined' && typeof performance.now === 'function') ? performance.now() : Date.now();
64
+ // Stitch PDF-split name fragments across line breaks (all profiles)
65
+ text = stitchOrphanedNameLines(text, profile);
66
+
67
+ // Protect system prompt from being scrubbed or counted
68
+ let extractedSystemPrompt = "";
69
+ let textToProcess = text.replace(/[\u200b\u200c\u200d\ufeff]/g, '');
70
+
71
+ const isSpecialized = profile && profile.toLowerCase() !== 'general';
72
+
73
+ // SDK / Core Engine Limits Enforcer (Free Tier)
74
+ if (!isPro) {
75
+ const charLimit = isSpecialized ? 5000 : 15000;
76
+ if (textToProcess.length > charLimit) {
77
+ throw new Error(`PrivacyScrubber Free Tier limit exceeded (${charLimit.toLocaleString()} chars). Please upgrade to PRO or TEAMS.`);
78
+ }
79
+ }
80
+
81
+ let trialMeta = null;
82
+
83
+ // Split attached table labels/headers (e.g. Sarah MitchellEmail: -> Sarah Mitchell Email:)
84
+ // Specifically matches a letter followed directly by field names and a colon
85
+ textToProcess = textToProcess.replace(/([a-zA-Z])(Email|Phone|Mobile|Tel|Address|IP|ID|URL|SSN|Date):/g, '$1 $2:');
86
+
87
+ const sysMarker = "[Privacy Scrubber Mode]";
88
+ const oldMarker = "[SYSTEM INSTRUCTION: DATA PRIVACY MODE]";
89
+ const ctxMarker = "[Context: identifiers";
90
+ const newMarker = "[Privacy Note:";
91
+ if (textToProcess.includes(sysMarker) || textToProcess.includes(oldMarker) || textToProcess.includes(ctxMarker) || textToProcess.includes(newMarker)) {
92
+ const sysPromptRegex = /(?:\n*----------------------\s*|\n*---\s*)?(?:\[SYSTEM INSTRUCTION: DATA PRIVACY MODE\]|\[Privacy Scrubber Mode\]|\[Context: identifiers|\[Privacy Note:)[\s\S]*/;
93
+ const match = textToProcess.match(sysPromptRegex);
94
+ if (match) {
95
+ extractedSystemPrompt = match[0].trim();
96
+ textToProcess = textToProcess.replace(sysPromptRegex, '').trimEnd();
97
+ }
98
+ }
99
+ const sessionMap = {};
100
+ const counters = { NAME: 0, EMAIL: 0, PHONE: 0, ID: 0, FINANCIAL: 0, SECRET: 0, ADDRESS: 0, CUSTOM: 0, SSN: 0, DATE: 0, PHI: 0 };
101
+ const customCounters = {};
102
+ const plaintextToTokenAtlas = {};
103
+
104
+ // Seed counters and atlas from existing session map to prevent overwrites
105
+ if (existingSessionMap && typeof existingSessionMap === 'object') {
106
+ Object.entries(existingSessionMap).forEach(([token, value]) => {
107
+ const match = token.match(/^\[([A-Z_a-z0-9]+)_(\d+)\]$/);
108
+ if (match) {
109
+ const type = match[1];
110
+ const idx = parseInt(match[2], 10);
111
+ if (counters[type] !== undefined) {
112
+ counters[type] = Math.max(counters[type], idx);
113
+ } else {
114
+ customCounters[type] = Math.max(customCounters[type] || 0, idx);
115
+ }
116
+ }
117
+ plaintextToTokenAtlas[value.toLowerCase()] = token;
118
+ plaintextToTokenAtlas[value] = token;
119
+ });
120
+ }
121
+
122
+
123
+ // Default labels
124
+ const labels = {
125
+ NAME: 'NAME', EMAIL: 'EMAIL', PHONE: 'PHONE', ID: 'ID',
126
+ FINANCIAL: 'FINANCIAL', SECRET: 'SECRET', ADDRESS: 'ADDRESS', CUSTOM: 'CUSTOM',
127
+ SSN: 'SSN', DATE: 'DATE', PHI: 'PHI',
128
+ ...tokenLabelMap
129
+ };
130
+
131
+
132
+ const PROFILE_ALIAS_MAP = (getEngine() && getEngine().PROFILE_ALIAS_MAP) ? getEngine().PROFILE_ALIAS_MAP : {};
133
+
134
+ // Node.js local license key validation enforcement (ZTDS Compliance)
135
+ if (!isPro && typeof process !== 'undefined' && process.env && typeof require === 'function' && profile && profile.toLowerCase() !== 'general') {
136
+ try {
137
+ const key = (process.env.PRIVACYSCRUBBER_KEY || "").trim();
138
+ let isKeyPro = false;
139
+ if (key) {
140
+ const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw3f37srO402PU4++Baf8\nFG8LY4l/IA3NKLlBnYmNHRTjfI/O/w5PDZn1xPcUQevojA1J+A5moKcjXsJ5b21X\nhJoYSkE4vLpcVYOt1FhRwEHs1APDSyss0HixboLz2eW2XQf2NbwajWtNlyxvgczO\nKE6ClnLomtsaKywwqB4alzdYnnnFJttFPjwmgPSO7D9AgN9sYaVkXOaOFrIZ90Ng\nTRhSHUeL7ReltWlCHwz9xf5m2FrKtxr2VBlEoyPjsFzalHMey1EX+yXe81zM7IIi\nt1Z8agLzo7WIfNBAIWmRlerTplaFFZrQgdF5g/Y0n8IIMZOtadgoY8E855psDNZV\n7wIDAQAB\n-----END PUBLIC KEY-----`;
141
+ const crypto = require('crypto');
142
+ const [payloadBase64, signatureBase64] = key.split('.');
143
+ if (payloadBase64 && signatureBase64) {
144
+ const verifier = crypto.createVerify('SHA256');
145
+ verifier.update(payloadBase64);
146
+ const isVerified = verifier.verify(PUBLIC_KEY, signatureBase64, 'base64');
147
+ if (isVerified) {
148
+ const payload = JSON.parse(Buffer.from(payloadBase64, 'base64').toString('utf8'));
149
+ if (!payload.expires || payload.expires > Math.floor(Date.now() / 1000)) {
150
+ isKeyPro = true;
151
+ isPro = true;
152
+ }
153
+ }
154
+ }
155
+ }
156
+ if (!isKeyPro && !isPro) {
157
+ profile = 'General';
158
+ }
159
+ } catch (e) {
160
+ if (!isPro) profile = 'General';
161
+ }
162
+ }
163
+
164
+ const engine = getEngine();
165
+ if (!engine) {
166
+ return { scrubbedText: textToProcess, count: 0, tokenMap: {}, piiBreakdown: {}, uniqueUnmasked: new Set(), trialMeta: null, executionMs: 0 };
167
+ }
168
+ const detectResult = engine.detectMatches(textToProcess, profile, customRules, [], null);
169
+ let filtered = detectResult.filteredMatches;
170
+ textToProcess = detectResult.processedText;
171
+
172
+ // Filter out items in ignoreList (False Positives restored by user)
173
+ if (ignoreList && (ignoreList instanceof Set || Array.isArray(ignoreList))) {
174
+ const ignoreSet = ignoreList instanceof Set ? ignoreList : new Set(ignoreList);
175
+ filtered = filtered.filter(m => {
176
+ if (!m || !m.value) return false;
177
+ const val = m.value;
178
+ const valLower = val.toLowerCase();
179
+ const valTrim = val.trim();
180
+ const valTrimLower = valTrim.toLowerCase();
181
+ return !ignoreSet.has(val) &&
182
+ !ignoreSet.has(valLower) &&
183
+ !ignoreSet.has(valTrim) &&
184
+ !ignoreSet.has(valTrimLower);
185
+ });
186
+ }
187
+
188
+ // Assign tokens in left-to-right order
189
+
190
+ const uniqueUnmasked = new Set();
191
+
192
+ filtered.forEach(m => {
193
+ // Deduplication Check: Reuse tokens for identical values (case-insensitive for core types)
194
+ const matchKey = m.type === 'CUSTOM' || m.type === 'ID' || m.type === 'SECRET' ? m.value : m.value.toLowerCase();
195
+
196
+ uniqueUnmasked.add(matchKey);
197
+
198
+ if (plaintextToTokenAtlas[matchKey]) {
199
+ m.token = plaintextToTokenAtlas[matchKey];
200
+ return;
201
+ }
202
+
203
+ // Generate unique token using custom labels if provided
204
+ const label = m.customLabel || labels[m.type] || m.type;
205
+ const isBuiltIn = counters[m.type] !== undefined;
206
+
207
+ if (isBuiltIn && !m.customLabel) {
208
+ // Built-in type (NAME, EMAIL, PHONE, etc.) — use seeded counters[] for collision prevention
209
+ counters[m.type]++;
210
+ m.token = `[${label}_${counters[m.type]}]`;
211
+ } else {
212
+ // Custom label (PRO rule) — use customCounters[]
213
+ customCounters[label] = (customCounters[label] || 0) + 1;
214
+ m.token = `[${label}_${customCounters[label]}]`;
215
+ }
216
+
217
+ plaintextToTokenAtlas[matchKey] = m.token;
218
+ sessionMap[m.token] = m.value;
219
+ });
220
+
221
+ // Replace from start → end using string builder (preserves O(N) linear performance on large payloads)
222
+ const leftToRight = [...filtered].sort((a, b) => a.start - b.start);
223
+ let lastIdx = 0;
224
+ const pieces = [];
225
+ for (let i = 0; i < leftToRight.length; i++) {
226
+ const m = leftToRight[i];
227
+ if (m.start > lastIdx) {
228
+ pieces.push(textToProcess.substring(lastIdx, m.start));
229
+ }
230
+ pieces.push(m.token);
231
+ lastIdx = m.end;
232
+ }
233
+ if (lastIdx < textToProcess.length) {
234
+ pieces.push(textToProcess.substring(lastIdx));
235
+ }
236
+ const result = pieces.join('');
237
+
238
+ const piiBreakdown = {};
239
+ Object.keys(sessionMap).forEach(k => {
240
+ let t = k.split('_')[0].replace('[', '');
241
+ piiBreakdown[t] = (piiBreakdown[t] || 0) + 1;
242
+ });
243
+
244
+ let finalScrubbed = result;
245
+ if (extractedSystemPrompt) {
246
+ if (extractedSystemPrompt.startsWith('----------------------')) {
247
+ finalScrubbed = result + '\n\n' + extractedSystemPrompt;
248
+ } else {
249
+ finalScrubbed = result + '\n\n----------------------\n' + extractedSystemPrompt;
250
+ }
251
+ }
252
+
253
+ const executionEndMs = (typeof performance !== 'undefined' && typeof performance.now === 'function') ? performance.now() : Date.now();
254
+
255
+ return {
256
+ scrubbedText: finalScrubbed,
257
+ tokenMap: sessionMap,
258
+ count: Object.keys(sessionMap).length,
259
+ uniqueUnmasked: uniqueUnmasked,
260
+ piiBreakdown: piiBreakdown,
261
+ trialMeta: trialMeta,
262
+ executionMs: Math.max(0, executionEndMs - executionStartMs)
263
+ };
264
+ }
265
+
266
+ const LABEL_ALIASES = {
267
+ NAME: ['NAME', 'NAMES', 'USERNAME', 'USER_NAME', 'CLIENTNAME', 'CLIENT_NAME', 'CANDIDATE_NAME', 'FULL_NAME', 'FIRSTNAME', 'FIRST_NAME', 'LASTNAME', 'LAST_NAME', 'SURNAME', 'ИМЯ', 'ИМЕНА', 'ПОЛЬЗОВАТЕЛЬ', 'ФИО', 'КЛИЕНТ', 'NOMBRE', 'NOMBRES', 'USUARIO', 'CLIENTE', 'NOM', 'NOMS', 'UTILISATEUR', 'NAME', 'NAMEN', 'BENUTZER', 'KUNDE', 'NOME', 'COGNOME', 'UTENTE', 'NAAM', 'GEBRUIKER', 'KLANT'],
268
+ EMAIL: ['EMAIL', 'EMAILS', 'EMAILADDR', 'EMAIL_ADDR', 'EMAILADDRESS', 'EMAIL_ADDRESS', 'EMAIL_ADR', 'MAIL', 'MAILS', 'ПОЧТА', 'ЭЛ_ПОЧТА', 'АДРЕС_ПОЧТЫ', 'МЕЙЛ', 'МАЙЛ', 'CORREO', 'COURRIEL', 'CORREO_ELECTRONICO', 'MEL'],
269
+ PHONE: ['PHONE', 'PHONES', 'PHONENUM', 'PHONE_NUM', 'PHONENUMBER', 'PHONE_NUMBER', 'TEL', 'TELS', 'TELEPHONE', 'TELEPHONES', 'MOBILE', 'CELL', 'ТЕЛЕФОН', 'ТЕЛЕФОНЫ', 'НОМЕР_ТЕЛЕФОНА', 'НОМЕР', 'MOVIL', 'PORTABLE', 'HANDY', 'TELEFONI', 'CELLULARE'],
270
+ ID: ['ID', 'IDS', 'IDNUM', 'ID_NUM', 'IDNUMBER', 'ID_NUMBER', 'IDENTIFIER', 'IDENTIFIERS', 'PASSPORT', 'SSN', 'EIN', 'TAXID', 'TAX_ID', 'LICENSE', 'LICENSE_PLATE', 'ИД', 'ИДЕНТИФИКАТОР', 'ПАСПОРТ', 'СНИЛС', 'ИНН', 'IDENTIFICADOR', 'PASAPORTE', 'IDENTIFIANT', 'PASSEPORT', 'IDENTIFIKATOR', 'PASS', 'IDENTIFICATORE', 'PASSAPORTO'],
271
+ FINANCIAL: ['FINANCIAL', 'FINANCIALS', 'MONEY', 'AMOUNT', 'PRICE', 'COST', 'CARD', 'CREDITCARD', 'DEBITCARD', 'ACCOUNT', 'IBAN', 'BIC', 'ДЕНЬГИ', 'СУММА', 'КАРТА', 'СЧЕТ', 'БАНК', 'DINERO', 'CANTIDAD', 'TARJETA', 'CUENTA', 'ARGENT', 'MONTANT', 'COMPTE', 'GELD', 'BETRAG', 'KONTO'],
272
+ ADDRESS: ['ADDRESS', 'ADDRESSES', 'STREET', 'STREET_ADDRESS', 'CITY', 'STATE', 'ZIP', 'ZIPCODE', 'ZIP_CODE', 'COUNTRY', 'LOCATION', 'АДРЕС', 'АДРЕСА', 'УЛИЦА', 'ГОРОД', 'СТРАНА', 'DIRECCION', 'DIRECCIONES', 'CALLE', 'CIUDAD', 'PAIS', 'ADRESSE', 'ADRESSES', 'RUE', 'VILLE', 'STRASSE', 'STADT', 'LAND'],
273
+ DATE: ['DATE', 'DATES', 'BIRTHDAY', 'DOB', 'ДАТА', 'ДАТЫ', 'ДЕНЬ_РОЖДЕНИЯ', 'FECHA', 'FECHAS', 'CUMPLEANOS', 'ANNIVERSAIRE', 'DATUM', 'DATEN', 'GEBURTSTAG'],
274
+ PHI: ['PHI', 'MRN', 'NHS', 'HEALTH', 'MEDICAL', 'PATIENT', 'МЕД', 'ПАЦИЕНТ', 'PACIENTE'],
275
+ SECRET: ['SECRET', 'SECRETS', 'KEY', 'KEYS', 'TOKEN', 'TOKENS', 'PASSWORD', 'PASSWORDS', 'AUTH', 'APIKEY', 'API_KEY', 'КЛЮЧ', 'КЛЮЧИ', 'ПАРОЛЬ', 'ПАРОЛИ', 'ТОКЕН', 'CLAVE', 'CONTRASENA', 'CLE', 'MOT_DE_PASSE', 'SCHLUESSEL', 'PASSWORT'],
276
+ CUSTOM: ['CUSTOM', 'CUSTOMS', 'RULE', 'RULES', 'КАСТОМ', 'ПРАВИЛО']
277
+ };
278
+
279
+ function getLabelAliases(label) {
280
+ const upper = label.toUpperCase();
281
+ if (LABEL_ALIASES[upper]) {
282
+ return LABEL_ALIASES[upper];
283
+ }
284
+ const aliases = new Set([label, upper, label.toLowerCase()]);
285
+ aliases.add(label.replace(/_/g, ' '));
286
+ aliases.add(label.replace(/_/g, '-'));
287
+ aliases.add(label.replace(/ /g, '_'));
288
+ aliases.add(label.replace(/-/g, '_'));
289
+ return Array.from(aliases);
290
+ }
291
+
292
+ function formatToken(label, index, format = 'brackets') {
293
+ const cleanLabel = String(label || 'PII').replace(/[^A-Za-z0-9_]/g, '_').toUpperCase();
294
+ switch(format) {
295
+ case 'xml': return `<${cleanLabel}_${index}>`;
296
+ case 'mustache': return `{{${cleanLabel}_${index}}}`;
297
+ case 'underscores': return `__${cleanLabel}_${index}__`;
298
+ case 'brackets':
299
+ default: return `[${cleanLabel}_${index}]`;
300
+ }
301
+ }
302
+
303
+ function buildRestorationRegexAndRules(tokenMap) {
304
+ const keys = Object.keys(tokenMap || {});
305
+ if (keys.length === 0) {
306
+ return { compositeRegex: null, looseRules: [] };
307
+ }
308
+
309
+ const sortedKeys = [...keys].sort((a, b) => {
310
+ const innerA = a.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
311
+ const innerB = b.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
312
+ const matchA = innerA.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
313
+ const matchB = innerB.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
314
+
315
+ if (matchA && matchB) {
316
+ const idxA = parseInt(matchA[2], 10);
317
+ const idxB = parseInt(matchB[2], 10);
318
+ const labelA = matchA[1];
319
+ const labelB = matchB[1];
320
+
321
+ if (idxA !== idxB) {
322
+ return idxB - idxA;
323
+ }
324
+ if (labelA.length !== labelB.length) {
325
+ return labelB.length - labelA.length;
326
+ }
327
+ }
328
+ return b.length - a.length;
329
+ });
330
+
331
+ const looseRules = [];
332
+ const regexParts = [];
333
+
334
+ sortedKeys.forEach(k => {
335
+ const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
336
+ const match = inner.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
337
+ if (match) {
338
+ const label = match[1];
339
+ const baseIndex = parseInt(match[2], 10);
340
+ const aliases = getLabelAliases(label);
341
+
342
+ const escapedAliases = aliases.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
343
+ const aliasesGroup = `(?:${escapedAliases.join('|')})`;
344
+
345
+ const looseRegex = '(?:\\[?\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*\\]?|<\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*>|\\{\\{\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*\\}\\}|__\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*__)(?:\'s|’s|s|[а-яёА-ЯЁ]{1,3})?';
346
+ looseRules.push({ patternStr: looseRegex, regex: new RegExp('^' + looseRegex + '$', 'i'), originalKey: k });
347
+ regexParts.push(looseRegex);
348
+ } else {
349
+ const safe = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
350
+ looseRules.push({ patternStr: safe, regex: new RegExp('^' + safe + '$', 'i'), originalKey: k });
351
+ regexParts.push(safe);
352
+ }
353
+ });
354
+
355
+ const compositeRegex = new RegExp('(?<=^|[^a-zA-Z0-9_А-Яа-яЁё])(' + regexParts.join('|') + ')(?=$|[^a-zA-Z0-9_А-Яа-яЁё])', 'gi');
356
+
357
+ return { compositeRegex, looseRules };
358
+ }
359
+
360
+ /**
361
+ * Clean AI prompt prefix (e.g., "Claude responded:", "ChatGPT:") from the text.
362
+ *
363
+ * @param {string} text
364
+ * @returns {string}
365
+ */
366
+ function isJsonPayload(str) {
367
+ if (!str || typeof str !== "string") return false;
368
+ const trimmed = str.trim();
369
+ if (!((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")))) {
370
+ return false;
371
+ }
372
+ try {
373
+ JSON.parse(trimmed);
374
+ return true;
375
+ } catch (_) {
376
+ return false;
377
+ }
378
+ }
379
+
380
+ function cleanAIPromptPrefix(text) {
381
+ if (!text) return "";
382
+ let cleaned = text;
383
+ // If text is a full valid JSON object or array, preserve structure
384
+ if (!isJsonPayload(cleaned)) {
385
+ // 1. Strip raw CSS / style blocks leaked from ChatGPT Canvas, web components or stylesheets
386
+ cleaned = cleaned.replace(/^\s*(?:[.#][a-zA-Z0-9_-]+|\[[a-zA-Z0-9_#.:\-*>=,'"\s]+\]|:is\([^)]+\)|[a-zA-Z0-9_-]+)?\s*\{[^}]*?(?:\}\s*|\n\n+|$)/gi, "");
387
+ cleaned = cleaned.replace(/^[;{} \t\r\n]+/, "");
388
+ cleaned = cleaned.replace(/(?:^|\n)[a-zA-Z0-9_#.:\-*>[\]=\s,'"]+\{[^}]*(--[a-zA-Z0-9_-]+:|color-mix\(|var\()[^}]*\}/g, "");
389
+ }
390
+ // 2. Strip AI author prefixes and platform artifacts
391
+ cleaned = cleaned.replace(/^\s*(?:Claude responded|Claude|ChatGPT|Gemini|Grok|DeepSeek|Kimi|Copilot|Assistant|User)\s*(?::|\bsaid\b|\bresponded\b|(?=\s))\s*/i, "");
392
+ cleaned = cleaned.replace(/^(?:Here (?:is|are) (?:the )?(?:redacted|scrubbed|sanitized|processed|clean|updated|modified) (?:text|output|version|data).*?[:\n]+|\*\*Scrubbed Text\*\*[:\n]+|### Scrubbed Text[:\n]+)/i, '');
393
+ cleaned = cleaned.replace(/^\s*Edit\s*\n+/i, "");
394
+ cleaned = cleaned.replace(/\s*\bEdit\s+in\s+a\s+page\b\s*$/i, "");
395
+ // 3. Strip stray leading colons, semicolons, or separators left by stripped icons/artifact headers
396
+ cleaned = cleaned.replace(/^[:;|\-\—\–]+(?=\n|$)/, "");
397
+ cleaned = cleaned.replace(/^[:;]+\s*/, "");
398
+ return cleaned.trim();
399
+ }
400
+
401
+ function buildFastTokenLookup(tokenMap) {
402
+ const lookup = new Map();
403
+ const customRegexParts = [];
404
+ const keys = Object.keys(tokenMap || {});
405
+
406
+ for (let i = 0; i < keys.length; i++) {
407
+ const k = keys[i];
408
+ const v = tokenMap[k];
409
+ lookup.set(k, v);
410
+ lookup.set(k.toUpperCase(), v);
411
+
412
+ const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
413
+ const match = inner.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
414
+ if (match) {
415
+ const label = match[1];
416
+ const baseIndex = parseInt(match[2], 10);
417
+ const aliases = getLabelAliases(label);
418
+ for (let a = 0; a < aliases.length; a++) {
419
+ const u = aliases[a].toUpperCase();
420
+ lookup.set(u + '_' + baseIndex, v);
421
+ lookup.set(u + '-' + baseIndex, v);
422
+ lookup.set(u + ' ' + baseIndex, v);
423
+ lookup.set(u + baseIndex, v);
424
+ lookup.set('[' + u + '_' + baseIndex + ']', v);
425
+ lookup.set('<' + u + '_' + baseIndex + '>', v);
426
+ lookup.set('{{' + u + '_' + baseIndex + '}}', v);
427
+ lookup.set('__' + u + '_' + baseIndex + '__', v);
428
+ lookup.set('[' + u + ' ' + baseIndex + ']', v);
429
+ lookup.set('[' + u + '-' + baseIndex + ']', v);
430
+ }
431
+ } else {
432
+ customRegexParts.push(k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
433
+ }
434
+ }
435
+
436
+ let regexStr = '(?:\\[\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*\\]|<\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*>|\\{\\{\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*\\}\\}|__\\s*[A-Za-z0-9_\\-А-Яа-яЁё ]+\\s*__|(?<=^|[^a-zA-Z0-9_А-Яа-яЁё])[A-Za-z_А-Яа-яЁё]+[-_\\s]*\\d+)';
437
+ if (customRegexParts.length > 0) {
438
+ regexStr = '(?:' + regexStr + '|' + customRegexParts.join('|') + ')';
439
+ }
440
+ const tokenRegex = new RegExp(regexStr + '(?:\'s|’s|s|[а-яёА-ЯЁ]{1,3})?', 'gi');
441
+
442
+ return { lookup, tokenRegex };
443
+ }
444
+
445
+ /**
446
+ * Reverse-protect: replace tokens in AI response with originals from tokenMap.
447
+ *
448
+ * @param {string} aiResponse - Text containing tokens like [NAME_1]
449
+ * @param {Object} tokenMap - { "[NAME_1]": "John Doe", ... }
450
+ * @returns {{ restoredText: string, restoredCount: number }}
451
+ */
452
+ function unscrubText(aiResponse, tokenMap) {
453
+ let text = cleanAIPromptPrefix(aiResponse);
454
+ let restoredCount = 0;
455
+
456
+ if (!tokenMap || Object.keys(tokenMap).length === 0) {
457
+ return { restoredText: text, restoredCount: 0 };
458
+ }
459
+
460
+ const keyCount = Object.keys(tokenMap).length;
461
+ if (keyCount > 50) {
462
+ const { lookup, tokenRegex } = buildFastTokenLookup(tokenMap);
463
+ text = text.replace(tokenRegex, (match) => {
464
+ if (lookup.has(match)) {
465
+ restoredCount++;
466
+ return lookup.get(match);
467
+ }
468
+ const upper = match.toUpperCase();
469
+ if (lookup.has(upper)) {
470
+ restoredCount++;
471
+ return lookup.get(upper);
472
+ }
473
+ const possMatch = match.match(/^([\s\S]+?)('s|’s|s|[а-яёА-ЯЁ]{1,3})$/);
474
+ if (possMatch) {
475
+ const base = possMatch[1];
476
+ const suffix = possMatch[2];
477
+ if (lookup.has(base)) {
478
+ restoredCount++;
479
+ return lookup.get(base) + suffix;
480
+ }
481
+ if (lookup.has(base.toUpperCase())) {
482
+ restoredCount++;
483
+ return lookup.get(base.toUpperCase()) + suffix;
484
+ }
485
+ }
486
+ return match;
487
+ });
488
+ return { restoredText: text, restoredCount };
489
+ }
490
+
491
+ const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
492
+ if (compositeRegex) {
493
+ text = text.replace(compositeRegex, (match) => {
494
+ restoredCount++;
495
+ if (tokenMap[match]) {
496
+ return tokenMap[match];
497
+ }
498
+ let origKey = match;
499
+ for (let i = 0; i < looseRules.length; i++) {
500
+ const rule = looseRules[i];
501
+ if (rule.regex && rule.regex.test(match)) {
502
+ origKey = rule.originalKey;
503
+ break;
504
+ }
505
+ }
506
+ return tokenMap[origKey] || match;
507
+ });
508
+ }
509
+
510
+ return { restoredText: text, restoredCount };
511
+ }
512
+
513
+ /**
514
+ * Reverse-protect with HTML highlighting: replace tokens in AI response with originals wrapped in span.
515
+ *
516
+ * @param {string} aiResponse - Text containing tokens like [NAME_1]
517
+ * @param {Object} tokenMap - { "[NAME_1]": "John Doe", ... }
518
+ * @returns {{ restoredHTML: string, restoredCount: number }}
519
+ */
520
+ function unscrubTextAsHTML(aiResponse, tokenMap) {
521
+ let restoredCount = 0;
522
+
523
+ const cleanResponse = cleanAIPromptPrefix(aiResponse);
524
+ // ALWAYS escape HTML first — even with empty tokenMap — to prevent XSS from AI-generated content
525
+ let text = cleanResponse.replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c] || c));
526
+
527
+ if (!tokenMap || Object.keys(tokenMap).length === 0) {
528
+ return { restoredHTML: text, restoredCount: 0 };
529
+ }
530
+
531
+ const keyCount = Object.keys(tokenMap).length;
532
+ if (keyCount > 50) {
533
+ const { lookup, tokenRegex } = buildFastTokenLookup(tokenMap);
534
+ text = text.replace(tokenRegex, (match) => {
535
+ let rawVal = null;
536
+ if (lookup.has(match)) {
537
+ rawVal = lookup.get(match);
538
+ } else if (lookup.has(match.toUpperCase())) {
539
+ rawVal = lookup.get(match.toUpperCase());
540
+ } else {
541
+ const possMatch = match.match(/^([\s\S]+?)('s|’s|s|[а-яёА-ЯЁ]{1,3})$/);
542
+ if (possMatch) {
543
+ const base = possMatch[1];
544
+ const suffix = possMatch[2];
545
+ if (lookup.has(base)) rawVal = lookup.get(base) + suffix;
546
+ else if (lookup.has(base.toUpperCase())) rawVal = lookup.get(base.toUpperCase()) + suffix;
547
+ }
548
+ }
549
+ if (rawVal !== null) {
550
+ restoredCount++;
551
+ const safeVal = rawVal.replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c] || c));
552
+ return `<span class="ps-restored-data" title="✓ Restored locally in-browser RAM (Never sent to AI)" style="border-bottom: 2px dashed #10b981; color: #10b981; background-color: rgba(16, 185, 129, 0.15); border-radius: 4px; padding: 1px 5px; margin: 0 1px; cursor: help; font-weight: 600; text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);">${safeVal}</span>`;
553
+ }
554
+ return match;
555
+ });
556
+ return { restoredHTML: text, restoredCount };
557
+ }
558
+
559
+ const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
560
+ if (compositeRegex) {
561
+ text = text.replace(compositeRegex, (match) => {
562
+ restoredCount++;
563
+ let origKey = match;
564
+ for (let i = 0; i < looseRules.length; i++) {
565
+ const rule = looseRules[i];
566
+ if (rule.regex && rule.regex.test(match)) {
567
+ origKey = rule.originalKey;
568
+ break;
569
+ }
570
+ }
571
+ const rawVal = tokenMap[origKey] || match;
572
+ const safeVal = rawVal.replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c] || c));
573
+ return `<span class="ps-restored-data" title="✓ Restored locally in-browser RAM (Never sent to AI)" style="border-bottom: 2px dashed #10b981; color: #10b981; background-color: rgba(16, 185, 129, 0.15); border-radius: 4px; padding: 1px 5px; margin: 0 1px; cursor: help; font-weight: 600; text-shadow: 0 0 5px rgba(16, 185, 129, 0.3);">${safeVal}</span>`;
574
+ });
575
+ }
576
+
577
+ return { restoredHTML: text, restoredCount };
578
+ }
579
+
580
+ // Expose standalone hydrateRegex for unit testing
581
+ function hydrateRegex(rule) {
582
+ if (rule && typeof rule.regex === 'string' && rule.regex.startsWith('/')) {
583
+ try {
584
+ const lastSlash = rule.regex.lastIndexOf('/');
585
+ const pattern = rule.regex.substring(1, lastSlash);
586
+ const flags = rule.regex.substring(lastSlash + 1);
587
+ return { ...rule, regex: new RegExp(pattern, flags) };
588
+ } catch (e) {
589
+ console.error('PS: Failed to hydrate regex:', rule.regex, e);
590
+ return rule;
591
+ }
592
+ }
593
+ return rule;
594
+ }
595
+
596
+ function showTeamsPassphraseModal(onSaveCallback) {
597
+ const existing = document.getElementById('ps-teams-modal');
598
+ if (existing) existing.remove();
599
+
600
+ const overlay = document.createElement('div');
601
+ overlay.id = 'ps-teams-modal';
602
+ Object.assign(overlay.style, {
603
+ position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh',
604
+ background: 'rgba(2,6,23,0.8)', backdropFilter: 'blur(10px)',
605
+ zIndex: '2147483647', display: 'flex', alignItems: 'center', justifyContent: 'center',
606
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
607
+ });
608
+
609
+ const modal = document.createElement('div');
610
+ Object.assign(modal.style, {
611
+ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(59,130,246,0.3)',
612
+ borderRadius: '16px', padding: '24px', width: '380px', maxWidth: '90%',
613
+ boxShadow: '0 25px 50px -12px rgba(0,0,0,0.5), 0 0 20px rgba(59,130,246,0.1)',
614
+ color: '#f8fafc', display: 'flex', flexDirection: 'column', gap: '16px',
615
+ animation: 'ps-slide-up 0.3s cubic-bezier(0.16, 1, 0.3, 1)'
616
+ });
617
+
618
+ if (!document.getElementById('ps-modal-styles')) {
619
+ const style = document.createElement('style');
620
+ style.id = 'ps-modal-styles';
621
+ style.textContent = `
622
+ @keyframes ps-slide-up { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
623
+ .ps-modal-input { width: 100%; box-sizing: border-box; background: #020617; border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 12px; border-radius: 8px; font-size: 14px; outline: none; transition: all 0.2s; }
624
+ .ps-modal-input:focus { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
625
+ .ps-modal-btn { flex: 1; padding: 10px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; }
626
+ .ps-modal-btn.primary { background: linear-gradient(135deg, #06b6d4, #3b82f6); color: white; box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
627
+ .ps-modal-btn.primary:hover { filter: brightness(1.1); transform: translateY(-1px); }
628
+ .ps-modal-btn.primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; filter: none; }
629
+ .ps-modal-btn.secondary { background: rgba(255,255,255,0.05); color: #cbd5e1; border: 1px solid rgba(255,255,255,0.1); }
630
+ .ps-modal-btn.secondary:hover { background: rgba(255,255,255,0.1); }
631
+ `;
632
+ document.head.appendChild(style);
633
+ }
634
+
635
+ const header = document.createElement('div');
636
+ Object.assign(header.style, { display: 'flex', alignItems: 'center', gap: '12px' });
637
+
638
+ const headerIcon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
639
+ headerIcon.setAttribute("width", "24"); headerIcon.setAttribute("height", "24"); headerIcon.setAttribute("viewBox", "0 0 24 24");
640
+ headerIcon.setAttribute("fill", "none"); headerIcon.setAttribute("stroke", "#06b6d4"); headerIcon.setAttribute("stroke-width", "2");
641
+ const hp1 = document.createElementNS("http://www.w3.org/2000/svg", "path");
642
+ hp1.setAttribute("d", "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2");
643
+ headerIcon.appendChild(hp1);
644
+ const hc1 = document.createElementNS("http://www.w3.org/2000/svg", "circle");
645
+ hc1.setAttribute("cx", "9"); hc1.setAttribute("cy", "7"); hc1.setAttribute("r", "4");
646
+ headerIcon.appendChild(hc1);
647
+
648
+ const title = document.createElement('h3');
649
+ title.textContent = 'TEAMS Passphrase';
650
+ Object.assign(title.style, { margin: '0', fontSize: '18px', fontWeight: '600' });
651
+
652
+ header.appendChild(headerIcon);
653
+ header.appendChild(title);
654
+
655
+ const desc = document.createElement('p');
656
+ desc.textContent = 'Secure Session Sharing requires a shared key. Set your team\'s passphrase to encrypt the data locally before sharing.';
657
+ Object.assign(desc.style, { margin: '0', fontSize: '13px', color: '#94a3b8', lineHeight: '1.5' });
658
+
659
+ const inputContainer = document.createElement('div');
660
+ const passInput = document.createElement('input');
661
+ passInput.type = 'password';
662
+ passInput.id = 'ps-teams-pass-input';
663
+ passInput.className = 'ps-modal-input';
664
+ passInput.placeholder = 'Configure Passphrase (min 8 chars)';
665
+
666
+ const strengthBar = document.createElement('div');
667
+ strengthBar.id = 'ps-teams-strength-bar';
668
+ Object.assign(strengthBar.style, { height: '4px', borderRadius: '2px', background: 'rgba(255,255,255,0.05)', marginTop: '8px', overflow: 'hidden' });
669
+ const strengthFill = document.createElement('div');
670
+ strengthFill.id = 'ps-teams-strength-fill';
671
+ Object.assign(strengthFill.style, { height: '100%', width: '0%', transition: 'all 0.3s' });
672
+ strengthBar.appendChild(strengthFill);
673
+
674
+ const strengthLabel = document.createElement('div');
675
+ strengthLabel.id = 'ps-teams-strength-label';
676
+ Object.assign(strengthLabel.style, { fontSize: '10px', textTransform: 'uppercase', fontWeight: 'bold', marginTop: '4px', textAlign: 'center' });
677
+ strengthLabel.textContent = '\u00A0';
678
+
679
+ const errorMsg = document.createElement('div');
680
+ errorMsg.id = 'ps-teams-error';
681
+ Object.assign(errorMsg.style, { color: '#ef4444', fontSize: '12px', marginTop: '4px', display: 'none', textAlign: 'center' });
682
+ errorMsg.textContent = 'Passphrase is too weak. Mix characters & letters.';
683
+
684
+ inputContainer.appendChild(passInput);
685
+ inputContainer.appendChild(strengthBar);
686
+ inputContainer.appendChild(strengthLabel);
687
+ inputContainer.appendChild(errorMsg);
688
+
689
+ const btnGroup = document.createElement('div');
690
+ Object.assign(btnGroup.style, { display: 'flex', gap: '10px', marginTop: '4px' });
691
+ const cancelBtn = document.createElement('button');
692
+ cancelBtn.id = 'ps-teams-cancel';
693
+ cancelBtn.className = 'ps-modal-btn secondary';
694
+ cancelBtn.textContent = 'Cancel';
695
+ const saveBtn = document.createElement('button');
696
+ saveBtn.id = 'ps-teams-save';
697
+ saveBtn.className = 'ps-modal-btn primary';
698
+ saveBtn.disabled = true;
699
+ saveBtn.textContent = 'Save & Encrypt';
700
+ btnGroup.appendChild(cancelBtn);
701
+ btnGroup.appendChild(saveBtn);
702
+
703
+ // SECURITY: Opt-in persistence removed to strictly enforce ZTDS (no local storage for plaintext keys).
704
+
705
+ const footer = document.createElement('div');
706
+ Object.assign(footer.style, { textAlign: 'center', marginTop: '4px' });
707
+ const learnLink = document.createElement('a');
708
+ learnLink.href = 'https://privacyscrubber.com/teams';
709
+ learnLink.target = '_blank';
710
+ Object.assign(learnLink.style, { color: '#60a5fa', fontSize: '12px', textDecoration: 'none' });
711
+ learnLink.textContent = 'Learn about TEAMS Cryptography →';
712
+ footer.appendChild(learnLink);
713
+
714
+ modal.appendChild(header);
715
+ modal.appendChild(desc);
716
+ modal.appendChild(inputContainer);
717
+ modal.appendChild(btnGroup);
718
+ modal.appendChild(footer);
719
+
720
+ overlay.appendChild(modal);
721
+ document.body.appendChild(overlay);
722
+
723
+ passInput.focus();
724
+
725
+ passInput.addEventListener('input', () => {
726
+ const p = passInput.value;
727
+ let score = 0;
728
+ if (p.length >= 8) score++;
729
+ if (p.length >= 14) score++;
730
+ if (/[A-Z]/.test(p) && /[a-z]/.test(p)) score++;
731
+ if (/[0-9]/.test(p)) score++;
732
+ if (/[^A-Za-z0-9]/.test(p)) score++;
733
+ score = Math.min(score, 4);
734
+
735
+ const levels = [
736
+ { pct: '0%', color: 'transparent', text: '' },
737
+ { pct: '20%', color: '#ef4444', text: 'Weak' },
738
+ { pct: '40%', color: '#fbbf24', text: 'Fair' },
739
+ { pct: '60%', color: '#facc15', text: 'Good' },
740
+ { pct: '80%', color: '#4ade80', text: 'Strong' },
741
+ { pct: '100%', color: '#10b981', text: 'Great' },
742
+ ];
743
+
744
+ const level = (p.length === 0) ? levels[0] : levels[score];
745
+ strengthFill.style.width = level.pct;
746
+ strengthFill.style.background = level.color;
747
+ strengthLabel.textContent = level.text || '\u00A0';
748
+ strengthLabel.style.color = level.color;
749
+
750
+ if (score >= 2) {
751
+ saveBtn.disabled = false;
752
+ errorMsg.style.display = 'none';
753
+ } else {
754
+ saveBtn.disabled = true;
755
+ if (p.length > 0) errorMsg.style.display = 'block';
756
+ else errorMsg.style.display = 'none';
757
+ }
758
+ });
759
+
760
+ const close = () => {
761
+ overlay.style.opacity = '0';
762
+ setTimeout(() => overlay.remove(), 200);
763
+ };
764
+
765
+ cancelBtn.addEventListener('click', close);
766
+ saveBtn.addEventListener('click', () => {
767
+ if (!saveBtn.disabled) {
768
+ const val = passInput.value.trim();
769
+ close();
770
+ if (onSaveCallback) onSaveCallback({
771
+ passphrase: val,
772
+ shouldClearSession: false,
773
+ shouldSaveLocal: false
774
+ });
775
+ }
776
+ });
777
+ passInput.addEventListener('keydown', (e) => {
778
+ if (e.key === 'Enter' && !saveBtn.disabled) {
779
+ saveBtn.click();
780
+ } else if (e.key === 'Escape') {
781
+ close();
782
+ }
783
+ });
784
+ overlay.addEventListener('click', (e) => {
785
+ if (e.target === overlay) close();
786
+ });
787
+ }
788
+ function showInPageToast(text, variant = 'success') {
789
+ const existing = document.getElementById('ps-ext-toast');
790
+ if (existing) existing.remove();
791
+
792
+ const toast = document.createElement('div');
793
+ toast.id = 'ps-ext-toast';
794
+ toast.textContent = text;
795
+
796
+ const colors = {
797
+ success: { bg: 'rgba(16,185,129,0.12)', border: 'rgba(16,185,129,0.35)', color: '#10b981' },
798
+ info: { bg: 'rgba(59,130,246,0.12)', border: 'rgba(59,130,246,0.35)', color: '#60a5fa' },
799
+ warning: { bg: 'rgba(251,191,36,0.1)', border: 'rgba(251,191,36,0.3)', color: '#fbbf24' },
800
+ error: { bg: 'rgba(239,68,68,0.12)', border: 'rgba(239,68,68,0.35)', color: '#ef4444' },
801
+ };
802
+ const { bg, border, color } = colors[variant] || colors.info;
803
+
804
+ Object.assign(toast.style, {
805
+ position: 'fixed', top: '24px', left: '50%', transform: 'translateX(-50%)', zIndex: '2147483647',
806
+ background: bg, border: `1px solid ${border}`, color,
807
+ padding: '9px 18px', borderRadius: '12px', fontSize: '13px',
808
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
809
+ fontWeight: '500', boxShadow: '0 8px 30px rgba(0,0,0,0.3)',
810
+ backdropFilter: 'blur(8px)', transition: 'opacity 0.3s ease', opacity: '1',
811
+ textAlign: 'center'
812
+ });
813
+
814
+ document.body.appendChild(toast);
815
+ setTimeout(() => {
816
+ toast.style.opacity = '0';
817
+ setTimeout(() => toast.remove(), 350);
818
+ }, 2500);
819
+ }
820
+
821
+ /**
822
+ * Natively walks DOM to replace Tokens with Original text natively inside SPAs.
823
+ * Guaranteed safe for React/Angular since we only manipulate target TextNodes.
824
+ */
825
+
826
+ /**
827
+ * Natively walks DOM to replace Original Text with Tokens natively inside SPAs.
828
+ * Guaranteed safe for React/Angular input fields via MutationObserver syncs.
829
+ */
830
+ function scrubDataInDOM(rootElement, customRules, globalTokenLabels, activeProfile, existingSessionMap, detectOnly = false, isPro = false) {
831
+ if (!rootElement) return null;
832
+
833
+ // ── TEXTAREA / INPUT fast path ────────────────────────────────────────
834
+ // Native <textarea> and <input> store their content in .value — it is
835
+ // NOT a child text node, so createTreeWalker finds nothing and always
836
+ // returns count=0. Handle them directly here.
837
+ if (rootElement.tagName === 'TEXTAREA' || rootElement.tagName === 'INPUT') {
838
+ const text = rootElement.value || '';
839
+ if (!text.trim()) return { count: 0, tokenMap: existingSessionMap || {} };
840
+ const result = scrubText(text, customRules, globalTokenLabels, activeProfile, existingSessionMap || {}, isPro);
841
+ if (result.uniqueUnmasked.size > 0 && !detectOnly) {
842
+ rootElement.value = result.scrubbedText;
843
+ rootElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
844
+ }
845
+ return { count: result.uniqueUnmasked.size, tokenMap: { ...(existingSessionMap || {}), ...result.tokenMap } };
846
+ }
847
+ // ─────────────────────────────────────────────────────────────────────
848
+
849
+ // ── DETECT ONLY fast path ─────────────────────────────────────────────
850
+ // If we only need to update the badge counter (no DOM mutation), evaluate
851
+ // the full text string at once. This bypasses text node splitting issues
852
+ // in ProseMirror/contenteditable and guarantees a 100% accurate count.
853
+ if (detectOnly) {
854
+ const fullText = rootElement.innerText || rootElement.textContent || '';
855
+ if (!fullText.trim()) return { count: 0, tokenMap: existingSessionMap || {} };
856
+ const fullResult = scrubText(fullText, customRules, globalTokenLabels, activeProfile, existingSessionMap || {}, isPro);
857
+ return { count: fullResult.uniqueUnmasked.size, tokenMap: existingSessionMap || {} };
858
+ }
859
+ // ─────────────────────────────────────────────────────────────────────
860
+
861
+ // ── ACTUAL DOM MUTATION (TreeWalker for AutoScrub) ────────────────────
862
+
863
+ const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_TEXT, null, false);
864
+ const nodesToProcess = [];
865
+ let node;
866
+ while(node = walker.nextNode()) {
867
+ if (!node.nodeValue.trim()) continue;
868
+ nodesToProcess.push(node);
869
+ }
870
+
871
+ let globalTokenMap = { ...existingSessionMap };
872
+ let allUniqueUnmasked = new Set();
873
+
874
+ nodesToProcess.forEach(node => {
875
+ const originalText = node.nodeValue;
876
+ const result = scrubText(originalText, customRules, globalTokenLabels, activeProfile, globalTokenMap, isPro);
877
+
878
+ if (result.uniqueUnmasked.size > 0) {
879
+ if (!detectOnly && result.scrubbedText !== originalText) {
880
+ node.nodeValue = result.scrubbedText;
881
+ }
882
+ if (result.count > 0) {
883
+ globalTokenMap = { ...globalTokenMap, ...result.tokenMap };
884
+ }
885
+ result.uniqueUnmasked.forEach(k => allUniqueUnmasked.add(k));
886
+ }
887
+ });
888
+
889
+ // Force React/ProseMirror to notice the change
890
+ if (allUniqueUnmasked.size > 0 && !detectOnly) {
891
+ rootElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
892
+ }
893
+
894
+ return {
895
+ count: allUniqueUnmasked.size,
896
+ tokenMap: globalTokenMap
897
+ };
898
+ }
899
+
900
+ const GLOBAL_ASSISTANT_SELECTORS = [
901
+ // ChatGPT
902
+ '[data-message-author-role="assistant"]',
903
+ '[data-message-author-role="model"]',
904
+ '[data-testid="chat-message-text"]',
905
+ '[data-testid="assistant-message"]',
906
+ '[data-is-streaming]',
907
+ '.agent-turn',
908
+ 'div[role="article"].agent-turn',
909
+ // Claude
910
+ 'model-response',
911
+ '.font-claude-message',
912
+ '.claude-artifact',
913
+ '[data-testid="artifact-renderer"]',
914
+ // Gemini
915
+ '.model-response-text__content',
916
+ '.response-content',
917
+ 'message-content[is-response]',
918
+ // Grok (x.com)
919
+ '[data-testid="messageblock"]',
920
+ '[class*="GrokResponseMessage"]',
921
+ // DeepSeek
922
+ '.ds-markdown',
923
+ // Copilot — NEW React UI (copilot.microsoft.com as of 2025)
924
+ '[data-testid="ai-message"]',
925
+ '[class*="AIMessageContent"]',
926
+ '[class*="ResponseMessage"]',
927
+ '[class*="CopilotMessage"]',
928
+ '[class*="BotMessage"]',
929
+ // Copilot — legacy cib-serp Shadow DOM + Adaptive Cards
930
+ '.ac-textBlock',
931
+ '.cib-message-text',
932
+ '.cib-message',
933
+ '[class*="bot-message"]',
934
+ '[class*="copilot-message"]',
935
+ // Qwen / Tongyi
936
+ '.output-area',
937
+ // Perplexity
938
+ '[data-testid="answer"]',
939
+ // Generic article-based message wrappers (used by many platforms)
940
+ // Excluded from sidebar via nav/aside filter in gatherUniversalAIContext()
941
+ 'div[role="article"]',
942
+ // Conservative generic fallbacks — nav/aside exclusion filter prevents sidebar matches
943
+ '.prose',
944
+ '.whitespace-pre-wrap',
945
+ '.message-content:not([contenteditable])',
946
+ '[class*="message-row"]',
947
+ '[class*="assistant"]',
948
+ '.markdown'
949
+ ].join(', ');
950
+
951
+ function revealDataInDOM(tokenMap) {
952
+ if (!tokenMap || Object.keys(tokenMap).length === 0) {
953
+ if (typeof showInPageToast === 'function') showInPageToast("No protected session data to reveal.", "info");
954
+ return 0;
955
+ }
956
+
957
+ const existingRestoredSpans = document.querySelectorAll('.ps-restored-data');
958
+
959
+ const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
960
+ if (!compositeRegex) {
961
+ if (typeof showInPageToast === 'function') showInPageToast("No protected session data to reveal.", "info");
962
+ return 0;
963
+ }
964
+
965
+ let restoredCount = 0;
966
+ const nodesToReplace = [];
967
+
968
+ const ASSISTANT_SELECTORS = GLOBAL_ASSISTANT_SELECTORS;
969
+
970
+ function isPromptInput(el) {
971
+ if (!el) return false;
972
+ if (el.closest) {
973
+ return !!el.closest('[data-ps-attached="true"]');
974
+ }
975
+ let parent = el;
976
+ while (parent) {
977
+ if (parent.dataset && parent.dataset.psAttached === 'true') {
978
+ return true;
979
+ }
980
+ parent = parent.parentNode || parent.host;
981
+ }
982
+ return false;
983
+ }
984
+
985
+ // Shadow-DOM-aware assistant container detection:
986
+ // .closest() cannot cross shadow boundaries, so if it returns null
987
+ // we check whether the node lives inside a ShadowRoot whose host
988
+ // (or host ancestor) matches an AI response selector.
989
+ function isInsideAssistant(el) {
990
+ if (!el || !el.closest) return false;
991
+ if (el.closest(ASSISTANT_SELECTORS)) return true;
992
+ // Shadow DOM fallback
993
+ const root = el.getRootNode ? el.getRootNode() : null;
994
+ if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot && root.host) {
995
+ // Check if the shadow host itself matches, or has an ancestor that does
996
+ if (root.host.matches && root.host.matches(ASSISTANT_SELECTORS)) return true;
997
+ if (root.host.closest && root.host.closest(ASSISTANT_SELECTORS)) return true;
998
+ }
999
+ return false;
1000
+ }
1001
+
1002
+ function walkTextNodes(root) {
1003
+ if (!root) return;
1004
+
1005
+ // Handle elements with shadow roots if the root itself has one
1006
+ if (root.shadowRoot) {
1007
+ walkTextNodes(root.shadowRoot);
1008
+ }
1009
+
1010
+ // Standard node iteration
1011
+ let child = root.firstChild;
1012
+ while (child) {
1013
+ const next = child.nextSibling;
1014
+ if (child.nodeType === Node.TEXT_NODE) {
1015
+ if (child.parentElement) {
1016
+ const tag = child.parentElement.tagName;
1017
+ if (!['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'NOSCRIPT'].includes(tag)) {
1018
+ // Determine if node is inside an AI assistant response container.
1019
+ // If so, ALWAYS process it — skip both inPromptInput and isInstructionNode checks.
1020
+ const isAssistant = isInsideAssistant(child.parentElement);
1021
+
1022
+ let shouldSkip = false;
1023
+ if (!isAssistant) {
1024
+ // Ignore user input areas where user types prompts actively.
1025
+ // Precise check via isPromptInput helper (looks for data-ps-attached attribute).
1026
+ const inPromptInput = isPromptInput(child.parentElement);
1027
+ if (inPromptInput) {
1028
+ shouldSkip = true;
1029
+ } else {
1030
+ // Check if inside the instruction block itself.
1031
+ const parentTc = child.parentElement.textContent;
1032
+ if (parentTc && parentTc.length < 8000 && (parentTc.includes('[Privacy Scrubber Mode]') || parentTc.includes('[SYSTEM INSTRUCTION: DATA PRIVACY MODE]'))) {
1033
+ shouldSkip = true;
1034
+ }
1035
+ }
1036
+ }
1037
+
1038
+
1039
+ if (!shouldSkip) {
1040
+ compositeRegex.lastIndex = 0;
1041
+ const hasMatch = compositeRegex.test(child.nodeValue);
1042
+ if (hasMatch) {
1043
+ if (['PRE', 'CODE'].includes(tag) || (child.parentElement.closest && child.parentElement.closest('pre, code'))) {
1044
+ const { restoredText, restoredCount: count } = unscrubText(child.nodeValue, tokenMap);
1045
+ if (count > 0) {
1046
+ child.nodeValue = restoredText;
1047
+ restoredCount += count;
1048
+ }
1049
+ } else {
1050
+ nodesToReplace.push(child);
1051
+ }
1052
+ }
1053
+ }
1054
+ }
1055
+ }
1056
+ } else if (child.nodeType === Node.ELEMENT_NODE) {
1057
+ const tag = child.tagName;
1058
+ if (!['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'NOSCRIPT'].includes(tag)) {
1059
+ if (tag === 'IFRAME') {
1060
+ try {
1061
+ const iframeDoc = child.contentDocument || child.contentWindow?.document;
1062
+ if (iframeDoc && iframeDoc.body) {
1063
+ walkTextNodes(iframeDoc.body);
1064
+ }
1065
+ } catch (e) {
1066
+ // ignore cross-origin
1067
+ }
1068
+ } else {
1069
+ // Check if inside an AI assistant response — always walk those.
1070
+ const isAssistantEl = isInsideAssistant(child);
1071
+
1072
+ let shouldSkipEl = false;
1073
+ if (!isAssistantEl) {
1074
+ const inPromptInput = isPromptInput(child);
1075
+ if (inPromptInput) {
1076
+ shouldSkipEl = true;
1077
+ }
1078
+ }
1079
+
1080
+ if (!shouldSkipEl) {
1081
+ walkTextNodes(child);
1082
+ if (child.shadowRoot) {
1083
+ walkTextNodes(child.shadowRoot);
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ child = next;
1090
+ }
1091
+ }
1092
+
1093
+ walkTextNodes(document.documentElement);
1094
+
1095
+ // TOGGLE RE-MASK: If no unmasked tokens were found on screen, but there are already revealed .ps-restored-data spans, re-mask them back to tokens
1096
+ if (nodesToReplace.length === 0 && existingRestoredSpans.length > 0) {
1097
+ let remaskedCount = 0;
1098
+ existingRestoredSpans.forEach(span => {
1099
+ const origToken = span.getAttribute('data-original-token') || span.getAttribute('data-token');
1100
+ let tokenToRestore = origToken;
1101
+ if (!tokenToRestore) {
1102
+ const currentVal = span.textContent;
1103
+ for (const [t, v] of Object.entries(tokenMap)) {
1104
+ if (v === currentVal) {
1105
+ tokenToRestore = t;
1106
+ break;
1107
+ }
1108
+ }
1109
+ }
1110
+ if (tokenToRestore && span.parentElement) {
1111
+ const textNode = document.createTextNode(tokenToRestore);
1112
+ try {
1113
+ span.parentElement.replaceChild(textNode, span);
1114
+ remaskedCount++;
1115
+ } catch (_) {}
1116
+ }
1117
+ });
1118
+ if (remaskedCount > 0) {
1119
+ if (typeof showInPageToast === 'function') showInPageToast(`🔒 Re-masked in DOM. Original tokens restored.`, "info");
1120
+ document.querySelectorAll('.ps-toolbar-reveal, [id$="-reveal"]').forEach(b => {
1121
+ b.setAttribute('title', 'Reveal Original Data (Decrypted Locally)');
1122
+ b.classList.remove('ps-revealed-active');
1123
+ });
1124
+ return remaskedCount;
1125
+ }
1126
+ }
1127
+
1128
+ nodesToReplace.forEach(node => {
1129
+ const text = node.nodeValue;
1130
+ const fragment = document.createDocumentFragment();
1131
+ let lastIndex = 0;
1132
+ let match;
1133
+
1134
+ compositeRegex.lastIndex = 0;
1135
+ while ((match = compositeRegex.exec(text)) !== null) {
1136
+ if (match.index > lastIndex) {
1137
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
1138
+ }
1139
+ let origKey = match[0];
1140
+ for (const rule of looseRules) {
1141
+ if (new RegExp('^' + rule.patternStr + '$', 'i').test(match[0])) {
1142
+ origKey = rule.originalKey;
1143
+ break;
1144
+ }
1145
+ }
1146
+ const rawVal = tokenMap[origKey] || match[0];
1147
+
1148
+ const span = document.createElement('span');
1149
+ span.className = 'ps-restored-data';
1150
+ span.setAttribute('data-original-token', origKey);
1151
+ span.title = `✓ Restored locally in-browser RAM (Never sent to AI) | Original: ${origKey}`;
1152
+ Object.assign(span.style, {
1153
+ borderBottom: '2px dashed #10b981',
1154
+ color: '#10b981',
1155
+ backgroundColor: 'rgba(16, 185, 129, 0.15)',
1156
+ borderRadius: '4px',
1157
+ padding: '1px 5px',
1158
+ margin: '0 1px',
1159
+ cursor: 'help',
1160
+ fontWeight: '600',
1161
+ position: 'relative',
1162
+ display: 'inline-block',
1163
+ zIndex: '1',
1164
+ boxShadow: '0 0 8px rgba(16, 185, 129, 0.25)',
1165
+ textShadow: '0 0 4px rgba(16, 185, 129, 0.4)'
1166
+ });
1167
+ span.textContent = rawVal;
1168
+
1169
+ fragment.appendChild(span);
1170
+ restoredCount++;
1171
+ lastIndex = compositeRegex.lastIndex;
1172
+ }
1173
+ if (lastIndex < text.length) {
1174
+ fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
1175
+ }
1176
+
1177
+ if (node.parentElement) {
1178
+ try {
1179
+ node.parentElement.replaceChild(fragment, node);
1180
+ } catch (e) {
1181
+ // Fail silently
1182
+ }
1183
+ }
1184
+ });
1185
+
1186
+ if (restoredCount > 0) {
1187
+ if (typeof showInPageToast === 'function') showInPageToast(`✅ Decrypted Locally! Your data is now safe to copy.`, "success");
1188
+ document.querySelectorAll('.ps-toolbar-reveal, [id$="-reveal"]').forEach(b => {
1189
+ b.setAttribute('title', 'Hide / Re-mask Protected Data in DOM');
1190
+ b.classList.add('ps-revealed-active');
1191
+ });
1192
+ } else {
1193
+ if (typeof showInPageToast === 'function') showInPageToast("No active tokens found on the screen.", "info");
1194
+ }
1195
+ return restoredCount;
1196
+ }
1197
+
1198
+ function gatherUniversalAIContext() {
1199
+ try {
1200
+ // Helper function to recursively find elements matching selectors across shadow DOMs and same-origin iframes
1201
+ function querySelectorAllRecursive(root, selector) {
1202
+ // Single depth-first traversal that crosses shadow DOM boundaries.
1203
+ // Does NOT use root.querySelectorAll() to avoid duplicating shadow DOM results.
1204
+ const results = [];
1205
+ if (!root) return results;
1206
+
1207
+ function walk(node) {
1208
+ if (!node) return;
1209
+ if (node.nodeType === Node.ELEMENT_NODE) {
1210
+ try {
1211
+ if (node.matches && node.matches(selector)) results.push(node);
1212
+ } catch (e) {}
1213
+ // Recurse into shadow DOM (depth-first before light children)
1214
+ if (node.shadowRoot) walk(node.shadowRoot);
1215
+ // Recurse into same-origin iframes
1216
+ if (node.tagName === 'IFRAME') {
1217
+ try {
1218
+ const iframeDoc = node.contentDocument || node.contentWindow?.document;
1219
+ if (iframeDoc) walk(iframeDoc);
1220
+ } catch (e) {}
1221
+ return; // children handled above
1222
+ }
1223
+ } else if (node.nodeType !== Node.DOCUMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
1224
+ return; // text node, comment, etc
1225
+ }
1226
+ // Walk light DOM / document children
1227
+ let child = node.firstChild;
1228
+ while (child) { walk(child); child = child.nextSibling; }
1229
+ }
1230
+
1231
+ walk(root);
1232
+ return results;
1233
+ }
1234
+
1235
+
1236
+ // 1. Gather Input Text
1237
+ const inputs = querySelectorAllRecursive(document, 'textarea, div[contenteditable="true"], div.ProseMirror');
1238
+ // Find the active visible input, ignoring our own injected toolbar
1239
+ const activeInput = inputs.find(el => ((el.offsetParent !== null && el.offsetHeight > 0) || (typeof window !== 'undefined' && !window.chrome?.runtime)) && !el.closest('.ps-toolbar-container'));
1240
+ const inputText = activeInput ? (activeInput.value || activeInput.innerText || activeInput.textContent || "") : "";
1241
+
1242
+ // 2. Gather Output Text (Last AI Response)
1243
+ let outputText = "";
1244
+
1245
+ // Universal selectors covering major AI models + Claude Artifacts
1246
+ const knownSelectors = GLOBAL_ASSISTANT_SELECTORS;
1247
+
1248
+ const candidateNodes = querySelectorAllRecursive(document, knownSelectors);
1249
+
1250
+ // Filter out nodes that obviously belong to the user or sidebar/navigation
1251
+ const validResponses = Array.from(candidateNodes).filter(el => {
1252
+ // If it is inside a shadow DOM, offsetParent might be null, check offsetHeight/getBoundingClientRect
1253
+ const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
1254
+ const isJSDOM = typeof window !== 'undefined' && !window.chrome?.runtime && el.offsetParent === null && el.offsetHeight === 0;
1255
+ const isVisible = isJSDOM || (el.offsetParent !== null && el.offsetHeight > 0) || (rect && rect.height > 0 && rect.width > 0);
1256
+ if (!isVisible) return false;
1257
+
1258
+ // SIDEBAR/NAV EXCLUSION: exclude elements inside navigation or sidebar areas.
1259
+ // This prevents Kimi sidebar, Grok left panel, and similar from being captured.
1260
+ if (el.closest) {
1261
+ if (el.closest('nav, aside, header')) return false;
1262
+ if (el.closest('[role="navigation"], [role="complementary"], [role="banner"]')) return false;
1263
+ }
1264
+
1265
+ // QWEN THINKING FILTER: exclude Qwen/DeepSeek "Thinking completed" reasoning blocks.
1266
+ // These are collapsible sections shown before the actual response.
1267
+ if (el.closest) {
1268
+ if (el.closest('[class*="thinking"], [class*="reasoning"], [class*="Thinking"], [class*="chain-of-thought"]')) return false;
1269
+ if (el.classList.contains('thinking') || el.classList.contains('reasoning')) return false;
1270
+ }
1271
+ // Also exclude by text heuristic: element whose ONLY content is the thinking header
1272
+ {
1273
+ const rawText = (el.innerText || el.textContent || '').trim();
1274
+ if (rawText.toLowerCase() === 'thinking completed' || rawText.toLowerCase() === 'thinking...') return false;
1275
+ }
1276
+
1277
+ // KEY EXCLUSION: elements that contain the active textarea are page/chat wrappers,
1278
+ // never AI responses. This is the definitive fix for Kimi (and similar platforms)
1279
+ // where a large wrapper div matches a generic selector and includes the entire
1280
+ // sidebar + chat area + user input field.
1281
+ if (activeInput && el.contains(activeInput)) return false;
1282
+
1283
+ // Exclude user messages that share generic classes like .message-content
1284
+ const role = el.getAttribute('data-message-author-role');
1285
+ if (role === 'user') return false;
1286
+
1287
+ // Exclude explicit user classes
1288
+ if (el.closest) {
1289
+ if (el.closest('.user-message') || el.classList.contains('user-message')) return false;
1290
+ if (el.closest('[data-testid="user-message"]')) return false;
1291
+ if (el.closest('[data-message-author-role="user"]')) return false;
1292
+ } else {
1293
+ let parent = el.parentNode;
1294
+ while (parent) {
1295
+ if (parent.classList && (parent.classList.contains('user-message') || parent.getAttribute('data-testid') === 'user-message')) {
1296
+ return false;
1297
+ }
1298
+ parent = parent.parentNode || parent.host;
1299
+ }
1300
+ }
1301
+
1302
+ return true;
1303
+ });
1304
+
1305
+ function getCleanElementText(el) {
1306
+ if (!el) return "";
1307
+ try {
1308
+ const clone = el.cloneNode(true);
1309
+ clone.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(s => s.remove());
1310
+ clone.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
1311
+ clone.querySelectorAll('p, div, li, tr, h1, h2, h3, h4, h5, h6, pre, blockquote').forEach(b => {
1312
+ b.insertAdjacentText('afterend', '\n');
1313
+ });
1314
+ const text = (clone.textContent || "").replace(/\n{3,}/g, '\n\n').trim();
1315
+ if (/^\[data-conversation-component=[^\]]+\]\{/.test(text) || /^\{[ \t\r\n]*--[a-zA-Z0-9_-]+:/.test(text)) return "";
1316
+ return text;
1317
+ } catch (_) {
1318
+ return (el.textContent || "").trim();
1319
+ }
1320
+ }
1321
+
1322
+ function extractCleanAssistantMessageText(turnEl) {
1323
+ if (!turnEl) return "";
1324
+ try {
1325
+ const clone = turnEl.cloneNode(true);
1326
+ // 1. Remove style, script, noscript, svg, link, template elements (prevents CSS leaks)
1327
+ clone.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(el => el.remove());
1328
+
1329
+ // 2. Remove reasoning / thought accordions
1330
+ clone.querySelectorAll(
1331
+ '[class*="thinking"], [class*="reasoning"], [class*="thought"], [data-testid*="thought"], [data-testid*="reasoning"], details, .thinking, .reasoning'
1332
+ ).forEach(el => el.remove());
1333
+
1334
+ // 3. Remove bottom toolbar action buttons and canvas headers
1335
+ clone.querySelectorAll(
1336
+ 'button, [role="button"], [class*="toolbar"], [class*="actions"], [class*="feedback"], [data-testid*="action"], [class*="copy-button"], [class*="read-aloud"], .ps-token-chips-bar, [class*="canvas-header"], [data-testid*="artifact-header"]'
1337
+ ).forEach(el => el.remove());
1338
+
1339
+ // 4. Find top-level markdown / content blocks
1340
+ const contentNodes = clone.querySelectorAll(
1341
+ '.markdown, .prose, [class*="markdown"], [class*="prose"], .text-message, .ds-markdown, .font-claude-message, message-content, pre, table'
1342
+ );
1343
+ if (contentNodes.length > 0) {
1344
+ const topNodes = Array.from(contentNodes).filter((node, _, list) =>
1345
+ !list.some(parent => parent !== node && parent.contains(node))
1346
+ );
1347
+ const joined = topNodes.map(n => getCleanElementText(n)).filter(Boolean).join('\n\n');
1348
+ if (joined) return cleanAIPromptPrefix(joined);
1349
+ }
1350
+
1351
+ const fullCleaned = getCleanElementText(clone);
1352
+ if (fullCleaned) return cleanAIPromptPrefix(fullCleaned);
1353
+ } catch (_) {}
1354
+ return cleanAIPromptPrefix(turnEl.innerText || turnEl.textContent || "").trim();
1355
+ }
1356
+
1357
+ if (validResponses.length > 0) {
1358
+ // Return the text of the very last valid response on the page.
1359
+ // De-ancestor: if element A contains element B (both matched), prefer B (more specific).
1360
+ const leafResponses = validResponses.filter((el, _, arr) =>
1361
+ !arr.some(other => other !== el && el.contains(other))
1362
+ );
1363
+ const candidates = leafResponses.length > 0 ? leafResponses : validResponses;
1364
+ const lastLeaf = candidates[candidates.length - 1];
1365
+
1366
+ const fullTurn = resolveFullAssistantTurnElement(lastLeaf, activeInput);
1367
+ outputText = extractCleanAssistantMessageText(fullTurn || lastLeaf);
1368
+ }
1369
+
1370
+
1371
+ if (!outputText.trim()) {
1372
+ // Fallback: look for any element containing tokens, excluding prompt inputs and nav/sidebar areas
1373
+ const allElements = querySelectorAllRecursive(document, 'div, p, span, li, td, input, textarea, [role="textbox"]');
1374
+ const tokenContainingElements = allElements.filter(el => {
1375
+ // Exclude navigation/sidebar areas (same as primary filter)
1376
+ if (el.closest && el.closest('nav, aside, header, [role="navigation"], [role="complementary"], [role="banner"]')) return false;
1377
+ // Exclude page/chat wrappers that contain the active input
1378
+ if (activeInput && el.contains(activeInput)) return false;
1379
+ // Exclude prompt inputs (active ones with data-ps-attached attribute)
1380
+ let parent = el;
1381
+ while (parent) {
1382
+ if (parent.dataset && parent.dataset.psAttached === 'true') return false;
1383
+ parent = parent.parentNode || parent.host;
1384
+ }
1385
+ if (['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(el.tagName)) return false;
1386
+ const text = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? (el.value || "") : (el.innerText || el.textContent || "");
1387
+ return /\[[A-Z]+_[0-9]+\]/.test(text);
1388
+ });
1389
+
1390
+ let bestEl = null;
1391
+ let maxTokens = 0;
1392
+ let bestTextLength = 0;
1393
+
1394
+ for (const el of tokenContainingElements) {
1395
+ const text = (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') ? (el.value || "") : (el.innerText || el.textContent || "");
1396
+ const matches = text.match(/\[[A-Z]+_[0-9]+\]/g) || [];
1397
+ const uniqueTokens = new Set(matches).size;
1398
+ if (uniqueTokens > maxTokens || (uniqueTokens === maxTokens && uniqueTokens > 0 && text.length > bestTextLength)) {
1399
+ maxTokens = uniqueTokens;
1400
+ bestTextLength = text.length;
1401
+ bestEl = el;
1402
+ }
1403
+ }
1404
+ if (bestEl) {
1405
+ outputText = (bestEl.tagName === 'INPUT' || bestEl.tagName === 'TEXTAREA') ? (bestEl.value || "") : (bestEl.innerText || bestEl.textContent || "");
1406
+ }
1407
+ }
1408
+
1409
+ return {
1410
+ inputText: inputText.trim(),
1411
+ outputText: cleanAIPromptPrefix(outputText).trim()
1412
+ };
1413
+ } catch (e) {
1414
+ console.error("[PrivacyScrubber] Universal Context gather failed:", e);
1415
+ return { inputText: "", outputText: "" };
1416
+ }
1417
+ }
1418
+
1419
+ // Export to window for content scripts
1420
+ // Expose for OCR and advanced usage
1421
+ function getRules(profile) {
1422
+ const engine = getEngine();
1423
+ if (engine) {
1424
+ return engine.getActiveRules(profile);
1425
+ }
1426
+ return [];
1427
+ }
1428
+
1429
+ /**
1430
+ * Show a right-click context menu for Profile Switching
1431
+ */
1432
+ function showProfileMenu(e, anchorElement, onProfileChange) {
1433
+ // Remove existing menu if any
1434
+ let existing = document.getElementById('ps-profile-menu');
1435
+ if (existing) {
1436
+ existing.remove();
1437
+ return; // Act as a toggle
1438
+ }
1439
+
1440
+ const PROFILES = [
1441
+ { id: 'general', label: 'General' },
1442
+ { id: 'engineering', label: 'Engineering' },
1443
+ { id: 'finance', label: 'Finance' },
1444
+ { id: 'legal', label: 'Legal' },
1445
+ { id: 'medical', label: 'Healthcare' },
1446
+ { id: 'hr', label: 'HR' }
1447
+ ];
1448
+
1449
+ chrome.storage.local.get(['ps_active_profile', 'ps_is_pro', 'ps_is_teams'], (data) => {
1450
+ const activeProfile = (data.ps_active_profile || 'general').toLowerCase();
1451
+ const isPro = data.ps_is_pro || data.ps_is_teams || false;
1452
+
1453
+ const menu = document.createElement('div');
1454
+ menu.id = 'ps-profile-menu';
1455
+ menu.className = 'ps-profile-menu';
1456
+
1457
+ // Positioning
1458
+ const rect = anchorElement.getBoundingClientRect();
1459
+ // Try to position it below and to the left of the button
1460
+ menu.style.top = (rect.bottom + window.scrollY + 5) + 'px';
1461
+ menu.style.left = (rect.right + window.scrollX - 150) + 'px';
1462
+
1463
+ const header = document.createElement('div');
1464
+ header.className = 'ps-profile-menu-header';
1465
+ header.innerText = 'Detection Profile';
1466
+ menu.appendChild(header);
1467
+
1468
+ PROFILES.forEach(p => {
1469
+ const item = document.createElement('div');
1470
+ item.className = 'ps-profile-menu-item';
1471
+ const isCurrentActive = p.id.toLowerCase() === activeProfile;
1472
+ if (isCurrentActive) item.classList.add('active');
1473
+
1474
+ let labelText = p.label;
1475
+ item.innerText = labelText;
1476
+
1477
+ if (!isPro && p.id !== 'general') {
1478
+ item.style.opacity = '0.5';
1479
+ item.style.cursor = 'not-allowed';
1480
+ item.title = 'PRO Feature';
1481
+ item.innerText = labelText + ' 🔒';
1482
+ } else if (isCurrentActive) {
1483
+ item.innerText = labelText + ' ✓';
1484
+ }
1485
+
1486
+ item.addEventListener('click', (ev) => {
1487
+ ev.stopPropagation();
1488
+ if (!isPro && p.id !== 'general') {
1489
+ if (typeof showInPageToast === 'function') {
1490
+ showInPageToast('Specialized Profiles require PRO upgrade.', 'warning');
1491
+ }
1492
+ menu.remove();
1493
+ return;
1494
+ }
1495
+
1496
+ chrome.storage.local.set({ ps_active_profile: p.id }, () => {
1497
+ if (typeof showInPageToast === 'function') {
1498
+ showInPageToast(`Profile Switched to ${p.label}`, 'success');
1499
+ }
1500
+ menu.remove();
1501
+
1502
+ // Trigger immediate re-scrub if callback provided
1503
+ if (typeof onProfileChange === 'function') {
1504
+ onProfileChange(p.id);
1505
+ }
1506
+ });
1507
+ });
1508
+
1509
+ menu.appendChild(item);
1510
+ });
1511
+
1512
+ document.body.appendChild(menu);
1513
+
1514
+ // Close on click outside
1515
+ const closeMenu = (ev) => {
1516
+ if (!menu.contains(ev.target) && ev.target !== anchorElement) {
1517
+ menu.remove();
1518
+ document.removeEventListener('click', closeMenu);
1519
+ document.removeEventListener('contextmenu', closeMenu);
1520
+ }
1521
+ };
1522
+
1523
+ setTimeout(() => {
1524
+ document.addEventListener('click', closeMenu);
1525
+ document.addEventListener('contextmenu', closeMenu);
1526
+ }, 50);
1527
+ });
1528
+ }
1529
+
1530
+ /**
1531
+ * exportSessionToFile — Downloads the current session map as a JSON file
1532
+ */
1533
+ function exportSessionToFile(sessionMap, filename = 'ps-session', extraContext = null) {
1534
+ if (!sessionMap || Object.keys(sessionMap).length === 0) {
1535
+ if (window.showInPageToast) window.showInPageToast("No active session data to export.", "info");
1536
+ return;
1537
+ }
1538
+ try {
1539
+ const data = {
1540
+ version: "1.6.4",
1541
+ timestamp: new Date().toISOString(),
1542
+ sessionMap: sessionMap,
1543
+ context: extraContext
1544
+ };
1545
+ const jsonStr = JSON.stringify(data, null, 2);
1546
+ if (typeof downloadFile === 'function') {
1547
+ downloadFile(jsonStr, `${filename}-${Date.now()}.json`, 'application/json');
1548
+ } else {
1549
+ const blob = new Blob([jsonStr], { type: 'application/json' });
1550
+ const url = URL.createObjectURL(blob);
1551
+ if (typeof chrome !== 'undefined' && chrome.downloads) {
1552
+ chrome.downloads.download({ url: url, filename: `${filename}-${Date.now()}.json` });
1553
+ } else {
1554
+ const a = document.createElement('a');
1555
+ a.href = url;
1556
+ a.download = `${filename}-${Date.now()}.json`;
1557
+ document.body.appendChild(a);
1558
+ a.click();
1559
+ document.body.removeChild(a);
1560
+ }
1561
+ }
1562
+ if (window.showInPageToast) window.showInPageToast("✓ Session exported successfully", "success");
1563
+ } catch (e) {
1564
+ console.error("PrivacyScrubber Export Error:", e);
1565
+ if (window.showInPageToast) window.showInPageToast("❌ Export failed.", "error");
1566
+ }
1567
+ }
1568
+
1569
+ function extractCleanAssistantMessageText(turnEl) {
1570
+ if (!turnEl) return "";
1571
+ try {
1572
+ const clone = turnEl.cloneNode(true);
1573
+ // 1. Remove style, script, noscript, svg, link, template elements (prevents CSS leaks)
1574
+ clone.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(el => el.remove());
1575
+
1576
+ // 2. Remove reasoning / thought accordions
1577
+ clone.querySelectorAll(
1578
+ '[class*="thinking"], [class*="reasoning"], [class*="thought"], [data-testid*="thought"], [data-testid*="reasoning"], details, .thinking, .reasoning'
1579
+ ).forEach(el => el.remove());
1580
+
1581
+ // 3. Remove bottom toolbar action buttons and canvas headers
1582
+ clone.querySelectorAll(
1583
+ 'button, [role="button"], [class*="toolbar"], [class*="actions"], [class*="feedback"], [data-testid*="action"], [class*="copy-button"], [class*="read-aloud"], .ps-token-chips-bar, [class*="canvas-header"], [data-testid*="artifact-header"]'
1584
+ ).forEach(el => el.remove());
1585
+
1586
+ // 4. Find top-level markdown / content blocks
1587
+ const contentNodes = clone.querySelectorAll(
1588
+ '.markdown, .prose, [class*="markdown"], [class*="prose"], .text-message, .ds-markdown, .font-claude-message, message-content, pre, table'
1589
+ );
1590
+ function getClean(el) {
1591
+ if (!el) return "";
1592
+ try {
1593
+ const c = el.cloneNode(true);
1594
+ c.querySelectorAll('style, script, noscript, svg, link, template, [hidden]').forEach(s => s.remove());
1595
+ c.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
1596
+ c.querySelectorAll('p, div, li, tr, h1, h2, h3, h4, h5, h6, pre, blockquote').forEach(b => {
1597
+ b.insertAdjacentText('afterend', '\n');
1598
+ });
1599
+ const text = (c.textContent || "").replace(/\n{3,}/g, '\n\n').trim();
1600
+ if (/^\[data-conversation-component=[^\]]+\]\{/.test(text) || /^\{[ \t\r\n]*--[a-zA-Z0-9_-]+:/.test(text)) return "";
1601
+ return text;
1602
+ } catch (_) {
1603
+ return (el.textContent || "").trim();
1604
+ }
1605
+ }
1606
+ if (contentNodes.length > 0) {
1607
+ const topNodes = Array.from(contentNodes).filter((node, _, list) =>
1608
+ !list.some(parent => parent !== node && parent.contains(node))
1609
+ );
1610
+ const joined = topNodes.map(n => getClean(n)).filter(Boolean).join('\n\n');
1611
+ if (joined) return cleanAIPromptPrefix(joined);
1612
+ }
1613
+
1614
+ const fullCleaned = getClean(clone);
1615
+ if (fullCleaned) return cleanAIPromptPrefix(fullCleaned);
1616
+ } catch (_) {}
1617
+ return cleanAIPromptPrefix(turnEl.innerText || turnEl.textContent || "").trim();
1618
+ }
1619
+
1620
+ function resolveFullAssistantTurnElement(leafOrContainer, textarea) {
1621
+ if (!leafOrContainer) return null;
1622
+ try {
1623
+ const turnContainer = leafOrContainer.closest ? leafOrContainer.closest(
1624
+ 'article, [data-message-author-role="assistant"], [data-message-author-role="model"], [data-testid*="assistant-message"], [data-testid*="conversation-turn"], [class*="chat-message"], [class*="ds-message"], [class*="message-item"], [class*="response-container"], [class*="chat-turn"], [class*="agent-turn"], [class*="talk-bubble"], [class*="bot-message"], [class*="output-block"]'
1625
+ ) : null;
1626
+
1627
+ if (turnContainer && (!textarea || !turnContainer.contains(textarea))) {
1628
+ const userMsgCount = turnContainer.querySelectorAll ? turnContainer.querySelectorAll('.user-message, [data-message-author-role="user"], [data-testid="user-message"]').length : 0;
1629
+ if (userMsgCount === 0) {
1630
+ return turnContainer;
1631
+ }
1632
+ }
1633
+
1634
+ if (leafOrContainer.parentElement) {
1635
+ const parent = leafOrContainer.parentElement;
1636
+ if (!parent.closest('nav, aside, header, [role="navigation"]') && (!textarea || !parent.contains(textarea))) {
1637
+ const siblingContentNodes = parent.querySelectorAll ? parent.querySelectorAll('.ds-markdown, [class*="ds-markdown"], .markdown, .prose, pre, p, blockquote, ol, ul, div') : [];
1638
+ if (siblingContentNodes.length > 1) {
1639
+ return parent;
1640
+ }
1641
+ }
1642
+ }
1643
+ } catch (_) {}
1644
+ return leafOrContainer;
1645
+ }
1646
+
1647
+ const engine = getEngine();
1648
+
1649
+ if (typeof window !== 'undefined') {
1650
+ window.PrivacyScrubberCore = {
1651
+ init,
1652
+ scrubText,
1653
+ unscrubText: (text, sessionMap, opts) => {
1654
+ if (!engine) return null;
1655
+ const res = engine.unscrubText(text, sessionMap, opts);
1656
+ return res ? { restoredText: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
1657
+ },
1658
+ unscrubTextAsHTML: (text, sessionMap, opts) => {
1659
+ if (!engine) return null;
1660
+ const res = engine.unscrubTextAsHTML(text, sessionMap, opts);
1661
+ return res ? { restoredHTML: res.text, html: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
1662
+ },
1663
+ showTeamsPassphraseModal,
1664
+ scrubDataInDOM,
1665
+ revealDataInDOM,
1666
+ gatherUniversalAIContext,
1667
+ resolveFullAssistantTurnElement,
1668
+ extractCleanAssistantMessageText,
1669
+ extractLLMAssistantText: extractCleanAssistantMessageText,
1670
+ exportSessionToFile,
1671
+ getRules,
1672
+ showProfileMenu,
1673
+ cleanAIPromptPrefix: (text) => engine ? engine.cleanAIPromptPrefix(text) : text,
1674
+ hydrateRegex: (rule) => engine ? engine.hydrateRegex(rule) : rule,
1675
+ isInitialized: false
1676
+ };
1677
+ window.showInPageToast = showInPageToast;
1678
+
1679
+ // v1.4.4: Automatic re-hydration on boot from local storage cache
1680
+ if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.local) {
1681
+ chrome.storage.local.get(['ps_rules_cache'], (data) => {
1682
+ if (data.ps_rules_cache) {
1683
+ init(data.ps_rules_cache);
1684
+ }
1685
+ });
1686
+ }
1687
+ }
1688
+ if (typeof module !== 'undefined' && module.exports) {
1689
+ module.exports = {
1690
+ init,
1691
+ scrubText,
1692
+ unscrubText: (text, sessionMap, opts) => {
1693
+ if (!engine) return null;
1694
+ const res = engine.unscrubText(text, sessionMap, opts);
1695
+ return res ? { restoredText: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
1696
+ },
1697
+ unscrubTextAsHTML: (text, sessionMap, opts) => {
1698
+ if (!engine) return null;
1699
+ const res = engine.unscrubTextAsHTML(text, sessionMap, opts);
1700
+ return res ? { restoredHTML: res.text, html: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
1701
+ },
1702
+ resolveFullAssistantTurnElement,
1703
+ extractCleanAssistantMessageText,
1704
+ extractLLMAssistantText: extractCleanAssistantMessageText,
1705
+ cleanAIPromptPrefix: (text) => engine ? engine.cleanAIPromptPrefix(text) : text,
1706
+ hydrateRegex: (rule) => engine ? engine.hydrateRegex(rule) : rule,
1707
+ getRules
1708
+ };
1709
+ }
1710
+ })();
1711
+