@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.
- package/dist/agents/pi-tools.policy.js +0 -1
- package/dist/agents/sandbox/constants.js +0 -1
- package/dist/agents/system-prompt.js +1 -4
- package/dist/agents/taskmaster-tools.js +0 -6
- package/dist/agents/tool-policy.js +0 -3
- package/dist/agents/tools/document-to-pdf-tool.js +9 -1
- package/dist/build-info.json +3 -3
- package/dist/cli/provision-seed.js +0 -1
- package/dist/control-ui/assets/index-DtQHRIVD.css +1 -0
- package/dist/control-ui/assets/{index-DzlEdl36.js → index-N8du4fwV.js} +168 -138
- package/dist/control-ui/assets/index-N8du4fwV.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/gateway/chat-sanitize.js +120 -22
- package/dist/gateway/media-http.js +9 -5
- package/dist/gateway/public-chat-api.js +31 -2
- package/package.json +1 -1
- package/dist/control-ui/assets/index-DTaSylHl.css +0 -1
- package/dist/control-ui/assets/index-DzlEdl36.js.map +0 -1
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<title>Taskmaster Control</title>
|
|
7
7
|
<meta name="color-scheme" content="dark light" />
|
|
8
8
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
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
|
|
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
|
|
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
|
|
8
|
-
//
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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"
|
|
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);
|