Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 cf8d1cfaaa Version 0.14.0
Rattrapage d'AJEAN jusqu'à la 0.17.6 (contexte, reprise réseau, presets
externes, tâches ponctuelles, file d'attente) et reprises d'OpenFox pour le
mode Code (vérification quand le builder a fini, pré-vol des écritures,
relance des appels textuels, alias d'outils, consignes du dépôt, terminal).
Les 19 commits locaux de la synchro 0.13.6 → 0.16.3, restés hors de main,
y sont rejoués.

Bump des trois porteurs de version (constante Go, versioninfo.json, .syso
Windows régénérés), notes de release réécrites, NOTICE à jour des idées
reprises d'OpenFox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:41:37 +02:00
MichaelandClaude Opus 5.5 b955181bb9 Mode Code : une écriture interdite est refusée dès son chemin, pas après le fichier
Repris du pré-vol d'OpenFox (tool-preflight, 2.0.154).

Un write sur un fichier existant jamais lu était refusé par le tracker…
une fois tout le fichier généré. builder.md demande des fichiers écrits en
entier : un refus pouvait coûter des minutes de décodage pour rien.

- Les schémas write, edit, mem_add et mem_edit annoncent le chemin AVANT
  le contenu. Une map Go sort ses clés par ordre alphabétique : le modèle,
  qui suit l'ordre du schéma, déroulait tout le contenu avant le chemin.
  L'interface nomme aussi le fichier dès le début de la frappe.
- Dès que le chemin d'un write/edit est complet dans le flux, les gardes du
  mode Code (fichier lu, chemin permis) sont vérifiées. Refus : la
  génération est coupée, l'appel réduit à son chemin entre dans
  l'historique avec le refus pour résultat, et le modèle repart (lire le
  fichier d'abord). Rien n'est écrit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:40:38 +02:00
MichaelandClaude Opus 5.5 15495431fc Mode Code : le compactage garde les fichiers touchés, les erreurs et les critères
Repris du COMPACTION_PROMPT d'OpenFox.

Après un compactage, le builder réécrivait des fichiers déjà faits et
retombait dans des erreurs déjà résolues : le résumé, pensé pour le chat,
ne gardait ni l'un ni l'autre. Et les critères, qui ne vivent pas dans le
fil (seuls les appels à l'outil criteria y passent), disparaissaient avec
le torse résumé.

- En mode Code, le résumé garde aussi chaque fichier créé ou modifié, les
  erreurs rencontrées et leur résolution, et les commandes de build/test.
- L'état des critères est rendu au builder dans le message de résumé — un
  message de toute façon neuf, donc sans cache invalidé en plus.

(Au passage : gofmt sur code_git.go, mal aligné au commit précédent.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:38:07 +02:00
MichaelandClaude Opus 5.5 d5632e3595 Mode Code : les consignes du dépôt (AGENTS.md, CLAUDE.md) font partie du contexte
Repris de context/instructions.ts d'OpenFox.

Un dépôt qui documente ses commandes de build et de test, ses conventions
et ses pièges le fait dans AGENTS.md ou CLAUDE.md. Le modèle les
redécouvrait à coups de read et de bash — quand il les redécouvrait.

En mode Code, le premier de ces fichiers trouvé (dépôt cloné détecté, puis
racine de la discussion) part avec le contexte du projet, tronqué à 4 000
caractères. Comme le reste du contexte projet, il est déplacé dans le
premier message utilisateur : le préfixe système, et le cache de prompt,
restent intacts.

Les tours de correction et de relance du builder reçoivent désormais le
même contexte projet que le tour de build : sans lui, le début du premier
message utilisateur changeait et tout le prompt était recalculé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:36:51 +02:00
MichaelandClaude Opus 5.5 dfafca6fb2 Mode Code : git sur le dépôt cloné, budget de build, sous-agents moins rigides
Trois trous relevés en comparant avec OpenFox.

- git_status / git_diff tournaient à la racine de la discussion, qui
  n'est souvent pas un dépôt : git_clone range le projet dans un
  sous-dossier, et le vérificateur — dont la consigne commence par
  git_diff — tombait sur « not a git repository ». Ils visent maintenant
  le sous-dossier demandé (dir), la racine si c'est un dépôt, sinon
  l'unique dépôt présent ; plusieurs : on demande de choisir.
- Budget d'outils triplé en mode Code : lire, éditer, compiler, relancer
  les tests dépasse vite 24 appels sans tourner en rond, et le troisième
  rappel (« n'appelle plus d'outil ») coupait le build au milieu.
- Sous-agents : température 0.6 au lieu de 0 (en glouton, Qwen avec
  réflexion boucle vite ; le TEMP du preset l'emporte toujours), et seul
  le texte écrit après le dernier outil revient au builder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:36:01 +02:00
MichaelandClaude Opus 5.5 2e0356cd6c Terminal : le vrai code de sortie, la sortie gardée au délai, sans ANSI
Repris de shell.ts / shell-tail.ts et diagnostics.ts d'OpenFox (2.0.160).

- « cmd | tail -N » : le code de sortie devenait celui de tail (0) et un
  build en échec passait pour réussi. Le tail est retiré de la commande et
  Loki garde lui-même les N dernières lignes : même sortie, vrai code.
- Délai dépassé : la sortie déjà produite est rendue (où en était le build,
  quel test bloquait) au lieu d'un « [timeout] » sec, avec le conseil de
  passer par bash_bg pour un serveur.
- Couleurs et séquences ANSI retirées : du bruit en tokens.
- La durée suit le code de sortie (« exit: 0 · 1.2s »).
- Mode Code : « cmd & » refusé, avec renvoi vers bash_bg — le process
  orphelin n'avait ni sortie lisible ni moyen d'être arrêté.
- Diagnostics LSP : erreurs d'abord, avec le compte total ; au-delà de la
  limite, des indices de style passaient devant l'erreur de compilation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:34:42 +02:00
MichaelandClaude Opus 5.5 a745d2399e Agent : read_file, str_replace, old_string… traduits vers les vrais outils
Généralisation du transformSubAgentAliases d'OpenFox. Les petits modèles
ont appris d'autres agents : read_file, str_replace, run_command, ou path /
old_string au lieu de file / old. L'appel tombait sur « outil inconnu »
ou sur un argument manquant, et le tour se perdait en allers-retours.

Le nom et les arguments sont traduits vers l'outil réel quand la cible est
disponible dans ce tour, avant que l'appel soit rangé dans l'historique
(le modèle y relit l'appel tel qu'exécuté). Un sous-agent appelé comme un
outil (« explorer », « code_reviewer ») devient subagent{role, task}. Un
appel déjà correct, ou dont la cible n'est pas offerte, reste intact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:32:29 +02:00
MichaelandClaude Opus 5.5 ad87f2fc9f Agent : un appel d'outil écrit en texte coupe le flux et la relance le cite
Repris d'OpenFox (stream-pure, agent-loop, 2.0.15x).

- Repéré PENDANT le flux (hors réflexion en ligne) : la génération est
  coupée tout de suite au lieu de laisser le modèle dérouler un faux appel
  — parfois un fichier entier — qui ne serait jamais exécuté.
- La consigne corrective cite l'extrait fautif : le modèle voit ce qu'il a
  mal écrit.
- Jusqu'à 3 relances consécutives au lieu d'une par tour ; le compteur
  repart à zéro après chaque appel émis par le protocole. Un long tour
  d'agent pouvait rater la syntaxe deux fois et finir sur un pseudo-appel.
- Nouveaux motifs : le format XML de Qwen3-Coder (<function=…>,
  <parameter=…>) quand le gabarit du moteur ne l'a pas converti.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:31:33 +02:00
MichaelandClaude Opus 5.5 7b754c0cb6 Mode Code : vérifier quand le builder a fini, jamais pendant une question
Repris du buildAgentNudge d'OpenFox (2.0.133).

La passe de vérification partait à chaque fin de tour de build, y compris
quand Qwen s'arrêtait sur « ensuite je vais… » ou venait de poser une
question avec ask. Chaque passe inutile coûte un prefill complet, évince
le cache KV du fil, et produit des « failed » qui ne disent que « pas
encore fait » — en courant par-dessus la question restée sans réponse.

- Nouveau statut « completed » : le builder marque un critère fait une
  fois vérifié par lui-même. Seule la vérification marque passed/failed.
- Fin de tour avec des critères encore ouverts : le builder est relancé
  sur ces critères (deux fois au plus) avant toute vérification.
- Fin de tour sur une question (ask) : pas de vérification, ni de
  correction, avant la réponse de l'utilisateur ; idem si le builder pose
  une question pendant une correction.
- Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien
  le bon critère au lieu de #0.
- Le panneau des critères montre « completed » (◐).
- Test de non-régression de la passe de vérification (code_verify_test.go).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:30:00 +02:00
MichaelandClaude Opus 5.5 9ccdc021cb Chiffrement : la discussion active et le mode Code ne deviennent plus illisibles
Activer le chiffrement de la mémoire chiffrait TOUTES les valeurs du
bucket des discussions, y compris des clés que le code lit et écrit en
clair (getStr/putStr) : le pointeur de discussion active, le mode
Chat|Code de chaque discussion, la puce « passer en mode Code ». Relues
comme du charabia, la discussion active devenait introuvable et le mode
Code disparaissait. Les critères, lus en clair eux aussi, s'effaçaient.

- Ces clés-pointeurs restent en clair (plainStoreKey) : ni rechiffrées, ni
  comptées comme « chiffrement incomplet ».
- Celles qu'une base existante a déjà chiffrées sont remises en clair dès
  le déverrouillage, avant le rechargement de la discussion active.
- Les critères passent par putStoreBytes/getStoreBytes : chiffrés comme le
  reste du fil, et lisibles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:27:43 +02:00
MichaelandClaude Opus 5.5 5e36b1cf82 Discussions : la liste montre la nouvelle dès le premier message
Repris d'AJEAN 0.17.6. La discussion est enregistrée au début du tour
(StartTurn persiste), mais la liste ne se rafraîchissait qu'à la fin de
la réponse. Elle se recharge maintenant à l'arrivée du message, groupée
quand plusieurs événements se suivent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:32 +02:00
MichaelandClaude Opus 5.5 2bbf0ba76d Tâches : « rappelle-moi dans 20 minutes » sans cron, à l'heure du navigateur
Repris d'AJEAN 0.17.5.

- task_create accepte in_minutes (dans N minutes) ou at (« HH:MM », prochaine
  occurrence, ou « AAAA-MM-JJ HH:MM ») pour une tâche unique, calculée côté
  serveur : le modèle n'a plus à écrire un cron ni à jongler avec les
  fuseaux pour un simple rappel. Elle s'écrit « @once AAAA-MM-JJ HH:MM » et
  se désactive après son passage ; manquée pendant un arrêt, elle part au
  redémarrage.
- Le navigateur envoie son fuseau (Europe/Paris…) avec chaque message ; il
  est retenu et sert aux tâches que l'IA planifie. Le conteneur tourne en
  UTC : « à 17h » tombait deux heures à côté. La réponse de task_create
  nomme le fuseau utilisé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:04 +02:00
MichaelandClaude Opus 5.5 8171899ac1 Contexte : fini le 400 « dépasse la fenêtre » qui bloquait la discussion
Repris d'AJEAN 0.17.5. Sur une fenêtre de 65k, un tour qui lit plusieurs
gros fichiers d'un coup passait de 60 % à plus de 130 % sans jamais
compacter, et l'erreur remontait telle quelle.

- Le test de compactage en cours de tour compte aussi les résultats
  d'outils de l'étape, que le moteur n'a pas encore vus. Sans usage côté
  serveur, l'estimation porte toujours sur tout l'historique.
- Tout résultat d'outil est borné à 30 000 caractères dans la vue du
  modèle, avec une mention qui l'invite à cibler. L'interface garde le
  résultat complet (« voir plus »).
- Dernier recours quand le moteur refuse encore le prompt et que le
  compactage n'a rien pu faire : shrinkToFit tronque les gros résultats
  d'outils puis retire les plus vieux échanges, vers 60 % de la fenêtre
  corrigés par la taille réelle lue dans l'erreur. Deux essais au plus.
- Tâches planifiées : la note de tâche passe en tête du message utilisateur
  au lieu du système. Le préfixe reste celui du chat et le cache de prompt
  de llama-server sert aux deux (l'amont mesurait 12 s de recalcul pour un
  « salut » après une tâche).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:23:21 +02:00
MichaelandClaude Opus 5.5 199d7f1e99 Chat : compactage sans redite, file multi-appareils, bascule par identifiant
Repris d'AJEAN 0.17.4.

- Compactage : la dernière demande du torse n'est plus réinjectée quand la
  queue contient déjà un message utilisateur (le modèle répondait une
  seconde fois à une vieille question), ni quand le résumé a échoué (elle
  est déjà dans le torse dégraissé et réapparaissait après ses propres
  résultats d'outils).
- Le texte écrit avant un appel d'outil n'est plus rangé deux fois dans
  l'historique du modèle (message tool_calls ET réponse finale) : du
  contexte gaspillé à chaque tour d'outil.
- Deux appareils qui envoient en même temps : le second message part en
  file au lieu d'un 409. Une tâche planifiée garde le refus.
- Un raisonnement qui reprend après du texte de réponse ouvre une nouvelle
  bulle sous la réponse au lieu de remplir l'ancienne, repliée au-dessus.
- Bascule de preset par identifiant : le numéro seul visait un autre
  preset quand la liste avait bougé sur un autre appareil. Le numéro reste
  accepté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:21:18 +02:00
MichaelandClaude Opus 5.5 7f8c5d360f Agent : reprise après coupure, appels parallèles séparés, outils gardés hors 500
Repris d'AJEAN 0.16.4 et 0.17.4.

- Flux coupé en cours de réponse vers une API externe (Wi-Fi, VPN, proxy
  qui décroche) : le tour reprend au lieu d'être abandonné, jusqu'à 5 fois
  avec attente croissante. Rien d'affiché : la requête est rejouée (le
  raisonnement montré est retiré). Du texte affiché : il est rendu au
  modèle avec « continue là où tu t'es arrêté », et la suite s'ajoute à
  l'écran. Un outil à moitié écrit est clos puis réémis. Jamais pour le
  llama-server local : coupé, il a planté. Une coupure après le dernier
  chunk (finish_reason reçu) garde la réponse telle quelle.
- Appels d'outils parallèles : chaque morceau est rangé selon son champ
  index, plus selon sa position dans le chunk. Un serveur qui envoie un
  appel par chunk les fusionnait (noms écrasés, JSON « {…}{…} »).
- Outils coupés et « réponds maintenant » seulement sur un 500 (appel mal
  formé). Un 401, 429 ou 502 d'une API externe coupait les outils et
  rejouait le tour, masquant la vraie cause.
- Débit de décodage mesuré côté Loki quand le serveur n'envoie pas de
  timings ; le débit de lecture, inconnu, n'est plus affiché à 0.
- Windows : la connexion refusée (WSA 10061) est reconnue comme telle par
  la reprise réseau (TestConnexionRefuseeEstRejouee échouait sous Windows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:18:35 +02:00
MichaelandClaude Opus 5.5 2db0ecd431 Presets externes : vision, compactage, bench et badge pour une API distante
La reprise faite en parallèle sur l'autre branche (b667fd4, jamais poussée)
couvrait des trous de celle-ci. Ses ajouts, posés sur la version en place :

- chat_template_kwargs, propre à llama.cpp, ne part plus vers une API
  distante — ni au chat ni au résumé de compactage. Une API stricte
  (OpenAI) répond 400 à un argument inconnu : toute compaction échouait.
- Vision : case « le modèle accepte les images » (EXTERNAL_VISION) dans la
  fenêtre API externe. Elle remplace, en externe, MMPROJ et la sonde
  /props : un modèle distant multimodal ne pouvait jamais recevoir d'image.
- Le benchmark refuse de tourner sur un preset externe : healthCheck le dit
  prêt sans moteur, et la mesure tapait un port arrêté ou un moteur resté
  en vie, attribuée à tort à ce preset.
- Le badge de modèle des réponses porte le nom du modèle distant.
- Éditer le preset en service l'applique aussitôt (SavePresetApplying).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:13:56 +02:00
MichaelandClaude Opus 5.5 48b8193dca Chat : écrire pendant que l'IA répond, et deux fils ne fusionnent plus
Repris d'AJEAN 0.14.0, adapté.

AJOUT EN COURS DE RÉPONSE
Il fallait arrêter la génération pour glisser une précision (le serveur
répondait 409). Un message envoyé pendant une réponse part désormais EN
FILE : runChat l'injecte à la prochaine frontière d'étape (après un appel
d'outil) — le modèle en tient compte dans la SUITE de sa réponse — ou, si
le tour se termine avant, il devient le tour suivant, dans l'ordre. Le
bouton envoyer réapparaît à côté de stop dès qu'il y a du texte ; le
message s'affiche « en attente » au-dessus de la carte jusqu'à ce que le
flux le confirme. Stop abandonne la file (queue_dropped, le client retire
ses pastilles).

Écarts avec l'amont :
- Les messages en attente sont posés AU-DESSUS de la carte, pas dans le
  fil qui s'écrit encore (ils s'y seraient intercalés entre deux bulles).
- Dédoublonnage par identifiant d'envoi (cid) : l'UI réessaie un envoi dont
  la réponse s'est perdue sur le tunnel. Le 409 « déjà en cours » faisait
  office de garde ; sans lui, le réessai mettait le message deux fois en
  file.
- Une tâche planifiée qui occupe le modèle garde le refus 409 (sa fin ne
  dépile rien).
- Pas d'injection après un stop : la boucle repassait en tête une fois
  l'outil interrompu et journalisait le message en file (vu en test).
- runChat prend l'injecteur en paramètre variadique : les appels hors chat
  (tâches, vérification du mode Code, tests) ne changent pas.

DEUX FILS FUSIONNÉS
Un appareil déconnecté pendant qu'un autre changeait de discussion se
réabonnait avec un `from` hérité de l'ancienne : les Seq n'étant pas
globaux, la fin de la nouvelle discussion se greffait sur le début de
l'ancienne restée à l'écran. caught_up et reset portent maintenant l'id de
la discussion ; le client le renvoie (conv_id) et, s'il ne correspond plus,
le serveur ordonne un reset avant de rejouer. La garde « from au-delà du
dernier Seq » émet elle aussi ce reset (elle rejouait par-dessus l'écran
sans le vider).

Vérifié dans le navigateur avec un faux moteur : précision injectée après
l'outil dans le même tour, message en file devenu tour suivant, stop qui
abandonne la file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:11:40 +02:00
MichaelandClaude Opus 5.5 58c2da1302 Chiffrement : les résultats d'outils et les images du fil suivent la mémoire
Les deux reprises d'AJEAN arrivées en parallèle du chiffrement écrivaient
en clair à côté de discussions chiffrées.

- Résultats complets des outils (« voir plus ») : bucket toolres ajouté à
  encryptedBuckets, écrits et relus par putStoreBytes / getStoreBytesErr.
  Mémoire verrouillée : rien n'est écrit, le flux porte le résultat entier.
  Le nettoyage des orphelins ne tourne plus quand la mémoire est
  verrouillée : l'index des discussions revenait vide et TOUT passait pour
  orphelin.
- Images du fil (chatimg/) : même enveloppe que les pages mémoire ;
  verrouillée, l'image reste en base64 dans le message. Elles n'étaient
  jamais effacées : comme un fil rechargé perd ses images (stripImageParts),
  celles de plus de 24 h partent au démarrage et à chaque bascule de
  discussion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:10:56 +02:00
MichaelandClaude Opus 5.5 ae0e181711 Outils : aperçu dans le flux, « voir plus » à la demande, coupes annoncées
Repris d'AJEAN 0.15.1 → 0.15.7 (tool_results.go adapté : pas de
chiffrement chez nous).

VOIR PLUS
Chaque résultat d'outil partait en entier dans le flux (jusqu'à 12 000
caractères) et dans le journal rejoué à chaque ouverture : un long fil
d'agent en transportait des centaines. Le flux ne porte plus qu'un aperçu
de 1 600 caractères, la taille réelle (le « ~N tok » de la bulle reste
juste) et un id. Le résultat complet est rangé dans le bucket `toolres`,
sous « <discussion>.<aléa> » — un id propre, pas le tool_call_id que
certains parseurs recyclent. « voir plus » le charge au clic via
/api/chat/tool-result et lève le plafond de hauteur du bloc ; il se range
avec « copier » dans une barre au coin du résultat. Les résultats partent
avec leur discussion (convDelete) ; les orphelins sont nettoyés toutes les
200 écritures.

COUPES ANNONCÉES
- Terminal : une sortie de plus de 8 000 caractères était coupée en
  silence ; le modèle croyait tout voir. Elle commence désormais par
  « [sortie tronquée : N caractères au total, seuls les 8000 derniers sont
  gardés] ».
- MCP : l'amont transmet désormais la réponse en entier. On s'en approche
  sans renoncer au garde-fou (65k de contexte) : le plafond suit la
  fenêtre (CTX caractères, ≈ un quart de la fenêtre, jamais sous les
  12 000 d'avant), et la coupe dit la taille réelle.

Vérifié dans le navigateur avec un faux moteur qui appelle bash : aperçu
de 1 600 caractères, « voir plus » charge les 8 099, mention de coupe en
tête.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:08:46 +02:00
Michael 6aafeeea98 Revert "Résultats d'outils : aperçu + « voir plus », vrai compteur, MCP complet"
This reverts commit 91d1796615.
2026-10-02 23:08:31 +02:00
MichaelandClaude Opus 5.5 f54c304019 Chat : 20 derniers échanges au chargement, et le toucher revient sur iOS
Repris d'AJEAN 0.15.7.

HISTORIQUE PAGINÉ
Rouvrir une longue conversation d'agent rejouait TOUT le journal : des Mo
à travers le tunnel, et des milliers de bulles construites avant
d'afficher la moindre chose. Le client annonce désormais `tail` : au
chargement (et à l'ouverture d'une autre discussion), le serveur ne rejoue
que les 20 derniers échanges, précédés de {history_more: N}. Un bouton en
tête du fil redemande le rejeu complet en gardant la position de lecture.
Un client qui n'envoie pas `tail` (app relais…) reçoit tout, comme avant.

Écart avec l'amont : les états que le client garde et que la partie
masquée avait posés — mode Chat/Code, critères du mode Code — sont réémis
à la coupe (historyHead), ainsi que ctx_used à l'ouverture d'une
discussion. Sans ça, une discussion en mode Code rouverte s'affichait en
mode Chat, et la jauge de contexte à zéro.

iOS : LES TAPS PERDUS PENDANT UNE RÉPONSE
iOS annule un appui si la position de défilement change pendant qu'il a
lieu. Le fil se recalant en bas à chaque token, presque chaque tap tombait
dessus — y compris sur stop. Le suivi est suspendu pendant un contact (et
350 ms après), scrollTop n'est plus réécrit quand on est déjà en bas, et
stop agit dès l'appui sur écran tactile (anti-doublon pour le clic qui
suit).

Vérifié dans le navigateur sur 23 échanges : 20 affichés + « afficher les
3 échanges précédents », puis rejeu complet à position de lecture égale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:07:56 +02:00
MichaelandClaude Opus 5.5 51c791268d Fil long : retour au replay complet avant la reprise du fenêtrage par échanges
Annule c32fd40. Le replay borné aux 4000 derniers événements coupait au
milieu d'un tour, perdait l'état du mode Code (critères, jauge de contexte)
porté par la partie masquée et s'imposait à tous les clients. Le fenêtrage
par échanges repris d'AJEAN (commit suivant) coupe aux frontières d'échange
et renvoie cet état en tête.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:07:46 +02:00
MichaelandClaude Opus 5.5 04137411fa Discussions : la liste se dessine par pages de 40
Repris d'AJEAN 0.15.6, pour la partie qui nous concerne. Côté serveur, la
liste était déjà légère (l'index ne porte que des métadonnées, aucune
conversation n'est relue) ; c'est le DOM qui coûtait : des centaines de
lignes construites d'un coup à chaque rafraîchissement de la liste.

On en dessine 40, puis la suite quand la ligne « afficher N de plus »
arrive à l'écran (IntersectionObserver, ou clic). La discussion active
reste toujours visible, même au-delà. La recherche filtre toujours la
liste ENTIÈRE, qui reste en mémoire ; changer la requête repart de la
première page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 f7a4ee71e7 Chat : les images ne sont plus réécrites en base64 à chaque tour
Repris d'AJEAN 0.15.7 (chat_images.go et son test), adapté.

Une image jointe (ou une capture de web_screenshot) vivait en base64 dans
les messages de la conversation, et persist() réécrit la conversation
ENTIÈRE à chaque fin de tour : une photo de téléphone, c'étaient des Mo
remis sur disque à chaque réponse. Elle est désormais rangée une fois sous
$LOKI_HOME/chatimg/<empreinte>.<ext> (hors du dossier de travail, que l'IA
peut vider) ; l'historique n'en garde que « loki-img:<nom> ». Juste avant
l'envoi, expandImageRefs remet les octets exacts (cache mémoire borné à
64 Mo) : pour le modèle, rien ne change.

DEUX ÉCARTS AVEC L'AMONT
- Copie à l'écriture : l'amont remplace l'URL en place. Chez nous, persist
  peut tourner pendant qu'un runChat sérialise ces mêmes maps (compactage
  en vol, bascule de projet) — « concurrent map read and map write », fatal.
  Les parties modifiées sont donc recopiées.
- Le rechargement reste éphémère : stripImageParts retire toujours les
  images d'une conversation rouverte (garde-fou contre un modèle sans vision
  qui lirait le base64 comme du texte). L'amont, lui, les garde ; on
  pourra le suivre plus tard si l'on veut.

Pas de migration des archives : une conversation rouverte est allégée par
stripImageParts, comme avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 1db987844e Chargement du modèle : --mlock seul ne coupe plus le mmap
UN VRAI BUG D'ABORD
normalizeLoadFlags traduisait --mlock seul en « --load-mode mlock ». Or,
pour llama.cpp (common/arg.cpp), « mlock » veut dire PAS de mmap + résident :
le modèle entier montait en RAM au lieu d'être mappé. L'ancien --mlock, lui,
gardait le mmap — son équivalent est « mmap+mlock ». Sur un modèle plus gros
que la RAM (Qwen3.8-Flash-Next, 82 Go pour 64 Go), un preset qui avait
coché « Garder en RAM » tournait donc à l'OOM dès qu'un moteur récent
prenait le relais. Correspondance alignée sur AJEAN 0.16.0 :
  --mlock seul → mmap+mlock · --no-mmap → none · les deux → mlock.

DANS LES DEUX SENS
Un moteur ancien (ou un fork) ne connaît pas --load-mode et sort en erreur
si on le lui passe : downgradeLoadMode le retraduit en anciens drapeaux
(dio, inconnu de ces moteurs, est abandonné avec un avertissement).

UN SÉLECTEUR À LA PLACE DE DEUX INTERRUPTEURS
« Garder en RAM » et « Charger tout en mémoire » ne disaient pas leur
combinaison. L'éditeur de preset propose les six modes de llama.cpp (auto,
mmap, none, mlock, mmap+mlock, dio), avec ce que fait chacun en sous-titre,
et écrit la forme moderne. Un preset aux anciens drapeaux s'affiche sur le
bon mode sans modification.

Au passage, q5_1 porte la mention « lent sur CUDA » dans la liste du cache
KV (voir le commit du port occupé : non accéléré en Flash-Attention).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 f75f3cf27d Web : réponses compressées en gzip
Repris d'AJEAN 0.15.7 (web_gzip.go et son test, tels quels).

L'UI (~590 Ko de HTML/JS/CSS en un seul fichier) et les gros JSON
(historique relu, liste des sessions, journal) partaient en clair. gzip
les divise par 4 à 8 — ça se sent sur un téléphone en Wi-Fi ou à travers
le tunnel.

La décision se prend au premier octet, sur le Content-Type posé par le
handler : les flux SSE (chat, complétions /v1 en streaming) et les
binaires passent tels quels — un flux compressé retiendrait ses
événements dans le tampon de gzip. Flush reste transmis pour les réponses
progressives, et Unwrap laisse le WebSocket des postes distants atteindre
le Hijacker d'origine.

Seul l'écouteur local est enveloppé : le tunnel sert le mux nu, comme
avant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 38802d9e21 Presets : une bascule faite sur un appareil se voit sur les autres
Repris d'AJEAN 0.13.6. Changer de preset sur le téléphone laissait l'onglet
du PC afficher l'ancien, sélection de la liste comprise, jusqu'à un
rechargement à la main. /api/status (sondé toutes les 5 s) expose l'id du
preset actif ; loadStatus rappelle loadPresets quand il change sans qu'on
y ait touché ici.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00
MichaelandClaude Opus 5.5 2c36fa6d2c Mémoire : un mode par projet, et un quatrième — « recherche »
Repris d'AJEAN 0.13.12.

PAR PROJET
Le mode mémoire était un réglage global de config.env. Il vit désormais
sur le projet (champ mem_mode) : un chantier de code peut couper la
mémoire pendant qu'un projet perso la garde proactive. Un projet sans
réglage propre — tous ceux d'avant — retombe sur l'ancien MEM_MODE global :
rien ne change tant qu'on n'y touche pas. L'API /api/memory et `loki
memory` agissent sur le projet actif ; l'UI le dit, et se recharge déjà au
changement de projet.

AUTO, EN DEUX SAVEURS
- « auto, injectée » (always, le défaut) : l'index des pages est en tête
  de conversation. La consigne dit maintenant d'y lire directement la
  bonne page ; mem_search ne sert plus qu'à chercher par contenu. Avant,
  on injectait l'index ET on exigeait une recherche préalable — un appel
  d'outil par tour pour retrouver ce que le modèle avait sous les yeux.
- « auto, recherche » (nouveau) : rien d'injecté, l'IA cherche avant
  chaque tâche. Contexte plus léger, démarrage plus rapide.
memProactive regroupe les deux là où seul « proactif » compte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:08 +02:00
MichaelandClaude Opus 5.5 4a1054c1b7 Mémoire : après un compactage, le modèle sait quelles pages il avait lues
Repris d'AJEAN 0.14.0 (chat_mem_pinned.go et ses tests, tels quels).

Le compactage résume le torse, résultats de mem_read compris. Une page qui
portait TOUTES les règles d'une tâche se retrouvait réduite à trois mots
dans le résumé, et le modèle, après compactage, les oubliait. Le rappel ne
recopie rien : il LISTE les pages lues (bornées aux 24 plus récentes) et
invite à les relire si elles concernent la tâche. Il s'accumule d'un
compactage à l'autre — l'ancien rappel est relu comme source, puisque le
mem_read d'origine a disparu.

Posé seulement en mode agent (sans lui, pas de mem_read pour y donner
suite). Comme le contexte projet, le rappel est sorti du bloc système
commun vers le premier message utilisateur (isProjectSystem) : il est
propre à la conversation et casserait sinon le cache du système partagé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 626227f2f2 Projets : changer de projet ne recalcule plus tout le prompt
Repris d'AJEAN 0.15.8, adapté : chez nous le contexte projet n'est jamais
persisté, il est injecté à chaque tour sous forme de messages système
préfixés — ce qui rend le déplacement trivial et sans migration.

LE PROBLÈME
Sur un modèle hybride (Qwen3.5 et suivants, couches récurrentes), llama.cpp
ne peut reprendre un prompt déjà calculé qu'à un point de sauvegarde, et il
n'en pose qu'au DÉBUT d'un message utilisateur. La description du projet,
l'index mémoire et l'index des trackers étaient fusionnés dans le bloc
système : deux projets divergeaient AVANT le premier point de reprise, et le
premier message après un changement de projet recalculait tout (l'amont
mesure ~8 s pour 5 000 tokens sur un 27B).

LE CORRECTIF
normalizeSystemMessages sort ces messages (isProjectSystem, par leurs
préfixes) du bloc système et les place, dans un bloc <project_context>, en
tête du PREMIER message utilisateur de la séquence envoyée. Le système
commun reste identique d'un projet à l'autre, donc en cache. L'historique
persisté et l'affichage ne changent pas (copie, entrée non mutée — testé).

InjectSkills ne fusionne plus son préambule DANS un message projet placé en
tête (cas d'un preset sans prompt système) : il y aurait perdu son préfixe
et serait resté dans le bloc commun.

Le gain ne vaut qu'entre projets de même mode mémoire : la consigne mémoire
fait partie du système commun.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 4602614be0 Chat : mode agent coupé = modèle brut, vraiment
Repris d'AJEAN 0.13.12 / 0.13.13. Couper le mode agent retirait bien les
outils, la mémoire et le préambule Loki, mais laissait passer deux choses :

- Le prompt système du PRESET et le contexte du projet (description, index
  mémoire, trackers). Ils décrivent des outils et une mémoire que ce mode
  n'a pas : le modèle, persuadé de pouvoir agir, écrivait des <tool_call>
  en clair dans sa réponse. Agent off, generate n'injecte plus rien — le
  modèle reçoit les messages nus, comme un llama-server direct.
- Un tool_call que le moteur parvient à parser alors qu'aucun outil n'a été
  annoncé (agent off, ou relance sans outils après un 500). Il était exécuté,
  répondait « outil inconnu », et la boucle repartait. Il est désormais
  ignoré : le modèle répond en texte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 f117a466af Presets : fini le benchmark fantôme
Repris d'AJEAN 0.16.3. Les benchmarks sont rangés par id de preset. Un
preset dont on changeait le modèle — ou supprimé puis recréé sous le même
nom — affichait les mesures d'un AUTRE modèle, qui n'avaient rien à voir.

- benchMatchesPreset : un bench n'est affiché que si le modèle mesuré est
  celui du preset (le nom de fichier est enregistré avec la mesure depuis
  toujours, il ne servait simplement à rien).
- DeletePreset oublie le bench, comme il oubliait déjà le prompt système.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:51 +02:00
MichaelandClaude Opus 5.5 97378da7af Agent : trois filets de secours qui faisaient plus de mal que de bien
Repris d'AJEAN 0.15.5.

COMPACTAGE DE SECOURS
Sur n'importe quel refus du moteur — appel d'outil mal formé, modèle en
cours de chargement, erreur de template — runChat résumait ~75 % de la
conversation avant de rejouer, même quand elle tenait en trois messages.
contextOverflow ne laisse passer que les vrais débordements : libellés de
llama.cpp (« exceeds the available context size ») et des API
OpenAI-compatibles (context_length_exceeded), ou, à défaut de libellé, une
conversation déjà à 90 % de la fenêtre.

ARGUMENTS D'OUTIL ILLISIBLES
Un JSON tronqué était neutralisé en {} (indispensable : le template les
re-parse à chaque requête) PUIS exécuté tel quel, d'où des erreurs
trompeuses (« fichier manquant », « commande vide ») qui envoyaient le
modèle sur une fausse piste. L'appel n'est plus exécuté ; le modèle reçoit
une erreur qui dit exactement quoi renvoyer. Des arguments VIDES restent
valides (outil sans paramètre).

RELANCE SANS OUTILS
La consigne imposait le français. Elle demande désormais la langue de
l'utilisateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:35 +02:00
MichaelandClaude Opus 5.5 e12f07426f Diff : le compteur de lignes dit enfin la vérité
Repris d'AJEAN 0.16.0 (chat_diff.go et son test, repris tels quels : le
fichier n'avait pas bougé chez nous depuis la 0.13.5).

- Un fichier de 500 lignes écrit par l'agent affichait « +500 » pendant la
  frappe puis retombait à « +120 » : le diff envoyé à l'UI est plafonné à 120
  lignes, et l'UI recomptait sur la version tronquée. lineDiff et addedDiff
  rendent désormais les VRAIS totaux, calculés avant la coupe, et ils
  voyagent dans le journal (added/removed) — donc survivent au refresh.
  Repli sur l'ancien décompte pour les conversations d'avant.
- Une retouche d'une ligne dans un bloc de plus de 400 lignes s'affichait en
  remplacement complet (le LCS n'y était pas tenté). Les lignes communes en
  tête et en queue sont écartées d'abord : on voit le vrai changement, avec
  trois lignes de contexte, et il n'est plus repoussé hors de la fenêtre.
- Un contenu terminé par un saut de ligne ne compte plus une ligne de trop,
  côté serveur comme dans la bulle en cours de frappe (bodyLineCount).

Le vérificateur du mode Code (code_verify.go) journalise les mêmes champs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:30 +02:00
MichaelandClaude Opus 5.5 20375d0e4c Moteur : port déjà occupé refusé, cache KV lent signalé
Deux reprises d'AJEAN 0.15.9, côté `loki serve`.

PORT OCCUPÉ
Un llama-server orphelin (un stop qui n'a pas tué, un relancement trop
rapide) peut encore tenir le port quand le suivant démarre. Deux moteurs
sur le même port se partagent alors les requêtes au hasard : VRAM saturée,
réponses du mauvais modèle. waitPortFree tente une connexion TCP (fiable
même en SO_REUSEADDR, là où un Listen de test réussirait), laisse 5 s à un
moteur qu'on vient d'arrêter pour libérer, puis refuse avec un message qui
dit quoi faire. argValue lit la DERNIÈRE occurrence de --port/--host, comme
llama-server, puisque EXTRA_ARGS peut les surcharger.

CACHE KV LENT
llama.cpp CUDA, compilé avec ses options par défaut (FA_ALL_QUANTS=OFF),
n'accélère en Flash-Attention que f16, bf16, q8_0 et q4_0 — et seulement à
l'identique pour K et V. C'est le cas du moteur de l'image server-cuda et de
ceux installés par OCI. q5_1 ou un couple mixte q8_0/q4_0 marchent mais sont
reconvertis en f16 à chaque pas. L'amont ne prévient que pour son binaire
précompilé ; chez nous tous les moteurs sont concernés, donc l'avertissement
part toujours dans loki-engine.log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:10 +02:00
MichaelandClaude Opus 5.5 b893d1a81f API : un site tiers ne peut plus piloter Loki en douce
Repris d'AJEAN 0.15.5. Sans clé de pilotage (le défaut), l'API /api était
ouverte à tout ce qui savait joindre le port — y compris une page web
quelconque ouverte dans un navigateur du réseau local. Un POST « simple »
(text/plain) ne déclenche aucune pré-vérification CORS : la page pouvait
changer des réglages et, mode agent actif, faire exécuter des commandes.

requireWebAuth passe désormais par crossSiteReject, AVANT le test de clé :
- Sec-Fetch-Site: cross-site → 403 ;
- Origin présent et différent de l'hôte appelé (ou « null ») → 403. curl,
  les scripts et les apps n'envoient pas d'Origin : non concernés ;
- sans clé seulement, l'hôte appelé doit être local (IP, localhost, nom sans
  point, .local/.lan/.home…, nom du conteneur) : c'est ce qui coupe le DNS
  rebinding, où un domaine malveillant se fait résoudre en IP locale.

DEUX ÉCARTS AVEC L'AMONT, DUS AU CONTENEUR
- LOKI_TRUSTED_HOSTS : derrière un reverse proxy, le nom public n'est ni
  local ni celui du conteneur. Plutôt qu'imposer une clé, on peut lister ce
  nom. Documenté dans le README.
- Le trafic du tunnel (marqué par withLocalAuth, authentifié par le relais)
  est dispensé du contrôle d'hôte : son Host est celui du relais.

À SAVOIR : un accès existant par nom de domaine SANS clé ni
LOKI_TRUSTED_HOSTS est désormais refusé (403, message explicite).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:04:05 +02:00
Claude 513aca1c7b Version 0.13.0
Le rattrapage de l'amont (navigateur piloté, chiffrement de la mémoire,
notifications, tâches script, sous-agents, anglais) était parti sur main
sous le numéro 0.12.3 : huit fonctionnalités sous un numéro de correctif.

Bump des trois porteurs de version — la constante Go, versioninfo.json et
les .syso Windows régénérés (go generate) — et notes de release réécrites
pour 0.13.0. Sans ça, `loki update` et le bandeau de mise à jour comparaient
une version qui ne bougeait pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-23 07:52:59 +00:00
Claude f1075030dd NOTICE : créditer OpenFox pour les sous-agents et le fenêtrage du fil
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:08:15 +00:00
Claude c4e399e101 Mode code : sous-agents (explorer, code-reviewer, planner)
Reprise de l'idée des sous-agents d'OpenFox, sur la mécanique déjà en place
pour la passe de vérification : un runChat isolé, non persisté.

L'outil `subagent` délègue une question bornée à un rôle qui travaille dans
SON propre contexte et ne rend que sa réponse. Sur un modèle local, c'est la
fenêtre de contexte qu'on sauve : « trouve où est géré le cache » coûte dix
lectures de fichiers qui restaient ensuite dans l'historique jusqu'à la
compaction, alors que seule la réponse comptait.

Les rôles explorer et code-reviewer, jusqu'ici définis mais jamais appelés,
deviennent utilisables. Tous les rôles délégués sont en LECTURE SEULE : pas
de write/edit (ce qui modifie le dépôt reste dans le fil principal, sous les
yeux de l'utilisateur), pas de subagent (aucune récursion), pas de mémoire ni
de web. Seul le planner pose des critères — son prompt le lui demande — et
marquer un critère « passé » reste le privilège de la passe de vérification.

Le rôle verifier n'est PAS délégable : le builder se décernerait son propre
satisfecit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:08:01 +00:00
Claude c32fd40f1a Longues discussions : ne rejouer que la fin, charger le début d'un clic
Inspiré du fenêtrage du fil d'OpenFox (2.0.151+). À chaque ouverture
d'onglet, le serveur rejouait TOUT le journal d'affichage : sur une
discussion de plusieurs centaines de tours, des dizaines de milliers
d'événements traversaient le flux, puis autant de bulles s'installaient dans
le DOM que le navigateur devait traîner à chaque rendu.

Le replay initial est désormais borné à ses 4000 derniers événements. Rien
n'est tronqué sur le disque : le serveur annonce combien d'événements sont
restés en arrière, l'interface l'affiche en tête du fil et le bouton
« charger le début » se réabonne en demandant le journal entier.

Jamais borné sur une reprise de flux (from > 0) : là, le client a déjà le
début à l'écran et attend la suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:04:58 +00:00
Claude 382c5dbdce Interface en anglais, par-dessus une source française
L'amont tient une table de clés FR/EN et marque chaque texte d'un data-i18n.
Reproduire ça ici demanderait de réécrire tous les écrans d'un coup, avec le
risque d'en casser un pour une clé oubliée — et un « settings.memory.title »
affiché en production.

Chemin additif : la source RESTE le français, et « English » applique un
dictionnaire sur le texte affiché (correspondance exacte, nœud par nœud).
Une chaîne absente du dictionnaire reste en français ; le dictionnaire
s'enrichit sans toucher au reste de l'interface.

Jamais traduits : le fil de discussion (#chat), le code, les zones de
saisie, et tout ce qui porte data-no-i18n. Un observateur couvre les
panneaux rendus en JS après le chargement. Revenir au français recharge la
page — le texte d'origine a été remplacé dans le DOM, c'est le moyen sûr de
le retrouver intact.

Couverture de départ : navigation des réglages, intitulés de sections,
libellés de lignes, boutons et interrupteurs. Sélecteur dans Apparence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 14:01:36 +00:00
Claude fa69dac77f Chiffrement de la mémoire, snapshots, sauvegarde chiffrée en fichier
Reprise d'AJEAN (mem_crypto/mem_vault/mem_store/mem_io/mem_migrate/
mem_snapshots/mem_health/mem_fsutil + backup_bundle), adaptée à Loki.

Chiffrement à enveloppe : une DEK tirée une fois chiffre les données en
AES-256-GCM ; elle est enfermée dans un coffre par une KEK dérivée en
Argon2id. Ce qui ouvre le coffre : la clé de pilotage de l'appareil (le
serveur n'en a que l'empreinte, il ne peut pas ouvrir seul) ou la clé de
récupération donnée une fois. La DEK ne vit qu'en RAM.

Périmètre chiffré, choisi pour Loki : les pages mémoire, les discussions
(journal ET index, qui porte les titres), les blocs archivés au compactage
— du verbatim de conversation — et les trackers. Pas les buckets de
réglages : le coffre lui-même y vit, les chiffrer serait une boucle.

Verrouillé, rien n'est écrasé : putStoreBytes REFUSE d'écrire du clair
par-dessus du chiffré, la liste des discussions se lit vide et les pages
s'affichent « 🔒 chiffré ». Le déverrouillage recharge la discussion et
rattrape ce qui serait resté en clair. Une migration interrompue reprend au
démarrage, un snapshot est pris avant chaque bascule, et aucune donnée n'est
supprimée avant relecture vérifiée de son remplaçant.

Piège corrigé au passage : chiffrer À L'INTÉRIEUR d'une transaction bbolt se
bloquait sur le verrou de la base (memEncActive relit la config, donc la
base). L'état du chiffrement est désormais résolu AVANT la transaction
(memEncoderNow), qui ne reçoit plus qu'un encodeur pur.

Sauvegarde : le paquet chiffré {mémoire, presets, réglages} s'exporte et
s'importe en FICHIER (/api/backup/export, /api/backup/import). La
sauvegarde vers le relais ajean.link n'est pas reprise — c'est le service de
l'auteur amont ; ici le fichier reste chez soi.

Le dossier mémoire n'était déjà plus joignable qu'aux outils mem_* : c'était
la condition de ce chiffrement (un `cat memory/…` aurait rendu du binaire).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:59:10 +00:00
Claude e9ec8ae3a8 Notifications Web Push (+ manifeste PWA)
Reprise d'AJEAN : le serveur pousse une notification vers les navigateurs
abonnés, directement via leur service de push — donc app fermée et téléphone
verrouillé, là où une notification côté page ne peut rien (un onglet caché
relâche son flux SSE).

Deux déclencheurs : la fin d'un tour utilisateur (sauf interruption par le
bouton stop : celui qui a coupé est devant l'écran) et — ajout propre à Loki
— la fin d'une TÂCHE PLANIFIÉE, succès comme échec. C'est le cas qui compte
le plus : une tâche tourne justement quand personne ne regarde.

Clés VAPID générées à la première demande et rangées dans la base ;
abonnements persistés et purgés quand le service de push répond 404/410.
Corps de notification générique, sans extrait de réponse : elle transite par
Apple ou Google. /sw.js et /manifest.webmanifest sont servis à la racine
(un service worker doit venir de l'origine) ; le worker ne fait QUE recevoir
les push, sans cache — mettre l'UI en cache servirait une interface périmée
après une mise à jour de l'image.

Interrupteur dans Réglages → Mode agent, à armer sur chaque appareil. L'UI
dit ce qui manque plutôt que d'échouer : HTTPS requis, notifications
bloquées, ou iPhone à ajouter d'abord à l'écran d'accueil.

Nouvelle dépendance : github.com/SherClockHolmes/webpush-go (RFC 8291).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:37:16 +00:00
Claude 74fb1fc781 Tâches : scripts planifiés sans modèle, et l'IA planifie la sienne
Deux reprises d'AJEAN autour des tâches planifiées.

Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory,
hors du workspace. Le workspace est jetable — supprimer une discussion
emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa
place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses
scripts normalement.

Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script
sans charger le modèle ni consommer un token, sa sortie devient le
compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne
hors du verrou de génération — d'où un registre à part pour l'afficher « en
cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le
verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale.

Outils task_list/create/update/delete : l'IA se donne elle-même rappels et
veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et
ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000
caractères, avec le palier documenté dans le test.

Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent
plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et
c'est la condition d'un chiffrement de la mémoire à venir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:33:39 +00:00
Claude 91d1796615 Résultats d'outils : aperçu + « voir plus », vrai compteur, MCP complet
Le flux n'envoie plus tout le résultat d'un outil : un aperçu de 1600
caractères, sa taille RÉELLE et l'id de l'appel. Le bouton « voir plus »
charge le reste à la demande (/api/chat/tool-result), et déplie vraiment le
bloc (plafond de 280 px levé) au lieu de le laisser scroller. « voir plus »
et « copier » vivent dans la même barre, ancrée en bas à droite d'un
bloc-parent non scrollant : elle reste au coin quand on scrolle le résultat.

Le compteur « ~N tok » de la bulle disait la taille de ce que l'UI avait
reçu, donc toujours le plafond sur un long résultat. Il lit maintenant
result_chars, la taille réelle.

MCP : plus de troncature à 12000 caractères avant le modèle (même règle que
la lecture mémoire). Un serveur MCP est configuré exprès pour ce qu'il rend ;
le couper au milieu rendait la réponse inutilisable. L'UI, elle, n'en affiche
que l'aperçu.

Reprises d'AJEAN 0.15.1, 0.15.2 et 0.15.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:24:53 +00:00
Claude 00b2350fb9 Navigateur piloté, images redressées : reprises d'AJEAN
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki
embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter.

Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go,
portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments
interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/
type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort.
Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée)
et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`,
/api/computer), sous le mode agent : cliquer et taper dans une page sont des
actions réelles, même niveau de confiance que bash.

Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de
Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur
de l'image — sinon la fonctionnalité se déclarait absente là où le
navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre
une vraie fenêtre.

Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation
EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos
de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux
pièces jointes, à see_image et aux captures.

Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les
browser_* ne sont plus court-circuités sur un appel identique. Relancer la
même commande après avoir modifié un fichier est légitime, et un outil qui
porte une image dans un message à part renvoyait « [déjà fait] » SANS
l'image — le modèle tournait en boucle.

Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/
rebase ni de redémarrage de service sans demande explicite), reprise de la
leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
2026-09-22 13:19:37 +00:00
Claude b5904907e8 Reprise réseau, presets externes, see_image
Trois apports repérés chez AJEAN (v0.13.8) et OpenFox (2.0.118), portés et
adaptés à Loki.

## Reprise réseau du tour (llm_retry_net.go)

La requête de complétion partait une fois : un Do() qui échoue ou un statut
d'erreur tuait le tour. Les messages d'erreur le disaient eux-mêmes —
« réessaie dans quelques secondes » — autrement dit on demandait à
l'utilisateur de refaire à la main ce que le code pouvait faire seul. Une
tâche planifiée tombée pendant un redémarrage du moteur échouait pour de
bon, sans personne pour recliquer.

Trois reprises consécutives, 0,8 → 1,6 → 3,2 s, plafonnées, interruptibles
par un /stop. La règle de sûreté ne souffre pas d'exception : on ne rejoue
que TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ, sinon la moitié de la réponse
déjà chez l'utilisateur serait dupliquée. Le compteur repart à zéro dès
qu'une réponse arrive.

500 n'est pas un statut de reprise : c'est ce que llama.cpp rend pour un
appel d'outil malformé ou un prompt trop long, deux échecs déterministes
que les filets sémantiques traitent déjà. Restent les codes qui disent
« pas maintenant » : 429, 502, 503, 504.

## Presets externes (backend_external.go, web_external.go)

Un preset avec EXTERNAL=1 route le chat vers une API OpenAI-compatible
distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre machine) au lieu
du llama-server local. C'est un preset COMME UN AUTRE : même liste, même
bascule, même prompt système par preset. La différence ne vit qu'à deux
endroits — l'inférence (resolveChatEndpoint) et la bascule, qui arrête le
moteur local au lieu de le redémarrer.

La clé du serveur local ne part jamais chez un tiers : chaque endpoint
porte la sienne. La clé du preset n'est jamais renvoyée en clair à
l'interface, et un champ vide ne l'efface pas — il faut y avoir touché.
Une fenêtre dédiée plutôt que l'éditeur habituel : un modèle distant n'a
ni quantification, ni couches GPU, ni moteur. Un bouton teste la connexion
avant d'enregistrer, et rend le message de l'API plutôt que le JSON brut.

Le résumé de compactage part au même endroit que le chat : le laisser
taper le moteur local aurait cassé toute compaction sur un preset externe.

## see_image (chat_vision_tool.go)

Loki savait voir une pièce jointe et une capture qu'il venait de prendre,
mais pas un fichier qui dort sur le disque : « regarde ~/photos/bug.png »
n'avait aucune réponse, `read` rendant des octets binaires. L'outil charge
l'image et la réinjecte dans un message utilisateur multimodal — même
chemin que les pièces jointes.

Même règle ÉPHÉMÈRE que les captures (et non celle de l'amont, qui persiste
l'image) : l'image va dans le tour en cours, pas dans l'historique. Un
base64 persisté repartirait à chaque tour et finirait par dépasser la
fenêtre pour de bon.

Le marqueur de perte à la compaction existait déjà mais n'offrait qu'un
recours, « reprends la capture » — ce qui enverrait photographier une page
web alors que l'image perdue est un PNG du disque. Formulation généralisée.

## Au passage

toolCallLabel est extrait de runChat. Cette table nom d'outil → argument a
une double fonction — libellé affiché ET argument principal — donc un outil
absent s'exécute sur une chaîne vide : see_image répondait « chemin de
fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne bronche.
Une table pareille se teste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 21:05:19 +00:00
Claude d1626781f9 Chargement de la conversation : compter les reprises, pas les millisecondes
TestLoadConversationAbsenceLegitimeResteRapide vérifie qu'une absence
légitime — première installation, rien d'enregistré — ne déclenche aucune
reprise. Il le mesurait au chronomètre : « moins de 250 ms », la durée
d'une attente de reprise.

Le temps de mur ne dit pas ça. Il mêle le coût du PREMIER essai (créer et
ouvrir la base bbolt, fsync compris) à l'attente qu'on cherche à exclure.
Sur un runner CI chargé, cet essai unique a pris 435 ms : le test a échoué
alors qu'aucune reprise n'avait eu lieu, et le même commit passait une
minute plus tôt sur un autre runner.

LoadConversation compte désormais ses reprises (loadConvRetries), et le
test lit ce compteur. Ce qu'il affirme est enfin ce qu'il mesure, et il ne
dépend plus de la charge de la machine.

Le test de la base illisible vérifie le compteur dans l'autre sens (quatre
reprises attendues) : sans ça, un compteur bloqué à zéro rendrait la
première assertion vraie sans rien prouver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 20:25:48 +00:00
Claude 9bdaf00ca4 Modèles : un .gguf gardé se réutilise et s'efface
Un modèle téléchargé depuis l'éditeur d'un preset survit à la suppression
de ce preset (c'est voulu : on le réutilise ailleurs). Il devenait pourtant
inatteignable — impossible de refaire un preset dessus, impossible de
l'effacer.

Trois causes, trois correctifs.

1. « Le modèle existe déjà » n'est plus une erreur. La recherche Hugging
   Face proposait le quant, le clic lançait la sonde, et le téléchargement
   répondait en rouge « le modèle existe déjà » — fin du parcours. La sonde
   constate maintenant la présence du fichier (sans même sortir sur le
   réseau) et renvoie la valeur à écrire dans MODEL= : l'interface le
   SÉLECTIONNE, et la file continue (le projecteur vision, par exemple).
   Les quants déjà présents portent une pastille « déjà installé » dans la
   liste du dépôt, avant le clic.

2. Le sélecteur de modèle reconnaissait mal ce qu'il avait sous les yeux.
   /api/models comparait le dossier de chaque .gguf à LokiHome() alors que
   les téléchargements atterrissent dans $LOKI_HOME/models : la comparaison
   ne pouvait jamais être vraie. Conséquences : l'étiquette « dossier loki »
   ne s'affichait nulle part, et un preset écrit MODEL=modele.gguf
   s'affichait « introuvable ; ajoute son dossier ci-dessous » — le fichier
   étant juste à côté. Un nom simple est désormais résolu comme le fait le
   moteur : dossier de loki d'abord, puis les autres.

3. Une liste « Modèles installés », dans le groupe Modèle de l'éditeur.
   La route /api/models/delete existait depuis longtemps ; aucun bouton ne
   l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en
   cochant « supprimer aussi le fichier » à la suppression de son preset.
   La liste montre taille, dossier, tranches manquantes, et qui s'en sert :
   le modèle en service ne s'efface pas (le moteur l'a ouvert, la place ne
   serait même pas rendue), celui que des presets nomment prévient en les
   nommant puis obéit. La suppression dit ce qu'elle a libéré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 20:20:33 +00:00
MichaelandClaude Opus 5 54468b3336 Dictée : Parakeet remplace whisper.cpp
Parakeet TDT 0.6B v3 (NVIDIA), servi par sherpa-onnx. La v3 et non la v2 :
c'est la seule des deux qui parle français — la v2 est anglais seul.

CE QUI DISPARAÎT DU DOCKERFILE
Deux étapes de compilation, dont une CUDA de ~200 fichiers nvcc qui a été tuée
par l'OOM du runner plus d'une fois, et avec elles tout l'appareillage de
garde-fous qu'elles réclamaient (GGML_NATIVE=OFF contre le SIGILL en
production, bornage des architectures CUDA, deux binaires CPU/CUDA à choisir à
l'exécution). À la place : le téléchargement d'un binaire statique de 35 Mo,
version épinglée et empreinte SHA-256 vérifiée — le binaire s'exécute sur la
machine de l'utilisateur, une release remplacée en amont ne doit pas passer en
silence.

CE QUI CHANGE DANS LE CODE
La forme est la même — un processus local supervisé, éteint après dix minutes
d'inactivité. Le dialogue, lui, change : whisper-server exposait du HTTP
multipart, sherpa-onnx n'expose qu'un WebSocket dont le protocole tient en deux
entiers et des flottants. D'où un client WebSocket et une conversion WAV →
float32 côté serveur. Le parcours des blocs du WAV n'est pas du zèle : l'offset
44 codé en dur transforme un bloc LIST intercalé en craquement au début de
chaque phrase.

DEUX RÉGLAGES DISPARAISSENT, ET C'EST LE MOTEUR QUI L'IMPOSE
La LANGUE : Parakeet la détecte lui-même, il n'a aucun drapeau pour la forcer.
Le réglage n'aurait servi qu'à mentir. À surveiller : whisper avait précisément
écarté la détection automatique parce qu'elle se trompait sur des tranches
courtes.
Le GPU : le build livré est le statique CPU. Annoncer un sélecteur de carte
sans pouvoir l'honorer serait pire que de ne rien annoncer — et un modèle de
0,6 B en int8 sur des tranches de quelques secondes n'en a pas besoin, la carte
reste au moteur de chat.

MIGRATION
Un réglage enregistré du temps de whisper retombe sur le défaut au lieu de
casser la dictée. Les modèles ggml de /data/whisper/ ne servent plus à rien
mais ne sont PAS effacés : ce sont des données que personne n'a demandé de
perdre. Ils sont à supprimer à la main.

Le paquet UI regénéré ici couvre aussi les sources du commit précédent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:57 +02:00
MichaelandClaude Opus 5 17f8fa8f34 Mémoire longue (recall), projets et trackers
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork.

MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go)
Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais
ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être
résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le
retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive
grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la
session n'est plus perdu au compactage suivant.
Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la
fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et
fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne
demande au lieu de continuer la tâche en cours.

PROJETS (projects.go)
Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture
est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en
hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md
→ memory/generale/, discussions et tâches orphelines rattachées). Une tâche
planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive
pas dans la mémoire du chantier affiché à l'écran.

TRACKERS (tracker.go)
3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais
en entier — consultation par niveaux (vue d'ensemble → année → mois →
événements), et la dernière valeur de chaque tracker est donnée d'emblée au
modèle, qui répond sans appeler l'outil.

Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation
avec la description du projet (le modèle ne peut plus le désynchroniser) ;
prompt système rattaché au PRESET et non plus global — stocké en base, pas
dans le .env, qui ne saurait pas porter un texte multiligne.

Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les
fichiers : msgText signale la présence d'une image au compactage, et le
garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges).

Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:33 +02:00
MichaelandClaude Opus 5 c45ba615fa Démarrage, captures, postes distants : correctifs portés d'AJEAN
- Au démarrage, une lecture ratée de la base (verrou bbolt transitoire pendant
  le chevauchement des process au redémarrage du conteneur) était confondue
  avec une conversation absente. Pire que l'amont chez nous : getStr rend ""
  dans les deux cas, donc convEnsureActive forgeait un NOUVEL identifiant et
  l'écrasait — le fil en cours devenait orphelin, en silence. On sonde
  désormais la base avec son erreur AVANT toute écriture, on réessaie quatre
  fois, et un échec durable est journalisé sans que rien ne soit touché.

- Une capture d'écran disparaissait sans laisser de trace : stripImageParts
  aplatissait le message en ne gardant que sa légende. Le marqueur
  imageLostMarker rend la perte VISIBLE, pour que le modèle sache reprendre
  une capture au lieu de la redécrire de mémoire.

- maxLogEvents 20000 → 200000 : un seul tour à très long raisonnement
  tronquait déjà le journal de rejeu, et l'utilisateur perdait le début de sa
  conversation à l'écran.

- Keepalive WebSocket des postes distants (ping toutes les 25 s, des DEUX
  côtés). Sans lui, un poste au repos était coupé au bout de ~60-100 s par les
  intermédiaires qui ferment les canaux inactifs, puis reconnecté après
  backoff — les déconnexions à répétition sur tous les postes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:55 +02:00
MichaelandClaude Fable 5 2d4f5378c0 Presets : modifier le preset en service l'applique aussitôt
CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du chat
qui affiche toujours 32768. Seul le fichier du preset changeait :
config.env gardait l'ancienne valeur, le moteur tournait avec, et plus
aucun preset n'était détecté actif (empreinte différente). Il fallait
penser à rebasculer dessus à la main.

SavePresetApplying décide AVANT l'écriture si le preset édité est celui
en service (empreinte de l'ancien fichier == configuration courante) ;
si oui, la nouvelle version est installée comme une bascule
(applyPresetFile : réglages machine et moteur préservés) et le service
redémarre en arrière-plan. Un preset inactif ou nouveau reste un simple
fichier. L'UI le dit et rafraîchit l'état — la jauge de contexte suit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFhcmBMUXdK6UesdGUgFPH
2026-08-30 17:13:19 +02:00
MichaelandClaude Fable 5 9d2ff33943 Fichiers : le panneau se redessine seul
Il ne se remplissait qu'à l'ouverture ; tout ce que l'agent écrivait
ensuite (rapports, captures, scripts) et chaque pièce jointe déposée
restaient invisibles tant qu'on ne cliquait pas « rafraîchir ».

filesOnActivity, regroupé à 400 ms : appelé à chaque résultat d'outil,
en fin de tour et après un dépôt. Rien pendant le rejeu du journal ni
panneau fermé — l'ouverture recharge de toute façon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFhcmBMUXdK6UesdGUgFPH
2026-08-30 17:13:19 +02:00
MichaelandClaude Fable 5 475d523e4a Journal : outils coalescés, curseur de replay borné, export allégé
Trois maux d'une conversation agentique longue :

- Chaque appel d'outil s'écrivait en de nombreux événements (annonce,
  frappe du corps, arguments) jusqu'au done=true qui porte déjà l'état
  final. Le journal enflait jusqu'à maxLogEvents et tronquait les plus
  VIEUX événements : les premiers messages disparaissaient à l'affichage.
  Seul le done=true est conservé au compactage.
- Un événement non-outil (stats, raisonnement) glissé entre l'annonce et
  le résultat faisait émettre l'outil DEUX fois au replay et à l'export
  Markdown. L'annonce reste en attente jusqu'au done.
- Ouvrir une session plus ancienne avec le curseur de la précédente
  (Seq plus élevés) sautait tout : conversation vide. Curseur au-delà du
  dernier Seq → on repart du début.
- L'export JSON embarquait les images en base64 (fichier énorme) : les
  pièces jointes sont réduites à leur descriptif.

Repris de l'amont AJEAN v0.12.7, avec ses tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 3edd356f34 Mémoire : recherche par couverture multi-mots et rareté des termes
Un mot fréquent noyait les bonnes pages : la recherche ne comptait que
les occurrences. Elle pondère maintenant chaque terme par sa rareté dans
la mémoire et favorise les pages qui couvrent plusieurs mots de la
requête.

Repris de l'amont AJEAN v0.11.7, avec ses tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 dfdee26e4c GPU : --list-devices qui plante en OOM faisait disparaître la 2e carte
Moteur qui tourne, carte déjà pleine : l'énumération des cartes plante
en « CUDA error: out of memory » et ne rend qu'une liste tronquée — le
groupe Cartes graphiques de l'éditeur se cachait (moins de deux cartes)
et le tensor-split était perdu après un simple rechargement de l'UI,
qui vide le cache mémoire.

La dernière énumération réussie est persistée dans $LOKI_HOME/devices.json
et servie en repli (stale) quand le moteur sort en erreur. Identité, ordre
et mémoire totale sont des faits matériels stables ; seule la mémoire
libre y est périmée, sans importance pour répartir.

Repris de l'amont AJEAN v0.10.8 et v0.11.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 cfe70f2b70 Presets : un switch Raisonnement laissé sur off ne l'écrivait jamais
onchange ne part que sur une interaction : créer un preset et laisser
l'interrupteur décoché n'écrivait pas REASONING=, et la clé absente
laisse le moteur suivre le gabarit du modèle — il raisonnait malgré le
switch affiché sur off. À l'enregistrement, l'état de l'interrupteur
est désormais matérialisé : off explicite, ou on si aucune valeur active
plus précise (auto/deepseek) n'est déjà là.

Repris de l'amont AJEAN v0.12.1 (issue #46).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 b4f4135101 Presets : la section « Moteur » ne servait plus à rien
Depuis 6f6a321 le moteur est un réglage de machine : le preset ne
l'impose plus, la bascule garde le courant. En conteneur l'éditeur ne
proposait de toute façon qu'une seule case, « Image », que personne ne
pouvait changer — et le BIN figé dans le preset servait encore à lister
les cartes graphiques : un vieux preset interrogeait le moteur de
l'image au lieu du moteur mis à jour.

Le groupe disparaît de l'éditeur ; la liste des GPU interroge le moteur
courant (config_bin), mémorisé au préchauffage. CSS mort retiré.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:44:55 +02:00
MichaelandClaude Fable 5 6f6a3218ad Presets : changer de modèle ramenait le moteur de l'image
Le moteur mis à jour depuis l'interface (Réglages → Moteur, BIN →
/data/engine/server-cuda-b10680/llama-server) ne tenait pas : à la
bascule de preset suivante — changer de modèle, tâche planifiée —
/app/llama-server revenait, et le modèle qui chargeait cinq minutes
plus tôt mourait sur « unknown model architecture: 'qwen4exp' ». Le
journal alternait les deux moteurs sans qu'aucun réglage visible ait
bougé, et le panneau Moteur affichait « fourni par l'image » juste
après un « ✓ moteur mis à jour ».

Cause : chaque preset créé depuis l'UI embarque BIN (newPresetSeedKeys),
donc le chemin de l'image de l'époque, et applyPresetFile remplace TOUTE
la configuration par le preset — BIN n'était pas dans preservedKeys.

Le moteur est un réglage de machine : le courant est conservé à la
bascule, sauf si le preset désigne un backend personnalisé (compilé pour
un modèle précis — là, c'est un vrai choix par modèle, il gagne). Et un
nouveau preset ne fige plus le moteur de l'image ni un moteur téléchargé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-30 08:27:42 +02:00
MichaelandClaude Fable 5 78c258ab45 Entrypoint : le moteur mis à jour ne survivait pas à un redémarrage
docker-entrypoint.sh reposait BIN=/app/llama-server à chaque démarrage
du conteneur, sans regarder ce qu'il y avait avant. L'intention était
saine — une mise à jour d'image ne doit pas laisser un BIN obsolète —
mais elle écrasait aussi le moteur téléchargé depuis l'interface
(« mettre à jour le moteur », /data/engine/<version>/), pourtant choisi
par l'utilisateur et toujours présent sur le volume.

Symptôme vécu : Qwen3.8-Flash-Next chargeait avec server-cuda-b10680 ;
au redémarrage suivant, retour silencieux au moteur de l'image et
« unknown model architecture: 'qwen4exp' », sans qu'aucun réglage ait
bougé. Le journal alternait /app et /data/engine sans raison visible.

On ne garde BIN que s'il désigne un moteur installé sous
$LOKI_HOME/engine/ et encore exécutable ; sinon, comportement d'avant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-30 08:02:13 +02:00
MichaelandClaude Fable 5 33de4a1144 VRAM : le bilan mentait, un GET coupait le moteur, la dictée bloquait
Relecture du bouton « Libérer la VRAM » (009b585) — six défauts, tous
sur la même feature :

- gpuUsedSettled comptait une lecture ratée de nvidia-smi comme « 0 Mo »
  (pilote en réinitialisation juste après l'arrêt) : freed = before, et
  l'interface annonçait 14 Gio rendus alors que rien n'avait bougé. Elle
  rendait aussi la main au premier palier, AVANT que le pilote ait réagi
  (taskkill et Process.Kill reviennent avant le ménage CUDA) : « 0 Mo »
  après un déchargement qui marchait. gpuSettle saute les lectures
  ratées, n'accepte un palier qu'une fois la baisse observée, et est
  testable (sampler injecté).
- /api/vram/unload et /reload acceptaient GET : sans clé de pilotage,
  une balise <img> sur une page tierce suffisait à couper le moteur.
  405 + Allow: POST.
- whisperShutdown prend wsrvMu, que whisperEnsure garde jusqu'à deux
  minutes pendant un chargement de modèle : le geste dépassait le délai
  du navigateur, moteur pourtant déjà arrêté. whisperShutdownVite tue le
  processus en train de démarrer (poignée atomique hors verrou) au lieu
  d'attendre derrière lui.
- Sous systemd, une unité en crash-loop répond « activating », pas
  « active » : le stop était sauté et systemd relançait llama-server
  toutes les trois secondes pendant que l'UI disait « déjà arrêté ».
  engineNeedsStop élargit aux états transitoires.
- Un moteur planté au chargement était présenté comme « modèle
  déchargé — recharge-le » : LOAD_ERROR distingue les deux, le conseil
  renvoie vers l'erreur affichée dans le moniteur.
- Deux clics concurrents (moniteur + réglages) lançaient un stop au
  milieu d'un start ; VRAM_BUSY fait verrou, et le bouton se repeint
  depuis l'état renvoyé par le serveur, pas depuis l'ancien poll.
  Quelques Mo de bruit entre deux lectures ne font plus « VRAM libérée :
  0.0 Gio ».

Au passage, le 404 d'un téléchargement nomme le fichier manquant : sur
un dépôt qui publie six fragments sur sept (table PLE livrée à part),
« la révision a pu être réécrite » envoyait chercher au mauvais endroit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-29 17:53:14 +02:00
MichaelandClaude Opus 5 009b585e75 VRAM : un bouton pour décharger le modèle et rendre la carte
Le modèle occupe la mémoire vidéo tant que le moteur tourne : une autre
application qui réclame la carte (jeu, encodage, autre serveur d'inférence)
ne trouvait plus rien à prendre. Le geste existait — « arrêter » le service,
au fond des réglages — mais son nom ne disait pas qu'il libérait la VRAM, et
il laissait tourner le serveur de dictée, qui garde la carte lui aussi.

- POST /api/vram/unload : arrête le moteur ET la dictée, attend que le pilote
  rende la mémoire (une lecture immédiate rapporte « 0 Mo libérés » après un
  déchargement pourtant réussi) et renvoie le bilan chiffré. Refuse pendant une
  génération, sauf {force:true} — la couper perdrait la réponse en cours.
- POST /api/vram/reload : relance le moteur, préflight compris (BIN/MODEL
  absents = la vraie raison tout de suite, pas un « chargement… » sans fin).
- Bouton sur les jauges du moniteur, là où l'on regarde la VRAM ; il devient
  « Recharger le modèle » dès que le moteur est arrêté, d'après /api/status et
  non d'un drapeau local (un second onglet afficherait sinon un bouton qui ment).
- Même commande dans Réglages → Moteur → Service du moteur.
- Carte de saisie : « Modèle déchargé — Recharger le modèle » au lieu de
  « Le modèle charge », qui promettait un chargement qui ne viendrait jamais.

La lecture nvidia-smi de /api/vram passe dans web_vram.go (gpuStats), partagée
avec le bilan du déchargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8n9tDn5sZudAcUT8U6uGg
2026-08-26 20:18:09 +00:00
Claude 895aa67366 Presets : le bouton « + » ne s'affichait jamais
.preset-add est en display:none par défaut, et n'était révélé que par
« details[open]>summary>.preset-add ». Les panneaux de réglages ont
migré vers <section class="set-pane"><div class="set-pane-h">, donc ce
sélecteur ne matchait plus : le « + » des Presets restait invisible et
il n'y avait plus aucun moyen d'en créer un depuis l'interface.

Le même bouton dans « Discussions » est resté visible, lui, parce qu'il
vit encore dans un <details open><summary> — d'où un défaut qui ne
touchait que les presets.

Règle ajoutée pour le nouveau conteneur, puis UI réassemblée
(tools/assemble-ui).

Vérifié dans l'application lancée : display passe de none à block, et le
« + » apparaît dans l'en-tête du volet Presets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-08-23 12:56:17 +00:00
LogiFlow 08eaf10a34 Merge pull request #23 from R0m1k3/fix/arg-scope-dockerfile
Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
2026-08-22 04:16:09 +02:00
MichaelandClaude Fable 5 e177d54e39 Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
Le build de 51 minutes a échoué au lien final : « libcuda.so.1 not found »,
références cuMem* non résolues, et une libggml-cuda.so PARTAGÉE alors que
BUILD_SHARED_LIBS=OFF est censé être passé. Ce dernier détail était le vrai
indice : les drapeaux n'atteignaient pas cmake du tout.

ARG WHISPER_CMAKE_FLAGS="…" était déclaré entre deux étapes. Règle Docker :
un ARG posé après un FROM appartient à l'étape où il apparaît ; les « ARG »
nus des étapes whisperbuild-* héritent, eux, du scope GLOBAL — où rien
n'était défini. Valeur vide, cmake sans aucun drapeau : ggml en bibliothèques
partagées (échec de lien contre les stubs du pilote), et surtout
-march=native — le SIGILL de la PR #18 revenu en silence sur les deux
binaires. Les tests du Dockerfile n'y voyaient rien : ils vérifiaient le
texte, pas les règles de portée de Docker.

- L'ARG remonte avant le premier FROM, à côté de LLAMACPP_IMAGE.
- Chaque étape vérifie désormais SON binaire : un ldd qui montre libggml ou
  libwhisper en dynamique fait échouer le build sur-le-champ, au lieu de
  laisser partir un binaire qui ne trouvera pas ses .so dans l'image finale.
- TestDockerfileArgFlagsGlobal verrouille la position de l'ARG, ancré en
  début de ligne — une première version se laissait berner par une
  occurrence en commentaire, sa contre-épreuve l'a montré.

libcuda.so.1 reste une dépendance dynamique normale du binaire CUDA : c'est
le pilote, injecté à l'exécution par le NVIDIA Container Toolkit, et
l'édition de liens la résout via les stubs du toolkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-22 04:12:52 +02:00
LogiFlow 66b53c8255 Merge pull request #22 from R0m1k3/fix/workflow-yaml-double-cle
Workflow : clé YAML dupliquée, plus aucun build ne partait
2026-08-21 23:07:17 +02:00
MichaelandClaude Fable 5 3991e68fc9 Workflow : clé YAML dupliquée, plus aucun build ne partait
Depuis la fusion de la PR #21, chaque push sur main échouait en moins d'une
minute avec zéro job lancé : le YAML du workflow était devenu invalide.

La PR #21 ajoutait cache-from/cache-to après build-args, alors que le bloc
with: les portait DÉJÀ en fin de liste. Une clé dupliquée dans un mapping
YAML fait rejeter le workflow avant même son démarrage — d'où des échecs
immédiats, sans le moindre journal.

Rectification au passage : le cache n'était pas absent, il était déjà actif.
Les trente-sept minutes venaient de la nouveauté de l'étape CUDA (cache
froid) et de l'absence de borne d'architectures, corrigée elle pour de bon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:36:37 +02:00
LogiFlow cc218ccfd7 Merge pull request #21 from R0m1k3/fix/cuda-blackwell-et-cache
Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
2026-08-21 22:33:06 +02:00
MichaelandClaude Opus 5 d8ed86bbba Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
Le build ne mourait plus, mais tournait encore après trente-sept minutes. Et
il aurait produit un binaire inutilisable sur la moitié du matériel visé.

Les GPU de la machine cible sont une RTX 5060 Ti (Blackwell, sm_120) et une
RTX 3060 (Ampere, sm_86). L'étape de compilation était épinglée sur
nvidia/cuda:12.4.1 : ce nvcc-là ne CONNAÎT pas Blackwell. Il refuse
l'architecture, et le binaire ne tournerait au mieux que par recompilation PTX
au chargement. 12.8.1 sur Ubuntu 24.04 est désormais utilisé — exactement ce
avec quoi llama.cpp bâtit l'image amont qui fournit libcudart.

Pour la durée, deux causes distinctes :

- Sans borne, ggml compile pour TOUTES les architectures qu'il connaît, de
  Maxwell à Blackwell. CMAKE_CUDA_ARCHITECTURES ramène le travail à quatre
  (Turing → Blackwell), et l'ARG CUDA_ARCHS permet d'élargir sans toucher au
  fichier pour un GPU plus ancien.
- Surtout : ce binaire ne dépend d'AUCUN fichier du dépôt, et il était pourtant
  recompilé à chaque push. Le cache de couches GitHub Actions le réutilise tant
  que ses lignes du Dockerfile ne bougent pas ; seuls le code Go et
  l'assemblage de l'image se refont.

Un test vérifie le plancher CUDA et la présence de la borne d'architectures :
ces deux fautes ne se voient pas à la lecture et coûtent quarante minutes à
découvrir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:25:53 +02:00
LogiFlow 748328db25 Merge pull request #20 from R0m1k3/fix/build-cuda-oom
Image : le build CUDA était tué par l'OOM, pas en échec de compilation
2026-08-21 21:44:47 +02:00
MichaelandClaude Opus 5 8e88a73d91 Image : le build CUDA était tué par l'OOM, pas en échec de compilation
Le premier build de l'image avec whisper CUDA a échoué après huit minutes
sans écrire une seule ligne d'erreur : le journal s'arrête à 92 % de la
compilation, puis « Complete job ». Un compilateur qui échoue dit pourquoi ;
un compilateur tué ne dit rien.

Ces 92 % étaient atteints en TREIZE secondes. Ce ne sont pas des compilations
terminées, ce sont des nvcc lancés simultanément.

« cmake --build -j » sans nombre autorise chez Make un parallélisme illimité.
ggml-cuda compte environ 200 fichiers d'instanciation de gabarits et chaque
nvcc réclame 1 à 2 Go : le runner (16 Go) mourait d'un OOM. L'étape CPU y
survivait — peu de fichiers, compilation légère — ce qui a rendu le piège
invisible jusqu'à l'arrivée de CUDA.

- -j"$(nproc)" sur les deux étapes, et un test le vérifie désormais : cette
  faute est indétectable à la lecture et ne se manifeste qu'en CI.
- Le runner libère dotnet, android et ghc avant de bâtir. L'empilement
  nvidia/cuda-devel + runtime CUDA + Chromium approche les 14 Go disponibles ;
  cette part-là reste une hypothèse, mais elle coûte une minute à écarter.
- whisper-server CUDA est strippé : les symboles de débogage d'un binaire
  ggml-cuda pèsent plusieurs centaines de mégaoctets dans l'image finale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:35:42 +02:00
LogiFlow f4969de9f7 Merge pull request #19 from R0m1k3/feat/dictee-temps-reel
Dictée : whisper-server supervisé, modèle et GPU au choix
2026-08-21 21:22:18 +02:00
MichaelandClaude Opus 5 5d391c4f73 Dictée : panneau de réglages — GPU, modèle, langue, test du micro
Le choix du matériel et du modèle n'existait nulle part : la dictée prenait
le seul modèle codé en dur et le seul chemin possible. Sur une machine à
plusieurs GPU, rien ne permettait de dire lequel prêter à whisper.

Le panneau liste les GPU avec leur VRAM libre (/api/vram donne déjà tout), et
chaque modèle avec sa taille de téléchargement et sa mémoire. Un modèle absent
le dit dans sa propre étiquette : le choisir n'est pas un réglage instantané
mais un transfert de plusieurs centaines de mégaoctets, et le taire ferait
passer l'attente pour une panne.

Le test du micro capte trois secondes, affiche le niveau mesuré puis la
transcription, sans toucher au champ de saisie. Vu de l'extérieur, un micro
muet, un niveau trop faible et un moteur en panne se ressemblent tous — il
fallait bricoler ce diagnostic à la main pour les distinguer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:11:21 +02:00
MichaelandClaude Opus 5 3cd4facb0e Dictée : whisper-server supervisé, modèle chargé une seule fois
Chaque dictée relançait whisper-cli, qui relisait le modèle depuis le disque
avant de transcrire. Ce chargement dominait le temps de réponse — 3,2 s de
calcul pour 3,4 s d'audio — et aucun réglage ne pouvait le rattraper.

whisper-server garde le modèle en mémoire entre deux phrases. Loki le
supervise : démarrage au premier clic sur le micro, extinction après dix
minutes sans dictée. Une dictée n'est pas un service permanent, et garder un
modèle chargé toute la journée priverait le moteur de chat de sa VRAM.

- Réglages serveur (modèle, langue, matériel, réactivité) dans bkState, avec
  leurs routes. La langue par défaut passe de « auto » à « fr » : sur quelques
  secondes d'audio la détection se trompe, et une langue mal détectée produit
  du charabia — des suites de caractères géorgiens ont été observées.
- Catalogue de quatre modèles, de small à large-v3 ; large-v3-turbo par
  défaut. Téléchargement par identifiant, dans un .part renommé à la fin : un
  transfert interrompu ne laisse plus un .bin tronqué qu'on croirait bon.
- Le GPU se choisit par CUDA_VISIBLE_DEVICES, posé en REMPLAÇANT toute valeur
  héritée. Dupliquée, la variable laisse le gagnant dépendre de la libc.
- Les marqueurs de whisper ([BLANK_AUDIO], (silence)) ne sont plus collés dans
  le champ de saisie comme s'ils étaient du texte dicté.

L'image construit DEUX binaires whisper-server, CPU et CUDA. LLAMACPP_IMAGE
accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
absentes et un binaire lié à CUDA n'a même pas de quoi démarrer, donc aucun
repli n'est possible depuis le programme. Le garde-fou du Dockerfile vérifie
maintenant les deux étapes : les drapeaux de portabilité de la PR #18 doivent
survivre à l'arrivée de CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:08:12 +02:00
MichaelandClaude Opus 5 3624194b39 Plan : moteur de dictée supervisé et configurable
Sept tâches, de la persistance des réglages au panneau de l'interface. Le
temps réel (WebSocket + découpeur) fait l'objet d'un second plan : ce plan-ci
livre déjà un logiciel utilisable — bon modèle, GPU au choix, lenteur
supprimée — sans le direct.

Contraintes relevées dans le dépôt et inscrites en tête du plan : la CI
compile pour Windows (donc pas de Setsid), staticcheck doit rester à zéro, et
index.html est généré par tools/assemble-ui, jamais édité à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:00:43 +02:00
MichaelandClaude Opus 5 2fa05fdaf7 Conception : dictée en temps réel, configurable selon le matériel
La dictée marche depuis la PR #18 mais comprend mal et n'écrit qu'à l'arrêt du
micro. La lenteur est structurelle : chaque requête relance whisper-cli, qui
recharge le modèle depuis le disque. Découper en tranches par-dessus ce
mécanisme reviendrait à recharger le modèle toutes les quelques secondes.

La conception retient whisper-server (modèle chargé une fois, supervisé par
Loki comme llama-server), un découpage en tranches calé sur les silences et
piloté côté Go, et un panneau Dictée pour choisir GPU, modèle, langue et
réactivité.

Deux points tranchés en cours de route :

- Deux binaires whisper-server, CPU et CUDA, pas un seul. LLAMACPP_IMAGE
  accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
  absentes et un binaire lié à CUDA n'a même pas de quoi démarrer. Un binaire
  unique condamnerait cette variante.
- Le seuil de silence est adaptatif. Un seuil fixe ne coupe jamais dans une
  pièce calme avec un micro discret, et coupe sans arrêt près d'un ventilateur.

Les drapeaux GGML_NATIVE=OFF restent exigés sur les deux cibles : le SIGILL de
la PR #18 ne doit pas revenir par la porte du build CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 17:18:49 +02:00
LogiFlow e0ed0a32af Merge pull request #18 from R0m1k3/fix/whisper-binaire-non-portable
Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
2026-08-21 16:30:48 +02:00
MichaelandClaude Opus 5 a19b490b6a Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
La dictée n'a jamais transcrit une seule phrase. Le micro enregistrait bien —
contexte HTTPS, autorisation accordée, bouton rouge — mais le serveur répondait
500 sur chaque envoi :

    whisper-cli : AMX is not ready to be used!
    read_audio_data: reading audio data from '/tmp/loki-dictee-...wav' ...
    read_audio_data: trying to decode with miniaudio

Deux indices dans ces trois lignes. « AMX is not ready to be used » ne sort que
d'un binaire COMPILÉ avec les instructions AMX, celles d'un Xeon récent — le
runner GitHub. Et le journal s'arrête net : ni « failed to decode », ni texte
transcrit. Le processus ne renvoie pas une erreur, il meurt.

ggml compile en -march=native par défaut. Le binaire partait donc taillé pour
le processeur qui l'avait construit, et rencontrait une instruction illégale
dès qu'on le posait ailleurs.

- GGML_NATIVE=OFF à l'étape whisperbuild, AVX-512 et AMX explicitement coupés.
  Reste la ligne de base AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis
  2013. Un test lit le Dockerfile et garde ces drapeaux : rien à l'exécution ne
  rappellerait leur raison d'être au prochain qui touchera cette étape.
- Le handler ne renvoyait que la dernière ligne de stderr. Sur une mort par
  signal, cette ligne est celle d'AVANT le coup fatal : elle ressemble à une
  explication sans en être une, et envoie chercher du côté de l'audio. Il dit
  maintenant comment le processus a fini — « signal: illegal instruction » vaut
  diagnostic à lui seul.

L'image doit être reconstruite : whisper-cli y est compilé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 16:27:46 +02:00
LogiFlow 393986faa6 Merge pull request #17 from R0m1k3/fix/ngl-sentinelle-999-auto
NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
2026-08-21 10:45:26 +02:00
MichaelandClaude Opus 5 2585d2c2e1 NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
Le correctif précédent ne pouvait pas s'appliquer. Il ne posait « -ngl auto »
que si la clé NGL était ABSENTE de config.env — or defaultConfig() y sème
NGL=999 sur chaque installation neuve. La quasi-totalité des configurations la
portent donc sans que personne ne l'ait choisie, le code la lisait comme un
choix délibéré, et l'abandon revenait intact :

  W common_fit_params: failed to fit params to free device memory:
    n_gpu_layers already set by user to 999, abort

999 n'a jamais été un nombre de couches : c'est la sentinelle historique
« toutes ». Sur un moteur qui sait mesurer la VRAM libre, elle devient donc
« -ngl auto », et Loki le dit sur stderr plutôt que de le faire en douce. Tout
autre nombre reste intouché — c'est un vrai choix. NGL=all force l'ancien
comportement, NGL=auto n'envoie toujours aucun drapeau, et un moteur ancien
reçoit toujours 999 (l'omettre le ferait tourner 100 % CPU).

- La décision sort dans nglArgs(), fonction pure : la seule question qui
  demande le moteur arrive déjà tranchée, donc elle se teste sans lancer
  llama-server. 10 cas couverts.
- binFitsLayersItself() double la lecture fine de l'aide par la présence de
  --load-mode. Les deux sont arrivés dans la même vague ; une description
  reformulée ou une colonne plus large ne doit pas faire conclure « moteur
  incapable » et réimposer 999.
- L'aide de la clé et le sous-titre du champ disent la nouvelle règle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:47:32 +02:00
LogiFlow fedbaf32a0 Merge pull request #16 from R0m1k3/fix/llamacpp-deprecated-flags-dictee
Moteur : drapeaux de chargement à jour, -ngl auto ; dictée qui dit po…
2026-08-21 09:26:46 +02:00
179 changed files with 26072 additions and 1145 deletions

No files matched your search

+14
View File
@@ -15,6 +15,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
- name: Libérer de l'espace disque
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
@@ -45,5 +56,8 @@ jobs:
LOKI_VERSION=${{ github.sha }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
+44 -15
View File
@@ -14,6 +14,17 @@
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
#
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
ARG SHERPA_ONNX_VERSION=v1.13.7
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
FROM golang:1.25 AS gobuild
WORKDIR /src
@@ -31,19 +42,37 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 bis : whisper-cli (dictée vocale) ───────────────────────────
# whisper.cpp compilé CPU seul, en statique : un binaire unique, aucune .so à
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
FROM ubuntu:22.04 AS whisperbuild
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
# plus d'une fois) laissent place à une extraction de 35 Mo.
#
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
#
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
FROM debian:12-slim AS asrfetch
ARG SHERPA_ONNX_VERSION
ARG SHERPA_ONNX_SHA256
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
curl ca-certificates bzip2 \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
&& cmake --build /w/build -j --target whisper-cli
RUN set -eu; \
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
curl -fsSL -o /tmp/sherpa.tar.bz2 \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
mkdir -p /out; \
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
chmod +x /out/sherpa-onnx-offline-websocket-server; \
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
@@ -98,9 +127,9 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
# Dictée vocale (POST /api/transcribe). Le modèle (~190 Mo) n'est PAS dans
# l'image : téléchargé au premier usage dans /data/whisper/.
COPY --from=whisperbuild /w/build/bin/whisper-cli /usr/local/bin/whisper-cli
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
@@ -109,7 +138,7 @@ ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_WHISPER_BIN=/usr/local/bin/whisper-cli \
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
+25 -1
View File
@@ -26,7 +26,31 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales.
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
son propre contexte), la **vérification déclenchée quand le builder a
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
qui garde l'état du mode Code.
## Reprises depuis l'amont après le fork
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
présent dans l'image) et la **préparation des images** envoyées au modèle
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
## Services externes
+90 -4
View File
@@ -122,6 +122,17 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_HOME` | données (volume) | `/data` |
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
toute requête venue d'un autre site : une page web ouverte dans un navigateur
du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière
un reverse proxy (`loki.mondomaine.fr`), définir une clé
(`docker exec -it loki loki set-web-key`) ou lister le nom dans
`LOKI_TRUSTED_HOSTS`.
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
@@ -137,7 +148,9 @@ et modèles compris.
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
| `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
@@ -175,11 +188,29 @@ Héritées d'AJEAN :
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
conversations. La **vision** demande un modèle multimodal *et* son projecteur
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
grossit sans rien apprendre de plus au modèle.
- **Mémoire persistante** (`memory off|ondemand|always`).
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
agent**, au même niveau de confiance que `bash`. La session navigateur est
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
@@ -195,7 +226,48 @@ Héritées d'AJEAN :
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
tâche attend son tour, et le bouton stop du chat l'interrompt.
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
pilote que les tâches de ce projet.
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
le reste sous `/data` ; le corps de la notification reste générique (aucun
extrait de réponse), puisqu'elle transite par Apple ou Google.
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
discussions, blocs archivés au compactage et trackers chiffrés en
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
interrompue reprend au démarrage.
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
reste chez toi.
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
française et « English » applique un dictionnaire sur la coque — navigation,
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
français** plutôt que d'afficher une clé technique, et le fil de discussion
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
@@ -228,7 +300,15 @@ Ajoutées par ce fork :
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
jamais bascule automatique. Conception reprise
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
`NOTICE.md`.
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
appeler un autre.
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
@@ -290,6 +370,12 @@ Ajoutées par ce fork :
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
visibles en pied de colonne.
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
du moteur. Conséquence directe : l'API est joignable partout où l'interface
+74 -34
View File
@@ -1,50 +1,90 @@
# Loki 0.12.0
# Loki 0.14.0
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
Loki rattrape AJEAN jusqu'à la 0.17.6 et reprend une série d'idées d'OpenFox
pour le mode Code. Le gros du travail est invisible : des discussions qui ne se
bloquent plus sur un contexte plein, des réponses qui survivent à une coupure
réseau, un agent de code qui gaspille moins de tours.
## Le sélecteur de l'en-tête charge vraiment un modèle
## Le contexte ne bloque plus la discussion
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
créé, le sélecteur n'offrait aucun choix.
Sur une fenêtre de 65k, un tour qui lisait plusieurs gros fichiers d'un coup
passait de 60 % à plus de 130 % sans jamais compacter, et le moteur répondait
par un 400 qui figeait la discussion.
## Dictée vocale
- Le compactage en cours de tour compte aussi les résultats d'outils que le
moteur n'a pas encore vus.
- Tout résultat d'outil est borné à 30 000 caractères **pour le modèle** ;
l'interface garde le résultat complet (« voir plus »).
- En dernier recours, les gros résultats sont tronqués puis les plus vieux
échanges retirés, au lieu de laisser remonter l'erreur.
- Le compactage ne fait plus répondre deux fois à une vieille question, et le
texte écrit avant un appel d'outil n'est plus rangé en double.
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
## Presets : API compatible OpenAI
## Cartes raisonnement/outils : taille fixe, défilement qui suit
Les presets d'API externe gagnent ce qui leur manquait :
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
génération — remonter lire le début est respecté. Les cartes d'outil suivent
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
texte complet est posé à la fin) : l'affichage ne se fige plus.
- une case **« le modèle accepte les images »** pour un modèle distant
multimodal ;
- plus de `chat_template_kwargs` (propre à llama.cpp) envoyé à l'API : une API
stricte répondait 400 et **chaque compactage échouait** ;
- **reprise automatique** quand le flux se coupe en pleine réponse (Wi-Fi, VPN,
proxy) : jusqu'à 5 fois, le modèle reprend là où il s'était arrêté ;
- un 401, 429 ou 502 n'est plus pris pour un appel d'outil mal formé ;
- le débit est mesuré même quand l'API ne le donne pas ; le benchmark refuse de
mesurer un moteur local qui n'est pas celui du preset ; le badge des réponses
porte le nom du modèle distant.
## Mesures
## Mode Code
- La carte du temps total affiche la **vitesse moyenne de la conversation**
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
seulement les octets lus).
- **La vérification attend que le builder ait fini.** Il marque chaque critère
`completed` une fois fait ; tant qu'il en reste d'ouverts, il est relancé au
lieu de payer une passe de vérification sur un travail inachevé. Plus aucune
vérification ne part quand il vient de poser une question.
- **Écriture refusée dès son chemin** : un `write` sur un fichier jamais lu
était refusé après tout le fichier généré. Le chemin passe maintenant en
premier et le refus coupe la génération aussitôt.
- **Appel d'outil écrit en texte** (`<tool_call>`, format XML de Qwen3-Coder) :
repéré pendant le flux, coupé, et la relance cite l'extrait fautif.
- `read_file`, `str_replace`, `old_string`… appris d'autres agents sont
traduits vers les vrais outils.
- Les consignes du dépôt (**AGENTS.md**, CLAUDE.md) font partie du contexte.
- Terminal : `cmd | tail -N` rend le **vrai** code de sortie, la sortie déjà
produite est gardée au délai dépassé, les couleurs ANSI sont retirées.
- `git_status` / `git_diff` trouvent le dépôt cloné dans un sous-dossier.
- Le compactage garde les fichiers touchés, les erreurs résolues et l'état des
critères.
## Interface
## Tâches et chat
- Discussions triées par date de **création** (récentes en tête) : une
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
Performance.
- **« Rappelle-moi dans 20 minutes »** : `task_create` accepte `in_minutes` ou
`at`, à l'heure du navigateur (le conteneur tourne en UTC).
- **Écrire pendant que l'IA répond** : le message part en file et le modèle en
tient compte dans la suite de sa réponse ; deux appareils qui envoient en
même temps ne se refusent plus.
- Longues discussions : au chargement, les 20 derniers échanges, le début d'un
clic ; la liste des discussions se dessine par pages et montre la nouvelle
dès le premier message.
- Mémoire : un mode par projet, et un quatrième, « recherche ».
- Appels d'outils parallèles séparés selon leur index ; bascule de preset par
identifiant ; un raisonnement qui reprend après la réponse a sa propre bulle.
## Rappel 0.11.0
## Sécurité et données
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
l'interface ; tâches planifiées reprises de l'amont.
- **Un site tiers ne peut plus piloter Loki** depuis le navigateur (Origin /
Sec-Fetch-Site, DNS rebinding). ⚠️ Derrière un reverse proxy par nom de
domaine, définis une clé de pilotage ou `LOKI_TRUSTED_HOSTS` **avant** la mise
à jour, et vérifie que le proxy transmet l'en-tête `Host` d'origine.
- **Chiffrement de la mémoire** : l'activer rendait illisibles la discussion
active et le mode Code. Corrigé ; une base déjà touchée est réparée au
déverrouillage. Les résultats d'outils (« voir plus ») et les images du fil
sont désormais chiffrés eux aussi, et les images effacées après 24 h.
## Moteur
- `--mlock` seul ne coupe plus le mmap (OOM au chargement).
- Port déjà occupé refusé avec un message clair ; cache KV lent signalé.
- Réponses web compressées en gzip.
## Mise à jour
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Minor": 14,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 12,
"Patch": 3,
"Minor": 14,
"Patch": 0,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.12.3",
"ProductVersion": "0.14.0",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
@@ -34,4 +34,4 @@
"CharsetID": "04B0"
}
}
}
}
+19 -1
View File
@@ -18,7 +18,25 @@ fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
#
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
# chargeait la veille ne chargeait plus le lendemain, « unknown model
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
# le vide.
cur=$(loki config get BIN)
case "$cur" in
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
*) cur="" ;;
esac
if [ -n "$cur" ]; then
echo "[entrypoint] moteur mis à jour conservé : $cur"
else
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
fi
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,187 @@
# Dictée en temps réel, configurable selon le matériel
*Conception validée le 21 août 2026.*
## Le problème
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
défauts la rendent pénible :
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
un rechargement complet du modèle toutes les quelques secondes.
## Ce qu'on construit
Trois choses, dans cet ordre de dépendance :
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
## 1. Le moteur
### whisper-server plutôt que whisper-cli
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
Loki le supervise comme il supervise déjà `llama-server`.
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
modèle ; les suivants sont immédiats.
### Choix du GPU
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
pas de code à maintenir dans ggml :
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
- valeur vide → CPU seul
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
### Compilation : deux binaires, pas un
L'étape `whisperbuild` produit **deux** binaires :
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
CUDA condamnerait donc la variante CPU de l'image.
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
le disant, plutôt que de laisser la dictée morte.
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
## 2. Le découpage
### On coupe dans les silences, jamais à heure fixe
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
l'améliorer.
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
est borné par la coupure forcée.
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
pas voir le texte se figer.
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
dont personne ne connaît l'interaction :
| Réglage | Réserve minimale | Coupure forcée |
|---|---|---|
| Court | 1 s | 5 s |
| Moyen (défaut) | 1,5 s | 8 s |
| Long | 3 s | 15 s |
### La logique vit en Go
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
Et le navigateur redevient bête : capter, envoyer, afficher.
### Contrat de la socket
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
sous les yeux.
### Langue forcée par défaut
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
## 3. Le panneau Dictée
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
« Identité ». Suit le gabarit des panneaux existants.
| Réglage | Contenu |
|---|---|
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
| **Langue** | Français par défaut, auto et autres langues disponibles |
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
progression et suppression, en généralisant le mécanisme mono-modèle existant.
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
le jeton Hugging Face.
## Les pannes, nommées
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
d'échouer sec
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
l'utilisateur est prévenu
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
dans la mauvaise direction.
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
## Tests
En Go, sur ce qui peut réellement casser :
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
modèle, langue
- **l'aller-retour des réglages** dans `bkState`
- **l'extinction sur inactivité**
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
## Ce que les tests ne prouveront pas
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
échantillon de voix n'est disponible côté développement. Les tests établissent que le
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
`small` reste une constatation d'usage.
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
+3 -1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
codeberg.org/readeck/go-readability/v2 v2.1.2
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/caddyserver/certmagic v0.25.4
github.com/coder/websocket v1.8.15
github.com/getlantern/systray v1.2.2
@@ -13,6 +14,7 @@ require (
go.etcd.io/bbolt v1.5.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
)
require (
@@ -28,6 +30,7 @@ require (
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/itlightning/dateparse v0.2.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -45,7 +48,6 @@ require (
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
)
+67
View File
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+2 -1
View File
@@ -10,6 +10,7 @@ Method — in this order:
Rules:
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
- Write each file COMPLETE in a single write call. Many small writes waste turns.
- You may NOT mark a criterion passed — the verification pass does that.
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
+132
View File
@@ -0,0 +1,132 @@
package loki
// backend_external.go — presets « externes » : au lieu de lancer un llama-server
// local, un tel preset route le chat vers une API OpenAI-compatible distante
// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…).
// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du
// modèle et la clé d'accès.
//
// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un
// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans
// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt
// système par preset, réglages d'échantillonnage. La différence ne vit qu'à
// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment
// de la bascule (aucun moteur à redémarrer).
//
// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni
// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche,
// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de
// compaction côté Loki.
import (
"fmt"
"strings"
)
const (
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
type chatEndpoint struct {
URL string // URL complète des complétions
Model string // nom de modèle envoyé dans le payload
Key string // Bearer ("" = aucun)
External bool // true = API distante (pas le llama-server local)
}
// isExternalConfig indique si une configuration décrit un endpoint externe.
func isExternalConfig(cfg map[string]string) bool {
return strings.TrimSpace(cfg[extKeyFlag]) == "1"
}
// externalActive : le preset actif est-il un endpoint externe ?
func externalActive() bool { return isExternalConfig(ReadConfig()) }
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
// preset dit si le modèle accepte les images.
func externalVisionActive() bool {
cfg := ReadConfig()
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
}
// completionsURL normalise l'URL saisie en une URL de complétions complète.
// Accepte les trois formes qu'on colle en pratique :
//
// - une URL déjà complète (…/chat/completions) — laissée telle quelle ;
// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ;
// - autre chose — on suppose une racine et on ajoute /v1/chat/completions.
//
// Exiger la forme exacte serait une source d'échecs muets : la page d'un
// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL
// complète, et se tromper ne produit qu'un 404 illisible.
func completionsURL(raw string) string {
u := strings.TrimRight(strings.TrimSpace(raw), "/")
if u == "" {
return ""
}
if strings.HasSuffix(u, "/chat/completions") {
return u
}
if strings.HasSuffix(u, "/v1") {
return u + "/chat/completions"
}
return u + "/v1/chat/completions"
}
// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le
// preset actif en est un, sinon le llama-server local.
func resolveChatEndpoint() chatEndpoint {
cfg := ReadConfig()
if isExternalConfig(cfg) {
return chatEndpoint{
URL: completionsURL(cfg[extKeyURL]),
Model: strings.TrimSpace(cfg[extKeyModel]),
Key: strings.TrimSpace(cfg[extKeyToken]),
External: true,
}
}
return chatEndpoint{
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
Model: "loki",
Key: readAPIKey(),
}
}
// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir
// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL,
// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même
// temps qu'un 401 garanti.
func (e chatEndpoint) auth(set func(k, v string)) {
if e.Key != "" {
set("Authorization", "Bearer "+e.Key)
}
}
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
// compaction, exactement comme pour un preset local. vision = le modèle distant
// accepte les images (EXTERNAL_VISION=1).
func externalPresetContent(url, model, key, ctx string, vision bool) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
extKeyModel: strings.TrimSpace(model),
}
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if vision {
m[extKeyVision] = "1"
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
return formatEnv(m)
}
+245
View File
@@ -0,0 +1,245 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
cas := map[string]string{
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
"": "",
}
for in, want := range cas {
if got := completionsURL(in); got != want {
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
}
}
}
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
// llama-server local avec la clé LOCALE.
func TestEndpointSuitLePresetActif(t *testing.T) {
testHome(t)
if err := SetConfigKey("PORT", "9999"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.External {
t.Fatal("aucun preset externe : l'endpoint doit rester local")
}
if ep.URL != "http://localhost:9999/v1/chat/completions" {
t.Fatalf("URL locale = %q", ep.URL)
}
if ep.Model != "loki" {
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
if !ep.External {
t.Fatal("EXTERNAL=1 non reconnu")
}
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
t.Fatalf("URL externe = %q", ep.URL)
}
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
}
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
// et le seuil de compaction, exactement comme pour un preset local.
if got := ReadConfig()["CTX"]; got != "65536" {
t.Fatalf("CTX = %q, attendu 65536", got)
}
}
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
// un secret en même temps qu'un 401 garanti.
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.Key != "" {
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
}
got := map[string]string{}
ep.auth(func(k, v string) { got[k] = v })
if len(got) != 0 {
t.Fatalf("en-tête posé sans clé de preset : %v", got)
}
}
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
func TestReconnaissanceDunPresetExterne(t *testing.T) {
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
t.Error("EXTERNAL=1 non reconnu")
}
for _, v := range []string{"", "0", "off", "false"} {
if isExternalConfig(map[string]string{extKeyFlag: v}) {
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
}
}
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
t.Error("un preset local est pris pour un externe")
}
}
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
// l'intérêt de tester avant d'enregistrer.
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{}})
}))
t.Cleanup(srv.Close)
post := func(body string) map[string]any {
w := httptest.NewRecorder()
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return out
}
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
if out["ok"] == true {
t.Fatal("clé refusée annoncée comme valide")
}
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
}
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
t.Fatalf("clé valide refusée : %v", out)
}
}
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, err := ReadPreset(id)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(content)
if cfg[extKeyToken] != "sk-gardee" {
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
}
if cfg[extKeyModel] != "llama-3.3" {
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
}
// Et la clé ne ressort jamais en clair par la route de lecture.
w = httptest.NewRecorder()
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
if strings.Contains(w.Body.String(), "sk-gardee") {
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
}
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
if out["hasKey"] != true {
t.Fatal("hasKey doit signaler la présence d'une clé")
}
}
// Un preset externe demande explicitement d'écraser la clé par une vide.
func TestEffacementExpliciteDeLaCle(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, _ := ReadPreset(id)
if k := parseEnv(content)[extKeyToken]; k != "" {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
// échouait à chaque fois.
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["chat_template_kwargs"]; ok {
w.WriteHeader(400)
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compactage refusé par l'API distante : %v", err)
}
}
// Vision et benchmark d'un preset externe : la case du preset décide, et le
// bench (qui mesure le moteur local) refuse de tourner.
func TestPresetExterneVisionEtBench(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
t.Fatal(err)
}
if !visionEnabled() || !engineSeesImages() {
t.Error("preset externe déclaré multimodal : la vision devrait être active")
}
if _, err := runBench(10, 10); err == nil {
t.Error("benchmark lancé sur un preset externe")
}
if got := chatModelName(); got != "gpt-4o" {
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
}
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
t.Fatal(err)
}
if visionEnabled() {
t.Error("preset externe texte seul : la vision devrait être coupée")
}
}
+85 -11
View File
@@ -201,38 +201,70 @@ func modelFromPresetContent(content string) string {
// n'effacer que la première laissait des dizaines de Go de fichiers que plus
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
// tranches suivantes n'y apparaissent pas).
func deleteModelFile(name string) error {
//
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
// un ménage de disque.
//
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
func deleteModelFile(name string) (int64, error) {
p, err := resolveModelPath(name)
if err != nil {
return err
return 0, err
}
if _, active := modelUsers(name); active {
return 0, fmt.Errorf("%s", modelInServiceMsg)
}
freed := modelFamilySize(p)
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
}
return err
return 0, err
}
dir := filepath.Dir(p)
for _, n := range shardFamily(filepath.Base(p)) {
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
}
return nil
return freed, nil
}
// handleModelDelete deletes a single .gguf from LOKI_HOME.
// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
//
// Deux garde-fous, et un seul est un refus définitif :
//
// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
// même requête avec force:true. Refuser tout court transformerait le
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Name string `json:"name"`
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := deleteModelFile(req.Name); err != nil {
users, active := modelUsers(req.Name)
if active {
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
return
}
if len(users) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
return
}
freed, err := deleteModelFile(req.Name)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
}
// ---- Hugging Face downloads -------------------------------------------------
@@ -382,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
}
if len(todoURLs) == 0 {
dlMu.Unlock()
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
// Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
value, _, _ := modelPresence(name, req.Dir)
if value == "" {
value = modelPickerValue(filepath.Dir(dests[0]), name)
}
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
"value": value, "parts": 0})
return
}
ctx, cancel := context.WithCancel(context.Background())
@@ -462,6 +503,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
// puis échouer au deux tiers du transfert.
urls, names := shardURLSet(dlURL, name)
name = names[0]
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
// mettait l'interface dans une impasse — un modèle gardé après la suppression
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
// où qu'il soit.
if value, path, ok := modelPresence(name, ""); ok {
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
"exists": true, "value": value, "size": modelFamilySize(path),
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var total int64
@@ -539,7 +595,11 @@ func dlSourceError(resp *http.Response, dlURL string) error {
case 404:
switch code {
case "EntryNotFound":
return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié")
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
// fragment qui manque — certains dépôts n'en publient que six sur
// sept et livrent la table PLE à part, à lier soi-même — et sans
// le nom, on cherche une révision réécrite qui n'existe pas.
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
case "RevisionNotFound":
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
}
@@ -555,6 +615,20 @@ func dlSourceError(resp *http.Response, dlURL string) error {
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
}
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
// lien n'a pas de nom de fichier lisible.
func dlFileLabel(dlURL string) string {
u, err := url.Parse(dlURL)
if err != nil {
return ""
}
name := path.Base(u.Path)
if name == "" || name == "/" || name == "." {
return ""
}
return "« " + name + " »"
}
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
// accepter, qui n'existent que là-bas.
+9 -1
View File
@@ -172,7 +172,15 @@ func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
},
{
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
want: []string{"absent du dépôt", "404"},
want: []string{"« m.gguf » absent du dépôt", "404"},
},
{
// Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
// le message doit NOMMER le fragment manquant, sinon on cherche une
// révision réécrite qui n'existe pas.
name: "fragment absent", resp: resp(404, "EntryNotFound"),
url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
},
{
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
+164
View File
@@ -0,0 +1,164 @@
package loki
// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ?
//
// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un
// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et
// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete
// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis
// l'interface demande deux choses : savoir qui s'en sert (le moteur en service,
// d'autres presets), et le dire AVANT d'effacer.
import (
"os"
"path/filepath"
"strings"
)
// modelRefIndex — l'index « ce fichier est référencé par… », construit en une
// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier
// relisait tous les presets à chaque ligne de la liste des modèles.
//
// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son
// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même
// fichier sur le disque.
type modelRefIndex struct {
active map[string]bool // référencé par config.env — donc par le moteur qui tourne
presets map[string][]string // chemin -> noms d'affichage des presets
}
// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle
// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le
// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la
// règle qu'appliquera le moteur.
func modelRefKey(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
p, err := resolveServeModelPath(v)
if err != nil {
// Introuvable : on garde quand même une clé comparable, sinon deux
// presets qui nomment le même fichier absent paraîtraient distincts.
p = filepath.Join(modelsDir(), baseName(v))
}
return normDir(p)
}
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
// d'avant la clé MMPROJ l'écrivaient là).
func modelKeysOf(env map[string]string) []string {
vals := []string{env["MODEL"], env["MMPROJ"]}
args := splitArgs(env["EXTRA_ARGS"])
for i, a := range args {
if a == "--mmproj" && i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
out := []string{}
for _, v := range vals {
if k := modelRefKey(v); k != "" {
out = append(out, k)
}
}
return out
}
// buildModelRefIndex lit la configuration active et tous les presets.
func buildModelRefIndex() modelRefIndex {
ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}}
for _, k := range modelKeysOf(ReadConfig()) {
ix.active[k] = true
}
list, err := ListPresets()
if err != nil {
return ix
}
for _, p := range list {
content, err := ReadPreset(p.ID)
if err != nil {
continue
}
seen := map[string]bool{}
for _, k := range modelKeysOf(parseEnv(content)) {
if seen[k] {
continue // MODEL et MMPROJ identiques : un seul « utilisé par »
}
seen[k] = true
ix.presets[k] = append(ix.presets[k], p.Name)
}
}
return ix
}
// lookup renvoie les presets qui référencent path et s'il est en service.
func (ix modelRefIndex) lookup(path string) (presets []string, active bool) {
k := normDir(path)
return ix.presets[k], ix.active[k]
}
// modelUsers répond pour UN modèle (nom de fichier ou chemin).
func modelUsers(name string) (presets []string, active bool) {
k := modelRefKey(name)
if k == "" {
return nil, false
}
return buildModelRefIndex().lookup(k)
}
// modelInServiceErr est le refus opposé à la suppression d'un modèle que le
// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère
// même pas la place (le fichier reste mappé) et rend le prochain démarrage
// impossible sans rien dire.
const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer"
// modelFamilySize additionne ce que la suppression libérera réellement
// (toutes les tranches présentes).
func modelFamilySize(p string) int64 {
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
}
// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur
// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par
// un téléchargement) ; vide = tous les dossiers déclarés.
//
// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au
// bout d'un clic sur un quant — en « déjà là, je le sélectionne ».
func modelPresence(name, dir string) (value, path string, ok bool) {
base := baseName(strings.TrimSpace(name))
if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") {
return "", "", false
}
dirs := modelDirs()
if strings.TrimSpace(dir) != "" {
if d, err := resolveDownloadDir(dir); err == nil {
dirs = []string{d}
}
}
for _, d := range dirs {
p := filepath.Join(d, base)
if st, err := os.Stat(p); err != nil || st.IsDir() {
continue
}
// Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est
// là, l'appelant doit pouvoir relancer le téléchargement des tranches
// manquantes (handleModelDownload les reprend une par une).
if len(shardFamilyMissing(d, base)) > 0 {
continue
}
return modelPickerValue(d, base), p, true
}
return "", "", false
}
// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné :
// le simple nom quand il vit dans le dossier de téléchargement de Loki (le
// preset reste alors portable d'une machine à l'autre), le chemin complet
// ailleurs. Même règle que la liste /api/models.
func modelPickerValue(dir, base string) string {
if normDir(dir) == normDir(modelsDir()) {
return base
}
return filepath.Join(dir, base)
}
+246
View File
@@ -0,0 +1,246 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
func writeModel(t *testing.T, dir, name string, size int) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "modele.gguf", 1024)
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
value, path, ok := modelPresence("modele.gguf", "")
if !ok {
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
}
if value != "modele.gguf" {
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
}
if path != filepath.Join(modelsDir(), "modele.gguf") {
t.Fatalf("chemin = %q", path)
}
// B) Plus personne ne le référence : il s'efface sans force.
users, active := modelUsers("modele.gguf")
if active || len(users) != 0 {
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
}
freed, err := deleteModelFile("modele.gguf")
if err != nil {
t.Fatalf("suppression refusée : %v", err)
}
if freed != 1024 {
t.Fatalf("octets libérés = %d, attendu 1024", freed)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("le fichier est toujours là")
}
}
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "servi.gguf", 8)
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("servi.gguf"); !active {
t.Fatal("modèle de la configuration active non reconnu")
}
if _, err := deleteModelFile("servi.gguf"); err == nil {
t.Fatal("le modèle en service a été supprimé")
}
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
t.Fatal("fichier supprimé malgré le refus")
}
}
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
testHome(t)
p := writeModel(t, modelsDir(), "partage.gguf", 16)
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
t.Fatal(err)
}
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
t.Fatal(err)
}
users, active := modelUsers("partage.gguf")
if active {
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
}
if len(users) != 2 {
t.Fatalf("presets référents = %v, attendu les deux", users)
}
post := func(body string) (int, map[string]any) {
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
w := httptest.NewRecorder()
handleModelDelete(w, r)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
code, out := post(`{"name":"partage.gguf"}`)
if code != 409 {
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
}
if len(out["used"].([]any)) != 2 {
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
}
if _, err := os.Stat(p); err != nil {
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
}
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
t.Fatalf("code = %d avec force:true — %v", code, out)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("force:true n'a pas supprimé le fichier")
}
}
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
// projecteur du modèle en service casse la vision sans rien dire.
func TestProjecteurCompteCommeReference(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("mmproj-F16.gguf"); !active {
t.Fatal("MMPROJ= non compté comme référence")
}
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
t.Fatal(err)
}
users, _ := modelUsers("vieux-mmproj.gguf")
if len(users) != 1 || users[0] != "Vision" {
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
}
}
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
// tenseur introuvable.
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
t.Fatal("famille incomplète annoncée présente")
}
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
t.Fatal("famille complète non détectée")
}
}
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
func TestPresenceDansUnDossierDeclare(t *testing.T) {
home := testHome(t)
ext := filepath.Join(home, "externe")
writeModel(t, ext, "ailleurs.gguf", 32)
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
}
if err := saveExtraModelDirs([]string{ext}); err != nil {
t.Fatal(err)
}
value, _, ok := modelPresence("ailleurs.gguf", "")
if !ok {
t.Fatal("modèle d'un dossier déclaré non détecté")
}
if value != filepath.Join(ext, "ailleurs.gguf") {
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
}
}
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "local.gguf", 64)
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
var out []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 1 {
t.Fatalf("liste = %v", out)
}
if out[0]["home"] != true {
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
}
if out[0]["value"] != "local.gguf" {
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
}
if out[0]["active"] != true {
t.Fatal("le modèle en service doit être signalé comme tel")
}
}
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
// existe déjà » et il devient impossible de refaire un preset dessus.
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "deja.gguf", 2048)
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
body := `{"url":"` + url + `","dir":""}`
w := httptest.NewRecorder()
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("sonde : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
}
code, out = call(handleModelDownload, "/api/models/download")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("téléchargement : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("téléchargement : value = %v", out["value"])
}
}
+5 -1
View File
@@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
t.Fatal(err)
}
}
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
freed, err := deleteModelFile("m-00001-of-00002.gguf")
if err != nil {
t.Fatal(err)
}
if freed != 2 {
t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed)
}
for _, n := range names[:2] {
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
t.Fatalf("tranche %s non supprimée", n)
+94 -3
View File
@@ -225,9 +225,38 @@ func applyPresetFile(target string) error {
}
next[k] = v
}
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
next["BIN"] = cur["BIN"]
}
return WriteConfig(next)
}
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
//
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
// preset : le courant reste.
//
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
// modèle : le preset gagne.
func presetImposesEngine(p string) bool {
p = strings.TrimSpace(p)
if p == "" || engineOwns(p) || prebuiltOwns(p) {
return false
}
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
return false
}
return true
}
// SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error {
@@ -235,6 +264,17 @@ func SwitchToPreset(target string) error {
return err
}
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
// le moteur encore en vie — le chat part vers l'API distante, garder le
// modèle en VRAM ne sert plus à rien.
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
if !serviceIsActive() {
return nil
}
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service..."))
return serviceAction("restart")
}
@@ -287,6 +327,10 @@ func cmdSwitch(args []string) error {
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
//
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
// moteur mis à jour à la première bascule.
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
@@ -294,9 +338,11 @@ func newPresetSeed() map[string]string {
cur := ReadConfig()
seed := map[string]string{}
for _, k := range newPresetSeedKeys {
if v := cur[k]; v != "" {
seed[k] = v
v := cur[k]
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
continue
}
seed[k] = v
}
return seed
}
@@ -331,6 +377,41 @@ func SavePreset(id, name, content string) (string, error) {
return id, os.WriteFile(p, []byte(content), 0o644)
}
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
// installe aussitôt la nouvelle version comme configuration active (mêmes
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
// l'appelant redémarre alors le moteur.
//
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
// preset n'était détecté actif (empreinte différente). Éditer le preset en
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
//
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
// porte l'empreinte de la configuration courante.
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
wasActive := false
if id != "" {
if old, rerr := ReadPreset(id); rerr == nil {
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
}
}
newID, err = SavePreset(id, name, content)
if err != nil || !wasActive {
return newID, false, err
}
p, err := safePresetPath(newID)
if err != nil {
return newID, false, err
}
if err := applyPresetFile(p); err != nil {
return newID, false, err
}
return newID, true, nil
}
// DeletePreset removes a preset by id; refuses if it is the active config.
func DeletePreset(id string) error {
p, err := safePresetPath(id)
@@ -344,7 +425,17 @@ func DeletePreset(id string) error {
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
return fmt.Errorf("preset actif, switche d'abord")
}
return os.Remove(p)
if err := os.Remove(p); err != nil {
return err
}
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
// sous le même id.
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
// Même ménage pour son benchmark : un preset recréé sous cet id en
// hériterait, alors qu'il a été mesuré sur un autre modèle.
deletePresetBench(id)
return nil
}
// ReadPreset returns the contents of a preset by id (filename).
+109
View File
@@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
}
}
// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface
// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on
// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et
// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul
// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN.
func TestSwitchToPresetGardeLeMoteur(t *testing.T) {
home := testHome(t)
t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server")
write := func(p, body string) {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server")
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
// Preset d'avant la mise à jour : il porte le moteur de l'image.
image := filepath.Join(home, "image.env")
write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n")
if err := applyPresetFile(image); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != engine {
t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine)
}
// Preset muet sur le moteur : idem.
muet := filepath.Join(home, "muet.env")
write(muet, "MODEL=autre.gguf\n")
if err := applyPresetFile(muet); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != engine {
t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine)
}
// Backend personnalisé : le preset gagne.
custom := filepath.Join(home, "custom.env")
write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n")
if err := applyPresetFile(custom); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" {
t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got)
}
// Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un
// choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé.
if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" {
t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"])
}
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
if seed := newPresetSeed(); seed["BIN"] != "" {
t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"])
}
setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n")
if seed := newPresetSeed(); seed["BIN"] != "" {
t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"])
}
}
// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle
// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son
// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
@@ -102,3 +160,54 @@ func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
}
}
// Modifier le preset ACTIF doit changer la configuration courante. Vécu : CTX
// passé à 100000 dans l'éditeur, « enregistré » — et la carte du chat qui
// affiche toujours 32768, parce que seul le fichier du preset avait bougé ;
// config.env, lui, gardait l'ancienne valeur et plus aucun preset n'était
// détecté actif. Un preset qui n'est pas celui en service, ou un preset
// nouveau, ne touche pas à la configuration.
func TestModifierLePresetActifLApplique(t *testing.T) {
testHome(t)
dir := presetsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, name+".env"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n")
write("autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n")
setConfig(t, "MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n")
// Le preset en service : la config suit, les réglages machine restent.
id, applied, err := SavePresetApplying("actif", "Actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=100000\n")
if err != nil {
t.Fatal(err)
}
if id != "actif" || !applied {
t.Fatalf("id=%q applied=%v, attendu actif/true", id, applied)
}
cfg := ReadConfig()
if cfg["CTX"] != "100000" || cfg["MEM_MODE"] != "always" {
t.Fatalf("configuration non suivie : %v", cfg)
}
// Un autre preset : le fichier change, la config non.
if _, applied, err = SavePresetApplying("autre", "Autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=8192\n"); err != nil {
t.Fatal(err)
}
if applied || ReadConfig()["CTX"] != "100000" {
t.Fatalf("un preset inactif a touché la configuration (applied=%v, CTX=%q)", applied, ReadConfig()["CTX"])
}
// Un preset nouveau : jamais appliqué à la volée.
if _, applied, err = SavePresetApplying("", "Neuf", "MODEL=baz.gguf\n"); err != nil {
t.Fatal(err)
}
if applied || ReadConfig()["MODEL"] != "foo.gguf" {
t.Fatalf("un preset nouveau a touché la configuration (applied=%v)", applied)
}
}
+201 -30
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
@@ -84,6 +85,41 @@ func binSupportsNGLAuto(bin string) bool {
return strings.Contains(h[i:end], "'auto'")
}
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
//
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
// grossier mais insensible à la mise en page.
func binFitsLayersItself(bin string) bool {
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
}
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
ngl = strings.TrimSpace(ngl)
switch {
case strings.EqualFold(ngl, "auto"):
return nil, "" // rien du tout : le moteur décide seul
case ngl == "999" && fitsItself:
return []string{"-ngl", "auto"},
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
"VRAM libre. NGL=all pour forcer l'ancien comportement."
case ngl != "":
return []string{"-ngl", ngl}, ""
case fitsItself:
return []string{"-ngl", "auto"}, ""
default:
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
return []string{"-ngl", "999"}, ""
}
}
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
@@ -105,22 +141,34 @@ func hasAnyFlag(args []string, flags ...string) bool {
return false
}
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
// --load-mode, sur les moteurs qui le connaissent :
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
//
// --mlock → --load-mode mlock (garder le modèle en RAM)
// --no-mmap → --load-mode none (tout charger, pas de mmap)
// --mmap → --load-mode mmap
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
//
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
// --no-mmap seul → none
// --mlock + --no-mmap → mlock
// --mmap → mmap
//
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
// alors simplement les vieux drapeaux.
//
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
//
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
// auto, mmap, dio → rien (défaut de l'ancien moteur)
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
if !supportsLoadMode {
return args
return downgradeLoadMode(args)
}
explicit := hasAnyFlag(args, "--load-mode")
explicit := hasAnyFlag(args, "--load-mode", "-lm")
mlock, nommap, mmap := false, false, false
out := make([]string, 0, len(args)+2)
for _, a := range args {
@@ -140,14 +188,62 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
}
mode := "mmap"
switch {
case mlock:
case mlock && nommap:
mode = "mlock"
case mlock:
mode = "mmap+mlock"
case nommap:
mode = "none"
}
return append(out, "--load-mode", mode)
}
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
func downgradeLoadMode(args []string) []string {
mode, found := "", false
kept := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--load-mode" || a == "-lm" {
found = true
if i+1 < len(args) {
mode = strings.ToLower(args[i+1])
i++
}
continue
}
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
found, mode = true, strings.ToLower(v)
continue
}
kept = append(kept, a)
}
if !found {
return args
}
add := func(flag string) {
for _, a := range kept {
if a == flag {
return
}
}
kept = append(kept, flag)
}
switch mode {
case "none":
add("--no-mmap")
case "mlock":
add("--mlock")
add("--no-mmap")
case "mmap+mlock":
add("--mlock")
case "dio":
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut\n")
}
return kept
}
// cmdServe replaces the historic start.sh: read config.env, build the
// llama-server invocation, and exec it (replacing this process so systemd
// supervises llama-server directly).
@@ -240,31 +336,32 @@ func cmdServe(args []string) error {
if !hasAnyFlag(extra, "--parallel", "-np") {
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
}
// Couches GPU. Trois cas, dans cet ordre :
// Couches GPU. Quatre cas, dans cet ordre :
//
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche)
// NGL=auto → rien du tout (compat : anciens presets)
// NGL=auto → rien du tout (le moteur décide seul)
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
//
// Imposer un nombre désarme common_fit_params, qui mesure la VRAM libre et
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
// D'où « auto » par défaut dès que le moteur sait le faire. Sur un moteur
// plus ancien, qui ne connaît pas la valeur, on garde 999 : omettre -ngl le
// ferait tourner 100 % CPU.
//
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
// c'est la sentinelle historique « toutes », semée dans config.env par
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
// configurations la portent donc sans que personne ne l'ait choisie — la
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
ngl := get("NGL", "")
switch {
case strings.EqualFold(ngl, "auto"):
// rien : le moteur décide seul
case ngl != "":
llmArgs = append(llmArgs, "-ngl", ngl)
case binSupportsNGLAuto(bin):
llmArgs = append(llmArgs, "-ngl", "auto")
default:
llmArgs = append(llmArgs, "-ngl", "999")
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
if note != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
}
llmArgs = append(llmArgs, args...)
}
if ktv != "" {
llmArgs = append(llmArgs, "-ctk", ktv)
@@ -334,10 +431,84 @@ func cmdServe(args []string) error {
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
if err := waitPortFree(host, port, 5*time.Second); err != nil {
return err
}
warnSlowKV(ktv, vtv)
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
bin, filepath.Base(model), port)
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
// retient la dernière : EXTRA_ARGS peut surcharger --port).
func argValue(args []string, flag string) string {
v := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
v = args[i+1]
}
}
return v
}
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
// Repris d'AJEAN 0.15.9.
func waitPortFree(host, port string, wait time.Duration) error {
if port == "" {
return nil
}
h := strings.Trim(host, "[]")
if h == "" || h == "0.0.0.0" || h == "::" {
h = "127.0.0.1"
}
addr := net.JoinHostPort(h, port)
deadline := time.Now().Add(wait)
for {
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err != nil {
return nil
}
c.Close()
if time.Now().After(deadline) {
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
"« loki stop », docker restart loki, ou change PORT", port)
}
time.Sleep(500 * time.Millisecond)
}
}
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
func slowKV(k, v string) bool {
if k == "" {
k = "f16"
}
if v == "" {
v = "f16"
}
return !fastKV[k+"|"+v]
}
func warnSlowKV(k, v string) {
if slowKV(k, v) {
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
}
}
+79 -4
View File
@@ -1,8 +1,10 @@
package loki
import (
"net"
"reflect"
"testing"
"time"
)
func TestHasAnyFlag(t *testing.T) {
@@ -31,16 +33,25 @@ func TestNormalizeLoadFlags(t *testing.T) {
supports bool
want []string
}{
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
[]string{"--mlock", "--no-mmap"}},
{"mlock traduit", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mlock"}},
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mmap+mlock"}},
{"no-mmap traduit", []string{"--no-mmap"}, true,
[]string{"--load-mode", "none"}},
{"mmap traduit", []string{"--mmap"}, true,
[]string{"--load-mode", "mmap"}},
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
[]string{"--load-mode", "mlock"}},
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
[]string{"-fa", "--no-mmap"}},
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
[]string{"--mlock", "--no-mmap"}},
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
[]string{"--mlock"}},
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
[]string{"-fa"}},
{"--load-mode explicite gagne, vieux drapeaux retirés",
[]string{"--mlock", "--load-mode", "dio"}, true,
[]string{"--load-mode", "dio"}},
@@ -56,3 +67,67 @@ func TestNormalizeLoadFlags(t *testing.T) {
})
}
}
func TestNGLArgs(t *testing.T) {
cases := []struct {
name string
ngl string
fitsItself bool
want []string
wantNote bool
}{
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
// installation neuve. Personne ne l'a choisi, et le moteur récent
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
{"auto : aucun drapeau", "auto", true, nil, false},
{"auto insensible à la casse", "AUTO", true, nil, false},
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, note := nglArgs(c.ngl, c.fitsItself)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %q, want %q", got, c.want)
}
if (note != "") != c.wantNote {
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
}
})
}
}
func TestArgValueEtPortLibre(t *testing.T) {
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(ln.Addr().String())
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
t.Fatal("un port occupé doit être refusé")
}
ln.Close()
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
t.Fatalf("un port libre doit passer : %v", err)
}
}
func TestSlowKV(t *testing.T) {
for _, c := range []struct {
k, v string
lent bool
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
if slowKV(c.k, c.v) != c.lent {
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
}
}
}
+220
View File
@@ -0,0 +1,220 @@
package loki
// backup_bundle.go — construit et restaure un paquet de sauvegarde
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
//
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
//
// Format du blob :
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
// corps : gcmSeal(backupDEK, tar.gz du bundle).
//
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
import (
"archive/tar"
"bytes"
"compress/gzip"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
)
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
const backupBodyAAD = "loki-backup-body"
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
func buildBundleTar() ([]byte, error) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
addDir := func(prefix, dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return nil // dossier absent = rien à ajouter
}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return err
}
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if _, err := tw.Write(b); err != nil {
return err
}
}
return nil
}
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
if roots, err := os.ReadDir(projectsRoot()); err == nil {
for _, d := range roots {
if !d.IsDir() {
continue
}
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
return nil, err
}
}
}
if err := addDir("presets", presetsDir()); err != nil {
return nil, err
}
// Réglages (bucket config) sérialisés en JSON.
cfg, _ := json.Marshal(ReadConfig())
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
return nil, err
}
if _, err := tw.Write(cfg); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gz.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
kv, err := json.Marshal(v)
if err != nil {
return nil, err
}
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
if err != nil {
return nil, err
}
var out bytes.Buffer
out.Write(backupMagic)
var lenbuf [4]byte
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
out.Write(lenbuf[:])
out.Write(kv)
out.Write(body)
return out.Bytes(), nil
}
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
// récupération) pour retrouver la DEK, puis déchiffre le corps.
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
if !bytes.HasPrefix(blob, backupMagic) {
return nil, fmt.Errorf("format de sauvegarde non reconnu")
}
rest := blob[len(backupMagic):]
if len(rest) < 4 {
return nil, fmt.Errorf("blob tronqué")
}
n := binary.BigEndian.Uint32(rest[:4])
rest = rest[4:]
if int(n) > len(rest) {
return nil, fmt.Errorf("entête tronqué")
}
var v keyVault
if err := json.Unmarshal(rest[:n], &v); err != nil {
return nil, fmt.Errorf("entête illisible : %w", err)
}
body := rest[n:]
dek, _, err := v.unlockWith(secret)
if err != nil {
dek, _, err = v.unlockWith(normalizeRecovery(secret))
}
if err != nil {
return nil, fmt.Errorf("clé incorrecte")
}
return gcmOpen(dek, body, []byte(backupBodyAAD))
}
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
func restoreBundleTar(tarData []byte) error {
if _, err := snapshotMemory("avant-restauration"); err != nil {
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
}
gz, err := gzip.NewReader(bytes.NewReader(tarData))
if err != nil {
return err
}
tr := tar.NewReader(gz)
var cfgJSON []byte
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
b, err := io.ReadAll(tr)
if err != nil {
return err
}
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
if strings.Contains(name, "..") {
continue // sécurité : jamais de traversée
}
base := path.Base(name)
switch {
case name == "config.json":
cfgJSON = b
case strings.HasPrefix(name, "memory/"):
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
// les rattache au projet par défaut.
rel := strings.TrimPrefix(name, "memory/")
if !strings.Contains(rel, "/") {
rel = defaultProjectSlug + "/" + rel
}
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
return err
}
case strings.HasPrefix(name, "presets/"):
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
return err
}
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
return err
}
}
}
if len(cfgJSON) > 0 {
var cfg map[string]string
if json.Unmarshal(cfgJSON, &cfg) == nil {
_ = WriteConfig(cfg)
}
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
func TestBackupBundleRoundTrip(t *testing.T) {
testHome(t)
clearMemDEK()
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
t.Fatal(err)
}
recovery, err := EnableMemEncryption("clé-de-pilotage")
if err != nil {
t.Fatal(err)
}
v, err := loadVault()
if err != nil || v == nil {
t.Fatalf("coffre introuvable après activation: %v", err)
}
dek, err := currentDEK()
if err != nil {
t.Fatal(err)
}
tarData, err := buildBundleTar()
if err != nil {
t.Fatal(err)
}
blob, err := buildBackupBlob(v, dek, tarData)
if err != nil {
t.Fatal(err)
}
// Le paquet ne doit rien laisser voir en clair.
if strings.Contains(string(blob), "secret du client") {
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
}
// La clé de pilotage l'ouvre…
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
}
// …la clé de récupération aussi…
back, err := openBackupBlob(blob, recovery)
if err != nil {
t.Fatalf("ouverture avec la clé de récupération: %v", err)
}
// …et une mauvaise clé, non.
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
t.Fatal("une clé fausse aurait dû être refusée")
}
// Page effacée puis restaurée depuis le paquet : contenu intact.
p, _ := safeMemPath("notes.md")
if err := os.Remove(p); err != nil {
t.Fatal(err)
}
if err := restoreBundleTar(back); err != nil {
t.Fatal(err)
}
got, ok := memPageText("notes.md")
if !ok || !strings.Contains(got, "secret du client") {
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
}
// Le snapshot de sécurité pris avant restauration doit exister.
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
t.Fatal("aucun snapshot pris avant la restauration")
}
}
+129
View File
@@ -0,0 +1,129 @@
package loki
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
// police de chiffres est un bitmap 3×5 codé ici.
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/png"
"strconv"
)
const gridStep = 100 // espacement des lignes, en pixels
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
var digits5x3 = map[rune][5]string{
'0': {"###", "# #", "# #", "# #", "###"},
'1': {" # ", "## ", " # ", " # ", "###"},
'2': {"###", " #", "###", "# ", "###"},
'3': {"###", " #", "###", " #", "###"},
'4': {"# #", "# #", "###", " #", " #"},
'5': {"###", "# ", "###", " #", "###"},
'6': {"###", "# ", "###", "# #", "###"},
'7': {"###", " #", " #", " #", " #"},
'8': {"###", "# #", "###", "# #", "###"},
'9': {"###", "# #", "###", " #", "###"},
}
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
func overlayGrid(raw []byte) []byte {
src, err := png.Decode(bytes.NewReader(raw))
if err != nil {
return raw
}
b := src.Bounds()
img := image.NewRGBA(b)
draw.Draw(img, b, src, b.Min, draw.Src)
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
w, h := b.Dx(), b.Dy()
// Lignes verticales + étiquette x en haut.
for x := gridStep; x < w; x += gridStep {
for y := 0; y < h; y++ {
blend(img, b.Min.X+x, b.Min.Y+y, line)
}
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
}
// Lignes horizontales + étiquette y à gauche.
for y := gridStep; y < h; y += gridStep {
for x := 0; x < w; x++ {
blend(img, b.Min.X+x, b.Min.Y+y, line)
}
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
}
var out bytes.Buffer
if err := png.Encode(&out, img); err != nil {
return raw
}
return out.Bytes()
}
// blend applique une couleur semi-transparente (alpha) sur un pixel.
func blend(img *image.RGBA, x, y int, c color.RGBA) {
if !(image.Pt(x, y).In(img.Bounds())) {
return
}
a := float64(c.A) / 255
o := img.RGBAAt(x, y)
img.SetRGBA(x, y, color.RGBA{
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
A: 255,
})
}
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
// rester lisible sur n'importe quel fond.
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
const sc = 2
glyphW, glyphH, gap := 3*sc, 5*sc, sc
boxW := len(s)*(glyphW+gap) + gap
boxH := glyphH + 2*gap
// Fond.
for dy := -gap; dy < boxH-gap; dy++ {
for dx := -gap; dx < boxW-gap; dx++ {
setOpaque(img, x+dx, y+dy, bg)
}
}
// Chiffres.
cx := x
for _, r := range s {
g, ok := digits5x3[r]
if !ok {
cx += glyphW + gap
continue
}
for row := 0; row < 5; row++ {
for col := 0; col < 3; col++ {
if g[row][col] != '#' {
continue
}
for iy := 0; iy < sc; iy++ {
for ix := 0; ix < sc; ix++ {
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
}
}
}
}
cx += glyphW + gap
}
}
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
if image.Pt(x, y).In(img.Bounds()) {
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
}
}
+383 -27
View File
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
)
@@ -34,7 +35,10 @@ const (
// Budget de la queue : fraction de la fenêtre gardée intacte (tours récents).
// Plus la queue est petite, plus on compacte de torse d'un coup → le contexte
// retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent).
compactTailFrac = 0.25
// Descendu de 0.25 à 0.20 avec l'archivage recall : compacter plus large ne
// coûte plus de perte (les gros blocs restent rappelables), donc autant faire
// des compactions franches et rares plutôt que fréquentes et molles.
compactTailFrac = 0.20
// Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
// dans le torse DÉGRAISSÉ (repli si le résumé échoue).
compactToolPruneLen = 200
@@ -77,11 +81,54 @@ func ctxWindow() int {
return 32768
}
// compactSummaryBudget renvoie le budget en tokens du résumé, adapté à la
// fenêtre. Assez pour PORTER LE FIL (la demande, les faits déjà trouvés, les
// décisions, l'état d'avancement) sans jamais le perdre — c'était la plainte de
// fond : à 700 tokens fixes, tout le passé d'une longue conversation était
// écrasé en un demi-paragraphe et le modèle « perdait le fil ». Le résumé reste
// de toute façon minuscule devant le torse compacté (souvent des dizaines de
// milliers de tokens), donc la réduction demeure massive. Bornes : 700 (petites
// fenêtres) à 1600 (grandes), ~4 % de la fenêtre entre les deux.
func compactSummaryBudget() int {
n := ctxWindow() * 4 / 100
if n < 700 {
n = 700
}
if n > 1600 {
n = 1600
}
return n
}
// imageLostMarker remplace la partie `image_url` d'un message multimodal dans
// msgText. Toute la chaîne de compaction passe par msgText — estimation de
// contexte, choix des messages à résumer, transcript donné au résumeur — et
// msgText n'extrayait QUE les parties `text`. Un message qui porte une capture
// (screenshotImageMessage) ne pesait donc que sa légende (« Capture … »), et le
// résumeur ne savait même pas qu'une image avait été montrée : au compactage
// suivant l'image disparaissait SANS AUCUNE trace. Observé en amont comme cause
// probable de boucles de raisonnement — le modèle a bien regardé l'image, ne
// peut plus s'y référer, et redérive à l'aveugle en texte.
//
// Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend
// sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il
// doit REVOIR l'image plutôt que deviner.
//
// Formulation volontairement générale depuis l'arrivée de see_image : une image
// peut désormais venir d'une capture (web_screenshot), d'une pièce jointe ou
// d'un fichier du disque. Dire « reprends la capture » enverrait le modèle
// photographier une page web alors que l'image perdue était un PNG sur le
// disque — le geste juste dépend de la provenance, que la légende conservée
// juste avant ce marqueur indique.
const imageLostMarker = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]"
// msgText extrait le texte d'un message (Content est `any`, en pratique string
// ou nil quand l'assistant n'a que des tool_calls). Un message multimodal
// (userMessageContent : parties texte + image quand la vision est active) porte
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
// de contexte et le transcript de compaction ne partent pas d'un texte vide.
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
// quoi elle reste invisible à toute la chaîne de compaction.
func msgText(m Message) string {
switch v := m.Content.(type) {
case string:
@@ -89,20 +136,30 @@ func msgText(m Message) string {
case []map[string]any:
var b strings.Builder
for _, part := range v {
if part["type"] == "text" {
switch part["type"] {
case "text":
if s, ok := part["text"].(string); ok {
b.WriteString(s)
}
case "image_url":
b.WriteString(imageLostMarker)
}
}
return b.String()
case []any: // même contenu relu depuis le JSON persisté (map générique)
var b strings.Builder
for _, p := range v {
if part, ok := p.(map[string]any); ok && part["type"] == "text" {
part, ok := p.(map[string]any)
if !ok {
continue
}
switch part["type"] {
case "text":
if s, ok := part["text"].(string); ok {
b.WriteString(s)
}
case "image_url":
b.WriteString(imageLostMarker)
}
}
return b.String()
@@ -181,8 +238,13 @@ func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens in
// l'historique compacté et true s'il a effectivement changé.
// compactBounds calcule les frontières head/tail pour un historique donné et un
// budget de queue (en tokens). Fonction pure (pas d'IO) → testable :
// - head : nb de messages protégés en tête = messages système + 1er message
// utilisateur (il ancre l'objectif). Un message user est une frontière sûre.
// - head : nb de messages protégés en tête = messages système, et RIEN d'autre.
// Le tout premier message utilisateur y était épinglé pour « ancrer
// l'objectif » ; c'était contre-productif. Après compaction, le modèle voyait
// deux demandes explicites — celle du DÉBUT de la conversation, épinglée, et
// celle réinjectée par `pending` — et répondait volontiers à l'ancienne, en
// abandonnant la tâche en cours. La demande courante est déjà garantie par
// `pending` ; le premier message, lui, part au résumé comme le reste.
// - tailStart : index de début de la queue protégée. On remonte depuis la fin
// jusqu'à remplir le budget, puis on recule jusqu'à une frontière SÛRE :
// un message `user`, ou un `assistant` (qui, s'il porte des tool_calls, part
@@ -204,9 +266,6 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
for head < len(msgs) && msgs[head].Role == "system" {
head++
}
if head < len(msgs) && msgs[head].Role == "user" {
head++
}
tailStart = len(msgs)
acc := 0
for i := len(msgs) - 1; i >= head; i-- {
@@ -238,13 +297,46 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
torso := msgs[head:tailStart]
// 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
// 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc
// du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et
// le repli le remplacent par une référence à cet id, que le modèle pourra
// rappeler avec recall(id). C'est ce qui rend le compactage sûr même
// agressif — rien n'est perdu, seulement déplacé hors du contexte.
// On n'archive QUE si le modèle a réellement ses outils (mode agent) : sans
// eux il ne pourrait pas rappeler, autant garder un résumé propre sans ids.
// Un échec d'écriture (base absente, tests) laisse archived[i] nil → repli
// transparent sur le comportement classique (troncature/marqueur sans id).
archived := make([]*recallEntry, len(torso))
var index []recallEntry
if caps.Agent && compactEnabled() {
names := torsoToolNames(torso)
for i, m := range torso {
if !recallEligible(m) {
continue
}
content := msgText(m)
label := recallLabel(m.Role, names[m.ToolCallID], content)
id, aerr := archiveRecallBlock(label, m.Role, content)
if aerr != nil {
continue
}
archived[i] = &recallEntry{id: id, label: label}
index = append(index, recallEntry{id: id, label: label})
}
}
// 4. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
// marqueur. On travaille sur une copie pour ne pas muter l'historique amont.
// ⚠️ Ce torse dégraissé ne sert QUE de repli si le résumé échoue — surtout
// PAS d'entrée au résumeur, cf. juste en dessous.
// PAS d'entrée au résumeur, cf. juste en dessous. Un bloc archivé pointe vers
// son id (récupérable par recall) au lieu d'être effacé aveuglément.
pruned := make([]Message, len(torso))
for i, m := range torso {
pruned[i] = m
if e := archived[i]; e != nil {
pruned[i].Content = recallMarker(e.id, e.label)
continue
}
if m.Role == "tool" {
if t := msgText(m); len(t) > compactToolPruneLen {
pruned[i].Content = compactPrunedMarker
@@ -252,7 +344,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
}
// 4. Résumé du torse par le modèle local (un seul appel).
// 5. Résumé du torse par le modèle local (un seul appel).
//
// Le résumé se fait sur le torse ORIGINAL, pas sur le dégraissé. C'était LE
// bug de fond : on effaçait tous les résultats d'outils PUIS on demandait un
@@ -262,29 +354,58 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// que des outils avaient tourné. À chaque compactage, l'IA repartait donc
// d'une recherche vide et recommençait à zéro : elle ne s'arrêtait jamais.
//
// Les résultats d'outils sont seulement RACCOURCIS (leur début, qui porte
// l'essentiel : titre, en-tête, premières lignes) pour que la transcription
// reste bornée. Les faits survivent, le volume reste maîtrisé.
// Un bloc archivé est réduit à sa TÊTE (assez pour le résumer fidèlement).
// On ne montre PAS l'id recall au résumeur : sa seule tâche est d'écrire de
// la prose. Lui exposer « recall:rN » l'incite à répondre juste par l'id
// (résumé dégénéré) ; l'index des ids est de toute façon ajouté séparément
// et de façon déterministe par compactSummaryUserMsg.
// Les résultats d'outils non archivés sont seulement RACCOURCIS (leur début,
// qui porte l'essentiel : titre, en-tête, premières lignes) pour que la
// transcription reste bornée. Les faits survivent, le volume reste maîtrisé.
forSummary := make([]Message, len(torso))
for i, m := range torso {
forSummary[i] = m
if archived[i] != nil {
r := []rune(msgText(m))
headTxt := string(r)
if len(r) > recallSummaryHeadLen {
headTxt = string(r[:recallSummaryHeadLen]) + "…"
}
forSummary[i].Content = headTxt + "\n[…reste de ce bloc omis ici]"
continue
}
if m.Role == "tool" {
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
}
}
}
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
var mid []Message
if err != nil || strings.TrimSpace(summary) == "" {
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
// torse dégraissé, qui porte au moins les têtes de blocs et les marqueurs
// recall : bien plus informatif qu'un résumé dégénéré.
mid = pruned
} else {
// Le résumé est injecté comme un tour utilisateur→assistant (jamais un
// message `system` au milieu : certains gabarits, ex. Qwen, exigent que le
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
// Le message porte aussi la CONSCIENCE de la compaction et l'index des ids
// rappelables (voir compactSummaryUserMsg).
sumMsg := compactSummaryUserMsg(summary, index)
// Mode Code : les critères ne vivent pas dans le fil (seuls les appels à
// l'outil criteria y passent, et ils viennent d'être résumés). On rend
// leur état au builder ici, dans un message de toute façon neuf — pas de
// cache invalidé en plus (repris d'OpenFox).
if caps.Code {
if list := critList(convEnsureActive()); len(list) > 0 {
sumMsg += "\n\nAcceptance criteria (current state):\n" + critRender(list)
}
}
mid = []Message{
{Role: "user", Content: compactSummaryPrefix + " The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n" + summary},
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, without redoing work that is already done."},
{Role: "user", Content: sumMsg},
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
}
}
@@ -298,8 +419,23 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
// dans l'historique d'origine). Le torse reste entièrement compactable.
//
// Seulement quand c'est nécessaire (AJEAN 0.17.4), sinon deux bugs :
// - la queue contient déjà un message `user` : la dernière demande du torse a
// DÉJÀ reçu sa réponse, la réinjecter juste avant la nouvelle faisait
// répondre le modèle une seconde fois à une vieille question ;
// - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se
// retrouvait en double, cette fois APRÈS ses propres résultats d'outils.
tailHasUser := false
for _, m := range msgs[tailStart:] {
if m.Role == "user" {
tailHasUser = true
break
}
}
summarized := err == nil && !summaryLooksEmpty(summary)
var pending []Message
for i := len(torso) - 1; i >= 0; i-- {
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
if torso[i].Role != "user" {
continue
}
@@ -327,6 +463,94 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
return out, true
}
// summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement
// de références « recall:rN » (un petit modèle, mal aiguillé, répond parfois par
// un id au lieu d'écrire de la vraie prose). Dans ce cas on préfère le repli
// dégraissé. Seuil à 40 caractères de VRAI texte (hors jetons recall).
func summaryLooksEmpty(s string) bool {
s = strings.TrimSpace(s)
if len([]rune(s)) < 40 {
return true
}
var real strings.Builder
for _, f := range strings.Fields(s) {
low := strings.ToLower(strings.Trim(f, ".,;:()[]-•*"))
if strings.HasPrefix(low, "recall:r") || strings.HasPrefix(low, "recall(") {
continue
}
real.WriteString(f)
real.WriteString(" ")
}
return len([]rune(strings.TrimSpace(real.String()))) < 40
}
// recallEntry associe un id d'archive à son libellé court, pour l'index injecté
// dans le message de compaction.
type recallEntry struct{ id, label string }
// recallEligible dit si un message du torse mérite d'être archivé sous un id :
// un bloc assez gros (le résumé ne le porterait pas fidèlement) d'un rôle réel,
// et qui n'est pas déjà le résumé d'une compaction précédente (on ne ré-archive
// pas un résumé).
func recallEligible(m Message) bool {
switch m.Role {
case "tool", "user", "assistant":
default:
return false
}
t := msgText(m)
if len([]rune(t)) <= recallArchiveMinLen {
return false
}
return !strings.HasPrefix(t, compactSummaryPrefix)
}
// torsoToolNames associe chaque ToolCallID du torse au nom de l'outil qui l'a
// produit (l'assistant porte les tool_calls, le résultat ne porte que l'id),
// pour donner un libellé lisible aux blocs archivés (« web_read: … »).
func torsoToolNames(torso []Message) map[string]string {
names := map[string]string{}
for _, m := range torso {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
names[tc.ID] = tc.Function.Name
}
}
}
return names
}
// recallMarker remplace un bloc archivé dans le torse dégraissé (repli). Il cite
// l'id récupérable et interdit explicitement de relancer l'outil pour le refaire.
func recallMarker(id, label string) string {
return fmt.Sprintf("[Block archived to save context — full content retrievable with recall(\"%s\") (%s). Do NOT re-run a tool to fetch it back.]", id, label)
}
// compactSummaryUserMsg construit le message `user` synthétique qui porte le
// résumé. Il rend le modèle CONSCIENT que la conversation a été compactée (il le
// sait aussi bien que nous), et lui donne l'index des blocs rappelables. L'index
// ne liste QUE les ids créés à cette passe : sa taille reste bornée, une
// conversation infinie ne gonfle jamais cette section (les vieux blocs restent
// joignables par recall_search).
func compactSummaryUserMsg(summary string, index []recallEntry) string {
var b strings.Builder
b.WriteString(compactSummaryPrefix)
if len(index) > 0 {
b.WriteString(" The earlier turns of this conversation were summarized to save context, but nothing is lost: any block referenced below (or in the summary) as recall:rN can be brought back verbatim with the recall(id) tool, and recall_search(\"keywords\") finds older archived blocks not listed here. Here is the summary:\n\n")
} else {
b.WriteString(" The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n")
}
b.WriteString(summary)
if len(index) > 0 {
b.WriteString("\n\nArchived blocks you can bring back with recall(id):\n")
for _, e := range index {
fmt.Fprintf(&b, "- %s — %s\n", e.id, e.label)
}
b.WriteString("Only recall a block when you actually need its full content.")
}
return b.String()
}
// renderTranscript sérialise le torse en texte lisible pour le résumeur.
func renderTranscript(msgs []Message) string {
var b strings.Builder
@@ -373,6 +597,20 @@ type summarizeResp struct {
// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
return summarizeTranscriptFor(ctx, transcript, false)
}
// codeSummaryRules : ce qu'un résumé de mode Code doit garder en plus — sans
// quoi le builder réécrit des fichiers déjà faits et retombe dans des erreurs
// déjà résolues (repris du COMPACTION_PROMPT d'OpenFox).
const codeSummaryRules = `
This is a CODING session. Also keep:
- Every file created or modified (path) and what changed in it
- Errors hit (build, tests, tools) and how each was resolved — or that it is still open
- The commands that build and test the project`
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
@@ -383,11 +621,19 @@ Summarize densely and faithfully, keeping ONLY the essentials:
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
- Decisions made and established facts
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Aim for 300 words MAX — this is a compression summary, not a report.
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
Write the summary in the SAME language as the conversation.`
if code {
sys += codeSummaryRules
}
// Le résumé part au MÊME endroit que le chat : sur un preset externe, il
// s'appuie sur l'API distante (backend_external.go). Le laisser taper le
// llama-server local ferait échouer toute compaction dès qu'aucun moteur
// local ne tourne — et la compaction, c'est ce qui empêche le fil de mourir.
ep := resolveChatEndpoint()
payload := map[string]any{
"model": "loki",
"model": ep.Model,
"messages": []Message{
{Role: "system", Content: sys},
{Role: "user", Content: transcript},
@@ -396,20 +642,24 @@ Write the summary in the SAME language as the conversation.`
"temperature": 0.2,
// Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un
// résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour.
"max_tokens": 700,
"max_tokens": compactSummaryBudget(),
// Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un
// modèle hybride gaspille tout le budget en <think> (résumé vide). llama.cpp
// passe ces kwargs au gabarit Jinja (--jinja).
"chat_template_kwargs": map[string]any{"enable_thinking": false},
}
if ep.External {
// Propre à llama.cpp : une API distante stricte (OpenAI) refuse un
// argument inconnu par un 400, et la compaction échouait à chaque fois.
delete(payload, "chat_template_kwargs")
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
authHeader(req)
ep.auth(req.Header.Set)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", friendlyLLMError(err)
@@ -417,7 +667,11 @@ Write the summary in the SAME language as the conversation.`
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
who := "llama-server"
if ep.External {
who = "API externe"
}
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
}
var out summarizeResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
@@ -443,3 +697,105 @@ Write the summary in the SAME language as the conversation.`
}
return c, nil
}
// contextOverflow : le refus du moteur (corps d'erreur msg) signale-t-il un
// débordement de la fenêtre de contexte ? Reconnaît les libellés de llama.cpp
// (« exceeds the available context size », exceed_context_size_error) et des API
// OpenAI-compatibles (context_length_exceeded, « maximum context length »). À
// défaut de libellé reconnu, on se fie à la taille estimée : une conversation
// déjà à 90 % de la fenêtre a toutes les chances d'être la cause.
func contextOverflow(msg string, msgs []Message) bool {
m := strings.ToLower(msg)
for _, k := range []string{"context size", "context_size", "context length", "context_length",
"context window", "n_ctx", "too many tokens", "prompt is too long", "exceeds the context"} {
if strings.Contains(m, k) {
return true
}
}
return estimateTokens(msgs) >= int(float64(ctxWindow())*0.9)
}
// ---- dernier recours : la requête ne rentre toujours pas -------------------
// Repris d'AJEAN 0.17.5.
var reqTokensRe = regexp.MustCompile(`request \((\d+) tokens\)`)
// overflowTokens extrait la taille réelle de la requête refusée par llama.cpp
// (« request (132291 tokens) exceeds… »), 0 si absente.
func overflowTokens(msg string) int {
if m := reqTokensRe.FindStringSubmatch(msg); m != nil {
n, _ := strconv.Atoi(m[1])
return n
}
return 0
}
const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de façon plus ciblée si tu as besoin de la suite]"
// shrinkToFit est le filet quand le moteur a refusé le prompt ET que le
// compactage n'a rien pu faire (torse vide, ou réduction jugée trop faible :
// typiquement un tour qui a lu beaucoup de gros fichiers d'un coup, tout est
// dans la queue protégée). Avant, l'erreur 400 remontait telle quelle et la
// conversation restait bloquée. Ici on réduit pour de bon :
// 1. les gros résultats d'outils (du plus ancien au plus récent) sont tronqués ;
// 2. si ça ne suffit pas, on retire les plus vieux échanges, par tour entier
// (frontière `user`), en gardant les messages système de tête.
//
// La cible vise 60 % de la fenêtre, corrigée par l'écart entre estimation et
// taille réelle (actual, lue dans l'erreur) : l'estimation sous-compte (images…).
func shrinkToFit(msgs []Message, actual int) ([]Message, bool) {
est := estimateTokens(msgs)
if est <= 0 {
return msgs, false
}
ratio := 1.0
if actual > est {
ratio = float64(actual) / float64(est)
}
target := int(float64(ctxWindow()) * 0.6 / ratio)
out := append([]Message(nil), msgs...)
for i := range out {
if estimateTokens(out) <= target {
break
}
if out[i].Role != "tool" {
continue
}
if r := []rune(msgText(out[i])); len(r) > 2000 {
out[i].Content = string(r[:1500]) + shrinkTruncMarker
}
}
head := 0
for head < len(out) && out[head].Role == "system" {
head++
}
for estimateTokens(out) > target {
next := -1
for k := head + 1; k < len(out); k++ {
if out[k].Role == "user" {
next = k
break
}
}
if next < 0 {
break
}
out = append(out[:head:head], out[next:]...)
}
return out, estimateTokens(out) < est
}
// toolResultMax borne TOUT résultat d'outil dans la vue du modèle (≈ 8k
// tokens). Le terminal et le web ont déjà leur propre plafond, plus bas ;
// celui-ci rattrape les autres (lecture de fichier, pages mémoire, MCP…) : un
// seul résultat géant suffisait à faire déborder une fenêtre de 65k d'un coup,
// sans que le compactage puisse rien y faire. L'UI, elle, garde le résultat
// complet (« voir plus », tool_results.go).
const toolResultMax = 30000
func capToolResult(s string) string {
if r := []rune(s); len(r) > toolResultMax {
return string(r[:toolResultMax]) + "\n[…tronqué : résultat trop long. Cible une partie plus précise si tu as besoin de la suite]"
}
return s
}
@@ -0,0 +1,69 @@
package loki
import (
"strings"
"testing"
)
// Le compactage de secours ne doit se déclencher que sur un vrai débordement de
// contexte, pas sur n'importe quel refus du moteur.
func TestContextOverflow(t *testing.T) {
small := []Message{{Role: "user", Content: "salut"}}
yes := []string{
`{"error":{"code":400,"message":"request (70000 tokens) exceeds the available context size (65536 tokens), try increasing it","type":"exceed_context_size_error"}}`,
`{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}`,
}
no := []string{
`{"error":{"code":500,"message":"Failed to parse tool call arguments as JSON","type":"server_error"}}`,
`{"error":{"code":503,"message":"Loading model","type":"unavailable_error"}}`,
`Unable to generate parser for this template`,
}
for _, m := range yes {
if !contextOverflow(m, small) {
t.Errorf("débordement non reconnu : %s", m)
}
}
for _, m := range no {
if contextOverflow(m, small) {
t.Errorf("faux débordement (déclencherait un compactage) : %s", m)
}
}
}
// Le dernier recours ramène sous la fenêtre même quand tout est dans un seul
// tour (AJEAN 0.17.5).
func TestShrinkToFit(t *testing.T) {
testHome(t)
big := strings.Repeat("x", 200000)
msgs := []Message{
{Role: "system", Content: "ctx"},
{Role: "user", Content: "vieux"},
{Role: "assistant", Content: "ok"},
{Role: "user", Content: "lis tout"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "a"}}},
{Role: "tool", ToolCallID: "a", Content: big},
{Role: "tool", ToolCallID: "b", Content: big},
}
out, changed := shrinkToFit(msgs, 132291)
if !changed || out[0].Role != "system" {
t.Fatalf("pas réduit : %v", changed)
}
if est := estimateTokens(out); est > int(float64(ctxWindow())*0.6) {
t.Fatalf("encore trop gros : %d", est)
}
if overflowTokens(`{"message":"request (132291 tokens) exceeds"}`) != 132291 {
t.Fatal("taille réelle non lue")
}
}
// Un résultat d'outil géant est borné pour le modèle, avec une mention qui dit
// au modèle de cibler plus précisément.
func TestCapToolResult(t *testing.T) {
if got := capToolResult("court"); got != "court" {
t.Fatalf("résultat court modifié : %q", got)
}
got := capToolResult(strings.Repeat("é", toolResultMax+500))
if r := []rune(got); len(r) > toolResultMax+200 || !strings.Contains(got, "tronqué") {
t.Fatalf("résultat géant mal borné (%d runes)", len(r))
}
}
+85 -3
View File
@@ -1,6 +1,10 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -20,10 +24,17 @@ func TestCompactBoundsProtectsHead(t *testing.T) {
um("q2"), am("r2"),
um("q3"), am("r3"),
}
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
// budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes.
// Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer
// l'objectif », mais après compaction le modèle voyait deux demandes (celle du
// début, épinglée, et la courante réinjectée par `pending`) et répondait
// volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest.
head, tail := compactBounds(msgs, 1)
if head != 2 { // system + premier user
t.Fatalf("head = %d, attendu 2", head)
if head != 1 { // le message système, et lui seul
t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head)
}
if msgs[head].Role != "user" {
t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role)
}
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
if r := msgs[tail].Role; r != "user" && r != "assistant" {
@@ -170,3 +181,74 @@ func TestEstimateTokensGrows(t *testing.T) {
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
}
}
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
testHome(t)
page := func(n int) Message {
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{
um("première question de la conversation"), am("ok"),
um("cherche les horaires du train pour Lyon"),
}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
out, _ := compactMessages(t.Context(), msgs, Caps{})
n := 0
for _, m := range out {
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
n++
}
}
if n != 1 {
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
}
}
// Mode Code : le résumé demande les fichiers touchés et les erreurs, et l'état
// des critères est rendu au builder après compactage.
func TestCompactageModeCodeGardeLEtat(t *testing.T) {
withWorkspace(t)
toolCriteria(map[string]any{"action": "add", "texts": []any{"les tests passent"}}, false)
var sys string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
sys = msgText(body.Messages[0])
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- main.go modifié : la fonction parse corrigée, go build et go test passent"}}}})
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
page := func(n int) Message {
return tm("sortie de commande " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{um("corrige le build"), am("ok"), um("continue")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("bash"), page(i))
}
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true, Code: true})
if !changed {
t.Fatal("pas compacté")
}
if !strings.Contains(sys, "CODING session") {
t.Fatal("consigne de résumé du mode Code absente")
}
found := false
for _, m := range out {
if strings.Contains(msgText(m), "Acceptance criteria (current state)") && strings.Contains(msgText(m), "les tests passent") {
found = true
}
}
if !found {
t.Fatal("critères non rendus après compactage")
}
}
+475 -20
View File
@@ -3,10 +3,13 @@ package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -28,8 +31,12 @@ import (
// qu'une poignée d'événements au lieu d'un par token → 20000 couvre des
// centaines de tours. La marge sert surtout à absorber UN tour en cours (streamé
// token par token) avant sa coalescence : un très gros tour (long raisonnement +
// réponse) ne doit pas se faire tronquer le début avant d'être compacté.
const maxLogEvents = 20000
// réponse) ne doit pas se faire tronquer le début avant d'être compacté. 20000
// n'y suffisait pas — un seul tour à très long raisonnement tronquait déjà le
// journal de rejeu, et l'utilisateur perdait le DÉBUT de sa conversation à
// l'écran. Le journal ne pèse que de la mémoire d'affichage, jamais du contexte
// modèle : la marge est bon marché.
const maxLogEvents = 200000
// LogEvent = un événement d'affichage rejouable (un delta SSE + son numéro de
// séquence monotone + un horodatage serveur en ms). Le TS permet au client de
@@ -62,6 +69,27 @@ type Conversation struct {
// à une génération fantôme dans la discussion ouverte.
runningTaskID string
runningTaskName string
// File d'attente des messages envoyés PENDANT une génération (AJEAN
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
// d'étape (après un appel d'outil), ou — si le tour se termine avant —
// traités comme tours suivants, dans l'ordre. Protégée par mu.
queued []queuedMsg
// recentCIDs : identifiants d'envoi déjà acceptés. L'UI réessaie un envoi
// dont la réponse s'est perdue (tunnel) : avant la file, le 409 « déjà en
// cours » faisait office de dédoublonnage ; sans lui, le réessai mettrait
// le même message deux fois en file.
recentCIDs []string
}
// queuedMsg = un message mis en file pendant la génération. Caps et
// température sont ceux choisis à l'envoi : un tour démarré depuis la file doit
// s'exécuter avec ces réglages-là.
type queuedMsg struct {
text string
files []attachInfo
caps Caps
temp float64
}
var conv = func() *Conversation {
@@ -74,12 +102,70 @@ var conv = func() *Conversation {
// modèle. En clair : cette machine déchiffre déjà pour lancer le modèle, le
// relais reste aveugle — la persister ici ne change rien à la posture E2E.
// loadConvAttempts / loadConvRetryWait : au redémarrage du conteneur, l'ancien
// process peut encore tenir le verrou bbolt quelques centaines de ms pendant que
// le nouveau démarre. Quatre essais espacés de 250 ms couvrent ce chevauchement
// sans retarder perceptiblement un démarrage normal (où le premier essai passe).
const (
loadConvAttempts = 4
loadConvRetryWait = 250 * time.Millisecond
)
// loadConvRetries compte les ATTENTES de reprise du dernier LoadConversation
// (0 = le premier essai a suffi). Il n'existe que pour être observable.
//
// Le test de non-régression veut vérifier qu'une absence légitime — première
// installation, rien d'enregistré — ne déclenche AUCUNE reprise. Il le mesurait
// au chronomètre (« moins de 250 ms »), ce qui confond « aucune reprise » avec
// « une seule tentative, mais lente » : sur un runner CI chargé, créer et
// ouvrir la base bbolt a pris 435 ms et le test a échoué alors qu'aucune reprise
// n'avait eu lieu. On compte donc les reprises au lieu de les déduire d'un
// temps de mur, qui ne dépend pas que de nous.
var loadConvRetries atomic.Int32
// LoadConversation recharge l'état persisté au démarrage du process. Sans état
// enregistré (première fois) on part d'une conversation vide.
//
// Une lecture RATÉE n'est PAS une absence, et les confondre coûte cher ici :
// getStr rend "" aussi bien pour « clé absente » que pour « base inaccessible »,
// donc sur un verrou transitoire convEnsureActive croyait la discussion active
// inexistante, forgeait un NOUVEL identifiant et l'écrasait — le fil en cours
// devenait orphelin, en silence. On sonde donc la base avec son erreur AVANT
// toute écriture, on réessaie, et en cas d'échec durable on le dit et on ne
// touche à rien.
func LoadConversation() {
// convEnsureActive reprend au passage le fil unique des versions
// précédentes (clé « conversation ») comme première discussion.
b := getBytes(bkChat, convKey(convEnsureActive()))
var b []byte
var err error
loadConvRetries.Store(0)
retry := func() {
loadConvRetries.Add(1)
time.Sleep(loadConvRetryWait)
}
for attempt := 0; attempt < loadConvAttempts; attempt++ {
// Sonde : lire la clé de la discussion active en gardant l'erreur. Tant
// qu'elle échoue, convEnsureActive ne doit surtout pas être appelée.
if _, err = getBytesErr(bkChat, ckActive); err != nil {
retry()
continue
}
// convEnsureActive reprend au passage le fil unique des versions
// précédentes (clé « conversation ») comme première discussion.
// getStoreBytesErr : même lecture, en DÉCHIFFRANT au besoin (mémoire
// chiffrée). Verrouillé, elle rend (nil, nil) — pas une erreur d'accès :
// on démarre alors sur un fil vide sans rien écraser, et tout revient au
// déverrouillage.
if b, err = getStoreBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
break
}
retry()
}
if err != nil {
// Toujours en échec : on le DIT au lieu de repartir à vide en silence, et
// on abandonne le chargement sans rien écrire — un serveur qui refuse de
// démarrer serait pire, et l'historique sur disque reste intact.
fmt.Fprintf(os.Stderr, "[conv] base illisible au démarrage (%v) — aucune discussion chargée ; rien n'a été écrasé, l'historique est intact\n", err)
return
}
if len(b) == 0 {
return
}
@@ -89,6 +175,7 @@ func LoadConversation() {
// Guérit les conversations d'avant le passage des captures en éphémère : un
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
conv.Messages = stripImageParts(conv.Messages)
go pruneChatImages()
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
conv.Generating = false
conv.cancel = nil
@@ -102,10 +189,12 @@ func (c *Conversation) loadFrom(b []byte) {
c.Stop()
c.mu.Lock()
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
if len(b) > 0 {
_ = json.Unmarshal(b, c)
c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement
}
go pruneChatImages() // plus rien ne référence les images de l'ancien fil
c.Generating = false
c.cancel = nil
c.epoch++
@@ -113,11 +202,27 @@ func (c *Conversation) loadFrom(b []byte) {
c.mu.Unlock()
}
// reloadEncryptedStores recharge la discussion depuis le disque après un
// déverrouillage : verrouillée, la lecture au démarrage a rendu un fil vide.
// On ne recharge QUE dans ce cas — écraser un fil déjà rempli ferait perdre le
// tour en cours, qui n'a pas pu être persisté.
func reloadEncryptedStores() {
conv.mu.Lock()
empty := len(conv.Log) == 0
conv.mu.Unlock()
if empty {
LoadConversation()
}
}
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
func (c *Conversation) persist() {
c.mu.Lock()
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
refImagesInMessages(c.Messages)
b, err := json.Marshal(c)
title := convSummary(c.Messages)
turns := 0
@@ -131,7 +236,10 @@ func (c *Conversation) persist() {
return
}
id := convEnsureActive()
_ = putBytes(bkChat, convKey(id), b)
// Chiffré si la mémoire l'est et qu'elle est déverrouillée. Verrouillée,
// l'écriture est REFUSÉE plutôt que de remplacer un blob chiffré par du
// clair — le fil de ce tour reste en RAM, rien n'est perdu sur disque.
_ = putStoreBytes(bkChat, convKey(id), b)
convTouchMeta(id, title, turns)
}
@@ -216,6 +324,21 @@ func (c *Conversation) compactLogLocked() {
bufKey, toks, ts0, seq0 = "", 0, 0, 0
}
for _, ev := range c.Log {
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
// l'affichage EN DIRECT ; les garder dans le journal le faisait enfler sur
// une conversation agentique (gros write, appels en chaîne) jusqu'à
// maxLogEvents → troncature des plus VIEUX événements, donc perte des
// premiers messages à l'affichage. On ne conserve donc que le done=true
// (même politique que coalesceReplay au replay/export).
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
flush()
if done, _ := tu["done"].(bool); done {
out = append(out, ev)
}
continue
}
key := textKey(ev.Delta)
if key == "" {
flush()
@@ -263,6 +386,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
c.appendDelta(epoch, map[string]any{"compact_noop": true})
return msgs, false
}
// Les pages mémoire lues ont été résumées avec le torse : un rappel liste
// leurs noms pour que le modèle les relise au besoin (AJEAN 0.14.0). Agent
// seulement — sans lui, pas d'outil mem_read pour y donner suite.
if caps.Agent {
compacted = remindReadMemPages(compacted, msgs)
}
overhead := ctxUsed - estimateTokens(msgs)
if overhead < 0 {
overhead = 0
@@ -400,10 +529,154 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
return nil
}
// ErrDupSend : cet envoi (même cid) a déjà été accepté — réessai d'un client
// qui n'a pas reçu la réponse. Ce n'est pas une erreur pour l'utilisateur.
var ErrDupSend = fmt.Errorf("envoi déjà reçu")
// seenCID enregistre cid et dit s'il avait déjà été vu. Appelant : mu tenu.
func (c *Conversation) seenCIDLocked(cid string) bool {
if cid == "" {
return false
}
for _, v := range c.recentCIDs {
if v == cid {
return true
}
}
c.recentCIDs = append(c.recentCIDs, cid)
if len(c.recentCIDs) > 64 {
c.recentCIDs = c.recentCIDs[len(c.recentCIDs)-64:]
}
return false
}
// EnqueueOrStart démarre un tour tout de suite si le moteur est libre, sinon
// MET EN FILE le message (AJEAN 0.14.0) — au lieu du 409 d'avant, qui obligeait
// à arrêter la réponse pour ajouter une précision. Seul un tour de CHAT accepte
// une file : une tâche planifiée qui occupe le modèle garde le refus
// (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir
// recentCIDs) ; un doublon renvoie ErrDupSend.
func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps Caps, temperature float64) (bool, error) {
c.mu.Lock()
if c.seenCIDLocked(cid) {
c.mu.Unlock()
return false, ErrDupSend
}
if c.Generating && c.runningTaskName == "" {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
return true, nil
}
c.mu.Unlock()
err := c.StartTurn(text, files, caps, temperature)
if errors.Is(err, ErrBusy) {
// Un autre tour a démarré entre le test ci-dessus et StartTurn (deux
// appareils qui envoient en même temps) : en file plutôt qu'un refus
// (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien.
c.mu.Lock()
if c.Generating && c.runningTaskName == "" {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
// Ce tour a pu finir entre-temps : sans relance, le message attendrait
// le prochain envoi.
c.startQueuedIfAny()
return true, nil
}
c.mu.Unlock()
}
if err != nil {
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
// ce même message, son cid ne doit pas rester marqué comme reçu.
c.mu.Lock()
for i, v := range c.recentCIDs {
if v == cid {
c.recentCIDs = append(c.recentCIDs[:i], c.recentCIDs[i+1:]...)
break
}
}
c.mu.Unlock()
}
return false, err
}
// drainQueued vide la file et renvoie les messages utilisateur à injecter dans
// le tour EN COURS (vue modèle), en les journalisant pour qu'ils apparaissent
// dans le fil sur tous les appareils. Appelé par runChat entre deux étapes.
// epoch garde contre un reset survenu entre-temps.
func (c *Conversation) drainQueued(epoch int) []Message {
c.mu.Lock()
if c.epoch != epoch || len(c.queued) == 0 {
c.mu.Unlock()
return nil
}
items := c.queued
c.queued = nil
c.mu.Unlock()
var out []Message
for _, q := range items {
delta := map[string]any{"user": q.text}
if m := chatModelName(); m != "" {
delta["model"] = m
}
if len(q.files) > 0 {
delta["files"] = q.files
}
c.appendDelta(epoch, delta)
prompt := q.text
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
out = append(out, Message{Role: "user", Content: userMessageContent(q.files, prompt)})
}
return out
}
// startQueuedIfAny démarre le prochain tour depuis la file, s'il en reste et
// que le moteur est libre. Appelé à la toute fin d'un tour : les messages
// envoyés après la dernière frontière d'étape sont ainsi traités sans que
// l'utilisateur ait à les renvoyer. Chaque tour lancé ainsi rappelle
// startQueuedIfAny à sa fin → la file se vide dans l'ordre. Renvoie true si un
// tour a démarré.
func (c *Conversation) startQueuedIfAny() bool {
c.mu.Lock()
if c.Generating || len(c.queued) == 0 {
c.mu.Unlock()
return false
}
q := c.queued[0]
c.queued = c.queued[1:]
epoch := c.epoch
c.mu.Unlock()
if err := c.StartTurn(q.text, q.files, q.caps, q.temp); err != nil {
// Modèle indisponible au moment de dépiler (rare) : on le dit dans le
// fil plutôt que de perdre le message en silence, et on tente le suivant.
c.appendDelta(epoch, map[string]any{"error": "message en attente non envoyé : " + err.Error()})
return c.startQueuedIfAny()
}
return true
}
// dropQueued abandonne la file (bouton stop : l'utilisateur reprend la main) et
// le signale au client, qui retire ses messages « en attente ».
func (c *Conversation) dropQueued(epoch int) {
c.mu.Lock()
n := len(c.queued)
c.queued = nil
c.mu.Unlock()
if n > 0 {
c.appendDelta(epoch, map[string]any{"queue_dropped": n})
}
}
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
func chatModelName() string {
m := strings.TrimSpace(ReadConfig()["MODEL"])
cfg := ReadConfig()
if isExternalConfig(cfg) {
return strings.TrimSpace(cfg[extKeyModel]) // modèle distant, tel quel
}
m := strings.TrimSpace(cfg["MODEL"])
if m == "" {
return ""
}
@@ -422,6 +695,7 @@ func chatModelName() string {
// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est
// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation.
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
turnStart := time.Now()
defer func() {
c.mu.Lock()
stale := c.epoch != epoch
@@ -443,6 +717,26 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
c.mu.Unlock()
c.persist()
// File d'attente : interrompu par un stop, l'utilisateur reprend la main
// et les messages en file sont abandonnés. Sinon on démarre le prochain
// tour depuis la file — ceux qui n'ont pas pu être injectés en cours de
// route sont traités, dans l'ordre.
if ctx.Err() != nil {
c.dropQueued(epoch)
return
}
// Notification Web Push, seulement quand plus rien ne suit : un message
// en file qui démarre aussitôt n'est pas une « réponse prête », et sa
// propre fin notifiera. Ce chemin (generate) ne sert QUE les tours
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
// le service de push ne doit pas retenir la fin du tour. Corps générique
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
// est là et a coupé volontairement.
if !c.startQueuedIfAny() && hasPushSubs() {
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
}
}()
// Snapshot de la vue modèle.
@@ -464,9 +758,23 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur).
// Injecté seulement dans la vue envoyée au modèle, jamais persisté dans
// c.Messages : modifiable à chaud, effet dès le tour suivant.
// Contexte du projet actif (description, index mémoire, index des trackers),
// même traitement : injecté dans la vue envoyée, jamais persisté — voir
// projectSystemMessages.
//
// Mode agent OFF = modèle BRUT (AJEAN 0.13.13) : ni l'un ni l'autre. Sans
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
// pas : le modèle se mettait à écrire des <tool_call> en clair.
final := msgs
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
if caps.Agent {
final = append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
}
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
@@ -480,11 +788,19 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
// déjà la compaction), approximative mais jamais absente.
sawUsage := false
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
// pas de vérification du mode Code avant sa réponse.
asked := false
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil:
// Le texte écrit AVANT cet appel d'outil est déjà rangé dans le message
// assistant porteur des tool_calls (extra). Le garder aussi dans la
// réponse finale le doublait dans l'historique vu par le modèle — du
// contexte gaspillé à chaque tour d'outil (AJEAN 0.17.4).
content.Reset()
tu := map[string]any{
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
@@ -498,10 +814,19 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// pour que le diff soit encore là après un rafraîchissement.
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
tu["added"] = ev.ToolUsed.Added
tu["removed"] = ev.ToolUsed.Removed
}
if ev.ToolUsed.Image != "" {
tu["image"] = ev.ToolUsed.Image
}
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
if ev.ToolUsed.ResultChars > 0 {
tu["result_chars"] = ev.ToolUsed.ResultChars
}
if ev.ToolUsed.ResultID != "" {
tu["result_id"] = ev.ToolUsed.ResultID
}
c.appendDelta(epoch, map[string]any{"tool_used": tu})
case ev.NewHistory != nil:
// Compaction faite en cours de tour : elle remplace la base au lieu de
@@ -532,6 +857,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.Ask != nil:
// Question structurée (outil ask) : carte à boutons dans l'UI.
asked = true
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
@@ -542,7 +868,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.appendDelta(epoch, map[string]any{"content": ev.Content})
}
return true // génération détachée : on ne s'interrompt jamais sur un abonné
})
}, func() []Message { return c.drainQueued(epoch) })
// Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
// PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
@@ -581,7 +907,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// corrections, re-vérification — AVANT de rendre la main. Voir
// code_verify.go. No-op hors mode code ou sans critères.
if !stale && ctx.Err() == nil {
c.codeVerifyLoop(ctx, caps, temperature, epoch)
c.codeVerifyLoop(ctx, caps, temperature, epoch, asked)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
@@ -683,6 +1009,7 @@ func (c *Conversation) Reset() {
c.epoch++
c.Generating = false
c.cancel = nil
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
c.cond.Broadcast()
c.mu.Unlock()
c.persist()
@@ -777,11 +1104,15 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
continue
}
_, isTool := ev.Delta["tool_used"].(map[string]any)
// Tout événement NON-outil clôt une éventuelle bulle d'outil en attente, pour
// préserver l'ordre (l'outil non terminé s'affiche avant ce qui le suit).
if !isTool {
flushTool()
}
// ⚠️ On ne « flushe » PAS l'annonce d'outil en attente sur un événement
// non-outil. Un appel d'outil s'écrit dans le journal en DEUX temps : une
// annonce (done=false, juste « je vais lancer X ») puis le résultat
// (done=true). Si un événement non-outil (stats, un bout de raisonnement)
// se glisse ENTRE les deux, flusher ici émettait l'annonce PUIS le done →
// le même outil apparaissait DEUX fois (à l'export Markdown et au replay).
// On garde donc l'annonce en attente : le done la remplace (émis une seule
// fois), et si aucun done n'arrive jamais (outil interrompu par un stop),
// le flushTool final l'émet une seule fois, en fin de fil.
// Delta texte ? (une seule clé content ou reasoning_content, valeur string)
key := ""
if s, ok := ev.Delta["content"].(string); ok {
@@ -824,11 +1155,76 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
return out
}
// Subscribe diffuse les événements au client via emit : d'abord un REPLAY coalescé
// historyTailDefault : échanges rejoués à l'ouverture d'une session (reset) pour
// un client qui gère la pagination de l'historique.
const historyTailDefault = 20
// historyCut calcule où couper le rejeu pour ne garder que les `tail` derniers
// échanges (un échange commence à un événement `user`). Renvoie le Seq à partir
// duquel rejouer (exclu) et le nombre d'échanges masqués ; (0, 0) = tout
// rejouer. Repris d'AJEAN 0.15.7.
func historyCut(log []LogEvent, tail int) (int, int) {
if tail <= 0 {
return 0, 0
}
seen := 0
for i := len(log) - 1; i >= 0; i-- {
if _, ok := log[i].Delta["user"]; !ok {
continue
}
seen++
if seen == tail {
hidden := 0
for _, ev := range log[:i] {
if _, ok := ev.Delta["user"]; ok {
hidden++
}
}
if hidden == 0 {
return 0, 0
}
return log[i].Seq - 1, hidden
}
}
return 0, 0
}
// historyHead : événements qui annoncent un historique tronqué. Les états que
// les échanges masqués avaient posés et que le client garde (mode Chat/Code,
// critères du mode Code) sont réémis tels qu'ils étaient à la coupe — sans eux,
// une discussion en mode Code rouverte s'afficherait en mode Chat.
func historyHead(log []LogEvent, cut, hidden int) []map[string]any {
out := []map[string]any{{"history_more": hidden}}
var mode, crit map[string]any
for _, ev := range log {
if ev.Seq > cut {
break
}
if _, ok := ev.Delta["mode"]; ok {
mode = ev.Delta
}
if _, ok := ev.Delta["criteria"]; ok {
crit = ev.Delta
}
}
for _, d := range []map[string]any{mode, crit} {
if d != nil {
out = append(out, d)
}
}
return out
}
// Subscribe : abonnement sans pagination (rejeu complet).
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
c.SubscribeTail(ctx, from, -1, "", emit)
}
// SubscribeTail diffuse les événements au client via emit : d'abord un REPLAY coalescé
// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
// Réveille les attentes de cond quand la connexion se ferme.
go func() {
<-ctx.Done()
@@ -852,6 +1248,48 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
snapshot := append([]LogEvent(nil), c.Log...)
epoch := c.epoch
c.mu.Unlock()
// ⚠️ Garde-fou anti-« premiers messages manquants au changement de session ».
// Le client se réabonne avec from=lastSeq (le dernier Seq qu'il a vu). Les Seq
// ne sont PAS globaux : ouvrir une session PLUS ANCIENNE charge un journal dont
// les Seq sont plus bas. Si le client garde un from élevé (hérité de la session
// qu'il quittait) et se reconnecte, coalesceReplay saute tout (Seq <= from) →
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
// l'est, son curseur vient d'une autre session → on repart du début.
//
// ⚠️ Même famille, cas plus sournois (AJEAN 0.14.0) : un AUTRE appareil a
// changé de discussion pendant que ce client était déconnecté, et son `from`
// est PLUS BAS que le dernier Seq de la nouvelle. Le rejeu greffait alors la
// fin de la nouvelle discussion sur le début de l'ancienne restée à l'écran —
// deux fils fusionnés jusqu'au prochain rafraîchissement. Le client renvoie
// l'id de la discussion qu'il affiche (convID) : s'il ne correspond plus, on
// lui ordonne d'abord de vider l'écran (reset), puis on rejoue tout.
curID := getStr(bkChat, ckActive)
staleConv := from > 0 && convID != "" && convID != curID
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
from = 0
staleConv = staleConv || convID != ""
}
if staleConv {
from = 0
if !emit(map[string]any{"reset": true, "id": curID}) {
return
}
}
// Chargement initial d'un client paginé : on ne rejoue que la fin du fil,
// précédée de {history_more: N}. Sur un long fil d'agent, tout rejouer
// envoyait des Mo et bâtissait des milliers de bulles avant d'afficher quoi
// que ce soit.
if from == 0 && tail > 0 {
if cut, hidden := historyCut(snapshot, tail); hidden > 0 {
from = cut
for _, ev := range historyHead(snapshot, cut, hidden) {
if !emit(ev) {
return
}
}
}
}
last := from
for _, ev := range coalesceReplay(snapshot, from) {
if ctx.Err() != nil {
@@ -864,7 +1302,7 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
last = s
}
}
if !emit(map[string]any{"caught_up": true}) {
if !emit(map[string]any{"caught_up": true, "id": curID}) {
return
}
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
@@ -897,10 +1335,27 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
if c.epoch != epoch { // reset → on ordonne au client de nettoyer et on repart
epoch = c.epoch
last = 0
// Session ouverte (fil complet qui suit) chez un client paginé : on
// saute directement aux derniers échanges.
var head []map[string]any
if tail >= 0 {
if cut, hidden := historyCut(c.Log, historyTailDefault); hidden > 0 {
last = cut
// ctx_used vit dans le journal des échanges masqués : on le
// donne tel qu'il est maintenant, sinon la jauge resterait à 0.
head = append(historyHead(c.Log, cut, hidden), map[string]any{"ctx_used": c.CtxUsed})
}
}
c.mu.Unlock()
if !emit(map[string]any{"reset": true}) {
// id : la discussion désormais affichée (lue hors verrou — base).
if !emit(map[string]any{"reset": true, "id": getStr(bkChat, ckActive)}) {
return
}
for _, ev := range head {
if !emit(ev) {
return
}
}
c.mu.Lock()
continue
}
@@ -0,0 +1,155 @@
package loki
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
bolt "go.etcd.io/bbolt"
)
// resetConvForTest vide l'état en RAM pour forcer un VRAI rechargement depuis la
// base, comme au démarrage d'un process qui ne connaît encore rien. `conv` est un
// global du paquet : sans ça, un test verrait l'état laissé par le précédent.
func resetConvForTest() {
conv.mu.Lock()
conv.Messages, conv.Log, conv.Seq, conv.CtxUsed = nil, nil, 0, 0
conv.mu.Unlock()
}
// TestLoadConversationReessayeSousContention : la panne d'origine est un verrou
// bbolt transitoire au redémarrage du conteneur (l'ancien process tient encore
// le fichier pendant que le nouveau démarre), confondu avec « rien à charger ».
//
// Réserve honnête : ce test ne reproduit PAS la panne à coup sûr. withDB ouvre
// déjà la base avec Options.Timeout de 5 s, qui absorbe seul une contention de
// 400 ms — ce test passe donc aussi sur le code d'AVANT le correctif. Il est
// gardé comme filet de non-régression sur le nouveau chemin (getBytesErr + boucle
// de tentatives) : ce chemin ne doit ni perdre la conversation, ni se bloquer.
func TestLoadConversationReessayeSousContention(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
// 1. Écrit une conversation par l'API normale (elle crée aussi la clé active).
resetConvForTest()
conv.mu.Lock()
conv.Messages = []Message{{Role: "user", Content: "bonjour"}}
conv.mu.Unlock()
conv.persist()
activeID := getStr(bkChat, ckActive)
if activeID == "" {
t.Fatal("persist n'a pas créé de discussion active — le montage du test est faux")
}
// 2. État en RAM vidé : le rechargement doit tout reprendre depuis la base.
resetConvForTest()
// 3. Verrou exclusif tenu une fenêtre courte dans une goroutine séparée : la
// base est momentanément indisponible, exactement comme au chevauchement.
db, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 2 * time.Second})
if err != nil {
t.Fatalf("impossible de prendre le verrou pour le test : %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(400 * time.Millisecond)
db.Close()
}()
LoadConversation()
wg.Wait()
conv.mu.Lock()
got := len(conv.Messages)
conv.mu.Unlock()
if got != 1 {
t.Fatalf("conversation perdue malgré une contention temporaire : %d message(s), attendu 1", got)
}
if id := getStr(bkChat, ckActive); id != activeID {
t.Fatalf("la discussion active a changé (%q → %q) : le fil d'origine est orphelin", activeID, id)
}
}
// TestLoadConversationBaseIllisibleNeForgePasDeDiscussion garde l'invariant qui
// justifie le correctif : convEnsureActive ÉCRIT (elle forge un identifiant et
// l'enregistre sous bkChat/active). Elle ne doit donc jamais être appelée sur la
// foi d'une lecture ratée — sinon un verrou durable suffisait à mettre le fil en
// cours de côté au profit d'une discussion neuve, en silence.
//
// Réserve honnête, là encore : avec une base illisible l'écriture échoue elle
// aussi, donc le disque finit identique dans les deux versions. Ce que ce test
// verrouille, c'est le CONTRAT — sortir sans rien écrire, et sans faire attendre
// indéfiniment — pas la reproduction du dégât.
func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
// Fichier de base invalide : bolt.Open échoue tout de suite (pas d'attente de
// verrou), ce qui exerce le chemin d'erreur sans allonger le test.
path := filepath.Join(home, "loki.db")
if err := os.WriteFile(path, []byte("ceci n'est pas une base bbolt"), 0o600); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
resetConvForTest()
start := time.Now()
LoadConversation()
elapsed := time.Since(start)
// Budget : loadConvAttempts tentatives espacées de loadConvRetryWait, plus une
// marge. Au-delà, la boucle de reprise retarderait le démarrage du serveur.
if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max {
t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max)
}
// Contrepartie de l'assertion « aucune reprise » du test d'absence légitime :
// un compteur bloqué à zéro la rendrait vraie sans rien prouver. Ici les
// reprises ont bel et bien lieu, et on le vérifie.
if n := loadConvRetries.Load(); int(n) != loadConvAttempts {
t.Fatalf("%d reprise(s) comptée(s) sur une base illisible, attendu %d", n, loadConvAttempts)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("la base a été modifiée alors qu'elle n'a même pas pu être lue")
}
conv.mu.Lock()
n := len(conv.Messages)
conv.mu.Unlock()
if n != 0 {
t.Fatalf("%d message(s) chargés depuis une base illisible", n)
}
}
// TestLoadConversationAbsenceLegitimeResteRapide : le correctif ne doit pas avoir
// transformé le cas NORMAL (base saine, rien d'enregistré encore — premier
// démarrage) en attente inutile. Une seule tentative doit suffire.
func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
resetConvForTest()
LoadConversation()
// Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence
// légitime doit sortir au premier essai. Le temps de mur ne disait pas ça —
// il mêlait le coût de l'essai lui-même (création de la base bbolt, fsync)
// à l'attente de reprise, et un runner CI chargé l'a fait échouer à 435 ms
// alors qu'aucune reprise n'avait été déclenchée.
if n := loadConvRetries.Load(); n != 0 {
t.Fatalf("absence légitime traitée comme une erreur : %d reprise(s) déclenchée(s), aucune ne devait l'être", n)
}
conv.mu.Lock()
n := len(conv.Messages)
conv.mu.Unlock()
if n != 0 {
t.Fatalf("%d message(s) chargés alors que rien n'avait été enregistré", n)
}
}
+84
View File
@@ -225,3 +225,87 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
c.appendDelta(c.epoch, map[string]any{"content": "suite"})
waitSeq(t, got, 503)
}
// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) ---
// compactLogLocked doit coalescer les événements tool_used d'un même appel en un
// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe
// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal
// jusqu'à la troncature des premiers messages.
func TestCompactLogCoalesceTools(t *testing.T) {
c := newTestConv()
c.Log = []LogEvent{
{Seq: 1, Delta: map[string]any{"user": "édite"}},
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}},
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}},
{Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}},
{Seq: 5, Delta: map[string]any{"content": "fini"}},
}
c.mu.Lock()
c.compactLogLocked()
c.mu.Unlock()
nTool := 0
for _, ev := range c.Log {
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
nTool++
if done, _ := tu["done"].(bool); !done {
t.Error("un tool_used non terminé a survécu à la compaction")
}
}
}
if nTool != 1 {
t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool)
}
}
// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil
// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true.
func TestCoalesceReplayNoDoubleTool(t *testing.T) {
events := []LogEvent{
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}},
{Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}},
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}},
}
n := 0
for _, ev := range coalesceReplay(events, 0) {
if _, ok := ev["tool_used"]; ok {
n++
}
}
if n != 1 {
t.Fatalf("outil émis %d fois au replay (attendu 1)", n)
}
}
// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur
// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien :
// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide.
func TestSubscribeStaleFromCrossSession(t *testing.T) {
c := newTestConv()
c.appendDelta(c.epoch, map[string]any{"user": "premier message"})
c.appendDelta(c.epoch, map[string]any{"content": "réponse"})
// Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session).
ctx, cancel := context.WithCancel(context.Background())
var got []map[string]any
done := make(chan struct{})
go func() {
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
got = append(got, ev)
if ev["caught_up"] != nil {
cancel()
}
return true
})
close(done)
}()
<-done
seen := ""
for _, ev := range got {
if u, ok := ev["user"].(string); ok {
seen += u
}
}
if !strings.Contains(seen, "premier message") {
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
}
}
+1 -1
View File
@@ -278,7 +278,7 @@ func migrateLegacyUploads(src string) {
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
// relie un fichier déposé à sa discussion.
func convAttachNames(id string) []string {
b := getBytes(bkChat, convKey(id))
b := storeBytes(bkChat, convKey(id))
if len(b) == 0 {
return nil
}
+83 -24
View File
@@ -5,12 +5,18 @@ package loki
// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
//
// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N
// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le
// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ».
import "strings"
const (
diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente)
diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
diffContext = 3 // lignes identiques gardées autour d'un changement
diffOmitPrefix = "…"
)
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
@@ -19,26 +25,69 @@ type DiffLine struct {
Text string `json:"text"`
}
// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
// lignes communes comme contexte : c'est ce qui rend le changement lisible
// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
func lineDiff(oldText, newText string) []DiffLine {
// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à
// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées.
//
// Les lignes communes en tête et en queue sont écartées avant la LCS : un
// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un
// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des
// lignes affichées par des centaines de lignes de contexte.
func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) {
a := splitLines(oldText)
b := splitLines(newText)
// Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
// simplement l'ancien en retrait et le nouveau en ajout.
if len(a) > diffMaxLines || len(b) > diffMaxLines {
out := make([]DiffLine, 0, len(a)+len(b))
for _, l := range a {
out = append(out, DiffLine{Op: "-", Text: l})
pre := 0
for pre < len(a) && pre < len(b) && a[pre] == b[pre] {
pre++
}
suf := 0
for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] {
suf++
}
ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf]
var mid []DiffLine
if len(ma) > diffMaxLines || len(mb) > diffMaxLines {
// Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en
// retrait puis le nouveau en ajout.
mid = make([]DiffLine, 0, len(ma)+len(mb))
for _, l := range ma {
mid = append(mid, DiffLine{Op: "-", Text: l})
}
for _, l := range b {
out = append(out, DiffLine{Op: "+", Text: l})
for _, l := range mb {
mid = append(mid, DiffLine{Op: "+", Text: l})
}
} else {
mid = lcsDiff(ma, mb)
}
for _, d := range mid {
switch d.Op {
case "+":
added++
case "-":
removed++
}
return capLines(out)
}
// LCS : table des longueurs, puis remontée.
// Contexte autour du changement : quelques lignes, le reste résumé.
var out []DiffLine
if pre > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)})
}
for _, l := range a[max(0, pre-diffContext):pre] {
out = append(out, DiffLine{Op: " ", Text: l})
}
out = append(out, mid...)
for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] {
out = append(out, DiffLine{Op: " ", Text: l})
}
if suf > diffContext {
out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)})
}
return capLines(out), added, removed
}
// lcsDiff : diff exact par plus longue sous-séquence commune.
func lcsDiff(a, b []string) []DiffLine {
n, m := len(a), len(b)
lcs := make([][]int, n+1)
for i := range lcs {
@@ -77,27 +126,37 @@ func lineDiff(oldText, newText string) []DiffLine {
for ; j < m; j++ {
out = append(out, DiffLine{Op: "+", Text: b[j]})
}
return capLines(out)
return out
}
// addedDiff présente un contenu entièrement nouveau (création d'une page).
func addedDiff(text string) []DiffLine {
lines := splitLines(text)
out := make([]DiffLine, 0, len(lines))
for _, l := range lines {
// addedDiff présente un contenu entièrement nouveau (création d'une page ou
// d'un fichier) : toutes ses lignes en ajout.
func addedDiff(text string) (lines []DiffLine, added int) {
ls := splitLines(text)
out := make([]DiffLine, 0, len(ls))
for _, l := range ls {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out)
return capLines(out), len(ls)
}
func splitLines(s string) []string {
s = strings.ReplaceAll(s, "\r\n", "\n")
// Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide.
s = strings.TrimSuffix(s, "\n")
if s == "" {
return nil
}
return strings.Split(s, "\n")
}
func omitted(n int) string {
if n == 1 {
return diffOmitPrefix + "(1 ligne identique)"
}
return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)"
}
// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
func capLines(d []DiffLine) []DiffLine {
if len(d) <= diffMaxShown {
@@ -105,7 +164,7 @@ func capLines(d []DiffLine) []DiffLine {
}
cut := len(d) - diffMaxShown
out := append([]DiffLine{}, d[:diffMaxShown]...)
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
}
func itoa(n int) string {
+68
View File
@@ -0,0 +1,68 @@
package loki
import (
"strings"
"testing"
)
func numbered(n int, suffix string) string {
var b strings.Builder
for i := 1; i <= n; i++ {
b.WriteString("ligne " + itoa(i) + suffix + "\n")
}
return b.String()
}
// Un fichier de 500 lignes : +500, même si l'UI n'en reçoit que 120.
func TestAddedDiffCountsBeyondCap(t *testing.T) {
lines, added := addedDiff(numbered(500, ""))
if added != 500 {
t.Fatalf("added = %d, attendu 500", added)
}
if len(lines) > diffMaxShown+1 {
t.Fatalf("%d lignes envoyées, plafond %d", len(lines), diffMaxShown)
}
}
// Le saut de ligne final ne compte pas pour une ligne.
func TestAddedDiffTrailingNewline(t *testing.T) {
if _, n := addedDiff("a\nb\n"); n != 2 {
t.Fatalf("n = %d, attendu 2", n)
}
if _, n := addedDiff("a\nb"); n != 2 {
t.Fatalf("n = %d, attendu 2", n)
}
}
// Une ligne changée au milieu d'un gros bloc : +1 -1, et le changement est
// bien dans les lignes affichées (pas noyé sous le contexte).
func TestLineDiffSmallChangeInBigBlock(t *testing.T) {
old := numbered(800, "")
neu := strings.Replace(old, "ligne 600\n", "ligne 600 modifiée\n", 1)
lines, add, del := lineDiff(old, neu)
if add != 1 || del != 1 {
t.Fatalf("+%d -%d, attendu +1 -1", add, del)
}
var sawAdd, sawDel bool
for _, l := range lines {
sawAdd = sawAdd || (l.Op == "+" && l.Text == "ligne 600 modifiée")
sawDel = sawDel || (l.Op == "-" && l.Text == "ligne 600")
}
if !sawAdd || !sawDel {
t.Fatalf("le changement n'est pas dans le diff affiché : %v", lines)
}
if len(lines) > 2*diffContext+4 {
t.Fatalf("trop de contexte : %d lignes", len(lines))
}
}
func TestLineDiffCountsAreExact(t *testing.T) {
_, add, del := lineDiff("a\nb\nc\n", "a\nx\ny\nc\n")
if add != 2 || del != 1 {
t.Fatalf("+%d -%d, attendu +2 -1", add, del)
}
_, add, del = lineDiff(numbered(300, ""), numbered(300, " bis"))
if add != 300 || del != 300 {
t.Fatalf("+%d -%d, attendu +300 -300", add, del)
}
}
+67 -1
View File
@@ -103,12 +103,78 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
Version: Version,
ExportedAt: time.Now().Format(time.RFC3339),
CtxUsed: ctxUsed,
Messages: msgs,
Messages: stripInlineImages(msgs),
Log: filterLog(log, o),
}
return json.MarshalIndent(p, "", " ")
}
// stripInlineImages remplace les images en base64 (data URI) portées par les
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et
// sur une COPIE : les messages appartiennent à la conversation vivante.
func stripInlineImages(msgs []Message) []Message {
out := make([]Message, len(msgs))
for i, m := range msgs {
out[i] = m
out[i].Content = stripInlineImagesValue(m.Content)
}
return out
}
func stripInlineImagesValue(v any) any {
// Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}.
// On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible.
elide := func(part map[string]any) map[string]any {
cp := make(map[string]any, len(part))
for k, val := range part {
cp[k] = val
}
if iu, ok := cp["image_url"].(map[string]any); ok {
c2 := make(map[string]any, len(iu))
for k, val := range iu {
c2[k] = val
}
if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") {
c2["url"] = elideDataURI(url)
}
cp["image_url"] = c2
}
return cp
}
switch parts := v.(type) {
case []map[string]any: // message fraîchement produit
out := make([]map[string]any, len(parts))
for i, p := range parts {
out[i] = elide(p)
}
return out
case []any: // message relu depuis la base (JSON) : []any de maps
out := make([]any, len(parts))
for i, p := range parts {
if mp, ok := p.(map[string]any); ok {
out[i] = elide(mp)
} else {
out[i] = p
}
}
return out
}
return v // simple chaîne (cas courant) : rien à faire
}
// elideDataURI garde l'en-tête « data:<mime>;base64, » (lisible) et remplace le
// corps base64 par sa taille, pour que l'export reste informatif sans le poids.
func elideDataURI(url string) string {
if i := strings.Index(url, ","); i >= 0 {
return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1)))
}
return "<image élidée>"
}
// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
// formats avaient auparavant des options distinctes (le JSON proposait un
// obscur « journal d'affichage » à la place des trois autres) : personne ne
+32
View File
@@ -258,3 +258,35 @@ func TestCmdExportEcritLeFichier(t *testing.T) {
t.Fatal("option inconnue acceptée")
}
}
// L'export JSON d'un message multimodal (vision) doit ÉLIDER le base64 des images :
// sinon le fichier pesait plusieurs Mo par image. Le Markdown, lui, n'a jamais porté
// les images (juste les noms de fichiers).
func TestExportJSONElideImages(t *testing.T) {
t.Cleanup(func() { conv.Reset() })
big := strings.Repeat("A", 300000)
conv.mu.Lock()
conv.Messages = []Message{
{Role: "user", Content: []map[string]any{
{"type": "text", "text": "regarde"},
{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + big}},
}},
{Role: "assistant", Content: "vu"},
}
conv.Log = []LogEvent{{Seq: 1, TS: 1, Delta: map[string]any{"user": "regarde"}}}
conv.Seq = 1
conv.mu.Unlock()
b, err := conv.ExportJSON(defaultExportOpts())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), big) {
t.Error("le base64 brut d'une image est encore présent dans le JSON")
}
if len(b) > 10000 {
t.Errorf("JSON toujours énorme malgré l'élision : %d octets", len(b))
}
if !strings.Contains(string(b), "élidé") {
t.Error("marqueur d'élision absent du JSON")
}
}
+80
View File
@@ -0,0 +1,80 @@
package loki
import (
"context"
"testing"
)
// historyCut ne garde que les N derniers échanges (un échange commence à `user`).
func TestHistoryCut(t *testing.T) {
var log []LogEvent
seq := 0
add := func(d map[string]any) { seq++; log = append(log, LogEvent{Seq: seq, Delta: d}) }
for i := 0; i < 5; i++ {
add(map[string]any{"user": "q"})
add(map[string]any{"content": "r"})
add(map[string]any{"turn_done": true})
}
// 5 échanges, seq 1..15 ; les 2 derniers commencent aux seq 10 et 13.
if cut, hidden := historyCut(log, 2); cut != 9 || hidden != 3 {
t.Fatalf("tail=2 : cut=%d hidden=%d, attendu 9 et 3", cut, hidden)
}
if cut, hidden := historyCut(log, 5); cut != 0 || hidden != 0 {
t.Fatalf("tail=5 (tout tient) : cut=%d hidden=%d, attendu 0 et 0", cut, hidden)
}
if cut, hidden := historyCut(log, 0); cut != 0 || hidden != 0 {
t.Fatalf("tail=0 : pas de coupure attendue (cut=%d hidden=%d)", cut, hidden)
}
// Les événements rejoués après la coupure commencent bien au 4e échange.
evs := coalesceReplay(log, 9)
if u, _ := evs[0]["user"].(string); u != "q" || evs[0]["seq"] != 10 {
t.Fatalf("premier événement rejoué inattendu : %v", evs[0])
}
}
// La coupe réémet le mode (Chat/Code) posé dans la partie masquée.
func TestHistoryHeadCarriesMode(t *testing.T) {
log := []LogEvent{
{Seq: 1, Delta: map[string]any{"mode": "code"}},
{Seq: 2, Delta: map[string]any{"user": "q1"}},
{Seq: 3, Delta: map[string]any{"user": "q2"}},
}
head := historyHead(log, 2, 1)
if len(head) != 2 || head[0]["history_more"] != 1 || head[1]["mode"] != "code" {
t.Fatalf("en-tête inattendu : %v", head)
}
}
// Un client paginé ne reçoit au chargement que les derniers échanges, précédés
// de {history_more} ; un ancien client (tail < 0) reçoit tout.
func TestSubscribeTailPaginates(t *testing.T) {
c := newTestConv()
for i := 0; i < 5; i++ {
c.appendDelta(c.epoch, map[string]any{"user": "q"})
c.appendDelta(c.epoch, map[string]any{"content": "r"})
}
collect := func(tail int) (more any, users int) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.SubscribeTail(ctx, 0, tail, "", func(m map[string]any) bool {
if v, ok := m["history_more"]; ok {
more = v
}
if _, ok := m["user"]; ok {
users++
}
if _, ok := m["caught_up"]; ok {
cancel()
return false
}
return true
})
return
}
if more, users := collect(2); more != 3 || users != 2 {
t.Fatalf("tail=2 : history_more=%v, %d échanges rejoués (attendu 3 et 2)", more, users)
}
if more, users := collect(-1); more != nil || users != 5 {
t.Fatalf("ancien client : history_more=%v, %d échanges (attendu rien et 5)", more, users)
}
}
+267
View File
@@ -0,0 +1,267 @@
package loki
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// chat_images.go — images d'une conversation stockées PAR RÉFÉRENCE.
//
// Une image vue par le modèle (pièce jointe, see_image, capture du navigateur) est
// une partie `image_url` en base64 dans un message. Gardée telle quelle dans
// l'historique, elle y pesait des Mo (une conversation de 14 messages : 36 Mo), et
// l'historique entier est réécrit à chaque fin de tour et relu à chaque ouverture.
//
// On la range donc UNE fois sur disque, sous LOKI_HOME/chatimg/<empreinte>.<ext>
// (hors du dossier de travail, que l'IA peut vider), et l'historique n'en garde
// que l'adresse « loki-img:<nom> ». Juste avant l'envoi au modèle,
// expandImageRefs remet les octets exacts (cache mémoire) : pour le modèle, rien
// ne change. Mêmes octets, même empreinte → aucune copie en double.
const imgRefScheme = "loki-img:"
func chatImgDir() string { return filepath.Join(LokiHome(), "chatimg") }
var imgExtByMime = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}
// storeChatImage range les octets d'une image et renvoie sa référence.
func storeChatImage(b []byte, mime string) (string, error) {
ext := imgExtByMime[mime]
if ext == "" {
ext = ".img"
}
sum := sha256.Sum256(b)
name := hex.EncodeToString(sum[:16]) + ext
dir := chatImgDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err != nil {
// Chiffrée comme le fil quand la mémoire l'est (même enveloppe que les
// pages). Verrouillée : refus, l'image reste en base64 dans le message.
enc, err := encodeMemContent(b)
if err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, p); err != nil {
return "", err
}
}
return imgRefScheme + name, nil
}
// Cache des data-URL reconstituées : un tour d'agent renvoie tout l'historique à
// CHAQUE étape, on ne relit pas le disque à chaque fois. Borné en taille.
var (
imgCacheMu sync.Mutex
imgCache = map[string]string{}
imgCacheOrder []string
imgCacheBytes int
)
const imgCacheMax = 64 << 20
func chatImageDataURL(ref string) (string, bool) {
name := strings.TrimPrefix(ref, imgRefScheme)
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return "", false
}
imgCacheMu.Lock()
if u, ok := imgCache[name]; ok {
imgCacheMu.Unlock()
return u, true
}
imgCacheMu.Unlock()
raw, err := os.ReadFile(filepath.Join(chatImgDir(), name))
if err != nil {
return "", false
}
b, err := decodeMemContent(raw)
if err != nil {
return "", false
}
mime := "image/jpeg"
for m, e := range imgExtByMime {
if strings.HasSuffix(name, e) {
mime = m
}
}
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
imgCacheMu.Lock()
if _, ok := imgCache[name]; !ok {
imgCache[name] = u
imgCacheOrder = append(imgCacheOrder, name)
imgCacheBytes += len(u)
for imgCacheBytes > imgCacheMax && len(imgCacheOrder) > 1 {
old := imgCacheOrder[0]
imgCacheOrder = imgCacheOrder[1:]
imgCacheBytes -= len(imgCache[old])
delete(imgCache, old)
}
}
imgCacheMu.Unlock()
return u, true
}
// chatImgKeep : âge minimal d'une image avant qu'on puisse l'effacer.
const chatImgKeep = 24 * time.Hour
// pruneChatImages efface les images de plus de chatImgKeep. Appelée au
// démarrage et à chaque bascule de discussion, c'est-à-dire quand le fil
// vivant vient d'être rechargé — et stripImageParts retire les images d'un fil
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
// fil resté ouvert longtemps sans bascule.
func pruneChatImages() {
dir := chatImgDir()
entries, err := os.ReadDir(dir)
if err != nil {
return
}
cutoff := time.Now().Add(-chatImgKeep)
for _, e := range entries {
if e.IsDir() {
continue
}
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
// contentParts renvoie les parties d'un contenu multimodal, sous l'une ou l'autre
// forme (fraîche []map, ou relue du JSON []any).
func contentParts(content any) []map[string]any {
switch v := content.(type) {
case []map[string]any:
return v
case []any:
out := make([]map[string]any, 0, len(v))
for _, p := range v {
if m, ok := p.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
return nil
}
func partImageURL(p map[string]any) (map[string]any, string) {
if p["type"] != "image_url" {
return nil, ""
}
iu, _ := p["image_url"].(map[string]any)
if iu == nil {
return nil, ""
}
u, _ := iu["url"].(string)
return iu, u
}
// refImagesInMessages remplace EN PLACE les images en base64 des messages par des
// références (voir plus haut). Renvoie true si quelque chose a changé. Une image
// qui ne peut pas être rangée (disque plein…) reste en base64 : rien ne se perd.
//
// ⚠️ Écart avec l'amont : les parties modifiées sont RECOPIÉES, jamais mutées.
// persist peut tourner pendant qu'un runChat (compactage en vol, bascule de
// projet) sérialise encore ces mêmes maps : une écriture en place y déclenchait
// un « concurrent map read and map write », fatal pour le process.
func refImagesInMessages(msgs []Message) bool {
changed := false
for mi, m := range msgs {
parts := contentParts(m.Content)
var np []map[string]any
for pi, p := range parts {
_, u := partImageURL(p)
if !strings.HasPrefix(u, "data:image/") {
continue
}
i := strings.Index(u, ";base64,")
if i < 0 {
continue
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
continue
}
ref, err := storeChatImage(b, u[5:i])
if err != nil {
continue
}
if np == nil {
np = append([]map[string]any(nil), parts...)
}
np[pi] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}}
}
if np != nil {
msgs[mi].Content = np
changed = true
}
}
return changed
}
// hasInlineImages : au moins une image encore en base64 dans ces messages ?
func hasInlineImages(msgs []Message) bool {
for _, m := range msgs {
for _, p := range contentParts(m.Content) {
if _, u := partImageURL(p); strings.HasPrefix(u, "data:image/") {
return true
}
}
}
return false
}
// expandImageRefs renvoie une COPIE des messages prête pour le modèle : chaque
// référence d'image redevient sa data-URL. Les messages sans référence sont
// partagés tels quels. Une image introuvable (dossier supprimé à la main) devient
// une mention textuelle plutôt qu'une requête qui échouerait.
func expandImageRefs(msgs []Message) []Message {
out := msgs
copied := false
for i, m := range msgs {
parts := contentParts(m.Content)
has := false
for _, p := range parts {
if _, u := partImageURL(p); strings.HasPrefix(u, imgRefScheme) {
has = true
break
}
}
if !has {
continue
}
if !copied {
out = append([]Message(nil), msgs...)
copied = true
}
np := make([]map[string]any, 0, len(parts))
for _, p := range parts {
_, u := partImageURL(p)
if !strings.HasPrefix(u, imgRefScheme) {
np = append(np, p)
continue
}
if d, ok := chatImageDataURL(u); ok {
np = append(np, map[string]any{"type": "image_url", "image_url": map[string]any{"url": d}})
} else {
np = append(np, map[string]any{"type": "text", "text": "[image indisponible : fichier supprimé]"})
}
}
m.Content = np
out[i] = m
}
return out
}
+95
View File
@@ -0,0 +1,95 @@
package loki
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Une image en base64 part sur disque et revient identique à l'envoi au modèle ;
// l'historique ne garde qu'une référence de quelques octets.
func TestImageRefsRoundTrip(t *testing.T) {
testHome(t)
raw := []byte(strings.Repeat("\x89PNG-fake-image-bytes", 5000))
url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
msgs := []Message{
{Role: "user", Content: []map[string]any{
{"type": "text", "text": "regarde"},
{"type": "image_url", "image_url": map[string]any{"url": url}},
}},
{Role: "assistant", Content: "vu"},
}
// Relu depuis le JSON (forme []any), comme après un redémarrage.
var reread []Message
b, _ := json.Marshal(msgs)
_ = json.Unmarshal(b, &reread)
for _, set := range [][]Message{msgs, reread} {
if !hasInlineImages(set) || !refImagesInMessages(set) || hasInlineImages(set) {
t.Fatal("l'image aurait dû être rangée par référence")
}
js, _ := json.Marshal(set)
if len(js) > 500 || !strings.Contains(string(js), imgRefScheme) {
t.Fatalf("historique encore lourd : %d octets", len(js))
}
out := expandImageRefs(set)
_, got := partImageURL(contentParts(out[0].Content)[1])
if got != url {
t.Fatal("l'image renvoyée au modèle diffère de l'originale")
}
// L'historique lui-même n'est pas modifié par l'expansion.
if _, u := partImageURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, imgRefScheme) {
t.Fatal("expandImageRefs ne doit pas toucher l'historique")
}
}
}
// Mémoire chiffrée : l'image rangée est chiffrée sur disque et revient intacte ;
// verrouillée, elle n'est pas écrite (elle reste en base64 dans le message).
func TestImageRefsChiffrees(t *testing.T) {
testHome(t)
clearMemDEK()
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
raw := []byte(strings.Repeat("\xff\xd8JPEG-fake", 3000))
ref, err := storeChatImage(raw, "image/jpeg")
if err != nil {
t.Fatalf("storeChatImage: %v", err)
}
disk, _ := os.ReadFile(filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)))
if !looksEncrypted(disk) {
t.Fatal("image en clair sur disque alors que la mémoire est chiffrée")
}
u, ok := chatImageDataURL(ref)
if !ok || u != "data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(raw) {
t.Fatal("image chiffrée non restituée à l'identique")
}
clearMemDEK()
if _, err := storeChatImage([]byte("autre image"), "image/png"); err == nil {
t.Fatal("mémoire verrouillée : l'image n'aurait pas dû être écrite")
}
}
// Les images de plus de chatImgKeep sont effacées, les récentes restent.
func TestPruneChatImages(t *testing.T) {
testHome(t)
vieille, _ := storeChatImage([]byte("vieille"), "image/png")
recente, _ := storeChatImage([]byte("récente"), "image/png")
pv := filepath.Join(chatImgDir(), strings.TrimPrefix(vieille, imgRefScheme))
old := time.Now().Add(-chatImgKeep - time.Hour)
if err := os.Chtimes(pv, old, old); err != nil {
t.Fatal(err)
}
pruneChatImages()
if _, err := os.Stat(pv); !os.IsNotExist(err) {
t.Fatal("la vieille image aurait dû être effacée")
}
if _, err := os.Stat(filepath.Join(chatImgDir(), strings.TrimPrefix(recente, imgRefScheme))); err != nil {
t.Fatal("la récente image a disparu")
}
}
+1 -1
View File
@@ -97,7 +97,7 @@ func webGrepTool() Tool {
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce
// toolMaxOutput (8000) pour le shell et mcpOutputCap pour MCP. Sans ce
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
// déclenchait des compactages en cascade au milieu du raisonnement.
+163
View File
@@ -0,0 +1,163 @@
package loki
import (
"encoding/json"
"fmt"
"strings"
)
// chat_mem_pinned.go — RAPPEL des pages mémoire lues au compactage.
//
// Problème corrigé : quand le contexte est compacté, le torse (dont les résultats
// de mem_read) est résumé. Une page mémoire qui portait, par exemple, TOUTES LES
// RÈGLES à respecter pour une tâche était donc réduite à quelques mots dans le
// résumé — et le modèle, après compactage, « oubliait » les règles et partait en
// vrille.
//
// Choix retenu (le plus léger pour le contexte) : on ne garde RIEN verbatim. On
// injecte simplement, en tête de la conversation compactée, un petit rappel qui
// LISTE les pages que le modèle a lues (mem_read) et l'invite à en relire une si
// elle est pertinente, pour être sûr d'avoir toutes les infos avant de répondre. Le
// rappel ne pèse que les NOMS des pages (borné), le contenu complet reste à un
// mem_read de distance. Le comportement de compactage est par ailleurs inchangé.
//
// Le rappel ACCUMULE les noms à travers les compactages successifs : le rappel du
// compactage précédent est relu comme source, donc une page lue tôt (dont le
// mem_read d'origine a depuis été résumé) reste listée.
// memReminderPrefix ouvre le message de rappel. Sert aussi à le reconnaître (pour le
// relire comme source au compactage suivant, et pour le retirer avant reconstruction).
const memReminderPrefix = "[MEMORY PAGES REMINDER]"
// memReminderMaxNames borne le nombre de noms listés : au-delà, une conversation qui
// lit énormément de pages ne ferait pas enfler le rappel. On garde les plus RÉCENTES
// (les plus susceptibles de concerner la tâche en cours).
const memReminderMaxNames = 24
// collectReadPageNames rassemble les noms des pages mémoire LUES au fil de la
// conversation, dans l'ordre (première lecture d'abord). Deux sources, toutes deux
// balayées pour survivre aux compactages successifs :
// - les résultats de mem_read encore présents (résultat `tool` relié à sa page via
// l'argument `file` du tool_call correspondant), en ignorant les lectures ratées ;
// - le rappel injecté à un compactage PRÉCÉDENT (une fois le mem_read d'origine
// résumé, c'est la seule trace restante des noms).
func collectReadPageNames(msgs []Message) []string {
seen := map[string]bool{}
var order []string
add := func(name string) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
return
}
seen[name] = true
order = append(order, name)
}
// tool_call_id → nom de page pour les appels mem_read (le résultat `tool` ne
// porte que l'id ; le nom vit dans l'argument `file` de l'appel de l'assistant).
readCall := map[string]string{}
for _, m := range msgs {
for _, tc := range m.ToolCalls {
if tc.Function.Name != "mem_read" || tc.ID == "" {
continue
}
var a map[string]any
if json.Unmarshal([]byte(tc.Function.Arguments), &a) == nil {
if f, _ := a["file"].(string); f != "" {
readCall[tc.ID] = f
}
}
}
}
for _, m := range msgs {
switch m.Role {
case "system":
for _, n := range parseReminderNames(msgText(m)) {
add(n)
}
case "tool":
name, ok := readCall[m.ToolCallID]
if !ok {
continue
}
if strings.HasPrefix(strings.TrimSpace(msgText(m)), "[erreur]") {
continue // lecture ratée : rien à rappeler
}
add(name)
}
}
return order
}
// parseReminderNames relit la liste de noms d'un message de rappel produit par
// buildReminderMessage. Renvoie nil si ce n'est pas un rappel.
func parseReminderNames(text string) []string {
if !strings.HasPrefix(text, memReminderPrefix) {
return nil
}
// Format : 1re ligne `[MEMORY PAGES REMINDER] … read earlier: a.md, b.md, c.md`
// (les noms après le dernier `: `, sans ponctuation finale) ; la consigne de
// relecture est sur la ou les lignes SUIVANTES, donc hors de la liste.
line := text
if i := strings.IndexByte(line, '\n'); i >= 0 {
line = line[:i]
}
i := strings.LastIndex(line, ": ")
if i < 0 {
return nil
}
list := line[i+2:]
var out []string
for _, part := range strings.Split(list, ",") {
if n := strings.TrimSpace(part); n != "" {
out = append(out, n)
}
}
return out
}
// buildReminderMessage construit le message `system` de rappel. Renvoie ok=false si
// aucune page n'a été lue. Borné à memReminderMaxNames (on garde les plus récentes).
func buildReminderMessage(names []string) (Message, bool) {
if len(names) == 0 {
return Message{}, false
}
if len(names) > memReminderMaxNames {
names = names[len(names)-memReminderMaxNames:] // les plus récentes
}
content := fmt.Sprintf("%s Memory pages you read earlier: %s\nTheir full content is no longer inline after context compaction — if any of them is relevant to what you're doing, mem_read it again so you have all the rules and info you need before answering.",
memReminderPrefix, strings.Join(names, ", "))
return Message{Role: "system", Content: content}, true
}
// stripReminder retire le(s) message(s) de rappel d'une séquence (avant reconstruction).
func stripReminder(msgs []Message) []Message {
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == "system" {
if s, ok := m.Content.(string); ok && strings.HasPrefix(s, memReminderPrefix) {
continue
}
}
out = append(out, m)
}
return out
}
// remindReadMemPages reconstruit, à partir de l'historique AVANT compactage (source),
// le rappel des pages mémoire lues, et l'injecte en tête de la séquence COMPACTÉE. On
// retire d'abord un éventuel rappel hérité (porté par compacted depuis la tête
// protégée) pour repartir d'un rappel propre et à jour.
//
// Appelé uniquement en mode agent (les outils mem_* existent) et hors mémoire coupée.
// Sans page lue, no-op.
func remindReadMemPages(compacted, source []Message) []Message {
if memMode() == MemOff {
return compacted
}
names := collectReadPageNames(source)
compacted = stripReminder(compacted)
if m, ok := buildReminderMessage(names); ok {
return append([]Message{m}, compacted...)
}
return compacted
}
+121
View File
@@ -0,0 +1,121 @@
package loki
import (
"strings"
"testing"
)
// Une page mémoire lue (mem_read) doit être retrouvée par son nom (via l'argument
// `file` du tool_call), puis listée dans un rappel relisable.
func TestReminderRoundTrip(t *testing.T) {
msgs := []Message{
{Role: "user", Content: "applique les règles de la page"},
{Role: "assistant", ToolCalls: []ToolCall{{
ID: "call_1", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"regles-tache.md"}`},
}}},
{Role: "tool", ToolCallID: "call_1", Content: "RÈGLE 1 : toujours vouvoyer."},
}
names := collectReadPageNames(msgs)
if len(names) != 1 || names[0] != "regles-tache.md" {
t.Fatalf("nom de page mal collecté : %v", names)
}
m, ok := buildReminderMessage(names)
if !ok {
t.Fatalf("un rappel aurait dû être construit")
}
txt, _ := m.Content.(string)
if !strings.Contains(txt, "regles-tache.md") || !strings.Contains(txt, "mem_read") {
t.Fatalf("le rappel ne cite pas la page ou n'invite pas à relire : %q", txt)
}
// Le rappel doit se relire (noms) pour survivre au compactage suivant.
back := parseReminderNames(txt)
if len(back) != 1 || back[0] != "regles-tache.md" {
t.Fatalf("aller-retour du rappel cassé : %v", back)
}
}
// Le rappel doit ACCUMULER : un nom listé à un compactage précédent (dont le mem_read
// d'origine a été résumé/effacé) est retrouvé via le rappel système, même sans le
// résultat d'outil correspondant.
func TestReminderAccumulatesAcrossCompactions(t *testing.T) {
old, _ := buildReminderMessage([]string{"regles.md"})
msgs := []Message{
old, // rappel hérité (system)
{Role: "user", Content: "[CONTEXT COMPACTED] résumé…"},
{Role: "assistant", ToolCalls: []ToolCall{{
ID: "c2", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"procedure.md"}`},
}}},
{Role: "tool", ToolCallID: "c2", Content: "procédure détaillée"},
}
names := collectReadPageNames(msgs)
joined := strings.Join(names, ",")
if !strings.Contains(joined, "regles.md") {
t.Fatalf("la page héritée a été perdue : %v", names)
}
if !strings.Contains(joined, "procedure.md") {
t.Fatalf("la nouvelle page lue n'a pas été captée : %v", names)
}
}
// Une même page lue plusieurs fois n'est listée qu'une fois.
func TestReminderDedupes(t *testing.T) {
msgs := []Message{
{Role: "assistant", ToolCalls: []ToolCall{{ID: "a", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
{Role: "tool", ToolCallID: "a", Content: "v1"},
{Role: "assistant", ToolCalls: []ToolCall{{ID: "b", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"p.md"}`}}}},
{Role: "tool", ToolCallID: "b", Content: "v2"},
}
if names := collectReadPageNames(msgs); len(names) != 1 || names[0] != "p.md" {
t.Fatalf("attendu une seule occurrence de p.md, obtenu %v", names)
}
}
// Une lecture ratée ([erreur] …) ne doit pas être rappelée.
func TestReminderSkipsErrors(t *testing.T) {
msgs := []Message{
{Role: "assistant", ToolCalls: []ToolCall{{ID: "e", Function: ToolCallFunc{Name: "mem_read", Arguments: `{"file":"absente.md"}`}}}},
{Role: "tool", ToolCallID: "e", Content: "[erreur] page introuvable"},
}
if names := collectReadPageNames(msgs); len(names) != 0 {
t.Fatalf("une lecture ratée ne doit pas être rappelée, obtenu %v", names)
}
}
// La liste de noms est bornée : au-delà du plafond, on garde les plus récentes.
func TestReminderCapsNames(t *testing.T) {
var names []string
for i := 0; i < memReminderMaxNames+10; i++ {
names = append(names, "page-"+string(rune('a'+i%26))+string(rune('0'+i/26))+".md")
}
m, ok := buildReminderMessage(names)
if !ok {
t.Fatalf("rappel attendu")
}
got := parseReminderNames(m.Content.(string))
if len(got) != memReminderMaxNames {
t.Fatalf("attendu %d noms max, obtenu %d", memReminderMaxNames, len(got))
}
// La toute dernière (plus récente) doit être conservée.
if got[len(got)-1] != names[len(names)-1] {
t.Fatalf("la page la plus récente doit être gardée : %q vs %q", got[len(got)-1], names[len(names)-1])
}
}
// stripReminder retire le rappel sans toucher au reste.
func TestStripReminder(t *testing.T) {
m, _ := buildReminderMessage([]string{"p.md"})
msgs := []Message{
m,
{Role: "system", Content: "Project context — autre chose"},
{Role: "user", Content: "salut"},
}
out := stripReminder(msgs)
if len(out) != 2 {
t.Fatalf("attendu 2 messages après strip, obtenu %d", len(out))
}
for _, mm := range out {
if s, _ := mm.Content.(string); strings.HasPrefix(s, memReminderPrefix) {
t.Fatalf("le rappel a survécu au strip")
}
}
}
+171 -68
View File
@@ -3,6 +3,7 @@ package loki
import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
@@ -88,8 +89,15 @@ func MemList() []MemPage {
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
continue
}
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
// Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle
// existe, la cacher ferait croire à une perte) avec un titre qui dit
// pourquoi son contenu n'apparaît pas.
txt, ok := memPageText(e.Name())
title := titleOf(txt)
if !ok {
title = "🔒 (chiffré — mémoire verrouillée)"
}
out = append(out, MemPage{Name: e.Name(), Title: title})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
@@ -98,36 +106,92 @@ func MemList() []MemPage {
// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
// recherche : à compléter par mem_read sur la page la plus pertinente.
//
// Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot
// fréquent écraser une petite page qui matche VRAIMENT la requête :
// 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts
// de la requête passent devant (chercher « copine Nathan » doit remonter la
// page qui a les deux mots, pas celle qui répète « Nathan » 40 fois).
// 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot
// rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent
// (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne
// pas, et un match dans le NOM ou le TITRE est fortement bonifié.
func MemSearch(query string, limit int) []MemHit {
if limit <= 0 || limit > 30 {
limit = 8
}
terms := strings.Fields(strings.ToLower(query))
type scored struct {
hit MemHit
score int
terms := uniqueTerms(strings.ToLower(query))
type doc struct {
p MemPage
name string // minuscules
title string // minuscules
content string // brut (pour l'extrait)
hay string // minuscules : nom + contenu
}
var ranked []scored
docs := make([]doc, 0)
df := make(map[string]int) // nb de pages contenant chaque terme
for _, p := range MemList() {
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
content := string(b)
hay := strings.ToLower(p.Name + "\n" + content)
score := 0
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
d := doc{
p: p,
name: strings.ToLower(p.Name),
title: strings.ToLower(p.Title),
content: content,
hay: strings.ToLower(p.Name + "\n" + content),
}
docs = append(docs, d)
for _, t := range terms {
score += strings.Count(hay, t)
if strings.Contains(strings.ToLower(p.Name), t) {
score += 3 // bonus si le terme est dans le nom de la page
if strings.Contains(d.hay, t) {
df[t]++
}
}
if score == 0 && query != "" {
}
n := len(docs)
type scored struct {
hit MemHit
matched int // termes distincts trouvés (clé de tri primaire)
score float64 // pertinence pondérée (clé secondaire)
}
var ranked []scored
for _, d := range docs {
matched := 0
score := 0.0
for _, t := range terms {
cnt := strings.Count(d.hay, t)
if cnt == 0 {
continue
}
matched++
// IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore.
idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1
// Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence.
tf := 1.0 + math.Log(float64(cnt))
field := 1.0
if strings.Contains(d.name, t) {
field += 4 // le terme est dans le NOM de la page
}
if strings.Contains(d.title, t) {
field += 2 // ... ou dans son TITRE
}
score += idf * tf * field
}
if matched == 0 && len(terms) > 0 {
continue
}
ranked = append(ranked, scored{
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
score: score,
hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)},
matched: matched,
score: score,
})
}
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].matched != ranked[j].matched {
return ranked[i].matched > ranked[j].matched // couverture d'abord
}
return ranked[i].score > ranked[j].score
})
out := []MemHit{}
for i, r := range ranked {
if i >= limit {
@@ -138,6 +202,19 @@ func MemSearch(query string, limit int) []MemHit {
return out
}
// uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé).
func uniqueTerms(q string) []string {
seen := map[string]bool{}
var out []string
for _, t := range strings.Fields(q) {
if !seen[t] {
seen[t] = true
out = append(out, t)
}
}
return out
}
// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
func snippetAround(content string, terms []string) string {
flat := strings.Join(strings.Fields(content), " ")
@@ -175,12 +252,11 @@ func snippetAround(content string, terms []string) string {
// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
func MemRead(name string, offset, limit int) (string, error) {
p, err := safeMemPath(name)
if err != nil {
return "", err
}
b, err := os.ReadFile(p)
b, err := memReadPage(name)
if err != nil {
if errors.Is(err, errMemLocked) {
return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
}
return "", fmt.Errorf("page '%s' introuvable", name)
}
lines := strings.Split(string(b), "\n")
@@ -207,11 +283,14 @@ func MemAdd(name, content string) error {
if _, err := os.Stat(p); err == nil {
return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
}
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
body := strings.TrimRight(content, "\n") + "\n"
if err := writeMemFile(name, []byte(body)); err != nil {
return err
}
body := strings.TrimRight(content, "\n") + "\n"
return os.WriteFile(p, []byte(body), 0o644)
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
// apparaît toujours, même si le modèle oublie de l'y écrire.
memIndexAdd(name)
return nil
}
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
@@ -221,12 +300,11 @@ func MemAdd(name, content string) error {
var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
func MemEdit(name, oldText, newText string) error {
p, err := safeMemPath(name)
if err != nil {
return err
}
b, err := os.ReadFile(p)
b, err := memReadPage(name)
if err != nil {
if errors.Is(err, errMemLocked) {
return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
}
return fmt.Errorf("page '%s' introuvable", name)
}
content := string(b)
@@ -246,17 +324,13 @@ func MemEdit(name, oldText, newText string) error {
return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
}
updated := strings.Replace(content, oldText, newText, 1)
return os.WriteFile(p, []byte(updated), 0o644)
return writeMemFile(name, []byte(updated))
}
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
// l'éditeur web.
func MemContent(name string) string {
p, err := safeMemPath(name)
if err != nil {
return ""
}
b, err := os.ReadFile(p)
b, err := memReadPage(name)
if err != nil {
return ""
}
@@ -276,15 +350,14 @@ func MemSave(name, old, content string) error {
}
}
}
p, err := safeMemPath(name)
if err != nil {
return err
}
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
return err
}
body := strings.TrimRight(content, "\n") + "\n"
return os.WriteFile(p, []byte(body), 0o644)
if err := writeMemFile(name, []byte(body)); err != nil {
return err
}
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
// nouvelle ajoutée). Une simple sauvegarde n'ajoute que si la ligne manquait.
memIndexRename(old, name)
return nil
}
// MemDelete supprime une page mémoire.
@@ -296,51 +369,77 @@ func MemDelete(name string) error {
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("introuvable")
}
return os.Remove(p)
if err := os.Remove(p); err != nil {
return err
}
memIndexRemove(name)
return nil
}
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
// mode agent (shell). Trois modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
// mode agent (shell). Réglé PAR PROJET (voir projectMemMode). Quatre modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
// - MemAlways : proactif, INDEX INJECTÉ — l'index MEMORY.md (titres +
// accroches) est en tête de conversation, l'IA lit directement la bonne page.
// - MemSearchFirst : proactif, RECHERCHE D'ABORD — rien n'est injecté, l'IA
// appelle mem_search avant toute tâche. Prompt léger, démarrage plus rapide.
type MemMode string
const (
MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always"
MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always"
MemSearchFirst MemMode = "search"
)
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
// actuel). Toute valeur inconnue retombe sur always.
func memMode() MemMode {
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
// memProactive : l'IA gère sa mémoire d'elle-même (cherche/sauve sans qu'on le
// demande). Vrai pour les deux modes auto (injecté ou recherche).
func memProactive(m MemMode) bool { return m == MemAlways || m == MemSearchFirst }
// normalizeMemMode ramène une valeur de config à un mode connu. Défaut = always
// (préserve le comportement historique). "auto" est un alias d'always.
func normalizeMemMode(v string) MemMode {
switch strings.ToLower(strings.TrimSpace(v)) {
case "off":
return MemOff
case "ondemand":
return MemOnDemand
case "search":
return MemSearchFirst
default: // "always", "auto", "" et inconnus
return MemAlways
}
}
// setMemMode persiste le mode mémoire dans config.env.
func setMemMode(m MemMode) error {
return SetConfigKey("MEM_MODE", string(m))
// memMode renvoie le mode mémoire du PROJET ACTIF (AJEAN 0.13.12). Repli : un
// projet sans réglage propre (tous ceux d'avant) retombe sur l'ancien réglage
// global MEM_MODE de config.env — always par défaut. Rien ne change donc pour
// les projets existants tant qu'on ne touche pas à leur mode.
func memMode() MemMode {
if pm := projectMemMode(activeProjectSlug()); pm != "" {
return normalizeMemMode(pm)
}
return normalizeMemMode(ReadConfig()["MEM_MODE"])
}
// cmdMemory : loki memory [off|ondemand|always|status]
// setMemMode persiste le mode mémoire du PROJET ACTIF.
func setMemMode(m MemMode) error {
return setProjectMemMode(activeProjectSlug(), string(m))
}
// cmdMemory : loki memory [off|ondemand|always|search|status]
func cmdMemory(args []string) error {
sub := ""
if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0]))
}
label := map[MemMode]string{
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
MemAlways: "auto/injectée (index en tête de conversation, l'IA lit directement la bonne page)",
MemSearchFirst: "auto/recherche (rien d'injecté, l'IA cherche avant toute tâche — prompt léger)",
}
switch sub {
case "off":
@@ -351,20 +450,24 @@ func cmdMemory(args []string) error {
if err := setMemMode(MemOnDemand); err != nil {
return err
}
case "always":
case "always", "auto", "inject", "injected":
if err := setMemMode(MemAlways); err != nil {
return err
}
case "search", "recherche", "lazy":
if err := setMemMode(MemSearchFirst); err != nil {
return err
}
case "", "status":
m := memMode()
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
fmt.Printf("%s projet %s, mode: %s — %s\n", cyan("Mémoire"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
pages := MemList()
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
return nil
default:
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]")
return fmt.Errorf("usage: loki memory [off|ondemand|always|search|status]")
}
m := memMode()
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package loki
import (
"strings"
"testing"
)
// Reproduit le bug rapporté : une petite page dont le NOM et le TITRE contiennent
// « copine » ne remontait pas pour la requête « copine Nathan », noyée par de
// grosses pages qui répètent « Nathan » des dizaines de fois. Le classement doit
// mettre en tête la page qui matche le PLUS de termes distincts (couverture),
// puis pondérer par la rareté.
func TestMemSearchCoverageBeatsFrequency(t *testing.T) {
testHome(t)
// Deux pages « bruyantes » bourrées de « Nathan », sans « copine ».
if err := MemAdd("business-nathan.md", "# Business Nathan\n"+strings.Repeat("Nathan veut un business. ", 40)); err != nil {
t.Fatal(err)
}
if err := MemAdd("guide-ajean.md", "# Guide AJEAN\n"+strings.Repeat("projet de Nathan. ", 30)); err != nil {
t.Fatal(err)
}
// La petite page cible : matche les DEUX termes de la requête.
if err := MemAdd("profil-copine-nathan.md", "# Profil : Copine de Nathan\n- Prénom : Bao\n"); err != nil {
t.Fatal(err)
}
hits := MemSearch("copine Nathan", 8)
if len(hits) == 0 {
t.Fatal("aucun résultat pour « copine Nathan »")
}
if hits[0].File != "profil-copine-nathan.md" {
var names []string
for _, h := range hits {
names = append(names, h.File)
}
t.Fatalf("la page copine devrait être 1re (matche les 2 termes), obtenu ordre : %v", names)
}
}
// Une requête mono-terme rare doit privilégier un match dans le nom/titre.
func TestMemSearchNameTitleBoost(t *testing.T) {
testHome(t)
_ = MemAdd("notes-diverses.md", "# Notes\nUn jour Bao est passée dire bonjour, puis repartie.\n")
_ = MemAdd("profil-copine.md", "# Profil : Copine\n- Prénom : Bao\n")
hits := MemSearch("copine", 8)
if len(hits) == 0 || hits[0].File != "profil-copine.md" {
t.Fatalf("la page dont le nom/titre porte « copine » devrait être 1re, obtenu : %+v", hits)
}
}
+30 -1
View File
@@ -17,7 +17,36 @@ import (
//
// Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans
// dépendance à un tokenizer.
const promptCharBudget = 7500 // ~1875 tokens, tout allumé
//
// Le budget n'a été relevé que pour de VRAIS outils nouveaux, jamais pour des
// lignes de prompt — et chaque palier dit lequel :
//
// 7500 → 7900 : recall + recall_search (chat_recall.go), ~720 car. Le compactage
// cesse d'être destructif : le modèle ne relance plus les lectures
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
// actions — quatre outils auraient coûté quatre fois ça.
// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se
// donne elle-même ses rappels et ses veilles ; sans eux il fallait
// passer par l'interface. Descriptions déjà au plus court, et un
// seul schéma par action (create/update/delete ne partagent pas
// assez d'arguments pour fusionner sans rendre chacun ambigu).
// Les ~500 car restants couvrent web_screenshot, déclaré dès que
// Playwright est présent — c'est le cas dans l'image, pas sur le
// runner de CI : le budget doit tenir pour l'image, la vraie.
// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses
// schémas n'existent que si on arme son interrupteur.)
//
// Les ~200 car de prompt qui les accompagnent sont le seul écart
// assumé à la règle « pas de relèvement pour du prompt » : un outil
// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et
// la ligne dit ce que les schémas ne disent pas (quand se planifier,
// et qu'un script planifié ne charge aucun modèle). Elle a été
// ramenée de trois lignes à une.
//
// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient
// pour la suite : ce budget ne se relève pas pour du prompt.
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
func TestSystemPromptStaysLean(t *testing.T) {
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
+82
View File
@@ -0,0 +1,82 @@
package loki
import (
"context"
"testing"
)
// Pendant une génération de chat, un envoi est mis en file ; il ressort à la
// frontière d'étape suivante, journalisé comme une bulle utilisateur.
func TestQueueDuringGeneration(t *testing.T) {
c := newTestConv()
c.Generating = true
queued, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7)
if err != nil || !queued {
t.Fatalf("mise en file attendue, got queued=%v err=%v", queued, err)
}
// Réessai du même envoi (réponse perdue) : pas de doublon.
if _, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7); err != ErrDupSend {
t.Fatalf("doublon attendu, got %v", err)
}
msgs := c.drainQueued(c.epoch)
if len(msgs) != 1 || msgs[0].Role != "user" {
t.Fatalf("un message user attendu, got %#v", msgs)
}
if len(c.Log) != 1 || c.Log[0].Delta["user"] != "précision" {
t.Fatalf("le message injecté doit apparaître dans le fil, journal : %#v", c.Log)
}
if again := c.drainQueued(c.epoch); len(again) != 0 {
t.Fatal("la file doit être vidée par drainQueued")
}
}
// Une tâche planifiée qui occupe le modèle garde le refus : sa fin ne dépile rien.
func TestQueueRefusedDuringTask(t *testing.T) {
c := newTestConv()
c.Generating = true
c.runningTaskName = "veille"
if queued, _ := c.EnqueueOrStart("", "x", nil, Caps{}, 0.7); queued {
t.Fatal("pas de file pendant une tâche planifiée")
}
}
// Stop : la file est abandonnée et le client en est prévenu.
func TestDropQueuedAnnounces(t *testing.T) {
c := newTestConv()
c.Generating = true
_, _ = c.EnqueueOrStart("", "a", nil, Caps{}, 0.7)
c.dropQueued(c.epoch)
if len(c.queued) != 0 || len(c.Log) != 1 || c.Log[0].Delta["queue_dropped"] != 1 {
t.Fatalf("abandon non signalé : file=%d journal=%#v", len(c.queued), c.Log)
}
}
// Un client dont la discussion affichée n'est plus l'active reçoit d'abord un
// reset, pour ne pas greffer le nouveau fil sur l'ancien.
func TestSubscribeStaleConvResets(t *testing.T) {
testHome(t)
_ = putStr(bkChat, ckActive, "c-nouvelle")
c := newTestConv()
for i := 0; i < 3; i++ {
c.appendDelta(c.epoch, map[string]any{"user": "q"})
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var first map[string]any
c.SubscribeTail(ctx, 2, -1, "c-ancienne", func(m map[string]any) bool {
if _, pad := m["pad"]; pad {
return true
}
if first == nil {
first = m
}
if _, ok := m["caught_up"]; ok {
cancel()
return false
}
return true
})
if first["reset"] != true || first["id"] != "c-nouvelle" {
t.Fatalf("reset attendu en premier, got %v", first)
}
}
+337
View File
@@ -0,0 +1,337 @@
package loki
import (
"encoding/binary"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
bolt "go.etcd.io/bbolt"
)
// chat_recall.go — mémoire longue de la conversation.
//
// Le compactage résume les vieux tours pour libérer la fenêtre, mais un résumé
// perd forcément du détail (un gros code, une longue page web, un diff). Plutôt
// que de le perdre pour de bon, on ARCHIVE chaque gros bloc AVANT de le
// compacter, sous un identifiant court et stable (r7, r42…). Le résumé cite l'id
// à la place du bloc (« code du jeu fourni → recall:r7 »), et le modèle peut le
// rappeler VERBATIM quand il en a besoin :
//
// recall(id) → ramène le bloc exact, tel qu'il était.
// recall_search(query) → retrouve un bloc (et son id) par mots-clés, même très
// ancien et disparu du résumé courant.
//
// L'archive est GLOBALE (un compteur monotone via bbolt NextSequence), pas liée
// à une discussion : un id ne pointe jamais que vers un seul bloc, pour toujours.
// Elle vit sur disque (bucket bkRecall) donc survit aux redémarrages et au
// multi-appareils. Le contexte, lui, ne porte que les ids ENCORE cités par le
// résumé courant : sa taille reste plate même sur une conversation infinie,
// pendant que l'archive grossit tranquillement sur disque (bon marché).
//
// En mode code, c'est ce qui rend une longue session tenable : un fichier lu ou
// un diff produit tôt dans la session n'est plus perdu au compactage suivant.
const (
// Un bloc du torse plus long que ça (en caractères) est archivé et remplacé
// par son id. En dessous, le résumé le porte très bien tout seul : pas la peine
// de créer un id pour trois lignes.
recallArchiveMinLen = 800
// Longueur de l'extrait de tête laissé au résumeur pour un bloc archivé : de
// quoi le résumer fidèlement, le reste étant récupérable par recall.
recallSummaryHeadLen = 400
// Nombre de résultats par défaut / max pour recall_search.
recallSearchDefault = 5
recallSearchMax = 20
)
// recallBlock est un bloc archivé, adressable par son id.
type recallBlock struct {
ID string `json:"id"` // "r<seq>"
Seq uint64 `json:"seq"` // séquence brute (tri, clé)
Label string `json:"label"` // libellé court lisible (rôle + première ligne)
Role string `json:"role"` // rôle d'origine (tool/user/assistant)
Content string `json:"content"` // contenu VERBATIM
Created int64 `json:"created"` // unix, pour un éventuel ménage futur
}
// recallKey encode une séquence en clé bbolt triable (big-endian).
func recallKey(seq uint64) []byte {
k := make([]byte, 8)
binary.BigEndian.PutUint64(k, seq)
return k
}
// recallIDToSeq lit "r<n>" → n. Tolère un id sans préfixe et les espaces : le
// modèle écrit aussi bien recall("r7") que recall("7") ou recall("R7 ").
func recallIDToSeq(id string) (uint64, bool) {
s := strings.TrimSpace(id)
s = strings.TrimPrefix(s, "r")
s = strings.TrimPrefix(s, "R")
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, false
}
return n, true
}
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
// l'appelant se rabat sur le compactage classique (troncature sans id).
func archiveRecallBlock(label, role, content string) (string, error) {
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
encode, err := memEncoderNow()
if err != nil {
return "", err
}
var id string
err = withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
if err != nil {
return err
}
seq, err := b.NextSequence()
if err != nil {
return err
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: label,
Role: role,
Content: content,
Created: time.Now().Unix(),
}
id = blk.ID
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
// refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put(recallKey(seq), enc)
})
})
if err != nil {
return "", err
}
cacheBust(bkRecall)
return id, nil
}
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
func recallGet(id string) (recallBlock, bool) {
seq, ok := recallIDToSeq(id)
if !ok {
return recallBlock{}, false
}
var blk recallBlock
found := false
_ = view(bkRecall, func(b *bolt.Bucket) error {
v := b.Get(recallKey(seq))
if v == nil {
return nil
}
dec, err := decodeMemContent(v)
if err != nil {
return nil // chiffré et verrouillé : le bloc reste hors de portée
}
found = json.Unmarshal(dec, &blk) == nil
return nil
})
return blk, found
}
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
// Score = nombre de termes distincts de la requête présents dans (libellé +
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
// Simple, lexical, zéro dépendance — l'issue de secours pour un bloc sorti du
// résumé courant. À égalité, le plus RÉCENT d'abord (seq décroissante).
func recallSearch(query string, limit int) []recallBlock {
terms := recallTokens(query)
if len(terms) == 0 {
return nil
}
if limit <= 0 {
limit = recallSearchDefault
}
if limit > recallSearchMax {
limit = recallSearchMax
}
type scored struct {
blk recallBlock
score int
}
var hits []scored
_ = view(bkRecall, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var blk recallBlock
dec, err := decodeMemContent(v)
if err != nil {
return nil // chiffré et verrouillé : ce bloc ne participe pas
}
if json.Unmarshal(dec, &blk) != nil {
return nil
}
label := strings.ToLower(blk.Label)
body := strings.ToLower(blk.Content)
score := 0
for t := range terms {
if strings.Contains(label, t) {
score += 2
}
if strings.Contains(body, t) {
score++
}
}
if score > 0 {
hits = append(hits, scored{blk, score})
}
return nil
})
})
sort.Slice(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
return hits[i].blk.Seq > hits[j].blk.Seq
})
if len(hits) > limit {
hits = hits[:limit]
}
out := make([]recallBlock, len(hits))
for i, h := range hits {
out[i] = h.blk
}
return out
}
// recallTokens éclate une chaîne en un ensemble de mots-clés minuscules (>= 2
// caractères), en coupant sur tout ce qui n'est ni lettre ni chiffre.
func recallTokens(s string) map[string]struct{} {
out := map[string]struct{}{}
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ')
}) {
if len([]rune(w)) >= 2 {
out[w] = struct{}{}
}
}
return out
}
// recallLabel fabrique un libellé court et lisible pour un bloc, à partir de son
// rôle (préfixé du nom d'outil quand on le connaît) et de sa première ligne.
func recallLabel(role, toolName, content string) string {
head := ""
for _, line := range strings.Split(content, "\n") {
if s := strings.TrimSpace(line); s != "" {
head = s
break
}
}
if r := []rune(head); len(r) > 70 {
head = strings.TrimSpace(string(r[:70])) + "…"
}
tag := role
if role == "tool" && toolName != "" {
tag = toolName
}
if head == "" {
return tag
}
return tag + ": " + head
}
// toolRecall ramène un bloc archivé pour le modèle (outil recall).
func toolRecall(args map[string]any) string {
id, _ := args["id"].(string)
if strings.TrimSpace(id) == "" {
return "[erreur] id manquant"
}
blk, ok := recallGet(id)
if !ok {
return fmt.Sprintf("[erreur] bloc %q introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", id)
}
return fmt.Sprintf("[%s — %s, rappelé verbatim]\n%s", blk.ID, blk.Label, blk.Content)
}
// toolRecallSearch retrouve des blocs par mots-clés (outil recall_search).
func toolRecallSearch(args map[string]any) string {
q, _ := args["query"].(string)
if strings.TrimSpace(q) == "" {
return "[erreur] query manquante"
}
lim := 0
if v, ok := args["limit"].(float64); ok {
lim = int(v)
}
hits := recallSearch(q, lim)
if len(hits) == 0 {
return "[aucun bloc archivé ne correspond]"
}
var b strings.Builder
b.WriteString("Blocs trouvés (rappelle le bon avec recall(id)) :\n")
for _, h := range hits {
fmt.Fprintf(&b, "- %s — %s\n %s\n", h.ID, h.Label, recallSnippet(h.Content, 160))
}
return strings.TrimRight(b.String(), "\n")
}
// recallSnippet renvoie un extrait d'une ligne, borné, pour l'aperçu de recherche.
func recallSnippet(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if r := []rune(s); len(r) > n {
return strings.TrimSpace(string(r[:n])) + "…"
}
return s
}
// recallTool / recallSearchTool : schémas annoncés au modèle en mode agent.
func recallTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "recall",
Description: "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "Block id, e.g. \"r7\"."},
},
"required": []string{"id"},
},
},
}
}
func recallSearchTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "recall_search",
Description: "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Keywords describing the block."},
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Default %d, max %d", recallSearchDefault, recallSearchMax)},
},
"required": []string{"query"},
},
},
}
}
+223
View File
@@ -0,0 +1,223 @@
package loki
import (
"strings"
"testing"
)
// bigBlock fabrique un contenu franchement au-dessus de recallArchiveMinLen,
// pour qu'il soit archivé plutôt que simplement porté par le résumé.
func bigBlock(marker string) string {
return marker + "\n" + strings.Repeat("ligne de contenu volumineux à conserver verbatim\n", 40)
}
// Un bloc archivé doit revenir MOT POUR MOT : c'est toute la promesse de recall.
// Un résumé approximatif suffisait déjà avant ; ce qu'on ajoute ici, c'est
// l'exactitude — un gros code ou un diff ne survit pas à une reformulation.
func TestRecallRendVerbatim(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
content := bigBlock("MARQUEUR-UNIQUE-42")
id, err := archiveRecallBlock("web_read: page", "tool", content)
if err != nil {
t.Fatalf("archivage impossible : %v", err)
}
if !strings.HasPrefix(id, "r") {
t.Fatalf("id mal formé : %q", id)
}
blk, ok := recallGet(id)
if !ok {
t.Fatalf("bloc %q introuvable juste après son archivage", id)
}
if blk.Content != content {
t.Fatal("le contenu rappelé diffère de l'original : l'archive n'est pas verbatim")
}
// Le passage par l'outil (ce que voit le modèle) doit aussi porter le contenu.
if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "MARQUEUR-UNIQUE-42") {
t.Fatalf("l'outil recall ne rend pas le bloc : %q", got)
}
}
// Les ids doivent être uniques et monotones : deux compactages qui se croisent ne
// doivent jamais écraser le bloc de l'autre. NextSequence le garantit côté bbolt ;
// ce test verrouille qu'on s'en sert bien.
func TestRecallIDsUniquesEtMonotones(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
vus := map[string]bool{}
var last uint64
for i := 0; i < 5; i++ {
id, err := archiveRecallBlock("bloc", "tool", bigBlock("contenu"))
if err != nil {
t.Fatalf("archivage %d : %v", i, err)
}
if vus[id] {
t.Fatalf("id %q réutilisé : un bloc en écrase un autre", id)
}
vus[id] = true
seq, ok := recallIDToSeq(id)
if !ok {
t.Fatalf("id illisible : %q", id)
}
if i > 0 && seq <= last {
t.Fatalf("séquence non monotone : %d après %d", seq, last)
}
last = seq
}
}
// recallIDToSeq doit tolérer ce que le modèle écrit réellement : avec ou sans
// préfixe, majuscule, espaces. Un id refusé pour un espace en trop enverrait le
// modèle croire que son bloc a disparu.
func TestRecallIDToSeqTolerant(t *testing.T) {
for _, in := range []string{"r7", "R7", " r7 ", "7"} {
if seq, ok := recallIDToSeq(in); !ok || seq != 7 {
t.Fatalf("%q → (%d, %v), attendu (7, true)", in, seq, ok)
}
}
for _, in := range []string{"", "rr", "abc", "r-1"} {
if _, ok := recallIDToSeq(in); ok {
t.Fatalf("%q accepté alors qu'il est invalide", in)
}
}
}
// recall_search est l'issue de secours quand l'id n'est plus dans le résumé
// courant : le bloc doit rester trouvable par mots-clés, et l'outil doit rendre
// son id pour que le modèle puisse enchaîner sur recall(id).
func TestRecallSearchRetrouveParMotsCles(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
if _, err := archiveRecallBlock("web_read: recette", "tool", bigBlock("gratin dauphinois")); err != nil {
t.Fatal(err)
}
wanted, err := archiveRecallBlock("web_read: horaires", "tool", bigBlock("le train de 14h12 part quai 3"))
if err != nil {
t.Fatal(err)
}
hits := recallSearch("train horaires", 0)
if len(hits) == 0 {
t.Fatal("aucun résultat pour des mots-clés pourtant présents")
}
if hits[0].ID != wanted {
t.Fatalf("meilleur résultat %q, attendu %q (le libellé doit compter double)", hits[0].ID, wanted)
}
out := toolRecallSearch(map[string]any{"query": "train horaires"})
if !strings.Contains(out, wanted) {
t.Fatalf("l'outil recall_search ne rend pas l'id à rappeler : %q", out)
}
if n := len(recallSearch("motabsolumentabsent", 0)); n != 0 {
t.Fatalf("%d bloc(s) remontent pour un terme absent", n)
}
}
// Le cœur du lot : un gros bloc du torse doit être ARCHIVÉ avant compaction, et
// l'historique compacté doit porter son id. Sans llama-server en test, le résumé
// échoue et on tombe sur le repli dégraissé — c'est justement le chemin où
// l'ancien code effaçait le contenu sans laisser d'adresse.
func TestCompactArchiveLesGrosBlocs(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
page := func(n int) Message {
return tm(bigBlock("page numéro " + string(rune('a'+n))))
}
msgs := []Message{um("cherche les horaires du train pour Lyon")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
// caps.Agent : sans le mode agent, le modèle n'aurait pas l'outil recall et on
// n'archive délibérément rien.
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true})
if !changed {
t.Fatal("aucune compaction alors que le torse est plein de grosses pages")
}
joined := ""
for _, m := range out {
joined += msgText(m) + "\n"
}
if !strings.Contains(joined, "recall(") {
t.Fatalf("l'historique compacté ne cite aucun bloc rappelable :\n%s", joined)
}
if strings.Contains(joined, compactPrunedMarker) {
t.Fatal("un gros bloc a été effacé sans adresse de rappel alors qu'il était archivable")
}
// Et le contenu doit être réellement récupérable, pas seulement cité.
hits := recallSearch("page numéro", 0)
if len(hits) == 0 {
t.Fatal("les blocs cités dans l'historique ne sont pas dans l'archive")
}
if !strings.Contains(hits[0].Content, "ligne de contenu volumineux") {
t.Fatal("le bloc archivé ne contient pas le texte d'origine")
}
}
// Sans mode agent, le modèle n'a pas l'outil recall : archiver produirait des ids
// qu'il ne peut pas utiliser, et un résumé pollué de références mortes.
func TestCompactNArchivePasSansAgent(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
msgs := []Message{um("question")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), tm(bigBlock("page")))
}
out, _ := compactMessages(t.Context(), msgs, Caps{})
for _, m := range out {
if strings.Contains(msgText(m), "recall(") {
t.Fatal("des références recall sont proposées alors que l'outil n'est pas fourni")
}
}
if len(recallSearch("page", 0)) != 0 {
t.Fatal("des blocs ont été archivés hors mode agent")
}
}
// recallEligible décide ce qui mérite un id. Deux pièges : les petits blocs (le
// résumé les porte très bien) et le résumé d'une compaction PRÉCÉDENTE, qu'on
// ré-archiverait à chaque passe en faisant enfler l'archive pour rien.
func TestRecallEligible(t *testing.T) {
gros := strings.Repeat("x", recallArchiveMinLen+1)
if !recallEligible(tm(gros)) {
t.Fatal("un gros résultat d'outil doit être archivé")
}
if recallEligible(tm("court")) {
t.Fatal("un petit bloc ne mérite pas un id : le résumé le porte")
}
if recallEligible(Message{Role: "system", Content: gros}) {
t.Fatal("un message système n'a rien à faire dans l'archive")
}
if recallEligible(um(compactSummaryPrefix + " " + gros)) {
t.Fatal("le résumé d'une compaction précédente serait ré-archivé à chaque passe")
}
}
// Garde-fou anti-résumé-dégénéré : un petit modèle mal aiguillé répond parfois
// par un simple « recall:r7 » au lieu d'écrire de la prose. Accepter ça, c'est
// remplacer tout le passé de la conversation par une référence vide.
func TestSummaryLooksEmpty(t *testing.T) {
vrai := "L'utilisateur cherche les horaires du train pour Lyon ; le train de 14h12 part quai 3, reste à confirmer le tarif."
if summaryLooksEmpty(vrai) {
t.Fatalf("un vrai résumé est rejeté : %q", vrai)
}
for _, degenere := range []string{"", " ", "recall:r7", "recall:r7 recall:r8 recall:r9", "voir recall(r7)."} {
if !summaryLooksEmpty(degenere) {
t.Fatalf("résumé dégénéré accepté : %q", degenere)
}
}
}
// compactSummaryUserMsg porte l'index des ids rappelables. Il doit rester borné
// à la passe courante : sinon une conversation infinie ferait enfler cette
// section jusqu'à annuler le bénéfice de la compaction.
func TestCompactSummaryUserMsg(t *testing.T) {
sans := compactSummaryUserMsg("résumé quelconque", nil)
if !strings.HasPrefix(sans, compactSummaryPrefix) {
t.Fatal("le message de compaction doit être reconnaissable à son préfixe")
}
if strings.Contains(sans, "recall(") {
t.Fatal("recall est proposé alors qu'aucun bloc n'a été archivé")
}
avec := compactSummaryUserMsg("résumé quelconque", []recallEntry{{id: "r7", label: "web_read: horaires"}})
if !strings.Contains(avec, "r7") || !strings.Contains(avec, "web_read: horaires") {
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
}
}
+17 -5
View File
@@ -16,7 +16,6 @@ package loki
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -118,6 +117,11 @@ var (
)
func engineSeesImages() bool {
// Preset externe : aucun /props à sonder, on s'en tient à la déclaration
// du preset (case « le modèle accepte les images »).
if externalActive() {
return externalVisionActive()
}
visionProbeMu.Lock()
defer visionProbeMu.Unlock()
if time.Since(visionProbeAt) < 10*time.Second {
@@ -154,6 +158,12 @@ func engineSeesImages() bool {
// où un base64 de plusieurs dizaines de milliers de tokens était rejoué à
// chaque tour jusqu'à dépasser définitivement le contexte ;
// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture.
//
// Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
func stripImageParts(msgs []Message) []Message {
for i, m := range msgs {
parts, ok := m.Content.([]any)
@@ -176,7 +186,7 @@ func stripImageParts(msgs []Message) []Message {
}
}
if dropped {
msgs[i].Content = strings.Join(texts, "\n")
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
}
}
return msgs
@@ -207,11 +217,13 @@ func screenshotImageMessage(relPath string) (Message, bool) {
if err != nil || len(b) == 0 {
return Message{}, false
}
// Redimensionnement sous maxImageDim avant l'envoi : une capture de page
// fait facilement 1500×4000 px, dont le base64 pèse plus que ce que le
// projecteur regarde (voir prepareImageForModel, web_upload_orient.go).
b, mime = prepareImageForModel(b, mime)
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": "Voici la capture demandée."},
{"type": "image_url", "image_url": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
}},
imageURLPart(b, mime),
}}, true
}
+37 -3
View File
@@ -139,7 +139,9 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en
// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun
// tour ne passait. stripImageParts guérit les conversations existantes en
// retirant les parties image et en aplatissant le texte restant.
// retirant les parties image et en aplatissant le texte restant — en laissant
// imageLostMarker, pour que le modèle sache qu'une image A ÉTÉ montrée et qu'il
// doit en reprendre une plutôt que la redécrire de mémoire.
func TestStripImagePartsGueritLHistorique(t *testing.T) {
msgs := []Message{
{Role: "user", Content: "bonjour"}, // simple chaîne : intouchée
@@ -153,8 +155,40 @@ func TestStripImagePartsGueritLHistorique(t *testing.T) {
t.Fatalf("message texte modifié : %#v", out[0].Content)
}
got, ok := out[1].Content.(string)
if !ok || got != "Voici la capture demandée." {
t.Fatalf("le message multimodal doit être aplati en texte sans l'image, obtenu %#v", out[1].Content)
if !ok || got != "Voici la capture demandée."+imageLostMarker {
t.Fatalf("le message multimodal doit être aplati en texte, sans l'image mais avec le marqueur de perte, obtenu %#v", out[1].Content)
}
if strings.Contains(got, "base64") {
t.Fatalf("le base64 de l'image ne doit plus apparaître : %q", got)
}
}
// msgText est le point de passage de TOUTE la chaîne de compaction (estimation
// du contexte, transcript donné au résumeur). S'il n'extrait que les parties
// `text`, un message qui porte une capture ne pèse que sa légende et le résumeur
// ignore jusqu'à l'existence de l'image : elle disparaît sans laisser de trace.
func TestMsgTextSignaleLaPresenceDUneImage(t *testing.T) {
// Message VIVANT tel que construit par screenshotImageMessage ([]map[string]any).
live := Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": "Capture demandée :"},
{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
}}
// Même message RELU depuis le JSON persisté ([]any de map génériques).
reloaded := Message{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "Capture demandée :"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
}}
for name, m := range map[string]Message{"vivant": live, "relu": reloaded} {
got := msgText(m)
if !strings.Contains(got, "Capture demandée :") {
t.Fatalf("%s : le texte de la légende doit être conservé, obtenu %q", name, got)
}
if !strings.Contains(got, imageLostMarker) {
t.Fatalf("%s : la présence de l'image doit être signalée, obtenu %q", name, got)
}
if strings.Contains(got, "base64") {
t.Fatalf("%s : le base64 ne doit jamais entrer dans le transcript : %q", name, got)
}
}
}
+175
View File
@@ -0,0 +1,175 @@
package loki
// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent.
//
// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou
// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() =
// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage —
// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace
// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests,
// fichiers temporaires).
//
// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/
// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le
// SEUL accès réservé est le dossier memory, joignable uniquement par les outils
// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le
// protéger d'une suppression.
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
)
// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS
// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu).
// Renvoie une erreur plutôt qu'un chemin hors périmètre.
func scriptsPath(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("nom de script vide")
}
// On interdit les chemins absolus et la remontée : un script vit dans
// scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui
// commence par un séparateur (« /etc/… ») est rejeté explicitement : sous
// Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là.
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name)
}
// Caractères hostiles au shell : le nom finit dans une ligne de commande
// (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait
// en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser
// « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser
// partout rend le comportement identique sur les deux plateformes. Un nom de
// fichier n'a de toute façon besoin d'aucun de ces caractères.
if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 {
return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name)
}
clean := filepath.Clean(filepath.FromSlash(name))
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name)
}
full := filepath.Join(scriptsDir(), clean)
// Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir.
if !underDir(full, scriptsDir()) {
return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name)
}
return full, nil
}
// scriptExists valide qu'un nom désigne bien un fichier script existant dans le
// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ».
func scriptExists(name string) error {
full, err := scriptsPath(name)
if err != nil {
return err
}
fi, err := os.Stat(full)
if err != nil {
return fmt.Errorf("script introuvable : %s", name)
}
if fi.IsDir() {
return fmt.Errorf("%s est un dossier, pas un script", name)
}
return nil
}
// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec
// leur taille), triés. Ignore les dossiers.
func listScripts() ([]scriptInfo, error) {
root := scriptsDir()
var out []scriptInfo
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return nil // dossier absent ou illisible : on renvoie ce qu'on a
}
if d.IsDir() {
return nil
}
rel, rerr := filepath.Rel(root, p)
if rerr != nil {
return nil
}
size := int64(0)
if fi, ferr := d.Info(); ferr == nil {
size = fi.Size()
}
out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size})
return nil
})
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, err
}
type scriptInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni
// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par
// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go
// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne
// sont pas concernés par ces gardes.
//
// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts
// librement (avec write/bash). Sa durabilité vient seulement de sa séparation
// d'avec le workspace jetable, pas d'un verrou.
func toolOnlyDirs() []string { return []string{memoryDir()} }
// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct
// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs).
func toolOnlyLabel() string {
return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"
}
// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé
// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé.
func guardToolOnlyPath(path string) string {
for _, d := range toolOnlyDirs() {
if underDir(path, d) {
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel())
}
}
return ""
}
// guardToolOnlyCommand refuse une commande shell qui référence un dossier
// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE
// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé.
func guardToolOnlyCommand(command string) string {
lc := strings.ToLower(command)
for _, d := range toolOnlyDirs() {
if strings.Contains(lc, strings.ToLower(normPath(d))) {
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel())
}
}
return ""
}
// underDir indique si path est égal à dir ou situé dessous, après nettoyage et
// (sous Windows) insensibilité à la casse.
func underDir(path, dir string) bool {
a := normPath(path)
b := normPath(dir)
if a == b {
return true
}
return strings.HasPrefix(a, b+string(filepath.Separator))
}
// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé,
// et minusculé sous Windows (système de fichiers insensible à la casse).
func normPath(p string) string {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
p = filepath.Clean(p)
if runtime.GOOS == "windows" {
p = strings.ToLower(p)
}
return p
}
+103
View File
@@ -0,0 +1,103 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestGuardToolOnly vérifie que memory est inaccessible en direct (shell/write/
// edit) et n'autorise que les outils dédiés, tandis que scripts reste libre.
func TestGuardToolOnly(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
// Shell : toute mention du dossier memory est refusée (lecture comprise). Le
// dossier scripts, lui, est librement utilisable (l'IA y écrit et exécute).
blockedCmds := []string{
"cat " + memoryDir() + "/note.md",
"echo x > " + memoryDir() + "/x",
}
for _, c := range blockedCmds {
if msg := guardToolOnlyCommand(c); msg == "" {
t.Errorf("commande aurait dû être refusée : %q", c)
}
}
allowedCmds := []string{
"ls " + workspaceDir(),
"bash " + scriptsDir() + "/backup.sh", // exécuter un script : autorisé
"ls " + scriptsDir(),
}
for _, c := range allowedCmds {
if msg := guardToolOnlyCommand(c); msg != "" {
t.Errorf("commande ne devait pas être bloquée : %q -> %s", c, msg)
}
}
// write/edit : un chemin dans memory est refusé ; dans scripts ou workspace, non.
if msg := guardToolOnlyPath(filepath.Join(memoryDir(), "a.md")); msg == "" {
t.Errorf("écriture directe dans memory aurait dû être refusée")
}
for _, p := range []string{filepath.Join(scriptsDir(), "b.sh"), filepath.Join(workspaceDir(), "ok.txt")} {
if msg := guardToolOnlyPath(p); msg != "" {
t.Errorf("écriture ne devait pas être bloquée : %q -> %s", p, msg)
}
}
}
// TestScriptsPathEscape vérifie qu'on ne peut pas sortir du dossier scripts ni
// glisser un caractère hostile au shell dans le nom.
func TestScriptsPathEscape(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
bad := []string{"../evil.sh", "..", "/etc/passwd", `..\..\x`, `a";rm -rf ~;".sh`, "a$b.sh", "a`b`.sh"}
for _, name := range bad {
if _, err := scriptsPath(name); err == nil {
t.Errorf("aurait dû refuser le nom : %q", name)
}
}
good := []string{"backup.ps1", "sub/backup.sh", "a.sh", "my backup.sh"}
for _, name := range good {
p, err := scriptsPath(name)
if err != nil {
t.Errorf("aurait dû accepter %q : %v", name, err)
continue
}
if !strings.HasPrefix(normPath(p), normPath(scriptsDir())) {
t.Errorf("%q résolu hors de scriptsDir : %s", name, p)
}
}
}
// TestScriptExists couvre la validation d'existence utilisée par les tâches script.
func TestScriptExists(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
if err := scriptExists("hello.sh"); err == nil {
t.Fatalf("un script absent aurait dû échouer")
}
full := filepath.Join(scriptsDir(), "hello.sh")
if err := os.MkdirAll(scriptsDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := scriptExists("hello.sh"); err != nil {
t.Fatalf("scriptExists : %v", err)
}
list, _ := listScripts()
if len(list) != 1 || list[0].Name != "hello.sh" {
t.Fatalf("list inattendu : %+v", list)
}
// Un dossier n'est pas un script.
if err := os.MkdirAll(filepath.Join(scriptsDir(), "d"), 0o755); err != nil {
t.Fatal(err)
}
if err := scriptExists("d"); err == nil {
t.Fatalf("un dossier ne doit pas passer pour un script")
}
}
+88 -10
View File
@@ -53,13 +53,58 @@ type convMeta struct {
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Turns int `json:"turns"`
// Project = slug du projet auquel la discussion appartient (projects.go). Vide
// sur les discussions d'avant les projets : ensureDefaultProject les rattache à
// « Générale » au premier démarrage, et convIndexForProject traite un champ vide
// comme le projet par défaut pour que rien ne disparaisse entre-temps.
Project string `json:"project,omitempty"`
}
func convKey(id string) string { return "conv:" + id }
// convProjectOf renvoie le projet d'une discussion, en traitant l'absence comme
// le projet par défaut : une discussion sans projet ne doit jamais devenir
// invisible.
func convProjectOf(m convMeta) string {
if s := strings.TrimSpace(m.Project); s != "" {
return s
}
return defaultProjectSlug
}
// convIndexForProject filtre l'index sur un projet.
func convIndexForProject(slug string) []convMeta {
var out []convMeta
for _, m := range convIndex() {
if convProjectOf(m) == slug {
out = append(out, m)
}
}
return out
}
// tagOrphanConversations rattache au projet donné les discussions qui n'en ont
// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines).
func tagOrphanConversations(slug string) {
idx := convIndex()
changed := false
for i := range idx {
if strings.TrimSpace(idx[i].Project) == "" {
idx[i].Project = slug
changed = true
}
}
if changed {
convIndexSave(idx)
}
}
func convIndex() []convMeta {
var idx []convMeta
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
// L'index porte les TITRES des discussions : chiffré comme leur contenu
// quand la mémoire l'est. Verrouillé, il se lit vide — la liste est masquée,
// rien n'est perdu.
if b, ok := getStoreBytes(bkChat, ckIndex); ok && len(b) > 0 {
_ = json.Unmarshal(b, &idx)
}
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
@@ -73,7 +118,7 @@ func convIndexSave(idx []convMeta) {
if err != nil {
return
}
_ = putBytes(bkChat, ckIndex, b)
_ = putStoreBytes(bkChat, ckIndex, b)
}
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
@@ -86,11 +131,11 @@ func convEnsureActive() string {
if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 {
// Migration : le fil unique devient la première discussion. On garde la
// clé d'origine intacte — en cas de retour arrière, rien n'est perdu.
_ = putBytes(bkChat, convKey(id), legacy)
_ = putStoreBytes(bkChat, convKey(id), legacy)
}
_ = putStr(bkChat, ckActive, id)
now := time.Now().Unix()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
return id
}
@@ -116,7 +161,7 @@ func convTouchMeta(id string, title string, turns int) {
convIndexSave(idx)
return
}
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns}))
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns, Project: activeProjectSlug()}))
}
// convSummary lit le premier message utilisateur pour en faire un titre. Sans
@@ -142,8 +187,40 @@ func convSummary(msgs []Message) string {
return ""
}
// ConvList renvoie les discussions et l'identifiant de l'active (pour l'UI).
func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() }
// ConvList renvoie les discussions DU PROJET ACTIF et l'identifiant de l'active
// (pour l'UI). Le cloisonnement est ici : une discussion d'un autre projet n'a
// aucune raison d'apparaître dans la barre latérale.
func ConvList() ([]convMeta, string) {
return convIndexForProject(activeProjectSlug()), convEnsureActive()
}
// projectSwitch bascule sur un autre projet ET ouvre une de ses discussions : la
// plus récemment modifiée, ou une neuve s'il n'en a aucune. Sans ça, changer de
// projet laisserait à l'écran une discussion qui n'appartient plus à la liste
// affichée — et la mémoire (memoryDir) aurait changé sous ses pieds.
func projectSwitch(slug string) error {
if !projectExists(slug) {
return fmt.Errorf("projet introuvable")
}
convOpMu.Lock()
defer convOpMu.Unlock()
if slug == activeProjectSlug() {
return nil
}
conv.persist() // fige la discussion du projet qu'on quitte
if err := setActiveProject(slug); err != nil {
return err
}
list := convIndexForProject(slug)
if len(list) == 0 {
convCreate() // convIndex trie par date de modification : voir convIndex
return nil
}
// convIndex rend la plus récemment modifiée en tête.
_ = putStr(bkChat, ckActive, list[0].ID)
conv.loadFrom(storeBytes(bkChat, convKey(list[0].ID)))
return nil
}
// convSwitch bascule sur une autre discussion : la courante est enregistrée,
// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients
@@ -169,7 +246,7 @@ func convSwitch(id string) error {
}
conv.persist() // fige la discussion qu'on quitte
_ = putStr(bkChat, ckActive, id)
conv.loadFrom(getBytes(bkChat, convKey(id)))
conv.loadFrom(storeBytes(bkChat, convKey(id)))
return nil
}
@@ -179,7 +256,7 @@ func convSwitch(id string) error {
func convCreate() string {
id := newConvID()
now := time.Now().Unix()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
_ = putStr(bkChat, ckActive, id)
conv.loadFrom(nil)
return id
@@ -241,6 +318,7 @@ func convDelete(id string) error {
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
dropConvFiles(id)
deleteToolResultsFor(id) // ses résultats « voir plus » partent avec elle
if id != getStr(bkChat, ckActive) {
return nil
}
@@ -249,6 +327,6 @@ func convDelete(id string) error {
return nil
}
_ = putStr(bkChat, ckActive, next[0].ID)
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
conv.loadFrom(storeBytes(bkChat, convKey(next[0].ID)))
return nil
}
+60
View File
@@ -0,0 +1,60 @@
package loki
// chat_shell_hygiene.go — ce que le terminal rend au modèle, nettoyé (repris de
// shell.ts / shell-tail.ts d'OpenFox, 2.0.160) :
//
// - « cmd | tail -N » : le code de sortie devenait celui de tail (0), et un
// build en échec passait pour réussi. On retire le tail de la commande et
// on garde nous-mêmes les N dernières lignes : même sortie, vrai code.
// - séquences ANSI (couleurs, curseur) : du bruit en tokens, illisible ;
// - « cmd & » en mode Code : le process orphelin n'a ni sortie ni arrêt —
// bash_bg est fait pour ça.
import (
"regexp"
"strconv"
"strings"
)
// tailPipeRe : « | tail », « | tail -20 », « | tail -n 20 », « | tail -n20 » en
// toute fin de commande.
var tailPipeRe = regexp.MustCompile(`\s*\|\s*tail(?:\s+-n\s*(\d+)|\s+-(\d+))?\s*$`)
// splitTailPipe retire un « | tail -N » final. Renvoie la commande sans lui et
// N (0 = pas de tail ; 10 = tail sans nombre, sa valeur par défaut).
func splitTailPipe(command string) (string, int) {
m := tailPipeRe.FindStringSubmatchIndex(command)
if m == nil {
return command, 0
}
n := 10
for _, g := range []int{2, 4} {
if m[g] >= 0 {
if v, err := strconv.Atoi(command[m[g]:m[g+1]]); err == nil && v > 0 {
n = v
}
}
}
rest := strings.TrimSpace(command[:m[0]])
if rest == "" {
return command, 0
}
return rest, n
}
// shellANSIRe : séquences CSI (couleurs, curseur) et OSC (titres de fenêtre).
var shellANSIRe = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`)
func stripANSI(s string) string {
if !strings.Contains(s, "\x1b") {
return s
}
return shellANSIRe.ReplaceAllString(s, "")
}
// trailingBackground : la commande se termine par un « & » de mise en
// arrière-plan (pas « && »).
func trailingBackground(command string) bool {
c := strings.TrimSpace(command)
return strings.HasSuffix(c, "&") && !strings.HasSuffix(c, "&&")
}
+58
View File
@@ -0,0 +1,58 @@
package loki
import (
"context"
"runtime"
"strings"
"testing"
)
func TestSplitTailPipe(t *testing.T) {
cas := map[string]struct {
cmd string
n int
}{
"go build ./... 2>&1 | tail -20": {"go build ./... 2>&1", 20},
"make | tail -n 5": {"make", 5},
"make|tail -n40": {"make", 40},
"go test | tail": {"go test", 10},
"tail -f log.txt": {"tail -f log.txt", 0},
"cat a | tail -5 | grep x": {"cat a | tail -5 | grep x", 0},
"echo ok": {"echo ok", 0},
}
for in, want := range cas {
if cmd, n := splitTailPipe(in); cmd != want.cmd || n != want.n {
t.Errorf("%q → (%q, %d), attendu (%q, %d)", in, cmd, n, want.cmd, want.n)
}
}
if !trailingBackground("./serveur &") || trailingBackground("make && make test") || trailingBackground("ls 2>&1") {
t.Error("trailingBackground se trompe")
}
if got := stripANSI("\x1b[1;31mFAIL\x1b[0m ok"); got != "FAIL ok" {
t.Errorf("stripANSI = %q", got)
}
}
// « cmd | tail -N » rend le VRAI code de sortie, et seulement N lignes.
func TestShellTailGardeLeCodeDeSortie(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell POSIX")
}
withWorkspace(t)
out := runShell(context.Background(), "sh -c 'printf \"l1\nl2\nl3\n\"; exit 2' | tail -n 2", 10)
if !strings.HasPrefix(out, "exit: 2") || strings.Contains(out, "l1") || !strings.Contains(out, "l3") {
t.Fatalf("sortie = %q", out)
}
}
// Au délai dépassé, la sortie déjà produite est rendue.
func TestShellTimeoutGardeLaSortie(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell POSIX")
}
withWorkspace(t)
out := runShell(context.Background(), "echo avant; sleep 5", 1)
if !strings.Contains(out, "timeout") || !strings.Contains(out, "avant") {
t.Fatalf("sortie = %q", out)
}
}
+75 -8
View File
@@ -56,7 +56,7 @@ func baseSystemPrompt(caps Caps) string {
b.WriteString("\n\n" + rp)
}
}
if caps.Mem == MemAlways {
if memProactive(caps.Mem) {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
// PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
@@ -76,7 +76,15 @@ func baseSystemPrompt(caps Caps) string {
case MemAlways:
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes.\n")
// Mode INJECTÉ : l'index est en tête de contexte (mem_index.go), la
// recherche devient facultative — l'IA lit directement la bonne page.
b.WriteString("- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n")
case MemSearchFirst:
// Mode RECHERCHE : rien d'injecté, prompt léger — mais l'IA DOIT chercher
// elle-même, sinon elle ignore ce que sa mémoire contient.
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes. Nothing is preloaded: a search is the only way to know what you already know.\n")
case MemOnDemand:
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
}
@@ -89,7 +97,14 @@ func baseSystemPrompt(caps Caps) string {
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
// à connaître pour le modèle — juste [texte](chemin).
b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
if caps.Mem == MemAlways {
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
// disent pas. Une ligne, pas trois : le budget du préambule est compté
// (TestSystemPromptStaysLean).
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n")
if caps.Mem == MemSearchFirst {
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
}
}
@@ -101,6 +116,9 @@ func baseSystemPrompt(caps Caps) string {
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
}
if caps.Agent && caps.ComputerUse {
b.WriteString(cuPromptLine())
}
if caps.Agent {
if line := mcpPromptLine(); line != "" {
b.WriteString(line)
@@ -177,7 +195,7 @@ func machineSystemPrompt(caps Caps) string {
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
// aux « installations » en /usr/local/bin qui échouent faute de root.
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.")
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.")
}
return b.String()
}
@@ -192,12 +210,24 @@ func machineSystemPrompt(caps Caps) string {
// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
// délai (5 minutes au maximum), bouton stop sans effet.
func runShell(parent context.Context, command string, timeoutSec int) string {
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
// n'avait pas servie.
if msg := guardToolOnlyCommand(command); msg != "" {
return msg
}
if timeoutSec <= 0 {
timeoutSec = toolDefaultTimeout
}
if timeoutSec > toolMaxTimeout {
timeoutSec = toolMaxTimeout
}
// « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes
// (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail.
command, keepLines := splitTailPipe(command)
start := time.Now()
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
@@ -228,9 +258,27 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
// demandait un tail.
clean := func(s string) string { return tailOutput(stripANSI(s)) }
out := stdout.String()
if keepLines > 0 {
out = tailLines(out, keepLines)
}
out = clean(out)
errOut := clean(stderr.String())
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
// La sortie déjà produite reste utile (où le build en était, quel test
// bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox).
msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec)
if out != "" {
msg += "\n\nstdout (partiel):\n" + out
}
if errOut != "" {
msg += "\n\nstderr (partiel):\n" + errOut
}
return msg
case errors.Is(parent.Err(), context.Canceled):
return "[commande interrompue]"
}
@@ -242,9 +290,8 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
return fmt.Sprintf("[erreur: %v]", err)
}
}
out := tailRunes(stdout.String(), toolMaxOutput)
errOut := tailRunes(stderr.String(), toolMaxOutput)
parts := []string{fmt.Sprintf("exit: %d", exit)}
// « exit: N » en tête : tasks_script.go s'y fie. La durée suit.
parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))}
if out != "" {
parts = append(parts, "stdout:\n"+out)
}
@@ -274,6 +321,10 @@ func fileWrite(path, content string) string {
return "[erreur] chemin vide"
}
path = resolveAgentPath(path)
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
if msg := guardToolOnlyPath(path); msg != "" {
return msg
}
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
@@ -312,6 +363,10 @@ func fileEdit(path, oldText, newText string) string {
return "[erreur] old vide"
}
path = resolveAgentPath(path)
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
if msg := guardToolOnlyPath(path); msg != "" {
return msg
}
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
@@ -357,6 +412,18 @@ func fileEdit(path, oldText, newText string) string {
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
}
// tailOutput garde la FIN d'une sortie de commande (toolMaxOutput runes) et dit
// explicitement ce qui a été coupé (AJEAN 0.15.5). La coupe était silencieuse :
// le modèle croyait voir toute la sortie, et la bulle affichait toujours
// ~2004 tok sans qu'on sache pourquoi.
func tailOutput(s string) string {
n := len([]rune(s))
if n <= toolMaxOutput {
return s
}
return fmt.Sprintf("[sortie tronquée : %d caractères au total, seuls les %d derniers sont gardés]\n", n, toolMaxOutput) + tailRunes(s, toolMaxOutput)
}
// tailRunes returns the last n runes of s (used to cap tool output).
func tailRunes(s string, n int) string {
r := []rune(s)
+133
View File
@@ -0,0 +1,133 @@
package loki
// chat_vision_tool.go — l'outil see_image : le modèle charge lui-même une image
// du disque dans sa VISION, sans que l'utilisateur ait à la joindre au message.
//
// Loki savait déjà voir deux choses : une pièce jointe (web_upload.go) et une
// capture d'écran qu'il venait de prendre (chat_screenshot.go). Pas un fichier
// qui dort sur le disque. « Regarde la capture dans ~/photos/bug.png » n'avait
// donc aucune réponse : `read` rend des octets binaires, et le modèle finissait
// par décrire ce qu'il croyait deviner du nom de fichier.
//
// Mécanique : le résultat de l'outil reste un simple texte (un accusé). L'image,
// elle, est réinjectée juste après dans un message utilisateur multimodal
// (image_url) — le SEUL format que llama-server comprenne une fois --mmproj
// chargé, et exactement le chemin déjà emprunté par les pièces jointes et les
// captures.
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
)
// maxVisionBytes borne la taille d'une image chargée dans la vision. Au-delà,
// le base64 gonfle le contexte pour rien : le moteur redimensionne de toute
// façon avant l'encodeur d'images.
const maxVisionBytes = 12 << 20 // 12 Mio
// toolSeeImage lit un fichier image et renvoie (accusé texte, partie image_url).
// La partie image vaut nil en cas d'erreur : l'appelant n'injecte alors rien.
//
// Chaque refus dit CE QUI manque plutôt que « impossible » : sans projecteur, la
// réponse n'est pas la même que sur un poste distant ou un fichier trop lourd,
// et un modèle qui reçoit un motif clair peut corriger son geste lui-même.
func toolSeeImage(path string) (string, map[string]any) {
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", nil
}
// Cible = un poste distant : le fichier est LÀ-BAS, pas lisible d'ici.
if agentTargetSlug() != "" {
return "[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
}
if path == "" {
return "[erreur] chemin de fichier manquant", nil
}
abs := resolveAgentPath(path)
mime := imageMime(abs)
if mime == "" {
return "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : " + path, nil
}
st, err := os.Stat(abs)
if err != nil {
return "[erreur] fichier introuvable : " + path, nil
}
if st.IsDir() {
return "[erreur] c'est un dossier, pas une image : " + path, nil
}
if st.Size() > maxVisionBytes {
return fmt.Sprintf("[erreur] image trop lourde (%s, max %s) : %s",
humanBytes(st.Size()), humanBytes(maxVisionBytes), path), nil
}
// Sonde du moteur EN DERNIER, juste avant le travail coûteux. Elle fait un
// appel réseau (/props, mis en cache 10 s) : la poser d'entrée, c'était payer
// un aller-retour pour répondre « chemin manquant », et surtout masquer les
// motifs précis ci-dessus derrière un « le moteur ne voit pas » qui n'apprend
// rien quand le vrai problème est une faute de frappe dans le chemin.
//
// Elle reste indispensable : un projecteur déclaré ne garantit pas que le
// moteur en service sache traiter une image (mauvais couple modèle/mmproj,
// moteur trop ancien). Autant le dire que d'envoyer 12 Mio de base64 que
// personne ne regardera.
if !engineSeesImages() {
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
}
b, err := os.ReadFile(abs)
if err != nil {
return "[erreur] lecture impossible : " + err.Error(), nil
}
if len(b) == 0 {
return "[erreur] fichier vide : " + path, nil
}
// Même préparation que les pièces jointes : orientation EXIF redressée et
// grand côté ramené sous maxImageDim (voir web_upload_orient.go).
b, mime = prepareImageForModel(b, mime)
return "[ok] image chargée : " + filepath.Base(abs), imageURLPart(b, mime)
}
// imageURLPart construit la partie multimodale {type:image_url, image_url:{url}}
// à partir d'octets image déjà préparés — le SEUL format qu'un llama-server
// --mmproj comprend. Partagé par see_image, web_screenshot et browser_screenshot.
func imageURLPart(b []byte, mime string) map[string]any {
return map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
},
}
}
// seeImageMessage emballe la partie image dans le message utilisateur qui la
// porte jusqu'au modèle. La légende nomme le fichier : après compaction il ne
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
// dirait pas LAQUELLE rouvrir.
func seeImageMessage(label string, img map[string]any) Message {
// Sans parenthèses vides : browser_screenshot n'a pas d'argument (label=""),
// là où see_image porte le chemin du fichier.
text := "Image :"
if label != "" {
text = "Image demandée (" + label + ") :"
}
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": text},
img,
}}
}
// seeImageTool — schéma envoyé au modèle. Description tenue au plus court : les
// schémas partent dans CHAQUE requête et le préambule a un budget
// (TestSystemPromptStaysLean).
func seeImageTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "see_image",
Description: "Ouvre un fichier image du disque pour le VOIR (png, jpg, gif, webp, bmp).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Chemin de l'image (relatif au dossier de travail, ou absolu)"},
},
"required": []string{"file"},
},
}}
}
+186
View File
@@ -0,0 +1,186 @@
package loki
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
// pngPixel : un PNG valide d'un pixel, assez pour qu'imageMime le reconnaisse.
var pngPixel, _ = base64.StdEncoding.DecodeString(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
// visionOn arme la vision (clé MMPROJ) et renvoie le dossier de travail.
func visionOn(t *testing.T) string {
t.Helper()
home := testHome(t)
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
return home
}
// Sans projecteur, l'outil DIT ce qui manque au lieu d'échouer vaguement — et
// il ne renvoie surtout aucune image.
func TestSeeImageSansVisionRefuseEtExplique(t *testing.T) {
testHome(t)
txt, img := toolSeeImage("photo.png")
if img != nil {
t.Fatal("une image a été chargée alors que la vision est inactive")
}
if !strings.Contains(txt, "MMPROJ") {
t.Fatalf("le refus ne dit pas ce qui manque : %q", txt)
}
}
// Les refus qui ne dépendent pas du moteur : format, absence, dossier, poids.
// Chacun doit nommer son motif — un modèle qui reçoit « impossible » ne peut
// pas corriger son geste, alors qu'un « ce n'est pas une image » se rattrape.
func TestSeeImageMotifsDeRefus(t *testing.T) {
home := visionOn(t)
write := func(name string, b []byte) string {
p := filepath.Join(home, name)
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
return p
}
write("notes.txt", []byte("bonjour"))
if err := os.MkdirAll(filepath.Join(home, "dossier.png"), 0o755); err != nil {
t.Fatal(err)
}
gros := write("gros.png", append(append([]byte(nil), pngPixel...), make([]byte, maxVisionBytes)...))
cas := []struct{ path, attendu string }{
{"", "chemin de fichier manquant"},
{filepath.Join(home, "notes.txt"), "format non reconnu"},
{filepath.Join(home, "absente.png"), "introuvable"},
{filepath.Join(home, "dossier.png"), "dossier"},
{gros, "trop lourde"},
}
for _, c := range cas {
txt, img := toolSeeImage(c.path)
if img != nil {
t.Errorf("%q : une image a été chargée malgré le refus", c.path)
}
if !strings.Contains(txt, c.attendu) {
t.Errorf("%q : motif = %q, attendu contenir %q", c.path, txt, c.attendu)
}
}
}
// Le message porteur nomme le fichier : après compactage il ne restera que sa
// légende et imageLostMarker, et « Image demandée : » tout court ne dirait pas
// LAQUELLE rouvrir.
func TestMessagePorteurNommeLeFichier(t *testing.T) {
img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}
m := seeImageMessage("captures/bug.png", img)
if m.Role != "user" {
t.Fatalf("rôle = %q, attendu user (un message tool ne porte que du texte)", m.Role)
}
parts, ok := m.Content.([]map[string]any)
if !ok || len(parts) != 2 {
t.Fatalf("contenu multimodal attendu, obtenu %#v", m.Content)
}
txt, _ := parts[0]["text"].(string)
if !strings.Contains(txt, "captures/bug.png") {
t.Fatalf("la légende ne nomme pas le fichier : %q", txt)
}
if parts[1]["type"] != "image_url" {
t.Fatalf("seconde partie = %v, attendu image_url", parts[1]["type"])
}
// Et cette légende survit au compactage, suivie du marqueur de perte.
got := msgText(m)
if !strings.Contains(got, "captures/bug.png") || !strings.Contains(got, imageLostMarker) {
t.Fatalf("après compaction : %q", got)
}
}
// Le marqueur ne doit plus parler QUE de captures d'écran : une image perdue
// peut venir du disque, et « reprends la capture » enverrait photographier une
// page web à la place.
func TestMarqueurDePerteNestPlusSpecifiqueAuxCaptures(t *testing.T) {
if strings.Contains(imageLostMarker, "take the screenshot again") {
t.Fatal("le marqueur impose encore la capture d'écran comme seul recours")
}
if !strings.Contains(imageLostMarker, "see_image") {
t.Fatal("le marqueur ne mentionne pas see_image : le modèle ne saura pas rouvrir un fichier")
}
}
// L'outil n'est proposé QUE si la vision est active : l'annoncer sans projecteur
// ferait promettre au modèle de regarder, puis se contredire.
func TestOutilProposeSeulementAvecLaVision(t *testing.T) {
testHome(t)
has := func() bool {
for _, tl := range EnabledTools(Caps{Agent: true}) {
if tl.Function.Name == "see_image" {
return true
}
}
return false
}
if has() {
t.Fatal("see_image proposé sans projecteur configuré")
}
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
if !has() {
t.Fatal("see_image absent alors que la vision est active")
}
// Hors mode agent, jamais : l'outil lit un fichier du disque.
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
for _, tl := range EnabledTools(Caps{}) {
if tl.Function.Name == "see_image" {
t.Fatal("see_image proposé hors mode agent")
}
}
}
// Le chemin doit remonter du JSON d'appel jusqu'à l'outil. Le libellé d'un appel
// est dérivé par une table nom d'outil → argument ; see_image n'y figurait pas,
// donc il recevait une chaîne vide et répondait invariablement « chemin de
// fichier manquant » — l'outil aurait été inutilisable sans rien casser ailleurs.
func TestLeCheminDeLImageRemonteJusquALOutil(t *testing.T) {
if got := toolCallLabel("see_image", map[string]any{"file": "captures/bug.png"}); got != "captures/bug.png" {
t.Fatalf("libellé de see_image = %q, attendu le chemin du fichier", got)
}
}
// Contre-épreuve de l'extraction : les libellés des autres outils n'ont pas
// bougé. Déplacer une table de 40 lignes hors d'une fonction de 700, c'est
// exactement le genre de geste qui casse un cas au passage sans bruit.
func TestLibellesDesAutresOutilsInchanges(t *testing.T) {
cas := []struct{ tool, key, val, want string }{
{"bash", "command", "ls -la", "ls -la"},
{"read", "file", "main.go", "main.go"},
{"web_search", "query", "météo", "météo"},
{"grep", "pattern", "TODO", "TODO"},
{"recall", "id", "b12", "b12"},
{"git_clone", "url", "https://x/y", "https://x/y"},
{"outil_inconnu", "file", "x", ""},
}
for _, c := range cas {
if got := toolCallLabel(c.tool, map[string]any{c.key: c.val}); got != c.want {
t.Errorf("toolCallLabel(%q) = %q, attendu %q", c.tool, got, c.want)
}
}
if got := toolCallLabel("web_grep", map[string]any{"url": "https://x", "pattern": "p"}); got != "p @ https://x" {
t.Errorf("web_grep : %q", got)
}
if got := toolCallLabel("tracker", map[string]any{"action": "add", "name": "poids"}); got != "add poids" {
t.Errorf("tracker : %q", got)
}
// Argument absent ou du mauvais type : chaîne vide, jamais de panique.
if got := toolCallLabel("read", map[string]any{"file": 42}); got != "" {
t.Errorf("argument non-chaîne : %q", got)
}
if got := toolCallLabel("bash", nil); got != "" {
t.Errorf("arguments nil : %q", got)
}
}
+102
View File
@@ -0,0 +1,102 @@
package loki
// code_alias.go — réparation des noms d'outils et d'arguments hallucinés.
//
// Les petits modèles ont appris d'autres agents : ils appellent read_file,
// str_replace, run_command, ou passent path / old_string au lieu de file /
// old. L'appel tombait sur « outil inconnu » ou sur un argument manquant, et
// le tour se perdait en allers-retours. On traduit vers l'outil RÉEL quand la
// cible existe dans ce tour (généralisation du transformSubAgentAliases
// d'OpenFox : un outil nommé d'après un sous-agent devient subagent{role}).
import (
"encoding/json"
"strings"
)
// toolNameAliases : nom halluciné → outil de Loki.
var toolNameAliases = map[string]string{
"read_file": "read", "view": "read", "cat": "read", "open_file": "read",
"write_file": "write", "create_file": "write",
"edit_file": "edit", "str_replace": "edit", "replace_in_file": "edit", "str_replace_editor": "edit",
"run_command": "bash", "shell": "bash", "run_shell": "bash", "execute_command": "bash", "terminal": "bash",
"search": "grep", "grep_search": "grep", "search_files": "grep",
"find_files": "glob", "list_files": "glob", "file_search": "glob",
}
// toolArgAliases : par outil, argument halluciné → argument attendu.
var toolArgAliases = map[string]map[string]string{
"read": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file"},
"write": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file", "contents": "content", "text": "content"},
"edit": {"path": "file", "file_path": "file", "filename": "file", "filepath": "file",
"old_string": "old", "old_str": "old", "old_text": "old", "search": "old",
"new_string": "new", "new_str": "new", "new_text": "new", "replace": "new"},
"bash": {"cmd": "command"},
"bash_bg": {"cmd": "command"},
"grep": {"query": "pattern", "regex": "pattern"},
"glob": {"query": "pattern"},
}
// repairToolCall corrige en place le nom et les arguments d'un appel quand ils
// visent, sous un autre nom, un outil disponible dans ce tour. Renvoie true si
// l'appel a été modifié. Les arguments doivent déjà être du JSON valide.
func repairToolCall(tc *ToolCall, tools []Tool) bool {
have := make(map[string]bool, len(tools))
for _, t := range tools {
have[t.Function.Name] = true
}
name := tc.Function.Name
var args map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil || args == nil {
args = map[string]any{}
}
changed := false
if !have[name] {
lower := strings.ToLower(strings.TrimSpace(name))
if t := toolNameAliases[lower]; t != "" && have[t] {
name, changed = t, true
} else if have["subagent"] {
// Un sous-agent appelé comme un outil (« explorer », « code_reviewer »).
role := strings.ReplaceAll(lower, "_", "-")
for _, r := range subagentRoles {
if role == r {
if _, ok := args["role"]; !ok {
args["role"] = r
}
if _, ok := args["task"]; !ok {
for _, k := range []string{"prompt", "query", "question", "description"} {
if v, ok := args[k].(string); ok && v != "" {
args["task"] = v
delete(args, k)
break
}
}
}
name, changed = "subagent", true
break
}
}
}
}
for from, to := range toolArgAliases[name] {
v, ok := args[from]
if !ok {
continue
}
if _, exists := args[to]; !exists {
args[to] = v
changed = true
}
delete(args, from)
}
if !changed {
return false
}
b, err := json.Marshal(args)
if err != nil {
return false
}
tc.Function.Name = name
tc.Function.Arguments = string(b)
return true
}
+56
View File
@@ -0,0 +1,56 @@
package loki
import (
"encoding/json"
"testing"
)
func outils(noms ...string) []Tool {
var ts []Tool
for _, n := range noms {
ts = append(ts, Tool{Type: "function", Function: ToolFunction{Name: n}})
}
return ts
}
// Noms et arguments appris d'autres agents : traduits vers les outils de Loki.
func TestReparationDesAppelsHallucines(t *testing.T) {
tools := outils("read", "edit", "bash", "subagent")
cas := []struct {
name, args, wantName string
wantArgs map[string]any
}{
{"read_file", `{"path":"a.go"}`, "read", map[string]any{"file": "a.go"}},
{"str_replace", `{"file_path":"a.go","old_string":"x","new_string":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
{"run_command", `{"cmd":"ls"}`, "bash", map[string]any{"command": "ls"}},
{"edit", `{"file":"a.go","old_str":"x","new":"y"}`, "edit", map[string]any{"file": "a.go", "old": "x", "new": "y"}},
{"code_reviewer", `{"prompt":"relis"}`, "subagent", map[string]any{"role": "code-reviewer", "task": "relis"}},
}
for _, c := range cas {
tc := ToolCall{Function: ToolCallFunc{Name: c.name, Arguments: c.args}}
if !repairToolCall(&tc, tools) {
t.Errorf("%s : non réparé", c.name)
continue
}
var got map[string]any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &got)
if tc.Function.Name != c.wantName || len(got) != len(c.wantArgs) {
t.Errorf("%s → %s %v", c.name, tc.Function.Name, got)
continue
}
for k, v := range c.wantArgs {
if got[k] != v {
t.Errorf("%s : %s=%v, attendu %v", c.name, k, got[k], v)
}
}
}
// Outil cible absent de ce tour, ou appel déjà correct : intact.
for _, tc := range []ToolCall{
{Function: ToolCallFunc{Name: "write_file", Arguments: `{"path":"a"}`}},
{Function: ToolCallFunc{Name: "read", Arguments: `{"file":"a"}`}},
} {
if repairToolCall(&tc, tools) {
t.Errorf("modifié à tort : %+v", tc)
}
}
}
+30 -8
View File
@@ -13,6 +13,7 @@ package loki
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
@@ -20,7 +21,7 @@ import (
type Criterion struct {
ID int `json:"id"`
Text string `json:"text"`
Status string `json:"status"` // pending | passed | failed
Status string `json:"status"` // pending | completed (le builder dit « fait ») | passed | failed
Note string `json:"note,omitempty"`
}
@@ -30,7 +31,7 @@ func critKey(convID string) string { return "crit:" + convID }
func critList(convID string) []Criterion {
var list []Criterion
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
if b, ok := getStoreBytes(bkChat, critKey(convID)); ok && len(b) > 0 {
_ = json.Unmarshal(b, &list)
}
return list
@@ -45,7 +46,7 @@ func critSave(convID string, list []Criterion) {
if err != nil {
return
}
_ = putBytes(bkChat, critKey(convID), b)
_ = putStoreBytes(bkChat, critKey(convID), b)
}
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
@@ -74,6 +75,18 @@ func critPending(list []Criterion) int {
return n
}
// critOpen : les critères que le builder n'a pas encore déclarés faits —
// pending, ou failed et pas encore repris (la reprise les remet à completed).
func critOpen(list []Criterion) []Criterion {
var out []Criterion
for _, c := range list {
if c.Status == "pending" || c.Status == "failed" || c.Status == "" {
out = append(out, c)
}
}
return out
}
// critRender : la liste formatée pour un prompt (builder ou verifier).
func critRender(list []Criterion) string {
var b strings.Builder
@@ -84,6 +97,8 @@ func critRender(list []Criterion) string {
mark = "[✓]"
case "failed":
mark = "[✗]"
case "completed":
mark = "[~]" // fait selon le builder, pas encore vérifié
}
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
if c.Note != "" {
@@ -100,15 +115,15 @@ func criteriaTool() Tool {
Function: ToolFunction{
Name: "criteria",
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
"action=add posits new criteria (texts[]), action=set updates one (id + status, optional note), " +
"action=list shows them, action=clear removes them all. Set criteria BEFORE building. Builder: mark each criterion completed once you have done AND checked it — verification only starts when nothing is left pending. Only the verification pass may mark passed or failed.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
"status": map[string]any{"type": "string", "enum": []string{"pending", "completed", "passed", "failed"}},
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
},
"required": []string{"action"},
@@ -158,8 +173,15 @@ func toolCriteria(args map[string]any, allowPass bool) string {
critNotify(list)
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
case "set":
idf, _ := args["id"].(float64)
id := int(idf)
// id numérique ou chaîne (« "2" », « "#2" ») : les petits modèles
// écrivent volontiers l'id entre guillemets, qui tombait sinon à #0.
id := 0
switch v := args["id"].(type) {
case float64:
id = int(v)
case string:
id, _ = strconv.Atoi(strings.TrimPrefix(strings.TrimSpace(v), "#"))
}
status, _ := args["status"].(string)
note, _ := args["note"].(string)
if status == "passed" && !allowPass {
+59 -7
View File
@@ -25,8 +25,10 @@ func gitStatusTool() Tool {
Name: "git_status",
Description: "git status of the working folder (branch + changed files, porcelain format).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
"type": "object",
"properties": map[string]any{
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
},
},
},
}
@@ -42,6 +44,7 @@ func gitDiffTool() Tool {
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
},
},
},
@@ -66,15 +69,56 @@ func gitCloneTool() Tool {
}
}
// gitRepoDir : où lancer git_status / git_diff. La discussion n'est souvent PAS
// un dépôt : git_clone range le dépôt dans un sous-dossier, et git, lancé à la
// racine, échouait (« not a git repository ») — alors que le vérificateur a
// pour consigne de commencer par git_diff. Ordre : le sous-dossier demandé
// (dir), la racine si c'est un dépôt, sinon l'unique sous-dossier qui en est
// un. Plusieurs candidats : on demande de choisir.
func gitRepoDir(dir string) (string, error) {
base := agentCwd()
if dir = strings.TrimSpace(dir); dir != "" {
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
return "", fmt.Errorf("dossier invalide : %s", dir)
}
return filepath.Join(base, dir), nil
}
if _, err := os.Stat(filepath.Join(base, ".git")); err == nil {
return base, nil
}
entries, _ := os.ReadDir(base)
var repos []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(base, e.Name(), ".git")); err == nil {
repos = append(repos, e.Name())
}
}
switch len(repos) {
case 0:
return base, nil // git dira lui-même « not a git repository »
case 1:
return filepath.Join(base, repos[0]), nil
}
return "", fmt.Errorf("plusieurs dépôts ici (%s) : précise dir", strings.Join(repos, ", "))
}
// runGit exécute git dans le dossier de la discussion, sortie bornée.
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
return runGitIn(ctx, agentCwd(), timeout, args...)
}
// runGitIn exécute git dans dir, sortie bornée.
func runGitIn(ctx context.Context, dir string, timeout time.Duration, args ...string) string {
if _, err := exec.LookPath("git"); err != nil {
return "[erreur] git n'est pas installé sur cette machine"
}
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cctx, "git", args...)
cmd.Dir = agentCwd()
cmd.Dir = dir
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
@@ -95,24 +139,32 @@ func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
return s
}
func toolGitStatus(ctx context.Context) string {
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
func toolGitStatus(ctx context.Context, args map[string]any) string {
dir, err := gitRepoDir(str(args["dir"]))
if err != nil {
return "[erreur] " + err.Error()
}
return runGitIn(ctx, dir, 20*time.Second, "status", "--porcelain=v1", "--branch")
}
func toolGitDiff(ctx context.Context, args map[string]any) string {
dir, err := gitRepoDir(str(args["dir"]))
if err != nil {
return "[erreur] " + err.Error()
}
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
gitArgs := []string{"diff", "HEAD"}
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
gitArgs = append(gitArgs, "--", file)
}
out := runGit(ctx, 30*time.Second, gitArgs...)
out := runGitIn(ctx, dir, 30*time.Second, gitArgs...)
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
fallback := []string{"diff"}
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
fallback = append(fallback, "--", file)
}
out = runGit(ctx, 30*time.Second, fallback...)
out = runGitIn(ctx, dir, 30*time.Second, fallback...)
}
return out
}
+55
View File
@@ -0,0 +1,55 @@
package loki
// code_instructions.go — consignes du dépôt (AGENTS.md, CLAUDE.md) en mode Code.
// Repris de context/instructions.ts d'OpenFox.
//
// Un dépôt qui documente ses commandes de build et de test, ses conventions,
// ses pièges, le fait dans ces fichiers. Sans eux, le modèle les redécouvrait
// à coups de read et de bash — quand il les redécouvrait. Le contenu part avec
// le contexte du projet : déplacé dans le premier message utilisateur par
// normalizeSystemMessages, il ne touche pas au préfixe système (cache).
import (
"os"
"path/filepath"
"strings"
)
const codeInstructionsPrefix = "Repository instructions"
// codeInstructionsMax : au-delà, on tronque (≈ 1k tokens) — ces fichiers
// peuvent être longs, et le contexte est compté.
const codeInstructionsMax = 4000
// codeInstructionFiles : par ordre de préférence ; le premier trouvé gagne.
var codeInstructionFiles = []string{"AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md"}
// codeInstructionsMessage renvoie les consignes du dépôt de la discussion
// (racine, ou dépôt cloné détecté par gitRepoDir), ok=false s'il n'y en a pas.
func codeInstructionsMessage(caps Caps) (Message, bool) {
if !caps.Code {
return Message{}, false
}
dirs := []string{agentCwd()}
if d, err := gitRepoDir(""); err == nil && d != dirs[0] {
dirs = append([]string{d}, dirs...)
}
for _, dir := range dirs {
for _, name := range codeInstructionFiles {
b, err := os.ReadFile(filepath.Join(dir, name))
if err != nil || len(strings.TrimSpace(string(b))) == 0 {
continue
}
s := strings.TrimSpace(string(b))
if r := []rune(s); len(r) > codeInstructionsMax {
s = string(r[:codeInstructionsMax]) + "\n…[tronqué — lis le fichier pour la suite]"
}
rel := name
if r, err := filepath.Rel(agentCwd(), filepath.Join(dir, name)); err == nil {
rel = filepath.ToSlash(r)
}
return Message{Role: "system", Content: codeInstructionsPrefix + " (" + rel + ") — follow them:\n\n" + s}, true
}
}
return Message{}, false
}
+34
View File
@@ -0,0 +1,34 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// AGENTS.md du dépôt cloné part avec le contexte en mode Code, déplacé dans le
// premier message utilisateur (préfixe système intact).
func TestConsignesDuDepot(t *testing.T) {
withWorkspace(t)
if _, ok := codeInstructionsMessage(Caps{Code: true}); ok {
t.Fatal("consignes trouvées dans un dossier vide")
}
repo := filepath.Join(agentCwd(), "projet")
_ = os.MkdirAll(filepath.Join(repo, ".git"), 0o755)
if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("Build : make test\n"+strings.Repeat("x", 5000)), 0o644); err != nil {
t.Fatal(err)
}
if _, ok := codeInstructionsMessage(Caps{}); ok {
t.Fatal("consignes injectées hors mode Code")
}
m, ok := codeInstructionsMessage(Caps{Code: true})
s := msgText(m)
if !ok || !strings.Contains(s, "make test") || !strings.Contains(s, "projet/AGENTS.md") || !strings.Contains(s, "tronqué") {
t.Fatalf("message = %.200q", s)
}
out := normalizeSystemMessages([]Message{{Role: "system", Content: "base"}, m, {Role: "user", Content: "fais-le"}})
if msgText(out[0]) != "base" || !strings.Contains(msgText(out[1]), "make test") {
t.Fatalf("consignes restées dans le système : %+v", out)
}
}
+86
View File
@@ -0,0 +1,86 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
// Le schéma de write annonce le chemin AVANT le contenu.
func TestSchemaWriteCheminDAbord(t *testing.T) {
b, _ := json.Marshal(writeTool().Function.Parameters)
s := string(b)
if i, j := strings.Index(s, `"file"`), strings.Index(s, `"content"`); i < 0 || j < 0 || i > j {
t.Fatalf("ordre des propriétés : %s", s)
}
}
// write sur un fichier existant jamais lu : refusé dès que le chemin est
// complet, sans attendre le contenu ; le fichier reste intact.
func TestPreVolCoupeLEcritureRefusee(t *testing.T) {
withWorkspace(t)
cible := filepath.Join(agentCwd(), "main.go")
if err := os.WriteFile(cible, []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
var n int32
var second []Message
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
chunk := func(args string) {
esc := strings.ReplaceAll(args, `"`, `\"`)
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"write","arguments":"` + esc + `"}}]}}]}` + "\n\n"))
w.(http.Flusher).Flush()
}
chunk(`{"file":"main.go",`)
chunk(`"content":"package main // début`)
time.Sleep(300 * time.Millisecond)
chunk(` FIN-JAMAIS-LUE"}`)
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
return
}
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
second = body.Messages
_, _ = w.Write([]byte(sseChunk("je lis d'abord")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var results []string
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "réécris main.go"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
if ev.ToolUsed != nil && ev.ToolUsed.Done {
results = append(results, ev.ToolUsed.Result)
}
if ev.ToolUsed != nil && strings.Contains(ev.ToolUsed.Body, "FIN-JAMAIS-LUE") {
t.Error("le contenu a continué d'arriver après le refus")
}
return true
}); err != nil {
t.Fatal(err)
}
if len(results) != 1 || !strings.Contains(results[0], "[refusé]") || !strings.Contains(results[0], "interrompue") {
t.Fatalf("résultats = %q", results)
}
if b, _ := os.ReadFile(cible); string(b) != "package main\n" {
t.Fatalf("fichier modifié : %q", b)
}
if len(second) < 2 || second[len(second)-1].Role != "tool" || !strings.Contains(msgText(second[len(second)-1]), "[refusé]") {
t.Fatalf("le refus n'a pas été rendu au modèle : %+v", second)
}
}
+41 -13
View File
@@ -14,7 +14,9 @@ package loki
import (
"encoding/json"
"regexp"
"strings"
"sync"
"unicode/utf8"
)
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
@@ -29,6 +31,10 @@ var defaultRetryPatterns = []string{
`\bfunctions\.\w+\s*\(`,
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
`\[TOOL_REQUEST\]`,
// Format XML de Qwen3-Coder, quand le gabarit du moteur ne l'a pas converti
// en tool_calls (OpenFox 2.0.15x) : <function=bash><parameter=command>…
`<function=\w+>`,
`<parameter=\w+>`,
}
// Compilés à la première utilisation, pas à l'init du package : la surcharge
@@ -67,18 +73,40 @@ func compileRetryPatterns() []*regexp.Regexp {
// textualToolCall dit si le texte final du tour contient un appel d'outil
// écrit en toutes lettres au lieu d'être émis par le protocole.
func textualToolCall(content string) bool {
if content == "" {
return false
}
for _, re := range retryPatterns() {
if re.MatchString(content) {
return true
}
}
return false
return textualToolCallSnippet(content) != ""
}
// retryCorrective : le message réinjecté pour relancer le tour.
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
"Tool calls must go through the tool-call protocol, never in the answer text. " +
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
// textualToolCallSnippet renvoie l'extrait fautif (le motif reconnu et un peu
// de ce qui le suit), "" si aucun. Cité dans la consigne corrective : le
// modèle voit CE qu'il a mal écrit au lieu d'une remontrance abstraite.
func textualToolCallSnippet(content string) string {
if content == "" {
return ""
}
for _, re := range retryPatterns() {
if loc := re.FindStringIndex(content); loc != nil {
end := loc[1] + 80
if end > len(content) {
end = len(content)
}
for end < len(content) && !utf8.RuneStart(content[end]) {
end++
}
return strings.TrimSpace(content[loc[0]:end])
}
}
return ""
}
// maxPatternRetries : relances « appel écrit en texte » consécutives. Le
// compteur repart à zéro après chaque appel d'outil réussi (OpenFox) : un long
// tour d'agent peut rater la syntaxe plusieurs fois, mais pas en boucle.
const maxPatternRetries = 3
// retryCorrective : le message réinjecté pour relancer le tour, avec l'extrait
// fautif.
func retryCorrective(snippet string) string {
return "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed:\n" + snippet +
"\nTool calls must go through the tool-call protocol, never in the answer text. " +
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
}
+68 -1
View File
@@ -1,6 +1,16 @@
package loki
import "testing"
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
// Les appels d'outils écrits en texte doivent être détectés…
func TestAppelOutilTextuelDetecte(t *testing.T) {
@@ -32,3 +42,60 @@ func TestReponseNormaleNonRelancee(t *testing.T) {
}
}
}
// Format XML de Qwen3-Coder resté en texte : détecté, et l'extrait est cité.
func TestAppelOutilQwenXMLDetecte(t *testing.T) {
s := "Je regarde.\n<function=bash>\n<parameter=command>ls -la</parameter>\n</function>"
snip := textualToolCallSnippet(s)
if snip == "" || !strings.HasPrefix(snip, "<function=bash>") {
t.Fatalf("extrait = %q", snip)
}
if textualToolCall("Un <div class=\"x\"> et un <param name=\"a\"> en HTML.") {
t.Fatal("HTML ordinaire pris pour un appel d'outil")
}
}
// Le faux appel est repéré PENDANT le flux : la génération est coupée (la suite
// n'arrive jamais à l'écran) et la relance cite l'extrait fautif.
func TestAppelTextuelCoupeLeFlux(t *testing.T) {
testHome(t)
var n int32
var second string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
_, _ = w.Write([]byte(sseChunk("Je lance <tool_call>")))
w.(http.Flusher).Flush()
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(sseChunk("FIN-INUTILE")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
return
}
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
second = msgText(body.Messages[len(body.Messages)-1])
_, _ = w.Write([]byte(sseChunk("ok")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var content strings.Builder
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "liste"}}, 0.7, Caps{Agent: true}, func(ev StreamEvent) bool {
content.WriteString(ev.Content)
return true
}); err != nil {
t.Fatal(err)
}
if strings.Contains(content.String(), "FIN-INUTILE") {
t.Fatal("le flux n'a pas été coupé sur l'appel écrit en texte")
}
if !strings.Contains(second, "<tool_call>") || !strings.Contains(second, "WRITTEN AS TEXT") {
t.Fatalf("relance sans l'extrait fautif : %q", second)
}
}
+131
View File
@@ -0,0 +1,131 @@
package loki
// code_subagent.go — l'outil `subagent` du mode code : déléguer une question
// bornée à un RÔLE qui travaille dans SON PROPRE contexte, et n'en ramener que
// la réponse.
//
// Repris de l'idée des sous-agents d'OpenFox (voir NOTICE.md), réécrit pour le
// fil unique de Loki, sur la mécanique déjà en place pour la passe de
// vérification (code_verify.go : un runChat isolé, non persisté).
//
// Pourquoi ça compte sur un modèle LOCAL : la fenêtre de contexte est petite.
// « Trouve où est géré le cache » coûte dix lectures de fichiers qui restent
// ensuite dans l'historique jusqu'à la compaction, alors que seule la RÉPONSE
// comptait. Le sous-agent paie ces lectures dans un contexte jetable et ne rend
// que le résultat : le fil du builder ne grossit que d'un résultat d'outil.
//
// Trois rôles, tous en LECTURE SEULE (voir EnabledTools) : explorer (cartographie
// le code), code-reviewer (relit un changement), planner (découpe un travail).
// Aucun n'écrit : ce qui modifie le dépôt reste dans le fil principal, sous les
// yeux de l'utilisateur.
import (
"context"
"strings"
)
// subagentRoles : les rôles délégables. `verifier` n'en fait PAS partie — cette
// passe est pilotée par la boucle du contrat (code_verify.go), c'est la seule
// habilitée à marquer un critère passé, et la laisser s'appeler à la demande
// permettrait au builder de se décerner son propre satisfecit.
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
// subagentTemperature : voir toolSubagent.
const subagentTemperature = 0.6
func isSubagentRole(r string) bool {
for _, x := range subagentRoles {
if x == r {
return true
}
}
return false
}
// subagentMaxOutput borne ce que le sous-agent ramène dans le contexte de
// l'appelant. Au-delà, l'économie de contexte serait perdue — c'est un rapport,
// pas un vidage de fichiers.
const subagentMaxOutput = 6000
func subagentTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "subagent",
// Description au plus court : elle part dans CHAQUE requête (budget du
// préambule, cf. TestSystemPromptStaysLean).
Description: "Délègue une recherche ou une relecture à un rôle qui travaille dans son propre contexte et ne rend que sa réponse. Lecture seule.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"role": map[string]any{
"type": "string",
"enum": subagentRoles,
"description": "explorer (trouver/cartographier), code-reviewer (relire un changement), planner (découper)",
},
"task": map[string]any{"type": "string", "description": "La question, précise et autonome : le rôle ne voit pas la discussion."},
},
"required": []string{"role", "task"},
},
}}
}
// toolSubagent exécute la délégation. Le contexte du sous-agent est ISOLÉ : le
// prompt de son rôle, le briefing machine, et la tâche — rien de l'historique
// du fil, que précisément on ne veut pas payer deux fois. Sa trace n'est ni
// persistée ni diffusée : seul son texte final revient.
func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string {
role, _ := args["role"].(string)
task, _ := args["task"].(string)
role = strings.TrimSpace(role)
task = strings.TrimSpace(task)
if !isSubagentRole(role) {
return "[erreur] rôle inconnu : " + role + " (attendu : " + strings.Join(subagentRoles, ", ") + ")"
}
if task == "" {
return "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire"
}
// Garde-fou anti-récursion : un sous-agent ne délègue pas à son tour. Sans
// ça, un modèle qui boucle ouvre autant de contextes que de tours, et la
// facture en jetons devient exponentielle pour une question unique.
if parent.Role != "" && parent.Role != "builder" {
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
}
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
if role == "planner" {
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
}
msgs := []Message{
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
{Role: "user", Content: task + framing},
}
var out strings.Builder
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
// Température 0.6 et non 0 : en glouton, Qwen avec réflexion tourne vite en
// boucle (OpenFox l'a relevé). Le TEMP du preset, s'il est posé, l'emporte.
// Seul le texte écrit APRÈS le dernier outil revient au builder : la
// narration entre deux lectures n'est pas le rapport.
if _, err := runChat(ctx, msgs, subagentTemperature, caps, func(ev StreamEvent) bool {
if ev.ToolUsed != nil {
out.Reset()
}
if ev.Content != "" {
out.WriteString(ev.Content)
}
return true
}); err != nil {
return "[erreur] sous-agent " + role + " : " + err.Error()
}
res := strings.TrimSpace(out.String())
if res == "" {
return "[erreur] le sous-agent " + role + " n'a rien répondu"
}
if r := []rune(res); len(r) > subagentMaxOutput {
res = string(r[:subagentMaxOutput]) + "\n…[tronqué]"
}
return "[" + role + "]\n" + res
}
+56
View File
@@ -0,0 +1,56 @@
package loki
import (
"context"
"strings"
"testing"
)
// Les garde-fous de l'outil subagent doivent répondre SANS appeler le modèle :
// un rôle inconnu, une tâche vide ou un appel depuis un sous-agent sont refusés
// à l'entrée (sinon chaque faute de frappe coûte une génération complète).
func TestSubagentRefusesBeforeAnyInference(t *testing.T) {
cases := []struct {
name string
args map[string]any
parent Caps
want string
}{
{"rôle inconnu", map[string]any{"role": "architecte", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
{"rôle verifier refusé", map[string]any{"role": "verifier", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
{"tâche vide", map[string]any{"role": "explorer", "task": " "}, Caps{Code: true}, "tâche vide"},
{"pas de récursion", map[string]any{"role": "explorer", "task": "x"}, Caps{Code: true, Role: "explorer"}, "ne peut pas en appeler un autre"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := toolSubagent(context.Background(), tc.args, tc.parent)
if !strings.HasPrefix(got, "[erreur]") || !strings.Contains(got, tc.want) {
t.Fatalf("attendu un refus contenant %q, obtenu %q", tc.want, got)
}
})
}
}
// Un rôle délégué travaille en LECTURE SEULE : ni write/edit (les modifications
// restent dans le fil principal), ni subagent (pas de récursion), et seul le
// planner pose des critères.
func TestSubagentRolesAreReadOnly(t *testing.T) {
forbidden := map[string]bool{"write": true, "edit": true, "subagent": true, "mem_add": true, "mem_edit": true, "web_search": true}
for _, role := range subagentRoles {
names := map[string]bool{}
for _, tool := range EnabledTools(Caps{Agent: true, Code: true, Role: role}) {
names[tool.Function.Name] = true
}
for f := range forbidden {
if names[f] {
t.Fatalf("le rôle %s ne devrait pas avoir l'outil %s", role, f)
}
}
if !names["read"] || !names["grep"] {
t.Fatalf("le rôle %s devrait pouvoir lire le code", role)
}
if got := names["criteria"]; got != (role == "planner") {
t.Fatalf("critères pour %s : %v (attendu %v)", role, got, role == "planner")
}
}
}
+25
View File
@@ -157,3 +157,28 @@ func TestEditCRLFMultiligne(t *testing.T) {
t.Fatalf("résultat inattendu : %q", b)
}
}
// git_status / git_diff trouvent le dépôt cloné dans un sous-dossier de la
// discussion, et demandent de choisir quand il y en a plusieurs.
func TestGitRepoDirDetecteLeClone(t *testing.T) {
ws := withWorkspace(t)
if d, err := gitRepoDir(""); err != nil || d != agentCwd() {
t.Fatalf("sans dépôt : %q, %v", d, err)
}
if err := os.MkdirAll(filepath.Join(agentCwd(), "projet", ".git"), 0o755); err != nil {
t.Fatal(err)
}
if d, err := gitRepoDir(""); err != nil || filepath.Base(d) != "projet" {
t.Fatalf("un clone : %q, %v (ws %s)", d, err, ws)
}
_ = os.MkdirAll(filepath.Join(agentCwd(), "autre", ".git"), 0o755)
if _, err := gitRepoDir(""); err == nil {
t.Fatal("deux dépôts : il fallait demander dir")
}
if d, err := gitRepoDir("autre"); err != nil || filepath.Base(d) != "autre" {
t.Fatalf("dir explicite : %q, %v", d, err)
}
if _, err := gitRepoDir("../dehors"); err == nil {
t.Fatal("dir hors de la discussion accepté")
}
}
+70 -11
View File
@@ -25,10 +25,19 @@ import (
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
const codeMaxFixTurns = 2
// codeMaxContinue : relances du builder qui a rendu la main avec des critères
// encore ouverts (« ensuite je vais… ») avant de vérifier quand même. Reprise
// du buildAgentNudge d'OpenFox : une passe de vérification sur un travail
// inachevé, c'est un prefill complet, le cache KV du fil évincé, et des
// « failed » qui ne disent que « pas encore fait ».
const codeMaxContinue = 2
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère), ni si le
// tour s'est terminé sur une question à l'utilisateur (asked) : sa réponse
// passe avant toute vérification.
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int, asked bool) {
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil || asked {
return
}
convID := convEnsureActive()
@@ -40,6 +49,24 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
if caps.Role == "planner" {
return
}
// Le builder vérifie lui-même puis marque « completed » ce qu'il estime
// fait. Tant qu'il reste des critères ouverts, on le relance plutôt que de
// vérifier un travail inachevé.
for n := 0; n < codeMaxContinue; n++ {
open := critOpen(list)
if len(open) == 0 {
break
}
msg := "Not done yet — these acceptance criteria are still open:\n" + critRender(open) +
"\n\nContinue working on them. Once one is done and checked, mark it completed (criteria action=set, status=completed). If you need the user's decision, use ask."
if c.runBuilderTurn(ctx, caps, temperature, epoch, msg) || ctx.Err() != nil {
return // question posée à l'utilisateur, ou arrêt
}
list = critList(convID)
if critAllPassed(list) {
return
}
}
for fix := 0; ; fix++ {
if ctx.Err() != nil {
return
@@ -59,7 +86,9 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
return
}
c.runFixTurn(ctx, caps, temperature, epoch, list)
if c.runFixTurn(ctx, caps, temperature, epoch, list) {
return // le builder attend une réponse de l'utilisateur
}
}
}
@@ -91,38 +120,59 @@ func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature
}
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
// critères en échec. Sa trace est persistée comme un tour ordinaire.
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
// critères en échec. Sa trace est persistée comme un tour ordinaire. Renvoie
// true si le builder a posé une question à l'utilisateur (ask).
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) bool {
var failed []Criterion
for _, cr := range list {
if cr.Status != "passed" {
failed = append(failed, cr)
}
}
return c.runBuilderTurn(ctx, caps, temperature, epoch, "Verification failed on these criteria:\n"+critRender(failed)+
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed. Mark each fixed criterion completed again.")
}
// runBuilderTurn relance le BUILDER sur l'historique normal avec une consigne
// (correction après vérification, ou relance sur des critères ouverts). Sa
// trace est persistée comme un tour ordinaire. Renvoie true si le builder a
// posé une question à l'utilisateur (ask) : la boucle doit alors s'arrêter.
func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperature float64, epoch int, instruction string) (asked bool) {
c.appendDelta(epoch, map[string]any{"role": "builder"})
defer c.appendDelta(epoch, map[string]any{"role": ""})
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
fixMsg := Message{Role: "user", Content: instruction}
c.mu.Lock()
if c.epoch != epoch {
c.mu.Unlock()
return
return false
}
c.Messages = append(c.Messages, fixMsg)
msgs := append([]Message(nil), c.Messages...)
c.mu.Unlock()
final := msgs
// Même contexte que le tour de build : projet (description, index mémoire,
// trackers) et consignes du dépôt. Sans eux, la reprise changeait le début
// du premier message utilisateur — tout le cache de prompt à refaire.
final := append(projectSystemMessages(), msgs...)
if m, ok := codeInstructionsMessage(caps); ok {
final = append([]Message{m}, final...)
}
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
var content strings.Builder
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
if ev.ToolUsed != nil {
content.Reset() // déjà dans le message tool_calls (voir generate)
}
if ev.Ask != nil {
asked = true
}
c.forwardStream(ev, epoch)
return true
})
@@ -135,6 +185,7 @@ func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature fl
}
c.mu.Unlock()
c.persist()
return asked
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
@@ -154,10 +205,18 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
}
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
tu["added"] = ev.ToolUsed.Added
tu["removed"] = ev.ToolUsed.Removed
}
if ev.ToolUsed.Image != "" {
tu["image"] = ev.ToolUsed.Image
}
if ev.ToolUsed.ResultChars > 0 {
tu["result_chars"] = ev.ToolUsed.ResultChars
}
if ev.ToolUsed.ResultID != "" {
tu["result_id"] = ev.ToolUsed.ResultID
}
c.appendDelta(epoch, map[string]any{"tool_used": tu})
case ev.Ask != nil:
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
+110
View File
@@ -0,0 +1,110 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
)
// moteurScripte : faux llama-server qui répond selon le DERNIER message reçu.
// Un message `tool` en queue → texte de clôture ; sinon la règle dont le motif
// figure dans le dernier message utilisateur → appel d'outil criteria.
func moteurScripte(t *testing.T, regles map[string]string) (port string, vus *[]string) {
t.Helper()
var mu sync.Mutex
var log []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
last := body.Messages[len(body.Messages)-1]
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
args := ""
if last.Role == "user" {
txt := msgText(last)
mu.Lock()
log = append(log, txt)
mu.Unlock()
for motif, a := range regles {
if strings.Contains(txt, motif) {
args = a
}
}
}
if args != "" {
esc := strings.ReplaceAll(args, `"`, `\"`)
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"criteria","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
} else {
_, _ = w.Write([]byte(sseChunk("ok")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
}
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return u.Port(), &log
}
// Le builder rend la main avec un critère encore ouvert : on le RELANCE (il le
// marque completed) avant de vérifier, au lieu de vérifier un travail
// inachevé (repris du buildAgentNudge d'OpenFox).
func TestVerificationAttendQueLeBuilderAitFini(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
port, vus := moteurScripte(t, map[string]string{
"Not done yet": `{"action":"set","id":"1","status":"completed"}`,
"Verify each non-pass": `{"action":"set","id":1,"status":"passed"}`,
})
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
c := newTestConv()
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, false)
if len(*vus) < 2 || !strings.Contains((*vus)[0], "Not done yet") || !strings.Contains((*vus)[1], "Verify each non-pass") {
t.Fatalf("ordre des passes = %q, attendu relance du builder PUIS vérification", *vus)
}
if l := critList(id); len(l) != 1 || l[0].Status != "passed" {
t.Fatalf("critère final : %+v", l)
}
}
// Tour terminé sur une question à l'utilisateur : aucune passe ne part avant
// sa réponse.
func TestPasDeVerificationApresUneQuestion(t *testing.T) {
withWorkspace(t)
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
port, vus := moteurScripte(t, nil)
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
c := newTestConv()
c.Messages = []Message{{Role: "user", Content: "fais le build"}}
c.codeVerifyLoop(context.Background(), Caps{Agent: true, Code: true}, 0.2, c.epoch, true)
if len(*vus) != 0 {
t.Fatalf("%d requête(s) au moteur alors qu'une question attend l'utilisateur", len(*vus))
}
}
// Un id de critère écrit entre guillemets (« "2" », « "#2" ») vise bien #2.
func TestCritereIdEnChaine(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
toolCriteria(map[string]any{"action": "add", "texts": []any{"a", "b"}}, false)
if out := toolCriteria(map[string]any{"action": "set", "id": "#2", "status": "completed"}, false); !strings.HasPrefix(out, "[ok]") {
t.Fatalf("id en chaîne refusé : %s", out)
}
if l := critList(id); l[1].Status != "completed" || l[0].Status != "pending" {
t.Fatalf("mauvais critère modifié : %+v", l)
}
}
+70
View File
@@ -0,0 +1,70 @@
package loki
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
)
// fauxMoteurOutil : un llama-server de comédie qui, à la PREMIÈRE requête,
// répond par un appel d'outil (name/args), puis par un texte à la suivante.
func fauxMoteurOutil(t *testing.T, name, args string) (port string, requêtes *int32) {
t.Helper()
var n int32
esc := strings.ReplaceAll(args, `"`, `\"`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"` + name + `","arguments":"` + esc + `"}}]},"finish_reason":null}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
} else {
_, _ = w.Write([]byte(sseChunk("Verdict enregistré.")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
}
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
return u.Port(), &n
}
// La passe de vérification (Role=verifier) doit pouvoir marquer un critère
// passed via l'outil criteria — c'est tout son rôle. Vécu : « en mode code,
// le système de vérification ne coche plus les critères ».
func TestPasseDeVerificationCocheLesCriteres(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
port, n := fauxMoteurOutil(t, "criteria", `{"action":"set","id":1,"status":"passed"}`)
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
caps := Caps{Agent: true, Code: true, Role: "verifier"}
var tools []string
_, err := runChat(context.Background(), []Message{
{Role: "system", Content: rolePrompt("verifier")},
{Role: "user", Content: "Verify."},
}, 0.2, caps, func(ev StreamEvent) bool {
if ev.ToolUsed != nil && ev.ToolUsed.Done {
tools = append(tools, ev.ToolUsed.Name+" → "+ev.ToolUsed.Result)
}
return true
})
if err != nil {
t.Fatal(err)
}
if *n < 2 {
t.Fatalf("%d requête(s) au moteur : l'appel d'outil n'a pas été suivi d'un second tour", *n)
}
list := critList(id)
if len(list) != 1 || list[0].Status != "passed" {
t.Fatalf("critère non coché par le vérificateur : %+v — outils : %v", list, tools)
}
}
+895
View File
@@ -0,0 +1,895 @@
package loki
// computer_cdp.go — pilote un Chromium/Chrome LOCAL via le protocole DevTools
// (CDP), en Go pur (websocket coder/websocket déjà présent), sans dépendance
// lourde type chromedp/playwright. C'est le moteur « web » du computer use :
// ouvrir une URL, lire l'arbre d'accessibilité (éléments interactifs numérotés,
// façon set-of-marks), cliquer/taper/défiler, et — à la demande — capturer un
// screenshot pour la vision.
//
// Choix d'archi : le conteneur Loki embarque déjà un Chromium (celui de
// Playwright, cf. Dockerfile) ; on le pilote en CDP. Une seule session navigateur
// est maintenue (lazy) et réutilisée entre les appels d'outils, comme le cache
// web de chat_internet.go.
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/coder/websocket"
)
var (
cdpMu sync.Mutex
cdpCur *cdpSession
)
// Taille FIXE du viewport piloté (et donc du screenshot). Communiquée au modèle
// pour que ses coordonnées browser_click_xy lues sur l'image tombent juste.
const (
cuViewW = 1280
cuViewH = 800
)
// cdpSession détient le process Chrome, la connexion websocket CDP vers la cible
// « page », et le multiplexage requête/réponse (un lecteur en tâche de fond
// distribue les réponses aux appels en attente par id).
type cdpSession struct {
cmd *exec.Cmd
userDir string
ws *websocket.Conn
wctx context.Context
wcancel context.CancelFunc
mu sync.Mutex
nextID int
pending map[int]chan cdpMessage
netMu sync.Mutex
inflight int // requêtes réseau en cours (pour l'attente d'inactivité)
lastNet time.Time // dernier événement réseau
lastSnap string // dernier snapshot renvoyé, pour dé-dupliquer les états identiques
}
type cdpMessage struct {
ID int `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *cdpError `json:"error,omitempty"`
}
type cdpError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ─── découverte du binaire navigateur ────────────────────────────────────────
// chromePath localise un binaire Chromium/Chrome/Edge exploitable. LOKI_CHROME
// force un chemin ; sinon on cherche D'ABORD le Chromium de Playwright (celui
// qu'embarque l'image Docker, sous PLAYWRIGHT_BROWSERS_PATH), puis les
// emplacements standards par OS, puis le PATH.
//
// L'ordre compte : dans le conteneur, AUCUN chromium n'est installé par le
// gestionnaire de paquets — le seul navigateur présent est celui que
// `playwright install chromium` a déposé dans /opt/pw-browsers. Sans cette
// recherche, le pilotage de navigateur serait déclaré indisponible dans l'image
// même qui embarque un navigateur (voir Dockerfile, ARG PLAYWRIGHT).
func chromePath() string {
if p := strings.TrimSpace(os.Getenv("LOKI_CHROME")); p != "" {
return p
}
if p := playwrightChromium(); p != "" {
return p
}
var cands []string
switch runtime.GOOS {
case "windows":
for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LocalAppData")} {
if base == "" {
continue
}
cands = append(cands,
filepath.Join(base, `Google\Chrome\Application\chrome.exe`),
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
filepath.Join(base, `Chromium\Application\chrome.exe`),
)
}
case "darwin":
cands = []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
}
default:
for _, n := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"} {
if p, err := exec.LookPath(n); err == nil {
return p
}
}
}
for _, c := range cands {
if c != "" {
if _, err := os.Stat(c); err == nil {
return c
}
}
}
for _, n := range []string{"chrome", "google-chrome", "chromium"} {
if p, err := exec.LookPath(n); err == nil {
return p
}
}
return ""
}
// playwrightChromium renvoie le binaire Chromium déposé par Playwright, ou ""
// s'il n'y en a pas. Playwright range ses navigateurs sous
// PLAYWRIGHT_BROWSERS_PATH (l'image Docker fixe /opt/pw-browsers) ou, à défaut,
// dans le cache utilisateur, en un dossier chromium-<build> par version.
//
// On prend la version la PLUS RÉCENTE (les builds sont numérotés, tri croissant
// sur le nom) : une image reconstruite laisse parfois l'ancien dossier derrière
// elle, et lancer un vieux Chromium sur le CDP d'aujourd'hui, c'est chercher
// des pannes sans raison. `chrome` d'abord, `headless_shell` en repli : ce
// dernier ne sait pas ouvrir de fenêtre (LOKI_CU_HEADFUL serait sans effet)
// mais pilote très bien une page en headless.
func playwrightChromium() string {
var roots []string
if p := strings.TrimSpace(os.Getenv("PLAYWRIGHT_BROWSERS_PATH")); p != "" && p != "0" {
roots = append(roots, p)
}
roots = append(roots, "/opt/pw-browsers")
if home, err := os.UserHomeDir(); err == nil && home != "" {
switch runtime.GOOS {
case "windows":
roots = append(roots, filepath.Join(home, "AppData", "Local", "ms-playwright"))
case "darwin":
roots = append(roots, filepath.Join(home, "Library", "Caches", "ms-playwright"))
default:
roots = append(roots, filepath.Join(home, ".cache", "ms-playwright"))
}
}
// Chemin du binaire À L'INTÉRIEUR d'un dossier chromium-<build>, par OS.
var rels [][]string
switch runtime.GOOS {
case "windows":
rels = [][]string{{"chrome-win", "chrome.exe"}, {"chrome-win", "headless_shell.exe"}}
case "darwin":
rels = [][]string{{"chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"}, {"chrome-mac", "headless_shell"}}
default:
rels = [][]string{{"chrome-linux", "chrome"}, {"chrome-linux", "headless_shell"}}
}
for _, root := range roots {
dirs, err := filepath.Glob(filepath.Join(root, "chromium*-*"))
if err != nil || len(dirs) == 0 {
continue
}
sort.Strings(dirs)
for i := len(dirs) - 1; i >= 0; i-- {
for _, rel := range rels {
p := filepath.Join(append([]string{dirs[i]}, rel...)...)
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
}
}
return ""
}
// ─── lancement + connexion ───────────────────────────────────────────────────
func cdpLaunch() (*cdpSession, error) {
bin := chromePath()
if bin == "" {
return nil, fmt.Errorf("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=<chemin du binaire>")
}
// Balaye les profils temporaires abandonnés (Chrome tué sans close() lors d'un
// redémarrage du service) : sinon /tmp se remplit de dossiers loki-cu-* au fil
// des restarts.
sweepStaleCUDirs()
userDir, err := os.MkdirTemp("", "loki-cu-")
if err != nil {
return nil, err
}
// Headless par défaut (marche sur le serveur sans écran) ; LOKI_CU_HEADFUL=1
// ouvre une vraie fenêtre visible sur une machine de bureau.
args := []string{
"--remote-debugging-port=0",
"--user-data-dir=" + userDir,
"--no-first-run", "--no-default-browser-check",
"--disable-gpu", "--disable-extensions",
"--disable-background-networking",
"--disable-features=Translate,BackForwardCache",
// Indispensables sur un serveur headless sous un compte non-root : sans
// --no-sandbox Chrome plante au lancement (namespaces refusés), et /dev/shm
// y est souvent minuscule → --disable-dev-shm-usage évite les crash onglet.
"--no-sandbox", "--disable-dev-shm-usage",
"--window-size=1280,900",
"about:blank",
}
if os.Getenv("LOKI_CU_HEADFUL") == "" {
args = append([]string{"--headless=new"}, args...)
}
cmd := exec.Command(bin, args...)
if err := cmd.Start(); err != nil {
os.RemoveAll(userDir)
return nil, err
}
// Chrome écrit le port de debug réel dans DevToolsActivePort (ligne 1).
port := ""
portFile := filepath.Join(userDir, "DevToolsActivePort")
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if b, err := os.ReadFile(portFile); err == nil {
if line := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2)[0]; line != "" {
port = line
break
}
}
time.Sleep(100 * time.Millisecond)
}
if port == "" {
_ = cmd.Process.Kill()
os.RemoveAll(userDir)
return nil, fmt.Errorf("Chrome n'a pas exposé son port de debug (DevToolsActivePort)")
}
wsURL, err := cdpPageWS(port)
if err != nil {
_ = cmd.Process.Kill()
os.RemoveAll(userDir)
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
c, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
cancel()
_ = cmd.Process.Kill()
os.RemoveAll(userDir)
return nil, err
}
c.SetReadLimit(-1) // les screenshots dépassent la limite par défaut (32 Kio)
s := &cdpSession{cmd: cmd, userDir: userDir, ws: c, wctx: ctx, wcancel: cancel, pending: map[int]chan cdpMessage{}}
go s.readLoop()
_, _ = s.call("Page.enable", nil)
_, _ = s.call("Runtime.enable", nil)
_, _ = s.call("DOM.enable", nil)
_, _ = s.call("Network.enable", nil) // pour l'attente d'inactivité réseau (settle)
// Viewport DÉTERMINISTE : sans ça, headless donne une taille bizarre (mesuré :
// 1264x749) que le modèle interprétait mal pour browser_click_xy (il visait trop
// bas). On force exactement cuViewW x cuViewH, DPR 1 → le screenshot fait cette
// taille, et les coordonnées lues dessus = coordonnées de clic, 1:1.
_, _ = s.call("Emulation.setDeviceMetricsOverride", map[string]any{
"width": cuViewW, "height": cuViewH, "deviceScaleFactor": 1, "mobile": false,
})
// Garder toute navigation dans le MÊME onglet : le pilote n'est attaché qu'à une
// cible « page ». Un lien target=_blank ou un window.open ouvrait un onglet que
// le pilote ne voyait pas → l'IA croyait que « rien ne se passe » et bouclait
// (vécu sur le test IONOS « Devenez client »). Ce script, réévalué au début de
// CHAQUE document, neutralise les nouveaux onglets.
_, _ = s.call("Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": cdpSameTabJS})
return s, nil
}
// cdpSameTabJS force les navigations « nouvel onglet » à rester dans l'onglet
// courant (window.open redirigé, target=_blank retiré au clic).
const cdpSameTabJS = `(function(){try{
window.open=function(u){if(u)location.href=u;return window;};
document.addEventListener('click',function(e){
var a=e.target&&e.target.closest&&e.target.closest('a[target]');
if(a)a.removeAttribute('target');
},true);
}catch(e){}})();`
// cdpPageWS interroge l'API HTTP de debug pour trouver l'URL websocket d'une
// cible de type « page ».
func cdpPageWS(port string) (string, error) {
cl := &http.Client{Timeout: 5 * time.Second}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
resp, err := cl.Get("http://127.0.0.1:" + port + "/json")
if err == nil {
var targets []struct {
Type string `json:"type"`
WS string `json:"webSocketDebuggerUrl"`
}
_ = json.NewDecoder(resp.Body).Decode(&targets)
resp.Body.Close()
for _, t := range targets {
if t.Type == "page" && t.WS != "" {
return t.WS, nil
}
}
}
time.Sleep(150 * time.Millisecond)
}
return "", fmt.Errorf("aucune cible 'page' exposée par Chrome")
}
func (s *cdpSession) readLoop() {
for {
_, data, err := s.ws.Read(s.wctx)
if err != nil {
s.mu.Lock()
for _, ch := range s.pending {
close(ch)
}
s.pending = map[int]chan cdpMessage{}
s.mu.Unlock()
return
}
var m cdpMessage
if json.Unmarshal(data, &m) != nil {
continue
}
if m.ID != 0 {
s.mu.Lock()
ch := s.pending[m.ID]
delete(s.pending, m.ID)
s.mu.Unlock()
if ch != nil {
ch <- m
}
continue
}
// Événements réseau : on suit le nombre de requêtes en vol pour que settle()
// puisse attendre l'inactivité réseau (fin du chargement AJAX).
switch m.Method {
case "Network.requestWillBeSent":
s.netMu.Lock()
s.inflight++
s.lastNet = time.Now()
s.netMu.Unlock()
case "Network.loadingFinished", "Network.loadingFailed":
s.netMu.Lock()
if s.inflight > 0 {
s.inflight--
}
s.lastNet = time.Now()
s.netMu.Unlock()
}
}
}
// call envoie une commande CDP et attend sa réponse (borné à 30 s).
func (s *cdpSession) call(method string, params map[string]any) (json.RawMessage, error) {
s.mu.Lock()
s.nextID++
id := s.nextID
ch := make(chan cdpMessage, 1)
s.pending[id] = ch
s.mu.Unlock()
payload := map[string]any{"id": id, "method": method}
if params != nil {
payload["params"] = params
}
data, _ := json.Marshal(payload)
wctx, cancel := context.WithTimeout(s.wctx, 30*time.Second)
defer cancel()
if err := s.ws.Write(wctx, websocket.MessageText, data); err != nil {
return nil, err
}
select {
case m, ok := <-ch:
if !ok {
return nil, fmt.Errorf("connexion CDP fermée")
}
if m.Error != nil {
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
}
return m.Result, nil
case <-wctx.Done():
return nil, fmt.Errorf("%s: délai dépassé", method)
}
}
func (s *cdpSession) close() {
if s == nil {
return
}
if s.ws != nil {
_ = s.ws.Close(websocket.StatusNormalClosure, "")
}
if s.wcancel != nil {
s.wcancel()
}
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
if s.userDir != "" {
os.RemoveAll(s.userDir)
}
}
// cdpGet renvoie la session courante, en (re)lançant Chrome au besoin. Un ping
// léger valide qu'une session existante répond encore.
func cdpGet() (*cdpSession, error) {
cdpMu.Lock()
defer cdpMu.Unlock()
if cdpCur != nil {
if _, err := cdpCur.call("Runtime.evaluate", map[string]any{"expression": "1", "returnByValue": true}); err == nil {
return cdpCur, nil
}
cdpCur.close()
cdpCur = nil
}
s, err := cdpLaunch()
if err != nil {
return nil, err
}
cdpCur = s
return s, nil
}
// cdpShutdown ferme la session navigateur si elle existe (appelé quand on coupe
// le computer use, pour ne pas laisser un Chrome headless traîner).
// sweepStaleCUDirs supprime les profils Chrome temporaires (loki-cu-*) de plus
// d'une heure laissés dans le dossier temp — best-effort, silencieux.
func sweepStaleCUDirs() {
entries, err := os.ReadDir(os.TempDir())
if err != nil {
return
}
cutoff := time.Now().Add(-time.Hour)
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), "loki-cu-") {
continue
}
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
os.RemoveAll(filepath.Join(os.TempDir(), e.Name()))
}
}
}
func cdpShutdown() {
cdpMu.Lock()
defer cdpMu.Unlock()
if cdpCur != nil {
cdpCur.close()
cdpCur = nil
}
}
// ─── évaluation JS ───────────────────────────────────────────────────────────
// cdpEval exécute une expression JS dans la page et renvoie sa valeur (JSON brut).
func (s *cdpSession) eval(expr string) (json.RawMessage, error) {
res, err := s.call("Runtime.evaluate", map[string]any{
"expression": expr,
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return nil, err
}
var r struct {
Result struct {
Value json.RawMessage `json:"value"`
} `json:"result"`
ExceptionDetails *struct {
Text string `json:"text"`
} `json:"exceptionDetails"`
}
_ = json.Unmarshal(res, &r)
if r.ExceptionDetails != nil {
return nil, fmt.Errorf("JS: %s", r.ExceptionDetails.Text)
}
return r.Result.Value, nil
}
// ─── snapshot d'accessibilité (set-of-marks) ─────────────────────────────────
// cdpHelpersJS : fonctions partagées par le snapshot et browser_find, préfixées au
// script évalué. Points clés :
// - ajActionable ne garde que les éléments RÉELLEMENT cliquables/saisissables :
// on écarte les conteneurs (list, listitem, banner, navigation, menu, region,
// div/span muets…) qui polluaient la liste et embrouillaient le modèle.
// - ajName enrichit un champ de formulaire avec son <label> (« Prénom » plutôt
// que « firstName »), et NE RÉVÈLE PAS la valeur d'un champ mot de passe
// (le secret ne doit pas repartir dans le contexte/l'export).
const cdpHelpersJS = `
function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;}
function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);}
function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;}
function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;}
var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]';
// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics,
// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le
// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll /
// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert.
function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
`
// cdpSnapshotJS marque chaque élément ACTIONNABLE et VISIBLE d'un attribut
// data-loki-ref numéroté, mémorise le centre de chacun dans window.__lokiRefs,
// et renvoie {title,url,refs,above,below}. Compact volontairement.
const cdpSnapshotJS = cdpHelpersJS + `(function(){
ajClear();
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
var map={},refs=[],i=0;
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
el.setAttribute('data-loki-ref',String(i));
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
}
window.__lokiRefs=map;
var de=document.documentElement;
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
return {title:document.title||'',url:location.href,refs:refs,
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
})()`
type cuSnap struct {
Title string `json:"title"`
URL string `json:"url"`
Refs []string `json:"refs"`
Above int `json:"above"` // pixels défilables au-dessus de la vue
Below int `json:"below"` // pixels défilables en dessous de la vue
}
// snapshot lit l'arbre interactif courant et le formate pour le modèle.
func (s *cdpSession) snapshot() (string, error) {
val, err := s.eval(cdpSnapshotJS)
if err != nil {
return "", err
}
var snap cuSnap
if err := json.Unmarshal(val, &snap); err != nil {
return "", fmt.Errorf("snapshot illisible: %v", err)
}
var b strings.Builder
fmt.Fprintf(&b, "# %s\n%s\n\n", snap.Title, snap.URL)
if len(snap.Refs) == 0 {
b.WriteString("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)")
} else {
fmt.Fprintf(&b, "%d élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", len(snap.Refs))
b.WriteString(strings.Join(snap.Refs, "\n"))
}
// Indice de défilement : la liste ne couvre QUE la partie visible. Sans ce
// repère, le modèle croit la page épuisée après un scroll (3 éléments restants)
// et se met à deviner des URLs au lieu de défiler (vécu sur le test IONOS).
if snap.Below > 20 || snap.Above > 20 {
b.WriteString("\n\n")
if snap.Below > 20 {
fmt.Fprintf(&b, "↓ encore ~%d px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ", snap.Below)
}
if snap.Above > 20 {
fmt.Fprintf(&b, "↑ ~%d px au-dessus — browser_scroll(up) pour y revenir.", snap.Above)
}
}
return strings.TrimRight(b.String(), " "), nil
}
// snapshotDedup renvoie le snapshot courant, ou une ligne courte s'il est
// RIGOUREUSEMENT identique au dernier renvoyé (clic sans effet, ré-ouverture de
// la même page…). Évite de re-lister 20+ éléments pour rien — les numéros restent
// valides puisque le DOM n'a pas changé. Économie de contexte pour petits modèles.
func (s *cdpSession) snapshotDedup() (string, error) {
full, err := s.snapshot()
if err != nil {
return "", err
}
if full == s.lastSnap {
return "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", nil
}
s.lastSnap = full
return full, nil
}
// cdpFindJS cherche, sur TOUTE la page (pas seulement le viewport), les éléments
// interactifs dont le rôle ou le nom contient la requête, leur pose un
// data-loki-ref numéroté, fait défiler le premier au centre, et renvoie la liste.
// Sert à localiser un lien/bouton hors écran sans défiler à l'aveugle.
// browser_find est NON DESTRUCTIF (contrairement au snapshot) : il n'efface PAS les
// data-loki-ref existants — sinon les numéros du dernier snapshot deviendraient
// invalides et un browser_click ensuite échouerait (« introuvable », vécu au test #11).
// Il RÉUTILISE le ref d'un élément déjà numéroté, et n'attribue un NOUVEAU numéro
// (au-delà du max courant) qu'aux éléments hors écran encore sans ref.
const cdpFindJS = `function(q){
q=(q||'').toLowerCase();
var pool=ajQueryAll();
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
var els=pool.filter(ajActionable);
var refs=[],count=0,first=null;
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
var ref=el.getAttribute('data-loki-ref');
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
if(!first)first=el;count++;
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
}
if(first)first.scrollIntoView({block:'center'});
return {refs:refs};
}`
func (s *cdpSession) find(query string) (string, error) {
qb, _ := json.Marshal(query)
val, err := s.eval(cdpHelpersJS + "(" + cdpFindJS + ")(" + string(qb) + ")")
if err != nil {
return "", err
}
var r struct {
Refs []string `json:"refs"`
}
if err := json.Unmarshal(val, &r); err != nil {
return "", fmt.Errorf("recherche illisible: %v", err)
}
if len(r.Refs) == 0 {
return fmt.Sprintf("Aucun élément contenant « %s » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", query), nil
}
var b strings.Builder
fmt.Fprintf(&b, "%d élément(s) contenant « %s » (le 1er est amené dans la vue) — clique par numéro :\n", len(r.Refs), query)
b.WriteString(strings.Join(r.Refs, "\n"))
return b.String(), nil
}
// ─── actions ─────────────────────────────────────────────────────────────────
// waitLoad attend (au mieux) que la page ait fini de charger après une navigation,
// puis que le DOM se STABILISE (contenu rendu en AJAX après readyState=complete).
func (s *cdpSession) waitLoad() {
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
val, err := s.eval(`document.readyState`)
if err == nil && strings.Trim(string(val), `"`) == "complete" {
break
}
time.Sleep(150 * time.Millisecond)
}
s.settle()
}
// settle attend l'INACTIVITÉ RÉSEAU : sur une SPA, readyState passe à « complete »
// avant que le contenu chargé en AJAX (résultats de recherche, étape de tunnel…)
// ne soit arrivé. On rend la main dès qu'il n'y a plus de requête en vol depuis
// ~600 ms, borné à 6 s. Ça supprime le « bash sleep » que le modèle bricolait
// avant un browser_snapshot (vécu au test IONOS #10).
func (s *cdpSession) settle() {
const quiet = 600 * time.Millisecond
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) {
s.netMu.Lock()
inflight := s.inflight
since := time.Since(s.lastNet)
s.netMu.Unlock()
if inflight <= 0 && since >= quiet {
return
}
time.Sleep(120 * time.Millisecond)
}
}
func (s *cdpSession) navigate(url string) error {
// Toute navigation = nouvelle page : on oublie le dernier snapshot pour que le
// browser_open qui suit renvoie la page COMPLÈTE, jamais « (page inchangée) »
// (piège quand la session persiste d'un test à l'autre, vécu sur Lady Sushi).
s.lastSnap = ""
// « back » / « forward » : navigation dans l'historique (récupération d'erreur),
// pas une URL — sinon on en ferait « https://back ».
switch strings.ToLower(strings.TrimSpace(url)) {
case "back":
_, err := s.eval("history.back();'ok'")
s.waitLoad()
return err
case "forward":
_, err := s.eval("history.forward();'ok'")
s.waitLoad()
return err
}
if !strings.Contains(url, "://") {
url = "https://" + url
}
res, err := s.call("Page.navigate", map[string]any{"url": url})
if err != nil {
return err
}
// Page.navigate signale l'échec réseau dans errorText (DNS, connexion refusée…) ;
// et une page d'erreur Chrome a une location « chrome-error:// ». Dans les deux
// cas on renvoie une erreur EXPLICITE plutôt qu'un faux snapshot « Actualiser »
// que le modèle re-tentait en boucle (vécu : account.ionos.com ré-ouvert 2×).
var nr struct {
ErrorText string `json:"errorText"`
}
_ = json.Unmarshal(res, &nr)
s.waitLoad()
loc, _ := s.eval("location.href")
// ERR_ABORTED est bénin (redirection/téléchargement) : on ne le traite pas comme
// un échec. Le signal fiable est une location « chrome-error:// ».
failed := strings.Contains(string(loc), "chrome-error")
if nr.ErrorText != "" && nr.ErrorText != "net::ERR_ABORTED" {
failed = true
}
if failed {
reason := nr.ErrorText
if reason == "" {
reason = "page injoignable"
}
return fmt.Errorf("chargement de %s échoué (%s) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", url, reason)
}
return nil
}
// refCenter renvoie les coordonnées viewport (fraîches) d'un élément marqué, en
// le faisant d'abord défiler au centre.
func (s *cdpSession) refCenter(ref int) (float64, float64, error) {
expr := cdpHelpersJS + fmt.Sprintf(`(function(){var el=ajByRef(%d);if(!el){return null;}el.scrollIntoView({block:'center',inline:'center'});var r=el.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`, ref)
val, err := s.eval(expr)
if err != nil {
return 0, 0, err
}
if string(val) == "null" || len(val) == 0 {
return 0, 0, fmt.Errorf("élément [%d] introuvable — refais un browser_snapshot (la page a peut-être changé)", ref)
}
var p struct{ X, Y float64 }
if err := json.Unmarshal(val, &p); err != nil {
return 0, 0, err
}
return p.X, p.Y, nil
}
func (s *cdpSession) clickRef(ref int) error {
x, y, err := s.refCenter(ref)
if err != nil {
return err
}
for _, typ := range []string{"mousePressed", "mouseReleased"} {
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
}); err != nil {
return err
}
}
s.waitLoad()
return nil
}
// clickXY clique à des coordonnées pixel du VIEWPORT (lues sur le screenshot).
// Seul moyen d'atteindre ce que le DOM ne voit pas : un bandeau de consentement
// dans un iframe cross-origin, un canvas, une carte (vécu sur Lady Sushi).
func (s *cdpSession) clickXY(x, y float64) error {
for _, typ := range []string{"mousePressed", "mouseReleased"} {
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
}); err != nil {
return err
}
}
s.waitLoad()
return nil
}
func (s *cdpSession) typeText(text string) error {
_, err := s.call("Input.insertText", map[string]any{"text": text})
return err
}
// typeInto écrit dans le champ [ref]. Cas spécial des listes déroulantes NATIVES
// (<select>, rôle « combobox ») : leur menu s'ouvre en natif hors du DOM, donc
// ses options ne sont pas cliquables dans le snapshot (galère du test IONOS #14).
// Ici on SÉLECTIONNE directement l'option dont le texte contient la valeur, et on
// émet input/change. Sinon on focus+sélectionne le contenu et on laisse insertText
// remplacer (comportement texte normal).
func (s *cdpSession) typeInto(ref int, text string) error {
vb, _ := json.Marshal(text)
expr := cdpHelpersJS + fmt.Sprintf(`(function(ref,val){
var el=ajByRef(ref);
if(!el)return 'NOTFOUND';
el.scrollIntoView({block:'center'});
if(el.tagName.toLowerCase()==='select'){
var o=el.options,lv=val.toLowerCase(),k=-1;
for(var i=0;i<o.length;i++){var tx=(o[i].text||'').toLowerCase();if(tx===lv||tx.indexOf(lv)>=0||(o[i].value||'').toLowerCase()===lv){k=i;break;}}
if(k<0)return 'NOOPT';
el.selectedIndex=k;el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));
return 'SELECT';
}
el.focus();
try{if(typeof el.select==='function')el.select();else if(window.getSelection){var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}catch(e){}
return 'TEXT';
})(%d,%s)`, ref, string(vb))
val, err := s.eval(expr)
if err != nil {
return err
}
switch strings.Trim(string(val), `"`) {
case "NOTFOUND":
return fmt.Errorf("champ [%d] introuvable — refais un browser_snapshot", ref)
case "NOOPT":
return fmt.Errorf("aucune option de la liste [%d] ne correspond à « %s » — fais un browser_screenshot pour voir les choix", ref, text)
case "SELECT":
return nil // liste déroulante réglée, rien à taper
default: // TEXT
return s.typeText(text)
}
}
// cdpKeys mappe les touches nommées vers leur description CDP.
var cdpKeys = map[string]struct {
key, code string
vk int
}{
"enter": {"Enter", "Enter", 13},
"tab": {"Tab", "Tab", 9},
"backspace": {"Backspace", "Backspace", 8},
"escape": {"Escape", "Escape", 27},
"esc": {"Escape", "Escape", 27},
"delete": {"Delete", "Delete", 46},
"up": {"ArrowUp", "ArrowUp", 38},
"down": {"ArrowDown", "ArrowDown", 40},
"left": {"ArrowLeft", "ArrowLeft", 37},
"right": {"ArrowRight", "ArrowRight", 39},
"home": {"Home", "Home", 36},
"end": {"End", "End", 35},
"pageup": {"PageUp", "PageUp", 33},
"pagedown": {"PageDown", "PageDown", 34},
}
func (s *cdpSession) pressKey(name string) error {
k, ok := cdpKeys[strings.ToLower(strings.TrimSpace(name))]
if !ok {
return fmt.Errorf("touche inconnue « %s » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", name)
}
for _, typ := range []string{"rawKeyDown", "keyUp"} {
if _, err := s.call("Input.dispatchKeyEvent", map[string]any{
"type": typ, "key": k.key, "code": k.code,
"windowsVirtualKeyCode": k.vk, "nativeVirtualKeyCode": k.vk,
}); err != nil {
return err
}
}
s.waitLoad()
return nil
}
func (s *cdpSession) scroll(dir string) error {
dy, dx := 0, 0
switch strings.ToLower(strings.TrimSpace(dir)) {
case "down", "":
dy = 600
case "up":
dy = -600
case "right":
dx = 600
case "left":
dx = -600
default:
return fmt.Errorf("direction inconnue « %s » (up, down, left, right)", dir)
}
_, err := s.eval(fmt.Sprintf("window.scrollBy(%d,%d);'ok'", dx, dy))
if err == nil {
time.Sleep(200 * time.Millisecond)
}
return err
}
// screenshot capture le viewport en PNG (octets décodés).
func (s *cdpSession) screenshot() ([]byte, error) {
res, err := s.call("Page.captureScreenshot", map[string]any{"format": "png"})
if err != nil {
return nil, err
}
var r struct {
Data string `json:"data"`
}
if err := json.Unmarshal(res, &r); err != nil {
return nil, err
}
return base64.StdEncoding.DecodeString(r.Data)
}
+422
View File
@@ -0,0 +1,422 @@
package loki
import (
"os"
"strconv"
"strings"
"testing"
"time"
)
// TestCDPSmoke pilote un vrai Chrome de bout en bout : lancement, navigation,
// snapshot d'accessibilité, click, screenshot. Coûteux et dépendant d'un
// navigateur installé → ne tourne QUE si LOKI_CU_SMOKE=1.
func TestCDPSmoke(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
if err := s.navigate("https://example.com"); err != nil {
t.Fatalf("navigate: %v", err)
}
snap, err := s.snapshot()
if err != nil {
t.Fatalf("snapshot: %v", err)
}
t.Logf("snapshot:\n%s", snap)
if !strings.Contains(strings.ToLower(snap), "example") {
t.Errorf("titre 'example' attendu dans le snapshot, absent")
}
if !strings.Contains(snap, "link") {
t.Errorf("le lien 'More information' devrait apparaître comme [n] link")
}
// browser_find doit retrouver le lien par son texte.
found, err := s.find("more")
if err != nil {
t.Fatalf("find: %v", err)
}
if !strings.Contains(strings.ToLower(found), "link") {
t.Errorf("browser_find('more') devrait trouver le lien, got: %s", found)
}
// Le screenshot doit produire des octets PNG non vides.
png, err := s.screenshot()
if err != nil {
t.Fatalf("screenshot: %v", err)
}
if len(png) < 100 {
t.Errorf("screenshot suspicieusement petit: %d octets", len(png))
}
t.Logf("screenshot: %d octets", len(png))
}
// TestCDPTypeReplacesField vérifie le fix du test IONOS #4 : browser_type(ref) cible le
// BON champ et REMPLACE son contenu, même avec deux champs et une valeur existante.
func TestCDPTypeReplacesField(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
// Deux champs texte injectés dans la page courante ; le 2e contient « ancien ».
if _, err := s.eval(`document.body.innerHTML='<input id=a placeholder=prenom><input id=b value=ancien>';'ok'`); err != nil {
t.Fatalf("inject: %v", err)
}
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
t.Fatalf("snapshot: %v", err)
}
// Retrouve le numéro du 2e champ (celui à remplacer).
val, err := s.eval(`document.querySelector('#b').getAttribute('data-loki-ref')`)
if err != nil {
t.Fatalf("eval ref: %v", err)
}
ref, _ := strconv.Atoi(strings.Trim(string(val), `"`))
if ref == 0 {
t.Fatalf("pas de ref sur #b (snapshot: %s)", val)
}
if err := s.typeInto(ref, "nouveau"); err != nil {
t.Fatalf("typeInto: %v", err)
}
got, _ := s.eval(`document.querySelector('#b').value`)
if v := strings.Trim(string(got), `"`); v != "nouveau" {
t.Errorf("champ #b = %q, attendu \"nouveau\" (remplacement raté)", v)
}
// Le premier champ ne doit PAS avoir été touché.
got2, _ := s.eval(`document.querySelector('#a').value`)
if v := strings.Trim(string(got2), `"`); v != "" {
t.Errorf("champ #a = %q, attendu vide (mauvais ciblage)", v)
}
}
// TestCDPSnapshotHygiene vérifie que le snapshot (1) masque la valeur d'un champ
// mot de passe et (2) ne liste pas les conteneurs non-actionnables (un <ul>/<li>).
func TestCDPSnapshotHygiene(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
inject := `document.body.innerHTML='` +
`<label for=pw>Mot de passe</label><input id=pw type=password value=SECRET123>` +
`<label for=em>Email</label><input id=em type=text value="a@b.co">` +
`<ul><li>bloc conteneur</li></ul>` +
`<a href="#x">un lien</a>';'ok'`
if _, err := s.eval(inject); err != nil {
t.Fatalf("inject: %v", err)
}
snap, err := s.snapshot()
if err != nil {
t.Fatalf("snapshot: %v", err)
}
t.Logf("snapshot:\n%s", snap)
if strings.Contains(snap, "SECRET123") {
t.Error("la valeur du champ mot de passe ne doit PAS apparaître dans le snapshot")
}
if strings.Contains(snap, "bloc conteneur") {
t.Error("un <li> conteneur ne doit pas figurer dans la liste des éléments actionnables")
}
if !strings.Contains(snap, "un lien") {
t.Error("le lien actionnable devrait figurer dans le snapshot")
}
// Le champ mot de passe lui-même reste listé (par son label), juste sans valeur.
if !strings.Contains(strings.ToLower(snap), "mot de passe") {
t.Error("le champ mot de passe devrait apparaître par son label")
}
// Un champ texte non-secret DOIT montrer sa valeur (observabilité du formulaire).
if !strings.Contains(snap, "a@b.co") {
t.Error("la valeur d'un champ texte non-secret devrait apparaître dans le snapshot")
}
// Un <input type=text> doit être annoncé « textbox », pas « text » (qui ressemble
// à du texte statique et faisait croire au modèle que c'était une étiquette).
if !strings.Contains(snap, "textbox") {
t.Error("un champ de saisie devrait avoir le rôle 'textbox'")
}
}
// TestCDPRefUniqueness reproduit le bug du test IONOS #9 : un snapshot doit
// EFFACER les anciens data-loki-ref, sinon deux éléments partagent un numéro et
// browser_type/browser_click visent le mauvais champ (querySelector prend le 1er du DOM).
func TestCDPRefUniqueness(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
if _, err := s.eval(`document.body.innerHTML='<a href=#>A</a><a href=#>B</a><a href=#>C</a>';'ok'`); err != nil {
t.Fatalf("inject: %v", err)
}
if _, err := s.snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
// Simule un attribut périmé laissé par un snapshot antérieur sur un AUTRE élément.
if _, err := s.eval(`document.querySelectorAll('a')[2].setAttribute('data-loki-ref','1');'ok'`); err != nil {
t.Fatalf("stale: %v", err)
}
// Un nouveau snapshot doit repartir propre : chaque numéro sur UN SEUL élément.
if _, err := s.snapshot(); err != nil {
t.Fatalf("snapshot2: %v", err)
}
dup, _ := s.eval(`document.querySelectorAll('[data-loki-ref="1"]').length`)
if strings.TrimSpace(string(dup)) != "1" {
t.Errorf("data-loki-ref=\"1\" devrait être unique, trouvé %s occurrence(s)", dup)
}
}
// TestCDPSettleWaitsAsync vérifie que settle() attend le contenu injecté en
// asynchrone (cas du test #10 où le modèle devait « bash sleep » avant snapshot).
func TestCDPSettleWaitsAsync(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
// Page réelle (réseau réel), puis on déclenche un fetch AJAX qui n'ajoute son
// lien qu'une fois la requête terminée. settle() doit attendre l'inactivité
// réseau et donc voir le lien.
if err := s.navigate("https://example.com"); err != nil {
t.Fatalf("navigate: %v", err)
}
if _, err := s.eval(`fetch(location.href,{cache:'no-store'}).then(function(){var a=document.createElement('a');a.href='#';a.textContent='AjaxDone';document.body.appendChild(a);});'ok'`); err != nil {
t.Fatalf("fetch: %v", err)
}
time.Sleep(60 * time.Millisecond) // laisse requestWillBeSent s'enregistrer
s.settle() // doit attendre la fin du fetch
snap, err := s.snapshot()
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if !strings.Contains(snap, "AjaxDone") {
t.Errorf("settle() aurait dû attendre la fin du fetch AJAX; snapshot:\n%s", snap)
}
}
// TestCDPTypeSelectsOption vérifie le fix du test #14 : browser_type sur un <select>
// choisit l'option par son texte au lieu de taper des caractères.
func TestCDPTypeSelectsOption(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
if _, err := s.eval(`document.body.innerHTML='<select id=civ><option value="">--</option><option>Madame</option><option>Monsieur</option><option>Autre</option></select>';'ok'`); err != nil {
t.Fatalf("inject: %v", err)
}
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
t.Fatalf("snapshot: %v", err)
}
ref, _ := s.eval(`+document.querySelector('#civ').getAttribute('data-loki-ref')`)
n, _ := strconv.Atoi(strings.TrimSpace(string(ref)))
if n == 0 {
t.Fatalf("pas de ref sur le select (%s)", ref)
}
if err := s.typeInto(n, "Monsieur"); err != nil {
t.Fatalf("typeInto select: %v", err)
}
got, _ := s.eval(`document.querySelector('#civ').value`)
if v := strings.Trim(string(got), `"`); v != "Monsieur" {
t.Errorf("le select devrait valoir « Monsieur », got %q", v)
}
}
// TestCDPSnapshotDedup vérifie qu'un état identique renvoie une ligne courte au
// lieu de re-lister tous les éléments (économie de contexte, test #12).
func TestCDPSnapshotDedup(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
if _, err := s.eval(`document.body.innerHTML='<a href=#>Un lien</a>';'ok'`); err != nil {
t.Fatalf("inject: %v", err)
}
first, err := s.snapshotDedup()
if err != nil {
t.Fatalf("snapshotDedup1: %v", err)
}
if !strings.Contains(first, "Un lien") {
t.Fatalf("1er snapshot devrait lister le lien, got: %s", first)
}
second, err := s.snapshotDedup()
if err != nil {
t.Fatalf("snapshotDedup2: %v", err)
}
if strings.Contains(second, "Un lien") || !strings.Contains(second, "inchang") {
t.Errorf("2e snapshot identique devrait être compact (inchangé), got: %s", second)
}
// Après un vrai changement, on redonne la liste complète.
if _, err := s.eval(`document.body.innerHTML+='<a href=#>Autre</a>';'ok'`); err != nil {
t.Fatalf("change: %v", err)
}
third, _ := s.snapshotDedup()
if !strings.Contains(third, "Autre") {
t.Errorf("après changement, le snapshot complet devrait revenir, got: %s", third)
}
}
// TestCDPFindKeepsRefs vérifie le fix du test #11 : browser_find ne doit PAS effacer
// les data-loki-ref du dernier snapshot (sinon un browser_click ensuite échoue).
func TestCDPFindKeepsRefs(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
if _, err := s.eval(`document.body.innerHTML='<a href=#>Alpha</a><a href=#>Beta</a><a href=#>Connexion</a>';'ok'`); err != nil {
t.Fatalf("inject: %v", err)
}
if _, err := s.snapshot(); err != nil {
t.Fatalf("snapshot: %v", err)
}
before, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
// Recherche d'un AUTRE élément : ne doit pas toucher au ref de « Connexion ».
if _, err := s.find("Alpha"); err != nil {
t.Fatalf("find: %v", err)
}
after, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
if string(before) != string(after) || strings.Trim(string(after), `"`) == "" {
t.Errorf("browser_find a modifié/effacé le ref de « Connexion » : avant=%s après=%s", before, after)
}
}
// TestCDPClickXY vérifie le clic par coordonnées (pour iframe cross-origin/canvas).
func TestCDPClickXY(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
// Un bouton à une position connue qui note son propre clic.
inject := `document.body.innerHTML='<button id=b style="position:fixed;left:40px;top:30px;width:120px;height:40px">X</button>';` +
`window.__hit=false;document.getElementById('b').addEventListener('click',function(){window.__hit=true;});'ok'`
if _, err := s.eval(inject); err != nil {
t.Fatalf("inject: %v", err)
}
if err := s.clickXY(100, 50); err != nil { // dans le rectangle du bouton
t.Fatalf("clickXY: %v", err)
}
hit, _ := s.eval(`window.__hit`)
if strings.TrimSpace(string(hit)) != "true" {
t.Errorf("le clic par coordonnées aurait dû atteindre le bouton, got %s", hit)
}
}
// TestCDPShadowDOM vérifie le fix du test Lady Sushi : les éléments d'un shadow
// DOM (bandeaux cookies, web components) sont vus par le snapshot ET cliquables.
func TestCDPShadowDOM(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
// Un host avec un bouton « TOUT REFUSER » DANS un shadow root ouvert.
inject := `document.body.innerHTML='<div id=host></div>';` +
`var sr=document.getElementById('host').attachShadow({mode:'open'});` +
`sr.innerHTML='<button id=b>TOUT REFUSER</button>';'ok'`
if _, err := s.eval(inject); err != nil {
t.Fatalf("inject: %v", err)
}
snap, err := s.snapshot()
if err != nil {
t.Fatalf("snapshot: %v", err)
}
if !strings.Contains(snap, "TOUT REFUSER") {
t.Fatalf("le bouton du shadow DOM devrait apparaître dans le snapshot; got:\n%s", snap)
}
// browser_find doit aussi le trouver, et browser_click le viser (via ajByRef).
found, err := s.find("refuser")
if err != nil || !strings.Contains(strings.ToLower(found), "refuser") {
t.Errorf("find n'a pas trouvé le bouton shadow: %v / %s", err, found)
}
}
// TestCDPNavigateError vérifie qu'une URL injoignable renvoie une ERREUR claire
// (et pas un faux snapshot avec un bouton « Actualiser » que le modèle re-tente).
func TestCDPNavigateError(t *testing.T) {
if os.Getenv("LOKI_CU_SMOKE") != "1" {
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
}
if chromePath() == "" {
t.Skip("aucun navigateur détecté")
}
s, err := cdpGet()
if err != nil {
t.Fatalf("cdpGet: %v", err)
}
defer cdpShutdown()
err = s.navigate("https://nonexistent-host-loki-test-99999.invalid")
if err == nil {
t.Error("une URL injoignable devrait renvoyer une erreur, pas nil")
} else {
t.Logf("erreur attendue: %v", err)
}
}
+22
View File
@@ -0,0 +1,22 @@
package loki
import "testing"
// TestDedupableTool verrouille le fix du test IONOS : les outils à état vivant /
// à image (cu_*, see_image, bash) ne doivent JAMAIS être dédupliqués, sinon un
// second appel identique renvoie « [déjà fait] » sans rejouer l'action ni
// l'image, et l'IA boucle.
func TestDedupableTool(t *testing.T) {
never := []string{"bash", "see_image", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_key", "browser_scroll", "browser_screenshot"}
for _, n := range never {
if dedupableTool(n) {
t.Errorf("%s ne doit PAS être déduplicable", n)
}
}
always := []string{"write", "edit", "mem_add", "web_open"}
for _, n := range always {
if !dedupableTool(n) {
t.Errorf("%s devrait rester déduplicable", n)
}
}
}
+404
View File
@@ -0,0 +1,404 @@
package loki
// computer_use.go — le « computer use » : donner à Jean la capacité d'agir sur
// une interface graphique de la machine hôte. Étape 1 : le WEB, via un Chromium
// piloté en CDP (computer_cdp.go). Le modèle lit l'arbre d'accessibilité (des
// éléments interactifs NUMÉROTÉS, façon set-of-marks) et agit par numéro —
// aucune vision requise, ce qui marche même avec de petits modèles texte à
// faible contexte. browser_screenshot n'est proposé que si la vision est active, pour
// les cas sans a11y (canvas, jeux).
//
// C'est un axe de capacité indépendant, sous le mode agent (comme l'accès
// internet) : un outil qui pilote un navigateur exécute des actions réelles, donc
// même niveau de confiance que bash. Interrupteur : clé bkState "computer".
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func computerUseEnabled() bool { return getBool(bkState, "computer") }
func setComputerUseEnabled(on bool) error { return putBool(bkState, "computer", on) }
// cuMaxOutput borne ce qu'un appel d'outil computer use injecte dans le contexte
// (aligné sur le web et le shell : 8000).
const cuMaxOutput = 8000
func capCUOutput(s string) string {
if r := []rune(s); len(r) > cuMaxOutput {
return string(r[:cuMaxOutput]) + "\n…[tronqué]"
}
return s
}
// ─── schémas d'outils (annoncés au modèle) ───────────────────────────────────
func cuOpenTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_open",
Description: "Open a URL in the controlled browser and return the page's interactive elements, each numbered [n]. Start here. Pass 'back' or 'forward' instead of a URL to move through history.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"url": map[string]any{"type": "string", "description": "URL to open, or 'back' / 'forward'"}},
"required": []string{"url"},
},
}}
}
func cuSnapshotTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_snapshot",
Description: "Re-read the current page's interactive elements (numbered). Use it after the page changes to get fresh [n] refs.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
}}
}
func cuClickTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_click",
Description: "Click the element with the given number from the latest snapshot. Returns the page state afterwards.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"ref": map[string]any{"type": "integer", "description": "Element number [n]"}},
"required": []string{"ref"},
},
}}
}
func cuTypeTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_type",
Description: "Type text into a field. ALWAYS give 'ref' (the field's number) so it targets the right field and REPLACES its current content — no need to browser_click the field first. For a dropdown/select (role 'combobox'), pass the option's text and it picks that option directly (don't click it open). Without a ref it types into whatever is focused, which is unreliable.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"text": map[string]any{"type": "string", "description": "Text to type"},
"ref": map[string]any{"type": "integer", "description": "Optional: field number [n] to focus first"},
},
"required": []string{"text"},
},
}}
}
func cuKeyTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_key",
Description: "Press a special key: enter, tab, backspace, escape, delete, up, down, left, right, home, end, pageup, pagedown.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"key": map[string]any{"type": "string", "description": "Key name"}},
"required": []string{"key"},
},
}}
}
func cuFindTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_find",
Description: "Find interactive elements ANYWHERE on the page (not just the visible part) whose label contains the text, and scroll the first into view. Use it to locate an off-screen link or button (e.g. 'créer un compte', 'panier') instead of scrolling blindly or guessing URLs.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"text": map[string]any{"type": "string", "description": "Text to look for in element labels"}},
"required": []string{"text"},
},
}}
}
func cuScrollTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_scroll",
Description: "Scroll the page (up, down, left, right), then return the now-visible interactive elements.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{"direction": map[string]any{"type": "string", "description": "up, down, left or right"}},
"required": []string{"direction"},
},
}}
}
func cuClickXYTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_click_xy",
Description: fmt.Sprintf("Click at pixel coordinates (x from the left, y from the top) read off the latest browser_screenshot — %dx%d pixels, 1:1 with the page, with a coordinate grid drawn every 100px to read x/y precisely. Use it ONLY for something the numbered elements can't reach: a cookie/consent banner inside a cross-origin iframe, a canvas, a map. Otherwise always prefer browser_click(ref).", cuViewW, cuViewH),
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"x": map[string]any{"type": "number", "description": "pixels from the left edge"},
"y": map[string]any{"type": "number", "description": "pixels from the top edge"},
},
"required": []string{"x", "y"},
},
}}
}
func cuScreenshotTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "browser_screenshot",
Description: "Capture the current page as an image. It is shown to the user immediately AND saved to your working folder; the result gives the filename to link with Markdown if they want the file. Use it to see a visual layout the numbered elements don't convey, or when the user asks for a screenshot — never hunt the disk for it.",
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
}}
}
// computerUseTools est la liste annoncée au modèle quand le computer use est
// actif. browser_screenshot n'y figure que si la vision est réellement active.
func computerUseTools() []Tool {
tools := []Tool{cuOpenTool(), cuSnapshotTool(), cuFindTool(), cuClickTool(), cuTypeTool(), cuKeyTool(), cuScrollTool()}
// Vision active : la capture d'écran ET le clic par coordonnées (qui n'a de sens
// que si le modèle peut lire l'image pour viser).
if visionEnabled() {
tools = append(tools, cuScreenshotTool(), cuClickXYTool())
}
return tools
}
// cuPromptLine est la consigne d'usage ajoutée au prompt système quand le
// computer use est actif (l'ordre d'appel que les schémas isolés ne disent pas).
func cuPromptLine() string {
line := "\nComputer use (a browser you control on this machine): browser_open a URL, then act on the NUMBERED elements — browser_click(ref), browser_type(text, ref), browser_key, browser_scroll. Each action returns the fresh numbered elements; after the page changes, trust the new numbers, not old ones. The list shows only what's in view: to reach an off-screen link or button (e.g. 'créer un compte'), use browser_find(text) to jump straight to it rather than scrolling blindly or, worse, guessing URLs — inventing paths mostly 404s. If a page shows an error (404, 'introuvable'), browser_open goes 'back' or try another link. Prefer the numbered elements over screenshots."
if visionEnabled() {
line += " Use browser_screenshot when the numbers aren't enough (canvas, visual layout). If a button is visible but NOT in the numbered list (e.g. a cookie banner inside an iframe), browser_screenshot then browser_click_xy(x,y) at its pixel position."
}
line += "\n"
return line
}
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
func toolCUOpen(args map[string]any) string {
url, _ := args["url"].(string)
if strings.TrimSpace(url) == "" {
return "[erreur] url manquante"
}
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
if err := s.navigate(url); err != nil {
return "[erreur] navigation : " + err.Error()
}
snap, err := s.snapshotDedup()
if err != nil {
return "[erreur] " + err.Error()
}
return snap
}
func toolCUSnapshot() string {
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
snap, err := s.snapshotDedup()
if err != nil {
return "[erreur] " + err.Error()
}
return snap
}
func toolCUFind(args map[string]any) string {
text, _ := args["text"].(string)
if strings.TrimSpace(text) == "" {
return "[erreur] texte de recherche manquant"
}
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
res, err := s.find(text)
if err != nil {
return "[erreur] " + err.Error()
}
return res
}
func toolCUClick(args map[string]any) string {
ref, ok := intArg(args, "ref")
if !ok {
return "[erreur] ref manquant (numéro de l'élément)"
}
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
if err := s.clickRef(ref); err != nil {
return "[erreur] " + err.Error()
}
snap, _ := s.snapshotDedup()
return fmt.Sprintf("[ok] cliqué [%d]\n\n%s", ref, snap)
}
func toolCUType(args map[string]any) string {
text, _ := args["text"].(string)
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
if ref, ok := intArg(args, "ref"); ok {
if err := s.typeInto(ref, text); err != nil {
return "[erreur] " + err.Error()
}
} else if err := s.typeText(text); err != nil {
return "[erreur] " + err.Error()
}
snap, _ := s.snapshotDedup()
return fmt.Sprintf("[ok] saisi « %s »\n\n%s", text, snap)
}
func toolCUKey(args map[string]any) string {
key, _ := args["key"].(string)
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
if err := s.pressKey(key); err != nil {
return "[erreur] " + err.Error()
}
snap, _ := s.snapshotDedup()
return fmt.Sprintf("[ok] touche %s\n\n%s", key, snap)
}
func toolCUClickXY(args map[string]any) string {
x, okx := floatArg(args, "x")
y, oky := floatArg(args, "y")
if !okx || !oky {
return "[erreur] coordonnées x et y requises (en pixels du screenshot)"
}
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
if err := s.clickXY(x, y); err != nil {
return "[erreur] " + err.Error()
}
snap, _ := s.snapshotDedup()
return fmt.Sprintf("[ok] clic en (%.0f, %.0f)\n\n%s", x, y, snap)
}
func toolCUScroll(args map[string]any) string {
dir, _ := args["direction"].(string)
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
}
if err := s.scroll(dir); err != nil {
return "[erreur] " + err.Error()
}
snap, _ := s.snapshotDedup()
return snap
}
// toolCUScreenshot renvoie (accusé texte, partie image_url) comme see_image :
// l'image est réinjectée dans un message utilisateur multimodal par l'appelant.
//
// La capture PLEINE RÉSOLUTION est aussi ENREGISTRÉE dans le dossier de travail :
// sans ça, le modèle qui reçoit « envoie-moi une capture » partait chercher un
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
func toolCUScreenshot() (string, map[string]any) {
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
}
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error(), nil
}
png, err := s.screenshot()
if err != nil {
return "[erreur] " + err.Error(), nil
}
// Enregistre le PNG plein format dans le workspace (chemin RELATIF renvoyé au
// modèle : un lien Markdown [capture](nom.png) est servi par /api/chat/file).
saved := ""
name := "capture-" + time.Now().Format("20060102-150405") + ".png"
if ws := agentWorkspace(); ws != "" {
if err := os.WriteFile(filepath.Join(ws, name), png, 0o644); err == nil {
saved = name
}
}
// L'image du MODÈLE porte une GRILLE de coordonnées (lignes graduées tous les
// 100 px) pour qu'il lise x/y avec précision ; celle sauvegardée pour l'utilisateur
// (plus haut, `png`) reste PROPRE, sans grille.
b, mime := prepareImageForModel(overlayGrid(png), "image/png")
// ⚠️ TOI (le modèle) vois l'image ci-dessous, mais l'utilisateur NE la voit PAS
// automatiquement dans le chat. Pour la lui afficher, il faut l'insérer en IMAGE
// Markdown ![...](fichier) — un simple lien [texte](fichier) ne fait qu'un bouton
// de téléchargement, pas d'aperçu.
msg := fmt.Sprintf("[ok] capture prise (tu la vois ci-dessous, %dx%d px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille.", cuViewW, cuViewH)
if saved != "" {
msg += " Enregistrée sous « " + saved + " ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : ![capture](" + saved + "). Un simple lien [capture](" + saved + ") ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci."
}
return msg, imageURLPart(b, mime)
}
// intArg extrait un entier d'un argument d'outil (le JSON décode les nombres en
// float64).
func intArg(args map[string]any, key string) (int, bool) {
switch v := args[key].(type) {
case float64:
return int(v), true
case int:
return v, true
}
return 0, false
}
// floatArg extrait un nombre d'un argument d'outil (coordonnées de clic).
func floatArg(args map[string]any, key string) (float64, bool) {
switch v := args[key].(type) {
case float64:
return v, true
case int:
return float64(v), true
}
return 0, false
}
// ─── CLI : loki computer [on|off|status] ────────────────────────────────────
func cmdComputer(args []string) error {
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "on":
if err := setComputerUseEnabled(true); err != nil {
return err
}
msg := " contrôle du navigateur activé — l'IA pilote un navigateur (browser_open/browser_click/browser_type/…) si le mode agent est actif"
if chromePath() == "" {
msg += "\n" + red("[!]") + " aucun Chrome/Chromium/Edge détecté — installe-le ou définis LOKI_CHROME=<chemin>"
}
fmt.Println(green("[ok]") + msg)
case "off":
if err := setComputerUseEnabled(false); err != nil {
return err
}
cdpShutdown()
fmt.Println(green("[ok]") + " contrôle du navigateur désactivé")
case "", "status":
state := dim("off")
if computerUseEnabled() {
state = green("on")
}
fmt.Printf("%s état: %s\n", cyan("Contrôle du navigateur"), state)
bin := chromePath()
if bin == "" {
fmt.Printf(" navigateur : %s — installe Chrome/Chromium/Edge ou LOKI_CHROME=<chemin>\n", red("introuvable"))
} else {
fmt.Printf(" navigateur : %s\n", bold(bin))
}
fmt.Printf(" outils : browser_open, browser_snapshot, browser_find, browser_click, browser_type, browser_key, browser_scroll")
if visionEnabled() {
fmt.Printf(", browser_screenshot, browser_click_xy")
}
fmt.Println()
default:
return fmt.Errorf("usage: loki computer [on|off|status]")
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
// Réglages de la dictée vocale — modèle et réactivité.
//
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
// est une propriété de la machine, identique pour tous les onglets ouverts.
package loki
import (
"fmt"
"time"
)
const dictateCfgKey = "dictate_cfg"
// DictateCfg : ce qui reste réglable depuis le passage à Parakeet.
//
// Deux champs ont disparu, et c'est le moteur qui l'impose, pas un choix de
// simplification :
// - la LANGUE : Parakeet v3 détecte la langue lui-même et n'a aucun drapeau
// pour la forcer. Le réglage n'aurait servi qu'à mentir.
// - le GPU : le binaire sherpa-onnx livré dans l'image est le build statique
// CPU. Annoncer un sélecteur de carte sans pouvoir l'honorer serait pire que
// de ne rien annoncer. Un modèle 0,6 B en int8 sur des tranches de quelques
// secondes n'a pas besoin de la carte, qui reste au moteur de chat.
type DictateCfg struct {
Model string `json:"model"`
Reactivity string `json:"reactivity"`
}
// withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à
// l'écriture : une version future qui ajoute un champ trouvera les réglages
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler. C'est
// aussi ce qui rattrape les réglages enregistrés du temps de whisper, dont le
// Model ne veut plus rien dire.
func (c DictateCfg) withDefauts() DictateCfg {
if _, connu := asrCatalogue[c.Model]; !connu {
// v3 : le seul des deux qui parle français.
c.Model = "parakeet-tdt-0.6b-v3"
}
if c.Reactivity == "" {
c.Reactivity = "moyen"
}
return c
}
func dictateCfgLoad() DictateCfg {
var c DictateCfg
getJSON(bkState, dictateCfgKey, &c)
return c.withDefauts()
}
func dictateCfgSave(c DictateCfg) error {
// Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue
// accepté en silence ne se manifesterait qu'au premier clic sur le micro,
// loin du geste qui l'a causé. On valide AVANT withDefauts, qui corrigerait
// justement la faute qu'on veut signaler.
if _, ok := asrCatalogue[c.Model]; !ok {
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return putJSON(bkState, dictateCfgKey, c.withDefauts())
}
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
switch c.Reactivity {
case "court":
return 1000 * time.Millisecond, 5 * time.Second
case "long":
return 3 * time.Second, 15 * time.Second
default:
return 1500 * time.Millisecond, 8 * time.Second
}
}
+78
View File
@@ -0,0 +1,78 @@
package loki
import (
"testing"
)
// Les défauts sont appliqués à la LECTURE : un réglage enregistré par une
// version antérieure, sans le champ, doit être comblé plutôt que de produire un
// modèle vide passé en argument au serveur.
func TestDefautsCombles(t *testing.T) {
testHome(t)
c := DictateCfg{}.withDefauts()
if _, ok := asrCatalogue[c.Model]; !ok {
t.Errorf("modèle par défaut %q hors catalogue", c.Model)
}
if c.Reactivity == "" {
t.Error("réactivité par défaut vide")
}
}
// Le passage de whisper à Parakeet laisse des réglages enregistrés dont le
// Model ne veut plus rien dire (« large-v3-turbo-q5_0 »). Ils doivent retomber
// sur le défaut, sans quoi la dictée serait morte après mise à jour, avec pour
// seul indice « modèle de dictée inconnu » au premier clic sur le micro.
func TestModeleWhisperHeriteRetombeSurLeDefaut(t *testing.T) {
testHome(t)
c := DictateCfg{Model: "large-v3-turbo-q5_0", Reactivity: "court"}.withDefauts()
if _, ok := asrCatalogue[c.Model]; !ok {
t.Fatalf("modèle hérité %q non corrigé", c.Model)
}
if c.Reactivity != "court" {
t.Errorf("réactivité = %q, elle devait être conservée", c.Reactivity)
}
}
// Valider à l'ÉCRITURE : un modèle hors catalogue accepté en silence ne se
// manifesterait qu'au premier clic sur le micro, loin du geste qui l'a causé.
func TestSaveRefuseModeleInconnu(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "nawak", Reactivity: "moyen"}); err == nil {
t.Error("un modèle hors catalogue a été accepté")
}
}
func TestSaveLoadAllerRetour(t *testing.T) {
testHome(t)
want := DictateCfg{Model: "parakeet-tdt-0.6b-v2", Reactivity: "long"}
if err := dictateCfgSave(want); err != nil {
t.Fatal(err)
}
if got := dictateCfgLoad(); got != want {
t.Errorf("relu %+v, attendu %+v", got, want)
}
}
// Les bornes de découpage pilotent la latence perçue : plus la réserve est
// courte, plus le texte arrive vite, au prix d'un contexte plus maigre pour le
// modèle. L'ordre entre les trois réglages est ce qui doit tenir.
func TestChunkBounds(t *testing.T) {
court, _ := DictateCfg{Reactivity: "court"}.chunkBounds()
moyen, _ := DictateCfg{Reactivity: "moyen"}.chunkBounds()
long, longMax := DictateCfg{Reactivity: "long"}.chunkBounds()
if !(court < moyen && moyen < long) {
t.Errorf("réserves non croissantes : court=%v moyen=%v long=%v", court, moyen, long)
}
if longMax <= long {
t.Error("la coupure forcée doit être plus grande que la réserve minimale")
}
// Une valeur inconnue doit retomber sur le réglage moyen, pas sur zéro : une
// réserve nulle couperait à chaque échantillon.
inconnu, _ := DictateCfg{Reactivity: "nawak"}.chunkBounds()
if inconnu != moyen {
t.Errorf("réactivité inconnue = %v, attendu le réglage moyen %v", inconnu, moyen)
}
if inconnu <= 0 {
t.Error("réserve nulle : la dictée couperait à chaque échantillon")
}
}
+222
View File
@@ -0,0 +1,222 @@
// Catalogue des modèles de dictée — Parakeet (NVIDIA), servi par sherpa-onnx.
//
// Aucun n'est embarqué dans l'image : ils pèsent près de 500 Mo compressés et
// vivent dans <LOKI_HOME>/asr/, donc dans le volume /data — ils survivent aux
// recréations du conteneur.
//
// Différence de forme avec whisper.cpp, qu'ils remplacent : un modèle n'est plus
// UN fichier .bin mais un DOSSIER (encodeur, décodeur, joiner, table de jetons),
// livré en .tar.bz2. Le téléchargement décompresse donc au passage — d'où
// asrExtract plutôt qu'une simple copie de flux.
package loki
import (
"archive/tar"
"compress/bzip2"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// Les archives sherpa-onnx officielles (conversions ONNX des modèles NeMo).
const asrModelBase = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
type asrModel struct {
Nom string // libellé affiché
Dossier string // nom du dossier, identique en local et dans l'archive
Octets int64 // taille du TÉLÉCHARGEMENT (archive compressée), pour prévenir
Langues string // ce que le modèle sait transcrire — c'est LE critère de choix
}
// asrFichiers : les quatre fichiers qu'un modèle doit avoir pour être utilisable.
// Sert au contrôle de présence : un dossier à moitié extrait (téléchargement
// interrompu) ne doit pas passer pour un modèle installé.
var asrFichiers = []string{"encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"}
// Les tailles sont celles publiées par le dépôt amont. Elles servent à informer
// avant un téléchargement, pas à vérifier le fichier : une taille qui dérive
// d'une release à l'autre ne doit pas casser la dictée.
var asrCatalogue = map[string]asrModel{
"parakeet-tdt-0.6b-v3": {
Nom: "Parakeet v3 — recommandé",
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
Octets: 487_170_055,
Langues: "25 langues européennes, français inclus — détection automatique",
},
"parakeet-tdt-0.6b-v2": {
Nom: "Parakeet v2 — anglais seul",
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8",
Octets: 482_468_385,
Langues: "anglais uniquement",
},
}
// asrRoot : la racine des modèles de dictée. Volontairement distincte de
// l'ancien dossier `whisper/` : les .bin ggml qui y dorment ne servent plus à
// rien, mais les effacer tout seuls serait détruire des données que
// l'utilisateur n'a pas demandé de perdre. Ils sont à supprimer à la main.
func asrRoot() string { return filepath.Join(LokiHome(), "asr") }
// asrModelDirFor : dossier local d'un modèle. Vide si l'identifiant est inconnu —
// l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne
// arbitraire venue d'une requête HTTP.
func asrModelDirFor(id string) string {
m, ok := asrCatalogue[id]
if !ok {
return ""
}
return filepath.Join(asrRoot(), m.Dossier)
}
func asrModelURLFor(id string) string {
m, ok := asrCatalogue[id]
if !ok {
return ""
}
return asrModelBase + m.Dossier + ".tar.bz2"
}
// asrModelPresent : le modèle est installé ET complet. On vérifie les quatre
// fichiers plutôt que l'existence du dossier : une extraction coupée en deux
// laisse un dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur
// tomberait au premier clic sur le micro plutôt qu'ici.
func asrModelPresent(id string) bool {
dir := asrModelDirFor(id)
if dir == "" {
return false
}
for _, f := range asrFichiers {
st, err := os.Stat(filepath.Join(dir, f))
if err != nil || st.Size() == 0 {
return false
}
}
return true
}
// asrModelFile : chemin d'un des fichiers du modèle, pour les arguments passés
// au serveur. Vide si le modèle est inconnu.
func asrModelFile(id, fichier string) string {
dir := asrModelDirFor(id)
if dir == "" {
return ""
}
return filepath.Join(dir, fichier)
}
// asrCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
// l'ordre dans lequel se pose la question du compromis.
func asrCatalogueTrie() []map[string]any {
out := make([]map[string]any, 0, len(asrCatalogue))
for id, m := range asrCatalogue {
out = append(out, map[string]any{
"id": id, "nom": m.Nom, "octets": m.Octets,
"langues": m.Langues, "present": asrModelPresent(id),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
})
return out
}
// asrExtract décompresse une archive .tar.bz2 sous `dest`, en ne gardant que le
// contenu du dossier de premier niveau (l'archive amont l'embarque déjà).
//
// Deux précautions qui ne sont pas du zèle :
// - un chemin d'entrée qui remonte (« ../ ») ou absolu est REFUSÉ : une archive
// téléchargée est une entrée non fiable, et une entrée piégée écrirait
// n'importe où sur le disque du serveur ;
// - on extrait dans un dossier temporaire renommé à la fin, pour qu'une
// extraction interrompue ne laisse jamais un modèle à moitié installé sous
// son nom définitif.
func asrExtract(r io.Reader, dest string) error {
tmp := dest + ".part"
if err := os.RemoveAll(tmp); err != nil {
return err
}
if err := os.MkdirAll(tmp, 0o755); err != nil {
return err
}
tr := tar.NewReader(bzip2.NewReader(r))
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
_ = os.RemoveAll(tmp)
return err
}
rel, err := asrSafeRel(h.Name)
if err != nil {
_ = os.RemoveAll(tmp)
return err
}
if rel == "" {
continue // le dossier racine de l'archive lui-même
}
cible := filepath.Join(tmp, rel)
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(cible, 0o755); err != nil {
_ = os.RemoveAll(tmp)
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(cible), 0o755); err != nil {
_ = os.RemoveAll(tmp)
return err
}
f, err := os.Create(cible)
if err != nil {
_ = os.RemoveAll(tmp)
return err
}
// #nosec G110 — l'archive vient d'une release GitHub épinglée par son
// nom, pas d'une source arbitraire ; et la borne utile ici est le
// disque, pas un plafond deviné.
if _, err := io.Copy(f, tr); err != nil {
_ = f.Close()
_ = os.RemoveAll(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.RemoveAll(tmp)
return err
}
default:
// Liens, périphériques, FIFO : une archive de modèle n'en a aucun
// besoin, et les suivre serait un autre moyen d'écrire hors du dossier.
continue
}
}
if err := os.RemoveAll(dest); err != nil {
_ = os.RemoveAll(tmp)
return err
}
return os.Rename(tmp, dest)
}
// asrSafeRel retire le dossier de premier niveau d'un chemin d'archive et refuse
// tout ce qui sortirait du dossier de destination. Renvoie "" pour l'entrée du
// dossier racine elle-même.
func asrSafeRel(name string) (string, error) {
n := strings.ReplaceAll(strings.TrimSpace(name), `\`, "/")
if n == "" || strings.HasPrefix(n, "/") || filepath.IsAbs(n) {
return "", fmt.Errorf("entrée d'archive au chemin absolu : %q", name)
}
parts := strings.Split(strings.Trim(n, "/"), "/")
for _, p := range parts {
if p == ".." {
return "", fmt.Errorf("entrée d'archive qui remonte hors du dossier : %q", name)
}
}
if len(parts) <= 1 {
return "", nil // le dossier racine de l'archive
}
return filepath.Join(parts[1:]...), nil
}
+158
View File
@@ -0,0 +1,158 @@
package loki
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// Le catalogue nourrit une liste déroulante ET des chemins de fichiers : une
// entrée bancale ne se verrait qu'au premier clic sur le micro.
func TestCatalogueCoherent(t *testing.T) {
for id, m := range asrCatalogue {
if m.Nom == "" || m.Dossier == "" || m.Langues == "" {
t.Errorf("%s : entrée incomplète %+v", id, m)
}
if m.Octets <= 0 {
t.Errorf("%s : taille annoncée %d — la confirmation de téléchargement en a besoin", id, m.Octets)
}
if !strings.HasPrefix(asrModelURLFor(id), "https://") {
t.Errorf("%s : URL non https (%s)", id, asrModelURLFor(id))
}
}
// Le défaut doit exister, et être celui qui parle français.
def := DictateCfg{}.withDefauts().Model
if _, ok := asrCatalogue[def]; !ok {
t.Fatalf("le modèle par défaut %q n'est pas au catalogue", def)
}
if !strings.Contains(asrCatalogue[def].Langues, "français") {
t.Errorf("le modèle par défaut (%s) ne parle pas français : %q", def, asrCatalogue[def].Langues)
}
}
// Un identifiant hors catalogue ne doit JAMAIS produire de chemin : il vient
// d'une requête HTTP, et bâtir un chemin dessus ouvrirait le disque.
func TestCheminVideSiInconnu(t *testing.T) {
testHome(t)
if p := asrModelDirFor("../../etc"); p != "" {
t.Errorf("chemin bâti sur un identifiant inconnu : %q", p)
}
if u := asrModelURLFor("nawak"); u != "" {
t.Errorf("URL bâtie sur un identifiant inconnu : %q", u)
}
if f := asrModelFile("nawak", "encoder.int8.onnx"); f != "" {
t.Errorf("chemin de fichier bâti sur un identifiant inconnu : %q", f)
}
}
// Un modèle n'est « présent » que COMPLET : une extraction coupée laisse un
// dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur tomberait
// au premier clic sur le micro plutôt qu'ici.
func TestPresentExigeTousLesFichiers(t *testing.T) {
testHome(t)
const id = "parakeet-tdt-0.6b-v3"
dir := asrModelDirFor(id)
if asrModelPresent(id) {
t.Fatal("modèle annoncé présent alors que rien n'est installé")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for i, f := range asrFichiers {
if asrModelPresent(id) {
t.Fatalf("modèle annoncé présent avec seulement %d fichier(s) sur %d", i, len(asrFichiers))
}
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if !asrModelPresent(id) {
t.Error("modèle complet non reconnu")
}
// Un fichier vide (disque plein en fin d'extraction) ne compte pas.
if err := os.WriteFile(filepath.Join(dir, asrFichiers[0]), nil, 0o644); err != nil {
t.Fatal(err)
}
if asrModelPresent(id) {
t.Error("un fichier vide passe pour un modèle installé")
}
}
func TestCatalogueTriParTaille(t *testing.T) {
testHome(t)
l := asrCatalogueTrie()
if len(l) != len(asrCatalogue) {
t.Fatalf("%d entrées, attendu %d", len(l), len(asrCatalogue))
}
for i := 1; i < len(l); i++ {
if l[i-1]["octets"].(int64) > l[i]["octets"].(int64) {
t.Error("catalogue non trié du plus léger au plus lourd")
}
}
for _, e := range l {
for _, clef := range []string{"id", "nom", "octets", "langues", "present"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
}
}
}
}
// ─── Extraction d'archive ────────────────────────────────────────────────────
// asrSafeRel est la seule chose entre une archive téléchargée et le disque du
// serveur. Une entrée qui remonte ou qui est absolue doit être REFUSÉE, pas
// nettoyée en silence.
func TestSafeRelRefuseLesEvasions(t *testing.T) {
for _, mauvais := range []string{
"/etc/passwd",
"modele/../../etc/passwd",
"../dehors.txt",
`modele\..\..\dehors.txt`,
} {
if _, err := asrSafeRel(mauvais); err == nil {
t.Errorf("entrée d'archive acceptée alors qu'elle sort du dossier : %q", mauvais)
}
}
// Le dossier de premier niveau est retiré, le reste conservé.
got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/encoder.int8.onnx")
if err != nil {
t.Fatal(err)
}
if got != "encoder.int8.onnx" {
t.Errorf("chemin = %q, le dossier racine de l'archive doit être retiré", got)
}
// L'entrée du dossier racine lui-même ne produit rien.
if got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/"); err != nil || got != "" {
t.Errorf("racine de l'archive : (%q, %v), attendu (\"\", nil)", got, err)
}
}
// Une extraction ratée ne doit jamais laisser un modèle à moitié installé sous
// son nom définitif : asrModelPresent le croirait bon et le serveur mourrait au
// démarrage. Elle ne doit pas non plus détruire un modèle déjà en place — le
// remplacement n'a lieu qu'une fois l'extraction terminée.
func TestExtractNeLaissePasDeDossierPartiel(t *testing.T) {
testHome(t)
dest := filepath.Join(t.TempDir(), "modele")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
temoin := filepath.Join(dest, "deja-la.txt")
if err := os.WriteFile(temoin, []byte("modèle précédent"), 0o644); err != nil {
t.Fatal(err)
}
// Un flux qui n'est pas du bzip2 : la lecture échoue dès la première entrée.
if err := asrExtract(bytes.NewReader([]byte("ceci n'est pas une archive bzip2")), dest); err == nil {
t.Fatal("un flux illisible doit produire une erreur")
}
if _, err := os.Stat(dest + ".part"); err == nil {
t.Error("le dossier temporaire n'a pas été nettoyé après l'échec")
}
if _, err := os.Stat(temoin); err != nil {
t.Error("le modèle précédent a été détruit par une extraction qui a échoué")
}
}
+105
View File
@@ -0,0 +1,105 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func TestConfigRouteAllerRetour(t *testing.T) {
testHome(t)
corps := strings.NewReader(`{"model":"parakeet-tdt-0.6b-v2","reactivity":"court"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code != 200 {
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
var got DictateCfg
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
if got.Model != "parakeet-tdt-0.6b-v2" || got.Reactivity != "court" {
t.Errorf("relu %+v, attendu le réglage enregistré", got)
}
}
// Le refus doit venir de la ROUTE, pas seulement du magasin : sinon l'UI reçoit
// un 200 et croit son réglage pris en compte.
func TestConfigRouteRefuseModeleInconnu(t *testing.T) {
testHome(t)
corps := strings.NewReader(`{"model":"nawak","reactivity":"moyen"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code == 200 {
t.Errorf("modèle hors catalogue accepté (%d) : %s", rec.Code, rec.Body.String())
}
}
func TestConfigRouteCorpsIllisible(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", strings.NewReader("pas du json")))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400", rec.Code)
}
}
func TestModelsRoute(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
if rec.Code != 200 {
t.Fatalf("GET = %d", rec.Code)
}
var l []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
if len(l) < 2 {
t.Errorf("%d modèles renvoyés, au moins 2 attendus", len(l))
}
}
// Un identifiant inconnu ne doit pas lancer de téléchargement : asrDownload
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
func TestDownloadRouteRefuseInconnu(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
corps := strings.NewReader(`{"id":"nawak"}`)
handleDictateDownload(rec, httptest.NewRequest("POST", "/api/dictate/models/download", corps))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400 pour un modèle inconnu", rec.Code)
}
}
func TestStateRoute(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateState(rec, httptest.NewRequest("GET", "/api/dictate/state", nil))
if rec.Code != 200 {
t.Fatalf("GET = %d", rec.Code)
}
var e map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
for _, clef := range []string{"actif", "modele", "present", "device", "dl"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante dans l'état", clef)
}
}
}
// Un audio trop court est rejeté AVANT toute tentative de lancer le serveur :
// inutile de charger un modèle pour un clic parasite.
func TestTranscribeAudioTropCourt(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleTranscribe(rec, httptest.NewRequest("POST", "/api/transcribe", strings.NewReader("court")))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400", rec.Code)
}
}
+455
View File
@@ -0,0 +1,455 @@
// Supervision du serveur de transcription : le modèle est chargé UNE fois et
// reste en mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
//
// Le moteur est sherpa-onnx (Parakeet, NVIDIA), qui a remplacé whisper.cpp. Ce
// qui n'a PAS changé : un processus local supervisé, arrêté après un temps
// d'inactivité, redémarré quand les réglages bougent. Ce qui a changé : le
// dialogue. whisper-server exposait du HTTP multipart ; sherpa-onnx n'expose
// qu'un WebSocket, dont le protocole tient en deux entiers et des flottants
// (voir asrInferSur).
package loki
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// asrServerBin : le binaire du serveur de transcription. Un seul, contrairement
// aux deux binaires whisper d'avant (CPU et CUDA) : le build sherpa-onnx livré
// est le statique CPU, il n'a donc aucune dépendance CUDA à satisfaire et
// démarre sur n'importe quelle base d'image.
func asrServerBin() string {
for _, p := range []string{os.Getenv("LOKI_ASR_SERVER"), "/usr/local/bin/sherpa-onnx-offline-websocket-server"} {
if p == "" {
continue
}
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("sherpa-onnx-offline-websocket-server"); err == nil {
return p
}
return ""
}
// asrServerArgs. Les arguments de sherpa-onnx s'écrivent --clef=valeur : la
// forme séparée par une espace n'est PAS reconnue et le serveur démarre alors
// sans modèle.
func asrServerArgs(c DictateCfg, port int) ([]string, error) {
if asrModelDirFor(c.Model) == "" {
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return []string{
"--encoder=" + asrModelFile(c.Model, "encoder.int8.onnx"),
"--decoder=" + asrModelFile(c.Model, "decoder.int8.onnx"),
"--joiner=" + asrModelFile(c.Model, "joiner.int8.onnx"),
"--tokens=" + asrModelFile(c.Model, "tokens.txt"),
// nemo_transducer : sans ce type, les sorties du modèle sont mal
// interprétées et la transcription est du bruit, pas une erreur.
"--model-type=nemo_transducer",
"--port=" + strconv.Itoa(port),
// Le serveur n'a AUCUNE authentification. Il n'écoute donc que la boucle
// locale : sur un conteneur au réseau partagé, l'exposer offrirait la
// transcription à tout le réseau.
"--host=127.0.0.1",
}, nil
}
// asrServerEnv masque tous les GPU, en REMPLAÇANT toute valeur déjà présente.
// Une variable dupliquée laisse le gagnant dépendre de la libc. Le binaire livré
// est CPU de toute façon ; la variable est là pour que ça reste vrai si l'image
// venait à embarquer un build GPU sans qu'on repense à la dictée.
func asrServerEnv(base []string) []string {
const clef = "CUDA_VISIBLE_DEVICES="
out := make([]string, 0, len(base)+1)
for _, v := range base {
if !strings.HasPrefix(v, clef) {
out = append(out, v)
}
}
return append(out, clef)
}
// portLibre demande au système un port disponible puis le relâche. Coder un
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
// la fermeture et la reprise par le serveur est négligeable devant le risque
// d'un conflit permanent.
func portLibre() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
// asrMarqueur reconnaît ce qui n'est pas de la parole dictée : le vide, et les
// annotations entre crochets ou parenthèses. Parakeet en produit beaucoup moins
// que whisper, mais le filtre ne coûte rien et un « (silence) » collé dans le
// champ de saisie se remarque tout de suite.
func asrMarqueur(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
}
// ─── Protocole ───────────────────────────────────────────────────────────────
// asrEnteteOctets : l'en-tête du serveur sherpa-onnx, deux entiers 32 bits en
// petit-boutien — la fréquence d'échantillonnage, puis le NOMBRE D'OCTETS
// d'audio annoncé. Le serveur décode dès qu'il a reçu exactement ce nombre : un
// compte faux, et il attend indéfiniment de quoi finir.
const asrEnteteOctets = 8
// asrTrameMax borne la taille d'une trame WebSocket envoyée. Le serveur
// reconstitue l'utterance à partir du compte annoncé, donc le découpage est
// libre — mais pas la TAILLE : une pile WebSocket refuse par défaut les messages
// au-delà de quelques dizaines de kilooctets et ferme la connexion au lieu de
// répondre. 10 Ko est la valeur qu'emploie le client de référence de
// sherpa-onnx ; on s'aligne dessus plutôt que de parier sur une limite.
const asrTrameMax = 10240
// asrInferSur prend l'adresse du serveur en paramètre : c'est ce qui rend
// l'appel testable sans lancer de processus.
//
// Le protocole, en entier : on ouvre une connexion, on envoie UNE trame binaire
// contenant l'en-tête suivi des échantillons en float32 petit-boutien, et on lit
// UNE trame texte qui porte le résultat en JSON.
func asrInferSur(ctx context.Context, adresse string, wav []byte) (string, error) {
taux, ech, err := wavVersFloat32(wav)
if err != nil {
return "", err
}
if len(ech) == 0 {
return "", nil // rien à transcrire : ce n'est pas une erreur
}
c, _, err := websocket.Dial(ctx, adresse, nil)
if err != nil {
return "", fmt.Errorf("connexion au serveur de dictée : %w", err)
}
defer func() { _ = c.CloseNow() }()
// Une transcription peut légitimement dépasser la taille par défaut acceptée
// en lecture ; on ne borne pas ce que le serveur nous renvoie.
c.SetReadLimit(-1)
corps := make([]byte, asrEnteteOctets+4*len(ech))
binary.LittleEndian.PutUint32(corps[0:4], uint32(taux))
binary.LittleEndian.PutUint32(corps[4:8], uint32(4*len(ech)))
for i, v := range ech {
binary.LittleEndian.PutUint32(corps[asrEnteteOctets+4*i:], math.Float32bits(v))
}
for off := 0; off < len(corps); off += asrTrameMax {
fin := off + asrTrameMax
if fin > len(corps) {
fin = len(corps)
}
if err := c.Write(ctx, websocket.MessageBinary, corps[off:fin]); err != nil {
return "", fmt.Errorf("envoi de l'audio : %w", err)
}
}
_, brut, err := c.Read(ctx)
if err != nil {
return "", fmt.Errorf("lecture du résultat : %w", err)
}
var j struct {
Text string `json:"text"`
}
if err := json.Unmarshal(brut, &j); err != nil {
return "", fmt.Errorf("réponse illisible du serveur de dictée : %w", err)
}
if asrMarqueur(j.Text) {
return "", nil
}
return strings.TrimSpace(j.Text), nil
}
// wavVersFloat32 lit le WAV produit par le navigateur (16 kHz mono) et rend ses
// échantillons normalisés dans [-1, 1], ce qu'attend sherpa-onnx.
//
// On parcourt les blocs plutôt que de sauter à un offset fixe : un WAV n'a pas
// d'en-tête de taille garantie (« LIST », « fact » et consorts s'intercalent
// avant « data »), et l'offset 44 codé en dur transformerait ces octets en
// craquement au début de chaque phrase.
func wavVersFloat32(wav []byte) (taux int, ech []float32, err error) {
if len(wav) < 12 || string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
return 0, nil, errors.New("audio illisible : ce n'est pas un WAV")
}
var canaux, bits, format int
pos := 12
for pos+8 <= len(wav) {
id := string(wav[pos : pos+4])
taille := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
corps := pos + 8
if taille < 0 || corps+taille > len(wav) {
// Bloc annoncé plus grand que le fichier : on prend ce qui reste
// plutôt que d'échouer — un enregistrement coupé net reste utile.
taille = len(wav) - corps
}
switch id {
case "fmt ":
if taille < 16 {
return 0, nil, errors.New("audio illisible : bloc fmt tronqué")
}
format = int(binary.LittleEndian.Uint16(wav[corps : corps+2]))
canaux = int(binary.LittleEndian.Uint16(wav[corps+2 : corps+4]))
taux = int(binary.LittleEndian.Uint32(wav[corps+4 : corps+8]))
bits = int(binary.LittleEndian.Uint16(wav[corps+14 : corps+16]))
case "data":
if canaux <= 0 || taux <= 0 {
return 0, nil, errors.New("audio illisible : bloc data avant fmt")
}
ech, err = pcmVersFloat32(wav[corps:corps+taille], format, bits, canaux)
if err != nil {
return 0, nil, err
}
return taux, ech, nil
}
// Les blocs sont alignés sur un nombre pair d'octets.
pos = corps + taille + taille%2
}
return 0, nil, errors.New("audio illisible : aucun bloc data")
}
// pcmVersFloat32 convertit les échantillons bruts. Les canaux sont MOYENNÉS
// plutôt que de garder le premier : un navigateur qui livrerait du stéréo par
// erreur donnerait sinon un canal muet une fois sur deux.
func pcmVersFloat32(data []byte, format, bits, canaux int) ([]float32, error) {
const pcmEntier, pcmFlottant = 1, 3
switch {
case format == pcmEntier && bits == 16:
n := len(data) / 2 / canaux
out := make([]float32, n)
for i := 0; i < n; i++ {
somme := 0.0
for c := 0; c < canaux; c++ {
v := int16(binary.LittleEndian.Uint16(data[2*(i*canaux+c):]))
somme += float64(v) / 32768
}
out[i] = float32(somme / float64(canaux))
}
return out, nil
case format == pcmFlottant && bits == 32:
n := len(data) / 4 / canaux
out := make([]float32, n)
for i := 0; i < n; i++ {
somme := 0.0
for c := 0; c < canaux; c++ {
somme += float64(math.Float32frombits(binary.LittleEndian.Uint32(data[4*(i*canaux+c):])))
}
out[i] = float32(somme / float64(canaux))
}
return out, nil
}
return nil, fmt.Errorf("format audio non géré (format %d, %d bits) — attendu PCM 16 bits", format, bits)
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// asrIdle : au-delà, le serveur s'éteint et rend sa mémoire. Une dictée n'est
// pas un service permanent ; garder un modèle chargé toute la journée pour
// quelques phrases prive la machine de mémoire vive.
const asrIdle = 10 * time.Minute
// asrReady : plafond d'attente au démarrage. Un modèle de 600 Mo lu depuis un
// disque lent prend du temps ; échouer trop tôt afficherait une panne là où il
// n'y a qu'une lenteur.
const asrReady = 120 * time.Second
var wsrvMu sync.Mutex
// wsrvDemarrage : le processus en cours de démarrage, visible SANS le verrou.
// asrStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est la
// seule poignée qu'un autre appelant (asrShutdownVite) a sur lui pour
// l'interrompre. Nil hors démarrage.
var wsrvDemarrage atomic.Pointer[exec.Cmd]
var wsrv struct {
cmd *exec.Cmd
port int
cfg DictateCfg
log *bytes.Buffer
timer *time.Timer
depuis time.Time
}
// asrEnsure garantit un serveur vivant pour la configuration COURANTE, et
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
// est arrêté : c'est ce qui rend un changement de modèle effectif sans
// redémarrer Loki.
func asrEnsure() (int, error) {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
asrTouchLocked()
return wsrv.port, nil
}
asrStopLocked()
return asrStartLocked(cfg)
}
func asrStartLocked(cfg DictateCfg) (int, error) {
if !asrModelPresent(cfg.Model) {
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
}
bin := asrServerBin()
if bin == "" {
return 0, fmt.Errorf("serveur de dictée introuvable (image à reconstruire)")
}
port, err := portLibre()
if err != nil {
return 0, fmt.Errorf("aucun port libre pour le serveur de dictée : %w", err)
}
args, err := asrServerArgs(cfg, port)
if err != nil {
return 0, err
}
log := &bytes.Buffer{}
cmd := exec.Command(bin, args...)
cmd.Env = asrServerEnv(os.Environ())
cmd.Stdout, cmd.Stderr = log, log
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("démarrage du serveur de dictée : %w", err)
}
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, cfg
wsrv.log, wsrv.depuis = log, time.Now()
wsrvDemarrage.Store(cmd)
err = asrAttendPret(cmd, port, log)
wsrvDemarrage.Store(nil)
if err != nil {
asrStopLocked()
return 0, err
}
asrTouchLocked()
return port, nil
}
// asrAttendPret sonde le serveur jusqu'à ce qu'il accepte une connexion. Le
// serveur sherpa-onnx construit son moteur AVANT d'ouvrir sa socket : le simple
// fait qu'elle réponde signifie donc que le modèle est chargé — inutile de lui
// envoyer un audio de test.
//
// Si le processus meurt entre-temps, on rapporte COMMENT il est mort : sur une
// mort par signal, la dernière ligne du journal est celle d'avant le coup fatal —
// elle ressemble à une explication sans en être une.
func asrAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
fin := time.Now().Add(asrReady)
adr := "127.0.0.1:" + strconv.Itoa(port)
mort := make(chan error, 1)
go func() { mort <- cmd.Wait() }()
for time.Now().Before(fin) {
select {
case err := <-mort:
msg := asrExitReason(err)
if l := lastLine(log.String()); l != "" {
msg += " — dernière sortie : " + l
}
return fmt.Errorf("le serveur de dictée s'est arrêté au démarrage : %s", msg)
case <-time.After(200 * time.Millisecond):
}
conn, err := net.DialTimeout("tcp", adr, 2*time.Second)
if err == nil {
_ = conn.Close()
return nil
}
}
return fmt.Errorf("le serveur de dictée n'a pas répondu en %s", asrReady)
}
func asrTouchLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
}
wsrv.timer = time.AfterFunc(asrIdle, asrShutdown)
}
func asrStopLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
wsrv.timer = nil
}
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
_ = wsrv.cmd.Process.Kill()
}
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
wsrv.cfg = DictateCfg{}
}
// asrShutdown arrête le serveur. Appelé par la minuterie d'inactivité et à
// chaque changement de réglages — sans ça, le serveur continuerait de tourner
// avec l'ancien modèle et le nouveau réglage semblerait sans effet.
func asrShutdown() {
wsrvMu.Lock()
defer wsrvMu.Unlock()
asrStopLocked()
}
// asrShutdownVite arrête le serveur sans jamais attendre derrière un démarrage.
// asrShutdown prend wsrvMu, et asrEnsure le garde pendant tout le chargement du
// modèle — jusqu'à asrReady (deux minutes) depuis un disque lent. Appelé par un
// geste de l'interface, ce blocage ferait abandonner le navigateur alors que le
// moteur, lui, est déjà arrêté.
//
// Si le verrou est pris, on tue le processus en train de démarrer : sa mort fait
// sortir asrAttendPret, le verrou se libère, et on finit le ménage par la voie
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
// l'utilisateur qui vient de demander la place, pas une panne.
func asrShutdownVite() {
for i := 0; i < 20; i++ {
if wsrvMu.TryLock() {
asrStopLocked()
wsrvMu.Unlock()
return
}
if cmd := wsrvDemarrage.Load(); cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
time.Sleep(100 * time.Millisecond)
}
}
// asrInfer garantit le serveur puis lui soumet le WAV.
func asrInfer(ctx context.Context, wav []byte) (string, error) {
port, err := asrEnsure()
if err != nil {
return "", err
}
return asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(port), wav)
}
func asrEtat() map[string]any {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
etat := map[string]any{
"actif": wsrv.cmd != nil,
"modele": cfg.Model,
"present": asrModelPresent(cfg.Model),
// Le moteur livré est le build statique CPU (voir asrServerBin) : le dire
// vaut mieux que laisser chercher un réglage de carte qui n'existe pas.
"device": "cpu",
}
if wsrv.cmd != nil {
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
}
return etat
}
+397
View File
@@ -0,0 +1,397 @@
package loki
import (
"context"
"encoding/binary"
"encoding/json"
"math"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
// wavTest fabrique un WAV 16 kHz mono PCM 16 bits, comme celui que le
// navigateur envoie.
func wavTest(ech []int16, taux int) []byte {
data := make([]byte, 2*len(ech))
for i, v := range ech {
binary.LittleEndian.PutUint16(data[2*i:], uint16(v))
}
b := make([]byte, 0, 44+len(data))
ajout32 := func(v uint32) {
var x [4]byte
binary.LittleEndian.PutUint32(x[:], v)
b = append(b, x[:]...)
}
ajout16 := func(v uint16) {
var x [2]byte
binary.LittleEndian.PutUint16(x[:], v)
b = append(b, x[:]...)
}
b = append(b, "RIFF"...)
ajout32(uint32(36 + len(data)))
b = append(b, "WAVE"...)
b = append(b, "fmt "...)
ajout32(16)
ajout16(1) // PCM entier
ajout16(1) // mono
ajout32(uint32(taux))
ajout32(uint32(taux * 2))
ajout16(2) // alignement de bloc
ajout16(16) // bits par échantillon
b = append(b, "data"...)
ajout32(uint32(len(data)))
return append(b, data...)
}
func TestServerArgsFormeCleValeur(t *testing.T) {
testHome(t)
args, err := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4242)
if err != nil {
t.Fatal(err)
}
joint := strings.Join(args, " ")
// sherpa-onnx ne reconnaît QUE --clef=valeur. La forme séparée par une
// espace le laisse démarrer sans modèle, sans se plaindre.
for _, a := range args {
if !strings.HasPrefix(a, "--") || !strings.Contains(a, "=") {
t.Errorf("argument %q : sherpa-onnx exige la forme --clef=valeur", a)
}
}
for _, attendu := range []string{"--model-type=nemo_transducer", "--host=127.0.0.1", "--port=4242"} {
if !strings.Contains(joint, attendu) {
t.Errorf("argument manquant : %s\nobtenu : %s", attendu, joint)
}
}
// Les quatre fichiers du modèle, sinon le serveur démarre puis meurt.
for _, f := range asrFichiers {
if !strings.Contains(joint, f) {
t.Errorf("le fichier %s du modèle n'est pas passé au serveur", f)
}
}
}
func TestServerArgsRefuseModeleInconnu(t *testing.T) {
testHome(t)
if _, err := asrServerArgs(DictateCfg{Model: "nawak"}, 1234); err == nil {
t.Error("un modèle hors catalogue doit être refusé avant le lancement")
}
}
// Le serveur ne doit jamais prendre une carte : le binaire livré est le build
// CPU, et la VRAM appartient au moteur de chat.
func TestServerEnvMasqueLesGPU(t *testing.T) {
got := asrServerEnv([]string{"PATH=/bin", "CUDA_VISIBLE_DEVICES=0,1", "HOME=/root"})
n := 0
for _, v := range got {
if strings.HasPrefix(v, "CUDA_VISIBLE_DEVICES=") {
n++
if v != "CUDA_VISIBLE_DEVICES=" {
t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu vide", v)
}
}
}
// Une variable en double laisse le gagnant dépendre de la libc.
if n != 1 {
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
}
if !contientTout(got, "PATH=/bin", "HOME=/root") {
t.Error("le reste de l'environnement doit être conservé")
}
}
func contientTout(l []string, vals ...string) bool {
for _, v := range vals {
trouve := false
for _, x := range l {
if x == v {
trouve = true
break
}
}
if !trouve {
return false
}
}
return true
}
func TestPortLibre(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatal(err)
}
if p < 1024 || p > 65535 {
t.Errorf("port = %d, hors de la plage utilisable", p)
}
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
if err != nil {
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
}
_ = l.Close()
}
// ─── Conversion WAV → float32 ────────────────────────────────────────────────
// L'offset 44 codé en dur est le piège classique : un WAV n'a pas d'en-tête de
// taille garantie, et des blocs (LIST, fact…) s'intercalent avant `data`. Les
// prendre pour de l'audio produit un craquement au début de chaque phrase.
func TestWavIgnoreLesBlocsIntercales(t *testing.T) {
base := wavTest([]int16{0, 16384, -16384, 32767}, 16000)
// Insère un bloc « LIST » de 6 octets juste après l'en-tête RIFF/WAVE.
extra := append([]byte("LIST"), 6, 0, 0, 0, 'I', 'N', 'F', 'O', 'x', 'y')
avec := append(append(append([]byte{}, base[:12]...), extra...), base[12:]...)
binary.LittleEndian.PutUint32(avec[4:8], uint32(len(avec)-8))
taux, ech, err := wavVersFloat32(avec)
if err != nil {
t.Fatalf("wavVersFloat32 : %v", err)
}
if taux != 16000 {
t.Errorf("taux = %d, attendu 16000", taux)
}
if len(ech) != 4 {
t.Fatalf("%d échantillons, attendu 4 — un bloc intercalé a été pris pour de l'audio", len(ech))
}
if ech[0] != 0 || math.Abs(float64(ech[1])-0.5) > 0.001 {
t.Errorf("échantillons mal normalisés : %v", ech)
}
}
func TestWavRefuseCeQuiNEnEstPas(t *testing.T) {
for nom, in := range map[string][]byte{
"vide": {},
"pas un WAV": []byte("ceci n'est pas un fichier audio du tout"),
"sans data": append([]byte("RIFF\x00\x00\x00\x00WAVE"), "fmt "...),
} {
if _, _, err := wavVersFloat32(in); err == nil {
t.Errorf("%s : accepté alors qu'il est illisible", nom)
}
}
}
// Le stéréo doit être MOYENNÉ, pas réduit au premier canal : un navigateur qui
// livrerait deux canaux donnerait sinon un silence une fois sur deux.
func TestPcmStereoMoyenne(t *testing.T) {
data := make([]byte, 8) // deux trames stéréo
binary.LittleEndian.PutUint16(data[0:], uint16(int16(0)))
binary.LittleEndian.PutUint16(data[2:], uint16(int16(32767)))
binary.LittleEndian.PutUint16(data[4:], uint16(0x8000)) // -32768
binary.LittleEndian.PutUint16(data[6:], uint16(int16(32767)))
ech, err := pcmVersFloat32(data, 1, 16, 2)
if err != nil {
t.Fatal(err)
}
if len(ech) != 2 {
t.Fatalf("%d échantillons, attendu 2", len(ech))
}
if math.Abs(float64(ech[0])-0.5) > 0.01 {
t.Errorf("premier échantillon = %v, attendu la moyenne des deux canaux (~0,5)", ech[0])
}
if math.Abs(float64(ech[1])) > 0.01 {
t.Errorf("second échantillon = %v, attendu ~0 (canaux opposés)", ech[1])
}
}
func TestPcmFormatNonGere(t *testing.T) {
if _, err := pcmVersFloat32(make([]byte, 8), 1, 24, 1); err == nil {
t.Error("un PCM 24 bits doit être refusé explicitement, pas décodé de travers")
}
}
// ─── Protocole WebSocket ─────────────────────────────────────────────────────
// serveurASRFactice rejoue le protocole de sherpa-onnx : il lit l'en-tête et
// les échantillons, puis répond en JSON. Il vérifie au passage que le client
// annonce le BON nombre d'octets — un compte faux et le vrai serveur attend
// indéfiniment de quoi finir, sans jamais répondre.
func serveurASRFactice(t *testing.T, texte string) (adresse string, arret func(), recu *struct {
Taux int
N int
}) {
t.Helper()
recu = &struct {
Taux int
N int
}{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
defer func() { _ = c.CloseNow() }()
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
// il ne borne pas la taille des trames recues, ce test non plus.
c.SetReadLimit(-1)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var buf []byte
for {
_, b, err := c.Read(ctx)
if err != nil {
return
}
buf = append(buf, b...)
if len(buf) < asrEnteteOctets {
continue
}
recu.Taux = int(binary.LittleEndian.Uint32(buf[0:4]))
attendu := int(binary.LittleEndian.Uint32(buf[4:8]))
if len(buf)-asrEnteteOctets < attendu {
continue
}
recu.N = attendu / 4
_ = c.Write(ctx, websocket.MessageText, []byte(`{"text":"`+texte+`"}`))
return
}
}))
return "ws" + strings.TrimPrefix(srv.URL, "http"), srv.Close, recu
}
func TestInferEnvoieLeBonEnTete(t *testing.T) {
adresse, arret, recu := serveurASRFactice(t, " bonjour ceci est un test ")
defer arret()
wav := wavTest(make([]int16, 800), 16000)
txt, err := asrInferSur(context.Background(), adresse, wav)
if err != nil {
t.Fatalf("asrInferSur : %v", err)
}
if txt != "bonjour ceci est un test" {
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
}
if recu.Taux != 16000 {
t.Errorf("taux annoncé = %d, attendu 16000", recu.Taux)
}
if recu.N != 800 {
t.Errorf("%d échantillons annoncés, attendu 800 — un compte faux fige le serveur", recu.N)
}
}
// Un audio plus gros qu'une trame doit arriver ENTIER : le serveur recolle les
// morceaux à partir du compte annoncé, encore faut-il tout envoyer.
func TestInferDecoupeLesGrosEnvois(t *testing.T) {
adresse, arret, recu := serveurASRFactice(t, "ok")
defer arret()
n := asrTrameMax // > une trame une fois converti en float32
if _, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, n), 16000)); err != nil {
t.Fatalf("asrInferSur : %v", err)
}
if recu.N != n {
t.Errorf("%d échantillons reçus, attendu %d", recu.N, n)
}
}
// Sur du silence, le moteur peut rendre une annotation plutôt qu'un texte. La
// laisser passer la collerait telle quelle dans le champ de saisie.
func TestInferFiltreLesMarqueurs(t *testing.T) {
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
adresse, arret, _ := serveurASRFactice(t, marqueur)
txt, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, 100), 16000))
arret()
if err != nil {
t.Fatalf("asrInferSur(%q) : %v", marqueur, err)
}
if txt != "" {
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
}
}
}
func TestInferServeurInjoignable(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(p), wavTest(make([]int16, 100), 16000))
if err == nil {
t.Error("un serveur injoignable doit remonter une erreur")
}
}
func TestInferReponseIllisible(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
defer func() { _ = c.CloseNow() }()
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
// il ne borne pas la taille des trames recues, ce test non plus.
c.SetReadLimit(-1)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, _, err := c.Read(ctx); err != nil {
return
}
_ = c.Write(ctx, websocket.MessageText, []byte("ceci n'est pas du JSON"))
}))
defer srv.Close()
_, err := asrInferSur(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"), wavTest(make([]int16, 100), 16000))
if err == nil {
t.Error("une réponse illisible doit remonter une erreur")
}
}
// Un audio vide n'est pas une panne : on rend une transcription vide sans même
// ouvrir de connexion.
func TestInferAudioVide(t *testing.T) {
txt, err := asrInferSur(context.Background(), "ws://127.0.0.1:1", wavTest(nil, 16000))
if err != nil {
t.Fatalf("un WAV sans échantillon ne doit pas être une erreur : %v", err)
}
if txt != "" {
t.Errorf("texte = %q, attendu vide", txt)
}
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// Un modèle absent doit être annoncé comme tel, pas produire un lancement du
// serveur voué à mourir sur un fichier introuvable.
func TestEnsureModeleAbsent(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "parakeet-tdt-0.6b-v3", Reactivity: "moyen"}); err != nil {
t.Fatal(err)
}
_, err := asrEnsure()
if err == nil {
t.Fatal("un modèle absent doit produire une erreur")
}
if !strings.Contains(err.Error(), "absent") {
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
}
}
func TestEtatSansServeur(t *testing.T) {
testHome(t)
e := asrEtat()
if e["actif"] != false {
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
}
for _, clef := range []string{"modele", "present", "device"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
}
}
// Le binaire livré est CPU : l'état ne doit pas laisser croire à une carte.
if e["device"] != "cpu" {
t.Errorf("device = %v, attendu \"cpu\"", e["device"])
}
}
func TestEtatSerialisable(t *testing.T) {
testHome(t)
if _, err := json.Marshal(asrEtat()); err != nil {
t.Fatalf("l'état doit être sérialisable pour /api/dictate/state : %v", err)
}
}
+24
View File
@@ -44,6 +44,12 @@ Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une po
// inflating decode numbers — what you measure here is close to what you'll
// see in real chat at the same context length.
func runBench(nPrompt, nPredict int) (*benchResult, error) {
// Un bench mesure le moteur LOCAL : sur un preset externe, healthCheck dit
// « prêt » sans moteur, et la mesure taperait un port arrêté ou un moteur
// resté en vie — attribuée à tort à ce preset.
if externalActive() {
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
}
port := LLMPort()
if !healthCheck() {
return nil, fmt.Errorf("serveur injoignable sur :%d", port)
@@ -158,6 +164,24 @@ func loadLastBench() *savedBench {
return &sb
}
// benchMatchesPreset : le bench a-t-il été mesuré sur le modèle ACTUEL du
// preset ? Les benchs sont rangés par id de preset : sans ce contrôle, un
// preset dont le modèle a changé affichait les mesures d'un autre modèle.
// Repris d'AJEAN 0.16.3.
func benchMatchesPreset(sb savedBench, cfg map[string]string) bool {
m := strings.TrimSpace(cfg["MODEL"])
return m != "" && sb.Model == filepath.Base(m)
}
// deletePresetBench oublie le bench d'un preset supprimé.
func deletePresetBench(id string) {
m := loadBenchStore()
if _, ok := m[id]; ok {
delete(m, id)
_ = putJSON(bkState, "bench_presets", m)
}
}
// loadBenchStore renvoie les benchmarks par preset (vide s'il n'y en a pas).
func loadBenchStore() map[string]savedBench {
m := map[string]savedBench{}
+17
View File
@@ -0,0 +1,17 @@
package loki
import "testing"
// Un bench n'est affiché que pour le modèle sur lequel il a été mesuré.
func TestBenchMatchesPreset(t *testing.T) {
sb := savedBench{Model: "Qwen3-27B-Q4_K_M.gguf"}
if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}) {
t.Fatal("même modèle : le bench doit s'afficher")
}
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}) {
t.Fatal("autre modèle : le bench ne doit pas s'afficher")
}
if benchMatchesPreset(sb, map[string]string{}) {
t.Fatal("preset sans modèle : pas de bench")
}
}
+3
View File
@@ -89,3 +89,6 @@ func logBudget(calls, budget, sent int) {
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
calls, budget, sent)
}
// codeBudgetFactor : multiplicateur du palier en mode Code (voir runChat).
const codeBudgetFactor = 3
File diff suppressed because it is too large. Load diff
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
// Flux coupé vers une API EXTERNE après un début de réponse : le tour reprend,
// le modèle reçoit le texte déjà affiché suivi d'un « continue », et la suite
// s'ajoute à l'écran sans doublon ni erreur.
func TestRepriseApresCoupureAPIExterne(t *testing.T) {
testHome(t)
var n int32
var second []Message
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
if atomic.AddInt32(&n, 1) == 1 {
w.WriteHeader(200)
_, _ = w.Write([]byte(sseChunk("Bonjour ")))
w.(http.Flusher).Flush()
conn, _, err := w.(http.Hijacker).Hijack()
if err == nil {
_ = conn.Close()
}
return
}
var body struct {
Messages []Message `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
second = body.Messages
w.WriteHeader(200)
_, _ = w.Write([]byte(sseChunk("le monde")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
t.Fatal(err)
}
var content strings.Builder
var gotErr error
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Err != nil {
gotErr = ev.Err
}
content.WriteString(ev.Content)
return true
})
if err != nil || gotErr != nil {
t.Fatalf("coupure externe non reprise : err=%v, ev=%v", err, gotErr)
}
if got := content.String(); got != "Bonjour le monde" {
t.Fatalf("texte affiché = %q", got)
}
if len(second) < 3 || second[len(second)-2].Role != "assistant" || second[len(second)-2].Content != "Bonjour " ||
second[len(second)-1].Role != "user" {
t.Fatalf("la reprise n'a pas rendu le début de réponse au modèle : %+v", second)
}
}
// Deux appels d'outils parallèles livrés chacun en position 0 de son chunk : le
// champ index les sépare. Avant, ils fusionnaient en un seul appel aux
// arguments collés « {…}{…} ».
func TestAppelsParallelesSuiventLIndex(t *testing.T) {
withWorkspace(t)
var n int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
if atomic.AddInt32(&n, 1) == 1 {
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"a","type":"function","function":{"name":"glob","arguments":"{\"pattern\":\"*.go\"}"}}]}}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","type":"function","function":{"name":"grep","arguments":"{\"pattern\":\"rien\"}"}}]}}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}` + "\n\n"))
} else {
_, _ = w.Write([]byte(sseChunk("fini")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
}
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
var done []string
extra, err := runChat(context.Background(), []Message{{Role: "user", Content: "cherche"}}, 0.7, Caps{Agent: true, Code: true}, func(ev StreamEvent) bool {
if ev.ToolUsed != nil && ev.ToolUsed.Done {
done = append(done, ev.ToolUsed.Name)
}
return true
})
if err != nil {
t.Fatal(err)
}
if len(done) != 2 || done[0] != "glob" || done[1] != "grep" {
t.Fatalf("appels exécutés = %v, attendu [glob grep]", done)
}
if len(extra) == 0 || len(extra[0].ToolCalls) != 2 {
t.Fatalf("message assistant : %+v", extra)
}
}
// Serveur sans `timings` (API tierce) : le débit de décodage est mesuré côté
// Loki au lieu de rester à zéro.
func TestDebitMesureSansTimings(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
for _, s := range []string{"un ", "deux ", "trois"} {
_, _ = w.Write([]byte(sseChunk(s)))
w.(http.Flusher).Flush()
time.Sleep(20 * time.Millisecond)
}
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\n"))
_, _ = w.Write([]byte(`data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15}}` + "\n\ndata: [DONE]\n\n"))
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
t.Fatal(err)
}
var last *StatsEvent
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "compte"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Stats != nil {
last = ev.Stats
}
return true
}); err != nil {
t.Fatal(err)
}
if last == nil || last.GenTokens != 3 || last.GenPerSecond <= 0 {
t.Fatalf("stats sans timings = %+v", last)
}
}
// Un refus qui n'a rien d'un appel d'outil mal formé (401 d'une API externe)
// remonte tel quel : avant, n'importe quel statut coupait les outils et
// rejouait le tour, masquant la vraie cause derrière une réponse sans outils.
func TestRefusAPIHors500NeCoupePasLesOutils(t *testing.T) {
testHome(t)
var n int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&n, 1)
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "m", "", "", false))); err != nil {
t.Fatal(err)
}
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("erreur = %v, attendu le 401 de l'API", err)
}
if got := atomic.LoadInt32(&n); got != 1 {
t.Fatalf("%d requêtes : un 401 a été rejoué sans outils", got)
}
}
+135
View File
@@ -0,0 +1,135 @@
package loki
// llm_retry_net.go — réessayer un tour que RIEN n'a encore diffusé.
//
// Jusqu'ici, la requête de complétion partait une fois : un `Do()` qui échoue
// ou un statut d'erreur tuait le tour. Les messages d'erreur eux-mêmes le
// disaient — « réessaie dans quelques secondes », « réessaie dans un
// instant » — c'est-à-dire qu'on demandait à l'utilisateur de refaire à la main
// ce que le code pouvait faire seul.
//
// Le cas courant chez Loki n'a rien d'exotique : le moteur redémarre (bascule de
// preset, rechargement de modèle, redémarrage du conteneur) et la connexion est
// refusée pendant quelques secondes. Une tâche planifiée qui tombe pile là
// échouait pour de bon, sans personne pour cliquer.
//
// LA règle de sûreté, et elle ne souffre pas d'exception : on ne rejoue que
// TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ. Une fois le flux commencé, la moitié de
// la réponse est déjà chez l'utilisateur ; la rejouer la dupliquerait. Les deux
// points de reprise (échec de `Do`, statut d'erreur avant lecture du corps)
// sont tous deux situés avant la première ligne SSE.
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"syscall"
"time"
)
const (
// llmNetRetries : tentatives CONSÉCUTIVES par tour. Le compteur repart à zéro
// dès qu'une réponse arrive, donc une boucle d'outils longue retrouve son
// budget à chaque itération réussie — mais un moteur durablement mort rend la
// main au lieu de tourner sans fin.
llmNetRetries = 3
// Attente de la 1re reprise, doublée ensuite (0,8 s → 1,6 s → 3,2 s). Un
// llama-server qui redémarre met quelques secondes à rouvrir son port : plus
// court ne sert à rien, plus long fait passer une panne franche pour un gel.
llmNetWait = 800 * time.Millisecond
llmNetMaxWait = 5 * time.Second
)
// wsaECONNREFUSED : la connexion refusée telle que Windows la rend (WSA 10061).
// syscall.ECONNREFUSED n'y est qu'une valeur inventée par Go, jamais renvoyée
// par le système ; ailleurs, 10061 n'est le numéro d'aucune erreur.
const wsaECONNREFUSED = syscall.Errno(10061)
// llmRetryableErr : cette erreur de transport vaut-elle une nouvelle tentative ?
//
// Un ctx déjà fini n'en vaut JAMAIS une : c'est le /stop de l'utilisateur ou la
// limite de l'appelant, et réessayer serait passer outre. On le vérifie donc
// avant de regarder l'erreur elle-même — une échéance dépassée remonte sinon
// comme un simple timeout réseau, indiscernable d'une surcharge du moteur.
func llmRetryableErr(ctx context.Context, err error) bool {
if err == nil || ctx.Err() != nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
switch {
case errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, wsaECONNREFUSED): // moteur en cours de démarrage
return true
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
return true
case isNetTimeout(err): // moteur surchargé ou en plein chargement du modèle
return true
}
return false
}
// llmRetryableStatus : ce code HTTP vaut-il une nouvelle tentative ?
//
// Volontairement étroit. 500 en est ABSENT : c'est le code que llama.cpp rend
// pour un appel d'outil malformé ou un prompt qui dépasse le contexte, deux
// échecs déterministes que les filets au-dessus traitent déjà (retrait des
// outils, compaction en vol). Le rejouer trois fois ne ferait que retarder
// l'erreur de plusieurs secondes. Restent les codes qui disent « pas
// maintenant » : passerelle, service indisponible, trop de requêtes.
func llmRetryableStatus(code int) bool {
switch code {
case http.StatusTooManyRequests, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
}
return false
}
// llmNetDelay : combien attendre avant la tentative n (0 = la première reprise).
// Doublement puis plafond. Séparé de l'attente pour être vérifiable sans dormir
// cinq secondes dans la suite de tests — et parce qu'un décalage de bits sur un
// n non borné déborderait en silence (0,8 s << 64 revient à zéro).
func llmNetDelay(n int) time.Duration {
if n < 0 {
n = 0
}
if n > 16 { // au-delà, le plafond s'applique de toute façon
n = 16
}
if d := llmNetWait << n; d < llmNetMaxWait {
return d
}
return llmNetMaxWait
}
// llmNetBackoff attend avant la tentative n (0 = la première reprise).
// Interruptible : un /stop pendant l'attente rend la main tout de suite au lieu
// de faire patienter l'utilisateur pour une requête qu'il vient d'annuler.
func llmNetBackoff(ctx context.Context, n int) error {
t := time.NewTimer(llmNetDelay(n))
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// logLLMRetry trace la reprise. Elle n'est PAS envoyée à l'interface : le tour
// n'a encore rien produit, et faire clignoter une erreur qu'on est en train de
// rattraper inquiéterait pour rien. Le journal du conteneur, lui, doit la voir —
// c'est là qu'on cherche quand « ça a mis huit secondes ».
func logLLMRetry(attempt int, cause string) {
fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries)
}
// logStreamRetry trace la reprise d'un flux coupé EN COURS de réponse (API
// externe) : budget distinct de celui d'avant le flux, voir runChat.
func logStreamRetry(attempt, max int, cause error) {
fmt.Fprintf(os.Stderr, "[llm] flux coupé en cours de réponse (%v) — reprise %d/%d\n", cause, attempt, max)
}
+199
View File
@@ -0,0 +1,199 @@
package loki
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
)
// flakyServer répond `code` aux `fails` premières requêtes, puis sert un flux
// SSE normal. Renvoie le port (à mettre dans PORT) et le compteur d'appels.
func flakyServer(t *testing.T, fails int, code int, body string) (string, *atomic.Int32) {
t.Helper()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if int(calls.Add(1)) <= fails {
http.Error(w, "moteur occupé", code)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(body))
w.(http.Flusher).Flush()
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
return u.Port(), &calls
}
// Le cas qui motive tout le fichier : le moteur redémarre, la connexion est
// refusée quelques secondes, et le tour mourait pour de bon — une tâche
// planifiée tombée pile là échouait sans personne pour recliquer.
func TestConnexionRefuseeEstRejouee(t *testing.T) {
testHome(t)
// Un port fermé : on ouvre puis on referme aussitôt pour en obtenir un dont on
// est sûr que personne n'écoute.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
if err := SetConfigKey("PORT", itoa(port)); err != nil {
t.Fatal(err)
}
start := time.Now()
_, err = runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
elapsed := time.Since(start)
if err == nil {
t.Fatal("moteur injoignable : aucune erreur remontée")
}
// 3 reprises espacées de 0,8 / 1,6 / 3,2 s : le tour ne peut pas avoir rendu
// la main tout de suite. Sans ce délai, aucune reprise n'a eu lieu.
if min := llmNetWait + 2*llmNetWait; elapsed < min {
t.Fatalf("rendu la main en %v : aucune reprise n'a eu lieu (attendu au moins %v)", elapsed, min)
}
}
// Un statut « pas maintenant » (503) est rejoué, et le tour aboutit.
func TestStatutTransitoireEstRejoueEtAboutit(t *testing.T) {
testHome(t)
port, calls := flakyServer(t, 2, http.StatusServiceUnavailable,
sseChunk("ça a fini par passer")+"data: [DONE]\n\n")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
var content strings.Builder
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
return true
})
if err != nil {
t.Fatalf("503 transitoire non rattrapé : %v", err)
}
if n := calls.Load(); n != 3 {
t.Fatalf("%d appel(s) au moteur, attendu 3 (deux échecs puis la bonne)", n)
}
if !strings.Contains(content.String(), "ça a fini par passer") {
t.Fatalf("réponse perdue : %q", content.String())
}
}
// Contre-épreuve : un 500 n'est PAS un statut de reprise RÉSEAU. C'est ce que
// llama.cpp rend pour un appel d'outil malformé ou un prompt trop long — deux
// échecs déterministes ; attendre puis rejouer à l'identique ne ferait que
// retarder l'erreur de plusieurs secondes.
//
// Le tour peut malgré tout repartir UNE fois : le filet de compaction en vol,
// antérieur à ce fichier, retente le tour avec un historique résumé (le 500 est
// souvent un dépassement de contexte). C'est un rejeu SÉMANTIQUE — il change la
// requête et n'attend pas. La preuve qu'aucune reprise réseau n'a eu lieu est
// donc le CHRONO : trois reprises coûteraient au bas mot 0,8 + 1,6 s d'attente.
func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) {
testHome(t)
port, calls := flakyServer(t, 99, http.StatusInternalServerError, "")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
start := time.Now()
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
elapsed := time.Since(start)
if err == nil {
t.Fatal("500 : aucune erreur remontée")
}
if elapsed >= llmNetWait {
t.Fatalf("rendu la main en %v : une attente de reprise réseau a eu lieu sur un 500", elapsed)
}
// Borne haute large : ce qui compte est qu'on ne soit pas allé au bout du
// budget réseau (1 + 3 = 4 appels).
if n := calls.Load(); n > 2 {
t.Fatalf("%d appels au moteur sur un 500 : le budget de reprise réseau a été consommé", n)
}
}
// Un /stop pendant le tour ne doit surtout pas être rattrapé : réessayer, c'est
// passer outre l'annulation de l'utilisateur.
func TestAnnulationNestJamaisRejouee(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if llmRetryableErr(ctx, syscall.ECONNREFUSED) {
t.Fatal("ctx annulé : la reprise doit être refusée")
}
if llmRetryableErr(context.Background(), context.Canceled) {
t.Fatal("context.Canceled : la reprise doit être refusée")
}
if llmRetryableErr(context.Background(), context.DeadlineExceeded) {
t.Fatal("échéance dépassée : c'est la limite de l'appelant, pas une panne réseau")
}
// L'attente elle-même rend la main tout de suite sur un ctx fini.
if err := llmNetBackoff(ctx, 2); err == nil {
t.Fatal("l'attente doit être interrompue par le ctx")
}
}
// Les erreurs de transport qui méritent une reprise, et celles qui n'en méritent
// pas. Une faute de frappe dans l'URL ne se répare pas en attendant.
func TestErreursDeTransportRejouables(t *testing.T) {
ctx := context.Background()
for _, e := range []error{syscall.ECONNREFUSED, syscall.ECONNRESET, io.EOF, io.ErrUnexpectedEOF} {
if !llmRetryableErr(ctx, e) {
t.Errorf("%v devrait être rejouable (moteur qui redémarre ou connexion perdue)", e)
}
}
if llmRetryableErr(ctx, errors.New("unsupported protocol scheme")) {
t.Error("une erreur de configuration ne se répare pas en réessayant")
}
if llmRetryableErr(ctx, nil) {
t.Error("pas d'erreur, pas de reprise")
}
}
// Les codes de reprise, et ceux qui n'en sont pas.
func TestStatutsRejouables(t *testing.T) {
for _, c := range []int{http.StatusTooManyRequests, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout} {
if !llmRetryableStatus(c) {
t.Errorf("%d dit « pas maintenant » : rejouable", c)
}
}
for _, c := range []int{200, 400, 401, 404, 500} {
if llmRetryableStatus(c) {
t.Errorf("%d ne doit pas déclencher de reprise", c)
}
}
}
// L'attente double à chaque tentative, se plafonne, et ne déborde pas : un
// décalage de bits sur un n non borné repasserait à zéro en silence — la reprise
// deviendrait une rafale.
func TestAttenteCroissantePuisPlafonnee(t *testing.T) {
if got := llmNetDelay(0); got != llmNetWait {
t.Errorf("1re attente = %v, attendu %v", got, llmNetWait)
}
if got := llmNetDelay(1); got != 2*llmNetWait {
t.Errorf("2e attente = %v, attendu le double", got)
}
for _, n := range []int{10, 20, 64, 1000} {
if got := llmNetDelay(n); got != llmNetMaxWait {
t.Errorf("llmNetDelay(%d) = %v, attendu le plafond %v", n, got, llmNetMaxWait)
}
}
if got := llmNetDelay(-1); got != llmNetWait {
t.Errorf("llmNetDelay(-1) = %v, attendu la 1re attente", got)
}
}
+26
View File
@@ -22,6 +22,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -145,10 +146,35 @@ func lspDiagnosticsFor(absPath string) string {
if len(diags) == 0 {
return ""
}
// Erreurs d'abord, puis avertissements, puis le reste (OpenFox) : au-delà
// de lspMaxDiags on tronque, et des indices de style passaient devant
// l'erreur de compilation qui comptait. Le compte annonce le total.
rank := func(s int) int {
if s <= 0 {
return 1 // sévérité absente : traitée comme une erreur
}
return s
}
sort.SliceStable(diags, func(i, j int) bool { return rank(diags[i].Severity) < rank(diags[j].Severity) })
nErr, nWarn := 0, 0
for _, d := range diags {
switch rank(d.Severity) {
case 1:
nErr++
case 2:
nWarn++
}
}
total := len(diags)
if len(diags) > lspMaxDiags {
diags = diags[:lspMaxDiags]
}
var b strings.Builder
fmt.Fprintf(&b, "%d erreur(s), %d avertissement(s)", nErr, nWarn)
if total > len(diags) {
fmt.Fprintf(&b, " — %d premiers sur %d", len(diags), total)
}
b.WriteString("\n")
for _, d := range diags {
sev := "info"
switch d.Severity {
+14 -4
View File
@@ -48,10 +48,20 @@ const (
mcpConnectTimeoutCold = 3 * time.Minute
// mcpCallTimeout borne un appel d'outil MCP.
mcpCallTimeout = 120 * time.Second
// mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput).
mcpMaxOutput = 12000
)
// mcpOutputCap borne la sortie d'un outil MCP renvoyée au modèle. L'amont
// (AJEAN 0.15.2) la transmet désormais EN ENTIER, comme une page mémoire : une
// réponse MCP (un ticket, un document, une requête) coupée à 12 000 caractères
// laissait le modèle travailler sur un début. On s'en rapproche sans renoncer
// à tout garde-fou — sur 65k de contexte, une seule réponse géante noierait la
// fenêtre et déclencherait un compactage en plein raisonnement : le plafond
// suit la fenêtre, ~1 caractère par token de contexte, soit environ un quart
// de celle-ci (≈4 caractères par token). Jamais sous l'ancien plafond.
func mcpOutputCap() int {
return max(12000, ctxWindow())
}
// mcpSession est une connexion vivante à un serveur MCP.
//
// L'entrée est publiée dans le pool AVANT que la connexion aboutisse, pour qu'un
@@ -399,8 +409,8 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
return "", err
}
out := flattenMCPContent(res)
if r := []rune(out); len(r) > mcpMaxOutput {
out = string(r[:mcpMaxOutput]) + "\n…[tronqué]"
if r, lim := []rune(out), mcpOutputCap(); len(r) > lim {
out = string(r[:lim]) + fmt.Sprintf("\n…[tronqué : %d caractères au total, %d gardés]", len(r), lim)
}
if res.IsError {
return "[l'outil a renvoyé une erreur]\n" + out, nil
+192
View File
@@ -0,0 +1,192 @@
package loki
// mem_crypto.go — primitives de chiffrement de la mémoire.
//
// Modèle à enveloppe (voir docs/chiffrement-memoire-et-sauvegarde.md) :
// - DEK (Data Encryption Key) : 32 octets aléatoires, chiffre chaque page
// (AES-256-GCM, nonce aléatoire par écriture). Ne change jamais.
// - KEK (Key Encryption Key) : dérivée d'un mot de passe par Argon2id, sert
// UNIQUEMENT à (dé)wrapper la DEK (voir mem_vault.go).
//
// Aucune dépendance exotique : AES-GCM de la stdlib + Argon2id de x/crypto.
// La DEK en clair ne touche jamais le disque : elle vit en RAM le temps d'une
// session déverrouillée, et sur disque uniquement sous forme wrappée.
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"golang.org/x/crypto/argon2"
)
const (
memDEKLen = 32 // AES-256
memNonceLen = 12 // taille standard du nonce GCM
memSaltLen = 16 // sel Argon2id
)
// Paramètres Argon2id par défaut. Stockés dans chaque wrap du keyvault pour que
// de futurs réglages n'invalident pas les coffres existants (on relit toujours
// les paramètres écrits, jamais ces constantes, lors du déballage).
const (
argonTime = 3
argonMemory = 64 * 1024 // 64 Mio
argonThreads = 1
)
// En-tête d'une page chiffrée. Sert de marqueur de format ET d'AAD (le tag GCM
// couvre ainsi la version : impossible de rejouer un blob d'une autre version).
var memPageMagic = []byte("LOKIMEMv1")
// errNotEncrypted signale un contenu qui n'est pas une page chiffrée valide
// (mauvais magic) — utile pour distinguer « clair » de « chiffré » à la volée.
var errNotEncrypted = errors.New("contenu non chiffré (magic absent)")
// randBytes renvoie n octets aléatoires cryptographiques.
func randBytes(n int) ([]byte, error) {
b := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return nil, fmt.Errorf("source aléatoire indisponible : %w", err)
}
return b, nil
}
// deriveKEK dérive une clé de 32 octets d'un mot de passe via Argon2id.
func deriveKEK(password string, salt []byte, t, m uint32, p uint8) []byte {
if t == 0 {
t = argonTime
}
if m == 0 {
m = argonMemory
}
if p == 0 {
p = argonThreads
}
return argon2.IDKey([]byte(password), salt, t, m, p, memDEKLen)
}
// gcmSeal chiffre plaintext avec key (AES-256-GCM) et renvoie nonce||ciphertext.
// aad (données authentifiées mais non chiffrées) lie le blob à un contexte.
func gcmSeal(key, plaintext, aad []byte) ([]byte, error) {
gcm, err := memNewGCM(key)
if err != nil {
return nil, err
}
nonce, err := randBytes(memNonceLen)
if err != nil {
return nil, err
}
ct := gcm.Seal(nil, nonce, plaintext, aad)
out := make([]byte, 0, len(nonce)+len(ct))
out = append(out, nonce...)
out = append(out, ct...)
return out, nil
}
// gcmOpen déchiffre un blob nonce||ciphertext produit par gcmSeal. Une clé
// erronée ou un blob altéré renvoie une erreur (jamais un contenu partiel).
func gcmOpen(key, blob, aad []byte) ([]byte, error) {
gcm, err := memNewGCM(key)
if err != nil {
return nil, err
}
if len(blob) < memNonceLen {
return nil, errors.New("blob trop court")
}
nonce, ct := blob[:memNonceLen], blob[memNonceLen:]
pt, err := gcm.Open(nil, nonce, ct, aad)
if err != nil {
return nil, fmt.Errorf("déchiffrement refusé (clé erronée ou donnée altérée) : %w", err)
}
return pt, nil
}
func memNewGCM(key []byte) (cipher.AEAD, error) {
if len(key) != memDEKLen {
return nil, fmt.Errorf("clé de %d octets, %d attendus", len(key), memDEKLen)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
// encPage chiffre le contenu d'une page mémoire : magic || nonce || ciphertext.
// Le magic sert d'AAD (authentifié) et de marqueur de format.
func encPage(dek, plaintext []byte) ([]byte, error) {
sealed, err := gcmSeal(dek, plaintext, memPageMagic)
if err != nil {
return nil, err
}
out := make([]byte, 0, len(memPageMagic)+len(sealed))
out = append(out, memPageMagic...)
out = append(out, sealed...)
return out, nil
}
// decPage déchiffre une page produite par encPage. Renvoie errNotEncrypted si
// le magic manque (donc si on tombe sur une page restée en clair).
func decPage(dek, blob []byte) ([]byte, error) {
if !bytes.HasPrefix(blob, memPageMagic) {
return nil, errNotEncrypted
}
return gcmOpen(dek, blob[len(memPageMagic):], memPageMagic)
}
// looksEncrypted indique si un contenu disque est une page chiffrée (magic).
func looksEncrypted(blob []byte) bool { return bytes.HasPrefix(blob, memPageMagic) }
// memCryptoSelfTest vérifie que la chaîne crypto marche RÉELLEMENT sur cette
// machine/ce build avant qu'on chiffre la moindre donnée : round-trip DEK sur
// une page, plus un cycle KEK wrap/unwrap. Toute anomalie renvoie une erreur, et
// l'appelant DOIT alors refuser d'activer le chiffrement (on ne touche pas à la
// mémoire tant que ceci n'a pas réussi).
func memCryptoSelfTest() error {
dek, err := randBytes(memDEKLen)
if err != nil {
return err
}
sample := []byte("loki auto-test — éàçùô — 0123456789 — ✔")
enc, err := encPage(dek, sample)
if err != nil {
return fmt.Errorf("auto-test chiffrement page : %w", err)
}
dec, err := decPage(dek, enc)
if err != nil {
return fmt.Errorf("auto-test déchiffrement page : %w", err)
}
if !bytes.Equal(dec, sample) {
return errors.New("auto-test : round-trip page incohérent")
}
// Une DEK erronée DOIT échouer (sinon le GCM ne protège rien).
badKey, _ := randBytes(memDEKLen)
if _, err := decPage(badKey, enc); err == nil {
return errors.New("auto-test : une clé erronée a déchiffré (GCM cassé ?!)")
}
// Cycle KEK : wrap la DEK sous un mot de passe, puis déballe.
salt, err := randBytes(memSaltLen)
if err != nil {
return err
}
kek := deriveKEK("mot-de-passe-auto-test", salt, argonTime, argonMemory, argonThreads)
box, err := gcmSeal(kek, dek, []byte("loki-vault-selftest"))
if err != nil {
return fmt.Errorf("auto-test wrap DEK : %w", err)
}
got, err := gcmOpen(kek, box, []byte("loki-vault-selftest"))
if err != nil {
return fmt.Errorf("auto-test unwrap DEK : %w", err)
}
if !bytes.Equal(got, dek) {
return errors.New("auto-test : DEK déballée ≠ DEK d'origine")
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package loki
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMemCryptoSelfTest(t *testing.T) {
if err := memCryptoSelfTest(); err != nil {
t.Fatalf("auto-test crypto échoué : %v", err)
}
}
func TestEncDecPageRoundTrip(t *testing.T) {
dek, _ := randBytes(memDEKLen)
cases := []string{"", "court", "# titre\n\nlignes\néàçù ✔\n", strings.Repeat("x", 100000)}
for _, c := range cases {
enc, err := encPage(dek, []byte(c))
if err != nil {
t.Fatalf("encPage: %v", err)
}
if !looksEncrypted(enc) {
t.Fatal("looksEncrypted devrait être vrai sur une page chiffrée")
}
dec, err := decPage(dek, enc)
if err != nil {
t.Fatalf("decPage: %v", err)
}
if !bytes.Equal(dec, []byte(c)) {
t.Fatalf("round-trip incohérent pour %q", c[:min(len(c), 20)])
}
}
}
func TestDecPagePlaintextDetected(t *testing.T) {
dek, _ := randBytes(memDEKLen)
if _, err := decPage(dek, []byte("# page en clair")); err != errNotEncrypted {
t.Fatalf("attendu errNotEncrypted, obtenu %v", err)
}
}
func TestDecPageWrongKeyFails(t *testing.T) {
dek, _ := randBytes(memDEKLen)
bad, _ := randBytes(memDEKLen)
enc, _ := encPage(dek, []byte("secret"))
if _, err := decPage(bad, enc); err == nil {
t.Fatal("une clé erronée n'aurait pas dû déchiffrer")
}
}
func TestDecPageTamperedFails(t *testing.T) {
dek, _ := randBytes(memDEKLen)
enc, _ := encPage(dek, []byte("secret"))
enc[len(enc)-1] ^= 0xFF // altère le tag
if _, err := decPage(dek, enc); err == nil {
t.Fatal("un blob altéré n'aurait pas dû déchiffrer")
}
}
func TestWriteFileVerified(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "sub", "f.bin")
data := []byte("contenu vérifié éàç")
if err := memWriteFileVerified(p, data, 0o600); err != nil {
t.Fatalf("écriture vérifiée: %v", err)
}
back, _ := os.ReadFile(p)
if !bytes.Equal(back, data) {
t.Fatal("relecture ≠ écrit")
}
}
+76
View File
@@ -0,0 +1,76 @@
package loki
// mem_fsutil.go — écritures fichier sûres pour la mémoire : temporaire + fsync +
// rename atomique. Aucune donnée n'est jamais écrite « en place » par-dessus une
// ancienne : on écrit à côté, on force sur disque, puis on bascule d'un rename
// (atomique au niveau du système de fichiers).
import (
"fmt"
"os"
"path/filepath"
)
// memWriteFileAtomic écrit data dans path de façon atomique et durable. En cas
// d'échec, path conserve son ancien contenu (le temporaire est nettoyé).
func memWriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)+"-*")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() {
tmp.Close()
os.Remove(tmpName)
}
if _, err := tmp.Write(data); err != nil {
cleanup()
return err
}
if err := tmp.Sync(); err != nil { // durabilité : sur disque avant rename
cleanup()
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Chmod(tmpName, perm); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return err
}
return nil
}
// memWriteFileVerified écrit data atomiquement PUIS relit le fichier et compare
// octet pour octet. C'est la garantie « ce qui est sur le disque est bien ce
// qu'on voulait » avant toute suppression de l'original. En cas d'écart, le
// fichier écrit est retiré et une erreur est renvoyée.
func memWriteFileVerified(path string, data []byte, perm os.FileMode) error {
if err := memWriteFileAtomic(path, data, perm); err != nil {
return err
}
back, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("relecture de vérification impossible : %w", err)
}
if len(back) != len(data) {
os.Remove(path)
return fmt.Errorf("vérification échouée : %d octets relus, %d écrits", len(back), len(data))
}
for i := range back {
if back[i] != data[i] {
os.Remove(path)
return fmt.Errorf("vérification échouée : octet %d diffère", i)
}
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package loki
// mem_health.go — état de santé de la mémoire pour l'UI : chiffrement actif ?
// verrouillé ? toutes les pages déchiffrables ? combien de copies du keyvault ?
// dernier snapshot ? C'est le tableau de bord qui rassure d'un coup d'œil.
import (
"os"
)
// MemHealth résume l'état de la mémoire (sérialisé tel quel vers l'UI).
type MemHealth struct {
Encrypted bool `json:"encrypted"` // chiffrement activé (drapeau)
Fully bool `json:"fully"` // TOUT est réellement chiffré au repos (pages + conversations)
Locked bool `json:"locked"` // chiffré mais DEK absente
Pages int `json:"pages"` // nb de pages
Readable int `json:"readable"` // pages déchiffrables (ou claires)
Unreadable int `json:"unreadable"` // pages illisibles (chiffrées + verrouillé, ou corrompues)
Mixed bool `json:"mixed"` // mélange clair/chiffré (migration en cours ?)
VaultCopies int `json:"vault_copies"` // nb de copies du keyvault présentes (sur 3)
HasRecovery bool `json:"has_recovery"` // une clé de récupération est enregistrée
Snapshots int `json:"snapshots"` // nb de snapshots locaux
LastSnapshot string `json:"last_snapshot"` // ID du snapshot le plus récent
MigrationBusy bool `json:"migration_busy"` // une migration est en cours (journal présent)
}
// memHealth calcule l'état courant.
func memHealth() MemHealth {
h := MemHealth{Encrypted: memEncActive(), Locked: memEncActive() && !memUnlocked()}
clair, chiffre := 0, 0
for _, name := range mdPages() {
h.Pages++
p, _ := safeMemPath(name)
raw, err := os.ReadFile(p)
if err != nil {
h.Unreadable++
continue
}
if looksEncrypted(raw) {
chiffre++
if _, err := decodeMemContent(raw); err == nil {
h.Readable++
} else {
h.Unreadable++
}
} else {
clair++
h.Readable++
}
}
h.Mixed = clair > 0 && chiffre > 0
// Fully : la case « activer le chiffrement » ne doit être cochée QUE si TOUT est
// réellement chiffré au repos — aucune page en clair ET aucune valeur de
// conversation en clair. Vérifiable même verrouillé (looksEncrypted lit le magic
// sans la DEK). Un chiffrement partiel/raté laisse donc la case DÉCOCHÉE.
if h.Encrypted {
full := clair == 0 && h.Pages == chiffre // toutes les pages chiffrées (0 en clair)
if full {
for _, b := range encryptedBuckets {
if !bucketFullyEncrypted(b) {
full = false
break
}
}
}
h.Fully = full
}
// Copies du keyvault présentes.
if _, err := os.Stat(vaultPathPrimary()); err == nil {
h.VaultCopies++
}
if _, err := os.Stat(vaultPathBackup()); err == nil {
h.VaultCopies++
}
if len(getBytes(bkState, vaultDBKey)) > 0 {
h.VaultCopies++
}
if v, _ := loadVault(); v != nil {
h.HasRecovery = v.hasKind(wrapRecovery)
}
snaps := listSnapshots()
h.Snapshots = len(snaps)
if len(snaps) > 0 {
h.LastSnapshot = snaps[0].ID
}
_, h.MigrationBusy = readMigrationJournal()
return h
}
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more