@youweichen/pi-harness 1.0.0 → 1.2.0

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.
Files changed (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +14 -0
  3. package/dist/server/agent-service.js +87 -7
  4. package/dist/server/control-socket.js +1 -0
  5. package/dist/server/document-conversion/extension.js +164 -0
  6. package/dist/server/document-conversion/python/bridge.py +424 -0
  7. package/dist/server/document-conversion/python/fixtures.py +201 -0
  8. package/dist/server/document-conversion/python/native_code.py +149 -0
  9. package/dist/server/document-conversion/runtime.js +321 -0
  10. package/dist/server/document-conversion/service.js +340 -0
  11. package/dist/server/document-conversion/settings.js +63 -0
  12. package/dist/server/extensions-worker.js +3 -2
  13. package/dist/server/files-service.js +8 -16
  14. package/dist/server/index.js +9 -3
  15. package/dist/server/native-tools.js +4 -0
  16. package/dist/server/node-agent.js +262 -0
  17. package/dist/server/node-workbench.js +62 -1
  18. package/dist/server/okf/extension.js +118 -0
  19. package/dist/server/okf/markdown-sources.js +58 -0
  20. package/dist/server/okf/service.js +713 -0
  21. package/dist/server/okf/storage.js +165 -0
  22. package/dist/server/okf/types.js +1 -0
  23. package/dist/server/plan/extension.js +4 -4
  24. package/dist/server/plan/state.js +11 -1
  25. package/dist/server/slash-commands.js +6 -0
  26. package/dist/server/wiki-links.js +24 -0
  27. package/dist/server/wiki-routes.js +8 -1
  28. package/dist/server/wiki-service.js +50 -5
  29. package/package.json +2 -2
  30. package/web/dist/assets/NodeWorkbench-DM6YTS5e.js +3 -0
  31. package/web/dist/assets/{TerminalPanel-CWh6vw_N.js → TerminalPanel-aFJylyxg.js} +1 -1
  32. package/web/dist/assets/{abnfDiagram-VCTEODGH-D5Ti0Kpa.js → abnfDiagram-VCTEODGH-CcM7f7oX.js} +1 -1
  33. package/web/dist/assets/{arc-DKJilLd6.js → arc-Bv7vS5l5.js} +1 -1
  34. package/web/dist/assets/{architectureDiagram-5GKGNRK7-Cf0w38g-.js → architectureDiagram-5GKGNRK7-Cl8QcB3w.js} +1 -1
  35. package/web/dist/assets/{blockDiagram-I7D4REHJ-BOTLWLIL.js → blockDiagram-I7D4REHJ-DarS2-4G.js} +1 -1
  36. package/web/dist/assets/{c4Diagram-7LVT6UL2-CLCCoJs3.js → c4Diagram-7LVT6UL2-BY0_ttBt.js} +1 -1
  37. package/web/dist/assets/channel-DJGMgVTh.js +1 -0
  38. package/web/dist/assets/{chunk-2Q5K7J3B-BHjga2Ge.js → chunk-2Q5K7J3B-Cz-zS7_X.js} +1 -1
  39. package/web/dist/assets/{chunk-5VM5RSS4-CQJ5VfDZ.js → chunk-5VM5RSS4-B7AyqkmI.js} +1 -1
  40. package/web/dist/assets/{chunk-F27PBJKO-C67qoPJH.js → chunk-F27PBJKO-B7hVduzL.js} +1 -1
  41. package/web/dist/assets/{chunk-IMKFNOWR-CgXXGwI8.js → chunk-IMKFNOWR-DNGJqYn5.js} +1 -1
  42. package/web/dist/assets/{chunk-JWPE2WC7-D0RPyByP.js → chunk-JWPE2WC7-B5iJgXvl.js} +1 -1
  43. package/web/dist/assets/{chunk-POPQ4Y6H-BeYC9s9x.js → chunk-POPQ4Y6H-BvWCkriR.js} +1 -1
  44. package/web/dist/assets/{chunk-SVP7TREG-eQPl4UBQ.js → chunk-SVP7TREG-BcdlBuOQ.js} +1 -1
  45. package/web/dist/assets/{chunk-TICWLB2K-DT73arPU.js → chunk-TICWLB2K-B1igqjQd.js} +1 -1
  46. package/web/dist/assets/{chunk-XXDRQBXY-B4FwQcP-.js → chunk-XXDRQBXY-DKGDquaw.js} +1 -1
  47. package/web/dist/assets/classDiagram-ZZMXUADV-D9nxgF0M.js +1 -0
  48. package/web/dist/assets/classDiagram-v2-VYDZK3BY-D9nxgF0M.js +1 -0
  49. package/web/dist/assets/{cose-bilkent-JH36ORCC-Bnn5nlQN.js → cose-bilkent-JH36ORCC-BU_9nu88.js} +1 -1
  50. package/web/dist/assets/{cynefin-OW5HDTMX-CStWX9kE.js → cynefin-OW5HDTMX-DY0BDLjc.js} +1 -1
  51. package/web/dist/assets/{cynefinDiagram-5FMLGOSQ-BMVHC03f.js → cynefinDiagram-5FMLGOSQ-B-zM25Fk.js} +1 -1
  52. package/web/dist/assets/{dagre-GXQ25YYZ-ClSaO4ml.js → dagre-GXQ25YYZ-BxooDyBc.js} +1 -1
  53. package/web/dist/assets/{diagram-S7CK7UJ4-C43LL08C.js → diagram-S7CK7UJ4-CCO-8de9.js} +1 -1
  54. package/web/dist/assets/{diagram-UQ7AKVKN-BPVn_ySW.js → diagram-UQ7AKVKN-CmGsH525.js} +1 -1
  55. package/web/dist/assets/{diagram-VSXAHHWV-C34FFhRi.js → diagram-VSXAHHWV-CSQJYo3m.js} +1 -1
  56. package/web/dist/assets/{diagram-VX7I27RA-BRk4bncf.js → diagram-VX7I27RA-CwuJsF66.js} +1 -1
  57. package/web/dist/assets/{diagram-Z3DM3KII-O5hYPLEd.js → diagram-Z3DM3KII-BF13T_lj.js} +1 -1
  58. package/web/dist/assets/{ebnfDiagram-PWID7BFC-DVGEBm99.js → ebnfDiagram-PWID7BFC-ZtoDLro6.js} +1 -1
  59. package/web/dist/assets/{erDiagram-RLTQ6QDP-M-BLpTEV.js → erDiagram-RLTQ6QDP-BBo-qmNH.js} +1 -1
  60. package/web/dist/assets/{flowDiagram-HODETNUW-BwdLopEx.js → flowDiagram-HODETNUW-DAq9qdP8.js} +1 -1
  61. package/web/dist/assets/{ganttDiagram-EL5Y4UJY-DYxx2ppA.js → ganttDiagram-EL5Y4UJY-C53eRlMu.js} +1 -1
  62. package/web/dist/assets/{gitGraphDiagram-WWUBYQGX-DUU0wMYB.js → gitGraphDiagram-WWUBYQGX-BviBMJxj.js} +1 -1
  63. package/web/dist/assets/index-Bab44wX8.js +276 -0
  64. package/web/dist/assets/index-C3vDFSEV.css +10 -0
  65. package/web/dist/assets/{infoDiagram-27XIBGKW-CFaW5C0C.js → infoDiagram-27XIBGKW-DI95SO3j.js} +1 -1
  66. package/web/dist/assets/{ishikawaDiagram-5VMMS53U--ENP1MTB.js → ishikawaDiagram-5VMMS53U-Cl8IDXwf.js} +1 -1
  67. package/web/dist/assets/{journeyDiagram-3NMN7TZE-DgnAzSTj.js → journeyDiagram-3NMN7TZE-Co6aJeTZ.js} +1 -1
  68. package/web/dist/assets/{kanban-definition-UXKFOSKX-_STZN1vU.js → kanban-definition-UXKFOSKX-CknQQpwW.js} +1 -1
  69. package/web/dist/assets/{layout-CkUClkXt.js → layout-CFtUmtTW.js} +1 -1
  70. package/web/dist/assets/{linear-sYHiUOxa.js → linear-mRlGUAKy.js} +1 -1
  71. package/web/dist/assets/{markdown-vNTCZJ6d.js → markdown-BhJ_LInD.js} +1 -1
  72. package/web/dist/assets/{mermaid.core-B8fKGWsc.js → mermaid.core-CvQEO3ue.js} +5 -5
  73. package/web/dist/assets/{mindmap-definition-YA3MSWOX-DP0BYOEx.js → mindmap-definition-YA3MSWOX-DpqhsxUl.js} +1 -1
  74. package/web/dist/assets/{pegDiagram-XKGWAZYB-DkWTmZre.js → pegDiagram-XKGWAZYB-C9eawB5u.js} +1 -1
  75. package/web/dist/assets/{pieDiagram-E7YTZNPT-DfZgMV4u.js → pieDiagram-E7YTZNPT-8vaqXVD6.js} +1 -1
  76. package/web/dist/assets/{quadrantDiagram-AXDQQJYC-Djl4TT7q.js → quadrantDiagram-AXDQQJYC-Diwr8ynm.js} +1 -1
  77. package/web/dist/assets/{railroadDiagram-O6MQD6OU-DbKF6zXn.js → railroadDiagram-O6MQD6OU-krW50Cgi.js} +1 -1
  78. package/web/dist/assets/{react-BNj3n7jX.js → react-BddUxV-9.js} +1 -1
  79. package/web/dist/assets/{requirementDiagram-BXWQKSXE-idola5qb.js → requirementDiagram-BXWQKSXE-BPLI6eMG.js} +1 -1
  80. package/web/dist/assets/{sankeyDiagram-P5KCCOFB-KA0hQOle.js → sankeyDiagram-P5KCCOFB-Bix_elmH.js} +1 -1
  81. package/web/dist/assets/{sequenceDiagram-WJ2MYXX4-BXSKvxzP.js → sequenceDiagram-WJ2MYXX4-0m0xaUKi.js} +1 -1
  82. package/web/dist/assets/{sizeCapture-INFHLROL-C_Ewv3S5.js → sizeCapture-INFHLROL-BtzJ_oOR.js} +1 -1
  83. package/web/dist/assets/{stateDiagram-D77RDMKH-DIf5X8oo.js → stateDiagram-D77RDMKH-Clq5bN0v.js} +1 -1
  84. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-Bn1oW4gp.js +1 -0
  85. package/web/dist/assets/{swimlanes-42K2YHIH-Cx_Rc8M2.js → swimlanes-42K2YHIH-11MDGnYA.js} +1 -1
  86. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-BW96Edgo.js +8 -0
  87. package/web/dist/assets/{timeline-definition-24CTP7MA-BRrDhWVz.js → timeline-definition-24CTP7MA-BeQfWP95.js} +1 -1
  88. package/web/dist/assets/{vennDiagram-4TSXK5OY-Ad6G0YwD.js → vennDiagram-4TSXK5OY-BMYkX0KL.js} +1 -1
  89. package/web/dist/assets/{wardleyDiagram-VM6X3IG4-X6-ENpSi.js → wardleyDiagram-VM6X3IG4-BV3fTUkU.js} +1 -1
  90. package/web/dist/assets/{xychartDiagram-S5SC5T6Z-DhegNS6d.js → xychartDiagram-S5SC5T6Z-Ct7ezLnc.js} +1 -1
  91. package/web/dist/index.html +4 -4
  92. package/web/dist/assets/NodeWorkbench-C4U1PldE.js +0 -3
  93. package/web/dist/assets/channel-D8Hd7T4P.js +0 -1
  94. package/web/dist/assets/classDiagram-ZZMXUADV-B_btgYRB.js +0 -1
  95. package/web/dist/assets/classDiagram-v2-VYDZK3BY-B_btgYRB.js +0 -1
  96. package/web/dist/assets/index-CI2KfVSr.css +0 -10
  97. package/web/dist/assets/index-DQcpggFU.js +0 -275
  98. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-DYwe_2mB.js +0 -1
  99. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-52Y8tgRV.js +0 -8
@@ -0,0 +1,149 @@
1
+ """Recover visible PDF code spacing only when native glyphs prove a fixed grid.
2
+
3
+ PDF text APIs can collapse explicit spaces. We retain native Unicode characters
4
+ and reconstruct their visible columns from glyph origins, never from code syntax.
5
+ Ambiguous geometry remains unchanged and is reported for review.
6
+ """
7
+ import ctypes
8
+ import re
9
+
10
+
11
+ _SIGNATURE = re.compile(r"^\s*(?:(?:async\s+)?def\s+[A-Za-z_]\w*\s*\(|(?:export\s+)?(?:async\s+)?function(?:\s+[A-Za-z_$][\w$]*)?\s*\(|(?:public\s+|private\s+|static\s+)*(?:void|int|bool|float|double|char|long)\s+[A-Za-z_]\w*\s*\()")
12
+
13
+
14
+ def _compact(value):
15
+ return re.sub(r"\s+", "", value)
16
+
17
+
18
+ def _native_region(page, textpage, bbox):
19
+ import pypdfium2 as pdfium
20
+ if page.get_rotation() != 0:
21
+ raise ValueError("rotated page")
22
+ if textpage.count_chars() > 100_000:
23
+ raise ValueError("native page exceeds the character inspection limit")
24
+ bounds = bbox.to_bottom_left_origin(page.get_height())
25
+ chars, pitches, font_heights = [], [], []
26
+ font_cache = {}
27
+ for index in range(textpage.count_chars()):
28
+ codepoint = pdfium.raw.FPDFText_GetUnicode(textpage.raw, index)
29
+ if codepoint in (10, 13):
30
+ continue
31
+ x, y = ctypes.c_double(), ctypes.c_double()
32
+ if not pdfium.raw.FPDFText_GetCharOrigin(textpage.raw, index, ctypes.byref(x), ctypes.byref(y)):
33
+ continue
34
+ left, bottom, right, top = textpage.get_charbox(index)
35
+ if not (bounds.l - 0.25 <= (left + right) / 2 <= bounds.r + 0.25 and bounds.b - 0.25 <= y.value <= bounds.t + 0.25):
36
+ continue
37
+ if pdfium.raw.FPDFText_IsGenerated(textpage.raw, index):
38
+ if codepoint == 32:
39
+ continue
40
+ raise ValueError("generated non-whitespace character")
41
+ if codepoint < 32 or codepoint > 0x10ffff or 0xd800 <= codepoint <= 0xdfff or pdfium.raw.FPDFText_HasUnicodeMapError(textpage.raw, index):
42
+ raise ValueError("unmapped or control character")
43
+ obj = textpage.get_textobj(index)
44
+ if obj is None:
45
+ raise ValueError("character has no native text object")
46
+ matrix = obj.get_matrix()
47
+ if abs(matrix.b) > 1e-5 or abs(matrix.c) > 1e-5 or matrix.a <= 0 or matrix.d <= 0 or abs(pdfium.raw.FPDFText_GetCharAngle(textpage.raw, index)) > 1e-5:
48
+ raise ValueError("rotated or skewed text")
49
+ if pdfium.raw.FPDFTextObj_GetTextRenderMode(obj.raw) in (3, 7):
50
+ raise ValueError("invisible text layer")
51
+ font, size = obj.get_font(), obj.get_font_size()
52
+ font_key = (ctypes.cast(font.raw, ctypes.c_void_p).value, size, matrix.a)
53
+ def width(character):
54
+ value = ctypes.c_float()
55
+ if not pdfium.raw.FPDFFont_GetGlyphWidth(font.raw, ord(character), size, ctypes.byref(value)):
56
+ raise ValueError("unavailable glyph advance")
57
+ return value.value * matrix.a
58
+ if font_key not in font_cache:
59
+ advances = [width(character) for character in " iMW0"]
60
+ if min(advances) <= 0 or max(advances) - min(advances) > max(0.01, advances[0] * 0.002):
61
+ raise ValueError("proportional font")
62
+ font_cache[font_key] = advances[0]
63
+ pitch = font_cache[font_key]
64
+ advance = width(chr(codepoint))
65
+ cells = round(advance / pitch)
66
+ if cells not in (1, 2) or abs(advance / pitch - cells) > 0.02:
67
+ raise ValueError("character advance is not an integral grid width")
68
+ chars.append({"index": index, "character": chr(codepoint), "x": x.value, "y": y.value, "cells": cells})
69
+ pitches.append(pitch)
70
+ font_heights.append(size * matrix.d)
71
+ if not chars:
72
+ raise ValueError("no visible native text in this region")
73
+ pitch = sum(pitches) / len(pitches)
74
+ if max(pitches) - min(pitches) > max(0.01, pitch * 0.002):
75
+ raise ValueError("mixed glyph advances")
76
+ rows = []
77
+ for char in sorted(chars, key=lambda value: (-value["y"], value["x"])):
78
+ if not rows or abs(rows[-1][0]["y"] - char["y"]) > 0.15:
79
+ rows.append([])
80
+ rows[-1].append(char)
81
+ gaps = [rows[i][0]["y"] - rows[i + 1][0]["y"] for i in range(len(rows) - 1)]
82
+ line_advance = min(gaps) if gaps else None
83
+ gap_cells = []
84
+ if line_advance is not None:
85
+ if line_advance < pitch or line_advance > 2 * min(font_heights):
86
+ raise ValueError("baseline spacing cannot establish a continuous line grid")
87
+ for gap in gaps:
88
+ cells = round(gap / line_advance)
89
+ if cells < 1 or cells > 8 or abs(gap / line_advance - cells) > 0.025:
90
+ raise ValueError("non-integral baseline gaps may contain significant blank lines")
91
+ gap_cells.append(cells)
92
+ origin = min(char["x"] for char in chars)
93
+ lines = []
94
+ for row_index, row in enumerate(rows):
95
+ if row_index:
96
+ # Empty rows preserve measured visual gaps, without interpreting syntax.
97
+ lines.extend([""] * (gap_cells[row_index - 1] - 1))
98
+ line, cursor = "", 0
99
+ for char in sorted(row, key=lambda value: value["x"]):
100
+ column = (char["x"] - origin) / pitch
101
+ cell = round(column)
102
+ if cell < cursor or cell > 2000 or abs(column - cell) > 0.025:
103
+ raise ValueError("overlapping or non-integral character positions")
104
+ line += " " * (cell - cursor) + char["character"]
105
+ cursor = cell + char["cells"]
106
+ lines.append(line.rstrip())
107
+ return "\n".join(lines), {"advance": pitch, "lineAdvance": line_advance, "rowPitch": line_advance, "baselineGaps": gaps, "gapRows": gap_cells, "originX": origin, "baselines": [row[0]["y"] for row in rows], "characters": chars}
108
+
109
+
110
+ def recover_native_code(document, source):
111
+ """Mutate qualified Docling text/code items; return auditable recovery metadata."""
112
+ import pypdfium2 as pdfium
113
+ from docling_core.types.doc import CodeItem
114
+ items = [item for item, _level in document.iterate_items() if str(getattr(item, "label", "")) in ("code", "text") and (str(item.label) == "code" or _SIGNATURE.match(getattr(item, "text", "")))]
115
+ result = {"recovered": 0, "warnings": [], "needs_review": False, "recoveries": []}
116
+ if not items:
117
+ return result
118
+ with pdfium.PdfDocument(source) as pdf:
119
+ for item in items:
120
+ try:
121
+ if len(item.prov) != 1:
122
+ raise ValueError("code spans multiple or unspecified regions")
123
+ prov = item.prov[0]
124
+ page = pdf[prov.page_no - 1]
125
+ try:
126
+ textpage = page.get_textpage()
127
+ try:
128
+ text, evidence = _native_region(page, textpage, prov.bbox)
129
+ finally:
130
+ textpage.close()
131
+ finally:
132
+ page.close()
133
+ if _compact(text) not in {_compact(item.text), _compact(item.orig)}:
134
+ raise ValueError("native characters differ from the recognized region")
135
+ if any(delimiter in text or delimiter in item.text for delimiter in ('"""', "'''", "`")):
136
+ raise ValueError("multiline string whitespace requires explicit review")
137
+ if str(item.label) != "code" and "\n" not in text:
138
+ continue
139
+ data = item.model_dump()
140
+ data.update(label="code", text=text, orig=text)
141
+ new_item = CodeItem(**data)
142
+ new_item.prov[0].charspan = (0, len(text))
143
+ document.replace_item(old_item=item, new_item=new_item)
144
+ result["recovered"] += 1
145
+ result["recoveries"].append({"id": new_item.self_ref, "page": prov.page_no, "bbox": prov.bbox.model_dump(mode="json"), "originalText": item.text, "text": text, **evidence})
146
+ except Exception as error:
147
+ result["needs_review"] = True
148
+ result["warnings"].append(f"Native code layout could not be verified for {item.self_ref}: {error}; recognized text was preserved for review.")
149
+ return result
@@ -0,0 +1,321 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readFile, access, writeFile, stat, readdir } from "node:fs/promises";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import lockfile from "proper-lockfile";
6
+ import { killPidTree } from "../process-utils.js";
7
+ import { documentDataDir, getDocumentSettings } from "./settings.js";
8
+ export const DOCLING_VERSION = "2.136.0";
9
+ export const CONVERSION_PROFILE = "docling-cpu-zh-v1";
10
+ const ownedChildren = new Set();
11
+ let shuttingDown = false;
12
+ function trackChild(child) { ownedChildren.add(child); child.once("close", () => ownedChildren.delete(child)); }
13
+ export async function shutdownDocumentRuntime() {
14
+ shuttingDown = true;
15
+ if (worker)
16
+ closeWorker(worker);
17
+ await Promise.all([...ownedChildren].map(child => new Promise(resolve => {
18
+ const done = () => { clearTimeout(timer); resolve(); };
19
+ const timer = setTimeout(done, 5000);
20
+ child.once("close", done);
21
+ if (child.pid)
22
+ killPidTree(child.pid);
23
+ else
24
+ done();
25
+ })));
26
+ }
27
+ export async function waitDocumentQueue(previous, signal) {
28
+ signal?.throwIfAborted();
29
+ if (!signal)
30
+ return previous;
31
+ await new Promise((resolve, reject) => {
32
+ const abort = () => reject(new DOMException("Document operation canceled", "AbortError"));
33
+ signal.addEventListener("abort", abort, { once: true });
34
+ previous.then(() => { signal.removeEventListener("abort", abort); resolve(); }, error => { signal.removeEventListener("abort", abort); reject(error); });
35
+ });
36
+ }
37
+ export function runtimeDirectory() {
38
+ return getDocumentSettings().runtimePath ?? join(documentDataDir(), "runtimes", CONVERSION_PROFILE);
39
+ }
40
+ export function runtimePython() {
41
+ return join(runtimeDirectory(), "venv", process.platform === "win32" ? "Scripts/python.exe" : "bin/python");
42
+ }
43
+ export function bridgePath() { return fileURLToPath(new URL("./python/bridge.py", import.meta.url)); }
44
+ /** A separate process group lets cancellation remove only this conversion's children. */
45
+ export async function runDocumentProcess(command, args, options = {}) {
46
+ if (shuttingDown)
47
+ throw new Error("Document runtime is shutting down");
48
+ options.signal?.throwIfAborted();
49
+ return new Promise((resolve, reject) => {
50
+ const child = spawn(command, args, {
51
+ stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true,
52
+ env: { ...process.env, PYTHONUNBUFFERED: "1", PYTHONUTF8: "1", ...(options.offline ? { HF_HUB_OFFLINE: "1", TRANSFORMERS_OFFLINE: "1", HF_DATASETS_OFFLINE: "1", HF_HUB_DISABLE_TELEMETRY: "1" } : {}) },
53
+ });
54
+ trackChild(child);
55
+ let stdout = "", stderr = "", pending = "", failure, lastProgress = 0;
56
+ const stop = (error) => { failure = error; if (child.pid)
57
+ killPidTree(child.pid); };
58
+ const abort = () => stop(new DOMException("Document operation canceled", "AbortError"));
59
+ const timer = setTimeout(() => stop(new Error("Document operation timed out")), options.timeout ?? 30 * 60_000);
60
+ options.signal?.addEventListener("abort", abort, { once: true });
61
+ child.stdout.on("data", (chunk) => {
62
+ stdout += chunk.toString("utf8");
63
+ if (stdout.length > 4 * 1024 * 1024)
64
+ stop(new Error("Parser response exceeds 4 MiB"));
65
+ });
66
+ child.stderr.on("data", (chunk) => {
67
+ const text = chunk.toString("utf8");
68
+ stderr = (stderr + text).slice(-32_768);
69
+ pending += text;
70
+ const lines = pending.split(/\r\n|\r|\n/);
71
+ pending = lines.pop() ?? "";
72
+ const latest = lines.map(line => line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").trim()).filter(Boolean).at(-1);
73
+ if (latest && Date.now() - lastProgress >= 400) {
74
+ options.onProgress?.(latest.slice(0, 1000));
75
+ lastProgress = Date.now();
76
+ }
77
+ if (pending.length > 2000)
78
+ pending = pending.slice(-2000);
79
+ });
80
+ child.on("error", error => { failure = error; });
81
+ child.on("close", code => {
82
+ clearTimeout(timer);
83
+ options.signal?.removeEventListener("abort", abort);
84
+ if (failure)
85
+ reject(failure);
86
+ else if (code !== 0)
87
+ reject(new Error(`Document process failed (${code}): ${stderr.trim().slice(-6000)}`));
88
+ else
89
+ resolve(stdout.trim());
90
+ });
91
+ child.stdin.on("error", () => { });
92
+ child.stdin.end(options.input === undefined ? undefined : JSON.stringify(options.input));
93
+ if (options.signal?.aborted)
94
+ abort();
95
+ });
96
+ }
97
+ export async function doctorRuntime(options = {}) {
98
+ const pythonPath = runtimePython();
99
+ try {
100
+ await access(pythonPath);
101
+ const result = JSON.parse(await runDocumentProcess(pythonPath, [bridgePath(), "doctor", runtimeDirectory()], { ...options, timeout: 60_000, offline: true }));
102
+ return { ...result, pythonPath };
103
+ }
104
+ catch (error) {
105
+ options.signal?.throwIfAborted();
106
+ return { ready: false, pythonPath, missing: [error instanceof Error ? error.message : String(error)], warnings: [] };
107
+ }
108
+ }
109
+ let qualification;
110
+ async function qualificationSignature(root) {
111
+ const receipt = await readFile(join(root, "ready.json"), "utf8");
112
+ const parsed = JSON.parse(receipt);
113
+ const assets = (await readdir(dirname(bridgePath()))).filter(name => name.endsWith(".py")).sort().map(name => join(dirname(bridgePath()), name));
114
+ const paths = [join(root, "ready.json"), join(root, "requirements.lock"), runtimePython(), ...assets, ...Object.keys(parsed.models ?? {}).sort().map(path => join(root, "models", path))];
115
+ const identities = await Promise.all(paths.map(async (path) => { const value = await stat(path); return [path, value.dev, value.ino, value.size, value.mtimeMs, value.ctimeMs]; }));
116
+ return JSON.stringify([receipt, identities]);
117
+ }
118
+ /** Explicit doctor always hashes models; conversions reuse a successful check
119
+ * until the receipt, dependency lock, Python executable or model files change. */
120
+ export async function ensureRuntimeReady(options = {}) {
121
+ options.signal?.throwIfAborted();
122
+ const root = runtimeDirectory();
123
+ let signature;
124
+ try {
125
+ signature = await qualificationSignature(root);
126
+ }
127
+ catch { /* Doctor gives actionable missing-runtime details. */ }
128
+ if (signature !== undefined && qualification?.root === root && qualification.signature === signature)
129
+ return qualification.result;
130
+ const result = await doctorRuntime(options);
131
+ if (result.ready && signature !== undefined)
132
+ qualification = { root, signature, result };
133
+ else
134
+ qualification = undefined;
135
+ return result;
136
+ }
137
+ let setupPromise;
138
+ export async function setupRuntime(options = {}) {
139
+ if (setupPromise)
140
+ throw new Error("Document runtime setup is already running");
141
+ setupPromise = performSetup(options);
142
+ try {
143
+ return await setupPromise;
144
+ }
145
+ finally {
146
+ setupPromise = undefined;
147
+ }
148
+ }
149
+ async function performSetup(options) {
150
+ if (process.platform === "darwin" && process.arch === "x64")
151
+ throw new Error("The current local Docling profile has not been qualified on Intel macOS. A separately tested runtime is required.");
152
+ const root = runtimeDirectory(), pythonPath = runtimePython();
153
+ await mkdir(root, { recursive: true });
154
+ const release = await lockfile.lock(root, { realpath: false, stale: 120_000, retries: 0 });
155
+ try {
156
+ const existing = await doctorRuntime(options);
157
+ if (existing.ready) {
158
+ options.onProgress?.("The isolated document runtime is already qualified");
159
+ return existing;
160
+ }
161
+ if (worker?.root === root)
162
+ closeWorker(worker);
163
+ try {
164
+ await access(pythonPath);
165
+ }
166
+ catch {
167
+ const configured = getDocumentSettings().pythonPath;
168
+ if (configured) {
169
+ const version = await runDocumentProcess(configured, ["-c", "import sys; print('.'.join(map(str,sys.version_info[:2])))"], options);
170
+ if (version !== "3.12")
171
+ throw new Error(`Python 3.12 is required for this profile; found ${version}`);
172
+ await runDocumentProcess(configured, ["-m", "venv", join(root, "venv")], options);
173
+ }
174
+ else {
175
+ options.onProgress?.("Creating the isolated Python 3.12 runtime with uv");
176
+ try {
177
+ await runDocumentProcess("uv", ["venv", "--seed", "--python", "3.12", join(root, "venv")], options);
178
+ }
179
+ catch (error) {
180
+ throw new Error(`Install uv or configure an absolute Python 3.12 executable path. ${error instanceof Error ? error.message : error}`);
181
+ }
182
+ }
183
+ }
184
+ options.onProgress?.(`Installing Docling ${DOCLING_VERSION} in the isolated runtime`);
185
+ const lockPath = join(root, "requirements.lock");
186
+ // The CPU Transformers backend is already a standard dependency. The broad
187
+ // vlm extra additionally installs unrelated MLX/audio backends on Apple Silicon.
188
+ let install = [`docling[rapidocr]==${DOCLING_VERSION}`, "transformers==5.19.0", "rapidocr==3.9.1"];
189
+ try {
190
+ const locked = await readFile(lockPath, "utf8");
191
+ if (!locked.trim().split(/\r?\n/).every(line => /^[A-Za-z0-9_.-]+==[A-Za-z0-9_.+!-]+$/.test(line)))
192
+ throw new Error("Invalid document runtime dependency lock");
193
+ install = ["-r", lockPath];
194
+ }
195
+ catch (error) {
196
+ if (error.code !== "ENOENT")
197
+ throw error;
198
+ }
199
+ try {
200
+ await runDocumentProcess("uv", ["pip", "install", "--python", pythonPath, "--index-url", "https://pypi.org/simple", ...install], { ...options, timeout: 45 * 60_000 });
201
+ }
202
+ catch (error) {
203
+ if (error.code !== "ENOENT")
204
+ throw error;
205
+ await runDocumentProcess(pythonPath, ["-m", "pip", "install", "--index-url", "https://pypi.org/simple", ...install], { ...options, timeout: 45 * 60_000 });
206
+ }
207
+ const freeze = await runDocumentProcess(pythonPath, ["-m", "pip", "freeze"], options);
208
+ if (!freeze.split(/\r?\n/).every(line => /^[A-Za-z0-9_.-]+==[A-Za-z0-9_.+!-]+$/.test(line)))
209
+ throw new Error("Installed dependencies contain unexpected non-registry references");
210
+ await writeFile(lockPath, freeze + "\n");
211
+ options.onProgress?.("Downloading local models and verifying the offline conversion profile");
212
+ await runDocumentProcess(pythonPath, [bridgePath(), "setup", root], { ...options, timeout: 60 * 60_000 });
213
+ return doctorRuntime(options);
214
+ }
215
+ finally {
216
+ await release();
217
+ }
218
+ }
219
+ export async function runtimeFingerprint() {
220
+ return readFile(join(runtimeDirectory(), "ready.json"), "utf8");
221
+ }
222
+ let worker;
223
+ let workerQueue = Promise.resolve();
224
+ function closeWorker(active) {
225
+ if (active.idle)
226
+ clearTimeout(active.idle);
227
+ if (active.child.pid)
228
+ killPidTree(active.child.pid);
229
+ if (worker === active)
230
+ worker = undefined;
231
+ }
232
+ function parserWorker() {
233
+ if (shuttingDown)
234
+ throw new Error("Document runtime is shutting down");
235
+ const root = runtimeDirectory();
236
+ if (worker?.root === root) {
237
+ if (worker.idle)
238
+ clearTimeout(worker.idle);
239
+ return worker;
240
+ }
241
+ if (worker)
242
+ closeWorker(worker);
243
+ const child = spawn(runtimePython(), [bridgePath(), "serve", root], { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", windowsHide: true, env: { ...process.env, PYTHONUNBUFFERED: "1", PYTHONUTF8: "1", HF_HUB_OFFLINE: "1", TRANSFORMERS_OFFLINE: "1", HF_HUB_DISABLE_TELEMETRY: "1" } });
244
+ trackChild(child);
245
+ const active = { child, root, stdout: "" };
246
+ worker = active;
247
+ child.stdout.on("data", (chunk) => {
248
+ active.stdout += chunk.toString("utf8");
249
+ if (active.stdout.length > 4 * 1024 * 1024) {
250
+ active.pending?.reject(new Error("Parser response exceeds 4 MiB"));
251
+ closeWorker(active);
252
+ return;
253
+ }
254
+ let end;
255
+ while ((end = active.stdout.indexOf("\n")) >= 0) {
256
+ const line = active.stdout.slice(0, end);
257
+ active.stdout = active.stdout.slice(end + 1);
258
+ try {
259
+ const response = JSON.parse(line);
260
+ if (response.error)
261
+ active.pending?.reject(new Error(response.error));
262
+ else
263
+ active.pending?.resolve(response);
264
+ }
265
+ catch {
266
+ active.pending?.reject(new Error("Invalid parser response"));
267
+ closeWorker(active);
268
+ }
269
+ }
270
+ });
271
+ let errorTail = "";
272
+ child.stderr.on("data", (chunk) => { const text = chunk.toString("utf8"); errorTail = (errorTail + text).slice(-6000); for (const line of text.split(/\r?\n/))
273
+ if (line.trim())
274
+ active.pending?.progress?.(line.slice(0, 1000)); });
275
+ child.on("error", error => { active.pending?.reject(error); if (worker === active)
276
+ worker = undefined; });
277
+ child.on("close", code => { active.pending?.reject(new Error(`Document worker exited (${code}): ${errorTail}`)); if (worker === active)
278
+ worker = undefined; });
279
+ child.stdin.on("error", error => active.pending?.reject(error));
280
+ return active;
281
+ }
282
+ /** One queued local worker keeps model weights warm without parallel RAM spikes. */
283
+ export async function runConversionWorker(input, options) {
284
+ const previous = workerQueue;
285
+ let release;
286
+ const gate = new Promise(done => { release = done; });
287
+ workerQueue = previous.then(() => gate);
288
+ try {
289
+ await waitDocumentQueue(previous, options.signal);
290
+ options.signal?.throwIfAborted();
291
+ const active = parserWorker();
292
+ return await new Promise((resolve, reject) => {
293
+ const finish = (error, response) => {
294
+ if (!active.pending)
295
+ return;
296
+ active.pending = undefined;
297
+ clearTimeout(timer);
298
+ options.signal?.removeEventListener("abort", abort);
299
+ if (error) {
300
+ closeWorker(active);
301
+ reject(error);
302
+ }
303
+ else {
304
+ active.idle = setTimeout(() => closeWorker(active), 10_000);
305
+ active.idle.unref();
306
+ resolve(response);
307
+ }
308
+ };
309
+ const abort = () => finish(new DOMException("Document operation canceled", "AbortError"));
310
+ const timer = setTimeout(() => finish(new Error("Document conversion exceeded 30 minutes")), 30 * 60_000);
311
+ active.pending = { resolve: response => finish(undefined, response), reject: error => finish(error), progress: options.onProgress };
312
+ options.signal?.addEventListener("abort", abort, { once: true });
313
+ active.child.stdin.write(JSON.stringify(input) + "\n");
314
+ if (options.signal?.aborted)
315
+ abort();
316
+ });
317
+ }
318
+ finally {
319
+ release();
320
+ }
321
+ }