Compare commits

..
Author SHA1 Message Date
Claude 96ef1ac95b Fusion de main : la suppression des modèles y a atterri, le sélecteur reste
main a livré entre-temps « un .gguf gardé se réutilise et s'efface »
(9bdaf00), qui couvre la suppression des modèles depuis l'éditeur avec un
index des références plus complet que celui de cette branche :
buildModelRefIndex lit aussi le --mmproj resté dans EXTRA_ARGS et le
construit en une passe, et deleteModelFile porte lui-même le refus « en
service ». Cette branche cède donc tout ce qui doublonnait.

Abandonné (repris par main) :
- modelSizeOnDisk, modelIsLoaded, modelPresetRefs, presetsUsingModel →
  modelFamilySize, modelUsers, buildModelRefIndex ;
- la liste « Modèles installés » (#m-mdl-*) et son CSS → celle de main
  (#m-inst-*), qui sélectionne aussi un modèle déjà téléchargé ;
- deletePresetModel → main supprime le .gguf partagé mais NOMME les autres
  presets concernés, plutôt que de conserver le fichier ;
- backend_models_delete_test.go → backend_models_refs_test.go couvre les
  mêmes cas, y compris les octets libérés d'une famille de tranches.

Conservé, c'est ce qui reste propre à cette branche : le sélecteur de
l'en-tête ne liste QUE des presets. Charger un .gguf n'écrivait que MODEL=,
laissant en place le contexte et l'échantillonnage du modèle précédent — un
27B par-dessus les réglages d'un 4B mourait en OOM sans explication.
/api/models/use disparaît avec le groupe qu'elle servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-12 22:26:34 +00:00
Claude 06f0e74c77 Modèles : les supprimer depuis l'interface, et ne charger que des presets
Un .gguf ne pouvait s'effacer qu'en cochant « supprimer aussi le fichier »
au moment de supprimer son preset. Tout ce qui avait été téléchargé sans
preset — une quantization essayée puis abandonnée, un projecteur du mauvais
dépôt — restait donc sur le disque pour des dizaines de gigaoctets, sans
aucun moyen de partir depuis Loki.

L'éditeur de preset porte désormais une ligne « Modèles installés » (repliée,
sous le sélecteur de fichier) : chaque .gguf des dossiers déclarés avec sa
taille, ses tranches, qui le réclame, et un bouton « supprimer ».

Rien n'est réversible ici, d'où deux garde-fous côté serveur, partagés par
/api/models/delete ET par la case de l'éditeur de preset :

  - le modèle CHARGÉ est intouchable — l'effacer sous le moteur ne rend pas
    l'espace tant qu'il tient le descripteur, et le démarrage suivant meurt
    sur un fichier introuvable ;
  - un modèle réclamé par des presets ne part qu'avec `force`, le refus 409
    nommant les presets pour que la seconde confirmation dise ce qu'on casse.
    Supprimer un preset ne peut donc plus emporter le modèle de son voisin.

La suppression emporte toute la famille de tranches et annonce la place
réellement libérée. /api/models marque au passage le modèle chargé et les
presets qui référencent chaque fichier.

Le sélecteur de l'en-tête ne liste plus que des presets. Il proposait aussi
les .gguf du disque, chargés en n'écrivant que MODEL= : le contexte, le NGL
et l'échantillonnage du modèle PRÉCÉDENT restaient en place, et un 27B chargé
par-dessus les réglages d'un 4B partait sur 100k de contexte pour mourir en
OOM sans que rien ne dise pourquoi. Charger un modèle, c'est appliquer TOUS
ses réglages : ça s'appelle un preset, et c'est le seul chemin. /api/models/use
disparaît avec le groupe qu'il servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-09 08:53:36 +00:00
128 changed files with 483 additions and 13590 deletions

No files matched your search

+1 -25
View File
@@ -26,31 +26,7 @@ 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. 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.
pour ce fork ; les prompts de rôles sont des réécritures originales.
## Services externes
+4 -84
View File
@@ -122,17 +122,6 @@ 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`…).
@@ -148,9 +137,7 @@ 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`) — 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/memory/` | pages de mémoire persistante (`.md`) |
| `/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`) |
@@ -188,29 +175,11 @@ 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). 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.
`mmproj` — voir [Installer un modèle](#installer-un-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
@@ -226,48 +195,7 @@ 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. 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`).
tâche attend son tour, et le bouton stop du chat l'interrompt.
- **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
@@ -300,15 +228,7 @@ 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`. **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.
`NOTICE.md`.
- **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
+34 -74
View File
@@ -1,90 +1,50 @@
# Loki 0.14.0
# Loki 0.12.0
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 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.
## Le contexte ne bloque plus la discussion
## Le sélecteur de l'en-tête charge vraiment un modèle
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.
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.
- 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.
## Dictée vocale
## Presets : API compatible OpenAI
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.
Les presets d'API externe gagnent ce qui leur manquait :
## Cartes raisonnement/outils : taille fixe, défilement qui suit
- 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.
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.
## Mode Code
## Mesures
- **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.
- 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).
## Tâches et chat
## Interface
- **« 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.
- 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.
## Sécurité et données
## Rappel 0.11.0
- **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.
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.
## Mise à jour
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 14,
"Patch": 0,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 14,
"Patch": 0,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.14.0",
"ProductVersion": "0.12.3",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
@@ -34,4 +34,4 @@
"CharsetID": "04B0"
}
}
}
}
+1 -3
View File
@@ -5,7 +5,6 @@ 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
@@ -14,7 +13,6 @@ 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 (
@@ -30,7 +28,6 @@ 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
@@ -48,6 +45,7 @@ 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,8 +6,6 @@ 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=
@@ -41,10 +39,8 @@ 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=
@@ -88,7 +84,6 @@ 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=
@@ -107,86 +102,24 @@ 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=
+1 -2
View File
@@ -10,7 +10,6 @@ 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.
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
- You may NOT mark a criterion passed — the verification pass does that.
- 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.
+6 -19
View File
@@ -24,11 +24,10 @@ import (
)
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
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
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
@@ -47,14 +46,6 @@ func isExternalConfig(cfg map[string]string) bool {
// 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 :
//
@@ -111,9 +102,8 @@ func (e chatEndpoint) auth(set func(k, v string)) {
// 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 {
// compaction, exactement comme pour un preset local.
func externalPresetContent(url, model, key, ctx string) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
@@ -122,9 +112,6 @@ func externalPresetContent(url, model, key, ctx string, vision bool) string {
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if vision {
m[extKeyVision] = "1"
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
+4 -53
View File
@@ -1,7 +1,6 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -47,7 +46,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
@@ -72,7 +71,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
@@ -143,7 +142,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
if err != nil {
t.Fatal(err)
}
@@ -180,7 +179,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
// 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))
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
if err != nil {
t.Fatal(err)
}
@@ -195,51 +194,3 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
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")
}
}
-3
View File
@@ -432,9 +432,6 @@ func DeletePreset(id string) error {
// 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
}
+13 -148
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
@@ -141,34 +140,22 @@ func hasAnyFlag(args []string, flags ...string) bool {
return false
}
// 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).
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
// --load-mode, sur les moteurs qui le connaissent :
//
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
// --mlock → --load-mode mlock (garder le modèle en RAM)
// --no-mmap → --load-mode none (tout charger, pas de mmap)
// --mmap → --load-mode mmap
//
// --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)
// 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é.
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
if !supportsLoadMode {
return downgradeLoadMode(args)
return args
}
explicit := hasAnyFlag(args, "--load-mode", "-lm")
explicit := hasAnyFlag(args, "--load-mode")
mlock, nommap, mmap := false, false, false
out := make([]string, 0, len(args)+2)
for _, a := range args {
@@ -188,62 +175,14 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
}
mode := "mmap"
switch {
case mlock && nommap:
mode = "mlock"
case mlock:
mode = "mmap+mlock"
mode = "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).
@@ -431,84 +370,10 @@ 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), port)
bin, filepath.Base(model), get("PORT", "8080"))
// 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)
}
}
+4 -44
View File
@@ -1,10 +1,8 @@
package loki
import (
"net"
"reflect"
"testing"
"time"
)
func TestHasAnyFlag(t *testing.T) {
@@ -33,25 +31,16 @@ func TestNormalizeLoadFlags(t *testing.T) {
supports bool
want []string
}{
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
[]string{"--mlock", "--no-mmap"}},
// 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"}},
{"mlock traduit", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mlock"}},
{"no-mmap traduit", []string{"--no-mmap"}, true,
[]string{"--load-mode", "none"}},
{"mmap traduit", []string{"--mmap"}, true,
[]string{"--load-mode", "mmap"}},
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
{"mlock l'emporte sur no-mmap", []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"}},
@@ -102,32 +91,3 @@ func TestNGLArgs(t *testing.T) {
})
}
}
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
@@ -1,220 +0,0 @@
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
@@ -1,75 +0,0 @@
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
@@ -1,129 +0,0 @@
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})
}
}
+3 -153
View File
@@ -8,7 +8,6 @@ import (
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
)
@@ -380,7 +379,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
}
}
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
@@ -393,18 +392,8 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// 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: sumMsg},
{Role: "user", Content: compactSummaryUserMsg(summary, index)},
{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."},
}
}
@@ -419,23 +408,8 @@ 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 && summarized && !tailHasUser; i-- {
for i := len(torso) - 1; i >= 0; i-- {
if torso[i].Role != "user" {
continue
}
@@ -597,20 +571,6 @@ 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.
@@ -623,9 +583,6 @@ Summarize densely and faithfully, keeping ONLY the essentials:
- 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. 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
@@ -648,11 +605,6 @@ Write the summary in the SAME language as the conversation.`
// 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)
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
@@ -697,105 +649,3 @@ 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
}
@@ -1,69 +0,0 @@
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))
}
}
-75
View File
@@ -1,10 +1,6 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
@@ -181,74 +177,3 @@ 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")
}
}
+12 -374
View File
@@ -3,7 +3,6 @@ package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
@@ -69,27 +68,6 @@ 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 {
@@ -150,11 +128,7 @@ func LoadConversation() {
}
// 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 {
if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
break
}
retry()
@@ -175,7 +149,6 @@ 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
@@ -189,12 +162,10 @@ 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++
@@ -202,27 +173,11 @@ 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
@@ -236,10 +191,7 @@ func (c *Conversation) persist() {
return
}
id := convEnsureActive()
// 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)
_ = putBytes(bkChat, convKey(id), b)
convTouchMeta(id, title, turns)
}
@@ -386,12 +338,6 @@ 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
@@ -529,154 +475,10 @@ 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 {
cfg := ReadConfig()
if isExternalConfig(cfg) {
return strings.TrimSpace(cfg[extKeyModel]) // modèle distant, tel quel
}
m := strings.TrimSpace(cfg["MODEL"])
m := strings.TrimSpace(ReadConfig()["MODEL"])
if m == "" {
return ""
}
@@ -695,7 +497,6 @@ 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
@@ -717,26 +518,6 @@ 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.
@@ -761,20 +542,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// 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 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...)
}
final := append(projectSystemMessages(), msgs...)
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.
@@ -788,19 +558,11 @@ 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,
@@ -814,19 +576,10 @@ 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
@@ -857,7 +610,6 @@ 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})
@@ -868,7 +620,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
@@ -907,7 +659,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, asked)
c.codeVerifyLoop(ctx, caps, temperature, epoch)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
@@ -1009,7 +761,6 @@ 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()
@@ -1155,76 +906,11 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
return out
}
// 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é
// Subscribe 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) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
// Réveille les attentes de cond quand la connexion se ferme.
go func() {
<-ctx.Done()
@@ -1256,39 +942,8 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, co
// 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) {
@@ -1302,7 +957,7 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, co
last = s
}
}
if !emit(map[string]any{"caught_up": true, "id": curID}) {
if !emit(map[string]any{"caught_up": true}) {
return
}
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
@@ -1335,27 +990,10 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, co
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()
// id : la discussion désormais affichée (lue hors verrou — base).
if !emit(map[string]any{"reset": true, "id": getStr(bkChat, ckActive)}) {
if !emit(map[string]any{"reset": true}) {
return
}
for _, ev := range head {
if !emit(ev) {
return
}
}
c.mu.Lock()
continue
}
+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 := storeBytes(bkChat, convKey(id))
b := getBytes(bkChat, convKey(id))
if len(b) == 0 {
return nil
}
+24 -83
View File
@@ -5,18 +5,12 @@ 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à (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 = "…"
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é)
)
// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
@@ -25,69 +19,26 @@ type DiffLine struct {
Text string `json:"text"`
}
// 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) {
// 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 {
a := splitLines(oldText)
b := splitLines(newText)
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})
// 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})
}
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++
for _, l := range b {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out)
}
// 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 {
// LCS : table des longueurs, puis remontée.
n, m := len(a), len(b)
lcs := make([][]int, n+1)
for i := range lcs {
@@ -126,37 +77,27 @@ func lcsDiff(a, b []string) []DiffLine {
for ; j < m; j++ {
out = append(out, DiffLine{Op: "+", Text: b[j]})
}
return out
return capLines(out)
}
// 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 {
// 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 {
out = append(out, DiffLine{Op: "+", Text: l})
}
return capLines(out), len(ls)
return capLines(out)
}
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 {
@@ -164,7 +105,7 @@ func capLines(d []DiffLine) []DiffLine {
}
cut := len(d) - diffMaxShown
out := append([]DiffLine{}, d[:diffMaxShown]...)
return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"})
return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
}
func itoa(n int) string {
-68
View File
@@ -1,68 +0,0 @@
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)
}
}
-80
View File
@@ -1,80 +0,0 @@
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
@@ -1,267 +0,0 @@
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
@@ -1,95 +0,0 @@
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 mcpOutputCap pour MCP. Sans ce
// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) 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
@@ -1,163 +0,0 @@
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
@@ -1,121 +0,0 @@
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")
}
}
}
+53 -69
View File
@@ -89,15 +89,8 @@ func MemList() []MemPage {
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
continue
}
// 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})
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
@@ -132,7 +125,8 @@ func MemSearch(query string, limit int) []MemHit {
docs := make([]doc, 0)
df := make(map[string]int) // nb de pages contenant chaque terme
for _, p := range MemList() {
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
content := string(b)
d := doc{
p: p,
name: strings.ToLower(p.Name),
@@ -252,11 +246,12 @@ 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) {
b, err := memReadPage(name)
p, err := safeMemPath(name)
if err != nil {
return "", err
}
b, err := os.ReadFile(p)
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")
@@ -283,8 +278,11 @@ 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 {
return err
}
body := strings.TrimRight(content, "\n") + "\n"
if err := writeMemFile(name, []byte(body)); err != nil {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
return err
}
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
@@ -300,11 +298,12 @@ 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 {
b, err := memReadPage(name)
p, err := safeMemPath(name)
if err != nil {
return err
}
b, err := os.ReadFile(p)
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)
@@ -324,13 +323,17 @@ 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 writeMemFile(name, []byte(updated))
return os.WriteFile(p, []byte(updated), 0o644)
}
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
// l'éditeur web.
func MemContent(name string) string {
b, err := memReadPage(name)
p, err := safeMemPath(name)
if err != nil {
return ""
}
b, err := os.ReadFile(p)
if err != nil {
return ""
}
@@ -350,8 +353,15 @@ 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"
if err := writeMemFile(name, []byte(body)); err != nil {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
return err
}
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
@@ -377,69 +387,47 @@ func MemDelete(name string) error {
}
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
// 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
// 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
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
// - 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.
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
type MemMode string
const (
MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always"
MemSearchFirst MemMode = "search"
MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always"
)
// 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)) {
// 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"])) {
case "off":
return MemOff
case "ondemand":
return MemOnDemand
case "search":
return MemSearchFirst
default: // "always", "auto", "" et inconnus
return MemAlways
}
}
// 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"])
}
// setMemMode persiste le mode mémoire du PROJET ACTIF.
// setMemMode persiste le mode mémoire dans config.env.
func setMemMode(m MemMode) error {
return setProjectMemMode(activeProjectSlug(), string(m))
return SetConfigKey("MEM_MODE", string(m))
}
// cmdMemory : loki memory [off|ondemand|always|search|status]
// cmdMemory : loki memory [off|ondemand|always|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/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)",
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)",
}
switch sub {
case "off":
@@ -450,24 +438,20 @@ func cmdMemory(args []string) error {
if err := setMemMode(MemOnDemand); err != nil {
return err
}
case "always", "auto", "inject", "injected":
case "always":
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 projet %s, mode: %s — %s\n", cyan("Mémoire"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), 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|search|status]")
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]")
}
m := memMode()
fmt.Printf("%s mémoire du projet %s : %s — %s\n", green("[ok]"), bold(projectName(activeProjectSlug())), bold(string(m)), label[m])
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
return nil
}
+3 -20
View File
@@ -26,27 +26,10 @@ import (
// 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é
// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne
// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt.
const promptCharBudget = 8600 // ~2150 tokens, tout allumé
func TestSystemPromptStaysLean(t *testing.T) {
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
-82
View File
@@ -1,82 +0,0 @@
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)
}
}
+4 -26
View File
@@ -84,14 +84,8 @@ func recallIDToSeq(id string) (uint64, bool) {
// (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 {
err := withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
if err != nil {
@@ -114,15 +108,7 @@ func archiveRecallBlock(label, role, content string) (string, error) {
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)
return b.Put(recallKey(seq), raw)
})
})
if err != nil {
@@ -145,11 +131,7 @@ func recallGet(id string) (recallBlock, bool) {
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
found = json.Unmarshal(v, &blk) == nil
return nil
})
return blk, found
@@ -179,11 +161,7 @@ func recallSearch(query string, limit int) []recallBlock {
_ = 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 {
if json.Unmarshal(v, &blk) != nil {
return nil
}
label := strings.ToLower(blk.Label)
+4 -10
View File
@@ -16,6 +16,7 @@ package loki
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -117,11 +118,6 @@ 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 {
@@ -217,13 +213,11 @@ 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."},
imageURLPart(b, mime),
{"type": "image_url", "image_url": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
}},
}}, true
}
-175
View File
@@ -1,175 +0,0 @@
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
@@ -1,103 +0,0 @@
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")
}
}
+6 -10
View File
@@ -101,10 +101,7 @@ func tagOrphanConversations(slug string) {
func convIndex() []convMeta {
var idx []convMeta
// 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 {
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
_ = json.Unmarshal(b, &idx)
}
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
@@ -118,7 +115,7 @@ func convIndexSave(idx []convMeta) {
if err != nil {
return
}
_ = putStoreBytes(bkChat, ckIndex, b)
_ = putBytes(bkChat, ckIndex, b)
}
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
@@ -131,7 +128,7 @@ 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.
_ = putStoreBytes(bkChat, convKey(id), legacy)
_ = putBytes(bkChat, convKey(id), legacy)
}
_ = putStr(bkChat, ckActive, id)
now := time.Now().Unix()
@@ -218,7 +215,7 @@ func projectSwitch(slug string) error {
}
// 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)))
conv.loadFrom(getBytes(bkChat, convKey(list[0].ID)))
return nil
}
@@ -246,7 +243,7 @@ func convSwitch(id string) error {
}
conv.persist() // fige la discussion qu'on quitte
_ = putStr(bkChat, ckActive, id)
conv.loadFrom(storeBytes(bkChat, convKey(id)))
conv.loadFrom(getBytes(bkChat, convKey(id)))
return nil
}
@@ -318,7 +315,6 @@ 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
}
@@ -327,6 +323,6 @@ func convDelete(id string) error {
return nil
}
_ = putStr(bkChat, ckActive, next[0].ID)
conv.loadFrom(storeBytes(bkChat, convKey(next[0].ID)))
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
return nil
}
-60
View File
@@ -1,60 +0,0 @@
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
@@ -1,58 +0,0 @@
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)
}
}
+8 -75
View File
@@ -56,7 +56,7 @@ func baseSystemPrompt(caps Caps) string {
b.WriteString("\n\n" + rp)
}
}
if memProactive(caps.Mem) {
if caps.Mem == MemAlways {
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,15 +76,7 @@ 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")
// 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")
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")
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")
}
@@ -97,14 +89,7 @@ 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")
// 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 {
if caps.Mem == MemAlways {
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")
}
}
@@ -116,9 +101,6 @@ 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)
@@ -195,7 +177,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 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.")
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.")
}
return b.String()
}
@@ -210,24 +192,12 @@ 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)
@@ -258,27 +228,9 @@ 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):
// 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
return fmt.Sprintf("[timeout après %ds]", timeoutSec)
case errors.Is(parent.Err(), context.Canceled):
return "[commande interrompue]"
}
@@ -290,8 +242,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
return fmt.Sprintf("[erreur: %v]", err)
}
}
// « 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))}
out := tailRunes(stdout.String(), toolMaxOutput)
errOut := tailRunes(stderr.String(), toolMaxOutput)
parts := []string{fmt.Sprintf("exit: %d", exit)}
if out != "" {
parts = append(parts, "stdout:\n"+out)
}
@@ -321,10 +274,6 @@ 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()
@@ -363,10 +312,6 @@ 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()
@@ -412,18 +357,6 @@ 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)
+2 -18
View File
@@ -80,17 +80,7 @@ func toolSeeImage(path string) (string, map[string]any) {
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{
return "[ok] image chargée : " + filepath.Base(abs), map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
@@ -103,14 +93,8 @@ func imageURLPart(b []byte, mime string) map[string]any {
// 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},
{"type": "text", "text": "Image demandée (" + label + ") :"},
img,
}}
}
-102
View File
@@ -1,102 +0,0 @@
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
@@ -1,56 +0,0 @@
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)
}
}
}
+8 -30
View File
@@ -13,7 +13,6 @@ package loki
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
@@ -21,7 +20,7 @@ import (
type Criterion struct {
ID int `json:"id"`
Text string `json:"text"`
Status string `json:"status"` // pending | completed (le builder dit « fait ») | passed | failed
Status string `json:"status"` // pending | passed | failed
Note string `json:"note,omitempty"`
}
@@ -31,7 +30,7 @@ func critKey(convID string) string { return "crit:" + convID }
func critList(convID string) []Criterion {
var list []Criterion
if b, ok := getStoreBytes(bkChat, critKey(convID)); ok && len(b) > 0 {
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
_ = json.Unmarshal(b, &list)
}
return list
@@ -46,7 +45,7 @@ func critSave(convID string, list []Criterion) {
if err != nil {
return
}
_ = putStoreBytes(bkChat, critKey(convID), b)
_ = putBytes(bkChat, critKey(convID), b)
}
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
@@ -75,18 +74,6 @@ 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
@@ -97,8 +84,6 @@ 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 != "" {
@@ -115,15 +100,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, 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.",
"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.",
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", "completed", "passed", "failed"}},
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
},
"required": []string{"action"},
@@ -173,15 +158,8 @@ 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":
// 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), "#"))
}
idf, _ := args["id"].(float64)
id := int(idf)
status, _ := args["status"].(string)
note, _ := args["note"].(string)
if status == "passed" && !allowPass {
+7 -59
View File
@@ -25,10 +25,8 @@ 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{
"dir": map[string]any{"type": "string", "description": "Repository subfolder (default: auto-detected)"},
},
"type": "object",
"properties": map[string]any{},
},
},
}
@@ -44,7 +42,6 @@ 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)"},
},
},
},
@@ -69,56 +66,15 @@ 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 = dir
cmd.Dir = agentCwd()
// 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")
@@ -139,32 +95,24 @@ func runGitIn(ctx context.Context, dir string, timeout time.Duration, args ...st
return s
}
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 toolGitStatus(ctx context.Context) string {
return runGit(ctx, 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 := runGitIn(ctx, dir, 30*time.Second, gitArgs...)
out := runGit(ctx, 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 = runGitIn(ctx, dir, 30*time.Second, fallback...)
out = runGit(ctx, 30*time.Second, fallback...)
}
return out
}
-55
View File
@@ -1,55 +0,0 @@
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
@@ -1,34 +0,0 @@
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
@@ -1,86 +0,0 @@
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)
}
}
+8 -36
View File
@@ -14,9 +14,7 @@ package loki
import (
"encoding/json"
"regexp"
"strings"
"sync"
"unicode/utf8"
)
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
@@ -31,10 +29,6 @@ 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
@@ -73,40 +67,18 @@ 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 {
return textualToolCallSnippet(content) != ""
}
// 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 ""
return false
}
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])
if re.MatchString(content) {
return true
}
}
return ""
return false
}
// 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."
}
// 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."
+1 -68
View File
@@ -1,16 +1,6 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
import "testing"
// Les appels d'outils écrits en texte doivent être détectés…
func TestAppelOutilTextuelDetecte(t *testing.T) {
@@ -42,60 +32,3 @@ 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
@@ -1,131 +0,0 @@
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
@@ -1,56 +0,0 @@
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,28 +157,3 @@ 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é")
}
}
+11 -70
View File
@@ -25,19 +25,10 @@ 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), 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 {
// 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 {
return
}
convID := convEnsureActive()
@@ -49,24 +40,6 @@ 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
@@ -86,9 +59,7 @@ func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperatur
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
return
}
if c.runFixTurn(ctx, caps, temperature, epoch, list) {
return // le builder attend une réponse de l'utilisateur
}
c.runFixTurn(ctx, caps, temperature, epoch, list)
}
}
@@ -120,59 +91,38 @@ 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. 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 {
// 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) {
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: instruction}
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."}
c.mu.Lock()
if c.epoch != epoch {
c.mu.Unlock()
return false
return
}
c.Messages = append(c.Messages, fixMsg)
msgs := append([]Message(nil), c.Messages...)
c.mu.Unlock()
// 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...)
}
final := msgs
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, final...)
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
}
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
})
@@ -185,7 +135,6 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
}
c.mu.Unlock()
c.persist()
return asked
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
@@ -205,18 +154,10 @@ 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
@@ -1,110 +0,0 @@
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
@@ -1,70 +0,0 @@
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
@@ -1,895 +0,0 @@
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
@@ -1,422 +0,0 @@
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
@@ -1,22 +0,0 @@
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
@@ -1,404 +0,0 @@
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
}
-24
View File
@@ -44,12 +44,6 @@ 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)
@@ -164,24 +158,6 @@ 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
@@ -1,17 +0,0 @@
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,6 +89,3 @@ 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
@@ -1,167 +0,0 @@
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)
}
}
+1 -12
View File
@@ -43,11 +43,6 @@ const (
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
@@ -62,7 +57,7 @@ func llmRetryableErr(ctx context.Context, err error) bool {
return false
}
switch {
case errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, wsaECONNREFUSED): // moteur en cours de démarrage
case errors.Is(err, syscall.ECONNREFUSED): // 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
@@ -127,9 +122,3 @@ func llmNetBackoff(ctx context.Context, n int) error {
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)
}
-26
View File
@@ -22,7 +22,6 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -146,35 +145,10 @@ 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 {
+4 -14
View File
@@ -48,20 +48,10 @@ 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
@@ -409,8 +399,8 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
return "", err
}
out := flattenMCPContent(res)
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 r := []rune(out); len(r) > mcpMaxOutput {
out = string(r[:mcpMaxOutput]) + "\n…[tronqué]"
}
if res.IsError {
return "[l'outil a renvoyé une erreur]\n" + out, nil
-192
View File
@@ -1,192 +0,0 @@
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
@@ -1,74 +0,0 @@
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
@@ -1,76 +0,0 @@
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
@@ -1,92 +0,0 @@
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
}
-142
View File
@@ -1,142 +0,0 @@
package loki
// mem_io.go — couche d'accès transparente aux pages mémoire. C'est le SEUL
// endroit qui sait si une page est chiffrée ou non : chat_memory.go passe par
// ici et n'a jamais à s'en soucier.
//
// Choix d'implémentation : le NOM du fichier ne change pas (toujours foo.md).
// C'est le CONTENU qui est chiffré en place, précédé d'un magic (voir
// mem_crypto.go) qui rend chaque fichier auto-descriptif. Avantage : rien à
// changer dans le listing, la recherche ou la validation de chemins — la
// détection se fait sur le contenu, pas sur l'extension.
import (
"errors"
"os"
"strings"
)
// errMemLocked : page chiffrée alors que la mémoire est verrouillée (DEK absente).
var errMemLocked = errors.New("page chiffrée, mémoire verrouillée")
// memEncActive indique si le chiffrement de la mémoire est activé (réglage
// MEM_ENCRYPTED). Indépendant de l'état verrouillé/déverrouillé.
func memEncActive() bool {
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_ENCRYPTED"])) {
case "1", "on", "true", "yes", "oui":
return true
}
return false
}
// decodeMemContent transforme le contenu BRUT d'un fichier en texte lisible :
// - pas chiffré (pas de magic) : renvoyé tel quel.
// - chiffré + DEK en RAM : déchiffré.
// - chiffré + verrouillé : errMemLocked.
func decodeMemContent(raw []byte) ([]byte, error) {
if !looksEncrypted(raw) {
return raw, nil
}
dek, err := currentDEK()
if err != nil {
return nil, errMemLocked
}
return decPage(dek, raw)
}
// memReadPage lit une page par son nom et renvoie son texte clair. Erreur si le
// fichier est absent, ou chiffré alors que la mémoire est verrouillée.
func memReadPage(name string) ([]byte, error) {
p, err := safeMemPath(name)
if err != nil {
return nil, err
}
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
return decodeMemContent(raw)
}
// memPageText lit une page et renvoie (texte, lisible). Ne renvoie jamais
// d'erreur : une page chiffrée non déchiffrable donne readable=false (utile pour
// lister/chercher sans planter quand la mémoire est verrouillée).
func memPageText(name string) (string, bool) {
b, err := memReadPage(name)
if err != nil {
return "", false
}
return string(b), true
}
// encodeMemContent prépare le contenu à écrire sur disque selon l'état du
// chiffrement : chiffré si activé ET déverrouillé, clair sinon. Renvoie une
// erreur si le chiffrement est activé mais la mémoire verrouillée (on refuse
// alors d'écrire, plutôt que d'écrire en clair une donnée censée être chiffrée).
func encodeMemContent(plain []byte) ([]byte, error) {
if !memEncActive() {
return plain, nil
}
dek, err := currentDEK()
if err != nil {
return nil, errMemLocked
}
return encPage(dek, plain)
}
// memEncoderNow résout l'état du chiffrement (drapeau + DEK) AVANT toute
// transaction, et renvoie un encodeur PUR. C'est la seule façon de chiffrer une
// valeur écrite À L'INTÉRIEUR d'une transaction bbolt : la base n'est pas
// rentrante — memEncActive lit la configuration, donc rouvrirait la base et se
// bloquerait sur son propre verrou (vécu sur l'archivage des blocs recall).
func memEncoderNow() (func([]byte) ([]byte, error), error) {
if !memEncActive() {
return func(p []byte) ([]byte, error) { return p, nil }, nil
}
dek, err := currentDEK()
if err != nil {
return nil, errMemLocked
}
return func(p []byte) ([]byte, error) { return encPage(dek, p) }, nil
}
// writeMemFile écrit (crée ou remplace) une page de façon sûre : encode selon
// l'état du chiffrement, garde un .bak de la version précédente, écrit de façon
// atomique et vérifiée. Un échec laisse l'ancienne version intacte.
func writeMemFile(name string, plain []byte) error {
p, err := safeMemPath(name)
if err != nil {
return err
}
out, err := encodeMemContent(plain)
if err != nil {
return err
}
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
return err
}
// .bak de la version précédente (best-effort) : filet en cas de pépin juste
// après le rename.
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
return memWriteFileVerified(p, out, 0o600)
}
// writeMemPlain écrit une page EN CLAIR quel que soit l'état du chiffrement.
// Réservé au déchiffrement (DisableMemEncryption) : il faut pouvoir poser du
// clair pendant que le drapeau MEM_ENCRYPTED est encore levé, sinon writeMemFile
// re-chiffrerait ce qu'on cherche justement à déchiffrer.
func writeMemPlain(name string, plain []byte) error {
p, err := safeMemPath(name)
if err != nil {
return err
}
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
return err
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
return memWriteFileVerified(p, plain, 0o600)
}
-340
View File
@@ -1,340 +0,0 @@
package loki
// mem_migrate.go — bascule chiffrer / déchiffrer la mémoire, sans jamais risquer
// de la perdre. Règles :
// - auto-test crypto AVANT de toucher quoi que ce soit ;
// - snapshot complet AVANT toute bascule ;
// - écriture atomique + relecture vérifiée de CHAQUE page ;
// - journal de migration pour reprendre proprement après un crash ;
// - une page à moitié migrée reste lisible : le contenu est auto-descriptif
// (magic), donc pages chiffrées et pages claires coexistent sans danger.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
func migrationJournalPath() string { return filepath.Join(memoryDir(), ".migration") }
type migrationJournal struct {
Op string `json:"op"` // "encrypt" | "decrypt"
StartedAt time.Time `json:"started_at"`
}
func writeMigrationJournal(op string) error {
b, _ := json.Marshal(migrationJournal{Op: op, StartedAt: time.Now().UTC()})
return memWriteFileAtomic(migrationJournalPath(), b, 0o600)
}
func clearMigrationJournal() { os.Remove(migrationJournalPath()) }
func readMigrationJournal() (*migrationJournal, bool) {
b, err := os.ReadFile(migrationJournalPath())
if err != nil {
return nil, false
}
var j migrationJournal
if json.Unmarshal(b, &j) != nil {
return nil, false
}
return &j, true
}
// mdPages liste les noms de fichiers .md du dossier mémoire (les pages) du projet
// ACTIF. Utilisé par les affichages ; le chiffrement, lui, passe par
// allMemPageFiles pour couvrir TOUS les projets.
func mdPages() []string {
entries, err := os.ReadDir(memoryDir())
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
out = append(out, e.Name())
}
}
return out
}
// allMemPageFiles renvoie les chemins ABSOLUS de toutes les pages .md de TOUS les
// projets (memory/<slug>/*.md). Sert au chiffrement/déchiffrement global : la DEK
// est unique, mais les pages sont réparties par projet.
func allMemPageFiles() []string {
var out []string
roots, err := os.ReadDir(projectsRoot())
if err != nil {
return nil
}
for _, d := range roots {
if !d.IsDir() {
continue
}
dir := filepath.Join(projectsRoot(), d.Name())
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
out = append(out, filepath.Join(dir, e.Name()))
}
}
}
return out
}
// EnableMemEncryption chiffre la mémoire. Génère la DEK, la wrappe sous le mot de
// passe ET sous une clé de récupération (renvoyée : à montrer UNE fois à
// l'utilisateur), puis chiffre chaque page en vérifiant. Idempotent-safe : si
// déjà activé, renvoie une erreur claire.
func EnableMemEncryption(password string) (recoveryKey string, err error) {
if strings.TrimSpace(password) == "" {
return "", fmt.Errorf("mot de passe vide")
}
if memEncActive() {
return "", fmt.Errorf("le chiffrement est déjà activé")
}
// GARDE-FOU ANTI-PERTE : refuser de créer un nouveau coffre si un coffre existe
// DÉJÀ sur le disque, même quand le drapeau MEM_ENCRYPTED a disparu. Sinon un
// « ré-activer » (drapeau perdu, ex. effacé par un changement de preset) forgeait
// une NOUVELLE DEK et re-chiffrait par-dessus : les pages déjà chiffrées sous
// l'ancienne DEK devenaient définitivement illisibles. Ici on impose de
// DÉVERROUILLER l'existant (unlock) au lieu de re-chiffrer. C'est ce bug qui a
// orphelin des pages le 2026-08-25.
if vaultExists() {
return "", fmt.Errorf("un coffre de chiffrement existe déjà : déverrouille la mémoire (unlock) au lieu de la re-chiffrer")
}
if err := memCryptoSelfTest(); err != nil {
return "", fmt.Errorf("auto-test crypto échoué, chiffrement refusé : %w", err)
}
if _, err := snapshotMemory("avant-chiffrement"); err != nil {
return "", fmt.Errorf("snapshot de sécurité impossible, chiffrement annulé : %w", err)
}
v, dek, err := newVault()
if err != nil {
return "", err
}
if err := v.addSecretWrap(dek, wrapPassword, "principal", password); err != nil {
return "", err
}
recoveryKey, err = newRecoveryKey()
if err != nil {
return "", err
}
if err := v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(recoveryKey)); err != nil {
return "", err
}
if err := saveVault(v); err != nil {
return "", fmt.Errorf("écriture du keyvault : %w", err)
}
// À partir d'ici la DEK est en RAM et le chiffrement est actif : writeMemFile
// chiffrera. On journalise, on migre chaque page, on lève le journal.
setMemDEK(dek)
if err := SetConfigKey("MEM_ENCRYPTED", "1"); err != nil {
return "", err
}
if err := writeMigrationJournal("encrypt"); err != nil {
return "", err
}
if err := reencryptPlaintextPages(); err != nil {
// Journal laissé en place : la reprise au démarrage réessaiera. Les pages
// déjà chiffrées sont valides, les pages claires restent lisibles.
return recoveryKey, fmt.Errorf("chiffrement partiel (sera repris) : %w", err)
}
// Étend le chiffrement aux conversations (fil courant + archives + index).
if err := reencryptChatStores(); err != nil {
return recoveryKey, fmt.Errorf("chiffrement conversations partiel (sera repris) : %w", err)
}
clearMigrationJournal()
scrubPlaintextResidue() // sinon le texte en clair traînerait à côté du chiffré
return recoveryKey, nil
}
// scrubPlaintextResidue efface le texte en clair laissé sur le disque APRÈS un
// chiffrement réussi et vérifié : les .bak clairs (copies de sauvegarde d'une
// écriture) et le snapshot « avant-chiffrement » (copie intégrale en clair).
// Sans ça, chiffrer ne servirait à rien — le clair resterait lisible à côté.
// Sûr : les pages chiffrées ont été relues et validées une par une, le keyvault
// (3 copies + clé de récupération) garantit la récupération.
func scrubPlaintextResidue() {
// .bak clairs de TOUS les projets (memory/<slug>/*.bak).
if roots, err := os.ReadDir(projectsRoot()); err == nil {
for _, d := range roots {
if !d.IsDir() {
continue
}
dir := filepath.Join(projectsRoot(), d.Name())
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".bak") {
continue
}
p := filepath.Join(dir, e.Name())
if b, err := os.ReadFile(p); err == nil && !looksEncrypted(b) {
os.Remove(p) // .bak en clair : à retirer
}
}
}
}
for _, s := range listSnapshots() {
if strings.Contains(s.Reason, "avant-chiffrement") {
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
}
}
}
// reencryptPlaintextPages (re)chiffre toutes les pages encore en clair, DANS TOUS
// LES PROJETS, et vérifie chacune par relecture. Sûr à rejouer.
func reencryptPlaintextPages() error {
dek, err := currentDEK()
if err != nil {
return fmt.Errorf("DEK indisponible : %w", err)
}
for _, p := range allMemPageFiles() {
raw, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("lecture %s : %w", p, err)
}
if looksEncrypted(raw) {
continue // déjà fait
}
enc, err := encPage(dek, raw)
if err != nil {
return fmt.Errorf("chiffrement %s : %w", p, err)
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
if err := memWriteFileVerified(p, enc, 0o600); err != nil {
return fmt.Errorf("écriture chiffrée %s : %w", p, err)
}
// Vérifie que la page se relit et se déchiffre à l'identique.
back, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("relecture %s : %w", p, err)
}
plain, err := decPage(dek, back)
if err != nil || string(plain) != string(raw) {
return fmt.Errorf("vérification post-chiffrement de %s échouée", p)
}
}
return nil
}
// DisableMemEncryption remet la mémoire en clair. Exige que la mémoire soit
// déverrouillée (DEK en RAM). Snapshot d'abord, puis chaque page est déchiffrée,
// écrite en clair et vérifiée ; enfin le keyvault est retiré.
func DisableMemEncryption() error {
if !memEncActive() {
return fmt.Errorf("le chiffrement n'est pas activé")
}
if !memUnlocked() {
return fmt.Errorf("mémoire verrouillée : déverrouille-la avant de déchiffrer")
}
if _, err := snapshotMemory("avant-dechiffrement"); err != nil {
return fmt.Errorf("snapshot de sécurité impossible, déchiffrement annulé : %w", err)
}
if err := writeMigrationJournal("decrypt"); err != nil {
return err
}
// On garde le drapeau LEVÉ et la DEK en RAM pendant toute l'opération : on
// écrit le clair via writeMemPlain (qui ignore le drapeau), on vérifie CHAQUE
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
// levé, donc DisableMemEncryption peut être relancé).
if err := decryptAllPages(); err != nil {
return err
}
if err := decryptChatStores(); err != nil {
return err
}
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
// retirer le keyvault et purger la DEK.
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
return err
}
removeVault()
clearMemDEK()
clearMigrationJournal()
return nil
}
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
func decryptAllPages() error {
for _, p := range allMemPageFiles() {
raw, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("lecture %s : %w", p, err)
}
if !looksEncrypted(raw) {
continue // déjà en clair
}
plain, err := decodeMemContent(raw)
if err != nil {
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
return fmt.Errorf("écriture claire %s : %w", p, err)
}
back, err := os.ReadFile(p)
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
}
}
return nil
}
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
// prochain déverrouillage. Ne perd jamais de données.
func resumeMemMigration() {
j, ok := readMigrationJournal()
if !ok {
return
}
switch j.Op {
case "encrypt":
if memEncActive() && memUnlocked() {
if err := reencryptPlaintextPages(); err == nil {
_ = reencryptChatStores()
clearMigrationJournal()
scrubPlaintextResidue()
}
}
case "decrypt":
// Déchiffrement interrompu : si la mémoire est déverrouillée et le drapeau
// encore levé, on termine proprement (écrit le clair restant), puis on
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
// et l'écran de santé signalera le mélange.
if memEncActive() && memUnlocked() {
if err := decryptAllPages(); err == nil {
_ = SetConfigKey("MEM_ENCRYPTED", "")
removeVault()
clearMemDEK()
clearMigrationJournal()
}
}
}
}
-263
View File
@@ -1,263 +0,0 @@
package loki
import (
"os"
"strings"
"testing"
)
// seedPages écrit quelques pages en clair dans un $LOKI_HOME de test.
func seedPages(t *testing.T) map[string]string {
t.Helper()
pages := map[string]string{
"projet.md": "# Projet\n\nUn secret très important à ne jamais perdre.\n",
"copine.md": "# Perso\n\nAnniversaire le 3 mars. éàçù ✔\n",
"vide.md": "# Vide\n",
}
for name, content := range pages {
if err := MemAdd(name, content); err != nil {
t.Fatalf("MemAdd %s: %v", name, err)
}
}
// MemAdd maintient désormais l'index MEMORY.md par le code : il existe donc sur
// disque comme les autres pages. On l'ajoute à l'ensemble attendu (avec son
// contenu réel) pour que les comptes et les lectures le prennent en compte.
if idx := MemContent(memIndexFile); idx != "" {
pages[memIndexFile] = idx
}
return pages
}
func TestEnableEncryptReadDisable(t *testing.T) {
testHome(t)
clearMemDEK()
pages := seedPages(t)
rec, err := EnableMemEncryption("motdepasse-fort")
if err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
if strings.TrimSpace(rec) == "" {
t.Fatal("clé de récupération vide")
}
if !memEncActive() || !memUnlocked() {
t.Fatal("devrait être actif et déverrouillé après activation")
}
// Sur disque, les pages doivent être chiffrées (magic présent).
for name := range pages {
p, _ := safeMemPath(name)
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatalf("page %s pas chiffrée sur disque", name)
}
}
// Mais lisibles via l'API (déverrouillé).
for name, want := range pages {
if got := MemContent(name); got != want {
t.Fatalf("contenu %s après chiffrement: %q != %q", name, got, want)
}
}
// Verrouillage : plus rien de lisible, mais on ne perd rien.
clearMemDEK()
if got := MemContent("projet.md"); got != "" {
t.Fatal("une page chiffrée verrouillée ne devrait pas être lisible")
}
h := memHealth()
if !h.Locked || h.Readable != 0 || h.Unreadable != len(pages) {
t.Fatalf("santé verrouillée inattendue: %+v", h)
}
// Déverrouillage via mot de passe.
v, _ := loadVault()
dek, _, err := v.unlockWith("motdepasse-fort")
if err != nil {
t.Fatalf("unlock: %v", err)
}
setMemDEK(dek)
if got := MemContent("copine.md"); got != pages["copine.md"] {
t.Fatal("contenu illisible après déverrouillage")
}
// Déchiffrement complet : retour en clair, keyvault retiré.
if err := DisableMemEncryption(); err != nil {
t.Fatalf("DisableMemEncryption: %v", err)
}
if memEncActive() {
t.Fatal("chiffrement devrait être désactivé")
}
for name, want := range pages {
p, _ := safeMemPath(name)
raw, _ := os.ReadFile(p)
if looksEncrypted(raw) {
t.Fatalf("page %s encore chiffrée après déchiffrement", name)
}
if got := MemContent(name); got != want {
t.Fatalf("contenu %s après déchiffrement altéré", name)
}
}
if vaultExists() {
t.Fatal("le keyvault aurait dû être retiré")
}
}
// Récupération : après chiffrement, on doit pouvoir rouvrir avec la clé de
// récupération même sans le mot de passe.
func TestUnlockWithRecoveryAfterEnable(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
rec, err := EnableMemEncryption("pw")
if err != nil {
t.Fatal(err)
}
clearMemDEK()
v, _ := loadVault()
if _, _, err := v.unlockWith(normalizeRecovery(rec)); err != nil {
t.Fatalf("récupération après activation: %v", err)
}
}
// Sécurité : quand la mémoire est chiffrée ET verrouillée, toute écriture doit
// être REFUSÉE (jamais écrire en clair une donnée censée être chiffrée).
func TestWriteRefusedWhenLocked(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
clearMemDEK() // verrouille
if err := MemAdd("nouvelle.md", "contenu"); err == nil {
t.Fatal("MemAdd aurait dû être refusé (mémoire verrouillée)")
}
if err := MemSave("projet.md", "", "écrasé"); err == nil {
t.Fatal("MemSave aurait dû être refusé (mémoire verrouillée)")
}
// Le fichier d'origine ne doit pas avoir été touché en clair.
p, _ := safeMemPath("projet.md")
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatal("la page d'origine a été altérée alors que la mémoire était verrouillée")
}
}
// Après un chiffrement réussi, AUCUN texte en clair ne doit subsister : ni .bak
// clair, ni snapshot « avant-chiffrement ».
func TestNoPlaintextResidueAfterEncrypt(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(memoryDir())
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".bak") {
b, _ := os.ReadFile(memoryDir() + "/" + e.Name())
if !looksEncrypted(b) {
t.Fatalf(".bak en clair résiduel : %s", e.Name())
}
}
}
for _, s := range listSnapshots() {
if strings.Contains(s.Reason, "avant-chiffrement") {
t.Fatalf("snapshot en clair avant-chiffrement resté : %s", s.ID)
}
}
}
// Reprise d'un chiffrement interrompu : journal présent + une page restée en
// clair, mémoire déverrouillée → resume doit finir le chiffrement.
func TestResumeInterruptedEncrypt(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
// Simule une interruption : on repose une page en clair et on rearme le journal.
if err := writeMemPlain("projet.md", []byte("# Projet\n\nclair résiduel\n")); err != nil {
t.Fatal(err)
}
if err := writeMigrationJournal("encrypt"); err != nil {
t.Fatal(err)
}
resumeMemMigration()
p, _ := safeMemPath("projet.md")
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatal("la reprise n'a pas re-chiffré la page restée en clair")
}
if _, busy := readMigrationJournal(); busy {
t.Fatal("le journal aurait dû être levé après reprise réussie")
}
}
// Un round-trip via snapshot : on prend une copie, on casse la mémoire, on
// restaure.
func TestSnapshotRestore(t *testing.T) {
testHome(t)
clearMemDEK()
pages := seedPages(t)
id, err := snapshotMemory("test")
if err != nil || id == "" {
t.Fatalf("snapshot: %v (id=%q)", err, id)
}
// Corrompt une page et en supprime une autre.
pApath, _ := safeMemPath("projet.md")
os.WriteFile(pApath, []byte("CORROMPU"), 0o600)
MemDelete("copine.md")
if err := restoreSnapshot(id); err != nil {
t.Fatalf("restore: %v", err)
}
for name, want := range pages {
if got := MemContent(name); got != want {
t.Fatalf("après restauration %s: %q != %q", name, got, want)
}
}
}
// Activer le chiffrement ne doit pas rendre illisibles les clés-pointeurs du
// bucket des discussions (discussion active, mode Code) ni les critères. Une
// version précédente les chiffrait : la discussion active devenait
// introuvable. Celles déjà chiffrées sont réparées au déverrouillage.
func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
testHome(t)
clearMemDEK()
id := convEnsureActive()
if err := setConvMode(id, "code"); err != nil {
t.Fatal(err)
}
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("discussion active illisible après chiffrement : %q", got)
}
if !convCodeMode() {
t.Fatal("mode Code perdu après chiffrement")
}
if l := critList(id); len(l) != 1 {
t.Fatalf("critères perdus après chiffrement : %+v", l)
}
if !looksEncrypted(getBytes(bkChat, critKey(id))) {
t.Fatal("critères en clair alors que les discussions sont chiffrées")
}
if !bucketFullyEncrypted(bkChat) {
t.Fatal("les clés-pointeurs en clair font croire à un chiffrement incomplet")
}
// Base d'une version précédente : le pointeur a été chiffré.
enc, err := encodeMemContent([]byte(id))
if err != nil {
t.Fatal(err)
}
_ = putBytes(bkChat, ckActive, enc)
healPlainStoreKeys()
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("pointeur chiffré non réparé : %q", got)
}
}
-38
View File
@@ -1,38 +0,0 @@
package loki
import "testing"
// Chaque projet garde son propre mode mémoire ; un projet sans réglage retombe
// sur l'ancien global MEM_MODE.
func TestMemModePerProject(t *testing.T) {
testHome(t)
ensureDefaultProject()
if err := SetConfigKey("MEM_MODE", "ondemand"); err != nil {
t.Fatal(err)
}
if memMode() != MemOnDemand {
t.Fatalf("sans réglage propre, repli global attendu, got %q", memMode())
}
b, err := createProject("Chantier")
if err != nil {
t.Fatal(err)
}
if err := setActiveProject(b.Slug); err != nil {
t.Fatal(err)
}
if err := setMemMode(MemSearchFirst); err != nil {
t.Fatal(err)
}
if memMode() != MemSearchFirst {
t.Fatalf("mode du projet actif attendu, got %q", memMode())
}
if err := setActiveProject(defaultProjectSlug); err != nil {
t.Fatal(err)
}
if memMode() != MemOnDemand {
t.Fatalf("l'autre projet ne doit pas hériter du réglage, got %q", memMode())
}
if setProjectMemMode(b.Slug, "n'importe quoi") == nil {
t.Fatal("un mode inconnu doit être refusé")
}
}
-186
View File
@@ -1,186 +0,0 @@
package loki
// mem_snapshots.go — filet de sécurité local : des copies horodatées du dossier
// memory/, indépendantes du chiffrement et du relais. Un bug qui corromprait la
// mémoire courante se rejoue depuis un snapshot antérieur, même pour un
// utilisateur non abonné.
//
// Les snapshots copient TELS QUELS les fichiers de memory/ (clairs ou chiffrés,
// keyvault compris) : ce sont des copies fidèles, jamais une transformation.
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// Nombre de snapshots conservés (rotation). Au-delà, les plus anciens sont
// purgés — sauf ceux marqués « pré-bascule », gardés plus longtemps.
const memSnapshotsKept = 20
func snapshotsRoot() string { return filepath.Join(LokiHome(), "backups", "memory") }
// MemSnapshot décrit une copie horodatée.
type MemSnapshot struct {
ID string `json:"id"` // nom de dossier (horodaté)
Reason string `json:"reason"` // pourquoi elle a été prise
When time.Time `json:"when"`
Pages int `json:"pages"` // nb de fichiers .md/.md.enc
}
// snapshotMemory prend une copie du dossier memory/. reason est un libellé court
// (ex. "auto", "avant-chiffrement"). Renvoie l'ID de la copie. Ne fait rien (ID
// vide) si le dossier mémoire est absent ou vide.
func snapshotMemory(reason string) (string, error) {
entries, err := os.ReadDir(memoryDir())
if err != nil || len(entries) == 0 {
return "", nil // rien à sauvegarder
}
id := time.Now().UTC().Format("20060102-150405") + "-" + sanitizeReason(reason)
dst := filepath.Join(snapshotsRoot(), id)
if err := os.MkdirAll(dst, 0o755); err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue // memory/ est plat ; on ignore un éventuel sous-dossier
}
src := filepath.Join(memoryDir(), e.Name())
b, err := os.ReadFile(src)
if err != nil {
return "", fmt.Errorf("lecture %s : %w", e.Name(), err)
}
if err := memWriteFileVerified(filepath.Join(dst, e.Name()), b, 0o600); err != nil {
return "", fmt.Errorf("copie %s : %w", e.Name(), err)
}
}
pruneSnapshots()
return id, nil
}
// listSnapshots renvoie les snapshots existants, du plus récent au plus ancien.
func listSnapshots() []MemSnapshot {
entries, err := os.ReadDir(snapshotsRoot())
if err != nil {
return nil
}
var out []MemSnapshot
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
pages := 0
if inner, err := os.ReadDir(filepath.Join(snapshotsRoot(), id)); err == nil {
for _, f := range inner {
n := strings.ToLower(f.Name())
if strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".md.enc") {
pages++
}
}
}
out = append(out, MemSnapshot{ID: id, Reason: reasonOf(id), When: whenOf(id), Pages: pages})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
return out
}
// restoreSnapshot remplace le contenu de memory/ par celui d'un snapshot. Pour
// ne rien perdre en cas d'erreur, on prend D'ABORD un snapshot de sécurité de
// l'état courant, puis on écrit chaque fichier de façon vérifiée avant de retirer
// les fichiers surnuméraires.
func restoreSnapshot(id string) error {
src := filepath.Join(snapshotsRoot(), filepath.Base(id))
entries, err := os.ReadDir(src)
if err != nil {
return fmt.Errorf("snapshot introuvable : %w", err)
}
if _, err := snapshotMemory("avant-restauration"); err != nil {
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
}
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
return err
}
restored := map[string]bool{}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(src, e.Name()))
if err != nil {
return err
}
if err := memWriteFileVerified(filepath.Join(memoryDir(), e.Name()), b, 0o600); err != nil {
return err
}
restored[e.Name()] = true
}
// Retire de memory/ ce qui n'est pas dans le snapshot (fichiers apparus après).
if cur, err := os.ReadDir(memoryDir()); err == nil {
for _, f := range cur {
if !f.IsDir() && !restored[f.Name()] {
os.Remove(filepath.Join(memoryDir(), f.Name()))
}
}
}
return nil
}
// pruneSnapshots garde les memSnapshotsKept plus récents, en conservant en plus
// tous les snapshots « avant-* » (pris avant une opération sensible).
func pruneSnapshots() {
all := listSnapshots()
if len(all) <= memSnapshotsKept {
return
}
kept := 0
for _, s := range all {
if strings.Contains(s.Reason, "avant") {
continue // on garde les snapshots de sécurité
}
kept++
if kept > memSnapshotsKept {
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
}
}
}
// --- Petits utilitaires d'ID --------------------------------------------------
func sanitizeReason(reason string) string {
reason = strings.ToLower(strings.TrimSpace(reason))
var b strings.Builder
for _, r := range reason {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
} else if r == ' ' || r == '_' {
b.WriteByte('-')
}
}
if b.Len() == 0 {
return "snap"
}
return b.String()
}
func reasonOf(id string) string {
// format : 20060102-150405-<reason>
parts := strings.SplitN(id, "-", 3)
if len(parts) == 3 {
return parts[2]
}
return ""
}
func whenOf(id string) time.Time {
parts := strings.SplitN(id, "-", 3)
if len(parts) >= 2 {
if t, err := time.Parse("20060102-150405", parts[0]+"-"+parts[1]); err == nil {
return t
}
}
return time.Time{}
}
-213
View File
@@ -1,213 +0,0 @@
package loki
// mem_store.go — chiffrement transparent de VALEURS de la base (bbolt), pour
// étendre le chiffrement au-delà des pages mémoire : conversations et archives
// contiennent aussi des infos sensibles.
//
// Même enveloppe que les pages (encPage/decPage + magic) : une valeur est
// chiffrée quand le chiffrement est actif ET la mémoire déverrouillée, en clair
// sinon. La détection se fait sur le magic, donc clair et chiffré cohabitent
// (utile pendant une migration ou un état verrouillé).
import (
"encoding/json"
"errors"
"strings"
)
// errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM.
var errStoreLocked = errors.New("valeur chiffrée, mémoire verrouillée")
// putStoreBytes écrit une valeur, chiffrée si le chiffrement est actif ET
// déverrouillé. Si actif mais verrouillé, renvoie errStoreLocked SANS écrire
// (on ne remplace jamais une valeur chiffrée par du clair).
func putStoreBytes(bucket, key string, plain []byte) error {
out, err := encodeMemContent(plain)
if err != nil {
return errStoreLocked
}
return putBytes(bucket, key, out)
}
// getStoreBytes lit une valeur et la déchiffre au besoin. Renvoie (nil, false)
// si absente, ou si chiffrée alors que la mémoire est verrouillée.
func getStoreBytes(bucket, key string) ([]byte, bool) {
raw := getBytes(bucket, key)
if len(raw) == 0 {
return nil, false
}
dec, err := decodeMemContent(raw)
if err != nil {
return nil, false
}
return dec, true
}
// storeBytes : getStoreBytes réduit à sa valeur, pour les appelants qui
// traitent déjà « absent » et « illisible » de la même façon (un fil vide).
func storeBytes(bucket, key string) []byte {
b, _ := getStoreBytes(bucket, key)
return b
}
// getStoreBytesErr fait la même lecture que getStoreBytes mais SANS avaler
// l'erreur d'accès (getBytes, lui, la jette — c'est le piège documenté sur
// getBytesErr : « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas dire
// la même chose). Une valeur absente reste (nil, nil) — err n'est non-nil QUE
// si la base elle-même n'a pas pu être ouverte/lue (verrou bbolt disputé, disque
// indisponible…), pas pour une clé qui n'existe simplement pas encore.
func getStoreBytesErr(bucket, key string) ([]byte, error) {
raw, err := getBytesErr(bucket, key)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, nil
}
dec, err := decodeMemContent(raw)
if err != nil {
// Chiffré mais verrouillé (ou payload corrompu) : légitime, pas un échec
// d'ACCÈS — même repli que getStoreBytes.
return nil, nil
}
return dec, nil
}
// putStoreJSON sérialise puis écrit (chiffré si actif+déverrouillé).
func putStoreJSON(bucket, key string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return putStoreBytes(bucket, key, b)
}
// getStoreJSON lit puis décode (déchiffre au besoin). false si absent/verrouillé.
func getStoreJSON(bucket, key string, dst any) bool {
b, ok := getStoreBytes(bucket, key)
if !ok {
return false
}
return json.Unmarshal(b, dst) == nil
}
// plainStoreKey : clés d'un bucket chiffré qui restent EN CLAIR, parce que le
// code les lit et les écrit avec getStr/putStr — des pointeurs et drapeaux, pas
// des données : la discussion active (son id), le mode Chat|Code d'une
// discussion, la puce « passer en mode Code » déjà montrée. Chiffrées par
// reencryptBucket, elles revenaient à la lecture comme du charabia : la
// discussion active devenait introuvable, le mode Code et ses critères
// disparaissaient dès l'activation du chiffrement.
func plainStoreKey(bucket, key string) bool {
if bucket != bkChat {
return false
}
return key == ckActive || strings.HasPrefix(key, "mode:") || strings.HasPrefix(key, "hinted:")
}
// healPlainStoreKeys remet en clair les clés-pointeurs qu'une version
// précédente avait chiffrées (voir plainStoreKey). À appeler dès que la DEK est
// en RAM, AVANT de recharger la discussion active. Sans DEK, ne fait rien.
func healPlainStoreKeys() {
if !memUnlocked() {
return
}
for _, bucket := range encryptedBuckets {
for k, v := range allKV(bucket) {
raw := []byte(v)
if !plainStoreKey(bucket, k) || !looksEncrypted(raw) {
continue
}
if plain, err := decodeMemContent(raw); err == nil {
_ = putBytes(bucket, k, plain)
}
}
}
}
// reencryptBucket (re)chiffre toutes les valeurs d'un bucket encore en clair,
// avec vérification par relecture. Sûr à rejouer. Exige la DEK en RAM.
func reencryptBucket(bucket string) error {
for k, v := range allKV(bucket) {
raw := []byte(v)
if looksEncrypted(raw) || plainStoreKey(bucket, k) {
continue
}
if err := putStoreBytes(bucket, k, raw); err != nil {
return err
}
back, ok := getStoreBytes(bucket, k)
if !ok || string(back) != v {
return errors.New("vérification post-chiffrement échouée pour " + bucket + "/" + k)
}
}
return nil
}
// bucketFullyEncrypted indique qu'AUCUNE valeur du bucket n'est en clair (toutes
// portent le magic). Lisible sans la DEK. Utilisé pour savoir si le chiffrement
// est réellement complet.
func bucketFullyEncrypted(bucket string) bool {
for k, v := range allKV(bucket) {
if plainStoreKey(bucket, k) {
continue
}
if v != "" && !looksEncrypted([]byte(v)) {
return false
}
}
return true
}
// encryptedBuckets : les buckets dont les valeurs sont chiffrées quand le
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
// discussion active et le journal de chacune), les blocs archivés au compactage
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
// les trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil).
//
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
// portent des réglages, pas des données personnelles — et surtout le coffre
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
// boucle : plus moyen de déverrouiller quoi que ce soit.
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes}
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
func reencryptChatStores() error {
healPlainStoreKeys()
for _, b := range encryptedBuckets {
if err := reencryptBucket(b); err != nil {
return err
}
}
return nil
}
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
func decryptChatStores() error {
for _, b := range encryptedBuckets {
if err := decryptBucket(b); err != nil {
return err
}
}
return nil
}
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
// la DEK en RAM. Sûr à rejouer.
func decryptBucket(bucket string) error {
for k, v := range allKV(bucket) {
raw := []byte(v)
if !looksEncrypted(raw) {
continue
}
plain, err := decodeMemContent(raw)
if err != nil {
return err
}
if err := putBytes(bucket, k, plain); err != nil {
return err
}
}
return nil
}
-133
View File
@@ -1,133 +0,0 @@
package loki
import (
"os"
"testing"
)
// La case « chiffré » (health.Fully) ne doit être vraie QUE si TOUT est chiffré.
func TestFullyReflectsRealState(t *testing.T) {
testHome(t)
clearMemDEK()
if err := MemAdd("a.md", "# A\n"); err != nil {
t.Fatal(err)
}
_ = putBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[]}`))
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
if !memHealth().Fully {
t.Fatal("après chiffrement complet, Fully devrait être vrai")
}
// Une page en clair réapparue → Fully faux.
p, _ := safeMemPath("a.md")
if err := os.WriteFile(p, []byte("# clair"), 0o600); err != nil {
t.Fatal(err)
}
if memHealth().Fully {
t.Fatal("une page en clair aurait dû rendre Fully faux")
}
// Remets la page chiffrée, salis une discussion → Fully faux aussi.
if err := writeMemFile("a.md", []byte("# A\n")); err != nil {
t.Fatal(err)
}
_ = putBytes(bkChat, convKey("c2"), []byte("journal en clair"))
if memHealth().Fully {
t.Fatal("une discussion en clair aurait dû rendre Fully faux")
}
}
// Les discussions (journal + index) et les blocs archivés doivent être chiffrés
// à l'activation, illisibles verrouillé, et intacts après déchiffrement.
func TestConversationEncryptionLifecycle(t *testing.T) {
testHome(t)
clearMemDEK()
// Une discussion avec son journal et son entrée d'index.
_ = putStoreBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[{"seq":1}],"messages":[]}`))
convIndexSave([]convMeta{{ID: "c1", Title: "Client confidentiel", Created: 1, Updated: 2}})
// Une page mémoire, pour activer le chiffrement (il chiffre tout).
if err := MemAdd("note.md", "# Note\n"); err != nil {
t.Fatal(err)
}
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
// Sur disque, journal ET index doivent porter le magic.
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
t.Fatal("journal de discussion non chiffré sur disque")
}
if raw := getBytes(bkChat, ckIndex); !looksEncrypted(raw) {
t.Fatal("index des discussions non chiffré sur disque")
}
// Déverrouillé : lisible.
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
t.Fatalf("index illisible alors que déverrouillé: %+v", idx)
}
// Verrouillé : plus rien de lisible, mais rien de perdu.
clearMemDEK()
if idx := convIndex(); len(idx) != 0 {
t.Fatal("les discussions devraient être masquées quand verrouillé")
}
if b := storeBytes(bkChat, convKey("c1")); len(b) != 0 {
t.Fatal("le journal devrait être illisible quand verrouillé")
}
// putStoreBytes doit REFUSER d'écrire du clair quand verrouillé.
if err := putStoreBytes(bkChat, convKey("c1"), []byte("clair")); err != errStoreLocked {
t.Fatalf("écriture clair aurait dû être refusée, obtenu %v", err)
}
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
t.Fatal("le blob chiffré a été écrasé alors que verrouillé")
}
// Re-déverrouille et déchiffre : tout revient en clair et intact.
v, _ := loadVault()
dek, _, err := v.unlockWith("pw")
if err != nil {
t.Fatal(err)
}
setMemDEK(dek)
if err := DisableMemEncryption(); err != nil {
t.Fatal(err)
}
if raw := getBytes(bkChat, convKey("c1")); looksEncrypted(raw) {
t.Fatal("journal encore chiffré après déchiffrement")
}
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
t.Fatal("index perdu après déchiffrement")
}
}
// Un bloc archivé au compactage est du verbatim de conversation : chiffré comme
// le reste, et hors de portée tant que la mémoire est verrouillée.
func TestRecallBlocksFollowEncryption(t *testing.T) {
testHome(t)
clearMemDEK()
if err := MemAdd("note.md", "# Note\n"); err != nil {
t.Fatal(err)
}
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
id, err := archiveRecallBlock("tool: lecture", "tool", "contenu très bavard")
if err != nil {
t.Fatal(err)
}
if blk, ok := recallGet(id); !ok || blk.Content != "contenu très bavard" {
t.Fatal("bloc illisible alors que déverrouillé")
}
if hits := recallSearch("bavard", 5); len(hits) != 1 {
t.Fatalf("la recherche devrait trouver le bloc déverrouillé, obtenu %d", len(hits))
}
clearMemDEK()
if _, ok := recallGet(id); ok {
t.Fatal("bloc lisible alors que verrouillé")
}
if hits := recallSearch("bavard", 5); len(hits) != 0 {
t.Fatal("la recherche ne devrait rien trouver quand verrouillé")
}
}
-375
View File
@@ -1,375 +0,0 @@
package loki
// mem_vault.go — le keyvault : la DEK de la mémoire, wrappée par une ou
// plusieurs KEK (mot de passe, clé de récupération, mot de passe app), et gardée
// en RAM le temps d'une session déverrouillée.
//
// Le keyvault est le POINT DE DÉFAILLANCE UNIQUE : le perdre = perdre la
// mémoire. Il est donc écrit en TROIS copies indépendantes, chacune suffisante
// seule, et contient plusieurs wraps indépendants de la même DEK. Perdre le mot
// de passe n'est pas fatal tant qu'il reste la clé de récupération.
import (
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// Types de wrap. Chaque wrap re-chiffre la MÊME DEK sous une KEK différente.
const (
wrapPassword = "password" // mot de passe mémoire choisi par l'utilisateur
wrapRecovery = "recovery" // clé de récupération (secours anti-perte)
wrapE2ERoot = "e2e-root" // racine E2E de l'abonné (mot de passe app)
)
// checkPlain est le témoin scellé par la DEK dans le keyvault : le déballer
// prouve que la DEK reconstituée est la bonne (détecte un wrap corrompu).
var checkPlain = []byte("loki-memoire-ok-v1")
type kekParams struct {
T uint32 `json:"t"`
M uint32 `json:"m"`
P uint8 `json:"p"`
}
type vaultWrap struct {
Kind string `json:"kind"`
Label string `json:"label"`
Salt string `json:"salt"` // base64 du sel Argon2id propre à ce wrap
KDF kekParams `json:"kdf"`
Box string `json:"box"` // base64 de gcmSeal(KEK, DEK, aad)
}
type keyVault struct {
V int `json:"v"`
Check string `json:"check"` // base64 de gcmSeal(DEK, checkPlain)
Wraps []vaultWrap `json:"wraps"`
}
func vaultWrapAAD(kind string) []byte { return []byte("loki-vault/" + kind) }
// --- Emplacements du keyvault (3 copies indépendantes) -----------------------
// Le keyvault est GLOBAL (une seule DEK pour la mémoire de TOUS les projets) : il
// vit à la racine de LOKI_HOME, pas dans le dossier d'un projet. L'ancien
// emplacement (memory/.keyvault) est migré par ensureDefaultProject.
func vaultPathPrimary() string { return filepath.Join(LokiHome(), ".keyvault") }
func vaultPathBackup() string { return filepath.Join(LokiHome(), ".keyvault.bak") }
const vaultDBKey = "mem_keyvault"
// --- État en RAM de la DEK ----------------------------------------------------
var (
memKeyMu sync.RWMutex
memDEK []byte // nil = mémoire verrouillée
)
// memUnlocked indique si la DEK est chargée en RAM (mémoire déverrouillée).
func memUnlocked() bool {
memKeyMu.RLock()
defer memKeyMu.RUnlock()
return memDEK != nil
}
// setMemDEK charge la DEK en RAM (copie défensive).
func setMemDEK(dek []byte) {
memKeyMu.Lock()
memDEK = append([]byte(nil), dek...)
memKeyMu.Unlock()
}
// clearMemDEK purge la DEK de la RAM (verrouille la mémoire).
func clearMemDEK() {
memKeyMu.Lock()
for i := range memDEK {
memDEK[i] = 0
}
memDEK = nil
memKeyMu.Unlock()
}
// currentDEK renvoie la DEK en RAM, ou une erreur si la mémoire est verrouillée.
func currentDEK() ([]byte, error) {
memKeyMu.RLock()
defer memKeyMu.RUnlock()
if memDEK == nil {
return nil, errors.New("mémoire verrouillée — aucune clé chargée")
}
return append([]byte(nil), memDEK...), nil
}
// --- Construction et manipulation du keyvault --------------------------------
// newVault crée un keyvault autour d'une DEK fraîche : aucun wrap encore, mais
// le témoin est posé. Renvoie aussi la DEK en clair (à garder en RAM, jamais sur
// disque).
func newVault() (*keyVault, []byte, error) {
dek, err := randBytes(memDEKLen)
if err != nil {
return nil, nil, err
}
check, err := gcmSeal(dek, checkPlain, nil)
if err != nil {
return nil, nil, err
}
return &keyVault{V: 1, Check: base64.StdEncoding.EncodeToString(check)}, dek, nil
}
// addSecretWrap (ou remplace) un wrap de la DEK sous un secret (mot de passe ou
// clé de récupération). kind identifie le wrap ; un même kind+label écrase le
// précédent (rotation de mot de passe = re-wrap, sans toucher aux pages).
func (v *keyVault) addSecretWrap(dek []byte, kind, label, secret string) error {
salt, err := randBytes(memSaltLen)
if err != nil {
return err
}
kp := kekParams{T: argonTime, M: argonMemory, P: argonThreads}
kek := deriveKEK(secret, salt, kp.T, kp.M, kp.P)
box, err := gcmSeal(kek, dek, vaultWrapAAD(kind))
if err != nil {
return err
}
w := vaultWrap{
Kind: kind,
Label: label,
Salt: base64.StdEncoding.EncodeToString(salt),
KDF: kp,
Box: base64.StdEncoding.EncodeToString(box),
}
for i := range v.Wraps {
if v.Wraps[i].Kind == kind && v.Wraps[i].Label == label {
v.Wraps[i] = w
return nil
}
}
v.Wraps = append(v.Wraps, w)
return nil
}
// removeWrap retire les wraps d'un kind (révocation). Renvoie le nombre retiré.
// Refuse de vider le dernier wrap (sinon la DEK deviendrait irrécupérable).
func (v *keyVault) removeWrap(kind, label string) (int, error) {
kept := v.Wraps[:0:0]
removed := 0
for _, w := range v.Wraps {
if w.Kind == kind && (label == "" || w.Label == label) {
removed++
continue
}
kept = append(kept, w)
}
if removed > 0 && len(kept) == 0 {
return 0, errors.New("refus : ce serait le dernier accès à la mémoire")
}
v.Wraps = kept
return removed, nil
}
// unlockWith tente de reconstituer la DEK à partir d'un secret : essaie chaque
// wrap, et ne renvoie la DEK que si le témoin la valide. Renvoie le kind du wrap
// qui a marché (utile pour l'UI).
func (v *keyVault) unlockWith(secret string) ([]byte, string, error) {
if len(v.Wraps) == 0 {
return nil, "", errors.New("keyvault sans aucun wrap")
}
for _, w := range v.Wraps {
salt, err := base64.StdEncoding.DecodeString(w.Salt)
if err != nil {
continue
}
box, err := base64.StdEncoding.DecodeString(w.Box)
if err != nil {
continue
}
kek := deriveKEK(secret, salt, w.KDF.T, w.KDF.M, w.KDF.P)
dek, err := gcmOpen(kek, box, vaultWrapAAD(w.Kind))
if err != nil {
continue // mauvais secret pour ce wrap, on essaie le suivant
}
if err := v.validate(dek); err != nil {
continue
}
return dek, w.Kind, nil
}
return nil, "", errors.New("secret incorrect (aucun wrap ne correspond)")
}
// validate confirme qu'une DEK candidate ouvre bien le témoin du keyvault.
func (v *keyVault) validate(dek []byte) error {
check, err := base64.StdEncoding.DecodeString(v.Check)
if err != nil {
return err
}
got, err := gcmOpen(dek, check, nil)
if err != nil {
return err
}
if subtle.ConstantTimeCompare(got, checkPlain) != 1 {
return errors.New("témoin du keyvault invalide")
}
return nil
}
func (v *keyVault) hasKind(kind string) bool {
for _, w := range v.Wraps {
if w.Kind == kind {
return true
}
}
return false
}
// --- Persistance redondée -----------------------------------------------------
// saveVault écrit le keyvault dans ses TROIS copies. Réussit si AU MOINS une
// copie a pu être écrite (on ne veut jamais bloquer sur une copie inaccessible),
// mais renvoie l'erreur agrégée sinon.
func saveVault(v *keyVault) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
var errs []string
okAny := false
if err := os.MkdirAll(memoryDir(), 0o755); err == nil {
if err := memWriteFileAtomic(vaultPathPrimary(), data, 0o600); err == nil {
okAny = true
} else {
errs = append(errs, "primaire: "+err.Error())
}
} else {
errs = append(errs, "memoryDir: "+err.Error())
}
if err := memWriteFileAtomic(vaultPathBackup(), data, 0o600); err == nil {
okAny = true
} else {
errs = append(errs, "backup: "+err.Error())
}
if err := putBytes(bkState, vaultDBKey, data); err == nil {
okAny = true
} else {
errs = append(errs, "db: "+err.Error())
}
if !okAny {
return fmt.Errorf("aucune copie du keyvault écrite : %s", strings.Join(errs, " ; "))
}
return nil
}
// loadVault lit le keyvault depuis la première copie valide (primaire, backup,
// db), puis RÉPARE silencieusement les copies manquantes/corrompues à partir de
// celle qui a marché. Renvoie (nil, nil) si aucun keyvault n'existe encore.
func loadVault() (*keyVault, error) {
sources := []func() ([]byte, error){
func() ([]byte, error) { return os.ReadFile(vaultPathPrimary()) },
func() ([]byte, error) { return os.ReadFile(vaultPathBackup()) },
func() ([]byte, error) {
b := getBytes(bkState, vaultDBKey)
if len(b) == 0 {
return nil, os.ErrNotExist
}
return b, nil
},
}
var found *keyVault
anyPresent := false
healthy := 0 // nb de copies présentes ET valides
for _, src := range sources {
b, err := src()
if err != nil || len(b) == 0 {
continue
}
anyPresent = true
var v keyVault
if json.Unmarshal(b, &v) == nil && v.V > 0 && len(v.Wraps) > 0 {
healthy++
if found == nil {
vv := v
found = &vv
}
}
}
if found == nil {
if anyPresent {
return nil, errors.New("keyvault présent mais illisible dans toutes les copies")
}
return nil, nil
}
// Ré-harmonise UNIQUEMENT si une des trois copies manque ou est corrompue :
// sinon loadVault (appelé à chaque sondage de santé) réécrirait le disque en
// boucle pour rien.
if healthy < 3 {
_ = saveVault(found)
}
return found, nil
}
// vaultExists indique si un keyvault est présent (mémoire chiffrée configurée).
func vaultExists() bool {
v, _ := loadVault()
return v != nil
}
// stripServerOpenableWraps retire du coffre tout wrap ouvrable par un secret que
// le SERVEUR détient (la clé de pilotage web, jadis ajoutée comme wrap
// « clé d'accès »). Objectif : le serveur ne doit JAMAIS pouvoir déchiffrer seul.
// La clé de chiffrement ne vit que côté client (localStorage). Idempotent ;
// refuse de vider le coffre (removeWrap garde toujours au moins un wrap).
func stripServerOpenableWraps() {
v, _ := loadVault()
if v == nil || !v.hasKind(wrapE2ERoot) {
return
}
if n, err := v.removeWrap(wrapE2ERoot, ""); err == nil && n > 0 {
_ = saveVault(v)
}
}
// removeVault efface les trois copies du keyvault (après un déchiffrement complet
// où il n'a plus lieu d'être).
func removeVault() {
os.Remove(vaultPathPrimary())
os.Remove(vaultPathBackup())
putBytes(bkState, vaultDBKey, nil)
}
// --- Clé de récupération ------------------------------------------------------
// recoveryEnc : base32 Crockford (32 caractères, sans les ambigus I/L/O/U),
// pour une saisie humaine sans confusion.
var recoveryEnc = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
// newRecoveryKey génère une clé de récupération lisible : 20 octets aléatoires,
// encodés en groupes de 4 (ex. ABCD-EFGH-JKMN-...). C'est un SECRET fort, à
// noter hors ligne ; il rouvre la mémoire même si le mot de passe est perdu.
func newRecoveryKey() (string, error) {
raw, err := randBytes(20)
if err != nil {
return "", err
}
s := recoveryEnc.EncodeToString(raw)
var b strings.Builder
for i, r := range s {
if i > 0 && i%4 == 0 {
b.WriteByte('-')
}
b.WriteRune(r)
}
return b.String(), nil
}
// normalizeRecovery met une clé de récupération saisie sous forme canonique
// (majuscules, sans séparateurs) pour la dérivation de KEK.
func normalizeRecovery(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
s = strings.NewReplacer("-", "", " ", "", "\t", "").Replace(s)
return s
}
-176
View File
@@ -1,176 +0,0 @@
package loki
import (
"bytes"
"os"
"testing"
)
func TestVaultCreateUnlock(t *testing.T) {
testHome(t)
v, dek, err := newVault()
if err != nil {
t.Fatal(err)
}
if err := v.addSecretWrap(dek, wrapPassword, "principal", "hunter2"); err != nil {
t.Fatal(err)
}
got, kind, err := v.unlockWith("hunter2")
if err != nil {
t.Fatalf("unlock: %v", err)
}
if kind != wrapPassword {
t.Fatalf("kind attendu password, obtenu %s", kind)
}
if !bytes.Equal(got, dek) {
t.Fatal("DEK déballée ≠ DEK d'origine")
}
if _, _, err := v.unlockWith("mauvais"); err == nil {
t.Fatal("un mauvais mot de passe n'aurait pas dû ouvrir")
}
}
// Rotation : re-wrapper sous un nouveau mot de passe ne doit PAS changer la DEK.
func TestVaultRotation(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "ancien")
if err := v.addSecretWrap(dek, wrapPassword, "principal", "nouveau"); err != nil {
t.Fatal(err)
}
if _, _, err := v.unlockWith("ancien"); err == nil {
t.Fatal("l'ancien mot de passe aurait dû être remplacé")
}
got, _, err := v.unlockWith("nouveau")
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, dek) {
t.Fatal("la rotation a changé la DEK")
}
}
// La clé de récupération rouvre la mémoire même si le wrap mot de passe est retiré.
func TestVaultRecovery(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "motdepasse")
rec, err := newRecoveryKey()
if err != nil {
t.Fatal(err)
}
v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(rec))
// On détruit l'accès mot de passe.
if _, err := v.removeWrap(wrapPassword, ""); err != nil {
t.Fatal(err)
}
got, kind, err := v.unlockWith(normalizeRecovery(rec))
if err != nil {
t.Fatalf("récupération: %v", err)
}
if kind != wrapRecovery || !bytes.Equal(got, dek) {
t.Fatal("la clé de récupération n'a pas rouvert la mémoire")
}
}
func TestVaultRefusesLastWrapRemoval(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "x")
if _, err := v.removeWrap(wrapPassword, ""); err == nil {
t.Fatal("retirer le dernier wrap aurait dû être refusé")
}
}
// Persistance : sauver puis relire depuis les 3 copies, y compris après
// destruction d'une ou deux copies.
func TestVaultPersistRedundancy(t *testing.T) {
testHome(t)
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "pw")
if err := saveVault(v); err != nil {
t.Fatal(err)
}
// Détruit la copie primaire et la db, il reste le backup.
os.Remove(vaultPathPrimary())
putBytes(bkState, vaultDBKey, nil)
got, err := loadVault()
if err != nil {
t.Fatalf("load après perte de 2 copies: %v", err)
}
if got == nil {
t.Fatal("keyvault introuvable alors que le backup existe")
}
// loadVault doit avoir réparé les copies manquantes.
if _, err := os.Stat(vaultPathPrimary()); err != nil {
t.Fatal("la copie primaire n'a pas été réparée")
}
d, _, err := got.unlockWith("pw")
if err != nil || !bytes.Equal(d, dek) {
t.Fatal("keyvault relu inutilisable")
}
}
func TestMemDEKState(t *testing.T) {
if memUnlocked() {
clearMemDEK()
}
if _, err := currentDEK(); err == nil {
t.Fatal("currentDEK devrait échouer quand verrouillé")
}
dek, _ := randBytes(memDEKLen)
setMemDEK(dek)
if !memUnlocked() {
t.Fatal("devrait être déverrouillé")
}
got, err := currentDEK()
if err != nil || !bytes.Equal(got, dek) {
t.Fatal("currentDEK incohérent")
}
clearMemDEK()
if memUnlocked() {
t.Fatal("devrait être verrouillé après clear")
}
}
// Zéro-connaissance : le secret détenu par le SERVEUR (clé de pilotage web_key)
// ne doit PAS pouvoir ouvrir le coffre, et stripServerOpenableWraps retire tout
// wrap qui le permettrait.
func TestServerCannotOpenVault(t *testing.T) {
testHome(t)
putStr(bkState, "web_key", "cle-de-pilotage-serveur")
v, dek, _ := newVault()
v.addSecretWrap(dek, wrapPassword, "principal", "mon-mdp-client")
// La clé de pilotage du serveur n'ouvre pas le coffre.
if _, _, err := v.unlockWith("cle-de-pilotage-serveur"); err == nil {
t.Fatal("la clé serveur n'aurait jamais dû ouvrir le coffre")
}
// Si un wrap « clé d'accès » (ouvrable par le serveur) traînait, on le retire.
v.addSecretWrap(dek, wrapE2ERoot, "clé d'accès", "cle-de-pilotage-serveur")
saveVault(v)
if got, _, err := v.unlockWith("cle-de-pilotage-serveur"); err != nil || !bytes.Equal(got, dek) {
t.Fatal("pré-condition: le wrap clé d'accès devrait ouvrir avant strip")
}
stripServerOpenableWraps()
v2, _ := loadVault()
if _, _, err := v2.unlockWith("cle-de-pilotage-serveur"); err == nil {
t.Fatal("après strip, la clé serveur ne doit plus ouvrir le coffre")
}
if _, _, err := v2.unlockWith("mon-mdp-client"); err != nil {
t.Fatal("le mot de passe client doit toujours ouvrir")
}
}
func TestRecoveryKeyFormat(t *testing.T) {
rec, err := newRecoveryKey()
if err != nil {
t.Fatal(err)
}
if normalizeRecovery(rec) == "" {
t.Fatal("clé de récupération vide")
}
if normalizeRecovery("abcd-efgh") != "ABCDEFGH" {
t.Fatalf("normalisation inattendue: %q", normalizeRecovery("abcd-efgh"))
}
}
-53
View File
@@ -1,53 +0,0 @@
package loki
import (
"reflect"
"strings"
"testing"
)
// Le contexte PROJET quitte le bloc système pour la tête du 1er message user :
// le système commun reste identique d'un projet à l'autre (cache de prompt), et
// l'historique d'origine n'est pas muté.
func TestNormalizeMovesProjectContextToFirstUser(t *testing.T) {
in := []Message{
{Role: "system", Content: "Préambule commun"},
{Role: "system", Content: projectContextPrefix + " — projet A"},
{Role: "system", Content: memIndexPrefix + " for project A"},
{Role: "user", Content: "bonjour"},
{Role: "assistant", Content: "salut"},
{Role: "user", Content: "suite"},
}
ref := append([]Message(nil), in...)
out := normalizeSystemMessages(in)
if len(out) != 4 || out[0].Content != "Préambule commun" {
t.Fatalf("système commun attendu seul en tête : %#v", out)
}
first, _ := out[1].Content.(string)
if !strings.HasPrefix(first, "<project_context>\n"+projectContextPrefix) ||
!strings.Contains(first, memIndexPrefix) || !strings.HasSuffix(first, "bonjour") {
t.Fatalf("contexte projet absent du 1er message user : %q", first)
}
if out[3].Content != "suite" {
t.Fatal("seul le PREMIER message user reçoit le contexte")
}
if !reflect.DeepEqual(in, ref) {
t.Fatal("l'entrée a été mutée (interdit)")
}
}
// Sans prompt de preset, le premier système est un message projet : le préambule
// ne doit pas l'absorber (il perdrait son préfixe et resterait dans le système).
func TestInjectSkillsKeepsProjectMessageSeparate(t *testing.T) {
in := []Message{
{Role: "system", Content: projectContextPrefix + " — projet A"},
{Role: "user", Content: "bonjour"},
}
out := normalizeSystemMessages(InjectSkills(in, Caps{Agent: true}))
if s, _ := out[0].Content.(string); out[0].Role != "system" || strings.Contains(s, projectContextPrefix) {
t.Fatalf("le contexte projet ne doit pas rester dans le système : %#v", out[0])
}
if u, _ := out[1].Content.(string); !strings.Contains(u, projectContextPrefix) {
t.Fatalf("contexte projet attendu dans le 1er message user : %#v", out[1])
}
}
-33
View File
@@ -39,9 +39,6 @@ type Project struct {
// de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi
// elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion.
Desc string `json:"desc,omitempty"`
// MemMode = mode mémoire PROPRE à ce projet (off/ondemand/always/search).
// Vide = pas encore réglé → repli sur l'ancien global MEM_MODE (voir memMode).
MemMode string `json:"mem_mode,omitempty"`
}
const (
@@ -189,36 +186,6 @@ func projectDesc(slug string) string {
// setProjectDesc enregistre la description d'un projet, tronquée à une taille
// raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des
// charges. Une chaîne vide efface la description.
// projectMemMode renvoie le mode mémoire propre à un projet ("" si non réglé, ce
// qui déclenche le repli global dans memMode).
func projectMemMode(slug string) string {
for _, p := range listProjects() {
if p.Slug == slug {
return p.MemMode
}
}
return ""
}
// setProjectMemMode enregistre le mode mémoire d'un projet. Une valeur vide
// efface le réglage propre (retour au repli global).
func setProjectMemMode(slug, mode string) error {
mode = strings.ToLower(strings.TrimSpace(mode))
switch mode {
case "", "off", "ondemand", "always", "search":
default:
return fmt.Errorf("mode mémoire invalide: %s", mode)
}
list := listProjects()
for i := range list {
if list[i].Slug == slug {
list[i].MemMode = mode
return saveProjects(list)
}
}
return fmt.Errorf("projet introuvable")
}
func setProjectDesc(slug, desc string) error {
desc = strings.TrimSpace(desc)
const max = 2000
-202
View File
@@ -1,202 +0,0 @@
// push.go — notifications Web Push : quand un tour utilisateur se termine
// (chat_conversation.go, turn_done), le serveur Loki pousse une notification
// vers les navigateurs abonnés (iPhone verrouillé, Android, desktop) via leur
// service de push (Apple/Google). C'est le SERVEUR qui pousse, directement vers
// le endpoint du navigateur — ça marche donc app fermée, contrairement à une
// notification purement client (l'onglet caché relâche son flux SSE).
//
// Ce que ça ne fait PAS transiter par le tunnel E2E : la charge utile est
// chiffrée de bout en bout avec les clés PROPRES de l'abonnement (RFC 8291),
// puis remise au service de push public. L'inscription, elle, passe par /api
// (donc jfetch/E2E) comme le reste.
package loki
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
)
// fmtDurFR : durée → « 42s », « 3 mn 05s », « 1 h 12 mn » pour le corps de la
// notification. Miroir serveur de fmtElapsed() côté UI (09-stream.js).
func fmtDurFR(d time.Duration) string {
secs := int(d.Round(time.Second).Seconds())
if secs < 0 {
secs = 0
}
h, m, s := secs/3600, (secs%3600)/60, secs%60
switch {
case h > 0:
return fmt.Sprintf("%d h %02d mn", h, m)
case m > 0:
return fmt.Sprintf("%d mn %02ds", m, s)
default:
return fmt.Sprintf("%ds", s)
}
}
// Stockées dans bkState (une seule base bbolt, voir store.go).
const (
stPushVAPIDPriv = "push_vapid_priv" // clé privée VAPID (base64url)
stPushVAPIDPub = "push_vapid_pub" // clé publique VAPID (base64url), servie à l'UI
stPushSubs = "push_subs" // liste JSON des abonnements
)
// pushSub : un abonnement PushSubscription du navigateur. Même forme que l'objet
// JS, on le reçoit tel quel depuis l'UI et on le rejoue tel quel à l'envoi.
type pushSub struct {
Endpoint string `json:"endpoint"`
Keys webpush.Keys `json:"keys"`
}
// vapidMu sérialise la génération paresseuse des clés : deux requêtes /api/push/key
// concurrentes sur une base neuve ne doivent pas produire deux paires différentes
// (la seconde écraserait la première, invalidant les abonnements de la première).
var vapidMu sync.Mutex
// vapidKeys renvoie la paire VAPID, la générant et la persistant au premier appel.
// La paire est STABLE pour la vie de l'installation : la changer invaliderait tous
// les abonnements déjà pris (le navigateur signe l'abonnement avec la clé publique).
func vapidKeys() (priv, pub string, err error) {
vapidMu.Lock()
defer vapidMu.Unlock()
priv = getStr(bkState, stPushVAPIDPriv)
pub = getStr(bkState, stPushVAPIDPub)
if priv != "" && pub != "" {
return priv, pub, nil
}
priv, pub, err = webpush.GenerateVAPIDKeys()
if err != nil {
return "", "", err
}
if err = putStr(bkState, stPushVAPIDPriv, priv); err != nil {
return "", "", err
}
if err = putStr(bkState, stPushVAPIDPub, pub); err != nil {
return "", "", err
}
return priv, pub, nil
}
// subsMu sérialise les mutations de la liste d'abonnements (lecture-modif-écriture) :
// une inscription et une purge concurrentes ne doivent pas s'écraser l'une l'autre.
var subsMu sync.Mutex
func loadSubs() []pushSub {
var subs []pushSub
getJSON(bkState, stPushSubs, &subs)
return subs
}
func saveSubs(subs []pushSub) error { return putJSON(bkState, stPushSubs, subs) }
// addSub enregistre un abonnement (idempotent : ré-inscrire le même endpoint met
// simplement à jour ses clés au lieu de le dupliquer — un navigateur ré-abonné
// garde le même endpoint mais peut renouveler ses clés).
func addSub(s pushSub) error {
if s.Endpoint == "" {
return nil
}
subsMu.Lock()
defer subsMu.Unlock()
subs := loadSubs()
for i, x := range subs {
if x.Endpoint == s.Endpoint {
subs[i] = s
return saveSubs(subs)
}
}
subs = append(subs, s)
return saveSubs(subs)
}
// removeSub retire un abonnement par son endpoint (désinscription explicite, ou
// purge après un 404/410 « gone » renvoyé par le service de push).
func removeSub(endpoint string) {
if endpoint == "" {
return
}
subsMu.Lock()
defer subsMu.Unlock()
subs := loadSubs()
out := subs[:0]
for _, x := range subs {
if x.Endpoint != endpoint {
out = append(out, x)
}
}
_ = saveSubs(out)
}
// pushPayload : ce que le service worker reçoit dans l'événement `push`.
type pushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
Tag string `json:"tag"`
}
// hasPushSubs : y a-t-il au moins un abonnement ? Évite de sérialiser une charge
// utile pour rien à chaque fin de tour quand personne n'a activé les notifs.
func hasPushSubs() bool { return len(loadSubs()) > 0 }
// sendPushToAll pousse une notification à tous les abonnés. Best-effort : les
// échecs réseau sont ignorés (le service de push réessaiera selon le TTL), mais un
// abonnement rejeté définitivement (404/410) est purgé pour ne pas s'accumuler.
// Appelé depuis la goroutine de génération, à turn_done — jamais bloquant pour l'UI.
func sendPushToAll(title, body string) {
subs := loadSubs()
if len(subs) == 0 {
fmt.Printf("[push] fin de tour : aucun abonné enregistré (rien à envoyer)\n")
return
}
priv, pub, err := vapidKeys()
if err != nil {
fmt.Printf("[push] clés VAPID indisponibles : %v\n", err)
return
}
msg, _ := json.Marshal(pushPayload{Title: title, Body: body, Tag: "loki-turn"})
opts := &webpush.Options{
// ⚠️ SANS préfixe « mailto: » : webpush-go l'ajoute lui-même (sauf si la
// chaîne commence par « https: »). Passer « mailto:… » ici donnait
// « mailto:mailto:… », un sujet VAPID malformé → Apple répond 403
// BadJwtToken et rien n'arrive. On donne donc l'email nu.
Subscriber: "mail@nathaninline.com",
VAPIDPublicKey: pub,
VAPIDPrivateKey: priv,
TTL: 120, // périmé après 2 min : une notif « réponse prête » n'a pas de sens tardive
Urgency: webpush.UrgencyHigh,
}
fmt.Printf("[push] envoi à %d abonné(s)…\n", len(subs))
for _, s := range subs {
sub := &webpush.Subscription{Endpoint: s.Endpoint, Keys: s.Keys}
resp, err := webpush.SendNotification(msg, sub, opts)
if err != nil {
fmt.Printf("[push] échec envoi (%s) : %v\n", endpointHost(s.Endpoint), err)
continue
}
// 201 Created = accepté par le service de push. 4xx = problème (VAPID,
// chiffrement, abonnement mort) — on TRACE le corps de la réponse, qui porte
// le motif exact d'Apple/Google (ex. « BadJwtToken »).
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
fmt.Printf("[push] refus %d (%s) : %s\n", resp.StatusCode, endpointHost(s.Endpoint), strings.TrimSpace(string(body)))
} else {
fmt.Printf("[push] accepté %d (%s)\n", resp.StatusCode, endpointHost(s.Endpoint))
}
// 404/410 = abonnement expiré côté service de push : on le retire.
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
removeSub(s.Endpoint)
}
resp.Body.Close()
}
}
// endpointHost : hôte de l'endpoint pour les logs (miroir de pushEndpointHost,
// gardé dans ce fichier pour éviter un couplage de compilation entre les deux).
func endpointHost(endpoint string) string { return pushEndpointHost(endpoint) }
+1 -10
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.14.0"
const Version = "0.12.3"
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
func Main() {
@@ -84,8 +84,6 @@ func Main() {
mustExit(cmdOAI(args))
case "agent":
mustExit(cmdAgent(args))
case "computer":
mustExit(cmdComputer(args))
case "internet":
mustExit(cmdInternet(args))
case "memory":
@@ -158,7 +156,6 @@ Interaction:
memory [off|ondemand|always|status] mode mémoire de l'IA
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
accès web de l'IA (moteur intégré ou serveur Crawl4AI)
computer [on|off|status] laisse l'IA piloter un navigateur (browser_open/click/type…)
Accès distant (ajean.link) :
link <token> enregistre le jeton et ouvre le tunnel
@@ -243,12 +240,6 @@ func memoryDir() string { return projectMemoryDir(activeProjectSlug()) }
func modelsDir() string { return filepath.Join(LokiHome(), "models") }
func workspaceDir() string { return filepath.Join(LokiHome(), "workspace") }
// scriptsDir est le dossier des scripts de l'agent : à côté de memory/presets,
// HORS du workspace. Le workspace est un bac à sable jetable (clones, tests,
// fichiers d'une discussion) qu'un nettoyage — ou la suppression de la
// discussion — peut raser ; les scripts qu'on veut CONSERVER vivent ici.
func scriptsDir() string { return filepath.Join(LokiHome(), "scripts") }
// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est
// uiUnitName (« loki-ui »), qui sert l'interface et le tunnel.
func serviceName() string {
-3
View File
@@ -22,7 +22,6 @@ type lokiPaths struct {
Installed string `json:"installed"` // binaire installé (peut différer de Exe)
Workspace string `json:"workspace"` // dossier de travail du mode agent
Memory string `json:"memory"`
Scripts string `json:"scripts"` // scripts durables de l'agent (hors workspace jetable)
Presets string `json:"presets"`
Models string `json:"models"`
Backends string `json:"backends"`
@@ -51,7 +50,6 @@ func currentPaths() lokiPaths {
Installed: installedExePath(),
Workspace: agentWorkspace(),
Memory: memoryDir(),
Scripts: scriptsDir(),
Presets: presetsDir(),
Models: modelsDir(),
Backends: backendsDir(),
@@ -68,7 +66,6 @@ func cmdWhere(args []string) error {
{"binaire installé", p.Installed},
{"travail de l'agent", p.Workspace},
{"mémoire", p.Memory},
{"scripts", p.Scripts},
{"presets", p.Presets},
{"modèles", p.Models},
{"backends", p.Backends},
+2 -23
View File
@@ -31,12 +31,6 @@ type Task struct {
// chantier — une veille « NAS » n'écrit pas dans la mémoire « recettes ». Vide
// sur les tâches d'avant les projets : traitées comme « Générale ».
Project string `json:"project,omitempty"`
// Kind distingue une tâche IA d'une tâche script. Vide ou "agent" = Prompt est
// une consigne exécutée par le modèle (RunAutonomous). "script" = on lance
// directement le script Script du dossier protégé, SANS charger le modèle ni
// consommer de tokens. Le zéro JSON ("") garde le comportement historique (IA).
Kind string `json:"kind,omitempty"`
Script string `json:"script,omitempty"` // pour Kind=="script" : nom du script dans scriptsDir()
// Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON
// (tâches créées avant ces champs) garde le comportement historique : accès.
NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche
@@ -81,11 +75,9 @@ func listTasks() []Task {
}
// taskProjectOf renvoie le projet d'une tâche, l'absence valant projet par défaut :
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. Un
// projet SUPPRIMÉ depuis retombe de même sur « Générale » : sans ça, la tâche
// restait rattachée à un slug fantôme et n'apparaissait plus dans aucun projet.
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire.
func taskProjectOf(t Task) string {
if s := strings.TrimSpace(t.Project); s != "" && projectExists(s) {
if s := strings.TrimSpace(t.Project); s != "" {
return s
}
return defaultProjectSlug
@@ -109,19 +101,6 @@ func tagOrphanTasks(slug string) {
}
}
// listTasksForProject ne renvoie que les tâches rattachées au projet `slug`
// (mêmes tri et résolution que listTasks). Sert à cloisonner les outils task_*
// exposés à l'IA : dans un projet, elle ne voit et ne pilote QUE ses tâches.
func listTasksForProject(slug string) []Task {
var out []Task
for _, t := range listTasks() {
if taskProjectOf(t) == slug {
out = append(out, t)
}
}
return out
}
// getTask lit une tâche par ID. Renvoie false si absente.
func getTask(id string) (Task, bool) {
var t Task
-17
View File
@@ -45,22 +45,11 @@ func loc(tz string) *time.Location {
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
// - expression cron 5 champs.
// - "@once 2026-10-01 16:54" : une seule fois, à cette date et heure murale.
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
s := strings.TrimSpace(schedule)
if s == "" {
return time.Time{}, fmt.Errorf("fréquence vide")
}
if rest, ok := cutPrefix(s, "@once"); ok {
at, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(rest), loc(tz))
if err != nil {
return time.Time{}, fmt.Errorf("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)")
}
if !at.After(from) {
return time.Time{}, fmt.Errorf("%s est déjà passé (il est %s)", at.Format("2006-01-02 15:04"), from.In(loc(tz)).Format("2006-01-02 15:04"))
}
return at, nil
}
if rest, ok := cutPrefix(s, "@every"); ok {
return nextEvery(strings.TrimSpace(rest), tz, from)
}
@@ -260,9 +249,3 @@ func cutPrefix(s, prefix string) (string, bool) {
}
return "", false
}
// isOnce : tâche à exécution unique (« @once … »), désactivée après son passage.
func isOnce(schedule string) bool {
_, ok := cutPrefix(strings.TrimSpace(schedule), "@once")
return ok
}
-68
View File
@@ -1,68 +0,0 @@
package loki
import (
"strings"
"testing"
"time"
)
// « @once » : une date future est acceptée, une date passée refusée.
func TestScheduleOnce(t *testing.T) {
from := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
at, err := nextAfter("@once 2026-10-02 12:30", "UTC", from)
if err != nil || !at.Equal(from.Add(30*time.Minute)) {
t.Fatalf("@once futur : %v, %v", at, err)
}
if _, err := nextAfter("@once 2026-10-02 11:00", "UTC", from); err == nil {
t.Fatal("@once passé accepté")
}
if !isOnce(" @ONCE 2026-10-02 12:30") || isOnce("@every 2h") {
t.Fatal("isOnce se trompe")
}
}
// L'IA crée un rappel « dans 20 minutes » ou « à HH:MM » sans écrire de cron,
// dans le fuseau du navigateur ; une fois passée, la tâche se désactive.
func TestTacheUniqueParLIA(t *testing.T) {
testHome(t)
rememberUserTZ("Europe/Paris")
if got := defaultTaskTZ(); got != "Europe/Paris" {
t.Fatalf("fuseau retenu = %q", got)
}
rememberUserTZ("Pas/Un_Fuseau") // invalide : ignoré
if got := defaultTaskTZ(); got != "Europe/Paris" {
t.Fatalf("fuseau invalide retenu : %q", got)
}
out := toolTaskCreate(map[string]any{"name": "rappel", "prompt": "dis bonjour", "in_minutes": float64(20)})
if !strings.HasPrefix(out, "[ok]") || !strings.Contains(out, "Europe/Paris") {
t.Fatalf("création « dans 20 min » : %s", out)
}
out = toolTaskCreate(map[string]any{"name": "apéro", "prompt": "rappelle l'apéro", "at": "18:30"})
if !strings.HasPrefix(out, "[ok]") {
t.Fatalf("création « à 18:30 » : %s", out)
}
if out := toolTaskCreate(map[string]any{"name": "flou", "prompt": "x", "at": "demain"}); !strings.HasPrefix(out, "[erreur]") {
t.Fatalf("'at' invalide accepté : %s", out)
}
var once Task
for _, tk := range listTasks() {
if tk.Name == "rappel" {
once = tk
}
}
if !isOnce(once.Schedule) || once.TZ != "Europe/Paris" || once.NextRun == 0 {
t.Fatalf("tâche unique mal posée : %+v", once)
}
want := time.Now().Add(20 * time.Minute)
if d := time.UnixMilli(once.NextRun).Sub(want); d > time.Minute || d < -time.Minute {
t.Fatalf("prochaine exécution à %v, attendu ≈ %v", time.UnixMilli(once.NextRun), want)
}
recordTaskEnd(once.ID, time.Now(), "bonjour", nil)
after, _ := getTask(once.ID)
if after.Enabled || after.NextRun != 0 {
t.Fatalf("tâche unique encore active après son passage : %+v", after)
}
}
+12 -33
View File
@@ -68,25 +68,26 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
if temperature == 0 {
temperature = 0.7
}
// La note de tâche (conscience du mode autonome + compte-rendu du passage
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : le
// préfixe système + outils reste ainsi identique à celui du chat, et le cache
// de prompt de llama-server sert aux deux. Une note dans le système faisait
// tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s
// mesurées en amont pour un simple « salut », AJEAN 0.17.5).
if note := taskContextNote(taskName, lastReport); note != "" {
prompt = note + "\n\n" + prompt
}
msgs := []Message{{Role: "user", Content: prompt}}
// Même préambule que la vraie génération : consigne personnelle + préambule
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
// contexte et les mêmes outils qu'en chat.
// contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de
// contexte (conscience du mode autonome + mémoire du passage précédent),
// fusionnée dans UN SEUL message système en tête — comme l'exigent les
// gabarits stricts (normalizeSystemMessages le garantit de toute façon).
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
// chantier — la tâche voit la mémoire et les trackers qu'elle vise.
final := append(projectSystemMessages(), msgs...)
if sys := readSysPrompt(); sys != "" {
sys := readSysPrompt()
note := taskContextNote(taskName, lastReport)
switch {
case sys != "" && note != "":
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, final...)
case sys != "":
final = append([]Message{{Role: "system", Content: sys}}, final...)
case note != "":
final = append([]Message{{Role: "system", Content: note}}, final...)
}
var content strings.Builder
@@ -148,14 +149,6 @@ const reportMax = 4000
func runTask(t Task) {
start := time.Now()
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
// Testé AVANT le preset : basculer le moteur pour un script serait absurde.
if t.Kind == "script" {
runScriptTask(t)
return
}
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
@@ -266,9 +259,6 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
cur.LastDurMs = now.Sub(start).Milliseconds()
cur.LastRun = now.UnixMilli()
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
if isOnce(cur.Schedule) {
cur.Enabled, cur.NextRun = false, 0 // passée une fois : terminée
}
if errors.Is(err, context.Canceled) {
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
// « interrompue » sans allumer l'indicateur rouge d'erreur.
@@ -287,15 +277,4 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
cur.LastReport = report
}
_ = saveTask(cur)
// Notification Web Push : une tâche planifiée tourne SANS personne devant
// l'écran — c'est le cas où être prévenu compte le plus. Détaché (l'envoi
// HTTP ne doit pas retenir le planificateur) et silencieux sur une
// interruption manuelle : celui qui a cliqué « stop » est déjà là.
if hasPushSubs() && !errors.Is(err, context.Canceled) {
title, body := "Loki · tâche terminée", cur.Name
if err != nil {
title = "Loki · tâche en échec"
}
go sendPushToAll(title, body)
}
}
-134
View File
@@ -1,134 +0,0 @@
package loki
// tasks_script.go — exécution d'une tâche « script seul » : on lance un script du
// dossier scriptsDir(), SANS charger le modèle ni consommer de tokens. La sortie
// du script devient le compte-rendu, exactement comme pour une tâche IA, si bien
// que l'UI et les outils task_* l'affichent sans traitement particulier.
//
// Une tâche script ne passe PAS par le verrou de génération de la conversation
// (elle n'utilise pas le modèle), donc conv.runningTaskID ne la voit pas. On tient
// donc un petit registre à part (scriptRunning) pour que l'UI l'affiche « en
// cours » et puisse l'arrêter.
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
// scriptTaskTimeout borne l'exécution d'un script planifié. Généreux (un backup ou
// une synchro peut durer), mais fini : une tâche coincée ne doit pas bloquer le
// planificateur à vie.
const scriptTaskTimeout = 10 * time.Minute
// scriptRunning suit les tâches script en cours (id → cancel + nom), pour que
// handleTasks les signale « en cours » et que l'arrêt (/api/tasks/stop) puisse
// annuler leur contexte.
var scriptRunning = struct {
mu sync.Mutex
cancel map[string]context.CancelFunc
name map[string]string
}{cancel: map[string]context.CancelFunc{}, name: map[string]string{}}
func scriptRunBegin(id, name string, cancel context.CancelFunc) {
scriptRunning.mu.Lock()
scriptRunning.cancel[id] = cancel
scriptRunning.name[id] = name
scriptRunning.mu.Unlock()
}
func scriptRunEnd(id string) {
scriptRunning.mu.Lock()
delete(scriptRunning.cancel, id)
delete(scriptRunning.name, id)
scriptRunning.mu.Unlock()
}
// scriptRunningAny renvoie l'id et le nom d'une tâche script en cours (la
// première trouvée), ou "","" si aucune. Le scheduler ne lance qu'une tâche à la
// fois, donc en pratique il y en a au plus une.
func scriptRunningAny() (string, string) {
scriptRunning.mu.Lock()
defer scriptRunning.mu.Unlock()
for id, n := range scriptRunning.name {
return id, n
}
return "", ""
}
// scriptRunStop annule la tâche script en cours d'id `id`. Renvoie true si une
// exécution correspondante a été trouvée et annulée.
func scriptRunStop(id string) bool {
scriptRunning.mu.Lock()
c := scriptRunning.cancel[id]
scriptRunning.mu.Unlock()
if c != nil {
c()
return true
}
return false
}
// runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask
// pour une tâche IA), mais sans aucune inférence.
func runScriptTask(t Task) {
start := time.Now()
name := strings.TrimSpace(t.Script)
if name == "" {
recordTaskEnd(t.ID, start, "", fmt.Errorf("aucun script associé à la tâche"))
return
}
full, err := scriptsPath(name)
if err != nil {
recordTaskEnd(t.ID, start, "", err)
return
}
if _, err := os.Stat(full); err != nil {
recordTaskEnd(t.ID, start, "", fmt.Errorf("script introuvable : %s", name))
return
}
ctx, cancel := context.WithTimeout(context.Background(), scriptTaskTimeout)
defer cancel()
// Registre : rend la tâche visible « en cours » dans l'UI et arrêtable via
// /api/tasks/stop (qui appelle scriptRunStop → cancel).
scriptRunBegin(t.ID, t.Name, cancel)
defer scriptRunEnd(t.ID)
// On réutilise runShell : garde-fous, dossier de travail, troncature de sortie
// et gestion du timeout y sont déjà. La sortie formatée ("exit: N ...") sert à
// la fois de compte-rendu et de signal de succès.
out := runShell(ctx, scriptRunCommand(full), int(scriptTaskTimeout/time.Second))
switch {
case ctx.Err() == context.Canceled:
// Arrêt volontaire (bouton stop) : recordTaskEnd l'affiche « interrompue »
// sans allumer l'indicateur d'échec.
recordTaskEnd(t.ID, start, "", context.Canceled)
case strings.HasPrefix(out, "exit: 0"):
recordTaskEnd(t.ID, start, out, nil)
default:
// Sortie non nulle, timeout ou erreur de lancement : échec, avec la sortie
// complète comme message d'erreur.
recordTaskEnd(t.ID, start, "", fmt.Errorf("%s", out))
}
}
// scriptRunCommand construit la ligne de commande qui exécute le script `path`
// selon la plateforme et l'extension. Sous Unix, on lance le fichier directement
// (il est écrit en 0755, le shebang choisit l'interpréteur). Sous Windows, un .ps1
// passe par PowerShell ; le reste (.bat/.cmd/.exe) se lance tel quel via cmd. Le
// nom de script est déjà bridé par scriptsPath (pas de guillemet ni de métacaractère
// shell), donc l'entourer de guillemets suffit.
func scriptRunCommand(path string) string {
q := `"` + path + `"`
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".ps1") {
return `powershell -NoProfile -ExecutionPolicy Bypass -File ` + q
}
return q
}
-331
View File
@@ -1,331 +0,0 @@
package loki
// tasks_tools.go — outils task_* exposés au modèle pour qu'il gère LUI-MÊME ses
// tâches planifiées (voir tasks.go) : se donner un rappel récurrent, une veille,
// un ménage périodique… puis les lister, les ajuster ou les supprimer. Ils
// réutilisent exactement l'infra de l'UI (validateSchedule, computeNextRun,
// saveTask), donc une tâche créée par l'IA apparaît et se pilote comme les
// autres dans le panneau Tâches.
//
// Gate : réservés au mode agent (EnabledTools). Une tâche n'a de sens que si
// l'IA peut agir en arrière-plan ; sans agent elle n'aurait aucun outil pour
// livrer son résultat.
import (
"fmt"
"strings"
"time"
)
// str extrait une chaîne d'un argument d'outil décodé en any ("" si absent ou
// d'un autre type).
func taskStrArg(v any) string {
s, _ := v.(string)
return s
}
func taskListTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "task_list",
Description: "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "Show only this task, with its full last report"},
},
},
},
}
}
func taskCreateTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "task_create",
Description: "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
"prompt": map[string]any{"type": "string", "description": "AI task: self-contained instruction to run each time (omit if using script)"},
"script": map[string]any{"type": "string", "description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved"},
// Le format du schedule est le seul point que le modèle ne peut pas
// deviner : il vit ici, dans le paramètre, pas dans la phrase de l'outil.
"schedule": map[string]any{"type": "string", "description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)"},
"in_minutes": map[string]any{"type": "integer", "description": "One-time task, N minutes from now"},
"at": map[string]any{"type": "string", "description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\""},
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
},
"required": []string{"name"},
},
},
}
}
func taskUpdateTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "task_update",
Description: "Change one or more fields of a scheduled task by id.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
"name": map[string]any{"type": "string"},
"prompt": map[string]any{"type": "string"},
"schedule": map[string]any{"type": "string"},
"enabled": map[string]any{"type": "boolean", "description": "false pauses, true resumes"},
},
"required": []string{"id"},
},
},
}
}
func taskDeleteTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "task_delete",
Description: "Delete a scheduled task by id.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string"},
},
"required": []string{"id"},
},
},
}
}
// stUserTZKey : fuseau IANA du navigateur de l'utilisateur, envoyé avec chaque
// message (voir rememberUserTZ).
const stUserTZKey = "user_tz"
// rememberUserTZ retient le fuseau du navigateur (« Europe/Paris ») s'il est
// valide et a changé. Le conteneur tourne en UTC : sans ça, « rappelle-moi à
// 17h » tombait deux heures à côté (AJEAN 0.17.5).
func rememberUserTZ(tz string) {
tz = strings.TrimSpace(tz)
if tz == "" || tz == getStr(bkState, stUserTZKey) {
return
}
if _, err := time.LoadLocation(tz); err == nil {
_ = putStr(bkState, stUserTZKey, tz)
}
}
// defaultTaskTZ : le fuseau de l'utilisateur pour ce que le modèle planifie.
// Celui du navigateur s'il est connu, sinon celui d'une tâche existante (posé
// par l'UI), sinon vide = heure locale du serveur (souvent UTC).
func defaultTaskTZ() string {
if tz := getStr(bkState, stUserTZKey); tz != "" {
return tz
}
for _, t := range listTasks() {
if strings.TrimSpace(t.TZ) != "" {
return t.TZ
}
}
return ""
}
// toolTaskList formate les tâches pour le modèle. Avec un id, on ne montre QUE
// cette tâche, compte-rendu COMPLET (le cas « donne-moi le compte-rendu de la
// tâche X ») ; sans id, la liste complète avec un aperçu court par tâche.
func toolTaskList(args map[string]any) string {
proj := activeProjectSlug()
tasks := listTasksForProject(proj)
if id := strings.TrimSpace(taskStrArg(args["id"])); id != "" {
// On ne révèle que les tâches du projet actif : une tâche d'un autre projet
// est traitée comme inexistante (même message que si l'id était faux).
t, ok := getTask(id)
if !ok || taskProjectOf(t) != proj {
return "[erreur] tâche introuvable : " + id
}
return formatTask(t, true)
}
if len(tasks) == 0 {
return "[aucune tâche planifiée dans ce projet]"
}
var b strings.Builder
for _, t := range tasks {
b.WriteString(formatTask(t, false) + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
// formatTask rend une tâche. full=true : compte-rendu entier (sur ses propres
// lignes) ; full=false : aperçu court d'une ligne, pour une liste scannable.
func formatTask(t Task, full bool) string {
var b strings.Builder
state := "off"
if t.Enabled {
state = "on"
}
fmt.Fprintf(&b, "- %s [%s] « %s » — %s", t.ID, state, t.Name, t.Schedule)
if t.NextRun > 0 && t.Enabled {
fmt.Fprintf(&b, " (prochaine : %s)", time.UnixMilli(t.NextRun).In(loc(t.TZ)).Format("2006-01-02 15:04"))
}
if t.LastRun > 0 {
when := time.UnixMilli(t.LastRun).In(loc(t.TZ)).Format("2006-01-02 15:04")
if t.LastOK {
fmt.Fprintf(&b, " ✓ dernier run %s", when)
} else {
fmt.Fprintf(&b, " ✗ dernier run %s : %s", when, t.LastError)
}
if r := strings.TrimSpace(t.LastReport); r != "" {
if full {
// Compte-rendu ENTIER (déjà borné à 4000 car au stockage), tel quel,
// sur ses propres lignes — c'est ce que l'utilisateur demande.
b.WriteString("\n compte-rendu complet :\n" + r)
} else {
b.WriteString("\n compte-rendu : " + previewReport(r))
}
}
}
return b.String()
}
// previewReport aplati + tronque le compte-rendu pour l'aperçu de la liste (un
// rapport entier par tâche gonflerait la sortie). Le texte complet s'obtient via
// task_list avec l'id de la tâche.
func previewReport(s string) string {
s = strings.Join(strings.Fields(s), " ")
const max = 300
if len(s) > max {
return s[:max] + " […] (id pour le compte-rendu complet)"
}
return s
}
// toolTaskCreate crée une tâche depuis un appel d'outil du modèle.
func toolTaskCreate(args map[string]any) string {
name := strings.TrimSpace(taskStrArg(args["name"]))
prompt := strings.TrimSpace(taskStrArg(args["prompt"]))
script := strings.TrimSpace(taskStrArg(args["script"]))
schedule := strings.TrimSpace(taskStrArg(args["schedule"]))
tz := defaultTaskTZ()
// Tâche unique : « dans N minutes » ou « à HH:MM », calculés ICI dans le
// fuseau de l'utilisateur (AJEAN 0.17.5). Le modèle n'a plus à jongler avec
// les fuseaux ni le cron pour un simple rappel.
if schedule == "" {
now := time.Now().In(loc(tz))
if v, ok := args["in_minutes"].(float64); ok && v >= 1 {
schedule = "@once " + now.Add(time.Duration(v)*time.Minute).Format("2006-01-02 15:04")
} else if at := strings.TrimSpace(taskStrArg(args["at"])); at != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04", at, loc(tz)); err == nil {
schedule = "@once " + t.Format("2006-01-02 15:04")
} else if hh, mm, err := parseHHMM(at); err == nil {
t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc(tz))
if !t.After(now) {
t = t.AddDate(0, 0, 1)
}
schedule = "@once " + t.Format("2006-01-02 15:04")
} else {
return "[erreur] 'at' invalide : « HH:MM » ou « AAAA-MM-JJ HH:MM »"
}
}
}
if name == "" || schedule == "" {
return "[erreur] name obligatoire, et schedule (tâche répétée) ou in_minutes / at (tâche unique)"
}
kind := ""
if script != "" {
// Tâche script : le script doit exister dans le dossier protégé.
kind = "script"
if err := scriptExists(script); err != nil {
return "[erreur] " + err.Error()
}
} else if prompt == "" {
return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)"
}
if err := validateSchedule(schedule, tz); err != nil {
return "[erreur] fréquence invalide : " + err.Error()
}
enabled := true
if v, ok := args["enabled"].(bool); ok {
enabled = v
}
t := Task{
ID: newTaskID(), Name: name, Prompt: prompt, Schedule: schedule,
TZ: tz, Enabled: enabled, Kind: kind, Script: script,
// Rattache la tâche au projet actif : l'IA ne crée que DANS son projet,
// comme elle n'y voit et n'y pilote que ses propres tâches.
Project: activeProjectSlug(),
}
t.NextRun = computeNextRun(schedule, tz, time.Now())
if err := saveTask(t); err != nil {
return "[erreur] " + err.Error()
}
when := ""
if t.NextRun > 0 && enabled {
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04") + " (" + tzLabel(tz) + ")"
}
return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when)
}
// toolTaskUpdate modifie une tâche existante (partiel).
func toolTaskUpdate(args map[string]any) string {
id := strings.TrimSpace(taskStrArg(args["id"]))
if id == "" {
return "[erreur] id manquant"
}
t, ok := getTask(id)
if !ok || taskProjectOf(t) != activeProjectSlug() {
return "[erreur] tâche introuvable : " + id
}
if v, ok := args["name"].(string); ok && strings.TrimSpace(v) != "" {
t.Name = strings.TrimSpace(v)
}
if v, ok := args["prompt"].(string); ok && strings.TrimSpace(v) != "" {
t.Prompt = strings.TrimSpace(v)
}
if v, ok := args["schedule"].(string); ok && strings.TrimSpace(v) != "" {
sched := strings.TrimSpace(v)
if err := validateSchedule(sched, t.TZ); err != nil {
return "[erreur] fréquence invalide : " + err.Error()
}
t.Schedule = sched
t.NextRun = computeNextRun(sched, t.TZ, time.Now())
}
if v, ok := args["enabled"].(bool); ok {
t.Enabled = v
if v && t.NextRun == 0 {
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
}
}
if err := saveTask(t); err != nil {
return "[erreur] " + err.Error()
}
return fmt.Sprintf("[ok] tâche '%s' (id %s) mise à jour", t.Name, t.ID)
}
// toolTaskDelete supprime une tâche.
func toolTaskDelete(args map[string]any) string {
id := strings.TrimSpace(taskStrArg(args["id"]))
if id == "" {
return "[erreur] id manquant"
}
t, ok := getTask(id)
if !ok || taskProjectOf(t) != activeProjectSlug() {
return "[erreur] tâche introuvable : " + id
}
if err := deleteTask(id); err != nil {
return "[erreur] " + err.Error()
}
return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id)
}
// tzLabel : nom du fuseau à montrer au modèle.
func tzLabel(tz string) string {
if tz == "" {
return "heure du serveur"
}
return tz
}
-121
View File
@@ -1,121 +0,0 @@
package loki
import (
"crypto/rand"
"encoding/hex"
"strings"
"sync/atomic"
bolt "go.etcd.io/bbolt"
)
// tool_results.go — résultats COMPLETS des outils, pour le bouton « voir plus ».
// Repris d'AJEAN 0.15.5 / 0.15.7. Chiffré comme les discussions quand le
// chiffrement mémoire est actif (bkToolRes figure dans encryptedBuckets).
//
// Le flux n'envoie à l'UI qu'un aperçu (toolPreviewChars) ; le reste se charge
// au clic. Il ne peut pas être cherché dans conv.Messages : pendant un tour
// d'agent, les messages tool n'y sont versés qu'à la FIN du tour, et après un
// compactage les anciens ont disparu de la vue modèle. On garde donc chaque
// résultat coupé dans un bucket dédié, sous un id PROPRE (pas le tool_call_id,
// que certains parseurs réutilisent d'un tour à l'autre).
//
// Rangement PAR DISCUSSION : la clé est « <id de discussion>.<aléa> ». Les
// résultats vivent aussi longtemps que leur discussion (supprimés avec elle,
// voir deleteToolResultsFor) ; les orphelins sont nettoyés de temps en temps.
const (
bkToolRes = "toolres"
toolResPruneGap = 200 // nettoyage tenté toutes les N écritures
)
var toolResWrites atomic.Int64
func toolResID(sid string) string {
var b [12]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
if sid = strings.TrimSpace(sid); sid == "" {
sid = "nosession"
}
return sid + "." + hex.EncodeToString(b[:])
}
// saveToolResult enregistre un résultat complet de la discussion ACTIVE et
// renvoie son id ("" en cas d'échec : l'appelant envoie alors le résultat
// entier dans le flux).
func saveToolResult(result string) string {
id := toolResID(getStr(bkChat, ckActive))
// Chiffrement actif mais mémoire verrouillée : putStoreBytes refuse d'écrire
// en clair, et l'appelant envoie alors le résultat entier dans le flux.
if id == "" || putStoreBytes(bkToolRes, id, []byte(result)) != nil {
return ""
}
if toolResWrites.Add(1)%toolResPruneGap == 0 {
go pruneToolResults()
}
return id
}
// loadToolResult relit un résultat enregistré.
func loadToolResult(id string) (string, bool) {
if strings.ContainsAny(id, "/\\") {
return "", false
}
b, err := getStoreBytesErr(bkToolRes, id)
if err != nil || b == nil {
return "", false
}
return string(b), true
}
// deleteToolResultsFor supprime les résultats d'une discussion supprimée.
func deleteToolResultsFor(sid string) {
if sid == "" {
return
}
prefix := sid + "."
_ = update(bkToolRes, func(b *bolt.Bucket) error {
c := b.Cursor()
var keys [][]byte
for k, _ := c.Seek([]byte(prefix)); k != nil && strings.HasPrefix(string(k), prefix); k, _ = c.Next() {
keys = append(keys, append([]byte(nil), k...))
}
for _, k := range keys {
if err := b.Delete(k); err != nil {
return err
}
}
return nil
})
}
// pruneToolResults supprime les résultats de discussions qui n'existent plus.
func pruneToolResults() {
// Mémoire verrouillée : l'index des discussions est illisible et reviendrait
// vide — tout passerait pour orphelin. On attend le déverrouillage.
if memEncActive() && !memUnlocked() {
return
}
alive := map[string]bool{getStr(bkChat, ckActive): true}
for _, m := range convIndex() {
alive[m.ID] = true
}
_ = update(bkToolRes, func(b *bolt.Bucket) error {
var orphans []string
_ = b.ForEach(func(k, _ []byte) error {
key := string(k)
if i := strings.LastIndexByte(key, '.'); i > 0 && key[:i] != "nosession" && !alive[key[:i]] {
orphans = append(orphans, key)
}
return nil
})
for _, k := range orphans {
if err := b.Delete([]byte(k)); err != nil {
return err
}
}
return nil
})
}
-72
View File
@@ -1,72 +0,0 @@
package loki
import (
"strings"
"testing"
)
// Un résultat coupé est rangé à part, relu en entier, et part avec sa discussion.
func TestToolResultsSaveLoadDelete(t *testing.T) {
testHome(t)
_ = putStr(bkChat, ckActive, "c42")
long := strings.Repeat("ligne de sortie\n", 500)
ev := fillToolResult(&ToolUsedEvent{Name: "bash", Done: true}, long)
if ev.ResultID == "" || !strings.HasPrefix(ev.ResultID, "c42.") {
t.Fatalf("id attendu préfixé par la discussion, got %q", ev.ResultID)
}
if len([]rune(ev.Result)) != toolPreviewChars || ev.ResultChars != len([]rune(long)) {
t.Fatalf("aperçu %d car., taille %d — attendu %d et %d", len([]rune(ev.Result)), ev.ResultChars, toolPreviewChars, len([]rune(long)))
}
if got, ok := loadToolResult(ev.ResultID); !ok || got != long {
t.Fatal("le résultat complet doit être relisible")
}
short := fillToolResult(&ToolUsedEvent{}, "ok")
if short.ResultID != "" || short.Result != "ok" {
t.Fatal("un résultat court part entier, sans id")
}
deleteToolResultsFor("c42")
if _, ok := loadToolResult(ev.ResultID); ok {
t.Fatal("les résultats doivent partir avec leur discussion")
}
}
// Une sortie de commande coupée le DIT, avec sa taille réelle.
func TestTailOutputAnnouncesCut(t *testing.T) {
if tailOutput("court") != "court" {
t.Fatal("une sortie courte reste intacte")
}
out := tailOutput(strings.Repeat("x", toolMaxOutput+10))
if !strings.HasPrefix(out, "[sortie tronquée : ") || !strings.Contains(out, "au total") {
t.Fatalf("mention de coupe absente : %q", out[:60])
}
}
// Mémoire chiffrée : le résultat complet est chiffré en base et relu intact ;
// verrouillée, rien n'est écrit (le flux portera le résultat entier) et le
// nettoyage des orphelins ne tourne pas (l'index illisible ferait tout effacer).
func TestToolResultsChiffres(t *testing.T) {
testHome(t)
clearMemDEK()
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
full := strings.Repeat("ligne de sortie\n", 500)
id := saveToolResult(full)
if id == "" {
t.Fatal("résultat non enregistré")
}
if !looksEncrypted(getBytes(bkToolRes, id)) {
t.Fatal("résultat en clair alors que la mémoire est chiffrée")
}
if got, ok := loadToolResult(id); !ok || got != full {
t.Fatal("résultat chiffré non restitué à l'identique")
}
clearMemDEK()
if saveToolResult("autre") != "" {
t.Fatal("mémoire verrouillée : rien n'aurait dû être écrit")
}
pruneToolResults()
if getBytes(bkToolRes, id) == nil {
t.Fatal("le nettoyage a effacé un résultat pendant que la mémoire était verrouillée")
}
}
-50
View File
@@ -1,50 +0,0 @@
package loki
import (
"bytes"
"encoding/json"
)
// orderedProps : propriétés d'un schéma d'outil sérialisées DANS L'ORDRE donné.
//
// Une map Go sort ses clés par ordre alphabétique : write annonçait content
// AVANT file, edit new avant old. Le modèle écrit ses arguments dans l'ordre
// du schéma — il déroulait donc tout le contenu d'un fichier avant d'en donner
// le chemin. Le chemin d'abord permet de refuser en plein flux une écriture
// interdite (fichier non lu) au lieu d'attendre la fin, et à l'interface de
// nommer le fichier dès le début de la frappe (repris d'OpenFox, 2.0.154).
type orderedProps []propEntry
type propEntry struct {
Key string
Schema map[string]any
}
func (o orderedProps) MarshalJSON() ([]byte, error) {
var b bytes.Buffer
b.WriteByte('{')
for i, p := range o {
if i > 0 {
b.WriteByte(',')
}
k, err := json.Marshal(p.Key)
if err != nil {
return nil, err
}
v, err := json.Marshal(p.Schema)
if err != nil {
return nil, err
}
b.Write(k)
b.WriteByte(':')
b.Write(v)
}
b.WriteByte('}')
return b.Bytes(), nil
}
// preflightRefusal : write/edit refusé pendant le flux, dès que son chemin est
// connu (voir runChat). msg vide = chemin vérifié et accepté.
type preflightRefusal struct {
name, id, file, msg string
}
+2 -2
View File
@@ -47,7 +47,7 @@ func trackerStoreKey(proj, slug string) string { return proj + "/" + slug }
// trackerLoad lit un tracker du projet actif par son slug. false si absent.
func trackerLoad(slug string) (*Tracker, bool) {
var s Tracker
if !getStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) {
if !getJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) {
return nil, false
}
return &s, true
@@ -55,7 +55,7 @@ func trackerLoad(slug string) (*Tracker, bool) {
func trackerSave(slug string, s *Tracker) error {
sort.SliceStable(s.Events, func(i, j int) bool { return s.Events[i].TS < s.Events[j].TS })
return putStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s)
return putJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s)
}
// trackerKeysForProject renvoie les clés brutes du bucket appartenant à un projet.
File diff suppressed because it is too large. Load diff
-17
View File
@@ -1,17 +0,0 @@
{
"name": "Loki",
"short_name": "Loki",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#5E7F5A",
"icons": [
{
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+10 -94
View File
@@ -14,10 +14,6 @@
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAAA9zQYyAAADD0lEQVR4nOzdW27aWgBGYag6ylZqh1I6lFZKppmjEylRLlyMsbG9+n3vsP2w8mfDC193d/Dj17ene5zDuv39/bif+4xZDhAwQ8wR+GRvKGJuMVXcN7+JkJnSrWGPfrGQmdPYsL+MeZGYmdvYxq76KxAyS7hmrQcvtJhZyjXtDQpazCxtaIMXgxYzazGkxbNBi5m1udTkyaDFzFqda3PU13awVkeDts6s3alGPwUtZrbiWKuuHKS8C9o6szUfm7XQpLwGbZ3ZqrftWmhSBE3Kc9CuG2zdS8MWmhRBkyJoUvbuz5RYaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSbnLj9ez2/05PCxy7s/D90XOXYqFJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAialK9LP8C9/Tk8LHLuz8P3Rc7911hoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZl/+PXt6elHwKmYqFJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgibly9/fj/ulHwKm8H/LFpoUQZMiaFKeg3aPZuteGrbQpAialNegXTvYqrftWmhS3gVtpdmaj81aaFI+BW2l2YpjrR5daFGzdqcadeUg5WTQVpq1Otfm2YUWNWtzqcmLVw5RsxZDWhx0hxY1Sxva4OAPhaJmKde0NypSPwXHPYwZ0VFf21lr5ja2sZvDtNZM6daxnGxphc0tpvqvP8vVQdwMMcfV9S53YYGzu9Nnr/8CAAD//xZRk2OJYiOqAAAAAElFTkSuQmCC">
<!-- Manifeste PWA : installabilité Android/desktop et, surtout, Web Push. iOS
s'appuie sur les métas apple-* ci-dessus ; le manifeste ne le gêne pas.
Servi à la racine par web_server.go, comme /sw.js. -->
<link rel="manifest" href="manifest.webmanifest">
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
@@ -119,7 +115,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
</button>
<div id="topbar-title">Nouvelle discussion</div>
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
@@ -163,10 +159,6 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
— injectés dans la réponse en cours, ou traités au tour suivant. -->
<div id="queue-list" aria-live="polite"></div>
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
@@ -280,9 +272,6 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row plain">
<label class="switchrow" style="margin:0"><span class="switch"><input type="checkbox" id="ext-vision"><span class="slider"></span></span> le modèle accepte les images<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">coche si le modèle distant est <b>multimodal</b> (vision) : les images jointes lui sont alors envoyées. À laisser décoché pour un modèle texte seul.</span></span></label>
</div>
<div class="pe-row stack">
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
<div id="ext-test-out" class="pe-note"></div>
@@ -371,42 +360,19 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<section class="set-pane" data-pane="agent">
<div class="set-pane-h"><span>Mode agent</span></div>
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
<!-- Notifications Web Push. Indépendantes du mode agent (une réponse de chat
prête est utile à savoir sans aucun outil), donc HORS du groupe grisé.
À régler sur CHAQUE appareil : l'abonnement appartient au navigateur. -->
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="push-toggle" onchange="togglePush()"><span class="slider"></span></span> me prévenir quand c'est prêt<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le serveur envoie une <b>notification</b> à la fin d'une réponse et à la fin d'une <b>tâche planifiée</b>, même l'app fermée ou le téléphone verrouillé. Demande <b>HTTPS</b> (ou localhost). Sur iPhone : <b>ajoute d'abord Loki à l'écran d'accueil</b>, puis active depuis l'app installée. À régler sur chaque appareil.</span></span></label>
<div class="rowbody"><div id="push-status" class="muted" style="font-size:11px"></div></div>
</div>
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
entier est grisé/inerte quand l'agent est off (voir setAgentGate). -->
<div id="mem-block" class="gated-group">
<div class="subhead">Mémoire<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span id="mem-mode-desc" class="tip"></span></span></div>
<div class="slist">
<label class="switchrow"><span>mode <small class="dim">(projet actif)</small></span>
<label class="switchrow"><span>mode</span>
<select id="mem-mode" onchange="setMemMode()" class="sctl">
<option value="always">auto, injectée</option>
<option value="search">auto, recherche</option>
<option value="always">auto</option>
<option value="ondemand">sur demande</option>
<option value="off">désactivée</option>
</select>
</label>
<!-- Chiffrement au repos. La clé est celle de pilotage de CET appareil
(localStorage) : le serveur n'en a que l'empreinte, il ne peut pas
ouvrir le coffre seul. Couvre les pages, les discussions, les blocs
archivés au compactage et les trackers. -->
<label class="switchrow"><span class="switch"><input type="checkbox" id="mem-enc-toggle" onchange="toggleMemEncrypt()"><span class="slider"></span></span> chiffrer la mémoire sur le disque<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Chiffre en <b>AES-256-GCM</b> les pages mémoire, les discussions, les blocs archivés et les trackers sous <code>/data</code>. La clé de données est enfermée dans un coffre (Argon2id) ouvert par la <b>clé de pilotage de cet appareil</b> ou par une <b>clé de récupération</b> donnée une seule fois à l'activation. Un <b>snapshot</b> est pris avant chaque bascule, et rien n'est supprimé avant relecture vérifiée.</span></span></label>
<div class="rowbody"><div id="mem-enc-status" class="muted" style="font-size:11px"></div></div>
<!-- Sauvegarde : le MÊME paquet chiffré que l'amont envoie à son relais,
mais en fichier local — rien ne quitte la machine sans qu'on le
demande. Nécessite le chiffrement actif : c'est sa clé qui scelle. -->
<div class="pe-row">
<span class="pe-row-l">Sauvegarde chiffrée</span>
<span class="pe-row-c">
<button onclick="backupExport()">exporter</button>
<button onclick="backupImport()">importer</button>
</span>
</div>
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
<span class="pages-title">Voir la mémoire <span id="mem-count" class="mem-count"></span></span>
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
@@ -468,18 +434,6 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
</div>
<!-- Pilotage de navigateur (computer use) : axe de capacité à part, sous le
mode agent comme l'accès internet — un outil qui clique et tape dans une
page fait des actions RÉELLES, même niveau de confiance que bash. -->
<div id="computer-block" class="gated-group">
<div class="subhead">Contrôle du navigateur<span id="computer-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Donne à l'IA le contrôle d'un <b>navigateur</b> dans le conteneur (outils <b>browser_open / browser_find / browser_click / browser_type / browser_key / browser_scroll</b>). L'IA lit les éléments <b>numérotés</b> de la page et agit par numéro — aucune vision requise, ça marche avec de petits modèles. <code>browser_screenshot</code> s'ajoute si la vision est active. Utilise le Chromium de Playwright embarqué dans l'image ; à défaut, <code>LOKI_CHROME=&lt;chemin&gt;</code>. Actif seulement si le <b>mode agent</b> est actif.</span></span></div>
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="computer-toggle" onchange="toggleComputer()"><span class="slider"></span></span> laisser l'IA piloter un navigateur</label>
<div class="rowbody">
<div id="computer-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
</div>
</div>
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="slist">
@@ -667,15 +621,6 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<section class="set-pane" data-pane="appearance">
<div class="set-pane-h"><span>Apparence</span></div>
<div class="slist">
<!-- Langue de l'interface. La source est française ; « English » applique
un dictionnaire sur la coque (navigation, libellés, boutons) — ce qui
n'y figure pas reste en français plutôt que d'afficher une clé. -->
<label class="switchrow"><span>Langue</span>
<select id="ui-lang" class="sctl" onchange="setUiLang(this.value)">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</label>
<label class="switchrow rev"><span>mode sombre</span>
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
</label>
@@ -969,34 +914,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
<!-- Type de tâche : une consigne exécutée par l'IA, ou un script du dossier
protégé lancé SEUL — sans charger le modèle ni consommer de tokens.
Une sauvegarde, une synchro, un nettoyage n'ont rien à demander à un
LLM ; les faire passer par lui coûtait un chargement de modèle. -->
<div class="pe-group">
<div class="pe-gh">Type</div>
<div class="pe-seg">
<label><input type="radio" name="task-kind" value="agent" onchange="taskKindUI('agent')"><span>Consigne IA</span></label>
<label><input type="radio" name="task-kind" value="script" onchange="taskKindUI('script')"><span>Script seul</span></label>
</div>
</div>
<div class="pe-group" id="task-prompt-group">
<div class="pe-gh">Consigne</div>
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat.&#10;&#10;Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
</div>
<div class="pe-group" id="task-script-group" style="display:none">
<div class="pe-gh">Script</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">À exécuter</span>
<span class="pe-row-c"><span class="pe-selc"><select id="task-script"></select></span></span>
</div>
<div class="pe-row stack plain"><div id="task-script-hint" class="muted" style="font-size:11px"></div></div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Preset</div>
<div class="pe-list">
@@ -1239,7 +1161,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1 (lent sur CUDA)</option>
<option value="q5_1">q5_1</option>
<option value="bf16">bf16</option>
</select></span></span>
</div>
@@ -1292,19 +1214,13 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
</div>
<!-- Chargement du modèle : --load-mode de llama.cpp (remplace --mlock et
--no-mmap). Pour un moteur ancien, loki serve retraduit au lancement
(normalizeLoadFlags). -->
<div class="pe-row">
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
<option value="">auto</option>
<option value="mmap">mmap</option>
<option value="none">none</option>
<option value="mlock">mlock</option>
<option value="mmap+mlock">mmap+mlock</option>
<option value="dio">dio</option>
</select></span></span>
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more