tandem-editor 0.1.2 → 0.2.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.
@@ -71,12 +71,14 @@ async function startHocuspocus(port) {
71
71
  // stdout is the MCP wire — suppress the startup banner
72
72
  async onConnect({ request, documentName }) {
73
73
  const origin = request?.headers?.origin;
74
- if (origin) {
75
- const url = new URL(origin);
76
- if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
77
- console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
78
- throw new Error("Connection rejected: invalid origin");
79
- }
74
+ if (!origin) {
75
+ console.error("[Hocuspocus] Rejected connection: missing Origin header");
76
+ throw new Error("Connection rejected: missing origin header");
77
+ }
78
+ const url = new URL(origin);
79
+ if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
80
+ console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
81
+ throw new Error("Connection rejected: invalid origin");
80
82
  }
81
83
  console.error(`[Hocuspocus] Client connected to: ${documentName}`);
82
84
  },
@@ -144,7 +146,8 @@ function freePort(port) {
144
146
  } else {
145
147
  freePortUnix(port);
146
148
  }
147
- } catch {
149
+ } catch (err) {
150
+ console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
148
151
  }
149
152
  }
150
153
  async function waitForPort(port, timeoutMs = 5e3) {
@@ -153,9 +156,7 @@ async function waitForPort(port, timeoutMs = 5e3) {
153
156
  if (await tryBind(port)) return;
154
157
  await new Promise((r) => setTimeout(r, 200));
155
158
  }
156
- console.error(
157
- `[Tandem] Warning: port ${port} still not available after ${timeoutMs}ms, proceeding anyway`
158
- );
159
+ throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
159
160
  }
160
161
  function tryBind(port) {
161
162
  return new Promise((resolve, reject) => {
@@ -275,7 +276,16 @@ async function loadSession(filePath) {
275
276
  try {
276
277
  const content = await fs.readFile(sessionPath, "utf-8");
277
278
  return JSON.parse(content);
278
- } catch {
279
+ } catch (err) {
280
+ const code = err.code;
281
+ if (code === "ENOENT") return null;
282
+ if (err instanceof SyntaxError) {
283
+ console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
284
+ await fs.unlink(sessionPath).catch(() => {
285
+ });
286
+ return null;
287
+ }
288
+ console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
279
289
  return null;
280
290
  }
281
291
  }
@@ -344,7 +354,16 @@ async function loadCtrlSession() {
344
354
  const content = await fs.readFile(sessionPath, "utf-8");
345
355
  const data = JSON.parse(content);
346
356
  return data.ydocState ?? null;
347
- } catch {
357
+ } catch (err) {
358
+ const code = err.code;
359
+ if (code === "ENOENT") return null;
360
+ if (err instanceof SyntaxError) {
361
+ console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
362
+ await fs.unlink(sessionPath).catch(() => {
363
+ });
364
+ return null;
365
+ }
366
+ console.error(`[Tandem] Failed to read ctrl session:`, err);
348
367
  return null;
349
368
  }
350
369
  }
@@ -378,18 +397,26 @@ async function listSessionFilePaths() {
378
397
  }
379
398
  async function cleanupSessions() {
380
399
  let cleaned = 0;
400
+ let files;
381
401
  try {
382
- const files = await fs.readdir(SESSION_DIR);
383
- const now = Date.now();
384
- for (const file of files) {
402
+ files = await fs.readdir(SESSION_DIR);
403
+ } catch (err) {
404
+ if (err.code === "ENOENT") return 0;
405
+ console.error("[Tandem] Failed to read session directory:", err);
406
+ return 0;
407
+ }
408
+ const now = Date.now();
409
+ for (const file of files) {
410
+ try {
385
411
  const filePath = path2.join(SESSION_DIR, file);
386
412
  const stat = await fs.stat(filePath);
387
413
  if (now - stat.mtimeMs > SESSION_MAX_AGE) {
388
414
  await fs.unlink(filePath);
389
415
  cleaned++;
390
416
  }
417
+ } catch (err) {
418
+ console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
391
419
  }
392
- } catch {
393
420
  }
394
421
  return cleaned;
395
422
  }
@@ -1200,9 +1227,11 @@ var init_docx_html = __esm({
1200
1227
  del: () => ({ strike: {} }),
1201
1228
  sup: () => ({ superscript: {} }),
1202
1229
  sub: () => ({ subscript: {} }),
1203
- a: (el) => ({
1204
- link: { href: el.attribs.href || "" }
1205
- })
1230
+ a: (el) => {
1231
+ const href = el.attribs.href || "";
1232
+ const safeHref = /^https?:\/\//i.test(href) || href.startsWith("mailto:") ? href : "";
1233
+ return { link: { href: safeHref } };
1234
+ }
1206
1235
  };
1207
1236
  BLOCK_TAGS = /* @__PURE__ */ new Set([
1208
1237
  "h1",
@@ -1550,9 +1579,138 @@ var init_types2 = __esm({
1550
1579
  }
1551
1580
  });
1552
1581
 
1582
+ // src/server/file-io/docx-walker.ts
1583
+ import { parseDocument as parseDocument2 } from "htmlparser2";
1584
+ function isElement2(node) {
1585
+ return node.type === "tag";
1586
+ }
1587
+ function getAttr(el, name) {
1588
+ return el.attribs?.[name];
1589
+ }
1590
+ function getTextContent(node) {
1591
+ if (node.type === "text") return node.data;
1592
+ if (!isElement2(node)) return "";
1593
+ return node.children.map(getTextContent).join("");
1594
+ }
1595
+ function findAllByName(name, nodes) {
1596
+ const results = [];
1597
+ for (const node of nodes) {
1598
+ if (isElement2(node)) {
1599
+ if (node.name === name) results.push(node);
1600
+ results.push(...findAllByName(name, node.children));
1601
+ }
1602
+ }
1603
+ return results;
1604
+ }
1605
+ function detectHeadingLevel(paragraph) {
1606
+ for (const child of paragraph.children) {
1607
+ if (!isElement2(child) || child.name !== "w:pPr") continue;
1608
+ for (const prop of child.children) {
1609
+ if (!isElement2(prop) || prop.name !== "w:pStyle") continue;
1610
+ const val = getAttr(prop, "w:val") || "";
1611
+ const match = val.match(/^heading\s*(\d)$/i);
1612
+ if (match) {
1613
+ const level = parseInt(match[1], 10);
1614
+ if (level >= 1 && level <= 6) return level;
1615
+ }
1616
+ }
1617
+ }
1618
+ return 0;
1619
+ }
1620
+ function walkDocumentBody(xml, callbacks = {}) {
1621
+ const doc = parseDocument2(xml, { xmlMode: true });
1622
+ let offset = 0;
1623
+ let firstParagraph = true;
1624
+ const textParts = [];
1625
+ let currentParagraph;
1626
+ let currentParagraphId;
1627
+ let currentRun;
1628
+ function walk(nodes) {
1629
+ for (const node of nodes) {
1630
+ if (!isElement2(node)) continue;
1631
+ if (node.name === "w:p") {
1632
+ if (!firstParagraph) {
1633
+ offset += 1;
1634
+ textParts.push("\n");
1635
+ }
1636
+ firstParagraph = false;
1637
+ const prevParagraph = currentParagraph;
1638
+ const prevParagraphId = currentParagraphId;
1639
+ currentParagraph = node;
1640
+ currentParagraphId = getAttr(node, "w14:paraId");
1641
+ const headingLevel = detectHeadingLevel(node);
1642
+ if (headingLevel > 0) {
1643
+ const prefixLen = headingPrefixLength(headingLevel);
1644
+ offset += prefixLen;
1645
+ textParts.push("#".repeat(headingLevel) + " ");
1646
+ }
1647
+ walk(node.children);
1648
+ currentParagraph = prevParagraph;
1649
+ currentParagraphId = prevParagraphId;
1650
+ } else if (node.name === "w:del") {
1651
+ } else if (node.name === "w:commentRangeStart") {
1652
+ const id = getAttr(node, "w:id");
1653
+ if (id) {
1654
+ callbacks.onCommentStart?.({
1655
+ commentId: id,
1656
+ offset,
1657
+ paragraph: currentParagraph,
1658
+ paragraphId: currentParagraphId
1659
+ });
1660
+ }
1661
+ } else if (node.name === "w:commentRangeEnd") {
1662
+ const id = getAttr(node, "w:id");
1663
+ if (id) {
1664
+ callbacks.onCommentEnd?.(id, offset);
1665
+ }
1666
+ } else if (node.name === "w:instrText") {
1667
+ } else if (node.name === "w:t") {
1668
+ const text = getTextContent(node);
1669
+ if (callbacks.onText && currentRun && currentParagraph) {
1670
+ callbacks.onText({
1671
+ run: currentRun,
1672
+ textNode: node,
1673
+ offsetStart: offset,
1674
+ text,
1675
+ paragraph: currentParagraph,
1676
+ paragraphId: currentParagraphId
1677
+ });
1678
+ }
1679
+ offset += text.length;
1680
+ textParts.push(text);
1681
+ } else if (SINGLE_CHAR_ELEMENTS.has(node.name)) {
1682
+ offset += 1;
1683
+ textParts.push(" ");
1684
+ } else if (node.name === "w:r") {
1685
+ const prevRun = currentRun;
1686
+ currentRun = node;
1687
+ walk(node.children);
1688
+ currentRun = prevRun;
1689
+ } else {
1690
+ walk(node.children);
1691
+ }
1692
+ }
1693
+ }
1694
+ const bodyElements = findAllByName("w:body", doc.children);
1695
+ if (bodyElements.length === 0) {
1696
+ return { totalLength: 0, flatText: "" };
1697
+ }
1698
+ walk(bodyElements[0].children);
1699
+ const flatText = textParts.join("");
1700
+ return { totalLength: offset, flatText };
1701
+ }
1702
+ var SINGLE_CHAR_ELEMENTS;
1703
+ var init_docx_walker = __esm({
1704
+ "src/server/file-io/docx-walker.ts"() {
1705
+ "use strict";
1706
+ init_offsets();
1707
+ SINGLE_CHAR_ELEMENTS = /* @__PURE__ */ new Set(["w:tab", "w:br", "w:noBreakHyphen", "w:softHyphen", "w:sym"]);
1708
+ }
1709
+ });
1710
+
1553
1711
  // src/server/file-io/docx-comments.ts
1554
1712
  import JSZip from "jszip";
1555
- import { parseDocument as parseDocument2 } from "htmlparser2";
1713
+ import { parseDocument as parseDocument3 } from "htmlparser2";
1556
1714
  async function extractDocxComments(buffer3) {
1557
1715
  const zip = await JSZip.loadAsync(buffer3);
1558
1716
  const commentsXml = await zip.file("word/comments.xml")?.async("text");
@@ -1583,7 +1741,7 @@ async function extractDocxComments(buffer3) {
1583
1741
  return result;
1584
1742
  }
1585
1743
  function parseCommentMetadata(xml) {
1586
- const doc = parseDocument2(xml, { xmlMode: true });
1744
+ const doc = parseDocument3(xml, { xmlMode: true });
1587
1745
  const map = /* @__PURE__ */ new Map();
1588
1746
  for (const comment of findAllByName("w:comment", doc.children)) {
1589
1747
  const id = getAttr(comment, "w:id");
@@ -1597,136 +1755,923 @@ function parseCommentMetadata(xml) {
1597
1755
  return map;
1598
1756
  }
1599
1757
  function calculateCommentRanges(xml) {
1600
- const doc = parseDocument2(xml, { xmlMode: true });
1601
1758
  const ranges = /* @__PURE__ */ new Map();
1602
1759
  const openRanges = /* @__PURE__ */ new Map();
1603
- let offset = 0;
1604
- let firstParagraph = true;
1605
- function walk(nodes) {
1606
- for (const node of nodes) {
1607
- if (!isElement2(node)) continue;
1608
- if (node.name === "w:p") {
1609
- if (!firstParagraph) offset += 1;
1610
- firstParagraph = false;
1611
- const headingLevel = detectHeadingLevel(node);
1612
- if (headingLevel > 0) {
1613
- offset += headingPrefixLength(headingLevel);
1760
+ walkDocumentBody(xml, {
1761
+ onCommentStart({ commentId, offset }) {
1762
+ openRanges.set(commentId, offset);
1763
+ },
1764
+ onCommentEnd(commentId, offset) {
1765
+ if (openRanges.has(commentId)) {
1766
+ ranges.set(commentId, {
1767
+ from: toFlatOffset(openRanges.get(commentId)),
1768
+ to: toFlatOffset(offset)
1769
+ });
1770
+ openRanges.delete(commentId);
1771
+ }
1772
+ }
1773
+ });
1774
+ if (openRanges.size > 0) {
1775
+ console.error(
1776
+ `[docx-comments] ${openRanges.size} comment range(s) had start markers but no end markers: ${[...openRanges.keys()].join(", ")}`
1777
+ );
1778
+ }
1779
+ return ranges;
1780
+ }
1781
+ function injectCommentsAsAnnotations(doc, comments) {
1782
+ if (comments.length === 0) return 0;
1783
+ const map = doc.getMap(Y_MAP_ANNOTATIONS);
1784
+ let injected = 0;
1785
+ doc.transact(() => {
1786
+ for (const comment of comments) {
1787
+ const result = anchoredRange(doc, toFlatOffset(comment.from), toFlatOffset(comment.to));
1788
+ if (!result.ok) {
1789
+ console.error(
1790
+ `[docx-comments] Skipping imported comment ${comment.commentId}: range [${comment.from}, ${comment.to}] \u2014 ${result.code}`
1791
+ );
1792
+ continue;
1793
+ }
1794
+ const id = `import-${comment.commentId}-${Date.now()}`;
1795
+ const content = comment.authorName !== "Unknown" ? `[${comment.authorName}] ${comment.bodyText}` : comment.bodyText;
1796
+ const annotation = {
1797
+ id,
1798
+ author: "import",
1799
+ type: "comment",
1800
+ range: { from: result.range.from, to: result.range.to },
1801
+ content,
1802
+ status: "pending",
1803
+ timestamp: comment.date ? new Date(comment.date).getTime() : Date.now()
1804
+ };
1805
+ if (result.fullyAnchored) {
1806
+ annotation.relRange = result.relRange;
1807
+ }
1808
+ map.set(id, annotation);
1809
+ injected++;
1810
+ }
1811
+ }, MCP_ORIGIN);
1812
+ if (injected > 0 || comments.length > 0) {
1813
+ console.error(`[docx-comments] Imported ${injected}/${comments.length} Word comments`);
1814
+ }
1815
+ return injected;
1816
+ }
1817
+ var init_docx_comments = __esm({
1818
+ "src/server/file-io/docx-comments.ts"() {
1819
+ "use strict";
1820
+ init_constants();
1821
+ init_positions2();
1822
+ init_types2();
1823
+ init_queue();
1824
+ init_docx_walker();
1825
+ }
1826
+ });
1827
+
1828
+ // node_modules/domelementtype/dist/index.js
1829
+ function isTag(element) {
1830
+ return element.type === ElementType.Tag || element.type === ElementType.Script || element.type === ElementType.Style;
1831
+ }
1832
+ var ElementType, Root, Text, Directive, Comment, Script, Style, Tag, CDATA, Doctype;
1833
+ var init_dist = __esm({
1834
+ "node_modules/domelementtype/dist/index.js"() {
1835
+ "use strict";
1836
+ (function(ElementType2) {
1837
+ ElementType2["Root"] = "root";
1838
+ ElementType2["Text"] = "text";
1839
+ ElementType2["Directive"] = "directive";
1840
+ ElementType2["Comment"] = "comment";
1841
+ ElementType2["Script"] = "script";
1842
+ ElementType2["Style"] = "style";
1843
+ ElementType2["Tag"] = "tag";
1844
+ ElementType2["CDATA"] = "cdata";
1845
+ ElementType2["Doctype"] = "doctype";
1846
+ })(ElementType || (ElementType = {}));
1847
+ Root = ElementType.Root;
1848
+ Text = ElementType.Text;
1849
+ Directive = ElementType.Directive;
1850
+ Comment = ElementType.Comment;
1851
+ Script = ElementType.Script;
1852
+ Style = ElementType.Style;
1853
+ Tag = ElementType.Tag;
1854
+ CDATA = ElementType.CDATA;
1855
+ Doctype = ElementType.Doctype;
1856
+ }
1857
+ });
1858
+
1859
+ // node_modules/domhandler/dist/node.js
1860
+ function isTag2(node) {
1861
+ return isTag(node);
1862
+ }
1863
+ function isCDATA(node) {
1864
+ return node.type === ElementType.CDATA;
1865
+ }
1866
+ function isText2(node) {
1867
+ return node.type === ElementType.Text;
1868
+ }
1869
+ function isComment(node) {
1870
+ return node.type === ElementType.Comment;
1871
+ }
1872
+ function isDirective(node) {
1873
+ return node.type === ElementType.Directive;
1874
+ }
1875
+ function isDocument(node) {
1876
+ return node.type === ElementType.Root;
1877
+ }
1878
+ function cloneNode(node, recursive = false) {
1879
+ let result;
1880
+ if (isText2(node)) {
1881
+ result = new Text2(node.data);
1882
+ } else if (isComment(node)) {
1883
+ result = new Comment2(node.data);
1884
+ } else if (isTag2(node)) {
1885
+ const children = recursive ? cloneChildren(node.children) : [];
1886
+ const clone = new Element(node.name, { ...node.attribs }, children);
1887
+ for (const child of children) {
1888
+ child.parent = clone;
1889
+ }
1890
+ if (node.namespace != null) {
1891
+ clone.namespace = node.namespace;
1892
+ }
1893
+ if (node["x-attribsNamespace"]) {
1894
+ clone["x-attribsNamespace"] = { ...node["x-attribsNamespace"] };
1895
+ }
1896
+ if (node["x-attribsPrefix"]) {
1897
+ clone["x-attribsPrefix"] = { ...node["x-attribsPrefix"] };
1898
+ }
1899
+ result = clone;
1900
+ } else if (isCDATA(node)) {
1901
+ const children = recursive ? cloneChildren(node.children) : [];
1902
+ const clone = new CDATA2(children);
1903
+ for (const child of children) {
1904
+ child.parent = clone;
1905
+ }
1906
+ result = clone;
1907
+ } else if (isDocument(node)) {
1908
+ const children = recursive ? cloneChildren(node.children) : [];
1909
+ const clone = new Document(children);
1910
+ for (const child of children) {
1911
+ child.parent = clone;
1912
+ }
1913
+ if (node["x-mode"]) {
1914
+ clone["x-mode"] = node["x-mode"];
1915
+ }
1916
+ result = clone;
1917
+ } else if (isDirective(node)) {
1918
+ const instruction = new ProcessingInstruction(node.name, node.data);
1919
+ if (node["x-name"] != null) {
1920
+ instruction["x-name"] = node["x-name"];
1921
+ instruction["x-publicId"] = node["x-publicId"];
1922
+ instruction["x-systemId"] = node["x-systemId"];
1923
+ }
1924
+ result = instruction;
1925
+ } else {
1926
+ throw new Error(`Not implemented yet: ${node.type}`);
1927
+ }
1928
+ result.startIndex = node.startIndex;
1929
+ result.endIndex = node.endIndex;
1930
+ if (node.sourceCodeLocation != null) {
1931
+ result.sourceCodeLocation = node.sourceCodeLocation;
1932
+ }
1933
+ return result;
1934
+ }
1935
+ function cloneChildren(childs) {
1936
+ const children = childs.map((child) => cloneNode(child, true));
1937
+ for (let index = 1; index < children.length; index++) {
1938
+ children[index].prev = children[index - 1];
1939
+ children[index - 1].next = children[index];
1940
+ }
1941
+ return children;
1942
+ }
1943
+ var Node, DataNode, Text2, Comment2, ProcessingInstruction, NodeWithChildren, CDATA2, Document, Element;
1944
+ var init_node = __esm({
1945
+ "node_modules/domhandler/dist/node.js"() {
1946
+ "use strict";
1947
+ init_dist();
1948
+ Node = class {
1949
+ /** Parent of the node */
1950
+ parent = null;
1951
+ /** Previous sibling */
1952
+ prev = null;
1953
+ /** Next sibling */
1954
+ next = null;
1955
+ /** The start index of the node. Requires `withStartIndices` on the handler to be `true. */
1956
+ startIndex = null;
1957
+ /** The end index of the node. Requires `withEndIndices` on the handler to be `true. */
1958
+ endIndex = null;
1959
+ // Read-write aliases for properties
1960
+ /**
1961
+ * Same as {@link parent}.
1962
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
1963
+ */
1964
+ get parentNode() {
1965
+ return this.parent;
1966
+ }
1967
+ set parentNode(parent) {
1968
+ this.parent = parent;
1969
+ }
1970
+ /**
1971
+ * Same as {@link prev}.
1972
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
1973
+ */
1974
+ get previousSibling() {
1975
+ return this.prev;
1976
+ }
1977
+ set previousSibling(previous) {
1978
+ this.prev = previous;
1979
+ }
1980
+ /**
1981
+ * Same as {@link next}.
1982
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
1983
+ */
1984
+ get nextSibling() {
1985
+ return this.next;
1986
+ }
1987
+ set nextSibling(next) {
1988
+ this.next = next;
1989
+ }
1990
+ /**
1991
+ * Clone this node, and optionally its children.
1992
+ * @param recursive Clone child nodes as well.
1993
+ * @returns A clone of the node.
1994
+ */
1995
+ cloneNode(recursive = false) {
1996
+ return cloneNode(this, recursive);
1997
+ }
1998
+ };
1999
+ DataNode = class extends Node {
2000
+ data;
2001
+ /**
2002
+ * @param data The content of the data node
2003
+ */
2004
+ constructor(data) {
2005
+ super();
2006
+ this.data = data;
2007
+ }
2008
+ /**
2009
+ * Same as {@link data}.
2010
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
2011
+ */
2012
+ get nodeValue() {
2013
+ return this.data;
2014
+ }
2015
+ set nodeValue(data) {
2016
+ this.data = data;
2017
+ }
2018
+ };
2019
+ Text2 = class extends DataNode {
2020
+ type = ElementType.Text;
2021
+ get nodeType() {
2022
+ return 3;
2023
+ }
2024
+ };
2025
+ Comment2 = class extends DataNode {
2026
+ type = ElementType.Comment;
2027
+ get nodeType() {
2028
+ return 8;
2029
+ }
2030
+ };
2031
+ ProcessingInstruction = class extends DataNode {
2032
+ type = ElementType.Directive;
2033
+ name;
2034
+ constructor(name, data) {
2035
+ super(data);
2036
+ this.name = name;
2037
+ }
2038
+ get nodeType() {
2039
+ return 1;
2040
+ }
2041
+ /** If this is a doctype, the document type name (parse5 only). */
2042
+ "x-name";
2043
+ /** If this is a doctype, the document type public identifier (parse5 only). */
2044
+ "x-publicId";
2045
+ /** If this is a doctype, the document type system identifier (parse5 only). */
2046
+ "x-systemId";
2047
+ };
2048
+ NodeWithChildren = class extends Node {
2049
+ children;
2050
+ /**
2051
+ * @param children Children of the node. Only certain node types can have children.
2052
+ */
2053
+ constructor(children) {
2054
+ super();
2055
+ this.children = children;
2056
+ }
2057
+ // Aliases
2058
+ /** First child of the node. */
2059
+ get firstChild() {
2060
+ return this.children[0] ?? null;
2061
+ }
2062
+ /** Last child of the node. */
2063
+ get lastChild() {
2064
+ return this.children.length > 0 ? this.children[this.children.length - 1] : null;
2065
+ }
2066
+ /**
2067
+ * Same as {@link children}.
2068
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
2069
+ */
2070
+ get childNodes() {
2071
+ return this.children;
2072
+ }
2073
+ set childNodes(children) {
2074
+ this.children = children;
2075
+ }
2076
+ };
2077
+ CDATA2 = class extends NodeWithChildren {
2078
+ type = ElementType.CDATA;
2079
+ get nodeType() {
2080
+ return 4;
2081
+ }
2082
+ };
2083
+ Document = class extends NodeWithChildren {
2084
+ type = ElementType.Root;
2085
+ get nodeType() {
2086
+ return 9;
2087
+ }
2088
+ };
2089
+ Element = class extends NodeWithChildren {
2090
+ name;
2091
+ attribs;
2092
+ type;
2093
+ /**
2094
+ * @param name Name of the tag, eg. `div`, `span`.
2095
+ * @param attribs Object mapping attribute names to attribute values.
2096
+ * @param children Children of the node.
2097
+ * @param type Node type used for the new node instance.
2098
+ */
2099
+ constructor(name, attribs, children = [], type = name === "script" ? ElementType.Script : name === "style" ? ElementType.Style : ElementType.Tag) {
2100
+ super(children);
2101
+ this.name = name;
2102
+ this.attribs = attribs;
2103
+ this.type = type;
2104
+ }
2105
+ get nodeType() {
2106
+ return 1;
2107
+ }
2108
+ // DOM Level 1 aliases
2109
+ /**
2110
+ * Same as {@link name}.
2111
+ * [DOM spec](https://dom.spec.whatwg.org)-compatible alias.
2112
+ */
2113
+ get tagName() {
2114
+ return this.name;
2115
+ }
2116
+ set tagName(name) {
2117
+ this.name = name;
2118
+ }
2119
+ get attributes() {
2120
+ return Object.keys(this.attribs).map((name) => ({
2121
+ name,
2122
+ value: this.attribs[name],
2123
+ namespace: this["x-attribsNamespace"]?.[name],
2124
+ prefix: this["x-attribsPrefix"]?.[name]
2125
+ }));
2126
+ }
2127
+ /** Element namespace (parse5 only). */
2128
+ namespace;
2129
+ /** Element attribute namespaces (parse5 only). */
2130
+ "x-attribsNamespace";
2131
+ /** Element attribute namespace-related prefixes (parse5 only). */
2132
+ "x-attribsPrefix";
2133
+ };
2134
+ }
2135
+ });
2136
+
2137
+ // node_modules/domhandler/dist/index.js
2138
+ var init_dist2 = __esm({
2139
+ "node_modules/domhandler/dist/index.js"() {
2140
+ "use strict";
2141
+ init_node();
2142
+ }
2143
+ });
2144
+
2145
+ // src/server/file-io/docx-apply.ts
2146
+ import JSZip2 from "jszip";
2147
+ import { parseDocument as parseDocument4 } from "htmlparser2";
2148
+ import render from "dom-serializer";
2149
+ function buildOffsetMap(xml, targetOffsets) {
2150
+ const entries = /* @__PURE__ */ new Map();
2151
+ const commentParagraphIds = /* @__PURE__ */ new Map();
2152
+ const hits = [];
2153
+ const { totalLength, flatText } = walkDocumentBody(xml, {
2154
+ onText(hit) {
2155
+ hits.push(hit);
2156
+ },
2157
+ onCommentStart(hit) {
2158
+ if (hit.paragraphId) {
2159
+ commentParagraphIds.set(hit.commentId, hit.paragraphId);
2160
+ }
2161
+ }
2162
+ });
2163
+ for (const offset of targetOffsets) {
2164
+ if (offset === totalLength && hits.length > 0) {
2165
+ const lastHit = hits[hits.length - 1];
2166
+ entries.set(offset, {
2167
+ run: lastHit.run,
2168
+ textNode: lastHit.textNode,
2169
+ charIndex: lastHit.text.length,
2170
+ paragraph: lastHit.paragraph,
2171
+ paragraphId: lastHit.paragraphId
2172
+ });
2173
+ continue;
2174
+ }
2175
+ for (let i = 0; i < hits.length; i++) {
2176
+ const hit = hits[i];
2177
+ const start = hit.offsetStart;
2178
+ const end = start + hit.text.length;
2179
+ if (offset >= start && offset < end) {
2180
+ entries.set(offset, {
2181
+ run: hit.run,
2182
+ textNode: hit.textNode,
2183
+ charIndex: offset - start,
2184
+ paragraph: hit.paragraph,
2185
+ paragraphId: hit.paragraphId
2186
+ });
2187
+ break;
2188
+ }
2189
+ if (offset === end) {
2190
+ const nextHit = hits[i + 1];
2191
+ if (!nextHit || nextHit.offsetStart !== offset) {
2192
+ entries.set(offset, {
2193
+ run: hit.run,
2194
+ textNode: hit.textNode,
2195
+ charIndex: hit.text.length,
2196
+ paragraph: hit.paragraph,
2197
+ paragraphId: hit.paragraphId
2198
+ });
2199
+ break;
1614
2200
  }
1615
- walk(node.children);
1616
- } else if (node.name === "w:commentRangeStart") {
1617
- const id = getAttr(node, "w:id");
1618
- if (id) openRanges.set(id, offset);
1619
- } else if (node.name === "w:commentRangeEnd") {
1620
- const id = getAttr(node, "w:id");
1621
- if (id && openRanges.has(id)) {
1622
- ranges.set(id, { from: toFlatOffset(openRanges.get(id)), to: toFlatOffset(offset) });
1623
- openRanges.delete(id);
2201
+ }
2202
+ }
2203
+ }
2204
+ let walkerBody;
2205
+ let walkerDoc;
2206
+ if (hits.length > 0) {
2207
+ let node = hits[0].paragraph.parent;
2208
+ while (node && isElement2(node) && node.name !== "w:body") {
2209
+ node = node.parent;
2210
+ }
2211
+ walkerBody = node;
2212
+ if (!walkerBody || walkerBody.name !== "w:body") {
2213
+ throw new Error("Could not recover w:body from walker's parsed DOM");
2214
+ }
2215
+ walkerDoc = walkerBody.parent;
2216
+ } else {
2217
+ const doc = parseDocument4(xml, { xmlMode: true });
2218
+ const bodyElements = findAllByName("w:body", doc.children);
2219
+ walkerBody = bodyElements[0] ?? new Element("w:body", {});
2220
+ walkerDoc = doc;
2221
+ }
2222
+ return {
2223
+ get(offset) {
2224
+ return entries.get(offset);
2225
+ },
2226
+ flatText,
2227
+ totalLength,
2228
+ body: walkerBody,
2229
+ doc: walkerDoc,
2230
+ commentParagraphIds
2231
+ };
2232
+ }
2233
+ function getNodeText(node) {
2234
+ for (const child of node.children) {
2235
+ if (child.type === "text") return child.data;
2236
+ }
2237
+ return "";
2238
+ }
2239
+ function setNodeText(node, text) {
2240
+ const textChild = new Text2(text);
2241
+ textChild.parent = node;
2242
+ node.children = [textChild];
2243
+ if (text.startsWith(" ") || text.endsWith(" ")) {
2244
+ node.attribs["xml:space"] = "preserve";
2245
+ } else {
2246
+ delete node.attribs["xml:space"];
2247
+ }
2248
+ }
2249
+ function cloneRPr(rPr) {
2250
+ const serialized = render(rPr, { xmlMode: true });
2251
+ const doc = parseDocument4(serialized, { xmlMode: true });
2252
+ return doc.children[0];
2253
+ }
2254
+ function findRPr(run) {
2255
+ for (const child of run.children) {
2256
+ if (isElement2(child) && child.name === "w:rPr") return child;
2257
+ }
2258
+ return void 0;
2259
+ }
2260
+ function buildRun(textElementName, text, rPrSource) {
2261
+ const children = [];
2262
+ if (rPrSource) {
2263
+ const cloned = cloneRPr(rPrSource);
2264
+ children.push(cloned);
2265
+ }
2266
+ const textChild = new Text2(text);
2267
+ const attribs = {};
2268
+ if (text.startsWith(" ") || text.endsWith(" ")) {
2269
+ attribs["xml:space"] = "preserve";
2270
+ }
2271
+ const textNode = new Element(textElementName, attribs, [textChild]);
2272
+ textChild.parent = textNode;
2273
+ children.push(textNode);
2274
+ const run = new Element("w:r", {}, children);
2275
+ for (const child of children) {
2276
+ child.parent = run;
2277
+ }
2278
+ return run;
2279
+ }
2280
+ function insertChild(parent, index, node) {
2281
+ parent.children.splice(index, 0, node);
2282
+ node.parent = parent;
2283
+ }
2284
+ function removeChild(node) {
2285
+ if (!node.parent) return;
2286
+ const parent = node.parent;
2287
+ const idx = parent.children.indexOf(node);
2288
+ if (idx >= 0) parent.children.splice(idx, 1);
2289
+ node.parent = null;
2290
+ }
2291
+ function applySingleSuggestion(offsetMap, suggestion) {
2292
+ const { from, to, newText, author, date, revisionId } = suggestion;
2293
+ const fromEntry = offsetMap.get(from);
2294
+ const toEntry = offsetMap.get(to);
2295
+ if (!fromEntry || !toEntry) {
2296
+ return { ok: false, reason: `Could not resolve offsets: from=${from} to=${to}` };
2297
+ }
2298
+ if (fromEntry.paragraph !== toEntry.paragraph) {
2299
+ return { ok: false, reason: "Cross-paragraph suggestions not yet supported" };
2300
+ }
2301
+ const paragraph = fromEntry.paragraph;
2302
+ if (fromEntry.charIndex > 0) {
2303
+ splitRun(fromEntry.run, fromEntry.textNode, fromEntry.charIndex, paragraph);
2304
+ const idx = paragraph.children.indexOf(fromEntry.run);
2305
+ const nextRun = paragraph.children[idx + 1];
2306
+ if (!nextRun || !isElement2(nextRun) || nextRun.name !== "w:r") {
2307
+ return { ok: false, reason: "Split failed: no next run after from-split" };
2308
+ }
2309
+ fromEntry.run = nextRun;
2310
+ const nextTextNode = findTextNode(nextRun);
2311
+ if (!nextTextNode) {
2312
+ return { ok: false, reason: "Run contains no text element" };
2313
+ }
2314
+ fromEntry.textNode = nextTextNode;
2315
+ fromEntry.charIndex = 0;
2316
+ }
2317
+ const toText = getNodeText(toEntry.textNode);
2318
+ if (toEntry.charIndex < toText.length && toEntry.charIndex > 0) {
2319
+ splitRun(toEntry.run, toEntry.textNode, toEntry.charIndex, paragraph);
2320
+ } else if (toEntry.charIndex === 0) {
2321
+ }
2322
+ const runsToDelete = [];
2323
+ let collecting = false;
2324
+ for (const child of paragraph.children) {
2325
+ if (!isElement2(child) || child.name !== "w:r") {
2326
+ if (collecting && child === toEntry.run) break;
2327
+ continue;
2328
+ }
2329
+ if (child === fromEntry.run) {
2330
+ collecting = true;
2331
+ }
2332
+ if (collecting) {
2333
+ if (toEntry.charIndex === 0 && child === toEntry.run) {
2334
+ break;
2335
+ }
2336
+ runsToDelete.push(child);
2337
+ if (child === toEntry.run) {
2338
+ break;
2339
+ }
2340
+ }
2341
+ }
2342
+ if (runsToDelete.length === 0) {
2343
+ if (from === to && newText.length > 0) {
2344
+ const insertionPoint = paragraph.children.indexOf(fromEntry.run);
2345
+ const rPr = findRPr(fromEntry.run);
2346
+ const insRun = buildRun("w:t", newText, rPr);
2347
+ const ins2 = new Element(
2348
+ "w:ins",
2349
+ {
2350
+ "w:id": String(revisionId + 1),
2351
+ "w:author": author,
2352
+ "w:date": date
2353
+ },
2354
+ [insRun]
2355
+ );
2356
+ insRun.parent = ins2;
2357
+ insertChild(paragraph, insertionPoint, ins2);
2358
+ return { ok: true };
2359
+ }
2360
+ return { ok: false, reason: "No runs found in deletion range" };
2361
+ }
2362
+ const rPrSource = findRPr(runsToDelete[0]);
2363
+ const delChildren = [];
2364
+ for (const run of runsToDelete) {
2365
+ const tn = findTextNode(run);
2366
+ if (!tn) continue;
2367
+ const text = getNodeText(tn);
2368
+ const delRun = buildRun("w:delText", text, findRPr(run));
2369
+ delChildren.push(delRun);
2370
+ }
2371
+ const del = new Element(
2372
+ "w:del",
2373
+ {
2374
+ "w:id": String(revisionId),
2375
+ "w:author": author,
2376
+ "w:date": date
2377
+ },
2378
+ delChildren
2379
+ );
2380
+ for (const child of delChildren) {
2381
+ child.parent = del;
2382
+ }
2383
+ let ins;
2384
+ if (newText.length > 0) {
2385
+ const insRun = buildRun("w:t", newText, rPrSource);
2386
+ ins = new Element(
2387
+ "w:ins",
2388
+ {
2389
+ "w:id": String(revisionId + 1),
2390
+ "w:author": author,
2391
+ "w:date": date
2392
+ },
2393
+ [insRun]
2394
+ );
2395
+ insRun.parent = ins;
2396
+ }
2397
+ const firstRunIndex = paragraph.children.indexOf(runsToDelete[0]);
2398
+ for (const run of runsToDelete) {
2399
+ removeChild(run);
2400
+ }
2401
+ insertChild(paragraph, firstRunIndex, del);
2402
+ if (ins) {
2403
+ insertChild(paragraph, firstRunIndex + 1, ins);
2404
+ }
2405
+ return { ok: true };
2406
+ }
2407
+ function splitRun(run, textNode, charIndex, paragraph) {
2408
+ const fullText = getNodeText(textNode);
2409
+ const before = fullText.slice(0, charIndex);
2410
+ const after = fullText.slice(charIndex);
2411
+ setNodeText(textNode, before);
2412
+ const rPr = findRPr(run);
2413
+ const newRun = buildRun("w:t", after, rPr);
2414
+ const idx = paragraph.children.indexOf(run);
2415
+ insertChild(paragraph, idx + 1, newRun);
2416
+ }
2417
+ function findTextNode(run) {
2418
+ for (const child of run.children) {
2419
+ if (isElement2(child) && child.name === "w:t") return child;
2420
+ }
2421
+ return void 0;
2422
+ }
2423
+ async function applyTrackedChanges(docxBuffer, suggestions, options) {
2424
+ const zip = await JSZip2.loadAsync(docxBuffer);
2425
+ const documentXml = await zip.file("word/document.xml")?.async("text");
2426
+ if (!documentXml) {
2427
+ throw new Error("Missing word/document.xml in .docx archive");
2428
+ }
2429
+ const date = options.date ?? (/* @__PURE__ */ new Date()).toISOString();
2430
+ const targetOffsets = /* @__PURE__ */ new Set();
2431
+ for (const s of suggestions) {
2432
+ targetOffsets.add(s.from);
2433
+ targetOffsets.add(s.to);
2434
+ }
2435
+ const offsetMap = buildOffsetMap(documentXml, targetOffsets);
2436
+ if (offsetMap.flatText !== options.ydocFlatText) {
2437
+ throw new Error(
2438
+ "Flat text mismatch: the .docx content does not match the Y.Doc flat text. The file may have changed since it was loaded."
2439
+ );
2440
+ }
2441
+ const sorted = [...suggestions].sort((a, b) => b.from - a.from);
2442
+ const valid = [];
2443
+ const rejectedDetails = [];
2444
+ for (const s of sorted) {
2445
+ if (s.textSnapshot !== void 0) {
2446
+ const actual = offsetMap.flatText.slice(s.from, s.to);
2447
+ if (actual !== s.textSnapshot) {
2448
+ rejectedDetails.push({
2449
+ id: s.id,
2450
+ reason: `Text snapshot mismatch: expected "${s.textSnapshot}", got "${actual}"`
2451
+ });
2452
+ continue;
2453
+ }
2454
+ }
2455
+ const fromEntry = offsetMap.get(s.from);
2456
+ const toEntry = offsetMap.get(s.to);
2457
+ if (!fromEntry || !toEntry) {
2458
+ rejectedDetails.push({
2459
+ id: s.id,
2460
+ reason: `Could not resolve offsets: from=${s.from} to=${s.to}`
2461
+ });
2462
+ continue;
2463
+ }
2464
+ valid.push(s);
2465
+ }
2466
+ const validAfterOverlapCheck = [];
2467
+ let lastFrom = Infinity;
2468
+ for (const s of valid) {
2469
+ if (s.to > lastFrom) {
2470
+ rejectedDetails.push({
2471
+ id: s.id,
2472
+ reason: `Overlapping range [${s.from}, ${s.to}) conflicts with another suggestion`
2473
+ });
2474
+ continue;
2475
+ }
2476
+ lastFrom = s.from;
2477
+ validAfterOverlapCheck.push(s);
2478
+ }
2479
+ const validAfterComplexCheck = [];
2480
+ for (const s of validAfterOverlapCheck) {
2481
+ const fromEntry = offsetMap.get(s.from);
2482
+ const toEntry = offsetMap.get(s.to);
2483
+ const paragraph = fromEntry.paragraph;
2484
+ let collecting = false;
2485
+ let hasComplex = false;
2486
+ for (const child of paragraph.children) {
2487
+ if (!isElement2(child) || child.name !== "w:r") continue;
2488
+ if (child === fromEntry.run) collecting = true;
2489
+ if (collecting) {
2490
+ for (const rc of child.children) {
2491
+ if (isElement2(rc) && COMPLEX_RUN_ELEMENTS.has(rc.name)) {
2492
+ hasComplex = true;
2493
+ break;
2494
+ }
1624
2495
  }
1625
- } else if (node.name === "w:t") {
1626
- const text = getTextContent(node);
1627
- offset += text.length;
1628
- } else if (node.name === "w:tab" || node.name === "w:br") {
1629
- offset += 1;
1630
- } else {
1631
- walk(node.children);
2496
+ if (hasComplex) break;
2497
+ if (child === toEntry.run) break;
1632
2498
  }
1633
2499
  }
2500
+ if (hasComplex) {
2501
+ rejectedDetails.push({
2502
+ id: s.id,
2503
+ reason: "Overlaps a complex element (footnote, drawing, or field) and couldn't be applied"
2504
+ });
2505
+ } else {
2506
+ validAfterComplexCheck.push(s);
2507
+ }
1634
2508
  }
1635
- const bodyElements = findAllByName("w:body", doc.children);
1636
- if (bodyElements.length === 0) {
1637
- console.error(
1638
- "[docx-comments] No <w:body> found in document.xml \u2014 cannot calculate comment ranges"
1639
- );
1640
- return ranges;
2509
+ const validAfterRunCheck = [];
2510
+ const claimedRuns = /* @__PURE__ */ new Map();
2511
+ for (const s of validAfterComplexCheck) {
2512
+ const fromEntry = offsetMap.get(s.from);
2513
+ const toEntry = offsetMap.get(s.to);
2514
+ const paragraph = fromEntry.paragraph;
2515
+ const touchedRuns = [];
2516
+ let collecting = false;
2517
+ for (const child of paragraph.children) {
2518
+ if (!isElement2(child) || child.name !== "w:r") continue;
2519
+ if (child === fromEntry.run) collecting = true;
2520
+ if (collecting) {
2521
+ touchedRuns.push(child);
2522
+ if (child === toEntry.run) break;
2523
+ }
2524
+ }
2525
+ let conflict = false;
2526
+ for (const run of touchedRuns) {
2527
+ if (claimedRuns.has(run)) {
2528
+ rejectedDetails.push({
2529
+ id: s.id,
2530
+ reason: "Targets the same text run as another suggestion"
2531
+ });
2532
+ conflict = true;
2533
+ break;
2534
+ }
2535
+ }
2536
+ if (!conflict) {
2537
+ for (const run of touchedRuns) {
2538
+ claimedRuns.set(run, s.id);
2539
+ }
2540
+ validAfterRunCheck.push(s);
2541
+ }
1641
2542
  }
1642
- walk(bodyElements[0].children);
1643
- if (openRanges.size > 0) {
1644
- console.error(
1645
- `[docx-comments] ${openRanges.size} comment range(s) had start markers but no end markers: ${[...openRanges.keys()].join(", ")}`
1646
- );
2543
+ const doc = offsetMap.doc;
2544
+ const idMatches = documentXml.match(/w:id="(\d+)"/g) || [];
2545
+ let maxId = 0;
2546
+ for (const m of idMatches) {
2547
+ const num = parseInt(m.match(/\d+/)[0], 10);
2548
+ if (num > maxId) maxId = num;
2549
+ }
2550
+ let applied = 0;
2551
+ const appliedSuggestions = [];
2552
+ for (const s of validAfterRunCheck) {
2553
+ maxId += 2;
2554
+ const result = applySingleSuggestion(offsetMap, {
2555
+ from: s.from,
2556
+ to: s.to,
2557
+ newText: s.newText,
2558
+ author: options.author,
2559
+ date,
2560
+ revisionId: maxId - 1
2561
+ });
2562
+ if (result.ok) {
2563
+ applied++;
2564
+ appliedSuggestions.push(s);
2565
+ } else {
2566
+ rejectedDetails.push({ id: s.id, reason: result.reason ?? "Unknown error" });
2567
+ }
1647
2568
  }
1648
- return ranges;
2569
+ const serialized = render(doc, { xmlMode: true });
2570
+ zip.file("word/document.xml", serialized);
2571
+ const commentsResolved = await resolveWordComments(
2572
+ zip,
2573
+ offsetMap.commentParagraphIds,
2574
+ appliedSuggestions
2575
+ );
2576
+ const buffer3 = Buffer.from(await zip.generateAsync({ type: "nodebuffer" }));
2577
+ return {
2578
+ buffer: buffer3,
2579
+ applied,
2580
+ rejected: rejectedDetails.length,
2581
+ rejectedDetails,
2582
+ commentsResolved
2583
+ };
1649
2584
  }
1650
- function detectHeadingLevel(paragraph) {
1651
- for (const child of paragraph.children) {
1652
- if (!isElement2(child) || child.name !== "w:pPr") continue;
1653
- for (const prop of child.children) {
1654
- if (!isElement2(prop) || prop.name !== "w:pStyle") continue;
1655
- const val = getAttr(prop, "w:val") || "";
1656
- const match = val.match(/^heading\s*(\d)$/i);
1657
- if (match) {
1658
- const level = parseInt(match[1], 10);
1659
- if (level >= 1 && level <= 6) return level;
1660
- }
2585
+ async function resolveWordComments(zip, commentParagraphIds, appliedSuggestions) {
2586
+ const toResolve = [];
2587
+ for (const s of appliedSuggestions) {
2588
+ if (!s.importCommentId) continue;
2589
+ const paraId = commentParagraphIds.get(s.importCommentId);
2590
+ if (!paraId) {
2591
+ console.warn(`[docx-apply] No paraId for comment ${s.importCommentId}; skipping resolution`);
2592
+ continue;
1661
2593
  }
2594
+ toResolve.push({ commentId: s.importCommentId, paraId });
1662
2595
  }
1663
- return 0;
1664
- }
1665
- function injectCommentsAsAnnotations(doc, comments) {
1666
- if (comments.length === 0) return 0;
1667
- const map = doc.getMap(Y_MAP_ANNOTATIONS);
1668
- let injected = 0;
1669
- doc.transact(() => {
1670
- for (const comment of comments) {
1671
- const result = anchoredRange(doc, toFlatOffset(comment.from), toFlatOffset(comment.to));
1672
- if (!result.ok) {
1673
- console.error(
1674
- `[docx-comments] Skipping imported comment ${comment.commentId}: range [${comment.from}, ${comment.to}] \u2014 ${result.code}`
1675
- );
1676
- continue;
2596
+ if (toResolve.length === 0) return 0;
2597
+ const seen = /* @__PURE__ */ new Set();
2598
+ const unique = toResolve.filter((r) => {
2599
+ if (seen.has(r.commentId)) return false;
2600
+ seen.add(r.commentId);
2601
+ return true;
2602
+ });
2603
+ const existingXml = await zip.file("word/commentsExtended.xml")?.async("text");
2604
+ if (existingXml) {
2605
+ const doc = parseDocument4(existingXml, { xmlMode: true });
2606
+ const root = doc.children.find((c) => isElement2(c) && c.name === "w15:commentsEx");
2607
+ if (root) {
2608
+ const existingParaIds = /* @__PURE__ */ new Set();
2609
+ for (const child of root.children) {
2610
+ if (isElement2(child) && child.name === "w15:commentEx") {
2611
+ const pid = getAttr(child, "w15:paraId");
2612
+ if (pid) existingParaIds.add(pid);
2613
+ }
1677
2614
  }
1678
- const id = `import-${comment.commentId}-${Date.now()}`;
1679
- const content = comment.authorName !== "Unknown" ? `[${comment.authorName}] ${comment.bodyText}` : comment.bodyText;
1680
- const annotation = {
1681
- id,
1682
- author: "import",
1683
- type: "comment",
1684
- range: { from: result.range.from, to: result.range.to },
1685
- content,
1686
- status: "pending",
1687
- timestamp: comment.date ? new Date(comment.date).getTime() : Date.now()
1688
- };
1689
- if (result.fullyAnchored) {
1690
- annotation.relRange = result.relRange;
2615
+ for (const { paraId } of unique) {
2616
+ if (existingParaIds.has(paraId)) continue;
2617
+ const entry = new Element("w15:commentEx", {
2618
+ "w15:paraId": paraId,
2619
+ "w15:done": "1"
2620
+ });
2621
+ insertChild(root, root.children.length, entry);
1691
2622
  }
1692
- map.set(id, annotation);
1693
- injected++;
2623
+ zip.file("word/commentsExtended.xml", render(doc, { xmlMode: true }));
1694
2624
  }
1695
- }, MCP_ORIGIN);
1696
- if (injected > 0 || comments.length > 0) {
1697
- console.error(`[docx-comments] Imported ${injected}/${comments.length} Word comments`);
1698
- }
1699
- return injected;
1700
- }
1701
- function isElement2(node) {
1702
- return node.type === "tag";
1703
- }
1704
- function getAttr(el, name) {
1705
- return el.attribs?.[name];
1706
- }
1707
- function getTextContent(node) {
1708
- if (node.type === "text") return node.data;
1709
- if (!isElement2(node)) return "";
1710
- return node.children.map(getTextContent).join("");
1711
- }
1712
- function findAllByName(name, nodes) {
1713
- const results = [];
1714
- for (const node of nodes) {
1715
- if (isElement2(node)) {
1716
- if (node.name === name) results.push(node);
1717
- results.push(...findAllByName(name, node.children));
2625
+ } else {
2626
+ const entries = unique.map((r) => `<w15:commentEx w15:paraId="${r.paraId}" w15:done="1"/>`).join("");
2627
+ const newXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w15:commentsEx xmlns:w15="${W15_NS}">${entries}</w15:commentsEx>`;
2628
+ zip.file("word/commentsExtended.xml", newXml);
2629
+ const relsXml = await zip.file("word/_rels/document.xml.rels")?.async("text");
2630
+ if (relsXml) {
2631
+ const relsDoc = parseDocument4(relsXml, { xmlMode: true });
2632
+ const relsRoot = relsDoc.children.find((c) => isElement2(c) && c.name === "Relationships");
2633
+ if (relsRoot) {
2634
+ const existingIds = findAllByName("Relationship", relsRoot.children).map((r) => getAttr(r, "Id") || "").filter((id) => id.startsWith("rId")).map((id) => parseInt(id.slice(3), 10)).filter((n) => !isNaN(n));
2635
+ const nextId = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 100;
2636
+ const rel = new Element("Relationship", {
2637
+ Id: `rId${nextId}`,
2638
+ Type: "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
2639
+ Target: "commentsExtended.xml"
2640
+ });
2641
+ insertChild(relsRoot, relsRoot.children.length, rel);
2642
+ zip.file("word/_rels/document.xml.rels", render(relsDoc, { xmlMode: true }));
2643
+ }
2644
+ }
2645
+ const ctXml = await zip.file("[Content_Types].xml")?.async("text");
2646
+ if (ctXml) {
2647
+ const ctDoc = parseDocument4(ctXml, { xmlMode: true });
2648
+ const typesRoot = ctDoc.children.find((c) => isElement2(c) && c.name === "Types");
2649
+ if (typesRoot) {
2650
+ const override = new Element("Override", {
2651
+ PartName: "/word/commentsExtended.xml",
2652
+ ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml"
2653
+ });
2654
+ insertChild(typesRoot, typesRoot.children.length, override);
2655
+ zip.file("[Content_Types].xml", render(ctDoc, { xmlMode: true }));
2656
+ }
1718
2657
  }
1719
2658
  }
1720
- return results;
2659
+ return unique.length;
1721
2660
  }
1722
- var init_docx_comments = __esm({
1723
- "src/server/file-io/docx-comments.ts"() {
2661
+ var COMPLEX_RUN_ELEMENTS, W15_NS;
2662
+ var init_docx_apply = __esm({
2663
+ "src/server/file-io/docx-apply.ts"() {
1724
2664
  "use strict";
1725
- init_constants();
1726
- init_offsets();
1727
- init_positions2();
1728
- init_types2();
1729
- init_queue();
2665
+ init_dist2();
2666
+ init_docx_walker();
2667
+ COMPLEX_RUN_ELEMENTS = /* @__PURE__ */ new Set([
2668
+ "w:footnoteReference",
2669
+ "w:endnoteReference",
2670
+ "w:drawing",
2671
+ "w:pict",
2672
+ "w:fldChar"
2673
+ ]);
2674
+ W15_NS = "http://schemas.microsoft.com/office/word/2012/wordml";
1730
2675
  }
1731
2676
  });
1732
2677
 
@@ -1741,6 +2686,17 @@ async function atomicWrite(filePath, content) {
1741
2686
  await fs2.writeFile(tempPath, content, "utf-8");
1742
2687
  await fs2.rename(tempPath, filePath);
1743
2688
  }
2689
+ async function atomicWriteBuffer(filePath, content) {
2690
+ const tempPath = path4.join(path4.dirname(filePath), `.tandem-tmp-${Date.now()}`);
2691
+ await fs2.writeFile(tempPath, content);
2692
+ try {
2693
+ await fs2.rename(tempPath, filePath);
2694
+ } catch (err) {
2695
+ await fs2.unlink(tempPath).catch(() => {
2696
+ });
2697
+ throw err;
2698
+ }
2699
+ }
1744
2700
  var markdownAdapter, plaintextAdapter, docxAdapter, adapters;
1745
2701
  var init_file_io = __esm({
1746
2702
  "src/server/file-io/index.ts"() {
@@ -1749,6 +2705,7 @@ var init_file_io = __esm({
1749
2705
  init_docx();
1750
2706
  init_docx_comments();
1751
2707
  init_document_model();
2708
+ init_docx_apply();
1752
2709
  markdownAdapter = {
1753
2710
  canSave: true,
1754
2711
  load(doc, content) {
@@ -1813,7 +2770,13 @@ async function openFileByPath(filePath, options) {
1813
2770
  let resolved = path5.resolve(filePath);
1814
2771
  try {
1815
2772
  resolved = fsSync.realpathSync(resolved);
1816
- } catch {
2773
+ } catch (err) {
2774
+ const code = err.code;
2775
+ if (code !== "ENOENT") {
2776
+ console.error(
2777
+ `[Tandem] realpathSync failed for ${filePath} (${code}), using path.resolve fallback`
2778
+ );
2779
+ }
1817
2780
  resolved = path5.resolve(filePath);
1818
2781
  }
1819
2782
  if (process.platform === "win32" && (resolved.startsWith("\\\\") || resolved.startsWith("//"))) {
@@ -2098,25 +3061,29 @@ function toDocListEntry(d) {
2098
3061
  };
2099
3062
  }
2100
3063
  function broadcastOpenDocs() {
3064
+ const docList = Array.from(openDocs.values()).map(toDocListEntry);
3065
+ const id = activeDocId;
2101
3066
  try {
2102
- const docList = Array.from(openDocs.values()).map(toDocListEntry);
2103
- const id = activeDocId;
2104
3067
  const ctrl = getOrCreateDocument(CTRL_ROOM);
2105
3068
  const ctrlMeta = ctrl.getMap(Y_MAP_DOCUMENT_META);
2106
3069
  ctrl.transact(() => {
2107
3070
  ctrlMeta.set("openDocuments", docList);
2108
3071
  ctrlMeta.set("activeDocumentId", id);
2109
3072
  }, MCP_ORIGIN);
2110
- for (const [docId] of openDocs) {
3073
+ } catch (err) {
3074
+ console.error("[Tandem] broadcastOpenDocs: failed to update CTRL_ROOM:", err);
3075
+ }
3076
+ for (const [docId] of openDocs) {
3077
+ try {
2111
3078
  const ydoc = getOrCreateDocument(docId);
2112
3079
  const meta = ydoc.getMap(Y_MAP_DOCUMENT_META);
2113
3080
  ydoc.transact(() => {
2114
3081
  meta.set("openDocuments", docList);
2115
3082
  meta.set("activeDocumentId", id);
2116
3083
  }, MCP_ORIGIN);
3084
+ } catch (err) {
3085
+ console.error(`[Tandem] broadcastOpenDocs: failed to update doc ${docId}:`, err);
2117
3086
  }
2118
- } catch (err) {
2119
- console.error("[Tandem] broadcastOpenDocs error:", err);
2120
3087
  }
2121
3088
  }
2122
3089
  async function closeDocumentById(id) {
@@ -2184,7 +3151,8 @@ async function restoreOpenDocuments(previousActiveDocId) {
2184
3151
  const code = err.code;
2185
3152
  if (code === "ENOENT") {
2186
3153
  console.error(`[Tandem] Skipping deleted file (removing stale session): ${filePath}`);
2187
- deleteSession(filePath).catch(() => {
3154
+ deleteSession(filePath).catch((err2) => {
3155
+ console.error(`[Tandem] Failed to delete stale session for ${filePath}:`, err2);
2188
3156
  });
2189
3157
  } else {
2190
3158
  console.error(`[Tandem] Failed to restore ${filePath}:`, err);
@@ -2355,15 +3323,16 @@ function attachObservers(docName, doc) {
2355
3323
  if (txn.origin === MCP_ORIGIN) return;
2356
3324
  if (event.keysChanged.has("selection")) {
2357
3325
  const selection = userAwareness.get("selection");
3326
+ if (!selection || selection.from === selection.to) return;
2358
3327
  pushEvent({
2359
3328
  id: generateEventId(),
2360
3329
  type: "selection:changed",
2361
3330
  timestamp: Date.now(),
2362
3331
  documentId: docName,
2363
3332
  payload: {
2364
- from: selection?.from ?? 0,
2365
- to: selection?.to ?? 0,
2366
- selectedText: selection?.from !== selection?.to ? selection?.selectedText ?? "" : ""
3333
+ from: selection.from,
3334
+ to: selection.to,
3335
+ selectedText: selection.selectedText ?? ""
2367
3336
  }
2368
3337
  });
2369
3338
  }
@@ -2580,7 +3549,7 @@ var init_launcher = __esm({
2580
3549
  });
2581
3550
 
2582
3551
  // src/server/index.ts
2583
- import path8 from "path";
3552
+ import path9 from "path";
2584
3553
  import { fileURLToPath as fileURLToPath2 } from "url";
2585
3554
 
2586
3555
  // src/server/mcp/server.ts
@@ -3272,7 +4241,14 @@ function createAnnotation(map, ydoc, type, anchored, content, extras) {
3272
4241
  }
3273
4242
  function collectAnnotations(map) {
3274
4243
  const result = [];
3275
- map.forEach((value) => result.push(value));
4244
+ map.forEach((value, key) => {
4245
+ const ann = value;
4246
+ if (ann && typeof ann === "object" && typeof ann.id === "string" && typeof ann.type === "string" && typeof ann.status === "string" && ann.range && typeof ann.range.from === "number" && typeof ann.range.to === "number") {
4247
+ result.push(ann);
4248
+ } else {
4249
+ console.warn(`[Tandem] Skipping malformed annotation entry: ${key}`);
4250
+ }
4251
+ });
3276
4252
  return result;
3277
4253
  }
3278
4254
  function registerAnnotationTools(server) {
@@ -3578,6 +4554,189 @@ init_constants();
3578
4554
  init_document_model();
3579
4555
  init_file_opener();
3580
4556
  init_document_service();
4557
+
4558
+ // src/server/mcp/docx-apply.ts
4559
+ init_document_service();
4560
+ init_constants();
4561
+ init_positions2();
4562
+ init_document_model();
4563
+ init_file_io();
4564
+ import { z as z4 } from "zod";
4565
+ import fs5 from "fs/promises";
4566
+ import path8 from "path";
4567
+ async function applyChangesCore(documentId, author, backupPath) {
4568
+ const r = requireDocument(documentId);
4569
+ if (!r) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
4570
+ const { doc: ydoc, filePath } = r;
4571
+ const docState = getCurrentDoc(documentId);
4572
+ if (!docState) throw Object.assign(new Error("No document is open."), { code: "NO_DOCUMENT" });
4573
+ if (docState.format !== "docx") {
4574
+ throw Object.assign(
4575
+ new Error(`Apply changes is only supported for .docx files (this is ${docState.format}).`),
4576
+ { code: "UNSUPPORTED_FORMAT" }
4577
+ );
4578
+ }
4579
+ if (docState.source !== "file") {
4580
+ throw Object.assign(new Error("Cannot apply changes to uploaded files. Save to disk first."), {
4581
+ code: "INVALID_PATH"
4582
+ });
4583
+ }
4584
+ if (backupPath) {
4585
+ const resolvedBp = path8.resolve(backupPath);
4586
+ if (process.platform === "win32" && (resolvedBp.startsWith("\\\\") || resolvedBp.startsWith("//"))) {
4587
+ throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
4588
+ code: "INVALID_PATH"
4589
+ });
4590
+ }
4591
+ }
4592
+ const map = ydoc.getMap(Y_MAP_ANNOTATIONS);
4593
+ const suggestions = [];
4594
+ let pendingCount = 0;
4595
+ for (const [, raw] of map) {
4596
+ const ann = raw;
4597
+ if (ann.type !== "suggestion") continue;
4598
+ if (ann.status === "pending") {
4599
+ pendingCount++;
4600
+ continue;
4601
+ }
4602
+ if (ann.status !== "accepted") continue;
4603
+ let from = ann.range.from;
4604
+ let to = ann.range.to;
4605
+ if (ann.relRange) {
4606
+ const resolvedFrom = relPosToFlatOffset(ydoc, ann.relRange.fromRel);
4607
+ const resolvedTo = relPosToFlatOffset(ydoc, ann.relRange.toRel);
4608
+ if (resolvedFrom !== null && resolvedTo !== null) {
4609
+ if (resolvedFrom > resolvedTo) {
4610
+ console.error(
4611
+ `[docx-apply] Inverted CRDT range for ${ann.id}: [${resolvedFrom}, ${resolvedTo}]; skipping`
4612
+ );
4613
+ continue;
4614
+ }
4615
+ from = resolvedFrom;
4616
+ to = resolvedTo;
4617
+ }
4618
+ }
4619
+ let newText = "";
4620
+ try {
4621
+ const parsed = JSON.parse(ann.content);
4622
+ newText = parsed.newText;
4623
+ } catch {
4624
+ newText = ann.content;
4625
+ }
4626
+ let importCommentId;
4627
+ if (ann.id.startsWith("import-")) {
4628
+ const withoutPrefix = ann.id.slice("import-".length);
4629
+ const lastDash = withoutPrefix.lastIndexOf("-");
4630
+ if (lastDash > 0) {
4631
+ importCommentId = withoutPrefix.slice(0, lastDash);
4632
+ }
4633
+ }
4634
+ suggestions.push({
4635
+ id: ann.id,
4636
+ from,
4637
+ to,
4638
+ newText,
4639
+ textSnapshot: ann.textSnapshot,
4640
+ importCommentId
4641
+ });
4642
+ }
4643
+ if (suggestions.length === 0) {
4644
+ throw Object.assign(new Error("No accepted suggestions to apply."), { code: "NO_SUGGESTIONS" });
4645
+ }
4646
+ const ydocFlatText = extractText(ydoc);
4647
+ const buffer3 = await fs5.readFile(filePath);
4648
+ const result = await applyTrackedChanges(buffer3, suggestions, {
4649
+ author: author ?? "Tandem Review",
4650
+ ydocFlatText
4651
+ });
4652
+ let resolvedBackup = backupPath ?? filePath.replace(/\.docx$/i, ".backup.docx");
4653
+ try {
4654
+ await fs5.access(resolvedBackup);
4655
+ const ext = path8.extname(resolvedBackup);
4656
+ const base = resolvedBackup.slice(0, -ext.length);
4657
+ resolvedBackup = `${base}-${Date.now()}${ext}`;
4658
+ } catch {
4659
+ }
4660
+ await fs5.copyFile(filePath, resolvedBackup);
4661
+ const [origStat, backupStat] = await Promise.all([fs5.stat(filePath), fs5.stat(resolvedBackup)]);
4662
+ if (origStat.size !== backupStat.size) {
4663
+ throw Object.assign(new Error("Backup verification failed: file sizes do not match."), {
4664
+ code: "BACKUP_FAILED"
4665
+ });
4666
+ }
4667
+ await atomicWriteBuffer(filePath, result.buffer);
4668
+ const output = {
4669
+ applied: result.applied,
4670
+ rejected: result.rejected,
4671
+ rejectedDetails: result.rejectedDetails,
4672
+ commentsResolved: result.commentsResolved,
4673
+ backupPath: resolvedBackup
4674
+ };
4675
+ if (pendingCount > 0) {
4676
+ output.pendingWarning = `${pendingCount} suggestion(s) are still pending review and were not applied.`;
4677
+ }
4678
+ return output;
4679
+ }
4680
+ function registerApplyTools(server) {
4681
+ server.tool(
4682
+ "tandem_applyChanges",
4683
+ "Apply all accepted suggestions to the .docx file as tracked changes (w:del + w:ins). Creates a backup before writing. Only works on .docx files opened from disk.",
4684
+ {
4685
+ documentId: z4.string().optional().describe("Target document ID (defaults to active doc)"),
4686
+ author: z4.string().optional().describe("Author name for tracked changes (default: 'Tandem Review')"),
4687
+ backupPath: z4.string().optional().describe("Custom backup path (default: {name}.backup.docx)")
4688
+ },
4689
+ withErrorBoundary("tandem_applyChanges", async (args) => {
4690
+ try {
4691
+ const result = await applyChangesCore(args.documentId, args.author, args.backupPath);
4692
+ return mcpSuccess(result);
4693
+ } catch (err) {
4694
+ const e = err;
4695
+ if (e.code === "NO_DOCUMENT") return noDocumentError();
4696
+ if (e.code === "NO_SUGGESTIONS") return mcpError("NO_SUGGESTIONS", e.message);
4697
+ if (e.code === "UNSUPPORTED_FORMAT") return mcpError("FORMAT_ERROR", e.message);
4698
+ if (e.code === "INVALID_PATH") return mcpError("FORMAT_ERROR", e.message);
4699
+ if (e.code === "BACKUP_FAILED") return mcpError("BACKUP_FAILED", e.message);
4700
+ throw err;
4701
+ }
4702
+ })
4703
+ );
4704
+ server.tool(
4705
+ "tandem_restoreBackup",
4706
+ "Restore a .docx file from its backup ({name}.backup.docx). Use after tandem_applyChanges if the result is unsatisfactory.",
4707
+ {
4708
+ documentId: z4.string().optional().describe("Target document ID (defaults to active doc)")
4709
+ },
4710
+ withErrorBoundary("tandem_restoreBackup", async (args) => {
4711
+ const r = requireDocument(args.documentId);
4712
+ if (!r) return noDocumentError();
4713
+ const { filePath } = r;
4714
+ const backupPath = filePath.replace(/\.docx$/i, ".backup.docx");
4715
+ try {
4716
+ await fs5.access(backupPath);
4717
+ } catch (err) {
4718
+ if (err.code === "ENOENT") {
4719
+ return mcpError("FILE_NOT_FOUND", `No backup file found at ${backupPath}`);
4720
+ }
4721
+ throw err;
4722
+ }
4723
+ await fs5.copyFile(backupPath, filePath);
4724
+ const [backupStat, restoredStat] = await Promise.all([
4725
+ fs5.stat(backupPath),
4726
+ fs5.stat(filePath)
4727
+ ]);
4728
+ if (backupStat.size !== restoredStat.size) {
4729
+ throw new Error("Restore verification failed: file sizes do not match.");
4730
+ }
4731
+ return mcpSuccess({
4732
+ message: `Restored ${path8.basename(filePath)} from backup.`,
4733
+ restoredFrom: backupPath
4734
+ });
4735
+ })
4736
+ );
4737
+ }
4738
+
4739
+ // src/server/mcp/api-routes.ts
3581
4740
  function isHostAllowed(host) {
3582
4741
  const reqHost = (host ?? "").split(":")[0];
3583
4742
  return reqHost === "localhost" || reqHost === "127.0.0.1";
@@ -3590,9 +4749,11 @@ function errorCodeToHttpStatus(code) {
3590
4749
  switch (code) {
3591
4750
  case "ENOENT":
3592
4751
  case "FILE_NOT_FOUND":
4752
+ case "NO_DOCUMENT":
3593
4753
  return 404;
3594
4754
  case "INVALID_PATH":
3595
4755
  case "UNSUPPORTED_FORMAT":
4756
+ case "NO_SUGGESTIONS":
3596
4757
  return 400;
3597
4758
  case "FILE_TOO_LARGE":
3598
4759
  return 413;
@@ -3601,6 +4762,8 @@ function errorCodeToHttpStatus(code) {
3601
4762
  return 423;
3602
4763
  case "EACCES":
3603
4764
  return 403;
4765
+ case "BACKUP_FAILED":
4766
+ return 500;
3604
4767
  default:
3605
4768
  return 500;
3606
4769
  }
@@ -3624,11 +4787,13 @@ function errorCodeToLabel(code) {
3624
4787
  switch (code) {
3625
4788
  case "ENOENT":
3626
4789
  case "FILE_NOT_FOUND":
3627
- return "FILE_NOT_FOUND";
4790
+ case "NO_DOCUMENT":
4791
+ return "NOT_FOUND";
3628
4792
  case "INVALID_PATH":
3629
4793
  return "INVALID_PATH";
3630
4794
  case "UNSUPPORTED_FORMAT":
3631
- return "UNSUPPORTED_FORMAT";
4795
+ case "NO_SUGGESTIONS":
4796
+ return "BAD_REQUEST";
3632
4797
  case "FILE_TOO_LARGE":
3633
4798
  return "FILE_TOO_LARGE";
3634
4799
  case "EBUSY":
@@ -3636,6 +4801,8 @@ function errorCodeToLabel(code) {
3636
4801
  return "FILE_LOCKED";
3637
4802
  case "EACCES":
3638
4803
  return "PERMISSION_DENIED";
4804
+ case "BACKUP_FAILED":
4805
+ return "INTERNAL";
3639
4806
  default:
3640
4807
  return "INTERNAL";
3641
4808
  }
@@ -3676,7 +4843,11 @@ function notifyStreamHandler(req, res) {
3676
4843
  const keepalive = setInterval(() => {
3677
4844
  try {
3678
4845
  if (!res.writableEnded) res.write(": keepalive\n\n");
3679
- } catch {
4846
+ } catch (err) {
4847
+ console.error(
4848
+ "[NotifyStream] Keepalive write failed, cleaning up:",
4849
+ err instanceof Error ? err.message : err
4850
+ );
3680
4851
  cleanup();
3681
4852
  }
3682
4853
  }, CHANNEL_SSE_KEEPALIVE_MS);
@@ -3767,11 +4938,37 @@ function registerApiRoutes(app, largeBody) {
3767
4938
  sendApiError(res, err);
3768
4939
  }
3769
4940
  });
4941
+ app.options("/api/apply-changes", apiMiddleware);
4942
+ app.post("/api/apply-changes", apiMiddleware, largeBody, async (req, res) => {
4943
+ const { documentId, author, backupPath } = req.body ?? {};
4944
+ if (documentId !== void 0 && typeof documentId !== "string") {
4945
+ res.status(400).json({ error: "BAD_REQUEST", message: "documentId must be a string" });
4946
+ return;
4947
+ }
4948
+ if (author !== void 0 && typeof author !== "string") {
4949
+ res.status(400).json({ error: "BAD_REQUEST", message: "author must be a string" });
4950
+ return;
4951
+ }
4952
+ if (backupPath !== void 0 && typeof backupPath !== "string") {
4953
+ res.status(400).json({ error: "BAD_REQUEST", message: "backupPath must be a string" });
4954
+ return;
4955
+ }
4956
+ try {
4957
+ const result = await applyChangesCore(
4958
+ documentId,
4959
+ author,
4960
+ backupPath
4961
+ );
4962
+ res.json({ data: result });
4963
+ } catch (err) {
4964
+ sendApiError(res, err);
4965
+ }
4966
+ });
3770
4967
  }
3771
4968
 
3772
4969
  // src/server/mcp/awareness.ts
3773
4970
  init_provider();
3774
- import { z as z4 } from "zod";
4971
+ import { z as z5 } from "zod";
3775
4972
  init_utils();
3776
4973
  init_constants();
3777
4974
  init_queue();
@@ -3781,7 +4978,7 @@ function registerAwarenessTools(server) {
3781
4978
  "tandem_getSelections",
3782
4979
  "Get text currently selected by the user in the editor",
3783
4980
  {
3784
- documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
4981
+ documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
3785
4982
  },
3786
4983
  withErrorBoundary("tandem_getSelections", async ({ documentId }) => {
3787
4984
  const current = getCurrentDoc(documentId);
@@ -3802,7 +4999,7 @@ function registerAwarenessTools(server) {
3802
4999
  "tandem_getActivity",
3803
5000
  "Check if the user is actively editing and where their cursor is",
3804
5001
  {
3805
- documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
5002
+ documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
3806
5003
  },
3807
5004
  withErrorBoundary("tandem_getActivity", async ({ documentId }) => {
3808
5005
  const current = getCurrentDoc(documentId);
@@ -3831,7 +5028,7 @@ function registerAwarenessTools(server) {
3831
5028
  "tandem_checkInbox",
3832
5029
  "Check for user actions you haven't seen yet \u2014 new highlights, comments, questions, flags, and responses to your annotations. Call this after completing any task, between steps, and whenever you pause. Low token cost.",
3833
5030
  {
3834
- documentId: z4.string().optional().describe("Target document ID (defaults to active document)")
5031
+ documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
3835
5032
  },
3836
5033
  withErrorBoundary("tandem_checkInbox", async ({ documentId }) => {
3837
5034
  const current = getCurrentDoc(documentId);
@@ -3924,9 +5121,9 @@ function registerAwarenessTools(server) {
3924
5121
  "tandem_reply",
3925
5122
  "Send a chat message to the user in the Tandem sidebar. Use this to respond to chat messages from tandem_checkInbox.",
3926
5123
  {
3927
- text: z4.string().describe("Your message to the user"),
3928
- replyTo: z4.string().optional().describe("ID of the user message you are replying to"),
3929
- documentId: z4.string().optional().describe("Document context for this reply (defaults to active document)")
5124
+ text: z5.string().describe("Your message to the user"),
5125
+ replyTo: z5.string().optional().describe("ID of the user message you are replying to"),
5126
+ documentId: z5.string().optional().describe("Document context for this reply (defaults to active document)")
3930
5127
  },
3931
5128
  withErrorBoundary("tandem_reply", async ({ text, replyTo, documentId }) => {
3932
5129
  const ctrlDoc = getOrCreateDocument(CTRL_ROOM);
@@ -4120,22 +5317,31 @@ init_constants();
4120
5317
  init_types();
4121
5318
  init_queue();
4122
5319
  init_provider();
4123
- import { z as z5 } from "zod";
5320
+ import { z as z6 } from "zod";
4124
5321
  function getFullText(docName) {
4125
5322
  const doc = getOrCreateDocument(docName);
4126
5323
  return extractText(doc);
4127
5324
  }
4128
5325
  function searchText(fullText, query, useRegex) {
5326
+ const MAX_MATCHES = 1e4;
4129
5327
  const matches = [];
4130
5328
  try {
4131
5329
  const pattern = useRegex ? new RegExp(query, "gi") : new RegExp(escapeRegex(query), "gi");
4132
5330
  let match;
5331
+ const start = Date.now();
4133
5332
  while ((match = pattern.exec(fullText)) !== null) {
4134
5333
  matches.push({
4135
5334
  from: toFlatOffset(match.index),
4136
5335
  to: toFlatOffset(match.index + match[0].length),
4137
5336
  text: match[0]
4138
5337
  });
5338
+ if (matches.length >= MAX_MATCHES) {
5339
+ return { matches, error: `Search capped at ${MAX_MATCHES} matches` };
5340
+ }
5341
+ if (Date.now() - start > 2e3) {
5342
+ return { matches, error: "Search timed out \u2014 simplify the regex pattern" };
5343
+ }
5344
+ if (match[0].length === 0) pattern.lastIndex++;
4139
5345
  }
4140
5346
  } catch (err) {
4141
5347
  return { matches: [], error: `Invalid regex: ${getErrorMessage(err)}` };
@@ -4176,9 +5382,9 @@ function registerNavigationTools(server) {
4176
5382
  "tandem_search",
4177
5383
  "Search for text in the document. Returns matching positions.",
4178
5384
  {
4179
- query: z5.string().describe("Search query (supports regex)"),
4180
- regex: z5.boolean().optional().describe("Treat query as regex"),
4181
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5385
+ query: z6.string().describe("Search query (supports regex)"),
5386
+ regex: z6.boolean().optional().describe("Treat query as regex"),
5387
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4182
5388
  },
4183
5389
  withErrorBoundary("tandem_search", async ({ query, regex, documentId }) => {
4184
5390
  const current = getCurrentDoc(documentId);
@@ -4193,9 +5399,9 @@ function registerNavigationTools(server) {
4193
5399
  "tandem_resolveRange",
4194
5400
  "Find text and return a valid range. Safer than raw character offsets under concurrent editing.",
4195
5401
  {
4196
- pattern: z5.string().describe("Text to find"),
4197
- occurrence: z5.number().optional().describe("Which occurrence (1-based, default 1)"),
4198
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5402
+ pattern: z6.string().describe("Text to find"),
5403
+ occurrence: z6.number().optional().describe("Which occurrence (1-based, default 1)"),
5404
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4199
5405
  },
4200
5406
  withErrorBoundary("tandem_resolveRange", async ({ pattern, occurrence = 1, documentId }) => {
4201
5407
  const current = getCurrentDoc(documentId);
@@ -4210,9 +5416,9 @@ function registerNavigationTools(server) {
4210
5416
  "tandem_setStatus",
4211
5417
  'Update Claude status text shown to user (e.g., "Reviewing cost figures..."). Tip: call tandem_checkInbox after completing work to see if the user has responded.',
4212
5418
  {
4213
- text: z5.string().describe("Status text"),
4214
- focusParagraph: z5.number().optional().describe("Index of paragraph Claude is focusing on"),
4215
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5419
+ text: z6.string().describe("Status text"),
5420
+ focusParagraph: z6.number().optional().describe("Index of paragraph Claude is focusing on"),
5421
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4216
5422
  },
4217
5423
  withErrorBoundary("tandem_setStatus", async ({ text, focusParagraph, documentId }) => {
4218
5424
  const current = getCurrentDoc(documentId);
@@ -4240,10 +5446,10 @@ function registerNavigationTools(server) {
4240
5446
  "tandem_getContext",
4241
5447
  "Read content around a range without pulling the full document. Reduces token usage.",
4242
5448
  {
4243
- from: z5.number().describe("Start position"),
4244
- to: z5.number().describe("End position"),
4245
- windowSize: z5.number().optional().describe("Characters of context before/after (default 500)"),
4246
- documentId: z5.string().optional().describe("Target document ID (defaults to active document)")
5449
+ from: z6.number().describe("Start position"),
5450
+ to: z6.number().describe("End position"),
5451
+ windowSize: z6.number().optional().describe("Characters of context before/after (default 500)"),
5452
+ documentId: z6.string().optional().describe("Target document ID (defaults to active document)")
4247
5453
  },
4248
5454
  withErrorBoundary(
4249
5455
  "tandem_getContext",
@@ -4283,6 +5489,7 @@ function createMcpServer() {
4283
5489
  registerAnnotationTools(server);
4284
5490
  registerNavigationTools(server);
4285
5491
  registerAwarenessTools(server);
5492
+ registerApplyTools(server);
4286
5493
  return server;
4287
5494
  }
4288
5495
  function jsonrpcId(body) {
@@ -4362,14 +5569,18 @@ async function startMcpServerHttp(port, host = "127.0.0.1") {
4362
5569
  await currentTransport.handleRequest(req, res, req.body);
4363
5570
  currentTransport = null;
4364
5571
  });
4365
- app.get("/health", (_req, res) => {
4366
- res.json({
4367
- status: "ok",
4368
- version: APP_VERSION,
4369
- transport: "http",
4370
- hasSession: currentTransport !== null
4371
- });
4372
- });
5572
+ app.get(
5573
+ "/health",
5574
+ apiMiddleware,
5575
+ (_req, res) => {
5576
+ res.json({
5577
+ status: "ok",
5578
+ version: APP_VERSION,
5579
+ transport: "http",
5580
+ hasSession: currentTransport !== null
5581
+ });
5582
+ }
5583
+ );
4373
5584
  app.get(
4374
5585
  "/.well-known/oauth-protected-resource/mcp",
4375
5586
  (_req, res) => {
@@ -4438,7 +5649,7 @@ function isKnownHocuspocusError(err) {
4438
5649
  }
4439
5650
  const msg = err.message;
4440
5651
  if (msg.startsWith("WebSocket is not open")) return true;
4441
- if (msg === "Unexpected end of array" || msg === "Integer out of Range") return true;
5652
+ if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
4442
5653
  if (msg.startsWith("Received a message with an unknown type:")) return true;
4443
5654
  return false;
4444
5655
  }
@@ -4631,7 +5842,11 @@ async function main() {
4631
5842
  if (transportMode === "http") {
4632
5843
  freePort(wsPort);
4633
5844
  freePort(mcpPort);
4634
- await Promise.all([waitForPort(wsPort), waitForPort(mcpPort)]);
5845
+ try {
5846
+ await Promise.all([waitForPort(wsPort), waitForPort(mcpPort)]);
5847
+ } catch (err) {
5848
+ console.error(`[Tandem] ${err instanceof Error ? err.message : err} \u2014 proceeding anyway`);
5849
+ }
4635
5850
  const [srv] = await Promise.all([
4636
5851
  startMcpServerHttp(mcpPort),
4637
5852
  startHocuspocus(wsPort).then(() => {
@@ -4640,8 +5855,8 @@ async function main() {
4640
5855
  ]);
4641
5856
  httpServer = srv;
4642
5857
  if (getOpenDocs().size === 0 && !process.env.TANDEM_NO_SAMPLE) {
4643
- const samplePath = path8.resolve(
4644
- path8.dirname(fileURLToPath2(import.meta.url)),
5858
+ const samplePath = path9.resolve(
5859
+ path9.dirname(fileURLToPath2(import.meta.url)),
4645
5860
  "../../sample/welcome.md"
4646
5861
  );
4647
5862
  openFileByPath(samplePath).then(() => {
@@ -4667,7 +5882,11 @@ async function main() {
4667
5882
  } else {
4668
5883
  (async () => {
4669
5884
  freePort(wsPort);
4670
- await waitForPort(wsPort);
5885
+ try {
5886
+ await waitForPort(wsPort);
5887
+ } catch (err) {
5888
+ console.error(`[Tandem] ${err instanceof Error ? err.message : err} \u2014 proceeding anyway`);
5889
+ }
4671
5890
  await startHocuspocus(wsPort);
4672
5891
  console.error(`[Tandem] Hocuspocus WebSocket server running on ws://localhost:${wsPort}`);
4673
5892
  })().catch((err) => {