diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 46d4053..2a188d2 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -801,7 +801,7 @@ _none_ - Returns: `200 str` #### Execute a virtual filesystem applet — `POST /vfs/{*path}` - Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }` -- Returns: `200 { ok: bool, result: str, columns: { name: str, type: str }[], rows: int, stdout: str, consoleLines: int, durationMs: int, warnings?: str[], truncated?: bool, truncationNote?: str }` +- Returns: `200 { ok: bool, result: str, columns: { name: str, type: str }[], rows: int, stdout: str, consoleLines: int, durationMs: int, description?: str, warnings?: str[], truncated?: bool, truncationNote?: str }` #### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}` - Params: `path: str, body: { content: str, mime?: str }` - Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }` diff --git a/docs/api/routes/vfs/exec.md b/docs/api/routes/vfs/exec.md index bbacf38..63a79fb 100644 --- a/docs/api/routes/vfs/exec.md +++ b/docs/api/routes/vfs/exec.md @@ -7,9 +7,10 @@ Execute a virtual filesystem applet Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company and returns the exported function's result. -The applet file must begin with two metadata lines: +The applet file must begin with three metadata lines: `// @applet-input-schema {JSON Schema}` followed by -`// @applet-output-schema {name:type;name:type}`. The input schema describes the GUI form posted as +`// @applet-output-schema {name:type;name:type}` and +`// @applet-description "description"`. The input schema describes the GUI form posted as `input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;` (e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`, `boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input` @@ -59,6 +60,7 @@ Applet executed successfully ```json { "ok": true, + "description": "…", "result": "id;name\n1;Kaffee", "columns": [ { diff --git a/src/api/routes/vfsExec.js b/src/api/routes/vfsExec.js index 2af020e..c38443d 100644 --- a/src/api/routes/vfsExec.js +++ b/src/api/routes/vfsExec.js @@ -41,7 +41,7 @@ function appletExecErrorStatus(error) { if (message.includes('must be an object')) return 400; if (message.includes('Unable to read applet') || message.includes('ENOENT')) return 404; if (message.includes('is not a file')) return 400; - if (message.includes('Applet metadata') || message.includes('Applet input schema') || message.includes('Applet output schema')) return 400; + if (message.includes('Applet metadata') || message.includes('Applet input schema') || message.includes('Applet output schema') || message.includes('Applet description')) return 400; if (message.includes('Sandbox exceeded')) return 504; if (message.includes('Sandbox worker did not report a result payload')) return 500; return 400; @@ -71,9 +71,10 @@ export function register(app, basePath = '') { * Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company * and returns the exported function's result. * - * The applet file must begin with two metadata lines: + * The applet file must begin with three metadata lines: * `// @applet-input-schema {JSON Schema}` followed by - * `// @applet-output-schema {name:type;name:type}`. The input schema describes the GUI form posted as + * `// @applet-output-schema {name:type;name:type}` and + * `// @applet-description "description"`. The input schema describes the GUI form posted as * `input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;` * (e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`, * `boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input` @@ -127,6 +128,9 @@ export function register(app, basePath = '') { * properties: * ok: * type: boolean + * description: + * type: string + * description: Applet description declared in the @applet-description header * result: * type: string * description: Typed CSV table (`;`-separated, header row first) built from the returned rows and the declared output columns diff --git a/src/defaultApplets/article-catalogue.applet.js b/src/defaultApplets/article-catalogue.applet.js index 92756b7..38946a6 100644 --- a/src/defaultApplets/article-catalogue.applet.js +++ b/src/defaultApplets/article-catalogue.applet.js @@ -1,5 +1,6 @@ // @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Article name contains","default":"","description":"Case-insensitive substring match on the article name. Leave empty to include every article."},"limit":{"type":"integer","title":"Max rows","default":200,"minimum":1,"maximum":1000},"onlyTracked":{"type":"boolean","title":"Only stock-tracked articles","default":false,"description":"When true, articles with track_stock=false are omitted."}}} // @applet-output-schema sku:string;name:string;net:number;taxRate:number;track_stock:boolean;categoryId:integer +// @applet-description "Article catalogue — articles with their net price, resolved by walking the category tree." // // Article catalogue — articles with their net price, resolved by walking the category tree. // diff --git a/src/defaultApplets/article-lookup.applet.js b/src/defaultApplets/article-lookup.applet.js index 2fff7f6..38eb188 100644 --- a/src/defaultApplets/article-lookup.applet.js +++ b/src/defaultApplets/article-lookup.applet.js @@ -1,5 +1,6 @@ // @applet-input-schema {"type":"object","properties":{"sku":{"type":"string","title":"Article SKU","description":"Exact SKU to inspect (e.g. \"42000009\")."}},"required":["sku"]} // @applet-output-schema key:string;value:string +// @applet-description "Article lookup — the raw GET /article/{sku} payload as a key/value sheet." // // Article lookup — the raw `GET /article/{sku}` payload as a key/value sheet. // diff --git a/src/defaultApplets/category-inventory.applet.js b/src/defaultApplets/category-inventory.applet.js index c0acd2c..8203ddb 100644 --- a/src/defaultApplets/category-inventory.applet.js +++ b/src/defaultApplets/category-inventory.applet.js @@ -1,5 +1,6 @@ // @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Category name contains","default":"","description":"Case-insensitive substring match on the category name. Leave empty to include every category."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500},"sortBy":{"type":"string","title":"Sort by","default":"articles","enum":["articles","name","depth"],"description":"articles = most articles first, name = A–Z, depth = tree depth then name."}}} // @applet-output-schema id:integer;name:string;depth:integer;articles:integer;children:integer +// @applet-description "Category inventory — every category with its article count and child count." // // Category inventory — every category with its article count and child count. // diff --git a/src/defaultApplets/delivery-register.applet.js b/src/defaultApplets/delivery-register.applet.js index f72885e..554ede2 100644 --- a/src/defaultApplets/delivery-register.applet.js +++ b/src/defaultApplets/delivery-register.applet.js @@ -1,5 +1,6 @@ // @applet-input-schema {"type":"object","properties":{"dateFrom":{"type":"string","title":"From date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or after this day. Leave empty for no lower bound."},"dateTo":{"type":"string","title":"To date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or before this day. Leave empty for no upper bound."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500}}} // @applet-output-schema number:string;date:string;supplier:string;lines:integer;net:number +// @applet-description "Goods receipt register — deliveries with their line-item count and net total." // // Goods receipt register — deliveries with their line-item count and net total. // diff --git a/src/defaultApplets/order-revenue-by-month.applet.js b/src/defaultApplets/order-revenue-by-month.applet.js index 0a834e5..65b57c8 100644 --- a/src/defaultApplets/order-revenue-by-month.applet.js +++ b/src/defaultApplets/order-revenue-by-month.applet.js @@ -1,5 +1,6 @@ // @applet-input-schema {"type":"object","properties":{"month":{"type":"string","title":"Month (YYYY-MM)","default":"","description":"Only include orders placed in this month. Leave empty for all months."},"limit":{"type":"integer","title":"Max rows","default":50,"minimum":1,"maximum":500}}} // @applet-output-schema month:string;orders:integer;positions:integer;net:number +// @applet-description "Order revenue by month — orders grouped per calendar month." // // Order revenue by month — orders grouped per calendar month. // diff --git a/src/defaultSkills/applet.SKILL b/src/defaultSkills/applet.SKILL index 381245c..82d316b 100644 --- a/src/defaultSkills/applet.SKILL +++ b/src/defaultSkills/applet.SKILL @@ -32,6 +32,9 @@ Applets can be executed by: - `boolean`: boolean value (`true`, `false`, `1`, `0`, `"true"`, `"false"`). - `date`: Date object or ISO string. +`@applet-description` +- Short human-readable summary of what the applet computes (e.g. `"Article catalogue with net prices"`). + --- # Execution Environment & Globals diff --git a/src/tools/javascript.js b/src/tools/javascript.js index 6f7b403..562ccd4 100644 --- a/src/tools/javascript.js +++ b/src/tools/javascript.js @@ -197,6 +197,32 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appl }); } +/** + * Parse the applet description header. + * + * Accepts either a quoted string (e.g. `"Lorem ipsum"` or `'Lorem ipsum'`) or bare text. + * Strips surrounding quotes if present and trims whitespace. + * + * @param {string} header raw text after the `// @applet-description ` marker + * @returns {string} + */ +function parseAppletDescription(header) { + const text = String(header || '').trim(); + if (!text) { + throw new ToolError('Applet description cannot be empty.'); + } + if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) { + try { + const parsed = JSON.parse(text); + if (typeof parsed === 'string' && parsed.trim()) return parsed.trim(); + } catch { + const unquoted = text.slice(1, -1).trim(); + if (unquoted) return unquoted; + } + } + return text; +} + function readApplet(vfsRoot, appletvfsfile) { if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) { throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.'); @@ -228,14 +254,16 @@ function readApplet(vfsRoot, appletvfsfile) { throw new ToolError(`Unable to read applet "${appletPath}" from the VFS: ${err.message}`); } - const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 3); + const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 4); const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/); const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/); - if (!inputHeader || !outputHeader) { + const descHeader = lines[2]?.match(/^\/\/ @applet-description (.+)$/); + if (!inputHeader || !outputHeader || !descHeader) { throw new ToolError( - 'Applet metadata must be on the first two lines: ' + + 'Applet metadata must be on the first three lines: ' + '`// @applet-input-schema {JSON Schema}` followed by ' + - '`// @applet-output-schema name:type;name:type` (typed CSV columns).' + '`// @applet-output-schema name:type;name:type` (typed CSV columns) and ' + + '`// @applet-description "description"`.' ); } @@ -251,8 +279,9 @@ function readApplet(vfsRoot, appletvfsfile) { } const outputColumns = parseTypedCsvColumns(outputHeader[1]); + const description = parseAppletDescription(descHeader[1]); - return { source, outputColumns }; + return { source, outputColumns, description, inputSchema }; } function formatSandboxError(code, report) { @@ -401,6 +430,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) { result.result = table.csv; result.rows = table.rows; result.columns = table.columns; + result.description = applet.description; if (table.warnings.length) result.warnings = table.warnings; } if (truncated) { diff --git a/src/tools/prompts.js b/src/tools/prompts.js index f43452f..8a6f2f1 100644 --- a/src/tools/prompts.js +++ b/src/tools/prompts.js @@ -48,7 +48,7 @@ export const TOOL_DESCRIPTIONS = { javascript_execute: 'Execute JS in an isolated sandbox via inline `code` (assign to `module.exports`) or `appletvfsfile` ' + - '(.applet.js returning typed CSV per @applet-input-schema and @applet-output-schema; details in applet.SKILL). ' + + '(.applet.js returning typed CSV per @applet-input-schema, @applet-output-schema, and @applet-description; details in applet.SKILL). ' + 'Pre-injected globals: fs/vfs, sharp, ExcelJS, PDFDocument, crypto, and fetch("/path"). No require/import.', find_files: