@webskill/sdk 0.27.1 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/agent.js CHANGED
@@ -305,7 +305,7 @@ const SCRIPT_EXTENSIONS = [".ts", ".js"];
305
305
  function defaultConfirmSkill(draft) {
306
306
  return `Save this conversation as the skill "${draft.name}"? ${draft.description}\nIt will be submitted for review, not activated. Please check the preview below for anything that must not be stored.`;
307
307
  }
308
- function fail$2(message, details) {
308
+ function fail$4(message, details) {
309
309
  throw new WebSkillError("SKILL_GENERATION_VALIDATION_FAILED", message, details);
310
310
  }
311
311
  /**
@@ -313,21 +313,21 @@ function fail$2(message, details) {
313
313
  * 再去打扰用户——不让用户确认一个注定会失败的技能。
314
314
  */
315
315
  function validateDraft(draft) {
316
- if (!isValidSkillName(draft.name)) fail$2(`"${draft.name}" is not a valid skill name; use lowercase letters, digits and hyphens`);
317
- if (draft.description.trim() === "") fail$2("The skill description must not be empty");
318
- if (typeof draft.content !== "string" || draft.content.trim() === "") fail$2("The SKILL.md content must not be empty");
316
+ if (!isValidSkillName(draft.name)) fail$4(`"${draft.name}" is not a valid skill name; use lowercase letters, digits and hyphens`);
317
+ if (draft.description.trim() === "") fail$4("The skill description must not be empty");
318
+ if (typeof draft.content !== "string" || draft.content.trim() === "") fail$4("The SKILL.md content must not be empty");
319
319
  let metadata;
320
320
  try {
321
321
  metadata = parseSkillMarkdown(draft.content).metadata;
322
322
  } catch (e) {
323
- fail$2(`The SKILL.md content is not a valid skill document: ${e instanceof Error ? e.message : String(e)}`);
323
+ fail$4(`The SKILL.md content is not a valid skill document: ${e instanceof Error ? e.message : String(e)}`);
324
324
  }
325
- if (metadata.name !== draft.name) fail$2(`The SKILL.md frontmatter declares name "${metadata.name}" but the draft name is "${draft.name}"`);
325
+ if (metadata.name !== draft.name) fail$4(`The SKILL.md frontmatter declares name "${metadata.name}" but the draft name is "${draft.name}"`);
326
326
  for (const file of draft.files ?? []) {
327
327
  const path = file.path;
328
- if (path.trim() === "") fail$2("Every attached file needs a path");
329
- if (path.startsWith("/") || path.includes("\\") || path.split("/").includes("..")) fail$2(`The attached file path "${path}" must be relative and must not escape the skill folder`);
330
- if (path.startsWith("scripts/") && !SCRIPT_EXTENSIONS.some((ext) => path.endsWith(ext))) fail$2(`The script "${path}" must end with .ts or .js`);
328
+ if (path.trim() === "") fail$4("Every attached file needs a path");
329
+ if (path.startsWith("/") || path.includes("\\") || path.split("/").includes("..")) fail$4(`The attached file path "${path}" must be relative and must not escape the skill folder`);
330
+ if (path.startsWith("scripts/") && !SCRIPT_EXTENSIONS.some((ext) => path.endsWith(ext))) fail$4(`The script "${path}" must end with .ts or .js`);
331
331
  }
332
332
  }
333
333
  /** 确认面板正文:完整 SKILL.md + 附带文件,供用户逐字检查(FR-9.4 风险缓解) */
@@ -359,25 +359,25 @@ async function materializeBinaries(draft, options) {
359
359
  const out = /* @__PURE__ */ new Map();
360
360
  if (claimed.length === 0) return out;
361
361
  const resolve = options.resolveAttachment;
362
- if (resolve === void 0) fail$2("This host cannot put uploaded files into a skill. Remove \"attachmentId\" from the file entries and write their contents instead.");
362
+ if (resolve === void 0) fail$4("This host cannot put uploaded files into a skill. Remove \"attachmentId\" from the file entries and write their contents instead.");
363
363
  const maxOne = options.policy?.maxBinaryBytes ?? DEFAULT_MAX_BINARY_BYTES;
364
364
  const maxTotal = options.policy?.maxTotalBinaryBytes ?? DEFAULT_MAX_TOTAL_BINARY_BYTES;
365
365
  let total = 0;
366
366
  for (const file of claimed) {
367
367
  const id = file.attachmentId;
368
368
  const attachment = await resolve(id);
369
- if (attachment === void 0) fail$2(`No file with attachment id "${id}" was uploaded in this conversation, so "${file.path}" cannot be added to the skill.`, {
369
+ if (attachment === void 0) fail$4(`No file with attachment id "${id}" was uploaded in this conversation, so "${file.path}" cannot be added to the skill.`, {
370
370
  attachmentId: id,
371
371
  path: file.path
372
372
  });
373
373
  const size = attachment.bytes.byteLength;
374
- if (size > maxOne) fail$2(`The attached file "${attachment.name}" is ${size} bytes, above the ${maxOne} byte limit for one file.`, {
374
+ if (size > maxOne) fail$4(`The attached file "${attachment.name}" is ${size} bytes, above the ${maxOne} byte limit for one file.`, {
375
375
  path: file.path,
376
376
  size,
377
377
  limit: maxOne
378
378
  });
379
379
  total += size;
380
- if (total > maxTotal) fail$2(`The attached files add up to ${total} bytes, above the ${maxTotal} byte limit for one skill.`, {
380
+ if (total > maxTotal) fail$4(`The attached files add up to ${total} bytes, above the ${maxTotal} byte limit for one skill.`, {
381
381
  path: file.path,
382
382
  total,
383
383
  limit: maxTotal
@@ -404,7 +404,7 @@ function assertStepsAreBackedByTrace(draft, records) {
404
404
  const executed = new Set(records.map((r) => r.tool));
405
405
  const unknown = [...new Set(claimed.map((s) => s.tool).filter((tool) => !executed.has(tool)))];
406
406
  if (unknown.length === 0) return;
407
- fail$2(`The skill claims tools that were never called in this session: ${unknown.join(", ")}. Tools actually called: ${executed.size === 0 ? "(none)" : [...executed].sort().join(", ")}. Rewrite the skill using only the tools above.`, {
407
+ fail$4(`The skill claims tools that were never called in this session: ${unknown.join(", ")}. Tools actually called: ${executed.size === 0 ? "(none)" : [...executed].sort().join(", ")}. Rewrite the skill using only the tools above.`, {
408
408
  claimed: unknown,
409
409
  executed: [...executed].sort()
410
410
  });
@@ -422,7 +422,7 @@ function assertStepsAreNotTruncated(draft, records) {
422
422
  const usable = new Set(records.filter((r) => r.truncated !== true).map((r) => r.tool));
423
423
  const truncated = [...new Set(claimed.map((s) => s.tool).filter((tool) => !usable.has(tool)))];
424
424
  if (truncated.length === 0) return;
425
- fail$2(`The arguments of these tool calls were too large to record, so the skill cannot state them: ${truncated.join(", ")}. Rewrite the skill without these steps.`, { truncated });
425
+ fail$4(`The arguments of these tool calls were too large to record, so the skill cannot state them: ${truncated.join(", ")}. Rewrite the skill without these steps.`, { truncated });
426
426
  }
427
427
  /**
428
428
  * 确认卡证据(AC-30.12 / AC-30.13):逐条列出模型**写进技能的**步骤,
@@ -561,6 +561,7 @@ const SKILL_GENERATION_SYSTEM_PROMPT = [
561
561
  " from inside SKILL.md as `skill:<the skill name>/<the path you gave it>`, for example",
562
562
  " `skill:monthly-report/assets/template.xlsx`. Write that, never the attachment id of this",
563
563
  " conversation — that id is gone by the time anyone uses the skill.",
564
+ "- For spreadsheet analysis and export, follow the scope requested by the user. `query_table.limit` only limits groups or rows returned to the model as a preview (default 50, maximum 500); it does not limit computation or exported files. When the user asks for complete data, do not set a limit and do not write `limit: 500` in the skill. Use `exportFile` to write the complete result locally instead of copying a truncated preview into `export_spreadsheet`. For one multi-sheet workbook, use `exportFile.sheetName` and `exportFile.additionalSheets`; do not paginate full results through the model or write one file per query.",
564
565
  "- Never copy credentials, tokens or personal data into the skill.",
565
566
  "- The user has to confirm every candidate, and the candidate stays unusable until a reviewer",
566
567
  " approves it. Say so instead of promising the skill is ready."
@@ -1805,7 +1806,7 @@ const RECORD_LIMIT$1 = 50;
1805
1806
  function sanitizeDescription(value) {
1806
1807
  return sanitizeUntrustedLine(value, DESCRIPTION_LIMIT);
1807
1808
  }
1808
- function isRecord$2(value) {
1809
+ function isRecord$3(value) {
1809
1810
  return typeof value === "object" && value !== null;
1810
1811
  }
1811
1812
  /**
@@ -1815,7 +1816,7 @@ function isRecord$2(value) {
1815
1816
  * 标成后者会让读代码的人以为它已经被验证过了。
1816
1817
  */
1817
1818
  function validate(raw, provenance) {
1818
- if (!isRecord$2(raw)) return void 0;
1819
+ if (!isRecord$3(raw)) return void 0;
1819
1820
  const { id, kind, description, target } = raw;
1820
1821
  if (typeof id !== "string" || !ID_PATTERN.test(id)) return void 0;
1821
1822
  if (typeof kind !== "string" || !OFFERABLE_KINDS.includes(kind)) return void 0;
@@ -3094,7 +3095,7 @@ function readContent(result) {
3094
3095
  //#region ../agent/src/spreadsheet/toolSource.ts
3095
3096
  const EXPORT_SPREADSHEET_TOOL = "export_spreadsheet";
3096
3097
  const DESCRIPTION$3 = "Write tabular data — and, when they belong in the sheet, pictures — to an Excel (.xlsx) file the user can download.";
3097
- const CELL_SCHEMA = {
3098
+ const CELL_SCHEMA$1 = {
3098
3099
  anyOf: [
3099
3100
  { type: "string" },
3100
3101
  { type: "number" },
@@ -3190,7 +3191,7 @@ function inputSchema(withImages) {
3190
3191
  items: {
3191
3192
  type: "array",
3192
3193
  maxItems: SPREADSHEET_LIMITS.columnsPerSheet,
3193
- items: CELL_SCHEMA
3194
+ items: CELL_SCHEMA$1
3194
3195
  }
3195
3196
  },
3196
3197
  ...withImages ? { images: IMAGES_SCHEMA } : {}
@@ -3220,7 +3221,7 @@ function toolError$2(e) {
3220
3221
  }
3221
3222
  };
3222
3223
  }
3223
- const isRecord$1 = (v) => typeof v === "object" && v !== null;
3224
+ const isRecord$2 = (v) => typeof v === "object" && v !== null;
3224
3225
  /**
3225
3226
  * FR-10.3a 的两条分支。忘写后缀是模型的高频行为,为它打断一整轮不划算;
3226
3227
  * 写成 `.csv` 则是**意图冲突**而不是疏漏,必须拒。
@@ -3228,15 +3229,15 @@ const isRecord$1 = (v) => typeof v === "object" && v !== null;
3228
3229
  * 路径安全不在这里做:`writeArtifact` 那一条(`resolveInsideRoot`)是唯一的一份,
3229
3230
  * FR-10.3b 明确禁止另写一份。
3230
3231
  */
3231
- function resolveFileName$1(value) {
3232
- if (typeof value !== "string" || value.trim() === "") throw new WebSkillError("VALIDATION_FAILED", "export_spreadsheet requires a non-empty \"fileName\".");
3232
+ function resolveFileName$1(value, toolName = "export_spreadsheet") {
3233
+ if (typeof value !== "string" || value.trim() === "") throw new WebSkillError("VALIDATION_FAILED", `${toolName} requires a non-empty "fileName".`);
3233
3234
  const fileName = value.trim();
3234
3235
  const lastSegment = fileName.split(/[\\/]/).pop() ?? "";
3235
3236
  const dot = lastSegment.lastIndexOf(".");
3236
3237
  const extension = dot <= 0 ? "" : lastSegment.slice(dot + 1);
3237
3238
  if (extension === "") return `${fileName}${SPREADSHEET_EXTENSION}`;
3238
3239
  if (extension.toLowerCase() === "xlsx") return fileName;
3239
- throw new WebSkillError("VALIDATION_FAILED", `export_spreadsheet writes .xlsx files; "${fileName}" asks for .${extension}. Drop the suffix or use .xlsx.`);
3240
+ throw new WebSkillError("VALIDATION_FAILED", `${toolName} writes .xlsx files; "${fileName}" asks for .${extension}. Drop the suffix or use .xlsx.`);
3240
3241
  }
3241
3242
  function summarize(spec) {
3242
3243
  const sheets = spec.sheets;
@@ -3270,8 +3271,8 @@ function createSpreadsheetToolSource(options) {
3270
3271
  canHandle: (name) => name === EXPORT_SPREADSHEET_TOOL,
3271
3272
  captureArgs: (_name, args) => {
3272
3273
  const spec = args["spec"];
3273
- const sheets = isRecord$1(spec) && Array.isArray(spec["sheets"]) ? spec["sheets"] : [];
3274
- const rowCount = sheets.reduce((total, sheet) => total + (isRecord$1(sheet) && Array.isArray(sheet["rows"]) ? sheet["rows"].length : 0), 0);
3274
+ const sheets = isRecord$2(spec) && Array.isArray(spec["sheets"]) ? spec["sheets"] : [];
3275
+ const rowCount = sheets.reduce((total, sheet) => total + (isRecord$2(sheet) && Array.isArray(sheet["rows"]) ? sheet["rows"].length : 0), 0);
3275
3276
  return {
3276
3277
  ...typeof args["fileName"] === "string" ? { fileName: args["fileName"] } : {},
3277
3278
  sheetCount: sheets.length,
@@ -3327,17 +3328,17 @@ function columnLetter(index) {
3327
3328
  }
3328
3329
  return out;
3329
3330
  }
3330
- const blank = (cells) => cells.every((cell) => cell.trim() === "");
3331
- function fail$1(message) {
3331
+ const blank$1 = (cells) => cells.every((cell) => cell.trim() === "");
3332
+ function fail$3(message) {
3332
3333
  throw new WebSkillError("VALIDATION_FAILED", message);
3333
3334
  }
3334
3335
  /** 先按序号(1 基)、再按表名定位;缺省取第一张有内容的表 */
3335
3336
  function resolveSheet(workbook, ref) {
3336
3337
  const { sheets } = workbook;
3337
- if (sheets.length === 0) fail$1("This workbook has no sheets.");
3338
+ if (sheets.length === 0) fail$3("This workbook has no sheets.");
3338
3339
  const names = () => sheets.map((sheet, i) => `${i + 1}: "${sheet.name}"`).join(", ");
3339
3340
  if (ref === void 0) {
3340
- const found = sheets.findIndex((sheet) => sheet.rows.some((row) => !blank(row)));
3341
+ const found = sheets.findIndex((sheet) => sheet.rows.some((row) => !blank$1(row)));
3341
3342
  const index = found === -1 ? 0 : found;
3342
3343
  return {
3343
3344
  sheet: sheets[index],
@@ -3346,7 +3347,7 @@ function resolveSheet(workbook, ref) {
3346
3347
  }
3347
3348
  if (typeof ref === "number") {
3348
3349
  const sheet = Number.isInteger(ref) ? sheets[ref - 1] : void 0;
3349
- if (sheet === void 0) fail$1(`There is no sheet number ${String(ref)}. Sheets: ${names()}.`);
3350
+ if (sheet === void 0) fail$3(`There is no sheet number ${String(ref)}. Sheets: ${names()}.`);
3350
3351
  return {
3351
3352
  sheet,
3352
3353
  index: ref - 1
@@ -3354,24 +3355,24 @@ function resolveSheet(workbook, ref) {
3354
3355
  }
3355
3356
  if (typeof ref === "string") {
3356
3357
  const index = sheets.findIndex((sheet) => sheet.name === ref);
3357
- if (index === -1) fail$1(`There is no sheet named "${ref}". Sheets: ${names()}.`);
3358
+ if (index === -1) fail$3(`There is no sheet named "${ref}". Sheets: ${names()}.`);
3358
3359
  return {
3359
3360
  sheet: sheets[index],
3360
3361
  index
3361
3362
  };
3362
3363
  }
3363
- return fail$1("\"sheet\" must be a sheet name or a 1-based sheet number.");
3364
+ return fail$3("\"sheet\" must be a sheet name or a 1-based sheet number.");
3364
3365
  }
3365
3366
  /** 表头行(缺省为第一个有内容的行)+ 其后有内容的行;行号保持工作表的 1 基行号 */
3366
3367
  function buildView(workbook, sheetRef, headerRowArg) {
3367
3368
  const { sheet, index } = resolveSheet(workbook, sheetRef);
3368
3369
  const rows = sheet.rows;
3369
3370
  let headerIndex;
3370
- if (headerRowArg === void 0) headerIndex = rows.findIndex((row) => !blank(row));
3371
+ if (headerRowArg === void 0) headerIndex = rows.findIndex((row) => !blank$1(row));
3371
3372
  else {
3372
- if (typeof headerRowArg !== "number" || !Number.isInteger(headerRowArg) || headerRowArg < 1) fail$1("\"headerRow\" must be a positive integer (1-based worksheet row number).");
3373
+ if (typeof headerRowArg !== "number" || !Number.isInteger(headerRowArg) || headerRowArg < 1) fail$3("\"headerRow\" must be a positive integer (1-based worksheet row number).");
3373
3374
  headerIndex = headerRowArg - 1;
3374
- if (headerIndex >= rows.length) fail$1(`"headerRow" ${headerRowArg} is past the last row (${rows.length}) of the sheet.`);
3375
+ if (headerIndex >= rows.length) fail$3(`"headerRow" ${headerRowArg} is past the last row (${rows.length}) of the sheet.`);
3375
3376
  }
3376
3377
  if (headerIndex === -1) return {
3377
3378
  sheetName: sheet.name,
@@ -3385,7 +3386,7 @@ function buildView(workbook, sheetRef, headerRowArg) {
3385
3386
  let width = header.length;
3386
3387
  for (let i = headerIndex + 1; i < rows.length; i += 1) {
3387
3388
  const cells = rows[i];
3388
- if (blank(cells)) continue;
3389
+ if (blank$1(cells)) continue;
3389
3390
  if (cells.length > width) width = cells.length;
3390
3391
  data.push({
3391
3392
  row: i + 1,
@@ -3418,16 +3419,16 @@ function listColumns(view) {
3418
3419
  }
3419
3420
  /** 表头文字优先,其次列字母;表头重名时拒绝按表头引用,并点出同名各列的列字母 */
3420
3421
  function resolveColumn(view, ref, what) {
3421
- if (typeof ref !== "string" || ref.trim() === "") fail$1(`${what} must name a column (header text or column letter).`);
3422
+ if (typeof ref !== "string" || ref.trim() === "") fail$3(`${what} must name a column (header text or column letter).`);
3422
3423
  const text = ref.trim();
3423
3424
  const byHeader = view.columns.filter((column) => column.header === text);
3424
3425
  if (byHeader.length === 1) return byHeader[0];
3425
- if (byHeader.length > 1) fail$1(`${what}: the header "${text}" is shared by columns ${byHeader.map((c) => c.letter).join(", ")}. Refer to the column by its letter instead.`);
3426
+ if (byHeader.length > 1) fail$3(`${what}: the header "${text}" is shared by columns ${byHeader.map((c) => c.letter).join(", ")}. Refer to the column by its letter instead.`);
3426
3427
  if (LETTER_RE.test(text)) {
3427
3428
  const byLetter = view.columns.find((column) => column.letter === text.toUpperCase());
3428
3429
  if (byLetter !== void 0) return byLetter;
3429
3430
  }
3430
- return fail$1(`${what}: no column "${text}" on sheet "${view.sheetName}". Columns: ${listColumns(view)}.`);
3431
+ return fail$3(`${what}: no column "${text}" on sheet "${view.sheetName}". Columns: ${listColumns(view)}.`);
3431
3432
  }
3432
3433
  /** Neumaier 补偿求和:几万个小数相加不累积漂移 */
3433
3434
  var Summer = class {
@@ -3447,9 +3448,9 @@ const tidy = (x) => Number(x.toPrecision(12));
3447
3448
 
3448
3449
  //#endregion
3449
3450
  //#region ../agent/src/table/profile.ts
3450
- const PROFILE_MAX_CHARS = 8e3;
3451
- const DISTINCT_CAP = 1e4;
3452
- const SAMPLE_ROWS = 5;
3451
+ const PROFILE_MAX_CHARS$1 = 8e3;
3452
+ const DISTINCT_CAP$1 = 1e4;
3453
+ const SAMPLE_ROWS$1 = 5;
3453
3454
  function statsOf(view, column) {
3454
3455
  let filled = 0;
3455
3456
  let numbers = 0;
@@ -3480,7 +3481,7 @@ function statsOf(view, column) {
3480
3481
  } else texts += 1;
3481
3482
  const seen = freq.get(text);
3482
3483
  if (seen !== void 0) freq.set(text, seen + 1);
3483
- else if (freq.size < DISTINCT_CAP) freq.set(text, 1);
3484
+ else if (freq.size < DISTINCT_CAP$1) freq.set(text, 1);
3484
3485
  else capped = true;
3485
3486
  }
3486
3487
  let type;
@@ -3505,16 +3506,16 @@ function statsOf(view, column) {
3505
3506
  top
3506
3507
  };
3507
3508
  }
3508
- const clip$2 = (text, max) => {
3509
+ const clip$3 = (text, max) => {
3509
3510
  const chars = [...text.replace(/[\r\n\t]+/g, " ")];
3510
3511
  return chars.length <= max ? chars.join("") : `${chars.slice(0, max - 1).join("")}…`;
3511
3512
  };
3512
3513
  /** 超出字符预算时的收缩阶梯:先少表、再少列、再收样本、最后去掉 top */
3513
- const LADDER = [
3514
+ const LADDER$1 = [
3514
3515
  {
3515
3516
  maxSheets: 8,
3516
3517
  maxColumns: 40,
3517
- sampleRows: SAMPLE_ROWS,
3518
+ sampleRows: SAMPLE_ROWS$1,
3518
3519
  sampleCell: 40,
3519
3520
  withTop: true,
3520
3521
  note: void 0
@@ -3522,7 +3523,7 @@ const LADDER = [
3522
3523
  {
3523
3524
  maxSheets: 3,
3524
3525
  maxColumns: 40,
3525
- sampleRows: SAMPLE_ROWS,
3526
+ sampleRows: SAMPLE_ROWS$1,
3526
3527
  sampleCell: 40,
3527
3528
  withTop: true,
3528
3529
  note: "sheets"
@@ -3530,7 +3531,7 @@ const LADDER = [
3530
3531
  {
3531
3532
  maxSheets: 3,
3532
3533
  maxColumns: 20,
3533
- sampleRows: SAMPLE_ROWS,
3534
+ sampleRows: SAMPLE_ROWS$1,
3534
3535
  sampleCell: 40,
3535
3536
  withTop: true,
3536
3537
  note: "sheets, columns"
@@ -3552,29 +3553,29 @@ const LADDER = [
3552
3553
  note: "sheets, columns, sample, top values"
3553
3554
  }
3554
3555
  ];
3555
- function columnLine(column, stats, withTop) {
3556
+ function columnLine$1(column, stats, withTop) {
3556
3557
  const parts = [
3557
- `${column.letter} ${clip$2(column.header, 40)}`.trimEnd(),
3558
+ `${column.letter} ${clip$3(column.header, 40)}`.trimEnd(),
3558
3559
  stats.type,
3559
3560
  `${stats.filled} filled`
3560
3561
  ];
3561
- parts.push(stats.distinct === void 0 ? `>${DISTINCT_CAP} distinct` : `${stats.distinct} distinct`);
3562
+ parts.push(stats.distinct === void 0 ? `>${DISTINCT_CAP$1} distinct` : `${stats.distinct} distinct`);
3562
3563
  if (stats.min !== void 0 && stats.max !== void 0) parts.push(`min ${tidy(stats.min)}`, `max ${tidy(stats.max)}`, `sum ${tidy(stats.sum)}`);
3563
3564
  if (stats.earliest !== void 0 && stats.latest !== void 0) parts.push(`${stats.earliest} … ${stats.latest}`);
3564
- if (withTop && stats.top.length > 0) parts.push(`top: ${stats.top.map(([value, count]) => `${clip$2(value, 24)} ×${count}`).join(", ")}`);
3565
+ if (withTop && stats.top.length > 0) parts.push(`top: ${stats.top.map(([value, count]) => `${clip$3(value, 24)} ×${count}`).join(", ")}`);
3565
3566
  return parts.join(" | ");
3566
3567
  }
3567
- function sheetSection(view, rung, expanded, statsFor) {
3568
+ function sheetSection$1(view, rung, expanded, statsFor) {
3568
3569
  const head = `Sheet ${view.sheetIndex + 1} "${view.sheetName}": ${view.data.length} data rows × ${view.columns.length} columns` + (view.headerRow > 0 ? `, header at row ${view.headerRow}` : ", empty");
3569
3570
  if (!expanded || view.columns.length === 0) return [head];
3570
3571
  const lines = [head, "Columns:"];
3571
3572
  const shown = view.columns.slice(0, rung.maxColumns);
3572
- for (const column of shown) lines.push(columnLine(column, statsFor(column), rung.withTop));
3573
+ for (const column of shown) lines.push(columnLine$1(column, statsFor(column), rung.withTop));
3573
3574
  if (view.columns.length > shown.length) lines.push(`+${view.columns.length - shown.length} more columns — call query_table with op "describe" and this sheet to list them`);
3574
3575
  const sample = view.data.slice(0, rung.sampleRows);
3575
3576
  if (sample.length > 0) {
3576
3577
  lines.push(`Sample (sheet rows ${sample[0].row}–${sample[sample.length - 1].row}):`);
3577
- for (const { cells } of sample) lines.push(shown.map((column) => clip$2((cells[column.index] ?? "").trim(), rung.sampleCell)).join(" "));
3578
+ for (const { cells } of sample) lines.push(shown.map((column) => clip$3((cells[column.index] ?? "").trim(), rung.sampleCell)).join(" "));
3578
3579
  }
3579
3580
  return lines;
3580
3581
  }
@@ -3601,13 +3602,293 @@ function profileTable(input) {
3601
3602
  return stats;
3602
3603
  };
3603
3604
  let result = "";
3604
- for (const rung of LADDER) {
3605
+ for (const rung of LADDER$1) {
3605
3606
  const lines = [header];
3606
3607
  let expandedCount = 0;
3607
3608
  for (const [index, view] of views.entries()) {
3608
3609
  const expanded = only === void 0 ? expandedCount < rung.maxSheets : index === only;
3609
3610
  if (expanded && view.data.length > 0) expandedCount += 1;
3610
- lines.push(...sheetSection(view, rung, expanded && (only !== void 0 || view.data.length > 0), statsFor(view)));
3611
+ lines.push(...sheetSection$1(view, rung, expanded && (only !== void 0 || view.data.length > 0), statsFor(view)));
3612
+ }
3613
+ if (rung.note !== void 0) lines.push(`Profile shortened to fit: ${rung.note}. Call query_table with op "describe" for the rest.`);
3614
+ result = lines.join("\n");
3615
+ if (result.length <= PROFILE_MAX_CHARS$1) return result;
3616
+ }
3617
+ return `${result.slice(0, PROFILE_MAX_CHARS$1 - 80)}\nProfile cut at ${PROFILE_MAX_CHARS$1} characters.`;
3618
+ }
3619
+
3620
+ //#endregion
3621
+ //#region ../agent/src/table/streaming.ts
3622
+ function isStreamingTableWorkbook(workbook) {
3623
+ return "kind" in workbook && workbook.kind === "streaming";
3624
+ }
3625
+ const blank = (cells) => cells.every((cell) => cell.trim() === "");
3626
+ function fail$2(message) {
3627
+ throw new WebSkillError("VALIDATION_FAILED", message);
3628
+ }
3629
+ async function resolveStreamingSheet(workbook, ref) {
3630
+ const { sheets } = workbook;
3631
+ if (sheets.length === 0) fail$2("This workbook has no sheets.");
3632
+ const names = () => sheets.map((sheet, i) => `${i + 1}: "${sheet.name}"`).join(", ");
3633
+ if (ref === void 0) {
3634
+ for (const [index, sheet] of sheets.entries()) if ((await sheet.meta()).firstNonEmptyRow > 0) return {
3635
+ sheet,
3636
+ index
3637
+ };
3638
+ return {
3639
+ sheet: sheets[0],
3640
+ index: 0
3641
+ };
3642
+ }
3643
+ if (typeof ref === "number") {
3644
+ const sheet = Number.isInteger(ref) ? sheets[ref - 1] : void 0;
3645
+ if (sheet === void 0) fail$2(`There is no sheet number ${String(ref)}. Sheets: ${names()}.`);
3646
+ return {
3647
+ sheet,
3648
+ index: ref - 1
3649
+ };
3650
+ }
3651
+ if (typeof ref === "string") {
3652
+ const index = sheets.findIndex((sheet) => sheet.name === ref);
3653
+ if (index === -1) fail$2(`There is no sheet named "${ref}". Sheets: ${names()}.`);
3654
+ return {
3655
+ sheet: sheets[index],
3656
+ index
3657
+ };
3658
+ }
3659
+ return fail$2("\"sheet\" must be a sheet name or a 1-based sheet number.");
3660
+ }
3661
+ async function rowAt(sheet, row) {
3662
+ for await (const entry of sheet.scan()) {
3663
+ if (entry.row === row) return entry.cells;
3664
+ if (entry.row > row) return void 0;
3665
+ }
3666
+ }
3667
+ async function buildStreamingView(workbook, sheetRef, headerRowArg) {
3668
+ const { sheet, index } = await resolveStreamingSheet(workbook, sheetRef);
3669
+ const meta = await sheet.meta();
3670
+ let headerRow;
3671
+ if (headerRowArg === void 0) headerRow = meta.firstNonEmptyRow;
3672
+ else {
3673
+ if (typeof headerRowArg !== "number" || !Number.isInteger(headerRowArg) || headerRowArg < 1) fail$2("\"headerRow\" must be a positive integer (1-based worksheet row number).");
3674
+ headerRow = headerRowArg;
3675
+ if (headerRow > meta.rowCount) fail$2(`"headerRow" ${headerRowArg} is past the last row (${meta.rowCount}) of the sheet.`);
3676
+ }
3677
+ if (headerRow === 0) return {
3678
+ sheetName: sheet.name,
3679
+ sheetIndex: index,
3680
+ headerRow: 0,
3681
+ columns: [],
3682
+ data: (async function* () {})()
3683
+ };
3684
+ const header = headerRow === meta.firstNonEmptyRow ? meta.firstRow : await rowAt(sheet, headerRow) ?? [];
3685
+ const width = Math.max(meta.columnCount, header.length);
3686
+ const columns = Array.from({ length: width }, (_, columnIndex) => {
3687
+ const text = (header[columnIndex] ?? "").trim();
3688
+ const letter = columnLetter(columnIndex);
3689
+ return {
3690
+ index: columnIndex,
3691
+ letter,
3692
+ header: text,
3693
+ label: text === "" ? letter : `${letter} ${text}`,
3694
+ name: text === "" ? letter : text
3695
+ };
3696
+ });
3697
+ const data = (async function* () {
3698
+ for await (const entry of sheet.scan()) if (entry.row > headerRow && !blank(entry.cells)) yield entry;
3699
+ })();
3700
+ return {
3701
+ sheetName: sheet.name,
3702
+ sheetIndex: index,
3703
+ headerRow,
3704
+ columns,
3705
+ data
3706
+ };
3707
+ }
3708
+ async function summarizeStreamingWorkbook(workbook) {
3709
+ let tokens = 0;
3710
+ let dataRows = 0;
3711
+ let textChars = 0;
3712
+ for (const sheet of workbook.sheets) {
3713
+ const meta = await sheet.meta();
3714
+ tokens += meta.estimatedTokens;
3715
+ dataRows += meta.dataRows;
3716
+ textChars += meta.textChars;
3717
+ }
3718
+ return {
3719
+ tokens,
3720
+ dataRows,
3721
+ textChars
3722
+ };
3723
+ }
3724
+
3725
+ //#endregion
3726
+ //#region ../agent/src/table/streamingProfile.ts
3727
+ const PROFILE_MAX_CHARS = 8e3;
3728
+ const DISTINCT_CAP = 1e4;
3729
+ const SAMPLE_ROWS = 5;
3730
+ var Stats = class {
3731
+ filled = 0;
3732
+ numbers = 0;
3733
+ dates = 0;
3734
+ texts = 0;
3735
+ summer = new Summer();
3736
+ min;
3737
+ max;
3738
+ earliest;
3739
+ latest;
3740
+ freq = /* @__PURE__ */ new Map();
3741
+ capped = false;
3742
+ add(raw) {
3743
+ const text = raw.trim();
3744
+ const kind = kindOf(text);
3745
+ if (kind === "empty") return;
3746
+ this.filled += 1;
3747
+ if (kind === "number") {
3748
+ this.numbers += 1;
3749
+ const value = Number(text);
3750
+ this.summer.add(value);
3751
+ if (this.min === void 0 || value < this.min) this.min = value;
3752
+ if (this.max === void 0 || value > this.max) this.max = value;
3753
+ } else if (kind === "date") {
3754
+ this.dates += 1;
3755
+ if (this.earliest === void 0 || text < this.earliest) this.earliest = text;
3756
+ if (this.latest === void 0 || text > this.latest) this.latest = text;
3757
+ } else this.texts += 1;
3758
+ const seen = this.freq.get(text);
3759
+ if (seen !== void 0) this.freq.set(text, seen + 1);
3760
+ else if (this.freq.size < DISTINCT_CAP) this.freq.set(text, 1);
3761
+ else this.capped = true;
3762
+ }
3763
+ finish() {
3764
+ let type;
3765
+ if (this.filled === 0) type = "empty";
3766
+ else if (this.numbers >= this.filled * .95) type = "number";
3767
+ else if (this.dates >= this.filled * .95) type = "date";
3768
+ else if (this.numbers + this.dates <= this.filled * .05) type = "text";
3769
+ else type = "mixed";
3770
+ const top = this.capped || type !== "text" && type !== "mixed" ? [] : [...this.freq.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, 3);
3771
+ return {
3772
+ filled: this.filled,
3773
+ type,
3774
+ distinct: this.capped ? void 0 : this.freq.size,
3775
+ sum: this.summer.total,
3776
+ min: this.min,
3777
+ max: this.max,
3778
+ earliest: this.earliest,
3779
+ latest: this.latest,
3780
+ top
3781
+ };
3782
+ }
3783
+ };
3784
+ async function analyze(view) {
3785
+ const collectors = view.columns.map(() => new Stats());
3786
+ const sample = [];
3787
+ let dataRows = 0;
3788
+ for await (const entry of view.data) {
3789
+ dataRows += 1;
3790
+ if (sample.length < SAMPLE_ROWS) sample.push(entry);
3791
+ for (const column of view.columns) collectors[column.index].add(entry.cells[column.index] ?? "");
3792
+ }
3793
+ return {
3794
+ dataRows,
3795
+ sample,
3796
+ columns: collectors.map((stats) => stats.finish())
3797
+ };
3798
+ }
3799
+ const clip$2 = (text, max) => {
3800
+ const chars = [...text.replace(/[\r\n\t]+/g, " ")];
3801
+ return chars.length <= max ? chars.join("") : `${chars.slice(0, max - 1).join("")}…`;
3802
+ };
3803
+ const LADDER = [
3804
+ {
3805
+ maxSheets: 8,
3806
+ maxColumns: 40,
3807
+ sampleRows: SAMPLE_ROWS,
3808
+ sampleCell: 40,
3809
+ withTop: true,
3810
+ note: void 0
3811
+ },
3812
+ {
3813
+ maxSheets: 3,
3814
+ maxColumns: 40,
3815
+ sampleRows: SAMPLE_ROWS,
3816
+ sampleCell: 40,
3817
+ withTop: true,
3818
+ note: "sheets"
3819
+ },
3820
+ {
3821
+ maxSheets: 3,
3822
+ maxColumns: 20,
3823
+ sampleRows: SAMPLE_ROWS,
3824
+ sampleCell: 40,
3825
+ withTop: true,
3826
+ note: "sheets, columns"
3827
+ },
3828
+ {
3829
+ maxSheets: 3,
3830
+ maxColumns: 20,
3831
+ sampleRows: 3,
3832
+ sampleCell: 16,
3833
+ withTop: true,
3834
+ note: "sheets, columns, sample"
3835
+ },
3836
+ {
3837
+ maxSheets: 2,
3838
+ maxColumns: 12,
3839
+ sampleRows: 2,
3840
+ sampleCell: 12,
3841
+ withTop: false,
3842
+ note: "sheets, columns, sample, top values"
3843
+ }
3844
+ ];
3845
+ function columnLine(column, stats, withTop) {
3846
+ const parts = [
3847
+ `${column.letter} ${clip$2(column.header, 40)}`.trimEnd(),
3848
+ stats.type,
3849
+ `${stats.filled} filled`
3850
+ ];
3851
+ parts.push(stats.distinct === void 0 ? `>${DISTINCT_CAP} distinct` : `${stats.distinct} distinct`);
3852
+ if (stats.min !== void 0 && stats.max !== void 0) parts.push(`min ${tidy(stats.min)}`, `max ${tidy(stats.max)}`, `sum ${tidy(stats.sum)}`);
3853
+ if (stats.earliest !== void 0 && stats.latest !== void 0) parts.push(`${stats.earliest} … ${stats.latest}`);
3854
+ if (withTop && stats.top.length > 0) parts.push(`top: ${stats.top.map(([value, count]) => `${clip$2(value, 24)} ×${count}`).join(", ")}`);
3855
+ return parts.join(" | ");
3856
+ }
3857
+ function sheetSection(view, analysis, rung, expanded) {
3858
+ const head = `Sheet ${view.sheetIndex + 1} "${view.sheetName}": ${analysis.dataRows} data rows × ${view.columns.length} columns` + (view.headerRow > 0 ? `, header at row ${view.headerRow}` : ", empty");
3859
+ if (!expanded || view.columns.length === 0) return [head];
3860
+ const lines = [head, "Columns:"];
3861
+ const shown = view.columns.slice(0, rung.maxColumns);
3862
+ for (const column of shown) lines.push(columnLine(column, analysis.columns[column.index], rung.withTop));
3863
+ if (view.columns.length > shown.length) lines.push(`+${view.columns.length - shown.length} more columns — call query_table with op "describe" and this sheet to list them`);
3864
+ const sample = analysis.sample.slice(0, rung.sampleRows);
3865
+ if (sample.length > 0) {
3866
+ lines.push(`Sample (sheet rows ${sample[0].row}–${sample[sample.length - 1].row}):`);
3867
+ for (const entry of sample) lines.push(shown.map((column) => clip$2((entry.cells[column.index] ?? "").trim(), rung.sampleCell)).join(" "));
3868
+ }
3869
+ return lines;
3870
+ }
3871
+ async function profileStreamingTable(input) {
3872
+ const { workbook } = input;
3873
+ const total = workbook.sheets.length;
3874
+ const only = input.sheet === void 0 ? void 0 : (await buildStreamingView(workbook, input.sheet, input.headerRow)).sheetIndex;
3875
+ const header = `--- Spreadsheet profile: ${input.name} (id: ${input.id}) · ${total} sheet${total === 1 ? "" : "s"} ---`;
3876
+ const views = [];
3877
+ const analyses = [];
3878
+ for (let index = 0; index < workbook.sheets.length; index += 1) {
3879
+ const view = await buildStreamingView(workbook, index + 1, only === index ? input.headerRow : void 0);
3880
+ views.push(view);
3881
+ analyses.push(await analyze(view));
3882
+ }
3883
+ let result = "";
3884
+ for (const rung of LADDER) {
3885
+ const lines = [header];
3886
+ let expandedCount = 0;
3887
+ for (const [index, view] of views.entries()) {
3888
+ const analysis = analyses[index];
3889
+ const expanded = only === void 0 ? expandedCount < rung.maxSheets : index === only;
3890
+ if (expanded && analysis.dataRows > 0) expandedCount += 1;
3891
+ lines.push(...sheetSection(view, analysis, rung, expanded && (only !== void 0 || analysis.dataRows > 0)));
3611
3892
  }
3612
3893
  if (rung.note !== void 0) lines.push(`Profile shortened to fit: ${rung.note}. Call query_table with op "describe" for the rest.`);
3613
3894
  result = lines.join("\n");
@@ -3623,6 +3904,8 @@ const QUERY_LIMITS = {
3623
3904
  maxRows: 500,
3624
3905
  maxFilters: 20,
3625
3906
  maxGroupBy: 3,
3907
+ maxPivotBy: 2,
3908
+ maxPivotColumns: 100,
3626
3909
  maxMetrics: 8,
3627
3910
  maxOrderBy: 3,
3628
3911
  maxIn: 200,
@@ -3657,32 +3940,32 @@ const BUCKETS = [
3657
3940
  "month",
3658
3941
  "day"
3659
3942
  ];
3660
- function fail(message) {
3943
+ function fail$1(message) {
3661
3944
  throw new WebSkillError("VALIDATION_FAILED", message);
3662
3945
  }
3663
3946
  const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3664
3947
  function list(v, what, max) {
3665
3948
  if (v === void 0) return [];
3666
- if (!Array.isArray(v)) fail(`"${what}" must be an array.`);
3667
- if (v.length > max) fail(`"${what}" has ${v.length} entries; at most ${max} are allowed.`);
3949
+ if (!Array.isArray(v)) fail$1(`"${what}" must be an array.`);
3950
+ if (v.length > max) fail$1(`"${what}" has ${v.length} entries; at most ${max} are allowed.`);
3668
3951
  return v;
3669
3952
  }
3670
3953
  function oneOf(v, allowed, what) {
3671
3954
  if (typeof v === "string" && allowed.includes(v)) return v;
3672
- return fail(`${what} must be one of: ${allowed.join(", ")}.`);
3955
+ return fail$1(`${what} must be one of: ${allowed.join(", ")}.`);
3673
3956
  }
3674
3957
  function noOtherKeys(obj, allowed, what) {
3675
3958
  const extra = Object.keys(obj).filter((key) => !allowed.includes(key));
3676
- if (extra.length > 0) fail(`${what} has unknown field${extra.length > 1 ? "s" : ""}: ${extra.join(", ")}.`);
3959
+ if (extra.length > 0) fail$1(`${what} has unknown field${extra.length > 1 ? "s" : ""}: ${extra.join(", ")}.`);
3677
3960
  }
3678
3961
  function scalar(v, what) {
3679
3962
  if (typeof v === "string") return v;
3680
3963
  if (typeof v === "number" && Number.isFinite(v)) return v;
3681
- return fail(`${what} must be a string or a finite number.`);
3964
+ return fail$1(`${what} must be a string or a finite number.`);
3682
3965
  }
3683
3966
  function compileFilter(view, raw, index, uncomparable) {
3684
3967
  const what = `where[${index}]`;
3685
- if (!isObject(raw)) fail(`${what} must be an object.`);
3968
+ if (!isObject(raw)) fail$1(`${what} must be an object.`);
3686
3969
  noOtherKeys(raw, [
3687
3970
  "column",
3688
3971
  "op",
@@ -3702,7 +3985,7 @@ function compileFilter(view, raw, index, uncomparable) {
3702
3985
  };
3703
3986
  if (op === "in") {
3704
3987
  const values = raw["value"];
3705
- if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxIn) fail(`${what}.value must be a non-empty array of at most ${QUERY_LIMITS.maxIn} strings or numbers for op "in".`);
3988
+ if (!Array.isArray(values) || values.length === 0 || values.length > QUERY_LIMITS.maxIn) fail$1(`${what}.value must be a non-empty array of at most ${QUERY_LIMITS.maxIn} strings or numbers for op "in".`);
3706
3989
  const texts = /* @__PURE__ */ new Set();
3707
3990
  const numbers = /* @__PURE__ */ new Set();
3708
3991
  for (const item of values) {
@@ -3718,7 +4001,7 @@ function compileFilter(view, raw, index, uncomparable) {
3718
4001
  }
3719
4002
  };
3720
4003
  }
3721
- if (raw["value"] === void 0) fail(`${what}.value is required for op "${op}".`);
4004
+ if (raw["value"] === void 0) fail$1(`${what}.value is required for op "${op}".`);
3722
4005
  const value = String(scalar(raw["value"], `${what}.value`)).trim();
3723
4006
  const valueKind = kindOf(value);
3724
4007
  if (op === "contains") {
@@ -3740,7 +4023,7 @@ function compileFilter(view, raw, index, uncomparable) {
3740
4023
  test: op === "eq" ? equal : (cells) => !equal(cells)
3741
4024
  };
3742
4025
  }
3743
- if (valueKind !== "number" && valueKind !== "date") fail(`${what}.value must be a number or an ISO date (YYYY-MM-DD) for op "${op}", received "${value}".`);
4026
+ if (valueKind !== "number" && valueKind !== "date") fail$1(`${what}.value must be a number or an ISO date (YYYY-MM-DD) for op "${op}", received "${value}".`);
3744
4027
  const numericValue = valueKind === "number" ? Number(value) : 0;
3745
4028
  const satisfied = (order) => op === "gt" ? order > 0 : op === "gte" ? order >= 0 : op === "lt" ? order < 0 : order <= 0;
3746
4029
  return {
@@ -3763,7 +4046,7 @@ function parseLimit(v) {
3763
4046
  limit: QUERY_LIMITS.defaultRows,
3764
4047
  clamped: false
3765
4048
  };
3766
- if (typeof v !== "number" || !Number.isInteger(v) || v < 1) fail("\"limit\" must be a positive integer.");
4049
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1) fail$1("\"limit\" must be a positive integer.");
3767
4050
  return v > QUERY_LIMITS.maxRows ? {
3768
4051
  limit: QUERY_LIMITS.maxRows,
3769
4052
  clamped: true
@@ -3772,32 +4055,49 @@ function parseLimit(v) {
3772
4055
  clamped: false
3773
4056
  };
3774
4057
  }
4058
+ function parseGroups(args, view, field) {
4059
+ const max = field === "groupBy" ? QUERY_LIMITS.maxGroupBy : QUERY_LIMITS.maxPivotBy;
4060
+ const groups = list(args[field], field, max).map((raw, i) => {
4061
+ if (!isObject(raw)) fail$1(`${field}[${i}] must be an object.`);
4062
+ noOtherKeys(raw, ["column", "bucket"], `${field}[${i}]`);
4063
+ const column = resolveColumn(view, raw["column"], `${field}[${i}].column`);
4064
+ return {
4065
+ column,
4066
+ bucket: raw["bucket"] === void 0 ? "value" : oneOf(raw["bucket"], BUCKETS, `${field}[${i}].bucket`),
4067
+ name: column.name
4068
+ };
4069
+ });
4070
+ if (new Set(groups.map((group) => group.column.index)).size !== groups.length) fail$1(`"${field}" names the same column twice.`);
4071
+ if (new Set(groups.map((group) => group.name)).size !== groups.length) for (const group of groups) group.name = group.column.label;
4072
+ return groups;
4073
+ }
3775
4074
  function parseQuery(args, view) {
3776
4075
  const op = oneOf(args["op"], ["aggregate", "rows"], "\"op\"");
3777
4076
  const uncomparable = /* @__PURE__ */ new Map();
3778
4077
  const filters = list(args["where"], "where", QUERY_LIMITS.maxFilters).map((raw, i) => compileFilter(view, raw, i, uncomparable));
3779
4078
  const { limit, clamped } = parseLimit(args["limit"]);
3780
4079
  const orderRaw = list(args["orderBy"], "orderBy", QUERY_LIMITS.maxOrderBy).map((raw, i) => {
3781
- if (!isObject(raw)) fail(`orderBy[${i}] must be an object.`);
4080
+ if (!isObject(raw)) fail$1(`orderBy[${i}] must be an object.`);
3782
4081
  noOtherKeys(raw, ["key", "desc"], `orderBy[${i}]`);
3783
- if (typeof raw["key"] !== "string" || raw["key"].trim() === "") fail(`orderBy[${i}].key must be a string.`);
3784
- if (raw["desc"] !== void 0 && typeof raw["desc"] !== "boolean") fail(`orderBy[${i}].desc must be a boolean.`);
4082
+ if (typeof raw["key"] !== "string" || raw["key"].trim() === "") fail$1(`orderBy[${i}].key must be a string.`);
4083
+ if (raw["desc"] !== void 0 && typeof raw["desc"] !== "boolean") fail$1(`orderBy[${i}].desc must be a boolean.`);
3785
4084
  return {
3786
4085
  key: raw["key"].trim(),
3787
4086
  desc: raw["desc"] === true
3788
4087
  };
3789
4088
  });
3790
4089
  if (op === "rows") {
3791
- if (args["groupBy"] !== void 0 || args["metrics"] !== void 0) fail("\"groupBy\" and \"metrics\" belong to op \"aggregate\". Use op \"rows\" with \"columns\" to list rows.");
4090
+ if (args["groupBy"] !== void 0 || args["pivotBy"] !== void 0 || args["metrics"] !== void 0) fail$1("\"groupBy\", \"pivotBy\" and \"metrics\" belong to op \"aggregate\". Use op \"rows\" with \"columns\" to list rows.");
3792
4091
  const columns = list(args["columns"], "columns", view.columns.length || 1).map((ref, i) => resolveColumn(view, ref, `columns[${i}]`));
3793
4092
  for (const entry of orderRaw) resolveColumn(view, entry.key, "orderBy.key");
3794
4093
  const offset = args["offset"];
3795
- if (offset !== void 0 && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) fail("\"offset\" must be a non-negative integer.");
4094
+ if (offset !== void 0 && (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0)) fail$1("\"offset\" must be a non-negative integer.");
3796
4095
  return {
3797
4096
  op,
3798
4097
  filters,
3799
4098
  uncomparable,
3800
4099
  groups: [],
4100
+ pivot: [],
3801
4101
  metrics: [],
3802
4102
  columns: columns.length > 0 ? columns : [...view.columns],
3803
4103
  order: orderRaw,
@@ -3806,31 +4106,22 @@ function parseQuery(args, view) {
3806
4106
  offset: typeof offset === "number" ? offset : 0
3807
4107
  };
3808
4108
  }
3809
- if (args["columns"] !== void 0 || args["offset"] !== void 0) fail("\"columns\" and \"offset\" belong to op \"rows\". Use op \"aggregate\" with \"groupBy\" and \"metrics\".");
3810
- const groups = list(args["groupBy"], "groupBy", QUERY_LIMITS.maxGroupBy).map((raw, i) => {
3811
- if (!isObject(raw)) fail(`groupBy[${i}] must be an object.`);
3812
- noOtherKeys(raw, ["column", "bucket"], `groupBy[${i}]`);
3813
- const column = resolveColumn(view, raw["column"], `groupBy[${i}].column`);
3814
- return {
3815
- column,
3816
- bucket: raw["bucket"] === void 0 ? "value" : oneOf(raw["bucket"], BUCKETS, `groupBy[${i}].bucket`),
3817
- name: column.name
3818
- };
3819
- });
3820
- if (new Set(groups.map((g) => g.column.index)).size !== groups.length) fail("\"groupBy\" names the same column twice.");
3821
- if (new Set(groups.map((g) => g.name)).size !== groups.length) for (const g of groups) g.name = g.column.label;
4109
+ if (args["columns"] !== void 0 || args["offset"] !== void 0) fail$1("\"columns\" and \"offset\" belong to op \"rows\". Use op \"aggregate\" with \"groupBy\" and \"metrics\".");
4110
+ const groups = parseGroups(args, view, "groupBy");
4111
+ const pivot = parseGroups(args, view, "pivotBy");
4112
+ if (pivot.some((dimension) => groups.some((group) => group.column.index === dimension.column.index))) fail$1("A column cannot appear in both \"groupBy\" and \"pivotBy\".");
3822
4113
  const metricsRaw = list(args["metrics"], "metrics", QUERY_LIMITS.maxMetrics);
3823
4114
  const metrics = (metricsRaw.length > 0 ? metricsRaw : [{ fn: "count" }]).map((raw, i) => {
3824
- if (!isObject(raw)) fail(`metrics[${i}] must be an object.`);
4115
+ if (!isObject(raw)) fail$1(`metrics[${i}] must be an object.`);
3825
4116
  noOtherKeys(raw, [
3826
4117
  "fn",
3827
4118
  "column",
3828
4119
  "as"
3829
4120
  ], `metrics[${i}]`);
3830
4121
  const fn = oneOf(raw["fn"], METRIC_FNS, `metrics[${i}].fn`);
3831
- if (raw["column"] === void 0 && fn !== "count") fail(`metrics[${i}].column is required for "${fn}".`);
4122
+ if (raw["column"] === void 0 && fn !== "count") fail$1(`metrics[${i}].column is required for "${fn}".`);
3832
4123
  const column = raw["column"] === void 0 ? void 0 : resolveColumn(view, raw["column"], `metrics[${i}].column`);
3833
- if (raw["as"] !== void 0 && (typeof raw["as"] !== "string" || raw["as"].trim() === "")) fail(`metrics[${i}].as must be a non-empty string.`);
4124
+ if (raw["as"] !== void 0 && (typeof raw["as"] !== "string" || raw["as"].trim() === "")) fail$1(`metrics[${i}].as must be a non-empty string.`);
3834
4125
  const alias = typeof raw["as"] === "string" ? raw["as"].trim() : column === void 0 ? fn : `${fn}(${column.name})`;
3835
4126
  return column === void 0 ? {
3836
4127
  fn,
@@ -3841,18 +4132,19 @@ function parseQuery(args, view) {
3841
4132
  alias
3842
4133
  };
3843
4134
  });
3844
- if (new Set(metrics.map((m) => m.alias)).size !== metrics.length) fail("Two metrics have the same name; give one of them a distinct \"as\".");
4135
+ if (new Set(metrics.map((m) => m.alias)).size !== metrics.length) fail$1("Two metrics have the same name; give one of them a distinct \"as\".");
3845
4136
  const available = () => [...metrics.map((m) => m.alias), ...groups.map((g) => g.name)].join(", ");
3846
4137
  for (const entry of orderRaw) {
3847
4138
  if (metrics.some((m) => m.alias === entry.key)) continue;
3848
4139
  const column = resolveColumn(view, entry.key, "orderBy.key");
3849
- if (!groups.some((g) => g.column.index === column.index)) fail(`orderBy.key "${entry.key}" is neither a metric nor a grouped column. Available: ${available()}.`);
4140
+ if (!groups.some((g) => g.column.index === column.index)) fail$1(`orderBy.key "${entry.key}" is neither a metric nor a grouped column. Available: ${available()}.`);
3850
4141
  }
3851
4142
  return {
3852
4143
  op,
3853
4144
  filters,
3854
4145
  uncomparable,
3855
4146
  groups,
4147
+ pivot,
3856
4148
  metrics,
3857
4149
  columns: [],
3858
4150
  order: orderRaw,
@@ -3916,7 +4208,8 @@ function finish(metric, acc) {
3916
4208
  return (metric.fn === "min" ? acc.minD : acc.maxD) ?? null;
3917
4209
  }
3918
4210
  }
3919
- function runAggregate(view, query) {
4211
+ async function runAggregate(view, query, complete = false) {
4212
+ if (query.pivot.length > 0) return await runPivot(view, query, complete);
3920
4213
  const { groups: groupSpecs, metrics } = query;
3921
4214
  const skipped = new Map(metrics.map((m) => [m.alias, {
3922
4215
  empty: 0,
@@ -3924,6 +4217,7 @@ function runAggregate(view, query) {
3924
4217
  }]));
3925
4218
  const unbucketed = /* @__PURE__ */ new Map();
3926
4219
  const table = /* @__PURE__ */ new Map();
4220
+ let dataRows = 0;
3927
4221
  let matched = 0;
3928
4222
  const groupOf = (key) => {
3929
4223
  const id = key.map((part) => part === null ? "\0" : part).join("");
@@ -3938,7 +4232,8 @@ function runAggregate(view, query) {
3938
4232
  return group;
3939
4233
  };
3940
4234
  if (groupSpecs.length === 0) groupOf([]);
3941
- for (const { cells } of view.data) {
4235
+ for await (const { cells } of view.data) {
4236
+ dataRows += 1;
3942
4237
  if (!query.filters.every((filter) => filter.test(cells))) continue;
3943
4238
  matched += 1;
3944
4239
  const group = groupOf(groupSpecs.map((spec) => {
@@ -4011,7 +4306,10 @@ function runAggregate(view, query) {
4011
4306
  const out = rows.slice(0, query.limit).map((row) => ({
4012
4307
  key: Object.fromEntries(row.key.map((part, i) => {
4013
4308
  if (part === null) return [names[i], null];
4014
- const cut = clip$1(part);
4309
+ const cut = complete ? {
4310
+ text: part,
4311
+ clipped: false
4312
+ } : clip$1(part);
4015
4313
  if (cut.clipped) cellsTruncated = true;
4016
4314
  return [names[i], cut.text];
4017
4315
  })),
@@ -4026,6 +4324,7 @@ function runAggregate(view, query) {
4026
4324
  };
4027
4325
  }
4028
4326
  return {
4327
+ dataRows,
4029
4328
  matchedRows: matched,
4030
4329
  uncomparable: Object.fromEntries(query.uncomparable),
4031
4330
  groups: out,
@@ -4035,8 +4334,194 @@ function runAggregate(view, query) {
4035
4334
  cellsTruncated
4036
4335
  };
4037
4336
  }
4038
- function runRows(view, query) {
4039
- const matched = view.data.filter((entry) => query.filters.every((filter) => filter.test(entry.cells)));
4337
+ async function runPivot(view, query, complete) {
4338
+ const { groups: rowSpecs, pivot: columnSpecs, metrics } = query;
4339
+ const skipped = new Map(metrics.map((metric) => [metric.alias, {
4340
+ empty: 0,
4341
+ nonNumeric: 0
4342
+ }]));
4343
+ const unbucketed = /* @__PURE__ */ new Map();
4344
+ const table = /* @__PURE__ */ new Map();
4345
+ const columnKeys = /* @__PURE__ */ new Map();
4346
+ const labels = /* @__PURE__ */ new Map();
4347
+ const uniqueLabels = /* @__PURE__ */ new Set();
4348
+ let dataRows = 0;
4349
+ let matched = 0;
4350
+ let cellsTruncated = false;
4351
+ const keyOf = (specs, cells) => specs.map((spec) => {
4352
+ const text = (cells[spec.column.index] ?? "").trim();
4353
+ if (text === "") return null;
4354
+ if (spec.bucket === "value") return text;
4355
+ if (kindOf(text) === "date") return text.slice(0, spec.bucket === "year" ? 4 : spec.bucket === "month" ? 7 : 10);
4356
+ unbucketed.set(spec.name, (unbucketed.get(spec.name) ?? 0) + 1);
4357
+ return null;
4358
+ });
4359
+ const idOf = (key) => key.map((part) => part === null ? "\0" : part).join("");
4360
+ const add = (metric, acc, cells, skip) => {
4361
+ if (metric.column === void 0) {
4362
+ acc.rows += 1;
4363
+ return;
4364
+ }
4365
+ const text = (cells[metric.column.index] ?? "").trim();
4366
+ if (metric.fn === "count") {
4367
+ if (text !== "") acc.rows += 1;
4368
+ return;
4369
+ }
4370
+ if (metric.fn === "countDistinct") {
4371
+ if (text !== "") acc.distinct.add(text);
4372
+ return;
4373
+ }
4374
+ const kind = kindOf(text);
4375
+ if (kind === "empty") {
4376
+ if (skip !== void 0) skip.empty += 1;
4377
+ } else if (kind === "number") {
4378
+ const value = Number(text);
4379
+ acc.numbers += 1;
4380
+ if (metric.fn === "sum" || metric.fn === "avg") acc.sum.add(value);
4381
+ else if (metric.fn === "median") acc.values.push(value);
4382
+ else if (metric.fn === "min") acc.minN = acc.minN === void 0 ? value : Math.min(acc.minN, value);
4383
+ else acc.maxN = acc.maxN === void 0 ? value : Math.max(acc.maxN, value);
4384
+ } else if (kind === "date" && (metric.fn === "min" || metric.fn === "max")) {
4385
+ acc.dates += 1;
4386
+ if (metric.fn === "min") acc.minD = acc.minD === void 0 ? text : acc.minD < text ? acc.minD : text;
4387
+ else acc.maxD = acc.maxD === void 0 ? text : acc.maxD > text ? acc.maxD : text;
4388
+ } else if (skip !== void 0) skip.nonNumeric += 1;
4389
+ };
4390
+ for await (const { cells } of view.data) {
4391
+ dataRows += 1;
4392
+ if (!query.filters.every((filter) => filter.test(cells))) continue;
4393
+ matched += 1;
4394
+ const rowKey = keyOf(rowSpecs, cells);
4395
+ const columnKey = keyOf(columnSpecs, cells);
4396
+ const rowId = idOf(rowKey);
4397
+ const columnId = idOf(columnKey);
4398
+ let group = table.get(rowId);
4399
+ if (group === void 0) {
4400
+ group = {
4401
+ key: rowKey,
4402
+ totals: metrics.map(newAcc),
4403
+ cells: /* @__PURE__ */ new Map()
4404
+ };
4405
+ table.set(rowId, group);
4406
+ }
4407
+ if (!columnKeys.has(columnId)) {
4408
+ columnKeys.set(columnId, columnKey);
4409
+ const base = columnKey.map((part) => part ?? "(empty)").join(" / ");
4410
+ let label = base;
4411
+ let suffix = 2;
4412
+ while (uniqueLabels.has(label)) label = `${base} (#${suffix++})`;
4413
+ labels.set(columnId, label);
4414
+ uniqueLabels.add(label);
4415
+ }
4416
+ let cellAccs = group.cells.get(columnId);
4417
+ if (cellAccs === void 0) {
4418
+ cellAccs = metrics.map(newAcc);
4419
+ group.cells.set(columnId, cellAccs);
4420
+ }
4421
+ for (const [index, metric] of metrics.entries()) {
4422
+ add(metric, group.totals[index], cells, skipped.get(metric.alias));
4423
+ add(metric, cellAccs[index], cells);
4424
+ }
4425
+ }
4426
+ const usedLabels = /* @__PURE__ */ new Set();
4427
+ const columnLabel = (base, alias) => {
4428
+ const clipped = complete ? {
4429
+ text: `${base} | ${alias}`,
4430
+ clipped: false
4431
+ } : clip$1(`${base} | ${alias}`);
4432
+ if (clipped.clipped) cellsTruncated = true;
4433
+ let label = clipped.text;
4434
+ let suffix = 2;
4435
+ while (usedLabels.has(label)) label = `${clipped.text.slice(0, 190)} (#${suffix++})`;
4436
+ usedLabels.add(label);
4437
+ return label;
4438
+ };
4439
+ const descriptors = [...columnKeys.keys()].map((id) => ({
4440
+ id,
4441
+ label: labels.get(id)
4442
+ }));
4443
+ const shown = complete ? descriptors : descriptors.slice(0, QUERY_LIMITS.maxPivotColumns);
4444
+ const dynamicColumns = shown.flatMap(({ label }) => metrics.map((metric) => columnLabel(label, metric.alias)));
4445
+ const totalColumns = metrics.map((metric) => columnLabel("Total", metric.alias));
4446
+ const outputColumns = [...dynamicColumns, ...totalColumns];
4447
+ const ordered = [...table.values()].map((group) => ({
4448
+ key: group.key,
4449
+ values: Object.fromEntries(metrics.map((metric, index) => [totalColumns[index], finish(metric, group.totals[index])]))
4450
+ }));
4451
+ const keyIndexOf = (key) => {
4452
+ const column = metrics.some((metric) => metric.alias === key) ? void 0 : resolveColumn(view, key, "orderBy.key");
4453
+ return column === void 0 ? -1 : rowSpecs.findIndex((spec) => spec.column.index === column.index);
4454
+ };
4455
+ const comparators = [];
4456
+ for (const entry of query.order) {
4457
+ const metric = metrics.find((candidate) => candidate.alias === entry.key);
4458
+ const metricIndex = metric === void 0 ? -1 : metrics.indexOf(metric);
4459
+ const groupIndex = metricIndex === -1 ? keyIndexOf(entry.key) : -1;
4460
+ comparators.push((a, b) => metricIndex === -1 ? compareNullable(a.key[groupIndex] ?? null, b.key[groupIndex] ?? null, entry.desc) : compareNullable(a.values[totalColumns[metricIndex]] ?? null, b.values[totalColumns[metricIndex]] ?? null, entry.desc));
4461
+ }
4462
+ if (comparators.length === 0 && rowSpecs.length > 0) {
4463
+ comparators.push((a, b) => compareNullable(a.values[totalColumns[0]] ?? null, b.values[totalColumns[0]] ?? null, true));
4464
+ for (let index = 0; index < rowSpecs.length; index += 1) comparators.push((a, b) => compareNullable(a.key[index] ?? null, b.key[index] ?? null, false));
4465
+ }
4466
+ ordered.sort((a, b) => {
4467
+ for (const compare of comparators) {
4468
+ const order = compare(a, b);
4469
+ if (order !== 0) return order;
4470
+ }
4471
+ return 0;
4472
+ });
4473
+ const names = rowSpecs.map((spec) => spec.name);
4474
+ const groups = ordered.slice(0, query.limit).map((entry) => {
4475
+ const original = table.get(idOf(entry.key));
4476
+ const values = {};
4477
+ for (const [index, metric] of metrics.entries()) {
4478
+ for (const [columnIndex, { id }] of shown.entries()) {
4479
+ const accs = original.cells.get(id);
4480
+ values[dynamicColumns[columnIndex * metrics.length + index]] = accs === void 0 ? null : finish(metric, accs[index]);
4481
+ }
4482
+ values[totalColumns[index]] = finish(metric, original.totals[index]);
4483
+ }
4484
+ return {
4485
+ key: Object.fromEntries(entry.key.map((part, index) => {
4486
+ if (part === null) return [names[index], null];
4487
+ const cut = complete ? {
4488
+ text: part,
4489
+ clipped: false
4490
+ } : clip$1(part);
4491
+ if (cut.clipped) cellsTruncated = true;
4492
+ return [names[index], cut.text];
4493
+ })),
4494
+ values
4495
+ };
4496
+ });
4497
+ const skippedOut = {};
4498
+ for (const [alias, { empty, nonNumeric }] of skipped) {
4499
+ if (empty === 0 && nonNumeric === 0) continue;
4500
+ skippedOut[alias] = {
4501
+ ...empty > 0 ? { empty } : {},
4502
+ ...nonNumeric > 0 ? { nonNumeric } : {}
4503
+ };
4504
+ }
4505
+ return {
4506
+ dataRows,
4507
+ matchedRows: matched,
4508
+ uncomparable: Object.fromEntries(query.uncomparable),
4509
+ groups,
4510
+ totalGroups: table.size,
4511
+ columns: outputColumns,
4512
+ ...complete || descriptors.length <= QUERY_LIMITS.maxPivotColumns ? {} : { pivotColumnsTruncated: true },
4513
+ ...Object.keys(skippedOut).length > 0 ? { skipped: skippedOut } : {},
4514
+ ...unbucketed.size > 0 ? { unbucketed: Object.fromEntries(unbucketed) } : {},
4515
+ cellsTruncated
4516
+ };
4517
+ }
4518
+ async function runRows(view, query, complete = false) {
4519
+ const matched = [];
4520
+ let dataRows = 0;
4521
+ for await (const entry of view.data) {
4522
+ dataRows += 1;
4523
+ if (query.filters.every((filter) => filter.test(entry.cells))) matched.push(entry);
4524
+ }
4040
4525
  if (query.order.length > 0) {
4041
4526
  const keys = query.order.map((entry) => ({
4042
4527
  index: resolveColumn(view, entry.key, "orderBy.key").index,
@@ -4058,12 +4543,17 @@ function runRows(view, query) {
4058
4543
  const rows = matched.slice(query.offset, query.offset + query.limit).map((entry) => ({
4059
4544
  row: entry.row,
4060
4545
  cells: query.columns.map((column) => {
4061
- const cut = clip$1((entry.cells[column.index] ?? "").trim());
4546
+ const raw = (entry.cells[column.index] ?? "").trim();
4547
+ const cut = complete ? {
4548
+ text: raw,
4549
+ clipped: false
4550
+ } : clip$1(raw);
4062
4551
  if (cut.clipped) cellsTruncated = true;
4063
4552
  return cut.text;
4064
4553
  })
4065
4554
  }));
4066
4555
  return {
4556
+ dataRows,
4067
4557
  matchedRows: matched.length,
4068
4558
  uncomparable: Object.fromEntries(query.uncomparable),
4069
4559
  rows,
@@ -4072,13 +4562,220 @@ function runRows(view, query) {
4072
4562
  };
4073
4563
  }
4074
4564
 
4565
+ //#endregion
4566
+ //#region ../agent/src/table/export.ts
4567
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4568
+ function fail(message) {
4569
+ throw new WebSkillError("VALIDATION_FAILED", message);
4570
+ }
4571
+ function onlyKeys(value, allowed, path) {
4572
+ const extra = Object.keys(value).filter((key) => !allowed.includes(key));
4573
+ if (extra.length > 0) fail(`${path} has unsupported field(s): ${extra.join(", ")}.`);
4574
+ }
4575
+ function sheetName(value, path) {
4576
+ if (typeof value !== "string" || value.trim() === "") fail(`${path} must be a non-empty sheet name.`);
4577
+ return value.trim();
4578
+ }
4579
+ function literalRows(value, path) {
4580
+ if (!Array.isArray(value)) fail(`${path} must be an array of rows.`);
4581
+ return value.map((row, rowIndex) => {
4582
+ if (!Array.isArray(row)) fail(`${path}[${rowIndex}] must be an array of cells.`);
4583
+ return row.map((cell, columnIndex) => {
4584
+ if (cell !== null && ![
4585
+ "string",
4586
+ "number",
4587
+ "boolean"
4588
+ ].includes(typeof cell)) fail(`${path}[${rowIndex}][${columnIndex}] must be a string, number, boolean or null.`);
4589
+ return cell;
4590
+ });
4591
+ });
4592
+ }
4593
+ function parseExportFile(raw) {
4594
+ if (raw === void 0) return void 0;
4595
+ if (!isRecord$1(raw)) fail("\"exportFile\" must be an object with a \"fileName\".");
4596
+ onlyKeys(raw, [
4597
+ "fileName",
4598
+ "sheetName",
4599
+ "additionalSheets"
4600
+ ], "\"exportFile\"");
4601
+ const additionalRaw = raw["additionalSheets"];
4602
+ if (additionalRaw !== void 0 && (!Array.isArray(additionalRaw) || additionalRaw.length === 0)) fail("\"exportFile.additionalSheets\" must be a non-empty array when present.");
4603
+ const additionalSheets = (additionalRaw ?? []).map((entry, index) => {
4604
+ const path = `exportFile.additionalSheets[${index}]`;
4605
+ if (!isRecord$1(entry)) fail(`"${path}" must be an object.`);
4606
+ const name = sheetName(entry["name"], `"${path}.name"`);
4607
+ if ("query" in entry) {
4608
+ onlyKeys(entry, ["name", "query"], `"${path}"`);
4609
+ if (!isRecord$1(entry["query"])) fail(`"${path}.query" must be an aggregate or rows query object.`);
4610
+ return {
4611
+ name,
4612
+ query: entry["query"]
4613
+ };
4614
+ }
4615
+ onlyKeys(entry, [
4616
+ "name",
4617
+ "columns",
4618
+ "rows"
4619
+ ], `"${path}"`);
4620
+ const columns = entry["columns"];
4621
+ if (columns !== void 0 && (!Array.isArray(columns) || columns.some((column) => typeof column !== "string"))) fail(`"${path}.columns" must be an array of header strings.`);
4622
+ return {
4623
+ name,
4624
+ ...columns !== void 0 ? { columns } : {},
4625
+ rows: literalRows(entry["rows"], `${path}.rows`)
4626
+ };
4627
+ });
4628
+ return {
4629
+ fileName: resolveFileName$1(raw["fileName"], "query_table"),
4630
+ sheetName: raw["sheetName"] === void 0 ? "Query result" : sheetName(raw["sheetName"], "\"exportFile.sheetName\""),
4631
+ additionalSheets
4632
+ };
4633
+ }
4634
+ const CELL_SCHEMA = { anyOf: [
4635
+ { type: "string" },
4636
+ { type: "number" },
4637
+ { type: "boolean" },
4638
+ { type: "null" }
4639
+ ] };
4640
+ function createExportFileSchema(queryProperties) {
4641
+ const nestedQueryProperties = Object.fromEntries(Object.entries(queryProperties).filter(([key]) => ![
4642
+ "id",
4643
+ "limit",
4644
+ "offset"
4645
+ ].includes(key)).map(([key, value]) => key === "op" ? [key, {
4646
+ ...value,
4647
+ enum: ["aggregate", "rows"],
4648
+ description: "The query operation for this sheet."
4649
+ }] : [key, value]));
4650
+ return {
4651
+ type: "object",
4652
+ description: "Write complete query results directly to one Excel file. Preview limits do not apply. The top-level query forms the primary sheet; additionalSheets can add more complete queries or small literal notes sheets.",
4653
+ properties: {
4654
+ fileName: {
4655
+ type: "string",
4656
+ description: "Workbook file name, for example \"contract-analysis.xlsx\"."
4657
+ },
4658
+ sheetName: {
4659
+ type: "string",
4660
+ description: "Name of the primary sheet produced by the top-level query. Defaults to \"Query result\"."
4661
+ },
4662
+ additionalSheets: {
4663
+ type: "array",
4664
+ minItems: 1,
4665
+ maxItems: SPREADSHEET_LIMITS.sheets - 1,
4666
+ description: "Additional sheets in the same workbook. Use {name, query} for complete query results. Use {name, columns, rows} only for a small notes or definitions sheet, never to copy large query results.",
4667
+ items: { anyOf: [{
4668
+ type: "object",
4669
+ properties: {
4670
+ name: {
4671
+ type: "string",
4672
+ description: "Output sheet name."
4673
+ },
4674
+ query: {
4675
+ type: "object",
4676
+ description: "A complete aggregate or rows query against the same workbook id. Every result row is written directly to this sheet without passing through the model. Preview limit and offset do not apply.",
4677
+ properties: nestedQueryProperties,
4678
+ required: ["op"],
4679
+ additionalProperties: false
4680
+ }
4681
+ },
4682
+ required: ["name", "query"],
4683
+ additionalProperties: false
4684
+ }, {
4685
+ type: "object",
4686
+ description: "A small literal notes or definitions sheet, not a way to transport large query results.",
4687
+ properties: {
4688
+ name: {
4689
+ type: "string",
4690
+ description: "Output sheet name."
4691
+ },
4692
+ columns: {
4693
+ type: "array",
4694
+ maxItems: SPREADSHEET_LIMITS.columnsPerSheet,
4695
+ items: { type: "string" }
4696
+ },
4697
+ rows: {
4698
+ type: "array",
4699
+ maxItems: SPREADSHEET_LIMITS.rowsPerSheet,
4700
+ items: {
4701
+ type: "array",
4702
+ maxItems: SPREADSHEET_LIMITS.columnsPerSheet,
4703
+ items: CELL_SCHEMA
4704
+ }
4705
+ }
4706
+ },
4707
+ required: ["name", "rows"],
4708
+ additionalProperties: false
4709
+ }] }
4710
+ }
4711
+ },
4712
+ required: ["fileName"],
4713
+ additionalProperties: false
4714
+ };
4715
+ }
4716
+ function sheetFromQuery(query, output, name) {
4717
+ if (output.groups !== void 0) {
4718
+ const valueColumns = query.pivot.length > 0 ? output.columns ?? [] : query.metrics.map((metric) => metric.alias);
4719
+ return {
4720
+ name,
4721
+ columns: [...query.groups.map((group) => group.name), ...valueColumns],
4722
+ rows: output.groups.map((group) => [...query.groups.map((dimension) => group.key[dimension.name] ?? null), ...valueColumns.map((column) => group.values[column] ?? null)])
4723
+ };
4724
+ }
4725
+ return {
4726
+ name,
4727
+ columns: output.columns ?? [],
4728
+ rows: (output.rows ?? []).map((row) => row.cells)
4729
+ };
4730
+ }
4731
+ async function exportQuerySheet(workbook, args, name) {
4732
+ const view = isStreamingTableWorkbook(workbook) ? await buildStreamingView(workbook, args["sheet"], args["headerRow"]) : buildView(workbook, args["sheet"], args["headerRow"]);
4733
+ const query = parseQuery(args, view);
4734
+ const computation = {
4735
+ ...query,
4736
+ limit: Number.MAX_SAFE_INTEGER,
4737
+ offset: 0,
4738
+ limitClamped: false
4739
+ };
4740
+ const output = computation.op === "aggregate" ? await runAggregate(view, computation, true) : await runRows(view, computation, true);
4741
+ return {
4742
+ sheet: sheetFromQuery(query, output, name),
4743
+ matchedRows: output.matchedRows,
4744
+ ...output.totalGroups !== void 0 ? { totalGroups: output.totalGroups } : {}
4745
+ };
4746
+ }
4747
+ function captureTableQueryArgs(args) {
4748
+ const exportFile = args["exportFile"];
4749
+ if (!isRecord$1(exportFile)) return args;
4750
+ const sheets = exportFile["additionalSheets"];
4751
+ if (!Array.isArray(sheets)) return args;
4752
+ let redacted = false;
4753
+ const captured = sheets.map((sheet) => {
4754
+ if (!isRecord$1(sheet) || !Array.isArray(sheet["rows"])) return sheet;
4755
+ redacted = true;
4756
+ const summary = {
4757
+ ...sheet,
4758
+ rowCount: sheet["rows"].length
4759
+ };
4760
+ delete summary["rows"];
4761
+ return summary;
4762
+ });
4763
+ return redacted ? {
4764
+ ...args,
4765
+ exportFile: {
4766
+ ...exportFile,
4767
+ additionalSheets: captured
4768
+ }
4769
+ } : args;
4770
+ }
4771
+
4075
4772
  //#endregion
4076
4773
  //#region ../agent/src/table/toolSource.ts
4077
4774
  /** @experimental */
4078
4775
  const QUERY_TABLE_TOOL = "query_table";
4079
- const DESCRIPTION$2 = "Run exact statistics over an Excel (.xlsx) workbook the user attached, without reading it row by row: describe its sheets and columns, aggregate (filter, group, count, distinct count, sum, average, min, max, median), or list matching rows. Every query covers all rows and is computed on the user’s device.";
4776
+ const DESCRIPTION$2 = "Run exact statistics over an Excel (.xlsx) workbook the user attached, without reading it row by row: describe its sheets and columns, aggregate (filter, group, pivot, count, distinct count, sum, average, min, max, median), or list matching rows. Every query covers all rows and is computed on the user’s device.";
4080
4777
  const DESCRIPTION_WITH_DOWNLOADS = DESCRIPTION$2.replace("the user attached,", "the user attached or downloaded,");
4081
- const SYSTEM_PROMPT$3 = "query_table computes over every row of an attached .xlsx workbook. Use it for any count, total, average, extreme, median, ranking or breakdown, and quote its numbers as returned — they are exact. Do not estimate statistics from sample rows, and do not page through a whole sheet with read_document to compute them. When a result reports skipped or uncomparable cells, tell the user how many and why. Attachment ids from earlier messages of this conversation stay valid for this tool.";
4778
+ const SYSTEM_PROMPT$3 = "query_table computes over every row of an attached .xlsx workbook. Use it for any count, total, average, extreme, median, ranking or breakdown, and quote its numbers as returned — they are exact. Do not estimate statistics from sample rows, and do not page through a whole sheet with read_document to compute them. For a multi-dimensional distinct count, put every row dimension in groupBy and use countDistinct directly. For a cross-tab, additionally put column dimensions in pivotBy; row totals are included. Do not delegate these single-scan queries to subagents. When a result reports skipped or uncomparable cells, tell the user how many and why. Attachment ids from earlier messages of this conversation stay valid for this tool.";
4082
4779
  const SYSTEM_PROMPT_WITH_DOWNLOADS = `${SYSTEM_PROMPT$3} A downloaded workbook\u2019s id from list_downloaded_files works too.`;
4083
4780
  const ID_DESCRIPTION = "The attachment id from the message that carried the workbook.";
4084
4781
  const ID_DESCRIPTION_WITH_DOWNLOADS = `${ID_DESCRIPTION} For a downloaded file, use the id from list_downloaded_files; the user is asked for permission to read it.`;
@@ -4160,6 +4857,24 @@ const INPUT_SCHEMA$2 = {
4160
4857
  additionalProperties: false
4161
4858
  }
4162
4859
  },
4860
+ pivotBy: {
4861
+ type: "array",
4862
+ maxItems: QUERY_LIMITS.maxPivotBy,
4863
+ description: "aggregate only. Pivot column dimensions. groupBy supplies the row dimensions and pivotBy supplies the column dimensions; every metric is computed for each cell and for the row total.",
4864
+ items: {
4865
+ type: "object",
4866
+ properties: {
4867
+ column: COLUMN,
4868
+ bucket: {
4869
+ type: "string",
4870
+ enum: [...BUCKETS],
4871
+ description: "\"value\" (default) groups by the cell text; year/month/day bucket ISO date cells."
4872
+ }
4873
+ },
4874
+ required: ["column"],
4875
+ additionalProperties: false
4876
+ }
4877
+ },
4163
4878
  metrics: {
4164
4879
  type: "array",
4165
4880
  maxItems: QUERY_LIMITS.maxMetrics,
@@ -4203,7 +4918,7 @@ const INPUT_SCHEMA$2 = {
4203
4918
  limit: {
4204
4919
  type: "integer",
4205
4920
  minimum: 1,
4206
- description: `Groups or rows to return. Default ${QUERY_LIMITS.defaultRows}, at most ${QUERY_LIMITS.maxRows}.`
4921
+ description: `Preview groups or rows to return. Default ${QUERY_LIMITS.defaultRows}, at most ${QUERY_LIMITS.maxRows}. Ignored when exportFile is present: exported files contain the complete query result.`
4207
4922
  },
4208
4923
  offset: {
4209
4924
  type: "integer",
@@ -4248,10 +4963,17 @@ function fit(head, field, items, more) {
4248
4963
  * @experimental
4249
4964
  */
4250
4965
  function createTableQueryToolSource(options) {
4251
- const schemaWithDownloads = {
4966
+ const schema = options.writeArtifact === void 0 ? INPUT_SCHEMA$2 : {
4252
4967
  ...INPUT_SCHEMA$2,
4253
4968
  properties: {
4254
4969
  ...INPUT_SCHEMA$2.properties,
4970
+ exportFile: createExportFileSchema(INPUT_SCHEMA$2.properties ?? {})
4971
+ }
4972
+ };
4973
+ const schemaWithDownloads = {
4974
+ ...schema,
4975
+ properties: {
4976
+ ...schema.properties,
4255
4977
  id: {
4256
4978
  type: "string",
4257
4979
  description: ID_DESCRIPTION_WITH_DOWNLOADS
@@ -4259,6 +4981,7 @@ function createTableQueryToolSource(options) {
4259
4981
  }
4260
4982
  };
4261
4983
  const downloads = () => options.downloadedFiles?.() === true;
4984
+ const exportPrompt = options.writeArtifact === void 0 ? "" : " When the user asks for an Excel file of query results, call this tool with exportFile so every group or matching row is written locally without passing through the model. Never copy a truncated preview into export_spreadsheet. For one workbook with multiple sheets, keep the primary query at the top level, name it with exportFile.sheetName, and put additional complete queries in exportFile.additionalSheets as {name, query}. A small notes sheet may use {name, columns, rows}. Do not page full results through the model or write one file per query when the user asked for one workbook.";
4262
4985
  return {
4263
4986
  kind: "table-query",
4264
4987
  listToolSpecs: () => Promise.resolve([downloads() ? {
@@ -4268,11 +4991,12 @@ function createTableQueryToolSource(options) {
4268
4991
  } : {
4269
4992
  name: QUERY_TABLE_TOOL,
4270
4993
  description: DESCRIPTION$2,
4271
- inputSchema: INPUT_SCHEMA$2
4994
+ inputSchema: schema
4272
4995
  }]),
4273
- systemPrompt: () => Promise.resolve(downloads() ? SYSTEM_PROMPT_WITH_DOWNLOADS : SYSTEM_PROMPT$3),
4996
+ systemPrompt: () => Promise.resolve((downloads() ? SYSTEM_PROMPT_WITH_DOWNLOADS : SYSTEM_PROMPT$3) + exportPrompt),
4274
4997
  canHandle: (name) => name === QUERY_TABLE_TOOL,
4275
4998
  argCaptureTrust: (name) => name === "query_table" ? { tier: "reviewed" } : void 0,
4999
+ captureArgs: (_name, args) => captureTableQueryArgs(args),
4276
5000
  call: async (name, args, context) => {
4277
5001
  try {
4278
5002
  if (name !== "query_table") throw new WebSkillError("TOOL_NOT_FOUND", `Unknown tool ${name}.`);
@@ -4288,26 +5012,83 @@ function createTableQueryToolSource(options) {
4288
5012
  const loaded = await options.tables.load(id, context);
4289
5013
  if (loaded === void 0) throw new WebSkillError("TOOL_UNSUPPORTED", `No readable workbook with id "${id}". Use the id of an Excel file attached in this conversation; files from other conversations are not available.`);
4290
5014
  const { workbook, name } = loaded;
4291
- if (args["op"] === "describe") return {
5015
+ const op = args["op"];
5016
+ const exportRequest = parseExportFile(args["exportFile"]);
5017
+ if (exportRequest !== void 0 && op === "describe") throw new WebSkillError("VALIDATION_FAILED", "\"exportFile\" applies to aggregate or rows, not describe.");
5018
+ if (exportRequest !== void 0 && options.writeArtifact === void 0) throw new WebSkillError("TOOL_UNSUPPORTED", "This host cannot write query results as Excel files.");
5019
+ const profileInput = {
5020
+ name,
5021
+ id,
5022
+ ...args["sheet"] !== void 0 ? { sheet: args["sheet"] } : {},
5023
+ ...args["headerRow"] !== void 0 ? { headerRow: args["headerRow"] } : {}
5024
+ };
5025
+ if (op === "describe") return {
4292
5026
  ok: true,
4293
5027
  content: [{
4294
5028
  type: "text",
4295
- text: profileTable({
4296
- name,
4297
- id,
4298
- workbook,
4299
- ...args["sheet"] !== void 0 ? { sheet: args["sheet"] } : {},
4300
- ...args["headerRow"] !== void 0 ? { headerRow: args["headerRow"] } : {}
5029
+ text: isStreamingTableWorkbook(workbook) ? await profileStreamingTable({
5030
+ ...profileInput,
5031
+ workbook
5032
+ }) : profileTable({
5033
+ ...profileInput,
5034
+ workbook
4301
5035
  })
4302
5036
  }]
4303
5037
  };
4304
- const view = buildView(workbook, args["sheet"], args["headerRow"]);
5038
+ if (exportRequest !== void 0) {
5039
+ const main = await exportQuerySheet(workbook, args, exportRequest.sheetName);
5040
+ const spec = { sheets: [main.sheet] };
5041
+ const sheetSummaries = [{
5042
+ name: exportRequest.sheetName,
5043
+ rowCount: main.sheet.rows.length,
5044
+ matchedRows: main.matchedRows,
5045
+ ...main.totalGroups !== void 0 ? { totalGroups: main.totalGroups } : {}
5046
+ }];
5047
+ for (const additional of exportRequest.additionalSheets) if ("query" in additional) {
5048
+ const result = await exportQuerySheet(workbook, additional.query, additional.name);
5049
+ spec.sheets.push(result.sheet);
5050
+ sheetSummaries.push({
5051
+ name: additional.name,
5052
+ rowCount: result.sheet.rows.length,
5053
+ matchedRows: result.matchedRows,
5054
+ ...result.totalGroups !== void 0 ? { totalGroups: result.totalGroups } : {}
5055
+ });
5056
+ } else {
5057
+ spec.sheets.push({
5058
+ name: additional.name,
5059
+ ...additional.columns !== void 0 ? { columns: additional.columns } : {},
5060
+ rows: additional.rows
5061
+ });
5062
+ sheetSummaries.push({
5063
+ name: additional.name,
5064
+ rowCount: additional.rows.length
5065
+ });
5066
+ }
5067
+ const bytes = await encodeSpreadsheet(spec);
5068
+ const artifact = await options.writeArtifact(exportRequest.fileName, bytes, SPREADSHEET_MIME_TYPE, context);
5069
+ return {
5070
+ ok: true,
5071
+ content: [{
5072
+ type: "json",
5073
+ data: {
5074
+ path: artifact.path,
5075
+ size: artifact.size,
5076
+ sheetCount: spec.sheets.length,
5077
+ rowCount: spec.sheets.reduce((total, sheet) => total + sheet.rows.length, 0),
5078
+ matchedRows: main.matchedRows,
5079
+ ...main.totalGroups !== void 0 ? { totalGroups: main.totalGroups } : {},
5080
+ ...exportRequest.additionalSheets.length > 0 ? { sheets: sheetSummaries } : {}
5081
+ }
5082
+ }]
5083
+ };
5084
+ }
5085
+ const view = isStreamingTableWorkbook(workbook) ? await buildStreamingView(workbook, args["sheet"], args["headerRow"]) : buildView(workbook, args["sheet"], args["headerRow"]);
4305
5086
  const query = parseQuery(args, view);
4306
- const output = query.op === "aggregate" ? runAggregate(view, query) : runRows(view, query);
5087
+ const output = query.op === "aggregate" ? await runAggregate(view, query, false) : await runRows(view, query, false);
4307
5088
  const head = {
4308
5089
  sheet: view.sheetName,
4309
5090
  headerRow: view.headerRow,
4310
- dataRows: view.data.length,
5091
+ dataRows: output.dataRows,
4311
5092
  matchedRows: output.matchedRows,
4312
5093
  ...output.totalGroups !== void 0 ? { totalGroups: output.totalGroups } : {},
4313
5094
  ...output.columns !== void 0 ? { columns: output.columns } : {},
@@ -4315,10 +5096,11 @@ function createTableQueryToolSource(options) {
4315
5096
  ...output.unbucketed !== void 0 ? { unbucketed: output.unbucketed } : {},
4316
5097
  ...Object.keys(output.uncomparable).length > 0 ? { uncomparable: output.uncomparable } : {},
4317
5098
  ...output.cellsTruncated ? { cellsTruncated: true } : {},
5099
+ ...output.pivotColumnsTruncated ? { pivotColumnsTruncated: true } : {},
4318
5100
  ...query.limitClamped ? { limitClamped: QUERY_LIMITS.maxRows } : {}
4319
5101
  };
4320
5102
  const items = output.groups ?? output.rows ?? [];
4321
- const more = output.groups !== void 0 ? (output.totalGroups ?? 0) > output.groups.length : query.offset + items.length < output.matchedRows;
5103
+ const more = output.pivotColumnsTruncated === true || (output.groups !== void 0 ? (output.totalGroups ?? 0) > output.groups.length : query.offset + items.length < output.matchedRows);
4322
5104
  return {
4323
5105
  ok: true,
4324
5106
  content: [{
@@ -5159,4 +5941,4 @@ function createDwgToolSource(options) {
5159
5941
  }
5160
5942
 
5161
5943
  //#endregion
5162
- export { CAPTURE_PAGE_IMAGE_TOOL, CLOSE_PAGE_TAB_TOOL, DEFAULT_MAX_DATA_SOURCE_BYTES, DELEGATE_TASK_TOOL, DELEGATION_SYSTEM_PROMPT, DOCUMENT_READ_BUDGET, DOWNLOAD_WAIT_DEFAULT_MS, DOWNLOAD_WAIT_MAX_MS, DOWNLOAD_WAIT_MIN_MS, DWG_MODEL_VIEW_ID, DataSourceCandidatePolicy, DataSourcePolicy, DelegationOrchestrator, DownloadedFilePolicy, EMU_PER_PIXEL, EMU_PER_POINT, EMU_PER_TWIP, EXPORT_SPREADSHEET_TOOL, FETCH_REMOTE_IMAGE_TOOL, FILL_TEMPLATE_TOOL, FOCUS_PAGE_TAB_TOOL, GENERATE_SKILL_TOOL, INSPECT_TEMPLATE_TOOL, LIST_DOWNLOADED_FILES_TOOL, LIST_PAGE_TABS_TOOL, MANAGE_TODO_TOOL, MAX_DIGIT_WIDTH, PAGE_ACTION_KINDS, PAGE_ACTION_SYSTEM_PROMPT, PAGE_ACTION_TOOL, PAGE_DOCUMENT_REF, PDF_GARBLED_RATIO, PDF_LARGE_IMAGE_RATIO, PERCEIVE_PAGE_TOOL, PERCEPTION_PAGING_DEFAULTS, PERCEPTION_SYSTEM_PROMPT, PageActionPolicy, PagePerceptionPolicy, QUERY_TABLE_TOOL, READ_DOCUMENT_TOOL, READ_DOWNLOADED_FILE_TOOL, SCROLL_DIRECTIONS, SKILL_GENERATION_SYSTEM_PROMPT, SkillGenerator, TAB_WORKSET_DEFAULT_MAX, TAB_WORKSET_MAX_MAX, TAB_WORKSET_MIN_MAX, TAB_WORKSET_SYSTEM_PROMPT, TEMPLATE_INSPECT_BUDGET, TODO_SYSTEM_PROMPT, TabWorksetPolicy, TodoStore, VIEW_DWG_TOOL, asReadMode, clampDownloadWaitMs, clampWorksetMax, columnWidthToPixels, createDelegationToolSource, createDocumentToolSource, createDownloadedFileToolSource, createDwgToolSource, createHttpDataSourceTransport, createPageActionToolSource, createPageImageToolSource, createPagePerceptionToolSource, createRemoteImageToolSource, createSkillGenerationToolSource, createSpreadsheetToolSource, createTabWorksetToolSource, createTableQueryToolSource, createTemplateToolSource, createTodoToolSource, defersImage, dwgPickView, dwgViewsOf, fitWithin, formatCellAddress, frameLabel, frameSteps, isPdfTextTrustworthy, isRangeTopLeft, measurePdfTextQuality, parseCellAddress, parseCellRange, profileTable, rangeContains, readImageSize, sniffDocumentFormat, takesImage, toActionFrameScopes, toFrameScopes, withDelegationOrigin };
5944
+ export { CAPTURE_PAGE_IMAGE_TOOL, CLOSE_PAGE_TAB_TOOL, DEFAULT_MAX_DATA_SOURCE_BYTES, DELEGATE_TASK_TOOL, DELEGATION_SYSTEM_PROMPT, DOCUMENT_READ_BUDGET, DOWNLOAD_WAIT_DEFAULT_MS, DOWNLOAD_WAIT_MAX_MS, DOWNLOAD_WAIT_MIN_MS, DWG_MODEL_VIEW_ID, DataSourceCandidatePolicy, DataSourcePolicy, DelegationOrchestrator, DownloadedFilePolicy, EMU_PER_PIXEL, EMU_PER_POINT, EMU_PER_TWIP, EXPORT_SPREADSHEET_TOOL, FETCH_REMOTE_IMAGE_TOOL, FILL_TEMPLATE_TOOL, FOCUS_PAGE_TAB_TOOL, GENERATE_SKILL_TOOL, INSPECT_TEMPLATE_TOOL, LIST_DOWNLOADED_FILES_TOOL, LIST_PAGE_TABS_TOOL, MANAGE_TODO_TOOL, MAX_DIGIT_WIDTH, PAGE_ACTION_KINDS, PAGE_ACTION_SYSTEM_PROMPT, PAGE_ACTION_TOOL, PAGE_DOCUMENT_REF, PDF_GARBLED_RATIO, PDF_LARGE_IMAGE_RATIO, PERCEIVE_PAGE_TOOL, PERCEPTION_PAGING_DEFAULTS, PERCEPTION_SYSTEM_PROMPT, PageActionPolicy, PagePerceptionPolicy, QUERY_TABLE_TOOL, READ_DOCUMENT_TOOL, READ_DOWNLOADED_FILE_TOOL, SCROLL_DIRECTIONS, SKILL_GENERATION_SYSTEM_PROMPT, SkillGenerator, TAB_WORKSET_DEFAULT_MAX, TAB_WORKSET_MAX_MAX, TAB_WORKSET_MIN_MAX, TAB_WORKSET_SYSTEM_PROMPT, TEMPLATE_INSPECT_BUDGET, TODO_SYSTEM_PROMPT, TabWorksetPolicy, TodoStore, VIEW_DWG_TOOL, asReadMode, buildStreamingView, clampDownloadWaitMs, clampWorksetMax, columnWidthToPixels, createDelegationToolSource, createDocumentToolSource, createDownloadedFileToolSource, createDwgToolSource, createHttpDataSourceTransport, createPageActionToolSource, createPageImageToolSource, createPagePerceptionToolSource, createRemoteImageToolSource, createSkillGenerationToolSource, createSpreadsheetToolSource, createTabWorksetToolSource, createTableQueryToolSource, createTemplateToolSource, createTodoToolSource, defersImage, dwgPickView, dwgViewsOf, fitWithin, formatCellAddress, frameLabel, frameSteps, isPdfTextTrustworthy, isRangeTopLeft, isStreamingTableWorkbook, measurePdfTextQuality, parseCellAddress, parseCellRange, profileStreamingTable, profileTable, rangeContains, readImageSize, sniffDocumentFormat, summarizeStreamingWorkbook, takesImage, toActionFrameScopes, toFrameScopes, withDelegationOrigin };