@chatpanel/bridge 0.10.42 → 0.11.1

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,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
+ }
@@ -0,0 +1,137 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/pipeline.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
+ // Pure turn-level orchestration shared by every ChatPanel surface (extension,
10
+ // gateway, bridge). It composes the deterministic engine (pii-redact.js) into the
11
+ // message pipeline and applies the tier / scope / dictionary gating.
12
+ //
13
+ // What lives HERE (portable): redactOutbound, redactToolResult/redactResult,
14
+ // makeStreamRestorer, restore/restoreDeep, effectiveTier + gating.
15
+ //
16
+ // Host glue kept in the EXTENSION (NOT here): reading settings.ui.piiRedaction,
17
+ // the entitlement flag, and chrome storage — those wrap these pure functions with
18
+ // host-specific config.
19
+
20
+ import { redactText, restoreText, restoreWithAliases, redactResultShape } from './pii-redact.js';
21
+
22
+ export function redactionEnabled(cfg) {
23
+ return !!(cfg && cfg.mode && cfg.mode !== 'off');
24
+ }
25
+
26
+ // The entity (name/org) tier is Pro; Free falls back to the deterministic regex tier.
27
+ export function effectiveTier(cfg, isPro) {
28
+ const t = cfg?.tier === 'full' ? 'full' : 'basic';
29
+ return t === 'full' && !isPro ? 'basic' : t;
30
+ }
31
+
32
+ // On Free the first FREE_DICT_LIMIT custom-dictionary entries apply; the full
33
+ // dictionary is Pro. Enforced here as well as in the UI.
34
+ export const FREE_DICT_LIMIT = 5;
35
+
36
+ export function gatedDictionary(cfg, isPro) {
37
+ const d = Array.isArray(cfg?.dictionary) ? cfg.dictionary : [];
38
+ return isPro ? d : d.slice(0, FREE_DICT_LIMIT);
39
+ }
40
+
41
+ export function gatedScope(cfg, isPro) {
42
+ const s = cfg?.scope || {};
43
+ if (isPro) return s;
44
+ return { chat: s.chat !== false, context: false, history: false, toolResults: false };
45
+ }
46
+
47
+ export function redactOpts(cfg, isPro, entities) {
48
+ return {
49
+ tier: effectiveTier(cfg, isPro),
50
+ entities: entities || [],
51
+ dictionary: gatedDictionary(cfg, isPro),
52
+ };
53
+ }
54
+
55
+ // Returns redacted COPIES — never mutates the stored conversation.
56
+ export function redactOutbound({ messages, system, vault, cfg, isPro = false, entities = [] }) {
57
+ if (!redactionEnabled(cfg) || !vault) return { messages, system };
58
+ const opts = redactOpts(cfg, isPro, entities);
59
+ const scope = gatedScope(cfg, isPro);
60
+ const redactMsg = (m) => {
61
+ const copy = { ...m };
62
+ if (scope.chat !== false && m.content) copy.content = redactText(m.content, vault, opts);
63
+ if (Array.isArray(m.attachments)) {
64
+ copy.attachments = m.attachments.map((a) => {
65
+ if (a.kind === 'image' || !a.text) return a;
66
+ const isHistory = a.kind === 'history-rag';
67
+ if (isHistory ? scope.history === false : scope.context === false) return a;
68
+ return { ...a, text: redactText(a.text, vault, opts) };
69
+ });
70
+ }
71
+ return copy;
72
+ };
73
+ return {
74
+ messages: (messages || []).map(redactMsg),
75
+ system: system ? redactText(system, vault, opts) : system,
76
+ };
77
+ }
78
+
79
+ export function redactToolResult(text, { vault, cfg, isPro = false, entities = [] } = {}) {
80
+ if (!redactionEnabled(cfg) || !vault || !gatedScope(cfg, isPro).toolResults) return text;
81
+ if (typeof text !== 'string') return text;
82
+ return redactText(text, vault, redactOpts(cfg, isPro, entities));
83
+ }
84
+
85
+ // Streaming-safe restorer. push() returns text safe to display now; flush() the rest.
86
+ export function makeStreamRestorer(vault) {
87
+ let buf = '';
88
+ return {
89
+ push(chunk) {
90
+ if (!vault) return chunk || '';
91
+ buf += chunk || '';
92
+ const open = buf.lastIndexOf('[[');
93
+ let safe;
94
+ if (open !== -1 && !buf.slice(open).includes(']]')) {
95
+ safe = buf.slice(0, open);
96
+ buf = buf.slice(open);
97
+ } else {
98
+ safe = buf;
99
+ buf = '';
100
+ }
101
+ return restoreText(safe, vault);
102
+ },
103
+ flush() {
104
+ const out = vault ? restoreText(buf, vault) : buf;
105
+ buf = '';
106
+ return out;
107
+ },
108
+ };
109
+ }
110
+
111
+ export function restore(text, vault) {
112
+ return vault ? restoreText(text, vault) : text;
113
+ }
114
+
115
+ // Deep-restore a value (tool-call args contain tokens; local tools must run on the
116
+ // REAL values). restoreWithAliases undoes pseudonyms too — local lookups hit real
117
+ // data; only the model stays blinded.
118
+ export function restoreDeep(value, vault) {
119
+ if (!vault) return value;
120
+ if (typeof value === 'string') return restoreWithAliases(value, vault);
121
+ if (Array.isArray(value)) return value.map((v) => restoreDeep(v, vault));
122
+ if (value && typeof value === 'object') {
123
+ const out = {};
124
+ for (const k of Object.keys(value)) out[k] = restoreDeep(value[k], vault);
125
+ return out;
126
+ }
127
+ return value;
128
+ }
129
+
130
+ // Re-redact a tool result of any shape (string / { text } / array / MCP
131
+ // { content:[{text}] }), gated once by the toolResults scope. The old path only
132
+ // covered string + { text }, so PII in a content[] item reached the model.
133
+ export function redactResult(result, ctx) {
134
+ const { vault, cfg, isPro = false, entities = [] } = ctx || {};
135
+ if (!redactionEnabled(cfg) || !vault || !gatedScope(cfg, isPro).toolResults) return result;
136
+ return redactResultShape(result, vault, redactOpts(cfg, isPro, entities));
137
+ }