@chatpanel/gateway 0.3.1 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "node": ">=18"
29
29
  },
30
30
  "dependencies": {
31
- "@chatpanel/pii": "^0.1.1"
31
+ "@chatpanel/pii": "^0.2.1"
32
32
  },
33
33
  "homepage": "https://chatpanel.net",
34
34
  "repository": {
@@ -19,7 +19,7 @@ export function persistConfig(cfg, path = configPath()) {
19
19
  host: cfg.host, port: cfg.port, backend: cfg.backend,
20
20
  bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
21
21
  ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
22
- pro: cfg.pro, logRequests: cfg.logRequests,
22
+ pro: cfg.pro, logRequests: cfg.logRequests, tools: cfg.tools,
23
23
  };
24
24
  writeFileSync(path, JSON.stringify(out, null, 2));
25
25
  }
@@ -44,6 +44,11 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
44
44
  allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
45
45
  pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: cfg.pro?.free },
46
46
  logRequests: !!cfg.logRequests,
47
+ tools: {
48
+ autoNarrow: cfg.tools?.autoNarrow !== false,
49
+ maxPerTurn: Number(cfg.tools?.maxPerTurn) > 0 ? Number(cfg.tools.maxPerTurn) : 8,
50
+ narrowAll: !!cfg.tools?.narrowAll,
51
+ },
47
52
  };
48
53
  }
49
54
 
@@ -96,5 +101,12 @@ export function applyConfigPatch(cfg, patch = {}) {
96
101
  if (Number.isFinite(cap) && cap >= 0) cfg.pro.free.maxRequestsPerDay = cap;
97
102
  }
98
103
  if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
104
+ if (patch.tools && typeof patch.tools === 'object') {
105
+ cfg.tools = cfg.tools || {};
106
+ if ('autoNarrow' in patch.tools) cfg.tools.autoNarrow = !!patch.tools.autoNarrow;
107
+ if ('narrowAll' in patch.tools) cfg.tools.narrowAll = !!patch.tools.narrowAll;
108
+ const cap = Number(patch.tools.maxPerTurn);
109
+ if (Number.isFinite(cap) && cap >= 1) cfg.tools.maxPerTurn = Math.floor(cap);
110
+ }
99
111
  return cfg;
100
112
  }
package/src/server.js CHANGED
@@ -21,7 +21,7 @@ import { createServer } from 'node:http';
21
21
  import { loadConfig } from './config.js';
22
22
  import { redactSegments } from './redact.js';
23
23
  import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
24
- import { restoreText, effectiveTier, gatedDictionary } from '@chatpanel/pii';
24
+ import { restoreText, effectiveTier, gatedDictionary, narrowSpecs } from '@chatpanel/pii';
25
25
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
26
26
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
27
27
  import { shaperFor } from './shape.js';
@@ -33,10 +33,40 @@ import * as openai from './openai.js';
33
33
  import * as responses from './responses.js';
34
34
  import * as anthropic from './anthropic.js';
35
35
 
36
- export const VERSION = '0.3.1';
36
+ export const VERSION = '0.4.0';
37
37
 
38
38
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
39
39
 
40
+ // Auto-narrow: arm only the top-K most-relevant MCP tools per turn (speed). Mirrors
41
+ // the extension's AUTO mode via the SAME shared ranker. We narrow only tools whose
42
+ // name looks like an MCP tool (server-prefixed) so a client's CORE tools (bash,
43
+ // read, edit…) are never dropped — that would break agent clients like OpenCode.
44
+ const DEFAULT_GATEWAY_TOOL_CAP = 8;
45
+ const MCP_NAME_RE = /^mcp[_-]/i;
46
+ const toolName = (t) => (t && t.function && t.function.name) || (t && t.name) || '';
47
+ const toolDesc = (t) => (t && t.function && t.function.description) || (t && t.description) || '';
48
+
49
+ // Flatten message/content shapes to the latest user text — the query we rank tools against.
50
+ function textFromContent(content) {
51
+ if (typeof content === 'string') return content;
52
+ if (Array.isArray(content)) return content.map((p) => (typeof p === 'string' ? p : (p && (p.text || p.content)) || '')).join(' ');
53
+ return '';
54
+ }
55
+ function latestUserText(body, kind) {
56
+ if (!body) return '';
57
+ if (kind === 'responses') {
58
+ const inp = body.input;
59
+ if (typeof inp === 'string') return inp;
60
+ if (Array.isArray(inp)) return inp.map((x) => textFromContent(x && (x.content ?? x))).join(' ');
61
+ return '';
62
+ }
63
+ const msgs = Array.isArray(body.messages) ? body.messages : [];
64
+ for (let i = msgs.length - 1; i >= 0; i--) {
65
+ if (msgs[i] && msgs[i].role === 'user') return textFromContent(msgs[i].content);
66
+ }
67
+ return '';
68
+ }
69
+
40
70
  const HOP_BY_HOP = new Set([
41
71
  'host', 'connection', 'content-length', 'transfer-encoding',
42
72
  'accept-encoding', 'content-encoding', 'keep-alive',
@@ -345,10 +375,24 @@ export function createGateway(cfg = loadConfig()) {
345
375
  let body = null;
346
376
  let outBody = raw;
347
377
  let redactedCount = 0;
378
+ let narrowedTools = 0;
348
379
  let isPro = true;
349
380
  if (r.redactable && req.method === 'POST' && raw.length) {
350
381
  try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
351
382
  if (body) {
383
+ // Auto-narrow tools to the top-K most relevant for this turn (speed) —
384
+ // same shared ranker as the extension's AUTO mode. Only MCP-named tools
385
+ // are narrowed; the client's core tools (bash/read/edit…) are always kept,
386
+ // unless tools.narrowAll is set. Mutates body.tools BEFORE redaction so
387
+ // both the API forward and the bridge relay see the trimmed set.
388
+ const tcfg = cfg.tools || {};
389
+ if (tcfg.autoNarrow !== false && Array.isArray(body.tools) && body.tools.length) {
390
+ const cap = Number(tcfg.maxPerTurn) > 0 ? Number(tcfg.maxPerTurn) : DEFAULT_GATEWAY_TOOL_CAP;
391
+ const keep = tcfg.narrowAll ? null : (t) => !MCP_NAME_RE.test(toolName(t));
392
+ const before = body.tools.length;
393
+ body.tools = narrowSpecs(body.tools, latestUserText(body, r.kind), { cap, keep, name: toolName, description: toolDesc });
394
+ narrowedTools = before - body.tools.length;
395
+ }
352
396
  // Free/Pro gate: meter the request and pick the effective tier.
353
397
  isPro = await resolvePro(cfg.pro?.entitlementToken);
354
398
  const allow = meter(cfg, isPro);
@@ -372,8 +416,8 @@ export function createGateway(cfg = loadConfig()) {
372
416
  // API we forward to). Falls back to the legacy backend when none configured.
373
417
  const dest = resolveDestination(body?.model, cfg, r.kind);
374
418
  if (cfg.logRequests && r.redactable) {
375
- recordRequest({ t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount });
376
- console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}`);
419
+ recordRequest({ t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, narrowed: narrowedTools });
420
+ console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}${narrowedTools ? ` · narrowed -${narrowedTools} tools` : ''}`);
377
421
  }
378
422
  if (dest && dest.type === 'api') {
379
423
  if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });