@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,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
+ }
@@ -0,0 +1,179 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/sanitize.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
+ // Unicode de-steganography for text that flows through the privacy boundary.
10
+ //
11
+ // Invisible and format-control characters are a single vector with three abuses,
12
+ // all relevant to a redaction product:
13
+ //
14
+ // 1. Redaction bypass - splitting a value with zero-width chars (j<ZWSP>o<ZWSP>hn@x.com)
15
+ // hides it from the regex/NER detector, then the model reassembles the real
16
+ // value. The deterministic engine works on text, so the smuggled PII leaks.
17
+ // 2. Hidden prompt injection - Unicode Tag characters (U+E0000-E007F) render as
18
+ // nothing but encode a full ASCII instruction the model reads ("ASCII smuggling").
19
+ // 3. Fingerprinting / watermarking - steganographic markers injected into a prompt
20
+ // (e.g. a client classifying a custom gateway and encoding a bit into invisible
21
+ // punctuation). A privacy proxy should scrub these - and never emit its own.
22
+ //
23
+ // We strip the channels that have no legitimate place in plain prompt text, while
24
+ // PRESERVING the few legitimate uses (emoji ZWJ/variation sequences, normal accents).
25
+ //
26
+ // The patterns are BUILT FROM NUMERIC CODE POINTS below - there are deliberately no
27
+ // literal invisible characters anywhere in this source (auditable, and fitting for a
28
+ // de-steg module). Pure + dependency-free ESM. Call it BEFORE detection so obfuscated
29
+ // PII becomes matchable, and on model output before restoration so a token can't be
30
+ // split/spoofed with invisibles.
31
+
32
+ // Code-point ranges (inclusive) per category, by their abuse.
33
+ const RANGES = {
34
+ // Unicode Tag block - the ASCII-smuggling channel.
35
+ tags: [[0xE0000, 0xE007F]],
36
+ // Bidi controls - reorder/override visible text to hide reversed instructions.
37
+ bidi: [[0x061C, 0x061C], [0x200E, 0x200F], [0x202A, 0x202E], [0x2066, 0x2069]],
38
+ // Zero-width & assorted invisible format chars: soft hyphen, Hangul/Mongolian
39
+ // fillers, ZWSP, word/invisible joiners, deprecated format controls, BOM/ZWNBSP,
40
+ // interlinear annotation, object replacement.
41
+ zeroWidth: [
42
+ [0x00AD, 0x00AD], [0x115F, 0x1160], [0x180E, 0x180E], [0x200B, 0x200B],
43
+ [0x2060, 0x2064], [0x206A, 0x206F], [0x3164, 0x3164], [0xFEFF, 0xFEFF],
44
+ [0xFFA0, 0xFFA0], [0xFFF9, 0xFFFB], [0xFFFC, 0xFFFC],
45
+ ],
46
+ // Supplementary variation selectors - the byte-smuggling range. Never legit in text.
47
+ supVS: [[0xE0100, 0xE01EF]],
48
+ // Line/paragraph separators - converted to '\n' (kill parser tricks, keep the break).
49
+ lineSep: [[0x2028, 0x2029]],
50
+ // ZWJ/ZWNJ + BMP variation selectors - legit ONLY next to an emoji base, so these
51
+ // are stripped contextually (see ANOMALOUS_JOIN_VS), not unconditionally.
52
+ joinVS: [[0x200C, 0x200D], [0xFE00, 0xFE0F]],
53
+ };
54
+
55
+ const u = (cp) => `\\u{${cp.toString(16).toUpperCase()}}`;
56
+ const cls = (ranges) => ranges.map(([a, b]) => (a === b ? u(a) : `${u(a)}-${u(b)}`)).join('');
57
+
58
+ const TAGS = new RegExp(`[${cls(RANGES.tags)}]`, 'gu');
59
+ const BIDI = new RegExp(`[${cls(RANGES.bidi)}]`, 'gu');
60
+ const ZERO_WIDTH = new RegExp(`[${cls(RANGES.zeroWidth)}]`, 'gu');
61
+ const SUP_VS = new RegExp(`[${cls(RANGES.supVS)}]`, 'gu');
62
+ const LINE_SEP = new RegExp(`[${cls(RANGES.lineSep)}]`, 'gu');
63
+ // Strip ZWJ/ZWNJ/VS only when NOT preceded by an emoji base (so emoji sequences and
64
+ // regional-indicator flags survive); supplementary VS are always stripped.
65
+ const ANOMALOUS_JOIN_VS = new RegExp(
66
+ `(?<![\\p{Extended_Pictographic}${u(0x1F1E6)}-${u(0x1F1FF)}])[${cls(RANGES.joinVS)}]|[${cls(RANGES.supVS)}]`,
67
+ 'gu',
68
+ );
69
+ // Runs of combining marks (Zalgo / bit-stuffing). A real stacked diacritic is 1-3
70
+ // marks; anything past the cap is signalling, not language.
71
+ const COMBINING_RUN = /\p{M}+/gu;
72
+
73
+ // Cheap boolean for hot paths / UI ("does this contain anything hidden?"). Excludes
74
+ // the context-dependent joinVS so legitimate emoji aren't flagged - sanitizeUnicode()
75
+ // stays the source of truth for those.
76
+ const ANY_HIDDEN = new RegExp(
77
+ `[${cls(RANGES.bidi)}${cls(RANGES.zeroWidth)}]|[${cls(RANGES.tags)}]|[${cls(RANGES.supVS)}]`,
78
+ 'u',
79
+ );
80
+
81
+ export function hasHiddenChars(text) {
82
+ return typeof text === 'string' && ANY_HIDDEN.test(text);
83
+ }
84
+
85
+ // sanitizeUnicode(text, opts) -> { clean, removed, findings }
86
+ // clean - text with the smuggling channels stripped/normalized
87
+ // removed - total count of stripped/collapsed characters (0 = nothing hidden)
88
+ // findings - per-category counts (only non-zero keys), for transparent reporting
89
+ //
90
+ // opts.normalize: 'NFC' (default, appearance-preserving) | 'NFKC' (also folds
91
+ // fullwidth/homoglyph compatibility forms - stronger for detection, but rewrites
92
+ // some visible glyphs) | 'none'.
93
+ // opts.collapseCombiningOver: max combining marks kept per run (default 4).
94
+ export function sanitizeUnicode(text, { normalize = 'NFC', collapseCombiningOver = 4 } = {}) {
95
+ if (typeof text !== 'string' || text === '') return { clean: text ?? '', removed: 0, findings: {} };
96
+ let s = text;
97
+ const findings = {};
98
+
99
+ // Strip one category, counting by code point (spread iterates code points, so a
100
+ // supplementary char like a Tag counts as 1, not 2 UTF-16 units).
101
+ const strip = (re, key) => {
102
+ let n = 0;
103
+ s = s.replace(re, (m) => { n += [...m].length; return ''; });
104
+ if (n) findings[key] = n;
105
+ };
106
+
107
+ let lineSep = 0;
108
+ s = s.replace(LINE_SEP, () => { lineSep++; return '\n'; });
109
+ if (lineSep) findings.lineSep = lineSep;
110
+
111
+ strip(TAGS, 'tags');
112
+ strip(BIDI, 'bidi');
113
+ strip(ZERO_WIDTH, 'zeroWidth');
114
+ strip(ANOMALOUS_JOIN_VS, 'joinersVS');
115
+
116
+ // Compose canonically so split/decomposed forms can't dodge the detector.
117
+ if (normalize && normalize !== 'none') {
118
+ try { s = s.normalize(normalize); } catch { /* invalid form name -> skip */ }
119
+ }
120
+
121
+ let combining = 0;
122
+ s = s.replace(COMBINING_RUN, (run) => {
123
+ const marks = [...run];
124
+ if (marks.length <= collapseCombiningOver) return run;
125
+ combining += marks.length - collapseCombiningOver;
126
+ return marks.slice(0, collapseCombiningOver).join('');
127
+ });
128
+ if (combining) findings.combining = combining;
129
+
130
+ const removed = (findings.tags || 0) + (findings.bidi || 0) + (findings.zeroWidth || 0)
131
+ + (findings.joinersVS || 0) + (findings.combining || 0);
132
+ return { clean: s, removed, findings };
133
+ }
134
+
135
+ // Convenience for the common "just give me clean text" caller.
136
+ export function stripHidden(text, opts) {
137
+ return sanitizeUnicode(text, opts).clean;
138
+ }
139
+
140
+ // ── Confusables skeleton ─────────────────────────────────────────────────────
141
+ // Fold single-code-point Latin LOOK-ALIKES (Cyrillic / Greek / fullwidth) to their
142
+ // ASCII skeleton so a homoglyph-obfuscated value (jоhn@x.com with a Cyrillic 'о')
143
+ // becomes matchable by the ASCII regexes. STRICTLY 1:1 per code point — every mapping
144
+ // is one char → one char — so a match's indices in the skeleton line up exactly with
145
+ // the original text. Use it for DETECTION only and redact the ORIGINAL span, so
146
+ // legitimate Cyrillic/Greek/CJK text is never rewritten (only deceptively-Latin
147
+ // values that actually match a detector get touched). Built from numeric code points
148
+ // (no literal confusables in source, like the rest of this module).
149
+ const CONFUSABLE = new Map([
150
+ // Cyrillic lowercase → Latin
151
+ [0x0430, 'a'], [0x0435, 'e'], [0x043E, 'o'], [0x0440, 'p'], [0x0441, 'c'],
152
+ [0x0443, 'y'], [0x0445, 'x'], [0x0455, 's'], [0x0456, 'i'], [0x0458, 'j'],
153
+ [0x04BB, 'h'], [0x043C, 'm'], [0x043D, 'h'], [0x0442, 't'], [0x043A, 'k'],
154
+ // Cyrillic uppercase → Latin
155
+ [0x0410, 'A'], [0x0412, 'B'], [0x0415, 'E'], [0x041A, 'K'], [0x041C, 'M'],
156
+ [0x041D, 'H'], [0x041E, 'O'], [0x0420, 'P'], [0x0421, 'C'], [0x0422, 'T'],
157
+ [0x0425, 'X'], [0x0406, 'I'], [0x0408, 'J'], [0x0405, 'S'],
158
+ // Greek → Latin
159
+ [0x03BF, 'o'], [0x03C1, 'p'], [0x03B1, 'a'], [0x03BD, 'v'], [0x03B9, 'i'],
160
+ [0x0391, 'A'], [0x0392, 'B'], [0x0395, 'E'], [0x0396, 'Z'], [0x0397, 'H'],
161
+ [0x0399, 'I'], [0x039A, 'K'], [0x039C, 'M'], [0x039D, 'N'], [0x039F, 'O'],
162
+ [0x03A1, 'P'], [0x03A4, 'T'], [0x03A5, 'Y'], [0x03A7, 'X'],
163
+ ]);
164
+
165
+ export function confusablesSkeleton(text) {
166
+ if (typeof text !== 'string' || text === '') return text ?? '';
167
+ let out = '';
168
+ for (const ch of text) {
169
+ const cp = ch.codePointAt(0);
170
+ if (cp >= 0xFF01 && cp <= 0xFF5E) { out += String.fromCharCode(cp - 0xFEE0); continue; } // fullwidth ASCII
171
+ const mapped = CONFUSABLE.get(cp);
172
+ out += mapped != null ? mapped : ch;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // Exposed for tests / external auditing.
178
+ export const SANITIZE_RANGES = RANGES;
179
+ export const CONFUSABLE_MAP = CONFUSABLE;
@@ -0,0 +1,152 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/tool-harness.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
+ // THE tool harness — one interception layer shared by every orchestrator
10
+ // (ChatPanel API + agent, gateway API + relay) so tool handling can't drift. It
11
+ // owns the boundaries around a model turn:
12
+ //
13
+ // ⓪ selectTools — inject + narrow the tools the model is offered (MCP-auto).
14
+ // ② toTool — what a tool RECEIVES: real values (so on-device / remote
15
+ // lookups work), or the redacted token for remote MCP tools
16
+ // when the user chose "redact remote".
17
+ // ③ toModelResult— what the MODEL sees back: the tool result re-redacted so it
18
+ // stays blinded.
19
+ // ④ toUser — the final reply: reversible tokens restored (pseudonyms stay).
20
+ //
21
+ // PRIVACY IS OPTIONAL. With no `vault` (redaction off), ②③④ are pass-throughs —
22
+ // no latency, no placeholder confusion — but ⓪ selectTools STILL narrows, because
23
+ // not every turn is privacy-sensitive yet every turn benefits from fewer tools.
24
+ //
25
+ // Self-contained on the SYNCED engine files (pii-redact.js, tool-rank.js), so the
26
+ // extension (browser ESM) and the gateway (npm) run the exact same code. The caller
27
+ // passes the already-gated `redactOpts` ({tier, entities, dictionary}) it computed
28
+ // from cfg+isPro — tier/dictionary selection stays out of the harness.
29
+
30
+ import { restoreText, restoreWithAliases, redactResultShape } from './pii-redact.js';
31
+ import { narrowSpecs } from './tool-rank.js';
32
+
33
+ // MCP / remote tools are server-prefixed (mcp_server__tool). Local tools
34
+ // (history/meeting/page, or a client's core bash/read) are not — they always get
35
+ // real values and are never narrowed away.
36
+ export const isRemoteToolName = (name) => /^mcp[_-]/i.test(String(name || ''));
37
+
38
+ // Deep restore of a tool-call argument value, undoing reversible tokens AND
39
+ // pseudonyms (tools run locally / on real data; only the model stays blinded).
40
+ export function restoreToolArgs(value, vault) {
41
+ if (!vault) return value;
42
+ if (typeof value === 'string') return restoreWithAliases(value, vault);
43
+ if (Array.isArray(value)) return value.map((v) => restoreToolArgs(v, vault));
44
+ if (value && typeof value === 'object') {
45
+ const out = {};
46
+ for (const k of Object.keys(value)) out[k] = restoreToolArgs(value[k], vault);
47
+ return out;
48
+ }
49
+ return value;
50
+ }
51
+
52
+ // System-prompt note that tells the model how to behave around placeholders when
53
+ // tools are armed. WITHOUT this, privacy-aware models (Codex, Claude) recognize a
54
+ // [[LOCATION_1]] token as redacted and REFUSE to use it for a lookup ("I can't see
55
+ // your real city") — the opposite of what we want. Weak models call the tool blindly
56
+ // and it works (the harness restores the real value), so the note levels them up.
57
+ export function placeholderToolNote({ toolData = 'real' } = {}) {
58
+ const intro =
59
+ 'PRIVACY PLACEHOLDERS: some values in this conversation are tokens like [[PERSON_1]], '
60
+ + '[[LOCATION_1]], [[ORG_1]] that stand in for the user\'s real private data. ';
61
+ const remote = toolData === 'redactRemote'
62
+ ? 'When you call a LOCAL tool the placeholder is automatically replaced with the real '
63
+ + 'value before the tool runs; REMOTE (MCP) tools deliberately receive the placeholder '
64
+ + 'to keep private data off third-party servers. '
65
+ : 'When you call ANY tool, these placeholders are AUTOMATICALLY replaced with the real '
66
+ + 'values before the tool executes — the tool receives the TRUE value and returns correct '
67
+ + 'results. ';
68
+ const rules =
69
+ 'So: treat each placeholder as a CONCRETE, specific value you already have — it is enough to '
70
+ + 'act on, NOT missing or unknown information. CALL THE TOOL using the placeholder exactly as '
71
+ + 'written, as if it were the real value. '
72
+ // The common failure isn\'t a privacy refusal — it\'s the model deciding it "lacks data"
73
+ // because the value is a token, and answering from general knowledge instead of looking up.
74
+ + 'If answering needs the real data behind a placeholder (e.g. which city [[LOCATION_1]] is, '
75
+ + 'who [[PERSON_1]] is, what [[ORG_1]] does), do NOT reply that you lack information or cannot '
76
+ + 'answer. Instead pick the most relevant available tool for that placeholder\'s TYPE — LOCATION '
77
+ + '→ geography / place lookups, PERSON → people lookups, ORG → company/org lookups, dates/IDs → '
78
+ + 'the matching lookup — and pass the placeholder straight through as the argument. The harness '
79
+ + 'restores the true value before the tool runs, so the lookup returns correct results. Make your '
80
+ + 'best-guess tool call FIRST; only conclude you lack data AFTER a tool has actually returned '
81
+ + 'nothing useful. '
82
+ + 'Do NOT ask the user to re-type the value and do NOT refuse on privacy grounds — the lookup '
83
+ + 'will work. The real values are restored in your final answer automatically, so write your '
84
+ + 'answer using the placeholders too.';
85
+ return intro + remote + rules;
86
+ }
87
+
88
+ // Tools whose results come from the PUBLIC web rather than from the user's own machine or
89
+ // accounts. Deliberately a short, explicit allowlist rather than a heuristic: being wrong in
90
+ // the "public" direction would send real PII to a model, so a tool earns its place here only
91
+ // when its output is public by construction. Page tools are NOT here — the user's open tab
92
+ // may be an internal app.
93
+ const PUBLIC_SOURCE_TOOLS = new Set(['web_search', 'web_fetch', 'fetch_url']);
94
+ export function isPublicSourceTool(name) {
95
+ return PUBLIC_SOURCE_TOOLS.has(String(name || '').toLowerCase());
96
+ }
97
+
98
+ export function makeToolHarness({ vault = null, toolData = 'real', redactOpts = null, redactResults = true, remoteTools = null } = {}) {
99
+ const on = !!vault; // privacy enabled for this turn?
100
+ const redactRemote = toolData === 'redactRemote';
101
+ // How we decide a tool is REMOTE (must not receive real PII under redactRemote).
102
+ // Prefer an EXPLICIT set/predicate the caller derived from the toolset (a remote
103
+ // tool not named mcp_* would otherwise be misclassified as local and get real
104
+ // values); fall back to the mcp_* name heuristic when the caller passes nothing.
105
+ const isRemoteTool = typeof remoteTools === 'function' ? remoteTools
106
+ : (remoteTools instanceof Set ? (name) => remoteTools.has(name)
107
+ : isRemoteToolName);
108
+ return {
109
+ enabled: on,
110
+ isRemoteTool,
111
+
112
+ // ⓪ Always-on tool selection (privacy-independent). `available` is any spec
113
+ // list; `opts` forwards { cap, keep, name, description } to the shared ranker.
114
+ selectTools(available, query, opts = {}) {
115
+ return narrowSpecs(available, query, opts);
116
+ },
117
+
118
+ // ② What the tool receives.
119
+ toTool(name, args) {
120
+ if (!on) return args; // privacy off → already real
121
+ if (redactRemote && isRemoteTool(name)) return args; // keep PII off remote MCP
122
+ return restoreToolArgs(args, vault); // real values for the tool
123
+ },
124
+
125
+ // ③ What the model sees back (re-redacted). Walks string / { text } / array /
126
+ // MCP { content:[{text}] } shapes so a tool result can't leak PII to the model
127
+ // via a nested field the old string/{text}-only path skipped.
128
+ toModelResult(name, raw) {
129
+ if (!on || !redactResults || !redactOpts) return raw;
130
+ // PUBLIC RESULTS ARE NOT THE USER'S DATA.
131
+ //
132
+ // Redaction exists to stop the user's information LEAVING the device. Text coming back
133
+ // from a public web search never left it — the model's provider could fetch the same
134
+ // page itself — so rewriting it buys no privacy and actively corrupts facts: a
135
+ // dictionary pseudonym (a user's own name → a stand-in) renamed a same-named public
136
+ // figure inside search results, and the answer came back about a person who does not
137
+ // exist. The detectors
138
+ // (emails, phones, keys) also fire on unrelated strangers' details in fetched pages.
139
+ //
140
+ // So public-source results pass through intact. Everything local or private — history,
141
+ // meetings, notes, the user's own page, any MCP server — is redacted exactly as before,
142
+ // which is where a leak could actually happen.
143
+ if (isPublicSourceTool(name)) return raw;
144
+ return redactResultShape(raw, vault, redactOpts);
145
+ },
146
+
147
+ // ④ The final reply the user sees.
148
+ toUser(text) {
149
+ return on ? restoreText(text, vault) : text;
150
+ },
151
+ };
152
+ }
@@ -0,0 +1,110 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-pii/tool-rank.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
+ // Deterministic, model-free tool ranking — shared by the extension's side panel
10
+ // and the gateway, so "auto mode" narrows the same way everywhere (single source
11
+ // of truth, per the no-duplication rule).
12
+ //
13
+ // Ranks tool specs by lexical relevance to a query, weighting each query word by
14
+ // INVERSE DOCUMENT FREQUENCY across the toolset: a distinctive word like "wiki"
15
+ // (in 1–2 tools) counts far more than a common one like "search" (in many) — so
16
+ // "use wiki search" ranks the Wikipedia tool above generic search tools instead
17
+ // of tying them. Latency-sensitive: pure string ops, runs on every turn, no model
18
+ // call. Generic over the spec shape via name/description accessors (the extension
19
+ // uses { name, description }; the gateway uses OpenAI's { function: { name, … } }).
20
+
21
+ const STOP = new Set([
22
+ 'the', 'and', 'for', 'with', 'that', 'this', 'use', 'can', 'you', 'your', 'please',
23
+ 'about', 'from', 'what', 'who', 'how', 'are', 'was', 'will', 'just', 'tell', 'find',
24
+ 'get', 'into', 'them', 'they', 'their', 'name', 'one', 'but', 'not', 'all',
25
+ ]);
26
+
27
+ const defName = (s) => (s && s.name) || '';
28
+ const defDesc = (s) => (s && s.description) || '';
29
+
30
+ // Names of GENERAL entry-point tools — preferred when a query doesn't pin a specific
31
+ // tool. Matches the tool segment (after the server prefix): e.g. ...__wikipedia_search,
32
+ // ...__ask_pipeworx, ...__get_summary, ...__search_wikipedia.
33
+ const GENERAL_TOOL_RE = /(?:^|_)(search|ask|lookup|find|answer|summary|wiki)(?:_|$)/i;
34
+
35
+ // Returns specs scored + sorted most-relevant first, as [{ s, i, n }] (i = original
36
+ // index, n = score). Stable for ties (preserves original order).
37
+ export function scoreToolSpecs(specs, query, { name = defName, description = defDesc } = {}) {
38
+ const q = String(query || '').toLowerCase();
39
+ const words = [...new Set(q.split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)))];
40
+ const list = [...(specs || [])];
41
+ const names = list.map((s) => String(name(s) || '').toLowerCase());
42
+ const hays = list.map((s, i) => `${names[i]} ${String(description(s) || '').toLowerCase()}`);
43
+ const N = list.length || 1;
44
+ const df = {}; // how many tools mention each query word
45
+ for (const w of words) df[w] = hays.reduce((n, h) => n + (h.includes(w) ? 1 : 0), 0);
46
+ const idf = (w) => Math.log(1 + N / (1 + (df[w] || 0))); // rarer → higher weight
47
+ const score = (i) => {
48
+ let n = 0;
49
+ for (const w of words) if (hays[i].includes(w)) n += idf(w);
50
+ for (const part of names[i].split(/[^a-z0-9]+/)) {
51
+ if (part.length > 2 && q.includes(part)) n += 2 + idf(part); // tool explicitly named
52
+ }
53
+ // General-purpose ENTRY-POINT tools (search / ask / lookup / get-summary / answer)
54
+ // are the right default when the query doesn't keyword-match a specific tool —
55
+ // e.g. "which state is Seattle in" → a wikipedia SEARCH/ASK tool, not one of 20
56
+ // dataset-query tools. A small tie-breaker boost (below a real keyword match) so
57
+ // those generic tools win when nothing else distinguishes them.
58
+ if (GENERAL_TOOL_RE.test(names[i])) n += 1.5;
59
+ return n;
60
+ };
61
+ return list.map((s, i) => ({ s, i, n: score(i) })).sort((a, b) => (b.n - a.n) || (a.i - b.i));
62
+ }
63
+
64
+ // Rank tool specs most-relevant first (stable for ties).
65
+ export function rankToolSpecs(specs, query, accessors) {
66
+ return scoreToolSpecs(specs, query, accessors).map((x) => x.s);
67
+ }
68
+
69
+ // Narrow a flat spec list to at most `cap` entries that DON'T match `keep`, always
70
+ // retaining everything that does (e.g. local page/history tools). `cap` therefore
71
+ // bounds the NARROWABLE (MCP) tools; kept tools ride along free. Returns the list
72
+ // unchanged when there's no cap or the narrowable set already fits.
73
+ // The MCP server a tool belongs to: mcp_<server>__<tool> → "mcp_<server>". Tools
74
+ // without that shape are their own "server" (never grouped together).
75
+ function serverKey(n) {
76
+ const s = String(n || '');
77
+ const i = s.indexOf('__');
78
+ return i > 0 ? s.slice(0, i) : s;
79
+ }
80
+
81
+ export function narrowSpecs(specs, query, { cap = 0, keep, name = defName, description = defDesc } = {}) {
82
+ const list = specs || [];
83
+ if (!cap || cap < 1) return list;
84
+ const kept = keep ? list.filter(keep) : [];
85
+ const rest = keep ? list.filter((s) => !kept.includes(s)) : list;
86
+ if (rest.length <= cap) return list;
87
+ // SERVER-DIVERSE selection: rank all narrowable tools, then pick ROUND-ROBIN across
88
+ // servers — each server's best tool first, then seconds, … up to `cap`. This keeps
89
+ // a relevant server (e.g. wikipedia) from being crowded out of the top-K by another
90
+ // server that happens to have many tools. Servers are visited best-first (the order
91
+ // their top-ranked tool appears in the global ranking).
92
+ const ranked = rankToolSpecs(rest, query, { name, description });
93
+ const queues = new Map(); // serverKey -> [tools] in rank order (insertion = best-first)
94
+ for (const s of ranked) {
95
+ const k = serverKey(name(s));
96
+ if (!queues.has(k)) queues.set(k, []);
97
+ queues.get(k).push(s);
98
+ }
99
+ const lanes = [...queues.values()];
100
+ const chosen = new Set();
101
+ for (let round = 0; chosen.size < cap; round++) {
102
+ let advanced = false;
103
+ for (const lane of lanes) {
104
+ if (chosen.size >= cap) break;
105
+ if (lane.length > round) { chosen.add(lane[round]); advanced = true; }
106
+ }
107
+ if (!advanced) break;
108
+ }
109
+ return list.filter((s) => kept.includes(s) || chosen.has(s)); // preserve original order
110
+ }