@chatpanel/bridge 0.10.41 → 0.11.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.
@@ -0,0 +1,177 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/pii-detect.js (npm @chatpanel/pii).
3
+ // Edit there, then run: npm run sync:pii
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // Phase 2: configurable, LOCAL entity detection.
10
+ //
11
+ // Produces [{value, type}] spans that feed the redaction engine, so names / orgs /
12
+ // IDs get redacted WITHOUT a hand-maintained dictionary. Detection runs on-device
13
+ // only — the detector is a local NER service (spaCy / Presidio / any HTTP service)
14
+ // or a local LLM (OpenAI-compatible, e.g. a gemma served by llama.cpp). Raw text
15
+ // reaches the detector but never the final agent; only the redacted text does.
16
+ //
17
+ // Performance / flexibility (the whole point):
18
+ // - backends are pluggable and user-configured (URL + model + timeout).
19
+ // - a content-hash cache avoids re-detecting unchanged text.
20
+ // - a per-call timeout + fail-open means a slow/broken detector NEVER blocks the
21
+ // chat — redaction silently falls back to the deterministic layer.
22
+ // - input is length-capped so a huge transcript can't stall detection.
23
+
24
+ import { assertEndpointUrl } from './net.js';
25
+
26
+ const cache = new Map(); // key -> [{value,type}]
27
+ const CACHE_MAX = 300;
28
+
29
+ export function clearDetectCache() { cache.clear(); }
30
+
31
+ function cacheKey(text, det) {
32
+ let h = 5381;
33
+ const s = `${det?.backend}|${det?.url}|${det?.model}|${text}`;
34
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
35
+ return `${s.length}:${h}`;
36
+ }
37
+
38
+ export function withTimeout(promise, ms, signal) {
39
+ return new Promise((resolve, reject) => {
40
+ const timer = setTimeout(() => reject(new Error('detect timeout')), Math.max(200, ms || 1500));
41
+ const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')); };
42
+ if (signal) signal.addEventListener?.('abort', onAbort, { once: true });
43
+ promise.then(
44
+ (v) => { clearTimeout(timer); resolve(v); },
45
+ (e) => { clearTimeout(timer); reject(e); },
46
+ );
47
+ });
48
+ }
49
+
50
+ // Map common NER labels (spaCy, HF, Presidio) onto our placeholder types.
51
+ function normType(t) {
52
+ const s = String(t || 'ENTITY').toUpperCase().replace(/[^A-Z0-9]/g, '') || 'ENTITY';
53
+ const map = {
54
+ PER: 'PERSON', PERSON: 'PERSON', PERSONNAME: 'PERSON',
55
+ ORG: 'ORG', ORGANIZATION: 'ORG',
56
+ GPE: 'LOCATION', LOC: 'LOCATION', LOCATION: 'LOCATION',
57
+ NORP: 'GROUP', EMAIL: 'EMAIL', EMAILADDRESS: 'EMAIL',
58
+ PHONE: 'PHONE', PHONENUMBER: 'PHONE',
59
+ };
60
+ return map[s] || s;
61
+ }
62
+
63
+ // Identifiers we ALWAYS redact (also caught deterministically). The user-facing
64
+ // category toggles (person/org/location/number) control the rest, so geography
65
+ // questions still work if "location" is turned off, etc. Numeric/temporal labels
66
+ // (DATE, CARDINAL, ORDINAL…) are noisy — small NER models tag "today" / "4" — so
67
+ // they only count when the value is a long digit run (phone/account/ID).
68
+ const ALWAYS_KEEP = new Set(['EMAIL', 'PHONE', 'SSN', 'CREDITCARD', 'IBAN', 'ID']);
69
+ const LOCATION_TYPES = new Set(['LOCATION', 'FAC', 'ADDRESS', 'GROUP', 'NRP']);
70
+
71
+ function keepEntity(value, type, types) {
72
+ const on = (k) => !types || types[k] !== false; // default on
73
+ if (ALWAYS_KEEP.has(type)) return true;
74
+ if (type === 'PERSON') return on('person');
75
+ if (type === 'ORG') return on('org');
76
+ if (LOCATION_TYPES.has(type)) return on('location');
77
+ const digits = (String(value).match(/\d/g) || []).length;
78
+ return digits >= 7 ? on('number') : false;
79
+ }
80
+
81
+ // Normalize the many detector response shapes to [{value, type}], de-duplicated.
82
+ // `types` (optional) is the user's category toggles {person,org,location,number}.
83
+ export function normalizeEntities(data, types) {
84
+ let list = [];
85
+ if (Array.isArray(data)) list = data;
86
+ else if (data && Array.isArray(data.entities)) list = data.entities;
87
+ else if (data && Array.isArray(data.ents)) list = data.ents; // spaCy displacy
88
+ else if (data && Array.isArray(data.results)) list = data.results; // Presidio
89
+ const out = [];
90
+ const seen = new Set();
91
+ for (const e of list) {
92
+ if (!e) continue;
93
+ const value = String(e.value ?? e.text ?? e.entity ?? e.word ?? '').trim();
94
+ const type = normType(e.type ?? e.label ?? e.entity_group ?? e.entity_type ?? e.tag);
95
+ if (!value || value.length > 200 || !keepEntity(value, type, types)) continue;
96
+ const k = `${type}:${value.toLowerCase()}`;
97
+ if (seen.has(k)) continue;
98
+ seen.add(k);
99
+ out.push({ value, type });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ export function parseJsonLoose(s) {
105
+ if (!s) return null;
106
+ const a = String(s).indexOf('{');
107
+ const b = String(s).lastIndexOf('}');
108
+ if (a < 0 || b <= a) return null;
109
+ try { return JSON.parse(String(s).slice(a, b + 1)); } catch { return null; }
110
+ }
111
+
112
+ export const EXTRACT_SYS = 'You extract sensitive entities from text for redaction. '
113
+ + 'Return ONLY JSON: {"entities":[{"value":"<verbatim text>","type":"PERSON|ORG|LOCATION|ID|EMAIL|PHONE|OTHER"}]}. '
114
+ + 'Copy each value exactly as it appears. Include people, organizations, locations, and account/ID numbers. No commentary, no code fences.';
115
+
116
+ async function detectViaEndpoint(text, det, signal, fetchImpl) {
117
+ const res = await fetchImpl(det.url, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json', ...(det.apiKey ? { Authorization: `Bearer ${det.apiKey}` } : {}) },
120
+ body: JSON.stringify({ text }),
121
+ signal,
122
+ });
123
+ if (!res.ok) throw new Error(`detect HTTP ${res.status}`);
124
+ return normalizeEntities(await res.json(), det.types);
125
+ }
126
+
127
+ async function detectViaOpenAI(text, det, signal, fetchImpl) {
128
+ const base = String(det.url || '').replace(/\/$/, '');
129
+ // Build the chat URL the SAME way the chat path does. An OpenAI-compatible baseUrl
130
+ // already ends in /v1 (Ollama, OpenRouter, NVIDIA, OpenAI…) → only add
131
+ // /chat/completions (appending /v1/chat/completions would 404 on /v1/v1/…). A bare
132
+ // host gets /v1/chat/completions; a full chat URL is used as-is.
133
+ const url = /\/chat\/completions$/.test(base) ? base
134
+ : /\/v\d+$/.test(base) ? `${base}/chat/completions`
135
+ : `${base}/v1/chat/completions`;
136
+ const res = await fetchImpl(url, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json', ...(det.apiKey ? { Authorization: `Bearer ${det.apiKey}` } : {}) },
139
+ body: JSON.stringify({
140
+ model: det.model || 'local',
141
+ temperature: 0,
142
+ max_tokens: det.maxTokens || 256,
143
+ messages: [{ role: 'system', content: EXTRACT_SYS }, { role: 'user', content: text }],
144
+ }),
145
+ signal,
146
+ });
147
+ if (!res.ok) throw new Error(`detect HTTP ${res.status}`);
148
+ const json = await res.json();
149
+ const content = json?.choices?.[0]?.message?.content ?? json?.content ?? '';
150
+ return normalizeEntities(parseJsonLoose(content), det.types);
151
+ }
152
+
153
+ // Returns [{value, type}] spans for `text`, or [] (fail-open) on any error/timeout.
154
+ export async function detectEntities(text, cfg, { signal, fetchImpl = globalThis.fetch, strict = false } = {}) {
155
+ const det = cfg?.detection;
156
+ if (!det || !det.backend || det.backend === 'off' || !det.url || typeof fetchImpl !== 'function') return [];
157
+ const capped = String(text || '').slice(0, det.maxChars || 8000);
158
+ if (capped.trim().length < 8) return [];
159
+ const key = cacheKey(capped, det);
160
+ if (!strict && cache.has(key)) return cache.get(key);
161
+ const run = det.backend === 'endpoint' ? detectViaEndpoint : detectViaOpenAI;
162
+ let ents = [];
163
+ try {
164
+ // SSRF guard before RAW (pre-redaction) text leaves for the detector: http(s)
165
+ // only, never cloud metadata. Loopback/LAN allowed — a local NER server / Ollama
166
+ // is the normal case. A blocked URL fails open (deterministic-only), or surfaces
167
+ // to the Test button in strict mode.
168
+ assertEndpointUrl(det.url);
169
+ ents = await withTimeout(run(capped, det, signal, fetchImpl), det.timeoutMs || 1500, signal);
170
+ } catch (e) {
171
+ if (strict) throw e; // surface errors to the Test button
172
+ ents = []; // otherwise fail open — deterministic redaction still applies
173
+ }
174
+ if (cache.size >= CACHE_MAX) cache.clear();
175
+ if (!strict) cache.set(key, ents);
176
+ return ents;
177
+ }
@@ -0,0 +1,399 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/pii-redact.js (npm @chatpanel/pii).
3
+ // Edit there, then run: npm run sync:pii
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve.
8
+
9
+ // Reversible PII redaction.
10
+ //
11
+ // Strips sensitive values out of everything that leaves the device for a model
12
+ // (chat text, attached page/meeting context, and tool results we feed back), then
13
+ // reconstructs the originals when the reply is rendered to the user. The model
14
+ // only ever sees opaque, stable placeholders like [[EMAIL_1]] / [[PERSON_2]] — so
15
+ // it can still reason about "who said what" without seeing the real values.
16
+ //
17
+ // Pure + dependency-free so it is unit-testable and runs identically for API and
18
+ // CLI/bridge agents (both assemble their outbound payload through providers.js).
19
+ //
20
+ // Tiers:
21
+ // 'basic' — deterministic regex: emails, phones, IPs, cards (Luhn), SSNs, keys.
22
+ // 'full' — basic + entity-aware: known people/orgs (meeting roster, contacts,
23
+ // the user's own identity) and a user-editable custom dictionary.
24
+ //
25
+ // Reversibility caveat: if the model paraphrases instead of echoing a token, that
26
+ // one reference won't restore (it shows the token) — but the privacy guarantee
27
+ // (the real value never left the device) always holds.
28
+
29
+ import { stripHidden, confusablesSkeleton } from './sanitize.js';
30
+
31
+ const TOKEN_RE = /\[\[([A-Z][A-Z0-9]*)_(\d+)\]\]/g;
32
+
33
+ // Bracket-TOLERANT match of the same token. Smaller models routinely drop or mangle
34
+ // the [[ ]] when echoing a placeholder into tool-call JSON — e.g. they emit "ORG_1"
35
+ // or "[ORG_1]" instead of "[[ORG_1]]" — which the strict TOKEN_RE misses, leaving
36
+ // the tool to search the literal "ORG_1" (and get nothing). We match 0–2 brackets
37
+ // on each side and reconstruct the canonical token to look up; only ACTUAL vault
38
+ // tokens are swapped, so a coincidental "ABC_1" that isn't ours is left untouched.
39
+ const TOLERANT_TOKEN_RE = /\[{0,2}([A-Z][A-Z0-9]*_\d+)\]{0,2}/g;
40
+
41
+ // A vault is the per-conversation mapping between placeholders and originals. Keep
42
+ // one per conversation so PERSON_1 means the same entity across turns.
43
+ export function createVault() {
44
+ // `aliases` maps a pseudonym (e.g. "Robin") back to the real value (e.g. "Alex Rivera")
45
+ // so LOCAL tool calls (history/meeting search) can run on real data. The reply
46
+ // restorer ignores it — pseudonyms stay permanent in the user's view.
47
+ return { byToken: new Map(), byValue: new Map(), counts: new Map(), aliases: new Map() };
48
+ }
49
+
50
+ export function vaultToJSON(vault) {
51
+ return {
52
+ entries: [...(vault?.byToken || new Map())].map(([token, value]) => ({ token, value })),
53
+ aliases: [...(vault?.aliases || new Map())].map(([alias, value]) => ({ alias, value })),
54
+ };
55
+ }
56
+
57
+ export function vaultFromJSON(data) {
58
+ const vault = createVault();
59
+ for (const { token, value } of data?.entries || []) {
60
+ const m = /^\[\[([A-Z][A-Z0-9]*)_(\d+)\]\]$/.exec(token);
61
+ vault.byToken.set(token, value);
62
+ vault.byValue.set(value, token);
63
+ if (m) vault.counts.set(m[1], Math.max(vault.counts.get(m[1]) || 0, Number(m[2])));
64
+ }
65
+ for (const { alias, value } of data?.aliases || []) vault.aliases.set(alias, value);
66
+ return vault;
67
+ }
68
+
69
+ function tokenFor(vault, type, value) {
70
+ const existing = vault.byValue.get(value);
71
+ if (existing) return existing;
72
+ const t = String(type || 'PII').toUpperCase().replace(/[^A-Z0-9]/g, '') || 'PII';
73
+ const n = (vault.counts.get(t) || 0) + 1;
74
+ vault.counts.set(t, n);
75
+ const token = `[[${t}_${n}]]`;
76
+ vault.byToken.set(token, value);
77
+ vault.byValue.set(value, token);
78
+ return token;
79
+ }
80
+
81
+ function escapeRegex(s) {
82
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
83
+ }
84
+
85
+ // Reject a user-supplied dictionary regex that is a likely ReDoS (catastrophic
86
+ // backtracking) BEFORE compiling + running it on untrusted-length input. Heuristic,
87
+ // not exhaustive: cap length, and reject the classic nested-quantifier families —
88
+ // a quantified group whose body also has a quantifier ((a+)+ / (a*)* / (.*)+) and
89
+ // back-to-back unbounded quantifiers (a**, .*+). A rejected pattern is skipped like a
90
+ // syntactically-invalid one, so redaction never breaks or hangs.
91
+ function isSafeUserPattern(p) {
92
+ if (typeof p !== 'string' || p.length === 0 || p.length > 200) return false;
93
+ if (/\([^)]*[+*}][^)]*\)\s*[+*{]/.test(p)) return false; // (…quantifier…)quantifier
94
+ if (/[+*]\s*[+*]/.test(p)) return false; // a**, a+*, .*+
95
+ return true;
96
+ }
97
+
98
+ // Apply many find/replace rules in a SINGLE left-to-right pass over the source.
99
+ // Each rule is { re: <global RegExp>, repl: (match) => string }. Unlike running
100
+ // rule[0].replace then rule[1].replace then …, text emitted by one rule is NEVER
101
+ // re-scanned by a later rule — so substitutions can't cascade (e.g. a pseudonym
102
+ // that happens to equal another entry's input). On a tie at the same position the
103
+ // earlier rule wins (rules carry priority by their order in the array).
104
+ function applyRulesOnce(text, rules) {
105
+ if (!rules || rules.length === 0) return text;
106
+ let out = '';
107
+ let pos = 0;
108
+ const n = text.length;
109
+ while (pos <= n) {
110
+ let best = null;
111
+ let bestRule = null;
112
+ for (const rule of rules) {
113
+ rule.re.lastIndex = pos;
114
+ const m = rule.re.exec(text);
115
+ if (m && (best === null || m.index < best.index)) {
116
+ best = m;
117
+ bestRule = rule;
118
+ if (m.index === pos) break; // nothing can start earlier than the cursor
119
+ }
120
+ }
121
+ if (!best) { out += text.slice(pos); break; }
122
+ out += text.slice(pos, best.index);
123
+ if (best[0].length === 0) { // pathological empty match — emit a char, advance
124
+ out += text[best.index] ?? '';
125
+ pos = best.index + 1;
126
+ } else {
127
+ out += bestRule.repl(best);
128
+ pos = best.index + best[0].length;
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ function luhnValid(digits) {
135
+ let sum = 0;
136
+ let alt = false;
137
+ for (let i = digits.length - 1; i >= 0; i--) {
138
+ let d = digits.charCodeAt(i) - 48;
139
+ if (alt) { d *= 2; if (d > 9) d -= 9; }
140
+ sum += d;
141
+ alt = !alt;
142
+ }
143
+ return sum % 10 === 0;
144
+ }
145
+
146
+ // Plausible IPv6? Controls false positives from the broad IPV6 regex: accept only a
147
+ // `::`-compressed form (≥1 hextet) or a full 8-hextet address, hextets ≤4 hex digits.
148
+ function isLikelyIpv6(s) {
149
+ if (!/^[0-9A-Fa-f:]+$/.test(s) || (s.match(/:/g) || []).length < 2) return false;
150
+ const parts = s.split(':');
151
+ if (parts.some((p) => p.length > 4)) return false;
152
+ if (s.includes('::')) return parts.filter(Boolean).length >= 1 && parts.filter(Boolean).length <= 7;
153
+ return parts.length === 8 && parts.every((p) => p.length >= 1);
154
+ }
155
+
156
+ // Deterministic detectors. Each: { type, re, valid? }. Order = priority; more
157
+ // specific patterns run first so they win the bytes before greedier ones.
158
+ const DETECTORS = [
159
+ // PEM private-key block (multi-line) — highest priority, most specific.
160
+ { type: 'SECRET', re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----[\s\S]*?-----END (?:[A-Z0-9]+ )*PRIVATE KEY-----/g },
161
+ { type: 'EMAIL', re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
162
+ // SSN: dash- OR space-separated (bare 9-digit is left alone — too false-positive-prone).
163
+ { type: 'SSN', re: /\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g },
164
+ {
165
+ // Vendor API keys / tokens. sk-… also covers OpenAI sk-proj-/sk-ant-. Adds Google
166
+ // (AIza…), Stripe (sk_live_/rk_test_…), GitHub fine-grained PATs, Slack xapp-.
167
+ type: 'KEY',
168
+ re: /\b(?:sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[0-9A-Za-z_]{22,}|xox[baprs]-[A-Za-z0-9-]{10,}|xapp-[0-9]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{35}|[rs]k_(?:live|test)_[0-9A-Za-z]{16,})\b/g,
169
+ },
170
+ // JWT — three base64url segments; `eyJ` is base64 of `{"…`, so this is specific.
171
+ { type: 'KEY', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g },
172
+ {
173
+ type: 'IP',
174
+ re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g,
175
+ },
176
+ {
177
+ // IPv6 (incl. :: compression). Broad match narrowed by isLikelyIpv6 to curb FPs.
178
+ type: 'IP',
179
+ re: /(?<![:\w])(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}(?![:\w])/g,
180
+ valid: (m) => isLikelyIpv6(m),
181
+ },
182
+ {
183
+ // Phone: only count it if it has a separator or a leading + and 7–15 digits —
184
+ // so long bare ids (a 11-digit page id, an order number) are NOT redacted.
185
+ type: 'PHONE',
186
+ re: /(?<![\w.])\+?\d[\d ().-]{6,}\d(?![\w])/g,
187
+ valid: (m) => {
188
+ const digits = m.replace(/\D/g, '');
189
+ // Needs a separator / leading + OR be a bare 10-digit run (a typed phone like
190
+ // 9320434444). 11+ bare digits still require formatting so long ids aren't hit.
191
+ return digits.length >= 7 && digits.length <= 15
192
+ && (/[ ().-]/.test(m) || m.trimStart().startsWith('+') || digits.length === 10);
193
+ },
194
+ },
195
+ {
196
+ type: 'CARD',
197
+ re: /\b(?:\d[ -]?){13,19}\b/g,
198
+ valid: (m) => { const d = m.replace(/\D/g, ''); return d.length >= 13 && d.length <= 19 && luhnValid(d); },
199
+ },
200
+ ];
201
+
202
+ // Redact `text`, recording placeholders in `vault`. `entities` (full tier) is a
203
+ // list of { value, type } known names/orgs; `dictionary` is the user's custom
204
+ // list of { value, type } (exact strings) or { pattern, flags, type } (regex).
205
+ export function redactText(text, vault, {
206
+ tier = 'basic',
207
+ entities = [],
208
+ dictionary = [],
209
+ sanitize = true,
210
+ sanitizeOpts = undefined,
211
+ } = {}) {
212
+ if (text == null || text === '') return text;
213
+ // De-steganography BEFORE detection, in-band: an obfuscated value
214
+ // (j<ZWSP>o<ZWSP>hn@x.com, homoglyphs, ASCII-smuggled Tag chars) must become
215
+ // matchable so the regex/NER can't be trivially bypassed. Callers used to have to
216
+ // remember to stripHidden() first; folding it in here makes the engine safe on its
217
+ // own — the sanitize:false escape hatch is only for a caller that already did it.
218
+ let out = sanitize ? stripHidden(String(text), sanitizeOpts) : String(text);
219
+ const v = vault || createVault();
220
+
221
+ const entityTier = tier === 'full' || tier === 'entities';
222
+
223
+ // 1) User dictionary first — highest authority, user explicitly chose these.
224
+ // An entry with `alias` PSEUDONYMIZES: permanent substitution (the model and
225
+ // the user's transcript both see the alias, never reversed). Otherwise it
226
+ // REDACTS to a reversible [[TYPE_n]] placeholder restored in the user's view.
227
+ // All entries are applied in ONE pass (applyRulesOnce): an alias produced by
228
+ // one entry must not be re-matched by a later entry, or substitutions cascade
229
+ // (e.g. value 'Arnav'→alias 'John' then 'John' caught by a later 'John' rule).
230
+ const dictRules = [];
231
+ for (const d of dictionary || []) {
232
+ if (!d) continue;
233
+ let re;
234
+ try {
235
+ re = d.pattern
236
+ ? (isSafeUserPattern(d.pattern) ? new RegExp(d.pattern, d.flags && /g/.test(d.flags) ? d.flags : `${d.flags || ''}g`) : null)
237
+ : (d.value ? new RegExp(`(?<![\\w])${escapeRegex(d.value)}(?![\\w])`, 'gi') : null);
238
+ } catch {
239
+ re = null; // a bad user regex must never break redaction
240
+ }
241
+ if (!re) continue;
242
+ if (d.alias != null && d.alias !== '') {
243
+ // pseudonymize: model + reply see the alias…
244
+ // …but record alias→original so LOCAL tool args (history/meeting search) map
245
+ // back to the real value. Local lookups must hit real data; only the model is blinded.
246
+ if (d.value) v.aliases.set(d.alias, d.value);
247
+ dictRules.push({ re, repl: () => d.alias });
248
+ } else {
249
+ const type = d.type || (d.pattern ? 'PII' : 'TERM');
250
+ dictRules.push({ re, repl: (m) => tokenFor(v, type, d.pattern ? m[0] : d.value) });
251
+ }
252
+ }
253
+ out = applyRulesOnce(out, dictRules);
254
+
255
+ // 2) Known entities (full tier) — longest value first so "Alex Rivera" wins
256
+ // before a bare "Alex". Restores to the canonical entity value.
257
+ if (entityTier) {
258
+ const ents = [...(entities || [])].filter((e) => e && e.value)
259
+ .sort((a, b) => String(b.value).length - String(a.value).length);
260
+ for (const e of ents) {
261
+ const re = new RegExp(`(?<![\\w])${escapeRegex(e.value)}(?![\\w])`, 'gi');
262
+ out = out.replace(re, () => tokenFor(v, e.type || 'PERSON', e.value));
263
+ }
264
+ }
265
+
266
+ // 3) Deterministic detectors (all tiers). Detect against a CONFUSABLES SKELETON so
267
+ // homoglyph-obfuscated values (Cyrillic/Greek/fullwidth Latin look-alikes) match
268
+ // the ASCII regexes — but REDACT the ORIGINAL span. The fold is 1:1 per code point,
269
+ // so a skeleton match's indices line up with `out`, and legitimate non-Latin text
270
+ // (which won't match a detector) is never rewritten. Higher-priority detectors
271
+ // (earlier in DETECTORS) claim overlapping spans first, matching the old order.
272
+ const skel = confusablesSkeleton(out);
273
+ const taken = []; // non-overlapping [start,end) spans, in priority order
274
+ const overlaps = (s, e) => taken.some((t) => s < t.end && e > t.start);
275
+ for (const det of DETECTORS) {
276
+ det.re.lastIndex = 0;
277
+ let m;
278
+ while ((m = det.re.exec(skel)) !== null) {
279
+ if (m[0].length === 0) { det.re.lastIndex++; continue; }
280
+ const start = m.index, end = start + m[0].length;
281
+ if ((det.valid && !det.valid(m[0])) || overlaps(start, end)) continue;
282
+ taken.push({ start, end, type: det.type });
283
+ }
284
+ }
285
+ if (taken.length) {
286
+ taken.sort((a, b) => a.start - b.start);
287
+ let rebuilt = '';
288
+ let pos = 0;
289
+ for (const t of taken) {
290
+ rebuilt += out.slice(pos, t.start) + tokenFor(v, t.type, out.slice(t.start, t.end));
291
+ pos = t.end;
292
+ }
293
+ out = rebuilt + out.slice(pos);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ // Re-redact a tool RESULT before the model sees it, walking the shapes tools
299
+ // actually return: a bare string, a { text } object, an array, and the
300
+ // MCP-standard { content: [{ type:'text', text }] } (incl. an embedded
301
+ // { resource: { text } }). Only text-bearing fields are redacted — arbitrary
302
+ // fields (ids, urls, mime types) are left intact so tool results stay valid.
303
+ // Restore is the inverse concern; this only runs on the model-facing direction.
304
+ export function redactResultShape(raw, vault, opts) {
305
+ if (raw == null || typeof raw === 'string') {
306
+ return raw == null ? raw : redactText(raw, vault, opts);
307
+ }
308
+ if (Array.isArray(raw)) return raw.map((r) => redactResultShape(r, vault, opts));
309
+ if (typeof raw === 'object') {
310
+ let out = raw;
311
+ if (typeof raw.text === 'string') out = { ...out, text: redactText(raw.text, vault, opts) };
312
+ if (Array.isArray(raw.content)) out = { ...out, content: raw.content.map((c) => redactResultShape(c, vault, opts)) };
313
+ if (raw.resource && typeof raw.resource === 'object' && typeof raw.resource.text === 'string') {
314
+ out = { ...out, resource: { ...raw.resource, text: redactText(raw.resource.text, vault, opts) } };
315
+ }
316
+ return out;
317
+ }
318
+ return raw;
319
+ }
320
+
321
+ // Swap placeholders back to their originals. Unknown tokens are left untouched.
322
+ export function restoreText(text, vault) {
323
+ if (text == null || !vault) return text;
324
+ return String(text).replace(TOLERANT_TOKEN_RE, (m, inner) => {
325
+ const canonical = `[[${inner}]]`;
326
+ return vault.byToken.has(canonical) ? vault.byToken.get(canonical) : m;
327
+ });
328
+ }
329
+
330
+ // Restore for LOCAL use only — e.g. tool-call args that hit on-device history /
331
+ // meeting search. Undoes reversible tokens AND pseudonyms, so local lookups run on
332
+ // the real values. NOT used for the user-facing reply (pseudonyms stay there).
333
+ export function restoreWithAliases(text, vault) {
334
+ let out = restoreText(text, vault);
335
+ if (vault?.aliases?.size) {
336
+ // ONE pass over every alias at once. Looping `replace` per alias re-scans the
337
+ // output and cascades when one alias's real value equals another alias (e.g.
338
+ // 'Twinkle'→'John' then 'John'→'Arnav'): the model's "Twinkle" would walk the
339
+ // chain to "Arnav". A single alternation replaces each span exactly once.
340
+ // Longest alias first so a multi-word pseudonym wins over its prefix.
341
+ const aliases = [...vault.aliases.keys()].filter(Boolean).sort((a, b) => b.length - a.length);
342
+ if (aliases.length) {
343
+ const re = new RegExp(`(?<![\\w])(?:${aliases.map(escapeRegex).join('|')})(?![\\w])`, 'g');
344
+ out = out.replace(re, (m) => (vault.aliases.has(m) ? vault.aliases.get(m) : m));
345
+ }
346
+ }
347
+ return out;
348
+ }
349
+
350
+ // True if the text still contains any redaction placeholder (useful for streaming
351
+ // restore — buffer a tail when a token may be split across chunks).
352
+ export function hasToken(text) {
353
+ TOKEN_RE.lastIndex = 0;
354
+ return TOKEN_RE.test(String(text || ''));
355
+ }
356
+
357
+ // ── What was redacted, for the user's own eyes ──────────────────────────────────────────
358
+ //
359
+ // The privacy promise is invisible unless you can SEE it: which entity types were caught,
360
+ // how many of each, and (on request) the actual before → after pairs. All of that already
361
+ // exists in the vault — this just summarises it, so every client renders the same shape
362
+ // instead of each one re-deriving it.
363
+ //
364
+ // PRIVACY OF THE SUMMARY ITSELF: `types` carries counts only, never values, so it is safe
365
+ // to render, log or persist. Real values live behind `pairs`, which a caller must ask for
366
+ // explicitly (`includeValues: true`) — they are the user's own data, shown on their own
367
+ // device, and must never be written anywhere durable.
368
+
369
+ /** Entity type + count for everything this vault redacted, most-frequent first. */
370
+ export function redactionSummary(vault, { includeValues = false, maxPairs = 200 } = {}) {
371
+ const byType = new Map();
372
+ const pairs = [];
373
+ for (const [token, value] of vault?.byToken || new Map()) {
374
+ const m = /^\[\[([A-Z][A-Z0-9]*)_(\d+)\]\]$/.exec(token);
375
+ const type = m ? m[1] : 'OTHER';
376
+ byType.set(type, (byType.get(type) || 0) + 1);
377
+ if (includeValues && pairs.length < maxPairs) pairs.push({ token, value, type });
378
+ }
379
+ const types = [...byType.entries()]
380
+ .map(([type, count]) => ({ type, count }))
381
+ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
382
+ return {
383
+ total: types.reduce((n, t) => n + t.count, 0),
384
+ types,
385
+ ...(includeValues ? { pairs } : {}),
386
+ };
387
+ }
388
+
389
+ /** Merge several vault summaries (e.g. every conversation) into one. Counts only. */
390
+ export function mergeRedactionSummaries(summaries) {
391
+ const byType = new Map();
392
+ for (const s of summaries || []) {
393
+ for (const t of s?.types || []) byType.set(t.type, (byType.get(t.type) || 0) + t.count);
394
+ }
395
+ const types = [...byType.entries()]
396
+ .map(([type, count]) => ({ type, count }))
397
+ .sort((a, b) => b.count - a.count || a.type.localeCompare(b.type));
398
+ return { total: types.reduce((n, t) => n + t.count, 0), types };
399
+ }