@rubytech/taskmaster 1.0.76 → 1.0.77

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.76",
3
- "commit": "5295efe0026f10b84e556b9bef3f4097a879e342",
4
- "builtAt": "2026-02-20T17:49:56.005Z"
2
+ "version": "1.0.77",
3
+ "commit": "a548906950602227b575ddecaf6b1bd6f9ae576c",
4
+ "builtAt": "2026-02-20T20:56:14.369Z"
5
5
  }
@@ -0,0 +1,721 @@
1
+ /**
2
+ * REST API for public chat — HTTP alternative to the WebSocket RPC protocol.
3
+ *
4
+ * Base path: /public/api/v1/:accountId/
5
+ *
6
+ * Endpoints:
7
+ * POST /session — create an anonymous session (returns sessionKey)
8
+ * POST /otp/request — request a WhatsApp OTP code
9
+ * POST /otp/verify — verify OTP and get a verified session (returns sessionKey)
10
+ * POST /chat — send a message, receive the agent reply (sync or SSE stream)
11
+ * GET /chat/history — retrieve past messages for a session
12
+ * POST /chat/abort — cancel an in-progress agent run
13
+ *
14
+ * Authentication mirrors the public chat widget: anonymous sessions use a
15
+ * client-provided identity string; verified sessions use WhatsApp OTP.
16
+ * The sessionKey returned from /session or /otp/verify is passed via the
17
+ * X-Session-Key header on subsequent requests.
18
+ *
19
+ * The chat endpoint uses `dispatchInboundMessage` — the same full pipeline
20
+ * as the WebSocket `chat.send` handler — so filler messages, internal hooks,
21
+ * and media processing all work identically.
22
+ */
23
+ import { randomUUID } from "node:crypto";
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../agents/agent-scope.js";
27
+ import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
28
+ import { dispatchInboundMessage } from "../auto-reply/dispatch.js";
29
+ import { createReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
30
+ import { extractShortModelName, } from "../auto-reply/reply/response-prefix-template.js";
31
+ import { loadConfig } from "../config/config.js";
32
+ import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
33
+ import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
34
+ import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
35
+ import { requestOtp, verifyOtp } from "./public-chat/otp.js";
36
+ import { deliverOtp } from "./public-chat/deliver-otp.js";
37
+ import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
38
+ import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
39
+ import { stripBase64ImagesFromMessages, stripEnvelopeFromMessages } from "./chat-sanitize.js";
40
+ import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, writeDone, } from "./http-common.js";
41
+ // ---------------------------------------------------------------------------
42
+ // Helpers
43
+ // ---------------------------------------------------------------------------
44
+ const API_PREFIX = "/public/api/v1/";
45
+ /** CORS headers for cross-origin REST clients. */
46
+ function setCorsHeaders(res) {
47
+ res.setHeader("Access-Control-Allow-Origin", "*");
48
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
49
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Session-Key");
50
+ res.setHeader("Access-Control-Max-Age", "86400");
51
+ }
52
+ function sendNotFound(res) {
53
+ sendJson(res, 404, { error: { message: "Not Found", type: "not_found" } });
54
+ }
55
+ function sendForbidden(res, message) {
56
+ sendJson(res, 403, { error: { message, type: "forbidden" } });
57
+ }
58
+ function sendUnavailable(res, message) {
59
+ sendJson(res, 503, { error: { message, type: "unavailable" } });
60
+ }
61
+ /** Validate accountId format: alphanumeric, hyphens, underscores, 1–64 chars. */
62
+ function validateAccountId(raw) {
63
+ const trimmed = raw.trim();
64
+ if (!trimmed || !/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed))
65
+ return null;
66
+ return trimmed;
67
+ }
68
+ /** Validate session key format: must be agent:*:dm:* (public DM sessions only). */
69
+ function validateSessionKey(raw) {
70
+ if (!raw)
71
+ return null;
72
+ const trimmed = raw.trim();
73
+ if (!/^agent:[^:]+:dm:/.test(trimmed))
74
+ return null;
75
+ return trimmed;
76
+ }
77
+ function getSessionKeyHeader(req) {
78
+ const raw = req.headers["x-session-key"];
79
+ if (typeof raw === "string")
80
+ return raw.trim();
81
+ if (Array.isArray(raw))
82
+ return raw[0]?.trim();
83
+ return undefined;
84
+ }
85
+ /** Minimal phone format check: starts with +, digits only, 7–15 digits. */
86
+ function isValidPhone(phone) {
87
+ return /^\+\d{7,15}$/.test(phone);
88
+ }
89
+ /** Strip spaces, dashes, and parentheses from a phone number. */
90
+ function normalizePhone(raw) {
91
+ return raw.replace(/[\s\-()]/g, "");
92
+ }
93
+ /** Check whether the auth mode allows anonymous sessions. */
94
+ function allowsAnonymous(cfg) {
95
+ const mode = cfg.publicChat?.auth ?? "anonymous";
96
+ return mode === "anonymous" || mode === "choice";
97
+ }
98
+ /** Check whether the auth mode allows OTP verification. */
99
+ function allowsVerified(cfg) {
100
+ const mode = cfg.publicChat?.auth ?? "anonymous";
101
+ return mode === "verified" || mode === "choice";
102
+ }
103
+ function writeSse(res, data) {
104
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // Attachment processing (mirrors WebSocket chat.send handler)
108
+ // ---------------------------------------------------------------------------
109
+ const IMAGE_EXT_MAP = {
110
+ "image/jpeg": ".jpg",
111
+ "image/png": ".png",
112
+ "image/gif": ".gif",
113
+ "image/webp": ".webp",
114
+ "image/heic": ".heic",
115
+ "image/heif": ".heif",
116
+ "image/svg+xml": ".svg",
117
+ "image/avif": ".avif",
118
+ };
119
+ function saveAttachments(attachments, uploadsDir) {
120
+ const imagePaths = [];
121
+ const imageTypes = [];
122
+ const docPaths = [];
123
+ for (const att of attachments) {
124
+ if (!att.content || typeof att.content !== "string")
125
+ continue;
126
+ const isDocument = att.type === "document";
127
+ if (isDocument) {
128
+ const safeName = (att.fileName ?? `file-${randomUUID().slice(0, 8)}`).replace(/[^a-zA-Z0-9._-]/g, "_");
129
+ const destPath = path.join(uploadsDir, safeName);
130
+ try {
131
+ fs.writeFileSync(destPath, Buffer.from(att.content, "base64"));
132
+ docPaths.push(destPath);
133
+ }
134
+ catch {
135
+ /* skip failed saves */
136
+ }
137
+ }
138
+ else {
139
+ try {
140
+ let b64 = att.content.trim();
141
+ const dataUrlMatch = /^data:[^;]+;base64,(.*)$/.exec(b64);
142
+ if (dataUrlMatch)
143
+ b64 = dataUrlMatch[1];
144
+ const buffer = Buffer.from(b64, "base64");
145
+ const mimeBase = att.mimeType?.split(";")[0]?.trim();
146
+ const ext = (mimeBase && IMAGE_EXT_MAP[mimeBase]) ?? ".jpg";
147
+ const uuid = randomUUID();
148
+ let safeName;
149
+ if (att.fileName) {
150
+ const base = path
151
+ .parse(att.fileName)
152
+ .name.replace(/[^a-zA-Z0-9._-]/g, "_")
153
+ .slice(0, 60);
154
+ safeName = base ? `${base}---${uuid}${ext}` : `${uuid}${ext}`;
155
+ }
156
+ else {
157
+ safeName = `${uuid}${ext}`;
158
+ }
159
+ const destPath = path.join(uploadsDir, safeName);
160
+ fs.writeFileSync(destPath, buffer);
161
+ imagePaths.push(destPath);
162
+ imageTypes.push(mimeBase ?? "image/png");
163
+ }
164
+ catch {
165
+ /* skip failed saves */
166
+ }
167
+ }
168
+ }
169
+ return { imagePaths, imageTypes, docPaths };
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Route: POST /session
173
+ // ---------------------------------------------------------------------------
174
+ async function handleSession(req, res, accountId, cfg, maxBodyBytes) {
175
+ if (req.method !== "POST") {
176
+ sendMethodNotAllowed(res);
177
+ return;
178
+ }
179
+ if (!allowsAnonymous(cfg)) {
180
+ sendForbidden(res, "anonymous sessions are disabled — use OTP verification");
181
+ return;
182
+ }
183
+ const body = await readJsonBodyOrError(req, res, maxBodyBytes);
184
+ if (body === undefined)
185
+ return;
186
+ const payload = (body && typeof body === "object" ? body : {});
187
+ const sessionId = typeof payload.session_id === "string" ? payload.session_id.trim() : "";
188
+ if (!sessionId || sessionId.length < 8 || sessionId.length > 128) {
189
+ sendInvalidRequest(res, "session_id must be 8–128 characters");
190
+ return;
191
+ }
192
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
193
+ sendInvalidRequest(res, "session_id must contain only alphanumeric characters, hyphens, and underscores");
194
+ return;
195
+ }
196
+ const agentId = resolvePublicAgentId(cfg, accountId);
197
+ const identifier = `anon-${sessionId}`;
198
+ const sessionKey = buildPublicSessionKey(agentId, identifier);
199
+ sendJson(res, 200, { session_key: sessionKey, agent_id: agentId });
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // Route: POST /otp/request
203
+ // ---------------------------------------------------------------------------
204
+ async function handleOtpRequest(req, res, _accountId, cfg, maxBodyBytes) {
205
+ if (req.method !== "POST") {
206
+ sendMethodNotAllowed(res);
207
+ return;
208
+ }
209
+ if (!allowsVerified(cfg)) {
210
+ sendForbidden(res, "OTP verification is disabled for this account");
211
+ return;
212
+ }
213
+ const body = await readJsonBodyOrError(req, res, maxBodyBytes);
214
+ if (body === undefined)
215
+ return;
216
+ const payload = (body && typeof body === "object" ? body : {});
217
+ const phone = typeof payload.phone === "string" ? normalizePhone(payload.phone.trim()) : "";
218
+ if (!phone || !isValidPhone(phone)) {
219
+ sendInvalidRequest(res, "invalid phone number — use E.164 format (e.g. +447123456789)");
220
+ return;
221
+ }
222
+ const result = requestOtp(phone);
223
+ if (!result.ok) {
224
+ sendJson(res, 429, {
225
+ error: { message: "rate limited — try again shortly", type: "rate_limited" },
226
+ retry_after_ms: result.retryAfterMs,
227
+ });
228
+ return;
229
+ }
230
+ try {
231
+ await deliverOtp(phone, result.code);
232
+ }
233
+ catch {
234
+ sendUnavailable(res, "failed to send verification code — is WhatsApp connected?");
235
+ return;
236
+ }
237
+ sendJson(res, 200, { ok: true });
238
+ }
239
+ // ---------------------------------------------------------------------------
240
+ // Route: POST /otp/verify
241
+ // ---------------------------------------------------------------------------
242
+ async function handleOtpVerify(req, res, accountId, cfg, maxBodyBytes) {
243
+ if (req.method !== "POST") {
244
+ sendMethodNotAllowed(res);
245
+ return;
246
+ }
247
+ if (!allowsVerified(cfg)) {
248
+ sendForbidden(res, "OTP verification is disabled for this account");
249
+ return;
250
+ }
251
+ const body = await readJsonBodyOrError(req, res, maxBodyBytes);
252
+ if (body === undefined)
253
+ return;
254
+ const payload = (body && typeof body === "object" ? body : {});
255
+ const phone = typeof payload.phone === "string" ? normalizePhone(payload.phone.trim()) : "";
256
+ const code = typeof payload.code === "string" ? payload.code.trim() : "";
257
+ const name = typeof payload.name === "string" ? payload.name.trim() : undefined;
258
+ if (!phone || !isValidPhone(phone)) {
259
+ sendInvalidRequest(res, "invalid phone number");
260
+ return;
261
+ }
262
+ if (!code) {
263
+ sendInvalidRequest(res, "code is required");
264
+ return;
265
+ }
266
+ const result = verifyOtp(phone, code);
267
+ if (!result.ok) {
268
+ const messages = {
269
+ not_found: "no pending verification for this number",
270
+ expired: "verification code expired",
271
+ max_attempts: "too many attempts — request a new code",
272
+ invalid: "incorrect code",
273
+ };
274
+ sendJson(res, 400, {
275
+ error: {
276
+ message: messages[result.error] ?? "verification failed",
277
+ type: result.error,
278
+ },
279
+ });
280
+ return;
281
+ }
282
+ const agentId = resolvePublicAgentId(cfg, accountId);
283
+ const sessionKey = buildPublicSessionKey(agentId, phone);
284
+ sendJson(res, 200, {
285
+ session_key: sessionKey,
286
+ agent_id: agentId,
287
+ phone,
288
+ ...(name ? { name } : {}),
289
+ });
290
+ }
291
+ // ---------------------------------------------------------------------------
292
+ // Route: POST /chat — uses dispatchInboundMessage (full pipeline)
293
+ // ---------------------------------------------------------------------------
294
+ async function handleChat(req, res, _accountId, cfg, maxBodyBytes) {
295
+ if (req.method !== "POST") {
296
+ sendMethodNotAllowed(res);
297
+ return;
298
+ }
299
+ const sessionKey = validateSessionKey(getSessionKeyHeader(req));
300
+ if (!sessionKey) {
301
+ sendInvalidRequest(res, "X-Session-Key header required (obtain from /session or /otp/verify)");
302
+ return;
303
+ }
304
+ const body = await readJsonBodyOrError(req, res, maxBodyBytes);
305
+ if (body === undefined)
306
+ return;
307
+ const payload = (body && typeof body === "object" ? body : {});
308
+ const message = typeof payload.message === "string" ? payload.message.trim() : "";
309
+ const stream = Boolean(payload.stream);
310
+ const rawAttachments = Array.isArray(payload.attachments)
311
+ ? payload.attachments
312
+ : [];
313
+ if (!message && rawAttachments.length === 0) {
314
+ sendInvalidRequest(res, "message or attachment required");
315
+ return;
316
+ }
317
+ const runId = `pub_${randomUUID()}`;
318
+ const now = Date.now();
319
+ // --- Save attachments to workspace uploads dir ---
320
+ let savedImagePaths = [];
321
+ let savedImageTypes = [];
322
+ let savedDocPaths = [];
323
+ const normalizedAttachments = rawAttachments
324
+ .map((a) => ({
325
+ type: typeof a?.type === "string" ? a.type : undefined,
326
+ mimeType: typeof a?.mimeType === "string" ? a.mimeType : undefined,
327
+ fileName: typeof a?.fileName === "string" ? a.fileName : undefined,
328
+ content: typeof a?.content === "string" ? a.content : undefined,
329
+ }))
330
+ .filter((a) => a.content);
331
+ if (normalizedAttachments.length > 0) {
332
+ const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
333
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
334
+ const uploadsDir = path.join(workspaceDir, "uploads");
335
+ try {
336
+ fs.mkdirSync(uploadsDir, { recursive: true });
337
+ }
338
+ catch {
339
+ /* ignore if exists */
340
+ }
341
+ const saved = saveAttachments(normalizedAttachments, uploadsDir);
342
+ savedImagePaths = saved.imagePaths;
343
+ savedImageTypes = saved.imageTypes;
344
+ savedDocPaths = saved.docPaths;
345
+ }
346
+ // --- Build MsgContext (same shape as WebSocket chat.send) ---
347
+ const docNote = savedDocPaths.length > 0 ? savedDocPaths.map((p) => `[file: ${p}]`).join("\n") + "\n\n" : "";
348
+ const messageWithDocs = docNote + message;
349
+ const ctx = {
350
+ Body: messageWithDocs,
351
+ BodyForAgent: messageWithDocs,
352
+ BodyForCommands: messageWithDocs,
353
+ RawBody: messageWithDocs,
354
+ CommandBody: messageWithDocs,
355
+ SessionKey: sessionKey,
356
+ Provider: INTERNAL_MESSAGE_CHANNEL,
357
+ Surface: INTERNAL_MESSAGE_CHANNEL,
358
+ OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
359
+ ChatType: "direct",
360
+ CommandAuthorized: false, // public clients never have command authority
361
+ MessageSid: runId,
362
+ MediaPaths: savedImagePaths.length > 0 ? savedImagePaths : undefined,
363
+ MediaPath: savedImagePaths[0],
364
+ MediaTypes: savedImageTypes.length > 0 ? savedImageTypes : undefined,
365
+ MediaType: savedImageTypes[0],
366
+ };
367
+ const agentId = resolveSessionAgentId({ sessionKey, config: cfg });
368
+ // --- Fire message:inbound hook for conversation archiving ---
369
+ const imageNote = savedImagePaths.length > 0 ? savedImagePaths.map((ip) => `[image: ${ip}]`).join("\n") : "";
370
+ const archiveText = [message, imageNote].filter(Boolean).join("\n").trim();
371
+ void triggerInternalHook(createInternalHookEvent("message", "inbound", sessionKey, {
372
+ text: archiveText || undefined,
373
+ timestamp: now,
374
+ chatType: "direct",
375
+ agentId,
376
+ channel: "webchat",
377
+ cfg,
378
+ }));
379
+ // --- Set up response prefix context ---
380
+ let prefixContext = {
381
+ identityName: resolveIdentityName(cfg, agentId),
382
+ };
383
+ // --- Dispatch via the full pipeline ---
384
+ const abortController = new AbortController();
385
+ const finalReplyParts = [];
386
+ let agentRunStarted = false;
387
+ if (!stream) {
388
+ // ---- Synchronous mode ----
389
+ try {
390
+ const dispatcher = createReplyDispatcher({
391
+ responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
392
+ responsePrefixContextProvider: () => prefixContext,
393
+ onError: () => { },
394
+ deliver: async (replyPayload, info) => {
395
+ const text = replyPayload.text?.trim() ?? "";
396
+ if (!text)
397
+ return;
398
+ if (info.kind === "final")
399
+ finalReplyParts.push(text);
400
+ },
401
+ });
402
+ await dispatchInboundMessage({
403
+ ctx,
404
+ cfg,
405
+ dispatcher,
406
+ replyOptions: {
407
+ runId,
408
+ abortSignal: abortController.signal,
409
+ disableBlockStreaming: true,
410
+ onAgentRunStart: () => {
411
+ agentRunStarted = true;
412
+ },
413
+ onModelSelected: (modelCtx) => {
414
+ prefixContext.provider = modelCtx.provider;
415
+ prefixContext.model = extractShortModelName(modelCtx.model);
416
+ prefixContext.modelFull = `${modelCtx.provider}/${modelCtx.model}`;
417
+ prefixContext.thinkingLevel = modelCtx.thinkLevel ?? "off";
418
+ },
419
+ },
420
+ });
421
+ // Wait for any queued reply delivery to complete.
422
+ await dispatcher.waitForIdle();
423
+ const combinedReply = finalReplyParts
424
+ .map((part) => part.trim())
425
+ .filter(Boolean)
426
+ .join("\n\n")
427
+ .trim();
428
+ // Fire message:outbound hook
429
+ if (combinedReply) {
430
+ void triggerInternalHook(createInternalHookEvent("message", "outbound", sessionKey, {
431
+ text: combinedReply,
432
+ timestamp: Date.now(),
433
+ chatType: "direct",
434
+ agentId,
435
+ channel: "webchat",
436
+ cfg,
437
+ }));
438
+ }
439
+ sendJson(res, 200, {
440
+ id: runId,
441
+ session_key: sessionKey,
442
+ message: combinedReply || null,
443
+ created: Math.floor(Date.now() / 1000),
444
+ });
445
+ }
446
+ catch (err) {
447
+ sendJson(res, 500, {
448
+ error: { message: String(err), type: "agent_error" },
449
+ });
450
+ }
451
+ return;
452
+ }
453
+ // ---- Streaming mode: SSE ----
454
+ setCorsHeaders(res);
455
+ setSseHeaders(res);
456
+ let closed = false;
457
+ // Listen for agent streaming events (text deltas, thinking, lifecycle).
458
+ const unsubscribe = onAgentEvent((evt) => {
459
+ if (evt.runId !== runId)
460
+ return;
461
+ if (closed)
462
+ return;
463
+ if (evt.stream === "assistant") {
464
+ const delta = evt.data?.delta;
465
+ const text = evt.data?.text;
466
+ const content = typeof delta === "string" ? delta : typeof text === "string" ? text : "";
467
+ if (!content)
468
+ return;
469
+ writeSse(res, { id: runId, type: "delta", content });
470
+ return;
471
+ }
472
+ if (evt.stream === "thinking") {
473
+ const text = evt.data?.text;
474
+ if (typeof text === "string" && text) {
475
+ writeSse(res, { id: runId, type: "thinking", content: text });
476
+ }
477
+ return;
478
+ }
479
+ if (evt.stream === "lifecycle") {
480
+ const phase = evt.data?.phase;
481
+ if (phase === "end" || phase === "error") {
482
+ if (!closed) {
483
+ closed = true;
484
+ unsubscribe();
485
+ writeDone(res);
486
+ res.end();
487
+ }
488
+ }
489
+ }
490
+ });
491
+ req.on("close", () => {
492
+ if (!closed) {
493
+ closed = true;
494
+ unsubscribe();
495
+ abortController.abort();
496
+ }
497
+ });
498
+ const dispatcher = createReplyDispatcher({
499
+ responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
500
+ responsePrefixContextProvider: () => prefixContext,
501
+ onError: () => { },
502
+ deliver: async (replyPayload, info) => {
503
+ if (closed)
504
+ return;
505
+ const text = replyPayload.text?.trim() ?? "";
506
+ if (!text)
507
+ return;
508
+ if (info.kind === "block") {
509
+ // Filler/block message — stream as a distinct event type.
510
+ writeSse(res, { id: runId, type: "filler", content: text });
511
+ return;
512
+ }
513
+ if (info.kind === "final") {
514
+ finalReplyParts.push(text);
515
+ }
516
+ },
517
+ });
518
+ void (async () => {
519
+ try {
520
+ await dispatchInboundMessage({
521
+ ctx,
522
+ cfg,
523
+ dispatcher,
524
+ replyOptions: {
525
+ runId,
526
+ abortSignal: abortController.signal,
527
+ onAgentRunStart: () => {
528
+ agentRunStarted = true;
529
+ },
530
+ onModelSelected: (modelCtx) => {
531
+ prefixContext.provider = modelCtx.provider;
532
+ prefixContext.model = extractShortModelName(modelCtx.model);
533
+ prefixContext.modelFull = `${modelCtx.provider}/${modelCtx.model}`;
534
+ prefixContext.thinkingLevel = modelCtx.thinkLevel ?? "off";
535
+ },
536
+ },
537
+ });
538
+ await dispatcher.waitForIdle();
539
+ if (closed)
540
+ return;
541
+ // If no agent run started (inline action / command), the response came
542
+ // through the dispatcher's deliver callback. Emit it as a final message
543
+ // so the client receives it.
544
+ if (!agentRunStarted) {
545
+ const combinedReply = finalReplyParts
546
+ .map((part) => part.trim())
547
+ .filter(Boolean)
548
+ .join("\n\n")
549
+ .trim();
550
+ if (combinedReply) {
551
+ writeSse(res, { id: runId, type: "message.final", content: combinedReply });
552
+ }
553
+ }
554
+ // Fire message:outbound hook
555
+ const outboundText = finalReplyParts.join("\n\n").trim();
556
+ if (outboundText) {
557
+ void triggerInternalHook(createInternalHookEvent("message", "outbound", sessionKey, {
558
+ text: outboundText,
559
+ timestamp: Date.now(),
560
+ chatType: "direct",
561
+ agentId,
562
+ channel: "webchat",
563
+ cfg,
564
+ }));
565
+ }
566
+ }
567
+ catch (err) {
568
+ if (closed)
569
+ return;
570
+ writeSse(res, { id: runId, type: "error", error: String(err) });
571
+ emitAgentEvent({
572
+ runId,
573
+ stream: "lifecycle",
574
+ data: { phase: "error" },
575
+ });
576
+ }
577
+ finally {
578
+ if (!closed) {
579
+ closed = true;
580
+ unsubscribe();
581
+ writeDone(res);
582
+ res.end();
583
+ }
584
+ }
585
+ })();
586
+ }
587
+ // ---------------------------------------------------------------------------
588
+ // Route: GET /chat/history
589
+ // ---------------------------------------------------------------------------
590
+ async function handleChatHistory(req, res) {
591
+ if (req.method !== "GET") {
592
+ sendMethodNotAllowed(res, "GET");
593
+ return;
594
+ }
595
+ const sessionKey = validateSessionKey(getSessionKeyHeader(req));
596
+ if (!sessionKey) {
597
+ sendInvalidRequest(res, "X-Session-Key header required");
598
+ return;
599
+ }
600
+ const { storePath, entry } = loadSessionEntry(sessionKey);
601
+ const sessionId = entry?.sessionId;
602
+ let rawMessages = [];
603
+ if (entry && storePath) {
604
+ // Stitch previous sessions + current session into one continuous history.
605
+ const previous = entry.previousSessions ?? [];
606
+ for (const prev of previous) {
607
+ if (!prev.sessionId)
608
+ continue;
609
+ const msgs = readSessionMessages(prev.sessionId, storePath, prev.sessionFile);
610
+ if (msgs.length > 0)
611
+ rawMessages.push(...msgs);
612
+ }
613
+ if (sessionId) {
614
+ const current = readSessionMessages(sessionId, storePath, entry.sessionFile);
615
+ rawMessages.push(...current);
616
+ }
617
+ }
618
+ // Apply limits and sanitize (same as the WebSocket chat.history handler).
619
+ const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
620
+ const limitParam = url.searchParams.get("limit");
621
+ const requested = limitParam ? Math.min(10_000, Math.max(1, Number(limitParam) || 5000)) : 5000;
622
+ const messages = rawMessages.length > requested ? rawMessages.slice(-requested) : rawMessages;
623
+ const sanitized = stripEnvelopeFromMessages(stripBase64ImagesFromMessages(messages));
624
+ sendJson(res, 200, {
625
+ session_key: sessionKey,
626
+ messages: sanitized,
627
+ });
628
+ }
629
+ // ---------------------------------------------------------------------------
630
+ // Route: POST /chat/abort
631
+ // ---------------------------------------------------------------------------
632
+ async function handleChatAbort(req, res, maxBodyBytes) {
633
+ if (req.method !== "POST") {
634
+ sendMethodNotAllowed(res);
635
+ return;
636
+ }
637
+ const sessionKey = validateSessionKey(getSessionKeyHeader(req));
638
+ if (!sessionKey) {
639
+ sendInvalidRequest(res, "X-Session-Key header required");
640
+ return;
641
+ }
642
+ const body = await readJsonBodyOrError(req, res, maxBodyBytes);
643
+ if (body === undefined)
644
+ return;
645
+ const payload = (body && typeof body === "object" ? body : {});
646
+ const runId = typeof payload.run_id === "string" ? payload.run_id.trim() : undefined;
647
+ // Emit a lifecycle error event — terminates any in-flight SSE stream and
648
+ // causes the agent runner to detect abort via the abort signal.
649
+ if (runId) {
650
+ emitAgentEvent({
651
+ runId,
652
+ stream: "lifecycle",
653
+ data: { phase: "error", error: "aborted via REST API" },
654
+ });
655
+ }
656
+ sendJson(res, 200, { ok: true, run_id: runId ?? null });
657
+ }
658
+ // ---------------------------------------------------------------------------
659
+ // Main handler
660
+ // ---------------------------------------------------------------------------
661
+ /**
662
+ * Handle all `/public/api/v1/:accountId/*` HTTP requests.
663
+ *
664
+ * Returns `true` if the request was handled (response written), `false` if
665
+ * the URL doesn't match and should fall through to the next handler.
666
+ */
667
+ export async function handlePublicChatApiRequest(req, res, opts) {
668
+ const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
669
+ if (!url.pathname.startsWith(API_PREFIX))
670
+ return false;
671
+ const maxBodyBytes = opts?.maxBodyBytes ?? 1024 * 1024;
672
+ // CORS preflight
673
+ setCorsHeaders(res);
674
+ if (req.method === "OPTIONS") {
675
+ res.statusCode = 204;
676
+ res.end();
677
+ return true;
678
+ }
679
+ // Parse: /public/api/v1/:accountId/:route
680
+ const rest = url.pathname.slice(API_PREFIX.length); // e.g. "myaccount/chat"
681
+ const slashIdx = rest.indexOf("/");
682
+ const rawAccountId = slashIdx >= 0 ? rest.slice(0, slashIdx) : rest;
683
+ const route = slashIdx >= 0 ? rest.slice(slashIdx + 1).replace(/\/+$/, "") : "";
684
+ const accountId = validateAccountId(rawAccountId);
685
+ if (!accountId) {
686
+ sendInvalidRequest(res, "invalid or missing accountId in URL");
687
+ return true;
688
+ }
689
+ const cfg = loadConfig();
690
+ if (!cfg.publicChat?.enabled) {
691
+ sendJson(res, 404, {
692
+ error: { message: "public chat is not enabled", type: "not_found" },
693
+ });
694
+ return true;
695
+ }
696
+ // CORS headers on all responses
697
+ setCorsHeaders(res);
698
+ switch (route) {
699
+ case "session":
700
+ await handleSession(req, res, accountId, cfg, maxBodyBytes);
701
+ return true;
702
+ case "otp/request":
703
+ await handleOtpRequest(req, res, accountId, cfg, maxBodyBytes);
704
+ return true;
705
+ case "otp/verify":
706
+ await handleOtpVerify(req, res, accountId, cfg, maxBodyBytes);
707
+ return true;
708
+ case "chat":
709
+ await handleChat(req, res, accountId, cfg, maxBodyBytes);
710
+ return true;
711
+ case "chat/history":
712
+ await handleChatHistory(req, res);
713
+ return true;
714
+ case "chat/abort":
715
+ await handleChatAbort(req, res, maxBodyBytes);
716
+ return true;
717
+ default:
718
+ sendNotFound(res);
719
+ return true;
720
+ }
721
+ }
@@ -6,6 +6,7 @@ import { handleSlackHttpRequest } from "../slack/http/index.js";
6
6
  import { createCloudApiWebhookHandler } from "../web/providers/cloud/webhook-http.js";
7
7
  import { resolveAgentAvatar } from "../agents/identity-avatar.js";
8
8
  import { handleBrandIconRequest, handleControlUiAvatarRequest, handleControlUiHttpRequest, handlePublicChatHttpRequest, handlePublicWidgetRequest, } from "./control-ui.js";
9
+ import { handlePublicChatApiRequest } from "./public-chat-api.js";
9
10
  import { isLicensed } from "../license/state.js";
10
11
  import { getEffectiveTrustedProxies, isExternalRequest } from "./net.js";
11
12
  import { extractHookToken, getHookChannelError, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody, resolveHookChannel, resolveHookDeliver, } from "./hooks.js";
@@ -154,6 +155,8 @@ export function createGatewayHttpServer(opts) {
154
155
  const configSnapshot = loadConfig();
155
156
  // Public chat routes — served before license enforcement so public visitors
156
157
  // are never redirected to /setup.
158
+ if (await handlePublicChatApiRequest(req, res))
159
+ return;
157
160
  if (handlePublicChatHttpRequest(req, res, { config: configSnapshot }))
158
161
  return;
159
162
  if (handlePublicWidgetRequest(req, res, { config: configSnapshot }))
@@ -13,7 +13,7 @@ import { importNodeLlamaCpp } from "./node-llama.js";
13
13
  * runaway memory consumption (40-76 GB) on Intel x64 Mac + AMD GPU.
14
14
  */
15
15
  const DEFAULT_LOCAL_MODEL = {
16
- model: "hf:ggml-org/embeddinggemma-300M-Q8_0-GGUF/embeddinggemma-300M-Q8_0.gguf",
16
+ model: "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf",
17
17
  label: "embeddinggemma-300M",
18
18
  };
19
19
  function selectDefaultLocalModel() {
@@ -2,15 +2,20 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  export function resolveBundledPluginsDir() {
5
+ return resolveBundledPluginsDirDetailed().dir;
6
+ }
7
+ export function resolveBundledPluginsDirDetailed() {
8
+ const triedPaths = [];
5
9
  const override = process.env.TASKMASTER_BUNDLED_PLUGINS_DIR?.trim();
6
10
  if (override)
7
- return override;
11
+ return { dir: override, source: "env" };
8
12
  // bun --compile: ship a sibling `extensions/` next to the executable.
9
13
  try {
10
14
  const execDir = path.dirname(process.execPath);
11
15
  const sibling = path.join(execDir, "extensions");
16
+ triedPaths.push(sibling);
12
17
  if (fs.existsSync(sibling))
13
- return sibling;
18
+ return { dir: sibling, source: "exec-sibling" };
14
19
  }
15
20
  catch {
16
21
  // ignore
@@ -20,8 +25,9 @@ export function resolveBundledPluginsDir() {
20
25
  let cursor = path.dirname(fileURLToPath(import.meta.url));
21
26
  for (let i = 0; i < 6; i += 1) {
22
27
  const candidate = path.join(cursor, "extensions");
28
+ triedPaths.push(candidate);
23
29
  if (fs.existsSync(candidate))
24
- return candidate;
30
+ return { dir: candidate, source: "walk-up" };
25
31
  const parent = path.dirname(cursor);
26
32
  if (parent === cursor)
27
33
  break;
@@ -31,5 +37,5 @@ export function resolveBundledPluginsDir() {
31
37
  catch {
32
38
  // ignore
33
39
  }
34
- return undefined;
40
+ return { dir: undefined, triedPaths };
35
41
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { resolveConfigDir, resolveUserPath } from "../utils.js";
4
- import { resolveBundledPluginsDir } from "./bundled-dir.js";
4
+ import { resolveBundledPluginsDirDetailed } from "./bundled-dir.js";
5
5
  const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
6
6
  function isExtensionFile(filePath) {
7
7
  const ext = path.extname(filePath);
@@ -262,15 +262,22 @@ export function discoverTaskmasterPlugins(params) {
262
262
  diagnostics,
263
263
  seen,
264
264
  });
265
- const bundledDir = resolveBundledPluginsDir();
266
- if (bundledDir) {
265
+ const bundled = resolveBundledPluginsDirDetailed();
266
+ if (bundled.dir) {
267
267
  discoverInDirectory({
268
- dir: bundledDir,
268
+ dir: bundled.dir,
269
269
  origin: "bundled",
270
270
  candidates,
271
271
  diagnostics,
272
272
  seen,
273
273
  });
274
274
  }
275
+ else {
276
+ diagnostics.push({
277
+ level: "warn",
278
+ message: `bundled extensions dir not resolved${bundled.triedPaths?.length ? ` (tried: ${bundled.triedPaths.join(", ")})` : ""}`,
279
+ source: "bundled-dir",
280
+ });
281
+ }
275
282
  return { candidates, diagnostics };
276
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.0.76",
3
+ "version": "1.0.77",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"