/** * Assistant outbound attachment types and helpers. * * Shared DTOs and utilities for building attachment candidates from * directives, tool content blocks, and file reads. */ import { readFileSync, statSync } from "node:fs"; import { isPlaceholderSentinelText } from "../providers/placeholder-sentinels.js"; import { hostPolicy, sandboxPolicy, } from "../tools/shared/filesystem/path-policy.js"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Maximum size in bytes for a single assistant attachment (100 MB). */ export const MAX_ASSISTANT_ATTACHMENT_BYTES = 100 * 1024 * 1024; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type AttachmentSourceType = "sandbox_file" | "host_file" | "tool_block"; export interface AssistantAttachmentDraft { sourceType: AttachmentSourceType; filename: string; mimeType: string; dataBase64: string; sizeBytes: number; kind: "image" | "video" | "document"; } // --------------------------------------------------------------------------- // Base64 size estimation // --------------------------------------------------------------------------- /** * Decoded byte length of an image/file block's media payload. * * Given a raw base64 string, estimate the decoded length from its length and * `=` padding. Given a media `source`, prefer its captured `sizeBytes` * (`workspace_ref` blocks carry it) and otherwise estimate from inline `data` * (legacy base64 blocks) — so callers get the byte size without caring which * storage form the block uses. */ export function estimateBase64Bytes(base64: string): number; export function estimateBase64Bytes( source: { data?: unknown; sizeBytes?: unknown } | null | undefined, ): number; export function estimateBase64Bytes( arg: string | { data?: unknown; sizeBytes?: unknown } | null | undefined, ): number { if (arg == null) { return 0; } if (typeof arg !== "string") { if (typeof arg.sizeBytes === "number") { return arg.sizeBytes; } if (typeof arg.data === "string") { return estimateBase64Bytes(arg.data); } return 0; } const trimmed = arg.replace(/\s/g, ""); const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0; return Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding); } // --------------------------------------------------------------------------- // MIME inference / filename resolution (shared contract) // --------------------------------------------------------------------------- // The stored-filename rule is a contract with the web client (which resolves // clicked vellum:// links back to attachments by filename), so it lives in // @vellumai/service-contracts. Re-exported here for the daemon's callers. import { inferMimeType, resolveAttachmentFilename, } from "@vellumai/service-contracts/attachment-naming"; export { inferMimeType, resolveAttachmentFilename }; export function classifyKind(mimeType: string): "image" | "video" | "document" { if (mimeType.startsWith("image/")) { return "image"; } if (mimeType.startsWith("video/")) { return "video"; } return "document"; } // --------------------------------------------------------------------------- // Validation / cap enforcement // --------------------------------------------------------------------------- interface ValidatedDrafts { accepted: AssistantAttachmentDraft[]; warnings: string[]; } /** * Enforce per-attachment size cap. * * - Rejects individual drafts that exceed `MAX_ASSISTANT_ATTACHMENT_BYTES`. */ export function validateDrafts( drafts: AssistantAttachmentDraft[], ): ValidatedDrafts { const accepted: AssistantAttachmentDraft[] = []; const warnings: string[] = []; for (const draft of drafts) { if (draft.sizeBytes > MAX_ASSISTANT_ATTACHMENT_BYTES) { warnings.push( `Skipped attachment "${draft.filename}": ` + `size ${formatBytes(draft.sizeBytes)} exceeds ${formatBytes( MAX_ASSISTANT_ATTACHMENT_BYTES, )} limit.`, ); continue; } accepted.push(draft); } return { accepted, warnings }; } // --------------------------------------------------------------------------- // Directive parser // --------------------------------------------------------------------------- export type DirectiveSource = "sandbox" | "host"; export interface DirectiveRequest { source: DirectiveSource; path: string; filename: string | undefined; mimeType: string | undefined; /** * Where `filename` came from. `"explicit"` filenames (directive * attributes) are authoritative and used verbatim. `"label"` filenames * (markdown link display text) are cosmetic and only honored when they * carry a recognized extension; otherwise the path basename wins. */ filenameSource?: "explicit" | "label"; } interface DirectiveParseResult { cleanText: string; directiveRequests: DirectiveRequest[]; parseWarnings: string[]; } interface DirectiveDisplayDrainResult { emitText: string; bufferedRemainder: string; } /** * Match self-closing `` tags. * * Captures the attribute string between the tag name and the `/>` close. * Non-greedy so multiple tags on separate lines are matched individually. */ const DIRECTIVE_RE = //g; /** * Parse individual attribute key="value" pairs. * Supports both double and single quotes. */ const ATTR_RE = /(\w+)\s*=\s*"([^"]*)"|(\w+)\s*=\s*'([^']*)'/g; function parseAttributes(raw: string): Record { const attrs: Record = {}; let m: RegExpExecArray | null; while ((m = ATTR_RE.exec(raw)) != null) { const key = m[1] ?? m[3]; const value = m[2] ?? m[4]; attrs[key] = value; } return attrs; } /** * Scan assistant text for `` directives. * * Returns the text with successfully parsed directives stripped, * along with the parsed directive requests and any warnings for * malformed tags. */ export function parseDirectives(text: string): DirectiveParseResult { const directiveRequests: DirectiveRequest[] = []; const parseWarnings: string[] = []; const cleanText = text.replace(DIRECTIVE_RE, (fullMatch, attrStr: string) => { const attrs = parseAttributes(attrStr); if (!attrs["path"]) { parseWarnings.push( 'Ignored : missing required "path" attribute.', ); return fullMatch; } const sourceRaw = attrs["source"] ?? "sandbox"; if (sourceRaw !== "sandbox" && sourceRaw !== "host") { parseWarnings.push( `Ignored : invalid source="${sourceRaw}". Must be "sandbox" or "host".`, ); return fullMatch; } directiveRequests.push({ source: sourceRaw, path: attrs["path"], filename: attrs["filename"] || undefined, mimeType: attrs["mime_type"] || undefined, filenameSource: "explicit", }); return ""; }); return { cleanText: directiveRequests.length > 0 ? cleanText.replace(/\n{3,}/g, "\n\n").trim() : cleanText, directiveRequests, parseWarnings, }; } // --------------------------------------------------------------------------- // vellum:// markdown link extraction // --------------------------------------------------------------------------- /** * Match markdown links with `vellum://workspace/` or `vellum://host/` URLs, * in both plain (`[text](vellum://…)`) and image (`![alt](vellum://…)`) form. * * Captures: * [1] = optional leading `!` marking the image form (empty for plain links) * [2] = link text / image alt text (may be empty) * [3] = scheme authority: "workspace" or "host" * [4] = path after the authority * * The link text is NOT stripped from the assistant's message — unlike * `` tags, the markdown link is valid user-facing * content that renders as a clickable download link or inline image. */ const VELLUM_LINK_RE = /(!?)\[([^\]]*)\]\(vellum:\/\/(workspace|host)(\/[^)]*)\)/g; interface VellumLinkExtractResult { directiveRequests: DirectiveRequest[]; parseWarnings: string[]; } /** * Extract `[text](vellum://workspace/path)` and `[text](vellum://host/path)` * markdown links from assistant text and return corresponding directive * requests. The text is NOT modified — the links remain as rendered markdown. */ /** * Decode a vellum:// path segment, returning null on malformed percent-encoding * (e.g. a literal `%` not followed by two hex digits). This prevents a single * bad link from throwing URIError and aborting the entire assistant message. */ function safeDecodePath(rawPath: string): string | null { try { return decodeURIComponent(rawPath); } catch { return null; } } export function extractVellumLinks(text: string): VellumLinkExtractResult { const directiveRequests: DirectiveRequest[] = []; const parseWarnings: string[] = []; let m: RegExpExecArray | null; while ((m = VELLUM_LINK_RE.exec(text)) != null) { const isImage = m[1] === "!"; const linkText = m[2]!; const authority = m[3]!; const rawPath = m[4]!; // For image links (`![alt](vellum://…)`) the bracket text is a // description, not a filename, so it must not override the resolved // basename. Plain links keep their text as the attachment filename. const filename = isImage ? undefined : linkText || undefined; const decodedPath = safeDecodePath(rawPath); if (decodedPath === null) { parseWarnings.push( `Ignored vellum://${authority} link "${linkText}": malformed percent-encoding in path.`, ); continue; } if (authority === "workspace") { // Strip the leading "/" to get a workspace-relative path const path = decodedPath.startsWith("/") ? decodedPath.slice(1) : decodedPath; if (!path) { parseWarnings.push( `Ignored vellum://workspace link "${linkText}": empty path.`, ); continue; } directiveRequests.push({ source: "sandbox", path, filename, mimeType: undefined, filenameSource: "label", }); } else { // host: decodedPath is already absolute (starts with /) if (!decodedPath || decodedPath === "/") { parseWarnings.push( `Ignored vellum://host link "${linkText}": empty path.`, ); continue; } directiveRequests.push({ source: "host", path: decodedPath, filename, mimeType: undefined, filenameSource: "label", }); } } return { directiveRequests, parseWarnings }; } /** * Replace `[text](vellum://...)` and `![alt](vellum://...)` markdown links with * their bracket text. Used to sanitize text before channel delivery (Slack, * Telegram, etc.) where the `vellum://` scheme has no meaning. The image form's * leading `!` is dropped so the result is just the alt text (empty when the alt * text is empty). */ export function stripVellumLinks(text: string): string { return text.replace(VELLUM_LINK_RE, "$2"); } /** Regex fragment matching any prefix of `literal`, including empty and full. */ function anyPrefixOf(literal: string): string { let pattern = ""; for (let i = literal.length - 1; i >= 0; i--) { pattern = `(?:${literal[i]}${pattern})?`; } return pattern; } /** * A `[label](vellum://…)` or `![alt](vellum://…)` link still being assembled at * the end of the text: an optional leading `!` and opening `[` whose remainder * is a prefix of the full link grammar and has not reached its closing `)`. * A lone trailing `!` also matches, since it may be the first character of an * image link whose `[` has not yet streamed in — withholding it prevents a * stray `!` leaking to append-only sinks ahead of the alt text. Matched only * when it runs to the end of the string. */ const INCOMPLETE_VELLUM_LINK_TAIL_RE = new RegExp( "(?:!?\\[[^\\]]*" + "(?:\\]" + "(?:\\(" + anyPrefixOf("vellum://") + "(?:" + `(?:${anyPrefixOf("workspace")}|${anyPrefixOf("host")})` + "(?:/[^)]*)?" + ")?" + ")?" + ")?" + "|!)$", ); /** * Length of the trailing run of `text` that is a `[label](vellum://…)` link * still being assembled (see {@link INCOMPLETE_VELLUM_LINK_TAIL_RE}), or 0 when * the text does not end mid-link. * * Callers that emit text to an append-only sink (e.g. Slack streaming) withhold * this suffix until the link closes: {@link stripVellumLinks} only removes * complete links, so a partially-emitted `vellum://` path would leak an internal * workspace/host path that cannot be retracted once sent. */ export function incompleteVellumLinkSuffixLength(text: string): number { const match = text.match(INCOMPLETE_VELLUM_LINK_TAIL_RE); return match ? match[0].length : 0; } /** * Drain streamed assistant text while stripping only valid, complete * self-closing `` directives. * * - Valid complete directives are removed from emitted text. * - Invalid directives are preserved as plain text. * - Incomplete directives are retained in `bufferedRemainder` until more * text arrives. */ const DIRECTIVE_TAG_PREFIX = "= searchStart; i--) { if (text[i] === "<") { const candidate = text.slice(i); if (tag.startsWith(candidate)) { return { safe: text.slice(0, i), trailing: candidate }; } } } return { safe: text, trailing: "" }; } export function drainDirectiveDisplayBuffer( buffer: string, ): DirectiveDisplayDrainResult { let emitText = ""; let cursor = 0; while (cursor < buffer.length) { const start = buffer.indexOf(DIRECTIVE_TAG_PREFIX, cursor); if (start === -1) { // No full tag-prefix match — but the remaining text might end with a // partial prefix of "", start); if (end === -1) { return { emitText, bufferedRemainder: buffer.slice(start), }; } const tag = buffer.slice(start, end + 2); const parsed = parseDirectives(tag); const isValidDirective = parsed.directiveRequests.length > 0 && parsed.parseWarnings.length === 0 && parsed.cleanText.length === 0; if (!isValidDirective) { emitText += tag; } else { // Only trim the trailing newline when the directive occupied its own // line (preceded by \n and followed by \n or \r\n). We intentionally // do NOT trim when nextChar is undefined (end-of-buffer) because in // streaming mode more data may arrive in the next chunk — eagerly // trimming would merge words across the directive boundary. const nextChar = buffer[end + 2]; if (emitText.endsWith("\r\n") && nextChar === "\r") { emitText = emitText.slice(0, -2); // trim full \r\n } else if ( emitText.endsWith("\n") && (nextChar === "\n" || nextChar === "\r") ) { emitText = emitText.slice(0, -1); // trim \n } else if ( nextChar !== undefined && !/\s/.test(emitText[emitText.length - 1] ?? "") && !/\s/.test(nextChar) ) { // Inline directive with no surrounding whitespace — insert a space // so the text on either side doesn't get smashed together (e.g. // "down.Let" → "down. Let"). emitText += " "; } } cursor = end + 2; } return { emitText, bufferedRemainder: "" }; } // --------------------------------------------------------------------------- // Sandbox file resolution // --------------------------------------------------------------------------- interface ResolveResult { draft: AssistantAttachmentDraft | null; warning: string | null; } /** * Resolve a sandbox directive to a draft attachment. * * Validates the path stays within the sandbox boundary, reads the file, * base64-encodes it, and enforces the per-attachment size cap. */ export function resolveSandboxDirective( directive: DirectiveRequest, workingDir: string, ): ResolveResult { const pathResult = sandboxPolicy(directive.path, workingDir); if (!pathResult.ok) { return { draft: null, warning: `Skipped sandbox attachment "${directive.path}": ${pathResult.error}`, }; } const resolved = pathResult.resolved; let stat; try { stat = statSync(resolved); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { return { draft: null, warning: `Skipped sandbox attachment "${directive.path}": file not found.`, }; } return { draft: null, warning: `Skipped sandbox attachment "${directive.path}": stat error: ${ (err as Error).message }`, }; } if (!stat.isFile()) { return { draft: null, warning: `Skipped sandbox attachment "${directive.path}": not a regular file.`, }; } if (stat.size > MAX_ASSISTANT_ATTACHMENT_BYTES) { return { draft: null, warning: `Skipped sandbox attachment "${ directive.path }": size ${formatBytes(stat.size)} exceeds ${formatBytes( MAX_ASSISTANT_ATTACHMENT_BYTES, )} limit.`, }; } let data: Buffer; try { data = readFileSync(resolved); } catch (err) { return { draft: null, warning: `Skipped sandbox attachment "${directive.path}": read error: ${ (err as Error).message }`, }; } const filename = resolveAttachmentFilename( directive.filename, resolved, directive.filenameSource, ); const mimeType = directive.mimeType ?? inferMimeType(filename); const dataBase64 = data.toString("base64"); return { draft: { sourceType: "sandbox_file", filename, mimeType, dataBase64, sizeBytes: data.length, kind: classifyKind(mimeType), }, warning: null, }; } // --------------------------------------------------------------------------- // Host file resolution // --------------------------------------------------------------------------- /** * Callback the caller provides to approve host file reads. * Returns `true` to allow, `false` to deny/skip. */ export type ApproveHostRead = (filePath: string) => Promise; /** * Resolve a host directive to a draft attachment. * * Requires an absolute path. Before reading, calls the `approve` callback * so the conversation layer can gate access via the user-facing permission prompt. */ export async function resolveHostDirective( directive: DirectiveRequest, approve: ApproveHostRead, ): Promise { const pathResult = hostPolicy(directive.path); if (!pathResult.ok) { return { draft: null, warning: `Skipped host attachment "${directive.path}": ${pathResult.error}`, }; } const resolved = pathResult.resolved; // Gate on user approval before touching the filesystem let approved: boolean; try { approved = await approve(resolved); } catch { return { draft: null, warning: `Skipped host attachment "${directive.path}": approval request failed.`, }; } if (!approved) { return { draft: null, warning: `Skipped host attachment "${directive.path}": access denied by user.`, }; } let stat; try { stat = statSync(resolved); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { return { draft: null, warning: `Skipped host attachment "${directive.path}": file not found.`, }; } return { draft: null, warning: `Skipped host attachment "${directive.path}": stat error: ${ (err as Error).message }`, }; } if (!stat.isFile()) { return { draft: null, warning: `Skipped host attachment "${directive.path}": not a regular file.`, }; } if (stat.size > MAX_ASSISTANT_ATTACHMENT_BYTES) { return { draft: null, warning: `Skipped host attachment "${directive.path}": size ${formatBytes( stat.size, )} exceeds ${formatBytes(MAX_ASSISTANT_ATTACHMENT_BYTES)} limit.`, }; } let data: Buffer; try { data = readFileSync(resolved); } catch (err) { return { draft: null, warning: `Skipped host attachment "${directive.path}": read error: ${ (err as Error).message }`, }; } const filename = resolveAttachmentFilename( directive.filename, resolved, directive.filenameSource, ); const mimeType = directive.mimeType ?? inferMimeType(filename); const dataBase64 = data.toString("base64"); return { draft: { sourceType: "host_file", filename, mimeType, dataBase64, sizeBytes: data.length, kind: classifyKind(mimeType), }, warning: null, }; } // --------------------------------------------------------------------------- // Batch directive resolution // --------------------------------------------------------------------------- /** * Resolve an array of parsed directives to attachment drafts. * * Sandbox directives are resolved synchronously; host directives go through * the async approval callback. */ export async function resolveDirectives( directives: DirectiveRequest[], workingDir: string, approveHostRead: ApproveHostRead, ): Promise<{ drafts: AssistantAttachmentDraft[]; warnings: string[] }> { const drafts: AssistantAttachmentDraft[] = []; const warnings: string[] = []; for (const d of directives) { const result = d.source === "sandbox" ? resolveSandboxDirective(d, workingDir) : await resolveHostDirective(d, approveHostRead); if (result.draft) { drafts.push(result.draft); } if (result.warning) { warnings.push(result.warning); } } return { drafts, warnings }; } // --------------------------------------------------------------------------- // Tool content block → draft conversion // --------------------------------------------------------------------------- interface ImageBlock { type: "image"; source: { type: "base64"; media_type: string; data: string }; } interface FileBlock { type: "file"; source: { type: "base64"; media_type: string; data: string; filename: string; }; } /** * Derive a human-friendly filename from the tool name that produced the * content block. Falls back to "tool-output" for unknown tools. */ function toolNameToFilePrefix(toolName?: string): string { if (!toolName) { return "tool-output"; } // Convert snake_case / camelCase tool names to kebab-case labels return toolName .replace(/([a-z])([A-Z])/g, "$1-$2") .replace(/_/g, "-") .toLowerCase(); } /** * Convert tool content blocks (images/files from tool results) into * attachment drafts. Blocks that aren't image or file types are skipped. * * An optional `toolNames` map (index → tool name) produces friendlier * filenames than the default "tool-output". */ export function contentBlocksToDrafts( blocks: readonly unknown[], toolNames?: ReadonlyMap, ): AssistantAttachmentDraft[] { const drafts: AssistantAttachmentDraft[] = []; for (let i = 0; i < blocks.length; i++) { const b = blocks[i] as Record; const toolName = toolNames?.get(i); if (b.type === "image") { const src = b.source as ImageBlock["source"]; const data = src.data; const mimeType = src.media_type; const ext = mimeType.split("/")[1] ?? "png"; const title = typeof b._title === "string" ? b._title : undefined; const prefix = title || toolNameToFilePrefix(toolName); drafts.push({ sourceType: "tool_block", filename: `${prefix}.${ext}`, mimeType, dataBase64: data, sizeBytes: estimateBase64Bytes(data), kind: "image", }); } else if (b.type === "file") { const src = b.source as FileBlock["source"]; const data = src.data; const mimeType = src.media_type; const filename = src.filename; drafts.push({ sourceType: "tool_block", filename, mimeType, dataBase64: data, sizeBytes: estimateBase64Bytes(data), kind: classifyKind(mimeType), }); } } return drafts; } // --------------------------------------------------------------------------- // Content cleaning: strip directives from assistant text blocks // --------------------------------------------------------------------------- /** * Parse directives from assistant content blocks, returning cleaned content * (tags stripped) and all accumulated directive requests + warnings. */ export function cleanAssistantContent(content: readonly unknown[]): { cleanedContent: unknown[]; directives: DirectiveRequest[]; warnings: string[]; } { const directives: DirectiveRequest[] = []; const warnings: string[] = []; const cleanedContent = content .filter((block) => { // Drop placeholder sentinel text blocks. These are injected by the // Anthropic provider to preserve role alternation in outbound requests // and must never be persisted or rendered to users. const b = block as Record; if (b.type !== "text") { return true; } const text = b.text; return typeof text !== "string" || !isPlaceholderSentinelText(text); }) .map((block) => { const b = block as Record; if (b.type !== "text") { return block; } const text = b.text as string; // Extract vellum:// markdown links (non-destructive — links stay in text) if (text.includes("vellum://")) { const linkResult = extractVellumLinks(text); directives.push(...linkResult.directiveRequests); warnings.push(...linkResult.parseWarnings); } // Strip legacy tags from the text if (!text.includes("(); const seenDirectiveHashes = new Set(); return drafts.filter((d) => { const hash = Bun.hash(d.dataBase64).toString(36); const key = `${d.filename}:${hash}`; // Exact duplicate (same filename + same content): always skip. if (seenKeys.has(key)) { return false; } // Tool-block draft whose content was already attached via a directive: // drop the tool-block copy so the directive's user-chosen name wins. if (d.sourceType === "tool_block" && seenDirectiveHashes.has(hash)) { return false; } seenKeys.add(key); if (d.sourceType !== "tool_block") { seenDirectiveHashes.add(hash); } return true; }); } // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- function formatBytes(bytes: number): string { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }