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.
- package/CHANGELOG.md +20 -0
- package/dist/index.js +141 -76
- 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
|
-
|
|
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
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// (
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
729
|
-
var
|
|
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
|
|
737
|
-
payloadImg = await
|
|
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(
|
|
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
|
-
|
|
749
|
-
|
|
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
|
|
775
|
-
if (
|
|
776
|
-
|
|
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
|
-
|
|
783
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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.
|
|
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-
|
|
41
|
-
"@sygal/sidecar-
|
|
42
|
-
"@sygal/sidecar-linux-x64": "0.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",
|