@rubytech/taskmaster 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,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-DzlEdl36.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-DTaSylHl.css">
9
+ <script type="module" crossorigin src="./assets/index-N8du4fwV.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-DtQHRIVD.css">
11
11
  </head>
12
12
  <body>
13
13
  <taskmaster-app></taskmaster-app>
@@ -130,6 +130,7 @@ export function stripEnvelopeFromMessages(messages) {
130
130
  // 2. Remove base64 image blocks from content
131
131
  // 3. Add { type: "image", url: "/api/media?path=..." } blocks
132
132
  // ---------------------------------------------------------------------------
133
+ import nodeFs from "node:fs";
133
134
  import nodePath from "node:path";
134
135
  function isBase64ImageBlock(block) {
135
136
  if (!block || typeof block !== "object")
@@ -175,10 +176,46 @@ function extractMediaRefs(text) {
175
176
  }
176
177
  // Pattern: MEDIA:/absolute/path (used by tool results like image_generate)
177
178
  const MEDIA_PREFIX_PATTERN = /\bMEDIA:(\S+)/g;
179
+ /** Map file extension to MIME type for MEDIA: refs. */
180
+ export function mimeFromExt(ext) {
181
+ switch (ext) {
182
+ case "jpg":
183
+ case "jpeg":
184
+ return "image/jpeg";
185
+ case "png":
186
+ return "image/png";
187
+ case "gif":
188
+ return "image/gif";
189
+ case "webp":
190
+ return "image/webp";
191
+ case "heic":
192
+ return "image/heic";
193
+ case "heif":
194
+ return "image/heif";
195
+ case "bmp":
196
+ return "image/bmp";
197
+ case "tiff":
198
+ case "tif":
199
+ return "image/tiff";
200
+ case "pdf":
201
+ return "application/pdf";
202
+ case "mp3":
203
+ return "audio/mpeg";
204
+ case "ogg":
205
+ case "oga":
206
+ return "audio/ogg";
207
+ case "wav":
208
+ return "audio/wav";
209
+ case "m4a":
210
+ return "audio/mp4";
211
+ default:
212
+ return "application/octet-stream";
213
+ }
214
+ }
178
215
  /**
179
216
  * 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.
217
+ * Tool results (e.g. image_generate, document_to_pdf) use this format
218
+ * instead of [media attached: ...] annotations.
182
219
  */
183
220
  function extractMediaPrefixRefs(text) {
184
221
  if (!text.includes("MEDIA:"))
@@ -190,8 +227,7 @@ function extractMediaPrefixRefs(text) {
190
227
  const absPath = match[1]?.trim();
191
228
  if (absPath) {
192
229
  const ext = absPath.split(".").pop()?.toLowerCase() ?? "";
193
- const mimeType = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
194
- refs.push({ absPath, mimeType });
230
+ refs.push({ absPath, mimeType: mimeFromExt(ext) });
195
231
  }
196
232
  }
197
233
  return refs;
@@ -201,7 +237,14 @@ function mediaRefToUrl(ref, workspaceRoot) {
201
237
  // Must stay within workspace (no ../ escapes)
202
238
  if (relPath.startsWith("..") || nodePath.isAbsolute(relPath))
203
239
  return null;
204
- return `/api/media?path=${encodeURIComponent(relPath)}`;
240
+ // Append file mtime as cache buster so updated files are never served stale.
241
+ let mtime = "";
242
+ try {
243
+ const stat = nodeFs.statSync(ref.absPath);
244
+ mtime = `&t=${stat.mtimeMs | 0}`;
245
+ }
246
+ catch { /* file may not exist yet */ }
247
+ return `/api/media?path=${encodeURIComponent(relPath)}${mtime}`;
205
248
  }
206
249
  function stripBase64FromContentBlocks(content) {
207
250
  let changed = false;
@@ -254,8 +297,11 @@ export function stripBase64ImagesFromMessages(messages) {
254
297
  /**
255
298
  * Sanitize media in chat messages for UI display.
256
299
  * - Extracts file paths from [media attached: ...] text annotations
300
+ * - Extracts file paths from MEDIA:/path annotations (all messages)
257
301
  * - Removes base64 image blocks
258
- * - Creates URL-based image references for the /api/media endpoint
302
+ * - Creates URL-based image/file references for the /api/media endpoint
303
+ * - Deduplicates by path so assistant echoes of tool results don't produce
304
+ * duplicate blocks
259
305
  *
260
306
  * Must be called BEFORE stripEnvelopeFromMessages (which strips annotations).
261
307
  */
@@ -264,42 +310,62 @@ export function sanitizeMediaForChat(messages, workspaceRoot) {
264
310
  // No workspace context — fall back to plain base64 stripping
265
311
  return stripBase64ImagesFromMessages(messages);
266
312
  }
313
+ // Track paths within each assistant turn to prevent duplicate blocks when an
314
+ // assistant message echoes a MEDIA: ref that already appeared in a tool result.
315
+ // Reset at each user message so the same file can be re-shared in later turns.
316
+ const seenPaths = new Set();
267
317
  let changed = false;
268
318
  const next = messages.map((message) => {
269
- const result = sanitizeMessageMedia(message, workspaceRoot);
319
+ // Reset dedup at user turn boundaries so files can appear again in later turns.
320
+ const entry = message;
321
+ const role = typeof entry?.role === "string" ? entry.role.toLowerCase() : "";
322
+ if (role === "user")
323
+ seenPaths.clear();
324
+ const result = sanitizeMessageMedia(message, workspaceRoot, seenPaths);
270
325
  if (result !== message)
271
326
  changed = true;
272
327
  return result;
273
328
  });
274
329
  return changed ? next : messages;
275
330
  }
276
- function sanitizeMessageMedia(message, workspaceRoot) {
331
+ function sanitizeMessageMedia(message, workspaceRoot, seenPaths) {
277
332
  if (!message || typeof message !== "object")
278
333
  return message;
279
334
  const entry = message;
280
335
  // 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
336
+ // MEDIA: prefix refs are extracted from ALL messages — the agent may reference
337
+ // existing workspace files directly via MEDIA: in its response text. The
338
+ // seenPaths set prevents duplicates when an assistant echoes a MEDIA: ref that
339
+ // already appeared in a tool result.
340
+ const mediaRefs = extractMediaRefsFromMessage(entry, true);
341
+ // Build URL-based media blocks from annotations.
342
+ // Images become { type: "image" }, everything else becomes { type: "file" }.
343
+ // Skip paths already converted in an earlier message (dedup across conversation).
288
344
  const imageBlocks = [];
345
+ const fileBlocks = [];
289
346
  for (const ref of mediaRefs) {
347
+ if (seenPaths.has(ref.absPath))
348
+ continue;
290
349
  const url = mediaRefToUrl(ref, workspaceRoot);
291
- if (url) {
350
+ if (!url)
351
+ continue;
352
+ seenPaths.add(ref.absPath);
353
+ if (ref.mimeType.startsWith("image/")) {
292
354
  imageBlocks.push({ type: "image", url });
293
355
  }
356
+ else {
357
+ const name = nodePath.basename(ref.absPath);
358
+ fileBlocks.push({ type: "file", url, name, mimeType: ref.mimeType });
359
+ }
294
360
  }
295
361
  if (!Array.isArray(entry.content)) {
296
- // String content — no base64 blocks to strip, just add image blocks if found
297
- if (imageBlocks.length === 0)
362
+ // String content — no base64 blocks to strip, just add media blocks if found
363
+ if (imageBlocks.length === 0 && fileBlocks.length === 0)
298
364
  return message;
299
365
  const textContent = typeof entry.content === "string" ? entry.content : "";
300
366
  return {
301
367
  ...entry,
302
- content: [{ type: "text", text: textContent }, ...imageBlocks],
368
+ content: [{ type: "text", text: textContent }, ...imageBlocks, ...fileBlocks],
303
369
  };
304
370
  }
305
371
  // Array content — remove base64 image blocks, add URL-based ones
@@ -332,15 +398,47 @@ function sanitizeMessageMedia(message, workspaceRoot) {
332
398
  }
333
399
  }
334
400
  }
335
- // Add URL-based image blocks from tool result annotations
336
- if (imageBlocks.length > 0) {
401
+ // Add URL-based media blocks from tool result annotations
402
+ if (imageBlocks.length > 0 || fileBlocks.length > 0) {
337
403
  didChange = true;
338
- filtered.push(...imageBlocks);
404
+ filtered.push(...imageBlocks, ...fileBlocks);
339
405
  }
340
406
  if (!didChange)
341
407
  return message;
342
408
  return { ...entry, content: filtered };
343
409
  }
410
+ /**
411
+ * Extract file attachment metadata from messages (for API responses).
412
+ * Scans all messages for MEDIA: refs that point to non-image files
413
+ * and returns structured attachment objects with download URLs.
414
+ * Deduplicates by path.
415
+ */
416
+ export function extractFileAttachments(messages, workspaceRoot) {
417
+ const attachments = [];
418
+ const seen = new Set();
419
+ for (const message of messages) {
420
+ if (!message || typeof message !== "object")
421
+ continue;
422
+ const entry = message;
423
+ const refs = extractMediaRefsFromMessage(entry, true);
424
+ for (const ref of refs) {
425
+ if (ref.mimeType.startsWith("image/"))
426
+ continue;
427
+ if (seen.has(ref.absPath))
428
+ continue;
429
+ const url = mediaRefToUrl(ref, workspaceRoot);
430
+ if (!url)
431
+ continue;
432
+ seen.add(ref.absPath);
433
+ attachments.push({
434
+ url,
435
+ name: nodePath.basename(ref.absPath),
436
+ mimeType: ref.mimeType,
437
+ });
438
+ }
439
+ }
440
+ return attachments;
441
+ }
344
442
  function extractMediaRefsFromMessage(entry, includeMediaPrefix) {
345
443
  if (typeof entry.content === "string") {
346
444
  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;
@@ -108,7 +109,10 @@ export function handleMediaRequest(req, res, opts) {
108
109
  res.statusCode = 200;
109
110
  res.setHeader("Content-Type", contentType(ext));
110
111
  res.setHeader("Content-Length", stat.size);
111
- res.setHeader("Cache-Control", "private, max-age=86400");
112
+ // Revalidate on every request so updated files (e.g. regenerated invoices)
113
+ // are never served stale. Last-Modified lets the browser use 304 for unchanged files.
114
+ res.setHeader("Cache-Control", "no-cache");
115
+ res.setHeader("Last-Modified", stat.mtime.toUTCString());
112
116
  if (req.method === "HEAD") {
113
117
  res.end();
114
118
  }
@@ -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.1",
3
+ "version": "1.2.1",
4
4
  "description": "AI-powered business assistant for small businesses",
5
5
  "publishConfig": {
6
6
  "access": "public"