Skip to content
144 linesCodeBlameRaw
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 */
7import { DOC_MAX_FILE_BYTES } from "../../../packages/contracts/src/docs.ts";
8import { type MakeFileFormat, MAKE_FILE_FORMATS } from "../../../packages/contracts/src/skills.ts";
9
10import { type Cell, type Sheet, workbook, markdownDocx } from "./ooxml.ts";
11import { markdownPdf } from "./pdf.ts";
12
13/** The longest Markdown a file is made from. */
14export const MAX_FILE_MARKDOWN = 200_000;
15export const MAX_SHEETS = 10;
16export const MAX_SHEET_ROWS = 5_000;
17export const MAX_SHEET_COLUMNS = 50;
18export const MAX_CELL_CHARS = 2_000;
19
20export 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
28const LABELS: Record<MakeFileFormat, string> = { pdf: "PDF", docx: "Word document", xlsx: "spreadsheet", csv: "CSV file", md: "Markdown file" };
29
30export 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`. */
33export 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. */
45export 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. */
71export 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. */
80export 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 */
93export 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". */
133export 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. */
140export 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}