@attentionform/astra-mcp-server 0.4.2 → 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
@@ -45,7 +45,7 @@ __export(index_exports, {
45
45
  takeDirFlag: () => takeDirFlag
46
46
  });
47
47
  module.exports = __toCommonJS(index_exports);
48
- var path21 = __toESM(require("path"));
48
+ var path24 = __toESM(require("path"));
49
49
  var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
50
50
  var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
51
51
  var import_types2 = require("@modelcontextprotocol/sdk/types.js");
@@ -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",
@@ -2003,8 +2041,8 @@ function sortChildrenBySchema(element, order) {
2003
2041
  }
2004
2042
  function generateValidParaId(seed, counter = 0) {
2005
2043
  if (seed !== void 0 && seed !== null && seed !== "") {
2006
- const hash = import_crypto2.default.createHash("sha256").update(`${seed}:${counter}`).digest();
2007
- const raw = hash.readUInt32BE(0) & 2147483647;
2044
+ const hash2 = import_crypto2.default.createHash("sha256").update(`${seed}:${counter}`).digest();
2045
+ const raw = hash2.readUInt32BE(0) & 2147483647;
2008
2046
  const val2 = raw === 0 ? 1 : raw;
2009
2047
  return val2.toString(16).toUpperCase().padStart(8, "0");
2010
2048
  }
@@ -2905,8 +2943,8 @@ function parserRandomUnit() {
2905
2943
  if (injectedParserRandom) return injectedParserRandom();
2906
2944
  if (resolveReproducibleMode(void 0)) {
2907
2945
  parserDeterministicSeq += 1;
2908
- const hash = (parserDeterministicSeq * 2654435761 >>> 0) / 4294967296;
2909
- return hash === 0 ? 0.5 : hash;
2946
+ const hash2 = (parserDeterministicSeq * 2654435761 >>> 0) / 4294967296;
2947
+ return hash2 === 0 ? 0.5 : hash2;
2910
2948
  }
2911
2949
  return Math.random();
2912
2950
  }
@@ -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;
@@ -4530,9 +4572,9 @@ function ommlToMathmlDisplay(xml) {
4530
4572
  // ../engine/src/ooxml/validator-backend.ts
4531
4573
  var nativeBackend = {
4532
4574
  name: "native",
4533
- async validateFile(path22, opts) {
4575
+ async validateFile(path25, opts) {
4534
4576
  const native = await import("@attentionform/ooxml-validator");
4535
- return native.validateFile(path22, opts ? { officeVersion: opts.officeVersion } : void 0);
4577
+ return native.validateFile(path25, opts ? { officeVersion: opts.officeVersion } : void 0);
4536
4578
  },
4537
4579
  async validateBuffer(buf, opts) {
4538
4580
  const native = await import("@attentionform/ooxml-validator");
@@ -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;
@@ -5494,10 +5538,10 @@ function mergeCorePropertiesXml(existingXml, patch, fallback) {
5494
5538
  return { xml: serializer2.serializeToString(doc), touched };
5495
5539
  }
5496
5540
  function ensureContentTypeOverride(adapter) {
5497
- const path22 = "[Content_Types].xml";
5541
+ const path25 = "[Content_Types].xml";
5498
5542
  let raw = null;
5499
5543
  try {
5500
- raw = adapter.readTextOrNull(path22);
5544
+ raw = adapter.readTextOrNull(path25);
5501
5545
  } catch {
5502
5546
  return;
5503
5547
  }
@@ -5514,13 +5558,13 @@ function ensureContentTypeOverride(adapter) {
5514
5558
  created.setAttribute("PartName", "/docProps/core.xml");
5515
5559
  created.setAttribute("ContentType", CORE_PROPERTIES_CONTENT_TYPE);
5516
5560
  root.appendChild(created);
5517
- adapter.writeText(path22, new import_xmldom7.XMLSerializer().serializeToString(doc));
5561
+ adapter.writeText(path25, new import_xmldom7.XMLSerializer().serializeToString(doc));
5518
5562
  }
5519
5563
  function ensurePackageRel(adapter) {
5520
- const path22 = "_rels/.rels";
5564
+ const path25 = "_rels/.rels";
5521
5565
  let raw = null;
5522
5566
  try {
5523
- raw = adapter.readTextOrNull(path22);
5567
+ raw = adapter.readTextOrNull(path25);
5524
5568
  } catch {
5525
5569
  return;
5526
5570
  }
@@ -5553,7 +5597,7 @@ function ensurePackageRel(adapter) {
5553
5597
  created.setAttribute("Type", CORE_PROPERTIES_REL_TYPE);
5554
5598
  created.setAttribute("Target", CORE_PROPERTIES_PART);
5555
5599
  root.appendChild(created);
5556
- adapter.writeText(path22, new import_xmldom7.XMLSerializer().serializeToString(doc));
5600
+ adapter.writeText(path25, new import_xmldom7.XMLSerializer().serializeToString(doc));
5557
5601
  }
5558
5602
  function writeCorePropertiesPart(adapter, xml) {
5559
5603
  adapter.writeText(CORE_PROPERTIES_PART, xml);
@@ -7416,20 +7460,20 @@ function sanitizeEmbeddedWorkbookBytes(buf, outerName) {
7416
7460
  }
7417
7461
  var XML_PART_RE = /^(word\/document\.xml|word\/styles\.xml|word\/stylesWithEffects\.xml|word\/numbering\.xml|word\/comments\.xml|word\/commentsExtended\.xml|word\/footnotes\.xml|word\/endnotes\.xml|word\/settings\.xml|word\/header.*\.xml|word\/footer.*\.xml|word\/people\.xml|word\/customXml\/.*\.xml|word\/charts\/.*\.xml|word\/embeddings\/.*\.xml)$/i;
7418
7462
  function bufferZipAdapter(files, touched) {
7419
- const write = (path22, content) => {
7420
- files.set(path22, content);
7421
- touched?.add(path22);
7463
+ const write = (path25, content) => {
7464
+ files.set(path25, content);
7465
+ touched?.add(path25);
7422
7466
  };
7423
7467
  return {
7424
- hasFile: (path22) => files.has(path22),
7425
- readText: (path22) => {
7426
- const data = files.get(path22);
7427
- if (!data) throw new Error(`E_VALIDATION: Part ${path22} was not found.`);
7468
+ hasFile: (path25) => files.has(path25),
7469
+ readText: (path25) => {
7470
+ const data = files.get(path25);
7471
+ if (!data) throw new Error(`E_VALIDATION: Part ${path25} was not found.`);
7428
7472
  return data.toString("utf8");
7429
7473
  },
7430
- readTextOrNull: (path22) => files.get(path22)?.toString("utf8") ?? null,
7431
- writeText: (path22, content) => write(path22, Buffer.from(content, "utf8")),
7432
- writeBuffer: (path22, content) => write(path22, Buffer.from(content))
7474
+ readTextOrNull: (path25) => files.get(path25)?.toString("utf8") ?? null,
7475
+ writeText: (path25, content) => write(path25, Buffer.from(content, "utf8")),
7476
+ writeBuffer: (path25, content) => write(path25, Buffer.from(content))
7433
7477
  };
7434
7478
  }
7435
7479
  function deleteRemovedComments(files, ids, touched) {
@@ -10414,7 +10458,7 @@ var DocxParser = class _DocxParser {
10414
10458
  * Captures a full package snapshot: a clone of every zip entry buffer.
10415
10459
  * Used by OperationEngine for transactional rollback of failed batches.
10416
10460
  */
10417
- capturePackageSnapshot() {
10461
+ capturePackageSnapshot(options) {
10418
10462
  this.purgeRogueZipEntries();
10419
10463
  const snapshot3 = /* @__PURE__ */ new Map();
10420
10464
  for (const entry of this.zip.getEntries()) {
@@ -10425,7 +10469,7 @@ var DocxParser = class _DocxParser {
10425
10469
  snapshot3.set(entry.entryName, Buffer.from(entry.getData()));
10426
10470
  }
10427
10471
  try {
10428
- snapshot3.set("word/document.xml", Buffer.from(this.readDocumentXml(), "utf8"));
10472
+ snapshot3.set("word/document.xml", Buffer.from(options?.documentXml ?? this.readDocumentXml(), "utf8"));
10429
10473
  } catch {
10430
10474
  }
10431
10475
  return snapshot3;
@@ -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;
@@ -11068,20 +11100,20 @@ var DocxParser = class _DocxParser {
11068
11100
  }
11069
11101
  getZipAdapter() {
11070
11102
  return {
11071
- hasFile: (path22) => !!this.zip.getEntry(this.sanitizePath(path22)),
11072
- readText: (path22) => {
11073
- const cleanPath = this.sanitizePath(path22);
11103
+ hasFile: (path25) => !!this.zip.getEntry(this.sanitizePath(path25)),
11104
+ readText: (path25) => {
11105
+ const cleanPath = this.sanitizePath(path25);
11074
11106
  const entry = this.zip.getEntry(cleanPath);
11075
11107
  if (!entry) throw new Error(`File not found: ${cleanPath}`);
11076
11108
  return entry.getData().toString("utf8");
11077
11109
  },
11078
- readTextOrNull: (path22) => {
11079
- const cleanPath = this.sanitizePath(path22);
11110
+ readTextOrNull: (path25) => {
11111
+ const cleanPath = this.sanitizePath(path25);
11080
11112
  const entry = this.zip.getEntry(cleanPath);
11081
11113
  return entry ? entry.getData().toString("utf8") : null;
11082
11114
  },
11083
- writeText: (path22, content) => {
11084
- const cleanPath = this.sanitizePath(path22);
11115
+ writeText: (path25, content) => {
11116
+ const cleanPath = this.sanitizePath(path25);
11085
11117
  assertNoXxe(content, cleanPath);
11086
11118
  assertNoXxeAdjacent(content, cleanPath);
11087
11119
  if (this.zip.getEntry(cleanPath)) {
@@ -11090,8 +11122,8 @@ var DocxParser = class _DocxParser {
11090
11122
  this.zip.addFile(cleanPath, Buffer.from(content, "utf8"));
11091
11123
  }
11092
11124
  },
11093
- writeBuffer: (path22, content) => {
11094
- const cleanPath = this.sanitizePath(path22);
11125
+ writeBuffer: (path25, content) => {
11126
+ const cleanPath = this.sanitizePath(path25);
11095
11127
  assertNoXxe(content.toString("utf8"), cleanPath);
11096
11128
  assertNoXxeAdjacent(content.toString("utf8"), cleanPath);
11097
11129
  if (this.zip.getEntry(cleanPath)) {
@@ -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;
@@ -12915,12 +12946,12 @@ ${validation.errors.join("\n")}`, "repair the part XML or re-save the document i
12915
12946
  if (!comments || /<w:comment[\s>]/.test(comments.getData().toString("utf8"))) return;
12916
12947
  const parts = ["comments", "commentsExtended", "commentsIds", "commentsExtensible"].map((n) => `${n}.xml`);
12917
12948
  for (const part of parts) if (this.zip.getEntry(`word/${part}`)) this.zip.deleteFile(`word/${part}`);
12918
- const edit = (path22, tag, attr, drop) => {
12919
- const entry = this.zip.getEntry(path22);
12949
+ const edit = (path25, tag, attr, drop) => {
12950
+ const entry = this.zip.getEntry(path25);
12920
12951
  if (!entry) return;
12921
12952
  const dom = this.parser.parseFromString(entry.getData().toString("utf8"), "text/xml");
12922
12953
  for (const el of Array.from(dom.getElementsByTagName(tag))) if (drop(el.getAttribute(attr) ?? "")) el.parentNode?.removeChild(el);
12923
- this.zip.updateFile(path22, Buffer.from(this.serializer.serializeToString(dom), "utf8"));
12954
+ this.zip.updateFile(path25, Buffer.from(this.serializer.serializeToString(dom), "utf8"));
12924
12955
  };
12925
12956
  edit("word/_rels/document.xml.rels", "Relationship", "Target", (value) => parts.includes(value.replace(/^\/?(word\/)?/, "")));
12926
12957
  edit("[Content_Types].xml", "Override", "PartName", (value) => parts.map((p) => `/word/${p}`).includes(value));
@@ -13658,12 +13689,11 @@ ${validation.errors.join("\n")}`, "repair the part XML or re-save the document i
13658
13689
  }
13659
13690
  };
13660
13691
 
13661
- // ../engine/src/git.ts
13662
- var import_fs4 = __toESM(require("fs"));
13663
- var import_promises2 = __toESM(require("fs/promises"));
13664
- var import_path3 = __toESM(require("path"));
13665
- var import_crypto4 = __toESM(require("crypto"));
13666
- var import_isomorphic_git = __toESM(require("isomorphic-git"));
13692
+ // ../engine/src/original.ts
13693
+ var import_node_fs = __toESM(require("node:fs"));
13694
+ var import_promises2 = __toESM(require("node:fs/promises"));
13695
+ var import_node_path = __toESM(require("node:path"));
13696
+ var import_node_crypto = require("node:crypto");
13667
13697
 
13668
13698
  // ../engine/src/sandbox/paths.ts
13669
13699
  var import_fs3 = __toESM(require("fs"));
@@ -13805,7 +13835,408 @@ function writeFileNoFollowSync(target, data, encoding = "utf8") {
13805
13835
  assertWriteTargetNoSymlink(import_path2.default.resolve(target));
13806
13836
  }
13807
13837
 
13838
+ // ../engine/src/original.ts
13839
+ var SNAPSHOT_NAME = "original.snapshot";
13840
+ var SNAPSHOT_MAGIC = Buffer.from("ASTRA-ORIGINAL\n");
13841
+ var MAX_SNAPSHOT_HEADER = 4096;
13842
+ function originalSha256(bytes) {
13843
+ return (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
13844
+ }
13845
+ function coded(message, code = "E_IO") {
13846
+ return Object.assign(new Error(message), { code });
13847
+ }
13848
+ function errno(e) {
13849
+ return typeof e === "object" && e !== null && "code" in e ? String(e.code) : "";
13850
+ }
13851
+ function originalDir(docPath) {
13852
+ return import_node_path.default.join(getRepoDir(docPath), ".git", "astra-original");
13853
+ }
13854
+ async function readSafe(file, maxBytes = resolveHistoryMaxSidecarBytes()) {
13855
+ assertWriteTargetNoSymlink(file);
13856
+ const h = await import_promises2.default.open(file, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
13857
+ try {
13858
+ const stat3 = await h.stat();
13859
+ if (!stat3.isFile() || stat3.size > maxBytes) throw coded("Original recovery refused: expected a regular file within the size limit.");
13860
+ return await h.readFile();
13861
+ } finally {
13862
+ await h.close();
13863
+ }
13864
+ }
13865
+ async function exists(file) {
13866
+ try {
13867
+ await import_promises2.default.lstat(file);
13868
+ return true;
13869
+ } catch (e) {
13870
+ if (errno(e) === "ENOENT") return false;
13871
+ throw e;
13872
+ }
13873
+ }
13874
+ async function syncDirectory(dir) {
13875
+ if (process.platform === "win32") return false;
13876
+ let h;
13877
+ let synced = false;
13878
+ try {
13879
+ h = await import_promises2.default.open(dir, import_node_fs.default.constants.O_RDONLY);
13880
+ await h.sync();
13881
+ synced = true;
13882
+ } catch {
13883
+ }
13884
+ try {
13885
+ await h?.close();
13886
+ } catch {
13887
+ synced = false;
13888
+ }
13889
+ return synced;
13890
+ }
13891
+ async function cleanupTemp(file) {
13892
+ try {
13893
+ await import_promises2.default.unlink(file);
13894
+ } catch (e) {
13895
+ if (errno(e) !== "ENOENT") throw Object.assign(coded(`Temporary file cleanup failed: ${file}. ${e instanceof Error ? e.message : String(e)}`), { tempPath: file });
13896
+ }
13897
+ }
13898
+ async function preparedTempParts(target, parts, mode = 384) {
13899
+ assertWriteTargetNoSymlink(target);
13900
+ const dir = import_node_path.default.dirname(target);
13901
+ await import_promises2.default.mkdir(dir, { recursive: true, mode: 448 });
13902
+ assertWriteTargetNoSymlink(target);
13903
+ const tmp = import_node_path.default.join(dir, `.${import_node_path.default.basename(target).slice(0, 60)}.astra-tmp-${(0, import_node_crypto.randomUUID)()}`);
13904
+ const h = await import_promises2.default.open(tmp, import_node_fs.default.constants.O_WRONLY | import_node_fs.default.constants.O_CREAT | import_node_fs.default.constants.O_EXCL | import_node_fs.default.constants.O_NOFOLLOW, mode);
13905
+ let writeError;
13906
+ try {
13907
+ for (const part of parts) {
13908
+ let offset = 0;
13909
+ while (offset < part.length) {
13910
+ const { bytesWritten } = await h.write(part, offset, part.length - offset, null);
13911
+ if (bytesWritten === 0) throw coded("Original recovery refused: temporary file write made no progress.");
13912
+ offset += bytesWritten;
13913
+ }
13914
+ }
13915
+ await h.sync();
13916
+ } catch (error) {
13917
+ writeError = error;
13918
+ }
13919
+ let closeError;
13920
+ try {
13921
+ await h.close();
13922
+ } catch (error) {
13923
+ closeError = error;
13924
+ }
13925
+ if (writeError || closeError) {
13926
+ const primary = writeError ?? closeError;
13927
+ let message = primary instanceof Error ? primary.message : String(primary);
13928
+ if (writeError && closeError) message += `; temporary file close also failed: ${closeError instanceof Error ? closeError.message : String(closeError)}`;
13929
+ let cleanupFailed = false;
13930
+ try {
13931
+ await cleanupTemp(tmp);
13932
+ } catch (cleanup) {
13933
+ cleanupFailed = true;
13934
+ message += `; ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`;
13935
+ }
13936
+ throw Object.assign(coded(message, errno(primary) || "E_IO"), { ...cleanupFailed ? { tempPath: tmp } : {}, cause: primary });
13937
+ }
13938
+ try {
13939
+ const expectedBytes = parts.reduce((total, part) => total + part.length, 0);
13940
+ const expectedHash = (0, import_node_crypto.createHash)("sha256");
13941
+ for (const part of parts) expectedHash.update(part);
13942
+ const check = await import_promises2.default.open(tmp, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
13943
+ try {
13944
+ const stat3 = await check.stat();
13945
+ if (!stat3.isFile() || stat3.size !== expectedBytes) throw coded("Original recovery refused: temporary file integrity check failed.");
13946
+ const actualHash = (0, import_node_crypto.createHash)("sha256");
13947
+ const chunk = Buffer.allocUnsafe(64 * 1024);
13948
+ let position2 = 0;
13949
+ while (position2 < expectedBytes) {
13950
+ const { bytesRead } = await check.read(chunk, 0, Math.min(chunk.length, expectedBytes - position2), position2);
13951
+ if (bytesRead === 0) throw coded("Original recovery refused: temporary file integrity check failed.");
13952
+ actualHash.update(chunk.subarray(0, bytesRead));
13953
+ position2 += bytesRead;
13954
+ }
13955
+ if (actualHash.digest("hex") !== expectedHash.digest("hex")) throw coded("Original recovery refused: temporary file integrity check failed.");
13956
+ } finally {
13957
+ await check.close();
13958
+ }
13959
+ } catch (e) {
13960
+ try {
13961
+ await cleanupTemp(tmp);
13962
+ } catch (cleanup) {
13963
+ throw coded(`${e instanceof Error ? e.message : String(e)}; ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`);
13964
+ }
13965
+ throw e;
13966
+ }
13967
+ return tmp;
13968
+ }
13969
+ async function preparedTemp(target, data, mode = 384) {
13970
+ return preparedTempParts(target, [data], mode);
13971
+ }
13972
+ async function writePartsExclusive(target, parts, mode = 384) {
13973
+ const tmp = await preparedTempParts(target, parts, mode);
13974
+ let failure;
13975
+ try {
13976
+ assertWriteTargetNoSymlink(target);
13977
+ try {
13978
+ await import_promises2.default.link(tmp, target);
13979
+ } catch (e) {
13980
+ if (errno(e) === "EEXIST") throw coded(`Destination exists: ${target}`, "E_CONFLICT");
13981
+ if (["ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV", "ENOSYS"].includes(errno(e))) {
13982
+ throw coded("This location does not support safe exclusive publication; export to another directory.");
13983
+ }
13984
+ throw e;
13985
+ }
13986
+ await syncDirectory(import_node_path.default.dirname(target));
13987
+ } catch (error) {
13988
+ failure = error;
13989
+ }
13990
+ try {
13991
+ await cleanupTemp(tmp);
13992
+ } catch (cleanup) {
13993
+ const detail = cleanup instanceof Error ? cleanup.message : String(cleanup);
13994
+ if (failure) {
13995
+ throw Object.assign(coded(`${failure instanceof Error ? failure.message : String(failure)}; ${detail}`, errno(failure) || "E_IO"), { tempPath: tmp });
13996
+ }
13997
+ throw Object.assign(coded(`Destination was published at ${target}, but ${detail}`), { tempPath: tmp, published: true });
13998
+ }
13999
+ if (failure) throw failure;
14000
+ }
14001
+ async function writeFileExclusive(target, data, mode = 384) {
14002
+ return writePartsExclusive(target, [data], mode);
14003
+ }
14004
+ async function createVerifiedBackup(target, bytes) {
14005
+ let ownsTarget = false;
14006
+ try {
14007
+ await writeFileExclusive(target, bytes);
14008
+ ownsTarget = true;
14009
+ assertWriteTargetNoSymlink(target);
14010
+ const written = await import_promises2.default.readFile(target);
14011
+ if (written.length !== bytes.length || originalSha256(written) !== originalSha256(bytes)) {
14012
+ throw coded(`Restore backup verification failed: ${target}`, "E_IO");
14013
+ }
14014
+ } catch (error) {
14015
+ if (error?.published === true) ownsTarget = true;
14016
+ if (ownsTarget) {
14017
+ try {
14018
+ await import_promises2.default.unlink(target);
14019
+ } catch (cleanupError) {
14020
+ if (errno(cleanupError) !== "ENOENT") {
14021
+ throw Object.assign(coded(`${error instanceof Error ? error.message : String(error)}; invalid backup could not be removed: ${target} (${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)})`, "E_IO"), { backupPath: target });
14022
+ }
14023
+ }
14024
+ }
14025
+ throw error;
14026
+ }
14027
+ }
14028
+ function newRestoreBackupPath(target) {
14029
+ return import_node_path.default.join(import_node_path.default.dirname(target), `.astra-restore-backup-${(0, import_node_crypto.randomUUID)()}`);
14030
+ }
14031
+ async function replaceFileAtomic(target, data, newFileMode = 384) {
14032
+ let tmp;
14033
+ let replaced = false;
14034
+ let renameStarted = false;
14035
+ try {
14036
+ assertWriteTargetNoSymlink(target);
14037
+ let mode = newFileMode;
14038
+ try {
14039
+ const st = await import_promises2.default.lstat(target);
14040
+ if (!st.isFile()) throw coded("Atomic replace requires a regular target file.");
14041
+ mode = st.mode & 511;
14042
+ if ((mode & 146) === 0) throw coded("Atomic replace refused: target is read-only.");
14043
+ await import_promises2.default.access(target, import_node_fs.default.constants.W_OK);
14044
+ } catch (e) {
14045
+ if (errno(e) !== "ENOENT") throw e;
14046
+ }
14047
+ tmp = await preparedTemp(target, data, mode);
14048
+ assertWriteTargetNoSymlink(target);
14049
+ renameStarted = true;
14050
+ for (let attempt = 0; ; attempt++) {
14051
+ try {
14052
+ await import_promises2.default.rename(tmp, target);
14053
+ replaced = true;
14054
+ break;
14055
+ } catch (e) {
14056
+ if (process.platform !== "win32" || !["EBUSY", "EPERM", "EACCES"].includes(errno(e)) || attempt >= 3) throw e;
14057
+ await new Promise((resolve10) => setTimeout(resolve10, 25 * (attempt + 1)));
14058
+ }
14059
+ }
14060
+ const durable = await syncDirectory(import_node_path.default.dirname(target));
14061
+ await cleanupTemp(tmp);
14062
+ tmp = void 0;
14063
+ return { outcome: "replaced", ...!durable ? { durabilityUnconfirmed: true } : {} };
14064
+ } catch (e) {
14065
+ let message = e instanceof Error ? e.message : String(e);
14066
+ let outcome = replaced ? "replaced" : renameStarted ? "uncertain" : "not-replaced";
14067
+ if (!replaced && renameStarted && tmp) {
14068
+ try {
14069
+ const tempStat = await import_promises2.default.lstat(tmp);
14070
+ if (tempStat.isFile() && !tempStat.isSymbolicLink()) outcome = "not-replaced";
14071
+ } catch (tempError) {
14072
+ if (errno(tempError) === "ENOENT") {
14073
+ try {
14074
+ assertWriteTargetNoSymlink(target);
14075
+ const targetStat = await import_promises2.default.lstat(target);
14076
+ if (targetStat.isFile() && targetStat.size === data.length && (await import_promises2.default.readFile(target)).equals(data)) {
14077
+ replaced = true;
14078
+ outcome = "replaced";
14079
+ }
14080
+ } catch {
14081
+ }
14082
+ }
14083
+ }
14084
+ }
14085
+ if (tmp) {
14086
+ try {
14087
+ await cleanupTemp(tmp);
14088
+ } catch (cleanup) {
14089
+ message += `; ${cleanup instanceof Error ? cleanup.message : String(cleanup)}`;
14090
+ }
14091
+ }
14092
+ throw Object.assign(coded(message, errno(e) || "E_IO"), {
14093
+ outcome
14094
+ });
14095
+ }
14096
+ }
14097
+ function encodeSnapshot(bytes, sourceName) {
14098
+ const meta = { magic: "ASTRA-ORIGINAL", version: 1, sha256: originalSha256(bytes), bytes: bytes.length, capturedAt: (/* @__PURE__ */ new Date()).toISOString(), sourceName };
14099
+ const json = Buffer.from(JSON.stringify(meta));
14100
+ if (json.length > MAX_SNAPSHOT_HEADER) throw coded("Original snapshot metadata exceeds its format limit.", "E_LIMIT");
14101
+ const prefix = Buffer.alloc(SNAPSHOT_MAGIC.length + 4);
14102
+ SNAPSHOT_MAGIC.copy(prefix);
14103
+ prefix.writeUInt32BE(json.length, SNAPSHOT_MAGIC.length);
14104
+ return [prefix, json, bytes];
14105
+ }
14106
+ function decodeSnapshot(all) {
14107
+ const offset = SNAPSHOT_MAGIC.length;
14108
+ if (all.length < offset + 4 || !all.subarray(0, offset).equals(SNAPSHOT_MAGIC)) throw coded("Original snapshot has an invalid format header.", "E_VALIDATION");
14109
+ const headerBytes = all.readUInt32BE(offset);
14110
+ if (headerBytes < 2 || headerBytes > MAX_SNAPSHOT_HEADER || all.length < offset + 4 + headerBytes) throw coded("Original snapshot has an invalid metadata length.", "E_VALIDATION");
14111
+ let meta;
14112
+ try {
14113
+ meta = JSON.parse(all.subarray(offset + 4, offset + 4 + headerBytes).toString("utf8"));
14114
+ } catch {
14115
+ throw coded("Original snapshot metadata is not valid JSON.", "E_VALIDATION");
14116
+ }
14117
+ const bytes = all.subarray(offset + 4 + headerBytes);
14118
+ if (meta.magic !== "ASTRA-ORIGINAL" || meta.version !== 1 || !/^[a-f0-9]{64}$/.test(meta.sha256) || !Number.isSafeInteger(meta.bytes) || meta.bytes < 1 || meta.bytes !== bytes.length || typeof meta.capturedAt !== "string" || typeof meta.sourceName !== "string") {
14119
+ throw coded("Original snapshot metadata is invalid.", "E_VALIDATION");
14120
+ }
14121
+ if (originalSha256(bytes) !== meta.sha256) throw coded("Original snapshot integrity verification failed.", "E_VALIDATION");
14122
+ return { meta, bytes };
14123
+ }
14124
+ async function verifiedSnapshot(docPath) {
14125
+ const file = import_node_path.default.join(originalDir(docPath), SNAPSHOT_NAME);
14126
+ const raw = await readSafe(file);
14127
+ return decodeSnapshot(raw);
14128
+ }
14129
+ async function getOriginalInfo(docPath) {
14130
+ if (!import_node_path.default.isAbsolute(docPath)) return { available: false, reason: "not-applicable" };
14131
+ const dir = originalDir(docPath);
14132
+ try {
14133
+ if (!await exists(dir)) return { available: false, reason: "predates-feature" };
14134
+ const snapshot3 = import_node_path.default.join(dir, SNAPSHOT_NAME);
14135
+ assertWriteTargetNoSymlink(snapshot3);
14136
+ if (await exists(snapshot3)) {
14137
+ const { meta } = await verifiedSnapshot(docPath);
14138
+ return { available: true, sha256: meta.sha256, bytes: meta.bytes, capturedAt: meta.capturedAt };
14139
+ }
14140
+ try {
14141
+ const status = JSON.parse((await readSafe(import_node_path.default.join(dir, "status.json"), 16 * 1024)).toString("utf8"));
14142
+ if (["capture-failed", "too-large", "not-applicable"].includes(status.reason)) return { available: false, reason: status.reason, ...typeof status.detail === "string" ? { detail: status.detail.slice(0, 1024) } : {} };
14143
+ } catch {
14144
+ }
14145
+ return { available: false, reason: "incomplete" };
14146
+ } catch (e) {
14147
+ return { available: false, reason: "corrupt", detail: e instanceof Error ? e.message : String(e) };
14148
+ }
14149
+ }
14150
+ async function captureOriginal(docPath, bytes) {
14151
+ const dir = originalDir(docPath);
14152
+ let result;
14153
+ try {
14154
+ assertWriteTargetNoSymlink(import_node_path.default.join(dir, SNAPSHOT_NAME));
14155
+ await import_promises2.default.mkdir(dir, { recursive: true, mode: 448 });
14156
+ assertWriteTargetNoSymlink(import_node_path.default.join(dir, SNAPSHOT_NAME));
14157
+ if (await exists(import_node_path.default.join(dir, SNAPSHOT_NAME))) {
14158
+ const info = await getOriginalInfo(docPath);
14159
+ if (info.available && bytes && originalSha256(bytes) !== info.sha256) return { available: false, reason: "capture-failed", detail: "An original is already recorded for this history." };
14160
+ return info;
14161
+ }
14162
+ if (!bytes) result = { available: false, reason: "not-applicable" };
14163
+ else if (!Buffer.isBuffer(bytes) || bytes.length === 0) result = { available: false, reason: "capture-failed", detail: "Original input is empty." };
14164
+ else if (await getSidecarBytes(getRepoDir(docPath)) + bytes.length + 4096 + SNAPSHOT_MAGIC.length > resolveHistoryMaxSidecarBytes()) result = { available: false, reason: "too-large" };
14165
+ else {
14166
+ const digest = originalSha256(bytes);
14167
+ const snapshot3 = import_node_path.default.join(dir, SNAPSHOT_NAME);
14168
+ try {
14169
+ await writePartsExclusive(snapshot3, encodeSnapshot(bytes, import_node_path.default.basename(docPath)));
14170
+ } catch (e) {
14171
+ if (e?.published === true) {
14172
+ const published = await getOriginalInfo(docPath);
14173
+ if (published.available && published.sha256 === digest) return published;
14174
+ throw e;
14175
+ }
14176
+ if (errno(e) !== "E_CONFLICT") throw e;
14177
+ const winner = await verifiedSnapshot(docPath);
14178
+ if (!winner.bytes.equals(bytes)) return { available: false, reason: "capture-failed", detail: "An original is already recorded for this history." };
14179
+ }
14180
+ const info = await getOriginalInfo(docPath);
14181
+ if (info.available && info.sha256 !== digest) return { available: false, reason: "capture-failed", detail: "An original is already recorded for this history." };
14182
+ return info;
14183
+ }
14184
+ } catch (e) {
14185
+ result = { available: false, reason: "capture-failed", detail: e instanceof Error ? e.message : String(e) };
14186
+ }
14187
+ try {
14188
+ await writeFileExclusive(import_node_path.default.join(dir, "status.json"), Buffer.from(JSON.stringify(result)));
14189
+ } catch {
14190
+ }
14191
+ return result;
14192
+ }
14193
+ async function readOriginal(docPath) {
14194
+ const info = await getOriginalInfo(docPath);
14195
+ if (!info.available) {
14196
+ throw Object.assign(coded(`Original unavailable: ${info.reason}${info.detail ? ` (${info.detail})` : ""}`, "E_STATE"), {
14197
+ remedy: "The retained original cannot be trusted, so no substitute is returned. Keep the working file and any separate backup, and check the history status for the reason."
14198
+ });
14199
+ }
14200
+ return (await verifiedSnapshot(docPath)).bytes;
14201
+ }
14202
+ async function exportOriginal(docPath, outPath, opts) {
14203
+ const bytes = await readOriginal(docPath);
14204
+ const source = await import_promises2.default.realpath(docPath).catch((e) => {
14205
+ if (errno(e) === "ENOENT") return import_node_path.default.resolve(docPath);
14206
+ throw e;
14207
+ });
14208
+ const parent = import_node_path.default.dirname(import_node_path.default.resolve(outPath));
14209
+ const destination = import_node_path.default.join(await import_promises2.default.realpath(parent).catch((e) => {
14210
+ if (errno(e) === "ENOENT") return parent;
14211
+ throw e;
14212
+ }), import_node_path.default.basename(outPath));
14213
+ const samePath = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
14214
+ let sameFile = samePath(source, destination);
14215
+ try {
14216
+ const [a, b] = await Promise.all([import_promises2.default.stat(source), import_promises2.default.stat(destination)]);
14217
+ sameFile ||= a.ino !== 0 && b.ino !== 0 && a.dev === b.dev && a.ino === b.ino;
14218
+ } catch (e) {
14219
+ if (errno(e) !== "ENOENT") throw e;
14220
+ }
14221
+ if (sameFile) throw coded("Use restore_original_document to replace the working file.", "E_CONFLICT");
14222
+ const repo = await import_promises2.default.realpath(getRepoDir(docPath));
14223
+ const normalizedDest = process.platform === "win32" ? destination.toLowerCase() : destination;
14224
+ const normalizedRepo = process.platform === "win32" ? repo.toLowerCase() : repo;
14225
+ if (normalizedDest === normalizedRepo || normalizedDest.startsWith(`${normalizedRepo}${import_node_path.default.sep}`)) {
14226
+ throw coded("Original export cannot write inside the document history store.", "E_CONFLICT");
14227
+ }
14228
+ if (opts?.overwrite) await replaceFileAtomic(outPath, bytes, 438);
14229
+ else await writeFileExclusive(outPath, bytes, 438);
14230
+ return getOriginalInfo(docPath);
14231
+ }
14232
+
13808
14233
  // ../engine/src/git.ts
14234
+ var import_fs4 = __toESM(require("fs"));
14235
+ var import_os = __toESM(require("os"));
14236
+ var import_promises3 = __toESM(require("fs/promises"));
14237
+ var import_path3 = __toESM(require("path"));
14238
+ var import_crypto4 = __toESM(require("crypto"));
14239
+ var import_isomorphic_git = __toESM(require("isomorphic-git"));
13809
14240
  var HISTORY_DIR_ENV = "ASTRA_HISTORY_DIR";
13810
14241
  function historyRoot() {
13811
14242
  const raw = process.env[HISTORY_DIR_ENV];
@@ -13916,7 +14347,7 @@ async function writeAndAdd(repoDir, relPath, content) {
13916
14347
  const fullPath = import_path3.default.join(repoDir, safeRel);
13917
14348
  const dirName = import_path3.default.dirname(fullPath);
13918
14349
  assertWriteTargetNoSymlink(fullPath);
13919
- await import_promises2.default.mkdir(dirName, { recursive: true, mode: 448 });
14350
+ await import_promises3.default.mkdir(dirName, { recursive: true, mode: 448 });
13920
14351
  await secureDirChain0700(repoDir, dirName);
13921
14352
  assertWriteTargetNoSymlink(fullPath);
13922
14353
  await writeFileNoFollow(fullPath, content);
@@ -13945,6 +14376,40 @@ async function indexedPartPaths(repoDir) {
13945
14376
  const tracked = await import_isomorphic_git.default.listFiles({ fs: import_fs4.default, dir: repoDir });
13946
14377
  return new Set(tracked.map((t) => toRepoRelative(t)).filter(isRepoPartPath));
13947
14378
  }
14379
+ async function clearUncommittedPackageParts(repoDir) {
14380
+ const repoRoot = import_path3.default.resolve(repoDir);
14381
+ for (const rel of await indexedPartPaths(repoDir)) {
14382
+ if (isTraversalName(rel)) {
14383
+ throw Object.assign(new Error(`History repair refused an unsafe indexed path: ${rel}`), { code: "E_SANDBOX" });
14384
+ }
14385
+ const fullPath = import_path3.default.resolve(repoRoot, rel);
14386
+ const relative3 = import_path3.default.relative(repoRoot, fullPath);
14387
+ if (relative3 === "" || relative3 === ".." || relative3.startsWith(`..${import_path3.default.sep}`) || import_path3.default.isAbsolute(relative3)) {
14388
+ throw Object.assign(new Error(`History repair refused an indexed path outside its repository: ${rel}`), { code: "E_SANDBOX" });
14389
+ }
14390
+ assertWriteTargetNoSymlink(fullPath);
14391
+ try {
14392
+ await import_promises3.default.unlink(fullPath);
14393
+ } catch (error) {
14394
+ if (error?.code !== "ENOENT") throw error;
14395
+ }
14396
+ await import_isomorphic_git.default.remove({ fs: import_fs4.default, dir: repoDir, filepath: rel });
14397
+ }
14398
+ }
14399
+ async function isUnbornHistory(repoDir) {
14400
+ let head;
14401
+ try {
14402
+ head = await import_promises3.default.readFile(import_path3.default.join(repoDir, ".git", "HEAD"), "utf8");
14403
+ } catch {
14404
+ return false;
14405
+ }
14406
+ if (!/^ref:\s*refs\/heads\/[^\s]+\s*$/.test(head)) return false;
14407
+ const [branches, tags] = await Promise.all([
14408
+ import_isomorphic_git.default.listBranches({ fs: import_fs4.default, dir: repoDir }),
14409
+ import_isomorphic_git.default.listTags({ fs: import_fs4.default, dir: repoDir })
14410
+ ]);
14411
+ return branches.length === 0 && tags.length === 0;
14412
+ }
13948
14413
  async function snapshotIsProvenComplete(repoDir, extraFiles, written) {
13949
14414
  if (!extraFiles) return false;
13950
14415
  try {
@@ -13963,7 +14428,7 @@ async function applySnapshotMarker(repoDir, complete) {
13963
14428
  return;
13964
14429
  }
13965
14430
  try {
13966
- await import_promises2.default.unlink(import_path3.default.join(repoDir, SNAPSHOT_FORMAT_MARKER));
14431
+ await import_promises3.default.unlink(import_path3.default.join(repoDir, SNAPSHOT_FORMAT_MARKER));
13967
14432
  } catch {
13968
14433
  }
13969
14434
  try {
@@ -13978,7 +14443,7 @@ async function applySnapshotMarker(repoDir, complete) {
13978
14443
  async function chmod0700BestEffort(dir) {
13979
14444
  if (process.platform === "win32") return;
13980
14445
  try {
13981
- await import_promises2.default.chmod(dir, 448);
14446
+ await import_promises3.default.chmod(dir, 448);
13982
14447
  } catch {
13983
14448
  }
13984
14449
  }
@@ -14011,7 +14476,7 @@ async function migrateExistingTree0700(repoDir) {
14011
14476
  await chmod0700BestEffort(dir);
14012
14477
  let entries = [];
14013
14478
  try {
14014
- entries = await import_promises2.default.readdir(dir, { withFileTypes: true });
14479
+ entries = await import_promises3.default.readdir(dir, { withFileTypes: true });
14015
14480
  } catch {
14016
14481
  continue;
14017
14482
  }
@@ -14061,7 +14526,7 @@ async function getSidecarBytes(repoDir) {
14061
14526
  const dir = stack.pop();
14062
14527
  let entries = [];
14063
14528
  try {
14064
- entries = await import_promises2.default.readdir(dir, { withFileTypes: true });
14529
+ entries = await import_promises3.default.readdir(dir, { withFileTypes: true });
14065
14530
  } catch {
14066
14531
  continue;
14067
14532
  }
@@ -14074,7 +14539,7 @@ async function getSidecarBytes(repoDir) {
14074
14539
  continue;
14075
14540
  }
14076
14541
  if (typeof entry?.isFile === "function" && !entry.isFile()) continue;
14077
- const st = await import_promises2.default.stat(full);
14542
+ const st = await import_promises3.default.stat(full);
14078
14543
  if (st.isDirectory()) stack.push(full);
14079
14544
  else if (st.isFile()) total += st.size;
14080
14545
  } catch {
@@ -14092,6 +14557,116 @@ async function getHistoryCommitCount(repoDir) {
14092
14557
  return 0;
14093
14558
  }
14094
14559
  }
14560
+ var GIT_INITIALIZATION_LOCK = ".astra-init.lock";
14561
+ function gitInitializationLockPath(repoDir) {
14562
+ return import_path3.default.join(repoDir, GIT_INITIALIZATION_LOCK);
14563
+ }
14564
+ function gitInitializationIncompleteError(repoDir) {
14565
+ return Object.assign(new Error(`History initialization is incomplete at ${repoDir}.`), {
14566
+ code: "E_STATE",
14567
+ remedy: "Stop other ASTra processes using this document, inspect the history directory, then run `astra history <file> --init` to record a baseline."
14568
+ });
14569
+ }
14570
+ async function gitInitializationLockExists(repoDir) {
14571
+ const file = gitInitializationLockPath(repoDir);
14572
+ try {
14573
+ const st = await import_promises3.default.lstat(file);
14574
+ if (!st.isFile() || st.isSymbolicLink()) throw Object.assign(new Error(`Unsafe history initialization lock: ${file}`), { code: "E_SANDBOX" });
14575
+ return true;
14576
+ } catch (error) {
14577
+ if (error?.code === "ENOENT") return false;
14578
+ throw error;
14579
+ }
14580
+ }
14581
+ async function describeInitializationLock(file) {
14582
+ let record = {};
14583
+ try {
14584
+ const st = await import_promises3.default.lstat(file);
14585
+ if (st.isFile() && !st.isSymbolicLink() && st.size <= 4096) record = JSON.parse(await import_promises3.default.readFile(file, "utf8"));
14586
+ } catch {
14587
+ }
14588
+ const pid = typeof record.pid === "number" && Number.isInteger(record.pid) && record.pid > 0 ? record.pid : void 0;
14589
+ const hostname = typeof record.hostname === "string" ? record.hostname.slice(0, 255) : void 0;
14590
+ const started = typeof record.startedAt === "string" ? ` since ${record.startedAt.slice(0, 40)}` : "";
14591
+ if (pid === void 0) return `the lock does not record its owner${started}; cannot tell whether an initializer is still running`;
14592
+ if (hostname === void 0) return `owner pid ${pid}, host not recorded${started}; cannot tell whether it is still running`;
14593
+ if (hostname !== import_os.default.hostname()) return `owner pid ${pid} on host ${hostname}${started}; this host is ${import_os.default.hostname()}, cannot tell whether it is still running`;
14594
+ try {
14595
+ process.kill(pid, 0);
14596
+ return `owner pid ${pid} on this host${started}; that process is running`;
14597
+ } catch (error) {
14598
+ if (error?.code === "EPERM") return `owner pid ${pid} on this host${started}; that process is running`;
14599
+ if (error?.code === "ESRCH") return `owner pid ${pid} on this host${started}; that process was not found (this alone does not prove the lock is stale)`;
14600
+ return `owner pid ${pid} on this host${started}; cannot tell whether it is still running`;
14601
+ }
14602
+ }
14603
+ async function gitInitializationConflictError(file) {
14604
+ return Object.assign(new Error(`Another or interrupted ASTra history initialization is marked at ${file} (${await describeInitializationLock(file)}).`), {
14605
+ code: "E_CONFLICT",
14606
+ remedy: `Do not delete the lock while any ASTra process (an agent, a pipeline or a CLI run) may be using this document. Once you have made sure none is, delete ${file}; if the history then has no commits, run \`astra history <file> --init\`. ASTra never removes the lock itself.`
14607
+ });
14608
+ }
14609
+ async function countCommitsStrict(repoDir) {
14610
+ await assertUsableSidecarGit(repoDir);
14611
+ try {
14612
+ return (await import_isomorphic_git.default.log({ fs: import_fs4.default, dir: repoDir })).length;
14613
+ } catch (error) {
14614
+ if (await isUnbornHistory(repoDir)) return 0;
14615
+ throw error;
14616
+ }
14617
+ }
14618
+ async function assertGitHistoryReady(repoDir) {
14619
+ await assertNoGitInitializationInProgress(repoDir);
14620
+ if (await countCommitsStrict(repoDir) === 0) throw gitInitializationIncompleteError(repoDir);
14621
+ }
14622
+ async function assertNoGitInitializationInProgress(repoDir) {
14623
+ if (await gitInitializationLockExists(repoDir)) throw await gitInitializationConflictError(gitInitializationLockPath(repoDir));
14624
+ }
14625
+ async function assertHistoryReadyForWrite(docPath) {
14626
+ const repoDir = getRepoDir(docPath);
14627
+ await assertNoGitInitializationInProgress(repoDir);
14628
+ if (import_fs4.default.existsSync(import_path3.default.join(repoDir, ".git"))) await assertGitHistoryReady(repoDir);
14629
+ }
14630
+ async function withGitInitializationLock(repoDir, run) {
14631
+ const file = gitInitializationLockPath(repoDir);
14632
+ assertWriteTargetNoSymlink(file);
14633
+ const token = import_crypto4.default.randomUUID();
14634
+ let handle;
14635
+ try {
14636
+ handle = await import_promises3.default.open(file, import_fs4.default.constants.O_WRONLY | import_fs4.default.constants.O_CREAT | import_fs4.default.constants.O_EXCL | import_fs4.default.constants.O_NOFOLLOW, 384);
14637
+ } catch (error) {
14638
+ if (error?.code === "EEXIST") throw await gitInitializationConflictError(file);
14639
+ throw error;
14640
+ }
14641
+ let primaryError;
14642
+ let result;
14643
+ try {
14644
+ await handle.writeFile(JSON.stringify({ version: 2, pid: process.pid, hostname: import_os.default.hostname(), token, startedAt: (/* @__PURE__ */ new Date()).toISOString() }));
14645
+ await handle.sync();
14646
+ result = await run();
14647
+ } catch (error) {
14648
+ primaryError = error;
14649
+ }
14650
+ await handle.close().catch(() => void 0);
14651
+ let cleanupError;
14652
+ try {
14653
+ const current = JSON.parse(await import_promises3.default.readFile(file, "utf8"));
14654
+ if (current.token !== token) throw Object.assign(new Error(`History initialization lock ownership changed; left in place: ${file}`), { code: "E_CONFLICT" });
14655
+ await import_promises3.default.unlink(file);
14656
+ } catch (error) {
14657
+ cleanupError = error;
14658
+ }
14659
+ if (cleanupError && cleanupError?.code !== "ENOENT") {
14660
+ if (primaryError) {
14661
+ const cause = primaryError instanceof Error ? primaryError.message : String(primaryError);
14662
+ throw Object.assign(new Error(`${cause}; initialization lock cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`), { code: "E_IO", lockPath: file });
14663
+ }
14664
+ throw Object.assign(new Error(`History initialization lock cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`), { code: "E_IO", lockPath: file });
14665
+ }
14666
+ if (cleanupError && !primaryError) throw Object.assign(new Error(`History initialization finished, but its lock file disappeared while it ran: ${file}. Another process or a cleaner may have removed it, so exclusivity is not guaranteed for this run. Inspect the history with \`astra history <file>\` before relying on it.`), { code: "E_CONFLICT" });
14667
+ if (primaryError) throw primaryError;
14668
+ return result;
14669
+ }
14095
14670
  var SIDECAR_TOMBSTONE_FILENAME = ".astra-tombstones.json";
14096
14671
  var sidecarRegenWarnings = /* @__PURE__ */ new Map();
14097
14672
  function resolveDocIdentity(target) {
@@ -14211,7 +14786,7 @@ async function checkSidecarTombstone(target, repoDir) {
14211
14786
  const key = import_path3.default.resolve(repoDir);
14212
14787
  let hasGit = false;
14213
14788
  try {
14214
- hasGit = (await import_promises2.default.stat(import_path3.default.join(repoDir, ".git"))).isDirectory();
14789
+ hasGit = (await import_promises3.default.stat(import_path3.default.join(repoDir, ".git"))).isDirectory();
14215
14790
  } catch {
14216
14791
  hasGit = false;
14217
14792
  }
@@ -14305,14 +14880,14 @@ async function assertUsableSidecarGit(repoDir) {
14305
14880
  const gitPath = import_path3.default.join(repoDir, ".git");
14306
14881
  let entry;
14307
14882
  try {
14308
- entry = await import_promises2.default.lstat(gitPath);
14883
+ entry = await import_promises3.default.lstat(gitPath);
14309
14884
  } catch {
14310
14885
  return;
14311
14886
  }
14312
14887
  let usable = false;
14313
14888
  if (entry.isSymbolicLink()) {
14314
14889
  try {
14315
- usable = (await import_promises2.default.stat(gitPath)).isDirectory();
14890
+ usable = (await import_promises3.default.stat(gitPath)).isDirectory();
14316
14891
  } catch {
14317
14892
  usable = false;
14318
14893
  }
@@ -14330,7 +14905,7 @@ async function assertUsableSidecarGit(repoDir) {
14330
14905
  async function ensureAstraGitRepo(target) {
14331
14906
  const repoDir = isRepoDirTarget(target) ? target : getRepoDir(target);
14332
14907
  await assertUsableSidecarGit(repoDir);
14333
- await import_promises2.default.mkdir(repoDir, { recursive: true, mode: 448 });
14908
+ await import_promises3.default.mkdir(repoDir, { recursive: true, mode: 448 });
14334
14909
  if (process.platform !== "win32") {
14335
14910
  await chmod0700BestEffort(repoDir);
14336
14911
  const parent = import_path3.default.dirname(repoDir);
@@ -14346,17 +14921,44 @@ async function ensureAstraGitRepo(target) {
14346
14921
  }
14347
14922
  return repoDir;
14348
14923
  }
14349
- async function initAstraRepo(docPath, initialXml, extraFiles) {
14924
+ async function initAstraRepo(docPath, initialXml, extraFiles, originalBytes) {
14350
14925
  const repoDir = await ensureAstraGitRepo(docPath);
14351
- await import_isomorphic_git.default.init({ fs: import_fs4.default, dir: repoDir });
14352
- await writeAndAdd(repoDir, "document.xml", initialXml);
14353
- const writtenParts = await writeSnapshotParts(repoDir, extraFiles);
14354
- await applySnapshotMarker(repoDir, await snapshotIsProvenComplete(repoDir, extraFiles, writtenParts));
14355
- await import_isomorphic_git.default.commit({
14356
- fs: import_fs4.default,
14357
- dir: repoDir,
14358
- author: { name: "ASTra", email: "astra@attentionform.com" },
14359
- message: "Initial state"
14926
+ await withGitInitializationLock(repoDir, async () => {
14927
+ await import_isomorphic_git.default.init({ fs: import_fs4.default, dir: repoDir });
14928
+ let commits;
14929
+ try {
14930
+ commits = await import_isomorphic_git.default.log({ fs: import_fs4.default, dir: repoDir });
14931
+ } catch (error) {
14932
+ if (!await isUnbornHistory(repoDir)) throw error;
14933
+ commits = [];
14934
+ }
14935
+ if (commits.length > 0) {
14936
+ throw Object.assign(new Error(`History is already initialized at ${repoDir}.`), { code: "E_CONFLICT" });
14937
+ }
14938
+ await clearUncommittedPackageParts(repoDir);
14939
+ if (originalBytes) {
14940
+ const existing = await getOriginalInfo(import_path3.default.resolve(docPath));
14941
+ if (existing.available && existing.sha256 !== originalSha256(originalBytes)) {
14942
+ const snapshotPath = import_path3.default.join(repoDir, ".git", "astra-original", "original.snapshot");
14943
+ throw Object.assign(new Error(
14944
+ `History initialization refused: an interrupted earlier initialization left a retained original (sha256 ${existing.sha256}) that differs from this file (sha256 ${originalSha256(originalBytes)}), and no history was recorded.`
14945
+ ), {
14946
+ code: "E_CONFLICT",
14947
+ reason: "original-mismatch",
14948
+ remedy: `Nothing was changed. The retained original may be the only copy of the earlier document: keep ${snapshotPath} (copy or rename it, do not delete it; \`astra history <file> --export-original <out>\` can still write its bytes to a new file), then run \`astra history <file> --init\` again once it is out of the way.`
14949
+ });
14950
+ }
14951
+ }
14952
+ await captureOriginal(import_path3.default.resolve(docPath), originalBytes);
14953
+ await writeAndAdd(repoDir, "document.xml", initialXml);
14954
+ const writtenParts = await writeSnapshotParts(repoDir, extraFiles);
14955
+ await applySnapshotMarker(repoDir, await snapshotIsProvenComplete(repoDir, extraFiles, writtenParts));
14956
+ await import_isomorphic_git.default.commit({
14957
+ fs: import_fs4.default,
14958
+ dir: repoDir,
14959
+ author: { name: "ASTra", email: "astra@attentionform.com" },
14960
+ message: "Initial state"
14961
+ });
14360
14962
  });
14361
14963
  try {
14362
14964
  await checkSidecarTombstone(docPath, repoDir);
@@ -14368,6 +14970,7 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14368
14970
  const maxCommits = resolveHistoryMaxCommits(limits?.maxCommits);
14369
14971
  const maxSidecarBytes = resolveHistoryMaxSidecarBytes(limits?.maxSidecarBytes);
14370
14972
  const repoDir = await ensureAstraGitRepo(docPath);
14973
+ await assertGitHistoryReady(repoDir);
14371
14974
  const existingCommits = await getHistoryCommitCount(repoDir);
14372
14975
  if (existingCommits + 1 > maxCommits) {
14373
14976
  throw Object.assign(
@@ -14446,7 +15049,7 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14446
15049
  if (isTraversalName(rel)) continue;
14447
15050
  const fullPath = import_path3.default.join(repoDir, rel);
14448
15051
  try {
14449
- await import_promises2.default.unlink(fullPath);
15052
+ await import_promises3.default.unlink(fullPath);
14450
15053
  } catch {
14451
15054
  }
14452
15055
  try {
@@ -14469,8 +15072,27 @@ async function commitChange(docPath, xmlContent, message, extraFiles, deletedFil
14469
15072
  }
14470
15073
  return sha;
14471
15074
  }
15075
+ async function getHistoryInitializationState(docPath) {
15076
+ const repoDir = getRepoDir(docPath);
15077
+ try {
15078
+ if (await gitInitializationLockExists(repoDir)) {
15079
+ return { state: "in-progress", detail: "Another ASTra process holds the history initialization lock (or it was interrupted)." };
15080
+ }
15081
+ if (!import_fs4.default.existsSync(import_path3.default.join(repoDir, ".git"))) return { state: "absent" };
15082
+ await assertUsableSidecarGit(repoDir);
15083
+ const commits = await import_isomorphic_git.default.log({ fs: import_fs4.default, dir: repoDir });
15084
+ if (commits.length === 0) return { state: "incomplete", detail: "History initialization is incomplete: no commit was recorded." };
15085
+ const root = commits[commits.length - 1];
15086
+ const initial = await checkoutCommit(docPath, root.oid);
15087
+ if (!initial.complete) return { state: "incomplete", detail: "The first history commit is not a complete package snapshot." };
15088
+ return { state: "complete" };
15089
+ } catch (error) {
15090
+ return { state: "incomplete", detail: error instanceof Error ? error.message : String(error) };
15091
+ }
15092
+ }
14472
15093
  async function getHistory(docPath) {
14473
15094
  const repoDir = getRepoDir(docPath);
15095
+ await assertNoGitInitializationInProgress(repoDir);
14474
15096
  await assertUsableSidecarGit(repoDir);
14475
15097
  let commits;
14476
15098
  try {
@@ -14513,7 +15135,7 @@ async function listRepoPaths(repoDir) {
14513
15135
  async function walk(dir, relBase) {
14514
15136
  let entries = [];
14515
15137
  try {
14516
- entries = await import_promises2.default.readdir(dir, { withFileTypes: true });
15138
+ entries = await import_promises3.default.readdir(dir, { withFileTypes: true });
14517
15139
  } catch {
14518
15140
  return;
14519
15141
  }
@@ -14944,6 +15566,89 @@ function assertNoCellAddress(action, fields, ast) {
14944
15566
  // ../engine/src/operations/table.ts
14945
15567
  var import_xmldom12 = require("@xmldom/xmldom");
14946
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
14947
15652
  function isTableReproducible() {
14948
15653
  try {
14949
15654
  return resolveReproducibleMode(void 0);
@@ -15343,13 +16048,13 @@ async function executeTable(opIn, ast, guide) {
15343
16048
  afterIndex: Number(pick("afterIndex", op.afterIndex) ?? rows.length - 1),
15344
16049
  cells: cellsArray,
15345
16050
  index: Number(pick("index", op.index) ?? 0),
15346
- fill: String(pick("fill", op.fill) ?? "F7F9FC").replace(/^#/, ""),
15347
- headerFill: pick("headerFill", op.headerFill)?.replace(/^#/, ""),
16051
+ fill: String(resolveColourInput(pick("fill", op.fill) ?? "F7F9FC")).replace(/^#/, ""),
16052
+ headerFill: resolveColourInput(pick("headerFill", op.headerFill))?.replace(/^#/, ""),
15348
16053
  zebra: pick("zebra", op.zebra),
15349
16054
  // Explicit header/single-cell text color for setShading/setCellShading
15350
16055
  // (flat or nested in params; wins over the guide per the precedence
15351
16056
  // invariant). Validated below before any mutation.
15352
- textColor: pick("textColor", op.textColor)
16057
+ textColor: resolveColourInput(pick("textColor", op.textColor))
15353
16058
  };
15354
16059
  const warnings = [];
15355
16060
  const revisionIds = [];
@@ -15363,7 +16068,7 @@ async function executeTable(opIn, ast, guide) {
15363
16068
  const normText = String(P.textColor).replace(/^#/, "");
15364
16069
  if (!/^[0-9A-Fa-f]{6}$/.test(normText)) {
15365
16070
  throw new Error(
15366
- `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'.`
15367
16072
  );
15368
16073
  }
15369
16074
  explicitTextColor = normText.toUpperCase();
@@ -15945,16 +16650,8 @@ async function executeTable(opIn, ast, guide) {
15945
16650
  }
15946
16651
  case "setBorder": {
15947
16652
  const borderType = String(pick("border", void 0) || "all");
15948
- const color = String(pick("color", void 0) || "D1D5DB").replace(/^#/, "");
15949
- const rawSz = pick("sz", void 0) || raw.size || 4;
15950
- const szNum = Number(rawSz);
15951
- if (!Number.isInteger(szNum) || szNum < 1) {
15952
- throw toError(
15953
- "E_VALIDATION",
15954
- `setBorder sz must be a whole number of eighths of a point (got ${String(rawSz)}).`,
15955
- "Pass sz:4 for a 0.5 pt line or sz:8 for 1 pt."
15956
- );
15957
- }
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);
15958
16655
  const sz = String(szNum);
15959
16656
  const val = String(raw.val || "single");
15960
16657
  let tblPr = directChildren5(tblEl, "w:tblPr")[0];
@@ -16212,7 +16909,7 @@ async function executeTable(opIn, ast, guide) {
16212
16909
  );
16213
16910
  }
16214
16911
  const tc = resolveTableCell(tr, colIndex, "setCellShading");
16215
- const fillInput = raw.fill ?? op.fill;
16912
+ const fillInput = resolveColourInput(raw.fill ?? op.fill);
16216
16913
  if (fillInput === void 0) {
16217
16914
  throw new Error(
16218
16915
  `E_VALIDATION: setCellShading requires fill (got no fill). Remedy: pass fill as 6 hex digits without '#', e.g. fill:'FF0000'.`
@@ -16221,7 +16918,7 @@ async function executeTable(opIn, ast, guide) {
16221
16918
  const fill = String(fillInput).replace(/^#/, "");
16222
16919
  if (!/^[0-9A-Fa-f]{6}$/.test(fill)) {
16223
16920
  throw new Error(
16224
- `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'.`
16225
16922
  );
16226
16923
  }
16227
16924
  const normFill = fill.toUpperCase();
@@ -16530,19 +17227,20 @@ async function executeTable(opIn, ast, guide) {
16530
17227
  break;
16531
17228
  }
16532
17229
  case "setCellTextColor": {
16533
- const colorInput = raw.color ?? op.color ?? raw.textColor ?? op.textColor;
17230
+ const colorInput = resolveColourInput(raw.color ?? op.color ?? raw.textColor ?? op.textColor, { allowAuto: true });
16534
17231
  if (colorInput === void 0 || colorInput === null || String(colorInput).trim() === "") {
16535
17232
  throw new Error(
16536
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.`
16537
17234
  );
16538
17235
  }
16539
17236
  const color = String(colorInput).replace(/^#/, "");
16540
- if (!/^[0-9A-Fa-f]{6}$/.test(color)) {
17237
+ const autoColor = color === "auto";
17238
+ if (!autoColor && !/^[0-9A-Fa-f]{6}$/.test(color)) {
16541
17239
  throw new Error(
16542
- `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'.`
16543
17241
  );
16544
17242
  }
16545
- const normColor = color.toUpperCase();
17243
+ const normColor = autoColor ? "auto" : color.toUpperCase();
16546
17244
  const cellFillOf2 = (tc) => {
16547
17245
  const tcPr = directChildren5(tc, "w:tcPr")[0];
16548
17246
  const shd = tcPr ? directChildren5(tcPr, "w:shd")[0] : void 0;
@@ -16581,7 +17279,7 @@ async function executeTable(opIn, ast, guide) {
16581
17279
  }
16582
17280
  }
16583
17281
  }
16584
- 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}'`);
16585
17283
  };
16586
17284
  const tRowSrc = rowIndexAlias(raw, op);
16587
17285
  const tColSrc = colIndexAlias(raw, op);
@@ -17764,6 +18462,8 @@ function generateEmbeddedWorkbook(seriesList, categories, scatterSeriesXValues)
17764
18462
  var DEFAULT_SERIES_PALETTE = ["0066CC", "10B981", "F59E0B", "EF4444", "8B5CF6", "06B6D4", "EC4899", "84CC16"];
17765
18463
  function defaultSeriesColor(color, seriesIdx) {
17766
18464
  if (color) {
18465
+ const named = normalizeColour(color);
18466
+ if (named !== void 0) return named;
17767
18467
  const raw = color.replace(/^#/, "");
17768
18468
  if (isShorthandHex(raw)) return expandShorthandHex(raw);
17769
18469
  return raw;
@@ -18994,7 +19694,7 @@ function sameStrings(a, b) {
18994
19694
  }
18995
19695
  function normalizeHex(value) {
18996
19696
  if (typeof value !== "string") return void 0;
18997
- const raw = value.replace(/^#/, "").trim();
19697
+ const raw = (normalizeColour(value) ?? value.replace(/^#/, "")).trim();
18998
19698
  return raw.length > 0 ? raw.toUpperCase() : void 0;
18999
19699
  }
19000
19700
  function isNumericText(value) {
@@ -19237,7 +19937,7 @@ function patchSeriesList(xml, req, patches, scope) {
19237
19937
  const haveColor = normalizeHex(existing?.color);
19238
19938
  if (wantColor !== void 0 && wantColor !== haveColor) {
19239
19939
  const lineSeries = combo ? (patch.chartType ?? existing?.chartType) === "line" : baseLineSeries;
19240
- serXml = patchSeriesColor(serXml, patch.color.replace(/^#/, ""), lineSeries, scope);
19940
+ serXml = patchSeriesColor(serXml, normalizeColour(patch.color) ?? patch.color.replace(/^#/, ""), lineSeries, scope);
19241
19941
  }
19242
19942
  pieces.push(serXml);
19243
19943
  cursor = spans[i].end;
@@ -19746,7 +20446,7 @@ function withGuideSeriesColors(series, palette) {
19746
20446
  return series.map((s, i) => {
19747
20447
  const raw = typeof s?.color === "string" ? s.color.replace(/^#/, "") : "";
19748
20448
  const existing = isShorthandHex(raw) ? expandShorthandHex(raw) : raw;
19749
- 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;
19750
20450
  return { ...s, color: guidePaletteColor(palette, i, taken) };
19751
20451
  });
19752
20452
  }
@@ -21023,10 +21723,15 @@ var twipsOrPoints = import_zod2.z.union([
21023
21723
  import_zod2.z.number().finite(),
21024
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"')
21025
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;
21026
21727
  var fontSizeOrSzSchema = import_zod2.z.union([
21027
- import_zod2.z.number().finite().nonnegative(),
21028
- import_zod2.z.string().regex(FONT_SIZE_PT_REGEX, 'Font size string must match format like "12pt" or "10.5pt"')
21029
- ]);
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
+ }
21030
21735
  var styleProperties = import_zod2.z.object({
21031
21736
  bold: import_zod2.z.boolean().optional(),
21032
21737
  italic: import_zod2.z.boolean().optional(),
@@ -21035,11 +21740,11 @@ var styleProperties = import_zod2.z.object({
21035
21740
  doubleStrikethrough: import_zod2.z.boolean().optional(),
21036
21741
  vertAlign: import_zod2.z.enum(["superscript", "subscript", "baseline"]).optional(),
21037
21742
  alignment: import_zod2.z.enum(["left", "center", "right", "both", "justify"]).optional(),
21038
- color: import_zod2.z.string().max(32).optional(),
21743
+ color: colourField({ allowAuto: true }).optional(),
21039
21744
  fontSize: fontSizeOrSzSchema.optional(),
21040
21745
  sz: fontSizeOrSzSchema.optional(),
21041
21746
  fontFamily: import_zod2.z.string().max(128).optional(),
21042
- highlight: import_zod2.z.string().max(32).optional(),
21747
+ highlight: import_zod2.z.enum(HIGHLIGHT_COLOURS).optional(),
21043
21748
  lineSpacing: import_zod2.z.number().optional(),
21044
21749
  spacingBefore: twipsOrPoints.optional(),
21045
21750
  spacingAfter: twipsOrPoints.optional(),
@@ -21057,7 +21762,7 @@ var styleProperties = import_zod2.z.object({
21057
21762
  var insertChartSeries = import_zod2.z.object({
21058
21763
  name: import_zod2.z.string().max(500),
21059
21764
  values: import_zod2.z.array(import_zod2.z.number()).max(1e3),
21060
- 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()
21061
21766
  }).catchall(import_zod2.z.unknown());
21062
21767
  var insertWrapMode = import_zod2.z.enum(["inline", "square", "tight", "through", "topAndBottom", "behindText", "inFrontOfText"]);
21063
21768
  var tableCellPadding = import_zod2.z.object({
@@ -21094,7 +21799,7 @@ var insertOp = import_zod2.z.object({
21094
21799
  headers: import_zod2.z.array(import_zod2.z.string().max(1e4)).max(100).optional(),
21095
21800
  rows: import_zod2.z.array(import_zod2.z.array(import_zod2.z.string().max(1e4)).max(100)).max(1e3).optional(),
21096
21801
  cellPadding: import_zod2.z.union([import_zod2.z.number().int().nonnegative(), tableCellPadding]).optional(),
21097
- color: import_zod2.z.string().max(16).optional(),
21802
+ color: colourField({ allowAuto: true }).optional(),
21098
21803
  thickness: import_zod2.z.number().optional(),
21099
21804
  lineStyle: import_zod2.z.string().max(32).optional(),
21100
21805
  spacingBefore: twipsOrPoints.optional(),
@@ -21181,11 +21886,11 @@ var styleOp = import_zod2.z.object({
21181
21886
  doubleStrikethrough: import_zod2.z.boolean().optional(),
21182
21887
  vertAlign: import_zod2.z.enum(["superscript", "subscript", "baseline"]).optional(),
21183
21888
  alignment: import_zod2.z.enum(["left", "center", "right", "both", "justify"]).optional(),
21184
- color: import_zod2.z.string().max(32).optional(),
21889
+ color: colourField({ allowAuto: true }).optional(),
21185
21890
  fontSize: fontSizeOrSzSchema.optional(),
21186
21891
  sz: fontSizeOrSzSchema.optional(),
21187
21892
  fontFamily: import_zod2.z.string().max(128).optional(),
21188
- highlight: import_zod2.z.string().max(32).optional(),
21893
+ highlight: import_zod2.z.enum(HIGHLIGHT_COLOURS).optional(),
21189
21894
  lineSpacing: import_zod2.z.number().optional(),
21190
21895
  spacingBefore: twipsOrPoints.optional(),
21191
21896
  spacingAfter: twipsOrPoints.optional(),
@@ -21262,20 +21967,20 @@ var tableOp = import_zod2.z.object({
21262
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(),
21263
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(),
21264
21969
  index: import_zod2.z.number().int().describe("deleteRow/deleteColumn: the row or column number to remove (counting from 1).").optional(),
21265
- fill: import_zod2.z.string().max(16).optional(),
21266
- headerFill: import_zod2.z.string().max(16).optional(),
21970
+ fill: colourField({}, 32).optional(),
21971
+ headerFill: colourField({}, 32).optional(),
21267
21972
  zebra: import_zod2.z.boolean().optional(),
21268
21973
  // Explicit header/single-cell text color for setShading/setCellShading
21269
21974
  // (flat or params-nested; wins over the guide per the precedence invariant).
21270
- textColor: import_zod2.z.string().max(16).optional(),
21975
+ textColor: colourField({}, 32).optional(),
21271
21976
  widths: import_zod2.z.array(import_zod2.z.number()).max(100).optional(),
21272
21977
  columnWidths: import_zod2.z.array(import_zod2.z.number()).max(100).optional(),
21273
21978
  width: import_zod2.z.number().int().positive().optional(),
21274
21979
  height: import_zod2.z.number().int().positive().optional(),
21275
21980
  rule: import_zod2.z.enum(["atLeast", "exact", "auto"]).optional(),
21276
21981
  border: import_zod2.z.string().max(32).optional(),
21277
- color: import_zod2.z.string().max(16).optional(),
21278
- sz: fontSizeOrSzSchema.optional(),
21982
+ color: colourField({ allowAuto: true }).optional(),
21983
+ sz: borderSizeSchema.optional(),
21279
21984
  vAlign: import_zod2.z.string().max(16).optional(),
21280
21985
  align: tableAlignmentSchema.optional(),
21281
21986
  alignment: tableAlignmentSchema.optional(),
@@ -21395,7 +22100,7 @@ function chartHoleSizeSchemaRemedy(detail) {
21395
22100
  var chartSeries = import_zod2.z.object({
21396
22101
  name: import_zod2.z.string().max(500),
21397
22102
  values: import_zod2.z.array(import_zod2.z.number()).max(1e3),
21398
- 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(),
21399
22104
  chartType: import_zod2.z.enum(["column", "line"]).optional(),
21400
22105
  axis: import_zod2.z.enum(["primary", "secondary"]).optional()
21401
22106
  }).catchall(import_zod2.z.unknown());
@@ -21521,7 +22226,7 @@ var watermarkSizeSchema = import_zod2.z.union([import_zod2.z.number().finite().n
21521
22226
  var watermarkOp = import_zod2.z.object({
21522
22227
  action: import_zod2.z.literal("watermark"),
21523
22228
  text: import_zod2.z.string().max(500).optional(),
21524
- 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(),
21525
22230
  fontSize: watermarkSizeSchema.optional(),
21526
22231
  sz: watermarkSizeSchema.optional(),
21527
22232
  opacity: import_zod2.z.number().min(0).max(1).optional(),
@@ -21531,9 +22236,9 @@ var watermarkOp = import_zod2.z.object({
21531
22236
  var pageBorderOp = import_zod2.z.object({
21532
22237
  action: import_zod2.z.literal("pageBorder"),
21533
22238
  style: import_zod2.z.string().max(32).optional(),
21534
- sz: fontSizeOrSzSchema.optional(),
22239
+ sz: borderSizeSchema.optional(),
21535
22240
  fontSize: fontSizeOrSzSchema.optional(),
21536
- color: import_zod2.z.string().max(16).optional(),
22241
+ color: colourField({ allowAuto: true }).optional(),
21537
22242
  marginOffset: import_zod2.z.enum(["page", "text"]).optional(),
21538
22243
  sectionIndex: sectionNumber.optional(),
21539
22244
  expectedVersion
@@ -21871,6 +22576,32 @@ var KEY_WORDS = {
21871
22576
  };
21872
22577
  var MAX_KEYS_LISTED = 60;
21873
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
+ }
21874
22605
  function actionShapes() {
21875
22606
  if (cachedShapes) return cachedShapes;
21876
22607
  const shapes = [];
@@ -21878,7 +22609,10 @@ function actionShapes() {
21878
22609
  for (const option of options) {
21879
22610
  const literal = option.shape["action"]?.def?.values?.[0];
21880
22611
  if (typeof literal !== "string") continue;
21881
- 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);
21882
22616
  }
21883
22617
  cachedShapes = shapes;
21884
22618
  return shapes;
@@ -21923,9 +22657,17 @@ function hintFor(operations, issue) {
21923
22657
  const given = op["action"];
21924
22658
  if (typeof given !== "string") return void 0;
21925
22659
  const actions = shapes.map((s) => s.action);
21926
- const near = closestNames(given, actions, ACTION_WORDS);
22660
+ let near = closestNames(given, actions, ACTION_WORDS);
22661
+ if (near.length === 0) near = verbPrefixCandidates(given, actions);
21927
22662
  const parts = [`${label}: "${given}" is not an action.`];
21928
- 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
+ }
21929
22671
  parts.push(`Actions: ${actions.join(", ")}.`);
21930
22672
  return parts.join(" ");
21931
22673
  }
@@ -22751,16 +23493,14 @@ function applyRunProps(doc, run, props) {
22751
23493
  vaEl.setAttribute("w:val", props.vertAlign);
22752
23494
  }
22753
23495
  }
22754
- 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 });
22755
23498
  let colorEl = rPr.getElementsByTagName("w:color")[0];
22756
23499
  if (!colorEl) {
22757
23500
  colorEl = createWordElement(doc, "w:color");
22758
23501
  rPr.appendChild(colorEl);
22759
23502
  }
22760
- const hex = String(props.color).replace(/^#/, "").trim();
22761
- if (hex) {
22762
- colorEl.setAttribute("w:val", hex);
22763
- }
23503
+ colorEl.setAttribute("w:val", hex);
22764
23504
  }
22765
23505
  const rawSz = props.sz !== void 0 ? props.sz : props.fontSize;
22766
23506
  if (rawSz !== void 0 && rawSz !== null) {
@@ -23276,9 +24016,9 @@ function executeStyle(op, ast) {
23276
24016
  pBdr.appendChild(el);
23277
24017
  }
23278
24018
  el.setAttribute("w:val", config.style || "single");
23279
- el.setAttribute("w:sz", String(config.sz ?? 12));
24019
+ el.setAttribute("w:sz", String(requireBorderSize(`${side} border sz`, config.sz, 12)));
23280
24020
  el.setAttribute("w:space", "1");
23281
- el.setAttribute("w:color", (config.color || "CBD5E1").replace(/^#/, ""));
24021
+ el.setAttribute("w:color", config.color ? requireColour("color", config.color, { allowAuto: true }) : "CBD5E1");
23282
24022
  };
23283
24023
  if (props.borderBox) {
23284
24024
  applyBdr("top", props.borderBox);
@@ -26277,7 +27017,7 @@ async function executeInsert(op, ast, parser2, guide, paraIdCache) {
26277
27017
  const pBdr = createWordElement(doc, "w:pBdr");
26278
27018
  const bottom = createWordElement(doc, "w:bottom");
26279
27019
  const lineStyle = op.lineStyle || "single";
26280
- const color = (op.color || "CBD5E1").replace(/^#/, "");
27020
+ const color = op.color ? requireColour("color", op.color, { allowAuto: true }) : "CBD5E1";
26281
27021
  const thickness = op.thickness !== void 0 ? String(op.thickness) : "12";
26282
27022
  bottom.setAttribute("w:val", lineStyle);
26283
27023
  bottom.setAttribute("w:sz", thickness);
@@ -31025,7 +31765,7 @@ function executeEndnote(op, ast, parser2) {
31025
31765
  function executeWatermark(op, ast, parser2) {
31026
31766
  const rawText = (op.text || "CONFIDENTIAL").trim();
31027
31767
  const text2 = rawText.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
31028
- const color = /^[0-9A-Fa-f]{6}$/.test((op.color || "").replace(/^#/, "")) ? op.color.replace(/^#/, "") : "D1D5DB";
31768
+ const color = normalizeColour(op.color) ?? "D1D5DB";
31029
31769
  const fontSize = op.fontSize || 60;
31030
31770
  const rotation = op.rotation !== void 0 ? op.rotation : 315;
31031
31771
  const watermarkXml = `<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -31143,8 +31883,8 @@ function executePageBorder(op, ast, parser2) {
31143
31883
  const pgBorders = upsertSectPrChild(sectPr, "w:pgBorders");
31144
31884
  pgBorders.setAttribute("w:offsetFrom", op.marginOffset || "page");
31145
31885
  const style = op.style || "single";
31146
- const sz = String(op.sz || 12);
31147
- 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";
31148
31888
  ["top", "left", "bottom", "right"].forEach((side) => {
31149
31889
  let sideEl = pgBorders.getElementsByTagName(`w:${side}`)[0];
31150
31890
  if (!sideEl) {
@@ -31666,6 +32406,7 @@ async function executeMetadata(op, ast, parser2) {
31666
32406
  }
31667
32407
 
31668
32408
  // ../engine/src/operation-engine.ts
32409
+ var COMPOSE_ID_IN_MESSAGE = /\b(md\d*_\d{4,})\b/;
31669
32410
  var ENGINE_MAX_OPS = 500;
31670
32411
  var ENGINE_MAX_BYTES = 200 * 1024 * 1024;
31671
32412
  var EngineQuotaError = class extends Error {
@@ -32465,6 +33206,10 @@ var OperationEngine = class _OperationEngine {
32465
33206
  }
32466
33207
  }
32467
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
+ }
32468
33213
  if (errorMessage.includes("not found") && !errorMessage.includes("not found in node")) {
32469
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.`;
32470
33215
  }
@@ -34088,12 +34833,12 @@ $$` : `[Equation]
34088
34833
  // ../engine/src/toon.ts
34089
34834
  var TOON_DEFAULT_FIDELITY = "standard";
34090
34835
  function isToonFidelity(value) {
34091
- return value === "compact" || value === "standard" || value === "full";
34836
+ return value === "outline" || value === "compact" || value === "standard" || value === "full";
34092
34837
  }
34093
34838
  function assertToonFidelity(value) {
34094
34839
  if (!isToonFidelity(value)) {
34095
34840
  throw new Error(
34096
- `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.`
34097
34842
  );
34098
34843
  }
34099
34844
  }
@@ -34605,6 +35350,15 @@ function resolveSectionScope(sections, sectionId) {
34605
35350
  if (sectionId.length >= MIN_NODE_ID_PREFIX_LENGTH) return pick((id) => id.startsWith(sectionId));
34606
35351
  return [];
34607
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;
34608
35362
  var ToonGenerator = class _ToonGenerator {
34609
35363
  cache = /* @__PURE__ */ new Map();
34610
35364
  checksumCache = /* @__PURE__ */ new Map();
@@ -34722,7 +35476,10 @@ var ToonGenerator = class _ToonGenerator {
34722
35476
  }
34723
35477
  const sectionPaths = this.buildSectionPaths(ast.sections);
34724
35478
  const maxLen = options?.maxTextLength;
34725
- 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) {
34726
35483
  const sectionPath = sectionPaths.get(section.id) || "Root";
34727
35484
  const sectionHeader = section.heading?.text || "";
34728
35485
  for (const node of section.nodes) {
@@ -34762,7 +35519,7 @@ var ToonGenerator = class _ToonGenerator {
34762
35519
  rows.push(row);
34763
35520
  }
34764
35521
  }
34765
- 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"];
34766
35523
  const table = {
34767
35524
  headers,
34768
35525
  rows,
@@ -34821,6 +35578,20 @@ var ToonGenerator = class _ToonGenerator {
34821
35578
  if (fidelity !== "compact") {
34822
35579
  combined += `${fingerprintFmt(n)};`;
34823
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
+ }
34824
35595
  }
34825
35596
  }
34826
35597
  try {
@@ -34852,6 +35623,42 @@ var ToonGenerator = class _ToonGenerator {
34852
35623
  }
34853
35624
  return combined;
34854
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
+ }
34855
35662
  buildSectionPaths(sections) {
34856
35663
  const paths = /* @__PURE__ */ new Map();
34857
35664
  const hierarchy = [];
@@ -34988,7 +35795,58 @@ var ToonGenerator = class _ToonGenerator {
34988
35795
  if (node.metadata.listLevel !== void 0) flags.push(`[L${node.metadata.listLevel + 1}]`);
34989
35796
  return flags.join(" ");
34990
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
+ }
34991
35848
  renderToonText(table, options) {
35849
+ if (table.tier === "outline") return this.renderOutlineText(table, options);
34992
35850
  const omits = new Set(options?.omitColumns ?? []);
34993
35851
  const compact = options?.compact ?? false;
34994
35852
  const tier = table.tier ?? "compact";
@@ -35036,40 +35894,8 @@ var ToonGenerator = class _ToonGenerator {
35036
35894
  rowParts.push(row.detail ?? "");
35037
35895
  }
35038
35896
  }
35039
- const sep5 = compact ? "|" : " | ";
35040
- const rawWidth = options?.width;
35041
- const width = typeof rawWidth === "number" && Number.isFinite(rawWidth) && rawWidth >= 1 ? Math.floor(rawWidth) : void 0;
35042
- if (width === void 0) {
35043
- output += `${rowParts.join(sep5)}
35044
- `;
35045
- continue;
35046
- }
35047
35897
  const textPos = 2 + (omits.has("path") ? 0 : 1) + (omits.has("header") ? 0 : 1) + (omits.has("style") ? 0 : 1);
35048
- const prefix = `${rowParts.slice(0, textPos).join(sep5)}${sep5}`;
35049
- const tail = rowParts.slice(textPos + 1);
35050
- const suffix = tail.length > 0 ? `${sep5}${tail.join(sep5)}` : "";
35051
- const prefixLen = displayLen(prefix);
35052
- const budget = width - prefixLen - displayLen(suffix);
35053
- if (budget <= 0) {
35054
- output += `${rowParts.join(sep5)}
35055
- `;
35056
- continue;
35057
- }
35058
- const segments = wrapTextCell(textVal, budget);
35059
- if (segments.length <= 1) {
35060
- output += `${rowParts.join(sep5)}
35061
- `;
35062
- continue;
35063
- }
35064
- const indent = " ".repeat(prefixLen);
35065
- output += `${prefix}${segments[0]}
35066
- `;
35067
- for (let k = 1; k < segments.length - 1; k++) {
35068
- output += `${indent}${segments[k]}
35069
- `;
35070
- }
35071
- output += `${indent}${segments[segments.length - 1]}${suffix}
35072
- `;
35898
+ output += this.renderRowLine(rowParts, textPos, compact ? "|" : " | ", textVal, options?.width);
35073
35899
  }
35074
35900
  return output;
35075
35901
  }
@@ -35195,7 +36021,7 @@ var ToonGenerator = class _ToonGenerator {
35195
36021
  // ../engine/src/tmp.ts
35196
36022
  var import_crypto5 = __toESM(require("crypto"));
35197
36023
  var import_fs7 = __toESM(require("fs"));
35198
- var import_os = __toESM(require("os"));
36024
+ var import_os2 = __toESM(require("os"));
35199
36025
  var import_path6 = __toESM(require("path"));
35200
36026
  var ASTRA_TMPDIR_ENV = "ASTRA_TMPDIR";
35201
36027
  var TMP_SYMLINK_REMEDY = "Point $ASTRA_TMPDIR at a real directory (not a symlink), or unset it and retry.";
@@ -35251,7 +36077,7 @@ function resolveAstraTmpBase() {
35251
36077
  try {
35252
36078
  import_fs7.default.mkdirSync(dir, { recursive: true });
35253
36079
  } catch {
35254
- return import_os.default.tmpdir();
36080
+ return import_os2.default.tmpdir();
35255
36081
  }
35256
36082
  refuseSymlinkBase(dir);
35257
36083
  refuseForeignOwner(dir);
@@ -35261,7 +36087,7 @@ function resolveAstraTmpBase() {
35261
36087
  }
35262
36088
  return dir;
35263
36089
  }
35264
- const fallback = import_os.default.tmpdir();
36090
+ const fallback = import_os2.default.tmpdir();
35265
36091
  const injected = [process.env.TMPDIR, process.env.TEMP, process.env.TMP].filter((v) => typeof v === "string" && v.trim() !== "").map((v) => import_path6.default.resolve(v.trim()));
35266
36092
  if (injected.includes(import_path6.default.resolve(fallback))) {
35267
36093
  const dir = import_path6.default.resolve(fallback);
@@ -35301,12 +36127,12 @@ function logForensics(p, reason) {
35301
36127
  try {
35302
36128
  const rawName = import_path6.default.basename(p);
35303
36129
  const name2 = rawName !== "" ? rawName : "artifact";
35304
- let hash = "unknown";
36130
+ let hash2 = "unknown";
35305
36131
  try {
35306
- hash = import_crypto5.default.createHash("sha256").update(p).digest("hex").slice(0, 16);
36132
+ hash2 = import_crypto5.default.createHash("sha256").update(p).digest("hex").slice(0, 16);
35307
36133
  } catch {
35308
36134
  }
35309
- process.stderr.write(`[ASTra] tmp artifact kept for forensics: name="${name2}" sha256="${hash}" (${reason}). Base honors $ASTRA_TMPDIR; remove manually after inspection.
36135
+ process.stderr.write(`[ASTra] tmp artifact kept for forensics: name="${name2}" sha256="${hash2}" (${reason}). Base honors $ASTRA_TMPDIR; remove manually after inspection.
35310
36136
  `);
35311
36137
  } catch {
35312
36138
  }
@@ -35370,12 +36196,12 @@ function logForensicPrune(target, reason) {
35370
36196
  try {
35371
36197
  const rawName = import_path6.default.basename(target);
35372
36198
  const name2 = rawName !== "" ? rawName : "artifact";
35373
- let hash = "unknown";
36199
+ let hash2 = "unknown";
35374
36200
  try {
35375
- hash = import_crypto5.default.createHash("sha256").update(target).digest("hex").slice(0, 16);
36201
+ hash2 = import_crypto5.default.createHash("sha256").update(target).digest("hex").slice(0, 16);
35376
36202
  } catch {
35377
36203
  }
35378
- process.stderr.write(`[ASTra] tmp forensics prune: name="${name2}" sha256="${hash}" (${reason}).
36204
+ process.stderr.write(`[ASTra] tmp forensics prune: name="${name2}" sha256="${hash2}" (${reason}).
35379
36205
  `);
35380
36206
  } catch {
35381
36207
  }
@@ -37611,9 +38437,9 @@ function escapeHtml(str) {
37611
38437
 
37612
38438
  // ../engine/src/export/pdf.ts
37613
38439
  var import_child_process = require("child_process");
37614
- var fs8 = __toESM(require("fs"));
37615
- var fsp3 = __toESM(require("fs/promises"));
37616
- var path7 = __toESM(require("path"));
38440
+ var fs10 = __toESM(require("fs"));
38441
+ var fsp4 = __toESM(require("fs/promises"));
38442
+ var path9 = __toESM(require("path"));
37617
38443
  var import_url = require("url");
37618
38444
 
37619
38445
  // ../engine/src/export/preflight.ts
@@ -37954,6 +38780,136 @@ function runPreflightChecks(docx) {
37954
38780
  return { blockers, warnings };
37955
38781
  }
37956
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
+
37957
38913
  // ../engine/src/export/pdf.ts
37958
38914
  var DependencyError = class extends Error {
37959
38915
  code = "E_DEPENDENCY";
@@ -37999,19 +38955,15 @@ function getPdfBackendBinary() {
37999
38955
  if (cachedBackendBinary !== void 0) {
38000
38956
  return cachedBackendBinary;
38001
38957
  }
38002
- const candidates = ["libreoffice", "soffice"];
38003
- for (const bin of candidates) {
38004
- try {
38005
- (0, import_child_process.execSync)(`which ${bin}`, { stdio: "ignore" });
38006
- cachedBackendBinary = bin;
38007
- return bin;
38008
- } catch {
38009
- }
38958
+ const found = findOnPath("soffice") ?? findOnPath("libreoffice");
38959
+ if (found) {
38960
+ cachedBackendBinary = found;
38961
+ return found;
38010
38962
  }
38011
38963
  cachedBackendBinary = null;
38012
38964
  return null;
38013
38965
  }
38014
- function hasPdfBackend() {
38966
+ function hasPdfBackend2() {
38015
38967
  return getPdfBackendBinary() !== null;
38016
38968
  }
38017
38969
  async function convertDocxToPdf(docxBuffer, outPdfPath, options) {
@@ -38022,7 +38974,8 @@ async function convertDocxToPdf(docxBuffer, outPdfPath, options) {
38022
38974
  Debian/Ubuntu: sudo apt install libreoffice
38023
38975
  Fedora/RHEL: sudo dnf install libreoffice
38024
38976
  macOS: brew install --cask libreoffice
38025
- 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).`
38026
38979
  );
38027
38980
  }
38028
38981
  const preflight = runPreflightChecks(docxBuffer);
@@ -38072,7 +39025,7 @@ var MAX_CONVERT_ATTEMPTS = 3;
38072
39025
  async function removeStagedDir(dir) {
38073
39026
  for (let attempt = 1; attempt <= 2; attempt++) {
38074
39027
  try {
38075
- await fsp3.rm(dir, { recursive: true, force: true });
39028
+ await fsp4.rm(dir, { recursive: true, force: true });
38076
39029
  return;
38077
39030
  } catch {
38078
39031
  if (attempt < 2) {
@@ -38084,10 +39037,10 @@ async function removeStagedDir(dir) {
38084
39037
  }
38085
39038
  async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, binary, stagedForensics) {
38086
39039
  const tmpDir = secureMkdtemp("astra-pdf-");
38087
- const tmpDocxPath = path7.join(tmpDir, `doc_${Date.now()}.docx`);
39040
+ const tmpDocxPath = path9.join(tmpDir, `doc_${Date.now()}.docx`);
38088
39041
  let succeeded = false;
38089
39042
  try {
38090
- await fsp3.writeFile(tmpDocxPath, docxBuffer);
39043
+ await fsp4.writeFile(tmpDocxPath, docxBuffer);
38091
39044
  secureFile(tmpDocxPath);
38092
39045
  let converted = false;
38093
39046
  let lastError = null;
@@ -38096,7 +39049,7 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38096
39049
  await new Promise((resolve10, reject) => {
38097
39050
  const child = (0, import_child_process.spawn)(
38098
39051
  binary,
38099
- [`-env:UserInstallation=${(0, import_url.pathToFileURL)(path7.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],
38100
39053
  { stdio: ["ignore", "pipe", "pipe"] }
38101
39054
  );
38102
39055
  let stderr = "";
@@ -38132,23 +39085,23 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38132
39085
  if (!converted && lastError) {
38133
39086
  throw lastError;
38134
39087
  }
38135
- const expectedPdfName = path7.basename(tmpDocxPath).replace(/\.docx$/i, ".pdf");
38136
- const generatedPdfPath = path7.join(tmpDir, expectedPdfName);
38137
- if (!fs8.existsSync(generatedPdfPath)) {
39088
+ const expectedPdfName = path9.basename(tmpDocxPath).replace(/\.docx$/i, ".pdf");
39089
+ const generatedPdfPath = path9.join(tmpDir, expectedPdfName);
39090
+ if (!fs10.existsSync(generatedPdfPath)) {
38138
39091
  throw new Error(`PDF conversion failed: output file ${generatedPdfPath} was not created`);
38139
39092
  }
38140
- const targetDir = path7.dirname(path7.resolve(outPdfPath));
38141
- assertWriteTargetNoSymlink(path7.resolve(outPdfPath));
38142
- await fsp3.mkdir(targetDir, { recursive: true });
38143
- assertWriteTargetNoSymlink(path7.resolve(outPdfPath));
38144
- await writeFileNoFollow(path7.resolve(outPdfPath), await fsp3.readFile(generatedPdfPath));
38145
- fs8.chmodSync(path7.resolve(outPdfPath), 420);
39093
+ const targetDir = path9.dirname(path9.resolve(outPdfPath));
39094
+ assertWriteTargetNoSymlink(path9.resolve(outPdfPath));
39095
+ await fsp4.mkdir(targetDir, { recursive: true });
39096
+ assertWriteTargetNoSymlink(path9.resolve(outPdfPath));
39097
+ await writeFileNoFollow(path9.resolve(outPdfPath), await fsp4.readFile(generatedPdfPath));
39098
+ fs10.chmodSync(path9.resolve(outPdfPath), 420);
38146
39099
  succeeded = true;
38147
- return path7.resolve(outPdfPath);
39100
+ return path9.resolve(outPdfPath);
38148
39101
  } finally {
38149
39102
  if (succeeded) {
38150
39103
  try {
38151
- await fsp3.rm(tmpDir, { recursive: true, force: true });
39104
+ await fsp4.rm(tmpDir, { recursive: true, force: true });
38152
39105
  } catch {
38153
39106
  keepForForensics(tmpDir, "could not remove tmp dir after success");
38154
39107
  }
@@ -38160,86 +39113,12 @@ async function convertWithFilter(docxBuffer, outPdfPath, filterSpec, options, bi
38160
39113
  }
38161
39114
  }
38162
39115
 
38163
- // ../engine/src/visual/backend.ts
38164
- var fs9 = __toESM(require("fs"));
38165
- var path8 = __toESM(require("path"));
38166
- 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).";
38167
- function createDependencyError(missing) {
38168
- const which = missing.length > 0 ? missing.join(", ") : "soffice, pdftoppm";
38169
- return Object.assign(new Error(`E_DEPENDENCY: headless visual backend unavailable (missing: ${which})`), {
38170
- code: "E_DEPENDENCY",
38171
- remedy: VISUAL_DEPENDENCY_REMEDY
38172
- });
38173
- }
38174
- function findOnPath(name2, pathEnv) {
38175
- try {
38176
- if (!name2 || name2.trim() === "") return null;
38177
- const trimmed = name2.trim();
38178
- if (trimmed.includes("/") || trimmed.includes("\\")) {
38179
- try {
38180
- const abs = path8.resolve(trimmed);
38181
- fs9.accessSync(abs, fs9.constants.X_OK);
38182
- const stat3 = fs9.statSync(abs);
38183
- if (stat3.isFile()) return abs;
38184
- return null;
38185
- } catch {
38186
- return null;
38187
- }
38188
- }
38189
- const pathValue = pathEnv !== void 0 ? pathEnv : process.env.PATH ?? "";
38190
- if (!pathValue) return null;
38191
- const dirs = pathValue.split(path8.delimiter).filter((d) => d.length > 0);
38192
- for (const dir of dirs) {
38193
- try {
38194
- const candidate = path8.join(dir, trimmed);
38195
- fs9.accessSync(candidate, fs9.constants.X_OK);
38196
- const stat3 = fs9.statSync(candidate);
38197
- if (stat3.isFile()) return candidate;
38198
- } catch {
38199
- continue;
38200
- }
38201
- }
38202
- return null;
38203
- } catch {
38204
- return null;
38205
- }
38206
- }
38207
- function resolveSoffice(override, pathEnv) {
38208
- if (override && override.trim()) {
38209
- const direct = findOnPath(override.trim(), pathEnv);
38210
- if (direct) return direct;
38211
- return null;
38212
- }
38213
- return findOnPath("soffice", pathEnv) ?? findOnPath("libreoffice", pathEnv);
38214
- }
38215
- function hasPdfBackend2(override) {
38216
- const pathEnv = override?.pathEnv;
38217
- const sofficePath = resolveSoffice(override?.sofficeBin, pathEnv);
38218
- let pdftoppmPath = null;
38219
- if (override?.pdftoppmBin && override.pdftoppmBin.trim()) {
38220
- pdftoppmPath = findOnPath(override.pdftoppmBin.trim(), pathEnv);
38221
- } else {
38222
- pdftoppmPath = findOnPath("pdftoppm", pathEnv);
38223
- }
38224
- const missing = [];
38225
- if (!sofficePath) missing.push("soffice");
38226
- if (!pdftoppmPath) missing.push("pdftoppm");
38227
- if (missing.length === 0) {
38228
- return { available: true, backend: "libreoffice", sofficePath, pdftoppmPath, missing: [] };
38229
- }
38230
- return { available: false, backend: "none", missing };
38231
- }
38232
- function assertPdfBackend(override) {
38233
- const status = hasPdfBackend2(override);
38234
- if (!status.available) {
38235
- throw createDependencyError(status.missing);
38236
- }
38237
- return { sofficePath: status.sofficePath, pdftoppmPath: status.pdftoppmPath };
38238
- }
39116
+ // ../engine/src/export/pdf-source.ts
39117
+ var STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
38239
39118
 
38240
39119
  // ../engine/src/visual/renderer.ts
38241
- var fs10 = __toESM(require("fs"));
38242
- var path9 = __toESM(require("path"));
39120
+ var fs11 = __toESM(require("fs"));
39121
+ var path10 = __toESM(require("path"));
38243
39122
  var import_url2 = require("url");
38244
39123
  var import_child_process2 = require("child_process");
38245
39124
  var import_util = require("util");
@@ -38250,13 +39129,13 @@ var VISUAL_DEFAULT_MAX_PAGES = 4;
38250
39129
  var VISUAL_MIN_DPI = 72;
38251
39130
  var VISUAL_MAX_DPI = 300;
38252
39131
  function getVisualArtifactsBaseDir() {
38253
- const dir = path9.join(resolveAstraTmpBase(), "ASTra", "visual");
39132
+ const dir = path10.join(resolveAstraTmpBase(), "ASTra", "visual");
38254
39133
  try {
38255
- fs10.mkdirSync(dir, { recursive: true, mode: 448 });
39134
+ fs11.mkdirSync(dir, { recursive: true, mode: 448 });
38256
39135
  } catch {
38257
39136
  }
38258
39137
  try {
38259
- secureDir(path9.join(resolveAstraTmpBase(), "ASTra"));
39138
+ secureDir(path10.join(resolveAstraTmpBase(), "ASTra"));
38260
39139
  } catch {
38261
39140
  }
38262
39141
  secureDir(dir);
@@ -38266,9 +39145,9 @@ function getVisualArtifactsDir(subDir) {
38266
39145
  const base = getVisualArtifactsBaseDir();
38267
39146
  if (!subDir || subDir.trim() === "") return base;
38268
39147
  const safe = subDir.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 64) || "run";
38269
- const dir = path9.join(base, safe);
39148
+ const dir = path10.join(base, safe);
38270
39149
  try {
38271
- fs10.mkdirSync(dir, { recursive: true, mode: 448 });
39150
+ fs11.mkdirSync(dir, { recursive: true, mode: 448 });
38272
39151
  } catch {
38273
39152
  }
38274
39153
  secureDir(dir);
@@ -38302,7 +39181,7 @@ function parsePageNumber(fileName) {
38302
39181
  function listRenderedPages(workDir, prefixBase) {
38303
39182
  let entries = [];
38304
39183
  try {
38305
- entries = fs10.readdirSync(workDir);
39184
+ entries = fs11.readdirSync(workDir);
38306
39185
  } catch {
38307
39186
  return [];
38308
39187
  }
@@ -38311,7 +39190,7 @@ function listRenderedPages(workDir, prefixBase) {
38311
39190
  if (!name2.startsWith(prefixBase)) continue;
38312
39191
  const page = parsePageNumber(name2);
38313
39192
  if (page === null) continue;
38314
- out.push({ page, file: path9.join(workDir, name2) });
39193
+ out.push({ page, file: path10.join(workDir, name2) });
38315
39194
  }
38316
39195
  out.sort((a, b) => a.page - b.page);
38317
39196
  return out;
@@ -38325,7 +39204,7 @@ function sandboxError(message, remedy) {
38325
39204
  function canonicalVisualBaseDir() {
38326
39205
  const base = getVisualArtifactsBaseDir();
38327
39206
  try {
38328
- return fs10.realpathSync(base);
39207
+ return fs11.realpathSync(base);
38329
39208
  } catch {
38330
39209
  return base;
38331
39210
  }
@@ -38349,7 +39228,7 @@ function resolveJailedVisualOutDir(rawOutDir) {
38349
39228
  if (trimmed.includes("\n") || trimmed.includes("\r")) {
38350
39229
  throw sandboxError("E_SANDBOX: visual outDir contains a forbidden line break.", "Remove line breaks from the path and retry.");
38351
39230
  }
38352
- const resolved = path9.resolve(trimmed);
39231
+ const resolved = path10.resolve(trimmed);
38353
39232
  const canonical = canonicalizeForAsset(resolved);
38354
39233
  const canonicalBase = canonicalVisualBaseDir();
38355
39234
  assertVisualInsideBase(canonical, trimmed, canonicalBase);
@@ -38381,7 +39260,7 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38381
39260
  outDir = getVisualArtifactsDir(runId);
38382
39261
  }
38383
39262
  try {
38384
- fs10.mkdirSync(outDir, { recursive: true });
39263
+ fs11.mkdirSync(outDir, { recursive: true });
38385
39264
  } catch (err) {
38386
39265
  const msg = err instanceof Error ? err.message : String(err);
38387
39266
  throw validationError(`E_VALIDATION: cannot create artifacts dir "${outDir}": ${msg}`, "Pass a writable outDir and retry.");
@@ -38392,16 +39271,16 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38392
39271
  outDir = live;
38393
39272
  }
38394
39273
  const workDir = secureMkdtemp("astra-visual-");
38395
- const inputDocx = path9.join(workDir, "input.docx");
39274
+ const inputDocx = path10.join(workDir, "input.docx");
38396
39275
  let succeeded = false;
38397
39276
  try {
38398
- fs10.writeFileSync(inputDocx, docxBuffer);
39277
+ fs11.writeFileSync(inputDocx, docxBuffer);
38399
39278
  secureFile(inputDocx);
38400
39279
  let lastErr;
38401
39280
  let converted = false;
38402
39281
  for (let attempt = 1; attempt <= 3; attempt++) {
38403
39282
  try {
38404
- await execFileAsync(sofficePath, [`-env:UserInstallation=${(0, import_url2.pathToFileURL)(path9.join(workDir, "lo_profile")).href}`, "--headless", "--convert-to", "pdf", "--outdir", workDir, inputDocx], {
39283
+ await execFileAsync(sofficePath, [`-env:UserInstallation=${(0, import_url2.pathToFileURL)(path10.join(workDir, "lo_profile")).href}`, "--headless", "--convert-to", "pdf", "--outdir", workDir, inputDocx], {
38405
39284
  timeout: timeoutMs
38406
39285
  });
38407
39286
  converted = true;
@@ -38419,17 +39298,17 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38419
39298
  }
38420
39299
  let pdfPath = null;
38421
39300
  try {
38422
- const names = fs10.readdirSync(workDir).filter((n) => n.toLowerCase().endsWith(".pdf"));
39301
+ const names = fs11.readdirSync(workDir).filter((n) => n.toLowerCase().endsWith(".pdf"));
38423
39302
  names.sort((a, b) => a === "input.pdf" ? -1 : b === "input.pdf" ? 1 : a.localeCompare(b));
38424
- if (names.length > 0) pdfPath = path9.join(workDir, names[0]);
39303
+ if (names.length > 0) pdfPath = path10.join(workDir, names[0]);
38425
39304
  } catch {
38426
39305
  pdfPath = null;
38427
39306
  }
38428
- if (!pdfPath || !fs10.existsSync(pdfPath)) {
39307
+ if (!pdfPath || !fs11.existsSync(pdfPath)) {
38429
39308
  throw validationError("E_VALIDATION: libreoffice produced no PDF output.", "Verify the .docx opens in Word/LibreOffice and retry.");
38430
39309
  }
38431
39310
  const prefixBase = "page";
38432
- const prefix = path9.join(workDir, prefixBase);
39311
+ const prefix = path10.join(workDir, prefixBase);
38433
39312
  const ppmArgs = format === "png" ? ["-png", "-r", String(dpi), pdfPath, prefix] : ["-jpeg", "-jpegopt", `quality=${jpegQuality}`, "-r", String(dpi), pdfPath, prefix];
38434
39313
  try {
38435
39314
  await execFileAsync(pdftoppmPath, ppmArgs, { timeout: Math.min(timeoutMs, 6e4) });
@@ -38448,15 +39327,15 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38448
39327
  const ext = format === "png" ? "png" : "jpg";
38449
39328
  for (const entry of selected) {
38450
39329
  const destName = `page-${entry.page}.${ext}`;
38451
- const dest = path9.join(outDir, destName);
38452
- if (!isPathInsideDir(path9.resolve(dest), path9.resolve(outDir))) {
39330
+ const dest = path10.join(outDir, destName);
39331
+ if (!isPathInsideDir(path10.resolve(dest), path10.resolve(outDir))) {
38453
39332
  throw sandboxError(
38454
39333
  `E_SANDBOX: visual artifact destination escapes outDir: "${dest}".`,
38455
39334
  "Pass an outDir inside the visual artifacts dir (use getVisualArtifactsDir(subDir) for a run-scoped dir) and retry."
38456
39335
  );
38457
39336
  }
38458
39337
  try {
38459
- fs10.copyFileSync(entry.file, dest);
39338
+ fs11.copyFileSync(entry.file, dest);
38460
39339
  secureFile(dest);
38461
39340
  } catch (err) {
38462
39341
  const msg = err instanceof Error ? err.message : String(err);
@@ -38465,7 +39344,7 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38465
39344
  const canonicalDest = canonicalizeForAsset(dest);
38466
39345
  if (!isPathInsideDir(canonicalDest, canonicalizeForAsset(outDir))) {
38467
39346
  try {
38468
- fs10.unlinkSync(dest);
39347
+ fs11.unlinkSync(dest);
38469
39348
  } catch {
38470
39349
  }
38471
39350
  throw sandboxError(
@@ -38475,7 +39354,7 @@ async function renderDocxToImages(docxBuffer, opts = {}) {
38475
39354
  }
38476
39355
  let bytes = 0;
38477
39356
  try {
38478
- bytes = fs10.statSync(dest).size;
39357
+ bytes = fs11.statSync(dest).size;
38479
39358
  } catch {
38480
39359
  bytes = 0;
38481
39360
  }
@@ -38625,8 +39504,18 @@ function computeDocStats(ast) {
38625
39504
  if (hits) words += hits.length;
38626
39505
  }
38627
39506
  }
38628
- const pageBudget = words / 450 + (tables + callouts) * 0.45 + images * 0.5 + pageBreaks;
38629
- 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) };
38630
39519
  }
38631
39520
 
38632
39521
  // ../engine/src/visual/telemetry.ts
@@ -40219,7 +41108,7 @@ function assertTemplateSeed(parser2, template) {
40219
41108
  }
40220
41109
  if (!matched) {
40221
41110
  throw new Error(
40222
- `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.`
40223
41112
  );
40224
41113
  }
40225
41114
  }
@@ -40791,9 +41680,39 @@ function summarizeStyleGuide(guide) {
40791
41680
  return out;
40792
41681
  }
40793
41682
 
41683
+ // ../engine/src/restore-original.ts
41684
+ var import_node_fs2 = __toESM(require("node:fs"));
41685
+ var import_promises4 = __toESM(require("node:fs/promises"));
41686
+ var import_node_path2 = __toESM(require("node:path"));
41687
+ var import_isomorphic_git2 = __toESM(require("isomorphic-git"));
41688
+ async function captureHistoryState(docPath) {
41689
+ const target = import_node_path2.default.resolve(docPath);
41690
+ const [head, index] = await Promise.all([
41691
+ getHistory(target).then((entries) => entries[0]?.oid, () => void 0),
41692
+ import_isomorphic_git2.default.statusMatrix({ fs: import_node_fs2.default, dir: getRepoDir(target) }).then((matrix) => JSON.stringify(matrix), () => void 0)
41693
+ ]);
41694
+ return { head, index };
41695
+ }
41696
+ async function historyChangedSince(docPath, before) {
41697
+ const after = await captureHistoryState(docPath);
41698
+ if (before.head === void 0 || after.head === void 0 || before.index === void 0 || after.index === void 0) return true;
41699
+ return after.head !== before.head || after.index !== before.index;
41700
+ }
41701
+ async function rollbackReplacedFile(docPath, previous, failure) {
41702
+ const outcome = failure.error?.outcome;
41703
+ if (!failure.replaced && outcome !== "replaced" && outcome !== "uncertain") return { recovered: true };
41704
+ try {
41705
+ await replaceFileAtomic(docPath, previous);
41706
+ if (originalSha256(await import_promises4.default.readFile(docPath)) === originalSha256(previous)) return { recovered: true };
41707
+ return { recovered: false, failure: "the file does not match the previous bytes after rollback" };
41708
+ } catch (error) {
41709
+ return { recovered: false, failure: error instanceof Error ? error.message : String(error) };
41710
+ }
41711
+ }
41712
+
40794
41713
  // src/lib/mcp/baseline-gate.ts
40795
41714
  var crypto13 = __toESM(require("crypto"));
40796
- var fsp4 = __toESM(require("fs/promises"));
41715
+ var fsp6 = __toESM(require("fs/promises"));
40797
41716
 
40798
41717
  // src/lib/mcp/errors.ts
40799
41718
  function mcpError(code, message, remedy) {
@@ -40873,8 +41792,8 @@ function invalidInput(message) {
40873
41792
  }
40874
41793
 
40875
41794
  // src/lib/mcp/diff.ts
40876
- var fs12 = __toESM(require("fs"));
40877
- var path11 = __toESM(require("path"));
41795
+ var fs14 = __toESM(require("fs"));
41796
+ var path13 = __toESM(require("path"));
40878
41797
  var DISCARDED_NODE_IDS_MAX = 20;
40879
41798
  var AUDIT_NODE_IDS_MAX = 100;
40880
41799
  var AUDIT_FILES_MAX = 100;
@@ -41241,7 +42160,7 @@ function pathDirname(p) {
41241
42160
  }
41242
42161
  function sidecarRepoExists(docPath) {
41243
42162
  try {
41244
- return fs12.existsSync(path11.join(getRepoDir(docPath), ".git"));
42163
+ return fs14.existsSync(path13.join(getRepoDir(docPath), ".git"));
41245
42164
  } catch {
41246
42165
  return false;
41247
42166
  }
@@ -41280,7 +42199,7 @@ async function resolveBaseline(handle) {
41280
42199
  if (handle.validationBaseline !== void 0) return handle.validationBaseline;
41281
42200
  if (!handle.openSha256) return null;
41282
42201
  try {
41283
- const bytes = await fsp4.readFile(handle.filePath);
42202
+ const bytes = await fsp6.readFile(handle.filePath);
41284
42203
  if (sha256Hex(bytes) !== handle.openSha256) return null;
41285
42204
  const baseline = buildBaseline(await validateForBaseline(bytes));
41286
42205
  handle.validationBaseline = baseline;
@@ -41364,7 +42283,7 @@ function withToleratedNote(message, preExisting) {
41364
42283
  async function validateAtOpen(handle, timeoutMs) {
41365
42284
  let bytes;
41366
42285
  try {
41367
- bytes = await fsp4.readFile(handle.filePath);
42286
+ bytes = await fsp6.readFile(handle.filePath);
41368
42287
  } catch (err) {
41369
42288
  return { status: "skipped", note: `the file could not be read for validation: ${err instanceof Error ? err.message : String(err)}` };
41370
42289
  }
@@ -41408,10 +42327,10 @@ async function validateAtOpen(handle, timeoutMs) {
41408
42327
  }
41409
42328
 
41410
42329
  // src/lib/session-store.ts
41411
- var fs13 = __toESM(require("fs"));
41412
- var fsp5 = __toESM(require("fs/promises"));
41413
- var os2 = __toESM(require("os"));
41414
- var path12 = __toESM(require("path"));
42330
+ var fs15 = __toESM(require("fs"));
42331
+ var fsp7 = __toESM(require("fs/promises"));
42332
+ var os3 = __toESM(require("os"));
42333
+ var path14 = __toESM(require("path"));
41415
42334
  var import_crypto6 = require("crypto");
41416
42335
  var MCP_SESSION_MAX_DOCS = 10;
41417
42336
  var MCP_SESSION_MAX_BYTES = 200 * 1024 * 1024;
@@ -41609,19 +42528,19 @@ function getMcpAllowedRoots() {
41609
42528
  if (named.length > 0) return [...new Set(named)];
41610
42529
  const roots = [];
41611
42530
  try {
41612
- roots.push(path12.resolve(process.cwd(), "documents"));
42531
+ roots.push(path14.resolve(process.cwd(), "documents"));
41613
42532
  } catch {
41614
42533
  }
41615
42534
  try {
41616
- roots.push(path12.resolve(os2.tmpdir()));
42535
+ roots.push(path14.resolve(os3.tmpdir()));
41617
42536
  } catch {
41618
42537
  }
41619
42538
  try {
41620
- roots.push(path12.resolve(process.cwd(), "documents", "Drafts"));
42539
+ roots.push(path14.resolve(process.cwd(), "documents", "Drafts"));
41621
42540
  } catch {
41622
42541
  }
41623
42542
  try {
41624
- roots.push(path12.resolve(os2.tmpdir(), "ASTra"));
42543
+ roots.push(path14.resolve(os3.tmpdir(), "ASTra"));
41625
42544
  } catch {
41626
42545
  }
41627
42546
  return [...new Set(roots)];
@@ -41638,12 +42557,12 @@ function getExistingExtraRoots() {
41638
42557
  const raw = (process.env.ASTRA_EXTRA_DOC_ROOTS || "").trim();
41639
42558
  if (!raw) return [];
41640
42559
  const out = [];
41641
- for (const part of raw.split(path12.delimiter)) {
42560
+ for (const part of raw.split(path14.delimiter)) {
41642
42561
  const p = part.trim();
41643
42562
  if (!p) continue;
41644
42563
  try {
41645
- const resolved = path12.resolve(p);
41646
- const stat3 = fs13.statSync(resolved);
42564
+ const resolved = path14.resolve(p);
42565
+ const stat3 = fs15.statSync(resolved);
41647
42566
  if (!stat3.isDirectory()) continue;
41648
42567
  out.push(resolved);
41649
42568
  } catch {
@@ -41655,7 +42574,7 @@ function findMcpContainingRoot(canonical) {
41655
42574
  for (const root of getMcpAllowedRoots()) {
41656
42575
  let canonicalRoot = root;
41657
42576
  try {
41658
- canonicalRoot = fs13.realpathSync(root);
42577
+ canonicalRoot = fs15.realpathSync(root);
41659
42578
  } catch {
41660
42579
  }
41661
42580
  if (isPathInsideDir(canonical, canonicalRoot) || isPathInsideDir(canonical, root)) {
@@ -41665,8 +42584,8 @@ function findMcpContainingRoot(canonical) {
41665
42584
  return void 0;
41666
42585
  }
41667
42586
  function assertNoDotSegments(canonical, root) {
41668
- const rel = path12.relative(root, canonical);
41669
- const segs = rel.split(path12.sep).filter((s) => s && s !== "." && s !== "..");
42587
+ const rel = path14.relative(root, canonical);
42588
+ const segs = rel.split(path14.sep).filter((s) => s && s !== "." && s !== "..");
41670
42589
  for (const seg of segs) {
41671
42590
  if (seg.startsWith(".")) {
41672
42591
  throw Object.assign(new Error(`Document path traverses a hidden directory "${seg}"`), {
@@ -41675,7 +42594,7 @@ function assertNoDotSegments(canonical, root) {
41675
42594
  });
41676
42595
  }
41677
42596
  }
41678
- const base = path12.basename(canonical);
42597
+ const base = path14.basename(canonical);
41679
42598
  if (base.startsWith(".")) {
41680
42599
  throw Object.assign(new Error("Document path must not use a hidden (dot-prefixed) file name"), {
41681
42600
  code: "E_SANDBOX",
@@ -41716,8 +42635,8 @@ function validateDocPath(rawPath) {
41716
42635
  remedy: `Use a path ${describeAllowedFolders()}.`
41717
42636
  });
41718
42637
  }
41719
- const resolved = path12.resolve(trimmed);
41720
- const resolvedSegments = resolved.split(path12.sep);
42638
+ const resolved = path14.resolve(trimmed);
42639
+ const resolvedSegments = resolved.split(path14.sep);
41721
42640
  if (resolvedSegments.includes("..")) {
41722
42641
  throw Object.assign(new Error("Document path resolves outside its directory"), {
41723
42642
  code: "E_SANDBOX",
@@ -41736,7 +42655,7 @@ function validateDocPath(rawPath) {
41736
42655
  );
41737
42656
  }
41738
42657
  assertNoDotSegments(canonical, containingRoot);
41739
- const ext = path12.extname(trimmed).toLowerCase();
42658
+ const ext = path14.extname(trimmed).toLowerCase();
41740
42659
  if (ext !== ".docx") {
41741
42660
  throw Object.assign(new Error(`Document path must use a .docx extension (got "${ext || "(none)"}")`), {
41742
42661
  code: "E_VALIDATION",
@@ -41758,9 +42677,9 @@ function validateOpenBufferLabel(rawLabel) {
41758
42677
  remedy: "Pass a plain memory label such as memory.docx."
41759
42678
  });
41760
42679
  }
41761
- if (path12.isAbsolute(trimmed)) return validateDocPath(trimmed);
42680
+ if (path14.isAbsolute(trimmed)) return validateDocPath(trimmed);
41762
42681
  if (trimmed.includes("\n") || trimmed.includes("\r")) return fallback;
41763
- const base = path12.basename(trimmed.replace(/\\/g, "/"));
42682
+ const base = path14.basename(trimmed.replace(/\\/g, "/"));
41764
42683
  if (!base || base === "." || base === "..") return fallback;
41765
42684
  const clean = base.slice(0, 128);
41766
42685
  return clean.toLowerCase().endsWith(".docx") ? clean : fallback;
@@ -41774,29 +42693,141 @@ function collectTrackedExtrasForGit(parser2) {
41774
42693
  }
41775
42694
  return extras;
41776
42695
  } catch (err) {
41777
- const msg = err instanceof Error ? err.message : String(err);
41778
- console.error(`[collectTrackedExtrasForGit] snapshot failed: ${msg}`);
41779
- return {};
42696
+ throw snapshotCollectionError(err);
41780
42697
  }
41781
42698
  }
41782
- async function ensureGitRepo(docPath, xml, extras) {
42699
+ function snapshotCollectionError(cause) {
42700
+ const detail = cause instanceof Error ? cause.message : String(cause);
42701
+ return Object.assign(new Error(`The document's package parts could not be collected for history (${detail}). Nothing was recorded.`), {
42702
+ code: "E_STATE",
42703
+ remedy: "Nothing was recorded in history. Retry; if it keeps failing, the document package may be damaged."
42704
+ });
42705
+ }
42706
+ async function ensureGitRepo(docPath, xml, extras, originalBytes) {
42707
+ return (await ensureGitRepoWith(docPath, () => ({ xml, extras }), originalBytes)).existed;
42708
+ }
42709
+ async function ensureGitRepoWith(docPath, baseline, originalBytes, options) {
41783
42710
  const repoDir = getRepoDir(docPath);
41784
42711
  let hasGit = false;
41785
42712
  try {
41786
- hasGit = fs13.existsSync(path12.join(repoDir, ".git"));
42713
+ hasGit = fs15.existsSync(path14.join(repoDir, ".git"));
41787
42714
  } catch {
41788
42715
  hasGit = false;
41789
42716
  }
41790
- if (hasGit) return true;
41791
- await initAstraRepo(docPath, xml, extras);
41792
- return false;
42717
+ if (hasGit) {
42718
+ if (!options?.alreadyReady) await assertGitHistoryReady(repoDir);
42719
+ return { existed: true };
42720
+ }
42721
+ try {
42722
+ const initial = baseline();
42723
+ await initAstraRepo(docPath, initial.xml, initial.extras, originalBytes);
42724
+ return { existed: false, baseline: initial };
42725
+ } catch (error) {
42726
+ if (fs15.existsSync(path14.join(repoDir, ".git"))) {
42727
+ await assertGitHistoryReady(repoDir);
42728
+ return { existed: true };
42729
+ }
42730
+ throw error;
42731
+ }
41793
42732
  }
41794
- async function initGitForHandleBestEffort(handle) {
42733
+ function collectBaselineForGit(parser2) {
42734
+ const xml = parser2.readDocumentXml();
41795
42735
  try {
41796
- if (!path12.isAbsolute(handle.filePath)) return;
41797
- const xml = handle.parser.readDocumentXml();
41798
- await ensureGitRepo(handle.filePath, xml, collectTrackedExtrasForGit(handle.parser));
41799
- } catch {
42736
+ const snapshot3 = parser2.capturePackageSnapshot({ documentXml: xml });
42737
+ const extras = {};
42738
+ for (const [name2, buf] of snapshot3) if (isTrackedPackageFile(name2)) extras[name2] = buf;
42739
+ return { xml, extras };
42740
+ } catch (err) {
42741
+ throw snapshotCollectionError(err);
42742
+ }
42743
+ }
42744
+ var sha256Of = (bytes) => (0, import_crypto6.createHash)("sha256").update(bytes).digest("hex");
42745
+ async function resolveSessionOriginal(handle) {
42746
+ const fallback = handle.originalInfo ?? { available: false, reason: "not-applicable" };
42747
+ const pin = handle.originalPin;
42748
+ if (!pin || !path14.isAbsolute(handle.filePath)) return fallback;
42749
+ const current = await getOriginalInfo(handle.filePath);
42750
+ if (pin.kind === "retained") {
42751
+ if (!current.available) return current;
42752
+ if (current.sha256 !== pin.sha256) {
42753
+ return { available: false, reason: "capture-failed", detail: "The retained original no longer matches the one this session opened." };
42754
+ }
42755
+ return current;
42756
+ }
42757
+ if (current.available && current.sha256 !== pin.sha256) {
42758
+ return { available: false, reason: "capture-failed", detail: "Another opener recorded a different original for this history." };
42759
+ }
42760
+ const initialization = await getHistoryInitializationState(handle.filePath);
42761
+ if (initialization.state !== "complete") {
42762
+ return { available: false, reason: "initializing", detail: initialization.detail ?? "History initialization has not completed. Check get_document_history again later." };
42763
+ }
42764
+ if (!current.available) return current;
42765
+ handle.originalInfo = current;
42766
+ handle.originalPin = { kind: "retained", sha256: current.sha256 };
42767
+ handle.originalWarning = void 0;
42768
+ return current;
42769
+ }
42770
+ var INITIALIZING_WARNING = "History initialization was not complete when this document opened (another ASTra process may be creating it). Editing is available. Exact-original recovery becomes available if that initialization finishes for this same file; check get_document_history.";
42771
+ async function initGitForHandleBestEffort(handle, originalBytes) {
42772
+ try {
42773
+ if (!path14.isAbsolute(handle.filePath)) return;
42774
+ const repoDir = getRepoDir(handle.filePath);
42775
+ let completeBeforeRead = false;
42776
+ if (fs15.existsSync(path14.join(repoDir, ".git"))) {
42777
+ try {
42778
+ await assertGitHistoryReady(repoDir);
42779
+ completeBeforeRead = true;
42780
+ } catch {
42781
+ }
42782
+ }
42783
+ const availabilityAtOpen = await getOriginalInfo(handle.filePath);
42784
+ const ensured = await ensureGitRepoWith(handle.filePath, () => collectBaselineForGit(handle.parser), originalBytes, { alreadyReady: completeBeforeRead });
42785
+ if (ensured.existed) {
42786
+ const preExisting = completeBeforeRead && availabilityAtOpen.available;
42787
+ const now = preExisting ? availabilityAtOpen : await getOriginalInfo(handle.filePath);
42788
+ handle.originalInfo = now;
42789
+ if (now.available) {
42790
+ if (preExisting || originalBytes && sha256Of(originalBytes) === now.sha256) {
42791
+ handle.originalPin = { kind: "retained", sha256: now.sha256 };
42792
+ } else {
42793
+ handle.originalInfo = { available: false, reason: "capture-failed", detail: "Another opener recorded a different original for this history." };
42794
+ }
42795
+ } else if (originalBytes && now.reason === "incomplete") {
42796
+ handle.originalInfo = { available: false, reason: "initializing", detail: "The history was still being initialized when this session opened." };
42797
+ handle.originalPin = { kind: "input", sha256: sha256Of(originalBytes) };
42798
+ handle.originalWarning = INITIALIZING_WARNING;
42799
+ }
42800
+ } else {
42801
+ handle.originalInfo = await getOriginalInfo(handle.filePath);
42802
+ const recorded = ensured.baseline;
42803
+ const history = await getHistory(handle.filePath);
42804
+ const head = history[0] ? await checkoutCommit(handle.filePath, history[0].oid) : void 0;
42805
+ const names = Object.keys(recorded.extras).sort();
42806
+ const headNames = head ? [...head.extra.keys()].sort() : [];
42807
+ const initialStateMatches = !!head?.complete && recorded.xml === head.xml && JSON.stringify(names) === JSON.stringify(headNames) && names.every((name2) => recorded.extras[name2].equals(head.extra.get(name2)));
42808
+ if (!initialStateMatches && handle.originalInfo.available) {
42809
+ handle.originalInfo = { available: false, reason: "capture-failed", detail: "Concurrent history initialization produced a different initial document state. Close and reopen after inspecting history." };
42810
+ }
42811
+ if (originalBytes && handle.originalInfo.available && handle.originalInfo.sha256 !== sha256Of(originalBytes)) {
42812
+ handle.originalInfo = { available: false, reason: "capture-failed", detail: "Another opener recorded a different original for this history." };
42813
+ }
42814
+ if (handle.originalInfo.available) handle.originalPin = { kind: "retained", sha256: handle.originalInfo.sha256 };
42815
+ }
42816
+ if (!handle.originalInfo.available && !["predates-feature", "not-applicable"].includes(handle.originalInfo.reason)) {
42817
+ handle.originalWarning = handle.originalInfo.reason === "initializing" ? INITIALIZING_WARNING : `Exact-original recovery is unavailable: ${handle.originalInfo.reason}. Keep a separate copy of the input file.`;
42818
+ }
42819
+ } catch (error) {
42820
+ const detail = error instanceof Error ? error.message : String(error);
42821
+ const mismatch = error?.reason === "original-mismatch";
42822
+ const initialization = originalBytes && !mismatch && path14.isAbsolute(handle.filePath) ? await getHistoryInitializationState(handle.filePath) : void 0;
42823
+ if (initialization && originalBytes && initialization.state !== "absent") {
42824
+ handle.originalInfo = { available: false, reason: "initializing", detail: initialization.detail ?? detail };
42825
+ handle.originalPin = { kind: "input", sha256: sha256Of(originalBytes) };
42826
+ handle.originalWarning = INITIALIZING_WARNING;
42827
+ } else {
42828
+ handle.originalInfo = mismatch ? { available: false, reason: "capture-failed", detail } : { available: false, reason: "incomplete", detail };
42829
+ handle.originalWarning = `History initialization or original-file capture did not complete. Editing is available, but exact-original recovery is not. ${detail} Keep a separate copy of the input file.`;
42830
+ }
41800
42831
  }
41801
42832
  }
41802
42833
  var McpSessionStore = class {
@@ -42046,12 +43077,12 @@ var McpSessionStore = class {
42046
43077
  * Macro/active content refuses by default (E_VALIDATION naming the parts);
42047
43078
  * pass {allowMacros:true} to preserve or {stripMacros:true} to strip.
42048
43079
  */
42049
- async openDocument(filePath, sessionId, macroOpts) {
43080
+ async openDocument(filePath, sessionId, macroOpts, captureInput = true) {
42050
43081
  const cleanPath = validateDocPath(filePath);
42051
43082
  const expiredEvicted = this.evictExpired();
42052
43083
  let buf;
42053
43084
  try {
42054
- buf = await fsp5.readFile(cleanPath);
43085
+ buf = await fsp7.readFile(cleanPath);
42055
43086
  } catch (err) {
42056
43087
  const msg = err instanceof Error ? err.message : String(err);
42057
43088
  throw Object.assign(new Error(`open_document: cannot read "${cleanPath}": ${msg}`), {
@@ -42086,10 +43117,12 @@ var McpSessionStore = class {
42086
43117
  lastAccess: Date.now(),
42087
43118
  mutex: new SessionMutex(),
42088
43119
  redoStack: this.createRedoStack(),
42089
- pendingAudit: { ops: 0, nodeIds: [] }
43120
+ pendingAudit: { ops: 0, nodeIds: [] },
43121
+ lastWrittenSha256: (0, import_crypto6.createHash)("sha256").update(buf).digest("hex"),
43122
+ macroPolicy: macroOpts
42090
43123
  };
42091
43124
  this.sessions.set(id, handle);
42092
- await initGitForHandleBestEffort(handle);
43125
+ await initGitForHandleBestEffort(handle, captureInput ? buf : void 0);
42093
43126
  this.lastOpenEvicted = [...expiredEvicted, ...this.evictToFit(id)];
42094
43127
  const live = this.sessions.get(id);
42095
43128
  if (!live) {
@@ -42156,10 +43189,13 @@ var McpSessionStore = class {
42156
43189
  lastAccess: Date.now(),
42157
43190
  mutex: new SessionMutex(),
42158
43191
  redoStack: this.createRedoStack(),
42159
- pendingAudit: { ops: 0, nodeIds: [] }
43192
+ pendingAudit: { ops: 0, nodeIds: [] },
43193
+ lastWrittenSha256: (0, import_crypto6.createHash)("sha256").update(buf).digest("hex"),
43194
+ macroPolicy: macroOpts
42160
43195
  };
42161
43196
  this.sessions.set(id, handle);
42162
- await initGitForHandleBestEffort(handle);
43197
+ await initGitForHandleBestEffort(handle, buf);
43198
+ if (!path14.isAbsolute(handle.filePath)) handle.originalInfo = { available: false, reason: "not-applicable" };
42163
43199
  this.lastOpenEvicted = [...expiredEvicted, ...this.evictToFit(id)];
42164
43200
  const live = this.sessions.get(id);
42165
43201
  if (!live) {
@@ -42334,6 +43370,8 @@ var LEAN6_TOOL_NAMES = [
42334
43370
  ];
42335
43371
  var GIT_HISTORY_TOOL_NAMES = [
42336
43372
  "get_document_history",
43373
+ "export_original_document",
43374
+ "restore_original_document",
42337
43375
  "preview_revert",
42338
43376
  "revert_document",
42339
43377
  "redo_document"
@@ -42363,7 +43401,7 @@ var LEAN6_DESCRIPTIONS = {
42363
43401
  { workflow: true }
42364
43402
  ),
42365
43403
  inspect_document: withSharedDocs(
42366
- '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.'
42367
43405
  ),
42368
43406
  search_content: withSharedDocs(
42369
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.'
@@ -42381,8 +43419,10 @@ var LEAN6_DESCRIPTIONS = {
42381
43419
  )
42382
43420
  };
42383
43421
  var GIT_HISTORY_DESCRIPTIONS = {
43422
+ export_original_document: "Export the exact original DOCX captured when this history was created. Requires sessionId and outPath; overwrite defaults to false. Verifies the retained SHA-256 and never serializes the original. Does not change the live session. Refuses overwriting the active document: use restore_original_document instead. Old histories and memory-only sessions may have no original. The original can contain content removed by later sanitization; history retains it until the history store is deleted.",
43423
+ restore_original_document: "Replace the working file with the exact captured original, verified by SHA-256, and record a new history commit. Requires sessionId. Unsaved edits refuse unless discard:true explicitly discards them. Refuses an externally changed file or history that does not match the saved state. Close other programs editing this file before restoring; an external write racing the final replacement cannot be excluded. Reloads the session under its opening policy. Normal revert/redo restore document state, not necessarily the original ZIP bytes. The next normal save may normalize the file again. Use export_original_document for recovery to a different path.",
42384
43424
  get_document_history: withSharedDocs(
42385
- 'List the saved git history for a session (newest first, read-only: never mutates, never flips dirty). Paginate with limit (1-100, default 20) and offset (default 0). Returns {ok:true,sessionId,documentPath,currentCommit,totalCommits,history:[{oid,message,author,timestamp,timestampMs,opCount,affectedNodeIds,resultingHeadOid}]} (timestampMs = ms-precision epoch millis; commit-time clock for tracked commits, git seconds for older ones; opCount is always present (0 for pre-tracking commits and heals-only saves: it folds operations, not normalizer rewrites); affectedNodeIds is always present ([] when untracked); resultingHeadOid always equals oid (append-only; refs never move). Example {"sessionId":"sess_abc","limit":10} -> history slice. Use the oids with revert_document (or preview_revert first) to restore an earlier saved state. '
43425
+ 'List the saved git history and original-file availability for a session (newest first, read-only: never mutates, never flips dirty). Paginate with limit (1-100, default 20) and offset (default 0). Returns {ok:true,sessionId,documentPath,currentCommit,totalCommits,history:[{oid,message,author,timestamp,timestampMs,opCount,affectedNodeIds,resultingHeadOid}]} (timestampMs = ms-precision epoch millis; commit-time clock for tracked commits, git seconds for older ones; opCount is always present (0 for pre-tracking commits and heals-only saves: it folds operations, not normalizer rewrites); affectedNodeIds is always present ([] when untracked); resultingHeadOid always equals oid (append-only; refs never move). Example {"sessionId":"sess_abc","limit":10} -> history slice. Use the oids with revert_document (or preview_revert first) to restore an earlier saved state. '
42386
43426
  ),
42387
43427
  preview_revert: withSharedDocs(
42388
43428
  `Read-only WOULD-restore preview for revert_document (never mutates: no revision bump, no disk write, no redo-stack change). Target one commit: {steps:N} walks back N saved commits from the head (1 = the previous saved commit, must be < totalCommits) OR {commitOid:"<40-hex>"} jumps to that commit (mutually exclusive). Returns {ok:true,sessionId,targetOid,targetMessage,targetAuthor,stepsBack,affectedFiles,changedValues,changedValuesTruncated,changedValuesPositional,dirty,dirtyHandling,currentHeadOid,totalCommits} where affectedFiles lists package paths that differ from the target (word/document.xml plus tracked extras). The preview also carries advisory changedValues [{nodeId,before,after}] (live current text -> target restore text, bounded: at most ${CHANGED_VALUES_MAX} entries, each side at most ${CHANGED_VALUE_TEXT_MAX} chars, truncated flag honest; changedValuesPositional:true means either side held orphan ids (no shared id across the boundary, common under duplicate native ids) so before/after pairing is positional, not identity; empty + changedValuesReason when the target range is not computable). Example {"sessionId":"sess_abc","steps":1} -> preview summary. Preview first when a revert would discard unsaved edits.`
@@ -42402,7 +43442,7 @@ var PARITY_DESCRIPTIONS = {
42402
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":[]}}. '
42403
43443
  ),
42404
43444
  get_stats: withSharedDocs(
42405
- '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}. '
42406
43446
  ),
42407
43447
  sanitize_document: withSharedDocs(
42408
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). `
@@ -42440,7 +43480,7 @@ var OpenDocumentSchema = import_zod4.z.object({
42440
43480
  }).strict();
42441
43481
  var InspectDocumentSchema = import_zod4.z.object({
42442
43482
  sessionId: import_zod4.z.string().min(1).max(128),
42443
- fidelity: import_zod4.z.enum(["compact", "standard", "full"]).optional(),
43483
+ fidelity: import_zod4.z.enum(["outline", "compact", "standard", "full"]).optional(),
42444
43484
  sectionId: import_zod4.z.string().min(1).max(256).optional(),
42445
43485
  maxTextLength: import_zod4.z.number().int().min(1).max(1e4).optional(),
42446
43486
  // Phase C-2 smart windowing: offset/limit page the node rows; aroundNode +
@@ -42571,6 +43611,15 @@ var ImportDocumentSchema = import_zod4.z.object({
42571
43611
  // Read $...$ as inline math (off by default: prices and shell text can look like math).
42572
43612
  dollarMath: import_zod4.z.boolean().optional()
42573
43613
  }).strict();
43614
+ var ExportOriginalDocumentSchema = import_zod4.z.object({
43615
+ sessionId: import_zod4.z.string().min(1).max(128),
43616
+ outPath: import_zod4.z.string().min(1).max(1024),
43617
+ overwrite: import_zod4.z.boolean().optional()
43618
+ }).strict();
43619
+ var RestoreOriginalDocumentSchema = import_zod4.z.object({
43620
+ sessionId: import_zod4.z.string().min(1).max(128),
43621
+ discard: import_zod4.z.boolean().optional()
43622
+ }).strict();
42574
43623
 
42575
43624
  // src/lib/mcp/handlers/open-inspect.ts
42576
43625
  function extractStylePalette2(parser2) {
@@ -42664,7 +43713,7 @@ async function handleOpenDocument(store, input) {
42664
43713
  const evicted = store.drainLastOpenEvicted();
42665
43714
  const pendingNormalizations = readPendingNormalizations(handle);
42666
43715
  const validation = await validateAtOpen(handle, parsed.data.validationTimeoutMs ?? openValidationTimeoutMs());
42667
- return { ok: true, sessionId: handle.sessionId, revision: handle.revision, summary, evicted, pendingNormalizations, validation };
43716
+ return { ok: true, sessionId: handle.sessionId, revision: handle.revision, summary, evicted, pendingNormalizations, validation, original: handle.originalInfo, ...handle.originalWarning ? { originalWarning: handle.originalWarning } : {} };
42668
43717
  } catch (err) {
42669
43718
  return mcpErrorFrom(err, "E_IO", "Check the .docx path is readable and retry open_document.");
42670
43719
  }
@@ -42678,6 +43727,12 @@ function readPendingNormalizations(handle) {
42678
43727
  return [];
42679
43728
  }
42680
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
+ }
42681
43736
  async function handleInspectDocument(store, input) {
42682
43737
  const parsed = InspectDocumentSchema.safeParse(input);
42683
43738
  if (!parsed.success) {
@@ -42777,6 +43832,7 @@ async function handleInspectDocument(store, input) {
42777
43832
  pendingNormalizations: readPendingNormalizations(handle),
42778
43833
  ...handle.styleGuide !== void 0 ? { styleGuide: summarizeStyleGuide(handle.styleGuide) } : {},
42779
43834
  toonText: fullText,
43835
+ ...fidelity === "outline" ? { outline: outlineCountsOf(handle.ast) } : {},
42780
43836
  rowCount: totalNodes,
42781
43837
  totalNodes,
42782
43838
  returnedNodes: totalNodes,
@@ -42790,11 +43846,40 @@ async function handleInspectDocument(store, input) {
42790
43846
  let start;
42791
43847
  let end;
42792
43848
  let effectiveLimit = limit;
43849
+ let aroundResolvedTo;
43850
+ let note;
42793
43851
  if (aroundNode !== void 0) {
42794
- 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
+ }
42795
43880
  if (idx < 0) {
42796
43881
  throw Object.assign(
42797
- 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).`),
42798
43883
  {
42799
43884
  code: "E_VALIDATION",
42800
43885
  remedy: 'Call search_content to find a valid target node id, then retry inspect_document with {aroundNode:"<id>", window:5}.'
@@ -42803,6 +43888,10 @@ async function handleInspectDocument(store, input) {
42803
43888
  }
42804
43889
  start = Math.max(0, idx - window);
42805
43890
  end = Math.min(totalNodes, idx + window + 1);
43891
+ if (aroundResolvedTo === null) {
43892
+ start = 0;
43893
+ end = Math.min(totalNodes, window * 2 + 1);
43894
+ }
42806
43895
  effectiveLimit = end - start;
42807
43896
  } else {
42808
43897
  if (offset >= totalNodes && totalNodes > 0) {
@@ -42869,6 +43958,7 @@ async function handleInspectDocument(store, input) {
42869
43958
  pendingNormalizations: readPendingNormalizations(handle),
42870
43959
  ...handle.styleGuide !== void 0 ? { styleGuide: summarizeStyleGuide(handle.styleGuide) } : {},
42871
43960
  toonText,
43961
+ ...fidelity === "outline" ? { outline: outlineCountsOf(handle.ast) } : {},
42872
43962
  rowCount: returnedNodes,
42873
43963
  totalNodes,
42874
43964
  returnedNodes,
@@ -42878,6 +43968,8 @@ async function handleInspectDocument(store, input) {
42878
43968
  nextOffset: hasMore ? start + returnedNodes : null,
42879
43969
  truncated
42880
43970
  };
43971
+ if (aroundResolvedTo !== void 0) out.aroundResolvedTo = aroundResolvedTo;
43972
+ if (note !== void 0) out.note = note;
42881
43973
  if (hasMore || truncated) {
42882
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.`;
42883
43975
  }
@@ -42889,11 +43981,11 @@ async function handleInspectDocument(store, input) {
42889
43981
  }
42890
43982
 
42891
43983
  // src/lib/mcp/handlers/search-batch.ts
42892
- var path14 = __toESM(require("path"));
43984
+ var path16 = __toESM(require("path"));
42893
43985
 
42894
43986
  // src/tools/compose-document.ts
42895
- var fs14 = __toESM(require("fs"));
42896
- var path13 = __toESM(require("path"));
43987
+ var fs16 = __toESM(require("fs"));
43988
+ var path15 = __toESM(require("path"));
42897
43989
  var import_zod5 = require("zod");
42898
43990
  var COMPOSE_MARKDOWN_MAX = 5e5;
42899
43991
  var COMPOSE_VALIDATION_ERRORS_MAX = 3;
@@ -42908,8 +44000,8 @@ function resolveMcpStyleGuide(ref, jail) {
42908
44000
  let anchor;
42909
44001
  if (jail?.allowedDir !== void 0) {
42910
44002
  anchor = jail.allowedDir;
42911
- } else if (jail?.sessionFilePath !== void 0 && path13.isAbsolute(jail.sessionFilePath)) {
42912
- anchor = canonicalizeForAsset(path13.resolve(path13.dirname(jail.sessionFilePath)));
44003
+ } else if (jail?.sessionFilePath !== void 0 && path15.isAbsolute(jail.sessionFilePath)) {
44004
+ anchor = canonicalizeForAsset(path15.resolve(path15.dirname(jail.sessionFilePath)));
42913
44005
  }
42914
44006
  if (anchor === void 0 && !hasGuideScheme(trimmed) && isFileGuideRef(trimmed)) {
42915
44007
  throw guideSandboxError(
@@ -42942,7 +44034,7 @@ var ComposeDocumentSchema = import_zod5.z.object({
42942
44034
  // Side of anchor the first block goes on (default after). Needs anchor.
42943
44035
  position: import_zod5.z.enum(["before", "after"]).optional()
42944
44036
  }).strict();
42945
- 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).';
42946
44038
  function persistentParagraphIds(ast, createdIds) {
42947
44039
  const wanted = new Set(createdIds);
42948
44040
  const out = {};
@@ -43008,22 +44100,22 @@ function assertPathAllowed(allowedDir, roots) {
43008
44100
  remedy: `Use a folder ${describeAllowedFolders()}.`
43009
44101
  });
43010
44102
  }
43011
- const resolved = path13.resolve(trimmed);
44103
+ const resolved = path15.resolve(trimmed);
43012
44104
  const canonical = canonicalizeForAsset(resolved);
43013
44105
  const inside = roots.some((root) => {
43014
44106
  let canonicalRoot = root;
43015
44107
  try {
43016
- canonicalRoot = fs14.realpathSync(root);
44108
+ canonicalRoot = fs16.realpathSync(root);
43017
44109
  } catch {
43018
44110
  }
43019
- const normC = path13.normalize(canonical);
43020
- const normR = path13.normalize(canonicalRoot);
44111
+ const normC = path15.normalize(canonical);
44112
+ const normR = path15.normalize(canonicalRoot);
43021
44113
  if (process.platform === "win32") {
43022
44114
  const lc = normC.toLowerCase();
43023
44115
  const lr = normR.toLowerCase();
43024
- return lc === lr || lc.startsWith(lr + path13.sep.toLowerCase());
44116
+ return lc === lr || lc.startsWith(lr + path15.sep.toLowerCase());
43025
44117
  }
43026
- return normC === normR || normC.startsWith(normR + path13.sep);
44118
+ return normC === normR || normC.startsWith(normR + path15.sep);
43027
44119
  });
43028
44120
  if (!inside) {
43029
44121
  throw Object.assign(
@@ -43401,8 +44493,8 @@ async function handleExecuteBatch(store, input) {
43401
44493
  const explicit = parsed.data.allowedDir;
43402
44494
  if (explicit !== void 0) {
43403
44495
  batchAllowedDir = assertPathAllowed(explicit, getMcpAllowedRoots());
43404
- } else if (path14.isAbsolute(handle.filePath)) {
43405
- batchAllowedDir = canonicalizeForAsset(path14.resolve(path14.dirname(handle.filePath)));
44496
+ } else if (path16.isAbsolute(handle.filePath)) {
44497
+ batchAllowedDir = canonicalizeForAsset(path16.resolve(path16.dirname(handle.filePath)));
43406
44498
  } else {
43407
44499
  throw Object.assign(
43408
44500
  new Error(
@@ -43551,8 +44643,9 @@ async function handleExecuteBatch(store, input) {
43551
44643
  }
43552
44644
 
43553
44645
  // src/lib/mcp/handlers/save-close.ts
43554
- var fsp6 = __toESM(require("fs/promises"));
43555
- var path15 = __toESM(require("path"));
44646
+ var import_crypto7 = require("crypto");
44647
+ var fsp8 = __toESM(require("fs/promises"));
44648
+ var path17 = __toESM(require("path"));
43556
44649
  async function executeSaveDocument(store, input) {
43557
44650
  const parsed = SaveDocumentSchema.safeParse(input);
43558
44651
  if (!parsed.success) {
@@ -43598,18 +44691,18 @@ async function executeSaveDocument(store, input) {
43598
44691
  ),
43599
44692
  {
43600
44693
  code: "E_VALIDATION",
43601
- remedy: `name the output "${path15.basename(target)}" with a .docm extension, or add stripMacros: true to remove the macro parts`
44694
+ remedy: `name the output "${path17.basename(target)}" with a .docm extension, or add stripMacros: true to remove the macro parts`
43602
44695
  }
43603
44696
  );
43604
44697
  }
43605
44698
  if (isStripped && macroParts.length === 0 && /\.(docm|dotm)$/i.test(target)) {
43606
44699
  throw Object.assign(
43607
44700
  new Error(
43608
- `E_VALIDATION: save_document: stripMacros removes the macro parts, so the result is a plain document and cannot be written to "${path15.basename(target)}", a macro-enabled name`
44701
+ `E_VALIDATION: save_document: stripMacros removes the macro parts, so the result is a plain document and cannot be written to "${path17.basename(target)}", a macro-enabled name`
43609
44702
  ),
43610
44703
  {
43611
44704
  code: "E_VALIDATION",
43612
- remedy: `write the result to a .docx with outPath: "${path15.basename(target).replace(/\.docm$/i, ".docx")}", or use allowMacros: true to keep the macro parts in the .docm`
44705
+ remedy: `write the result to a .docx with outPath: "${path17.basename(target).replace(/\.docm$/i, ".docx")}", or use allowMacros: true to keep the macro parts in the .docm`
43613
44706
  }
43614
44707
  );
43615
44708
  }
@@ -43650,19 +44743,22 @@ async function executeSaveDocument(store, input) {
43650
44743
  const saveGateNormalized = readParserNormalizations(handle.parser);
43651
44744
  const buf = handle.parser.rezip();
43652
44745
  const saveNormalized = mergeNormalized(saveGateNormalized, readParserNormalizations(handle.parser));
44746
+ const savedXml = handle.parser.readDocumentXml();
44747
+ const extras = collectTrackedExtrasForGit(handle.parser);
43653
44748
  const prevRevision = handle.revision;
43654
44749
  const prevSavedRevision = handle.savedRevision;
43655
44750
  const prevByteSize = handle.byteSize;
43656
44751
  const prevFilePath = handle.filePath;
43657
44752
  let prevBytes = null;
43658
44753
  try {
43659
- prevBytes = await fsp6.readFile(target);
44754
+ prevBytes = await fsp8.readFile(target);
43660
44755
  } catch {
43661
44756
  prevBytes = null;
43662
44757
  }
44758
+ await assertHistoryReadyForWrite(target);
43663
44759
  try {
43664
44760
  assertWriteTargetNoSymlink(target);
43665
- await fsp6.mkdir(pathDirname(target), { recursive: true });
44761
+ await fsp8.mkdir(pathDirname(target), { recursive: true });
43666
44762
  assertWriteTargetNoSymlink(target);
43667
44763
  await writeFileNoFollow(target, buf);
43668
44764
  } catch (err) {
@@ -43673,7 +44769,7 @@ async function executeSaveDocument(store, input) {
43673
44769
  if (prevBytes !== null) {
43674
44770
  await writeFileNoFollow(target, prevBytes);
43675
44771
  } else {
43676
- await fsp6.unlink(target);
44772
+ await fsp8.unlink(target);
43677
44773
  }
43678
44774
  } catch (restoreErr) {
43679
44775
  diskOk = false;
@@ -43685,8 +44781,6 @@ async function executeSaveDocument(store, input) {
43685
44781
  remedy
43686
44782
  });
43687
44783
  }
43688
- const savedXml = handle.parser.readDocumentXml();
43689
- const extras = collectTrackedExtrasForGit(handle.parser);
43690
44784
  const opsSinceSave = handle.revision - handle.savedRevision;
43691
44785
  const fallbackMessage = opsSinceSave <= 0 ? `save_document: rev ${handle.revision} (initial baseline)` : `save_document: rev ${handle.revision} (${opsSinceSave} ops since last save)`;
43692
44786
  const baseMessage = sanitizeCommitMessage(parsed.data.message, fallbackMessage);
@@ -43694,7 +44788,7 @@ async function executeSaveDocument(store, input) {
43694
44788
  const authorName = sanitizeCommitAuthor(parsed.data.author);
43695
44789
  let commitOid;
43696
44790
  try {
43697
- await ensureGitRepo(target, savedXml, extras);
44791
+ await ensureGitRepo(target, savedXml, extras, prevBytes ?? void 0);
43698
44792
  commitOid = await commitChange(target, savedXml, commitMessage, extras, void 0, authorName);
43699
44793
  } catch (err) {
43700
44794
  const msg = err instanceof Error ? err.message : String(err);
@@ -43703,7 +44797,7 @@ async function executeSaveDocument(store, input) {
43703
44797
  if (prevBytes !== null) {
43704
44798
  await writeFileNoFollow(target, prevBytes);
43705
44799
  } else {
43706
- await fsp6.unlink(target);
44800
+ await fsp8.unlink(target);
43707
44801
  }
43708
44802
  } catch (restoreErr) {
43709
44803
  diskOk = false;
@@ -43724,6 +44818,15 @@ async function executeSaveDocument(store, input) {
43724
44818
  }
43725
44819
  try {
43726
44820
  handle.filePath = target;
44821
+ if (prevFilePath !== target || !handle.originalInfo) {
44822
+ handle.originalInfo = await getOriginalInfo(target);
44823
+ handle.originalPin = handle.originalInfo.available ? { kind: "retained", sha256: handle.originalInfo.sha256 } : void 0;
44824
+ }
44825
+ if (prevFilePath !== target) handle.originalWarning = void 0;
44826
+ if (!handle.originalInfo.available && !["predates-feature", "not-applicable"].includes(handle.originalInfo.reason)) {
44827
+ handle.originalWarning = `Exact-original recovery is unavailable: ${handle.originalInfo.reason}. Keep a separate copy of the input file.`;
44828
+ }
44829
+ handle.lastWrittenSha256 = (0, import_crypto7.createHash)("sha256").update(buf).digest("hex");
43727
44830
  handle.byteSize = buf.length;
43728
44831
  const auditNodeIds = [...handle.pendingAudit.nodeIds];
43729
44832
  const auditOps = handle.pendingAudit.ops;
@@ -43761,6 +44864,7 @@ async function executeSaveDocument(store, input) {
43761
44864
  ok: true,
43762
44865
  sessionId: handle.sessionId,
43763
44866
  path: target,
44867
+ ...handle.originalWarning ? { originalWarning: handle.originalWarning } : {},
43764
44868
  revision: handle.revision,
43765
44869
  commitOid,
43766
44870
  commitMessage,
@@ -43830,8 +44934,151 @@ async function handleCloseDocument(store, input) {
43830
44934
  }
43831
44935
  }
43832
44936
 
44937
+ // src/lib/mcp/handlers/original.ts
44938
+ var fsp9 = __toESM(require("fs/promises"));
44939
+ var path18 = __toESM(require("path"));
44940
+ var import_crypto8 = require("crypto");
44941
+ var hash = (buf) => (0, import_crypto8.createHash)("sha256").update(buf).digest("hex");
44942
+ function refuse2(code, message, remedy = "Inspect the file and its history before retrying. Export the original to a new path for recovery without changing this document.") {
44943
+ throw Object.assign(new Error(message), { code, remedy });
44944
+ }
44945
+ async function requireAvailableOriginal(handle) {
44946
+ const info = await resolveSessionOriginal(handle);
44947
+ if (info.available) return;
44948
+ const detail = info.detail ?? "Close and reopen after checking history.";
44949
+ const remedy = info.reason === "initializing" ? "History initialization for this document has not finished. Call get_document_history again later; the original becomes available once it completes for this same file." : "The retained original cannot be trusted, so no substitute is returned. Keep the working file and any separate backup, and call get_document_history to see the reason.";
44950
+ refuse2("E_CONFLICT", `Exact-original recovery is unavailable to this session (${info.reason}). ${detail}`, remedy);
44951
+ }
44952
+ async function handleExportOriginalDocument(store, input) {
44953
+ const parsed = ExportOriginalDocumentSchema.safeParse(input);
44954
+ if (!parsed.success) return invalidInput(`export_original_document: ${parsed.error.message}`);
44955
+ try {
44956
+ return await store.withSession(parsed.data.sessionId, async (handle) => {
44957
+ if (!path18.isAbsolute(handle.filePath)) refuse2("E_VALIDATION", "This memory session has no retained original.");
44958
+ await requireAvailableOriginal(handle);
44959
+ const outPath = validateSaveTarget(parsed.data.outPath);
44960
+ if (outPath === handle.filePath) refuse2("E_CONFLICT", "Use restore_original_document to replace the active document.");
44961
+ const original = await exportOriginal(handle.filePath, outPath, { overwrite: parsed.data.overwrite === true });
44962
+ return { ok: true, sessionId: handle.sessionId, path: outPath, original };
44963
+ });
44964
+ } catch (err) {
44965
+ return mcpErrorFrom(err, "E_IO", "Choose a writable output path inside the allowed directory.");
44966
+ }
44967
+ }
44968
+ async function confirmHistoryCurrent(handle, bytes) {
44969
+ const history = await getHistory(handle.filePath);
44970
+ if (!history[0]) refuse2("E_CONFLICT", "Cannot confirm history is current: there is no recorded head.");
44971
+ const head = await checkoutCommit(handle.filePath, history[0].oid);
44972
+ if (!head.complete) refuse2("E_CONFLICT", "Cannot confirm history is current: the head is not a complete package snapshot. Save the document first.");
44973
+ const parser2 = new DocxParser(bytes, handle.macroPolicy);
44974
+ await parser2.getAST();
44975
+ const snapshot3 = parser2.capturePackageSnapshot();
44976
+ if (parser2.readDocumentXml() !== head.xml) refuse2("E_CONFLICT", "History differs from the current file. Save or revert before restoring the original.");
44977
+ const names = [...snapshot3.keys()].filter(isTrackedPackageFile).sort();
44978
+ const recorded = [...head.extra.keys()].sort();
44979
+ if (JSON.stringify(names) !== JSON.stringify(recorded) || names.some((name2) => !snapshot3.get(name2).equals(head.extra.get(name2)))) {
44980
+ refuse2("E_CONFLICT", "History is behind the file or the file was modified outside ASTra. Save or revert before restoring the original.");
44981
+ }
44982
+ return { oid: history[0].oid, parser: parser2 };
44983
+ }
44984
+ async function handleRestoreOriginalDocument(store, input) {
44985
+ const parsed = RestoreOriginalDocumentSchema.safeParse(input);
44986
+ if (!parsed.success) return invalidInput(`restore_original_document: ${parsed.error.message}`);
44987
+ try {
44988
+ return await store.withSession(parsed.data.sessionId, async (handle) => {
44989
+ if (!path18.isAbsolute(handle.filePath)) refuse2("E_VALIDATION", "This memory session has no retained original.");
44990
+ await requireAvailableOriginal(handle);
44991
+ if (handle.revision !== handle.savedRevision && !parsed.data.discard) refuse2("E_CONFLICT", "The session has unsaved edits. Save first, or explicitly pass discard:true.");
44992
+ const source = validateSaveTarget(handle.filePath);
44993
+ await assertHistoryReadyForWrite(source);
44994
+ const original = await getOriginalInfo(source);
44995
+ if (!original.available) refuse2("E_NOT_FOUND", `The original is unavailable: ${original.reason}.`);
44996
+ const bytes = await readOriginal(source);
44997
+ const previous = await fsp9.readFile(source);
44998
+ if (!handle.lastWrittenSha256 || hash(previous) !== handle.lastWrittenSha256) refuse2("E_CONFLICT", "The file changed outside ASTra. Reopen it before restoring.");
44999
+ const checked = await confirmHistoryCurrent(handle, previous);
45000
+ const parser2 = new DocxParser(bytes, handle.macroPolicy);
45001
+ const ast = await parser2.getAST();
45002
+ const engine = new OperationEngine(ast, parser2);
45003
+ if (handle.styleGuide) engine.setStyleGuide(handle.styleGuide);
45004
+ const validation = await validateForBaseline(bytes);
45005
+ const backup = newRestoreBackupPath(source);
45006
+ await createVerifiedBackup(backup, previous);
45007
+ const historyBefore = await captureHistoryState(source);
45008
+ const oldRedo = Array.from(handle.redoStack);
45009
+ const previousSession = {
45010
+ parser: handle.parser,
45011
+ ast: handle.ast,
45012
+ engine: handle.engine,
45013
+ toon: handle.toon,
45014
+ revision: handle.revision,
45015
+ savedRevision: handle.savedRevision,
45016
+ byteSize: handle.byteSize,
45017
+ lastWrittenSha256: handle.lastWrittenSha256,
45018
+ validationBaseline: handle.validationBaseline,
45019
+ pendingAudit: { ops: handle.pendingAudit.ops, nodeIds: [...handle.pendingAudit.nodeIds] }
45020
+ };
45021
+ let replaced = false;
45022
+ let commitAttempted = false;
45023
+ let commitOid;
45024
+ let durabilityUnconfirmed = false;
45025
+ try {
45026
+ handle.redoStack.length = 0;
45027
+ try {
45028
+ handle.redoStack.push((parsed.data.discard ? checked.parser : handle.parser).capturePackageSnapshot());
45029
+ } catch (quota) {
45030
+ if (quota?.code !== "E_QUOTA") throw quota;
45031
+ }
45032
+ if (hash(await fsp9.readFile(source)) !== hash(previous)) refuse2("E_CONFLICT", "The file changed while restore was being prepared.");
45033
+ const write = await replaceFileAtomic(source, bytes);
45034
+ replaced = true;
45035
+ durabilityUnconfirmed = write.durabilityUnconfirmed === true;
45036
+ commitAttempted = true;
45037
+ commitOid = await commitChange(source, parser2.readDocumentXml(), `Restore original file (sha256 ${hash(bytes)})`, collectTrackedExtrasForGit(parser2));
45038
+ handle.parser = parser2;
45039
+ handle.ast = ast;
45040
+ handle.engine = engine;
45041
+ handle.toon = new ToonGenerator();
45042
+ handle.byteSize = bytes.length;
45043
+ store.bumpRevision(handle);
45044
+ store.markSaved(handle);
45045
+ handle.lastWrittenSha256 = hash(bytes);
45046
+ adoptBaseline(handle, validation);
45047
+ } catch (err) {
45048
+ Object.assign(handle, previousSession);
45049
+ handle.redoStack.length = 0;
45050
+ Array.prototype.push.apply(handle.redoStack, oldRedo);
45051
+ const { recovered: diskRecovered, failure: rollbackFailure } = await rollbackReplacedFile(source, previous, { replaced, error: err });
45052
+ const rollbackError = rollbackFailure ?? "";
45053
+ const historyChanged = commitAttempted && await historyChangedSince(source, historyBefore);
45054
+ if (diskRecovered) await fsp9.unlink(backup).catch(() => void 0);
45055
+ const detail = `${String(err instanceof Error ? err.message : err)}${historyChanged ? " History may have changed despite the failed commit; inspect history before continuing." : ""}${diskRecovered ? "" : ` Rollback failed (${rollbackError}); recovery bytes are retained at ${backup}.`}`;
45056
+ throw Object.assign(new Error(detail), {
45057
+ code: err?.code ?? "E_IO",
45058
+ remedy: diskRecovered ? "Inspect the file and history before retrying." : `Recover from ${backup} before editing further.`
45059
+ });
45060
+ }
45061
+ const cleanupFailed = await fsp9.unlink(backup).then(() => false, () => true);
45062
+ return {
45063
+ ok: true,
45064
+ sessionId: handle.sessionId,
45065
+ path: source,
45066
+ revision: handle.revision,
45067
+ dirty: false,
45068
+ commitOid,
45069
+ original,
45070
+ ...durabilityUnconfirmed ? { durabilityUnconfirmed: true } : {},
45071
+ ...cleanupFailed ? { warning: `Restore succeeded but the temporary recovery backup could not be removed: ${backup}` } : {}
45072
+ };
45073
+ });
45074
+ } catch (err) {
45075
+ return mcpErrorFrom(err, "E_IO", "Inspect the document and history; export the original to a new path if in-place restore is unavailable.");
45076
+ }
45077
+ }
45078
+
43833
45079
  // src/lib/mcp/handlers/history.ts
43834
- var fsp7 = __toESM(require("fs/promises"));
45080
+ var import_crypto9 = require("crypto");
45081
+ var fsp10 = __toESM(require("fs/promises"));
43835
45082
  async function handleGetDocumentHistory(store, input) {
43836
45083
  const parsed = GetDocumentHistorySchema.safeParse(input);
43837
45084
  if (!parsed.success) {
@@ -43858,7 +45105,21 @@ async function handleGetDocumentHistory(store, input) {
43858
45105
  currentCommit: null,
43859
45106
  totalCommits: 0,
43860
45107
  history: [],
43861
- warning: emptyWarning
45108
+ warning: emptyWarning,
45109
+ original: await resolveSessionOriginal(handle)
45110
+ };
45111
+ }
45112
+ const original = await resolveSessionOriginal(handle);
45113
+ if (!original.available && original.reason === "initializing") {
45114
+ return {
45115
+ ok: true,
45116
+ sessionId,
45117
+ documentPath: filePath,
45118
+ currentCommit: null,
45119
+ totalCommits: 0,
45120
+ history: [],
45121
+ warning: original.detail ?? "History initialization has not completed.",
45122
+ original
43862
45123
  };
43863
45124
  }
43864
45125
  const full = await getHistory(filePath);
@@ -43892,7 +45153,10 @@ async function handleGetDocumentHistory(store, input) {
43892
45153
  currentCommit: full.length > 0 ? full[0].oid : null,
43893
45154
  totalCommits: full.length,
43894
45155
  history: slice,
43895
- warning
45156
+ warning,
45157
+ // Recovery status was re-verified above against what this session pinned at open, so a corrupted or
45158
+ // replaced original is not reported as available and another history's original is never adopted.
45159
+ original
43896
45160
  };
43897
45161
  });
43898
45162
  } catch (err) {
@@ -43994,7 +45258,7 @@ async function handleRevertDocument(store, input) {
43994
45258
  const prevByteSize = handle.byteSize;
43995
45259
  let prevFileBytes = null;
43996
45260
  try {
43997
- prevFileBytes = await fsp7.readFile(handle.filePath);
45261
+ prevFileBytes = await fsp10.readFile(handle.filePath);
43998
45262
  } catch {
43999
45263
  prevFileBytes = null;
44000
45264
  }
@@ -44017,6 +45281,7 @@ async function handleRevertDocument(store, input) {
44017
45281
  truncated: false
44018
45282
  };
44019
45283
  }
45284
+ await assertHistoryReadyForWrite(handle.filePath);
44020
45285
  let target;
44021
45286
  try {
44022
45287
  target = await checkoutCommit(handle.filePath, targetOid);
@@ -44046,7 +45311,7 @@ async function handleRevertDocument(store, input) {
44046
45311
  }
44047
45312
  assertWriteTargetNoSymlink(handle.filePath);
44048
45313
  writeAttempted = true;
44049
- await fsp7.mkdir(pathDirname(handle.filePath), { recursive: true });
45314
+ await fsp10.mkdir(pathDirname(handle.filePath), { recursive: true });
44050
45315
  assertWriteTargetNoSymlink(handle.filePath);
44051
45316
  await writeFileNoFollow(handle.filePath, buf);
44052
45317
  handle.byteSize = buf.length;
@@ -44075,6 +45340,7 @@ async function handleRevertDocument(store, input) {
44075
45340
  affectedNodeIds: diffIdTexts(liveBefore.texts, liveAfter.order, liveAfter.texts)
44076
45341
  });
44077
45342
  store.markSaved(handle);
45343
+ handle.lastWrittenSha256 = (0, import_crypto9.createHash)("sha256").update(buf).digest("hex");
44078
45344
  adoptBaseline(handle, candidate);
44079
45345
  return {
44080
45346
  ok: true,
@@ -44110,7 +45376,7 @@ async function handleRevertDocument(store, input) {
44110
45376
  if (prevFileBytes !== null) {
44111
45377
  await writeFileNoFollow(handle.filePath, prevFileBytes);
44112
45378
  } else {
44113
- await fsp7.unlink(handle.filePath);
45379
+ await fsp10.unlink(handle.filePath);
44114
45380
  }
44115
45381
  } catch (restoreErr) {
44116
45382
  diskOk = false;
@@ -44167,6 +45433,7 @@ async function handleRedoDocument(store, input) {
44167
45433
  const redoReasonProvided = "reason" in rawRedo && parsed.data.reason !== void 0;
44168
45434
  const redoReasonText = redoReasonProvided ? sanitizeCommitMessage(parsed.data.reason, "") : "";
44169
45435
  const authorName = sanitizeCommitAuthor(parsed.data.author);
45436
+ await assertHistoryReadyForWrite(handle.filePath);
44170
45437
  const redoBefore = collectAstIdTexts(handle.ast);
44171
45438
  const prevRevision = handle.revision;
44172
45439
  const prevSavedRevision = handle.savedRevision;
@@ -44174,7 +45441,7 @@ async function handleRedoDocument(store, input) {
44174
45441
  const preRedoSnap = handle.parser.capturePackageSnapshot();
44175
45442
  let prevFileBytes = null;
44176
45443
  try {
44177
- prevFileBytes = await fsp7.readFile(handle.filePath);
45444
+ prevFileBytes = await fsp10.readFile(handle.filePath);
44178
45445
  } catch {
44179
45446
  prevFileBytes = null;
44180
45447
  }
@@ -44194,7 +45461,7 @@ async function handleRedoDocument(store, input) {
44194
45461
  didPop = true;
44195
45462
  assertWriteTargetNoSymlink(handle.filePath);
44196
45463
  writeAttempted = true;
44197
- await fsp7.mkdir(pathDirname(handle.filePath), { recursive: true });
45464
+ await fsp10.mkdir(pathDirname(handle.filePath), { recursive: true });
44198
45465
  assertWriteTargetNoSymlink(handle.filePath);
44199
45466
  await writeFileNoFollow(handle.filePath, buf);
44200
45467
  handle.byteSize = buf.length;
@@ -44227,6 +45494,7 @@ async function handleRedoDocument(store, input) {
44227
45494
  affectedNodeIds: diffIdTexts(redoBefore.texts, redoAfter.order, redoAfter.texts)
44228
45495
  });
44229
45496
  store.markSaved(handle);
45497
+ handle.lastWrittenSha256 = (0, import_crypto9.createHash)("sha256").update(buf).digest("hex");
44230
45498
  adoptBaseline(handle, candidate);
44231
45499
  return {
44232
45500
  ok: true,
@@ -44261,7 +45529,7 @@ async function handleRedoDocument(store, input) {
44261
45529
  if (prevFileBytes !== null) {
44262
45530
  await writeFileNoFollow(handle.filePath, prevFileBytes);
44263
45531
  } else {
44264
- await fsp7.unlink(handle.filePath);
45532
+ await fsp10.unlink(handle.filePath);
44265
45533
  }
44266
45534
  } catch (restoreErr) {
44267
45535
  diskOk = false;
@@ -44455,6 +45723,8 @@ async function handlePreviewRevert(store, input) {
44455
45723
  }
44456
45724
  }
44457
45725
  var GIT_HISTORY_HANDLERS = {
45726
+ export_original_document: handleExportOriginalDocument,
45727
+ restore_original_document: handleRestoreOriginalDocument,
44458
45728
  get_document_history: handleGetDocumentHistory,
44459
45729
  preview_revert: handlePreviewRevert,
44460
45730
  revert_document: handleRevertDocument,
@@ -44502,9 +45772,9 @@ function adoptForkNumbering(fork, ast) {
44502
45772
  }
44503
45773
 
44504
45774
  // src/tools/export.ts
44505
- var fs15 = __toESM(require("fs"));
44506
- var fsp8 = __toESM(require("fs/promises"));
44507
- var path16 = __toESM(require("path"));
45775
+ var fs17 = __toESM(require("fs"));
45776
+ var fsp11 = __toESM(require("fs/promises"));
45777
+ var path19 = __toESM(require("path"));
44508
45778
  var ExportDocumentSchema = import_zod6.z.object({
44509
45779
  sessionId: import_zod6.z.string().describe("Active document session ID returned by open_document"),
44510
45780
  format: import_zod6.z.enum(["html", "markdown", "md", "pdf"]).describe('Target export format: "html", "markdown", "md", or "pdf"'),
@@ -44560,18 +45830,18 @@ function validateExportTarget(rawPath, expectedFormat) {
44560
45830
  remedy: "Use a path inside the workspace documents directory."
44561
45831
  });
44562
45832
  }
44563
- const resolved = path16.resolve(trimmed);
45833
+ const resolved = path19.resolve(trimmed);
44564
45834
  const canonical = canonicalizeForAsset(resolved);
44565
45835
  const allowedRoots = getMcpAllowedRoots();
44566
45836
  const isInside2 = allowedRoots.some((root) => {
44567
45837
  let canonicalRoot = root;
44568
45838
  try {
44569
- canonicalRoot = fs15.realpathSync(root);
45839
+ canonicalRoot = fs17.realpathSync(root);
44570
45840
  } catch {
44571
45841
  }
44572
- const normC = path16.normalize(canonical);
44573
- const normR = path16.normalize(canonicalRoot);
44574
- return normC === normR || normC.startsWith(normR + path16.sep);
45842
+ const normC = path19.normalize(canonical);
45843
+ const normR = path19.normalize(canonicalRoot);
45844
+ return normC === normR || normC.startsWith(normR + path19.sep);
44575
45845
  });
44576
45846
  if (!isInside2) {
44577
45847
  throw Object.assign(
@@ -44582,7 +45852,7 @@ function validateExportTarget(rawPath, expectedFormat) {
44582
45852
  }
44583
45853
  );
44584
45854
  }
44585
- const ext = path16.extname(trimmed).toLowerCase();
45855
+ const ext = path19.extname(trimmed).toLowerCase();
44586
45856
  const validExts = {
44587
45857
  html: [".html", ".htm"],
44588
45858
  markdown: [".md", ".markdown"],
@@ -44626,18 +45896,18 @@ function validateAssetTarget(rawPath) {
44626
45896
  remedy: "Use a path inside the workspace documents directory."
44627
45897
  });
44628
45898
  }
44629
- const resolved = path16.resolve(trimmed);
45899
+ const resolved = path19.resolve(trimmed);
44630
45900
  const canonical = canonicalizeForAsset(resolved);
44631
45901
  const allowedRoots = getMcpAllowedRoots();
44632
45902
  const isInside2 = allowedRoots.some((root) => {
44633
45903
  let canonicalRoot = root;
44634
45904
  try {
44635
- canonicalRoot = fs15.realpathSync(root);
45905
+ canonicalRoot = fs17.realpathSync(root);
44636
45906
  } catch {
44637
45907
  }
44638
- const normC = path16.normalize(canonical);
44639
- const normR = path16.normalize(canonicalRoot);
44640
- return normC === normR || normC.startsWith(normR + path16.sep);
45908
+ const normC = path19.normalize(canonical);
45909
+ const normR = path19.normalize(canonicalRoot);
45910
+ return normC === normR || normC.startsWith(normR + path19.sep);
44641
45911
  });
44642
45912
  if (!isInside2) {
44643
45913
  throw Object.assign(
@@ -44737,7 +46007,7 @@ async function handleExportDocument(store, rawArgs) {
44737
46007
  }
44738
46008
  let validatedPdfPath;
44739
46009
  if (format === "pdf") {
44740
- if (!hasPdfBackend()) {
46010
+ if (!hasPdfBackend2()) {
44741
46011
  return {
44742
46012
  ok: false,
44743
46013
  code: "E_DEPENDENCY",
@@ -44745,7 +46015,7 @@ async function handleExportDocument(store, rawArgs) {
44745
46015
  remedy: "Install LibreOffice on the host system: sudo apt install libreoffice (Linux) or brew install --cask libreoffice (macOS)."
44746
46016
  };
44747
46017
  }
44748
- const defaultPdf = targetPath || path16.join(path16.dirname(session.filePath), `${path16.basename(session.filePath, path16.extname(session.filePath))}.pdf`);
46018
+ const defaultPdf = targetPath || path19.join(path19.dirname(session.filePath), `${path19.basename(session.filePath, path19.extname(session.filePath))}.pdf`);
44749
46019
  try {
44750
46020
  validatedPdfPath = validateExportTarget(defaultPdf, "pdf");
44751
46021
  } catch (err) {
@@ -44784,7 +46054,7 @@ async function handleExportDocument(store, rawArgs) {
44784
46054
  }
44785
46055
  throw original;
44786
46056
  }
44787
- const stat3 = await fsp8.stat(validatedPdfPath);
46057
+ const stat3 = await fsp11.stat(validatedPdfPath);
44788
46058
  return {
44789
46059
  ok: true,
44790
46060
  format: "pdf",
@@ -44798,22 +46068,22 @@ async function handleExportDocument(store, rawArgs) {
44798
46068
  }
44799
46069
  const ast = session.ast;
44800
46070
  if (format === "html") {
44801
- const title = htmlDocumentTitle(ast, path16.basename(session.filePath, path16.extname(session.filePath)));
46071
+ const title = htmlDocumentTitle(ast, path19.basename(session.filePath, path19.extname(session.filePath)));
44802
46072
  const html = convertAstToHtml(ast, title, void 0, session.parser);
44803
46073
  if (targetPath) {
44804
46074
  assertWriteTargetNoSymlink(targetPath);
44805
- await fsp8.mkdir(path16.dirname(targetPath), { recursive: true });
46075
+ await fsp11.mkdir(path19.dirname(targetPath), { recursive: true });
44806
46076
  assertWriteTargetNoSymlink(targetPath);
44807
46077
  await writeFileNoFollow(targetPath, html, "utf-8");
44808
- const stat3 = await fsp8.stat(targetPath);
46078
+ const stat3 = await fsp11.stat(targetPath);
44809
46079
  return { ok: true, format: "html", path: targetPath, bytes: stat3.size, dirty, revision };
44810
46080
  }
44811
46081
  return { ok: true, format: "html", content: html, bytes: Buffer.byteLength(html, "utf-8"), dirty, revision };
44812
46082
  } else {
44813
46083
  const effectiveImageMode = parsed.data.imageMode ?? (targetPath ? "file" : "embed");
44814
- const docBase = path16.basename(
46084
+ const docBase = path19.basename(
44815
46085
  targetPath || session.filePath || "document.docx",
44816
- path16.extname(targetPath || session.filePath || "document.docx")
46086
+ path19.extname(targetPath || session.filePath || "document.docx")
44817
46087
  ) || "document";
44818
46088
  const safeDocBase = docBase.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "").slice(0, 120) || "document";
44819
46089
  const defaultAssetPrefix = `${safeDocBase}_assets`;
@@ -44821,39 +46091,39 @@ async function handleExportDocument(store, rawArgs) {
44821
46091
  if (effectiveImageMode === "file") {
44822
46092
  const initialAssets = collectMarkdownImageAssets(ast, effectiveAssetPrefix);
44823
46093
  if (!targetPath) throw new Error("Markdown file image mode requires an output path.");
44824
- const destDir = path16.dirname(targetPath);
46094
+ const destDir = path19.dirname(targetPath);
44825
46095
  let generation;
44826
46096
  let staging;
44827
46097
  let published = false;
44828
46098
  try {
44829
46099
  assertWriteTargetNoSymlink(targetPath);
44830
- await fsp8.mkdir(destDir, { recursive: true });
46100
+ await fsp11.mkdir(destDir, { recursive: true });
44831
46101
  assertWriteTargetNoSymlink(targetPath);
44832
46102
  let outputPrefix = effectiveAssetPrefix;
44833
46103
  if (initialAssets.length > 0) {
44834
- const prefixDir = validateAssetTarget(path16.resolve(destDir, effectiveAssetPrefix));
46104
+ const prefixDir = validateAssetTarget(path19.resolve(destDir, effectiveAssetPrefix));
44835
46105
  assertWriteTargetNoSymlink(prefixDir);
44836
- await fsp8.mkdir(prefixDir, { recursive: true });
46106
+ await fsp11.mkdir(prefixDir, { recursive: true });
44837
46107
  assertWriteTargetNoSymlink(prefixDir);
44838
- generation = await fsp8.mkdtemp(path16.join(prefixDir, "export-"));
46108
+ generation = await fsp11.mkdtemp(path19.join(prefixDir, "export-"));
44839
46109
  assertWriteTargetNoSymlink(generation);
44840
- outputPrefix = path16.relative(destDir, generation).split(path16.sep).join("/");
46110
+ outputPrefix = path19.relative(destDir, generation).split(path19.sep).join("/");
44841
46111
  }
44842
46112
  const md = convertAstToMarkdown(ast, session.parser, { imageMode: "file", assetPrefix: outputPrefix });
44843
46113
  const imageAssets = collectMarkdownImageAssets(ast, outputPrefix);
44844
46114
  const writtenAssets = [];
44845
46115
  for (const asset of imageAssets) {
44846
- const assetPath = validateAssetTarget(path16.resolve(destDir, asset.name));
46116
+ const assetPath = validateAssetTarget(path19.resolve(destDir, asset.name));
44847
46117
  assertWriteTargetNoSymlink(assetPath);
44848
46118
  await writeFileNoFollow(assetPath, asset.data);
44849
46119
  writtenAssets.push(assetPath);
44850
46120
  }
44851
- staging = await fsp8.mkdtemp(path16.join(destDir, ".astra-export-"));
44852
- const stagedFile = path16.join(staging, "document.md");
46121
+ staging = await fsp11.mkdtemp(path19.join(destDir, ".astra-export-"));
46122
+ const stagedFile = path19.join(staging, "document.md");
44853
46123
  assertWriteTargetNoSymlink(stagedFile);
44854
46124
  await writeFileNoFollow(stagedFile, md, "utf-8");
44855
46125
  assertWriteTargetNoSymlink(targetPath);
44856
- await fsp8.rename(stagedFile, targetPath);
46126
+ await fsp11.rename(stagedFile, targetPath);
44857
46127
  published = true;
44858
46128
  return {
44859
46129
  ok: true,
@@ -44868,17 +46138,17 @@ async function handleExportDocument(store, rawArgs) {
44868
46138
  assetCount: writtenAssets.length
44869
46139
  };
44870
46140
  } finally {
44871
- if (staging) await fsp8.rm(staging, { recursive: true, force: true }).catch(() => void 0);
44872
- if (!published && generation) await fsp8.rm(generation, { recursive: true, force: true }).catch(() => void 0);
46141
+ if (staging) await fsp11.rm(staging, { recursive: true, force: true }).catch(() => void 0);
46142
+ if (!published && generation) await fsp11.rm(generation, { recursive: true, force: true }).catch(() => void 0);
44873
46143
  }
44874
46144
  } else {
44875
46145
  const md = convertAstToMarkdown(ast, session.parser, { imageMode: "embed" });
44876
46146
  if (targetPath) {
44877
46147
  assertWriteTargetNoSymlink(targetPath);
44878
- await fsp8.mkdir(path16.dirname(targetPath), { recursive: true });
46148
+ await fsp11.mkdir(path19.dirname(targetPath), { recursive: true });
44879
46149
  assertWriteTargetNoSymlink(targetPath);
44880
46150
  await writeFileNoFollow(targetPath, md, "utf-8");
44881
- const stat3 = await fsp8.stat(targetPath);
46151
+ const stat3 = await fsp11.stat(targetPath);
44882
46152
  return {
44883
46153
  ok: true,
44884
46154
  format: "markdown",
@@ -44916,8 +46186,8 @@ async function handleExportDocument(store, rawArgs) {
44916
46186
  }
44917
46187
 
44918
46188
  // src/tools/visual.ts
44919
- var fs16 = __toESM(require("fs"));
44920
- var path17 = __toESM(require("path"));
46189
+ var fs18 = __toESM(require("fs"));
46190
+ var path20 = __toESM(require("path"));
44921
46191
  var import_zod7 = require("zod");
44922
46192
  function visualError(code, message, remedy) {
44923
46193
  return { ok: false, code, message, remedy };
@@ -45065,14 +46335,14 @@ function serializeSessionCopy2(parser2, ast) {
45065
46335
  return fork.rezip();
45066
46336
  }
45067
46337
  function placeImages(images, dir, docPath) {
45068
- fs16.mkdirSync(dir, { recursive: true });
45069
- const base = path17.basename(docPath || "document", path17.extname(docPath || "")).replace(/[^a-zA-Z0-9._-]+/g, "-") || "document";
46338
+ fs18.mkdirSync(dir, { recursive: true });
46339
+ const base = path20.basename(docPath || "document", path20.extname(docPath || "")).replace(/[^a-zA-Z0-9._-]+/g, "-") || "document";
45070
46340
  return images.map((img) => {
45071
- const ext = path17.extname(img.path) || (img.format === "jpeg" ? ".jpg" : ".png");
45072
- const dest = path17.join(dir, `${base}-page-${img.page}${ext}`);
45073
- writeFileNoFollowSync(dest, fs16.readFileSync(img.path));
46341
+ const ext = path20.extname(img.path) || (img.format === "jpeg" ? ".jpg" : ".png");
46342
+ const dest = path20.join(dir, `${base}-page-${img.page}${ext}`);
46343
+ writeFileNoFollowSync(dest, fs18.readFileSync(img.path));
45074
46344
  try {
45075
- fs16.rmSync(img.path, { force: true });
46345
+ fs18.rmSync(img.path, { force: true });
45076
46346
  } catch {
45077
46347
  }
45078
46348
  return { ...img, path: dest };
@@ -45199,8 +46469,8 @@ async function handleVisualInspect(store, input) {
45199
46469
 
45200
46470
  // src/tools/new-document.ts
45201
46471
  var import_zod8 = require("zod");
45202
- var fsp9 = __toESM(require("fs/promises"));
45203
- var path18 = __toESM(require("path"));
46472
+ var fsp12 = __toESM(require("fs/promises"));
46473
+ var path21 = __toESM(require("path"));
45204
46474
  var NEW_DOCUMENT_TEMPLATES = ["report", "memo", "blank"];
45205
46475
  var NewDocumentSchema = import_zod8.z.object({
45206
46476
  outPath: import_zod8.z.string().min(1).max(1024),
@@ -45276,10 +46546,10 @@ async function handleNewDocument(store, rawArgs) {
45276
46546
  }
45277
46547
  if (!force) {
45278
46548
  try {
45279
- await fsp9.access(target);
46549
+ await fsp12.access(target);
45280
46550
  return newDocError(
45281
46551
  "E_CONFLICT",
45282
- `new_document: destination "${path18.basename(target)}" already exists (refusing to overwrite without force)`,
46552
+ `new_document: destination "${path21.basename(target)}" already exists (refusing to overwrite without force)`,
45283
46553
  'Retry with {"force":true} to overwrite, or choose a different outPath.'
45284
46554
  );
45285
46555
  } catch {
@@ -45330,7 +46600,7 @@ async function handleNewDocument(store, rawArgs) {
45330
46600
  }
45331
46601
  try {
45332
46602
  assertWriteTargetNoSymlink(target);
45333
- await fsp9.mkdir(path18.dirname(target), { recursive: true });
46603
+ await fsp12.mkdir(path21.dirname(target), { recursive: true });
45334
46604
  assertWriteTargetNoSymlink(target);
45335
46605
  await writeFileNoFollow(target, buf);
45336
46606
  } catch (err) {
@@ -45342,12 +46612,12 @@ async function handleNewDocument(store, rawArgs) {
45342
46612
  );
45343
46613
  }
45344
46614
  try {
45345
- const handle = await store.openDocument(target, parsed.data.sessionId);
46615
+ const handle = await store.openDocument(target, parsed.data.sessionId, void 0, false);
45346
46616
  handle.parser.adoptBuiltinSeedFrom(parser2);
45347
46617
  if (parsed.data.author !== void 0) handle.ast.settings.defaultAuthor = parsed.data.author;
45348
46618
  let bytes = buf.length;
45349
46619
  try {
45350
- bytes = (await fsp9.stat(target)).size;
46620
+ bytes = (await fsp12.stat(target)).size;
45351
46621
  } catch {
45352
46622
  }
45353
46623
  if (guideRef !== void 0 && resolvedGuide !== void 0) {
@@ -45377,8 +46647,8 @@ async function handleNewDocument(store, rawArgs) {
45377
46647
  }
45378
46648
 
45379
46649
  // src/lib/mcp/handlers/parity.ts
45380
- var fs17 = __toESM(require("fs"));
45381
- var path19 = __toESM(require("path"));
46650
+ var fs19 = __toESM(require("fs"));
46651
+ var path22 = __toESM(require("path"));
45382
46652
  var IMPORT_FILE_MAX_BYTES = COMPOSE_MARKDOWN_MAX;
45383
46653
  async function refreshSessionAst(handle) {
45384
46654
  await refreshAstInPlace(handle.ast, handle.parser);
@@ -45463,7 +46733,8 @@ async function handleGetStats(store, input) {
45463
46733
  tables: stats.tables,
45464
46734
  callouts: stats.callouts,
45465
46735
  images: stats.images,
45466
- pages: stats.pages
46736
+ pages: stats.pages,
46737
+ pageTerms: roundedPageTerms(stats.pageTerms)
45467
46738
  };
45468
46739
  });
45469
46740
  } catch (err) {
@@ -45633,17 +46904,17 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45633
46904
  if (segments.includes("..")) {
45634
46905
  throw toError("E_SANDBOX", 'import path contains forbidden ".." traversal', "Use a path inside the workspace documents directory.");
45635
46906
  }
45636
- const resolved = path19.resolve(trimmed);
46907
+ const resolved = path22.resolve(trimmed);
45637
46908
  const canonical = canonicalizeForAsset(resolved);
45638
46909
  const inside = roots.some((root) => {
45639
46910
  let canonicalRoot = root;
45640
46911
  try {
45641
- canonicalRoot = fs17.realpathSync(root);
46912
+ canonicalRoot = fs19.realpathSync(root);
45642
46913
  } catch {
45643
46914
  }
45644
- const normC = path19.normalize(canonical);
45645
- const normR = path19.normalize(canonicalRoot);
45646
- return normC === normR || normC.startsWith(normR + path19.sep);
46915
+ const normC = path22.normalize(canonical);
46916
+ const normR = path22.normalize(canonicalRoot);
46917
+ return normC === normR || normC.startsWith(normR + path22.sep);
45647
46918
  });
45648
46919
  if (!inside) {
45649
46920
  throw toError(
@@ -45652,12 +46923,12 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45652
46923
  "Place the Markdown file inside one of the allowed directories, or name its folder with --allowed-dir or ASTRA_EXTRA_DOC_ROOTS."
45653
46924
  );
45654
46925
  }
45655
- const base = path19.basename(canonical);
45656
- const nofollow = fs17.constants.O_NOFOLLOW;
45657
- const flags = typeof nofollow === "number" && nofollow !== 0 ? fs17.constants.O_RDONLY | nofollow : "r";
46926
+ const base = path22.basename(canonical);
46927
+ const nofollow = fs19.constants.O_NOFOLLOW;
46928
+ const flags = typeof nofollow === "number" && nofollow !== 0 ? fs19.constants.O_RDONLY | nofollow : "r";
45658
46929
  let fd;
45659
46930
  try {
45660
- fd = fs17.openSync(canonical, flags);
46931
+ fd = fs19.openSync(canonical, flags);
45661
46932
  } catch (err) {
45662
46933
  const code = err?.code;
45663
46934
  if (code === "ENOENT") {
@@ -45669,7 +46940,7 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45669
46940
  throw toError("E_IO", `import file cannot be read (${base})`, "Check the file exists and is readable, then retry.");
45670
46941
  }
45671
46942
  try {
45672
- const st = fs17.fstatSync(fd);
46943
+ const st = fs19.fstatSync(fd);
45673
46944
  if (!st.isFile()) {
45674
46945
  throw toError("E_IO", `import reference is not a file (${base})`, "Point the import at a regular Markdown file.");
45675
46946
  }
@@ -45683,7 +46954,7 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45683
46954
  "Split the Markdown source into smaller files and import each in its own call."
45684
46955
  );
45685
46956
  }
45686
- const buf = fs17.readFileSync(fd);
46957
+ const buf = fs19.readFileSync(fd);
45687
46958
  if (buf.length > IMPORT_FILE_MAX_BYTES) {
45688
46959
  throw toError(
45689
46960
  "E_LIMIT",
@@ -45693,18 +46964,18 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45693
46964
  }
45694
46965
  let post = canonical;
45695
46966
  try {
45696
- post = fs17.realpathSync(canonical);
46967
+ post = fs19.realpathSync(canonical);
45697
46968
  } catch {
45698
46969
  }
45699
46970
  const stillInside = roots.some((root) => {
45700
46971
  let canonicalRoot = root;
45701
46972
  try {
45702
- canonicalRoot = fs17.realpathSync(root);
46973
+ canonicalRoot = fs19.realpathSync(root);
45703
46974
  } catch {
45704
46975
  }
45705
- const normC = path19.normalize(post);
45706
- const normR = path19.normalize(canonicalRoot);
45707
- return normC === normR || normC.startsWith(normR + path19.sep);
46976
+ const normC = path22.normalize(post);
46977
+ const normR = path22.normalize(canonicalRoot);
46978
+ return normC === normR || normC.startsWith(normR + path22.sep);
45708
46979
  });
45709
46980
  if (!stillInside) {
45710
46981
  throw toError("E_SANDBOX", "import path points outside the allowed directory", `Keep Markdown files ${describeAllowedFolders()}.`);
@@ -45712,7 +46983,7 @@ function readImportMarkdownFile(rawPath, roots = getMcpAllowedRoots()) {
45712
46983
  return buf.toString("utf8");
45713
46984
  } finally {
45714
46985
  try {
45715
- if (fd !== void 0) fs17.closeSync(fd);
46986
+ if (fd !== void 0) fs19.closeSync(fd);
45716
46987
  } catch {
45717
46988
  }
45718
46989
  }
@@ -45728,12 +46999,12 @@ async function handleImportDocument(store, input) {
45728
46999
  remedy: 'Retry with {"sessionId":"<id from open_document>","path":"<allowed-dir>/file.md"} (optional allowedDir for image refs, optional template report|memo|blank, optional styleGuide).'
45729
47000
  };
45730
47001
  }
45731
- const srcExt = path19.extname(parsed.data.path).toLowerCase();
47002
+ const srcExt = path22.extname(parsed.data.path).toLowerCase();
45732
47003
  if (srcExt === ".html" || srcExt === ".htm") {
45733
47004
  return {
45734
47005
  ok: false,
45735
47006
  code: "E_VALIDATION",
45736
- message: `HTML import is not supported directly ("${path19.basename(parsed.data.path)}")`,
47007
+ message: `HTML import is not supported directly ("${path22.basename(parsed.data.path)}")`,
45737
47008
  remedy: "ASTra imports Markdown (.md). Convert the HTML file to Markdown or supply a .md document"
45738
47009
  };
45739
47010
  }
@@ -45741,7 +47012,7 @@ async function handleImportDocument(store, input) {
45741
47012
  return {
45742
47013
  ok: false,
45743
47014
  code: "E_VALIDATION",
45744
- message: `import_document cannot ingest .docx files ("${path19.basename(parsed.data.path)}"): open .docx files directly with open_document instead`,
47015
+ message: `import_document cannot ingest .docx files ("${path22.basename(parsed.data.path)}"): open .docx files directly with open_document instead`,
45745
47016
  remedy: "Call open_document with the .docx path to work with it, or convert it to Markdown first and import the .md file."
45746
47017
  };
45747
47018
  }
@@ -45777,15 +47048,15 @@ async function handleImportDocument(store, input) {
45777
47048
  }
45778
47049
 
45779
47050
  // src/lib/mcp/schema-dump.ts
45780
- var fs18 = __toESM(require("fs"));
45781
- var path20 = __toESM(require("path"));
47051
+ var fs20 = __toESM(require("fs"));
47052
+ var path23 = __toESM(require("path"));
45782
47053
  var import_zod9 = require("zod");
45783
47054
 
45784
47055
  // package.json
45785
47056
  var package_default = {
45786
47057
  name: "@attentionform/astra-mcp-server",
45787
47058
  mcpName: "com.attentionform/astra-mcp-server",
45788
- version: "0.4.2",
47059
+ version: "0.5.1",
45789
47060
  description: "Official Model Context Protocol (MCP) server for ASTra document IDE",
45790
47061
  main: "dist/index.js",
45791
47062
  types: "dist/index.d.ts",
@@ -45826,8 +47097,8 @@ var package_default = {
45826
47097
  "adm-zip-gate": "node ../../scripts/check-adm-zip-imports.mjs"
45827
47098
  },
45828
47099
  dependencies: {
45829
- "@attentionform/astra-engine": "^0.4.2",
45830
- "@attentionform/ooxml-validator": "^0.4.2",
47100
+ "@attentionform/astra-engine": "^0.5.1",
47101
+ "@attentionform/ooxml-validator": "^0.5.1",
45831
47102
  "@modelcontextprotocol/sdk": "^1.32.2",
45832
47103
  "@xmldom/xmldom": "0.8.15",
45833
47104
  "adm-zip": "0.6.1",
@@ -45872,6 +47143,8 @@ function getToolDefinitions() {
45872
47143
  { name: "compose_document", description: COMPOSE_DOCUMENT_DESCRIPTION, schema: ComposeDocumentSchema },
45873
47144
  { name: "export_document", description: EXPORT_DOCUMENT_DESCRIPTION, schema: ExportDocumentSchema },
45874
47145
  { name: "visual_inspect", description: VISUAL_INSPECT_DESCRIPTION, schema: VisualInspectSchema },
47146
+ { name: "export_original_document", description: GIT_HISTORY_DESCRIPTIONS.export_original_document, schema: ExportOriginalDocumentSchema },
47147
+ { name: "restore_original_document", description: GIT_HISTORY_DESCRIPTIONS.restore_original_document, schema: RestoreOriginalDocumentSchema },
45875
47148
  { name: "get_document_history", description: GIT_HISTORY_DESCRIPTIONS.get_document_history, schema: GetDocumentHistorySchema },
45876
47149
  { name: "preview_revert", description: GIT_HISTORY_DESCRIPTIONS.preview_revert, schema: PreviewRevertSchema },
45877
47150
  { name: "revert_document", description: GIT_HISTORY_DESCRIPTIONS.revert_document, schema: RevertDocumentSchema },
@@ -45892,8 +47165,8 @@ function compactToolSchema(schema) {
45892
47165
  return raw;
45893
47166
  }
45894
47167
  function generateSchemasToDir(targetDir, version = getServerVersion()) {
45895
- if (!fs18.existsSync(targetDir)) {
45896
- fs18.mkdirSync(targetDir, { recursive: true });
47168
+ if (!fs20.existsSync(targetDir)) {
47169
+ fs20.mkdirSync(targetDir, { recursive: true });
45897
47170
  }
45898
47171
  const stamp2 = buildStamp(version);
45899
47172
  const written = [];
@@ -45905,8 +47178,8 @@ function generateSchemasToDir(targetDir, version = getServerVersion()) {
45905
47178
  parameters: raw,
45906
47179
  [SCHEMA_STAMP_KEY]: stamp2
45907
47180
  };
45908
- const filePath = path20.join(targetDir, `${tool.name}.json`);
45909
- fs18.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
47181
+ const filePath = path23.join(targetDir, `${tool.name}.json`);
47182
+ fs20.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
45910
47183
  written.push(filePath);
45911
47184
  }
45912
47185
  return { written, count: written.length };
@@ -45916,11 +47189,11 @@ function checkSchemasInDir(dir, expectedVersion2 = getServerVersion()) {
45916
47189
  const mismatches = [];
45917
47190
  let checked = 0;
45918
47191
  for (const tool of getToolDefinitions()) {
45919
- const file = path20.join(dir, `${tool.name}.json`);
47192
+ const file = path23.join(dir, `${tool.name}.json`);
45920
47193
  checked += 1;
45921
47194
  let stamped = "<missing>";
45922
47195
  try {
45923
- const raw = JSON.parse(fs18.readFileSync(file, "utf8"));
47196
+ const raw = JSON.parse(fs20.readFileSync(file, "utf8"));
45924
47197
  const value = raw[SCHEMA_STAMP_KEY];
45925
47198
  stamped = typeof value === "string" ? value : "<unstamped>";
45926
47199
  } catch {
@@ -45933,7 +47206,7 @@ function checkSchemasInDir(dir, expectedVersion2 = getServerVersion()) {
45933
47206
  return { ok: mismatches.length === 0, mismatches, checked };
45934
47207
  }
45935
47208
  function formatMismatchWarning(mismatches) {
45936
- const lines = mismatches.map((m) => ` ${path20.basename(m.file)}: stamped ${m.stamped}, installed ${m.expected}`);
47209
+ const lines = mismatches.map((m) => ` ${path23.basename(m.file)}: stamped ${m.stamped}, installed ${m.expected}`);
45937
47210
  return "Schema cache is stale: regenerated wire schemas differ from the installed server. Refresh file-based client copies with `astra-mcp-server --dump-schemas <dir>` and restart memory-cached clients.\n" + lines.join("\n");
45938
47211
  }
45939
47212
 
@@ -46000,20 +47273,20 @@ function buildUsageText() {
46000
47273
  var CLI_USAGE_ERROR = 2;
46001
47274
  function initAllowedDirs(argv = process.argv) {
46002
47275
  const currentRoots = new Set(
46003
- (process.env.ASTRA_EXTRA_DOC_ROOTS || "").split(path21.delimiter).map((p) => p.trim()).filter(Boolean)
47276
+ (process.env.ASTRA_EXTRA_DOC_ROOTS || "").split(path24.delimiter).map((p) => p.trim()).filter(Boolean)
46004
47277
  );
46005
47278
  for (let i = 0; i < argv.length; i++) {
46006
47279
  if (argv[i] === "--") break;
46007
47280
  if (argv[i] === "--allowed-dir" && argv[i + 1] && !argv[i + 1].startsWith("-")) {
46008
- currentRoots.add(path21.resolve(argv[i + 1]));
47281
+ currentRoots.add(path24.resolve(argv[i + 1]));
46009
47282
  i++;
46010
47283
  } else if (argv[i].startsWith("--allowed-dir=")) {
46011
47284
  const value = argv[i].slice("--allowed-dir=".length).trim();
46012
- if (value && !value.startsWith("-")) currentRoots.add(path21.resolve(value));
47285
+ if (value && !value.startsWith("-")) currentRoots.add(path24.resolve(value));
46013
47286
  }
46014
47287
  }
46015
47288
  if (currentRoots.size > 0) {
46016
- process.env.ASTRA_EXTRA_DOC_ROOTS = Array.from(currentRoots).join(path21.delimiter);
47289
+ process.env.ASTRA_EXTRA_DOC_ROOTS = Array.from(currentRoots).join(path24.delimiter);
46017
47290
  }
46018
47291
  }
46019
47292
  function takeDirFlag(argv, flag) {
@@ -46079,6 +47352,16 @@ function createAstraMcpServer(store = createMcpSessionStore()) {
46079
47352
  },
46080
47353
  async (args) => toTextResult(await handleVisualInspect(store, args))
46081
47354
  );
47355
+ server.registerTool(
47356
+ "export_original_document",
47357
+ { description: GIT_HISTORY_DESCRIPTIONS.export_original_document, inputSchema: ExportOriginalDocumentSchema },
47358
+ async (args) => toTextResult(await GIT_HISTORY_HANDLERS.export_original_document(store, args))
47359
+ );
47360
+ server.registerTool(
47361
+ "restore_original_document",
47362
+ { description: GIT_HISTORY_DESCRIPTIONS.restore_original_document, inputSchema: RestoreOriginalDocumentSchema },
47363
+ async (args) => toTextResult(await GIT_HISTORY_HANDLERS.restore_original_document(store, args))
47364
+ );
46082
47365
  server.registerTool(
46083
47366
  "get_document_history",
46084
47367
  { description: GIT_HISTORY_DESCRIPTIONS.get_document_history, inputSchema: GetDocumentHistorySchema },
@@ -46206,7 +47489,7 @@ async function main(argv = process.argv) {
46206
47489
  if (openTimeout !== void 0) setOpenValidationTimeoutMs(Number(openTimeout));
46207
47490
  const dumpDir = takeDirFlag(argv, "--dump-schemas");
46208
47491
  if (dumpDir !== void 0) {
46209
- const resolved = path21.resolve(dumpDir);
47492
+ const resolved = path24.resolve(dumpDir);
46210
47493
  const result = generateSchemasToDir(resolved, SERVER_VERSION);
46211
47494
  process.stdout.write(`[${SERVER_NAME} v${SERVER_VERSION}] wrote ${result.count} schemas to ${resolved}
46212
47495
  `);
@@ -46214,7 +47497,7 @@ async function main(argv = process.argv) {
46214
47497
  }
46215
47498
  const checkDir = takeDirFlag(argv, "--check-schemas");
46216
47499
  if (checkDir !== void 0) {
46217
- const resolved = path21.resolve(checkDir);
47500
+ const resolved = path24.resolve(checkDir);
46218
47501
  const verdict = checkSchemasInDir(resolved, SERVER_VERSION);
46219
47502
  if (verdict.ok) {
46220
47503
  process.stdout.write(`[${SERVER_NAME} v${SERVER_VERSION}] schemas fresh (${verdict.checked} tools)