/**
* 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 (``) 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 (``) 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 `` 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 `` 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`;
}