@chatpanel/pii 0.2.14 → 0.2.15

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/pii-redact.js +44 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chatpanel/pii",
3
- "version": "0.2.14",
4
- "description": "The canonical ChatPanel privacy engine reversible PII redaction + pseudonymization with local entity detection. Pure, dependency-free ESM shared by the ChatPanel extension, gateway, and bridge.",
3
+ "version": "0.2.15",
4
+ "description": "The canonical ChatPanel privacy engine \u2014 reversible PII redaction + pseudonymization with local entity detection. Pure, dependency-free ESM shared by the ChatPanel extension, gateway, and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
package/pii-redact.js CHANGED
@@ -345,3 +345,47 @@ export function hasToken(text) {
345
345
  TOKEN_RE.lastIndex = 0;
346
346
  return TOKEN_RE.test(String(text || ''));
347
347
  }
348
+
349
+ // ── What was redacted, for the user's own eyes ──────────────────────────────────────────
350
+ //
351
+ // The privacy promise is invisible unless you can SEE it: which entity types were caught,
352
+ // how many of each, and (on request) the actual before → after pairs. All of that already
353
+ // exists in the vault — this just summarises it, so every client renders the same shape
354
+ // instead of each one re-deriving it.
355
+ //
356
+ // PRIVACY OF THE SUMMARY ITSELF: `types` carries counts only, never values, so it is safe
357
+ // to render, log or persist. Real values live behind `pairs`, which a caller must ask for
358
+ // explicitly (`includeValues: true`) — they are the user's own data, shown on their own
359
+ // device, and must never be written anywhere durable.
360
+
361
+ /** Entity type + count for everything this vault redacted, most-frequent first. */
362
+ export function redactionSummary(vault, { includeValues = false, maxPairs = 200 } = {}) {
363
+ const byType = new Map();
364
+ const pairs = [];
365
+ for (const [token, value] of vault?.byToken || new Map()) {
366
+ const m = /^\[\[([A-Z][A-Z0-9]*)_(\d+)\]\]$/.exec(token);
367
+ const type = m ? m[1] : 'OTHER';
368
+ byType.set(type, (byType.get(type) || 0) + 1);
369
+ if (includeValues && pairs.length < maxPairs) pairs.push({ token, value, type });
370
+ }
371
+ const types = [...byType.entries()]
372
+ .map(([type, count]) => ({ type, count }))
373
+ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
374
+ return {
375
+ total: types.reduce((n, t) => n + t.count, 0),
376
+ types,
377
+ ...(includeValues ? { pairs } : {}),
378
+ };
379
+ }
380
+
381
+ /** Merge several vault summaries (e.g. every conversation) into one. Counts only. */
382
+ export function mergeRedactionSummaries(summaries) {
383
+ const byType = new Map();
384
+ for (const s of summaries || []) {
385
+ for (const t of s?.types || []) byType.set(t.type, (byType.get(t.type) || 0) + t.count);
386
+ }
387
+ const types = [...byType.entries()]
388
+ .map(([type, count]) => ({ type, count }))
389
+ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
390
+ return { total: types.reduce((n, t) => n + t.count, 0), types };
391
+ }