@nopeek/agent-bridge 0.6.3 → 0.7.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/dist/bot.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainBackend } from "./config.js";
2
+ import { type ChannelMode } from "./control.js";
2
3
  export interface BotInfo {
3
4
  userId: string;
4
5
  handle: string;
@@ -28,6 +29,8 @@ export declare class BotRunner {
28
29
  private ownerPresence;
29
30
  private mutedNoOwner;
30
31
  private runtimeOwnerId;
32
+ private channelModes;
33
+ private mutedOff;
31
34
  private chains;
32
35
  private cantPost;
33
36
  private log;
@@ -47,6 +50,17 @@ export declare class BotRunner {
47
50
  refreshAccess(): Promise<void>;
48
51
  /** Sender permitted to talk to this bot? Fail closed if access never loaded. */
49
52
  private isAllowed;
53
+ /** Merge live per-channel reply-mode updates (from a bot_config_changed
54
+ * control frame). Additive: only the listed channels change. */
55
+ applyChannelModes(modes: Record<string, ChannelMode>): void;
56
+ /** Regex matching an @-mention of this bot: the exact @handle (word-boundary,
57
+ * case-insensitive) plus the display nickname when we have one. The
58
+ * lookbehind keeps "user@handle.com" from counting as a mention. */
59
+ private mentionRegex;
60
+ /** "mention" mode: returns the text with the mention token(s) stripped (so
61
+ * prompts read naturally), or null when the bot is not mentioned. A message
62
+ * that is ONLY the mention ("@bot") passes through unstripped. */
63
+ private extractMention;
50
64
  /** The USER whose presence the owner-present policy requires: the bot's
51
65
  * owner for user-owned bots, else the user who paired this runtime. */
52
66
  private effectiveOwnerId;
package/dist/bot.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // for decrypted messages and answers through the resolved brain. Failures are
4
4
  // isolated — a broken bot retries with backoff and never takes down its peers.
5
5
  import { NoPeek } from "@nopeek/chat";
6
+ import { isChannelMode } from "./control.js";
6
7
  import { FALLBACK_REPLY, resolveBrain } from "./brain.js";
7
8
  import { FileStore } from "./storage.js";
8
9
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -43,6 +44,12 @@ export class BotRunner {
43
44
  // human is whoever paired the runtime that runs it. The access endpoint
44
45
  // reports that as runtimeOwnerUserId; the owner-present policy uses it.
45
46
  runtimeOwnerId;
47
+ // CHANNEL REPLY MODES (groups): per-channel "everyone" | "mention" | "off",
48
+ // loaded from the access endpoint and merged live from bot_config_changed
49
+ // frames. A channel absent from the map is "everyone". DIRECT channels always
50
+ // behave as "everyone" — a DM with the bot is always for the bot.
51
+ channelModes = new Map();
52
+ mutedOff = new Set(); // channels already logged as mode=off
46
53
  // Per-channel serialization: two messages in one channel must be answered in
47
54
  // order, one at a time — concurrent brain runs against the same agent session
48
55
  // (e.g. one Hermes session per channel) deadlock or reply out of order.
@@ -111,6 +118,14 @@ export class BotRunner {
111
118
  throw new Error(`access HTTP ${res.status}`);
112
119
  const j = (await res.json());
113
120
  this.allowed = new Set(j.allowedUserIds ?? []);
121
+ // Channel reply modes: the access response is a full snapshot — replace
122
+ // the map (a channel the server no longer lists reverts to "everyone").
123
+ const modes = new Map();
124
+ for (const [channelId, mode] of Object.entries(j.channelModes ?? {})) {
125
+ if (isChannelMode(mode))
126
+ modes.set(channelId, mode);
127
+ }
128
+ this.channelModes = modes;
114
129
  if (j.runtimeOwnerUserId)
115
130
  this.runtimeOwnerId = j.runtimeOwnerUserId;
116
131
  // The access response is authoritative on ownership — adopt it so the
@@ -121,7 +136,7 @@ export class BotRunner {
121
136
  this.info.ownerType = j.ownerType ?? this.info.ownerType;
122
137
  }
123
138
  this.accessLoaded = true;
124
- this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s)`);
139
+ this.log(`access: policy=${j.policy ?? "private"}, ${this.allowed.size} allowed sender(s), ${this.channelModes.size} channel mode(s)`);
125
140
  }
126
141
  catch (err) {
127
142
  this.logErr(`access refresh failed: ${err.message} (keeping last-known allow list)`);
@@ -135,6 +150,53 @@ export class BotRunner {
135
150
  return false; // fail closed until we know the list
136
151
  return this.allowed.has(senderUserId);
137
152
  }
153
+ /** Merge live per-channel reply-mode updates (from a bot_config_changed
154
+ * control frame). Additive: only the listed channels change. */
155
+ applyChannelModes(modes) {
156
+ for (const [channelId, mode] of Object.entries(modes)) {
157
+ if (!isChannelMode(mode))
158
+ continue;
159
+ const prev = this.channelModes.get(channelId) ?? "everyone";
160
+ if (mode === "everyone")
161
+ this.channelModes.delete(channelId); // absent = everyone
162
+ else
163
+ this.channelModes.set(channelId, mode);
164
+ if (prev !== mode) {
165
+ this.mutedOff.delete(channelId); // transitioning into "off" re-logs once
166
+ this.log(`reply mode for ${channelId}: ${prev} -> ${mode}`);
167
+ }
168
+ }
169
+ }
170
+ /** Regex matching an @-mention of this bot: the exact @handle (word-boundary,
171
+ * case-insensitive) plus the display nickname when we have one. The
172
+ * lookbehind keeps "user@handle.com" from counting as a mention. */
173
+ mentionRegex() {
174
+ const names = [this.info.handle, this.info.nickname]
175
+ .filter((n) => typeof n === "string" && !!n.trim())
176
+ .map((n) => n.trim().replace(/^@/, ""))
177
+ .map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
178
+ if (names.length === 0)
179
+ return null;
180
+ return new RegExp(`(?<![\\w@])@(?:${names.join("|")})\\b`, "gi");
181
+ }
182
+ /** "mention" mode: returns the text with the mention token(s) stripped (so
183
+ * prompts read naturally), or null when the bot is not mentioned. A message
184
+ * that is ONLY the mention ("@bot") passes through unstripped. */
185
+ extractMention(text) {
186
+ const re = this.mentionRegex();
187
+ if (!re)
188
+ return null;
189
+ if (!re.test(text))
190
+ return null;
191
+ re.lastIndex = 0;
192
+ const stripped = text
193
+ .replace(re, " ")
194
+ .replace(/^[\s,:;!—–-]+/, "")
195
+ .replace(/[ \t]+([,.:;!?])/g, "$1")
196
+ .replace(/[ \t]{2,}/g, " ")
197
+ .trim();
198
+ return stripped || text.trim();
199
+ }
138
200
  /** The USER whose presence the owner-present policy requires: the bot's
139
201
  * owner for user-owned bots, else the user who paired this runtime. */
140
202
  effectiveOwnerId() {
@@ -356,9 +418,32 @@ export class BotRunner {
356
418
  }
357
419
  return;
358
420
  }
359
- const text = m.body.text;
360
- this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
361
421
  const ch = await this.getChannel(m.channelId);
422
+ // CHANNEL REPLY MODE (after the owner-present + access gates): the owner
423
+ // picks per group whether the bot answers everyone, only @mentions, or
424
+ // nobody. The rules apply to ALL allowed senders including the owner —
425
+ // predictable: mention-only means even the owner must @mention there.
426
+ // DIRECT channels always behave as "everyone" (a DM with the bot is always
427
+ // for the bot).
428
+ let text = m.body.text;
429
+ if (ch.record.kind !== "direct" && ch.record.kind !== "dm") {
430
+ const mode = this.channelModes.get(m.channelId) ?? "everyone";
431
+ if (mode === "off") {
432
+ if (!this.mutedOff.has(m.channelId)) {
433
+ this.mutedOff.add(m.channelId);
434
+ this.log(`muting ${m.channelId} — reply mode is "off"; staying silent (no brain runs)`);
435
+ }
436
+ return;
437
+ }
438
+ this.mutedOff.delete(m.channelId);
439
+ if (mode === "mention") {
440
+ const stripped = this.extractMention(text);
441
+ if (stripped === null)
442
+ return; // not addressed to the bot — silent, no brain run
443
+ text = stripped;
444
+ }
445
+ }
446
+ this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
362
447
  ch.markRead(m.messageId).catch(() => { });
363
448
  try {
364
449
  ch.typing(true);
package/dist/bridge.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { BridgeConfig, Pairing, BrainSpec, BrainBackend } from "./config.js";
2
- export declare const VERSION = "0.6.3";
2
+ export declare const VERSION = "0.7.0";
3
3
  export interface PairRequest {
4
4
  pairingSecret: string;
5
5
  appId: string;
package/dist/bridge.js CHANGED
@@ -17,7 +17,7 @@ import { resolveBrain } from "./brain.js";
17
17
  import { provisionSoul, provisionHermesProfile } from "./backends.js";
18
18
  import { reportCapabilities } from "./capabilities.js";
19
19
  import { isBrainBackend } from "./config.js";
20
- export const VERSION = "0.6.3";
20
+ export const VERSION = "0.7.0";
21
21
  /** How often to re-probe + report brain availability to the server. */
22
22
  const CAPABILITIES_INTERVAL_MS = 5 * 60_000;
23
23
  export class PairError extends Error {
@@ -90,18 +90,24 @@ class PairingRuntime {
90
90
  void runner.refreshAccess();
91
91
  },
92
92
  onBotConfig: (f) => {
93
- // The owner changed this bot's brain from the phone. Record the server
94
- // backend and re-resolve live the runner resolves per message off the
95
- // shared cfg, so the NEXT message already uses it; we also refresh the
96
- // cached kind so status reflects it immediately. A running bot is keyed
97
- // by userId; map it to a handle via its runner.
93
+ // The owner changed this bot's config from the phone. A running bot is
94
+ // keyed by userId; map it to a handle via its runner.
98
95
  const runner = this.bots.get(f.botUserId);
99
96
  if (!runner) {
100
97
  console.log(`${this.tag} bot_config_changed for ${f.botUserId} not running yet — applied on next sync/adopt`);
101
98
  return;
102
99
  }
103
- this.app.applyServerBackend(runner.info.handle, f.backend);
104
- runner.refreshBrainKind();
100
+ if (f.backend) {
101
+ // Record the server backend and re-resolve live — the runner resolves
102
+ // per message off the shared cfg, so the NEXT message already uses
103
+ // it; also refresh the cached kind so status reflects it immediately.
104
+ this.app.applyServerBackend(runner.info.handle, f.backend);
105
+ runner.refreshBrainKind();
106
+ }
107
+ // Per-channel reply modes ride the same frame (additive) — merge them
108
+ // into the runner so the very next message obeys the new mode.
109
+ if (f.channelModes)
110
+ runner.applyChannelModes(f.channelModes);
105
111
  },
106
112
  });
107
113
  // Bots first (so a control-socket hiccup doesn't delay serving), then the
package/dist/control.d.ts CHANGED
@@ -8,10 +8,16 @@ export interface AdoptBotFrame {
8
8
  /** Server-mediated backend picked from the phone (optional). */
9
9
  backend?: BrainBackend;
10
10
  }
11
+ /** Per-channel reply mode for a bot in a group. Absent channel = "everyone". */
12
+ export type ChannelMode = "everyone" | "mention" | "off";
13
+ export declare function isChannelMode(v: unknown): v is ChannelMode;
11
14
  export interface BotConfigChangedFrame {
12
15
  type: "bot_config_changed";
13
16
  botUserId: string;
14
- backend: BrainBackend;
17
+ /** Brain backend change (may be absent when only channelModes changed). */
18
+ backend?: BrainBackend;
19
+ /** Additive per-channel reply-mode updates: {channelId: mode}. */
20
+ channelModes?: Record<string, ChannelMode>;
15
21
  }
16
22
  export interface BotGrantChangedFrame {
17
23
  type: "bot_grant_changed";
package/dist/control.js CHANGED
@@ -1,4 +1,7 @@
1
1
  import { isBrainBackend } from "./config.js";
2
+ export function isChannelMode(v) {
3
+ return v === "everyone" || v === "mention" || v === "off";
4
+ }
2
5
  export class ControlSocket {
3
6
  connected = false;
4
7
  runtimeId = null;
@@ -86,11 +89,19 @@ export class ControlSocket {
86
89
  }
87
90
  case "bot_config_changed": {
88
91
  const f = frame;
89
- if (!isBrainBackend(f.backend)) {
90
- console.error(`${this.tag} bot_config_changed with invalid backend "${String(f.backend)}" ignored`);
91
- return;
92
+ // backend and channelModes are each optional and additive — drop
93
+ // whichever is invalid, forward the frame if anything useful remains.
94
+ if (f.backend !== undefined && !isBrainBackend(f.backend)) {
95
+ console.error(`${this.tag} bot_config_changed with invalid backend "${String(f.backend)}" — backend ignored`);
96
+ delete f.backend;
97
+ }
98
+ if (f.channelModes !== undefined && (typeof f.channelModes !== "object" || f.channelModes === null || Array.isArray(f.channelModes))) {
99
+ console.error(`${this.tag} bot_config_changed with malformed channelModes ignored`);
100
+ delete f.channelModes;
92
101
  }
93
- console.log(`${this.tag} bot_config_changed bot=${f.botUserId} backend=${f.backend}`);
102
+ if (f.backend === undefined && f.channelModes === undefined)
103
+ return; // nothing valid left
104
+ console.log(`${this.tag} bot_config_changed bot=${f.botUserId}${f.backend ? ` backend=${f.backend}` : ""}${f.channelModes ? ` channelModes(${Object.keys(f.channelModes).length})` : ""}`);
94
105
  this.handlers.onBotConfig(f);
95
106
  return;
96
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nopeek/agent-bridge",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Run your own agents as E2EE NoPeek bots. Pairs with one-time codes (multiple accounts per computer), runs every bot each account owns, and pipes messages to any command or webhook.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,7 +23,7 @@
23
23
  "node": ">=22"
24
24
  },
25
25
  "dependencies": {
26
- "@nopeek/chat": "0.2.4"
26
+ "@nopeek/chat": "0.2.5"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^22.10.0",