@privacyscrubber/sdk 2.3.1 → 2.3.3

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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Air-Gapped](https://img.shields.io/badge/network_calls-0-blueviolet.svg)](https://privacyscrubber.com)
7
7
  [![TypeScript Typings](https://img.shields.io/badge/types-TypeScript-blue.svg)](https://www.npmjs.com/package/@privacyscrubber/sdk)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
9
- [![Patent Pending](https://img.shields.io/badge/Patent-Pending%20(ILPO%20331905)-06b6d4.svg)](https://privacyscrubber.com/patents/)
9
+ [![Patent Pending](https://img.shields.io/badge/Patent-Pending%20(US%20%26%20Intl)-06b6d4.svg)](https://privacyscrubber.com/patents/)
10
10
  [![IETF Specification](https://img.shields.io/badge/IETF-ZTDS--Protocol-0284c7.svg)](https://datatracker.ietf.org/doc/draft-sibiryakov-ztds-protocol/)
11
11
  [![ZTDS Verified](https://ztds.ai/badge/privacyscrubber-sdk.svg)](https://ztds.ai/registry/#privacyscrubber-sdk)
12
12
  [![GitHub Stars](https://img.shields.io/github/stars/moxno/privacyscrubber-mcp?style=social)](https://github.com/moxno/privacyscrubber-mcp)
@@ -71,7 +71,7 @@ Backend engineering and InfoSec review boards consistently ask four core archite
71
71
  > - **CERN / Zenodo Foundation**: [DOI 10.5281/zenodo.22058770](https://doi.org/10.5281/zenodo.22058770)
72
72
  > - **Center for Open Science (OSF) Benchmark**: [DOI 10.17605/OSF.IO/5BYJF](https://doi.org/10.17605/OSF.IO/5BYJF)
73
73
  > - **Law Archive Treatise**: [osf.io/preprints/lawarchive/4wc86/](https://osf.io/preprints/lawarchive/4wc86/)
74
- > - **Patent Pending**: Israel Patent Office Application `IL 331905` (WIPO DAS Code: `B17B`)
74
+ > - **Patent Pending**: U.S. & International Patents Pending (Paris Convention / PCT Priority)
75
75
 
76
76
  ---
77
77
 
@@ -700,16 +700,15 @@ MIT License © 2026 PrivacyScrubber. Commercial production usage without quota l
700
700
 
701
701
  ---
702
702
 
703
- ## 🏛️ Intellectual Property & Virtual Patent Marking
703
+ ## 🏛️ Intellectual Property & Patent Disclosures
704
704
 
705
705
  The Zero-Trust Data Sanitization (ZTDS) architecture, in-memory deterministic tokenization, cryptographic session handoff, and headless execution methods implemented in this SDK are proprietary technology of Ilya Sibiryakov (BrandMeWeb) and are protected under **Patent Pending** status:
706
706
 
707
- - **Patent Office:** State of Israel Ministry of Justice, Patent Office (ILPO)
708
- - **Application Number:** `331905` (Tracking ID: `94221`)
709
- - **WIPO DAS Access Control Code:** `B17B` (World Intellectual Property Organization Digital Access Service electronic priority document exchange for USPTO, EPO, CNIPA, and PCT receiving offices)
710
- - **Filing / Priority Date:** September 14, 2026 (Paris Convention Art. 4 & 35 U.S.C. § 119 Priority locked through September 14, 2027)
707
+ - **Legal Status:** U.S. & International Patents Pending
708
+ - **Priority Framework:** Paris Convention Art. 4 & 35 U.S.C. § 119 Priority (locked through September 14, 2027)
709
+ - **Filing / Priority Date:** September 14, 2026
711
710
  - **Official Title:** *SYSTEM AND METHOD FOR CLIENT-SIDE ZERO-TRUST DATA SANITIZATION AND CRYPTOGRAPHIC SESSION HANDOFF IN ARTIFICIAL INTELLIGENCE WORKFLOWS*
712
- - **Virtual Patent Marking:** [privacyscrubber.com/patents/](https://privacyscrubber.com/patents/) in accordance with 35 U.S.C. § 287(a).
711
+ - **Patent Disclosures Hub:** [privacyscrubber.com/patents/](https://privacyscrubber.com/patents/) (formal verification via counsel NDA).
713
712
 
714
713
  ### 🌐 Internet Engineering Task Force (IETF) Specification
715
714
  - **Specification Title:** *The Zero-Trust Data Sanitization (ZTDS) Protocol for Frontier Artificial Intelligence Ingestion*
package/browser.js CHANGED
@@ -549,84 +549,230 @@ export class PrivacyScrubberEngine {
549
549
  }
550
550
 
551
551
  /**
552
- * Browser & Web Worker Streaming Detokenizer.
552
+ * Helper to escape special regex characters.
553
553
  */
554
- export class StreamingDetokenizer {
555
- constructor(tokenMap = {}, options = {}) {
556
- this.tokenMap = { ...tokenMap };
557
- this.buffer = '';
558
- this.onTokenRestored = typeof options.onTokenRestored === 'function' ? options.onTokenRestored : null;
559
- this.maxTokenLength = Math.max(
560
- 32,
561
- ...Object.keys(this.tokenMap).map(k => k.length + 8)
562
- );
563
- }
554
+ function escapeRegex(s) {
555
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
556
+ }
564
557
 
565
- updateTokenMap(newTokenMap) {
566
- Object.assign(this.tokenMap, newTokenMap);
567
- this.maxTokenLength = Math.max(
568
- this.maxTokenLength,
569
- ...Object.keys(this.tokenMap).map(k => k.length + 8)
570
- );
558
+ /**
559
+ * Builds an ultra-fast streaming token replacement function.
560
+ * Preserves all whitespace, indentation, newlines, and code blocks (does NOT trim).
561
+ * Handles descending key ordering (e.g. [NAME_10] before [NAME_1]), case insensitivity,
562
+ * alternative delimiters (<...>, {{...}}, __...__), and possessive suffixes.
563
+ *
564
+ * @param {Record<string, string>} tokenMap
565
+ * @param {Object} [options]
566
+ * @returns {(text: string) => string}
567
+ */
568
+ export function buildStreamReplacer(tokenMap = {}, options = {}) {
569
+ if (!tokenMap || typeof tokenMap !== 'object' || Object.keys(tokenMap).length === 0) {
570
+ return (text) => text || '';
571
+ }
572
+
573
+ const onTokenRestored = typeof options?.onTokenRestored === 'function' ? options.onTokenRestored : null;
574
+ const lookup = new Map();
575
+ const patterns = [];
576
+ const sortedKeys = Object.keys(tokenMap).sort((a, b) => b.length - a.length);
577
+
578
+ for (const k of sortedKeys) {
579
+ const val = tokenMap[k];
580
+ const inner = k.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '');
581
+ const match = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
582
+
583
+ if (match) {
584
+ const label = escapeRegex(match[1]);
585
+ const idx = match[2];
586
+ const patternStr = '(?:\\[\\s*' + label + '[-_\\s]*0*' + idx + '\\s*\\]|<\\s*' + label + '[-_\\s]*0*' + idx + '\\s*>|\\{\\{\\s*' + label + '[-_\\s]*0*' + idx + '\\s*\\}\\}|__\\s*' + label + '[-_\\s]*0*' + idx + '\\s*__|\\b' + label + '[-_\\s]+0*' + idx + '\\b)';
587
+ patterns.push(patternStr);
588
+ const normKey = (match[1] + '_' + parseInt(idx, 10)).toUpperCase();
589
+ lookup.set(normKey, val);
590
+ lookup.set(k, val);
591
+ lookup.set(k.toUpperCase(), val);
592
+ } else {
593
+ const safe = escapeRegex(k);
594
+ patterns.push(safe);
595
+ lookup.set(k, val);
596
+ lookup.set(k.toUpperCase(), val);
597
+ }
571
598
  }
572
599
 
573
- process(chunk) {
574
- if (!chunk) return '';
575
- this.buffer += chunk;
576
- let output = '';
577
-
578
- while (true) {
579
- const openIdx = this.buffer.search(/\[|\<|\{\{|\_\_/);
580
- if (openIdx === -1) {
581
- output += this.buffer;
582
- this.buffer = '';
583
- break;
584
- }
600
+ const unifiedRegex = new RegExp('(?:' + patterns.join('|') + ')(\'s|’s|s)?', 'gi');
585
601
 
586
- output += this.buffer.slice(0, openIdx);
587
- this.buffer = this.buffer.slice(openIdx);
602
+ return function replaceTokens(text) {
603
+ if (!text) return '';
604
+ unifiedRegex.lastIndex = 0;
605
+ return text.replace(unifiedRegex, (match, suffix) => {
606
+ const isPossessive = suffix && /('s|’s)$/i.test(suffix);
607
+ const coreMatch = suffix ? match.slice(0, match.length - suffix.length) : match;
608
+ let restored = null;
588
609
 
589
- const closeIdx = this.buffer.search(/\]|\>|\}\}|\_\_/);
590
- if (closeIdx === -1) {
591
- if (this.buffer.length > this.maxTokenLength) {
592
- output += this.buffer.slice(0, this.maxTokenLength);
593
- this.buffer = this.buffer.slice(this.maxTokenLength);
594
- continue;
610
+ if (lookup.has(coreMatch)) {
611
+ restored = lookup.get(coreMatch);
612
+ } else if (lookup.has(coreMatch.toUpperCase())) {
613
+ restored = lookup.get(coreMatch.toUpperCase());
614
+ } else {
615
+ const inner = coreMatch.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '').trim();
616
+ const m = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
617
+ if (m) {
618
+ const normKey = (m[1] + '_' + parseInt(m[2], 10)).toUpperCase();
619
+ if (lookup.has(normKey)) {
620
+ restored = lookup.get(normKey);
621
+ }
595
622
  }
596
- break;
597
623
  }
598
624
 
599
- const candidateToken = this.buffer.slice(0, closeIdx + 1);
600
- const originalValue = this.tokenMap[candidateToken];
625
+ if (restored !== null) {
626
+ if (onTokenRestored) {
627
+ try { onTokenRestored({ token: match, originalValue: restored }); } catch (_) {}
628
+ }
629
+ return restored + (isPossessive ? "'s" : '');
630
+ }
601
631
 
602
- if (originalValue !== undefined) {
603
- output += originalValue;
604
- if (this.onTokenRestored) {
605
- this.onTokenRestored({ token: candidateToken, originalValue });
632
+ if (lookup.has(match)) {
633
+ restored = lookup.get(match);
634
+ if (onTokenRestored) {
635
+ try { onTokenRestored({ token: match, originalValue: restored }); } catch (_) {}
606
636
  }
607
- this.buffer = this.buffer.slice(closeIdx + 1);
608
- } else {
609
- output += candidateToken[0];
610
- this.buffer = this.buffer.slice(1);
637
+ return restored;
611
638
  }
639
+
640
+ return match;
641
+ });
642
+ };
643
+ }
644
+
645
+ /**
646
+ * Browser & Web Worker Streaming Detokenizer with sliding window buffer for LLM chunk boundary splits.
647
+ * Seamlessly restores split tokens (e.g. [EM + AIL_1]), preserves multi-byte UTF-8,
648
+ * and maintains zero-latency streaming throughput.
649
+ */
650
+ export class StreamingDetokenizer {
651
+ constructor(tokenMap = {}, options = {}) {
652
+ this.tokenMap = tokenMap && typeof tokenMap === 'object' ? tokenMap : {};
653
+ this.options = options || {};
654
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
655
+ this.pendingBuffer = '';
656
+ this.hasTokens = Object.keys(this.tokenMap).length > 0;
657
+ this._lastTokenCount = Object.keys(this.tokenMap).length;
658
+ this.decoder = new TextDecoder('utf-8', { fatal: false });
659
+ }
660
+
661
+ /**
662
+ * Backward-compatible buffer property getter/setter.
663
+ */
664
+ get buffer() {
665
+ return this.pendingBuffer;
666
+ }
667
+
668
+ set buffer(val) {
669
+ this.pendingBuffer = typeof val === 'string' ? val : '';
670
+ }
671
+
672
+ /**
673
+ * Dynamically updates the active token map for this streaming session.
674
+ * @param {Record<string, string>} newMap
675
+ */
676
+ updateTokenMap(newMap) {
677
+ if (newMap && typeof newMap === 'object') {
678
+ this.tokenMap = newMap;
679
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
680
+ this.hasTokens = Object.keys(this.tokenMap).length > 0;
681
+ this._lastTokenCount = Object.keys(this.tokenMap).length;
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Resets the internal sliding window buffer.
687
+ */
688
+ reset() {
689
+ this.pendingBuffer = '';
690
+ }
691
+
692
+ /**
693
+ * Feeds an incoming chunk (string, Uint8Array, or Buffer) and returns de-tokenized text.
694
+ * Buffers any unclosed token candidates at chunk boundaries until closed or flushed.
695
+ * @param {string | Uint8Array | Buffer} chunk
696
+ * @returns {string}
697
+ */
698
+ feed(chunk) {
699
+ if (chunk === undefined || chunk === null) return '';
700
+ let text = '';
701
+ if (typeof chunk === 'string') {
702
+ text = chunk;
703
+ } else if (chunk instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(chunk))) {
704
+ text = this.decoder.decode(chunk, { stream: true });
705
+ } else {
706
+ text = String(chunk);
707
+ }
708
+
709
+ if (!text) return '';
710
+
711
+ // Dynamically recompile if tokens were added to the referenced map
712
+ const currentTokenCount = Object.keys(this.tokenMap).length;
713
+ if (currentTokenCount !== this._lastTokenCount) {
714
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
715
+ this.hasTokens = currentTokenCount > 0;
716
+ this._lastTokenCount = currentTokenCount;
717
+ }
718
+
719
+ if (!this.hasTokens) return text;
720
+
721
+ this.pendingBuffer += text;
722
+
723
+ // Candidate unclosed token at tail of buffer (inspect tail window up to 160 chars)
724
+ // Matches incomplete tokens starting with [, <, {{, or __, plus single delimiter prefixes { and _
725
+ const tailLen = 160;
726
+ const tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
727
+ const offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
728
+ const m = tail.match(/(?:\[|<|\{\{?|__?)[A-Za-z0-9_\-А-Яа-яЁё ]{0,128}$/);
729
+
730
+ if (m) {
731
+ const candidate = m[0];
732
+ // Only closed double-underscore tokens (e.g. __NAME_1__) could match m since '_' is in character class.
733
+ // Bracketed tokens ([, <, {{) cannot end with their closing delimiter here and must be buffered.
734
+ const isClosedUnderscoreToken = candidate.length >= 4 && candidate.startsWith('__') && candidate.endsWith('__');
735
+ if (isClosedUnderscoreToken && this.replacer(candidate) !== candidate) {
736
+ const toEmit = this.pendingBuffer;
737
+ this.pendingBuffer = '';
738
+ return this.replacer(toEmit);
739
+ }
740
+ const splitIdx = offset + m.index;
741
+ const safeToEmit = this.pendingBuffer.slice(0, splitIdx);
742
+ this.pendingBuffer = this.pendingBuffer.slice(splitIdx);
743
+ return safeToEmit ? this.replacer(safeToEmit) : '';
612
744
  }
613
745
 
614
- return output;
746
+ const toEmit = this.pendingBuffer;
747
+ this.pendingBuffer = '';
748
+ return this.replacer(toEmit);
615
749
  }
616
750
 
751
+ /**
752
+ * Alias for feed() to maintain backwards compatibility with process() callers.
753
+ */
754
+ process(chunk) {
755
+ return this.feed(chunk);
756
+ }
757
+
758
+ /**
759
+ * Flushes remaining buffered tokens and finalizes UTF-8 stream decoder.
760
+ * @returns {string}
761
+ */
617
762
  flush() {
618
- let output = '';
619
- if (this.buffer) {
620
- let flushed = this.buffer;
621
- for (const [token, original] of Object.entries(this.tokenMap)) {
622
- if (flushed.includes(token)) {
623
- flushed = flushed.split(token).join(original);
624
- }
625
- }
626
- output = flushed;
627
- this.buffer = '';
763
+ let text = '';
764
+ try {
765
+ text = this.decoder.decode(new Uint8Array(), { stream: false });
766
+ } catch (_) {}
767
+
768
+ if (text) {
769
+ this.pendingBuffer += text;
628
770
  }
629
- return output;
771
+
772
+ if (!this.pendingBuffer) return '';
773
+ const remaining = this.pendingBuffer;
774
+ this.pendingBuffer = '';
775
+ return this.replacer(remaining);
630
776
  }
631
777
  }
632
778
 
@@ -682,11 +828,6 @@ export function createStreamTokenRestorer(tokenMap = {}, options = {}) {
682
828
  };
683
829
  }
684
830
 
685
- export function buildStreamReplacer(tokenMap = {}) {
686
- const detok = new StreamingDetokenizer(tokenMap);
687
- return (chunk) => detok.process(chunk);
688
- }
689
-
690
831
  export const scrubText = scrubberPkg ? scrubberPkg.scrubText : null;
691
832
  export const unscrubText = scrubberPkg ? scrubberPkg.unscrubText : null;
692
833
 
package/index.d.ts CHANGED
@@ -26,6 +26,8 @@ export type PiiProfile =
26
26
  | 'Compliance'
27
27
  | 'CCPA'
28
28
  | 'Engineering'
29
+ | 'Manufacturing'
30
+ | 'Industrial'
29
31
  | 'Agents'
30
32
  | 'Academic'
31
33
  | 'Creative'
package/index.js CHANGED
@@ -621,6 +621,7 @@ export function restore(aiResponse, tokenMap = {}) {
621
621
  * Alias for restore.
622
622
  */
623
623
  export const unscrub = restore;
624
+ export const desanitize = restore;
624
625
 
625
626
  /**
626
627
  * Recursively sanitizes all string values within an object or array.
@@ -1346,11 +1347,12 @@ function escapeRegex(s) {
1346
1347
  * @param {Record<string, string>} tokenMap
1347
1348
  * @returns {(text: string) => string}
1348
1349
  */
1349
- export function buildStreamReplacer(tokenMap = {}) {
1350
+ export function buildStreamReplacer(tokenMap = {}, options = {}) {
1350
1351
  if (!tokenMap || typeof tokenMap !== 'object' || Object.keys(tokenMap).length === 0) {
1351
1352
  return (text) => text || '';
1352
1353
  }
1353
1354
 
1355
+ const onTokenRestored = typeof options?.onTokenRestored === 'function' ? options.onTokenRestored : null;
1354
1356
  const lookup = new Map();
1355
1357
  const patterns = [];
1356
1358
  const sortedKeys = Object.keys(tokenMap).sort((a, b) => b.length - a.length);
@@ -1385,24 +1387,39 @@ export function buildStreamReplacer(tokenMap = {}) {
1385
1387
  return text.replace(unifiedRegex, (match, suffix) => {
1386
1388
  const isPossessive = suffix && /('s|’s)$/i.test(suffix);
1387
1389
  const coreMatch = suffix ? match.slice(0, match.length - suffix.length) : match;
1390
+ let restored = null;
1388
1391
 
1389
1392
  if (lookup.has(coreMatch)) {
1390
- return lookup.get(coreMatch) + (isPossessive ? "'s" : '');
1393
+ restored = lookup.get(coreMatch);
1394
+ } else if (lookup.has(coreMatch.toUpperCase())) {
1395
+ restored = lookup.get(coreMatch.toUpperCase());
1396
+ } else {
1397
+ const inner = coreMatch.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '').trim();
1398
+ const m = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
1399
+ if (m) {
1400
+ const normKey = (m[1] + '_' + parseInt(m[2], 10)).toUpperCase();
1401
+ if (lookup.has(normKey)) {
1402
+ restored = lookup.get(normKey);
1403
+ }
1404
+ }
1391
1405
  }
1392
- if (lookup.has(coreMatch.toUpperCase())) {
1393
- return lookup.get(coreMatch.toUpperCase()) + (isPossessive ? "'s" : '');
1406
+
1407
+ if (restored !== null) {
1408
+ if (onTokenRestored) {
1409
+ try { onTokenRestored({ token: match, originalValue: restored }); } catch (_) {}
1410
+ }
1411
+ return restored + (isPossessive ? "'s" : '');
1394
1412
  }
1395
1413
 
1396
- const inner = coreMatch.replace(/^\[|<|\{\{|__|\]|>|\}\}|__/g, '').trim();
1397
- const m = inner.match(/^([A-Za-z_0-9]+?)[-_ ]?(\d+)$/);
1398
- if (m) {
1399
- const normKey = (m[1] + '_' + parseInt(m[2], 10)).toUpperCase();
1400
- if (lookup.has(normKey)) {
1401
- return lookup.get(normKey) + (isPossessive ? "'s" : '');
1414
+ if (lookup.has(match)) {
1415
+ restored = lookup.get(match);
1416
+ if (onTokenRestored) {
1417
+ try { onTokenRestored({ token: match, originalValue: restored }); } catch (_) {}
1402
1418
  }
1419
+ return restored;
1403
1420
  }
1404
1421
 
1405
- return lookup.has(match) ? lookup.get(match) : match;
1422
+ return match;
1406
1423
  });
1407
1424
  };
1408
1425
  }
@@ -1416,13 +1433,24 @@ export class StreamingDetokenizer {
1416
1433
  constructor(tokenMap = {}, options = {}) {
1417
1434
  this.tokenMap = tokenMap && typeof tokenMap === 'object' ? tokenMap : {};
1418
1435
  this.options = options || {};
1419
- this.replacer = buildStreamReplacer(this.tokenMap);
1436
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
1420
1437
  this.pendingBuffer = '';
1421
1438
  this.hasTokens = Object.keys(this.tokenMap).length > 0;
1422
1439
  this._lastTokenCount = Object.keys(this.tokenMap).length;
1423
1440
  this.decoder = new TextDecoder('utf-8', { fatal: false });
1424
1441
  }
1425
1442
 
1443
+ /**
1444
+ * Backward-compatible buffer property getter/setter.
1445
+ */
1446
+ get buffer() {
1447
+ return this.pendingBuffer;
1448
+ }
1449
+
1450
+ set buffer(val) {
1451
+ this.pendingBuffer = typeof val === 'string' ? val : '';
1452
+ }
1453
+
1426
1454
  /**
1427
1455
  * Dynamically updates the active token map for this streaming session.
1428
1456
  * @param {Record<string, string>} newMap
@@ -1430,7 +1458,7 @@ export class StreamingDetokenizer {
1430
1458
  updateTokenMap(newMap) {
1431
1459
  if (newMap && typeof newMap === 'object') {
1432
1460
  this.tokenMap = newMap;
1433
- this.replacer = buildStreamReplacer(this.tokenMap);
1461
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
1434
1462
  this.hasTokens = Object.keys(this.tokenMap).length > 0;
1435
1463
  this._lastTokenCount = Object.keys(this.tokenMap).length;
1436
1464
  }
@@ -1465,7 +1493,7 @@ export class StreamingDetokenizer {
1465
1493
  // Dynamically recompile if tokens were added to the referenced map
1466
1494
  const currentTokenCount = Object.keys(this.tokenMap).length;
1467
1495
  if (currentTokenCount !== this._lastTokenCount) {
1468
- this.replacer = buildStreamReplacer(this.tokenMap);
1496
+ this.replacer = buildStreamReplacer(this.tokenMap, this.options);
1469
1497
  this.hasTokens = currentTokenCount > 0;
1470
1498
  this._lastTokenCount = currentTokenCount;
1471
1499
  }
@@ -1474,12 +1502,26 @@ export class StreamingDetokenizer {
1474
1502
 
1475
1503
  this.pendingBuffer += text;
1476
1504
 
1477
- // Candidate unclosed token at tail of buffer (up to 64 chars)
1478
- // Matches incomplete tokens starting with [, <, {{, or __
1479
- const m = this.pendingBuffer.match(/(?:\[|<|\{\{|__)[A-Za-z0-9_\-А-Яа-яЁё ]{0,64}$/);
1505
+ // Candidate unclosed token at tail of buffer (inspect tail window up to 160 chars)
1506
+ // Matches incomplete tokens starting with [, <, {{, or __, plus single delimiter prefixes { and _
1507
+ const tailLen = 160;
1508
+ const tail = this.pendingBuffer.length > tailLen ? this.pendingBuffer.slice(-tailLen) : this.pendingBuffer;
1509
+ const offset = this.pendingBuffer.length > tailLen ? this.pendingBuffer.length - tailLen : 0;
1510
+ const m = tail.match(/(?:\[|<|\{\{?|__?)[A-Za-z0-9_\-А-Яа-яЁё ]{0,128}$/);
1511
+
1480
1512
  if (m) {
1481
- const safeToEmit = this.pendingBuffer.slice(0, m.index);
1482
- this.pendingBuffer = this.pendingBuffer.slice(m.index);
1513
+ const candidate = m[0];
1514
+ // Only closed double-underscore tokens (e.g. __NAME_1__) could match m since '_' is in character class.
1515
+ // Bracketed tokens ([, <, {{) cannot end with their closing delimiter here and must be buffered.
1516
+ const isClosedUnderscoreToken = candidate.length >= 4 && candidate.startsWith('__') && candidate.endsWith('__');
1517
+ if (isClosedUnderscoreToken && this.replacer(candidate) !== candidate) {
1518
+ const toEmit = this.pendingBuffer;
1519
+ this.pendingBuffer = '';
1520
+ return this.replacer(toEmit);
1521
+ }
1522
+ const splitIdx = offset + m.index;
1523
+ const safeToEmit = this.pendingBuffer.slice(0, splitIdx);
1524
+ this.pendingBuffer = this.pendingBuffer.slice(splitIdx);
1483
1525
  return safeToEmit ? this.replacer(safeToEmit) : '';
1484
1526
  }
1485
1527
 
@@ -2489,6 +2531,7 @@ export default {
2489
2531
  restore,
2490
2532
  restoreObject,
2491
2533
  unscrub,
2534
+ desanitize,
2492
2535
  validateLicense,
2493
2536
  validateLicenseKey,
2494
2537
  buildAuditTelemetry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@privacyscrubber/sdk",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "description": "In-process consumer privacy defense and pre-emptive PII interception SDK for Node.js, Next.js, and AI pipelines. Zero data loss, zero third parties, sub-millisecond in-memory execution.",
5
5
  "main": "index.js",
6
6
  "browser": "browser.js",