@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.
- package/dist/agents/tools/logs-read-tool.js +9 -0
- package/dist/agents/tools/memory-tool.js +1 -0
- package/dist/agents/workspace-migrations.js +61 -0
- package/dist/auto-reply/group-activation.js +2 -0
- package/dist/auto-reply/reply/commands-session.js +28 -11
- package/dist/build-info.json +3 -3
- package/dist/config/agent-tools-reconcile.js +58 -0
- package/dist/config/group-policy.js +16 -0
- package/dist/config/zod-schema.providers-whatsapp.js +2 -0
- package/dist/control-ui/assets/index-XqRo9tNW.css +1 -0
- package/dist/control-ui/assets/{index-Bd75cI7J.js → index-koe4eKhk.js} +526 -493
- package/dist/control-ui/assets/index-koe4eKhk.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/cron/preloaded.js +27 -23
- package/dist/cron/service/timer.js +5 -1
- package/dist/gateway/protocol/index.js +7 -2
- package/dist/gateway/protocol/schema/logs-chat.js +6 -0
- package/dist/gateway/protocol/schema/protocol-schemas.js +6 -0
- package/dist/gateway/protocol/schema/sessions-transcript.js +1 -0
- package/dist/gateway/protocol/schema/sessions.js +6 -1
- package/dist/gateway/protocol/schema/whatsapp.js +24 -0
- package/dist/gateway/protocol/schema.js +1 -0
- package/dist/gateway/public-chat/session-token.js +52 -0
- package/dist/gateway/public-chat-api.js +40 -13
- package/dist/gateway/server-methods/apikeys.js +2 -0
- package/dist/gateway/server-methods/logs.js +17 -1
- package/dist/gateway/server-methods/public-chat.js +5 -0
- package/dist/gateway/server-methods/sessions-transcript.js +30 -6
- package/dist/gateway/server-methods/whatsapp-conversations.js +387 -0
- package/dist/gateway/server-methods-list.js +6 -0
- package/dist/gateway/server-methods.js +7 -0
- package/dist/gateway/server.impl.js +19 -2
- package/dist/gateway/sessions-patch.js +1 -1
- package/dist/hooks/bundled/ride-dispatch/HOOK.md +7 -6
- package/dist/hooks/bundled/ride-dispatch/handler.js +98 -39
- package/dist/memory/manager.js +3 -3
- package/dist/tui/tui-command-handlers.js +1 -1
- package/dist/web/auto-reply/monitor/group-activation.js +12 -10
- package/dist/web/auto-reply/monitor/group-gating.js +23 -2
- package/dist/web/auto-reply/monitor/on-message.js +27 -5
- package/dist/web/auto-reply/monitor/process-message.js +64 -53
- package/dist/web/inbound/monitor.js +30 -0
- package/extensions/whatsapp/src/channel.ts +1 -1
- package/package.json +1 -1
- package/skills/log-review/SKILL.md +17 -4
- package/skills/log-review/references/review-protocol.md +4 -4
- package/taskmaster-docs/USER-GUIDE.md +14 -0
- package/templates/beagle-zanzibar/agents/admin/AGENTS.md +16 -8
- package/templates/beagle-zanzibar/agents/public/AGENTS.md +10 -5
- package/dist/control-ui/assets/index-Bd75cI7J.js.map +0 -1
- 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
|
|
90
|
-
*
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
105
|
-
function
|
|
106
|
-
|
|
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 =
|
|
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,
|
|
231
|
-
`
|
|
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 =
|
|
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:${
|
|
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 =
|
|
357
|
+
const adminAgentId = findSiblingAdminAgentId(cfg, agentId);
|
|
321
358
|
if (!adminAgentId) {
|
|
322
|
-
console.warn("[ride-dispatch] No admin agent found
|
|
359
|
+
console.warn("[ride-dispatch] No admin agent found for public agent:", agentId);
|
|
323
360
|
return;
|
|
324
361
|
}
|
|
325
|
-
|
|
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:
|
|
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 (!
|
|
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 =
|
|
427
|
+
const adminAgentId = findSiblingAdminAgentId(cfg, agentId);
|
|
379
428
|
if (!adminAgentId)
|
|
380
429
|
return;
|
|
381
|
-
const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ??
|
|
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
|
|
388
|
-
`
|
|
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
|
-
|
|
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 === "
|
|
505
|
+
else if (event.type === "message" && event.action === "before-dispatch") {
|
|
447
506
|
await handleDriverReply(event);
|
|
448
507
|
}
|
|
449
508
|
};
|
package/dist/memory/manager.js
CHANGED
|
@@ -538,7 +538,7 @@ export class MemoryIndexManager {
|
|
|
538
538
|
return this.syncing;
|
|
539
539
|
}
|
|
540
540
|
async readFile(params) {
|
|
541
|
-
const 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 =
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
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) —
|
|
80
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
185
|
+
if (params.contextOnly) {
|
|
186
186
|
shouldClearGroupHistory = false;
|
|
187
187
|
}
|
|
188
|
-
if (!params.
|
|
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:
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
354
|
-
params.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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"
|
|
59
|
+
reload: { configPrefixes: ["web", "channels.whatsapp"], noopPrefixes: [] },
|
|
60
60
|
gatewayMethods: ["web.login.start", "web.login.wait"],
|
|
61
61
|
configSchema: buildChannelConfigSchema(WhatsAppConfigSchema),
|
|
62
62
|
config: {
|