Skip to content
144 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Every agent starts with g1t's foundational skills, playbooks for documents, research, data, code, communication, and files and media, each using only tools the agent already has and saying plainly what isn't possible yet: agents make PDFs, Word documents, spreadsheets, CSV and Markdown files with make_file, kept with a doc in Artifacts and served from g1tusercontent.com; an agent's Skills tab shows what each skill can do and with which tools, and owners turn a skill off per agent; the agent skills guide says how.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}