@privacyscrubber/mcp-server 1.7.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/scrubber-core.cjs CHANGED
@@ -35,78 +35,15 @@
35
35
  }
36
36
  }
37
37
 
38
- // Stitcher runs for ALL profiles — LEGAL_STARTERS guard prevents false positives.
39
-
40
- // Signal A: line ends with a name fragment (all-caps or mixed-case caps word or initial+dot)
41
- // e.g. "KKKKK I.", "John I.", "JOHNSON", or "Johnson"
42
- const RE_LINE_ENDS_AS_NAME = /(?:^|[ ,])(?:[A-Z]{2,}(?:[A-Z'-]*[A-Z])?|[A-Z][a-z]+(?:[' -][A-Z][a-z]+)*)(?:\s+[A-Z]\s*\.?)*\s*$/m;
43
-
44
- // Signal B: next line starts with a surname (all-caps or mixed-case) — supports short surnames (e.g. Li, Wu)
45
- const RE_LINE_STARTS_AS_SURNAME = /^(?:[A-Z][a-z]+(?:[A-Za-z'-]*)?|[A-Z]{2,}(?:[A-Z'-]*[A-Z])?)(?:'[sS])?(?:[,\s]|$)/;
46
-
47
- // Signal C: line is exactly a middle initial + optional dot
48
- const RE_MIDDLE_INITIAL = /^[A-Z]\s*\.?,?\s*$/;
49
-
50
- // Guard: if next line starts with one of these words, it is a legal/structural paragraph
51
- // starter — NOT a surname. Skip stitching to avoid false positives.
52
- const LEGAL_STARTERS = new Set([
53
- // Mixed-case legal clause starters
54
- 'Wherein','That','This','The','Said','Dated','Whereas','Now','Therefore',
55
- 'Hereby','Herein','Hereto','Herewith','Hereafter','Upon','Pursuant',
56
- 'Between','Among','Under','Within','Without','During','After','Before',
57
- 'Each','Any','All','Such','Both','Either','Neither','No','Not','If','When',
58
- // ALL-CAPS equivalents (trust/deed documents often use all-caps paragraphs)
59
- 'WHEREIN','THAT','THIS','WHEREAS','NOW','THEREFORE','HEREBY','HEREIN',
60
- 'PURSUANT','BETWEEN','AMONG','UNDER','DURING','AFTER','BEFORE',
61
- 'TRUST','AGREEMENT','DEED','SCHEDULE','EXHIBIT','ARTICLE','SECTION'
62
- ]);
63
-
64
38
  function stitchOrphanedNameLines(text, profile) {
65
- // Normalize line endings: \r\n (Windows) and \r (old Mac) → \n
66
- // Prevents stitcher from silently failing on PDFs with non-Unix line endings
67
- text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
68
- // Only run on multi-line text (single-line paste has nothing to stitch)
69
- if (!text.includes('\n')) return text;
70
- const lines = text.split('\n');
71
- if (lines.length < 2) return text;
72
- const result = [];
73
- let i = 0;
74
- while (i < lines.length) {
75
- const line = lines[i];
76
- const next1 = lines[i + 1];
77
- const next2 = lines[i + 2];
78
-
79
- // 1. Try 3-line stitch first: Firstname \n MiddleInitial \n Surname
80
- if (next1 !== undefined && next2 !== undefined &&
81
- RE_LINE_ENDS_AS_NAME.test(line) &&
82
- RE_MIDDLE_INITIAL.test(next1.trim()) &&
83
- RE_LINE_STARTS_AS_SURNAME.test(next2.trimStart())) {
84
-
85
- const firstWord = (next2.trimStart().match(/^[A-Za-z]+/) || [''])[0];
86
- if (LEGAL_STARTERS.has(firstWord)) {
87
- result.push(line);
88
- i++;
89
- } else {
90
- result.push(line.trimEnd() + ' ' + next1.trim() + ' ' + next2.trimStart());
91
- i += 3;
92
- }
93
- }
94
- // 2. Fall back to 2-line stitch
95
- else if (next1 !== undefined && RE_LINE_ENDS_AS_NAME.test(line) && RE_LINE_STARTS_AS_SURNAME.test(next1.trimStart())) {
96
- const firstWord = (next1.trimStart().match(/^[A-Za-z]+/) || [''])[0];
97
- if (LEGAL_STARTERS.has(firstWord)) {
98
- result.push(line);
99
- i++;
100
- } else {
101
- result.push(line.trimEnd() + ' ' + next1.trimStart());
102
- i += 2;
103
- }
104
- } else {
105
- result.push(line);
106
- i++;
107
- }
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');
108
45
  }
109
- return result.join('\n');
46
+ return text;
110
47
  }
111
48
 
112
49
  /**
@@ -120,7 +57,7 @@ function stitchOrphanedNameLines(text, profile) {
120
57
  * count: total number of items protected
121
58
  * uniqueUnmasked: Set of unmasked values
122
59
  */
123
- function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'General', existingSessionMap = {}, isPro = false) {
60
+ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'General', existingSessionMap = {}, isPro = false, ignoreList = null) {
124
61
  if (!text) return { scrubbedText: "", tokenMap: {}, count: 0, uniqueUnmasked: new Set(), trialMeta: null, executionMs: 0 };
125
62
 
126
63
  const executionStartMs = (typeof performance !== 'undefined' && typeof performance.now === 'function') ? performance.now() : Date.now();
@@ -149,13 +86,14 @@ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'Genera
149
86
 
150
87
  const sysMarker = "[Privacy Scrubber Mode]";
151
88
  const oldMarker = "[SYSTEM INSTRUCTION: DATA PRIVACY MODE]";
152
- const newMarker = "[Context: identifiers";
153
- if (textToProcess.includes(sysMarker) || textToProcess.includes(oldMarker) || textToProcess.includes(newMarker)) {
154
- const sysPromptRegex = /(?:----------------------\s*)?(?:\[SYSTEM INSTRUCTION: DATA PRIVACY MODE\]|\[Privacy Scrubber Mode\]|\[Context: identifiers)[\s\S]*/;
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]*/;
155
93
  const match = textToProcess.match(sysPromptRegex);
156
94
  if (match) {
157
- extractedSystemPrompt = match[0];
158
- textToProcess = textToProcess.replace(sysPromptRegex, '');
95
+ extractedSystemPrompt = match[0].trim();
96
+ textToProcess = textToProcess.replace(sysPromptRegex, '').trimEnd();
159
97
  }
160
98
  }
161
99
  const sessionMap = {};
@@ -194,10 +132,10 @@ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'Genera
194
132
  const PROFILE_ALIAS_MAP = (getEngine() && getEngine().PROFILE_ALIAS_MAP) ? getEngine().PROFILE_ALIAS_MAP : {};
195
133
 
196
134
  // Node.js local license key validation enforcement (ZTDS Compliance)
197
- if (typeof process !== 'undefined' && process.env && typeof require === 'function' && profile && profile.toLowerCase() !== 'general') {
135
+ if (!isPro && typeof process !== 'undefined' && process.env && typeof require === 'function' && profile && profile.toLowerCase() !== 'general') {
198
136
  try {
199
137
  const key = (process.env.PRIVACYSCRUBBER_KEY || "").trim();
200
- let isPro = false;
138
+ let isKeyPro = false;
201
139
  if (key) {
202
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-----`;
203
141
  const crypto = require('crypto');
@@ -209,16 +147,17 @@ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'Genera
209
147
  if (isVerified) {
210
148
  const payload = JSON.parse(Buffer.from(payloadBase64, 'base64').toString('utf8'));
211
149
  if (!payload.expires || payload.expires > Math.floor(Date.now() / 1000)) {
150
+ isKeyPro = true;
212
151
  isPro = true;
213
152
  }
214
153
  }
215
154
  }
216
155
  }
217
- if (!isPro) {
156
+ if (!isKeyPro && !isPro) {
218
157
  profile = 'General';
219
158
  }
220
159
  } catch (e) {
221
- profile = 'General';
160
+ if (!isPro) profile = 'General';
222
161
  }
223
162
  }
224
163
 
@@ -230,6 +169,22 @@ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'Genera
230
169
  let filtered = detectResult.filteredMatches;
231
170
  textToProcess = detectResult.processedText;
232
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
+
233
188
  // Assign tokens in left-to-right order
234
189
 
235
190
  const uniqueUnmasked = new Set();
@@ -263,12 +218,22 @@ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'Genera
263
218
  sessionMap[m.token] = m.value;
264
219
  });
265
220
 
266
- // Replace from end → start (preserves string indices while mutating result)
267
- const rightToLeft = [...filtered].sort((a, b) => b.start - a.start);
268
- let result = textToProcess;
269
- rightToLeft.forEach(m => {
270
- result = result.substring(0, m.start) + m.token + result.substring(m.end);
271
- });
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('');
272
237
 
273
238
  const piiBreakdown = {};
274
239
  Object.keys(sessionMap).forEach(k => {
@@ -276,10 +241,19 @@ function scrubText(text, customRules = [], tokenLabelMap = {}, profile = 'Genera
276
241
  piiBreakdown[t] = (piiBreakdown[t] || 0) + 1;
277
242
  });
278
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
+
279
253
  const executionEndMs = (typeof performance !== 'undefined' && typeof performance.now === 'function') ? performance.now() : Date.now();
280
254
 
281
255
  return {
282
- scrubbedText: extractedSystemPrompt + result,
256
+ scrubbedText: finalScrubbed,
283
257
  tokenMap: sessionMap,
284
258
  count: Object.keys(sessionMap).length,
285
259
  uniqueUnmasked: uniqueUnmasked,
@@ -315,15 +289,26 @@ function getLabelAliases(label) {
315
289
  return Array.from(aliases);
316
290
  }
317
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
+
318
303
  function buildRestorationRegexAndRules(tokenMap) {
319
- const keys = Object.keys(tokenMap);
304
+ const keys = Object.keys(tokenMap || {});
320
305
  if (keys.length === 0) {
321
306
  return { compositeRegex: null, looseRules: [] };
322
307
  }
323
308
 
324
309
  const sortedKeys = [...keys].sort((a, b) => {
325
- const innerA = a.replace(/^\[|\]$/g, '');
326
- const innerB = b.replace(/^\[|\]$/g, '');
310
+ const innerA = a.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
311
+ const innerB = b.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
327
312
  const matchA = innerA.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
328
313
  const matchB = innerB.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
329
314
 
@@ -347,7 +332,7 @@ function buildRestorationRegexAndRules(tokenMap) {
347
332
  const regexParts = [];
348
333
 
349
334
  sortedKeys.forEach(k => {
350
- const inner = k.replace(/^\[|\]$/g, '');
335
+ const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
351
336
  const match = inner.match(/^([A-Za-z_0-9]+?)[-_]?(\d+)$/);
352
337
  if (match) {
353
338
  const label = match[1];
@@ -357,12 +342,12 @@ function buildRestorationRegexAndRules(tokenMap) {
357
342
  const escapedAliases = aliases.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
358
343
  const aliasesGroup = `(?:${escapedAliases.join('|')})`;
359
344
 
360
- const looseRegex = '\\[?\\s*' + aliasesGroup + '[-_\\s]*0*' + baseIndex + '\\s*\\]?';
361
- looseRules.push({ patternStr: looseRegex, originalKey: k });
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 });
362
347
  regexParts.push(looseRegex);
363
348
  } else {
364
349
  const safe = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
365
- looseRules.push({ patternStr: safe, originalKey: k });
350
+ looseRules.push({ patternStr: safe, regex: new RegExp('^' + safe + '$', 'i'), originalKey: k });
366
351
  regexParts.push(safe);
367
352
  }
368
353
  });
@@ -378,12 +363,83 @@ function buildRestorationRegexAndRules(tokenMap) {
378
363
  * @param {string} text
379
364
  * @returns {string}
380
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
+
381
380
  function cleanAIPromptPrefix(text) {
382
381
  if (!text) return "";
383
- let cleaned = text.replace(/^\s*(Claude responded|Claude|ChatGPT|Gemini|Grok|DeepSeek|Kimi|Copilot|Assistant|User)\s*(?::|\bsaid\b|\bresponded\b)\s*/i, "");
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, '');
384
393
  cleaned = cleaned.replace(/^\s*Edit\s*\n+/i, "");
385
394
  cleaned = cleaned.replace(/\s*\bEdit\s+in\s+a\s+page\b\s*$/i, "");
386
- return cleaned;
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 };
387
443
  }
388
444
 
389
445
  /**
@@ -397,13 +453,52 @@ function unscrubText(aiResponse, tokenMap) {
397
453
  let text = cleanAIPromptPrefix(aiResponse);
398
454
  let restoredCount = 0;
399
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
+
400
491
  const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
401
492
  if (compositeRegex) {
402
493
  text = text.replace(compositeRegex, (match) => {
403
494
  restoredCount++;
495
+ if (tokenMap[match]) {
496
+ return tokenMap[match];
497
+ }
404
498
  let origKey = match;
405
- for (const rule of looseRules) {
406
- if (new RegExp('^' + rule.patternStr + '$', 'i').test(match)) {
499
+ for (let i = 0; i < looseRules.length; i++) {
500
+ const rule = looseRules[i];
501
+ if (rule.regex && rule.regex.test(match)) {
407
502
  origKey = rule.originalKey;
408
503
  break;
409
504
  }
@@ -429,20 +524,53 @@ function unscrubTextAsHTML(aiResponse, tokenMap) {
429
524
  // ALWAYS escape HTML first — even with empty tokenMap — to prevent XSS from AI-generated content
430
525
  let text = cleanResponse.replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c] || c));
431
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
+
432
559
  const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
433
560
  if (compositeRegex) {
434
561
  text = text.replace(compositeRegex, (match) => {
435
562
  restoredCount++;
436
563
  let origKey = match;
437
- for (const rule of looseRules) {
438
- if (new RegExp('^' + rule.patternStr + '$', 'i').test(match)) {
564
+ for (let i = 0; i < looseRules.length; i++) {
565
+ const rule = looseRules[i];
566
+ if (rule.regex && rule.regex.test(match)) {
439
567
  origKey = rule.originalKey;
440
568
  break;
441
569
  }
442
570
  }
443
571
  const rawVal = tokenMap[origKey] || match;
444
572
  const safeVal = rawVal.replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c] || c));
445
- return `<span class="ps-restored-data" title="Restored by PrivacyScrubber" style="border-bottom: 2px dashed #10b981; color: inherit; cursor: help; padding-bottom: 1px; font-weight: 500; text-shadow: 0 0 5px rgba(16, 185, 129, 0.2);">${safeVal}</span>`;
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>`;
446
574
  });
447
575
  }
448
576
 
@@ -821,6 +949,13 @@ function hydrateRegex(rule) {
821
949
  ].join(', ');
822
950
 
823
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
+
824
959
  const { compositeRegex, looseRules } = buildRestorationRegexAndRules(tokenMap);
825
960
  if (!compositeRegex) {
826
961
  if (typeof showInPageToast === 'function') showInPageToast("No protected session data to reveal.", "info");
@@ -855,8 +990,8 @@ function hydrateRegex(rule) {
855
990
  if (!el || !el.closest) return false;
856
991
  if (el.closest(ASSISTANT_SELECTORS)) return true;
857
992
  // Shadow DOM fallback
858
- const root = el.getRootNode();
859
- if (root instanceof ShadowRoot && root.host) {
993
+ const root = el.getRootNode ? el.getRootNode() : null;
994
+ if (typeof ShadowRoot !== 'undefined' && root instanceof ShadowRoot && root.host) {
860
995
  // Check if the shadow host itself matches, or has an ancestor that does
861
996
  if (root.host.matches && root.host.matches(ASSISTANT_SELECTORS)) return true;
862
997
  if (root.host.closest && root.host.closest(ASSISTANT_SELECTORS)) return true;
@@ -940,13 +1075,6 @@ function hydrateRegex(rule) {
940
1075
  if (inPromptInput) {
941
1076
  shouldSkipEl = true;
942
1077
  }
943
- // NOTE: We intentionally do NOT check textContent for
944
- // '[Privacy Scrubber Mode]' at the element level because
945
- // textContent aggregates ALL descendant text. A parent
946
- // container (like <main>) would match the user prompt's
947
- // instruction block and skip the entire subtree including
948
- // AI responses. The instruction check is only safe at the
949
- // TEXT_NODE level where parentElement scope is narrow.
950
1078
  }
951
1079
 
952
1080
  if (!shouldSkipEl) {
@@ -964,6 +1092,39 @@ function hydrateRegex(rule) {
964
1092
 
965
1093
  walkTextNodes(document.documentElement);
966
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
+
967
1128
  nodesToReplace.forEach(node => {
968
1129
  const text = node.nodeValue;
969
1130
  const fragment = document.createDocumentFragment();
@@ -986,16 +1147,22 @@ function hydrateRegex(rule) {
986
1147
 
987
1148
  const span = document.createElement('span');
988
1149
  span.className = 'ps-restored-data';
989
- span.title = 'Decrypted Locally. LLMs cannot see this.';
1150
+ span.setAttribute('data-original-token', origKey);
1151
+ span.title = `✓ Restored locally in-browser RAM (Never sent to AI) | Original: ${origKey}`;
990
1152
  Object.assign(span.style, {
991
1153
  borderBottom: '2px dashed #10b981',
992
1154
  color: '#10b981',
1155
+ backgroundColor: 'rgba(16, 185, 129, 0.15)',
1156
+ borderRadius: '4px',
1157
+ padding: '1px 5px',
1158
+ margin: '0 1px',
993
1159
  cursor: 'help',
994
- paddingBottom: '1px',
995
- fontWeight: '500',
1160
+ fontWeight: '600',
996
1161
  position: 'relative',
1162
+ display: 'inline-block',
997
1163
  zIndex: '1',
998
- textShadow: '0 0 5px rgba(16, 185, 129, 0.2)'
1164
+ boxShadow: '0 0 8px rgba(16, 185, 129, 0.25)',
1165
+ textShadow: '0 0 4px rgba(16, 185, 129, 0.4)'
999
1166
  });
1000
1167
  span.textContent = rawVal;
1001
1168
 
@@ -1018,6 +1185,10 @@ function hydrateRegex(rule) {
1018
1185
 
1019
1186
  if (restoredCount > 0) {
1020
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
+ });
1021
1192
  } else {
1022
1193
  if (typeof showInPageToast === 'function') showInPageToast("No active tokens found on the screen.", "info");
1023
1194
  }
@@ -1065,7 +1236,7 @@ function hydrateRegex(rule) {
1065
1236
  // 1. Gather Input Text
1066
1237
  const inputs = querySelectorAllRecursive(document, 'textarea, div[contenteditable="true"], div.ProseMirror');
1067
1238
  // Find the active visible input, ignoring our own injected toolbar
1068
- const activeInput = inputs.find(el => el.offsetParent !== null && el.offsetHeight > 0 && !el.closest('.ps-toolbar-container'));
1239
+ const activeInput = inputs.find(el => ((el.offsetParent !== null && el.offsetHeight > 0) || (typeof window !== 'undefined' && !window.chrome?.runtime)) && !el.closest('.ps-toolbar-container'));
1069
1240
  const inputText = activeInput ? (activeInput.value || activeInput.innerText || activeInput.textContent || "") : "";
1070
1241
 
1071
1242
  // 2. Gather Output Text (Last AI Response)
@@ -1080,7 +1251,8 @@ function hydrateRegex(rule) {
1080
1251
  const validResponses = Array.from(candidateNodes).filter(el => {
1081
1252
  // If it is inside a shadow DOM, offsetParent might be null, check offsetHeight/getBoundingClientRect
1082
1253
  const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
1083
- const isVisible = (el.offsetParent !== null && el.offsetHeight > 0) || (rect && rect.height > 0 && rect.width > 0);
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);
1084
1256
  if (!isVisible) return false;
1085
1257
 
1086
1258
  // SIDEBAR/NAV EXCLUSION: exclude elements inside navigation or sidebar areas.
@@ -1130,6 +1302,58 @@ function hydrateRegex(rule) {
1130
1302
  return true;
1131
1303
  });
1132
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
+
1133
1357
  if (validResponses.length > 0) {
1134
1358
  // Return the text of the very last valid response on the page.
1135
1359
  // De-ancestor: if element A contains element B (both matched), prefer B (more specific).
@@ -1139,29 +1363,8 @@ function hydrateRegex(rule) {
1139
1363
  const candidates = leafResponses.length > 0 ? leafResponses : validResponses;
1140
1364
  const lastLeaf = candidates[candidates.length - 1];
1141
1365
 
1142
- // MULTI-LEAF AGGREGATION (fixes Copilot partial response):
1143
- // When multiple leaf elements exist, check if they share a close ancestor
1144
- // within 2 DOM levels of the last leaf. If yes, use the ancestor's full text
1145
- // (groups same-message paragraphs). Depth limit of 2 prevents merging Kimi's
1146
- // separate user + AI message elements whose common ancestor is 4+ levels up.
1147
- let found = false;
1148
- if (candidates.length > 1) {
1149
- let probe = lastLeaf.parentElement;
1150
- for (let depth = 0; depth < 2 && probe; depth++) {
1151
- if (activeInput && probe.contains(activeInput)) break;
1152
- if (probe.tagName === 'BODY') break;
1153
- const contained = candidates.filter(el => probe.contains(el));
1154
- if (contained.length >= 2) {
1155
- outputText = probe.innerText || probe.textContent || '';
1156
- found = true;
1157
- break;
1158
- }
1159
- probe = probe.parentElement;
1160
- }
1161
- }
1162
- if (!found) {
1163
- outputText = lastLeaf.innerText || lastLeaf.textContent || '';
1164
- }
1366
+ const fullTurn = resolveFullAssistantTurnElement(lastLeaf, activeInput);
1367
+ outputText = extractCleanAssistantMessageText(fullTurn || lastLeaf);
1165
1368
  }
1166
1369
 
1167
1370
 
@@ -1235,16 +1438,16 @@ function hydrateRegex(rule) {
1235
1438
  }
1236
1439
 
1237
1440
  const PROFILES = [
1238
- { id: 'General', label: 'General' },
1239
- { id: 'Engineering', label: 'Engineering' },
1240
- { id: 'Finance', label: 'Finance' },
1241
- { id: 'Legal', label: 'Legal' },
1242
- { id: 'Medical', label: 'Healthcare' },
1243
- { id: 'HR', label: 'HR' }
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' }
1244
1447
  ];
1245
1448
 
1246
1449
  chrome.storage.local.get(['ps_active_profile', 'ps_is_pro', 'ps_is_teams'], (data) => {
1247
- const activeProfile = data.ps_active_profile || 'General';
1450
+ const activeProfile = (data.ps_active_profile || 'general').toLowerCase();
1248
1451
  const isPro = data.ps_is_pro || data.ps_is_teams || false;
1249
1452
 
1250
1453
  const menu = document.createElement('div');
@@ -1265,23 +1468,24 @@ function hydrateRegex(rule) {
1265
1468
  PROFILES.forEach(p => {
1266
1469
  const item = document.createElement('div');
1267
1470
  item.className = 'ps-profile-menu-item';
1268
- if (p.id === activeProfile) item.classList.add('active');
1471
+ const isCurrentActive = p.id.toLowerCase() === activeProfile;
1472
+ if (isCurrentActive) item.classList.add('active');
1269
1473
 
1270
1474
  let labelText = p.label;
1271
1475
  item.innerText = labelText;
1272
1476
 
1273
- if (!isPro && p.id !== 'General') {
1477
+ if (!isPro && p.id !== 'general') {
1274
1478
  item.style.opacity = '0.5';
1275
1479
  item.style.cursor = 'not-allowed';
1276
1480
  item.title = 'PRO Feature';
1277
1481
  item.innerText = labelText + ' 🔒';
1278
- } else if (p.id === activeProfile) {
1482
+ } else if (isCurrentActive) {
1279
1483
  item.innerText = labelText + ' ✓';
1280
1484
  }
1281
1485
 
1282
1486
  item.addEventListener('click', (ev) => {
1283
1487
  ev.stopPropagation();
1284
- if (!isPro && p.id !== 'General') {
1488
+ if (!isPro && p.id !== 'general') {
1285
1489
  if (typeof showInPageToast === 'function') {
1286
1490
  showInPageToast('Specialized Profiles require PRO upgrade.', 'warning');
1287
1491
  }
@@ -1362,6 +1566,84 @@ function hydrateRegex(rule) {
1362
1566
  }
1363
1567
  }
1364
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
+
1365
1647
  const engine = getEngine();
1366
1648
 
1367
1649
  if (typeof window !== 'undefined') {
@@ -1382,6 +1664,9 @@ function hydrateRegex(rule) {
1382
1664
  scrubDataInDOM,
1383
1665
  revealDataInDOM,
1384
1666
  gatherUniversalAIContext,
1667
+ resolveFullAssistantTurnElement,
1668
+ extractCleanAssistantMessageText,
1669
+ extractLLMAssistantText: extractCleanAssistantMessageText,
1385
1670
  exportSessionToFile,
1386
1671
  getRules,
1387
1672
  showProfileMenu,
@@ -1414,6 +1699,9 @@ function hydrateRegex(rule) {
1414
1699
  const res = engine.unscrubTextAsHTML(text, sessionMap, opts);
1415
1700
  return res ? { restoredHTML: res.text, html: res.text, text: res.text, restoredCount: res.count, count: res.count } : null;
1416
1701
  },
1702
+ resolveFullAssistantTurnElement,
1703
+ extractCleanAssistantMessageText,
1704
+ extractLLMAssistantText: extractCleanAssistantMessageText,
1417
1705
  cleanAIPromptPrefix: (text) => engine ? engine.cleanAIPromptPrefix(text) : text,
1418
1706
  hydrateRegex: (rule) => engine ? engine.hydrateRegex(rule) : rule,
1419
1707
  getRules