@rubytech/taskmaster 1.1.0 → 1.2.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-D7ZHRWnP.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-CfybK7_N.css">
9
+ <script type="module" crossorigin src="./assets/index-DwMopZij.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-DvB85yTz.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -175,10 +175,46 @@ function extractMediaRefs(text) {
175
175
  }
176
176
  // Pattern: MEDIA:/absolute/path (used by tool results like image_generate)
177
177
  const MEDIA_PREFIX_PATTERN = /\bMEDIA:(\S+)/g;
178
+ /** Map file extension to MIME type for MEDIA: refs. */
179
+ export function mimeFromExt(ext) {
180
+ switch (ext) {
181
+ case "jpg":
182
+ case "jpeg":
183
+ return "image/jpeg";
184
+ case "png":
185
+ return "image/png";
186
+ case "gif":
187
+ return "image/gif";
188
+ case "webp":
189
+ return "image/webp";
190
+ case "heic":
191
+ return "image/heic";
192
+ case "heif":
193
+ return "image/heif";
194
+ case "bmp":
195
+ return "image/bmp";
196
+ case "tiff":
197
+ case "tif":
198
+ return "image/tiff";
199
+ case "pdf":
200
+ return "application/pdf";
201
+ case "mp3":
202
+ return "audio/mpeg";
203
+ case "ogg":
204
+ case "oga":
205
+ return "audio/ogg";
206
+ case "wav":
207
+ return "audio/wav";
208
+ case "m4a":
209
+ return "audio/mp4";
210
+ default:
211
+ return "application/octet-stream";
212
+ }
213
+ }
178
214
  /**
179
215
  * Parse MEDIA:/path references from text to extract file paths.
180
- * Tool results (e.g. image_generate) use this format instead of
181
- * [media attached: ...] annotations.
216
+ * Tool results (e.g. image_generate, document_to_pdf) use this format
217
+ * instead of [media attached: ...] annotations.
182
218
  */
183
219
  function extractMediaPrefixRefs(text) {
184
220
  if (!text.includes("MEDIA:"))
@@ -190,8 +226,7 @@ function extractMediaPrefixRefs(text) {
190
226
  const absPath = match[1]?.trim();
191
227
  if (absPath) {
192
228
  const ext = absPath.split(".").pop()?.toLowerCase() ?? "";
193
- const mimeType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
194
- refs.push({ absPath, mimeType });
229
+ refs.push({ absPath, mimeType: mimeFromExt(ext) });
195
230
  }
196
231
  }
197
232
  return refs;
@@ -254,8 +289,11 @@ export function stripBase64ImagesFromMessages(messages) {
254
289
  /**
255
290
  * Sanitize media in chat messages for UI display.
256
291
  * - Extracts file paths from [media attached: ...] text annotations
292
+ * - Extracts file paths from MEDIA:/path annotations (all messages)
257
293
  * - Removes base64 image blocks
258
- * - Creates URL-based image references for the /api/media endpoint
294
+ * - Creates URL-based image/file references for the /api/media endpoint
295
+ * - Deduplicates by path so assistant echoes of tool results don't produce
296
+ * duplicate blocks
259
297
  *
260
298
  * Must be called BEFORE stripEnvelopeFromMessages (which strips annotations).
261
299
  */
@@ -264,42 +302,56 @@ export function sanitizeMediaForChat(messages, workspaceRoot) {
264
302
  // No workspace context — fall back to plain base64 stripping
265
303
  return stripBase64ImagesFromMessages(messages);
266
304
  }
305
+ // Track paths across all messages to prevent duplicate blocks when an
306
+ // assistant message echoes a MEDIA: ref that already appeared in a tool result.
307
+ const seenPaths = new Set();
267
308
  let changed = false;
268
309
  const next = messages.map((message) => {
269
- const result = sanitizeMessageMedia(message, workspaceRoot);
310
+ const result = sanitizeMessageMedia(message, workspaceRoot, seenPaths);
270
311
  if (result !== message)
271
312
  changed = true;
272
313
  return result;
273
314
  });
274
315
  return changed ? next : messages;
275
316
  }
276
- function sanitizeMessageMedia(message, workspaceRoot) {
317
+ function sanitizeMessageMedia(message, workspaceRoot, seenPaths) {
277
318
  if (!message || typeof message !== "object")
278
319
  return message;
279
320
  const entry = message;
280
321
  // Collect media refs from text content (works for both string and array content).
281
- // MEDIA: prefix refs are only extracted from tool result messages — assistant text
282
- // may echo "MEDIA:" but that should not produce a duplicate image block.
283
- const role = typeof entry.role === "string" ? entry.role.toLowerCase() : "";
284
- const isToolResult = role === "toolresult" || role === "tool_result" ||
285
- typeof entry.toolCallId === "string" || typeof entry.tool_call_id === "string";
286
- const mediaRefs = extractMediaRefsFromMessage(entry, isToolResult);
287
- // Build URL-based image blocks from annotations
322
+ // MEDIA: prefix refs are extracted from ALL messages — the agent may reference
323
+ // existing workspace files directly via MEDIA: in its response text. The
324
+ // seenPaths set prevents duplicates when an assistant echoes a MEDIA: ref that
325
+ // already appeared in a tool result.
326
+ const mediaRefs = extractMediaRefsFromMessage(entry, true);
327
+ // Build URL-based media blocks from annotations.
328
+ // Images become { type: "image" }, everything else becomes { type: "file" }.
329
+ // Skip paths already converted in an earlier message (dedup across conversation).
288
330
  const imageBlocks = [];
331
+ const fileBlocks = [];
289
332
  for (const ref of mediaRefs) {
333
+ if (seenPaths.has(ref.absPath))
334
+ continue;
290
335
  const url = mediaRefToUrl(ref, workspaceRoot);
291
- if (url) {
336
+ if (!url)
337
+ continue;
338
+ seenPaths.add(ref.absPath);
339
+ if (ref.mimeType.startsWith("image/")) {
292
340
  imageBlocks.push({ type: "image", url });
293
341
  }
342
+ else {
343
+ const name = nodePath.basename(ref.absPath);
344
+ fileBlocks.push({ type: "file", url, name, mimeType: ref.mimeType });
345
+ }
294
346
  }
295
347
  if (!Array.isArray(entry.content)) {
296
- // String content — no base64 blocks to strip, just add image blocks if found
297
- if (imageBlocks.length === 0)
348
+ // String content — no base64 blocks to strip, just add media blocks if found
349
+ if (imageBlocks.length === 0 && fileBlocks.length === 0)
298
350
  return message;
299
351
  const textContent = typeof entry.content === "string" ? entry.content : "";
300
352
  return {
301
353
  ...entry,
302
- content: [{ type: "text", text: textContent }, ...imageBlocks],
354
+ content: [{ type: "text", text: textContent }, ...imageBlocks, ...fileBlocks],
303
355
  };
304
356
  }
305
357
  // Array content — remove base64 image blocks, add URL-based ones
@@ -332,15 +384,47 @@ function sanitizeMessageMedia(message, workspaceRoot) {
332
384
  }
333
385
  }
334
386
  }
335
- // Add URL-based image blocks from tool result annotations
336
- if (imageBlocks.length > 0) {
387
+ // Add URL-based media blocks from tool result annotations
388
+ if (imageBlocks.length > 0 || fileBlocks.length > 0) {
337
389
  didChange = true;
338
- filtered.push(...imageBlocks);
390
+ filtered.push(...imageBlocks, ...fileBlocks);
339
391
  }
340
392
  if (!didChange)
341
393
  return message;
342
394
  return { ...entry, content: filtered };
343
395
  }
396
+ /**
397
+ * Extract file attachment metadata from messages (for API responses).
398
+ * Scans all messages for MEDIA: refs that point to non-image files
399
+ * and returns structured attachment objects with download URLs.
400
+ * Deduplicates by path.
401
+ */
402
+ export function extractFileAttachments(messages, workspaceRoot) {
403
+ const attachments = [];
404
+ const seen = new Set();
405
+ for (const message of messages) {
406
+ if (!message || typeof message !== "object")
407
+ continue;
408
+ const entry = message;
409
+ const refs = extractMediaRefsFromMessage(entry, true);
410
+ for (const ref of refs) {
411
+ if (ref.mimeType.startsWith("image/"))
412
+ continue;
413
+ if (seen.has(ref.absPath))
414
+ continue;
415
+ const url = mediaRefToUrl(ref, workspaceRoot);
416
+ if (!url)
417
+ continue;
418
+ seen.add(ref.absPath);
419
+ attachments.push({
420
+ url,
421
+ name: nodePath.basename(ref.absPath),
422
+ mimeType: ref.mimeType,
423
+ });
424
+ }
425
+ }
426
+ return attachments;
427
+ }
344
428
  function extractMediaRefsFromMessage(entry, includeMediaPrefix) {
345
429
  if (typeof entry.content === "string") {
346
430
  const refs = extractMediaRefs(entry.content);
@@ -4,12 +4,13 @@ import { resolveAgentWorkspaceRoot } from "../agents/agent-scope.js";
4
4
  // ---------------------------------------------------------------------------
5
5
  // Workspace media file endpoint
6
6
  // ---------------------------------------------------------------------------
7
- // Serves image files from the workspace root so that chat history can display
8
- // inline images by URL instead of embedding base64 data in WebSocket messages.
7
+ // Serves media files (images, PDFs, audio) from the workspace root so that
8
+ // chat history can display inline images and file download cards by URL
9
+ // instead of embedding base64 data in WebSocket messages.
9
10
  //
10
11
  // Route: GET /api/media?path=<workspace-relative-path>
11
12
  // ---------------------------------------------------------------------------
12
- const ALLOWED_IMAGE_EXTENSIONS = new Set([
13
+ const ALLOWED_MEDIA_EXTENSIONS = new Set([
13
14
  ".png",
14
15
  ".jpg",
15
16
  ".jpeg",
@@ -78,7 +79,7 @@ export function handleMediaRequest(req, res, opts) {
78
79
  return true;
79
80
  }
80
81
  const ext = path.extname(relPath).toLowerCase();
81
- if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
82
+ if (!ALLOWED_MEDIA_EXTENSIONS.has(ext)) {
82
83
  res.statusCode = 403;
83
84
  res.end("Forbidden");
84
85
  return true;
@@ -37,7 +37,7 @@ import { requestOtp, verifyOtp } from "./public-chat/otp.js";
37
37
  import { deliverOtp } from "./public-chat/deliver-otp.js";
38
38
  import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
39
39
  import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
40
- import { sanitizeMediaForChat, stripEnvelopeFromMessages } from "./chat-sanitize.js";
40
+ import { extractFileAttachments, sanitizeMediaForChat, stripEnvelopeFromMessages } from "./chat-sanitize.js";
41
41
  import { resolveWorkspaceRoot } from "./media-http.js";
42
42
  import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, writeDone, } from "./http-common.js";
43
43
  // ---------------------------------------------------------------------------
@@ -442,11 +442,20 @@ async function handleChat(req, res, _accountId, cfg, maxBodyBytes) {
442
442
  cfg,
443
443
  }));
444
444
  }
445
+ // Extract file attachments from tool results in this session
446
+ const workspaceRoot = resolveWorkspaceRoot(cfg);
447
+ const { entry: sessionEntry, storePath } = loadSessionEntry(sessionKey);
448
+ let attachments = [];
449
+ if (sessionEntry?.sessionId && storePath) {
450
+ const sessionMsgs = readSessionMessages(sessionEntry.sessionId, storePath, sessionEntry.sessionFile);
451
+ attachments = extractFileAttachments(sessionMsgs, workspaceRoot);
452
+ }
445
453
  sendJson(res, 200, {
446
454
  id: runId,
447
455
  session_key: sessionKey,
448
456
  message: combinedReply || null,
449
457
  created: Math.floor(Date.now() / 1000),
458
+ ...(attachments.length > 0 ? { attachments } : {}),
450
459
  });
451
460
  }
452
461
  catch (err) {
@@ -484,7 +493,12 @@ async function handleChat(req, res, _accountId, cfg, maxBodyBytes) {
484
493
  }
485
494
  if (evt.stream === "lifecycle") {
486
495
  const phase = evt.data?.phase;
487
- if (phase === "end" || phase === "error") {
496
+ if (phase === "end") {
497
+ // Don't close here — let the finally block emit file attachments
498
+ // before writing [DONE]. Just stop listening for further events.
499
+ unsubscribe();
500
+ }
501
+ else if (phase === "error") {
488
502
  if (!closed) {
489
503
  closed = true;
490
504
  unsubscribe();
@@ -582,6 +596,21 @@ async function handleChat(req, res, _accountId, cfg, maxBodyBytes) {
582
596
  }
583
597
  finally {
584
598
  if (!closed) {
599
+ // Emit file attachments (e.g. PDFs from document_to_pdf) before closing
600
+ try {
601
+ const workspaceRoot = resolveWorkspaceRoot(cfg);
602
+ const { entry: sessionEntry, storePath } = loadSessionEntry(sessionKey);
603
+ if (sessionEntry?.sessionId && storePath) {
604
+ const sessionMsgs = readSessionMessages(sessionEntry.sessionId, storePath, sessionEntry.sessionFile);
605
+ const attachments = extractFileAttachments(sessionMsgs, workspaceRoot);
606
+ for (const att of attachments) {
607
+ writeSse(res, { id: runId, type: "attachment", ...att });
608
+ }
609
+ }
610
+ }
611
+ catch {
612
+ // Attachment extraction failure should not break the stream
613
+ }
585
614
  closed = true;
586
615
  unsubscribe();
587
616
  writeDone(res);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/taskmaster",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"