@rubytech/taskmaster 1.19.0 → 1.19.1

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.
@@ -6,7 +6,7 @@
6
6
  <title>Taskmaster Control</title>
7
7
  <meta name="color-scheme" content="dark light" />
8
8
  <link rel="icon" type="image/png" href="./favicon.png" />
9
- <script type="module" crossorigin src="./assets/index-BuvDuhk9.js"></script>
9
+ <script type="module" crossorigin src="./assets/index-mjAT1dyG.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="./assets/index-0WHVrpg7.css">
11
11
  </head>
12
12
  <body>
@@ -62,6 +62,39 @@ export function loadCronTemplatesFromBundledSkills(bundledSkillsDir) {
62
62
  // ---------------------------------------------------------------------------
63
63
  // Seeding function — create cron jobs from un-seeded templates
64
64
  // ---------------------------------------------------------------------------
65
+ /**
66
+ * Seed cron jobs from a single workspace's skills directory into a specific account.
67
+ * Used for workspace-template skills that live outside the bundled skills directory.
68
+ * Reuses the same tracker, dedup logic, and file format as bundled skill seeding.
69
+ */
70
+ export async function seedCronJobsFromSkillsDir(params) {
71
+ const { skillsDir, accountId, trackerPath, cronService } = params;
72
+ const templates = loadCronTemplatesFromBundledSkills(skillsDir);
73
+ if (templates.length === 0)
74
+ return 0;
75
+ const tracker = await loadSeedTracker(trackerPath);
76
+ let seeded = 0;
77
+ for (const template of templates) {
78
+ if (isTemplateSeeded(tracker, template.templateId, accountId))
79
+ continue;
80
+ const job = await cronService.add({
81
+ name: template.name,
82
+ description: template.description,
83
+ enabled: template.enabled,
84
+ agentId: template.agentId,
85
+ accountId,
86
+ schedule: template.schedule,
87
+ sessionTarget: template.sessionTarget,
88
+ wakeMode: template.wakeMode,
89
+ payload: template.payload,
90
+ isolation: template.isolation,
91
+ });
92
+ markTemplateSeeded(tracker, template.templateId, job.id, accountId);
93
+ await saveSeedTracker(trackerPath, tracker);
94
+ seeded++;
95
+ }
96
+ return seeded;
97
+ }
65
98
  export async function seedPreloadedCronJobs(params) {
66
99
  const { bundledSkillsDir, trackerPath, cronService, accountIds } = params;
67
100
  if (accountIds.length === 0)
@@ -449,8 +449,9 @@ export function handlePublicChatHttpRequest(req, res, opts) {
449
449
  const pathname = url.pathname;
450
450
  if (!pathname.startsWith("/public/"))
451
451
  return false;
452
- // Static asset passthrough (JS/CSS/images served from /public/assets/*)
453
- if (pathname.startsWith("/public/assets/")) {
452
+ // Static asset passthrough (JS/CSS/images served from /public/assets/* and root-level
453
+ // static files like /public/favicon.png referenced by the SPA via <base href="/public/">)
454
+ if (pathname.startsWith("/public/assets/") || pathname === "/public/favicon.png") {
454
455
  const root = resolveControlUiRoot();
455
456
  if (!root) {
456
457
  respondNotFound(res);
@@ -558,13 +559,14 @@ export function handlePublicChatHttpRequest(req, res, opts) {
558
559
  accentColor,
559
560
  backgroundColor,
560
561
  });
561
- // Inject <base href="/"> right after <head> so relative asset paths (./assets/...)
562
- // resolve from root. The URL is /public/chat/:accountId 3 levels deep, so without
563
- // <base> the browser would look for /public/chat/assets/... which doesn't exist.
562
+ // Inject <base href="/public/"> right after <head> so relative asset paths (./assets/...)
563
+ // resolve to /public/assets/... within the /public/* prefix that the Funnel external
564
+ // request filter allows. Using <base href="/"> would route assets to /assets/... which
565
+ // is blocked for Funnel (public internet) requests, making the widget blank externally.
564
566
  // The <base> tag MUST appear before any tags that use relative URLs.
565
567
  const headOpen = injected.indexOf("<head>");
566
568
  const baseInjected = headOpen !== -1
567
- ? `${injected.slice(0, headOpen + 6)}<base href="/">${injected.slice(headOpen + 6)}`
569
+ ? `${injected.slice(0, headOpen + 6)}<base href="/public/">${injected.slice(headOpen + 6)}`
568
570
  : injected;
569
571
  // Detect available OTP delivery channels for the public chat config
570
572
  const whatsappAccountId = resolveAgentBoundAccountId(config, publicAgentId, "whatsapp") ?? undefined;
@@ -10,6 +10,8 @@ export const LogsTailParamsSchema = Type.Object({
10
10
  Type.Literal("warn"),
11
11
  Type.Literal("info"),
12
12
  ])),
13
+ /** PIN access session token. Required when the device has a master PIN and the caller is the control UI. */
14
+ accessToken: Type.Optional(Type.String()),
13
15
  }, { additionalProperties: false });
14
16
  export const LogsTailResultSchema = Type.Object({
15
17
  file: NonEmptyString,
@@ -217,7 +217,8 @@ export function createGatewayHttpServer(opts) {
217
217
  const trustedProxies = getEffectiveTrustedProxies(configSnapshot);
218
218
  if (isExternalRequest(req, trustedProxies)) {
219
219
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
220
- if (!pathname.startsWith("/public/")) {
220
+ const isAllowedExternal = pathname.startsWith("/public/") || pathname.startsWith("/webhook/");
221
+ if (!isAllowedExternal) {
221
222
  res.statusCode = 403;
222
223
  res.setHeader("Content-Type", "text/plain; charset=utf-8");
223
224
  res.end("Forbidden");
@@ -60,6 +60,19 @@ function validateSession(token) {
60
60
  return session;
61
61
  }
62
62
  // ---------------------------------------------------------------------------
63
+ // Exported session helpers
64
+ // ---------------------------------------------------------------------------
65
+ /**
66
+ * Returns true when the given token corresponds to a valid master (device-owner) session.
67
+ * Used by handlers that must be restricted to admin-only callers.
68
+ */
69
+ export function isMasterSession(token) {
70
+ if (!token)
71
+ return false;
72
+ const session = validateSession(token);
73
+ return session !== null && session.master === true;
74
+ }
75
+ // ---------------------------------------------------------------------------
63
76
  // RPC Handlers
64
77
  // ---------------------------------------------------------------------------
65
78
  export const accessHandlers = {
@@ -1,9 +1,12 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { loadConfig } from "../../config/config.js";
3
4
  import { getResolvedLoggerSettings } from "../../logging.js";
4
5
  import { levelToMinLevel, ALLOWED_LOG_LEVELS } from "../../logging/levels.js";
5
6
  import { parseLogLine } from "../../logging/parse-log-line.js";
6
7
  import { ErrorCodes, errorShape, formatValidationErrors, validateLogsTailParams, } from "../protocol/index.js";
8
+ import { isMasterSession } from "./access.js";
9
+ const CONTROL_UI_CLIENT_ID = "taskmaster-control-ui";
7
10
  const DEFAULT_LIMIT = 500;
8
11
  const DEFAULT_MAX_BYTES = 250_000;
9
12
  const MAX_LIMIT = 5000;
@@ -121,12 +124,24 @@ async function readLogSlice(params) {
121
124
  }
122
125
  }
123
126
  export const logsHandlers = {
124
- "logs.tail": async ({ params, respond }) => {
127
+ "logs.tail": async ({ params, client, respond }) => {
125
128
  if (!validateLogsTailParams(params)) {
126
129
  respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid logs.tail params: ${formatValidationErrors(validateLogsTailParams.errors)}`));
127
130
  return;
128
131
  }
129
132
  const p = params;
133
+ // System logs are not account-scoped and contain cross-account private information.
134
+ // When the caller is the control UI and a master PIN is configured, require the
135
+ // access session token to belong to a master (admin) session.
136
+ const isControlUi = client?.connect?.client?.id === CONTROL_UI_CLIENT_ID;
137
+ if (isControlUi) {
138
+ const config = loadConfig();
139
+ const hasMasterPin = Boolean(config.access?.masterPin);
140
+ if (hasMasterPin && !isMasterSession(p.accessToken)) {
141
+ respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unauthorized: system logs require admin login"));
142
+ return;
143
+ }
144
+ }
130
145
  const configuredFile = getResolvedLoggerSettings().file;
131
146
  try {
132
147
  const file = await resolveLogFile(configuredFile);
@@ -1,4 +1,5 @@
1
- import { listAgentIds, resolveAgentWorkspaceDir, resolveDefaultAgentId, } from "../agents/agent-scope.js";
1
+ import path from "node:path";
2
+ import { listAgentIds, resolveAgentWorkspaceDir, resolveAgentWorkspaceRoot, resolveDefaultAgentId, } from "../agents/agent-scope.js";
2
3
  import { initSubagentRegistry } from "../agents/subagent-registry.js";
3
4
  import { bumpSkillsSnapshotVersion, registerSkillsChangeListener, } from "../agents/skills/refresh.js";
4
5
  import { syncBundledSkillsToWorkspace } from "../agents/skills/workspace.js";
@@ -38,7 +39,7 @@ import { createChannelManager } from "./server-channels.js";
38
39
  import { createAgentEventHandler } from "./server-chat.js";
39
40
  import { createGatewayCloseHandler } from "./server-close.js";
40
41
  import { resolveBundledSkillsDir } from "../agents/skills/bundled-dir.js";
41
- import { DEFAULT_SEED_TRACKER_PATH, seedPreloadedCronJobs } from "../cron/preloaded.js";
42
+ import { DEFAULT_SEED_TRACKER_PATH, seedCronJobsFromSkillsDir, seedPreloadedCronJobs, } from "../cron/preloaded.js";
42
43
  import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
43
44
  import { buildGatewayCronService } from "./server-cron.js";
44
45
  import { applyGatewayLaneConcurrency } from "./server-lanes.js";
@@ -496,28 +497,49 @@ export async function startGatewayServer(port = 18789, opts = {}) {
496
497
  }
497
498
  });
498
499
  let heartbeatRunner = startHeartbeatRunner({ cfg: cfgAtStart });
499
- // Start cron, then seed preloaded cron jobs from bundled skills (one-time per template).
500
+ // Start cron, then seed preloaded cron jobs (one-time per template per account).
500
501
  void cron
501
502
  .start()
502
503
  .then(async () => {
503
504
  const bundledSkillsDir = resolveBundledSkillsDir();
504
- if (!bundledSkillsDir)
505
- return;
506
- try {
507
- const workspaceIds = Object.keys(cfgAtStart.workspaces ?? {});
508
- const accountIds = workspaceIds.length > 0 ? workspaceIds : [DEFAULT_ACCOUNT_ID];
509
- const seeded = await seedPreloadedCronJobs({
510
- bundledSkillsDir,
511
- trackerPath: DEFAULT_SEED_TRACKER_PATH,
512
- cronService: cron,
513
- accountIds,
514
- });
515
- if (seeded > 0) {
516
- logCron.info(`cron: seeded ${seeded} preloaded job(s)`);
505
+ const workspaceIds = Object.keys(cfgAtStart.workspaces ?? {});
506
+ const accountIds = workspaceIds.length > 0 ? workspaceIds : [DEFAULT_ACCOUNT_ID];
507
+ // Seed from bundled skills (same templates for all accounts).
508
+ if (bundledSkillsDir) {
509
+ try {
510
+ const seeded = await seedPreloadedCronJobs({
511
+ bundledSkillsDir,
512
+ trackerPath: DEFAULT_SEED_TRACKER_PATH,
513
+ cronService: cron,
514
+ accountIds,
515
+ });
516
+ if (seeded > 0) {
517
+ logCron.info(`cron: seeded ${seeded} preloaded job(s)`);
518
+ }
519
+ }
520
+ catch (err) {
521
+ logCron.error(`cron: failed to seed preloaded jobs: ${String(err)}`);
517
522
  }
518
523
  }
519
- catch (err) {
520
- logCron.error(`cron: failed to seed preloaded jobs: ${String(err)}`);
524
+ // Seed from each workspace's own skills directory (per-account templates).
525
+ // Uses the {accountId}-admin naming convention to resolve the workspace root.
526
+ for (const accountId of workspaceIds) {
527
+ try {
528
+ const workspaceRoot = resolveAgentWorkspaceRoot(cfgAtStart, `${accountId}-admin`);
529
+ const workspaceSkillsDir = path.join(workspaceRoot, "skills");
530
+ const wsSeeded = await seedCronJobsFromSkillsDir({
531
+ skillsDir: workspaceSkillsDir,
532
+ accountId,
533
+ trackerPath: DEFAULT_SEED_TRACKER_PATH,
534
+ cronService: cron,
535
+ });
536
+ if (wsSeeded > 0) {
537
+ logCron.info(`cron: seeded ${wsSeeded} workspace job(s) for account ${accountId}`);
538
+ }
539
+ }
540
+ catch (err) {
541
+ logCron.error(`cron: failed to seed workspace jobs for account ${accountId}: ${String(err)}`);
542
+ }
521
543
  }
522
544
  })
523
545
  .catch((err) => logCron.error(`failed to start: ${String(err)}`));
@@ -233,7 +233,7 @@ function buildTripRequestInstruction(fields, resolvedAccountId) {
233
233
  const fareEstimate = fields.fare_estimate ?? "unknown";
234
234
  const accountId = resolvedAccountId;
235
235
  return (`[System: Ride Dispatch — Trip Request]\n\n` +
236
- `A tourist has requested a ride. Process this by contacting available drivers.\n\n` +
236
+ `A tourist has requested a ride. Execute every step below in exact order. Do not skip or defer any step.\n\n` +
237
237
  `Job ID: ${jobId}\n` +
238
238
  `Tourist phone: ${touristPhone}\n` +
239
239
  `Tourist name: ${touristName}\n` +
@@ -244,63 +244,145 @@ function buildTripRequestInstruction(fields, resolvedAccountId) {
244
244
  `Passengers: ${passengers}\n` +
245
245
  `Luggage: ${luggage}\n` +
246
246
  `Special requests: ${specialRequests}\n` +
247
- `Fare estimate: ${fareEstimate}\n` +
247
+ `Fare estimate (knowledge base): ${fareEstimate}\n` +
248
248
  `WhatsApp account: ${accountId}\n\n` +
249
- `Process this request:\n` +
250
- `1. Call contact_lookup to get the driver roster\n` +
251
- `2. For each driver, call memory_get on drivers/{name}.md to check their status\n` +
252
- `3. Select up to 3 idle drivers (prefer those with route history for this route)\n` +
253
- `4. For each selected driver, update their status to awaiting_response via memory_write\n` +
254
- `5. Write shared/active-negotiations/{driver-phone}.md with job_id: ${jobId} for each driver\n` +
255
- `6. Message each driver in Swahili with the route details, pickup time, passengers, and job ID [${jobId}]\n` +
256
- ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
257
- `7. Message the tourist at ${touristPhone} confirming you've contacted drivers and are waiting for quotes\n` +
258
- ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
259
- `8. When drivers reply with quotes (dispatched to this session), compile the offers\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 offersmessage the tourist directly\n`);
249
+ `STEP 1 — Get the driver roster\n` +
250
+ `Call contact_lookup with no filter to get all registered drivers.\n\n` +
251
+ `STEP 2 Check driver availability\n` +
252
+ `For each driver returned, call memory_get on drivers/{name}.md and read the status field.\n` +
253
+ `Select up to 3 drivers whose status is "idle". Prefer drivers whose Route History includes "${pickup}" or "${destination}".\n\n` +
254
+ `STEP 3 — Update driver state and write negotiation index\n` +
255
+ `For each selected driver, do both of the following:\n` +
256
+ ` a. memory_write to drivers/{name}.md set status: awaiting_response, current_booking: ${jobId}\n` +
257
+ ` b. memory_write to shared/active-negotiations/{phone-digits}.md (phone digits only, no + or spaces):\n` +
258
+ ` job_id: ${jobId}\n` +
259
+ ` driver_name: [driver name]\n` +
260
+ ` contacted_at: [current_time output]\n\n` +
261
+ `STEP 4 Message each driver (fare enquiry, not a confirmed booking)\n` +
262
+ `For each selected driver, call message with channel: "whatsapp", accountId: "${accountId}", to: [driver phone].\n` +
263
+ `Send in Swahili frame as a competitive quote request, not a confirmed job. Include: route, date, time, passengers, luggage, job ID.\n` +
264
+ `Example: "Habari [name], nina mteja anayehitaji gari kutoka ${pickup} hadi ${destination}, ${date} saa ${time}. Abiria ${passengers}, mizigo: ${luggage}. Ninapata bei kutoka madereva kadhaa tafadhali niambie bei yako bora na kama unapatikana. Kumbukumbu: [${jobId}]."\n\n` +
265
+ `STEP 5 — Acknowledge to the tourist\n` +
266
+ `Call message with channel: "whatsapp", accountId: "${accountId}", to: ${touristPhone}.\n` +
267
+ `Send exactly: "I've reached out to our available drivers and I'm collecting quotes for [${jobId}]. I'll send you the options in a few minutes."\n\n` +
268
+ `--- WAIT FOR DRIVER REPLIES ---\n` +
269
+ `Driver replies arrive in this session as [System: Ride Dispatch — Driver Reply] messages.\n\n` +
270
+ `STEP 6 — Record each quote (on each Driver Reply message)\n` +
271
+ `Via memory_write to drivers/{name}.md — append under "## Quotes": "${jobId}: $[fare] quoted [date]"\n\n` +
272
+ `STEP 7 — Write offers file and message the tourist (when all expected quotes are in, or after 5 minutes)\n` +
273
+ ` a. memory_write to shared/offers/${jobId}.md with exactly this format:\n` +
274
+ ` # Offers: ${jobId}\n` +
275
+ ` job_id: ${jobId}\n` +
276
+ ` status: ready\n` +
277
+ ` tourist_phone: ${touristPhone}\n` +
278
+ ` offer_1_driver: [name]\n` +
279
+ ` offer_1_phone: [digits only]\n` +
280
+ ` offer_1_vehicle: [vehicle type]\n` +
281
+ ` offer_1_rating: [rating]\n` +
282
+ ` offer_1_fare: [USD amount as number]\n` +
283
+ ` offer_1_duration: [estimated minutes as number]\n` +
284
+ ` [offer_2_* fields if applicable]\n` +
285
+ ` [offer_3_* fields if applicable]\n` +
286
+ ` Sort by fare ascending. Only include offers from drivers who actually quoted.\n` +
287
+ ` b. Call message with channel: "whatsapp", accountId: "${accountId}", to: ${touristPhone}.\n` +
288
+ ` Send exactly:\n` +
289
+ ` "Here are the quotes for your trip [${jobId}]:\n` +
290
+ ` Option 1 — $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
291
+ ` Option 2 — $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
292
+ ` Option 3 — $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
293
+ ` Which option would you prefer?"\n` +
294
+ ` (Omit Option lines for offers you don't have. Do not include driver names or phone numbers.)\n`);
265
295
  }
266
296
  function buildBookingConfirmInstruction(fields, resolvedAccountId) {
267
297
  const jobId = fields.job_id ?? "UNKNOWN";
268
298
  const touristPhone = fields.tourist_phone ?? "unknown";
299
+ const touristName = fields.tourist_name ?? "unknown";
269
300
  const driverName = fields.driver_name ?? "unknown";
270
301
  const driverPhone = fields.driver_phone ?? "unknown";
302
+ const selectedOffer = fields.selected_offer ?? "unknown";
271
303
  const fare = fields.fare ?? "unknown";
272
304
  const accountId = resolvedAccountId;
273
305
  return (`[System: Ride Dispatch — Booking Confirmation]\n\n` +
274
- `The tourist has selected a driver. Generate a Stripe payment link and send it.\n\n` +
306
+ `The tourist has selected a driver. Execute every step below in exact order.\n\n` +
275
307
  `Job ID: ${jobId}\n` +
276
308
  `Tourist phone: ${touristPhone}\n` +
277
- `Selected driver: ${driverName} (${driverPhone})\n` +
278
- `Agreed fare: ${fare}\n` +
309
+ `Tourist name: ${touristName}\n` +
310
+ `Selected: ${selectedOffer} — ${driverName} (phone digits: ${driverPhone})\n` +
311
+ `Agreed fare: $${fare}\n` +
279
312
  `WhatsApp account: ${accountId}\n\n` +
280
- `Process this request:\n` +
281
- `1. Load the stripe skill and generate a Checkout Session for the booking fee\n` +
282
- ` Set metadata: booking_id="${jobId}", tourist_phone="${touristPhone}"\n` +
283
- `2. Message the tourist at ${touristPhone} with the payment link and booking terms\n` +
284
- ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
285
- `3. Record the booking details in shared/bookings/${jobId}.md via memory_write\n` +
286
- `4. Clear the active negotiation files for drivers NOT selected (delete their shared/active-negotiations/{phone}.md)\n`);
313
+ `STEP 1 — Load the Stripe skill\n` +
314
+ `Load the stripe skill and create a Checkout Session for $2.00 USD (booking fee):\n` +
315
+ ` - success_url: https://zanzibar.beagle.taxi/booking-confirmed\n` +
316
+ ` - cancel_url: https://zanzibar.beagle.taxi/booking-cancelled\n` +
317
+ ` - metadata: booking_id="${jobId}", tourist_phone="${touristPhone}", account_id="${accountId}"\n\n` +
318
+ `STEP 2 — Write the booking record\n` +
319
+ `memory_write to shared/bookings/${jobId}.md with exactly this format:\n` +
320
+ ` # Booking: ${jobId}\n` +
321
+ ` status: payment_pending\n` +
322
+ ` tourist_phone: ${touristPhone}\n` +
323
+ ` tourist_name: ${touristName}\n` +
324
+ ` driver_name: ${driverName}\n` +
325
+ ` driver_phone: ${driverPhone}\n` +
326
+ ` vehicle: [vehicle type from shared/offers/${jobId}.md]\n` +
327
+ ` plate: [driver's plate from contact record]\n` +
328
+ ` pickup: [from trip request]\n` +
329
+ ` destination: [from trip request]\n` +
330
+ ` date: [from trip request]\n` +
331
+ ` time: [from trip request]\n` +
332
+ ` fare: ${fare}\n` +
333
+ ` payment_url: [Stripe Checkout URL]\n` +
334
+ ` created_at: [current_time output]\n` +
335
+ ` pin: \n\n` +
336
+ `STEP 3 — Message the tourist with the payment link\n` +
337
+ `Call message with channel: "whatsapp", accountId: "${accountId}", to: ${touristPhone}.\n` +
338
+ `Send exactly:\n` +
339
+ `"Great news! I've found your driver for [${jobId}]. To confirm your booking, please pay the $2 reservation fee:\n` +
340
+ `[payment link]\n` +
341
+ `This secures your driver for [date] at [time]. Your driver's full details and pickup verification PIN will be sent immediately after payment."\n\n` +
342
+ `STEP 4 — Clear unselected driver negotiations\n` +
343
+ `For each driver contacted for ${jobId} who was NOT selected:\n` +
344
+ ` - memory_write to shared/active-negotiations/{phone-digits}.md with empty content (clears the file)\n` +
345
+ ` - memory_write to drivers/{name}.md — set status: idle, current_booking: null\n`);
287
346
  }
288
347
  function buildPaymentConfirmedInstruction(params) {
289
348
  const { bookingId, touristPhone, accountId } = params;
290
349
  return (`[System: Ride Dispatch — Payment Confirmed]\n\n` +
291
- `Stripe has confirmed payment for this booking. Finalise the ride.\n\n` +
350
+ `Stripe has confirmed payment for this booking. Execute every step below in exact order.\n\n` +
292
351
  `Booking ID: ${bookingId}\n` +
293
352
  `Tourist phone: ${touristPhone}\n` +
294
353
  `WhatsApp account: ${accountId}\n\n` +
295
- `Process this request:\n` +
296
- `1. Read the booking record at shared/bookings/${bookingId}.md for driver details\n` +
297
- `2. Generate the pickup PIN and QR code (see references/pin-qr.md)\n` +
298
- `3. Message the tourist at ${touristPhone} with: driver name, phone, vehicle details, plate, and pickup PIN\n` +
299
- ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
300
- `4. Message the driver with: passenger name, pickup time/location, fare, and QR code URL\n` +
301
- ` Use the message tool with channel: "whatsapp", accountId: "${accountId}"\n` +
302
- `5. Update the booking record status to "confirmed"\n` +
303
- `6. Clear the active negotiation file for the driver (shared/active-negotiations/{phone}.md)\n`);
354
+ `STEP 1 — Read the booking record\n` +
355
+ `Call memory_get on shared/bookings/${bookingId}.md.\n` +
356
+ `Extract: tourist_phone, tourist_name, driver_name, driver_phone, vehicle, plate, pickup, destination, date, time, fare.\n\n` +
357
+ `STEP 2 Generate the pickup PIN\n` +
358
+ `Generate a random 4-digit integer between 1000 and 9999 (e.g. 4827).\n` +
359
+ `QR code URL: https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=PIN%3A+{pin}\n` +
360
+ `(Replace {pin} with the actual 4 digits. This URL returns a PNG image the driver can show tourists.)\n\n` +
361
+ `STEP 3 Message the tourist\n` +
362
+ `Call message with channel: "whatsapp", accountId: "${accountId}", to: [tourist_phone from booking record].\n` +
363
+ `Send exactly:\n` +
364
+ `"[${bookingId}] Your booking is confirmed! Here are your driver details:\n` +
365
+ `Driver: [driver_name]\n` +
366
+ `Phone: [driver_phone]\n` +
367
+ `Vehicle: [vehicle] — Plate: [plate]\n` +
368
+ `Pickup: [pickup] at [time] on [date]\n` +
369
+ `\n` +
370
+ `Your verification PIN: *[pin]*\n` +
371
+ `When your driver arrives, ask them to say your PIN — it should match this number. This confirms you're in the right car."\n\n` +
372
+ `STEP 4 — Message the driver with the QR code\n` +
373
+ `Call message with channel: "whatsapp", accountId: "${accountId}", to: [driver_phone from booking record],\n` +
374
+ ` media: https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=PIN%3A+[pin],\n` +
375
+ ` body: "[${bookingId}] You have a confirmed booking.\n` +
376
+ `Pickup: [pickup] → [destination]\n` +
377
+ `Time: [time] on [date]\n` +
378
+ `Passenger: [tourist_name]\n` +
379
+ `Fare: $[fare]\n` +
380
+ `\n` +
381
+ `Your pickup PIN is [pin]. When you meet your passenger, say: 'Your PIN is [pin].' They will confirm it matches theirs. The QR code above is a backup they can scan instead."\n\n` +
382
+ `STEP 5 — Update the booking record\n` +
383
+ `memory_write to shared/bookings/${bookingId}.md — set status: confirmed, set pin: [pin].\n\n` +
384
+ `STEP 6 — Clear the active negotiation file for the confirmed driver\n` +
385
+ `memory_write to shared/active-negotiations/[driver_phone_digits].md with empty content.\n`);
304
386
  }
305
387
  // ---------------------------------------------------------------------------
306
388
  // Event handlers
@@ -430,19 +512,48 @@ async function handleDriverReply(event) {
430
512
  const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ??
431
513
  (agentId.replace(/-(public|admin)$/, "") || "default");
432
514
  const instruction = `[System: Ride Dispatch — Driver Reply]\n\n` +
433
- `A driver has replied regarding job ${jobId}.\n\n` +
434
- `Driver phone: ${senderPhone}\n` +
515
+ `A driver has replied for job ${jobId}. Execute the correct branch below.\n\n` +
516
+ `Driver phone digits: ${senderPhone}\n` +
435
517
  `Message: ${text}\n\n` +
436
- `Process this reply in the context of the ongoing negotiation for ${jobId}.\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`;
518
+ `IF THE DRIVER IS QUOTING A FARE:\n` +
519
+ ` STEP A Look up the driver's name\n` +
520
+ ` Call contact_lookup and match the driver by phone digits "${senderPhone}" to get their name and vehicle.\n\n` +
521
+ ` STEP B Record the quote\n` +
522
+ ` memory_write to drivers/{name}.md append under "## Quotes": "${jobId}: $[fare] quoted [today's date]"\n\n` +
523
+ ` STEP C Check whether all expected quotes are in\n` +
524
+ ` Read shared/active-negotiations/ to count how many drivers were contacted for ${jobId}.\n` +
525
+ ` If all have replied, OR if it has been 5+ minutes since the trip-request, proceed to STEP D.\n` +
526
+ ` If still waiting for replies, stop here and wait.\n\n` +
527
+ ` STEP D Write the offers file\n` +
528
+ ` Call memory_get on shared/dispatch/${jobId}-trip-request.md to read tourist_phone.\n` +
529
+ ` Read shared/offers/${jobId}.md if it exists. If not, create it.\n` +
530
+ ` Add or update this driver's offer entry in shared/offers/${jobId}.md (memory_write):\n` +
531
+ ` # Offers: ${jobId}\n` +
532
+ ` job_id: ${jobId}\n` +
533
+ ` status: ready\n` +
534
+ ` tourist_phone: [tourist_phone from shared/dispatch/${jobId}-trip-request.md]\n` +
535
+ ` offer_N_driver: [name]\n` +
536
+ ` offer_N_phone: ${senderPhone}\n` +
537
+ ` offer_N_vehicle: [vehicle type]\n` +
538
+ ` offer_N_rating: [rating from drivers/{name}.md]\n` +
539
+ ` offer_N_fare: [quoted fare as USD number]\n` +
540
+ ` offer_N_duration: [estimated minutes]\n` +
541
+ ` (N = 1, 2, or 3 for each driver, sorted by fare ascending.)\n\n` +
542
+ ` STEP E — Message the tourist with formatted options\n` +
543
+ ` Read tourist_phone from shared/offers/${jobId}.md.\n` +
544
+ ` Call message with channel: "whatsapp", accountId: "${accountId}", to: [tourist_phone].\n` +
545
+ ` Send exactly:\n` +
546
+ ` "Here are the quotes for your trip [${jobId}]:\n` +
547
+ ` Option 1 — $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
548
+ ` Option 2 — $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
549
+ ` Option 3 — $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
550
+ ` Which option would you prefer?"\n` +
551
+ ` (Include only available options. Do not include driver names or phone numbers.)\n\n` +
552
+ `IF THE DRIVER IS DECLINING:\n` +
553
+ ` STEP A — Update driver status\n` +
554
+ ` memory_write to drivers/{name}.md — set status: idle, current_booking: null\n\n` +
555
+ ` STEP B — Clear negotiation index\n` +
556
+ ` memory_write to shared/active-negotiations/${senderPhone}.md with empty content\n`;
446
557
  console.log(`[ride-dispatch] Driver reply from ${senderPhone} for ${jobId}, dispatching to admin agent "${adminAgentId}"`);
447
558
  // Fire and forget — suppress is already set, caller will skip processForRoute
448
559
  dispatchToAdmin({
@@ -7,7 +7,7 @@ export const DEFAULT_MAX_CHARS_BY_CAPABILITY = {
7
7
  };
8
8
  export const DEFAULT_MAX_BYTES = {
9
9
  image: 10 * MB,
10
- audio: 20 * MB,
10
+ audio: 25 * MB,
11
11
  video: 50 * MB,
12
12
  };
13
13
  export const DEFAULT_TIMEOUT_SECONDS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.19.0",
3
+ "version": "1.19.1",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -931,7 +931,7 @@ Tap **Filters** to expand the filter chips, then narrow by:
931
931
  - **Agent** — show only entries from a specific agent (e.g., public or admin)
932
932
  - **Search** — free-text search across all entries
933
933
 
934
- **System Logs** show the internal machinery of the gateway — connections, heartbeats, startup messages. This is mainly useful for troubleshooting when something isn't working. Tap **Filters** to expand the filter chips and select which log levels to show (info, warning, error, etc.). You can also export logs to share with support.
934
+ **System Logs** show the internal machinery of the gateway — connections, heartbeats, startup messages. This is mainly useful for troubleshooting when something isn't working. Tap **Filters** to expand the filter chips and select which log levels to show (info, warning, error, etc.). You can also export logs to share with support. **System Logs are only available when logged in as admin** — account-PIN users see the Session Logs tab only, because system logs contain information from all accounts on the device.
935
935
 
936
936
  Both views support **auto-follow** (available in the Filters panel — keeps the view scrolled to the latest entries) and **export** to download a log file. Filter chips are collapsed by default to keep the log view uncluttered.
937
937