@rubytech/taskmaster 1.14.2 → 1.16.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 (50) hide show
  1. package/dist/agents/apply-patch.js +3 -1
  2. package/dist/agents/bash-tools.exec.js +3 -1
  3. package/dist/agents/bash-tools.process.js +3 -1
  4. package/dist/agents/skills/frontmatter.js +1 -0
  5. package/dist/agents/skills/workspace.js +64 -22
  6. package/dist/agents/system-prompt.js +1 -1
  7. package/dist/agents/taskmaster-tools.js +6 -4
  8. package/dist/agents/tool-policy.js +2 -1
  9. package/dist/agents/tools/contact-create-tool.js +4 -3
  10. package/dist/agents/tools/contact-delete-tool.js +3 -2
  11. package/dist/agents/tools/contact-lookup-tool.js +5 -4
  12. package/dist/agents/tools/contact-update-tool.js +6 -3
  13. package/dist/agents/tools/memory-tool.js +3 -1
  14. package/dist/agents/tools/qr-generate-tool.js +45 -0
  15. package/dist/agents/workspace-migrations.js +351 -0
  16. package/dist/build-info.json +3 -3
  17. package/dist/config/agent-tools-reconcile.js +47 -0
  18. package/dist/control-ui/assets/{index-B3nkSwMP.js → index-Bd75cI7J.js} +547 -573
  19. package/dist/control-ui/assets/index-Bd75cI7J.js.map +1 -0
  20. package/dist/control-ui/assets/index-BkymP95Y.css +1 -0
  21. package/dist/control-ui/index.html +2 -2
  22. package/dist/gateway/server-http.js +5 -0
  23. package/dist/gateway/server-methods/web.js +13 -0
  24. package/dist/gateway/server.impl.js +15 -1
  25. package/dist/hooks/bundled/ride-dispatch/HOOK.md +57 -0
  26. package/dist/hooks/bundled/ride-dispatch/handler.js +450 -0
  27. package/dist/hooks/bundled/ride-dispatch/stripe-webhook.js +191 -0
  28. package/dist/memory/internal.js +24 -1
  29. package/dist/memory/manager.js +3 -3
  30. package/dist/records/records-manager.js +7 -2
  31. package/package.json +1 -1
  32. package/skills/business-assistant/SKILL.md +1 -1
  33. package/skills/qr-code/SKILL.md +63 -0
  34. package/skills/sales-closer/SKILL.md +1 -1
  35. package/templates/beagle-zanzibar/agents/admin/AGENTS.md +67 -1
  36. package/templates/beagle-zanzibar/agents/public/AGENTS.md +102 -22
  37. package/templates/beagle-zanzibar/skills/beagle-zanzibar/SKILL.md +7 -8
  38. package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/ride-matching.md +46 -55
  39. package/templates/customer/agents/admin/BOOTSTRAP.md +5 -1
  40. package/templates/customer/agents/public/AGENTS.md +1 -2
  41. package/templates/real-agent/skills/buyer-feedback/SKILL.md +111 -0
  42. package/templates/real-agent/skills/property-enquiry/SKILL.md +126 -0
  43. package/templates/real-agent/skills/valuation-booking/SKILL.md +182 -0
  44. package/templates/real-agent/skills/vendor-updates/SKILL.md +153 -0
  45. package/templates/real-agent/skills/viewing-management/SKILL.md +111 -0
  46. package/templates/taskmaster/agents/public/AGENTS.md +1 -1
  47. package/templates/taskmaster/agents/public/IDENTITY.md +1 -1
  48. package/templates/taskmaster/agents/public/SOUL.md +2 -2
  49. package/dist/control-ui/assets/index-B3nkSwMP.js.map +0 -1
  50. package/dist/control-ui/assets/index-l54GcTyj.css +0 -1
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Ride Dispatch Hook Handler
3
+ *
4
+ * Bridges the public and admin agents for privilege-separated ride matching.
5
+ *
6
+ * Two event paths:
7
+ *
8
+ * 1. memory:add — Public agent writes a dispatch file at shared/dispatch/{jobId}-{phase}.md.
9
+ * The handler parses it and dispatches a structured instruction to the admin agent's
10
+ * ride-{jobId} session. The admin then uses privileged tools (contact_lookup, message,
11
+ * Stripe) to process the request.
12
+ *
13
+ * 2. message:inbound — A driver replies on WhatsApp. The handler checks the active
14
+ * negotiation index (shared/active-negotiations/{phone}.md) to find the job ID,
15
+ * then dispatches the driver's message to the admin's ride session for that job.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { randomUUID } from "node:crypto";
20
+ import { dispatchInboundMessageWithDispatcher } from "../../../auto-reply/dispatch.js";
21
+ import { formatInboundEnvelope, resolveEnvelopeFormatOptions, } from "../../../auto-reply/envelope.js";
22
+ import { createReplyPrefixContext } from "../../../channels/reply-prefix.js";
23
+ import { readConfigFileSnapshot } from "../../../config/io.js";
24
+ import { resolveDefaultAgentId, resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
25
+ import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
26
+ import { resolveAgentBoundAccountId } from "../../../routing/bindings.js";
27
+ import { buildAgentSessionKey } from "../../../routing/resolve-route.js";
28
+ // ---------------------------------------------------------------------------
29
+ // Constants
30
+ // ---------------------------------------------------------------------------
31
+ /** Dispatch file path pattern: shared/dispatch/{jobId}-{phase}.md */
32
+ const DISPATCH_FILE_RE = /shared\/dispatch\/([A-Z]+-\d+)-(trip-request|booking-confirm)\.md$/;
33
+ /** Dedup cache: Map<key, timestamp>. Prevents re-dispatching within the cooldown window. */
34
+ const recentDispatches = new Map();
35
+ const DEDUP_COOLDOWN_MS = 30 * 1000; // 30 seconds
36
+ // ---------------------------------------------------------------------------
37
+ // Helpers
38
+ // ---------------------------------------------------------------------------
39
+ function isDuplicate(key) {
40
+ const now = Date.now();
41
+ // Prune stale entries
42
+ for (const [k, ts] of recentDispatches) {
43
+ if (now - ts > DEDUP_COOLDOWN_MS)
44
+ recentDispatches.delete(k);
45
+ }
46
+ const lastSeen = recentDispatches.get(key);
47
+ if (lastSeen && now - lastSeen < DEDUP_COOLDOWN_MS)
48
+ return true;
49
+ recentDispatches.set(key, now);
50
+ return false;
51
+ }
52
+ /**
53
+ * Parse a dispatch file's key: value fields into a Record.
54
+ * Lines starting with `#` are treated as headers and skipped.
55
+ */
56
+ function parseDispatchFile(content) {
57
+ const fields = {};
58
+ for (const line of content.split("\n")) {
59
+ const trimmed = line.trim();
60
+ if (!trimmed || trimmed.startsWith("#"))
61
+ continue;
62
+ const colonIdx = trimmed.indexOf(":");
63
+ if (colonIdx === -1)
64
+ continue;
65
+ const key = trimmed.slice(0, colonIdx).trim();
66
+ const value = trimmed.slice(colonIdx + 1).trim();
67
+ if (key && value)
68
+ fields[key] = value;
69
+ }
70
+ return fields;
71
+ }
72
+ /**
73
+ * Extract peer phone from a DM session key.
74
+ * Formats:
75
+ * - 4-part: agent:{agentId}:dm:{peer}
76
+ * - 5-part: agent:{agentId}:{channel}:dm:{peer}
77
+ */
78
+ function extractPeerFromSessionKey(sessionKey) {
79
+ const parts = sessionKey.toLowerCase().split(":").filter(Boolean);
80
+ if (parts[0] !== "agent" || parts.length < 4)
81
+ return null;
82
+ if (parts.length >= 4 && parts[2] === "dm")
83
+ return parts.slice(3).join(":");
84
+ if (parts.length >= 5 && parts[3] === "dm")
85
+ return parts.slice(4).join(":");
86
+ return null;
87
+ }
88
+ /**
89
+ * Find the admin agent ID from config.
90
+ * The admin agent is the one marked `default: true`.
91
+ */
92
+ function findAdminAgentId(cfg) {
93
+ const agents = cfg.agents?.list ?? [];
94
+ const admin = agents.find((a) => a.default === true);
95
+ if (admin?.id)
96
+ return admin.id;
97
+ const defaultId = resolveDefaultAgentId(cfg);
98
+ return defaultId || null;
99
+ }
100
+ /** Check if an agent ID belongs to a Beagle instance. */
101
+ function isBeagleAgent(agentId) {
102
+ return Boolean(agentId && agentId.toLowerCase().includes("beagle"));
103
+ }
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;
108
+ }
109
+ /**
110
+ * Read the active negotiation index file for a driver phone.
111
+ * Returns the job ID if the driver has an active negotiation, null otherwise.
112
+ */
113
+ function readActiveNegotiation(workspaceDir, phone) {
114
+ // Sanitise phone for filesystem: replace + with nothing, keep digits
115
+ const safePhone = phone.replace(/[^0-9]/g, "");
116
+ const filePath = path.join(workspaceDir, "memory", "shared", "active-negotiations", `${safePhone}.md`);
117
+ try {
118
+ const content = fs.readFileSync(filePath, "utf-8");
119
+ const fields = parseDispatchFile(content);
120
+ return fields.job_id || null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Resolve the workspace directory for the memory event's agent.
128
+ * memory:add events include workspaceDir in context.
129
+ */
130
+ function resolveWorkspaceDirFromContext(context) {
131
+ const dir = context.workspaceDir;
132
+ return typeof dir === "string" && dir.trim() ? dir.trim() : null;
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Dispatch to admin
136
+ // ---------------------------------------------------------------------------
137
+ async function dispatchToAdmin(params) {
138
+ const { cfg, adminAgentId, jobId, instruction, fromId, accountId } = params;
139
+ const sessionKey = buildAgentSessionKey({
140
+ agentId: adminAgentId,
141
+ channel: "system",
142
+ peer: { kind: "dm", id: `ride-${jobId.toLowerCase()}` },
143
+ }).toLowerCase();
144
+ const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
145
+ const envelope = formatInboundEnvelope({
146
+ channel: "System",
147
+ from: "ride-dispatch",
148
+ timestamp: Date.now(),
149
+ body: instruction,
150
+ chatType: "direct",
151
+ senderLabel: "Ride Dispatch Hook",
152
+ envelope: envelopeOptions,
153
+ });
154
+ const ctx = {
155
+ Body: envelope,
156
+ RawBody: instruction,
157
+ CommandBody: instruction,
158
+ From: fromId,
159
+ SessionKey: sessionKey,
160
+ AccountId: accountId,
161
+ MessageSid: randomUUID(),
162
+ ChatType: "direct",
163
+ CommandAuthorized: false,
164
+ Provider: "system",
165
+ Surface: "system",
166
+ OriginatingChannel: "system",
167
+ OriginatingTo: "",
168
+ };
169
+ const prefixCtx = createReplyPrefixContext({ cfg, agentId: adminAgentId });
170
+ await dispatchInboundMessageWithDispatcher({
171
+ ctx,
172
+ cfg,
173
+ dispatcherOptions: {
174
+ responsePrefix: prefixCtx.responsePrefix,
175
+ responsePrefixContextProvider: prefixCtx.responsePrefixContextProvider,
176
+ deliver: async () => {
177
+ // No-op: the admin agent sends messages via its `message` tool directly.
178
+ // Cross-agent echo handles visibility in the public agent's session.
179
+ },
180
+ onError: () => {
181
+ // Logged internally by the dispatcher
182
+ },
183
+ },
184
+ replyOptions: {
185
+ onModelSelected: prefixCtx.onModelSelected,
186
+ },
187
+ });
188
+ }
189
+ // ---------------------------------------------------------------------------
190
+ // Build instruction text for each dispatch phase
191
+ // ---------------------------------------------------------------------------
192
+ function buildTripRequestInstruction(fields) {
193
+ const jobId = fields.job_id ?? "UNKNOWN";
194
+ const touristPhone = fields.tourist_phone ?? "unknown";
195
+ const touristName = fields.tourist_name ?? "Tourist";
196
+ const pickup = fields.pickup ?? "unknown";
197
+ const destination = fields.destination ?? "unknown";
198
+ const date = fields.date ?? "unknown";
199
+ const time = fields.time ?? "unknown";
200
+ const passengers = fields.passengers ?? "unknown";
201
+ const luggage = fields.luggage ?? "none";
202
+ const specialRequests = fields.special_requests ?? "none";
203
+ const fareEstimate = fields.fare_estimate ?? "unknown";
204
+ const accountId = fields.account_id ?? "default";
205
+ return (`[System: Ride Dispatch — Trip Request]\n\n` +
206
+ `A tourist has requested a ride. Process this by contacting available drivers.\n\n` +
207
+ `Job ID: ${jobId}\n` +
208
+ `Tourist phone: ${touristPhone}\n` +
209
+ `Tourist name: ${touristName}\n` +
210
+ `Pickup: ${pickup}\n` +
211
+ `Destination: ${destination}\n` +
212
+ `Date: ${date}\n` +
213
+ `Time: ${time}\n` +
214
+ `Passengers: ${passengers}\n` +
215
+ `Luggage: ${luggage}\n` +
216
+ `Special requests: ${specialRequests}\n` +
217
+ `Fare estimate: ${fareEstimate}\n` +
218
+ `WhatsApp account: ${accountId}\n\n` +
219
+ `Process this request:\n` +
220
+ `1. Call contact_lookup to get the driver roster\n` +
221
+ `2. For each driver, call memory_get on drivers/{name}.md to check their status\n` +
222
+ `3. Select up to 3 idle drivers (prefer those with route history for this route)\n` +
223
+ `4. For each selected driver, update their status to awaiting_response via memory_write\n` +
224
+ `5. Write shared/active-negotiations/{driver-phone}.md with job_id: ${jobId} for each driver\n` +
225
+ `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` +
227
+ `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` +
229
+ `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`);
232
+ }
233
+ function buildBookingConfirmInstruction(fields) {
234
+ const jobId = fields.job_id ?? "UNKNOWN";
235
+ const touristPhone = fields.tourist_phone ?? "unknown";
236
+ const driverName = fields.driver_name ?? "unknown";
237
+ const driverPhone = fields.driver_phone ?? "unknown";
238
+ const fare = fields.fare ?? "unknown";
239
+ const accountId = fields.account_id ?? "default";
240
+ return (`[System: Ride Dispatch — Booking Confirmation]\n\n` +
241
+ `The tourist has selected a driver. Generate a Stripe payment link and send it.\n\n` +
242
+ `Job ID: ${jobId}\n` +
243
+ `Tourist phone: ${touristPhone}\n` +
244
+ `Selected driver: ${driverName} (${driverPhone})\n` +
245
+ `Agreed fare: ${fare}\n` +
246
+ `WhatsApp account: ${accountId}\n\n` +
247
+ `Process this request:\n` +
248
+ `1. Load the stripe skill and generate a Checkout Session for the booking fee\n` +
249
+ ` Set metadata: booking_id="${jobId}", tourist_phone="${touristPhone}"\n` +
250
+ `2. Message the tourist at ${touristPhone} with the payment link and booking terms\n` +
251
+ ` Use the message tool with accountId: "${accountId}"\n` +
252
+ `3. Record the booking details in shared/bookings/${jobId}.md via memory_write\n` +
253
+ `4. Clear the active negotiation files for drivers NOT selected (delete their shared/active-negotiations/{phone}.md)\n`);
254
+ }
255
+ function buildPaymentConfirmedInstruction(params) {
256
+ const { bookingId, touristPhone, accountId } = params;
257
+ return (`[System: Ride Dispatch — Payment Confirmed]\n\n` +
258
+ `Stripe has confirmed payment for this booking. Finalise the ride.\n\n` +
259
+ `Booking ID: ${bookingId}\n` +
260
+ `Tourist phone: ${touristPhone}\n` +
261
+ `WhatsApp account: ${accountId}\n\n` +
262
+ `Process this request:\n` +
263
+ `1. Read the booking record at shared/bookings/${bookingId}.md for driver details\n` +
264
+ `2. Generate the pickup PIN and QR code (see references/pin-qr.md)\n` +
265
+ `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` +
267
+ `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` +
269
+ `5. Update the booking record status to "confirmed"\n` +
270
+ `6. Clear the active negotiation file for the driver (shared/active-negotiations/{phone}.md)\n`);
271
+ }
272
+ // ---------------------------------------------------------------------------
273
+ // Event handlers
274
+ // ---------------------------------------------------------------------------
275
+ /**
276
+ * Handle memory:add events — dispatch files written by the public agent.
277
+ */
278
+ async function handleMemoryAdd(event) {
279
+ const context = event.context || {};
280
+ const relativePath = context.relativePath;
281
+ const agentId = context.agentId;
282
+ if (!relativePath || !agentId)
283
+ return;
284
+ // Only handle Beagle agents
285
+ if (!isBeagleAgent(agentId))
286
+ return;
287
+ // Match dispatch file pattern
288
+ const match = relativePath.match(DISPATCH_FILE_RE);
289
+ if (!match)
290
+ return;
291
+ const jobId = match[1]; // e.g. BGL-0042
292
+ const phase = match[2]; // trip-request | booking-confirm
293
+ // Dedup
294
+ const dedupKey = `dispatch:${relativePath}`;
295
+ if (isDuplicate(dedupKey))
296
+ return;
297
+ // Read the dispatch file
298
+ const filePath = context.filePath;
299
+ if (!filePath)
300
+ return;
301
+ let content;
302
+ try {
303
+ content = fs.readFileSync(filePath, "utf-8");
304
+ }
305
+ catch {
306
+ console.warn(`[ride-dispatch] Could not read dispatch file: ${filePath}`);
307
+ return;
308
+ }
309
+ const fields = parseDispatchFile(content);
310
+ // Load config (memory:add events don't carry cfg)
311
+ let cfg;
312
+ try {
313
+ const snapshot = await readConfigFileSnapshot();
314
+ cfg = snapshot.config;
315
+ }
316
+ catch {
317
+ console.warn("[ride-dispatch] Could not read config");
318
+ return;
319
+ }
320
+ const adminAgentId = findAdminAgentId(cfg);
321
+ if (!adminAgentId) {
322
+ console.warn("[ride-dispatch] No admin agent found in config");
323
+ return;
324
+ }
325
+ const accountId = fields.account_id ?? resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? "default";
326
+ let instruction;
327
+ if (phase === "trip-request") {
328
+ instruction = buildTripRequestInstruction(fields);
329
+ }
330
+ else if (phase === "booking-confirm") {
331
+ instruction = buildBookingConfirmInstruction(fields);
332
+ }
333
+ else {
334
+ return;
335
+ }
336
+ console.log(`[ride-dispatch] Dispatch file detected: ${relativePath} (${phase}), dispatching to admin agent "${adminAgentId}"`);
337
+ // Fire and forget
338
+ dispatchToAdmin({
339
+ cfg,
340
+ adminAgentId,
341
+ jobId,
342
+ instruction,
343
+ fromId: `ride-${jobId.toLowerCase()}`,
344
+ accountId,
345
+ }).catch((err) => {
346
+ console.error("[ride-dispatch] Failed to dispatch to admin:", err instanceof Error ? err.message : String(err));
347
+ });
348
+ }
349
+ /**
350
+ * Handle message:inbound events — driver replies routed to the public agent.
351
+ */
352
+ async function handleDriverReply(event) {
353
+ const context = event.context || {};
354
+ const cfg = context.cfg;
355
+ const text = context.text;
356
+ if (!cfg || !text?.trim())
357
+ return;
358
+ // Only act on public agent DM sessions
359
+ const agentId = resolveAgentIdFromSessionKey(event.sessionKey);
360
+ if (!agentId || !isBeagleAgent(agentId))
361
+ return;
362
+ if (!isPublicAgentConfig(cfg, agentId))
363
+ return;
364
+ // Extract sender phone from session key
365
+ const senderPhone = extractPeerFromSessionKey(event.sessionKey);
366
+ if (!senderPhone)
367
+ return;
368
+ // Resolve workspace dir from the agent config to find active-negotiations
369
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
370
+ // Check active negotiation index
371
+ const jobId = readActiveNegotiation(workspaceDir, senderPhone);
372
+ if (!jobId)
373
+ return; // Not a driver in active negotiation
374
+ // Dedup
375
+ const dedupKey = `reply:${senderPhone}:${Date.now().toString().slice(0, -4)}`; // ~10s granularity
376
+ if (isDuplicate(dedupKey))
377
+ return;
378
+ const adminAgentId = findAdminAgentId(cfg);
379
+ if (!adminAgentId)
380
+ return;
381
+ const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? "default";
382
+ const instruction = `[System: Ride Dispatch — Driver Reply]\n\n` +
383
+ `A driver has replied regarding job ${jobId}.\n\n` +
384
+ `Driver phone: ${senderPhone}\n` +
385
+ `Message: ${text}\n\n` +
386
+ `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`;
389
+ console.log(`[ride-dispatch] Driver reply from ${senderPhone} for ${jobId}, dispatching to admin agent "${adminAgentId}"`);
390
+ // Fire and forget
391
+ dispatchToAdmin({
392
+ cfg,
393
+ adminAgentId,
394
+ jobId,
395
+ instruction,
396
+ fromId: `ride-${jobId.toLowerCase()}`,
397
+ accountId,
398
+ }).catch((err) => {
399
+ console.error("[ride-dispatch] Failed to dispatch driver reply:", err instanceof Error ? err.message : String(err));
400
+ });
401
+ }
402
+ // ---------------------------------------------------------------------------
403
+ // Stripe webhook dispatch (called from gateway HTTP handler)
404
+ // ---------------------------------------------------------------------------
405
+ /**
406
+ * Dispatch a payment confirmation to the admin agent's ride session.
407
+ * Called by the Stripe webhook handler when checkout.session.completed fires.
408
+ */
409
+ export async function dispatchPaymentConfirmed(params) {
410
+ const { bookingId, touristPhone, accountId } = params;
411
+ const dedupKey = `payment:${bookingId}`;
412
+ if (isDuplicate(dedupKey))
413
+ return;
414
+ let cfg;
415
+ try {
416
+ const snapshot = await readConfigFileSnapshot();
417
+ cfg = snapshot.config;
418
+ }
419
+ catch {
420
+ console.warn("[ride-dispatch] Could not read config for payment dispatch");
421
+ return;
422
+ }
423
+ const adminAgentId = findAdminAgentId(cfg);
424
+ if (!adminAgentId) {
425
+ console.warn("[ride-dispatch] No admin agent for payment dispatch");
426
+ return;
427
+ }
428
+ const instruction = buildPaymentConfirmedInstruction({ bookingId, touristPhone, accountId });
429
+ console.log(`[ride-dispatch] Payment confirmed for ${bookingId}, dispatching to admin agent "${adminAgentId}"`);
430
+ await dispatchToAdmin({
431
+ cfg,
432
+ adminAgentId,
433
+ jobId: bookingId,
434
+ instruction,
435
+ fromId: `ride-${bookingId.toLowerCase()}`,
436
+ accountId,
437
+ });
438
+ }
439
+ // ---------------------------------------------------------------------------
440
+ // Main hook handler
441
+ // ---------------------------------------------------------------------------
442
+ const handleRideDispatch = async (event) => {
443
+ if (event.type === "memory" && event.action === "add") {
444
+ await handleMemoryAdd(event);
445
+ }
446
+ else if (event.type === "message" && event.action === "inbound") {
447
+ await handleDriverReply(event);
448
+ }
449
+ };
450
+ export default handleRideDispatch;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Stripe Webhook HTTP Handler for Ride Dispatch
3
+ *
4
+ * Handles `checkout.session.completed` events from Stripe to trigger
5
+ * automatic post-payment processing. When a tourist completes payment,
6
+ * this dispatches to the admin agent's ride session without requiring
7
+ * the tourist to say "I've paid."
8
+ *
9
+ * Route: POST /webhook/stripe
10
+ *
11
+ * The Stripe signing secret must be configured at config.apiKeys.stripe_webhook_secret.
12
+ */
13
+ import { createHmac } from "node:crypto";
14
+ import { createSubsystemLogger } from "../../../logging/subsystem.js";
15
+ import { readConfigFileSnapshot } from "../../../config/io.js";
16
+ import { dispatchPaymentConfirmed } from "./handler.js";
17
+ const log = createSubsystemLogger("stripe-webhook");
18
+ const WEBHOOK_PATH = "/webhook/stripe";
19
+ const MAX_BODY_BYTES = 65_536; // 64 KB — Stripe payloads are small
20
+ // ---------------------------------------------------------------------------
21
+ // Signature verification
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * Verify a Stripe webhook signature.
25
+ * Uses the v1 scheme: HMAC-SHA256 of "timestamp.payload" against the signing secret.
26
+ */
27
+ function verifyStripeSignature(payload, signatureHeader, secret, toleranceSeconds = 300) {
28
+ const elements = signatureHeader.split(",");
29
+ let timestamp = null;
30
+ const signatures = [];
31
+ for (const element of elements) {
32
+ const [key, value] = element.split("=");
33
+ if (key === "t")
34
+ timestamp = value;
35
+ if (key === "v1")
36
+ signatures.push(value);
37
+ }
38
+ if (!timestamp || signatures.length === 0)
39
+ return false;
40
+ // Check timestamp tolerance
41
+ const ts = parseInt(timestamp, 10);
42
+ if (isNaN(ts))
43
+ return false;
44
+ const age = Math.abs(Date.now() / 1000 - ts);
45
+ if (age > toleranceSeconds)
46
+ return false;
47
+ // Compute expected signature
48
+ const signedPayload = `${timestamp}.${payload}`;
49
+ const expected = createHmac("sha256", secret).update(signedPayload, "utf8").digest("hex");
50
+ return signatures.some((sig) => timingSafeEqual(sig, expected));
51
+ }
52
+ /** Constant-time string comparison. */
53
+ function timingSafeEqual(a, b) {
54
+ if (a.length !== b.length)
55
+ return false;
56
+ const bufA = Buffer.from(a, "utf8");
57
+ const bufB = Buffer.from(b, "utf8");
58
+ try {
59
+ const crypto = require("node:crypto");
60
+ return crypto.timingSafeEqual(bufA, bufB);
61
+ }
62
+ catch {
63
+ // Fallback — still works, just not constant-time
64
+ return a === b;
65
+ }
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // HTTP body reader
69
+ // ---------------------------------------------------------------------------
70
+ async function readRawBody(req, maxBytes) {
71
+ return new Promise((resolve, reject) => {
72
+ let body = "";
73
+ let bytes = 0;
74
+ req.setEncoding("utf8");
75
+ req.on("data", (chunk) => {
76
+ bytes += Buffer.byteLength(chunk);
77
+ if (bytes > maxBytes) {
78
+ reject(new Error("Request body too large"));
79
+ req.destroy();
80
+ return;
81
+ }
82
+ body += chunk;
83
+ });
84
+ req.on("end", () => resolve(body));
85
+ req.on("error", reject);
86
+ });
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // Handler
90
+ // ---------------------------------------------------------------------------
91
+ /**
92
+ * Create an HTTP request handler for Stripe webhooks.
93
+ *
94
+ * Wired into the gateway HTTP server chain in server-http.ts.
95
+ * Returns `true` if the request was handled (matched the webhook path),
96
+ * `false` otherwise (pass to next handler).
97
+ */
98
+ export function createStripeWebhookHandler() {
99
+ return async (req, res) => {
100
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
101
+ if (url.pathname !== WEBHOOK_PATH)
102
+ return false;
103
+ // Only POST
104
+ if (req.method !== "POST") {
105
+ res.writeHead(405, { "Content-Type": "application/json" });
106
+ res.end(JSON.stringify({ error: "Method not allowed" }));
107
+ return true;
108
+ }
109
+ // Read config to get signing secret
110
+ let signingSecret;
111
+ try {
112
+ const snapshot = await readConfigFileSnapshot();
113
+ signingSecret = snapshot.config.apiKeys
114
+ ?.stripe_webhook_secret;
115
+ }
116
+ catch {
117
+ log.warn("Could not read config for Stripe webhook secret");
118
+ }
119
+ // Read body
120
+ let rawBody;
121
+ try {
122
+ rawBody = await readRawBody(req, MAX_BODY_BYTES);
123
+ }
124
+ catch (err) {
125
+ res.writeHead(400, { "Content-Type": "application/json" });
126
+ res.end(JSON.stringify({ error: "Invalid request body" }));
127
+ return true;
128
+ }
129
+ // Verify signature if secret is configured
130
+ if (signingSecret) {
131
+ const signatureHeader = req.headers["stripe-signature"];
132
+ if (typeof signatureHeader !== "string") {
133
+ res.writeHead(401, { "Content-Type": "application/json" });
134
+ res.end(JSON.stringify({ error: "Missing Stripe-Signature header" }));
135
+ return true;
136
+ }
137
+ if (!verifyStripeSignature(rawBody, signatureHeader, signingSecret)) {
138
+ log.warn("Stripe webhook signature verification failed");
139
+ res.writeHead(401, { "Content-Type": "application/json" });
140
+ res.end(JSON.stringify({ error: "Invalid signature" }));
141
+ return true;
142
+ }
143
+ }
144
+ else {
145
+ log.warn("No stripe_webhook_secret configured — accepting webhook without signature verification");
146
+ }
147
+ // Parse event
148
+ let event;
149
+ try {
150
+ event = JSON.parse(rawBody);
151
+ }
152
+ catch {
153
+ res.writeHead(400, { "Content-Type": "application/json" });
154
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
155
+ return true;
156
+ }
157
+ // Only handle checkout.session.completed
158
+ if (event.type !== "checkout.session.completed") {
159
+ // Acknowledge but ignore other event types
160
+ res.writeHead(200, { "Content-Type": "application/json" });
161
+ res.end(JSON.stringify({ received: true }));
162
+ return true;
163
+ }
164
+ const session = event.data?.object;
165
+ if (!session) {
166
+ res.writeHead(400, { "Content-Type": "application/json" });
167
+ res.end(JSON.stringify({ error: "Missing session object" }));
168
+ return true;
169
+ }
170
+ const metadata = session.metadata;
171
+ const bookingId = metadata?.booking_id;
172
+ const touristPhone = metadata?.tourist_phone;
173
+ const accountId = metadata?.account_id ?? "default";
174
+ if (!bookingId || !touristPhone) {
175
+ // Not a ride booking payment — ignore silently
176
+ log.info("Stripe checkout completed but missing booking_id/tourist_phone metadata — ignoring");
177
+ res.writeHead(200, { "Content-Type": "application/json" });
178
+ res.end(JSON.stringify({ received: true }));
179
+ return true;
180
+ }
181
+ log.info(`Stripe payment confirmed for booking ${bookingId} (tourist: ${touristPhone})`);
182
+ // Respond immediately — dispatch is async
183
+ res.writeHead(200, { "Content-Type": "application/json" });
184
+ res.end(JSON.stringify({ received: true }));
185
+ // Fire and forget
186
+ dispatchPaymentConfirmed({ bookingId, touristPhone, accountId }).catch((err) => {
187
+ log.warn(`Failed to dispatch payment confirmation for ${bookingId}: ${String(err)}`);
188
+ });
189
+ return true;
190
+ };
191
+ }
@@ -36,6 +36,26 @@ export function isMemoryPath(relPath) {
36
36
  return true;
37
37
  return normalized.startsWith("memory/");
38
38
  }
39
+ /**
40
+ * Ensure a relative path has the `memory/` prefix.
41
+ *
42
+ * Agents are sometimes instructed to strip the prefix (system prompt) while
43
+ * the storage layer requires it. Accept both forms so that either convention
44
+ * works:
45
+ * "public/data.md" → "memory/public/data.md"
46
+ * "memory/public/data.md" → "memory/public/data.md" (unchanged)
47
+ * "MEMORY.md" → "MEMORY.md" (root file, unchanged)
48
+ */
49
+ export function ensureMemoryPrefix(relPath) {
50
+ const normalized = normalizeRelPath(relPath);
51
+ if (!normalized)
52
+ return normalized;
53
+ if (normalized === "MEMORY.md" || normalized === "memory.md")
54
+ return normalized;
55
+ if (normalized.startsWith("memory/"))
56
+ return normalized;
57
+ return `memory/${normalized}`;
58
+ }
39
59
  async function exists(filePath) {
40
60
  try {
41
61
  await fs.access(filePath);
@@ -113,7 +133,10 @@ export async function buildFileEntry(absPath, workspaceDir) {
113
133
  // Binary files (PDF, DOCX, etc.) are hashed from raw bytes; text files from UTF-8.
114
134
  const binary = isBinaryMemoryFile(absPath);
115
135
  const hash = binary
116
- ? crypto.createHash("sha256").update(await fs.readFile(absPath)).digest("hex")
136
+ ? crypto
137
+ .createHash("sha256")
138
+ .update(await fs.readFile(absPath))
139
+ .digest("hex")
117
140
  : hashText(await fs.readFile(absPath, "utf-8"));
118
141
  return {
119
142
  path: path.relative(workspaceDir, absPath).replace(/\\/g, "/"),