@youweichen/pi-harness 1.1.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 (82) hide show
  1. package/README.md +14 -0
  2. package/dist/server/document-conversion/extension.js +164 -0
  3. package/dist/server/document-conversion/python/bridge.py +424 -0
  4. package/dist/server/document-conversion/python/fixtures.py +201 -0
  5. package/dist/server/document-conversion/python/native_code.py +149 -0
  6. package/dist/server/document-conversion/runtime.js +321 -0
  7. package/dist/server/document-conversion/service.js +340 -0
  8. package/dist/server/document-conversion/settings.js +63 -0
  9. package/dist/server/index.js +2 -0
  10. package/dist/server/native-tools.js +4 -0
  11. package/dist/server/okf/extension.js +118 -0
  12. package/dist/server/okf/markdown-sources.js +58 -0
  13. package/dist/server/okf/service.js +713 -0
  14. package/dist/server/okf/storage.js +165 -0
  15. package/dist/server/okf/types.js +1 -0
  16. package/dist/server/wiki-links.js +24 -0
  17. package/dist/server/wiki-service.js +37 -5
  18. package/package.json +2 -2
  19. package/web/dist/assets/{NodeWorkbench-BiP2ktQP.js → NodeWorkbench-DM6YTS5e.js} +1 -1
  20. package/web/dist/assets/{TerminalPanel-CKaSUsN-.js → TerminalPanel-aFJylyxg.js} +1 -1
  21. package/web/dist/assets/{abnfDiagram-VCTEODGH-CnwRJDRL.js → abnfDiagram-VCTEODGH-CcM7f7oX.js} +1 -1
  22. package/web/dist/assets/{arc-CG4Q7-W_.js → arc-Bv7vS5l5.js} +1 -1
  23. package/web/dist/assets/{architectureDiagram-5GKGNRK7-BrGqX_Rg.js → architectureDiagram-5GKGNRK7-Cl8QcB3w.js} +1 -1
  24. package/web/dist/assets/{blockDiagram-I7D4REHJ-C2t0PjZT.js → blockDiagram-I7D4REHJ-DarS2-4G.js} +1 -1
  25. package/web/dist/assets/{c4Diagram-7LVT6UL2-CadrcgGL.js → c4Diagram-7LVT6UL2-BY0_ttBt.js} +1 -1
  26. package/web/dist/assets/channel-DJGMgVTh.js +1 -0
  27. package/web/dist/assets/{chunk-2Q5K7J3B-CC3V2ITZ.js → chunk-2Q5K7J3B-Cz-zS7_X.js} +1 -1
  28. package/web/dist/assets/{chunk-5VM5RSS4-DqKSbGmE.js → chunk-5VM5RSS4-B7AyqkmI.js} +1 -1
  29. package/web/dist/assets/{chunk-F27PBJKO-_CDr3JbY.js → chunk-F27PBJKO-B7hVduzL.js} +1 -1
  30. package/web/dist/assets/{chunk-IMKFNOWR-BIR4H4fX.js → chunk-IMKFNOWR-DNGJqYn5.js} +1 -1
  31. package/web/dist/assets/{chunk-JWPE2WC7-CrsdBGXT.js → chunk-JWPE2WC7-B5iJgXvl.js} +1 -1
  32. package/web/dist/assets/{chunk-POPQ4Y6H-C6q9kQmf.js → chunk-POPQ4Y6H-BvWCkriR.js} +1 -1
  33. package/web/dist/assets/{chunk-SVP7TREG-l2XXTr2v.js → chunk-SVP7TREG-BcdlBuOQ.js} +1 -1
  34. package/web/dist/assets/{chunk-TICWLB2K-29I_mZgB.js → chunk-TICWLB2K-B1igqjQd.js} +1 -1
  35. package/web/dist/assets/{chunk-XXDRQBXY-htV9FBqi.js → chunk-XXDRQBXY-DKGDquaw.js} +1 -1
  36. package/web/dist/assets/classDiagram-ZZMXUADV-D9nxgF0M.js +1 -0
  37. package/web/dist/assets/classDiagram-v2-VYDZK3BY-D9nxgF0M.js +1 -0
  38. package/web/dist/assets/{cose-bilkent-JH36ORCC-Cop-ihyx.js → cose-bilkent-JH36ORCC-BU_9nu88.js} +1 -1
  39. package/web/dist/assets/{cynefin-OW5HDTMX-IEk8qBRq.js → cynefin-OW5HDTMX-DY0BDLjc.js} +1 -1
  40. package/web/dist/assets/{cynefinDiagram-5FMLGOSQ-Bmzt6Qb5.js → cynefinDiagram-5FMLGOSQ-B-zM25Fk.js} +1 -1
  41. package/web/dist/assets/{dagre-GXQ25YYZ-DiXTJYYU.js → dagre-GXQ25YYZ-BxooDyBc.js} +1 -1
  42. package/web/dist/assets/{diagram-S7CK7UJ4-BT7CLuNk.js → diagram-S7CK7UJ4-CCO-8de9.js} +1 -1
  43. package/web/dist/assets/{diagram-UQ7AKVKN-BZZ3nTCO.js → diagram-UQ7AKVKN-CmGsH525.js} +1 -1
  44. package/web/dist/assets/{diagram-VSXAHHWV-Dal8LeZh.js → diagram-VSXAHHWV-CSQJYo3m.js} +1 -1
  45. package/web/dist/assets/{diagram-VX7I27RA-D6V9FfNA.js → diagram-VX7I27RA-CwuJsF66.js} +1 -1
  46. package/web/dist/assets/{diagram-Z3DM3KII-KUAIcFiD.js → diagram-Z3DM3KII-BF13T_lj.js} +1 -1
  47. package/web/dist/assets/{ebnfDiagram-PWID7BFC-s0FEPZrV.js → ebnfDiagram-PWID7BFC-ZtoDLro6.js} +1 -1
  48. package/web/dist/assets/{erDiagram-RLTQ6QDP-CiIQPFCD.js → erDiagram-RLTQ6QDP-BBo-qmNH.js} +1 -1
  49. package/web/dist/assets/{flowDiagram-HODETNUW-CvG4MYWn.js → flowDiagram-HODETNUW-DAq9qdP8.js} +1 -1
  50. package/web/dist/assets/{ganttDiagram-EL5Y4UJY-CUf115ii.js → ganttDiagram-EL5Y4UJY-C53eRlMu.js} +1 -1
  51. package/web/dist/assets/{gitGraphDiagram-WWUBYQGX-o3K1r6fL.js → gitGraphDiagram-WWUBYQGX-BviBMJxj.js} +1 -1
  52. package/web/dist/assets/{index-C5or_GAq.js → index-Bab44wX8.js} +81 -81
  53. package/web/dist/assets/{infoDiagram-27XIBGKW-BxiBeQ8E.js → infoDiagram-27XIBGKW-DI95SO3j.js} +1 -1
  54. package/web/dist/assets/{ishikawaDiagram-5VMMS53U-B1ONuCkH.js → ishikawaDiagram-5VMMS53U-Cl8IDXwf.js} +1 -1
  55. package/web/dist/assets/{journeyDiagram-3NMN7TZE-DwmuoXcX.js → journeyDiagram-3NMN7TZE-Co6aJeTZ.js} +1 -1
  56. package/web/dist/assets/{kanban-definition-UXKFOSKX-pMKHubxH.js → kanban-definition-UXKFOSKX-CknQQpwW.js} +1 -1
  57. package/web/dist/assets/{layout-CEwfjQR0.js → layout-CFtUmtTW.js} +1 -1
  58. package/web/dist/assets/{linear-D22Gdbt1.js → linear-mRlGUAKy.js} +1 -1
  59. package/web/dist/assets/{mermaid.core-CO2nrD04.js → mermaid.core-CvQEO3ue.js} +5 -5
  60. package/web/dist/assets/{mindmap-definition-YA3MSWOX-9MQn9ZAp.js → mindmap-definition-YA3MSWOX-DpqhsxUl.js} +1 -1
  61. package/web/dist/assets/{pegDiagram-XKGWAZYB-Dy6fLjM-.js → pegDiagram-XKGWAZYB-C9eawB5u.js} +1 -1
  62. package/web/dist/assets/{pieDiagram-E7YTZNPT-EDLez6nA.js → pieDiagram-E7YTZNPT-8vaqXVD6.js} +1 -1
  63. package/web/dist/assets/{quadrantDiagram-AXDQQJYC-Cgcd9mvy.js → quadrantDiagram-AXDQQJYC-Diwr8ynm.js} +1 -1
  64. package/web/dist/assets/{railroadDiagram-O6MQD6OU-B4Td0tjj.js → railroadDiagram-O6MQD6OU-krW50Cgi.js} +1 -1
  65. package/web/dist/assets/{requirementDiagram-BXWQKSXE-aF2QNZ78.js → requirementDiagram-BXWQKSXE-BPLI6eMG.js} +1 -1
  66. package/web/dist/assets/{sankeyDiagram-P5KCCOFB-DgTYwYMa.js → sankeyDiagram-P5KCCOFB-Bix_elmH.js} +1 -1
  67. package/web/dist/assets/{sequenceDiagram-WJ2MYXX4-EUcddWUc.js → sequenceDiagram-WJ2MYXX4-0m0xaUKi.js} +1 -1
  68. package/web/dist/assets/{sizeCapture-INFHLROL-CXHOv9Sx.js → sizeCapture-INFHLROL-BtzJ_oOR.js} +1 -1
  69. package/web/dist/assets/{stateDiagram-D77RDMKH-B-xlgfaK.js → stateDiagram-D77RDMKH-Clq5bN0v.js} +1 -1
  70. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-Bn1oW4gp.js +1 -0
  71. package/web/dist/assets/{swimlanes-42K2YHIH-BtFBOCDh.js → swimlanes-42K2YHIH-11MDGnYA.js} +1 -1
  72. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-BW96Edgo.js +8 -0
  73. package/web/dist/assets/{timeline-definition-24CTP7MA-CZlQY6pb.js → timeline-definition-24CTP7MA-BeQfWP95.js} +1 -1
  74. package/web/dist/assets/{vennDiagram-4TSXK5OY-Cn61dJvL.js → vennDiagram-4TSXK5OY-BMYkX0KL.js} +1 -1
  75. package/web/dist/assets/{wardleyDiagram-VM6X3IG4-fiMNrWpr.js → wardleyDiagram-VM6X3IG4-BV3fTUkU.js} +1 -1
  76. package/web/dist/assets/{xychartDiagram-S5SC5T6Z-CuzM_sof.js → xychartDiagram-S5SC5T6Z-Ct7ezLnc.js} +1 -1
  77. package/web/dist/index.html +1 -1
  78. package/web/dist/assets/channel-C27Jizb6.js +0 -1
  79. package/web/dist/assets/classDiagram-ZZMXUADV-DYTBrAEP.js +0 -1
  80. package/web/dist/assets/classDiagram-v2-VYDZK3BY-DYTBrAEP.js +0 -1
  81. package/web/dist/assets/stateDiagram-v2-MP3YSRHH-kwAwAlWX.js +0 -1
  82. package/web/dist/assets/swimlanesDiagram-VR7AAH4N-D5j5Cj3S.js +0 -8
@@ -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
+ }
@@ -0,0 +1,340 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { readFile, writeFile, stat, mkdir, rm, rename, access, lstat, readdir, cp, copyFile } from "node:fs/promises";
4
+ import { basename, dirname, extname, join, resolve, isAbsolute } from "node:path";
5
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
6
+ import { unified } from "unified";
7
+ import remarkParse from "remark-parse";
8
+ import lockfile from "proper-lockfile";
9
+ import { documentDataDir } from "./settings.js";
10
+ import { decodeText } from "../text-sniff.js";
11
+ import { CONVERSION_PROFILE, ensureRuntimeReady, runConversionWorker, runtimeFingerprint, waitDocumentQueue } from "./runtime.js";
12
+ export const DOCUMENT_EXTENSIONS = new Set([".pdf", ".md", ".markdown", ".txt", ".docx", ".xlsx", ".pptx"]);
13
+ const locks = new Map();
14
+ async function hashFile(path, signal) {
15
+ const hash = createHash("sha256"), stream = createReadStream(path, { signal });
16
+ for await (const chunk of stream)
17
+ hash.update(chunk);
18
+ return hash.digest("hex");
19
+ }
20
+ function digest(value) { return createHash("sha256").update(value).digest("hex"); }
21
+ function markdownImageNodes(text) {
22
+ const tree = unified().use(remarkParse).parse(text);
23
+ const nodes = [], references = new Set();
24
+ const visit = (node) => { nodes.push(node); if (node.type === "imageReference" && node.identifier)
25
+ references.add(node.identifier); node.children?.forEach(visit); };
26
+ visit(tree);
27
+ return nodes.filter(node => node.type === "image" || (node.type === "definition" && node.identifier && references.has(node.identifier)));
28
+ }
29
+ function imageSource(url, source, assets) {
30
+ if (assets !== undefined) {
31
+ if (!Object.hasOwn(assets, url) || !assets[url])
32
+ throw new Error("Image is not present in the archived evidence snapshot");
33
+ return assets[url];
34
+ }
35
+ return resolve(dirname(source), decodeURIComponent(url.split("#")[0]));
36
+ }
37
+ async function markdownDependencyFingerprint(text, source, assets) {
38
+ const dependencies = [];
39
+ for (const node of markdownImageNodes(text)) {
40
+ const url = node.url ?? "";
41
+ if (!url || /^(?:[a-z]+:|#)/i.test(url) || isAbsolute(url))
42
+ continue;
43
+ try {
44
+ const path = imageSource(url, source, assets), info = await stat(path);
45
+ if (!info.isFile() || info.size > 20 * 1024 * 1024)
46
+ throw new Error("Unsupported asset");
47
+ dependencies.push(`${url}:${await hashFile(path)}`);
48
+ }
49
+ catch {
50
+ dependencies.push(`${url}:missing`);
51
+ }
52
+ }
53
+ return digest(JSON.stringify(dependencies));
54
+ }
55
+ async function preserveMarkdownImages(text, source, output, warnings, assets) {
56
+ const replacements = [];
57
+ for (const node of markdownImageNodes(text)) {
58
+ const url = node.url ?? "";
59
+ if (!url || /^(?:https?:|data:|#)/i.test(url))
60
+ continue;
61
+ try {
62
+ if (isAbsolute(url) || /^[a-z]+:/i.test(url))
63
+ throw new Error("absolute image paths are not portable");
64
+ const local = imageSource(url, source, assets);
65
+ if (![".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".bmp", ".tiff"].includes(extname(local).toLowerCase()))
66
+ throw new Error("unsupported image format");
67
+ const info = await stat(local);
68
+ if (!info.isFile() || info.size > 20 * 1024 * 1024)
69
+ throw new Error("image is not a regular file below 20 MiB");
70
+ const target = `assets/${(await hashFile(local)).slice(0, 24)}${extname(local).toLowerCase()}`;
71
+ await copyFile(local, join(output, target));
72
+ const start = node.position?.start.offset, end = node.position?.end.offset;
73
+ if (start !== undefined && end !== undefined)
74
+ replacements.push({ start, end, text: node.type === "definition" ? `[${node.identifier}]: ${target}${node.title ? ` ${JSON.stringify(node.title)}` : ""}` : `![${(node.alt ?? "").replace(/]/g, "\\]")}](${target}${node.title ? ` ${JSON.stringify(node.title)}` : ""})` });
75
+ }
76
+ catch (error) {
77
+ warnings.push(`Markdown image was not copied: ${url}: ${error instanceof Error ? error.message : error}`);
78
+ }
79
+ }
80
+ for (const replacement of replacements.sort((a, b) => b.start - a.start))
81
+ text = text.slice(0, replacement.start) + replacement.text + text.slice(replacement.end);
82
+ return text;
83
+ }
84
+ export function textBlocks(text) {
85
+ const lines = text.split("\n"), blocks = [];
86
+ let start = 0, heading, fence;
87
+ const emit = (end) => {
88
+ const value = lines.slice(start, end).join("\n");
89
+ if (value.trim())
90
+ blocks.push({ id: `block-${blocks.length + 1}`, text: value, locator: { lineStart: start + 1, lineEnd: end, ...(heading ? { heading } : {}) } });
91
+ start = end;
92
+ };
93
+ for (let i = 0; i < lines.length; i++) {
94
+ const marker = lines[i].match(/^\s*(`{3,}|~{3,})/);
95
+ if (marker) {
96
+ if (!fence)
97
+ fence = marker[1];
98
+ else if (marker[1][0] === fence[0] && marker[1].length >= fence.length)
99
+ fence = undefined;
100
+ }
101
+ if (!fence && /^#{1,6}\s+/.test(lines[i])) {
102
+ emit(i);
103
+ heading = lines[i].replace(/^#{1,6}\s+/, "");
104
+ }
105
+ if (!fence && !lines[i].trim()) {
106
+ emit(i);
107
+ start = i + 1;
108
+ }
109
+ }
110
+ emit(lines.length);
111
+ return blocks;
112
+ }
113
+ export async function convertDocument(options) {
114
+ const key = resolve(options.outputDir), prior = locks.get(key) ?? Promise.resolve();
115
+ let release;
116
+ const lock = new Promise(done => { release = done; });
117
+ const chain = prior.then(() => lock);
118
+ locks.set(key, chain);
119
+ try {
120
+ await waitDocumentQueue(prior, options.signal);
121
+ options.signal?.throwIfAborted();
122
+ return await convert({ ...options, outputDir: key });
123
+ }
124
+ finally {
125
+ release();
126
+ if (locks.get(key) === chain)
127
+ locks.delete(key);
128
+ }
129
+ }
130
+ async function artifactFiles(root, prefix = "") {
131
+ const paths = [];
132
+ for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) {
133
+ const path = join(prefix, entry.name);
134
+ if (entry.isSymbolicLink())
135
+ throw new Error("Parser artifacts must not contain symlinks");
136
+ if (entry.isDirectory())
137
+ paths.push(...await artifactFiles(root, path));
138
+ else if (entry.isFile() && path !== "manifest.json")
139
+ paths.push(path);
140
+ }
141
+ return paths;
142
+ }
143
+ async function loadResult(root, manifest) {
144
+ const sourceMap = JSON.parse(await readFile(join(root, "source-map.json"), "utf8"));
145
+ return { markdownPath: join(root, "document.md"), structurePath: join(root, "structure.json"), sourceMapPath: join(root, "source-map.json"), assetsDir: join(root, "assets"), sourceHash: manifest.sourceHash, parserVersion: manifest.parserVersion, warnings: manifest.warnings, status: manifest.status, blocks: sourceMap.blocks };
146
+ }
147
+ async function existingManifest(root, signal) {
148
+ try {
149
+ await access(root);
150
+ }
151
+ catch (error) {
152
+ if (error.code === "ENOENT")
153
+ return undefined;
154
+ throw error;
155
+ }
156
+ if ((await lstat(root)).isSymbolicLink())
157
+ throw new Error("Output directory must not be a symlink");
158
+ if ((await readdir(root)).length === 0)
159
+ return undefined;
160
+ let manifest;
161
+ try {
162
+ manifest = JSON.parse(await readFile(join(root, "manifest.json"), "utf8"));
163
+ }
164
+ catch {
165
+ throw new Error("Output directory contains existing files without a conversion manifest; choose another directory");
166
+ }
167
+ if (manifest.schemaVersion !== 1 || !manifest.files || typeof manifest.files !== "object")
168
+ throw new Error("Invalid existing conversion manifest");
169
+ const actual = await artifactFiles(root);
170
+ if (actual.length !== Object.keys(manifest.files).length)
171
+ throw new Error("Conversion output has added or removed files; choose another directory to preserve changes");
172
+ for (const path of actual) {
173
+ if (!manifest.files[path] || await hashFile(join(root, path), signal) !== manifest.files[path])
174
+ throw new Error(`Conversion output was edited: ${path}. Choose another directory to preserve changes.`);
175
+ }
176
+ for (const required of ["document.md", "structure.json", "source-map.json"])
177
+ if (!manifest.files[required])
178
+ throw new Error("Incomplete conversion manifest");
179
+ return manifest;
180
+ }
181
+ async function destinationLock(root, signal) {
182
+ for (;;) {
183
+ signal?.throwIfAborted();
184
+ try {
185
+ return await lockfile.lock(root, { realpath: false, lockfilePath: `${root}.conversion-lock`, stale: 120_000, retries: 0 });
186
+ }
187
+ catch (error) {
188
+ if (error.code !== "ELOCKED")
189
+ throw error;
190
+ await new Promise(done => setTimeout(done, 100));
191
+ }
192
+ }
193
+ }
194
+ async function withArtifactMutationQueues(root, paths, operation) {
195
+ const ordered = [...new Set(paths)].sort();
196
+ const acquire = (index) => index === ordered.length ? operation() : withFileMutationQueue(join(root, ordered[index]), () => acquire(index + 1));
197
+ return acquire(0);
198
+ }
199
+ async function convert(options) {
200
+ const { outputDir, signal, onProgress } = options, inputPath = resolve(options.inputPath), suffix = extname(inputPath).toLowerCase();
201
+ if (!DOCUMENT_EXTENSIONS.has(suffix))
202
+ throw new Error(`Unsupported document format: ${suffix}`);
203
+ if (inputPath === outputDir || inputPath.startsWith(outputDir + "/") || inputPath.startsWith(outputDir + "\\"))
204
+ throw new Error("Output directory must not contain the source document");
205
+ const info = await stat(inputPath);
206
+ if (!info.isFile() || info.size > 100 * 1024 * 1024)
207
+ throw new Error("Document must be a file no larger than 100 MiB");
208
+ const sourceHash = await hashFile(inputPath, signal), plain = [".md", ".markdown", ".txt"].includes(suffix);
209
+ let parserVersion = "text-v1", fingerprint = "text-v1", markdownDependencies;
210
+ if (suffix === ".md" || suffix === ".markdown") {
211
+ if (info.size > 16 * 1024 * 1024)
212
+ throw new Error("Text documents are limited to 16 MiB");
213
+ markdownDependencies = await markdownDependencyFingerprint(decodeText(await readFile(inputPath)), inputPath, options.markdownAssets);
214
+ fingerprint += markdownDependencies;
215
+ }
216
+ if (!plain) {
217
+ const doctor = await ensureRuntimeReady({ signal, onProgress });
218
+ if (!doctor.ready)
219
+ throw new Error(`Local document runtime is not ready. Run /pdf-md setup. ${doctor.missing.join("; ")}`);
220
+ parserVersion = `docling-${doctor.parserVersion}`;
221
+ fingerprint = await runtimeFingerprint();
222
+ }
223
+ const cacheKey = digest(JSON.stringify({ sourceHash, suffix, profile: CONVERSION_PROFILE, fingerprint }));
224
+ await mkdir(dirname(outputDir), { recursive: true });
225
+ const unlock = await destinationLock(outputDir, signal);
226
+ try {
227
+ const existing = await existingManifest(outputDir, signal);
228
+ if (existing?.cacheKey === cacheKey && existing.status === "complete") {
229
+ const priorSource = JSON.parse(await readFile(join(outputDir, "source-map.json"), "utf8")).source;
230
+ if (priorSource === inputPath) {
231
+ onProgress?.("Using unchanged document conversion");
232
+ return await loadResult(outputDir, existing);
233
+ }
234
+ }
235
+ const cacheDir = join(documentDataDir(), "document-cache", cacheKey);
236
+ const staging = join(dirname(outputDir), `.${basename(outputDir)}-${randomUUID()}.partial`), backup = `${staging}.previous`;
237
+ await mkdir(join(staging, "assets"), { recursive: true });
238
+ let installed = false, backedUp = false;
239
+ try {
240
+ let warnings = [], status = "complete";
241
+ let reused;
242
+ try {
243
+ reused = await existingManifest(cacheDir, signal);
244
+ }
245
+ catch {
246
+ signal?.throwIfAborted();
247
+ }
248
+ if (reused?.cacheKey === cacheKey && reused.status === "complete") {
249
+ await cp(cacheDir, staging, { recursive: true });
250
+ warnings = reused.warnings;
251
+ status = reused.status;
252
+ const sourceMap = JSON.parse(await readFile(join(staging, "source-map.json"), "utf8"));
253
+ sourceMap.source = inputPath;
254
+ await writeFile(join(staging, "source-map.json"), JSON.stringify(sourceMap, null, 2));
255
+ const structure = JSON.parse(await readFile(join(staging, "structure.json"), "utf8"));
256
+ if (structure.origin && typeof structure.origin === "object")
257
+ structure.origin.filename = basename(inputPath);
258
+ await writeFile(join(staging, "structure.json"), JSON.stringify(structure, null, 2));
259
+ onProgress?.("Reusing the local document content cache");
260
+ }
261
+ else if (plain) {
262
+ if (info.size > 16 * 1024 * 1024)
263
+ throw new Error("Text documents are limited to 16 MiB");
264
+ const data = await readFile(inputPath);
265
+ try {
266
+ new TextDecoder("utf-8", { fatal: true }).decode(data);
267
+ }
268
+ catch {
269
+ warnings.push("Source is not UTF-8; decoded using the legacy text encoding fallback. Verify non-ASCII text.");
270
+ }
271
+ const text = decodeText(data).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n"), blocks = textBlocks(text);
272
+ const markdown = suffix === ".txt" ? text : await preserveMarkdownImages(text, inputPath, staging, warnings, options.markdownAssets);
273
+ if (warnings.some(warning => warning.startsWith("Markdown image was not copied")))
274
+ status = "partial";
275
+ await writeFile(join(staging, "document.md"), markdown.endsWith("\n") ? markdown : markdown + "\n");
276
+ await writeFile(join(staging, "structure.json"), JSON.stringify({ schemaVersion: 1, kind: "text", blocks }, null, 2));
277
+ await writeFile(join(staging, "source-map.json"), JSON.stringify({ schemaVersion: 1, source: inputPath, sourceHash, blocks }, null, 2));
278
+ }
279
+ else {
280
+ onProgress?.(`Converting ${basename(inputPath)} locally`);
281
+ const response = await runConversionWorker({ inputPath, outputDir: staging, sourceHash }, { signal, onProgress });
282
+ if (response.status !== "complete" && response.status !== "partial")
283
+ throw new Error("Invalid parser completion status");
284
+ status = response.status;
285
+ warnings = response.warnings ?? [];
286
+ }
287
+ if (await hashFile(inputPath, signal) !== sourceHash)
288
+ throw new Error("Source changed during conversion; retry with the current document");
289
+ if (markdownDependencies !== undefined && await markdownDependencyFingerprint(decodeText(await readFile(inputPath)), inputPath, options.markdownAssets) !== markdownDependencies)
290
+ throw new Error("Markdown images changed during conversion; retry with the current document");
291
+ for (const path of ["document.md", "structure.json", "source-map.json"])
292
+ await access(join(staging, path));
293
+ const files = {};
294
+ for (const path of await artifactFiles(staging))
295
+ files[path] = await hashFile(join(staging, path), signal);
296
+ const manifest = { schemaVersion: 1, cacheKey, sourceHash, parserVersion, status, warnings, files };
297
+ await writeFile(join(staging, "manifest.json"), JSON.stringify(manifest, null, 2));
298
+ signal?.throwIfAborted();
299
+ onProgress?.("Committing verified document artifacts");
300
+ await withArtifactMutationQueues(outputDir, ["manifest.json", ...Object.keys(existing?.files ?? {}), ...Object.keys(files)], async () => {
301
+ // Revalidate after the slow parser run, catching edits made during OCR.
302
+ await existingManifest(outputDir, signal);
303
+ try {
304
+ await access(outputDir);
305
+ await rename(outputDir, backup);
306
+ backedUp = true;
307
+ }
308
+ catch (error) {
309
+ if (error.code !== "ENOENT")
310
+ throw error;
311
+ }
312
+ await rename(staging, outputDir);
313
+ installed = true;
314
+ });
315
+ if (status === "complete") {
316
+ await mkdir(dirname(cacheDir), { recursive: true });
317
+ const temporaryCache = `${cacheDir}.${randomUUID()}.partial`;
318
+ try {
319
+ await cp(outputDir, temporaryCache, { recursive: true });
320
+ await rename(temporaryCache, cacheDir);
321
+ }
322
+ catch { /* Cache races or cache I/O failures never invalidate the output. */ }
323
+ finally {
324
+ await rm(temporaryCache, { recursive: true, force: true });
325
+ }
326
+ }
327
+ return await loadResult(outputDir, manifest);
328
+ }
329
+ finally {
330
+ if (!installed && backedUp)
331
+ await rename(backup, outputDir);
332
+ await rm(staging, { recursive: true, force: true });
333
+ if (installed)
334
+ await rm(backup, { recursive: true, force: true });
335
+ }
336
+ }
337
+ finally {
338
+ await unlock();
339
+ }
340
+ }