@exulu/backend 3.7.4 → 4.0.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 (38) hide show
  1. package/dist/{chunk-27K2CO47.js → chunk-QMN6MVHQ.js} +1 -1
  2. package/dist/{chunk-AWMU6QXB.js → chunk-RBEWHG7I.js} +297 -39
  3. package/dist/cli/start-whisper.js +1 -1
  4. package/dist/{convert-exulu-tools-to-ai-sdk-tools-XNQ6Q3X6.js → convert-exulu-tools-to-ai-sdk-tools-6RU4IZMI.js} +1 -1
  5. package/dist/index.cjs +566 -217
  6. package/dist/index.d.cts +3 -1
  7. package/dist/index.d.ts +3 -1
  8. package/dist/index.js +250 -180
  9. package/dist/{python-setup-JZGHWQCG.js → python-setup-DRJ3QX5F.js} +1 -1
  10. package/ee/LICENSE.md +2 -2
  11. package/ee/agentic-retrieval/pipeline/config.test.ts +18 -1
  12. package/ee/agentic-retrieval/pipeline/config.ts +15 -0
  13. package/ee/agentic-retrieval/pipeline/index.test.ts +73 -0
  14. package/ee/agentic-retrieval/pipeline/index.ts +67 -13
  15. package/ee/agentic-retrieval/pipeline/memory.test.ts +59 -0
  16. package/ee/agentic-retrieval/pipeline/memory.ts +181 -11
  17. package/ee/agentic-retrieval/pipeline/pin-rerun.test.ts +17 -0
  18. package/ee/agentic-retrieval/pipeline/pin-rerun.ts +29 -0
  19. package/ee/agentic-retrieval/pipeline/routing.test.ts +34 -0
  20. package/ee/agentic-retrieval/pipeline/routing.ts +96 -5
  21. package/ee/agentic-retrieval/pipeline/search.ts +9 -6
  22. package/ee/agentic-retrieval/pipeline/timing.test.ts +24 -0
  23. package/ee/agentic-retrieval/pipeline/timing.ts +26 -0
  24. package/ee/agentic-retrieval/pipeline/types.ts +2 -0
  25. package/ee/python/documents/processing/README.md +2 -3
  26. package/ee/python/documents/processing/doc_processor.ts +21 -61
  27. package/ee/python/documents/processing/split_pdf.py +25 -30
  28. package/ee/python/documents/processing/tests/__init__.py +0 -0
  29. package/ee/python/documents/processing/tests/test_split_pdf.py +230 -0
  30. package/ee/python/requirements.txt +12 -2
  31. package/ee/python/setup.sh +40 -1
  32. package/ee/python/transcription/pipeline.py +109 -15
  33. package/ee/python/transcription/tests/test_align_model_licensing.py +184 -0
  34. package/ee/workers.ts +2 -7
  35. package/license.md +2 -2
  36. package/package.json +3 -4
  37. package/scripts/postinstall.cjs +52 -1
  38. package/ee/python/documents/processing/document_to_markdown.py +0 -413
@@ -35,6 +35,51 @@ class CancelledError(Exception):
35
35
  pass
36
36
 
37
37
 
38
+ # Forced-alignment models whisperx would download that are NOT licence-cleared
39
+ # for commercial use. Keys are the Hugging Face repo ids in whisperx's own
40
+ # DEFAULT_ALIGN_MODELS_HF table; values are the reason, which is logged verbatim.
41
+ #
42
+ # whisperx picks an alignment model from the language Whisper *detected*, so
43
+ # without this guard an ordinary upload in one of these languages silently pulls
44
+ # the model onto the server and uses it. Alignment only refines timestamps to
45
+ # word level: when it is skipped the transcript, its segment-level timings and
46
+ # its speaker labels are all still produced (see _get_align_model).
47
+ #
48
+ # To allow one of these again, supply a licence-cleared replacement through
49
+ # EXULU_ALIGN_MODEL_<LANG> rather than deleting the entry — e.g.
50
+ # EXULU_ALIGN_MODEL_VI=my-org/licensed-vi-aligner.
51
+ RESTRICTED_ALIGN_MODELS: dict[str, str] = {
52
+ "nguyenvulebinh/wav2vec2-base-vi":
53
+ "CC-BY-NC-4.0 — non-commercial use only",
54
+ "classla/wav2vec2-xls-r-parlaspeech-hr":
55
+ "no licence stated on the model card or in the Hugging Face metadata",
56
+ "imvladikon/wav2vec2-xls-r-300m-hebrew":
57
+ "no licence stated on the model card or in the Hugging Face metadata",
58
+ "theainerd/Wav2Vec2-large-xlsr-hindi":
59
+ "no licence stated on the model card or in the Hugging Face metadata",
60
+ # Danish is blocked on the conservative side: the model card states only
61
+ # that use "needs to adhere to this license from the Danish Parliament",
62
+ # and those terms have not been reviewed. Remove this entry once they have.
63
+ "saattrupdan/wav2vec2-xls-r-300m-ftspeech":
64
+ "licence is 'other' — refers to unreviewed Danish Parliament terms",
65
+ }
66
+
67
+
68
+ def _default_align_model_for(language_code: str) -> Optional[str]:
69
+ """The model id whisperx would resolve for this language, without loading it.
70
+
71
+ Mirrors load_align_model's own lookup order. Returns None for a language
72
+ whisperx has no default for, which it treats as an error anyway.
73
+ """
74
+ from whisperx.alignment import DEFAULT_ALIGN_MODELS_HF, DEFAULT_ALIGN_MODELS_TORCH
75
+
76
+ # The TORCH table holds torchaudio bundle names, which ship with torchaudio
77
+ # under BSD-2-Clause rather than being downloaded from Hugging Face.
78
+ if language_code in DEFAULT_ALIGN_MODELS_TORCH:
79
+ return DEFAULT_ALIGN_MODELS_TORCH[language_code]
80
+ return DEFAULT_ALIGN_MODELS_HF.get(language_code)
81
+
82
+
38
83
  def detect_device(requested: str = "auto") -> str:
39
84
  if requested != "auto":
40
85
  return requested
@@ -72,7 +117,9 @@ class TranscriptionPipeline:
72
117
  self.diarize_model = None
73
118
  self.diarization_enabled = False
74
119
  self.diarization_disabled_reason: str = "not attempted"
75
- self.align_models: dict[str, tuple] = {}
120
+ self.align_models: dict[str, Optional[tuple]] = {}
121
+ # language_code -> why alignment was skipped, for observability
122
+ self.align_skipped_reasons: dict[str, str] = {}
76
123
 
77
124
  def load(self) -> None:
78
125
  # whisperx doesn't ship MPS support; run whisper on CPU when DEVICE=mps
@@ -122,12 +169,49 @@ class TranscriptionPipeline:
122
169
  print(f"[pipeline] Failed to load pyannote ({self.diarization_disabled_reason}); diarization disabled", flush=True)
123
170
 
124
171
  def _get_align_model(self, language_code: str):
125
- if language_code not in self.align_models:
126
- device = "cpu" if self.device == "mps" else self.device
127
- print(f"[pipeline] Loading align model for {language_code}", flush=True)
128
- self.align_models[language_code] = whisperx.load_align_model(
129
- language_code=language_code, device=device
172
+ """Load the forced-alignment model for a language, or None if blocked.
173
+
174
+ Returns None when the model whisperx would use is not licence-cleared
175
+ and no replacement is configured. The caller skips alignment in that
176
+ case; nothing is downloaded, because the check runs before the load.
177
+ """
178
+ if language_code in self.align_models:
179
+ return self.align_models[language_code]
180
+
181
+ device = "cpu" if self.device == "mps" else self.device
182
+
183
+ # An operator-supplied replacement wins over whisperx's default, so a
184
+ # deployment that has licensed a model for one of the blocked languages
185
+ # can use it without patching this file.
186
+ override = os.getenv(f"EXULU_ALIGN_MODEL_{language_code.upper()}") or None
187
+ model_name = override or _default_align_model_for(language_code)
188
+
189
+ if override:
190
+ print(
191
+ f"[pipeline] Align model for {language_code} overridden by "
192
+ f"EXULU_ALIGN_MODEL_{language_code.upper()}={override}",
193
+ flush=True,
194
+ )
195
+ elif model_name in RESTRICTED_ALIGN_MODELS:
196
+ reason = RESTRICTED_ALIGN_MODELS[model_name]
197
+ self.align_skipped_reasons[language_code] = reason
198
+ print(
199
+ f"[pipeline] WARNING: word-level alignment skipped for "
200
+ f"'{language_code}'. Its default model '{model_name}' is not "
201
+ f"licence-cleared ({reason}) and was neither downloaded nor used. "
202
+ f"The transcript, segment timings and speaker labels are "
203
+ f"unaffected; only word-level timing precision is lost. Set "
204
+ f"EXULU_ALIGN_MODEL_{language_code.upper()} to a licensed model "
205
+ f"to re-enable alignment for this language.",
206
+ flush=True,
130
207
  )
208
+ self.align_models[language_code] = None
209
+ return None
210
+
211
+ print(f"[pipeline] Loading align model for {language_code}", flush=True)
212
+ self.align_models[language_code] = whisperx.load_align_model(
213
+ language_code=language_code, device=device, model_name=model_name
214
+ )
131
215
  return self.align_models[language_code]
132
216
 
133
217
  def transcribe(
@@ -170,15 +254,25 @@ class TranscriptionPipeline:
170
254
 
171
255
  language = transcribe_result["language"]
172
256
  align_device = "cpu" if self.device == "mps" else self.device
173
- model_a, metadata = self._get_align_model(language)
174
- aligned = whisperx.align(
175
- transcribe_result["segments"],
176
- model_a,
177
- metadata,
178
- audio,
179
- align_device,
180
- return_char_alignments=False,
181
- )
257
+ align_bundle = self._get_align_model(language)
258
+ if align_bundle is None:
259
+ # Alignment blocked for this language (see RESTRICTED_ALIGN_MODELS).
260
+ # Whisper's own segments already carry start/end/text, and
261
+ # assign_word_speakers accepts an unaligned TranscriptionResult —
262
+ # it assigns speakers per segment and only walks words when a
263
+ # segment has them. So the transcript degrades to segment-level
264
+ # timing rather than failing.
265
+ aligned = transcribe_result
266
+ else:
267
+ model_a, metadata = align_bundle
268
+ aligned = whisperx.align(
269
+ transcribe_result["segments"],
270
+ model_a,
271
+ metadata,
272
+ audio,
273
+ align_device,
274
+ return_char_alignments=False,
275
+ )
182
276
 
183
277
  if is_cancelled():
184
278
  raise CancelledError()
@@ -0,0 +1,184 @@
1
+ """Tests for the forced-alignment licence guard in pipeline.py.
2
+
3
+ The guard exists so that an ordinary upload in one of a handful of languages
4
+ cannot pull a non-commercial or unlicensed wav2vec2 model onto the server.
5
+ These tests assert the two things that matter: the blocked models are never
6
+ loaded (so never downloaded), and a blocked language still produces a usable
7
+ transcript.
8
+
9
+ Run from the repo root with the venv active:
10
+ cd ee/python/transcription && ../.venv/bin/python -m pytest tests
11
+ """
12
+
13
+ import sys
14
+ from pathlib import Path
15
+ from unittest.mock import patch
16
+
17
+ import pytest
18
+
19
+ # Make ee/python/transcription importable.
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
21
+
22
+ import pipeline as pl # noqa: E402
23
+
24
+
25
+ def _pipeline():
26
+ p = pl.TranscriptionPipeline("large-v3", "cpu", 4)
27
+ return p
28
+
29
+
30
+ # --- the block list itself -------------------------------------------------
31
+
32
+
33
+ def test_restricted_models_match_whisperx_defaults():
34
+ """Every blocked id must actually be a model whisperx would resolve.
35
+
36
+ Guards against a typo or an upstream rename silently disarming the guard.
37
+ """
38
+ from whisperx.alignment import DEFAULT_ALIGN_MODELS_HF
39
+
40
+ defaults = set(DEFAULT_ALIGN_MODELS_HF.values())
41
+ for model_id in pl.RESTRICTED_ALIGN_MODELS:
42
+ assert model_id in defaults, (
43
+ f"{model_id} is blocked but is no longer a whisperx default — "
44
+ "the upstream table changed and the guard may be stale"
45
+ )
46
+
47
+
48
+ @pytest.mark.parametrize(
49
+ "language,expected_model",
50
+ [
51
+ ("vi", "nguyenvulebinh/wav2vec2-base-vi"),
52
+ ("hr", "classla/wav2vec2-xls-r-parlaspeech-hr"),
53
+ ("he", "imvladikon/wav2vec2-xls-r-300m-hebrew"),
54
+ ("hi", "theainerd/Wav2Vec2-large-xlsr-hindi"),
55
+ ("da", "saattrupdan/wav2vec2-xls-r-300m-ftspeech"),
56
+ ],
57
+ )
58
+ def test_restricted_languages_resolve_to_blocked_models(language, expected_model):
59
+ assert pl._default_align_model_for(language) == expected_model
60
+ assert expected_model in pl.RESTRICTED_ALIGN_MODELS
61
+
62
+
63
+ # --- the guard -------------------------------------------------------------
64
+
65
+
66
+ @pytest.mark.parametrize("language", ["vi", "hr", "he", "hi", "da"])
67
+ def test_blocked_language_never_loads_a_model(language):
68
+ """The decisive test: load_align_model must not be called at all.
69
+
70
+ load_align_model is what reaches Hugging Face, so not calling it means the
71
+ weights are neither downloaded nor used.
72
+ """
73
+ p = _pipeline()
74
+ with patch.object(pl.whisperx, "load_align_model") as load:
75
+ assert p._get_align_model(language) is None
76
+ load.assert_not_called()
77
+
78
+
79
+ @pytest.mark.parametrize("language", ["vi", "hr", "he", "hi", "da"])
80
+ def test_blocked_language_records_and_logs_a_reason(language, capsys):
81
+ p = _pipeline()
82
+ with patch.object(pl.whisperx, "load_align_model"):
83
+ p._get_align_model(language)
84
+
85
+ assert language in p.align_skipped_reasons
86
+ out = capsys.readouterr().out
87
+ assert "WARNING" in out
88
+ assert "not licence-cleared" in out
89
+ assert pl.RESTRICTED_ALIGN_MODELS[pl._default_align_model_for(language)] in out
90
+
91
+
92
+ def test_blocked_result_is_cached_so_the_warning_is_not_repeated(capsys):
93
+ p = _pipeline()
94
+ with patch.object(pl.whisperx, "load_align_model") as load:
95
+ p._get_align_model("vi")
96
+ first = capsys.readouterr().out
97
+ p._get_align_model("vi")
98
+ second = capsys.readouterr().out
99
+ load.assert_not_called()
100
+
101
+ assert "WARNING" in first
102
+ assert second.strip() == ""
103
+
104
+
105
+ # --- languages that are fine ------------------------------------------------
106
+
107
+
108
+ @pytest.mark.parametrize("language", ["en", "de", "nl", "fr", "es"])
109
+ def test_permitted_language_still_loads_normally(language):
110
+ p = _pipeline()
111
+ with patch.object(pl.whisperx, "load_align_model", return_value=("model", "meta")) as load:
112
+ assert p._get_align_model(language) == ("model", "meta")
113
+ load.assert_called_once()
114
+ assert load.call_args.kwargs["language_code"] == language
115
+
116
+ assert p.align_skipped_reasons == {}
117
+
118
+
119
+ def test_permitted_language_is_cached():
120
+ p = _pipeline()
121
+ with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
122
+ p._get_align_model("en")
123
+ p._get_align_model("en")
124
+ load.assert_called_once()
125
+
126
+
127
+ # --- operator override ------------------------------------------------------
128
+
129
+
130
+ def test_override_re_enables_a_blocked_language(monkeypatch):
131
+ monkeypatch.setenv("EXULU_ALIGN_MODEL_VI", "my-org/licensed-vi-aligner")
132
+ p = _pipeline()
133
+ with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
134
+ assert p._get_align_model("vi") == ("m", "meta")
135
+ assert load.call_args.kwargs["model_name"] == "my-org/licensed-vi-aligner"
136
+
137
+ assert p.align_skipped_reasons == {}
138
+
139
+
140
+ def test_override_applies_to_a_permitted_language_too(monkeypatch):
141
+ monkeypatch.setenv("EXULU_ALIGN_MODEL_EN", "my-org/custom-en")
142
+ p = _pipeline()
143
+ with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
144
+ p._get_align_model("en")
145
+ assert load.call_args.kwargs["model_name"] == "my-org/custom-en"
146
+
147
+
148
+ def test_no_override_passes_the_whisperx_default_through(monkeypatch):
149
+ monkeypatch.delenv("EXULU_ALIGN_MODEL_EN", raising=False)
150
+ p = _pipeline()
151
+ with patch.object(pl.whisperx, "load_align_model", return_value=("m", "meta")) as load:
152
+ p._get_align_model("en")
153
+ assert load.call_args.kwargs["model_name"] == pl._default_align_model_for("en")
154
+
155
+
156
+ # --- graceful degradation ---------------------------------------------------
157
+
158
+
159
+ def test_unaligned_segments_carry_everything_the_output_needs():
160
+ """transcribe() reads start/end/text/speaker; Whisper's own segments have
161
+ the first three, so skipping alignment cannot break the response shape."""
162
+ unaligned = {
163
+ "segments": [{"start": 0.0, "end": 1.5, "text": "xin chao"}],
164
+ "language": "vi",
165
+ }
166
+ for seg in unaligned["segments"]:
167
+ assert "start" in seg and "end" in seg and "text" in seg
168
+
169
+
170
+ def test_assign_word_speakers_accepts_unaligned_input():
171
+ """The diarization step must tolerate segments that have no 'words' key,
172
+ otherwise a blocked language would crash instead of degrading."""
173
+ import pandas as pd
174
+ from whisperx.diarize import assign_word_speakers
175
+
176
+ diarize_df = pd.DataFrame(
177
+ [{"start": 0.0, "end": 2.0, "speaker": "SPEAKER_00", "segment": None, "label": "a"}]
178
+ )
179
+ unaligned = {"segments": [{"start": 0.0, "end": 1.5, "text": "xin chao"}]}
180
+
181
+ result = assign_word_speakers(diarize_df, unaligned)
182
+
183
+ assert result["segments"][0]["speaker"] == "SPEAKER_00"
184
+ assert result["segments"][0]["text"] == "xin chao"
package/ee/workers.ts CHANGED
@@ -4,6 +4,7 @@ import { guardRedisStartup, logRedisErrors } from "@EE/queues/redis-startup.ts";
4
4
  import { Job, Worker, type JobState } from "bullmq";
5
5
  import { bullmq } from "@SRC/validators/bullmq.ts";
6
6
  import { serializeError } from "@SRC/utils/serialize-error.ts";
7
+ import { finishTurnMetadata } from "@SRC/exulu/turn-metadata.ts";
7
8
  import { getEnabledTools } from "@SRC/utils/enabled-tools.ts";
8
9
  import { ExuluStorage } from "@SRC/exulu/storage.ts";
9
10
  import type { ExuluAgent } from "@EXULU_TYPES/models/agent.ts";
@@ -1702,13 +1703,7 @@ export const processUiMessagesFlow = async ({
1702
1703
  messageMetadata: ({ part }) => {
1703
1704
  console.log("[EXULU] part", part.type);
1704
1705
  if (part.type === "finish") {
1705
- return {
1706
- totalTokens: part.totalUsage.totalTokens,
1707
- reasoningTokens: part.totalUsage.reasoningTokens,
1708
- inputTokens: part.totalUsage.inputTokens,
1709
- outputTokens: part.totalUsage.outputTokens,
1710
- cachedInputTokens: part.totalUsage.cachedInputTokens,
1711
- };
1706
+ return finishTurnMetadata({ totalUsage: part.totalUsage, startedAt: startTime });
1712
1707
  }
1713
1708
  return undefined;
1714
1709
  },
package/license.md CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2023-2026 Qventu B.v.
1
+ Copyright (c) 2023-2026 Qventu B.V.
2
2
 
3
3
  Certain portions of this software are licensed as described below:
4
4
 
@@ -102,4 +102,4 @@ these terms.
102
102
 
103
103
  **use** means anything you do with the software requiring one of your licenses.
104
104
 
105
- **trademark** means trademarks, service marks, and similar rights.
105
+ **trademark** means trademarks, service marks, and similar rights.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@exulu/backend",
3
- "author": "Qventu Bv.",
4
- "version": "3.7.4",
3
+ "author": "Qventu B.V.",
4
+ "version": "4.0.0",
5
5
  "main": "./dist/index.js",
6
6
  "private": false,
7
7
  "publishConfig": {
@@ -48,7 +48,7 @@
48
48
  "start:whisper": "node ./dist/cli/start-whisper.cjs",
49
49
  "python:setup": "cd ee/python && ./setup.sh",
50
50
  "python:install": "source ee/python/.venv/bin/activate && pip install -r ee/python/requirements.txt",
51
- "python:validate": "source ee/python/.venv/bin/activate && python -c 'import docling; import transformers; print(\"✓ Python environment is working correctly\")'",
51
+ "python:validate": "source ee/python/.venv/bin/activate && python -c 'import pypdf; import transformers; print(\"✓ Python environment is working correctly\")'",
52
52
  "python:clean": "rm -rf ee/python/.venv ee/python/__pycache__ ee/python/**/__pycache__ ee/python/**/*.pyc",
53
53
  "python:rebuild": "npm run python:clean && npm run python:setup"
54
54
  },
@@ -143,7 +143,6 @@
143
143
  "jose": "^6.0.10",
144
144
  "json-schema-to-zod": "^2.6.1",
145
145
  "jsonwebtoken": "^9.0.2",
146
- "just-bash": "^2.14.0",
147
146
  "knex": "^3.1.0",
148
147
  "link": "^2.1.1",
149
148
  "mailparser": "^3.9.14",
@@ -8,7 +8,7 @@
8
8
 
9
9
  const { exec } = require('child_process');
10
10
  const { promisify } = require('util');
11
- const { existsSync } = require('fs');
11
+ const { existsSync, rmSync } = require('fs');
12
12
  const { resolve, join } = require('path');
13
13
 
14
14
  const execAsync = promisify(exec);
@@ -82,6 +82,53 @@ async function setupPythonEnvironment() {
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * Remove node-liblzma (LGPL-3.0) from the installed tree.
87
+ *
88
+ * It arrives as an OPTIONAL dependency of just-bash, which reaches us through
89
+ * bash-tool. just-bash is the only dependent. It is loaded lazily, by a dynamic
90
+ * import inside a try/catch, and only when a shell command uses xz compression;
91
+ * with it absent that path throws its own "xz compression requires
92
+ * node-liblzma" error and every other command is unaffected.
93
+ *
94
+ * Why not `npm install --omit=optional`: that flag is all-or-nothing, and 24 of
95
+ * the 91 runtime optional packages are the `@img/sharp-*` platform binaries.
96
+ * Omitting them leaves sharp unable to load, which breaks document processing.
97
+ * So the removal has to be targeted at this one package.
98
+ *
99
+ * Set EXULU_KEEP_NODE_LIBLZMA=true to keep it — for example if you want xz
100
+ * support and have satisfied yourself about LGPL-3.0 in your deployment.
101
+ *
102
+ * Best-effort by design: it walks up from this package looking for the hoisted
103
+ * copy, and silently does nothing if the layout differs (pnpm, yarn PnP) or the
104
+ * directory is not writable. Never fails the install.
105
+ */
106
+ function removeNodeLiblzma() {
107
+ if (String(process.env.EXULU_KEEP_NODE_LIBLZMA).toLowerCase() === 'true') {
108
+ console.log(`${colors.yellow}⊘${colors.reset} Keeping node-liblzma (EXULU_KEEP_NODE_LIBLZMA=true) — LGPL-3.0`);
109
+ return;
110
+ }
111
+
112
+ // Walk up from this package looking for a hoisted node_modules/node-liblzma.
113
+ let dir = resolve(__dirname, '..');
114
+ for (let depth = 0; depth < 6; depth++) {
115
+ const candidate = join(dir, 'node_modules', 'node-liblzma');
116
+ if (existsSync(candidate)) {
117
+ try {
118
+ rmSync(candidate, { recursive: true, force: true });
119
+ console.log(`${colors.green}✓${colors.reset} Removed node-liblzma (LGPL-3.0; optional, xz compression only)`);
120
+ } catch (err) {
121
+ console.log(`${colors.yellow}!${colors.reset} Could not remove node-liblzma (LGPL-3.0): ${err.message}`);
122
+ console.log(' Remove it manually, or set EXULU_KEEP_NODE_LIBLZMA=true to keep it deliberately.');
123
+ }
124
+ return;
125
+ }
126
+ const parent = resolve(dir, '..');
127
+ if (parent === dir) break;
128
+ dir = parent;
129
+ }
130
+ }
131
+
85
132
  /**
86
133
  * Main postinstall function
87
134
  */
@@ -92,6 +139,10 @@ async function main() {
92
139
  console.log(`${colors.blue}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
93
140
  console.log('');
94
141
 
142
+ // Runs before the Python setup and before any early return below, so it
143
+ // happens even when SKIP_PYTHON_SETUP=1 or the venv already exists.
144
+ removeNodeLiblzma();
145
+
95
146
  // Check if we should skip setup
96
147
  if (shouldSkipSetup()) {
97
148
  console.log(`${colors.yellow}⊘${colors.reset} Skipping Python setup (SKIP_PYTHON_SETUP=1)`);