@attentionform/astra-mcp-server 0.5.0 → 0.5.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/index.js CHANGED
@@ -212,6 +212,44 @@ async function refreshAstInPlace(target, parser2, options = {}) {
212
212
  return notes;
213
213
  }
214
214
 
215
+ // ../engine/src/heading-style.ts
216
+ function headingLevelOfStyle(lowerStyleId) {
217
+ const id = lowerStyleId.replace(/\s+/g, "");
218
+ const numbered = /^heading([1-9])$/.exec(id);
219
+ if (numbered) return Number(numbered[1]);
220
+ if (id === "subtitle") return 2;
221
+ return 1;
222
+ }
223
+ function headingLevelOfStyleId(styleId) {
224
+ if (!styleId) return null;
225
+ const lower = styleId.toLowerCase();
226
+ if (!(lower.includes("heading") || lower.includes("title") || lower.includes("subtitle"))) return null;
227
+ return headingLevelOfStyle(lower);
228
+ }
229
+ function ownParagraphStyleId(paragraph) {
230
+ if (paragraph.tagName !== "w:p") return void 0;
231
+ for (let i = 0; i < paragraph.childNodes.length; i++) {
232
+ const pPr = paragraph.childNodes[i];
233
+ if (pPr.nodeType !== 1 || pPr.tagName !== "w:pPr") continue;
234
+ for (let k = 0; k < pPr.childNodes.length; k++) {
235
+ const el = pPr.childNodes[k];
236
+ if (el.nodeType === 1 && el.tagName === "w:pStyle") return el.getAttribute("w:val") || void 0;
237
+ }
238
+ return void 0;
239
+ }
240
+ return void 0;
241
+ }
242
+ function headingOfNode(node) {
243
+ const style = ownParagraphStyleId(node.xml);
244
+ const level = headingLevelOfStyleId(style);
245
+ return style !== void 0 && level !== null ? { style, level } : null;
246
+ }
247
+ function countHeadings(ast) {
248
+ let n = 0;
249
+ for (const sec of ast.sections) for (const node of sec.nodes) if (headingOfNode(node)) n += 1;
250
+ return n;
251
+ }
252
+
215
253
  // ../engine/src/ooxml/revision-ids.ts
216
254
  var REVISION_TAGS = [
217
255
  "ins",
@@ -3033,11 +3071,12 @@ function sortContainerChildren(container, order) {
3033
3071
  const after = next.filter((node) => node.nodeType === 1).map((node) => node.tagName);
3034
3072
  return { reordered: true, before, after };
3035
3073
  }
3036
- function passSorts(doc, entries) {
3074
+ function passSorts(doc, entries, reorderedContainers) {
3037
3075
  for (const { tag, order } of SORT_CONTAINERS) {
3038
3076
  for (const container of snapshotElements(doc, tag)) {
3039
3077
  const { reordered, before, after } = sortContainerChildren(container, order);
3040
3078
  if (reordered) {
3079
+ reorderedContainers?.push(elementXPath(container));
3041
3080
  entries.push(`Reordered ${tag} into schema order${locationSuffix(container)}: [${before.join(",")}] \u2192 [${after.join(",")}]`);
3042
3081
  }
3043
3082
  }
@@ -3100,6 +3139,9 @@ function normalizePartXml(partName, xml) {
3100
3139
  if (entries.length === 0) return { xml, normalized: [] };
3101
3140
  return { xml: partSerializer.serializeToString(doc), normalized: entries };
3102
3141
  }
3142
+ var ORDERED_CONTAINERS = new Map(
3143
+ SORT_CONTAINERS.map((c) => [c.tag.slice(c.tag.indexOf(":") + 1), new Set(c.order)])
3144
+ );
3103
3145
 
3104
3146
  // ../engine/src/ooxml/omml.ts
3105
3147
  var OMML_MAX_DEPTH = 6;
@@ -4562,14 +4604,16 @@ var OoxmlValidationError = class extends Error {
4562
4604
  errors;
4563
4605
  firstError;
4564
4606
  code = "E_VALIDATION";
4565
- remedy = "re-export the file from Word (or pick a valid .docx) and retry";
4607
+ remedy;
4566
4608
  retryable = false;
4567
- constructor(errors, context = "Document") {
4609
+ /** `remedy` replaces the default text for callers that know a better next step (PDF export does). */
4610
+ constructor(errors, context = "Document", remedy) {
4568
4611
  const first = errors[0] || {};
4569
4612
  const xp = typeof first.xPath === "string" && first.xPath || typeof first.path === "string" && first.path || "unknown";
4570
4613
  const hint = tableIndexHintForXPath(first.xPath ?? first.path);
4571
4614
  const detail = `${first.description || "Schema error"} @ ${xp}${hint ? ` (${hint})` : ""}`;
4572
4615
  super(`[OOXML Schema Violation] ${context} has ${errors.length} validation error(s): ${detail}`);
4616
+ this.remedy = remedy ?? "re-export the file from Word (or pick a valid .docx) and retry";
4573
4617
  this.name = "OoxmlValidationError";
4574
4618
  this.errors = errors;
4575
4619
  this.firstError = first;
@@ -10912,22 +10956,10 @@ var DocxParser = class _DocxParser {
10912
10956
  }
10913
10957
  let isHeading = false;
10914
10958
  let headingLevel = void 0;
10915
- const pStyle = node.getElementsByTagName("w:pStyle")[0];
10916
- if (pStyle) {
10917
- const val = pStyle.getAttribute("w:val") || "";
10918
- const lowerVal = val.toLowerCase();
10919
- if (lowerVal.includes("heading") || lowerVal.includes("title") || lowerVal.includes("subtitle")) {
10920
- isHeading = true;
10921
- if (lowerVal.includes("heading 1") || lowerVal === "heading1" || lowerVal === "h1" || lowerVal.includes("title")) {
10922
- headingLevel = 1;
10923
- } else if (lowerVal.includes("heading 2") || lowerVal === "heading2" || lowerVal === "h2" || lowerVal.includes("subtitle")) {
10924
- headingLevel = 2;
10925
- } else if (lowerVal.includes("heading 3") || lowerVal === "heading3" || lowerVal === "h3") {
10926
- headingLevel = 3;
10927
- } else {
10928
- headingLevel = 1;
10929
- }
10930
- }
10959
+ const ownLevel = headingLevelOfStyleId(ownParagraphStyleId(node));
10960
+ if (ownLevel !== null) {
10961
+ isHeading = true;
10962
+ headingLevel = ownLevel;
10931
10963
  }
10932
10964
  const hasTrackedChanges = nodeHasTrackedChanges(node);
10933
10965
  if (hasTrackedChanges) summary.hasTrackedChanges = true;
@@ -11223,8 +11255,7 @@ var DocxParser = class _DocxParser {
11223
11255
  }
11224
11256
  }
11225
11257
  }
11226
- const pStyleEl = element.getElementsByTagName("w:pStyle")[0];
11227
- const namedStyle = pStyleEl ? pStyleEl.getAttribute("w:val") || void 0 : void 0;
11258
+ const namedStyle = ownParagraphStyleId(element);
11228
11259
  let nodeType = element.tagName === "w:p" ? "paragraph" : "table";
11229
11260
  let chartData = void 0;
11230
11261
  let detectedChart = void 0;
@@ -15535,6 +15566,89 @@ function assertNoCellAddress(action, fields, ast) {
15535
15566
  // ../engine/src/operations/table.ts
15536
15567
  var import_xmldom12 = require("@xmldom/xmldom");
15537
15568
  var crypto3 = __toESM(require("crypto"));
15569
+
15570
+ // ../engine/src/operations/colour.ts
15571
+ var COLOUR_NAMES = {
15572
+ red: "FF0000",
15573
+ green: "008000",
15574
+ blue: "0000FF",
15575
+ black: "000000",
15576
+ white: "FFFFFF",
15577
+ yellow: "FFFF00",
15578
+ orange: "FFA500",
15579
+ gray: "808080",
15580
+ grey: "808080",
15581
+ purple: "800080"
15582
+ };
15583
+ var HEX = /^#?([0-9A-Fa-f]{6})$/;
15584
+ function normalizeColour(input, options = {}) {
15585
+ if (typeof input !== "string") return void 0;
15586
+ const text2 = input.trim();
15587
+ const hex = HEX.exec(text2);
15588
+ if (hex) return hex[1];
15589
+ const lower = text2.toLowerCase();
15590
+ if (options.allowAuto && lower === "auto") return "auto";
15591
+ return Object.prototype.hasOwnProperty.call(COLOUR_NAMES, lower) ? COLOUR_NAMES[lower] : void 0;
15592
+ }
15593
+ function resolveColourInput(input, options = {}) {
15594
+ const value = normalizeColour(input, options);
15595
+ return value === void 0 ? input : value;
15596
+ }
15597
+ function colourForms(options = {}) {
15598
+ const names = Object.keys(COLOUR_NAMES).join(", ");
15599
+ return `6 hex digits such as FF0000 (optionally with #), ${options.allowAuto ? '"auto", ' : ""}or one of: ${names}`;
15600
+ }
15601
+ function colourError(field, value, options = {}) {
15602
+ return new Error(
15603
+ `E_VALIDATION: ${field} must be ${colourForms(options)}. Got ${JSON.stringify(value)}. Remedy: pass ${field} as hex, for example "FF0000", or a colour name such as "red".`
15604
+ );
15605
+ }
15606
+ function requireColour(field, input, options = {}) {
15607
+ const value = normalizeColour(input, options);
15608
+ if (value === void 0) throw colourError(field, input, options);
15609
+ return value;
15610
+ }
15611
+ var BORDER_WIDTH_MIN = 1;
15612
+ var BORDER_WIDTH_MAX = 96;
15613
+ function requireBorderSize(field, input, fallback) {
15614
+ if (input === void 0) return fallback;
15615
+ const n = typeof input === "number" ? input : typeof input === "string" && input.trim() !== "" ? Number(input) : Number.NaN;
15616
+ if (!Number.isInteger(n) || n < BORDER_WIDTH_MIN || n > BORDER_WIDTH_MAX) {
15617
+ throw new Error(
15618
+ `E_VALIDATION: ${field}: border width must be a whole number of eighths of a point from ${BORDER_WIDTH_MIN} to ${BORDER_WIDTH_MAX} (got ${JSON.stringify(input)}). Remedy: pass sz:4 for a 0.5 pt line, sz:8 for 1 pt, sz:96 for 12 pt (the widest).`
15619
+ );
15620
+ }
15621
+ return n;
15622
+ }
15623
+ function colourPattern(options = {}) {
15624
+ const ci = (word) => [...word].map((c) => `[${c.toLowerCase()}${c.toUpperCase()}]`).join("");
15625
+ const words = [...Object.keys(COLOUR_NAMES), ...options.allowAuto ? ["auto"] : []].map(ci);
15626
+ return `^\\s*(#?[0-9A-Fa-f]{6}|${words.join("|")})\\s*$`;
15627
+ }
15628
+ function colourDescription(options = {}) {
15629
+ return `A colour: ${colourForms(options)}.`;
15630
+ }
15631
+ var HIGHLIGHT_COLOURS = [
15632
+ "yellow",
15633
+ "green",
15634
+ "cyan",
15635
+ "magenta",
15636
+ "blue",
15637
+ "red",
15638
+ "darkBlue",
15639
+ "darkCyan",
15640
+ "darkGreen",
15641
+ "darkMagenta",
15642
+ "darkRed",
15643
+ "darkYellow",
15644
+ "darkGray",
15645
+ "lightGray",
15646
+ "black",
15647
+ "white",
15648
+ "none"
15649
+ ];
15650
+
15651
+ // ../engine/src/operations/table.ts
15538
15652
  function isTableReproducible() {
15539
15653
  try {
15540
15654
  return resolveReproducibleMode(void 0);
@@ -15934,13 +16048,13 @@ async function executeTable(opIn, ast, guide) {
15934
16048
  afterIndex: Number(pick("afterIndex", op.afterIndex) ?? rows.length - 1),
15935
16049
  cells: cellsArray,
15936
16050
  index: Number(pick("index", op.index) ?? 0),
15937
- fill: String(pick("fill", op.fill) ?? "F7F9FC").replace(/^#/, ""),
15938
- headerFill: pick("headerFill", op.headerFill)?.replace(/^#/, ""),
16051
+ fill: String(resolveColourInput(pick("fill", op.fill) ?? "F7F9FC")).replace(/^#/, ""),
16052
+ headerFill: resolveColourInput(pick("headerFill", op.headerFill))?.replace(/^#/, ""),
15939
16053
  zebra: pick("zebra", op.zebra),
15940
16054
  // Explicit header/single-cell text color for setShading/setCellShading
15941
16055
  // (flat or nested in params; wins over the guide per the precedence
15942
16056
  // invariant). Validated below before any mutation.
15943
- textColor: pick("textColor", op.textColor)
16057
+ textColor: resolveColourInput(pick("textColor", op.textColor))
15944
16058
  };
15945
16059
  const warnings = [];
15946
16060
  const revisionIds = [];
@@ -15954,7 +16068,7 @@ async function executeTable(opIn, ast, guide) {
15954
16068
  const normText = String(P.textColor).replace(/^#/, "");
15955
16069
  if (!/^[0-9A-Fa-f]{6}$/.test(normText)) {
15956
16070
  throw new Error(
15957
- `E_VALIDATION: ${resolvedOp} requires textColor as a 6-digit hex color with optional leading '#' (got textColor='${String(P.textColor)}'). Remedy: pass textColor as 6 hex digits, e.g. textColor:'FFFFFF'.`
16071
+ `E_VALIDATION: ${resolvedOp} requires textColor as ${colourForms()} (got textColor='${String(P.textColor)}'). Remedy: pass textColor as 6 hex digits, e.g. textColor:'FFFFFF', or a colour name such as 'white'.`
15958
16072
  );
15959
16073
  }
15960
16074
  explicitTextColor = normText.toUpperCase();
@@ -16536,16 +16650,8 @@ async function executeTable(opIn, ast, guide) {
16536
16650
  }
16537
16651
  case "setBorder": {
16538
16652
  const borderType = String(pick("border", void 0) || "all");
16539
- const color = String(pick("color", void 0) || "D1D5DB").replace(/^#/, "");
16540
- const rawSz = pick("sz", void 0) || raw.size || 4;
16541
- const szNum = Number(rawSz);
16542
- if (!Number.isInteger(szNum) || szNum < 1) {
16543
- throw toError(
16544
- "E_VALIDATION",
16545
- `setBorder sz must be a whole number of eighths of a point (got ${String(rawSz)}).`,
16546
- "Pass sz:4 for a 0.5 pt line or sz:8 for 1 pt."
16547
- );
16548
- }
16653
+ const color = requireColour("color", pick("color", void 0) || "D1D5DB", { allowAuto: true });
16654
+ const szNum = requireBorderSize("setBorder sz", pick("sz", void 0) ?? raw.size, 4);
16549
16655
  const sz = String(szNum);
16550
16656
  const val = String(raw.val || "single");
16551
16657
  let tblPr = directChildren5(tblEl, "w:tblPr")[0];
@@ -16803,7 +16909,7 @@ async function executeTable(opIn, ast, guide) {
16803
16909
  );
16804
16910
  }
16805
16911
  const tc = resolveTableCell(tr, colIndex, "setCellShading");
16806
- const fillInput = raw.fill ?? op.fill;
16912
+ const fillInput = resolveColourInput(raw.fill ?? op.fill);
16807
16913
  if (fillInput === void 0) {
16808
16914
  throw new Error(
16809
16915
  `E_VALIDATION: setCellShading requires fill (got no fill). Remedy: pass fill as 6 hex digits without '#', e.g. fill:'FF0000'.`
@@ -16812,7 +16918,7 @@ async function executeTable(opIn, ast, guide) {
16812
16918
  const fill = String(fillInput).replace(/^#/, "");
16813
16919
  if (!/^[0-9A-Fa-f]{6}$/.test(fill)) {
16814
16920
  throw new Error(
16815
- `E_VALIDATION: setCellShading requires a 6-digit hex fill with optional leading '#' (got fill='${String(fillInput)}'). Remedy: pass fill as 6 hex digits, e.g. fill:'FF0000' or fill:'#FF0000'.`
16921
+ `E_VALIDATION: setCellShading requires a fill that is ${colourForms()} (got fill='${String(fillInput)}'). Remedy: pass fill as 6 hex digits, e.g. fill:'FF0000' or fill:'#FF0000', or a colour name such as 'red'.`
16816
16922
  );
16817
16923
  }
16818
16924
  const normFill = fill.toUpperCase();
@@ -17121,19 +17227,20 @@ async function executeTable(opIn, ast, guide) {
17121
17227
  break;
17122
17228
  }
17123
17229
  case "setCellTextColor": {
17124
- const colorInput = raw.color ?? op.color ?? raw.textColor ?? op.textColor;
17230
+ const colorInput = resolveColourInput(raw.color ?? op.color ?? raw.textColor ?? op.textColor, { allowAuto: true });
17125
17231
  if (colorInput === void 0 || colorInput === null || String(colorInput).trim() === "") {
17126
17232
  throw new Error(
17127
17233
  `E_VALIDATION: setCellTextColor requires color (got no color). Remedy: pass color as 6 hex digits without '#', e.g. color:'FFFFFF' for white header text.`
17128
17234
  );
17129
17235
  }
17130
17236
  const color = String(colorInput).replace(/^#/, "");
17131
- if (!/^[0-9A-Fa-f]{6}$/.test(color)) {
17237
+ const autoColor = color === "auto";
17238
+ if (!autoColor && !/^[0-9A-Fa-f]{6}$/.test(color)) {
17132
17239
  throw new Error(
17133
- `E_VALIDATION: setCellTextColor requires a 6-digit hex color with optional leading '#' (got color='${String(colorInput)}'). Remedy: pass color as 6 hex digits, e.g. color:'FFFFFF' or color:'#FFFFFF'.`
17240
+ `E_VALIDATION: setCellTextColor requires ${colourForms({ allowAuto: true })} (got color='${String(colorInput)}'). Remedy: pass color as 6 hex digits, e.g. color:'FFFFFF' or color:'#FFFFFF', or a colour name such as 'white'.`
17134
17241
  );
17135
17242
  }
17136
- const normColor = color.toUpperCase();
17243
+ const normColor = autoColor ? "auto" : color.toUpperCase();
17137
17244
  const cellFillOf2 = (tc) => {
17138
17245
  const tcPr = directChildren5(tc, "w:tcPr")[0];
17139
17246
  const shd = tcPr ? directChildren5(tcPr, "w:shd")[0] : void 0;
@@ -17172,7 +17279,7 @@ async function executeTable(opIn, ast, guide) {
17172
17279
  }
17173
17280
  }
17174
17281
  }
17175
- warnIfLowContrast(warnings, warnedContrastPairs, cellFillOf2(tc), normColor, `cell (${rIdx + 1},${cIdx + 1}) of table '${tableNode.id}'`);
17282
+ if (!autoColor) warnIfLowContrast(warnings, warnedContrastPairs, cellFillOf2(tc), normColor, `cell (${rIdx + 1},${cIdx + 1}) of table '${tableNode.id}'`);
17176
17283
  };
17177
17284
  const tRowSrc = rowIndexAlias(raw, op);
17178
17285
  const tColSrc = colIndexAlias(raw, op);
@@ -18355,6 +18462,8 @@ function generateEmbeddedWorkbook(seriesList, categories, scatterSeriesXValues)
18355
18462
  var DEFAULT_SERIES_PALETTE = ["0066CC", "10B981", "F59E0B", "EF4444", "8B5CF6", "06B6D4", "EC4899", "84CC16"];
18356
18463
  function defaultSeriesColor(color, seriesIdx) {
18357
18464
  if (color) {
18465
+ const named = normalizeColour(color);
18466
+ if (named !== void 0) return named;
18358
18467
  const raw = color.replace(/^#/, "");
18359
18468
  if (isShorthandHex(raw)) return expandShorthandHex(raw);
18360
18469
  return raw;
@@ -19585,7 +19694,7 @@ function sameStrings(a, b) {
19585
19694
  }
19586
19695
  function normalizeHex(value) {
19587
19696
  if (typeof value !== "string") return void 0;
19588
- const raw = value.replace(/^#/, "").trim();
19697
+ const raw = (normalizeColour(value) ?? value.replace(/^#/, "")).trim();
19589
19698
  return raw.length > 0 ? raw.toUpperCase() : void 0;
19590
19699
  }
19591
19700
  function isNumericText(value) {
@@ -19828,7 +19937,7 @@ function patchSeriesList(xml, req, patches, scope) {
19828
19937
  const haveColor = normalizeHex(existing?.color);
19829
19938
  if (wantColor !== void 0 && wantColor !== haveColor) {
19830
19939
  const lineSeries = combo ? (patch.chartType ?? existing?.chartType) === "line" : baseLineSeries;
19831
- serXml = patchSeriesColor(serXml, patch.color.replace(/^#/, ""), lineSeries, scope);
19940
+ serXml = patchSeriesColor(serXml, normalizeColour(patch.color) ?? patch.color.replace(/^#/, ""), lineSeries, scope);
19832
19941
  }
19833
19942
  pieces.push(serXml);
19834
19943
  cursor = spans[i].end;
@@ -20337,7 +20446,7 @@ function withGuideSeriesColors(series, palette) {
20337
20446
  return series.map((s, i) => {
20338
20447
  const raw = typeof s?.color === "string" ? s.color.replace(/^#/, "") : "";
20339
20448
  const existing = isShorthandHex(raw) ? expandShorthandHex(raw) : raw;
20340
- if (/^[0-9A-Fa-f]{6}$/.test(existing)) return s;
20449
+ if (/^[0-9A-Fa-f]{6}$/.test(existing) || typeof s?.color === "string" && normalizeColour(s.color) !== void 0) return s;
20341
20450
  return { ...s, color: guidePaletteColor(palette, i, taken) };
20342
20451
  });
20343
20452
  }
@@ -21614,10 +21723,15 @@ var twipsOrPoints = import_zod2.z.union([
21614
21723
  import_zod2.z.number().finite(),
21615
21724
  import_zod2.z.string().regex(FONT_SIZE_PT_REGEX, 'must be a number of twips (20 per point) or a string such as "12pt"')
21616
21725
  ]).describe('Twips (20 per point, so 240 is 12 pt), or a string in points such as "12pt".');
21726
+ var MAX_FONT_HALF_POINTS = 3276;
21617
21727
  var fontSizeOrSzSchema = import_zod2.z.union([
21618
- import_zod2.z.number().finite().nonnegative(),
21619
- import_zod2.z.string().regex(FONT_SIZE_PT_REGEX, 'Font size string must match format like "12pt" or "10.5pt"')
21620
- ]);
21728
+ import_zod2.z.number().finite().nonnegative().max(MAX_FONT_HALF_POINTS, "font size must be at most 1638 pt (3276 half-points)"),
21729
+ import_zod2.z.string().regex(FONT_SIZE_PT_REGEX, 'Font size string must match format like "12pt" or "10.5pt"').refine((v) => (parseFontSize(v) ?? 0) <= MAX_FONT_HALF_POINTS, "font size must be at most 1638 pt (3276 half-points)")
21730
+ ]).describe('Font size: a number of half-points (24 is 12 pt) or a string in points such as "12pt"; at most 1638 pt.');
21731
+ var borderSizeSchema = import_zod2.z.number("border width is a whole number of eighths of a point, e.g. 8 for 1 pt").int("border width is a whole number of eighths of a point, e.g. 8 for 1 pt").min(BORDER_WIDTH_MIN, "border width must be at least 1 (an eighth of a point)").max(BORDER_WIDTH_MAX, "border width must be at most 96 (12 pt)").describe("Border width in eighths of a point (8 is 1 pt), a whole number from 1 to 96.");
21732
+ function colourField(options = {}, max = 32) {
21733
+ return import_zod2.z.string().max(max).regex(new RegExp(colourPattern(options)), `color must be ${colourForms(options)}`).describe(colourDescription(options));
21734
+ }
21621
21735
  var styleProperties = import_zod2.z.object({
21622
21736
  bold: import_zod2.z.boolean().optional(),
21623
21737
  italic: import_zod2.z.boolean().optional(),
@@ -21626,11 +21740,11 @@ var styleProperties = import_zod2.z.object({
21626
21740
  doubleStrikethrough: import_zod2.z.boolean().optional(),
21627
21741
  vertAlign: import_zod2.z.enum(["superscript", "subscript", "baseline"]).optional(),
21628
21742
  alignment: import_zod2.z.enum(["left", "center", "right", "both", "justify"]).optional(),
21629
- color: import_zod2.z.string().max(32).optional(),
21743
+ color: colourField({ allowAuto: true }).optional(),
21630
21744
  fontSize: fontSizeOrSzSchema.optional(),
21631
21745
  sz: fontSizeOrSzSchema.optional(),
21632
21746
  fontFamily: import_zod2.z.string().max(128).optional(),
21633
- highlight: import_zod2.z.string().max(32).optional(),
21747
+ highlight: import_zod2.z.enum(HIGHLIGHT_COLOURS).optional(),
21634
21748
  lineSpacing: import_zod2.z.number().optional(),
21635
21749
  spacingBefore: twipsOrPoints.optional(),
21636
21750
  spacingAfter: twipsOrPoints.optional(),
@@ -21648,7 +21762,7 @@ var styleProperties = import_zod2.z.object({
21648
21762
  var insertChartSeries = import_zod2.z.object({
21649
21763
  name: import_zod2.z.string().max(500),
21650
21764
  values: import_zod2.z.array(import_zod2.z.number()).max(1e3),
21651
- color: import_zod2.z.string().max(16).optional()
21765
+ color: import_zod2.z.string().max(16).describe("Series colour: 6 hex digits, optionally with #, or one of: red, green, blue, black, white, yellow, orange, gray, grey, purple. Any other value is replaced by the style guide palette colour when there is one.").optional()
21652
21766
  }).catchall(import_zod2.z.unknown());
21653
21767
  var insertWrapMode = import_zod2.z.enum(["inline", "square", "tight", "through", "topAndBottom", "behindText", "inFrontOfText"]);
21654
21768
  var tableCellPadding = import_zod2.z.object({
@@ -21685,7 +21799,7 @@ var insertOp = import_zod2.z.object({
21685
21799
  headers: import_zod2.z.array(import_zod2.z.string().max(1e4)).max(100).optional(),
21686
21800
  rows: import_zod2.z.array(import_zod2.z.array(import_zod2.z.string().max(1e4)).max(100)).max(1e3).optional(),
21687
21801
  cellPadding: import_zod2.z.union([import_zod2.z.number().int().nonnegative(), tableCellPadding]).optional(),
21688
- color: import_zod2.z.string().max(16).optional(),
21802
+ color: colourField({ allowAuto: true }).optional(),
21689
21803
  thickness: import_zod2.z.number().optional(),
21690
21804
  lineStyle: import_zod2.z.string().max(32).optional(),
21691
21805
  spacingBefore: twipsOrPoints.optional(),
@@ -21772,11 +21886,11 @@ var styleOp = import_zod2.z.object({
21772
21886
  doubleStrikethrough: import_zod2.z.boolean().optional(),
21773
21887
  vertAlign: import_zod2.z.enum(["superscript", "subscript", "baseline"]).optional(),
21774
21888
  alignment: import_zod2.z.enum(["left", "center", "right", "both", "justify"]).optional(),
21775
- color: import_zod2.z.string().max(32).optional(),
21889
+ color: colourField({ allowAuto: true }).optional(),
21776
21890
  fontSize: fontSizeOrSzSchema.optional(),
21777
21891
  sz: fontSizeOrSzSchema.optional(),
21778
21892
  fontFamily: import_zod2.z.string().max(128).optional(),
21779
- highlight: import_zod2.z.string().max(32).optional(),
21893
+ highlight: import_zod2.z.enum(HIGHLIGHT_COLOURS).optional(),
21780
21894
  lineSpacing: import_zod2.z.number().optional(),
21781
21895
  spacingBefore: twipsOrPoints.optional(),
21782
21896
  spacingAfter: twipsOrPoints.optional(),
@@ -21853,20 +21967,20 @@ var tableOp = import_zod2.z.object({
21853
21967
  cells: import_zod2.z.array(import_zod2.z.string().max(1e5)).max(100).describe("addRow: the text of the new cells, one entry per column from the first (also accepted inside params).").optional(),
21854
21968
  afterIndex: import_zod2.z.number().int().describe("addRow: insert after this row number; addColumn: after this column number (counting from 1; default the last).").optional(),
21855
21969
  index: import_zod2.z.number().int().describe("deleteRow/deleteColumn: the row or column number to remove (counting from 1).").optional(),
21856
- fill: import_zod2.z.string().max(16).optional(),
21857
- headerFill: import_zod2.z.string().max(16).optional(),
21970
+ fill: colourField({}, 32).optional(),
21971
+ headerFill: colourField({}, 32).optional(),
21858
21972
  zebra: import_zod2.z.boolean().optional(),
21859
21973
  // Explicit header/single-cell text color for setShading/setCellShading
21860
21974
  // (flat or params-nested; wins over the guide per the precedence invariant).
21861
- textColor: import_zod2.z.string().max(16).optional(),
21975
+ textColor: colourField({}, 32).optional(),
21862
21976
  widths: import_zod2.z.array(import_zod2.z.number()).max(100).optional(),
21863
21977
  columnWidths: import_zod2.z.array(import_zod2.z.number()).max(100).optional(),
21864
21978
  width: import_zod2.z.number().int().positive().optional(),
21865
21979
  height: import_zod2.z.number().int().positive().optional(),
21866
21980
  rule: import_zod2.z.enum(["atLeast", "exact", "auto"]).optional(),
21867
21981
  border: import_zod2.z.string().max(32).optional(),
21868
- color: import_zod2.z.string().max(16).optional(),
21869
- sz: fontSizeOrSzSchema.optional(),
21982
+ color: colourField({ allowAuto: true }).optional(),
21983
+ sz: borderSizeSchema.optional(),
21870
21984
  vAlign: import_zod2.z.string().max(16).optional(),
21871
21985
  align: tableAlignmentSchema.optional(),
21872
21986
  alignment: tableAlignmentSchema.optional(),
@@ -21986,7 +22100,7 @@ function chartHoleSizeSchemaRemedy(detail) {
21986
22100
  var chartSeries = import_zod2.z.object({
21987
22101
  name: import_zod2.z.string().max(500),
21988
22102
  values: import_zod2.z.array(import_zod2.z.number()).max(1e3),
21989
- color: import_zod2.z.string().max(16).optional(),
22103
+ color: import_zod2.z.string().max(16).describe("Series colour: 6 hex digits, optionally with #, or one of: red, green, blue, black, white, yellow, orange, gray, grey, purple. Any other value is replaced by the style guide palette colour when there is one.").optional(),
21990
22104
  chartType: import_zod2.z.enum(["column", "line"]).optional(),
21991
22105
  axis: import_zod2.z.enum(["primary", "secondary"]).optional()
21992
22106
  }).catchall(import_zod2.z.unknown());
@@ -22112,7 +22226,7 @@ var watermarkSizeSchema = import_zod2.z.union([import_zod2.z.number().finite().n
22112
22226
  var watermarkOp = import_zod2.z.object({
22113
22227
  action: import_zod2.z.literal("watermark"),
22114
22228
  text: import_zod2.z.string().max(500).optional(),
22115
- color: import_zod2.z.string().max(16).optional(),
22229
+ color: import_zod2.z.string().max(16).describe("Watermark colour: 6 hex digits, optionally with #, or one of: red, green, blue, black, white, yellow, orange, gray, grey, purple. Any other value falls back to D1D5DB.").optional(),
22116
22230
  fontSize: watermarkSizeSchema.optional(),
22117
22231
  sz: watermarkSizeSchema.optional(),
22118
22232
  opacity: import_zod2.z.number().min(0).max(1).optional(),
@@ -22122,9 +22236,9 @@ var watermarkOp = import_zod2.z.object({
22122
22236
  var pageBorderOp = import_zod2.z.object({
22123
22237
  action: import_zod2.z.literal("pageBorder"),
22124
22238
  style: import_zod2.z.string().max(32).optional(),
22125
- sz: fontSizeOrSzSchema.optional(),
22239
+ sz: borderSizeSchema.optional(),
22126
22240
  fontSize: fontSizeOrSzSchema.optional(),
22127
- color: import_zod2.z.string().max(16).optional(),
22241
+ color: colourField({ allowAuto: true }).optional(),
22128
22242
  marginOffset: import_zod2.z.enum(["page", "text"]).optional(),
22129
22243
  sectionIndex: sectionNumber.optional(),
22130
22244
  expectedVersion
@@ -22462,6 +22576,32 @@ var KEY_WORDS = {
22462
22576
  };
22463
22577
  var MAX_KEYS_LISTED = 60;
22464
22578
  var cachedShapes;
22579
+ function subOperationValues(shape) {
22580
+ for (const key of ["operation", "op"]) {
22581
+ let field = shape[key];
22582
+ for (let depth = 0; depth < 4 && field?.def; depth++) {
22583
+ const values = field.def.entries;
22584
+ if (values && typeof values === "object") return Object.keys(values);
22585
+ field = field.def["innerType"] ?? field.def["in"] ?? field.def["out"];
22586
+ }
22587
+ }
22588
+ return [];
22589
+ }
22590
+ var ACTION_VERBS = ["set", "add", "insert", "update", "edit", "create", "apply", "remove", "delete", "change", "make"];
22591
+ function verbPrefixCandidates(given, actions) {
22592
+ const key = squash(given);
22593
+ for (const verb of ACTION_VERBS) {
22594
+ if (!key.startsWith(verb) || key.length - verb.length < 3) continue;
22595
+ const rest = key.slice(verb.length);
22596
+ const target = actions.find((a) => squash(a) === rest);
22597
+ if (!target) continue;
22598
+ const out = [target];
22599
+ const verbAction = actions.find((a) => squash(a) === verb) ?? ACTION_WORDS[verb];
22600
+ if (verbAction && actions.includes(verbAction) && verbAction !== target) out.push(verbAction);
22601
+ return out;
22602
+ }
22603
+ return [];
22604
+ }
22465
22605
  function actionShapes() {
22466
22606
  if (cachedShapes) return cachedShapes;
22467
22607
  const shapes = [];
@@ -22469,7 +22609,10 @@ function actionShapes() {
22469
22609
  for (const option of options) {
22470
22610
  const literal = option.shape["action"]?.def?.values?.[0];
22471
22611
  if (typeof literal !== "string") continue;
22472
- shapes.push({ action: literal, keys: Object.keys(option.shape).filter((k) => k !== "action") });
22612
+ const shape = { action: literal, keys: Object.keys(option.shape).filter((k) => k !== "action") };
22613
+ const sub = subOperationValues(option.shape);
22614
+ if (sub.length > 0) shape.subOperations = sub;
22615
+ shapes.push(shape);
22473
22616
  }
22474
22617
  cachedShapes = shapes;
22475
22618
  return shapes;
@@ -22514,9 +22657,17 @@ function hintFor(operations, issue) {
22514
22657
  const given = op["action"];
22515
22658
  if (typeof given !== "string") return void 0;
22516
22659
  const actions = shapes.map((s) => s.action);
22517
- const near = closestNames(given, actions, ACTION_WORDS);
22660
+ let near = closestNames(given, actions, ACTION_WORDS);
22661
+ if (near.length === 0) near = verbPrefixCandidates(given, actions);
22518
22662
  const parts = [`${label}: "${given}" is not an action.`];
22519
- if (near.length > 0) parts.push(`Did you mean ${quoted(near)}?`);
22663
+ if (near.length > 0) {
22664
+ parts.push(`Did you mean ${near.map((n) => `"${n}"`).join(" or ")}?`);
22665
+ const candidate = shapes.find((s) => s.action === near[0]);
22666
+ if (candidate) {
22667
+ parts.push(`Keys of "${candidate.action}": ${candidate.keys.slice(0, MAX_KEYS_LISTED).join(", ")}${candidate.keys.length > MAX_KEYS_LISTED ? ", ..." : ""}.`);
22668
+ if (candidate.subOperations) parts.push(`Operations of "${candidate.action}": ${candidate.subOperations.join(", ")}.`);
22669
+ }
22670
+ }
22520
22671
  parts.push(`Actions: ${actions.join(", ")}.`);
22521
22672
  return parts.join(" ");
22522
22673
  }
@@ -23342,16 +23493,14 @@ function applyRunProps(doc, run, props) {
23342
23493
  vaEl.setAttribute("w:val", props.vertAlign);
23343
23494
  }
23344
23495
  }
23345
- if (props.color !== void 0 && props.color !== null) {
23496
+ if (props.color !== void 0 && props.color !== null && String(props.color).trim() !== "") {
23497
+ const hex = requireColour("color", props.color, { allowAuto: true });
23346
23498
  let colorEl = rPr.getElementsByTagName("w:color")[0];
23347
23499
  if (!colorEl) {
23348
23500
  colorEl = createWordElement(doc, "w:color");
23349
23501
  rPr.appendChild(colorEl);
23350
23502
  }
23351
- const hex = String(props.color).replace(/^#/, "").trim();
23352
- if (hex) {
23353
- colorEl.setAttribute("w:val", hex);
23354
- }
23503
+ colorEl.setAttribute("w:val", hex);
23355
23504
  }
23356
23505
  const rawSz = props.sz !== void 0 ? props.sz : props.fontSize;
23357
23506
  if (rawSz !== void 0 && rawSz !== null) {
@@ -23867,9 +24016,9 @@ function executeStyle(op, ast) {
23867
24016
  pBdr.appendChild(el);
23868
24017
  }
23869
24018
  el.setAttribute("w:val", config.style || "single");
23870
- el.setAttribute("w:sz", String(config.sz ?? 12));
24019
+ el.setAttribute("w:sz", String(requireBorderSize(`${side} border sz`, config.sz, 12)));
23871
24020
  el.setAttribute("w:space", "1");
23872
- el.setAttribute("w:color", (config.color || "CBD5E1").replace(/^#/, ""));
24021
+ el.setAttribute("w:color", config.color ? requireColour("color", config.color, { allowAuto: true }) : "CBD5E1");
23873
24022
  };
23874
24023
  if (props.borderBox) {
23875
24024
  applyBdr("top", props.borderBox);
@@ -26868,7 +27017,7 @@ async function executeInsert(op, ast, parser2, guide, paraIdCache) {
26868
27017
  const pBdr = createWordElement(doc, "w:pBdr");
26869
27018
  const bottom = createWordElement(doc, "w:bottom");
26870
27019
  const lineStyle = op.lineStyle || "single";
26871
- const color = (op.color || "CBD5E1").replace(/^#/, "");
27020
+ const color = op.color ? requireColour("color", op.color, { allowAuto: true }) : "CBD5E1";
26872
27021
  const thickness = op.thickness !== void 0 ? String(op.thickness) : "12";
26873
27022
  bottom.setAttribute("w:val", lineStyle);
26874
27023
  bottom.setAttribute("w:sz", thickness);
@@ -31616,7 +31765,7 @@ function executeEndnote(op, ast, parser2) {
31616
31765
  function executeWatermark(op, ast, parser2) {
31617
31766
  const rawText = (op.text || "CONFIDENTIAL").trim();
31618
31767
  const text2 = rawText.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
31619
- const color = /^[0-9A-Fa-f]{6}$/.test((op.color || "").replace(/^#/, "")) ? op.color.replace(/^#/, "") : "D1D5DB";
31768
+ const color = normalizeColour(op.color) ?? "D1D5DB";
31620
31769
  const fontSize = op.fontSize || 60;
31621
31770
  const rotation = op.rotation !== void 0 ? op.rotation : 315;
31622
31771
  const watermarkXml = `<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -31734,8 +31883,8 @@ function executePageBorder(op, ast, parser2) {
31734
31883
  const pgBorders = upsertSectPrChild(sectPr, "w:pgBorders");
31735
31884
  pgBorders.setAttribute("w:offsetFrom", op.marginOffset || "page");
31736
31885
  const style = op.style || "single";
31737
- const sz = String(op.sz || 12);
31738
- const color = (op.color || "CBD5E1").replace(/^#/, "");
31886
+ const sz = String(requireBorderSize("pageBorder sz", op.sz, 12));
31887
+ const color = op.color ? requireColour("color", op.color, { allowAuto: true }) : "CBD5E1";
31739
31888
  ["top", "left", "bottom", "right"].forEach((side) => {
31740
31889
  let sideEl = pgBorders.getElementsByTagName(`w:${side}`)[0];
31741
31890
  if (!sideEl) {
@@ -32257,6 +32406,7 @@ async function executeMetadata(op, ast, parser2) {
32257
32406
  }
32258
32407
 
32259
32408
  // ../engine/src/operation-engine.ts
32409
+ var COMPOSE_ID_IN_MESSAGE = /\b(md\d*_\d{4,})\b/;
32260
32410
  var ENGINE_MAX_OPS = 500;
32261
32411
  var ENGINE_MAX_BYTES = 200 * 1024 * 1024;
32262
32412
  var EngineQuotaError = class extends Error {
@@ -33056,6 +33206,10 @@ var OperationEngine = class _OperationEngine {
33056
33206
  }
33057
33207
  }
33058
33208
  generateHint(op, errorMessage) {
33209
+ const missing = errorMessage.includes("not found") && !errorMessage.includes("not found in node") ? COMPOSE_ID_IN_MESSAGE.exec(errorMessage) : null;
33210
+ if (missing) {
33211
+ return `"${missing[1]}" looks like an id from compose_document. It may be an id of a session that was closed and reopened, a mistyped id, or a node that was deleted. If the document was reopened, use the paragraph id from persistentIds in the compose response, find the text with search_content, or re-read ids with inspect_document. Table ids are renumbered by position (t_001...) after reopen.`;
33212
+ }
33059
33213
  if (errorMessage.includes("not found") && !errorMessage.includes("not found in node")) {
33060
33214
  return `Check if the target node ID is correct. Node ids are session-scoped and go stale after reopen/edit; re-read ids via inspect_document; locate current text with search_content, or append without an id via anchor:end.`;
33061
33215
  }
@@ -34679,12 +34833,12 @@ $$` : `[Equation]
34679
34833
  // ../engine/src/toon.ts
34680
34834
  var TOON_DEFAULT_FIDELITY = "standard";
34681
34835
  function isToonFidelity(value) {
34682
- return value === "compact" || value === "standard" || value === "full";
34836
+ return value === "outline" || value === "compact" || value === "standard" || value === "full";
34683
34837
  }
34684
34838
  function assertToonFidelity(value) {
34685
34839
  if (!isToonFidelity(value)) {
34686
34840
  throw new Error(
34687
- `Unknown TOON fidelity '${String(value)}'. Supported: compact|standard|full. Remedy: call toon({ fidelity: 'standard' }) for the default enriched outline, 'compact' for the minimal outline, or 'full' with a sectionId for rPr/pPr detail.`
34841
+ `Unknown TOON fidelity '${String(value)}'. Supported: outline|compact|standard|full. Remedy: call toon({ fidelity: 'standard' }) for the default enriched outline, 'compact' for the minimal table, 'outline' for headings only, or 'full' with a sectionId for rPr/pPr detail.`
34688
34842
  );
34689
34843
  }
34690
34844
  }
@@ -35196,6 +35350,15 @@ function resolveSectionScope(sections, sectionId) {
35196
35350
  if (sectionId.length >= MIN_NODE_ID_PREFIX_LENGTH) return pick((id) => id.startsWith(sectionId));
35197
35351
  return [];
35198
35352
  }
35353
+ var OUTLINE_HEADERS = ["ID", "L", "Style", "Text", "Section"];
35354
+ var OUTLINE_DEFAULT_TEXT_LENGTH = 80;
35355
+ function escapeOutlineCell(value) {
35356
+ return value.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
35357
+ }
35358
+ function escapeOutlinePathPart(value) {
35359
+ return escapeOutlineCell(value).replace(/>/g, "\\>");
35360
+ }
35361
+ var OUTLINE_PATH_PART_LENGTH = 60;
35199
35362
  var ToonGenerator = class _ToonGenerator {
35200
35363
  cache = /* @__PURE__ */ new Map();
35201
35364
  checksumCache = /* @__PURE__ */ new Map();
@@ -35313,7 +35476,10 @@ var ToonGenerator = class _ToonGenerator {
35313
35476
  }
35314
35477
  const sectionPaths = this.buildSectionPaths(ast.sections);
35315
35478
  const maxLen = options?.maxTextLength;
35316
- for (const section of sectionsToProcess) {
35479
+ if (fidelity === "outline") {
35480
+ rows.push(...this.buildOutlineRows(ast, sectionsToProcess, maxLen ?? OUTLINE_DEFAULT_TEXT_LENGTH));
35481
+ }
35482
+ for (const section of fidelity === "outline" ? [] : sectionsToProcess) {
35317
35483
  const sectionPath = sectionPaths.get(section.id) || "Root";
35318
35484
  const sectionHeader = section.heading?.text || "";
35319
35485
  for (const node of section.nodes) {
@@ -35353,7 +35519,7 @@ var ToonGenerator = class _ToonGenerator {
35353
35519
  rows.push(row);
35354
35520
  }
35355
35521
  }
35356
- const headers = fidelity === "full" ? ["ID", "V", "Path", "Header", "Style", "Text", "Flags", "Detail"] : ["ID", "V", "Path", "Header", "Style", "Text", "Flags"];
35522
+ const headers = fidelity === "outline" ? [...OUTLINE_HEADERS] : fidelity === "full" ? ["ID", "V", "Path", "Header", "Style", "Text", "Flags", "Detail"] : ["ID", "V", "Path", "Header", "Style", "Text", "Flags"];
35357
35523
  const table = {
35358
35524
  headers,
35359
35525
  rows,
@@ -35412,6 +35578,20 @@ var ToonGenerator = class _ToonGenerator {
35412
35578
  if (fidelity !== "compact") {
35413
35579
  combined += `${fingerprintFmt(n)};`;
35414
35580
  }
35581
+ if (fidelity === "outline") {
35582
+ combined += `os${n.type === "paragraph" ? ownParagraphStyleId(n.xml) ?? "-" : "-"};`;
35583
+ }
35584
+ }
35585
+ }
35586
+ if (fidelity === "outline") {
35587
+ combined += "oh:";
35588
+ for (const sec of ast.sections) {
35589
+ for (const n of sec.nodes) {
35590
+ const h = headingOfNode(n);
35591
+ if (!h) continue;
35592
+ const t = (n.metadata.text ?? "").trim();
35593
+ combined += `${n.id}:${h.level}:${h.style}:${t.length}:${t.slice(0, OUTLINE_PATH_PART_LENGTH + 4)};`;
35594
+ }
35415
35595
  }
35416
35596
  }
35417
35597
  try {
@@ -35443,6 +35623,42 @@ var ToonGenerator = class _ToonGenerator {
35443
35623
  }
35444
35624
  return combined;
35445
35625
  }
35626
+ /**
35627
+ * The `outline` tier: one row per heading, in document order. A heading is a top-level body paragraph whose own
35628
+ * `w:pStyle` is a heading style (`headingLevelOfStyleId`), the rule the structural summary uses. Section is the
35629
+ * chain of headings above it, nested by level, and is built over the whole document so a `sectionId` scope still
35630
+ * shows the real parents. `scope` limits which headings are listed.
35631
+ */
35632
+ buildOutlineRows(ast, scope, maxLen) {
35633
+ const inScope = /* @__PURE__ */ new Set();
35634
+ for (const sec of scope) for (const n of sec.nodes) inScope.add(n.id);
35635
+ const cut = (text2, max) => max > 0 && text2.length > max ? `${text2.substring(0, max)}...` : text2;
35636
+ const stack = [];
35637
+ const rows = [];
35638
+ for (const sec of ast.sections) {
35639
+ for (const node of sec.nodes) {
35640
+ const heading = headingOfNode(node);
35641
+ if (!heading) continue;
35642
+ const { style, level } = heading;
35643
+ while (stack.length > 0 && stack[stack.length - 1].level >= level) stack.pop();
35644
+ const text2 = node.metadata.text.trim() || this.generatePlaceholderText(node);
35645
+ if (inScope.has(node.id)) {
35646
+ rows.push({
35647
+ id: node.id,
35648
+ version: node.version,
35649
+ path: stack.map((h) => escapeOutlinePathPart(cut(h.text, OUTLINE_PATH_PART_LENGTH))).join(" > "),
35650
+ header: "",
35651
+ style,
35652
+ text: cut(text2, maxLen),
35653
+ flags: "",
35654
+ level
35655
+ });
35656
+ }
35657
+ stack.push({ level, text: text2 });
35658
+ }
35659
+ }
35660
+ return rows;
35661
+ }
35446
35662
  buildSectionPaths(sections) {
35447
35663
  const paths = /* @__PURE__ */ new Map();
35448
35664
  const hierarchy = [];
@@ -35579,7 +35795,58 @@ var ToonGenerator = class _ToonGenerator {
35579
35795
  if (node.metadata.listLevel !== void 0) flags.push(`[L${node.metadata.listLevel + 1}]`);
35580
35796
  return flags.join(" ");
35581
35797
  }
35798
+ /**
35799
+ * One rendered row, wrapped to `rawWidth` when it is a usable width. Text is the cell at `textPos`; only it may
35800
+ * wrap: the prefix (all columns before Text, trailing separator included) and the suffix (the columns after it,
35801
+ * leading separator included) stay whole. The suffix rides on the LAST line so folding continuations back recovers
35802
+ * the default line byte-identically; a row that fits emits exactly the default line.
35803
+ */
35804
+ renderRowLine(rowParts, textPos, sep5, textVal, rawWidth) {
35805
+ const width = typeof rawWidth === "number" && Number.isFinite(rawWidth) && rawWidth >= 1 ? Math.floor(rawWidth) : void 0;
35806
+ if (width === void 0) return `${rowParts.join(sep5)}
35807
+ `;
35808
+ const prefix = `${rowParts.slice(0, textPos).join(sep5)}${sep5}`;
35809
+ const tail = rowParts.slice(textPos + 1);
35810
+ const suffix = tail.length > 0 ? `${sep5}${tail.join(sep5)}` : "";
35811
+ const prefixLen = displayLen(prefix);
35812
+ const budget = width - prefixLen - displayLen(suffix);
35813
+ if (budget <= 0) return `${rowParts.join(sep5)}
35814
+ `;
35815
+ const segments = wrapTextCell(textVal, budget);
35816
+ if (segments.length <= 1) return `${rowParts.join(sep5)}
35817
+ `;
35818
+ const indent = " ".repeat(prefixLen);
35819
+ let out = `${prefix}${segments[0]}
35820
+ `;
35821
+ for (let k = 1; k < segments.length - 1; k++) out += `${indent}${segments[k]}
35822
+ `;
35823
+ out += `${indent}${segments[segments.length - 1]}${suffix}
35824
+ `;
35825
+ return out;
35826
+ }
35827
+ renderOutlineText(table, options) {
35828
+ const compact = options?.compact ?? false;
35829
+ const sep5 = compact ? "|" : " | ";
35830
+ let output = `> fidelity: ${table.fidelity || "lossy"} tier: outline
35831
+ `;
35832
+ if (options?.includeHeaderRow !== false) {
35833
+ output += `${OUTLINE_HEADERS.join(sep5)}
35834
+ `;
35835
+ if (!compact) output += `${OUTLINE_HEADERS.map(() => "---").join(" | ")}
35836
+ `;
35837
+ }
35838
+ for (const row of table.rows) {
35839
+ let text2 = row.text;
35840
+ if (options?.maxTextLength && options.maxTextLength > 0 && text2.length > options.maxTextLength) {
35841
+ text2 = `${text2.substring(0, options.maxTextLength)}...`;
35842
+ }
35843
+ const cells = [row.id, String(row.level ?? ""), escapeOutlineCell(row.style), escapeOutlineCell(text2), row.path];
35844
+ output += this.renderRowLine(cells, 3, sep5, cells[3], options?.width);
35845
+ }
35846
+ return output;
35847
+ }
35582
35848
  renderToonText(table, options) {
35849
+ if (table.tier === "outline") return this.renderOutlineText(table, options);
35583
35850
  const omits = new Set(options?.omitColumns ?? []);
35584
35851
  const compact = options?.compact ?? false;
35585
35852
  const tier = table.tier ?? "compact";
@@ -35627,40 +35894,8 @@ var ToonGenerator = class _ToonGenerator {
35627
35894
  rowParts.push(row.detail ?? "");
35628
35895
  }
35629
35896
  }
35630
- const sep5 = compact ? "|" : " | ";
35631
- const rawWidth = options?.width;
35632
- const width = typeof rawWidth === "number" && Number.isFinite(rawWidth) && rawWidth >= 1 ? Math.floor(rawWidth) : void 0;
35633
- if (width === void 0) {
35634
- output += `${rowParts.join(sep5)}
35635
- `;
35636
- continue;
35637
- }
35638
35897
  const textPos = 2 + (omits.has("path") ? 0 : 1) + (omits.has("header") ? 0 : 1) + (omits.has("style") ? 0 : 1);
35639
- const prefix = `${rowParts.slice(0, textPos).join(sep5)}${sep5}`;
35640
- const tail = rowParts.slice(textPos + 1);
35641
- const suffix = tail.length > 0 ? `${sep5}${tail.join(sep5)}` : "";
35642
- const prefixLen = displayLen(prefix);
35643
- const budget = width - prefixLen - displayLen(suffix);
35644
- if (budget <= 0) {
35645
- output += `${rowParts.join(sep5)}
35646
- `;
35647
- continue;
35648
- }
35649
- const segments = wrapTextCell(textVal, budget);
35650
- if (segments.length <= 1) {
35651
- output += `${rowParts.join(sep5)}
35652
- `;
35653
- continue;
35654
- }
35655
- const indent = " ".repeat(prefixLen);
35656
- output += `${prefix}${segments[0]}
35657
- `;
35658
- for (let k = 1; k < segments.length - 1; k++) {
35659
- output += `${indent}${segments[k]}
35660
- `;
35661
- }
35662
- output += `${indent}${segments[segments.length - 1]}${suffix}
35663
- `;
35898
+ output += this.renderRowLine(rowParts, textPos, compact ? "|" : " | ", textVal, options?.width);
35664
35899
  }
35665
35900
  return output;
35666
35901
  }
@@ -38202,9 +38437,9 @@ function escapeHtml(str) {
38202
38437
 
38203
38438
  // ../engine/src/export/pdf.ts
38204
38439
  var import_child_process = require("child_process");
38205
- var fs9 = __toESM(require("fs"));
38440
+ var fs10 = __toESM(require("fs"));
38206
38441
  var fsp4 = __toESM(require("fs/promises"));
38207
- var path8 = __toESM(require("path"));
38442
+ var path9 = __toESM(require("path"));
38208
38443
  var import_url = require("url");
38209
38444
 
38210
38445
  // ../engine/src/export/preflight.ts
@@ -38545,6 +38780,136 @@ function runPreflightChecks(docx) {
38545
38780
  return { blockers, warnings };
38546
38781
  }
38547
38782
 
38783
+ // ../engine/src/visual/backend.ts
38784
+ var fs9 = __toESM(require("fs"));
38785
+ var path8 = __toESM(require("path"));
38786
+ var VISUAL_DEPENDENCY_REMEDY = "PDF render backend missing. Install LibreOffice and poppler-utils (pdftoppm): Linux: sudo apt install libreoffice poppler-utils; macOS: brew install --cask libreoffice && brew install poppler; Windows: winget install TheDocumentFoundation.LibreOffice, then install Poppler for Windows (for example scoop install poppler, or choco install poppler) and make sure pdftoppm.exe is on PATH. Alternatively inspect the XML/DOM without images (telemetry-only mode).";
38787
+ function createDependencyError(missing) {
38788
+ const which = missing.length > 0 ? missing.join(", ") : "soffice, pdftoppm";
38789
+ return Object.assign(new Error(`E_DEPENDENCY: headless visual backend unavailable (missing: ${which})`), {
38790
+ code: "E_DEPENDENCY",
38791
+ remedy: VISUAL_DEPENDENCY_REMEDY
38792
+ });
38793
+ }
38794
+ function windowsExtensions(pathExt) {
38795
+ const requested = (pathExt ?? ".EXE;.COM").split(";").map((e) => e.trim().toLowerCase()).filter((e) => e === ".exe" || e === ".com");
38796
+ const ordered = [".exe", ".com"].filter((e) => requested.includes(e));
38797
+ return ordered.length > 0 ? ordered : [".exe", ".com"];
38798
+ }
38799
+ function isExecutableFile(candidate) {
38800
+ try {
38801
+ fs9.accessSync(candidate, fs9.constants.X_OK);
38802
+ return fs9.statSync(candidate).isFile();
38803
+ } catch {
38804
+ return false;
38805
+ }
38806
+ }
38807
+ function candidateNames(name2, platform, pathExt) {
38808
+ if (platform !== "win32") return [name2];
38809
+ const lower = name2.toLowerCase();
38810
+ if (lower.endsWith(".exe") || lower.endsWith(".com")) return [name2];
38811
+ return windowsExtensions(pathExt).map((ext) => name2 + ext);
38812
+ }
38813
+ function installFolders(name2, platform = process.platform, env = process.env) {
38814
+ const bare = name2.toLowerCase().replace(/\.(exe|com)$/, "");
38815
+ const isOffice = bare === "soffice" || bare === "libreoffice";
38816
+ const isPoppler = bare === "pdftoppm";
38817
+ const dirs = [];
38818
+ if (platform === "win32") {
38819
+ const programFiles = env.ProgramFiles ?? "C:\\Program Files";
38820
+ const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
38821
+ const localAppData = env.LOCALAPPDATA;
38822
+ const userProfile = env.USERPROFILE;
38823
+ if (isOffice) {
38824
+ dirs.push(path8.join(programFiles, "LibreOffice", "program"));
38825
+ dirs.push(path8.join(programFilesX86, "LibreOffice", "program"));
38826
+ if (localAppData) dirs.push(path8.join(localAppData, "Programs", "LibreOffice", "program"));
38827
+ }
38828
+ if (isPoppler) {
38829
+ if (userProfile) dirs.push(path8.join(userProfile, "scoop", "shims"));
38830
+ dirs.push(path8.join(env.ProgramData ?? "C:\\ProgramData", "chocolatey", "bin"));
38831
+ if (localAppData) dirs.push(path8.join(localAppData, "Microsoft", "WinGet", "Links"));
38832
+ }
38833
+ } else if (platform === "darwin") {
38834
+ const home = env.HOME;
38835
+ if (isOffice) {
38836
+ dirs.push("/Applications/LibreOffice.app/Contents/MacOS");
38837
+ if (home) dirs.push(path8.join(home, "Applications", "LibreOffice.app", "Contents", "MacOS"));
38838
+ }
38839
+ dirs.push("/opt/homebrew/bin", "/usr/local/bin", "/opt/local/bin");
38840
+ }
38841
+ return dirs;
38842
+ }
38843
+ function findOnPath(name2, pathEnv, options = {}) {
38844
+ try {
38845
+ if (!name2 || name2.trim() === "") return null;
38846
+ const trimmed = name2.trim();
38847
+ const platform = options.platform ?? process.platform;
38848
+ const env = options.env ?? process.env;
38849
+ const pathExt = options.pathExt ?? (platform === "win32" ? env.PATHEXT : void 0);
38850
+ const names = candidateNames(trimmed, platform, pathExt);
38851
+ if (trimmed.includes("/") || trimmed.includes("\\")) {
38852
+ for (const n of names) {
38853
+ const abs = path8.resolve(n);
38854
+ if (isExecutableFile(abs)) return abs;
38855
+ }
38856
+ return null;
38857
+ }
38858
+ const pathValue = pathEnv !== void 0 ? pathEnv : env.PATH ?? env.Path ?? "";
38859
+ const dirs = pathValue.split(path8.delimiter).map((d) => d.trim().replace(/^"(.*)"$/, "$1")).filter((d) => d.length > 0);
38860
+ for (const dir of dirs) {
38861
+ for (const n of names) {
38862
+ const candidate = path8.resolve(dir, n);
38863
+ if (isExecutableFile(candidate)) return candidate;
38864
+ }
38865
+ }
38866
+ const useFallback = options.fallbackDirs ?? (pathEnv === void 0 && env.ASTRA_VISUAL_NO_FALLBACK_DIRS !== "1");
38867
+ if (useFallback) {
38868
+ for (const dir of options.installDirs ?? installFolders(trimmed, platform, env)) {
38869
+ for (const n of names) {
38870
+ const candidate = path8.resolve(dir, n);
38871
+ if (isExecutableFile(candidate)) return candidate;
38872
+ }
38873
+ }
38874
+ }
38875
+ return null;
38876
+ } catch {
38877
+ return null;
38878
+ }
38879
+ }
38880
+ function resolveSoffice(override, pathEnv) {
38881
+ if (override && override.trim()) {
38882
+ const direct = findOnPath(override.trim(), pathEnv);
38883
+ if (direct) return direct;
38884
+ return null;
38885
+ }
38886
+ return findOnPath("soffice", pathEnv) ?? findOnPath("libreoffice", pathEnv);
38887
+ }
38888
+ function hasPdfBackend(override) {
38889
+ const pathEnv = override?.pathEnv;
38890
+ const sofficePath = resolveSoffice(override?.sofficeBin, pathEnv);
38891
+ let pdftoppmPath = null;
38892
+ if (override?.pdftoppmBin && override.pdftoppmBin.trim()) {
38893
+ pdftoppmPath = findOnPath(override.pdftoppmBin.trim(), pathEnv);
38894
+ } else {
38895
+ pdftoppmPath = findOnPath("pdftoppm", pathEnv);
38896
+ }
38897
+ const missing = [];
38898
+ if (!sofficePath) missing.push("soffice");
38899
+ if (!pdftoppmPath) missing.push("pdftoppm");
38900
+ if (missing.length === 0) {
38901
+ return { available: true, backend: "libreoffice", sofficePath, pdftoppmPath, missing: [] };
38902
+ }
38903
+ return { available: false, backend: "none", missing };
38904
+ }
38905
+ function assertPdfBackend(override) {
38906
+ const status = hasPdfBackend(override);
38907
+ if (!status.available) {
38908
+ throw createDependencyError(status.missing);
38909
+ }
38910
+ return { sofficePath: status.sofficePath, pdftoppmPath: status.pdftoppmPath };
38911
+ }
38912
+
38548
38913
  // ../engine/src/export/pdf.ts
38549
38914
  var DependencyError = class extends Error {
38550
38915
  code = "E_DEPENDENCY";
@@ -38590,19 +38955,15 @@ function getPdfBackendBinary() {
38590
38955
  if (cachedBackendBinary !== void 0) {
38591
38956
  return cachedBackendBinary;
38592
38957
  }
38593
- const candidates = ["libreoffice", "soffice"];
38594
- for (const bin of candidates) {
38595
- try {
38596
- (0, import_child_process.execSync)(`which ${bin}`, { stdio: "ignore" });
38597
- cachedBackendBinary = bin;
38598
- return bin;
38599
- } catch {
38600
- }
38958
+ const found = findOnPath("soffice") ?? findOnPath("libreoffice");
38959
+ if (found) {
38960
+ cachedBackendBinary = found;
38961
+ return found;
38601
38962
  }
38602
38963
  cachedBackendBinary = null;
38603
38964
  return null;
38604
38965
  }
38605
- function hasPdfBackend() {
38966
+ function hasPdfBackend2() {
38606
38967
  return getPdfBackendBinary() !== null;
38607
38968
  }
38608
38969
  async function convertDocxToPdf(docxBuffer, outPdfPath, options) {
@@ -38613,7 +38974,8 @@ async function convertDocxToPdf(docxBuffer, outPdfPath, options) {
38613
38974
  Debian/Ubuntu: sudo apt install libreoffice
38614
38975
  Fedora/RHEL: sudo dnf install libreoffice
38615
38976
  macOS: brew install --cask libreoffice
38616
- Windows: winget install TheDocumentFoundation.LibreOffice`
38977
+ Windows: winget install TheDocumentFoundation.LibreOffice
38978
+ ASTra also looks in the standard install folders (for example C:\\Program Files\\LibreOffice\\program and /Applications/LibreOffice.app/Contents/MacOS).`
38617
38979
  );
38618
38980
  }
38619
38981
  const preflight = runPreflightChecks(docxBuffer);
@@ -38675,7 +39037,7 @@ async function removeStagedDir(dir) {
38675
39037
  }
38676
39038
  async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, binary, stagedForensics) {
38677
39039
  const tmpDir = secureMkdtemp("astra-pdf-");
38678
- const tmpDocxPath = path8.join(tmpDir, `doc_${Date.now()}.docx`);
39040
+ const tmpDocxPath = path9.join(tmpDir, `doc_${Date.now()}.docx`);
38679
39041
  let succeeded = false;
38680
39042
  try {
38681
39043
  await fsp4.writeFile(tmpDocxPath, docxBuffer);
@@ -38687,7 +39049,7 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38687
39049
  await new Promise((resolve10, reject) => {
38688
39050
  const child = (0, import_child_process.spawn)(
38689
39051
  binary,
38690
- [`-env:UserInstallation=${(0, import_url.pathToFileURL)(path8.join(tmpDir, "lo_profile")).href}`, "--headless", "--convert-to", filterSpec, tmpDocxPath, "--outdir", tmpDir],
39052
+ [`-env:UserInstallation=${(0, import_url.pathToFileURL)(path9.join(tmpDir, "lo_profile")).href}`, "--headless", "--convert-to", filterSpec, tmpDocxPath, "--outdir", tmpDir],
38691
39053
  { stdio: ["ignore", "pipe", "pipe"] }
38692
39054
  );
38693
39055
  let stderr = "";
@@ -38723,19 +39085,19 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38723
39085
  if (!converted && lastError) {
38724
39086
  throw lastError;
38725
39087
  }
38726
- const expectedPdfName = path8.basename(tmpDocxPath).replace(/\.docx$/i, ".pdf");
38727
- const generatedPdfPath = path8.join(tmpDir, expectedPdfName);
38728
- if (!fs9.existsSync(generatedPdfPath)) {
39088
+ const expectedPdfName = path9.basename(tmpDocxPath).replace(/\.docx$/i, ".pdf");
39089
+ const generatedPdfPath = path9.join(tmpDir, expectedPdfName);
39090
+ if (!fs10.existsSync(generatedPdfPath)) {
38729
39091
  throw new Error(`PDF conversion failed: output file ${generatedPdfPath} was not created`);
38730
39092
  }
38731
- const targetDir = path8.dirname(path8.resolve(outPdfPath));
38732
- assertWriteTargetNoSymlink(path8.resolve(outPdfPath));
39093
+ const targetDir = path9.dirname(path9.resolve(outPdfPath));
39094
+ assertWriteTargetNoSymlink(path9.resolve(outPdfPath));
38733
39095
  await fsp4.mkdir(targetDir, { recursive: true });
38734
- assertWriteTargetNoSymlink(path8.resolve(outPdfPath));
38735
- await writeFileNoFollow(path8.resolve(outPdfPath), await fsp4.readFile(generatedPdfPath));
38736
- fs9.chmodSync(path8.resolve(outPdfPath), 420);
39096
+ assertWriteTargetNoSymlink(path9.resolve(outPdfPath));
39097
+ await writeFileNoFollow(path9.resolve(outPdfPath), await fsp4.readFile(generatedPdfPath));
39098
+ fs10.chmodSync(path9.resolve(outPdfPath), 420);
38737
39099
  succeeded = true;
38738
- return path8.resolve(outPdfPath);
39100
+ return path9.resolve(outPdfPath);
38739
39101
  } finally {
38740
39102
  if (succeeded) {
38741
39103
  try {
@@ -38751,82 +39113,8 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38751
39113
  }
38752
39114
  }
38753
39115
 
38754
- // ../engine/src/visual/backend.ts
38755
- var fs10 = __toESM(require("fs"));
38756
- var path9 = __toESM(require("path"));
38757
- var VISUAL_DEPENDENCY_REMEDY = "PDF render backend missing. Install LibreOffice and poppler-utils (pdftoppm): sudo apt install libreoffice poppler-utils, or brew install libreoffice poppler. Alternatively inspect the XML/DOM without images (telemetry-only mode).";
38758
- function createDependencyError(missing) {
38759
- const which = missing.length > 0 ? missing.join(", ") : "soffice, pdftoppm";
38760
- return Object.assign(new Error(`E_DEPENDENCY: headless visual backend unavailable (missing: ${which})`), {
38761
- code: "E_DEPENDENCY",
38762
- remedy: VISUAL_DEPENDENCY_REMEDY
38763
- });
38764
- }
38765
- function findOnPath(name2, pathEnv) {
38766
- try {
38767
- if (!name2 || name2.trim() === "") return null;
38768
- const trimmed = name2.trim();
38769
- if (trimmed.includes("/") || trimmed.includes("\\")) {
38770
- try {
38771
- const abs = path9.resolve(trimmed);
38772
- fs10.accessSync(abs, fs10.constants.X_OK);
38773
- const stat3 = fs10.statSync(abs);
38774
- if (stat3.isFile()) return abs;
38775
- return null;
38776
- } catch {
38777
- return null;
38778
- }
38779
- }
38780
- const pathValue = pathEnv !== void 0 ? pathEnv : process.env.PATH ?? "";
38781
- if (!pathValue) return null;
38782
- const dirs = pathValue.split(path9.delimiter).filter((d) => d.length > 0);
38783
- for (const dir of dirs) {
38784
- try {
38785
- const candidate = path9.join(dir, trimmed);
38786
- fs10.accessSync(candidate, fs10.constants.X_OK);
38787
- const stat3 = fs10.statSync(candidate);
38788
- if (stat3.isFile()) return candidate;
38789
- } catch {
38790
- continue;
38791
- }
38792
- }
38793
- return null;
38794
- } catch {
38795
- return null;
38796
- }
38797
- }
38798
- function resolveSoffice(override, pathEnv) {
38799
- if (override && override.trim()) {
38800
- const direct = findOnPath(override.trim(), pathEnv);
38801
- if (direct) return direct;
38802
- return null;
38803
- }
38804
- return findOnPath("soffice", pathEnv) ?? findOnPath("libreoffice", pathEnv);
38805
- }
38806
- function hasPdfBackend2(override) {
38807
- const pathEnv = override?.pathEnv;
38808
- const sofficePath = resolveSoffice(override?.sofficeBin, pathEnv);
38809
- let pdftoppmPath = null;
38810
- if (override?.pdftoppmBin && override.pdftoppmBin.trim()) {
38811
- pdftoppmPath = findOnPath(override.pdftoppmBin.trim(), pathEnv);
38812
- } else {
38813
- pdftoppmPath = findOnPath("pdftoppm", pathEnv);
38814
- }
38815
- const missing = [];
38816
- if (!sofficePath) missing.push("soffice");
38817
- if (!pdftoppmPath) missing.push("pdftoppm");
38818
- if (missing.length === 0) {
38819
- return { available: true, backend: "libreoffice", sofficePath, pdftoppmPath, missing: [] };
38820
- }
38821
- return { available: false, backend: "none", missing };
38822
- }
38823
- function assertPdfBackend(override) {
38824
- const status = hasPdfBackend2(override);
38825
- if (!status.available) {
38826
- throw createDependencyError(status.missing);
38827
- }
38828
- return { sofficePath: status.sofficePath, pdftoppmPath: status.pdftoppmPath };
38829
- }
39116
+ // ../engine/src/export/pdf-source.ts
39117
+ var STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
38830
39118
 
38831
39119
  // ../engine/src/visual/renderer.ts
38832
39120
  var fs11 = __toESM(require("fs"));
@@ -39216,8 +39504,18 @@ function computeDocStats(ast) {
39216
39504
  if (hits) words += hits.length;
39217
39505
  }
39218
39506
  }
39219
- const pageBudget = words / 450 + (tables + callouts) * 0.45 + images * 0.5 + pageBreaks;
39220
- return { words, paragraphs, pages: Math.max(1, Math.ceil(pageBudget)), tables, callouts, images };
39507
+ const pageTerms = {
39508
+ words: words / 450,
39509
+ tables: (tables + callouts) * 0.45,
39510
+ images: images * 0.5,
39511
+ pageBreaks
39512
+ };
39513
+ const pageBudget = pageTerms.words + pageTerms.tables + pageTerms.images + pageTerms.pageBreaks;
39514
+ return { words, paragraphs, pages: Math.max(1, Math.ceil(pageBudget)), pageTerms, tables, callouts, images };
39515
+ }
39516
+ function roundedPageTerms(terms) {
39517
+ const r = (n) => Math.round(n * 100) / 100;
39518
+ return { words: r(terms.words), tables: r(terms.tables), images: r(terms.images), pageBreaks: r(terms.pageBreaks) };
39221
39519
  }
39222
39520
 
39223
39521
  // ../engine/src/visual/telemetry.ts
@@ -40810,7 +41108,7 @@ function assertTemplateSeed(parser2, template) {
40810
41108
  }
40811
41109
  if (!matched) {
40812
41110
  throw new Error(
40813
- `E_VALIDATION: line 1: open document does not hold the ${template} seed ("${marker}"). Remedy: open a fresh ${template} document first, then compose into it.`
41111
+ `E_VALIDATION: line 1: open document does not hold the ${template} seed ("${marker}"). Remedy: to add to this document, call compose_document again without template; to start from the ${template} template, call new_document with template "${template}" first and compose into that.`
40814
41112
  );
40815
41113
  }
40816
41114
  }
@@ -43103,7 +43401,7 @@ var LEAN6_DESCRIPTIONS = {
43103
43401
  { workflow: true }
43104
43402
  ),
43105
43403
  inspect_document: withSharedDocs(
43106
- 'Inspect a session: standard TOON outline (fidelity compact|standard|full, default standard) plus style palette passthrough {availableStyles,brandColors,primaryFont} parsed from word/styles.xml, plus document core properties `metadata` {title,creator,subject,description,keywords,category,lastModifiedBy,revision,created,modified} read from docProps/core.xml (absent when the docx has no core part). Use include:["comments","revisions"] for review records paged by reviewOffset/reviewLimit (max 50; offset/limit page nodes only); commentId or revisionId reads full detail and needs its include. Renders the in-memory session state (includes unsaved execute_batch edits); `dirty:true` means the session differs from the last saved file, `revision` is the in-memory revision. Node ids are verbatim: paragraphs carry 8-hex w14:paraId (e.g. 3F2A9C1B), tables t_001.., sections sec_root, sec_2, sec_3.. by position; use them as-is for targets/anchors. A repeat paragraph sharing one native paraId reports a `<paraId>_dup` session id instead (e.g. 3F2A9C1B_dup): saving refuses until the repeat is deleted and its text reinserted for a fresh id. Tables render one row per table with grid cells as rNcM coordinates (r1c1 value | r1c2 value, rows joined by ;); merged spans are marked (spanN); merge covers are tagged hidden-cover (not directly editable, address the restart cell). Cell edits: the replace, style, namedStyle, clearFormat, hyperlink, bookmark and comment actions accept target "<tableId>:r<row>c<col>" (1-based, as the grid prints it; add ":p<n>" to pick one paragraph of a multi-paragraph cell). Other actions, including table, take the table id. Every row and column number counts from 1 (cell addresses, rowIndex, colIndex, columnIndex, row, col, index, afterIndex, fromRow/toRow/fromCol/toCol), so r3c2 and {rowIndex:3,colIndex:2} are the same cell. Chart nodes render style chart|chart3d with title + categories + series in the text ([Chart column] Title | cats: Q1,Q2 | ARR: 10,25), read from the chart part. Flag legend: [TBL] table [IMG] image/drawing [EQ] equation [CITE] citation [CMT] comment [TRK] tracked [LNK] link [BKMK] bookmark [L#] list level (1 is the top level; list ops count the same way) [+ADDED]/[~MODIFIED] diff; standard tier adds [B] bold [I] italic [C:hex] color [SZ:half-points] size [RS:name] run style [J:just] [N:num] numbering [HDR]/[ZEBRA] table look [CH:type]/[RID:id] chart. Palette lists every style id verbatim with no trim (large templates return the full list). 3-tier workflow for large docs: (1) macro-overview inspect (compact, first window) -> (2) search_content to find a target node id -> (3) local zoom via {aroundNode:"<id>",window:5} or page with {offset,limit}. Windowing opts: offset (min 0, default 0), limit (1-200, default 50), aroundNode + window (1-20, default 5) returning [node-w,node+w], maxTokens (200-8000, cuts on a clean row boundary, always reported via truncated/hasMore). aroundNode and offset/limit are mutually exclusive (use one or the other). Windowed renders preserve the requested tier; the TOON fidelity marker stays lossy by design (TOON is a lossy projection). Docs under ~2000 tokens return the full TOON (unchanged); larger docs default to the first ~50-node window {totalNodes,returnedNodes,offset,limit,hasMore,nextOffset,toonText} with a navigation remedy. Full fidelity requires sectionId; sectionId is a section id or a node id (a node id prefix needs 4 characters or more), and an id that matches nothing is an error that names the available section ids. Example {"sessionId":"sess_abc","fidelity":"standard"} -> TOON text + palette. Use the palette style names verbatim (e.g. Corporate Callout); never invent styles. When the session carries a style guide, the envelope also includes its compact styleGuide summary {name,headerFill,headerText,chartPalette,calloutTheme}. IDs are session-scoped: valid only in the issuing session, must not be stored across sessions, and duplicate client-supplied ids are rejected.'
43404
+ 'Inspect a session: standard TOON outline (fidelity outline|compact|standard|full, default standard) plus style palette passthrough fidelity outline lists headings only (ID, level L, Style, Text, Section = the headings above); there rowCount/totalNodes count heading rows, `outline` {headings,nodes,tables,images,words} counts the whole document, and aroundNode on a body node shows the heading above it (`aroundResolvedTo`). {availableStyles,brandColors,primaryFont} parsed from word/styles.xml, plus document core properties `metadata` {title,creator,subject,description,keywords,category,lastModifiedBy,revision,created,modified} read from docProps/core.xml (absent when the docx has no core part). Use include:["comments","revisions"] for review records paged by reviewOffset/reviewLimit (max 50; offset/limit page nodes only); commentId or revisionId reads full detail and needs its include. Renders the in-memory session state (includes unsaved execute_batch edits); `dirty:true` means the session differs from the last saved file, `revision` is the in-memory revision. Node ids are verbatim: paragraphs carry 8-hex w14:paraId (e.g. 3F2A9C1B), tables t_001.., sections sec_root, sec_2, sec_3.. by position; use them as-is for targets/anchors. A repeat paragraph sharing one native paraId reports a `<paraId>_dup` session id instead (e.g. 3F2A9C1B_dup): saving refuses until the repeat is deleted and its text reinserted for a fresh id. Tables render one row per table with grid cells as rNcM coordinates (r1c1 value | r1c2 value, rows joined by ;); merged spans are marked (spanN); merge covers are tagged hidden-cover (not directly editable, address the restart cell). Cell edits: the replace, style, namedStyle, clearFormat, hyperlink, bookmark and comment actions accept target "<tableId>:r<row>c<col>" (1-based, as the grid prints it; add ":p<n>" to pick one paragraph of a multi-paragraph cell). Other actions, including table, take the table id. Every row and column number counts from 1 (cell addresses, rowIndex, colIndex, columnIndex, row, col, index, afterIndex, fromRow/toRow/fromCol/toCol), so r3c2 and {rowIndex:3,colIndex:2} are the same cell. Chart nodes render style chart|chart3d with title + categories + series in the text ([Chart column] Title | cats: Q1,Q2 | ARR: 10,25), read from the chart part. Flag legend: [TBL] table [IMG] image/drawing [EQ] equation [CITE] citation [CMT] comment [TRK] tracked [LNK] link [BKMK] bookmark [L#] list level (1 is the top level; list ops count the same way) [+ADDED]/[~MODIFIED] diff; standard tier adds [B] bold [I] italic [C:hex] color [SZ:half-points] size [RS:name] run style [J:just] [N:num] numbering [HDR]/[ZEBRA] table look [CH:type]/[RID:id] chart. Palette lists every style id verbatim with no trim (large templates return the full list). 3-tier workflow for large docs: (1) macro-overview inspect (compact, first window) -> (2) search_content to find a target node id -> (3) local zoom via {aroundNode:"<id>",window:5} or page with {offset,limit}. Windowing opts: offset (min 0, default 0), limit (1-200, default 50), aroundNode + window (1-20, default 5) returning [node-w,node+w], maxTokens (200-8000, cuts on a clean row boundary, always reported via truncated/hasMore). aroundNode and offset/limit are mutually exclusive (use one or the other). Windowed renders preserve the requested tier; the TOON fidelity marker stays lossy by design (TOON is a lossy projection). Docs under ~2000 tokens return the full TOON (unchanged); larger docs default to the first ~50-node window {totalNodes,returnedNodes,offset,limit,hasMore,nextOffset,toonText} with a navigation remedy. Full fidelity requires sectionId; sectionId is a section id or a node id (a node id prefix needs 4 characters or more), and an id that matches nothing is an error that names the available section ids. Example {"sessionId":"sess_abc","fidelity":"standard"} -> TOON text + palette. Use the palette style names verbatim (e.g. Corporate Callout); never invent styles. When the session carries a style guide, the envelope also includes its compact styleGuide summary {name,headerFill,headerText,chartPalette,calloutTheme}. IDs are session-scoped: valid only in the issuing session, must not be stored across sessions, and duplicate client-supplied ids are rejected.'
43107
43405
  ),
43108
43406
  search_content: withSharedDocs(
43109
43407
  'Search live paragraph/table w:t text; returns [{id,preview}] node hits for anchors/targets. Use scope:"comments" or scope:"all" to search comment text; the default scope is body. At most 50 matches come back; truncated:true says more exist. A table hit also lists `cells` (up to 5 addresses such as t_001:r3c1) for the cells that hold the match; pass one as the target of replace/style/namedStyle/clearFormat/hyperlink/bookmark/comment. Chart titles/series are NOT matched by search (they live in the chart part, not w:t); discover them in inspect TOON chart rows, then anchor by the chart node id. Image alt text and footnote/endnote bodies are NOT matched either (search covers body w:t text only); find images via inspect [IMG] rows and footnotes via their anchor ids. Search extension to those parts is a later-program candidate, not this surface. Default is an exact literal substring (case-insensitive); pass {"regex":true} to interpret the query as a regular expression (bounded: pattern <=200 chars, ~500ms evaluation budget, case-insensitive). Caps: first 50 hits, 120-char previews. Literal queries containing regex metacharacters return literal results plus a hint that regex:true exists. Example {"sessionId":"sess_abc","query":"Executive Summary"} -> {"ok":true,"matches":[{"id":"3F2A9C1B","preview":"Executive ..."}]}. Search before editing when the anchor id is unknown.'
@@ -43144,7 +43442,7 @@ var PARITY_DESCRIPTIONS = {
43144
43442
  'Preflight read-only OOXML audit for a session: runs the same validator gate save_document enforces, before anything is written. Detects schema violations while they are still cheap to fix. Strictly read-only: zero session mutation (revision and dirty flag untouched), zero disk writes, zero history commits. Returns {ok:true,sessionId,revision,dirty,validation:{valid,errorCount,errors}} where errors carry description + xPath (first 10, same shape as the save-blocked envelope). The verdict is on the document as a save would write it (children put in schema order first); when that save would repair something the result also carries pendingNormalizations (a list of repairs, absent when none), so a valid verdict is not mistaken for "valid exactly as it stands". Use before save_document when the package may be non-conforming; save_document re-gates before writing. Example {"sessionId":"sess_abc"} -> {"ok":true,"validation":{"valid":true,"errorCount":0,"errors":[]}}. '
43145
43443
  ),
43146
43444
  get_stats: withSharedDocs(
43147
- 'Token-cheap document census for a session: words, paragraphs, tables, callouts, images, and an estimated editorial page budget (layout-weighted by words, tables, images, and explicit page breaks) in a single small envelope. Counters are the exact engine counters behind CLI stats, so both surfaces always agree. Physical pagination is finalized when exporting to PDF via LibreOffice. Read-only: never mutates, never bumps the revision. Returns {ok:true,sessionId,revision,dirty,words,paragraphs,tables,callouts,images,pages}. A callout (a "> [!NOTE]" alert or a callout op: a one-cell table with a fill) is counted in callouts and not in tables. Use instead of inspect_document when only counts are needed (a full TOON dump costs thousands of tokens for the same question). Example {"sessionId":"sess_abc"} -> {"ok":true,"words":42,"paragraphs":5}. '
43445
+ 'Token-cheap document census for a session: words, paragraphs, tables, callouts, images, and an estimated editorial page budget (layout-weighted by words, tables, images, and explicit page breaks) in a single small envelope. Counters are the exact engine counters behind CLI stats, so both surfaces always agree. Physical pagination is finalized when exporting to PDF via LibreOffice. Read-only: never mutates, never bumps the revision. Returns {ok:true,sessionId,revision,dirty,words,paragraphs,tables,callouts,images,pages,pageTerms}. pages is a rough estimate, max(1, ceil(words/450 + 0.45 per table or callout + 0.5 per image + 1 per page break)); pageTerms gives those four terms in pages, so a caller can see how much of it is counted from tables, callouts, images and page breaks (`pageTerms.tables` covers tables and callouts), which the estimate cannot size. Use visual_inspect for the renderer\'s page count. A callout (a "> [!NOTE]" alert or a callout op: a one-cell table with a fill) is counted in callouts and not in tables. Use instead of inspect_document when only counts are needed (a full TOON dump costs thousands of tokens for the same question). Example {"sessionId":"sess_abc"} -> {"ok":true,"words":42,"paragraphs":5}. '
43148
43446
  ),
43149
43447
  sanitize_document: withSharedDocs(
43150
43448
  `Scrub an open session document. By default it removes revision ids, authorship, embedded EXIF and hidden text PII. Tracked changes and comments are kept, with the text of every tracked deletion, unless you ask: acceptAllRevisions:true accepts all tracked changes (deleted text is removed) and stripComments:true removes comments. The CLI presets (bare sanitize, --client-safe, --metadata-only, --redact-pii) turn both on; this tool does not. Macro/active parts are removed on request. It uses the same engine scrub path as CLI sanitize. Body text is kept. Options mirror the engine scrub keys 1:1 (zeroRetention, purgeRSIDs, purgeAuthors, purgeExif, purgePII, acceptAllRevisions, stripComments, replacementAuthor, allowMacros, stripMacros); omitted options take engine defaults. With acceptAllRevisions it returns E_VALIDATION and no file when revision markup remains that cannot be resolved. allowMacros and stripMacros together fail with E_VALIDATION. ${piiCoverageSentence()} Returns {ok:true,sessionId,revision,dirty,removedRSIDs,purgedAuthors,strippedExifBytes,purgedPII,bodyPII,piiCoverage,acceptedRevisions,strippedComments,strippedMacros,macroParts,filesTouched,droppedRogue,...} plus the standard redoCleared signal when a redo chain is dropped. Example {"sessionId":"sess_abc","stripMacros":true} -> scrubbed session at a new revision (save after). `
@@ -43182,7 +43480,7 @@ var OpenDocumentSchema = import_zod4.z.object({
43182
43480
  }).strict();
43183
43481
  var InspectDocumentSchema = import_zod4.z.object({
43184
43482
  sessionId: import_zod4.z.string().min(1).max(128),
43185
- fidelity: import_zod4.z.enum(["compact", "standard", "full"]).optional(),
43483
+ fidelity: import_zod4.z.enum(["outline", "compact", "standard", "full"]).optional(),
43186
43484
  sectionId: import_zod4.z.string().min(1).max(256).optional(),
43187
43485
  maxTextLength: import_zod4.z.number().int().min(1).max(1e4).optional(),
43188
43486
  // Phase C-2 smart windowing: offset/limit page the node rows; aroundNode +
@@ -43429,6 +43727,12 @@ function readPendingNormalizations(handle) {
43429
43727
  return [];
43430
43728
  }
43431
43729
  }
43730
+ function outlineCountsOf(ast) {
43731
+ const stats = computeDocStats(ast);
43732
+ let nodes = 0;
43733
+ for (const sec of ast.sections) nodes += sec.nodes.length;
43734
+ return { headings: countHeadings(ast), nodes, tables: stats.tables + stats.callouts, images: stats.images, words: stats.words };
43735
+ }
43432
43736
  async function handleInspectDocument(store, input) {
43433
43737
  const parsed = InspectDocumentSchema.safeParse(input);
43434
43738
  if (!parsed.success) {
@@ -43528,6 +43832,7 @@ async function handleInspectDocument(store, input) {
43528
43832
  pendingNormalizations: readPendingNormalizations(handle),
43529
43833
  ...handle.styleGuide !== void 0 ? { styleGuide: summarizeStyleGuide(handle.styleGuide) } : {},
43530
43834
  toonText: fullText,
43835
+ ...fidelity === "outline" ? { outline: outlineCountsOf(handle.ast) } : {},
43531
43836
  rowCount: totalNodes,
43532
43837
  totalNodes,
43533
43838
  returnedNodes: totalNodes,
@@ -43541,11 +43846,40 @@ async function handleInspectDocument(store, input) {
43541
43846
  let start;
43542
43847
  let end;
43543
43848
  let effectiveLimit = limit;
43849
+ let aroundResolvedTo;
43850
+ let note;
43544
43851
  if (aroundNode !== void 0) {
43545
- const idx = table.rows.findIndex((r) => r.id === aroundNode);
43852
+ let idx = table.rows.findIndex((r) => r.id === aroundNode);
43853
+ if (idx < 0 && fidelity === "outline") {
43854
+ const scoped = sectionId ? resolveSectionScope(handle.ast.sections, sectionId) : handle.ast.sections;
43855
+ const order = scoped.flatMap((sec) => sec.nodes.map((n) => n.id));
43856
+ const at = order.indexOf(aroundNode);
43857
+ if (at < 0) {
43858
+ const anywhere = handle.ast.sections.some((sec) => sec.nodes.some((n) => n.id === aroundNode));
43859
+ if (anywhere) {
43860
+ throw Object.assign(
43861
+ new Error(`inspect_document: aroundNode "${aroundNode}" is not in section "${sectionId}".`),
43862
+ { code: "E_VALIDATION", remedy: "Drop sectionId, or pass a node id that belongs to that section." }
43863
+ );
43864
+ }
43865
+ } else {
43866
+ const headingIds = new Set(table.rows.map((r) => r.id));
43867
+ let above = -1;
43868
+ for (let k = at; k >= 0 && above < 0; k--) if (headingIds.has(order[k])) above = k;
43869
+ if (above >= 0) {
43870
+ idx = table.rows.findIndex((r) => r.id === order[above]);
43871
+ aroundResolvedTo = order[above];
43872
+ note = `Node ${aroundNode} is not a heading; showing the heading above it (${order[above]}).`;
43873
+ } else {
43874
+ aroundResolvedTo = null;
43875
+ note = totalNodes === 0 ? `No headings${sectionId ? " in this scope" : " in the document"}; the outline is empty.` : `Node ${aroundNode} has no heading above it${sectionId ? " in this scope" : ""}; showing the first headings.`;
43876
+ idx = 0;
43877
+ }
43878
+ }
43879
+ }
43546
43880
  if (idx < 0) {
43547
43881
  throw Object.assign(
43548
- new Error(`inspect_document: aroundNode "${aroundNode}" not found (${totalNodes} nodes in scope).`),
43882
+ new Error(`inspect_document: aroundNode "${aroundNode}" not found (${totalNodes} ${fidelity === "outline" ? "headings" : "nodes"} in scope).`),
43549
43883
  {
43550
43884
  code: "E_VALIDATION",
43551
43885
  remedy: 'Call search_content to find a valid target node id, then retry inspect_document with {aroundNode:"<id>", window:5}.'
@@ -43554,6 +43888,10 @@ async function handleInspectDocument(store, input) {
43554
43888
  }
43555
43889
  start = Math.max(0, idx - window);
43556
43890
  end = Math.min(totalNodes, idx + window + 1);
43891
+ if (aroundResolvedTo === null) {
43892
+ start = 0;
43893
+ end = Math.min(totalNodes, window * 2 + 1);
43894
+ }
43557
43895
  effectiveLimit = end - start;
43558
43896
  } else {
43559
43897
  if (offset >= totalNodes && totalNodes > 0) {
@@ -43620,6 +43958,7 @@ async function handleInspectDocument(store, input) {
43620
43958
  pendingNormalizations: readPendingNormalizations(handle),
43621
43959
  ...handle.styleGuide !== void 0 ? { styleGuide: summarizeStyleGuide(handle.styleGuide) } : {},
43622
43960
  toonText,
43961
+ ...fidelity === "outline" ? { outline: outlineCountsOf(handle.ast) } : {},
43623
43962
  rowCount: returnedNodes,
43624
43963
  totalNodes,
43625
43964
  returnedNodes,
@@ -43629,6 +43968,8 @@ async function handleInspectDocument(store, input) {
43629
43968
  nextOffset: hasMore ? start + returnedNodes : null,
43630
43969
  truncated
43631
43970
  };
43971
+ if (aroundResolvedTo !== void 0) out.aroundResolvedTo = aroundResolvedTo;
43972
+ if (note !== void 0) out.note = note;
43632
43973
  if (hasMore || truncated) {
43633
43974
  out.remedy = `Large document: showing ${returnedNodes}/${totalNodes} TOON nodes${truncated ? ` (cut to fit maxTokens ${maxTokens})` : ""}. Navigate: 1) search_content to find a target node id, 2) inspect_document {aroundNode:"<id>", window:5} to zoom, 3) page with {offset:${hasMore ? start + returnedNodes : start}, limit:${effectiveLimit}} to continue.`;
43634
43975
  }
@@ -43693,7 +44034,7 @@ var ComposeDocumentSchema = import_zod5.z.object({
43693
44034
  // Side of anchor the first block goes on (default after). Needs anchor.
43694
44035
  position: import_zod5.z.enum(["before", "after"]).optional()
43695
44036
  }).strict();
43696
- var COMPOSE_DOCUMENT_DESCRIPTION = 'Compose a Markdown string into the open session document (appends after existing content, or at anchor with position before|after; an unchanged built-in template created in this session has its sample content replaced). A document can be built in several compose calls: the first call of a session mints ids md_0001..., later calls mint md2_0001..., md3_0001..., so ids never collide; pass anchor (a node id, start or end) and position to add a section in the middle without another tool. Runs the engine compose path, then applies the resulting operations through the same execute_batch path (strict schema gate, per-chunk quotas, structural checks, per-chunk atomic rollback, then inline-span run splitting), so every gate a batch meets applies here unchanged. Supported input: #/##/### headings (real Heading1..3 styles), blank-line paragraphs (single newlines join with a space), flat bullet (-, *, +) and ordered (1., 1)) lists with real numbering, GitHub-style tables with a header separator row (header row shaded and repeated), --- dividers, fenced or indented code blocks (monospace, line breaks kept), [^label] footnotes with definitions, inline spans (**bold**, *italic*, `code`, [text](url)) applied as runs with real hyperlink targets, plain `> quote` blocks (written in the Quote paragraph style: blank > lines start a new paragraph, > > nests up to 3 levels, flat lists and fenced code are kept inside, a table, heading or image inside a quote fails), and standalone block images/charts (![alt](path)) resolved from allowedDir. Out-of-scope input fails closed with E_VALIDATION plus a 1-based line number and writes nothing: raw HTML (except [!NOTE]-style alert callouts, which compile to callout containers and keep bold, italic, code and links), custom styles, deeper headings, nested lists or tables, task items. Block image/chart refs need allowedDir pointing at the folder holding the files (refs stay jailed inside it; without allowedDir they fail closed); remote URL refs fail closed; inline data-URL block images bypass allowedDir by design (bytes decode in memory through the same image gates). Sources compiling past 500 operations apply as sequential 500-operation chunks; the compose is atomic as a whole: when any chunk fails, every chunk before it is rewound and the document stays as it was. Citation numbers and the References list restart in each compose call. Optional template (report|memo|blank) is accepted on the first compose only and requires the open document to still hold that template seed, otherwise composition fails before any write (for a fresh templated document call new_document first, then compose_document with template), and placement is append after existing content except for an unchanged built-in template created in this session. Reopened documents and external templates keep existing content. Returns {ok:true,opCount,appliedCount,revision,createdNodeIds,results,validation:{valid,errorCount,errors}} where createdNodeIds lists new node ids in document order (use them as anchors without re-inspecting) and validation proves the post-compose package state via a read-only OOXML check (save_document re-gates before writing). A compose that drops a non-empty redo stack signals it (redoCleared:true + redoClearedWarning naming the depth): a fresh edit invalidates redo. Optional styleGuide (bundled guide name or guide .md path, jailed like image refs: explicit allowedDir else the session anchor; remote URLs fail closed) replaces the session guide (not a one-batch override: later batches inherit it until another compose replaces it) and skins this compose (table/header-fill, chart-palette, callout, and code defaults apply where ops omit params; explicit params still win). Guides cannot be detached: open a new session to work without a guide. With no guide, compose uses the built-in Default Corporate look (navy table header, bold white header text, striped rows). styleGuide "legacy" gives the older grey header for this call only and is not stored on the session. An italic-only line directly under the opening # title becomes the Subtitle; a [!NOTE]-style line outside a blockquote stays plain text and comes back in warnings. Later batches inherit it, inspect_document shows its compact summary, save_document reports advisory styleWarnings. Unknown names fail with E_VALIDATION naming the available set. Math: $$ ... $$ or \\[ ... \\] blocks become display equations and \\(...\\) becomes inline math (one line, up to 500 characters; refused inside links and table cells). Display blocks are top level only (inside a list item or alert body they fail closed). Optional dollarMath (default false) treats $...$ as inline math; off by default because prices and shell text can look like math. Workflow: open_document -> compose_document -> save_document. Example {"sessionId":"sess_abc","markdown":"# Plan\\n\\n- a\\n- b\\n"} (add "allowedDir":"<dir>" for image/chart refs, "template":"report|memo|blank" to verify the seed). IDs are session-scoped: valid only in the issuing session, must not be stored across sessions. When dc:title is still a template placeholder it is set from the first Title or Heading 1 (titleSetFrom says so); a title already set is kept. persistentIds maps each created paragraph id (md_0003) to the paragraph id the saved file carries, so a reopened session can be addressed with it (tables are renumbered t_001.. by position on reopen).';
44037
+ var COMPOSE_DOCUMENT_DESCRIPTION = 'Compose a Markdown string into the open session document (appends after existing content, or at anchor with position before|after; an unchanged built-in template created in this session has its sample content replaced). A document can be built in several compose calls: the first call of a session mints ids md_0001..., later calls mint md2_0001..., md3_0001..., so ids never collide; pass anchor (a node id, start or end) and position to add a section in the middle without another tool. Runs the engine compose path, then applies the resulting operations through the same execute_batch path (strict schema gate, per-chunk quotas, structural checks, per-chunk atomic rollback, then inline-span run splitting), so every gate a batch meets applies here unchanged. Supported input: #/##/### headings (real Heading1..3 styles), blank-line paragraphs (single newlines join with a space), flat bullet (-, *, +) and ordered (1., 1)) lists with real numbering, GitHub-style tables with a header separator row (header row shaded and repeated), --- dividers, fenced or indented code blocks (monospace, line breaks kept), [^label] footnotes with definitions, inline spans (**bold**, *italic*, `code`, [text](url)) applied as runs with real hyperlink targets, plain `> quote` blocks (written in the Quote paragraph style: blank > lines start a new paragraph, > > nests up to 3 levels, flat lists and fenced code are kept inside, a table, heading or image inside a quote fails), and standalone block images/charts (![alt](path)) resolved from allowedDir. Out-of-scope input fails closed with E_VALIDATION plus a 1-based line number and writes nothing: raw HTML (except [!NOTE]-style alert callouts, which compile to callout containers and keep bold, italic, code and links), custom styles, deeper headings, nested lists or tables, task items. Block image/chart refs need allowedDir pointing at the folder holding the files (refs stay jailed inside it; without allowedDir they fail closed); remote URL refs fail closed; inline data-URL block images bypass allowedDir by design (bytes decode in memory through the same image gates). Sources compiling past 500 operations apply as sequential 500-operation chunks; the compose is atomic as a whole: when any chunk fails, every chunk before it is rewound and the document stays as it was. Citation numbers and the References list restart in each compose call. Optional template (report|memo|blank) is accepted on the first compose only and requires the open document to still hold that template seed, otherwise composition fails before any write (to append to an existing document omit template; for a fresh templated document call new_document first, then compose_document with template), and placement is append after existing content except for an unchanged built-in template created in this session. Reopened documents and external templates keep existing content. Returns {ok:true,opCount,appliedCount,revision,createdNodeIds,results,validation:{valid,errorCount,errors}} where createdNodeIds lists new node ids in document order (use them as anchors without re-inspecting) and validation proves the post-compose package state via a read-only OOXML check (save_document re-gates before writing). A compose that drops a non-empty redo stack signals it (redoCleared:true + redoClearedWarning naming the depth): a fresh edit invalidates redo. Optional styleGuide (bundled guide name or guide .md path, jailed like image refs: explicit allowedDir else the session anchor; remote URLs fail closed) replaces the session guide (not a one-batch override: later batches inherit it until another compose replaces it) and skins this compose (table/header-fill, chart-palette, callout, and code defaults apply where ops omit params; explicit params still win). Guides cannot be detached: open a new session to work without a guide. With no guide, compose uses the built-in Default Corporate look (navy table header, bold white header text, striped rows). styleGuide "legacy" gives the older grey header for this call only and is not stored on the session. An italic-only line directly under the opening # title becomes the Subtitle; a [!NOTE]-style line outside a blockquote stays plain text and comes back in warnings. Later batches inherit it, inspect_document shows its compact summary, save_document reports advisory styleWarnings. Unknown names fail with E_VALIDATION naming the available set. Math: $$ ... $$ or \\[ ... \\] blocks become display equations and \\(...\\) becomes inline math (one line, up to 500 characters; refused inside links and table cells). Display blocks are top level only (inside a list item or alert body they fail closed). Optional dollarMath (default false) treats $...$ as inline math; off by default because prices and shell text can look like math. Workflow: open_document -> compose_document -> save_document. Example {"sessionId":"sess_abc","markdown":"# Plan\\n\\n- a\\n- b\\n"} (add "allowedDir":"<dir>" for image/chart refs, "template":"report|memo|blank" to verify the seed). IDs are session-scoped: valid only in the issuing session, must not be stored across sessions. When dc:title is still a template placeholder it is set from the first Title or Heading 1 (titleSetFrom says so); a title already set is kept. persistentIds maps each created paragraph id (md_0003) to the paragraph id the saved file carries, so a reopened session can be addressed with it (tables are renumbered t_001.. by position on reopen).';
43697
44038
  function persistentParagraphIds(ast, createdIds) {
43698
44039
  const wanted = new Set(createdIds);
43699
44040
  const out = {};
@@ -45666,7 +46007,7 @@ async function handleExportDocument(store, rawArgs) {
45666
46007
  }
45667
46008
  let validatedPdfPath;
45668
46009
  if (format === "pdf") {
45669
- if (!hasPdfBackend()) {
46010
+ if (!hasPdfBackend2()) {
45670
46011
  return {
45671
46012
  ok: false,
45672
46013
  code: "E_DEPENDENCY",
@@ -46392,7 +46733,8 @@ async function handleGetStats(store, input) {
46392
46733
  tables: stats.tables,
46393
46734
  callouts: stats.callouts,
46394
46735
  images: stats.images,
46395
- pages: stats.pages
46736
+ pages: stats.pages,
46737
+ pageTerms: roundedPageTerms(stats.pageTerms)
46396
46738
  };
46397
46739
  });
46398
46740
  } catch (err) {
@@ -46714,7 +47056,7 @@ var import_zod9 = require("zod");
46714
47056
  var package_default = {
46715
47057
  name: "@attentionform/astra-mcp-server",
46716
47058
  mcpName: "com.attentionform/astra-mcp-server",
46717
- version: "0.5.0",
47059
+ version: "0.5.1",
46718
47060
  description: "Official Model Context Protocol (MCP) server for ASTra document IDE",
46719
47061
  main: "dist/index.js",
46720
47062
  types: "dist/index.d.ts",
@@ -46755,8 +47097,8 @@ var package_default = {
46755
47097
  "adm-zip-gate": "node ../../scripts/check-adm-zip-imports.mjs"
46756
47098
  },
46757
47099
  dependencies: {
46758
- "@attentionform/astra-engine": "^0.5.0",
46759
- "@attentionform/ooxml-validator": "^0.5.0",
47100
+ "@attentionform/astra-engine": "^0.5.1",
47101
+ "@attentionform/ooxml-validator": "^0.5.1",
46760
47102
  "@modelcontextprotocol/sdk": "^1.32.2",
46761
47103
  "@xmldom/xmldom": "0.8.15",
46762
47104
  "adm-zip": "0.6.1",