sygal-cli 0.5.5 → 0.5.7

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 (3) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.js +141 -76
  3. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -9,6 +9,26 @@ partagent toujours le même numéro de version, publiés ensemble, même si un
9
9
  seul a réellement changé de contenu — même principe que l'app desktop.
10
10
  Détails du process : `CLI_RELEASE_GUIDE.md` à la racine du repo.
11
11
 
12
+ ## 0.5.6
13
+
14
+ - Fix critique : le sidecar `@sygal/sidecar-darwin-arm64` était compilé depuis un Python
15
+ installé via Homebrew, dont la bottle cible `MACOSX_DEPLOYMENT_TARGET=26` — le binaire
16
+ plantait au démarrage (`dlopen ... Symbol not found`) sur toute machine tournant sous
17
+ macOS antérieur à la version 26, malgré la compatibilité macOS 12+ annoncée. Rebuild
18
+ effectué depuis un Python installé via python.org (cible de déploiement basse), sans
19
+ changement de code source.
20
+
21
+ ## 0.5.5
22
+
23
+ - Fix : exporter un document dont le nom contient des caractères illégaux sur Windows/macOS
24
+ (`/`, `:`, etc.) — fréquent dans les titres de pages web importées — ne plante plus en
25
+ `ENOENT`. Les noms trop longs (> 255 octets UTF-8, ex. titres avec emojis) sont désormais
26
+ tronqués sur une frontière de code point plutôt que de planter en `ENAMETOOLONG`.
27
+ - Fix : convertir une URL dont le fetch réussit (200) mais dont le contenu n'est pas
28
+ exploitable (site protégé par authwall/anti-bot JS-only, ex. LinkedIn) remonte désormais
29
+ le code d'erreur `url_empty_content` en mode `--json` au lieu de produire un fichier vide
30
+ avec un statut de succès.
31
+
12
32
  ## 0.5.3
13
33
 
14
34
  - Aucun changement fonctionnel côté CLI/sidecars : bump lockstep pour rester
package/dist/index.js CHANGED
@@ -102,42 +102,38 @@ function needsPngNormalization(ext) {
102
102
  async function toPngBuffer(input) {
103
103
  return (0, import_sharp.default)(input).png().toBuffer();
104
104
  }
105
- async function shrinkToMaxBytes(input, maxBytes) {
106
- const QUALITY = 80;
107
- let output = await (0, import_sharp.default)(input).jpeg({ quality: QUALITY }).toBuffer();
108
- if (output.length <= maxBytes) return output;
109
- const meta = await (0, import_sharp.default)(input).metadata();
110
- let width = meta.width ?? 1600;
111
- while (output.length > maxBytes && width > 200) {
112
- width = Math.round(width * 0.75);
113
- output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality: QUALITY }).toBuffer();
114
- }
115
- let quality = QUALITY;
116
- while (output.length > maxBytes && quality > 20) {
117
- quality -= 15;
118
- output = await (0, import_sharp.default)(input).resize({ width }).jpeg({ quality }).toBuffer();
119
- }
120
- return output;
121
- }
122
105
 
123
106
  // ../core/src/ocr-models.ts
124
107
  var OCR_MODELS = {
125
- openai: "gpt-4o",
108
+ // Bascule 02/09/2026 : gpt-4o retiré de l'API OpenAI le 16/02/2026 (recherche
109
+ // en direct — mort depuis 7 mois, provider par défaut de Sygal). Remplacé par
110
+ // gpt-5.6-terra (famille GPT-5.6, GA depuis 07/2026 : sol/terra/luna) — terra
111
+ // choisi pour l'équilibre coût/qualité sur de la lecture de document/écran,
112
+ // pas le tier flagship (sol, plus cher) ni économique (luna). Modèle
113
+ // "raisonneur" : voir openai-provider.ts pour les paramètres API affectés
114
+ // (max_tokens, temperature). Qualité réelle non validée sur cas réels
115
+ // (contrainte budget API de Cyrill) — à confirmer dès que possible.
116
+ openai: "gpt-5.6-terra",
126
117
  mistral: "mistral-ocr-latest",
127
118
  claude: "claude-sonnet-4-6",
128
- // Bascule 27/07/2026 (banc comparatif sur images réelles, validation Cyrill) :
129
- // nemotron-nano-12b-v2-vl remplace llama-3.1-nemotron-nano-vl-8b-v1. Sur une
130
- // capture de dossier de 22 lignes, le 8b s'arrêtait au premier en-tête (« Nom »,
131
- // 1 token) 3 fois sur 3 ; le 12b la transcrit intégralement 3 fois sur 3, accents
132
- // préservés. Il respecte aussi la consigne de langue sur une image sans texte
133
- // (description en français, là où le 8b répondait en anglais), sans confabuler.
134
- // Entraîné sur DocLayNet / PubTables-1M / PDF CommonCrawl — OCRBench 85,6.
135
- // Précédemment : llama-3.2-11b-vision-instruct, écarté le 18/07/2026 (5/5
136
- // confabulations sur un portrait sans texte).
137
- nvidia: "nvidia/nemotron-nano-12b-v2-vl",
138
- gemini: "gemini-2.0-flash"
119
+ // Bascule 02/09/2026 : nemotron-nano-12b-v2-vl (et toute la lignée nemotron
120
+ // vision-chat, nano-vl-8b-v1 y compris) retirée par NVIDIA — HTTP 410 "end
121
+ // of life" confirmé en direct (26/08/2026) sur les deux modèles. Remplacé
122
+ // par nemotron-ocr-v2 : modèle OCR dédié (pas un chat vision-langage), API
123
+ // /v1/cv séparée — voir nvidia-provider.ts. Détection de texte fiable
124
+ // (pas d'hallucination possible, contrairement aux chat vision), mais pas
125
+ // de description d'image ni de traduction : NVIDIA_FALLBACK_MODEL
126
+ // (llama-3.2-90b-vision-instruct) prend le relais pour ces deux cas.
127
+ nvidia: "nvidia/nemotron-ocr-v2",
128
+ // Bascule 02/09/2026 : gemini-2.0-flash coupé par Google le 01/06/2026
129
+ // (recherche en direct — mort depuis 3 mois). Remplacé par gemini-3.7-flash
130
+ // (GA depuis 08/2026) — un seul choix clair côté Google à cette date, pas
131
+ // d'arbitrage de tier comme pour OpenAI. Qualité réelle non validée sur cas
132
+ // réels (contrainte budget API de Cyrill) — à confirmer dès que possible.
133
+ gemini: "gemini-3.7-flash"
139
134
  };
140
135
  var MISTRAL_TRANSLATE_MODEL = "mistral-large-latest";
136
+ var NVIDIA_FALLBACK_MODEL = "meta/llama-3.2-90b-vision-instruct";
141
137
 
142
138
  // ../core/src/sidecar/manager.ts
143
139
  var import_node_child_process = require("node:child_process");
@@ -460,7 +456,7 @@ function httpStatusToOcrError(status, providerId) {
460
456
  function toDataUrl(img, mime) {
461
457
  return `data:${mime};base64,${img.toString("base64")}`;
462
458
  }
463
- async function callOpenAiCompatibleChatText(endpoint, model, apiKey, signal, prompt, text, providerId, maxTokens = 8192, frequencyPenalty) {
459
+ async function callOpenAiCompatibleChatText(endpoint, model, apiKey, signal, prompt, text, providerId, maxTokens = 8192, frequencyPenalty, reasoningModel = false) {
464
460
  const response = await fetch(endpoint, {
465
461
  method: "POST",
466
462
  signal,
@@ -470,9 +466,10 @@ async function callOpenAiCompatibleChatText(endpoint, model, apiKey, signal, pro
470
466
  },
471
467
  body: JSON.stringify({
472
468
  model,
473
- max_tokens: maxTokens,
474
- // Traduction = tâche déterministe : toute créativité est un bug ici.
475
- temperature: 0,
469
+ [reasoningModel ? "max_completion_tokens" : "max_tokens"]: maxTokens,
470
+ // Traduction = tâche déterministe : toute créativité est un bug ici
471
+ // (paramètre rejeté par les modèles raisonneurs, voir reasoningModel).
472
+ ...reasoningModel ? {} : { temperature: 0 },
476
473
  ...frequencyPenalty !== void 0 ? { frequency_penalty: frequencyPenalty } : {},
477
474
  // Consigne et document dans deux messages distincts : concaténés, un
478
475
  // document court était traité comme une simple amorce et le modèle
@@ -724,63 +721,126 @@ function convertLatexTables(markdown) {
724
721
  }
725
722
 
726
723
  // ../core/src/ocr/nvidia-provider.ts
724
+ var import_sharp2 = __toESM(require("sharp"));
727
725
  var NVIDIA_OCR_MODEL = OCR_MODELS.nvidia;
728
- var ENDPOINT4 = "https://integrate.api.nvidia.com/v1/chat/completions";
729
- var NVIDIA_MAX_IMAGE_BYTES = 17e4;
726
+ var OCR_ENDPOINT = "https://ai.api.nvidia.com/v1/cv/nvidia/nemotron-ocr-v2";
727
+ var CHAT_ENDPOINT2 = "https://integrate.api.nvidia.com/v1/chat/completions";
728
+ function median(nums) {
729
+ if (nums.length === 0) return 0;
730
+ const sorted = [...nums].sort((a, b) => a - b);
731
+ const mid = Math.floor(sorted.length / 2);
732
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
733
+ }
734
+ function groupLines(blocks) {
735
+ const lines = [];
736
+ for (const block of [...blocks].sort((a, b) => a.top - b.top)) {
737
+ const current = lines[lines.length - 1];
738
+ if (current) {
739
+ const envTop = Math.min(...current.map((b) => b.top));
740
+ const envBottom = Math.max(...current.map((b) => b.bottom));
741
+ const maxHeight = Math.max(...current.map((b) => b.height), block.height);
742
+ const overlaps = block.top < envBottom && block.bottom > envTop;
743
+ const newHeight = Math.max(envBottom, block.bottom) - Math.min(envTop, block.top);
744
+ if (overlaps && newHeight <= maxHeight * 1.6) {
745
+ current.push(block);
746
+ continue;
747
+ }
748
+ }
749
+ lines.push([block]);
750
+ }
751
+ return lines;
752
+ }
753
+ var BULLET_RE = /^[•▪◦○*·-]\s+/;
754
+ function reconstructReadingOrder(detections) {
755
+ const blocks = detections.map((d) => {
756
+ const text = d.text_prediction?.text?.trim();
757
+ const points = d.bounding_box?.points;
758
+ if (!text || !points || points.length === 0) return null;
759
+ const ys = points.map((p) => p.y);
760
+ const xs = points.map((p) => p.x);
761
+ const top = Math.min(...ys);
762
+ const bottom = Math.max(...ys);
763
+ return { text, top, bottom, left: Math.min(...xs), height: bottom - top };
764
+ }).filter((b) => b !== null);
765
+ const lines = groupLines(blocks);
766
+ const typicalHeight = median(lines.map((line) => median(line.map((b) => b.height)))) || 0.02;
767
+ const out = [];
768
+ let previousBottom = null;
769
+ for (const line of lines) {
770
+ const top = Math.min(...line.map((b) => b.top));
771
+ const bottom = Math.max(...line.map((b) => b.bottom));
772
+ const height = median(line.map((b) => b.height));
773
+ if (previousBottom !== null && top - previousBottom > typicalHeight * 0.9) out.push("");
774
+ previousBottom = bottom;
775
+ let text = [...line].sort((a, b) => a.left - b.left).map((b) => b.text).join(" ");
776
+ const bullet = text.match(BULLET_RE);
777
+ if (bullet) text = `- ${text.slice(bullet[0].length)}`;
778
+ else if (line.length === 1 && height > typicalHeight * 1.25 && text.split(" ").length <= 8) {
779
+ text = `## ${text}`;
780
+ }
781
+ out.push(text);
782
+ }
783
+ return out.join("\n");
784
+ }
785
+ async function describeOrTranslateFallback(img, mime, apiKey, signal, prompt) {
786
+ const content = [{ type: "text", text: prompt }];
787
+ if (img && mime) {
788
+ content.push({ type: "image_url", image_url: { url: toDataUrl(img, mime) } });
789
+ }
790
+ const response = await fetch(CHAT_ENDPOINT2, {
791
+ method: "POST",
792
+ signal,
793
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
794
+ body: JSON.stringify({
795
+ model: NVIDIA_FALLBACK_MODEL,
796
+ max_tokens: 4096,
797
+ frequency_penalty: 0.4,
798
+ temperature: 0,
799
+ messages: [{ role: "user", content }]
800
+ })
801
+ });
802
+ if (!response.ok) throw httpStatusToOcrError(response.status, "nvidia");
803
+ const payload = await response.json();
804
+ const markdown = payload.choices?.[0]?.message?.content;
805
+ if (typeof markdown !== "string" || markdown.length === 0) {
806
+ throw new OcrError("UNKNOWN", "nvidia : r\xE9ponse sans contenu");
807
+ }
808
+ return { markdown, raw: payload };
809
+ }
730
810
  var nvidiaProvider = {
731
811
  id: "nvidia",
732
812
  model: NVIDIA_OCR_MODEL,
733
813
  async convertImage(img, mime, apiKey, signal, prompt) {
814
+ if (!prompt.includes(NO_TEXT_SENTINEL)) {
815
+ return describeOrTranslateFallback(img, mime, apiKey, signal, prompt);
816
+ }
734
817
  let payloadImg = img;
735
818
  let payloadMime = mime;
736
- if (payloadMime === "image/webp" || payloadImg.length > NVIDIA_MAX_IMAGE_BYTES) {
737
- payloadImg = await shrinkToMaxBytes(payloadImg, NVIDIA_MAX_IMAGE_BYTES);
819
+ if (payloadMime !== "image/png" && payloadMime !== "image/jpeg") {
820
+ payloadImg = await (0, import_sharp2.default)(img).jpeg({ quality: 90 }).toBuffer();
738
821
  payloadMime = "image/jpeg";
739
822
  }
740
- const response = await fetch(ENDPOINT4, {
823
+ const response = await fetch(OCR_ENDPOINT, {
741
824
  method: "POST",
742
825
  signal,
743
- headers: {
744
- "Content-Type": "application/json",
745
- Authorization: `Bearer ${apiKey}`
746
- },
826
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
747
827
  body: JSON.stringify({
748
- model: NVIDIA_OCR_MODEL,
749
- max_tokens: 4096,
750
- // Llama vision peut dégénérer en boucle de répétition sans pénalité
751
- // (observé en réel : même phrase répétée ~200 fois jusqu'à max_tokens).
752
- frequency_penalty: 0.4,
753
- // Petit modèle (11B) : sans température 0 ni cadrage renforcé, il "interprète"
754
- // (résumé en anglais observé en réel 12/07/2026 au lieu d'une transcription).
755
- // ATTENTION : pas de message system — le endpoint NIM llama vision rejette
756
- // les requêtes system + image (constaté en réel 12/07/2026, appel en échec).
757
- temperature: 0,
758
- messages: [
759
- {
760
- role: "user",
761
- content: [
762
- {
763
- type: "text",
764
- text: "You are an OCR engine. You copy text from images character by character. You never summarize, never translate, never comment, never add steps or headings of your own. You only describe the image in the single case where it contains no readable text at all, exactly as instructed below.\n\n" + prompt
765
- },
766
- { type: "image_url", image_url: { url: toDataUrl(payloadImg, payloadMime) } }
767
- ]
768
- }
769
- ]
828
+ input: [{ type: "image_url", url: toDataUrl(payloadImg, payloadMime) }],
829
+ merge_levels: ["paragraph"]
770
830
  })
771
831
  });
772
832
  if (!response.ok) throw httpStatusToOcrError(response.status, "nvidia");
773
833
  const payload = await response.json();
774
- const markdown = payload.choices?.[0]?.message?.content;
775
- if (typeof markdown !== "string" || markdown.length === 0) {
776
- throw new OcrError("UNKNOWN", "nvidia : r\xE9ponse sans contenu");
834
+ const detections = payload.data?.[0]?.text_detections ?? [];
835
+ if (detections.length === 0) {
836
+ return { markdown: NO_TEXT_SENTINEL, raw: payload };
777
837
  }
778
- return { markdown, raw: payload };
838
+ return { markdown: reconstructReadingOrder(detections), raw: payload };
779
839
  },
780
840
  translateText(text, apiKey, signal, prompt) {
781
841
  return callOpenAiCompatibleChatText(
782
- ENDPOINT4,
783
- NVIDIA_OCR_MODEL,
842
+ CHAT_ENDPOINT2,
843
+ NVIDIA_FALLBACK_MODEL,
784
844
  apiKey,
785
845
  signal,
786
846
  prompt,
@@ -788,19 +848,19 @@ var nvidiaProvider = {
788
848
  "nvidia",
789
849
  8192,
790
850
  0.4
791
- // même garde-fou anti-répétition que convertImage ci-dessus
851
+ // même garde-fou anti-répétition qu'en OCR (voir callOpenAiCompatibleChatText)
792
852
  );
793
853
  }
794
854
  };
795
855
 
796
856
  // ../core/src/ocr/openai-provider.ts
797
857
  var OPENAI_OCR_MODEL = OCR_MODELS.openai;
798
- var ENDPOINT5 = "https://api.openai.com/v1/chat/completions";
858
+ var ENDPOINT4 = "https://api.openai.com/v1/chat/completions";
799
859
  var openAiProvider = {
800
860
  id: "openai",
801
861
  model: OPENAI_OCR_MODEL,
802
862
  async convertImage(img, mime, apiKey, signal, prompt) {
803
- const response = await fetch(ENDPOINT5, {
863
+ const response = await fetch(ENDPOINT4, {
804
864
  method: "POST",
805
865
  signal,
806
866
  headers: {
@@ -809,7 +869,8 @@ var openAiProvider = {
809
869
  },
810
870
  body: JSON.stringify({
811
871
  model: OPENAI_OCR_MODEL,
812
- max_tokens: 8192,
872
+ // gpt-5.6 (modèle raisonneur) rejette `max_tokens` — voir en-tête de fichier.
873
+ max_completion_tokens: 8192,
813
874
  messages: [
814
875
  {
815
876
  role: "user",
@@ -831,13 +892,17 @@ var openAiProvider = {
831
892
  },
832
893
  translateText(text, apiKey, signal, prompt) {
833
894
  return callOpenAiCompatibleChatText(
834
- ENDPOINT5,
895
+ ENDPOINT4,
835
896
  OPENAI_OCR_MODEL,
836
897
  apiKey,
837
898
  signal,
838
899
  prompt,
839
900
  text,
840
- "openai"
901
+ "openai",
902
+ 8192,
903
+ void 0,
904
+ true
905
+ // gpt-5.6 : max_completion_tokens + pas de temperature, voir en-tête de fichier
841
906
  );
842
907
  }
843
908
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sygal-cli",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Sygal CLI - convert files and web pages to Markdown/JSON (local MarkItDown pipeline, OCR for images)",
5
5
  "author": "Cyrill Semah",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -37,9 +37,9 @@
37
37
  "@sygal/core": "0.1.0"
38
38
  },
39
39
  "optionalDependencies": {
40
- "@sygal/sidecar-win32-x64": "0.5.5",
41
- "@sygal/sidecar-darwin-arm64": "0.5.5",
42
- "@sygal/sidecar-linux-x64": "0.5.5"
40
+ "@sygal/sidecar-darwin-arm64": "0.5.7",
41
+ "@sygal/sidecar-win32-x64": "0.5.7",
42
+ "@sygal/sidecar-linux-x64": "0.5.7"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --external:sharp --external:gpt-tokenizer --external:commander --external:picocolors",