@rubytech/taskmaster 1.18.2 → 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.
- package/dist/agents/workspace-migrations.js +14 -0
- package/dist/build-info.json +3 -3
- package/dist/control-ui/assets/index-0WHVrpg7.css +1 -0
- package/dist/control-ui/assets/{index-DYBHelH8.js → index-mjAT1dyG.js} +779 -687
- package/dist/control-ui/assets/index-mjAT1dyG.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/cron/isolated-agent/delivery-record.js +3 -3
- package/dist/cron/isolated-agent/run.js +4 -1
- package/dist/cron/preloaded.js +33 -0
- package/dist/gateway/control-ui.js +8 -6
- package/dist/gateway/protocol/schema/logs-chat.js +2 -0
- package/dist/gateway/server-http.js +2 -1
- package/dist/gateway/server-methods/access.js +13 -0
- package/dist/gateway/server-methods/logs.js +16 -1
- package/dist/gateway/server.impl.js +40 -18
- package/dist/hooks/bundled/ride-dispatch/handler.js +161 -50
- package/dist/media-understanding/defaults.js +1 -1
- package/dist/memory/audit.js +1 -1
- package/package.json +1 -1
- package/taskmaster-docs/USER-GUIDE.md +1 -1
- package/templates/beagle-zanzibar/agents/admin/AGENTS.md +157 -41
- package/templates/beagle-zanzibar/agents/public/AGENTS.md +27 -8
- package/templates/beagle-zanzibar/skills/beagle-zanzibar/cron-template.json +20 -0
- package/templates/beagle-zanzibar/skills/beagle-zanzibar/references/pin-qr.md +22 -9
- package/templates/beagle-zanzibar/skills/stripe/references/payment-links.md +25 -15
- package/dist/control-ui/assets/index-DYBHelH8.js.map +0 -1
- package/dist/control-ui/assets/index-XqRo9tNW.css +0 -1
|
@@ -6,8 +6,8 @@
|
|
|
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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-mjAT1dyG.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-0WHVrpg7.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<taskmaster-app></taskmaster-app>
|
|
@@ -4,14 +4,14 @@ import path from "node:path";
|
|
|
4
4
|
* Writes a delivery record to the agent's workspace memory so the admin agent
|
|
5
5
|
* can discover what was sent by cron jobs when recipients reply later.
|
|
6
6
|
*
|
|
7
|
-
* Files are stored in `memory/
|
|
8
|
-
* discoverable via the agent's normal memory search.
|
|
7
|
+
* Files are stored in `memory/admin/cron-activity/` as dated markdown,
|
|
8
|
+
* discoverable via the admin agent's normal memory search.
|
|
9
9
|
*/
|
|
10
10
|
export async function writeCronDeliveryRecord(params) {
|
|
11
11
|
const { workspaceDir, jobName, jobId, agentOutput, deliveredTo, timestamp } = params;
|
|
12
12
|
if (!agentOutput.trim() || deliveredTo.length === 0)
|
|
13
13
|
return null;
|
|
14
|
-
const dir = path.join(workspaceDir, "memory", "
|
|
14
|
+
const dir = path.join(workspaceDir, "memory", "admin", "cron-activity");
|
|
15
15
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
16
16
|
const dateSlug = formatDateSlug(timestamp);
|
|
17
17
|
const nameSlug = slugify(jobName);
|
|
@@ -403,8 +403,11 @@ export async function runCronIsolatedAgentTurn(params) {
|
|
|
403
403
|
}
|
|
404
404
|
}
|
|
405
405
|
// Also persist delivery record to memory for searchability.
|
|
406
|
+
// Always write to the admin agent's workspace so records land in memory/admin/,
|
|
407
|
+
// regardless of which agent is executing the cron job.
|
|
408
|
+
const adminWorkspaceDir = resolveAgentWorkspaceDir(params.cfg, "admin");
|
|
406
409
|
await writeCronDeliveryRecord({
|
|
407
|
-
workspaceDir,
|
|
410
|
+
workspaceDir: adminWorkspaceDir,
|
|
408
411
|
jobName: params.job.name,
|
|
409
412
|
jobId: params.job.id,
|
|
410
413
|
agentOutput: outputText,
|
package/dist/cron/preloaded.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
563
|
-
// <base>
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
520
|
-
|
|
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.
|
|
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
|
-
`
|
|
250
|
-
`
|
|
251
|
-
`2
|
|
252
|
-
`
|
|
253
|
-
`
|
|
254
|
-
`
|
|
255
|
-
`
|
|
256
|
-
`
|
|
257
|
-
`
|
|
258
|
-
`
|
|
259
|
-
`
|
|
260
|
-
`
|
|
261
|
-
`
|
|
262
|
-
`
|
|
263
|
-
`
|
|
264
|
-
`
|
|
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.
|
|
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
|
-
`
|
|
278
|
-
`
|
|
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
|
-
`
|
|
281
|
-
`
|
|
282
|
-
`
|
|
283
|
-
`
|
|
284
|
-
`
|
|
285
|
-
`
|
|
286
|
-
`
|
|
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.
|
|
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
|
-
`
|
|
296
|
-
`
|
|
297
|
-
`
|
|
298
|
-
`
|
|
299
|
-
`
|
|
300
|
-
`
|
|
301
|
-
`
|
|
302
|
-
`
|
|
303
|
-
`
|
|
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
|
|
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
|
-
`
|
|
437
|
-
`
|
|
438
|
-
`
|
|
439
|
-
`
|
|
440
|
-
`
|
|
441
|
-
`
|
|
442
|
-
`
|
|
443
|
-
`
|
|
444
|
-
`
|
|
445
|
-
`
|
|
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({
|
package/dist/memory/audit.js
CHANGED
|
@@ -15,7 +15,7 @@ import fs from "node:fs";
|
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
const AUDIT_FILENAME = ".memory-audit.json";
|
|
17
17
|
/** Paths that are excluded from audit — expected operational writes. */
|
|
18
|
-
const EXCLUDED_PREFIXES = ["memory/shared/events/", "memory/
|
|
18
|
+
const EXCLUDED_PREFIXES = ["memory/shared/events/", "memory/admin/cron-activity/"];
|
|
19
19
|
/**
|
|
20
20
|
* Returns true if a memory write path should be audited.
|
|
21
21
|
* Auditable paths: memory/shared/** and memory/public/** (excluding exemptions).
|
package/package.json
CHANGED
|
@@ -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
|
|