@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/.well-known/mcp/server-card.json +78 -0
- package/LICENSE +21 -0
- package/README.md +5 -5
- package/index.js +168 -9
- package/package.json +1 -1
- package/ps-license-manager.js +10 -16
- package/ps-pii-engine.cjs +472 -116
- package/ps-pii-engine.js +472 -116
- package/scrubber-core.cjs +438 -150
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
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
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
|
267
|
-
const
|
|
268
|
-
let
|
|
269
|
-
|
|
270
|
-
|
|
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:
|
|
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(/^\[|\]
|
|
326
|
-
const innerB = b.replace(/^\[|\]
|
|
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(/^\[|\]
|
|
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 = '
|
|
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
|
|
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
|
-
|
|
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 (
|
|
406
|
-
|
|
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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 (
|
|
438
|
-
|
|
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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c] || c));
|
|
445
|
-
return `<span class="ps-restored-data" title="Restored
|
|
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.
|
|
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
|
-
|
|
995
|
-
fontWeight: '500',
|
|
1160
|
+
fontWeight: '600',
|
|
996
1161
|
position: 'relative',
|
|
1162
|
+
display: 'inline-block',
|
|
997
1163
|
zIndex: '1',
|
|
998
|
-
|
|
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
|
|
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
|
-
|
|
1143
|
-
|
|
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: '
|
|
1239
|
-
{ id: '
|
|
1240
|
-
{ id: '
|
|
1241
|
-
{ id: '
|
|
1242
|
-
{ id: '
|
|
1243
|
-
{ id: '
|
|
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 || '
|
|
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
|
-
|
|
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 !== '
|
|
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 (
|
|
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 !== '
|
|
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
|