@rubytech/taskmaster 1.16.2 → 1.17.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.
Files changed (47) hide show
  1. package/dist/agents/taskmaster-tools.js +1 -1
  2. package/dist/agents/tools/logs-read-tool.js +9 -0
  3. package/dist/agents/tools/memory-tool.js +1 -0
  4. package/dist/agents/tools/qr-generate-tool.js +7 -3
  5. package/dist/auto-reply/group-activation.js +2 -0
  6. package/dist/auto-reply/reply/commands-session.js +28 -11
  7. package/dist/build-info.json +3 -3
  8. package/dist/config/group-policy.js +16 -0
  9. package/dist/config/zod-schema.providers-whatsapp.js +2 -0
  10. package/dist/control-ui/assets/{index-Bd75cI7J.js → index-Beuhzjy_.js} +525 -492
  11. package/dist/control-ui/assets/index-Beuhzjy_.js.map +1 -0
  12. package/dist/control-ui/assets/index-XqRo9tNW.css +1 -0
  13. package/dist/control-ui/index.html +2 -2
  14. package/dist/cron/preloaded.js +27 -23
  15. package/dist/gateway/protocol/index.js +7 -2
  16. package/dist/gateway/protocol/schema/logs-chat.js +6 -0
  17. package/dist/gateway/protocol/schema/protocol-schemas.js +6 -0
  18. package/dist/gateway/protocol/schema/sessions-transcript.js +1 -0
  19. package/dist/gateway/protocol/schema/sessions.js +6 -1
  20. package/dist/gateway/protocol/schema/whatsapp.js +24 -0
  21. package/dist/gateway/protocol/schema.js +1 -0
  22. package/dist/gateway/public-chat/session-token.js +52 -0
  23. package/dist/gateway/public-chat-api.js +40 -13
  24. package/dist/gateway/server-methods/logs.js +17 -1
  25. package/dist/gateway/server-methods/public-chat.js +5 -0
  26. package/dist/gateway/server-methods/sessions-transcript.js +30 -6
  27. package/dist/gateway/server-methods/whatsapp-conversations.js +387 -0
  28. package/dist/gateway/server-methods-list.js +6 -0
  29. package/dist/gateway/server-methods.js +7 -0
  30. package/dist/gateway/server.impl.js +3 -1
  31. package/dist/gateway/sessions-patch.js +1 -1
  32. package/dist/hooks/bundled/ride-dispatch/HOOK.md +7 -6
  33. package/dist/hooks/bundled/ride-dispatch/handler.js +75 -30
  34. package/dist/memory/manager.js +3 -3
  35. package/dist/tui/tui-command-handlers.js +1 -1
  36. package/dist/web/auto-reply/monitor/group-activation.js +12 -10
  37. package/dist/web/auto-reply/monitor/group-gating.js +23 -2
  38. package/dist/web/auto-reply/monitor/on-message.js +27 -5
  39. package/dist/web/auto-reply/monitor/process-message.js +64 -53
  40. package/dist/web/inbound/monitor.js +30 -0
  41. package/extensions/whatsapp/src/channel.ts +1 -1
  42. package/package.json +1 -1
  43. package/skills/log-review/SKILL.md +17 -4
  44. package/skills/log-review/references/review-protocol.md +4 -4
  45. package/taskmaster-docs/USER-GUIDE.md +14 -0
  46. package/dist/control-ui/assets/index-Bd75cI7J.js.map +0 -1
  47. package/dist/control-ui/assets/index-BkymP95Y.css +0 -1
@@ -95,6 +95,12 @@ const BASE_METHODS = [
95
95
  "chat.history",
96
96
  "chat.abort",
97
97
  "chat.send",
98
+ // WhatsApp conversation browser
99
+ "whatsapp.conversations",
100
+ "whatsapp.messages",
101
+ "whatsapp.groupInfo",
102
+ "whatsapp.setActivation",
103
+ "whatsapp.sendMessage",
98
104
  ];
99
105
  export function listGatewayMethods() {
100
106
  const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
@@ -40,6 +40,7 @@ import { wifiHandlers } from "./server-methods/wifi.js";
40
40
  import { workspacesHandlers } from "./server-methods/workspaces.js";
41
41
  import { brandHandlers } from "./server-methods/brand.js";
42
42
  import { businessHandlers } from "./server-methods/business.js";
43
+ import { whatsappConversationsHandlers } from "./server-methods/whatsapp-conversations.js";
43
44
  const ADMIN_SCOPE = "operator.admin";
44
45
  const READ_SCOPE = "operator.read";
45
46
  const WRITE_SCOPE = "operator.write";
@@ -109,6 +110,9 @@ const READ_METHODS = new Set([
109
110
  "memory.audit",
110
111
  "qr.generate",
111
112
  "business.openingHours.get",
113
+ "whatsapp.conversations",
114
+ "whatsapp.messages",
115
+ "whatsapp.groupInfo",
112
116
  ]);
113
117
  const WRITE_METHODS = new Set([
114
118
  "send",
@@ -124,6 +128,8 @@ const WRITE_METHODS = new Set([
124
128
  "node.invoke",
125
129
  "chat.send",
126
130
  "chat.abort",
131
+ "whatsapp.setActivation",
132
+ "whatsapp.sendMessage",
127
133
  ]);
128
134
  function authorizeGatewayMethod(method, client) {
129
135
  // Access methods bypass all scope checks — needed before PIN login
@@ -250,6 +256,7 @@ export const coreGatewayHandlers = {
250
256
  ...networkHandlers,
251
257
  ...tailscaleHandlers,
252
258
  ...wifiHandlers,
259
+ ...whatsappConversationsHandlers,
253
260
  };
254
261
  export async function handleGatewayRequest(opts) {
255
262
  const { req, respond, client, isWebchatConnect, context } = opts;
@@ -475,11 +475,13 @@ export async function startGatewayServer(port = 18789, opts = {}) {
475
475
  if (!bundledSkillsDir)
476
476
  return;
477
477
  try {
478
+ const workspaceIds = Object.keys(cfgAtStart.workspaces ?? {});
479
+ const accountIds = workspaceIds.length > 0 ? workspaceIds : [DEFAULT_ACCOUNT_ID];
478
480
  const seeded = await seedPreloadedCronJobs({
479
481
  bundledSkillsDir,
480
482
  trackerPath: DEFAULT_SEED_TRACKER_PATH,
481
483
  cronService: cron,
482
- defaultAccountId: DEFAULT_ACCOUNT_ID,
484
+ accountIds,
483
485
  });
484
486
  if (seeded > 0) {
485
487
  logCron.info(`cron: seeded ${seeded} preloaded job(s)`);
@@ -274,7 +274,7 @@ export async function applySessionsPatchToStore(params) {
274
274
  else if (raw !== undefined) {
275
275
  const normalized = normalizeGroupActivation(String(raw));
276
276
  if (!normalized) {
277
- return invalid('invalid groupActivation (use "mention"|"always")');
277
+ return invalid('invalid groupActivation (use "mention"|"always"|"off")');
278
278
  }
279
279
  next.groupActivation = normalized;
280
280
  }
@@ -7,7 +7,7 @@ metadata:
7
7
  "taskmaster":
8
8
  {
9
9
  "emoji": "🚕",
10
- "events": ["memory:add", "message:inbound"],
10
+ "events": ["memory:add", "message:before-dispatch"],
11
11
  "requires": { "config": ["workspace.dir"] },
12
12
  "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Taskmaster" }],
13
13
  },
@@ -28,13 +28,13 @@ When the public agent writes a file matching `shared/dispatch/{jobId}-{phase}.md
28
28
  2. **Dispatches to admin agent** in a session scoped to the booking (`ride-{jobId}`)
29
29
  3. **Admin agent autonomously processes** — contacts drivers, generates payment links, or finalises bookings
30
30
 
31
- ### Driver Replies (message:inbound)
31
+ ### Driver Replies (message:before-dispatch)
32
32
 
33
- When a driver replies on WhatsApp (routed to the public agent's DM session):
33
+ Before a WhatsApp DM is dispatched to the public agent's LLM:
34
34
 
35
35
  1. **Checks the active negotiation index** at `shared/active-negotiations/{phone}.md`
36
- 2. **If the driver has an active negotiation**, dispatches their message to the admin's ride session for that job
37
- 3. **If no active negotiation**, ignores the message (normal public agent handling)
36
+ 2. **If the driver has an active negotiation**, sets `event.suppress = true` and dispatches the reply to the admin's ride session the public agent never sees the message
37
+ 3. **If no active negotiation**, does nothing — the message proceeds normally to the public agent
38
38
 
39
39
  ## Why This Exists
40
40
 
@@ -44,7 +44,8 @@ The public agent must not have `contact_lookup` or `message` tools — exposing
44
44
 
45
45
  - Only fires for **Beagle Zanzibar agents** (detected by `beagle` in agent ID)
46
46
  - **Deduplicates** — same dispatch file within 30 seconds is ignored
47
- - **Non-blocking** — dispatch is fire-and-forget so neither agent's reply is delayed
47
+ - **Driver suppression** — driver replies set `event.suppress = true` so the public agent's LLM is never invoked for driver messages
48
+ - **Non-blocking** — admin dispatch is fire-and-forget after suppression is set
48
49
  - Admin agent uses `contact_lookup` → `message` → `memory_write` for driver outreach
49
50
  - Admin agent uses `message` to inject results (offers, payment links, driver details) into tourist's conversation via cross-agent echo
50
51
 
@@ -86,25 +86,54 @@ function extractPeerFromSessionKey(sessionKey) {
86
86
  return null;
87
87
  }
88
88
  /**
89
- * Find the admin agent ID from config.
90
- * The admin agent is the one marked `default: true`.
89
+ * Find the sibling admin agent for a given public agent.
90
+ *
91
+ * Convention: if the public agent is `beagle-zanzibar-public`, the admin is
92
+ * `beagle-zanzibar-admin`. Falls back to any admin agent that shares the same
93
+ * Beagle prefix, then the global default.
91
94
  */
92
- function findAdminAgentId(cfg) {
95
+ function findSiblingAdminAgentId(cfg, publicAgentId) {
93
96
  const agents = cfg.agents?.list ?? [];
97
+ // Derive sibling: beagle-zanzibar-public → beagle-zanzibar-admin
98
+ if (publicAgentId.endsWith("-public")) {
99
+ const siblingId = publicAgentId.replace(/-public$/, "-admin");
100
+ if (agents.some((a) => a.id === siblingId))
101
+ return siblingId;
102
+ }
103
+ // Fallback: any admin agent sharing a Beagle prefix
104
+ const prefix = publicAgentId.replace(/-public$/, "");
105
+ const match = agents.find((a) => a.id?.startsWith(prefix) && a.id?.endsWith("-admin"));
106
+ if (match?.id)
107
+ return match.id;
108
+ // Last resort: global default
94
109
  const admin = agents.find((a) => a.default === true);
95
110
  if (admin?.id)
96
111
  return admin.id;
97
- const defaultId = resolveDefaultAgentId(cfg);
98
- return defaultId || null;
112
+ return resolveDefaultAgentId(cfg) || null;
113
+ }
114
+ /**
115
+ * Find the admin agent for a given account ID (used by payment webhooks
116
+ * where there's no public agent context). Convention: accountId
117
+ * "beagle-zanzibar" → admin agent "beagle-zanzibar-admin".
118
+ */
119
+ function findAdminAgentForAccount(cfg, accountId) {
120
+ const agents = cfg.agents?.list ?? [];
121
+ const candidateId = `${accountId}-admin`;
122
+ if (agents.some((a) => a.id === candidateId))
123
+ return candidateId;
124
+ // Fallback: any admin agent whose ID starts with the account prefix
125
+ const match = agents.find((a) => a.id?.startsWith(accountId) && a.id?.endsWith("-admin"));
126
+ if (match?.id)
127
+ return match.id;
128
+ return resolveDefaultAgentId(cfg) || null;
99
129
  }
100
130
  /** Check if an agent ID belongs to a Beagle instance. */
101
131
  function isBeagleAgent(agentId) {
102
132
  return Boolean(agentId && agentId.toLowerCase().includes("beagle"));
103
133
  }
104
- /** Check if an agent is the public (non-admin) agent. */
105
- function isPublicAgentConfig(cfg, agentId) {
106
- const agent = cfg.agents?.list?.find((a) => a.id === agentId);
107
- return agent?.default !== true;
134
+ /** Check if an agent is a public (non-admin) agent by naming convention. */
135
+ function isPublicAgent(agentId) {
136
+ return agentId.endsWith("-public");
108
137
  }
109
138
  /**
110
139
  * Read the active negotiation index file for a driver phone.
@@ -140,6 +169,7 @@ async function dispatchToAdmin(params) {
140
169
  agentId: adminAgentId,
141
170
  channel: "system",
142
171
  peer: { kind: "dm", id: `ride-${jobId.toLowerCase()}` },
172
+ dmScope: "per-peer",
143
173
  }).toLowerCase();
144
174
  const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
145
175
  const envelope = formatInboundEnvelope({
@@ -189,7 +219,7 @@ async function dispatchToAdmin(params) {
189
219
  // ---------------------------------------------------------------------------
190
220
  // Build instruction text for each dispatch phase
191
221
  // ---------------------------------------------------------------------------
192
- function buildTripRequestInstruction(fields) {
222
+ function buildTripRequestInstruction(fields, resolvedAccountId) {
193
223
  const jobId = fields.job_id ?? "UNKNOWN";
194
224
  const touristPhone = fields.tourist_phone ?? "unknown";
195
225
  const touristName = fields.tourist_name ?? "Tourist";
@@ -201,7 +231,7 @@ function buildTripRequestInstruction(fields) {
201
231
  const luggage = fields.luggage ?? "none";
202
232
  const specialRequests = fields.special_requests ?? "none";
203
233
  const fareEstimate = fields.fare_estimate ?? "unknown";
204
- const accountId = fields.account_id ?? "default";
234
+ const accountId = resolvedAccountId;
205
235
  return (`[System: Ride Dispatch — Trip Request]\n\n` +
206
236
  `A tourist has requested a ride. Process this by contacting available drivers.\n\n` +
207
237
  `Job ID: ${jobId}\n` +
@@ -230,13 +260,13 @@ function buildTripRequestInstruction(fields) {
230
260
  `9. Message the tourist with up to 3 competing offers: fare, driver rating, vehicle type, estimated journey time\n` +
231
261
  ` Do NOT reveal driver name, phone, or plate — those are gated by payment\n`);
232
262
  }
233
- function buildBookingConfirmInstruction(fields) {
263
+ function buildBookingConfirmInstruction(fields, resolvedAccountId) {
234
264
  const jobId = fields.job_id ?? "UNKNOWN";
235
265
  const touristPhone = fields.tourist_phone ?? "unknown";
236
266
  const driverName = fields.driver_name ?? "unknown";
237
267
  const driverPhone = fields.driver_phone ?? "unknown";
238
268
  const fare = fields.fare ?? "unknown";
239
- const accountId = fields.account_id ?? "default";
269
+ const accountId = resolvedAccountId;
240
270
  return (`[System: Ride Dispatch — Booking Confirmation]\n\n` +
241
271
  `The tourist has selected a driver. Generate a Stripe payment link and send it.\n\n` +
242
272
  `Job ID: ${jobId}\n` +
@@ -290,8 +320,8 @@ async function handleMemoryAdd(event) {
290
320
  return;
291
321
  const jobId = match[1]; // e.g. BGL-0042
292
322
  const phase = match[2]; // trip-request | booking-confirm
293
- // Dedup
294
- const dedupKey = `dispatch:${relativePath}`;
323
+ // Dedup (use jobId+phase, not relativePath, since multiple watchers report different paths)
324
+ const dedupKey = `dispatch:${jobId}:${phase}`;
295
325
  if (isDuplicate(dedupKey))
296
326
  return;
297
327
  // Read the dispatch file
@@ -317,18 +347,21 @@ async function handleMemoryAdd(event) {
317
347
  console.warn("[ride-dispatch] Could not read config");
318
348
  return;
319
349
  }
320
- const adminAgentId = findAdminAgentId(cfg);
350
+ const adminAgentId = findSiblingAdminAgentId(cfg, agentId);
321
351
  if (!adminAgentId) {
322
- console.warn("[ride-dispatch] No admin agent found in config");
352
+ console.warn("[ride-dispatch] No admin agent found for public agent:", agentId);
323
353
  return;
324
354
  }
325
- const accountId = fields.account_id ?? resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? "default";
355
+ // Resolve account: binding first, then derive from agent ID prefix
356
+ // (beagle-zanzibar-public → beagle-zanzibar), which matches WhatsApp account naming.
357
+ const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ??
358
+ (agentId.replace(/-(public|admin)$/, "") || "default");
326
359
  let instruction;
327
360
  if (phase === "trip-request") {
328
- instruction = buildTripRequestInstruction(fields);
361
+ instruction = buildTripRequestInstruction(fields, accountId);
329
362
  }
330
363
  else if (phase === "booking-confirm") {
331
- instruction = buildBookingConfirmInstruction(fields);
364
+ instruction = buildBookingConfirmInstruction(fields, accountId);
332
365
  }
333
366
  else {
334
367
  return;
@@ -347,7 +380,13 @@ async function handleMemoryAdd(event) {
347
380
  });
348
381
  }
349
382
  /**
350
- * Handle message:inbound events — driver replies routed to the public agent.
383
+ * Handle message:before-dispatch events — intercept driver replies before they
384
+ * reach the public agent's LLM.
385
+ *
386
+ * Sets event.suppress = true when the sender is a driver in active negotiation,
387
+ * then dispatches the reply to the admin agent's ride session instead.
388
+ * Because this handler is awaited by the caller, suppress takes effect before
389
+ * processForRoute is invoked.
351
390
  */
352
391
  async function handleDriverReply(event) {
353
392
  const context = event.context || {};
@@ -359,7 +398,7 @@ async function handleDriverReply(event) {
359
398
  const agentId = resolveAgentIdFromSessionKey(event.sessionKey);
360
399
  if (!agentId || !isBeagleAgent(agentId))
361
400
  return;
362
- if (!isPublicAgentConfig(cfg, agentId))
401
+ if (!isPublicAgent(agentId))
363
402
  return;
364
403
  // Extract sender phone from session key
365
404
  const senderPhone = extractPeerFromSessionKey(event.sessionKey);
@@ -367,18 +406,22 @@ async function handleDriverReply(event) {
367
406
  return;
368
407
  // Resolve workspace dir from the agent config to find active-negotiations
369
408
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
370
- // Check active negotiation index
409
+ // Check active negotiation index (synchronous — must complete before we return
410
+ // so that event.suppress is set before the caller checks it)
371
411
  const jobId = readActiveNegotiation(workspaceDir, senderPhone);
372
412
  if (!jobId)
373
- return; // Not a driver in active negotiation
413
+ return; // Not a driver in active negotiation — let public agent handle it
414
+ // Suppress delivery to the public agent before doing anything else
415
+ event.suppress = true;
374
416
  // Dedup
375
417
  const dedupKey = `reply:${senderPhone}:${Date.now().toString().slice(0, -4)}`; // ~10s granularity
376
418
  if (isDuplicate(dedupKey))
377
419
  return;
378
- const adminAgentId = findAdminAgentId(cfg);
420
+ const adminAgentId = findSiblingAdminAgentId(cfg, agentId);
379
421
  if (!adminAgentId)
380
422
  return;
381
- const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? "default";
423
+ const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ??
424
+ (agentId.replace(/-(public|admin)$/, "") || "default");
382
425
  const instruction = `[System: Ride Dispatch — Driver Reply]\n\n` +
383
426
  `A driver has replied regarding job ${jobId}.\n\n` +
384
427
  `Driver phone: ${senderPhone}\n` +
@@ -387,7 +430,7 @@ async function handleDriverReply(event) {
387
430
  `If this is a fare quote, note it and compile offers when ready.\n` +
388
431
  `If the driver is declining, update their status and active negotiation index.\n`;
389
432
  console.log(`[ride-dispatch] Driver reply from ${senderPhone} for ${jobId}, dispatching to admin agent "${adminAgentId}"`);
390
- // Fire and forget
433
+ // Fire and forget — suppress is already set, caller will skip processForRoute
391
434
  dispatchToAdmin({
392
435
  cfg,
393
436
  adminAgentId,
@@ -420,9 +463,11 @@ export async function dispatchPaymentConfirmed(params) {
420
463
  console.warn("[ride-dispatch] Could not read config for payment dispatch");
421
464
  return;
422
465
  }
423
- const adminAgentId = findAdminAgentId(cfg);
466
+ // Payment webhooks don't have a public agent context. Derive admin from
467
+ // the accountId (e.g. "beagle-zanzibar" → "beagle-zanzibar-admin").
468
+ const adminAgentId = findAdminAgentForAccount(cfg, accountId);
424
469
  if (!adminAgentId) {
425
- console.warn("[ride-dispatch] No admin agent for payment dispatch");
470
+ console.warn("[ride-dispatch] No admin agent for payment dispatch, accountId:", accountId);
426
471
  return;
427
472
  }
428
473
  const instruction = buildPaymentConfirmedInstruction({ bookingId, touristPhone, accountId });
@@ -443,7 +488,7 @@ const handleRideDispatch = async (event) => {
443
488
  if (event.type === "memory" && event.action === "add") {
444
489
  await handleMemoryAdd(event);
445
490
  }
446
- else if (event.type === "message" && event.action === "inbound") {
491
+ else if (event.type === "message" && event.action === "before-dispatch") {
447
492
  await handleDriverReply(event);
448
493
  }
449
494
  };
@@ -538,7 +538,7 @@ export class MemoryIndexManager {
538
538
  return this.syncing;
539
539
  }
540
540
  async readFile(params) {
541
- const relPath = ensureMemoryPrefix(normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(normalizeRelPath(params.relPath))));
541
+ const relPath = normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(ensureMemoryPrefix(normalizeRelPath(params.relPath))));
542
542
  if (!relPath || !isMemoryPath(relPath)) {
543
543
  throw new Error(relPath
544
544
  ? `invalid path "${relPath}" — must start with "memory/" (e.g. "memory/admin/file.md")`
@@ -578,7 +578,7 @@ export class MemoryIndexManager {
578
578
  * matching the session's scope configuration.
579
579
  */
580
580
  async writeFile(params) {
581
- const relPath = ensureMemoryPrefix(normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(normalizeRelPath(params.relPath))));
581
+ const relPath = normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(ensureMemoryPrefix(normalizeRelPath(params.relPath))));
582
582
  if (!relPath || !isMemoryPath(relPath)) {
583
583
  throw new Error(relPath
584
584
  ? `invalid path "${relPath}" — must start with "memory/" (e.g. "memory/admin/file.md")`
@@ -626,7 +626,7 @@ export class MemoryIndexManager {
626
626
  let relPath;
627
627
  if (params.destFolder) {
628
628
  // Explicit folder — use as-is (scope checking enforces access)
629
- relPath = normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(`${params.destFolder}/${params.destFilename}`));
629
+ relPath = normalizeGroupIdInMemoryPath(normalizePhoneInMemoryPath(ensureMemoryPrefix(`${params.destFolder}/${params.destFilename}`)));
630
630
  }
631
631
  else {
632
632
  // Default: memory/users/{peer}/media/{filename}
@@ -363,7 +363,7 @@ export function createCommandHandlers(context) {
363
363
  try {
364
364
  await client.patchSession({
365
365
  key: state.currentSessionKey,
366
- groupActivation: args === "always" ? "always" : "mention",
366
+ groupActivation: args === "always" ? "always" : args === "off" ? "off" : "mention",
367
367
  });
368
368
  chatLog.addSystem(`activation set to ${args}`);
369
369
  await refreshSessionInfo();
@@ -1,6 +1,5 @@
1
- import { normalizeGroupActivation } from "../../../auto-reply/group-activation.js";
2
- import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../../config/group-policy.js";
3
- import { loadSessionStore, resolveGroupSessionKey, resolveStorePath, } from "../../../config/sessions.js";
1
+ import { resolveChannelGroupActivation, resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../../config/group-policy.js";
2
+ import { resolveGroupSessionKey } from "../../../config/sessions.js";
4
3
  export function resolveGroupPolicyFor(cfg, conversationId) {
5
4
  const groupId = resolveGroupSessionKey({
6
5
  From: conversationId,
@@ -26,12 +25,15 @@ export function resolveGroupRequireMentionFor(cfg, conversationId) {
26
25
  });
27
26
  }
28
27
  export function resolveGroupActivationFor(params) {
29
- const storePath = resolveStorePath(params.cfg.session?.store, {
30
- agentId: params.agentId,
28
+ const groupId = resolveGroupSessionKey({
29
+ From: params.conversationId,
30
+ ChatType: "group",
31
+ Provider: "whatsapp",
32
+ })?.id;
33
+ const configActivation = resolveChannelGroupActivation({
34
+ cfg: params.cfg,
35
+ channel: "whatsapp",
36
+ groupId: groupId ?? params.conversationId,
31
37
  });
32
- const store = loadSessionStore(storePath);
33
- const entry = store[params.sessionKey];
34
- const requireMention = resolveGroupRequireMentionFor(params.cfg, params.conversationId);
35
- const defaultActivation = requireMention === false ? "always" : "mention";
36
- return normalizeGroupActivation(entry?.groupActivation) ?? defaultActivation;
38
+ return configActivation ?? "mention";
37
39
  }
@@ -58,6 +58,10 @@ export function applyGroupGating(params) {
58
58
  sessionKey: params.sessionKey,
59
59
  conversationId: params.conversationId,
60
60
  });
61
+ if (activation === "off") {
62
+ params.logVerbose(`Group ${params.conversationId} activation=off, ignoring`);
63
+ return { shouldProcess: false };
64
+ }
61
65
  const requireMention = activation !== "always";
62
66
  const selfJid = params.msg.selfJid?.replace(/:\\d+/, "");
63
67
  const replySenderJid = params.msg.replyToSenderJid?.replace(/:\\d+/, "");
@@ -76,8 +80,25 @@ export function applyGroupGating(params) {
76
80
  });
77
81
  params.msg.wasMentioned = mentionGate.effectiveWasMentioned;
78
82
  if (!shouldBypassMention && requireMention && mentionGate.shouldSkip) {
79
- params.logVerbose(`Group message (no mention) — agent will process silently in ${params.conversationId}: ${params.msg.body}`);
80
- return { shouldProcess: true, mentionGated: true };
83
+ params.logVerbose(`Group message (no mention) — accumulating context only in ${params.conversationId}: ${params.msg.body}`);
84
+ // Record the un-mentioned message in the group history buffer so it is
85
+ // available as context when the bot is eventually @mentioned.
86
+ const sender = params.msg.senderName && params.msg.senderE164
87
+ ? `${params.msg.senderName} (${params.msg.senderE164})`
88
+ : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
89
+ recordPendingHistoryEntryIfEnabled({
90
+ historyMap: params.groupHistories,
91
+ historyKey: params.groupHistoryKey,
92
+ limit: params.groupHistoryLimit,
93
+ entry: {
94
+ sender,
95
+ body: params.msg.body,
96
+ timestamp: params.msg.timestamp,
97
+ id: params.msg.id,
98
+ senderJid: params.msg.senderJid,
99
+ },
100
+ });
101
+ return { shouldProcess: true, contextOnly: true };
81
102
  }
82
103
  return { shouldProcess: true };
83
104
  }
@@ -1,3 +1,4 @@
1
+ import { loadConfig } from "../../../config/config.js";
1
2
  import { logVerbose } from "../../../globals.js";
2
3
  import { isLicensed } from "../../../license/state.js";
3
4
  import { createInternalHookEvent, triggerInternalHook } from "../../../hooks/internal-hooks.js";
@@ -29,7 +30,7 @@ export function createWebOnMessageHandler(params) {
29
30
  buildCombinedEchoKey: params.echoTracker.buildCombinedKey,
30
31
  groupHistory: opts?.groupHistory,
31
32
  suppressGroupHistoryClear: opts?.suppressGroupHistoryClear,
32
- suppressDelivery: opts?.suppressDelivery,
33
+ contextOnly: opts?.contextOnly,
33
34
  });
34
35
  return async (msg) => {
35
36
  // Block all message processing when unlicensed.
@@ -40,8 +41,12 @@ export function createWebOnMessageHandler(params) {
40
41
  logVerbose(`[on-message] processing message from=${msg.from} body=${msg.body?.slice(0, 60)}`);
41
42
  const conversationId = msg.conversationId ?? msg.from;
42
43
  const peerId = resolvePeerId(msg);
44
+ // Read config fresh for routing so that binding changes (e.g. admin revocation)
45
+ // take effect on the next message without requiring a channel restart.
46
+ // `loadConfig()` has a 200ms cache so this is cheap under normal message rates.
47
+ const routingCfg = loadConfig();
43
48
  const route = resolveAgentRoute({
44
- cfg: params.cfg,
49
+ cfg: routingCfg,
45
50
  channel: "whatsapp",
46
51
  accountId: msg.accountId,
47
52
  peer: {
@@ -67,7 +72,7 @@ export function createWebOnMessageHandler(params) {
67
72
  params.echoTracker.forget(msg.body);
68
73
  return;
69
74
  }
70
- let mentionGated = false;
75
+ let contextOnly = false;
71
76
  if (msg.chatType === "group") {
72
77
  const metaCtx = {
73
78
  From: msg.from,
@@ -132,7 +137,7 @@ export function createWebOnMessageHandler(params) {
132
137
  }
133
138
  return;
134
139
  }
135
- mentionGated = gating.mentionGated ?? false;
140
+ contextOnly = gating.contextOnly ?? false;
136
141
  }
137
142
  else {
138
143
  // Ensure `peerId` for DMs is stable and stored as E.164 when possible.
@@ -153,8 +158,25 @@ export function createWebOnMessageHandler(params) {
153
158
  })) {
154
159
  return;
155
160
  }
161
+ // Fire message:before-dispatch awaited so hooks can suppress processing.
162
+ // Unlike message:inbound (fire-and-forget inside processMessage), this is
163
+ // awaited here, allowing a handler to set event.suppress = true before
164
+ // the public agent's LLM is invoked. Used by ride-dispatch to intercept
165
+ // driver replies and route them to the admin agent instead.
166
+ const beforeDispatch = createInternalHookEvent("message", "before-dispatch", route.sessionKey, {
167
+ from: msg.senderE164 ?? msg.from,
168
+ to: msg.to,
169
+ text: msg.body,
170
+ chatType: msg.chatType,
171
+ agentId: route.agentId,
172
+ channel: "whatsapp",
173
+ cfg: params.cfg,
174
+ });
175
+ await triggerInternalHook(beforeDispatch);
176
+ if (beforeDispatch.suppress)
177
+ return;
156
178
  await processForRoute(msg, route, groupHistoryKey, {
157
- suppressDelivery: mentionGated || undefined,
179
+ contextOnly: contextOnly || undefined,
158
180
  });
159
181
  };
160
182
  }