@rubytech/taskmaster 1.16.3 → 1.17.4

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 (51) hide show
  1. package/dist/agents/tools/logs-read-tool.js +9 -0
  2. package/dist/agents/tools/memory-tool.js +1 -0
  3. package/dist/agents/workspace-migrations.js +61 -0
  4. package/dist/auto-reply/group-activation.js +2 -0
  5. package/dist/auto-reply/reply/commands-session.js +28 -11
  6. package/dist/build-info.json +3 -3
  7. package/dist/config/agent-tools-reconcile.js +58 -0
  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-XqRo9tNW.css +1 -0
  11. package/dist/control-ui/assets/{index-Bd75cI7J.js → index-koe4eKhk.js} +526 -493
  12. package/dist/control-ui/assets/index-koe4eKhk.js.map +1 -0
  13. package/dist/control-ui/index.html +2 -2
  14. package/dist/cron/preloaded.js +27 -23
  15. package/dist/cron/service/timer.js +5 -1
  16. package/dist/gateway/protocol/index.js +7 -2
  17. package/dist/gateway/protocol/schema/logs-chat.js +6 -0
  18. package/dist/gateway/protocol/schema/protocol-schemas.js +6 -0
  19. package/dist/gateway/protocol/schema/sessions-transcript.js +1 -0
  20. package/dist/gateway/protocol/schema/sessions.js +6 -1
  21. package/dist/gateway/protocol/schema/whatsapp.js +24 -0
  22. package/dist/gateway/protocol/schema.js +1 -0
  23. package/dist/gateway/public-chat/session-token.js +52 -0
  24. package/dist/gateway/public-chat-api.js +40 -13
  25. package/dist/gateway/server-methods/apikeys.js +2 -0
  26. package/dist/gateway/server-methods/logs.js +17 -1
  27. package/dist/gateway/server-methods/public-chat.js +5 -0
  28. package/dist/gateway/server-methods/sessions-transcript.js +30 -6
  29. package/dist/gateway/server-methods/whatsapp-conversations.js +387 -0
  30. package/dist/gateway/server-methods-list.js +6 -0
  31. package/dist/gateway/server-methods.js +7 -0
  32. package/dist/gateway/server.impl.js +19 -2
  33. package/dist/gateway/sessions-patch.js +1 -1
  34. package/dist/hooks/bundled/ride-dispatch/HOOK.md +7 -6
  35. package/dist/hooks/bundled/ride-dispatch/handler.js +98 -39
  36. package/dist/memory/manager.js +3 -3
  37. package/dist/tui/tui-command-handlers.js +1 -1
  38. package/dist/web/auto-reply/monitor/group-activation.js +12 -10
  39. package/dist/web/auto-reply/monitor/group-gating.js +23 -2
  40. package/dist/web/auto-reply/monitor/on-message.js +27 -5
  41. package/dist/web/auto-reply/monitor/process-message.js +64 -53
  42. package/dist/web/inbound/monitor.js +30 -0
  43. package/extensions/whatsapp/src/channel.ts +1 -1
  44. package/package.json +1 -1
  45. package/skills/log-review/SKILL.md +17 -4
  46. package/skills/log-review/references/review-protocol.md +4 -4
  47. package/taskmaster-docs/USER-GUIDE.md +14 -0
  48. package/templates/beagle-zanzibar/agents/admin/AGENTS.md +16 -8
  49. package/templates/beagle-zanzibar/agents/public/AGENTS.md +10 -5
  50. package/dist/control-ui/assets/index-Bd75cI7J.js.map +0 -1
  51. package/dist/control-ui/assets/index-BkymP95Y.css +0 -1
@@ -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` +
@@ -223,20 +253,23 @@ function buildTripRequestInstruction(fields) {
223
253
  `4. For each selected driver, update their status to awaiting_response via memory_write\n` +
224
254
  `5. Write shared/active-negotiations/{driver-phone}.md with job_id: ${jobId} for each driver\n` +
225
255
  `6. Message each driver in Swahili with the route details, pickup time, passengers, and job ID [${jobId}]\n` +
226
- ` Use the message tool with accountId: "${accountId}"\n` +
256
+ ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
227
257
  `7. Message the tourist at ${touristPhone} confirming you've contacted drivers and are waiting for quotes\n` +
228
- ` Use the message tool with accountId: "${accountId}"\n` +
258
+ ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
229
259
  `8. When drivers reply with quotes (dispatched to this session), compile the offers\n` +
230
- `9. Message the tourist with up to 3 competing offers: fare, driver rating, vehicle type, estimated journey time\n` +
231
- ` Do NOT reveal driver name, phone, or plate — those are gated by payment\n`);
260
+ `9. Message the tourist at ${touristPhone} with up to 3 competing offers: fare, vehicle type, driver rating, estimated journey time\n` +
261
+ ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
262
+ ` Cross-agent echo will relay the message to the tourist's active session automatically\n` +
263
+ ` Do NOT reveal driver name, phone, or plate — those are gated by payment\n` +
264
+ ` NOTE: Do NOT write a dispatch file for the offers — message the tourist directly\n`);
232
265
  }
233
- function buildBookingConfirmInstruction(fields) {
266
+ function buildBookingConfirmInstruction(fields, resolvedAccountId) {
234
267
  const jobId = fields.job_id ?? "UNKNOWN";
235
268
  const touristPhone = fields.tourist_phone ?? "unknown";
236
269
  const driverName = fields.driver_name ?? "unknown";
237
270
  const driverPhone = fields.driver_phone ?? "unknown";
238
271
  const fare = fields.fare ?? "unknown";
239
- const accountId = fields.account_id ?? "default";
272
+ const accountId = resolvedAccountId;
240
273
  return (`[System: Ride Dispatch — Booking Confirmation]\n\n` +
241
274
  `The tourist has selected a driver. Generate a Stripe payment link and send it.\n\n` +
242
275
  `Job ID: ${jobId}\n` +
@@ -248,7 +281,7 @@ function buildBookingConfirmInstruction(fields) {
248
281
  `1. Load the stripe skill and generate a Checkout Session for the booking fee\n` +
249
282
  ` Set metadata: booking_id="${jobId}", tourist_phone="${touristPhone}"\n` +
250
283
  `2. Message the tourist at ${touristPhone} with the payment link and booking terms\n` +
251
- ` Use the message tool with accountId: "${accountId}"\n` +
284
+ ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
252
285
  `3. Record the booking details in shared/bookings/${jobId}.md via memory_write\n` +
253
286
  `4. Clear the active negotiation files for drivers NOT selected (delete their shared/active-negotiations/{phone}.md)\n`);
254
287
  }
@@ -263,9 +296,9 @@ function buildPaymentConfirmedInstruction(params) {
263
296
  `1. Read the booking record at shared/bookings/${bookingId}.md for driver details\n` +
264
297
  `2. Generate the pickup PIN and QR code (see references/pin-qr.md)\n` +
265
298
  `3. Message the tourist at ${touristPhone} with: driver name, phone, vehicle details, plate, and pickup PIN\n` +
266
- ` Use the message tool with accountId: "${accountId}"\n` +
299
+ ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
267
300
  `4. Message the driver with: passenger name, pickup time/location, fare, and QR code URL\n` +
268
- ` Use the message tool with accountId: "${accountId}"\n` +
301
+ ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
269
302
  `5. Update the booking record status to "confirmed"\n` +
270
303
  `6. Clear the active negotiation file for the driver (shared/active-negotiations/{phone}.md)\n`);
271
304
  }
@@ -290,8 +323,8 @@ async function handleMemoryAdd(event) {
290
323
  return;
291
324
  const jobId = match[1]; // e.g. BGL-0042
292
325
  const phase = match[2]; // trip-request | booking-confirm
293
- // Dedup
294
- const dedupKey = `dispatch:${relativePath}`;
326
+ // Dedup (use jobId+phase, not relativePath, since multiple watchers report different paths)
327
+ const dedupKey = `dispatch:${jobId}:${phase}`;
295
328
  if (isDuplicate(dedupKey))
296
329
  return;
297
330
  // Read the dispatch file
@@ -307,6 +340,10 @@ async function handleMemoryAdd(event) {
307
340
  return;
308
341
  }
309
342
  const fields = parseDispatchFile(content);
343
+ // Normalize: public agents may write tourist_id instead of tourist_phone
344
+ if (!fields.tourist_phone && fields.tourist_id) {
345
+ fields.tourist_phone = fields.tourist_id;
346
+ }
310
347
  // Load config (memory:add events don't carry cfg)
311
348
  let cfg;
312
349
  try {
@@ -317,18 +354,21 @@ async function handleMemoryAdd(event) {
317
354
  console.warn("[ride-dispatch] Could not read config");
318
355
  return;
319
356
  }
320
- const adminAgentId = findAdminAgentId(cfg);
357
+ const adminAgentId = findSiblingAdminAgentId(cfg, agentId);
321
358
  if (!adminAgentId) {
322
- console.warn("[ride-dispatch] No admin agent found in config");
359
+ console.warn("[ride-dispatch] No admin agent found for public agent:", agentId);
323
360
  return;
324
361
  }
325
- const accountId = fields.account_id ?? resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? "default";
362
+ // Resolve account: binding first, then derive from agent ID prefix
363
+ // (beagle-zanzibar-public → beagle-zanzibar), which matches WhatsApp account naming.
364
+ const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ??
365
+ (agentId.replace(/-(public|admin)$/, "") || "default");
326
366
  let instruction;
327
367
  if (phase === "trip-request") {
328
- instruction = buildTripRequestInstruction(fields);
368
+ instruction = buildTripRequestInstruction(fields, accountId);
329
369
  }
330
370
  else if (phase === "booking-confirm") {
331
- instruction = buildBookingConfirmInstruction(fields);
371
+ instruction = buildBookingConfirmInstruction(fields, accountId);
332
372
  }
333
373
  else {
334
374
  return;
@@ -347,7 +387,13 @@ async function handleMemoryAdd(event) {
347
387
  });
348
388
  }
349
389
  /**
350
- * Handle message:inbound events — driver replies routed to the public agent.
390
+ * Handle message:before-dispatch events — intercept driver replies before they
391
+ * reach the public agent's LLM.
392
+ *
393
+ * Sets event.suppress = true when the sender is a driver in active negotiation,
394
+ * then dispatches the reply to the admin agent's ride session instead.
395
+ * Because this handler is awaited by the caller, suppress takes effect before
396
+ * processForRoute is invoked.
351
397
  */
352
398
  async function handleDriverReply(event) {
353
399
  const context = event.context || {};
@@ -359,7 +405,7 @@ async function handleDriverReply(event) {
359
405
  const agentId = resolveAgentIdFromSessionKey(event.sessionKey);
360
406
  if (!agentId || !isBeagleAgent(agentId))
361
407
  return;
362
- if (!isPublicAgentConfig(cfg, agentId))
408
+ if (!isPublicAgent(agentId))
363
409
  return;
364
410
  // Extract sender phone from session key
365
411
  const senderPhone = extractPeerFromSessionKey(event.sessionKey);
@@ -367,27 +413,38 @@ async function handleDriverReply(event) {
367
413
  return;
368
414
  // Resolve workspace dir from the agent config to find active-negotiations
369
415
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
370
- // Check active negotiation index
416
+ // Check active negotiation index (synchronous — must complete before we return
417
+ // so that event.suppress is set before the caller checks it)
371
418
  const jobId = readActiveNegotiation(workspaceDir, senderPhone);
372
419
  if (!jobId)
373
- return; // Not a driver in active negotiation
420
+ return; // Not a driver in active negotiation — let public agent handle it
421
+ // Suppress delivery to the public agent before doing anything else
422
+ event.suppress = true;
374
423
  // Dedup
375
424
  const dedupKey = `reply:${senderPhone}:${Date.now().toString().slice(0, -4)}`; // ~10s granularity
376
425
  if (isDuplicate(dedupKey))
377
426
  return;
378
- const adminAgentId = findAdminAgentId(cfg);
427
+ const adminAgentId = findSiblingAdminAgentId(cfg, agentId);
379
428
  if (!adminAgentId)
380
429
  return;
381
- const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? "default";
430
+ const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ??
431
+ (agentId.replace(/-(public|admin)$/, "") || "default");
382
432
  const instruction = `[System: Ride Dispatch — Driver Reply]\n\n` +
383
433
  `A driver has replied regarding job ${jobId}.\n\n` +
384
434
  `Driver phone: ${senderPhone}\n` +
385
435
  `Message: ${text}\n\n` +
386
436
  `Process this reply in the context of the ongoing negotiation for ${jobId}.\n` +
387
- `If this is a fare quote, note it and compile offers when ready.\n` +
388
- `If the driver is declining, update their status and active negotiation index.\n`;
437
+ `If this is a fare quote:\n` +
438
+ ` - Record the quote in the driver's memory profile\n` +
439
+ ` - When all expected quotes are in (or after a reasonable wait), compile the offers\n` +
440
+ ` - Message the tourist directly using the message tool with the compiled offers\n` +
441
+ ` The tourist phone is in the earlier trip-request message in this session\n` +
442
+ ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
443
+ ` Do NOT write a dispatch file — use the message tool to send the offers directly\n` +
444
+ ` Cross-agent echo will relay it to the tourist's active session automatically\n` +
445
+ `If the driver is declining, update their status in memory and delete their shared/active-negotiations/{phone-digits}.md file.\n`;
389
446
  console.log(`[ride-dispatch] Driver reply from ${senderPhone} for ${jobId}, dispatching to admin agent "${adminAgentId}"`);
390
- // Fire and forget
447
+ // Fire and forget — suppress is already set, caller will skip processForRoute
391
448
  dispatchToAdmin({
392
449
  cfg,
393
450
  adminAgentId,
@@ -420,9 +477,11 @@ export async function dispatchPaymentConfirmed(params) {
420
477
  console.warn("[ride-dispatch] Could not read config for payment dispatch");
421
478
  return;
422
479
  }
423
- const adminAgentId = findAdminAgentId(cfg);
480
+ // Payment webhooks don't have a public agent context. Derive admin from
481
+ // the accountId (e.g. "beagle-zanzibar" → "beagle-zanzibar-admin").
482
+ const adminAgentId = findAdminAgentForAccount(cfg, accountId);
424
483
  if (!adminAgentId) {
425
- console.warn("[ride-dispatch] No admin agent for payment dispatch");
484
+ console.warn("[ride-dispatch] No admin agent for payment dispatch, accountId:", accountId);
426
485
  return;
427
486
  }
428
487
  const instruction = buildPaymentConfirmedInstruction({ bookingId, touristPhone, accountId });
@@ -443,7 +502,7 @@ const handleRideDispatch = async (event) => {
443
502
  if (event.type === "memory" && event.action === "add") {
444
503
  await handleMemoryAdd(event);
445
504
  }
446
- else if (event.type === "message" && event.action === "inbound") {
505
+ else if (event.type === "message" && event.action === "before-dispatch") {
447
506
  await handleDriverReply(event);
448
507
  }
449
508
  };
@@ -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
  }
@@ -182,10 +182,10 @@ export async function processMessage(params) {
182
182
  // ── End opening hours gate ──────────────────────────────────────────
183
183
  // Send ack reaction immediately upon message receipt (post-gating).
184
184
  // Suppress when running silently on un-mentioned group messages.
185
- if (params.suppressDelivery) {
185
+ if (params.contextOnly) {
186
186
  shouldClearGroupHistory = false;
187
187
  }
188
- if (!params.suppressDelivery)
188
+ if (!params.contextOnly)
189
189
  maybeSendAckReaction({
190
190
  cfg: params.cfg,
191
191
  msg: params.msg,
@@ -315,6 +315,21 @@ export async function processMessage(params) {
315
315
  }, "failed updating session meta");
316
316
  });
317
317
  trackBackgroundTask(params.backgroundTasks, metaTask);
318
+ // contextOnly mode: accumulate user message in transcript but skip LLM.
319
+ // This prevents phantom assistant messages that corrupt conversation history.
320
+ if (params.contextOnly) {
321
+ // Store user message in transcript so context accumulates for the next mentioned reply
322
+ void appendUserMessageToSessionTranscript({
323
+ agentId: params.route.agentId,
324
+ sessionKey: params.route.sessionKey,
325
+ text: combinedBody,
326
+ storePath,
327
+ }).catch((err) => {
328
+ params.replyLogger.warn({ error: formatError(err) }, "contextOnly: failed to store user turn");
329
+ });
330
+ params.replyLogger.info({ correlationId, from: fromDisplay }, "context-only mode: user message stored, LLM skipped");
331
+ return false;
332
+ }
318
333
  const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
319
334
  ctx: ctxPayload,
320
335
  cfg: params.cfg,
@@ -328,60 +343,56 @@ export async function processMessage(params) {
328
343
  logVerbose("Stripped stray HEARTBEAT_OK token from web reply");
329
344
  }
330
345
  },
331
- deliver: params.suppressDelivery
332
- ? async () => {
333
- /* Silently discard — agent ran but reply is suppressed (no mention) */
346
+ deliver: async (payload, info) => {
347
+ await deliverWebReply({
348
+ replyResult: payload,
349
+ msg: params.msg,
350
+ maxMediaBytes: params.maxMediaBytes,
351
+ textLimit,
352
+ chunkMode,
353
+ replyLogger: params.replyLogger,
354
+ connectionId: params.connectionId,
355
+ // Tool + block updates are noisy; skip their log lines.
356
+ skipLog: info.kind !== "final",
357
+ tableMode,
358
+ });
359
+ didSendReply = true;
360
+ if (info.kind === "tool") {
361
+ params.rememberSentText(payload.text, {});
362
+ return;
334
363
  }
335
- : async (payload, info) => {
336
- await deliverWebReply({
337
- replyResult: payload,
338
- msg: params.msg,
339
- maxMediaBytes: params.maxMediaBytes,
340
- textLimit,
341
- chunkMode,
342
- replyLogger: params.replyLogger,
343
- connectionId: params.connectionId,
344
- // Tool + block updates are noisy; skip their log lines.
345
- skipLog: info.kind !== "final",
346
- tableMode,
347
- });
348
- didSendReply = true;
349
- if (info.kind === "tool") {
350
- params.rememberSentText(payload.text, {});
351
- return;
364
+ const shouldLog = info.kind === "final" && payload.text ? true : undefined;
365
+ params.rememberSentText(payload.text, {
366
+ combinedBody,
367
+ combinedBodySessionKey: params.route.sessionKey,
368
+ logVerboseMessage: shouldLog,
369
+ });
370
+ if (info.kind === "final") {
371
+ const fromDisplay = params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown");
372
+ const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
373
+ whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`);
374
+ if (shouldLogVerbose()) {
375
+ const preview = payload.text != null ? elide(payload.text, 400) : "<media>";
376
+ whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`);
352
377
  }
353
- const shouldLog = info.kind === "final" && payload.text ? true : undefined;
354
- params.rememberSentText(payload.text, {
355
- combinedBody,
356
- combinedBodySessionKey: params.route.sessionKey,
357
- logVerboseMessage: shouldLog,
378
+ // Fire message:outbound hook for conversation archiving
379
+ const outboundHookEvent = createInternalHookEvent("message", "outbound", params.route.sessionKey, {
380
+ from: params.msg.to, // Agent is sending
381
+ to: params.msg.senderE164 ?? params.msg.from, // To the user
382
+ text: payload.text,
383
+ timestamp: Date.now(),
384
+ chatType: params.msg.chatType,
385
+ agentId: params.route.agentId,
386
+ channel: "whatsapp",
387
+ hasMedia,
388
+ senderE164: params.msg.senderE164,
389
+ groupSubject: params.msg.groupSubject,
390
+ conversationId,
391
+ cfg: params.cfg,
358
392
  });
359
- if (info.kind === "final") {
360
- const fromDisplay = params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown");
361
- const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);
362
- whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`);
363
- if (shouldLogVerbose()) {
364
- const preview = payload.text != null ? elide(payload.text, 400) : "<media>";
365
- whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`);
366
- }
367
- // Fire message:outbound hook for conversation archiving
368
- const outboundHookEvent = createInternalHookEvent("message", "outbound", params.route.sessionKey, {
369
- from: params.msg.to, // Agent is sending
370
- to: params.msg.senderE164 ?? params.msg.from, // To the user
371
- text: payload.text,
372
- timestamp: Date.now(),
373
- chatType: params.msg.chatType,
374
- agentId: params.route.agentId,
375
- channel: "whatsapp",
376
- hasMedia,
377
- senderE164: params.msg.senderE164,
378
- groupSubject: params.msg.groupSubject,
379
- conversationId,
380
- cfg: params.cfg,
381
- });
382
- void triggerInternalHook(outboundHookEvent);
383
- }
384
- },
393
+ void triggerInternalHook(outboundHookEvent);
394
+ }
395
+ },
385
396
  onError: (err, info) => {
386
397
  const label = info.kind === "tool"
387
398
  ? "tool update"
@@ -395,5 +395,35 @@ export async function monitorWebInbox(options) {
395
395
  },
396
396
  // IPC surface (sendMessage/sendPoll/sendReaction/sendComposingTo)
397
397
  ...sendApi,
398
+ // Conversation browser methods
399
+ listConversations: async () => {
400
+ const entries = [];
401
+ for (const [jid, cached] of groupMetaCache) {
402
+ entries.push({
403
+ jid,
404
+ type: "group",
405
+ name: cached.subject ?? jid,
406
+ lastMessageTimestamp: undefined,
407
+ });
408
+ }
409
+ return entries;
410
+ },
411
+ getMessages: async (_jid, _limit) => {
412
+ // No in-memory message store — Baileys makeInMemoryStore is not used.
413
+ return [];
414
+ },
415
+ getGroupMetadata: async (jid) => {
416
+ const meta = await sock.groupMetadata(jid);
417
+ return {
418
+ subject: meta.subject,
419
+ participants: (meta.participants ?? []).map((p) => ({
420
+ jid: p.id,
421
+ name: p.name ?? undefined,
422
+ admin: p.admin === "admin" || p.admin === "superadmin",
423
+ })),
424
+ creation: meta.creation,
425
+ desc: meta.desc ?? undefined,
426
+ };
427
+ },
398
428
  };
399
429
  }
@@ -56,7 +56,7 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
56
56
  reactions: true,
57
57
  media: true,
58
58
  },
59
- reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
59
+ reload: { configPrefixes: ["web", "channels.whatsapp"], noopPrefixes: [] },
60
60
  gatewayMethods: ["web.login.start", "web.login.wait"],
61
61
  configSchema: buildChannelConfigSchema(WhatsAppConfigSchema),
62
62
  config: {