@nopeek/agent-bridge 0.6.2 → 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;
@@ -27,6 +28,9 @@ export declare class BotRunner {
27
28
  private declined;
28
29
  private ownerPresence;
29
30
  private mutedNoOwner;
31
+ private runtimeOwnerId;
32
+ private channelModes;
33
+ private mutedOff;
30
34
  private chains;
31
35
  private cantPost;
32
36
  private log;
@@ -46,6 +50,20 @@ export declare class BotRunner {
46
50
  refreshAccess(): Promise<void>;
47
51
  /** Sender permitted to talk to this bot? Fail closed if access never loaded. */
48
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;
64
+ /** The USER whose presence the owner-present policy requires: the bot's
65
+ * owner for user-owned bots, else the user who paired this runtime. */
66
+ private effectiveOwnerId;
49
67
  /** Is the bot's owner currently a member of this channel? Server-checked via
50
68
  * the channel roster (the bot is a member, so it may list members), cached
51
69
  * for a short TTL, invalidated by member.joined/left. On a fetch failure we
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));
@@ -39,6 +40,16 @@ export class BotRunner {
39
40
  // server per channel and cached briefly; member.joined/left invalidate it.
40
41
  ownerPresence = new Map();
41
42
  mutedNoOwner = new Set(); // channels already logged as owner-absent
43
+ // For a workspace-owned bot there is no single owner USER — the accountable
44
+ // human is whoever paired the runtime that runs it. The access endpoint
45
+ // reports that as runtimeOwnerUserId; the owner-present policy uses it.
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
42
53
  // Per-channel serialization: two messages in one channel must be answered in
43
54
  // order, one at a time — concurrent brain runs against the same agent session
44
55
  // (e.g. one Hermes session per channel) deadlock or reply out of order.
@@ -107,6 +118,16 @@ export class BotRunner {
107
118
  throw new Error(`access HTTP ${res.status}`);
108
119
  const j = (await res.json());
109
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;
129
+ if (j.runtimeOwnerUserId)
130
+ this.runtimeOwnerId = j.runtimeOwnerUserId;
110
131
  // The access response is authoritative on ownership — adopt it so the
111
132
  // owner-present policy always knows who the owner is, even when the
112
133
  // runtime/bots listing omitted the fields.
@@ -115,7 +136,7 @@ export class BotRunner {
115
136
  this.info.ownerType = j.ownerType ?? this.info.ownerType;
116
137
  }
117
138
  this.accessLoaded = true;
118
- 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)`);
119
140
  }
120
141
  catch (err) {
121
142
  this.logErr(`access refresh failed: ${err.message} (keeping last-known allow list)`);
@@ -129,12 +150,66 @@ export class BotRunner {
129
150
  return false; // fail closed until we know the list
130
151
  return this.allowed.has(senderUserId);
131
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
+ }
200
+ /** The USER whose presence the owner-present policy requires: the bot's
201
+ * owner for user-owned bots, else the user who paired this runtime. */
202
+ effectiveOwnerId() {
203
+ if (this.info.ownerType === "workspace")
204
+ return this.runtimeOwnerId;
205
+ return this.info.ownerId ?? this.runtimeOwnerId;
206
+ }
132
207
  /** Is the bot's owner currently a member of this channel? Server-checked via
133
208
  * the channel roster (the bot is a member, so it may list members), cached
134
209
  * for a short TTL, invalidated by member.joined/left. On a fetch failure we
135
210
  * keep the last-known answer; with no known answer we FAIL CLOSED. */
136
211
  async ownerIsChannelMember(channelId) {
137
- const ownerId = this.info.ownerId;
212
+ const ownerId = this.effectiveOwnerId();
138
213
  if (!ownerId)
139
214
  return false; // unknown owner → fail closed
140
215
  const cached = this.ownerPresence.get(channelId);
@@ -214,7 +289,7 @@ export class BotRunner {
214
289
  if (!p?.channelId)
215
290
  return;
216
291
  this.ownerPresence.delete(p.channelId);
217
- if (p.userId && p.userId === this.info.ownerId) {
292
+ if (p.userId && p.userId === this.effectiveOwnerId()) {
218
293
  this.mutedNoOwner.delete(p.channelId); // re-log if the owner leaves again later
219
294
  this.log(`owner membership changed in ${p.channelId} — presence cache invalidated`);
220
295
  }
@@ -311,15 +386,16 @@ export class BotRunner {
311
386
  // OWNER-PRESENT POLICY: in a multi-party channel (anything but a direct
312
387
  // chat), a non-owner sender may use the bot ONLY while the bot's owner is
313
388
  // also a member of that channel. Owner absent → completely silent (no
314
- // brain run, not even the decline notice), regardless of grants. Workspace
315
- // bots are exempt they have no single owner user to require present.
316
- if (m.senderUserId !== this.info.ownerId && this.info.ownerType !== "workspace") {
389
+ // brain run, not even the decline notice), regardless of grants. For a
390
+ // workspace bot "owner" means the user who paired this runtime.
391
+ const effOwner = this.effectiveOwnerId();
392
+ if (m.senderUserId !== effOwner) {
317
393
  const ch = await this.getChannel(m.channelId);
318
394
  if (ch.record.kind !== "direct") {
319
395
  if (!(await this.ownerIsChannelMember(m.channelId))) {
320
396
  if (!this.mutedNoOwner.has(m.channelId)) {
321
397
  this.mutedNoOwner.add(m.channelId);
322
- this.log(`muting ${m.channelId} — owner ${this.info.ownerId ?? "?"} is not a member (owner-present policy); staying silent`);
398
+ this.log(`muting ${m.channelId} — owner ${effOwner ?? "?"} is not a member (owner-present policy); staying silent`);
323
399
  }
324
400
  return;
325
401
  }
@@ -342,9 +418,32 @@ export class BotRunner {
342
418
  }
343
419
  return;
344
420
  }
345
- const text = m.body.text;
346
- this.log(`${m.channelId} <- ${m.senderUserId}: ${text.slice(0, 120)}`);
347
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)}`);
348
447
  ch.markRead(m.messageId).catch(() => { });
349
448
  try {
350
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.2";
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.2";
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.2",
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",