@chatpanel/bridge 0.11.5 → 0.11.7

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/bridge",
3
- "version": "0.11.5",
3
+ "version": "0.11.7",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -38,6 +38,29 @@ const HELP = [
38
38
  * it needs is injected — a bot token, the bridge address+token, a pairing store, an event sink
39
39
  * — so nothing here reads a secret or reaches for global state.
40
40
  */
41
+ // What a turn driven from a phone can actually DO, told to the agent up front.
42
+ //
43
+ // Without this the agent plans as if it were sitting at a terminal: asked to create a note it
44
+ // announced it would look for a notes folder, discovered writes were not enabled, said "let me
45
+ // check what notes tools are available" — and stopped mid-thought, having done nothing and
46
+ // explained nothing. The ceiling is real and enforced elsewhere (reach caps the toolset); the
47
+ // agent simply had no way to know about it, so it spent a turn discovering it and left the
48
+ // user staring at half a sentence.
49
+ export function channelSystem(userSystem, reach) {
50
+ const limits = !reach || reach === 'any' ? '' : [
51
+ "You are answering a message sent from the user's PHONE, relayed to this machine.",
52
+ reach === 'device'
53
+ ? 'You cannot read files, run commands, browse the web, or change anything.'
54
+ : "You can READ files and search the user's ChatPanel history. You cannot write or edit "
55
+ + 'files, run shell commands, or browse the web — those are switched off for messages '
56
+ + 'from a phone, and no tool will grant them.',
57
+ 'If a request needs something you cannot do, say so plainly in your FIRST reply and offer '
58
+ + 'what you can do instead. Never begin work you cannot finish.',
59
+ 'Answers are read on a phone: be brief, and put the answer first.',
60
+ ].join(' ');
61
+ return [limits, userSystem].filter(Boolean).join('\n\n');
62
+ }
63
+
41
64
  export function startTelegram({
42
65
  botToken,
43
66
  baseUrl,
@@ -52,7 +75,17 @@ export function startTelegram({
52
75
  // which it has: both expose chat()/cancel() and fold into one reply state.
53
76
  transport = bridge,
54
77
  model = '',
78
+ provider = '',
55
79
  system = '',
80
+ // Read at the START OF EACH MESSAGE rather than captured when the loop starts.
81
+ //
82
+ // `chats` below holds every conversation's history AND its privacy vault, and it lives only
83
+ // as long as this loop. Settings used to be baked in at start, so changing the model — or
84
+ // the privacy mode, or anything else — had to stop and respawn the loop, which silently threw
85
+ // all of that away: the next message arrived as "a fresh session with no prior conversation",
86
+ // and PERSON_1 stopped meaning the same person. Switching which model answers is not a
87
+ // reason to forget what you were talking about.
88
+ route = null,
56
89
  redact = { tier: 'basic' },
57
90
  privacy = 'standard',
58
91
  logger = console,
@@ -119,7 +152,7 @@ export function startTelegram({
119
152
  }
120
153
  if (name === 'stop') {
121
154
  const st = chatState(norm.chatId);
122
- const ok = await transport.cancel(st.runId, { baseUrl, token });
155
+ const ok = await ((route && route().transport) || transport).cancel(st.runId, { baseUrl, token });
123
156
  st.runId = null;
124
157
  return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
125
158
  }
@@ -156,11 +189,22 @@ export function startTelegram({
156
189
  // on the bridge renders all-but-last as history and the last as "answer this now".
157
190
  const messages = [...st.history, { role: 'user', content: redacted }];
158
191
 
192
+ // Whatever the settings say RIGHT NOW — see `route` above.
193
+ const live = (route && route()) || {};
194
+ const useTransport = live.transport || transport;
159
195
  try {
160
- const finalState = await transport.chat(
161
- { agent, model, system, messages, images, options: { reach } },
196
+ const finalState = await useTransport.chat(
197
+ {
198
+ agent: live.agent ?? agent,
199
+ model: live.model ?? model,
200
+ provider: live.provider ?? provider,
201
+ system: channelSystem(live.system ?? system, reach),
202
+ messages,
203
+ images,
204
+ options: { reach },
205
+ },
162
206
  {
163
- baseUrl, token, signal,
207
+ baseUrl: live.baseUrl || baseUrl, token, signal,
164
208
  onEvent: (ev, state) => {
165
209
  if (state.runId) st.runId = state.runId;
166
210
  // Driven by the folded STATE, not by an event's `type`: the bridge emits
@@ -40,7 +40,7 @@ const inflight = new Map();
40
40
  * not know which transport it has: streams events to onEvent(ev, state) and returns the folded
41
41
  * final state.
42
42
  */
43
- export async function chat({ model = '', system = '', messages = [], options = {} }, {
43
+ export async function chat({ model = '', provider = '', system = '', messages = [], options = {} }, {
44
44
  baseUrl, signal, onEvent = () => {},
45
45
  } = {}) {
46
46
  const runId = `gw_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
@@ -57,7 +57,18 @@ export async function chat({ model = '', system = '', messages = [], options = {
57
57
  try {
58
58
  const res = await fetch(`${String(baseUrl).replace(/\/$/, '')}/v1/chat/completions`, {
59
59
  method: 'POST',
60
- headers: { 'content-type': 'application/json' },
60
+ headers: {
61
+ 'content-type': 'application/json',
62
+ // ChatPanel's routing metadata goes in HEADERS, never in the request body. It rode the
63
+ // body first and NVIDIA answered "unsupported parameters": OpenAI-compatible providers
64
+ // validate the body strictly and reject unknown fields, while ignoring unknown headers.
65
+ // The gateway strips x-chatpanel-* before forwarding, so the provider never sees it.
66
+ ...(options?.reach ? { 'x-chatpanel-reach': options.reach } : {}),
67
+ // WHICH provider, not just which model. A model id is not a unique key — two providers
68
+ // can serve the same one — so without this the gateway picks whichever destination it
69
+ // lists first and the call goes out on a key the user never chose.
70
+ ...(provider ? { 'x-chatpanel-destination': provider } : {}),
71
+ },
61
72
  body: JSON.stringify({
62
73
  model,
63
74
  stream: true,
@@ -65,9 +76,6 @@ export async function chat({ model = '', system = '', messages = [], options = {
65
76
  ...(system ? [{ role: 'system', content: system }] : []),
66
77
  ...messages.map((m) => ({ role: m.role, content: String(m.content ?? '') })),
67
78
  ],
68
- // Carried through so a capped phone's reach still reaches the router. The gateway
69
- // ignores what it does not know, which is what keeps an older gateway working.
70
- ...(options?.reach ? { chatpanel: { reach: options.reach } } : {}),
71
79
  }),
72
80
  signal: ac.signal,
73
81
  });
@@ -41,7 +41,8 @@ const TELEGRAM_API = 'https://api.telegram.org';
41
41
  // its own bridge backend, the same agents. They are mutually exclusive: update() clears one
42
42
  // when the other is set, because "which thing answers" is one choice, not two.
43
43
  export const DEFAULT_SETTINGS = Object.freeze({
44
- agent: 'claude', model: '', gatewayUrl: DEFAULT_GATEWAY_URL, privacy: 'standard', tier: 'basic',
44
+ agent: 'claude', model: '', provider: '', gatewayUrl: DEFAULT_GATEWAY_URL,
45
+ privacy: 'standard', tier: 'basic',
45
46
  });
46
47
 
47
48
  // Restart backoff. A long-poll that dies (network drop, laptop asleep, Telegram hiccup) must
@@ -131,13 +132,28 @@ export function createChannelService({
131
132
  function spawnLoop(botToken) {
132
133
  controller = new AbortController();
133
134
  running = true;
134
- // One choice, resolved here so the adapter never has to ask "which kind am I?".
135
- const viaGateway = !!settings.model;
135
+ // Resolved PER MESSAGE, not once at start. The loop owns every conversation's history and
136
+ // privacy vault, so restarting it to pick up a settings change threw away the thing the
137
+ // user came for. Now nothing needs a restart: the next message simply reads this.
138
+ const route = () => {
139
+ const viaGateway = !!settings.model;
140
+ return {
141
+ transport: viaGateway ? gateway : bridgeTransport,
142
+ agent: viaGateway ? '' : settings.agent,
143
+ model: viaGateway ? settings.model : '',
144
+ provider: viaGateway ? (settings.provider || '') : '',
145
+ baseUrl: viaGateway ? (settings.gatewayUrl || DEFAULT_GATEWAY_URL) : bridge.baseUrl,
146
+ system: settings.system || '',
147
+ privacy: settings.privacy,
148
+ };
149
+ };
136
150
  const done = startAdapter({
137
151
  botToken,
138
- transport: viaGateway ? gateway : bridgeTransport,
139
- model: viaGateway ? settings.model : '',
140
- baseUrl: viaGateway ? (settings.gatewayUrl || DEFAULT_GATEWAY_URL) : bridge.baseUrl,
152
+ route,
153
+ transport: route().transport,
154
+ model: route().model,
155
+ provider: route().provider,
156
+ baseUrl: route().baseUrl,
141
157
  token: bridge.token,
142
158
  pairing,
143
159
  savePairing,
@@ -247,17 +263,19 @@ export function createChannelService({
247
263
  async update(patch = {}) {
248
264
  await load();
249
265
  const next = { ...settings };
250
- for (const k of ['agent', 'model', 'gatewayUrl', 'privacy', 'tier', 'system']) {
266
+ for (const k of ['agent', 'model', 'provider', 'gatewayUrl', 'privacy', 'tier', 'system']) {
251
267
  if (patch[k] != null) next[k] = patch[k];
252
268
  }
253
269
  // Picking one target unpicks the other. Without this a stale `model` would silently win
254
270
  // over the agent the user just chose, and the screen would disagree with the machine.
255
- if (patch.agent != null && patch.model == null) next.model = '';
271
+ if (patch.agent != null && patch.model == null) { next.model = ''; next.provider = ''; }
256
272
  if (patch.model) next.agent = '';
257
273
  settings = next;
258
274
  await saveSettings();
259
- // Settings are read when the loop starts, so a change only lands on a restart.
260
- if (running) { await this.stop(); await start(); }
275
+ // NO RESTART. The loop reads settings per message through `route`, and restarting it
276
+ // would discard every conversation's history and vault — which is exactly what made
277
+ // changing the model answer the next question with "this looks like a fresh session".
278
+ // The only change that still needs a restart is a new bot token, and connect() does that.
261
279
  return { settings: { ...settings } };
262
280
  },
263
281
 
@@ -297,6 +315,7 @@ export function createChannelService({
297
315
  settings: {
298
316
  agent: settings.agent,
299
317
  model: settings.model || '',
318
+ provider: settings.provider || '',
300
319
  gatewayUrl: settings.gatewayUrl || DEFAULT_GATEWAY_URL,
301
320
  privacy: settings.privacy,
302
321
  tier: settings.tier,
package/src/server.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  // Hardcoded (not read from package.json) so it survives Bun's single-file
68
68
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
69
69
  // this drifts from package.json, so the two can't silently diverge.
70
- const VERSION = '0.11.5';
70
+ const VERSION = '0.11.7';
71
71
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
72
72
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
73
73