@rubytech/taskmaster 1.27.0 → 1.28.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.
@@ -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-1WwUK7EM.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-C7ieCeTV.css">
9
+ <script type="module" crossorigin src="./assets/index-B_QfEVs7.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-CAE6wsJy.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -152,6 +152,22 @@ function readActiveNegotiation(workspaceDir, phone) {
152
152
  return null;
153
153
  }
154
154
  }
155
+ /**
156
+ * Read the active booking index file for a driver phone.
157
+ * Returns the job ID if the driver has a confirmed active booking, null otherwise.
158
+ */
159
+ function readActiveBooking(workspaceDir, phone) {
160
+ const safePhone = phone.replace(/[^0-9]/g, "");
161
+ const filePath = path.join(workspaceDir, "memory", "shared", "active-bookings", `${safePhone}.md`);
162
+ try {
163
+ const content = fs.readFileSync(filePath, "utf-8");
164
+ const fields = parseDispatchFile(content);
165
+ return fields.job_id || null;
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
155
171
  /**
156
172
  * Resolve the workspace directory for the memory event's agent.
157
173
  * memory:add events include workspaceDir in context.
@@ -324,6 +340,7 @@ function buildBookingConfirmInstruction(fields, resolvedAccountId) {
324
340
  ` driver_name: ${driverName}\n` +
325
341
  ` driver_phone: ${driverPhone}\n` +
326
342
  ` vehicle: [vehicle type from shared/offers/${jobId}.md]\n` +
343
+ ` duration: [offer_N_duration from shared/offers/${jobId}.md — estimated journey minutes]\n` +
327
344
  ` plate: [driver's plate from contact record]\n` +
328
345
  ` pickup: [from trip request]\n` +
329
346
  ` destination: [from trip request]\n` +
@@ -353,7 +370,7 @@ function buildPaymentConfirmedInstruction(params) {
353
370
  `WhatsApp account: ${accountId}\n\n` +
354
371
  `STEP 1 — Read the booking record\n` +
355
372
  `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` +
373
+ `Extract: tourist_phone, tourist_name, driver_name, driver_phone, vehicle, plate, pickup, destination, date, time, duration, fare.\n\n` +
357
374
  `STEP 2 — Generate the pickup PIN\n` +
358
375
  `Generate a random 4-digit integer between 1000 and 9999 (e.g. 4827).\n` +
359
376
  `QR code URL: https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=PIN%3A+{pin}\n` +
@@ -380,9 +397,53 @@ function buildPaymentConfirmedInstruction(params) {
380
397
  `\n` +
381
398
  `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
399
  `STEP 5 — Update the booking record\n` +
383
- `memory_write to shared/bookings/${bookingId}.md — set status: confirmed, set pin: [pin].\n\n` +
400
+ `memory_write to shared/bookings/${bookingId}.md — set status: confirmed, set pin: [pin],\n` +
401
+ `and add the following empty tracking fields (leave them blank for now):\n` +
402
+ ` reminder_sent_at:\n` +
403
+ ` acknowledgement_at:\n` +
404
+ ` tourist_checkin_sent_at:\n` +
405
+ ` estimated_arrival:\n` +
406
+ ` rating_requested_at:\n\n` +
384
407
  `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`);
408
+ `memory_write to shared/active-negotiations/[driver_phone_digits].md with empty content.\n\n` +
409
+ `STEP 7 — Write the post-booking routing file\n` +
410
+ `memory_write to shared/active-bookings/[driver_phone_digits].md with exactly:\n` +
411
+ ` job_id: ${bookingId}\n` +
412
+ ` driver_name: [driver_name from booking record]\n` +
413
+ ` confirmed_at: [current_time output]\n`);
414
+ }
415
+ function buildPostBookingDriverMessageInstruction(params) {
416
+ const { jobId, senderPhone, text, accountId } = params;
417
+ return (`[System: Ride Dispatch — Driver Message]\n\n` +
418
+ `A driver has sent a message regarding a confirmed booking. Read the message, determine what it means, and respond appropriately.\n\n` +
419
+ `Job ID: ${jobId}\n` +
420
+ `Driver phone digits: ${senderPhone}\n` +
421
+ `Message: ${text}\n` +
422
+ `WhatsApp account: ${accountId}\n\n` +
423
+ `STEP 1 — Read the booking record\n` +
424
+ `Call memory_get on shared/bookings/${jobId}.md. Extract: tourist_phone, tourist_name, driver_name, pickup, destination, date, time, duration, fare, pin.\n\n` +
425
+ `STEP 2 — Interpret the message and act\n\n` +
426
+ `IF THE DRIVER IS CONFIRMING PICKUP (e.g. "collected", "passenger on board", "niko na mteja"):\n` +
427
+ ` a. memory_write to shared/bookings/${jobId}.md — set status: picked_up, set picked_up_at: [current_time],\n` +
428
+ ` set estimated_arrival: [calculate pickup time + duration minutes in ISO format].\n` +
429
+ ` b. memory_write to shared/active-bookings/${senderPhone}.md with empty content (clears routing).\n` +
430
+ ` c. memory_write to drivers/[driver_name].md — add +1 to standing field.\n\n` +
431
+ `IF THE DRIVER IS REPORTING A DELAY:\n` +
432
+ ` a. Call message with channel: "whatsapp", accountId: "${accountId}", to: [tourist_phone].\n` +
433
+ ` Send: "[driver_name] is running a little late but is on their way. We'll keep you updated."\n` +
434
+ ` b. memory_write to shared/bookings/${jobId}.md — append a note under a ## Notes section.\n\n` +
435
+ `IF THE DRIVER IS REPORTING A PASSENGER NO-SHOW:\n` +
436
+ ` a. Call message with channel: "whatsapp", accountId: "${accountId}", to: [tourist_phone].\n` +
437
+ ` Send: "Your driver [driver_name] has arrived at [pickup] but can't find you. Please make your way to the pickup point — your driver will wait a few minutes."\n` +
438
+ ` b. Include in your response output (do NOT use the message tool for this part):\n` +
439
+ ` NO-SHOW ALERT [${jobId}]: Driver reports passenger not at pickup. Tourist: [tourist_phone]. Please monitor.\n\n` +
440
+ `IF THE DRIVER IS REQUESTING A SUBSTITUTION:\n` +
441
+ ` a. Do NOT approve the substitution.\n` +
442
+ ` b. Include in your response output:\n` +
443
+ ` SUBSTITUTION REQUEST [${jobId}]: Driver [driver_name] has requested a substitution. Tourist: [tourist_phone]. Operator decision required.\n\n` +
444
+ `IF UNCLEAR:\n` +
445
+ ` Interpret charitably. If the message could plausibly be a pickup confirmation, treat it as one.\n` +
446
+ ` If genuinely ambiguous, reply to the driver asking for clarification.\n`);
386
447
  }
387
448
  // ---------------------------------------------------------------------------
388
449
  // Event handlers
@@ -497,9 +558,12 @@ async function handleDriverReply(event) {
497
558
  const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
498
559
  // Check active negotiation index (synchronous — must complete before we return
499
560
  // so that event.suppress is set before the caller checks it)
500
- const jobId = readActiveNegotiation(workspaceDir, senderPhone);
561
+ const negotiationJobId = readActiveNegotiation(workspaceDir, senderPhone);
562
+ const bookingJobId = negotiationJobId ? null : readActiveBooking(workspaceDir, senderPhone);
563
+ const jobId = negotiationJobId ?? bookingJobId;
501
564
  if (!jobId)
502
- return; // Not a driver in active negotiation let public agent handle it
565
+ return; // Not a driver in active negotiation or confirmed booking
566
+ const isPostBooking = !negotiationJobId && Boolean(bookingJobId);
503
567
  // Suppress delivery to the public agent before doing anything else
504
568
  event.suppress = true;
505
569
  // Dedup
@@ -511,49 +575,57 @@ async function handleDriverReply(event) {
511
575
  return;
512
576
  const accountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ??
513
577
  (agentId.replace(/-(public|admin)$/, "") || "default");
514
- const instruction = `[System: Ride Dispatch — Driver Reply]\n\n` +
515
- `A driver has replied for job ${jobId}. Execute the correct branch below.\n\n` +
516
- `Driver phone digits: ${senderPhone}\n` +
517
- `Message: ${text}\n\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}.mdappend 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 DWrite 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 BClear negotiation index\n` +
556
- ` memory_write to shared/active-negotiations/${senderPhone}.md with empty content\n`;
578
+ const safePhone = senderPhone.replace(/[^0-9]/g, "");
579
+ const instruction = isPostBooking
580
+ ? buildPostBookingDriverMessageInstruction({
581
+ jobId,
582
+ senderPhone: safePhone,
583
+ text,
584
+ accountId,
585
+ })
586
+ : `[System: Ride DispatchDriver Reply]\n\n` +
587
+ `A driver has replied for job ${jobId}. Execute the correct branch below.\n\n` +
588
+ `Driver phone digits: ${senderPhone}\n` +
589
+ `Message: ${text}\n\n` +
590
+ `IF THE DRIVER IS QUOTING A FARE:\n` +
591
+ ` STEP ALook up the driver's name\n` +
592
+ ` Call contact_lookup and match the driver by phone digits "${senderPhone}" to get their name and vehicle.\n\n` +
593
+ ` STEP B Record the quote\n` +
594
+ ` memory_write to drivers/{name}.md append under "## Quotes": "${jobId}: $[fare] quoted [today's date]"\n\n` +
595
+ ` STEP C — Check whether all expected quotes are in\n` +
596
+ ` Read shared/active-negotiations/ to count how many drivers were contacted for ${jobId}.\n` +
597
+ ` If all have replied, OR if it has been 5+ minutes since the trip-request, proceed to STEP D.\n` +
598
+ ` If still waiting for replies, stop here and wait.\n\n` +
599
+ ` STEP D — Write the offers file\n` +
600
+ ` Call memory_get on shared/dispatch/${jobId}-trip-request.md to read tourist_phone.\n` +
601
+ ` Read shared/offers/${jobId}.md if it exists. If not, create it.\n` +
602
+ ` Add or update this driver's offer entry in shared/offers/${jobId}.md (memory_write):\n` +
603
+ ` # Offers: ${jobId}\n` +
604
+ ` job_id: ${jobId}\n` +
605
+ ` status: ready\n` +
606
+ ` tourist_phone: [tourist_phone from shared/dispatch/${jobId}-trip-request.md]\n` +
607
+ ` offer_N_driver: [name]\n` +
608
+ ` offer_N_phone: ${senderPhone}\n` +
609
+ ` offer_N_vehicle: [vehicle type]\n` +
610
+ ` offer_N_rating: [rating from drivers/{name}.md]\n` +
611
+ ` offer_N_fare: [quoted fare as USD number]\n` +
612
+ ` offer_N_duration: [estimated minutes]\n` +
613
+ ` (N = 1, 2, or 3 for each driver, sorted by fare ascending.)\n\n` +
614
+ ` STEP E Message the tourist with formatted options\n` +
615
+ ` Read tourist_phone from shared/offers/${jobId}.md.\n` +
616
+ ` Call message with channel: "whatsapp", accountId: "${accountId}", to: [tourist_phone].\n` +
617
+ ` Send exactly:\n` +
618
+ ` "Here are the quotes for your trip [${jobId}]:\n` +
619
+ ` Option 1$[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
620
+ ` Option 2 $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
621
+ ` Option 3 — $[fare], [vehicle], [rating]⭐, ~[duration] min\n` +
622
+ ` Which option would you prefer?"\n` +
623
+ ` (Include only available options. Do not include driver names or phone numbers.)\n\n` +
624
+ `IF THE DRIVER IS DECLINING:\n` +
625
+ ` STEP A — Update driver status\n` +
626
+ ` memory_write to drivers/{name}.md — set status: idle, current_booking: null\n\n` +
627
+ ` STEP B — Clear negotiation index\n` +
628
+ ` memory_write to shared/active-negotiations/${senderPhone}.md with empty content\n`;
557
629
  console.log(`[ride-dispatch] Driver reply from ${senderPhone} for ${jobId}, dispatching to admin agent "${adminAgentId}"`);
558
630
  // Fire and forget — suppress is already set, caller will skip processForRoute
559
631
  dispatchToAdmin({
@@ -0,0 +1,225 @@
1
+ /**
2
+ * White Glove Pack — signed bundle containing agent files, memory, and skills
3
+ * for bespoke workspace installation.
4
+ *
5
+ * Extends the skill pack concept to include everything needed to transform a
6
+ * generic workspace into a fully-configured, business-specific one:
7
+ * - Agent identity files (IDENTITY.md, SOUL.md, AGENTS.md, HEARTBEAT.md)
8
+ * - Pre-populated memory files (shared/, public/, admin/)
9
+ * - Custom skills with references
10
+ *
11
+ * Uses the same Ed25519 signing infrastructure as skill packs.
12
+ */
13
+ import crypto from "node:crypto";
14
+ // ---------------------------------------------------------------------------
15
+ // Canonical JSON
16
+ // ---------------------------------------------------------------------------
17
+ /** Build deterministic JSON string for signing. Key order is fixed. */
18
+ export function buildCanonicalPayload(payload) {
19
+ const ordered = {
20
+ format: payload.format,
21
+ pack: {
22
+ id: payload.pack.id,
23
+ version: payload.pack.version,
24
+ name: payload.pack.name,
25
+ description: payload.pack.description,
26
+ author: payload.pack.author,
27
+ },
28
+ device: {
29
+ did: payload.device.did,
30
+ cid: payload.device.cid,
31
+ },
32
+ content: {
33
+ agents: payload.content.agents.map((a) => ({
34
+ id: a.id,
35
+ files: a.files.map((f) => ({ name: f.name, content: f.content })),
36
+ })),
37
+ memory: payload.content.memory.map((m) => ({
38
+ scope: m.scope,
39
+ name: m.name,
40
+ content: m.content,
41
+ })),
42
+ skills: payload.content.skills.map((s) => ({
43
+ id: s.id,
44
+ skill: s.skill,
45
+ references: s.references.map((r) => ({ name: r.name, content: r.content })),
46
+ })),
47
+ },
48
+ signedAt: payload.signedAt,
49
+ };
50
+ return JSON.stringify(ordered);
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Signing
54
+ // ---------------------------------------------------------------------------
55
+ function loadSigningKey() {
56
+ const raw = process.env.LICENSE_SIGNING_KEY;
57
+ if (!raw) {
58
+ throw new Error("LICENSE_SIGNING_KEY not set. Cannot sign white glove packs.");
59
+ }
60
+ const pem = raw.replace(/\\n/g, "\n");
61
+ return crypto.createPrivateKey(pem);
62
+ }
63
+ /** Sign a white glove payload with the Ed25519 private key. */
64
+ export function signWhiteGlovePack(payload) {
65
+ const privateKey = loadSigningKey();
66
+ const canonical = buildCanonicalPayload(payload);
67
+ const signature = crypto.sign(null, Buffer.from(canonical), privateKey);
68
+ return {
69
+ ...payload,
70
+ signature: signature.toString("base64url"),
71
+ };
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // Verification
75
+ // ---------------------------------------------------------------------------
76
+ const PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
77
+ MCowBQYDK2VwAyEA/t/C4A4I0rDlj5rEqv6Hy6VdHJr7WiJHWUxgwGz9HcM=
78
+ -----END PUBLIC KEY-----`;
79
+ const publicKey = crypto.createPublicKey(PUBLIC_KEY_PEM);
80
+ /** Verify a white glove pack file's structure and Ed25519 signature. */
81
+ export function verifyWhiteGlovePack(raw) {
82
+ if (raw === null || typeof raw !== "object") {
83
+ return { valid: false, message: "White glove pack must be a JSON object" };
84
+ }
85
+ const obj = raw;
86
+ if (typeof obj.signature !== "string" || !obj.signature) {
87
+ return { valid: false, message: "Missing or invalid signature" };
88
+ }
89
+ if (obj.format !== "whiteglove-v1") {
90
+ return { valid: false, message: `Unsupported format: ${String(obj.format)}` };
91
+ }
92
+ // Validate pack metadata
93
+ const pack = obj.pack;
94
+ if (!pack || typeof pack !== "object") {
95
+ return { valid: false, message: "Missing pack metadata" };
96
+ }
97
+ for (const key of ["id", "version", "name", "author"]) {
98
+ if (typeof pack[key] !== "string" || !pack[key]) {
99
+ return { valid: false, message: `Missing or invalid pack.${key}` };
100
+ }
101
+ }
102
+ if (typeof pack.description !== "string") {
103
+ return { valid: false, message: "Missing or invalid pack.description" };
104
+ }
105
+ // Validate device
106
+ const device = obj.device;
107
+ if (!device || typeof device !== "object") {
108
+ return { valid: false, message: "Missing device info" };
109
+ }
110
+ if (typeof device.did !== "string" || !device.did) {
111
+ return { valid: false, message: "Missing or invalid device.did" };
112
+ }
113
+ if (typeof device.cid !== "string" || !device.cid) {
114
+ return { valid: false, message: "Missing or invalid device.cid" };
115
+ }
116
+ // Validate content
117
+ const content = obj.content;
118
+ if (!content || typeof content !== "object") {
119
+ return { valid: false, message: "Missing content" };
120
+ }
121
+ // Validate agents
122
+ if (!Array.isArray(content.agents)) {
123
+ return { valid: false, message: "Missing or invalid content.agents array" };
124
+ }
125
+ const agents = [];
126
+ for (let i = 0; i < content.agents.length; i++) {
127
+ const a = content.agents[i];
128
+ if (!a || typeof a !== "object") {
129
+ return { valid: false, message: `Invalid agent at index ${i}` };
130
+ }
131
+ if (typeof a.id !== "string" || !a.id) {
132
+ return { valid: false, message: `Missing agents[${i}].id` };
133
+ }
134
+ if (!Array.isArray(a.files)) {
135
+ return { valid: false, message: `Missing agents[${i}].files` };
136
+ }
137
+ const files = [];
138
+ for (let j = 0; j < a.files.length; j++) {
139
+ const f = a.files[j];
140
+ if (typeof f?.name !== "string" || typeof f?.content !== "string") {
141
+ return { valid: false, message: `Invalid file at agents[${i}].files[${j}]` };
142
+ }
143
+ files.push({ name: f.name, content: f.content });
144
+ }
145
+ agents.push({ id: a.id, files });
146
+ }
147
+ // Validate memory
148
+ if (!Array.isArray(content.memory)) {
149
+ return { valid: false, message: "Missing or invalid content.memory array" };
150
+ }
151
+ const memory = [];
152
+ for (let i = 0; i < content.memory.length; i++) {
153
+ const m = content.memory[i];
154
+ if (!m || typeof m !== "object") {
155
+ return { valid: false, message: `Invalid memory entry at index ${i}` };
156
+ }
157
+ if (typeof m.scope !== "string" ||
158
+ typeof m.name !== "string" ||
159
+ typeof m.content !== "string") {
160
+ return { valid: false, message: `Invalid memory entry at index ${i}` };
161
+ }
162
+ memory.push({ scope: m.scope, name: m.name, content: m.content });
163
+ }
164
+ // Validate skills
165
+ if (!Array.isArray(content.skills)) {
166
+ return { valid: false, message: "Missing or invalid content.skills array" };
167
+ }
168
+ const skills = [];
169
+ for (let i = 0; i < content.skills.length; i++) {
170
+ const s = content.skills[i];
171
+ if (!s || typeof s !== "object") {
172
+ return { valid: false, message: `Invalid skill at index ${i}` };
173
+ }
174
+ if (typeof s.id !== "string" || !s.id) {
175
+ return { valid: false, message: `Missing skills[${i}].id` };
176
+ }
177
+ if (typeof s.skill !== "string") {
178
+ return { valid: false, message: `Missing skills[${i}].skill` };
179
+ }
180
+ if (!Array.isArray(s.references)) {
181
+ return { valid: false, message: `Missing skills[${i}].references` };
182
+ }
183
+ const refs = [];
184
+ for (let j = 0; j < s.references.length; j++) {
185
+ const r = s.references[j];
186
+ if (typeof r?.name !== "string" || typeof r?.content !== "string") {
187
+ return { valid: false, message: `Invalid reference at skills[${i}].references[${j}]` };
188
+ }
189
+ refs.push({ name: r.name, content: r.content });
190
+ }
191
+ skills.push({ id: s.id, skill: s.skill, references: refs });
192
+ }
193
+ // Validate signedAt
194
+ if (typeof obj.signedAt !== "string" || !obj.signedAt) {
195
+ return { valid: false, message: "Missing or invalid signedAt" };
196
+ }
197
+ // Rebuild payload for verification
198
+ const payload = {
199
+ format: "whiteglove-v1",
200
+ pack: {
201
+ id: pack.id,
202
+ version: pack.version,
203
+ name: pack.name,
204
+ description: pack.description,
205
+ author: pack.author,
206
+ },
207
+ device: { did: device.did, cid: device.cid },
208
+ content: { agents, memory, skills },
209
+ signedAt: obj.signedAt,
210
+ };
211
+ // Verify Ed25519 signature
212
+ const canonical = buildCanonicalPayload(payload);
213
+ let signatureValid;
214
+ try {
215
+ const signature = Buffer.from(obj.signature, "base64url");
216
+ signatureValid = crypto.verify(null, Buffer.from(canonical), publicKey, signature);
217
+ }
218
+ catch {
219
+ return { valid: false, message: "Signature verification failed" };
220
+ }
221
+ if (!signatureValid) {
222
+ return { valid: false, message: "Invalid white glove pack signature" };
223
+ }
224
+ return { valid: true, payload };
225
+ }
@@ -7,59 +7,9 @@
7
7
  import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../agents/agent-scope.js";
8
8
  import { resolveIdentityName } from "../agents/identity.js";
9
9
  import { loadConfig } from "../config/config.js";
10
- import { loadSessionEntry, readSessionMessages } from "../gateway/session-utils.js";
11
10
  import { generateSuggestions } from "./generator.js";
12
11
  /** Default model for suggestion generation. */
13
12
  const SUGGESTION_MODEL = "claude-haiku-4-5-20251001";
14
- /** Number of recent messages to include as context for suggestion generation. */
15
- const RECENT_MESSAGES_LIMIT = 10;
16
- /** Extract text from a session message (handles both string and structured content). */
17
- function extractMessageText(msg) {
18
- if (!msg || typeof msg !== "object")
19
- return "";
20
- const m = msg;
21
- if (typeof m.content === "string")
22
- return m.content;
23
- if (Array.isArray(m.content)) {
24
- return m.content
25
- .filter((c) => c.type === "text" && typeof c.text === "string")
26
- .map((c) => c.text)
27
- .join(" ");
28
- }
29
- return "";
30
- }
31
- /**
32
- * Load recent conversation messages for context. Returns a formatted string
33
- * with the last N messages (user + assistant turns).
34
- */
35
- function loadRecentMessages(sessionKey) {
36
- try {
37
- const { storePath, entry } = loadSessionEntry(sessionKey);
38
- if (!entry?.sessionId || !storePath)
39
- return "";
40
- const messages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile);
41
- if (messages.length === 0)
42
- return "";
43
- // Take the last N messages
44
- const recent = messages.slice(-RECENT_MESSAGES_LIMIT);
45
- return recent
46
- .map((msg) => {
47
- const m = msg;
48
- const role = m.role === "assistant" ? "Assistant" : "User";
49
- const text = extractMessageText(msg);
50
- if (!text)
51
- return null;
52
- // Truncate long messages
53
- const truncated = text.length > 200 ? `${text.slice(0, 200)}...` : text;
54
- return `${role}: ${truncated}`;
55
- })
56
- .filter(Boolean)
57
- .join("\n");
58
- }
59
- catch {
60
- return "";
61
- }
62
- }
63
13
  /**
64
14
  * Generate suggestions and broadcast them. Fire-and-forget — errors are logged, never thrown.
65
15
  */
@@ -69,16 +19,10 @@ export function fireSuggestion(params) {
69
19
  const agentId = resolveSessionAgentId({ sessionKey, config });
70
20
  const agentDir = resolveAgentWorkspaceDir(config, agentId);
71
21
  const assistantName = resolveIdentityName(config, agentId);
72
- // Load recent conversation for better context
73
- const recentMessages = loadRecentMessages(sessionKey);
74
- if (!recentMessages) {
75
- console.warn(`[suggestions] no recent messages found for sessionKey=${sessionKey}`);
76
- }
77
22
  void generateSuggestions({
78
23
  model: SUGGESTION_MODEL,
79
24
  lastUserMessage,
80
25
  lastAssistantReply,
81
- recentMessages,
82
26
  assistantName,
83
27
  cfg: config,
84
28
  agentDir,
@@ -10,7 +10,7 @@ import { complete } from "@mariozechner/pi-ai";
10
10
  import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
11
11
  import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js";
12
12
  import { ensureTaskmasterModelsJson } from "../agents/models-config.js";
13
- const FOLLOW_UP_SYSTEM_PROMPT = `Output ONLY a JSON array with exactly two strings. No explanation, no markdown, no labels.`;
13
+ const FOLLOW_UP_SYSTEM_PROMPT = `You predict what the user will say next in a chat with an AI assistant. Output ONLY a JSON array with exactly two short strings — concise, natural quick-replies a user would tap. No explanation, no markdown, no labels.`;
14
14
  /**
15
15
  * Curated session-start opener pairs. Picked randomly.
16
16
  * Each pair has an affirmative and an alternative option.
@@ -42,7 +42,7 @@ function stripQuotes(s) {
42
42
  * Generate two follow-up suggestions using Haiku. Requires conversation context.
43
43
  */
44
44
  export async function generateFollowUpSuggestions(params) {
45
- const { model: modelId, lastUserMessage, lastAssistantReply, recentMessages, assistantName, timeoutMs = 4000, cfg, agentDir, } = params;
45
+ const { model: modelId, lastUserMessage, lastAssistantReply, assistantName, timeoutMs = 4000, cfg, agentDir, } = params;
46
46
  if (!lastAssistantReply) {
47
47
  return { ok: false, error: new Error("Follow-up requires lastAssistantReply") };
48
48
  }
@@ -61,24 +61,9 @@ export async function generateFollowUpSuggestions(params) {
61
61
  const apiKeyInfo = await getApiKeyForModel({ model, cfg, agentDir });
62
62
  const apiKey = requireApiKey(apiKeyInfo, model.provider);
63
63
  authStorage.setRuntimeApiKey(model.provider, apiKey);
64
- // Build prompt from conversation history when available, fall back to last exchange
65
- let prompt;
66
- // Build the conversation context — prefer full history, fall back to last exchange
67
64
  const name = assistantName || "Assistant";
68
- let conversationContext;
69
- if (recentMessages) {
70
- conversationContext = recentMessages;
71
- }
72
- else {
73
- const truncatedReply = lastAssistantReply.length > 300
74
- ? `${lastAssistantReply.slice(0, 300)}...`
75
- : lastAssistantReply;
76
- const truncatedUser = lastUserMessage && lastUserMessage.length > 200
77
- ? `${lastUserMessage.slice(0, 200)}...`
78
- : (lastUserMessage ?? "");
79
- conversationContext = `User: ${truncatedUser}\n${name}: ${truncatedReply}`;
80
- }
81
- prompt = `${conversationContext}\n\nPredict the next message from the user. Give me two options that oppose each other if possible.`;
65
+ const userLine = lastUserMessage ? `User: ${lastUserMessage}\n` : "";
66
+ const prompt = `${userLine}${name}: ${lastAssistantReply}\n\nWhat will the user say next? Give two short, distinct options.`;
82
67
  const context = {
83
68
  systemPrompt: FOLLOW_UP_SYSTEM_PROMPT,
84
69
  messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
@@ -90,7 +75,7 @@ export async function generateFollowUpSuggestions(params) {
90
75
  complete(model, context, {
91
76
  apiKey,
92
77
  maxTokens: 100,
93
- temperature: 0.8,
78
+ temperature: 0.5,
94
79
  }),
95
80
  timeoutPromise,
96
81
  ]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"