| 1 | /** |
| 2 | * The files `make_file` makes (docs.g1t.sh/guides/agent-skills/): a PDF or |
| 3 | * a Word document from Markdown, a spreadsheet or CSV from rows, or the |
| 4 | * Markdown itself. Checked and written here, pure; tools.ts keeps the file |
| 5 | * with an artifact. Nothing runs: each is written byte by byte. |
| 6 | */ |
| 7 | import { DOC_MAX_FILE_BYTES } from "../../../packages/contracts/src/docs.ts"; |
| 8 | import { type MakeFileFormat, MAKE_FILE_FORMATS } from "../../../packages/contracts/src/skills.ts"; |
| 9 | |
| 10 | import { type Cell, type Sheet, workbook, markdownDocx } from "./ooxml.ts"; |
| 11 | import { markdownPdf } from "./pdf.ts"; |
| 12 | |
| 13 | /** The longest Markdown a file is made from. */ |
| 14 | export const MAX_FILE_MARKDOWN = 200_000; |
| 15 | export const MAX_SHEETS = 10; |
| 16 | export const MAX_SHEET_ROWS = 5_000; |
| 17 | export const MAX_SHEET_COLUMNS = 50; |
| 18 | export const MAX_CELL_CHARS = 2_000; |
| 19 | |
| 20 | export const CONTENT_TYPES: Record<MakeFileFormat, string> = { |
| 21 | pdf: "application/pdf", |
| 22 | docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", |
| 23 | xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| 24 | csv: "text/csv", |
| 25 | md: "text/markdown", |
| 26 | }; |
| 27 | |
| 28 | const LABELS: Record<MakeFileFormat, string> = { pdf: "PDF", docx: "Word document", xlsx: "spreadsheet", csv: "CSV file", md: "Markdown file" }; |
| 29 | |
| 30 | export type MadeFile = { name: string; content_type: string; bytes: Uint8Array; format: MakeFileFormat; summary: string }; |
| 31 | |
| 32 | /** A file name from a title: `Q3 report` becomes `Q3 report.pdf`. */ |
| 33 | export function fileName(title: string, format: MakeFileFormat): string { |
| 34 | const base = |
| 35 | String(title ?? "") |
| 36 | .replace(/[\\/:*?"<>|\u0000-\u001f]+/g, " ") |
| 37 | .replace(/\s+/g, " ") |
| 38 | .trim() |
| 39 | .replace(/\.(pdf|docx|xlsx|csv|md)$/i, "") |
| 40 | .slice(0, 120) || "file"; |
| 41 | return `${base}.${format}`; |
| 42 | } |
| 43 | |
| 44 | /** Sheets from the model's input: a list of { name, rows }, rows of cells. */ |
| 45 | export function readSheets(input: unknown): { ok: true; sheets: Sheet[] } | { ok: false; message: string } { |
| 46 | if (!Array.isArray(input) || !input.length) return { ok: false, message: "Give sheets: a list of { name, rows }, the first row each sheet's header." }; |
| 47 | if (input.length > MAX_SHEETS) return { ok: false, message: `A spreadsheet has at most ${MAX_SHEETS} sheets.` }; |
| 48 | const sheets: Sheet[] = []; |
| 49 | for (const [i, raw] of input.entries()) { |
| 50 | const given = raw && typeof raw === "object" ? (raw as { name?: unknown; rows?: unknown }) : {}; |
| 51 | if (!Array.isArray(given.rows) || !given.rows.length) return { ok: false, message: `Sheet ${i + 1} has no rows.` }; |
| 52 | if (given.rows.length > MAX_SHEET_ROWS) return { ok: false, message: `A sheet has at most ${MAX_SHEET_ROWS} rows.` }; |
| 53 | const rows: Cell[][] = []; |
| 54 | for (const row of given.rows) { |
| 55 | const cells = Array.isArray(row) ? row : [row]; |
| 56 | if (cells.length > MAX_SHEET_COLUMNS) return { ok: false, message: `A sheet has at most ${MAX_SHEET_COLUMNS} columns.` }; |
| 57 | rows.push( |
| 58 | cells.map((cell): Cell => { |
| 59 | if (cell === null || cell === undefined) return null; |
| 60 | if (typeof cell === "number" || typeof cell === "boolean") return cell; |
| 61 | return String(typeof cell === "object" ? JSON.stringify(cell) : cell).slice(0, MAX_CELL_CHARS); |
| 62 | }), |
| 63 | ); |
| 64 | } |
| 65 | sheets.push({ name: typeof given.name === "string" ? given.name : `Sheet${i + 1}`, rows }); |
| 66 | } |
| 67 | return { ok: true, sheets }; |
| 68 | } |
| 69 | |
| 70 | /** One sheet as CSV (RFC 4180), with a byte-order mark so spreadsheet apps read it as UTF-8. */ |
| 71 | export function csv(rows: Cell[][]): string { |
| 72 | const field = (cell: Cell) => { |
| 73 | const text = cell === null || cell === undefined ? "" : String(cell); |
| 74 | return /[",\r\n]/.test(text) || /^\s|\s$/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; |
| 75 | }; |
| 76 | return `${rows.map((row) => row.map(field).join(",")).join("\r\n")}\r\n`; |
| 77 | } |
| 78 | |
| 79 | /** Up to `limit` rows of a sheet as a Markdown table, for the doc a file is attached to. */ |
| 80 | export function previewTable(sheet: Sheet, limit = 20): string { |
| 81 | const columns = Math.max(1, ...sheet.rows.map((row) => row.length)); |
| 82 | const cell = (value: Cell) => String(value ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").slice(0, 80); |
| 83 | const [header = [], ...body] = sheet.rows; |
| 84 | const line = (row: Cell[]) => `| ${Array.from({ length: columns }, (_, c) => cell(row[c] ?? null)).join(" | ")} |`; |
| 85 | const shown = body.slice(0, limit); |
| 86 | return [line(header), `|${" --- |".repeat(columns)}`, ...shown.map(line), ...(body.length > shown.length ? ["", `_First ${shown.length} of ${body.length} rows._`] : [])].join("\n"); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * The file for one `make_file` call, or why not. `content` is Markdown |
| 91 | * (pdf, docx, md); `sheets` are rows (xlsx, csv). |
| 92 | */ |
| 93 | export function makeFile(input: { format: unknown; title: string; content?: unknown; sheets?: unknown }, at = new Date()): { ok: true; file: MadeFile } | { ok: false; message: string } { |
| 94 | const format = String(input.format ?? "").toLowerCase().replace(/^\./, "") as MakeFileFormat; |
| 95 | if (!MAKE_FILE_FORMATS.includes(format)) { |
| 96 | return { ok: false, message: `make_file writes ${MAKE_FILE_FORMATS.join(", ")}. Slide decks and images aren't available yet: say so, and offer a PDF or a doc instead.` }; |
| 97 | } |
| 98 | const title = String(input.title ?? "").trim().slice(0, 200); |
| 99 | if (!title) return { ok: false, message: "Give the file a title." }; |
| 100 | const name = fileName(title, format); |
| 101 | let bytes: Uint8Array; |
| 102 | let summary: string; |
| 103 | if (format === "pdf" || format === "docx" || format === "md") { |
| 104 | const markdown = typeof input.content === "string" ? input.content : ""; |
| 105 | if (!markdown.trim()) return { ok: false, message: `Give the ${LABELS[format]}'s content as Markdown in content.` }; |
| 106 | if (markdown.length > MAX_FILE_MARKDOWN) return { ok: false, message: `Content is at most ${MAX_FILE_MARKDOWN.toLocaleString("en-US")} characters: split it into more than one file.` }; |
| 107 | if (format === "pdf") { |
| 108 | const pdf = markdownPdf(title, markdown); |
| 109 | bytes = pdf.bytes; |
| 110 | summary = `${pdf.pages} ${pdf.pages === 1 ? "page" : "pages"}${pdf.truncated ? ", cut at the page limit" : ""}`; |
| 111 | } else if (format === "docx") { |
| 112 | bytes = markdownDocx(title, markdown, at); |
| 113 | summary = "Word document"; |
| 114 | } else { |
| 115 | bytes = new TextEncoder().encode(markdown.endsWith("\n") ? markdown : `${markdown}\n`); |
| 116 | summary = "Markdown"; |
| 117 | } |
| 118 | } else { |
| 119 | const read = readSheets(input.sheets); |
| 120 | if (!read.ok) return read; |
| 121 | if (format === "csv") { |
| 122 | if (read.sheets.length > 1) return { ok: false, message: "A CSV holds one sheet: make an xlsx for more, or one CSV per sheet." }; |
| 123 | bytes = new TextEncoder().encode(csv(read.sheets[0]!.rows)); |
| 124 | } else bytes = workbook(title, read.sheets, at); |
| 125 | const rows = read.sheets.reduce((sum, sheet) => sum + Math.max(0, sheet.rows.length - 1), 0); |
| 126 | summary = `${read.sheets.length === 1 ? "" : `${read.sheets.length} sheets, `}${rows} ${rows === 1 ? "row" : "rows"}`; |
| 127 | } |
| 128 | if (bytes.length > DOC_MAX_FILE_BYTES) return { ok: false, message: `That comes to more than ${DOC_MAX_FILE_BYTES / 1024 / 1024} MB: make it smaller, or split it.` }; |
| 129 | return { ok: true, file: { name, content_type: CONTENT_TYPES[format], bytes, format, summary } }; |
| 130 | } |
| 131 | |
| 132 | /** "34 KB". */ |
| 133 | export function sizeLabel(bytes: number): string { |
| 134 | if (bytes < 1024) return `${bytes} bytes`; |
| 135 | if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; |
| 136 | return `${(bytes / 1024 / 1024).toFixed(1)} MB`; |
| 137 | } |
| 138 | |
| 139 | /** Bytes as base64, in chunks so a large file never overflows the call stack. */ |
| 140 | export function base64(bytes: Uint8Array): string { |
| 141 | let binary = ""; |
| 142 | for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); |
| 143 | return btoa(binary); |
| 144 | } |