Compare commits

..
125 Commits
Author SHA1 Message Date
LogiFlow 6a45f19831 Merge pull request #34 from R0m1k3/fix/lot1-ajean-openfox
Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
2026-10-19 11:22:20 +02:00
MichaelandClaude Opus 5.5 d22a087d8e Tests : trace du bench vérifiée sans longueur de prompt en dur
bail_travail_annexe_et_trace attendait prompt_n = 2036 : la longueur du
dernier prompt suit le corpus de code du bench, c'est-à-dire l'UI embarquée
(internal/loki/ui/index.html). Toute retouche de l'interface cassait donc
ce test (ci / rust rouge après l'ajout de la pause des téléchargements et du
choix de la seconde carte Strata). Le test vérifie désormais la forme de la
trace relayée (prompt_ms = prompt_n / 2, débits du faux moteur) pour un
prompt non vide. Le journal de parité du bench, lui, se régénère avec
LOKI_PARITY_UPDATE=1 comme les autres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:36:47 +02:00
MichaelandClaude Opus 5.5 0ef2ed72f5 Téléchargements de modèles : pause et reprise
Repris d'AJEAN 9c7423bf (v0.18.1) : POST /api/models/download/pause
{"filename", "pause"} met en pause ou reprend un téléchargement en cours.
La pause coupe les connexions (rien ne reste ouvert vers le CDN), garde le
fichier partiel, et chaque morceau attend la reprise puis repart de l'octet
où il s'était arrêté par une requête de plage ; une pause ne consomme pas
d'essai. /status expose « paused », la vitesse publiée tombe à zéro.

Façade Rust (downloads.rs) : même route, même porte (watch), mêmes
réponses ; la route est ajoutée à celles que le Go remet à la façade quand
LOKI_MODELS=rust. Tests portés de part et d'autre (pause puis reprise de
bout en bout, fichier identique à la source).

UI : bouton « Mettre en pause » / « Reprendre » à côté de « Annuler le
téléchargement », « en pause » dans la ligne de progression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:39 +02:00
MichaelandClaude Opus 5.5 615e58dd2f Strata : seconde carte en partage des couches au choix, l'aide aux experts reste le défaut
Repris d'AJEAN 38919238 (#121 amont) et de la partie moteur de 6db3f3d0 :
STRATA_SPLIT=layers dans le preset fait lister les deux cartes dans la clé
« gpu » de la config (principale d'abord, numéros nvidia-smi) ; le serveur de
Strata en déduit --layer-split. Rien du mode d'aide ne subsiste alors : ni
--expert-cache-device1 / --remote-expert-opt, ni source d'aide à la lecture
des prompts, ni encodeur d'images déplacé ; la réserve de VRAM de
l'installeur reste. Sans la clé, ou la seconde carte coupée, rien ne change.

Propre à Loki : le placement des experts selon la RAM disponible ne choisit
jamais le mode hors RAM en partage des couches (la seconde carte n'y garde
pas d'experts, la marge calculée pour elle serait fausse).

Réglages : « split » (helper ou layers ; absent = inchangé pour un ancien
client), détail split_layers. Fenêtre Strata : la case « Carte d'appoint »
devient un choix « Seconde carte » (non utilisée, aide aux experts, partage
des couches) ; la carte de configuration du moteur actif l'affiche.

Façade Rust alignée (strata/mod.rs, strata/api.rs, tests portés) ; journal
de parité régénéré sous Linux avec les réglages et l'affichage du partage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:37 +02:00
MichaelandClaude Opus 5.5 9792953d62 Strata : paquet AJEAN MoE 1.1, reprise automatique d'une installation 1.0
Repris d'AJEAN 9c7423bf (v0.19.0, renumérotée v0.18.1 par e7b024ad) :

- release moe-v1.1 d'AJEAN, empreintes SHA-256 reprises telles qu'AJEAN les
  publie dans son backend_moe.go ;
- avec une carte d'aide, la lecture d'un prompt passe par elle
  (STRATA_PREFILL_HELPER_SOURCE=1) et le chemin direct ne garde que les
  ajouts sous 48 jetons (--short-read 48 au lieu de 150) ;
- mode hors RAM : réserve de VRAM de la carte d'aide à 640 Mio, contexte
  plafonné à 131 072 jetons, marge de RAM portée de 7 à 8 Go (le moteur 1.1
  prend ~1 Go de plus) ;
- strataMigrate (moeMigrate d'AJEAN) : au premier lancement d'un modèle
  installé avec la 1.0, `loki serve` pose les sources 1.1, reprend
  l'environnement Python par un lien quand les dépendances n'ont pas changé,
  installe le moteur 1.1, recopie la config de l'installeur en réécrivant ses
  chemins, puis repointe les presets et la config vivante ensemble
  (l'empreinte du preset actif suit). En cas d'échec, rien n'est réécrit et
  la 1.0 continue de tourner.

Les correctifs propres à Loki restent : placement des experts selon la RAM
disponible et mode « depuis le disque » (6aaf9360).

Façade Rust : mêmes constantes et même configuration de lancement
(strata/mod.rs) ; la migration reste au Go, seul à servir un preset Strata.
Journal de parité régénéré sous Linux : noms du paquet tirés des constantes,
étapes du seuil du mode hors RAM recalées sur la nouvelle marge, étape du
contexte plafonné ajoutée.

La consolidation de Jean la nuit et le niveau de réflexion du moteur MoE
(même commit amont) ne sont pas repris : Loki n'a ni Jean ni ce raccourci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:32 +02:00
MichaelandClaude Opus 5.5 4c2cf8b4e6 Façade : délai d'appel MCP par serveur et par argument, pas de relance au délai dépassé
Même règle que le Go (mcpCallTimeoutFor) : 120 s par défaut, remplacés par
callTimeoutSec du serveur, allongés par l'argument timeout de l'outil (+30 s,
millisecondes au-delà de 3600), plafond 30 min. Un délai dépassé rend la même
erreur que le Go sans reconnecter ni relancer l'outil. callTimeoutSec est lu
et réécrit tel quel dans la configuration des serveurs : un enregistrement
par la façade ne le perd plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 68dd65cd5a Façade : motifs de relance vides écartés, comme le Go
Un motif vide (ou blanc) dans retry_patterns donnait (?mi), qui correspond à
tout : chaque tour était relancé une fois (OpenFox 8e9b4869). Sans un seul
motif utilisable, la façade retombe sur les motifs embarqués, comme
compileRetryPatterns côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
MichaelandClaude Opus 5.5 01c11be4ae Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
- LSP (lsp.go) : un signal resté dans le tampon par une republication
  tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même
  que le serveur ait vu la nouvelle version. Le canal est vidé, une
  publication d'une version antérieure est ignorée, et check attend 350 ms de
  calme après la dernière publication (liste vide puis analyse : c'est
  l'analyse qui compte), toujours borné par lspDiagWait. Au passage :
  interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par
  check) — dead devient atomique.
- run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan
  sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui
  l'effaçait (AJEAN 7403a8a0).
- Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir
  la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par
  toute écriture du bucket d'état, par la date/taille du fichier (autre
  process) et au bout d'une seconde.
- MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par
  serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil,
  plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8).
- Recall : les blocs d'une compaction s'archivent en une seule transaction
  au lieu d'une par bloc (AJEAN #130).
- Compactage en cours de tour : après un essai qui n'a rien réduit, pas de
  nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et
  sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN
  68659e73).
- Motifs de relance : un motif vide (qui correspondait à tout) est écarté,
  une surcharge sans motif utilisable retombe sur l'embarqué, et un
  changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869,
  c52c5bf7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:05:04 +02:00
LogiFlow 00e09a71e3 Lots 8 à 47 : migration Rust — routes, moteur, tâches, optimiseur, tour de la façade et tous les outils de l'agent (#33)
Lots 8 à 47 : migration Rust — routes, moteur, tâches, optimiseur, tour de la façade et tous les outils de l'agent
2026-10-16 09:31:59 +02:00
Claude cbbe62ff97 Tests : faux outils écrits par un sh à part (ETXTBSY)
plan_par_machine (lot 41) a vu « cpu » au lieu de « cuda » en CI : le faux
nvidia-smi, écrit par le process des tests puis lancé aussitôt, peut être
encore ouvert en écriture dans le fils qu'un test voisin vient de forker ;
son exec échoue alors sur ETXTBSY et l'outil paraît absent. Les scripts des
tests de la compilation et le faux Chrome (lot 44, lancé à côté des tests
parallèles de tests/lot6.rs) sont désormais écrits par un sh à part, comme
les autres faux binaires du dépôt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 07:26:36 +00:00
Claude e8563d992c Lot 47 : journal du sous-agent indépendant du poste
Le briefing machine des vues des rôles nomme l'hôte et le compte : masqués
dans le journal ({{HOTE}}, {{COMPTE}}), qui différait sur le poste de la CI.
Il nomme aussi le système et l'architecture : journal tiré sous Linux
seulement, comme les autres journaux qui dépendent du poste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 07:15:53 +00:00
Claude ded1c97c17 Lot 47 : outil subagent du mode Code exécuté par la façade
Le builder délègue à un rôle en lecture seule (explorer, code-reviewer,
planner) sans passer par /tool/exec : src/chat/subagent.rs porte
toolSubagent. Mêmes refus avant tout appel au moteur (rôle inconnu,
verifier compris, tâche vide, récursion), contexte isolé servi par le Go
(POST /internal/v1/turn/subagent : prompt du rôle + briefing machine,
outils, capacités ; subagentView et subagentFraming partagés), tour
imbriqué run_turn avec l'endpoint, le raisonnement, l'écho et l'arrêt du
tour qui délègue, température 0,6, travail annexe (second slot, effacé à
la fin), télémétrie à part. Seul le texte écrit après le dernier outil
revient, rogné, préfixé du rôle, tronqué à 6000 runes.

Le tour du rôle est porté par SubHost, qui délègue tout à son GoTurnHost
sauf un nouvel appel à subagent ; les outils de la façade passent dans
GoTurnHost::exec_tool_base, que seul le tour principal complète de
subagent (le type du tour imbriqué ne se contient pas lui-même).

Parité : journal tiré du Go contre un faux moteur scripté
(parity_subagent_test.go → tests/parity_subagent/subagent.json : refus,
réponse directe, rapport après deux outils, texte vide relancé, rapport
tronqué, cadrage du planner, moteur qui refuse ; requêtes du rôle),
rejoué par un tour complet de la façade (tests/lot6.rs) ;
TestSubagentVueInterne côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 07:12:29 +00:00
Claude 92e9adee7d Lot 46 : outils recall et recall_search exécutés par la façade
Les deux outils qui rappellent un bloc archivé par la compaction ne passent
plus par /tool/exec du Go : src/recall.rs porte toolRecall et
toolRecallSearch (id tolérant comme recallIDToSeq, mots-clés en minuscules
rune par rune comme strings.ToLower, score libellé double et plus récent
d'abord, limites, extraits et textes du Go). L'archive reste au Go, seul à
tenir la séquence de la base, qui la sert à la façade
(GET /internal/v1/recall/block?seq=N et /internal/v1/recall/blocks, blocs
chiffrés sous une mémoire verrouillée exclus) ; ses propres outils passent
par les mêmes lectures. Go injoignable : l'appel passe par lui.

Parité : journal de 41 appels sur une archive de 35 blocs tiré du Go
(parity_recall_test.go → tests/parity_recall/recall.json), rejoué par
tests/recall.rs ; un tour de la façade qui rappelle et cherche
(tests/lot6.rs) ; TestRecallInterneLecture côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 06:54:35 +00:00
Claude db1f20c0a4 Lot 45 : outils d'image (see_image, web_screenshot) tenus par la façade
La façade exécute les deux outils qui montrent une image au modèle
(src/imagetools.rs, port de chat_vision_tool.go, chat_screenshot.go et
prepareImageForModel) : préparation de l'image comme les pièces jointes
(orientation EXIF, grand côté sous 1568 px, mêmes pixels), sonde du moteur
(/props authentifié, gardée 10 s ; preset externe : sa case), see_image aux
textes du Go, web_screenshot par Playwright (adresses locales quand internet
est coupé, mêmes arguments, erreur résumée, capture rangée dans le dossier
de la discussion, ménage à 20 captures et 40 Mo, relayée au modèle s'il
voit les images). Le Go garde les siens pour ses sous-agents et tâches IA.

Parité : journal tiré du Go avec ses fichiers, un faux moteur et un faux
Playwright (tests/parity_images/images.json), rejoué par
tests/imagetools.rs ; un tour de la façade sans passer par le Go
(tests/lot6.rs) ; tests unitaires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 06:36:08 +00:00
Claude fe51bdeee9 Lot 44 : navigateur piloté tenu par la façade
La façade tient la session Chrome des outils browser_* (src/browser/,
port de computer_cdp.go, computer_use.go, browser_grid.go et
cu_autoshot.go) : lancement (profil temporaire, mêmes arguments, tué avec
la façade), client CDP multiplexé sur websocket, attente du chargement et
du calme réseau, scripts injectés copiés à l'octet près, les neuf outils
aux textes du Go (snapshot dédoublonné, recherche, clics, saisie et listes
déroulantes, touches, défilement, navigation et ses erreurs), capture
rangée propre et quadrillée pour le modèle (mêmes pixels), aperçus JPEG en
anneau servis par /api/chat/cushot.

Un seul navigateur : quand la façade tient la conversation, le Go lui
passe les outils de ses sous-agents et tâches IA, ses aperçus et la
fermeture (POST /internal/facade/v1/browser) ; façade injoignable, le
navigateur du Go prend le relais. Couper l'interrupteur ferme les deux.

Parité : journal de 42 étapes tiré du Go contre un faux Chrome (script +
faux serveur CDP qui note chaque commande, scripts résumés par leur
empreinte, pixels de la grille), rejoué par tests/browser.rs ; un vrai
Chromium de bout en bout avec LOKI_CU_SMOKE=1 ; un tour de la façade qui
ouvre une page (tests/lot6.rs) ; TestFacadeNavigateur côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 06:17:51 +00:00
Claude 7288db632e Lot 43 : jobs d'arrière-plan du mode Code tenus par la façade
bash_bg et bash_tail des tours de la façade ne passent plus par /tool/exec
du Go : src/tools/jobs.rs porte code_jobs.go. Refus avant lancement
(commande vide ou dangereuse, optimisation en cours), journal
.loki/jobs/<id>.log dans le dossier de la discussion, processus qui survit
au tour, shell sans le jeton interne ; relecture bornée (40 lignes par
défaut, 200 au plus), état et durée, arrêt relu aussitôt comme le Go.
paths::go_mkdir_all reproduit os.MkdirAll, erreur comprise.

Les jobs d'une discussion supprimée s'arrêtent avec elle. L'optimiseur du
Go compte ceux de la façade (POST /internal/facade/v1/bgjobs) avant de
mesurer ; façade injoignable : les siens seulement.

Parité : journal de 24 étapes tiré du Go (parity_jobs_test.go →
tests/parity_jobs/jobs.json), rejoué par tests/jobs.rs ; un tour de la
façade qui lance un job (tests/lot6.rs) ; TestFacadeJobsArrierePlan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 05:42:32 +00:00
Claude f66d1b7621 Lot 42 : outils web de l'agent exécutés par la façade
Les outils web des tours de la façade (web_search, web_images, web_open,
web_read, web_grep) ne passent plus par /tool/exec du Go : src/webtools.rs
porte chat_internet_tools.go, chat_internet.go et chat_web_images.go. Le
cache des pages (10 min, clé MD5 des options comme le Go) est dans la
façade ; client Crawl4AI au corps près, réponses lues comme json.Unmarshal ;
résultats DuckDuckGo tirés du markdown (uddg, entités, extraits) ;
recherche d'images avec le jeton vqd et les cookies gardés par hôte ;
web_grep sur les regex du Go, erreurs au texte de regexp.

Le moteur intégré lit le HTML avec go-readability et html-to-markdown, que
rien en Rust ne reproduit au caractère près : la conversion d'une page et la
lecture des résultats restent au Go, derrière /internal/v1/web/fetch et
/internal/v1/web/search (sans état). web_screenshot reste au Go.

Le traducteur go_regex refuse la répétition d'une répétition et les comptes
au-delà de 1000, et rend littéral un « { » qui n'ouvre pas de répétition,
comme le Go (motifs retry_patterns compris).

Parité : journal de 74 appels tiré du Go (parity_web_tools_test.go →
tests/parity_web/outils.json : résultats, requêtes reçues par les faux
Crawl4AI et DuckDuckGo, pages demandées au moteur intégré), rejoué par
tests/webtools.rs ; un tour de la façade qui ouvre une page (tests/lot6.rs) ;
TestInternalMoteurWebIntegre côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 05:29:35 +00:00
Claude bed6d07739 Optimiseur : l'essai muet du banc tient son port
L'essai qui ne répond jamais (tests/tune.rs, essai_muet) ne liait pas le
port choisi par l'optimiseur : pendant les 2 s d'attente, un autre test
lancé en parallèle pouvait le prendre et répondre à /health à sa place
(« /props illisible » au lieu de « pas prêt après 2s », vu sur la CI). Il
le lie désormais sans jamais accepter, comme un moteur bloqué au
chargement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:56:31 +00:00
Claude 5197ee2542 Lot 41 : compilation de llama.cpp depuis les sources tenue par la façade
Avec LOKI_BACKENDS=rust, la façade compile elle-même llama.cpp sous Linux
et macOS (backends/build.rs) : install {force}, update {clean} et
install-custom {repo, name, ref}, port de backend_build.go, des jobs
lcRunInstall / lcRunUpdate / lcBuildAndSwitch et d'installCustomBackend.

- plan complet (Metal, CUDA avec racine du toolkit et architectures, HIP,
  Vulkan), build/ déplacé ou propre mis de côté en build.old ;
- étapes suivies : sorties des deux flux dans un même tube, découpage du
  sinkWriter (git) et du scanner (CMake), configure.log et build.log,
  fin des journaux remontée en cas d'échec ;
- outils manquants installés au mieux, compilateur vérifié ;
- service moteur arrêté le temps du build et relancé même en échec ;
- refus comme le Go : moteur de l'image, URL absente, optimisation en
  cours, job déjà lancé.

Côté Go, facadeBackendsRoutes gagne les trois routes hors Windows (la
chaîne MSVC reste au Go, qui rapporte ses étapes au job de la façade).

Parité : internal/loki/parity_build_test.go tire un journal de 15 étapes
(tests/parity_backends/build.json) que tests/backends.rs rejoue par la
façade ; tests unitaires du découpage, du plan, du cache et des erreurs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:54:32 +00:00
Claude 90d6dd4a9a Lot 40 : outils mémoire de l'agent exécutés par la façade
Quand la façade tient la mémoire (LOKI_MEM=rust), les outils mem_search,
mem_read, mem_add, mem_edit et tracker de ses tours s'exécutent chez elle
(src/memory/tools.rs, port de chat_memory.go et de l'outil de tracker.go) :
mêmes résultats et diffs, mêmes lectures et écritures que les routes de la
mémoire (scellées selon le chiffrement, .bak, index tenu par le code, seed
au nom du projet). Recherche classée par couverture puis pertinence,
extrait coupé à l'octet comme le Go ; trackers vus niveau par niveau.

ChatShared porte la même mémoire que les routes ; tracker_add prend l'heure
en paramètre (tracker_add_at). Côté Go, l'heure des trackers passe par
trackerNow, que le journal fixe.

Parité : journal de 57 appels tiré du Go (parity_mem_tools_test.go →
tests/parity_mem/outils.json : résultats, diffs, fichiers de la mémoire et
trackers), rejoué par tests/memory.rs ; un tour de la façade qui crée une
page (tests/lot6.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:29:53 +00:00
Claude 35886da9b1 Lot 39 : anneau de télémétrie et /api/perf/summary tenus par la façade
L'anneau des complétions (perf_log.go) est porté en Rust (src/perfring.rs) :
perte de cache contre la complétion précédente de la même discussion et de
la même nature, ce qui s'est intercalé, seuil d'alerte du preset (dans
l'ordre du lancement, 2048 sur un modèle hybride), ligne [perf], résumé de
/api/perf/summary. ChatShared le porte : tours, vérifications, résumés de
compaction et préchauffage de la façade y rangent leurs complétions sans
aller-retour au Go.

Côté Go, quand la façade tient la conversation, perfRecord range chez elle
(/internal/facade/v1/perf/record) et perfSnapshot relit son anneau
(optimiseur) ; /api/perf/summary lui est remis. Façade injoignable : l'anneau
du Go prend le relais. La route /internal/v1/turn/perf, plus appelée, est
retirée.

Parité : journal tiré du Go (parity_perf_test.go → tests/parity_perf/
perf.json : 14 complétions, résumé, onze seuils), rejoué par
src/perfring/tests.rs ; deux tours qui perdent le cache (tests/lot6.rs),
l'API de l'anneau pour le Go, et TestFacadePerfAnneau côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:17:04 +00:00
Claude 76d9dce74a Lot 38 : l'outil criteria du mode Code exécuté par la façade
L'outil par lequel le modèle tient les critères d'acceptation de sa tâche
(poser, marquer faits, lister, effacer) partait au Go, qui renvoyait la
liste à la façade comme delta. Il s'exécute maintenant dans le tour de la
façade (chat/crittool.rs, port de toolCriteria) : mêmes messages, ids en
nombre ou en chaîne, vingt critères au plus, `passed` réservé à la passe de
vérification, rangement scellé (chat/crit:<id>), liste poussée à l'écran à
chaque changement par un nouveau champ `deltas` de l'hôte du tour.

Les critères sont rangés sous la discussion du tour (le Go prenait la
discussion active : elles ne diffèrent que si l'utilisateur en change
pendant la génération). see_image reste au Go (préparation de l'image).

Parité : journal de 22 appels tiré du Go (parity_criteria_test.go →
tests/parity_tools/criteria.json), rejoué par tests/tools.rs ; le contrat
du mode Code de tests/lot6.rs est désormais vérifié par la façade seule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 04:01:03 +00:00
Claude 1d33ca5a49 Lot 37 : journal des outils task_* réservé à Linux, comme celui des tâches
Il emprunte parityTaskIDRe et parityApplyFiles aux journaux Linux : go vet
échouait sur macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:59:28 +00:00
Claude e94b625931 Lot 37 : outils task_* de l'agent exécutés par la façade
Quand la façade tient les tâches (LOKI_TASKS=rust), les quatre outils par
lesquels le modèle gère ses tâches planifiées — task_list, task_create,
task_update, task_delete — s'exécutent dans ses tours (src/tasks/tools.rs,
port de tasks_tools.go), sans passer par le Go : mêmes messages, projet
actif, fuseau de l'utilisateur, tâche unique « dans N minutes » ou « à
HH:MM », fréquence validée, script vérifié, compte-rendu coupé comme le Go.
ChatShared porte la même base de tâches que les routes.

Côté Go, les outils prennent l'heure des tâches (tasksNow) au lieu de
time.Now : rien ne change en service, le journal de parité la fixe.

Parité : journal de 44 appels tiré du Go
(parity_tasks_tools_test.go → tests/parity_tasks/tools.json), rejoué par
tests/tasks.rs ; un tour de la façade qui crée une tâche (tests/lot6.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:51:09 +00:00
Claude 869bd18bff Lot 36 : appels d'outils MCP du tour de la façade par son pool
Quand la façade tient les serveurs MCP (LOKI_MCP=rust), un appel mcp__*
d'un de ses tours partait au Go (/internal/v1/tool/exec), qui le renvoyait
aussitôt à la façade (/internal/facade/v1/mcp/call) : deux allers-retours
pour revenir au même pool. Le Go n'ajoute rien au résultat d'un outil MCP.

ChatShared porte désormais le pool (le même que celui des routes, créé une
fois) ; GoTurnHost::exec_tool l'appelle directement — même résultat que la
route interne, reconnexion comprise. Une reconnexion annonce la version du
Go (/internal/v1/auth), lue une fois et retenue. Sans pool tenu, rien ne
change. Le Go garde la route interne pour les tours qu'il exécute encore.

Test : un tour de la façade contre un faux serveur MCP stdio — rien par le
Go, résultats tels que le modèle les reçoit, erreur après reconnexion,
version du Go annoncée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:47:58 +00:00
Claude 9dfce204bf Lot 28 : tests contre les mutations survivantes de l'optimiseur
105 mutations du verrou et du job ; le premier passage en tuait 39. Les
survivantes le sont désormais par des tests ajoutés :

- l'essai noté dans le verrou pendant la mesure, la sauvegarde notée avant
  d'écrire, l'application refusée pendant la mesure, la cible inconnue ;
- l'application gardée : preset qui n'est plus en service, API externe,
  clé changée ailleurs pendant l'application (défaite) ;
- l'application planifiée : clé sur option refusée sans l'option ;
- l'essai muet (délai de chargement), /props sans
  default_generation_settings, SIDE_SLOT sur un seul slot, threads comptés
  sur les cœurs de la sonde, aucun changement de moteur signalé quand son
  build est inconnu ;
- VRAM mesurée par un faux nvidia-smi, budget, profondeur, notes et poids,
  écarts de mesure, essais écartés, moteur piloté par la façade.

nvidia-smi, la pause entre deux lectures de VRAM et l'unité du budget
deviennent réglables (tests). Le test de l'essai orphelin lit l'identité
du processus une fois l'exec visible dans /proc : lue trop tôt, elle
portait l'exécutable du test (échec intermittent en CI).

Les vingt survivantes restantes, équivalentes ou hors de portée d'un test
(courses, estimation seulement), sont décrites dans la spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:46:44 +00:00
Claude c157da2141 Lot 35 : routes de base de la façade aussi par le tunnel du relais
Les neuf routes que la façade sert toujours (ping, status, config, ligne
de commande du moteur, couches du modèle, chemins, RAM, VRAM, journal du
service) arrivent désormais à la façade par le tunnel du relais aussi :
withFacadeChat les lui remet dès qu'elle tourne (facade_core.go). Les
handlers du Go ne servent plus que sans façade (LOKI_FACADE=0).

Côté Rust, une requête relayée par le Go (jeton interne) n'est pas
réauthentifiée, comme pour les routes des lots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:19:00 +00:00
Claude ad52da18a9 Lot 34 : bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
PROJ_SNAPSHOT était le dernier réglage qui gardait la conversation au
Go : la façade la tient désormais dans tous les cas (LOKI_CHAT=go reste
la porte de sortie). facadeChatBlocker et la décision prise une fois par
processus disparaissent.

Go (facade_projsnap.go) :
- /internal/v1/turn/view, avec la clé et l'agent : tête du tour portant
  le bloc figé (ou le vivant à rafraîchir), préambule sans date ni
  dossier, et la décision projsnap (rafraîchir, mise à jour à poser,
  instantané à ranger, clé et système du tour) ; dry pour le
  préchauffage ;
- /internal/v1/turn/projsnap/refresh : bloc vivant et instantané qu'il
  devient, pour une compaction en plein tour.

Rust :
- ChatService::assemble_turn, partagé par le tour et la relance du
  builder : instantané rangé avec la discussion, mise à jour posée en tête
  du dernier message utilisateur (prependCtxUpdate), historique nettoyé
  au rafraîchissement ;
- TurnHost::project_refresh après une compaction ou une réduction en
  plein tour (replaceProjectHead) ; l'instantané posé devient celui de la
  discussion ;
- le préchauffage demande la vue en dry et garde les mises à jour tant
  que le bloc n'est pas rafraîchi.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 03:13:27 +00:00
Claude ff5c6d4571 Lot 33 : préchauffage (PREWARM) tenu par la façade
PREWARM ne garde plus la conversation au Go. La façade tient
l'historique : elle assemble et envoie la requête du prochain tour ; le
Go garde le compteur des requêtes en vol et ce qui l'annule.

Go (facade_prewarm.go) :
- /internal/v1/prewarm/check : moteur libre d'un vrai travail, loin de
  minuit, en bonne santé, slot visé ;
- /internal/v1/prewarm/begin : rechargement SLOT_PERSIST puis inscription
  comme LA requête en vol ; son annulation est relayée à la façade ;
- /internal/v1/prewarm/end : place rendue, cache repris noté ;
- /internal/v1/oai/begin : prewarm_keep pour l'étape qui le prolonge ;
- fin de tâche planifiée : le déclencheur part à la façade.

Rust (chat/prewarm.rs) :
- assemblage par les mêmes fonctions que le tour, sentinelle « . », un
  seul jeton, non streamé ; abstention (capacités inconnues, tour en
  cours, discussion vide, compaction attendue, assemblage inattendu) ;
- déclencheurs : fin de tour, fin de tâche, changement de discussion ou
  de projet (full : après 3 s) ;
- le tour garde le préchauffage qu'il prolonge (empreintes des messages
  et des options de rendu) et coupe tout de suite les autres ;
- télémétrie kind=prewarm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 02:52:44 +00:00
Claude f79b74dac3 Lot 32 : images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
KEEP_TURN_IMAGES ne garde plus la conversation au Go. L'état du tour vit
dans la façade ; les fichiers et la vision restent au Go.

Go (facade_keepimg.go) :
- /internal/v1/turn/keepimg : vision réellement active, budget des images
  gardées ;
- /internal/v1/turn/keepimg/store : une image relayée rangée par
  référence (refRelayImage), relue à l'octet près.

Rust :
- chat/keepimg.rs : port de keepImages (image à l'essai, mesure par le
  moteur répartie au prorata des octets, budget, publication, fin de tour).
- La boucle du tour : budget demandé pour le fil principal, image rangée
  par le Go, historique réécrit publié sans les relais non gardés ; les
  images gardées partent avant tout résumé (filet réactif, en cours de
  tour, fenêtre pleine).
- Début et fin de tour : retirées si le contexte le demande ou si elles
  ne doivent plus partir (clé retirée, vision absente).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 02:30:59 +00:00
Claude a005626e66 Lot 31 : slots du moteur pour le tour tenu par la façade
SIDE_SLOT et SLOT_PERSIST ne gardent plus la conversation au Go. La
machinerie reste au Go (configuration, requêtes en vol, tampon du slot) ;
la façade la consulte avant chaque requête au moteur local.

Go :
- /internal/v1/turn/slot (facade_slots.go) rend engineSlotFor et, pour
  une étape de tour, recharge d'abord l'état gardé de la discussion
  (slotPersistRestore). La sauvegarde à la bascule voit les étapes de la
  façade : /internal/v1/oai/served marque le slot (engineMarkMain).
- facadeChatBlocker : SIDE_SLOT et SLOT_PERSIST retirés.

Rust :
- TurnHost::engine_slot : l'id_slot posé dans chaque requête (0 pour le
  fil de la discussion, 1 pour la vérification), le ticket « en vol »
  ouvert sur le second slot.
- Cache KV plein en plein calcul (sideSlotPoolError) : rejoué deux fois
  tel quel, sans compaction, puis l'erreur.
- Résumés de compaction comptés en vol comme postSummary : transcription
  sur le second slot, continuation sur le slot 0.
- La passe de vérification est un travail annexe (engineSideJob) : le Go
  efface le slot à la fin s'il a servi (écart du lot 7 levé).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 02:13:20 +00:00
Claude 89ed527c96 Fusion de main : conversation Rust par défaut, télémétrie du tour
main a reçu en parallèle la télémétrie du tour tenu par la façade
(/internal/v1/turn/perf), les tickets « en vol » du moteur et la
conversation tenue par la façade par défaut (LOKI_CHAT=go pour revenir
au Go). Le lot 29 faisait la même chose par /internal/v1/perf/record :
un seul chemin est gardé, celui de main.

- TurnHost::perf_record (main) remplace TurnHost::perf ; la nature est
  posée par l'hôte (verify pour la passe isolée seulement, main pour les
  corrections du builder, comme le Go).
- Les résumés de compaction passent par le même point d'entrée, nature
  compact : chat/perf.rs ne garde que le relevé de la réponse entière
  (perfRecFromWire) et l'envoi.
- La sonde du gabarit reste à la façade (lot 23) : /internal/v1/turn/perf
  ne la lance plus côté Go.
- facade_perf.go retiré ; ses cas (perte, intercalé, alerte, preset
  externe, incomplète, GET) rejoignent web_internal_turnperf_test.go.
- README : LOKI_CHAT défaut Rust, avec ce que les lots 19, 23, 29 et 30
  y ajoutent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:53:53 +00:00
Claude e444202b4b Lot 30 : motifs de relance personnalisés tenus par la façade
La surcharge retry_patterns (tableau JSON de regex du Go, chacune en
(?mi)) ne garde plus la conversation au Go : la façade la lit au
démarrage de sa conversation, comme le Go la compile une fois par
processus ; illisible ou vide, les motifs embarqués s'appliquent, une
regex illisible est sautée.

Les regex restent celles du Go : chat::stream::go_regex réécrit pour le
crate regex ce qui n'a pas le même sens — classes Perl et limites de mot
ASCII (\w \d \s \b et leurs contraires, dans et hors d'une classe),
\Q…\E (refusé dans une classe, comme le Go), [ && -- ~~ dans une classe,
octal permis, référence arrière refusée.

Parité : parity_retry_test.go compile côté Go une soixantaine de regex
(dont celles qu'il refuse) et note leur première correspondance sur onze
textes (accents, chiffres non ASCII, espaces insécables, crochets) ;
tests/retry.rs rejoue le journal à l'identique et vérifie la lecture de
la surcharge au démarrage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:24:16 +00:00
Claude 8a61879bfd Lot 29 : télémétrie des complétions de la façade
Quand la façade tient la conversation (LOKI_CHAT=rust), ses complétions
rejoignent l'anneau de télémétrie du Go (perf_log.go) au lieu de passer
inaperçues : /api/perf/summary les compte, l'optimiseur en tire ses
poids, et l'interface reçoit à nouveau la perte de cache.

- chat/perf.rs : perfRecFromStats, perfRecFromWire (lu sans échec
  possible, mêmes tolérances que perfNum, timings d'un autre type
  présents mais vides comme le Go) et perfPrefix (FNV-1a cumulé) ;
- la boucle du tour range chaque complétion lue, numérotée dans le tour,
  avec ses empreintes si elle est complète ; une complétion coupée par un
  stop est rangée incomplète ; la dernière statistique porte la nature,
  la perte, ce qui s'est intercalé et l'alerte ;
- natures : main (tour), verify (vérification du mode Code), compact
  (résumé de compaction, rangé dès que la réponse se lit) ;
- le Go (/internal/v1/perf/record) range le relevé comme les siens et
  calcule l'alerte au seuil du preset actif, jamais pour un preset
  externe.

Tests : relevés et empreintes (chat/perf.rs), télémétrie de la boucle
(complète, coupée), du vrai hôte et des résumés (tests/lot6.rs),
TestFacadePerf côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:17:49 +00:00
Claude f6c191922f Lot 28 : optimiseur tenu par la façade (LOKI_TUNE=rust)
Avec LOKI_TUNE=rust (expérimental, Unix seulement), la façade sert
/api/tune, /api/tune/status, /api/tune/cancel, /api/tune/apply et
/api/tune/last, et déroule l'optimisation sans perte (rust/loki-rs/src/tune) :
vrai moteur arrêté, essais composés à blanc puis lancés par les « loki
serve » du Go (LOKI_TUNE_TRIAL), chacun dans son groupe de processus,
mesurés par le bench complet à profondeur fixe, retenus au-delà du
bruit ; vrai moteur relancé. Rien n'est écrit sans un clic : copie du
preset, ou le preset lui-même — redémarrage, sonde, et ancienne version
rétablie si elle échoue.

- tune/core.rs : la partie pure (EXTRA_ARGS, réglages, essais de chaque
  étape, garde-fous, diff et réécriture du preset, score) ;
- tune/lock.rs : le verrou tune.lock au format du Go, identité des
  processus comme procIdentity (la reprise du Go reconnaît la façade
  vivante, et écarte son verrou si elle meurt), ménage d'un verrou
  périmé (essai orphelin arrêté, application interrompue défaite) ;
- tune/job.rs : routes, job, essais et application.

Le verrou de génération reste celui du Go, pris par le bail du moteur au
motif « tune » (refus de l'optimiseur pour les autres, stop du chat qui
annule). Le Go prête /internal/v1/tune/context (bench, génération, jobs
du mode code, état du moteur, télémétrie) et le démarrage ou l'arrêt du
moteur sans le refus de l'optimiseur (service/action « os »). Hors
Unix, la façade passe LOKI_TUNE vide au Go, qui garde l'optimiseur.

serde_json lit désormais les flottants à l'arrondi exact
(float_roundtrip), comme le Go.

Parité : parity_tune_test.go tire du Go un journal (partie pure, contenu
du verrou, résultat enregistré, vérification après application) rejoué
par tests/tune_core.rs. tests/tune.rs déroule de vraies optimisations, le
binaire de test jouant « loki serve » ; les tests du verrou du Go sont
portés ; TestFacadeTune côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 01:06:31 +00:00
Claude 84ae3a0cc4 Lot 27 : tests contre les mutations survivantes du banc d'essai
84 mutations du port Rust, 81 tuées ; trois équivalentes documentées
dans la spécification (annulation déjà vue au premier tour du select,
progression d'un job remplacé, PID nul du moteur).

- bench/tests.rs : un mini moteur qui garde les corps des requêtes ;
  tranche pile à la fin du corpus, tour sans attendu, débits nuls
  recalculés, niveau rejoué une seule fois, préchauffage et ligne courte
  d'un run rapide, profondeur arrêtée (contexte plein, corpus épuisé,
  réponse reprise, note de reprise hybride ou gabarit) ;
- tests/bench.rs : second lancement pendant la prise du bail, preset
  relu au départ du job, mesure enregistrée sous le preset actif ;
- journal de parité : un prompt juste au-delà de la prose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 00:41:20 +00:00
Claude 8f61870224 Lot 27 : banc d'essai tenu par la façade (LOKI_BENCH=rust)
Avec LOKI_BENCH=rust (expérimental), la façade sert /api/bench,
/api/bench/status, /api/bench/cancel et /api/bench/last, et déroule le
protocole de mesure contre le moteur local (rust/loki-rs/src/bench) :
mode rapide (préchauffage, ligne courte) et mode complet (prefill à froid
à profondeur réelle, trois tours qui reprennent le cache). Corpus,
requêtes, calculs et messages sont ceux du Go ; seule une mesure complète
est enregistrée (last_bench, bench_presets, empreinte de configuration du
Go).

Le verrou de génération reste celui de la conversation du Go : la façade
le prend par le bail du moteur, avec le motif « bench ». Le Go note alors
benching et donne aux autres le refus du benchmark ; changer de
discussion ne rompt pas le bail ; benchRunning() le voit (clients /v1,
préchauffage, optimiseur). Un stop du chat, un Reset ou un bail perdu
annulent le bench. Le Go prête aussi le travail annexe (effacement du
slot à la fin, /internal/v1/engine/sidejob) et la télémétrie
(/internal/v1/bench/perf).

Correction des tests du Go : trois tests du préchauffage échouaient dix
minutes par jour, avant minuit (prewarmNearMidnight lisait l'heure
réelle). L'heure du garde-fou devient remplaçable (prewarmNow), fixée à
midi dans les tests.

Parité : parity_bench_test.go tire du Go un journal rejoué par
tests/bench.rs contre le même faux moteur ; chaque requête reçue par le
moteur est comparée (longs textes réduits à leur taille et à leur
empreinte). Il couvre les refus, les deux modes, les échecs de chaque
phase, le raisonnement refusé puis rejoué, la profondeur partielle et
l'annulation. Les tests du Go sont portés (bench/tests.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-16 00:09:09 +00:00
Claude 9a0455be8f Lot 26 : tâches planifiées tenues par la façade (LOKI_TASKS=rust)
Avec LOKI_TASKS=rust (expérimental), la façade sert les routes
/api/tasks* et fait tourner le planificateur, que le Go ne démarre plus.
Elle exécute elle-même les tâches « script seul » (shell de l'agent,
limite de 10 min, arrêt à la main). Une tâche IA passe par le verrou de
génération et les outils de l'agent, restés au Go : la façade la lui fait
exécuter (/internal/v1/tasks/run), comme son propre planificateur l'aurait
fait. Les notifications partent par la façade si elle les tient, sinon par
le Go (/internal/v1/push/send).

- src/tz.rs : les fuseaux IANA comme le Go (TZif v1/v2 et règle POSIX,
  time.Local, time.Date et ses heures qui sautent ou se répètent) ;
- tasks/cron.rs : les quatre formes de fréquence, avec ParseDuration du Go
  et ses messages d'erreur ; instants sur 128 bits (dates au-delà de
  2262) ;
- tasks/mod.rs : la base, l'exécution, le planificateur (un tic par
  minute, une tâche due par tic) et les sept routes.

Le Go reçoit un crochet de test pour l'heure des tâches (tasksNow).

Correction de la façade : les appels à l'API interne du Go étaient tous
bornés à 10 s. Un outil exécuté par le Go qui dure plus longtemps (page
lente, navigateur, MCP tenu par le Go) était coupé, alors qu'il continuait.
Ces appels sont désormais bornés à 30 min ; une tâche IA est attendue
jusqu'à 24 h.

Parité : parity_tasks_test.go tire du Go un journal rejoué par
tests/tasks.rs, l'heure des tâches fixée pas à pas. Il couvre
l'enregistrement (chaque forme et chaque erreur de fréquence, plusieurs
fuseaux), la bascule, l'interrupteur maître, la suppression, les
lancements et les tics du planificateur. Les mutations (79, dont 77
tuées et 2 équivalentes) ont fait trouver et corriger cinq écarts du
port : dates lointaines, intervalle en jours démesuré, heure qui saute au
printemps, compte-rendu coupé au milieu d'un caractère, noms de fuseau
contenant « .. ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 23:30:03 +00:00
Claude ed9b2e8c1c Lot 25 : Strata tenu par la façade (LOKI_STRATA=rust)
Port de backend_strata.go (rust/loki-rs/src/strata) : la machine (cartes
par nvidia-smi, Python 3.10+ et venv, RAM totale et disponible, RAM du
moteur Strata en marche, place libre), ce qui y tient et la
recommandation, la configuration de lancement en clair des modèles
installés (placement des experts drop/ram/arena/disk, carte d'aide,
réglages du preset), les réglages d'un modèle installé et l'installation
(job des moteurs : paquet figé vérifié par SHA-256, installeur lancé et
journalisé comme le sinkWriter du Go, preset écrit puis activé par le Go).

Tenu seulement avec LOKI_BACKENDS=rust ; le lancement (loki serve) reste
au Go, qui relaie les routes du tunnel.

Correction du Go : /api/strata/settings décidait « en service » après
avoir écrit les nouveaux réglages, donc jamais pour un réglage changé ;
décidé désormais avant l'écriture, comme SavePresetApplying. Le port suit.

Parité : parity_strata_test.go (crochets de test : release, meminfo,
place libre, action de service) tire du Go un journal rejoué par
tests/strata.rs ; les tests du Go portés dans strata/tests.rs ;
TestFacadeStrata côté Go. Mutations : 71 tuées sur 71.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 22:43:04 +00:00
Claude 8c9bb6b450 Lot 24 : moteur de l'image tenu par la façade (LOKI_ENGINE_IMAGE=rust)
Port de web_engine.go, web_engine_reco.go et backend_engine_oci.go
(rust/loki-rs/src/engineimg) : statut du moteur (build, source, variante,
versions installées, avis, version recommandée, version précédente,
relevé du rendu), dernier build publié, plan (dernière ou recommandée,
risque de rendu du raisonnement au défaut de b10763), mise à jour depuis
le registre officiel (couches du sommet, liens, runtime ignoré, essai à
blanc, REASONING_PRESERVE=off dans le preset actif ou la configuration,
ménage, vérification du rendu avant/après par la sonde de gabarit),
bascules, suppression et retour à la version précédente.

Tenu seulement avec LOKI_BACKENDS=rust : la mise à jour est un job des
moteurs. La sonde est celle de la conversation quand la façade la tient.
/api/engine/cmdline reste au Go, qui relaie les routes du tunnel.

Parité : parity_engineimg_test.go tire du Go un journal (faux registre
OCI, faux moteur local pour la sonde) rejoué par tests/engineimg.rs ;
TestFacadeMoteurImage côté Go. Les aides de fichiers des rejeux passent
dans tests/common/parity.rs. Mutations : 71 tuées sur 74, trois
équivalentes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 22:15:30 +00:00
Claude 0f3a3d52c1 Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust)
Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses
complétions (/health, /props, /apply-template), avec les verdicts
preserves_history, prefix_stable et renders_reasoning, le cache par
build/modèle/gabarit/forme et la relance bornée.

- l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ;
- NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget
  va dans le dernier résultat d'outil quand le préfixe n'est pas stable ;
- le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand
  elle tient la conversation.

Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué
octet pour octet par tests/tplprobe.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 21:34:57 +00:00
Claude 78766a28d9 Tests des backends : resetBackendsState hors du fichier réservé à Linux
TestFacadeBackends tourne sur toutes les plateformes (job macOS de la CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 21:08:37 +00:00
Claude 2c926a8274 Lot 22 : backends llama.cpp et job des moteurs tenus par la façade (LOKI_BACKENDS=rust)
La façade (rust/loki-rs/src/backends) sert le statut du moteur llama.cpp
(dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé,
plan de build et conseil), la vérification git, la dernière release et
l'installation des binaires précompilés (extraction avec liens, VERSION,
ménage), la bascule fast/opt, les backends custom et leur désinstallation,
et les GPU vus par un moteur (caches mémoire et disque).

Elle détient LE job des moteurs (state/build_job + loki-build.log, repris
au démarrage). Les jobs que le Go fait encore tourner (compilation depuis
les sources, moteur de l'image, Strata) lui rapportent chaque étape par
/internal/facade/v1/build-job/* ; le Go garde le garde de l'optimiseur et
ne restaure plus de job quand la façade tient le lot.

Correction du Go : prebuiltVersionFormat lisait le format comme 3e champ
du marqueur VERSION ; hors CUDA Windows (« b300  fmt2 ») il n'y en a que
deux, et chaque vérification retéléchargeait la release au lieu de dire
« déjà à jour ». Le port suit.

Parité : parity_backends_test.go (Linux) → tests/parity_backends, rejoué
par tests/backends.rs (fausses machines dans le PATH, faux GitHub, dépôt
git reproductible) ; TestFacadeBackends pour les étapes transmises.
Mutations : 55 tuées sur 57, deux inobservables sous Linux (cudart
Windows, garde d'un avertissement).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 20:58:28 +00:00
Claude 25e31c1c26 Lot 21 : mise à jour de Loki tenue par la façade (LOKI_UPDATE=rust)
En conteneur, l'image se met à jour par Docker : /api/update rend la
version du Go sans mise à jour disponible et la note Docker,
/api/update/apply la refuse (409). La façade sert ces deux routes quand
LOKI_UPDATE=rust et LOKI_CONTAINER=1 (sysupdate.rs) ; hors conteneur,
loki-rs ne passe pas la variable et le Go garde le remplacement du
binaire.

Parité : parity_update_test.go tire du Go les quatre réponses en
conteneur, rejouées par tests/sysupdate.rs ; TestFacadeMiseAJour côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 20:01:37 +00:00
Claude 090f15615e Lot 20 : serveurs MCP tenus par la façade (LOKI_MCP=rust)
La façade tient les serveurs MCP quand LOKI_MCP=rust (expérimental ; le
Go reste le défaut) : configuration (state/mcp), catalogue embarqué (le
même fichier que le Go, copié dans l'étape Rust de l'image), état et
routes /api/mcp*. Un client MCP en Rust (mcp/client.rs) parle JSON-RPC
sur stdio et en Streamable HTTP (réponses JSON ou SSE, session suivie,
en-têtes configurés, ping du serveur servi). Le pool suit mcp_client.go :
session gardée entre les tours, connexions parallèles et partagées,
session invalidée pendant sa connexion jamais republiée, délais npx/uvx,
appel borné avec une reconnexion, sortie plafonnée à la fenêtre.

Un seul pool, celui de la façade : le Go, qui exécute encore les outils
de l'agent, lui demande les outils MCP, leurs appels et le
pré-chauffage (/internal/facade/v1/mcp/*, facade_mcp.go).

Parité : parity_mcp_test.go tire du Go les fonctions pures, seize
mutations de configuration, et un vrai serveur du SDK MCP Go (onze
outils) : ce qu'il met sur le fil et ce que le client Go en tire (état,
outils annoncés, appels). tests/mcp.rs le rejoue contre un faux serveur
qui resert ces réponses, et vérifie stdio, reconnexion, routes, catalogue
et session invalidée de bout en bout ; TestFacadeMCP côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 19:56:11 +00:00
Claude 3fc36a3acc Lot 19 : export, mode Code et critères tenus par la façade (avec LOKI_CHAT=rust)
Quand la façade tient la conversation, /api/chat/export, /api/chat/mode
et /api/chat/criteria rejoignent ses routes (chatx.rs, facadeChatRoutes
côté Go), sans nouvelle variable. L'export Markdown ou JSON part de la
conversation vivante (le Go relisait la copie enregistrée, en retard d'un
tour au besoin) : raisonnement replié, outils dans leur état final,
compaction signalée, export partiel annoncé, JSON indenté et HTML
échappé, sans mise à jour de contexte ni image en base64. Le mode et les
critères (scellés, vingt au plus, statuts ramenés) vont sur le fil de la
façade. Le reste du mode Code (indice, travaux de fond, outil criteria)
reste au Go et lit les mêmes clés.

Parité : parity_chatx_test.go tire du Go l'export d'une conversation
riche sous 22 jeux d'options et 23 appels au mode et aux critères (base
et fil après chacun, erreurs JSON imbriquées comprises), rejoués octet
pour octet par tests/chatx.rs ; tests du relais mis à jour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 19:26:27 +00:00
Claude ec55f7d3d6 Lot 18 : hub des projets tenu par la façade (LOKI_PROJECTS=rust)
La façade sert /api/projects quand LOKI_PROJECTS=rust (expérimental ; le
Go reste le défaut) : liste avec discussions, pages et trackers comptés,
création (slug translittéré et unique, index amorcé), renommage,
description, suppression (discussions, trackers et mémoire du projet,
actif réassigné, dernier projet indestructible) et bascule, refusées
pendant une génération.

Le projet actif passe toujours par le Go, qui en garde une copie en RAM.
Les discussions sont à qui tient la conversation : la façade bascule et
supprime elle-même quand elle la tient, sinon le Go garde
/internal/v1/project/switch et /internal/v1/project/drop-convs
(facade_projects.go).

Parité : parity_projects_test.go tire du Go 40 appels sur une base
amorcée (pages, discussions, trackers piégés), avec le registre, l'actif,
memory/, les trackers et l'index après chacun, rejoués par
tests/projects_api.rs contre un faux Go qui bascule et supprime comme le
vrai ; bascule et suppression par la façade de bout en bout ;
TestFacadeProjets côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 19:10:49 +00:00
MichaelandClaude Opus 5.5 4c8ac93581 Lot 6, étape 6 : la conversation tenue par la façade Rust par défaut
Sans LOKI_CHAT, c'est désormais la façade qui tient la conversation ;
LOKI_CHAT=go la rend au Go (porte de sortie, aussi listée dans le compose
Unraid). Le Go la garde de toute façon si un réglage que la façade ne
sait pas encore honorer est posé (PROJ_SNAPSHOT, KEEP_TURN_IMAGES,
NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, PREWARM, motifs retry_patterns),
décidé au démarrage comme avant.

Vérifié de bout en bout avec les vrais binaires et un faux moteur, dans
les deux modes : deux tours (texte, puis appel d'outil), même état, même
journal rejoué, discussion intacte après relance, et un panneau perf
identique (complétions, tours, pertes, sonde du gabarit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:06:27 +02:00
MichaelandClaude Opus 5.5 5edcad50a5 Conversation tenue par Rust : télémétrie et requêtes en vol comme le Go
Trois choses manquaient au tour tenu par la façade, actives même sans
clé opt-in, et qu'il fallait avant d'en faire le défaut :

- la télémétrie de chaque complétion (perfRecord) : /api/perf/summary et
  l'alerte « cache perdu » posée sur les stats finales d'une étape. La
  boucle Rust la remet au Go (/internal/v1/turn/perf) au même moment que
  la boucle Go, avec ses empreintes de messages ; le Go range, calcule la
  perte et rend nature, jetons perdus et alerte, posés sur la copie finale
  des stats ;
- la sonde du gabarit, lancée dans les mêmes conditions (requête de forme
  normale du moteur local) ;
- le comptage des requêtes en vol : chaque requête au moteur local ouvre
  un ticket (/internal/v1/oai/begin), marqué servi à la réponse 200
  (engineServed, engineMarkMain pour une étape de la discussion), rendu en
  fin de lecture du flux. Le slot de la discussion n'est ainsi ni effacé
  pendant une requête ni pris pour un autre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:04:05 +02:00
Claude 2db4cf1090 Parité moteur et système : tests réservés à Linux
parity_engine_test.go appelle lastErrorLine (sys_service_rust_linux.go)
et suppose la supervision par fichier PID du conteneur ; la façade ne
tourne qu'en conteneur Linux. La CI macOS ne compilait plus les tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:53:36 +00:00
Claude a4f8bd3ec4 Lot 17 : interrupteurs système tenus par la façade (LOKI_SYSTEM=rust)
La façade sert /api/internet (moteur web, serveur Crawl4AI et sa clé
jamais rendue, joignabilité avec son cache de 30 s), /api/computer
(interrupteur du navigateur, binaire trouvé, vision), /api/apikey (clé de
complétion, repli sur config.env, adresse /v1 à coller dans un client
tiers) et /api/network (écoute du moteur) quand LOKI_SYSTEM=rust
(expérimental ; le Go reste le défaut).

Restent au Go derrière son API interne (facade_system.go) : le cache de
joignabilité de ses outils web (/internal/v1/web/reach/reset), le
Chromium des outils browser_* (/internal/v1/cdp/shutdown) et le
redémarrage qui applique une clé neuve quand la façade ne pilote pas le
moteur (/internal/v1/service/action). Le relais du tunnel garde l'hôte
et X-Forwarded-Proto du client.

Correction en passant : maskAPIKey paniquait sur une clé d'un octet, et
/api/apikey tombait à chaque lecture.

Parité : parity_system_test.go tire du Go 61 appels de route avec la
base et les sondes reçues par trois faux Crawl4AI après chacun, et huit
dispositions du dossier de Playwright, rejoués par tests/system.rs ; ce
qui reste au Go vérifié de bout en bout ; TestFacadeSysteme,
TestFacadeRelaisGardeHote et TestMaskAPIKeyCourte côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:51:58 +00:00
Claude 2da2efc1fd Lot 16 : pilotage du moteur tenu par la façade (LOKI_ENGINE=rust)
La façade sert /api/start, /api/stop, /api/restart, /api/vram/unload et
/api/vram/reload quand LOKI_ENGINE=rust (expérimental ; le Go reste le
défaut). Elle lance elle-même « loki-rs engine <action> », le superviseur
que le Go appelait déjà en conteneur, avec l'environnement qu'il lui
passait et de quoi joindre l'API interne du Go ; sa dernière ligne
d'erreur devient l'erreur rendue. Le contrôle d'avant démarrage
(preflightEngine : preset externe, Strata, BIN, MODEL, tranches) et le
bilan de VRAM du déchargement (gpuSettle) sont portés.

La façade ne tient ce lot qu'en conteneur : sous systemd ou avec
LOKI_ENGINE_SUPERVISOR=go, loki-rs ne passe pas la variable au Go.

Restent au Go derrière son API interne (facade_engine.go) : le verrou de
l'optimiseur avant tout démarrage (/internal/v1/service/prestart), la
réponse en cours quand il tient la conversation
(/internal/v1/chat/generating) et l'arrêt de la dictée quand il la tient
(/internal/v1/dictate/stop).

Parité : parity_engine_test.go tire du Go dix bilans de VRAM, vingt-six
installations passées au préflight, les messages du superviseur et dix
réponses de route, rejoués par tests/enginectl.rs ; démarrage, arrêt,
redémarrage, déchargement chiffré et rechargement vérifiés de bout en bout
avec un faux superviseur et un faux nvidia-smi ; TestFacadeMoteur côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:33:31 +00:00
Claude a50b8f3cc6 Lot 15 : presets tenus par la façade (LOKI_PRESETS=rust)
La façade sert la liste des presets (pastilles, mesures du bench quand
elles correspondent à la configuration), leur ordre, l'éditeur (lecture,
graine d'un preset neuf, enregistrement avec nom de fichier unique,
prompt système), la suppression (refusée pour l'actif, modèle compris sur
demande), les aperçus de l'éditeur (cache de prompts, second slot, avis
MoE et de chargement, réutilisant le port de loki serve), la copie en
placement auto, les presets externes (la clé n'est jamais rendue, test
par un vrai appel de complétion) et la bascule, quand LOKI_PRESETS=rust
(expérimental ; le Go reste le défaut).

Ce qui touche au moteur reste au Go jusqu'à son lot : facade_presets.go
expose /internal/v1/preset/apply (bascule avec état du slot gardé et
moteur relancé ou arrêté, preset en service réappliqué) et
/internal/v1/tune/guard (verrou de l'optimiseur), et remet les routes du
tunnel à la façade.

Parité : parity_presets_test.go tire du Go les fonctions pures et 70
appels de route avec presets/, models/ et la base après chacun, rejoués
par tests/presets_api.rs ; bascule et application vérifiées de bout en
bout contre le faux Go, presetApply côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 18:13:13 +00:00
Claude 13bcbc74fe Lot 14 : ligne de commande du moteur composée en Rust (LOKI_SERVE=rust)
loki-rs engine serve fait ce que faisait loki serve : lire la
configuration (par l'API interne du Go), sonder le monde (aide du moteur,
--version, --list-devices, GGUF, RAM, VRAM, cgroup, cpuset, variables
LLAMA_ARG_*/GGML_*), composer la ligne de llama-server, vérifier le port,
garder la commande pour l'interface, puis exec. Le superviseur le lance à
la place de loki serve quand LOKI_SERVE=rust (expérimental ; le Go reste
le défaut).

serve/ porte backend_serve*.go fonction par fonction (couches GPU, --fit
et sa marge, threads, files CUDA, second slot, mode tensor, cache de
prompts, points de reprise, décodage spéculatif, fidélité, LOAD_GUARD,
SLOT_PERSIST), toute la décision restant pure. Strata, l'essai de
l'optimiseur et un lancement sans API interne passent toujours par
loki serve (Go). La façade donne au Go l'adresse de son API interne
(LOKI_GO_ADDR), héritée par le superviseur.

Parité : parity_serve_test.go tire du Go 1 872 lancements (12 machines ×
112 presets × 3 aides de moteur × 13 jeux de variables) et les sondes
pures, rejoués argument par argument par tests/serve.rs ; deux essais de
bout en bout lancent le vrai binaire contre un faux Go et un faux moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:58:24 +00:00
Claude 1e5f0904f2 Lot 13 : mémoire tenue par la façade (LOKI_MEM=rust)
La façade sert l'éditeur des pages mémoire (lecture, enregistrement,
renommage, suppression, déplacement vers un autre projet, index MEMORY.md
tenu par le code), le panneau des trackers, le mode mémoire du projet
actif et les interrupteurs du mode agent, quand LOKI_MEM=rust
(expérimental ; le Go reste le défaut).

Les pages restent des fichiers memory/<projet>/*.md écrits de façon sûre
(.bak, temporaire synchronisé puis renommé, relecture vérifiée), les
trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la
façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et
n'écrit jamais en clair quand la mémoire est verrouillée.

gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF,
erreurs du scanner et de type, structs anonymes) pour renvoyer au client
les mêmes messages que le Go ; il servira aux lots suivants.

Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent
par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go.

Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ;
parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de
route avec le disque et la base après chacun) → tests/memory.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:33:22 +00:00
Claude db9b0b44f5 Clippy 1.99 : as_chunks au lieu de chunks_exact(4) pour la vignette
Le clippy de la CI (Rust 1.99) refuse chunks_exact à taille constante
(chunks_exact_to_as_chunks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:05:47 +00:00
Claude eddcf97e3b Lot 12 : notifications Web Push tenues par la façade (LOKI_PUSH=rust)
La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même
les notifications « réponse prête » quand LOKI_PUSH=rust (expérimental ;
le Go reste le défaut) : chiffrement RFC 8291 (aes128gcm, enregistrement
complété à 4096 octets comme webpush-go), signature VAPID RFC 8292 (JWT
ES256), abonnements purgés sur 404/410, par ring déjà présent via rustls.
Mêmes clés en base que le Go.

Côté Go : facade_push.go remet les routes du tunnel à la façade, et
sendPushToAll (fin de tour, fin de tâche) lui délègue l'envoi.

Parité : parity_push_test.go tire du Go les durées et onze appels de route
avec la liste des abonnements en base, rejoués par tests/push.rs ; le
chiffrement reproduit l'exemple de la RFC 8291 à l'octet près, et un envoi
de bout en bout est déchiffré côté navigateur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 17:01:07 +00:00
Claude cbd0d5e356 Tests : scripts d'essai écrits par un sh à part (plus d'ETXTBSY)
tests/engine.rs (redemarre) a échoué en CI sur « Text file busy » : un
test écrivait son faux binaire pendant qu'un test voisin, dans un autre
thread du même process, lançait un enfant — qui héritait du descripteur
d'écriture le temps de son exec, et l'exec du script échouait. Les faux
binaires (moteur, serveur de dictée) sont désormais écrits par un sh à
part : le process des tests ne tient jamais le fichier ouvert en
écriture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:50:25 +00:00
Claude 8bba80dee8 Lot 11 : fichiers de la discussion tenus par la façade (LOKI_FILES=rust)
La façade sert le dépôt des pièces jointes (morceaux base64, 1 Gio au
plus, espace disque vérifié), le panneau Fichiers, la suppression, le
téléchargement (fiche, tranches base64 pour le tunnel E2E, brut avec
plage d'octets), l'affichage des images (type reniflé, CSP fermée,
vignette redressée et réduite par moyennage) et l'archive .zip, quand
LOKI_FILES=rust (expérimental ; le Go reste le défaut).

Côté Go : la nouvelle route /internal/v1/conv/workspace donne le dossier
de la discussion active (le Go la connaît toujours), facade_files.go
remet ces routes à la façade quand elles arrivent par le tunnel, et les
.part d'envois ne sont plus vidés par le Go au démarrage.

Parité : parity_files_test.go tire du Go les noms, slugs, en-têtes EXIF,
orientations et réductions (pixel pour pixel), reniflages, puis 42 appels
de route sur une arborescence fixe, rejoués par tests/convfiles.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:45:17 +00:00
Claude 28f90543de Lot 10 : réglages partagés tenus par la façade (LOKI_SETTINGS=rust)
La façade sert /api/prefs, /api/sysprompt, /api/reasoning-effort et
/api/oai/public quand LOKI_SETTINGS=rust (expérimental ; le Go reste le
défaut), sur les mêmes clés de la base : préférences fusionnées et
nettoyées (prénom borné, avatars en liste fermée, valeurs vides
effacées), prompt système du preset actif avec repli sur le global,
intensité du raisonnement en liste blanche, adresse publique normalisée.

Côté Go, facade_settings.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_settings_test.go tire du Go 19 adresses, 11 intensités et
29 appels de route avec le contenu de la base après chacun, rejoués par
tests/settings.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:25:51 +00:00
Claude 46bfab3343 Lot 9 : dictée vocale tenue par la façade (LOKI_DICTATE=rust)
La façade sert /api/transcribe et /api/dictate/* quand LOKI_DICTATE=rust
(expérimental ; le Go reste le défaut) et supervise elle-même le serveur
de transcription sherpa-onnx : lancé à la demande sur la boucle locale,
GPU masqués et sans le jeton interne, prêt dès que sa socket répond, mort
au démarrage expliquée, éteint après dix minutes d'inactivité ou quand
les réglages changent. Modèles Parakeet téléchargés et décompressés au fil
de l'eau (tar.bz2, entrées hors du dossier refusées, liens ignorés),
protocole WebSocket identique (en-tête de 8 octets, trames de 10 Kio).

Côté Go, facade_dictate.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais, et asrShutdownVite (libération de la
VRAM, optimiseur) lui demande l'arrêt par l'API interne.

Parité : parity_dictate_test.go tire du Go la lecture de treize WAV, les
chemins d'archive, réglages, catalogue, arguments et treize appels de
route, rejoués par tests/dictate.rs ; supervision testée contre un faux
sherpa-onnx qui parle le protocole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:17:29 +00:00
Claude 17ec2291d4 Parité des modèles : conversion directe vers hfFile (staticcheck S1016)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 16:09:48 +00:00
Claude a33e5ccffe Lot 8 : toutes les routes des modèles tenues par la façade (LOKI_MODELS=rust)
La variable LOKI_DOWNLOADS devient LOKI_MODELS et couvre, en plus des
téléchargements, la liste du sélecteur (avec qui référence chaque .gguf),
la suppression (refus si en service, 409 avec les presets sans force,
tranches emportées), les dossiers de modèles et le catalogue Hugging Face
(recherche, fichiers d'un dépôt avec verdict mémoire, jeton vérifié puis
enregistré, cache vidé à son changement).

Côté Go, facade_models.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_models_test.go tire du Go les fonctions pures du catalogue
et un scénario sur disque de 20 appels (liste, dossiers, suppressions),
rejoués à l'identique par tests/models.rs ; catalogue et routes testés de
bout en bout contre un faux Hugging Face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 13:25:45 +00:00
Claude bbea5fcd3f Lot 8 : téléchargements de modèles tenus par la façade (LOKI_DOWNLOADS=rust)
La façade Rust mène les téléchargements de .gguf quand LOKI_DOWNLOADS=rust
(expérimental ; le Go reste le défaut) : sonde, lancement, suivi,
annulation, avec les mêmes contrats et la même mécanique que le Go
(tranches rapatriées en entier, plages d'octets parallèles, reprise, place
disque vérifiée sur le total, jeton réservé aux hôtes Hugging Face, refus
traduits, .part vidés au démarrage).

Côté Go : facade_downloads.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais, et le Go ne vide plus les .part qu'elle
mène.

Parité : journal des fonctions pures tiré du Go (parity_dl_test.go) et
rejoué en Rust ; téléchargements de bout en bout contre un faux Hugging
Face (tests/downloads.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 13:11:07 +00:00
LogiFlow 98196e1730 Merge branch 'claude/awesome-albattani-q0do3m' : lots 6 et 7, conversation et outils tenus par la façade Rust (LOKI_CHAT=rust)
Conversation, tours, routes du chat et outils fichiers/shell/git en Rust derrière LOKI_CHAT=rust (expérimental, Go par défaut) ; boucle du contrat du mode Code ; changement de projet par la façade ; parité Go/Rust par journaux de référence (19 tours, 7 tours complets, 70 appels d'outils). Repris de la PR #25 : le sélecteur de l'en-tête ne charge que des presets.
2026-10-15 14:40:56 +02:00
Claude 57629f7254 Le sélecteur de l'en-tête ne charge que des presets (repris de la PR #25)
Le sélecteur de l'en-tête proposait, sous les presets, un groupe « Modèles
(.gguf) » listant les fichiers du disque. En choisir un écrivait MODEL= et
rien d'autre : le contexte, le NGL et l'échantillonnage du modèle
précédent restaient en place — un 27B chargé par-dessus les réglages d'un
4B partait sur 100k de contexte et mourait 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 désormais le seul chemin.

/api/models/use disparaît avec le groupe qu'elle servait, ainsi que
switchModelFile, loadModelSwitchFiles et MS_FILES côté interface. Le title
du sélecteur le dit : « preset actif — en changer recharge tous ses
réglages et relance le moteur ».

Repris tel quel de la PR #25, dont l'historique (antérieur à la réécriture
de main) ne peut plus y être fusionné. index.html régénéré par
tools/assemble-ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:35:50 +00:00
Claude 911501ac0b Parité d'un tour complet : journal indépendant de Playwright
En mode Code, le Go n'annonce web_screenshot que si Playwright est
installé : présent dans le conteneur qui avait tiré les journaux, absent
des machines de la CI, d'où deux tours de référence qui ne s'y
retrouvaient plus. Le test retire Playwright du PATH : le journal ne
dépend plus de la machine qui le tire (Linux et macOS vérifiés sur la
sortie de la CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:34:59 +00:00
Claude dc262a44b8 Lot 6 : parité d'un tour complet avec le Go (journal d'affichage)
internal/loki/parity_turn_test.go passe sept tours par la vraie route
/api/chat/send du Go contre un faux moteur — réponse simple, raisonnement,
outil puis réponse, « pensé sans agir », erreur du moteur, et en mode Code
un contrat déjà tenu et un contrat abandonné après deux corrections — et
range dans rust/loki-rs/tests/parity_turn/ le journal d'affichage du tour
(coalescé, mesures et horodatages masqués), l'historique rangé, les corps
envoyés au moteur, ainsi que les réponses du Go que la façade demandera
pendant ce tour (/turn/start, /turn/view, /turn/verifier). Ce que le prompt
de la machine porte de propre au lancement (dossiers temporaires,
discussion, date, hôte) est masqué des deux côtés.

tests/lot6.rs (parite_du_tour_complet) rejoue les sept tours par
chat::generate et retrouve journal, historique et requêtes à l'identique,
numérotation du journal coalescé comprise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:28:24 +00:00
Claude f11f667a4c Lot 7 : outils git exécutés par la façade
git_status, git_diff et git_clone (rust/loki-rs/src/tools/git.rs) quand la
façade tient la conversation : dépôt deviné comme le Go (sous-dossier
demandé, racine, unique sous-dossier dépôt), sorties mêlées et bornées,
jamais d'invite, clone limité à https:// et git@hôte: et rangé dans le
dossier de la discussion. Toujours sur cette machine, cible distante ou
non, comme le Go.

Parité : TestPariteOutilsGit (deux dépôts, l'un modifié, l'autre sans
commit ; aucun appel réseau) range tests/parity_tools/git.json ;
tests/tools_git.rs le retrouve à l'identique. Le rejeu des journaux des
outils est partagé (tests/common/toolsfx.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:19:07 +00:00
Claude 8fdef5455b Façade : changement de projet sans écraser la discussion, détenteur décidé par le Go
Avec LOKI_CHAT=rust, changer de projet passait par projectSwitch du Go,
qui enregistrait SA copie de la discussion — chargée au démarrage, périmée
— par-dessus celle de la façade, ouvrait une autre discussion que la façade
ignorait, et ne voyait pas un tour de la façade en cours. Désormais :

- /api/projects (switch) remet le changement à la façade
  (/internal/facade/v1/project/switch) : elle enregistre sa discussion avec
  le projet quitté, fait changer le projet actif au Go
  (/internal/v1/project/activate), ouvre la plus récente du nouveau projet
  (une neuve s'il n'en a pas) ; refusé pendant un tour (409) ;
- supprimer un projet : la façade le quitte d'abord s'il est actif, puis
  supprime ses discussions (/internal/facade/v1/project/drop).

Réglages que la façade ne sait pas honorer (PROJ_SNAPSHOT,
KEEP_TURN_IMAGES, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST, PREWARM, motifs
retry_patterns) : le Go garde la conversation, décidé une fois au démarrage
(facadeChatBlocker) ; la façade le lit (/internal/v1/chat/holder) avant de
servir quoi que ce soit, au lieu d'échouer en plein tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:12:44 +00:00
Claude daa2e7e4c1 Lot 7 : boucle du contrat du mode Code tenue par la façade
Port de codeVerifyLoop (rust/loki-rs/src/chat/verify.rs) : à la fin d'un
tour de build en mode Code, relances du builder sur les critères encore
ouverts, passe de vérification sur un contexte isolé, corrections, abandon
dit dans le fil après deux corrections — mêmes deltas que le Go (role,
verify_done, mesures ctx_isolated), relances et corrections rangées dans
l'historique, vérifications non.

Le Go garde les critères (l'outil criteria s'y exécute) : la façade les lit
en base (chat/crit:<id>) et reçoit le contexte isolé du vérificateur par
POST /internal/v1/turn/verifier (prompt du rôle et faits de la machine,
outils fermés, rôle verifier, seul autorisé à marquer passed). Toute
compaction en mode Code rend l'état des critères au résumé, comme
compactMessages.

generate : vue du tour factorisée (turn_view), question à l'utilisateur
suivie (une question posée suspend la vérification).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 12:02:36 +00:00
Claude a739534104 Lot 7 : outils fichiers et shell exécutés par la façade
Quand la façade tient la conversation (LOKI_CHAT=rust), read, grep, glob,
write, edit, bash et ask s'exécutent dans rust/loki-rs/src/tools au lieu de
repartir au Go par /internal/v1/tool/exec : mêmes garde-fous dans le même
ordre (commande catastrophique, « & » final en mode Code, bac à sable du
mode Code, dossier mémoire interdit, lu avant d'écrire), même diff pour
l'UI, même nettoyage de la sortie du shell. Le jeton de l'API interne est
retiré de l'environnement du shell de l'agent.

L'état partagé reste au Go, en un seul exemplaire (sous-agents et tâches
le lisent aussi) : POST /internal/v1/tool/context (dossier de travail,
dossier mémoire, cible distante), /tool/tracker (lecture notée, contrôle
avant écriture), /tool/wrote (note après écriture, diagnostics LSP).
Repli sur le Go pour une cible distante ou un contexte indisponible.

Parité : internal/loki/parity_tools_test.go passe 58 appels à execTool sur
une arborescence de test et range le journal dans tests/parity_tools ;
tests/tools.rs le retrouve à l'identique avec exec_local. Écarts assumés
consignés dans docs/superpowers/specs/2026-10-15-rust-migration-lot7-outils-design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:51:20 +00:00
Claude bc388d7a01 Lot 6 : parité de la boucle d'un tour avec le Go (journaux de référence)
internal/loki/parity_test.go rejoue 19 scénarios dans runChatTools contre
un faux moteur aux réponses scriptées, outils aux résultats fixés
(execToolHook, nil en service) : réponse simple, raisonnement, outils
seuls et en parallèle, appel répété, arguments illisibles, outil inconnu,
budget dépassé, « pensé sans agir » une et deux fois, pensée en ligne,
échantillonnage et intensité, raisonnement coupé, 500 une et deux fois,
erreur du moteur, appel d'outil écrit en texte, API externe, historique
déjà outillé. Les corps envoyés au moteur, les événements d'affichage
(texte regroupé, mesures ignorées), l'historique ajouté et l'erreur sont
rangés dans rust/loki-rs/tests/parity/*.json ; le test Go échoue si la
boucle ne les produit plus (LOKI_PARITY_UPDATE=1 pour régénérer).

rust/loki-rs/tests/parity.rs rejoue les mêmes scénarios dans
chat::turn::run_turn et retrouve les 19 journaux à l'identique.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:32:11 +00:00
Claude ad46bb69a6 Lot 6 : la façade reçoit le chat du Go et ses événements
Une requête qui porte le jeton interne vient du Go, qui l'a déjà
authentifiée (tunnel du relais) : les routes du chat la servent sans
redemander la clé. L'API interne de la façade, réservée à ce jeton, reçoit
les événements d'affichage nés au Go (mode Code, critères) et le
rechargement après déverrouillage : une discussion chargée vide mémoire
verrouillée revient au déverrouillage, jamais par-dessus un fil rempli
(sinon le premier message aurait écrasé la discussion chiffrée).

Le relais par la façade de /api/chat/mode et /api/chat/criteria et les
critères rendus par l'outil disparaissent : le Go pousse ses événements
lui-même, une seule source quel que soit le chemin.

README : LOKI_CHAT=rust et ce qu'il ne prend pas encore en charge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:19:42 +00:00
Claude 306c7a2f6c Conversation tenue par la façade : le Go lui remet ce qui lui arrive
Avec LOKI_CHAT=rust, la façade sert les routes du chat. Deux chemins
arrivent pourtant au Go sans passer par elle — le tunnel du relais et sa
redistribution /api/e2e/req — et liraient la conversation du Go, périmée,
voire l'écriraient en base par-dessus celle de la façade. facade_chat.go :
- routes du chat remises à la façade (après requireWebAuth), avec le jeton
  interne qui y tient lieu d'authentification, flux compris ;
- /api/e2e/chat lit le flux de la façade et le rechiffre événement par
  événement (nombres repris tels quels) ;
- les événements d'affichage nés ici (bascule du mode Code, critères)
  vont au journal de la façade (/internal/facade/v1/delta) ;
- l'export lit la discussion enregistrée ;
- le déverrouillage de la mémoire fait recharger la façade.

L'outil d'exécution ne rend plus les critères : critNotify les pousse lui-
même, d'où qu'il soit appelé. Sans LOKI_CHAT=rust, rien ne change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:19:42 +00:00
Claude 846b561efb Lot 6 : routes du chat servies par la façade (LOKI_CHAT=rust)
Avec LOKI_CHAT=rust, la façade charge la discussion active au démarrage
et sert elle-même /api/chat (flux SSE : rejeu puis direct, battement de
cœur toutes les 4 s, trames regroupées), /api/chat/send, stop, reset,
state, compact, tool-result et /api/conversations{,/new,/switch,/rename,
/delete}, avec les réponses des handlers Go. /api/chat/mode et
/api/chat/criteria restent au Go ; la façade relaie puis journalise
l'état rendu, pour que tous les appareils suivent. Pièces jointes,
fichiers, images et export restent au Go, qui suit la discussion active.

Le Go reçoit LOKI_CHAT=rust et l'adresse de la façade (étape suivante :
lui remettre le trafic de chat du tunnel). Sans la variable, rien ne
change : tout part au Go comme avant.

ChatService gagne reset (fichiers effacés par le Go), la compaction
manuelle, le résultat complet d'un outil et le relais d'un événement ;
les critères changés par l'outil criteria sont journalisés pendant le
tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:11:22 +00:00
Claude e96ffe319c API interne : critères changés par un outil, fichiers seuls au reset
/internal/v1/tool/exec rend la liste des critères après un appel de
l'outil criteria qui l'a changée (add, set, clear) : critNotify la pousse
au journal du Go, une conversation tenue par la façade la reçoit ici.
/internal/v1/conv/dropped accepte files_only : Reset vide la discussion
et ses fichiers, sans la supprimer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:11:22 +00:00
Claude b5649eb506 Lot 6 : un tour de discussion de bout en bout en Rust (chat::generate)
Port d'EnqueueOrStart, StartTurn, generate, drainQueued,
startQueuedIfAny, dropQueued et compactAndPublish :
- envoi : réessai reconnu (cid), tour préparé par le Go (/turn/start), mis
  en file si un tour tourne, sinon moteur prêt (/health), verrou du tour
  puis bail du moteur du Go, bulle journalisée et enregistrée tout de suite ;
- tour : compaction de début de tour, vue d'envoi (tête du Go, historique
  sans <context_update>, préambule fusionné comme InjectSkills), boucle
  dont chaque événement devient un delta du journal, historique rangé
  (outils, réponse, raisonnement renvoyé, rappels en porte-à-faux retirés),
  contexte estimé si le moteur ne le compte pas, compaction de fin de tour ;
- fin : turn_done, journal coalescé, verrou et bail rendus, discussion
  enregistrée, file abandonnée après un stop ou message suivant lancé,
  sinon Web Push ;
- stop : le tour en cours, ou le détenteur Go du moteur.

chat::history porte historyFromView, dropStrayNudges, le rappel des pages
mémoire lues, l'assemblage d'InjectSkills et la forme des tool_used du
journal, avec leurs tests Go. ConvState garde ctxUsedLen et genPeak en
mémoire seulement. EngineLease::interrupted ne garde plus de verrou à
travers une attente.

Écarts : boucle de vérification du mode Code, critères pour la
compaction, préchauffage, télémétrie, retry_patterns personnalisés.

Tests de bout en bout : tour complet (outil, réponse, bail rendu,
discussion enregistrée, doublon), message en file injecté entre deux
étapes, moteur tenu par une tâche planifiée (409, stop), moteur pas prêt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 11:00:17 +00:00
Claude f2d2669690 Lot 6 : effets des discussions par le Go (GoSessionHooks)
Les outils exécutés par le Go lisent la discussion active (dossier de la
discussion, mode Code, critères) : chaque chargement, création ou bascule
la lui annonce (hook activated, /internal/v1/conv/active). Une discussion
supprimée fait nettoyer ses fichiers, travaux, critères, mode et résultats
d'outils (/internal/v1/conv/dropped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:48:38 +00:00
Claude ef11454a21 API interne d'un tour de discussion tenu par Rust
Ce qui décrit un tour reste au Go le temps d'être porté (lot 7) ; la
façade le demande ici :
- /turn/start : pièces jointes et vision (userMessageContent), capacités
  demandées et celles du tour (rôle du mode Code figé), nom du modèle,
  puce « mode Code » (une fois par discussion) ;
- /turn/view : la tête de turnView sans PROJ_SNAPSHOT (prompt du preset,
  consignes du dépôt, contexte du projet), le préambule d'InjectSkills et
  les outils du tour, MCP compris ; 409 sous PROJ_SNAPSHOT ;
- /turn/done : notification Web Push de fin de tour ;
- /conv/active : la discussion active des outils suit la façade ;
- /conv/dropped : fichiers, travaux, critères, mode et résultats d'outils
  d'une discussion supprimée.

injectPrefix et codeHintDue sortent d'InjectSkills et de maybeCodeHint,
sans changement de comportement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:48:38 +00:00
Claude fea055fd52 Lot 6 : le tour branché sur le moteur, les outils et la base (chat::host)
GoTurnHost est le TurnHost de la façade : envoi par EngineHttp (clé de
l'endpoint, pannes classées comme friendlyLLMError), outils par l'API du Go
avec leurs suites (capture, aperçu, image relayée), résultats complets
confiés à l'écrivain des discussions (id « <discussion>.<aléa> »),
compaction par Compactor avec un résumeur qui interroge le moteur
(EngineSummarizer) et l'archive recall du Go (StoreRecall), images jointes
lues sur disque et ouvertes par le Go, en cache borné à 64 Mio.

RecallArchive devient asynchrone ; la boucle consulte la mémoire des
refus de compaction avant d'annoncer une compaction en cours de tour.

Écarts : pas de résumé en continuation (état du slot tenu par le Go), ni
conseil de cache, ni garde d'écriture du mode Code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:34:15 +00:00
Claude e114ba3800 API interne : suites d'un outil et archive recall pour le tour tenu par Rust
/internal/v1/tool/exec rend aussi ce que la boucle Go tire du résultat
après l'exécution, et qui demande le disque ou le navigateur
(toolStepExtras) : capture de web_screenshot pour la bulle, aperçu du
navigateur après une action qui change la page (jamais après une erreur),
message qui relaie l'image au modèle (capture avec la vision, see_image).

/internal/v1/recall/archive range un bloc verbatim pour l'outil recall :
son numéro vient de la séquence bbolt, seul ce process peut le donner. 423
si la mémoire est verrouillée, rien d'écrit en clair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:34:14 +00:00
Claude 73bbf92f69 Lot 6 : la boucle d'un tour portée en Rust (chat::turn)
Port de runChatLoop : complétions enchaînées jusqu'à la réponse, sur les
modules déjà portés (charge utile, flux, compactage). Les filets du Go y
sont : reprises réseau et statuts passagers, flux externe coupé (rejoué ou
poursuivi après le texte affiché), intensité refusée par le gabarit,
raisonnement renvoyé refusé ou trop long, compaction réactive puis
réduction forcée, 500 (tool_choice none puis outils retirés), réponse vide
(rejeu si fenêtre pleine, relances « pensé sans agir »), appel écrit en
texte, budget souple, appels identiques court-circuités, arguments
illisibles jamais exécutés, pré-vol du mode Code, compaction en cours de
tour, file d'attente injectée entre deux étapes.

Aucune entrée-sortie dans la boucle : envoi, outils, compaction, résultats
complets et mémoires des refus passent par TurnHost ; l'arrêt par Cancel.
Non portés (clés opt-in ou machinerie du moteur local restée au Go) :
KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT, SLOT_PERSIST,
préchauffage, sonde de gabarit, télémétrie.

15 tests sur un faux hôte aux réponses scriptées (temps suspendu pour les
reprises).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:22:48 +00:00
Claude bec3d2cbfd Lot 6 : petites règles de la boucle d'un tour (chat::nudges)
Port de llm_budget.go (palier AGENT_BUDGET, rappels qui reviennent à
chaque palier), appendNudge, steerSystem, dedupableTool,
repeatedCallResult et toolResultPreview : ce qui manquait à la boucle
d'un tour en plus des modules déjà portés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:22:48 +00:00
Claude a73b20ea04 Lot 6 : plusieurs discussions côté Rust (chat::sessions)
Port des opérations de chat_sessions.go et de LoadConversation :
discussion active créée au besoin (reprise du fil unique d'avant les
discussions, clé d'origine gardée), chargement au démarrage qui réessaie et
n'écrit rien sur une base illisible, liste du projet actif, création,
bascule (la courante enregistrée sous SON identifiant), renommage,
suppression (l'écriture en vol ne la fait pas renaître ; bascule sur la plus
récente restante ou une neuve : toujours une discussion active).

Les opérations sont sérialisées ; l'index partage son verrou avec
l'écrivain (StoreSink::index_lock, convIndexMu en Go). Fichiers, travaux,
critères, mode, résultats d'outils d'une discussion supprimée et
préchauffage après bascule passent par SessionHooks. Store gagne
get_open / put_seal (getStoreBytesErr / putStoreBytes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:09:00 +00:00
Claude 74c2d5105c Lot 6 : la discussion en mémoire portée en Rust (chat::conv)
Port de l'état de Conversation et de subscribeSink : état persisté et
discussion chargée, epoch (reset, bascule), verrou du tour et son arrêt,
ajout au journal avec rejet des deltas d'un tour périmé, coalescence de fin
de tour, instantané numéroté sous le même verrou, /api/chat/state.

Subscription rend les lots à émettre : rejeu (padding, reset si le curseur
vient d'une autre discussion, pagination, caught_up, contexte), puis le
direct, avec {reset} et l'en-tête paginé quand l'epoch change. Réveil par
compteur de version marqué lu avant la relecture de l'état : aucun réveil
perdu.

Tests Go de l'abonnement portés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 10:05:33 +00:00
Claude 091902039d Lot 6 : bail du moteur côté Rust (chat::lease)
EngineLease prend le verrou de génération du Go, le renouvelle au tiers de
sa durée en tâche de fond et signale au tour un stop passé par le Go ou la
perte du bail (Reset, bascule, expiration). Rendu par release, ou en tâche
de fond s'il est lâché. engine_stop arrête un détenteur Go (tâche, bench,
optimiseur). Store::internal_post garde le statut HTTP pour distinguer les
refus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 09:59:16 +00:00
Claude 27ce303d7f API interne du bail du moteur pour la conversation tenue par Rust
Le verrou de génération (conv.Generating) est partagé par le chat, les
tâches planifiées, le bench et l'optimiseur. Une conversation tenue par la
façade doit prendre ce verrou-là : sinon une tâche partirait vers le même
moteur en plein tour.

/internal/v1/engine/lease prend le verrou (409 avec la raison du refus et
la tâche en cours), /renew le prolonge et remonte un stop passé par le Go,
/release le rend (seulement s'il le tient encore), /engine/stop arrête un
détenteur Go. Sans renouvellement le bail expire : une façade tuée en plein
tour ne garde pas le moteur. Reset et les bascules de discussion rompent le
bail comme ils libèrent un tour Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 09:59:16 +00:00
Claude f15baa79aa Lot 6 : persistance des discussions portée en Rust (chat::persist)
Port de chat_persist.go et de la partie index de chat_sessions.go, au même
format en base que le Go (chat/conv:<id>, chat/index, toolres/<clé>, tous
scellés par l'API interne) :

- ConvState : JSON de Conversation à l'octet près (tranches vides en null,
  bloc projet omis), relecture tolérante comme json.Unmarshal et guérison
  stripImageParts ; instantané en plein tour avec le journal compacté ;
- index : ConvMeta, titre déduit (rappels de loki et <context_update>
  exclus, 60 caractères), tri, projets, renommage, rafraîchissement d'une
  entrée ;
- écrivain unique (Persister) : lots fusionnés, jamais un état plus ancien
  qu'un état écrit, résultats d'outils servis depuis la mémoire jusqu'à
  l'écriture, suppressions qui ne font rien renaître, file jamais bloquée
  par un écrivain qui panique ;
- StoreSink : écriture par l'API interne du Go. Mémoire verrouillée : rien
  en clair ; index illisible : jamais réécrit.

Tests Go de l'écrivain portés, plus des tests de bout en bout contre le
faux Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 08:50:51 +00:00
MichaelandClaude Opus 5.5 d8e2c3dbf6 Lot 6 : analyse du flux SSE du moteur portée en Rust (chat::stream)
La partie « protocole » de runChatLoop devient une machine à états pure et
incrémentale (StreamParser) : lignes data:, décodage strict de streamChunk
façon encoding/json (un champ mal typé saute le chunk), télémétrie tolérante
(perfWire/applyPerf), timings et usage, accumulation des tool_calls par
index, découpage </think> côté client, frappe en direct (argPreview,
bodyTail), détection des appels d'outils écrits en texte (motifs embarqués
codés à la main, fenêtre retryScanStart), pré-vol du mode Code, stats de
secours sans timings. Plus toolCallLabel, repairToolCall, l'assemblage des
appels (badArgs, ids de secours) et l'encodage json.Marshal de Go.

L'état global Go (REASONING, preset externe, REASONING_ECHO, retry_patterns,
noteEnginePrompt, codeWriteGuard, tReq) devient des paramètres explicites
(StreamConfig). HTTP, reprises, compactage et exécution restent à la boucle.

Tests Go portés un pour un, et les flux enregistrés des tests de bout en
bout rejoués sur le parseur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:07:09 +02:00
MichaelandClaude Opus 5.5 4c615d131f Compactage : politique d'écho et comptes de wire.rs plutôt qu'une copie
Le port du compactage et celui de la charge utile avaient chacun leur
version d'echoPolicy / echoTokens / echoable / hasBody. Une seule reste,
celle de wire.rs ; les 111 tests du chat passent inchangés.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:01:02 +02:00
MichaelandClaude Opus 5.5 e862ac834e Lot 6 : estimation du contexte et compactage de l'historique en Rust
Port fidèle de chat_compact.go et chat_compact_cont.go dans
chat/compact.rs : estimation des jetons (raisonnement renvoyé compris),
seuils et garde de marge de génération, bornes tête/torse/queue, archivage
recall, torse dégraissé, résumé sur transcription ou en continuation,
demande en cours réinjectée, garantie de réduction de 20 %, pré-contrôle,
mémoire des refus et des échecs de la continuation, filet shrinkToFit.
Prompts et marqueurs repris à l'octet près.

L'état global Go devient explicite : CompactConfig (configuration, fenêtre,
modèle, politique d'écho), trait Summarizer (appel au moteur, tampon du
slot, appel d'outil écrit en texte), trait RecallArchive, CompactMemory,
critères du mode Code en paramètre, vue d'envoi du tour fournie par
fermeture. Tests Go portés un pour un avec un faux moteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:00:08 +02:00
MichaelandClaude Opus 5.5 7defedf141 Lot 6 : ce qui part vers le moteur porté en Rust (chat/wire.rs)
Port fidèle du côté envoi du client de chat : wireMessages et ses étapes
(normalisation du système, contexte projet en tête du premier message
utilisateur, retrait des marques img_relay/ctx_upd, consigne de fin quand
les outils sont neutralisés), le point de sortie REASONING_ECHO
(politique, écho, comptage, mémoire des refus, erreur de gabarit), le
repli de reasoning_effort (llm_effort.go) et turnReasoning,
l'échantillonnage du preset, buildChatPayload, chatEndpoint et
completionsURL.

L'état global Go devient paramètre : configuration, clé locale, mémoire
des traductions d'intensité et des refus d'écho, verdict de la sonde de
gabarit, lecture des images rangées par référence. Aucune copie de
l'historique quand rien ne change (Cow), comme les tranches Go.

Tests Go portés un pour un (normalisation, consigne de fin, écho,
intensité, échantillonnage, endpoint, images, corps identique à
l'ancien assemblage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:54:49 +02:00
MichaelandClaude Opus 5.5 0c1760c31a Lot 6 : journal d'affichage du chat porté en Rust (logique pure)
Port fidèle de la partie déterministe de chat_conversation.go et des trames
SSE de web_chat.go : ajout au journal avec plafond de 200 000 événements,
compaction de fin de tour (compactLog : blocs texte fusionnés avec toks, ts0
et seq0, seul le done=true des outils gardé, aperçu navigateur retiré),
rejeu coalescé (coalesceReplay, replace quand le client a vu le début du
bloc), pagination (historyCut, historyHead, 20 échanges par défaut), plan
d'abonnement (padding 2048 / 16384, reset de discussion périmée, caught_up,
ctx_used), direct par dichotomie sur le Seq (decorateEvent), et trames SSE
avec l'échappement HTML de json.Marshal et le regroupement 128 Kio / 256
événements.

Ni goroutine, ni verrou, ni base : la discussion active, le contexte utilisé
et l'horloge arrivent en paramètres, pour bâtir l'acteur de conversation
dessus. Les tests Go correspondants sont portés un pour un.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:50:17 +02:00
MichaelandClaude Opus 5.5 461ef8030d Lot 6 : clients Rust du chiffrement et de l'exécution des outils
Sceller / ouvrir un blob par le Go (mémoire verrouillée : erreur dédiée,
rien d'écrit en clair) et faire exécuter un appel d'outil par sa boucle
— les deux ponts dont la conversation Rust aura besoin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:49:56 +02:00
MichaelandClaude Opus 5.5 c38b1ac43e API interne de chiffrement pour la conversation tenue par Rust
Les discussions sont chiffrées en base quand la mémoire l'est, avec une
clé de données qui ne vit qu'en mémoire du Go après déverrouillage. Une
conversation tenue par la façade fera sceller et ouvrir ses blobs ici :
la clé ne sort jamais. Mémoire verrouillée : 423, jamais d'écriture en
clair ni de perte — l'appelant attend le déverrouillage, comme le Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:49:08 +02:00
MichaelandClaude Opus 5.5 f62271b7fe Lot 6 : transport HTTP vers le moteur en Rust
Envoi de la requête de complétion (clé de CET endpoint seulement, jamais
celle du serveur local vers une API externe), découpe du flux en lignes
comme bufio.Scanner (8 Mio au plus par ligne, arrêt net au-delà), et les
primitives de reprise de llm_retry_net.go : statuts rejouables, délai
800 ms doublé et plafonné à 5 s, classement des pannes réseau et messages
de friendlyLLMError / streamCutError à l'identique. La décision de rejouer
reste à la boucle.

HTTPS pour les presets externes : reqwest + rustls (ring), derrière une
fonctionnalité Cargo « tls » activée par défaut — le contrôle croisé Linux
sous Windows s'en passe, faute d'en-têtes libc Linux. L'étape Alpine de
l'image installe gcc pour ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:47:53 +02:00
MichaelandClaude Opus 5.5 b2fc40d48e Lot 6 : types partagés du chat en Rust et modules réservés
Message, ToolCall, Tool, événements de flux, statistiques, journal :
sérialisation JSON identique aux types Go (omitempty, contenu any). Socle
commun des ports parallèles qui suivent (charge utile, flux, compactage,
journal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:42:25 +02:00
MichaelandClaude Opus 5.5 c744b744ee Chat : l'exécution des outils sort de runChatLoop (execTool)
Lot 6, étape 1, sans changement de comportement : le switch des outils de
la boucle de chat (une cinquantaine d'outils) devient execTool, extrait
tel quel ; seule la question « ask », qui émettait un événement, le rend
désormais à l'appelant qui l'émet au même moment.

C'est la frontière par laquelle une boucle de chat écrite en Rust fera
exécuter ses outils par le Go tant qu'ils ne seront pas portés : API
interne /internal/v1/tool/exec, même fonction, donc mêmes garde-fous
(commande dangereuse refusée avant exécution, cible distante, écriture
hors du dossier de travail) ; la coupure de la connexion interrompt la
commande comme le bouton stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:40:59 +02:00
MichaelandClaude Opus 5.5 99e456b9a2 Spec (brouillon) du lot 6 : carte du cœur chat et stratégie de portage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:12:48 +02:00
MichaelandClaude Opus 5.5 73db4459d7 Lot 4 : /api/status, /api/service/log, /api/paths, /api/model/layers en Rust
L'en-tête de l'interface interroge /api/status en boucle : il est
désormais assemblé par la façade, à partir des aides portées (état du
service par le fichier PID, sonde /health, preset externe, cause d'un
échec de chargement lue dans le journal, pourcentage de chargement, preset
actif, alertes de volume et de FUSE). Sur une base réelle, sa réponse est
octet pour octet celle du Go.

Journal du moteur, emplacements et nombre de couches du modèle suivent.
L'état du moteur s'appuie sur la résolution des modèles de gguf.rs plutôt
que sur sa propre copie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:57:53 +02:00
MichaelandClaude Opus 5.5 d805d9f1ba Lot 4 : lecteur GGUF, couches du modèle et emplacements portés en Rust
gguf.rs reprend backend_gguf.go à l'identique : en-tête, clés et table des
tenseurs lus sous les mêmes bornes (chaînes, compteurs, profondeur des
tableaux, alignement, premier octet du dernier tenseur), v1/v2/v3 et
gros-boutiste, modèles en tranches (shardFamily porté avec), cache par
empreinte taille/date, mêmes messages d'erreur. Un panic éventuel reste
rattrapé en « GGUF illisible ». Tous les tests Go sont portés un pour un,
avec le même constructeur de GGUF synthétique.

Pour /api/model/layers : resolveModelPath / resolveServeModelPath et leurs
dossiers de modèles (models/, state/model_dirs, LOKI_MODEL_DIRS), la liste
stockée étant passée par l'appelant qui la lit via le store ; le corps JSON
de handleModelLayers est reproduit tel quel.

paths.rs sert /api/paths avec le même JSON que lokiPaths. Le « binaire en
cours » reste celui du serveur Go supervisé, passé par l'appelant, comme le
projet actif (state/active_project) et LOKI_WORKSPACE. Les équivalents de
filepath.Clean/Join/Dir/Abs/IsAbs garantissent les mêmes chaînes que le Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:39 +02:00
MichaelandClaude Opus 5.5 0d75e90386 Lot 4 : état du moteur porté en Rust (engstatus)
Les aides qui nourrissent /api/status et /api/service/log passent dans
loki-rs/src/engstatus.rs, à l'identique du Go :

- model_load_error_from / is_backtrace_frame (modelLoadErrorFrom) : même
  ordre des cas, même sensibilité à la casse cas par cas, libellés
  identiques à l'octet près ;
- service_log_tail : la fin du journal du moteur en conteneur ;
- engine_load_pct (Linux, -1 ailleurs) : /proc/<pid>/io et VmRSS rapportés
  à la taille du modèle, tranches comprises ; la résolution de MODEL= est
  portée localement (models/, $LOKI_MODEL_DIRS, et les dossiers de la base
  via engine_load_pct_dirs) en attendant crate::paths ;
- llm_port, is_external, health_check (sonde /health sur localhost, 3 s,
  prêt seulement sur un 200, preset externe toujours prêt).

Tests Go repris un pour un (LOAD_GUARD, Strata, OOM avant la pile
d'appels, preset externe, tranches), plus une sonde contre un serveur
hyper local : 200, 503, port fermé, connexion muette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:39 +02:00
MichaelandClaude Opus 5.5 b98ee5c3e7 Lot 4 : /api/ram et /api/vram servis par Rust
RAM lue dans /proc/meminfo, jauges des cartes par un seul nvidia-smi
partagé entre tous les onglets (cache de deux secondes, une minute quand
nvidia-smi est absent), comme le Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:48:58 +02:00
MichaelandClaude Opus 5.5 dc6a001581 Lot 4 : mesures système et diagnostics de stockage portés en Rust
Le module sysinfo de loki-rs reprend, à l'identique, ce dont les routes
/api/ram, /api/vram et le bandeau de /api/status ont besoin côté Go.

La RAM se lit dans /proc/meminfo (MemTotal moins MemAvailable, en Mo) ;
hors Linux la façade rend (0, 0) et l'interface masque le bloc. Les jauges
GPU interrogent nvidia-smi, bornées à dix secondes, et passent par un cache
partagé : deux secondes de validité, une minute quand le binaire est
introuvable, et un verrou tenu pendant la requête pour que les onglets
simultanés attendent la même lecture au lieu d'en lancer chacun une. La
structure GpuStat sérialise name, used, total, util et temp, les noms que
handleVram envoie déjà.

L'alerte « /data n'est PAS un volume monté » et le conseil fuse.shfs
d'Unraid gardent leurs textes, leur règle du montage le plus long et leur
cache d'une minute. Les dossiers de modèles venant de la base, le conseil
reçoit leur lecture en fermeture asynchrone, appelée seulement quand le
cache expire.

Les tests Go de sys_storagefuse_test.go et la partie cache de
web_vram_test.go sont portés un pour un ; le test hors conteneur, qui
touche à l'environnement, vit seul dans tests/sysinfo.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:48:18 +02:00
MichaelandClaude Opus 5.5 2fcafa4ffe Presets : SHA-1 de la crate sha1 plutôt qu'une implémentation maison
Même empreinte (vecteurs officiels et empreinte du Go toujours vérifiés par
les tests), cinquante lignes de cryptographie en moins à maintenir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:48:18 +02:00
MichaelandClaude Opus 5.5 a4b0885a22 Lot 4 : la liste des presets passe à Rust (lecture seule)
Portage fidèle de ListPresets, configFingerprint et activePresetID
(backend_presets.go, web_sysprompt.go) dans loki-rs/src/presets.rs, pour
que /api/status puis GET /api/presets puissent se servir côté façade.

Le Go lisait lui-même LOKI_HOME, la configuration active et l'ordre choisi
par glisser-déposer ; ici tout arrive en paramètres : home, la config
(Store::config) et l'ordre (seau state, clé preset_order, décodé par
parse_preset_order comme le faisait getJSON). Le reste suit le Go à la
lettre : création du dossier presets, fichiers illisibles sautés, tri par
nom puis ordre stocké, clés « appareil » exclues de l'empreinte.

L'empreinte reste un SHA-1 identique octet pour octet à celle du Go, car
elle est aussi stockée ailleurs (réglage auto, résultats de tune). Faute
de crate SHA-1 disponible, une implémentation directe, vérifiée sur les
vecteurs officiels et sur une empreinte calculée côté Go.

presetCtx (pastille de contexte de /api/presets) vient avec, ainsi que
les tests Go correspondants : empreinte insensible à la mise en forme,
ordre des presets, tailles de contexte, preset actif après bascule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:47:40 +02:00
MichaelandClaude Opus 5.5 c9c9ac1ddd Lot 5 : /v1 relayé par la façade directement au moteur
La surface compatible OpenAI ne fait plus le détour par le Go : la façade
authentifie avec la clé des complétions (préflight CORS exempté), réécrit
le corps vers le second slot quand il est en service (id_slot à 1, valeurs
gardées telles quelles), retire les X-Forwarded-* et relaie au moteur sans
tampon — Host d'origine, sauf pour Strata.

Les décisions restent au Go, qui tient l'état de la conversation : la
façade lui demande où router, ouvre un ticket « en vol » avant de relayer
(refus pendant un benchmark ou une optimisation, avec Retry-After) et le
rend quand le flux se termine, que le client parte ou que le moteur soit
injoignable. Sans ticket obtenu, la requête est refusée : le Go pourrait
sinon effacer le slot en plein calcul.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:47:40 +02:00
MichaelandClaude Opus 5.5 451770bfcf Lot 5 (Go) : décisions de la surface /v1 exposées à la façade
La façade Rust va relayer /v1 directement au moteur. Les décisions restent
ici, parce qu'elles dépendent de l'état de la conversation que ce process
tient en mémoire : route (port, Strata, clé des complétions, second slot
en service), begin (requête « en vol » : le slot de la discussion n'est
pas effacé pendant ce temps, préchauffage annulé si besoin ; refus pendant
un benchmark ou une optimisation ; trace de perte de cache), end.

Mêmes étapes, même ordre qu'oaiHandler. Un ticket jamais rendu (façade
tuée en plein flux) expire au bout de six heures, sans quoi le compteur
de requêtes en vol resterait bloqué.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:45:12 +02:00
MichaelandClaude Opus 5.5 7017b66c08 Lot 4 : /api/config et /api/engine/cmdline servis par Rust
Deux routes de pilotage de plus en Rust, lues dans la base à travers le
Go. La configuration sort clés triées, comme json.Encoder le fait d'une
map Go. Faux serveur Go de test à store en mémoire, réutilisable par les
lots suivants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:43:15 +02:00
MichaelandClaude Opus 5.5 bea29bc6a9 Lot 4 (socle) : store accessible à Rust, routeur /api côté façade
Le Go expose sa base à la façade par l'API interne : lecture, écriture,
suppression d'une clé et lecture d'un bucket entier. Il reste l'unique
propriétaire du fichier bbolt ; les écritures passent par putBytes/update
et invalident son cache comme une écriture locale. Base illisible : 503,
pour que Rust distingue « rien » de « pas pu lire ».

Côté Rust : client du store (seul point de passage, que la migration
finale de la base ne touchera qu'ici), routeur /api avec contexte partagé
et garde d'authentification réutilisable ; /api/ping y emménage. Modules
réservés aux aides portées ensuite (système, état du moteur, presets,
chemins, GGUF).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:40:29 +02:00
MichaelandClaude Opus 5.5 a672760f3c Façade Rust, lot 3 : la supervision du moteur passe à loki-rs
En conteneur, start/stop/restart/status du moteur passent par
`loki-rs engine <action>`, port à l'identique de la supervision Go :
`loki serve` lancé chef de session, fichier PID et journal aux mêmes
chemins (serviceIsActive, l'UI et `loki logs` lisent les mêmes fichiers),
contrôle à 2 s avec la fin du journal en cas d'échec, arrêt de tout
l'arbre — session, groupe et filiation, pour le moteur de Strata qui prend
sa propre session — avec les mêmes délais (SIGTERM, 4 s, SIGKILL, 20 s).

Le Go garde ses garde-fous (optimisation en cours, preset externe) puis
délègue au loki-rs voisin de son binaire ; la dernière ligne d'erreur du
superviseur devient l'erreur montrée par l'UI. LOKI_ENGINE_SUPERVISOR=go
revient à l'ancienne supervision. `loki serve`, qui construit la ligne de
commande du moteur, ne change pas : il aura son propre lot.

Le contrôle « démarré ? » regarde si l'enfant est sorti plutôt que le
signal 0 : un enfant mort reste un zombie tant que son parent vit, et un
zombie répond encore au signal 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:38:37 +02:00
MichaelandClaude Opus 5.5 d4fa4488cc Façade Rust, lot 2 : interface, authentification et /api/ping
L'interface (index, marked, service worker, manifeste, polices) est
désormais servie par loki-rs, embarquée par build.rs depuis la même source
que le go:embed — les deux binaires d'un build servent la même UI. Mêmes
en-têtes de cache que le Go, gzip à la première demande puis gardé en
mémoire, jamais sur les polices ni en HEAD.

L'authentification de pilotage est portée à l'identique (requireWebAuth,
crossSiteReject, localHostName, Bearer en temps constant, mêmes messages,
mêmes codes) ; les cas de TestCrossSiteReject sont repris un pour un. La
clé est demandée à l'API interne du Go à chaque requête ; si elle ne
répond pas proprement, la façade ferme (503). /api/ping est la première
route authentifiée servie par Rust.

Le jeton de l'API interne est tiré au hasard à chaque démarrage et passé
au Go par l'environnement. La façade rend 404 sur /internal/* venu de
l'extérieur et efface X-Loki-Internal entrant avant tout relais.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:40:10 +02:00
MichaelandClaude Opus 5.5 a99d988e70 API interne pour la façade Rust : clé de pilotage à la demande
Lot 2 de la migration : la façade va authentifier elle-même ses routes
/api/*. La clé de pilotage vit dans la base bbolt, verrouillée par ce
process ; la façade la demande donc à GET /internal/v1/auth, à chaque
requête — un changement de clé prend effet tout de suite, comme avant.

La réponse contient un secret, d'où trois verrous : route branchée
uniquement sur l'écoute TCP directe (le tunnel du relais sert le mux nu),
jeton tiré au hasard par la façade à chaque démarrage et exigé en
temps constant, 404 sans rien révéler sinon. Base illisible : 503, la
façade fermera l'accès plutôt que de l'ouvrir.

Spec du lot 2 jointe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:35:00 +02:00
MichaelandClaude Opus 5.5 4b859c4eb6 Conteneur : façade Rust en frontal, binaires mis à jour au démarrage
L'image embarque loki-rs (étape Alpine, binaire musl statique) et
l'entrypoint en fait la porte d'entrée : `loki-rs serve` sur le port
public, le Go derrière lui sur 127.0.0.1:8091. LOKI_FACADE=0 revient à
l'ancien schéma si la façade pose problème.

Au démarrage, le loki-rs de l'image installe la dernière release
« rolling » dans /data/bin/<build>/ ; l'entrypoint prend ensuite loki et
loki-rs du même dossier (which-bin), et un lien dans /usr/local/sbin fait
que `docker exec loki loki …` vise lui aussi la version installée. C'est
toujours le loki-rs de l'image qui met à jour : un binaire publié
défectueux ne peut pas bloquer le correctif suivant.

CI : job Rust (fmt, clippy -D warnings, tests). Publication : la release
« rolling » part à chaque push sur main avec le même numéro de run que
l'image (LOKI_BUILD) ; IMAGE_ABI, source unique, empêche d'installer des
binaires qui exigent une image plus récente. manifest.json est envoyé en
dernier.

Message corrigé quand l'image est plus récente que la release : rien à
tirer dans ce cas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:33:18 +02:00
MichaelandClaude Opus 5.5 0a6914c936 Façade Rust loki-rs : proxy devant le Go et mise à jour au démarrage
Premier lot de la migration vers Rust. Le binaire loki-rs :

- serve : prend le port public (8090), lance `loki web 8091` en écoute
  locale et lui relaie tout — HTTP, flux SSE trame par trame, WebSocket.
  Les en-têtes de bout en bout passent intacts (Host, Authorization,
  Origin, Sec-Fetch-*, X-Forwarded-Proto : le Go en tire ses contrôles
  d'accès) ; X-Loki-Via entrant est effacé, un client ne doit jamais
  pouvoir se faire passer pour le tunnel. Go mort : relancé avec attente
  croissante, 502 lisible entre-temps.
- self-update : lit la release « rolling », installe loki et loki-rs dans
  $LOKI_HOME/bin/<build>/ après vérification SHA-256, bascule par
  réécriture atomique de release.json. Les versions s'ordonnent par numéro
  de run CI ; une ABI d'image différente n'est jamais installée. Toute
  erreur laisse l'état précédent, le démarrage n'est jamais bloqué.
  Le HTTPS passe par curl (présent dans l'image) : pas de pile TLS ici.
- which-bin : dossier d'où l'entrypoint prend les deux binaires, toujours
  ensemble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:30:09 +02:00
MichaelandClaude Opus 5.5 342f787eff Web : adresse d'écoute réglable par LOKI_WEB_BIND
La façade Rust du conteneur prend le port public et lance `loki web`
derrière elle : le Go doit alors n'écouter qu'en local, sinon il resterait
joignable en direct sans passer par la façade. Défaut inchangé (0.0.0.0).

Plan du lot 1 de la migration Rust joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:31:28 +02:00
MichaelandClaude Opus 5.5 f1f73cfc50 Spec : migration Rust, lot 1 — façade et mise à jour au démarrage
La réécriture en Rust se fait par lots, Rust en production dès le premier :
une façade loki-rs prend le port 8090 et proxifie vers le Go, qui passe en
écoute locale sur 8091. Chaque lot suivant lui retire des routes.

Le même lot fait que le conteneur prend la dernière version au démarrage :
release GitHub « rolling » publiée à chaque push sur main, binaires vérifiés
par SHA256SUMS et posés dans /data/bin, garde-fou d'ABI d'image, jamais de
boot bloqué.

La façade efface X-Loki-Via à l'entrée : sur l'écoute directe, l'en-tête
pouvait être forgé pour contourner le contrôle d'hôte anti-rebinding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:27:59 +02:00
LogiFlow 6ea661d705 Merge branch 'claude/bold-faraday-11i7lh' : Strata sur un SSD dédié (Unraid)
Strata sur un SSD dédié (Unraid)
2026-10-08 12:30:44 +02:00
Claude e236069c92 Strata sur un SSD dédié : place libre mesurée sur /data/strata, caches pip/HF hors de l'image
- strataDetect mesure l'espace libre de /data/strata (et non /data) : un SSD
  monté à cet endroit est celui qui compte pour l'installation.
- L'installeur reçoit XDG_CACHE_HOME=/data/strata/cache : les caches pip et
  Hugging Face ne remplissent plus /root, c.-à-d. l'image Docker d'Unraid.
- docker-compose.unraid.yml + README : monter un pool SSD en /data/strata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLbFcZj6mJjDpWvCpcChGA
2026-10-08 09:33:26 +00:00
LogiFlow 6afe07a823 Merge branch 'claude/wizardly-lamport-jfgrq5' : MTP sans recharger le modèle en brouillon
MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
2026-10-08 11:18:00 +02:00
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00
LogiFlow a463ccbd9f Merge branch 'claude/wizardly-lamport-jfgrq5' : arrêt du moteur sans course avec la relance, VRAM rendue
Arrêt du moteur : attendre tout l'arbre (VRAM rendue avant la relance)
2026-10-08 10:08:13 +02:00
Claude 393004cb06 Arrêt du moteur : attendre tout l'arbre, pas seulement le chef
En conteneur, userSvcStop n'attendait que le processus `loki serve`. Avec
Strata, le moteur qui tient la VRAM (cache d'experts, ~9 Go) est un autre
processus qui finit de s'arrêter après le serveur Python : la relance
suivante démarrait llama-server sur une carte encore pleine (« cudaMalloc
failed: out of memory » à l'encodeur de vision) et Strata mesurait la RAM
disponible avec l'ancien moteur encore chargé (mode mmap choisi à tort).

stopProcTree relève l'arbre (session, groupe et descendants) avant tout
signal, SIGTERM puis SIGKILL aux survivants, et ne revient qu'une fois
tout parti.

Diagnostic : une pile d'appels citant load_model ne passe plus pour une
nouvelle tentative (la cause mémoire était perdue), et le chat donne
l'échec de chargement au lieu de « il démarre, réessaie ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 07:18:53 +00:00
MichaelandClaude Opus 5.5 7d614324a0 Merge branch 'fix/tune-lock-race' : CI verte, verrou de l'optimiseur sans course
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:11 +02:00
470 changed files with 314887 additions and 643 deletions

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ dist/
!README.md
.env
.env.*
rust/target/
+18
View File
@@ -31,6 +31,24 @@ jobs:
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
done
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
macos:
runs-on: macos-latest
steps:
+54
View File
@@ -54,6 +54,7 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
LOKI_BUILD=${{ github.run_number }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
@@ -61,3 +62,56 @@ jobs:
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
# Release « rolling » : les binaires que le conteneur installe lui-même au
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
# voit pas.
rolling:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: rolling-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Outils musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
- name: Binaires
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
file dist/loki-rs-linux
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
abi=$(tr -d '[:space:]' < IMAGE_ABI)
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
cat dist/manifest.json dist/SHA256SUMS
- name: Publier
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view rolling >/dev/null 2>&1 || \
gh release create rolling --prerelease --target "$GITHUB_SHA" \
--title "rolling (mise à jour au démarrage)" \
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
gh release upload rolling dist/manifest.json --clobber
+3
View File
@@ -22,3 +22,6 @@ Thumbs.db
.pytest_cache/
__pycache__/
node_modules/
# Rust (façade loki-rs)
rust/target/
+27
View File
@@ -42,6 +42,23 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
# le même que celui publié dans la release « rolling » et installé à chaud
# dans /data/bin par `loki-rs self-update`.
FROM rust:1-alpine AS rustbuild
RUN apk add --no-cache musl-dev gcc
WORKDIR /src
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
# deux binaires d'une image servent toujours la même interface.
COPY internal/loki/ui/ internal/loki/ui/
# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go.
COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json
COPY rust/ rust/
RUN cd rust && cargo build --release --locked \
&& mkdir -p /out && cp target/release/loki-rs /out/loki-rs
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
@@ -131,6 +148,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
@@ -147,6 +165,15 @@ ENV LOKI_CONTAINER=1 \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
# dépendances d'image les binaires exigent — un binaire publié pour une autre
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
ARG LOKI_BUILD=0
ENV LOKI_BUILD=${LOKI_BUILD}
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
# conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
+1
View File
@@ -0,0 +1 @@
1
+6 -4
View File
@@ -56,11 +56,13 @@ expose à la place un export/import de paquet chiffré en fichier local.
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
inclus dans l'image : ils sont téléchargés à la demande.
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
l'image : ils sont téléchargés à la demande.
## Services externes
+118 -4
View File
@@ -16,7 +16,9 @@ seule image.
```
┌────────────────── conteneur loki ──────────────────┐
│ loki web (UI + API, port 8090, premier plan) │
│ loki-rs serve (façade Rust, port 8090) │
│ │ relaie HTTP/SSE/WebSocket │
│ loki web (UI + API Go, 127.0.0.1:8091) │
│ │ pilote (fichier PID — pas de systemd) │
│ loki serve ──exec──► llama-server (CUDA, :8080) │
│ ▲ modèles .gguf │
@@ -35,6 +37,13 @@ seule image.
redémarre le moteur normalement.
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
seule l'UI (8090) l'est.
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
lui fait reprendre des routes. Déjà servis par Rust : l'interface (HTML,
JS, polices) et `/api/ping`, avec l'authentification de pilotage portée à
l'identique (la clé est demandée au Go par une API interne locale,
protégée par un jeton tiré à chaque démarrage). Plan et feuille de route :
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Démarrage rapide (Docker, GPU NVIDIA)
@@ -102,7 +111,7 @@ Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
SHA-256).
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
@@ -111,7 +120,26 @@ puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go d
`/data/strata`). Le modèle installé devient un preset comme les autres :
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
défaut : elle garde et calcule une part des experts) ou **partage des couches**
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
l'environnement Python et la configuration de la 1.0 (le dossier
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
le cache KV verrouillé en RAM ne tient plus).
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
@@ -120,6 +148,43 @@ verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
llama.cpp.
### Strata sur un SSD dédié (Unraid)
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
et un disque dur de l'array est à proscrire.
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
Le SSD est alors monté en `/mnt/strata`.
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
les presets restent valides : le chemin vu par le conteneur ne change pas),
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
montage et occupe de la place pour rien.
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
```yaml
- /mnt/strata/loki:/data/strata
```
(sous les deux volumes existants), puis *Compose Up*.
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
proposées, puis installe.
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
ou cache : le pool `strata` n'est lié à aucun partage.
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
@@ -135,6 +200,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
`docker-compose.unraid.yml` → **Compose Up**.
4. Interface : `http://<ip-unraid>:8090`.
## Mise à jour au démarrage
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
changements de code.
- À chaque push sur `main`, la CI publie la release GitHub
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
dit ce qui s'est passé (`[loki-rs update] …`, puis
`[entrypoint] binaires Loki : …`).
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
les binaires déjà en place servent, le conteneur démarre normalement.
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
conteneurs plus anciens n'installent pas ces binaires et le journal demande
un `docker compose pull`.
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
téléchargé : une version publiée défectueuse ne peut pas empêcher le
correctif suivant d'arriver au redémarrage d'après.
- `docker exec loki loki …` utilise aussi la version installée.
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
## Configuration
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
@@ -152,6 +243,29 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `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 | — |
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust |
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go |
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go |
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go |
**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
@@ -1025,7 +1139,7 @@ Une étape à la fois, mesurée avant la suivante :
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
Pour récupérer les évolutions de l'amont :
+16 -2
View File
@@ -16,8 +16,10 @@
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
#
# Mise à jour : Compose Down puis Up avec « pull image », ou
# « docker compose pull » avant Up.
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
# Si le journal demande une nouvelle image : Compose Down puis Up avec
# « pull image », ou « docker compose pull » avant Up.
# ───────────────────────────────────────────────────────────────────────────
services:
loki:
@@ -39,6 +41,9 @@ services:
# sinon l'API répond 403 (protection contre les sites tiers / DNS
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
# Conversation tenue par la façade Rust (défaut). « go » la rend au Go si
# quelque chose ne va pas — puis Compose Down / Up.
# - LOKI_CHAT=go
volumes:
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
@@ -64,6 +69,15 @@ services:
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
+39 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
# configuration au premier démarrage, lance le moteur (supervision PID, sans
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
set -eu
: "${LOKI_HOME:=/data}"
@@ -15,6 +16,33 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# ── Mise à jour au démarrage ───────────────────────────────────────────────
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
/usr/local/bin/loki-rs self-update || true
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
# est plus récent que l'image et de même ABI, sinon l'image.
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
BIN_DIR=/usr/local/bin
fi
echo "[entrypoint] binaires Loki : $BIN_DIR"
# Tous les appels « loki » ci-dessous visent le binaire choisi.
loki() { "$BIN_DIR/loki" "$@"; }
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
if [ "$BIN_DIR" != /usr/local/bin ]; then
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
else
rm -f /usr/local/sbin/loki 2>/dev/null || true
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
@@ -59,4 +87,12 @@ else
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
fi
exec loki web "${LOKI_WEB_PORT:-8090}"
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
# si la façade pose problème.
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
if [ "${LOKI_FACADE:-1}" = "0" ]; then
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
fi
exec "$BIN_DIR/loki-rs" serve
@@ -0,0 +1,122 @@
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Global Constraints
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
## File Structure
```
IMAGE_ABI ABI d'image (entier)
rust/Cargo.toml workspace
rust/loki-rs/Cargo.toml
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
rust/loki-rs/src/child.rs supervision de `loki web`
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
rust/loki-rs/tests/proxy.rs intégration façade
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
internal/loki/web_server.go LOKI_WEB_BIND
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
.github/workflows/ci.yml job rust
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
README.md section mise à jour au démarrage
```
---
### Task 1: Go — adresse d'écoute configurable
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
**Interfaces (produits) :**
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
### Task 3: self-update (téléchargement vérifié, installation atomique)
**Files:** `src/update/mod.rs`, `tests/update.rs`
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 4: Façade proxy
**Files:** `src/proxy.rs`, `tests/proxy.rs`
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 5: Supervision du Go + commande `serve`
**Files:** `src/child.rs`, `src/main.rs`
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
- [ ] Commit.
### Task 6: Image, entrypoint, CI, release rolling, doc
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
- CI : job `rust` (fmt, clippy, test sur ubuntu).
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
## Self-review
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
@@ -0,0 +1,150 @@
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
Date : 2026-10-13
Statut : validé (design), à planifier
## Contexte
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
semaines : la migration est **progressive**, par lots, avec Rust en production
dès le premier lot.
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
sans `docker compose pull` manuel.
## Architecture de cohabitation (« strangler fig »)
```
:8090 ──► loki-rs (Rust, axum/tokio)
├─ routes portées → traitées en Rust
└─ tout le reste → proxy HTTP / SSE / WebSocket
→ loki web (Go) sur 127.0.0.1:8091
```
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
de `loki web`). Le port public reste 8090 : compose et reverse proxy
inchangés.
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
de socle aux lots suivants.
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
le ferme pour `loki web` hors conteneur. Test dédié.
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
### Démarrage et arrêt
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
arrêté proprement par Go, comme aujourd'hui).
## Mise à jour au démarrage
### Publication (CI)
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
release GitHub **`rolling`** (pré-release, assets écrasés) :
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
- `loki-rs-linux` — binaire Rust musl
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
### ABI d'image
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
source du manifeste dans le même commit) quand le code exige un changement de
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
installé : il ne pourrait pas tourner correctement.
### Au boot (`loki-rs self-update`)
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
étape 6.
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
Toute erreur → `.part` supprimés, rien remplacé.
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
binaires viennent toujours de la même source (jamais un Go de l'image avec un
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
l'ABI.
## Organisation du code Rust
```
rust/
Cargo.toml workspace
loki-rs/
src/main.rs CLI : serve | self-update | version
src/proxy.rs façade HTTP/SSE/WebSocket
src/child.rs supervision du processus Go
src/update/ manifeste, téléchargement, SHA256SUMS, installation
```
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
## Tests
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
installation atomique (échec au milieu → rien remplacé).
- Intégration : façade devant un faux backend axum —
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
entrant effacé, 502 si backend absent.
- Mise à jour : faux serveur de release local (URL de base injectable par
variable d'environnement de test).
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
## Feuille de route des lots suivants (hors périmètre de ce spec)
| Lot | Contenu |
|---|---|
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
| 3 | Supervision de llama-server |
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
| 5 | Outils de l'agent, MCP, mode Code/LSP |
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
| 7 | Mémoire, tâches, dictée, relais, CDP |
| 8 | Store bbolt → redb, retrait de Go |
Chaque lot aura son propre spec, plan et PR.
@@ -0,0 +1,74 @@
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
## But
Faire passer en Rust les premières routes réelles, et surtout poser le socle
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
## Périmètre
1. **UI statique servie par Rust**, embarquée dans le binaire à la
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
| Route | Content-Type | Cache-Control | Autre |
|---|---|---|---|
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
GET et HEAD seulement (les autres méthodes restent relayées au Go).
Compression gzip si le client l'accepte, sauf polices (comme
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
Version compressée calculée une fois, à la première demande.
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
nu et ne la voit jamais.
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
caractères, comparaison en temps constant). Jeton absent, trop court ou
faux → 404 (rien ne révèle l'existence de la route).
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
passe au Go par l'environnement.
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
- clé relue à **chaque requête** (API interne) : un changement de clé prend
effet immédiatement ;
- API interne en erreur (Go absent, 503, réponse illisible) → 503
`{"error":"configuration illisible — réessaie dans un instant"}` ;
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
(mêmes messages que le Go) ;
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
`WWW-Authenticate: Bearer realm="loki"` ;
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
le trafic du tunnel ne traverse pas la façade).
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
après authentification — première route `/api/*` servie par Rust.
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
en-tête `X-Loki-Internal` entrant effacé avant relais.
## Hors périmètre
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
## Tests
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
avec clé et version, autres chemins relayés au mux).
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
ping avec et sans clé.
@@ -0,0 +1,49 @@
# Migration Rust — lot 3 : supervision du moteur
Date : 2026-10-14 · Statut : livré · Prérequis : lots 1-2.
## Découpage
Le « moteur » en conteneur, c'est deux couches de taille très différente :
1. **La supervision du processus** (~350 lignes Go : `sys_service_container.go`,
`sys_proctree_linux.go`) — lancer `loki serve` détaché, fichier PID,
journal, arrêter tout l'arbre (Strata compris) et attendre que la VRAM
soit rendue.
2. **La ligne de commande** de llama-server / Strata (~9 000 lignes :
`backend_serve*.go`, presets, MoE, spéculatif, threads, cache…), là où
s'applique la règle « ne jamais dénaturer les modèles ».
Le lot 3 porte la couche 1. La couche 2 reste au Go (`loki serve`) et fera
l'objet d'un lot dédié, parce que chaque option y a été réglée contre un
défaut mesuré.
## Ce qui change
- `loki-rs engine start|stop|restart|status` : port à l'identique de la
supervision Go — chef de session (`setsid`), fichier PID et journal aux
**mêmes chemins** (`$LOKI_HOME/<service>.pid|.log`), contrôle à 2 s avec
les 20 dernières lignes du journal en cas d'échec, arrêt de l'arbre
(session, groupe, filiation ; SIGTERM, 4 s, SIGKILL, 20 s au plus), mêmes
messages.
- Le Go garde ses garde-fous (optimisation en cours via `serviceAction`,
preset externe) puis délègue au `loki-rs` voisin de son binaire.
`loki logs`, `enable`/`disable` restent au Go.
- Porte de sortie : `LOKI_ENGINE_SUPERVISOR=go`, ou absence de `loki-rs`.
- Différence assumée : le contrôle « démarré ? » regarde si l'enfant est
sorti (`try_wait`), pas le signal 0 — un enfant mort reste un zombie, qui
répond encore au signal 0.
- L'arrêt exclut de l'arbre `loki-rs` lui-même ET son parent (le Go qui l'a
appelé), comme le Go excluait son propre PID.
## Tests
- Rust (toutes plateformes) : `/proc/<pid>/stat` avec parenthèses dans le
nom, arbre par session/groupe/filiation avec exclusion de l'appelant.
- Rust (Linux, CI) : faux `loki serve` qui lance un enfant dans sa session et
un « moteur » dans SA PROPRE session (comme Strata) — démarrage, « déjà
démarré », arrêt de tout l'arbre, redémarrage, échec avec journal, PID
nettoyé.
- Go (Linux, CI) : choix du `loki-rs` voisin (absent, non exécutable,
`LOKI_ENGINE_SUPERVISOR=go`), appel réel avec l'environnement attendu,
erreur rendue = dernière ligne d'erreur du superviseur.
@@ -0,0 +1,93 @@
# Migration Rust — lot 6 : le cœur chat
Date : 2026-10-14 · Statut : **livré** (étapes 1 à 6 ; défaut Rust depuis le 2026-10-15, `LOKI_CHAT=go` pour revenir au Go) ·
Prérequis : lots 1-5 (façade, auth, store via API interne, `/v1`).
## Pourquoi c'est un lot à part
Le chat est le cœur de Loki et le seul chemin qu'on ne peut pas casser un
soir de déploiement. ~17 700 lignes Go (`chat_*.go` 11 491, `llm_*.go`
6 245), dont une fonction de 1 469 lignes (`runChatLoop`,
`llm_client.go:1279`) qui mêle protocole du moteur, reprises sur erreur,
compactage en cours de tour et exécution des outils.
## Carte (HEAD b98ee5c)
### Surface HTTP
Toutes derrière `requireWebAuth` (déjà portée).
| Route | Handler | Rôle |
|---|---|---|
| `/api/chat` | `handleChat` web_chat.go:316 | **SSE** : rejoue le journal puis le suit en direct |
| `/api/chat/send` | `handleChatSend` :178 | démarre un tour ou le met en file (409 occupé, 503 pas prêt) |
| `/api/chat/stop`, `/reset`, `/state`, `/compact`, `/tool-result` | web_chat.go | contrôle et lecture |
| `/api/chat/mode`, `/criteria` | code_mode.go | mode Code |
| `/api/chat/export`, `/upload`, `/files*`, `/file*`, `/image`, `/cushot` | divers | pièces jointes, espace de travail, aperçu navigateur |
| `/api/conversations{,/new,/switch,/rename,/delete}` | web_chat.go | index des discussions |
| `/api/e2e/chat` | relay_e2e.go:143 | même flux, chiffré AES-GCM par événement (tunnel) |
Trame SSE : `data: {"choices":[{"delta":…}]}`, battement `: ping` toutes les
4 s, regroupement à 128 Kio ou 256 événements.
### État en mémoire
- `conv *Conversation` (chat_conversation.go:114) : `Messages` (vue du
modèle), `Log []LogEvent{Seq,TS,Delta}` (journal d'affichage, **seule voie
vers l'UI**, via `appendDelta`), `Seq`, `CtxUsed`, `Generating`, `cancel`,
`epoch`, file d'attente, tâche en cours. Un `mu` + `cond`.
- `Generating` est un **verrou global du moteur** partagé avec les tâches
planifiées (`RunAutonomous`), le bench et l'optimiseur (`measureLease`) et
quatre sondes `isGenerating`.
- Persistance : `snapshot()` sérialise Messages ET Log dans un seul JSON,
**chiffré** (`memEncoderNow`, clé en mémoire du Go après déverrouillage),
écrit par un écrivain unique (`persistQ`) en une transaction bbolt —
bucket `chat`, clés `conv:<id>`, `index`, `active`.
### Boucle d'un tour
`handleChatSend` → `EnqueueOrStart` → `StartTurn` → `go generate` →
`turnView` (prompt système, outils, MCP, mémoire, instantané projet) →
`runChatTools` → `runChatLoop` (payload, slot, requête au moteur, analyse du
flux, reprises, compactage réactif, **switch des outils inline**
llm_client.go:2363-2549, compactage en cours de tour) → retour dans
`generate` (validation mode Code `codeVerifyLoop`, compactage de fin de
tour, persistance, file suivante, préchauffage).
## Coupures possibles
| | Coupure | Ce qui traverse | Risque |
|---|---|---|---|
| A | Journal + SSE en Rust, boucle au Go | flux de deltas Go→Rust, epoch, reset | Log persisté DANS le même blob chiffré que Messages |
| B | Client LLM en Rust (protocole, flux, reprises) | appel d'outil Go en RPC, porte du moteur, compactage | fonction de 1 469 lignes, appels récursifs (sous-agents, vérificateur) |
| C | Machine d'état de la conversation en Rust | turnView, compactage, outils, persistance en RPC | verrou `Generating` partagé avec tâches/bench/optimiseur |
| D | Sessions + persistance | — | écrivain unique bbolt, chiffrement, ordre des résultats d'outils |
## Stratégie proposée
Pas de bascule partielle de la discussion de l'utilisateur : le cœur chat
est réécrit **en entier** côté Rust, derrière `LOKI_CHAT=rust` (Go par
défaut), puis comparé au Go sur des conversations rejouées avant de devenir
le défaut. Étapes, chacune livrée et testée :
1. **Go, sans changement de comportement** : extraire le switch des outils en
`execTool(ctx, name, args, caps) → résultat` et l'exposer en API interne
(`/internal/v1/tool/exec`), ainsi que le chiffrement des blobs
(`/internal/v1/crypt/seal|open`, la clé reste au Go) et le bail du moteur
(`Generating` → `/internal/v1/engine/lease`).
2. **Rust, bibliothèque** : protocole du moteur (payload OpenAI, analyse SSE
llama.cpp/Strata, reasoning, appels d'outils natifs et textuels, effort,
reprises réseau), testé contre des flux enregistrés.
3. **Rust, conversation** : Messages + Log + epoch + file + SSE `/api/chat`
(rejeu, pagination `historyCut`, `coalesceReplay`, `compactLog`),
persistance au même format (`conv:<id>`, chiffré via l'étape 1).
4. **Rust, boucle** : `runChatLoop` réécrit en machine d'état explicite,
outils exécutés par le Go (étape 1), compactage porté.
5. **Parité** : rejeu de conversations réelles exportées, comparaison des
journaux produits (Go vs Rust) avec un faux moteur déterministe.
6. **Bascule** `LOKI_CHAT=rust` par défaut ; outils, MCP, mémoire, mode Code
portés ensuite (lot 7) en retirant les appels au Go un par un.
## Questions ouvertes
- Chiffrement : port du format (`memEncoderNow`) en Rust à l'étape 3, ou
RPC au Go jusqu'au lot 9 ?
- Tunnel du relais (`/api/e2e/chat`) : reste au Go jusqu'au portage du
relais ; tant que `LOKI_CHAT=rust`, le flux chiffré doit lire le journal
Rust (API interne inverse) — à concevoir.
@@ -0,0 +1,44 @@
# Lot 10 — réglages partagés tenus par la façade
## Contexte
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
tous les appareils :
| Route | Port de | Ce qu'elle règle |
|---|---|---|
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<id>`), repli sur le global (`state/sysprompt`) |
| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) |
| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » |
Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`,
expérimental ; sans la variable rien ne change).
Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là
où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se
périme sur l'horodatage du fichier de base, qu'une écriture de la façade
modifie. `facade_settings.go` remet les routes du tunnel à la façade.
## Écarts assumés
- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une
valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`.
- Préférences réécrites clé par clé au lieu d'une transaction.
- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait
par `/etc/services`.
## Parité
`internal/loki/parity_settings_test.go` tire du Go :
- 19 adresses publiques normalisées ou refusées ;
- 11 valeurs d'intensité ;
- 29 appels de route, avec le contenu de la base après chacun :
- fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ;
- prompt par preset avec repli, effacement, méthode refusée ;
- intensité acceptée ou refusée ;
- adresse publique.
`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom
tronqué à 25 caractères) fait échouer le rejeu.
@@ -0,0 +1,63 @@
# Lot 11 — fichiers de la discussion tenus par la façade
## Contexte
Les fichiers rangés par discussion vivaient dans le Go :
- `web_upload.go` : dépôt, téléchargement, image ;
- `web_files.go` : panneau Fichiers ;
- `web_files_zip.go` : archive ;
- `web_thumb.go` et `web_upload_orient.go` : vignettes ;
- `chat_convfiles.go` : chemins.
Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`,
expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique |
| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » |
| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions |
| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) |
| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=<px>` redressée (EXIF) et réduite par moyennage |
| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut |
Le dossier de la discussion active reste décidé par le Go : la façade le lui
demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace`
(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la
conversation ou que ce soit la façade. Les liens des anciens messages
(`captures/<id>/…`, `uploads/…` à la racine) restent résolus comme avant.
`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les
`.part` d'envois au démarrage : la façade le fait au sien.
## Écarts assumés
- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le
redressement et la réduction (algorithme porté pixel pour pixel) sont les
mêmes.
- Horodatage des entrées `.zip` en UTC.
- Une seule plage d'octets par requête de téléchargement : le Go en acceptait
plusieurs, en multipart.
## Parité
`internal/loki/parity_files_test.go` tire du Go :
- 10 noms de dépôt et 6 slugs ;
- 6 en-têtes EXIF ;
- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ;
- 10 reniflages ;
- 42 appels de route sur une arborescence fixe, dates comprises :
- panneau, téléchargement, image et vignette ;
- dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide,
base64 illisible ;
- suppressions ;
- archive de la discussion et « hors discussion », contenu comparé
entrée par entrée ;
- les fichiers restants après chaque POST.
`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets,
un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du
titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer
le rejeu.
@@ -0,0 +1,47 @@
# Lot 12 — notifications Web Push tenues par la façade
## Contexte
Les notifications « réponse prête » vivaient dans le Go : `push.go`,
`web_push.go` et webpush-go. Lot 12 : la façade les tient quand
`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable |
| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) |
| `POST /api/push/unsubscribe` | le retire |
| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond |
Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go,
fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils
sont délégués à la façade. Clés et abonnements restent en base aux mêmes
clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`).
Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets
comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet
« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon
(404/410) est purgé. La cryptographie passe par ring, déjà présent via
rustls. Une façade construite sans TLS répond que les notifications sont
indisponibles.
## Écarts assumés
- Messages des journaux d'envoi au format des crates Rust.
## Parité
- `internal/loki/parity_push_test.go` tire du Go :
- les durées du corps de notification ;
- onze appels de route (clé, abonnements valides et refusés, mise à jour
des clés, désabonnements), avec la liste en base après chacun.
`tests/push.rs` rejoue le tout par la façade. La liste en base est
comparée à l'octet près, et la clé reste la même d'un appel à l'autre.
- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet
près.
- Un envoi de bout en bout vers un faux service de push vérifie :
- les en-têtes ;
- la signature VAPID, contrôlée avec la clé rangée ;
- le corps, déchiffré côté navigateur avec la clé de l'abonnement ;
- l'abonnement en 410, purgé.
@@ -0,0 +1,117 @@
# Lot 13 — mémoire tenue par la façade
## Contexte
La mémoire de Loki vivait dans le Go :
- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ;
- `moveMemPage` (`projects.go`) pour le déplacement entre projets ;
- `tracker.go` et `web_tracker.go` pour les trackers ;
- les routes du mode agent.
Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) |
| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit |
| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle |
| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent |
| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages |
| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) |
| `POST /api/agent/compact` | compactage automatique (`COMPACT`) |
| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` |
| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement |
### Disque et base
Les pages restent des fichiers `memory/<projet>/<nom>.md`, et l'index
`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour
sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures
restent sûres, comme dans le Go :
- un `.bak` de la version précédente est gardé ;
- le fichier est écrit dans un temporaire, synchronisé, puis renommé ;
- le contenu est relu et comparé octet par octet.
Les trackers restent dans le bucket `tracker`, sous la clé
`<projet>/<slug>`. Le registre des projets (`state/projects`) est lu tel
quel. Le mode mémoire y est écrit avec l'échappement HTML de
`encoding/json`, pour garder les mêmes octets en base.
### Chiffrement
La clé de la mémoire ne quitte pas le Go :
- une page qui porte le magic `LOKIMEMv1` est ouverte par
`/internal/v1/crypt/open` ;
- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`,
qui rend le clair tel quel quand le chiffrement est coupé.
Quand la mémoire est verrouillée :
- les pages restent listées avec le titre « 🔒 (chiffré — mémoire
verrouillée) » ;
- les trackers illisibles sont tus ;
- aucune écriture ne retombe en clair.
### Messages d'erreur JSON
Ces routes renvoient au client le message d'erreur de décodage du Go.
`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` :
- seule la première valeur compte ;
- `EOF` et `unexpected EOF` ;
- les erreurs du scanner (`invalid character …`) ;
- les erreurs de type, champs anonymes compris ;
- la casse des noms de champs, et l'allocation des pointeurs.
`gojson.rs` est mutualisé pour les lots suivants.
## Restent au Go
- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`).
- La santé, les instantanés, la sauvegarde.
- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion.
- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en
RAM du Go : la façade sert toujours le projet actif rangé.
## Écarts assumés
- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8
invalide, nombre hors limites) donne le message de serde.
- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par
`mktime`.
## Parité
### Décodage JSON
`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs
et message d'erreur. `tests/gojson.rs` les rejoue.
### Mémoire
`internal/loki/parity_mem_test.go` tire du Go :
- les noms de pages, les titres, les slugs et les modes mémoire ;
- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ;
- 102 appels de route, avec après chacun le dossier `memory/` complet
(`.bak` compris) et les clés touchées en base (mode agent, compactage,
mode mémoire, projets, trackers).
Ces appels couvrent :
- les pages : création, renommage, réenregistrement qui perd l'accroche,
noms refusés, fichiers cachés et dossiers ignorés ;
- le déplacement, avec ses six refus ;
- la bascule de projet ;
- le mode agent et le compactage ;
- le mode mémoire, y compris un projet actif inconnu ;
- les trackers : ajout, modification, suppression, renommage, déplacement,
valeurs illisibles et événements nuls.
`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des
identifiants et des instants « maintenant ».
Une mutation de chaque harnais fait échouer son rejeu.
Un essai de bout en bout simule le chiffrement par le faux Go :
- les pages sont écrites scellées et relues en clair ;
- l'index reste en clair ;
- les trackers sont scellés ;
- une fois la mémoire verrouillée, rien n'est réécrit en clair.
@@ -0,0 +1,97 @@
# Lot 14 — `loki serve` composé en Rust
## Contexte
Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine
start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose
la ligne de commande de llama-server avant de lui céder la place.
Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option
y a été réglée contre un défaut mesuré :
- couches GPU et placement automatique (`--fit`, `--fit-target`) ;
- threads et sonde de conteneur ;
- files de lancement CUDA ;
- second slot, parallélisme de tenseurs ;
- cache de prompts, points de reprise des hybrides ;
- décodage spéculatif (MTP, brouillon, n-grammes) ;
- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ;
- état des slots gardé à la bascule.
Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust`
(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la
variable, rien ne change.
## Découpage
- `serve/` : port fonction par fonction. `mod.rs` contient
`build_serve_args` et les outils de drapeaux. Les modules suivent les
fichiers Go :
- `threads`, `queues`, `expert`, `fidelity` ;
- `side`, `split`, `cache`, `ckpt` ;
- `spec`, `moe`, `persist`.
Tout ce qui décide est pur.
- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les
dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne
du Go. Il sonde ensuite :
- l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps,
avec les bibliothèques du moteur) ;
- le GGUF ;
- la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ;
- les variables `LLAMA_ARG_*` et `GGML_*`.
Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il
garde la commande en base pour l'interface (`engine_cmdline`), puis fait
`exec` du moteur.
- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté
du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi.
## Restent au Go
`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique :
- un preset Strata (son propre serveur, lot à venir) ;
- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ;
- l'absence d'API interne (lancement à la main hors de la façade).
Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`,
`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot
des presets.
## Écarts assumés
- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système
au format Rust.
- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose
qu'une ligne par lancement, le cache du Go ne servait qu'au process web.
- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne,
sans transaction : le Go seul l'efface, une fois le moteur prêt.
## Parité
`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun
avec sa ligne de commande, son environnement et ses notes. Les lancements
croisent :
- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture
exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ;
- 112 presets ;
- 3 aides de moteur (récent, ancien, inconnu) ;
- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`.
Le journal comprend aussi les sondes pures :
- listes de CPU, cpuset et quotas, mémoire du cgroup ;
- sortie de nvidia-smi, `--list-devices`, build du moteur ;
- forme copiable de la commande ;
- verdicts de SPEC=auto ;
- drapeaux de chargement, taille d'état par jeton.
`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument
par argument. Une mutation du calcul du cache, des files CUDA ou des bornes
des n-grammes fait échouer le rejeu.
Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un
faux Go et un faux llama-server. Ils vérifient :
- la ligne exécutée et le chemin des bibliothèques ;
- la commande gardée en base ;
- le refus d'un port pris, d'un modèle absent ;
- la sortie propre d'un preset externe ;
- la délégation au Go (sans API interne, essai de l'optimiseur, Strata).
@@ -0,0 +1,86 @@
# Lot 15 — presets tenus par la façade
## Contexte
Les presets vivaient dans le Go :
- `backend_presets.go` pour l'écriture ;
- les handlers de `web_api.go` et `web_external.go` ;
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
`moePreview`, `autoPlacementPreset`).
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
| `POST /api/switch` | bascule de preset |
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
sondes comprises.
## Restent au Go
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
expose deux routes internes pour cela (`facade_presets.go`).
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
configuration vivante :
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
est relancé, ou arrêté pour un preset externe ;
- `save` : application, puis relance ;
- `apply` : la configuration seule (preset externe en service modifié).
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
## Écarts assumés
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
pareil.
- Le message d'un échec de réseau du test externe est celui de reqwest.
## Parité
`internal/loki/parity_presets_test.go` tire du Go deux séries.
Les fonctions pures :
- nom d'affichage, guillemets, format `.env`, noms refusés ;
- quantification, raisonnement, modèle ;
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
- arguments rejoints, clé réécrite dans un preset ;
- huit copies en placement auto ;
- empreinte de mesure, moteur imposé par un preset.
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
`models/`, et les clés touchées en base. Ils couvrent :
- créations, doublons, noms refusés, erreurs JSON ;
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
configuration) ;
- preset actif et suppressions (modèle compris, partagé, absent) ;
- presets externes et leur test contre un faux service (succès, 401 au
format OpenAI, 404 brut, 500 vide) ;
- placement auto et aperçus.
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
échouer le rejeu.
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
bout contre le faux Go :
- appels d'application, avec leur mode ;
- refus quand une optimisation tourne ;
- erreur d'application rendue ;
- prompt système.
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
refusé.
@@ -0,0 +1,90 @@
# Lot 16 — pilotage du moteur tenu par la façade
## Contexte
Le Go pilotait le moteur depuis l'interface :
- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et
`/api/restart` ;
- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la
carte graphique à la machine, puis la reprendre ;
- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en
conteneur, le superviseur Rust `loki-rs engine <action>` (lot 3,
`sys_service_rust_linux.go`) ;
- `preflightEngine` : ce sans quoi le moteur ne peut pas démarrer, dit avant
de le lancer.
Lot 16 : la façade tient ces routes quand `LOKI_ENGINE=rust`
(`enginectl.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `/api/start`, `/api/stop`, `/api/restart` | pilotage du moteur ; réponse `{ok, out}` comme le Go |
| `POST /api/vram/unload` | arrête le moteur et la dictée, rapporte la VRAM rendue ; refusé pendant une réponse en cours sauf `{force: true}` |
| `POST /api/vram/reload` | relance le moteur après le contrôle d'avant démarrage ; `{already: true}` s'il tourne |
La façade lance elle-même `loki-rs engine <action>`, avec l'environnement que
le Go lui passait (`LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE`) et de quoi
joindre l'API interne du Go pour `loki-rs engine serve` (`LOKI_GO_ADDR`,
`LOKI_INTERNAL_TOKEN`). Sa dernière ligne d'erreur devient l'erreur rendue,
sans le préfixe du binaire. Un preset externe ne lance rien ; un redémarrage
arrête alors le moteur local.
Le bilan de VRAM suit `gpuSettle` :
- une lecture ratée est sautée, jamais comptée « 0 Mo » ;
- un palier ne conclut qu'une fois la baisse commencée ;
- les jauges du moniteur sont invalidées après coup.
## Quand la façade ne tient pas ce lot
La façade ne prend ce lot qu'en conteneur, avec son superviseur. Sous
systemd, ou avec `LOKI_ENGINE_SUPERVISOR=go`, `loki-rs` ne passe pas
`LOKI_ENGINE` au Go, qui garde les routes (décidé au démarrage, raison dans
le journal).
## Restent au Go
Le Go expose trois routes internes (`facade_engine.go`) :
- `POST /internal/v1/service/prestart` : avant tout démarrage, refus pendant
une optimisation, puis ménage d'un verrou périmé (essai orphelin arrêté,
application interrompue défaite). Le verrou de l'optimiseur suit le
processus qui le tient, il reste au Go jusqu'au lot de l'optimiseur ;
- `GET /internal/v1/chat/generating` : la réponse en cours, quand le Go tient
la conversation (sinon la façade lit la sienne) ;
- `POST /internal/v1/dictate/stop` : l'arrêt de la dictée, quand le Go la
tient (sinon la façade arrête la sienne).
`GET /internal/v1/tune/guard` (lot 15) sert le refus 409 du rechargement.
Les autres appelants de `serviceAction` côté Go (bascule de preset,
optimiseur, mises à jour du moteur) passent toujours par le Go. Les deux voies
lancent le même superviseur, sur les mêmes fichiers PID et journal.
## Écarts assumés
- `engineNeedsStop` regarde aussi les états transitoires de systemd. En
conteneur, seul cas où la façade tient ce lot, il se réduit au PID vivant.
- Un superviseur tué par un signal est décrit comme par le Go (« signal:
killed ») pour les signaux courants, par son numéro sinon.
## Parité
`internal/loki/parity_engine_test.go` tire du Go :
- dix bilans de VRAM (palier avant la baisse, lectures ratées, VRAM en
hausse…) ;
- la dernière ligne d'erreur du superviseur et le retrait des couleurs ;
- vingt-six installations passées au contrôle d'avant démarrage (preset
externe, Strata, BIN absent ou relatif, MODEL absent, hors des dossiers,
remontant, en tranches incomplètes…) ;
- dix réponses de route qui ne lancent rien (GET refusé avec `Allow: POST`,
optimisation ou réponse en cours, moteur déjà arrêté, contrôle en échec).
`tests/enginectl.rs` rejoue le tout par la façade. Il vérifie aussi de bout en
bout, avec un faux superviseur et un faux nvidia-smi :
- démarrage, arrêt, redémarrage, refus pendant une optimisation ;
- échec du démarrage, preset externe ;
- déchargement chiffré, arrêt en échec, VRAM en hausse ;
- rechargement.
Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeMoteur` vérifie le relais et les trois routes internes.
@@ -0,0 +1,92 @@
# Lot 17 — interrupteurs système tenus par la façade
## Contexte
Quatre réglages de l'interface vivaient dans le Go :
- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`,
`web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ;
- le pilotage du navigateur : `handleComputer`, `chromePath`,
`visionEnabled` ;
- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`,
`oaiBaseURL` ;
- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`.
Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité |
| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle |
| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers |
| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint |
Les réglages restent des clés de la base, écrites comme le Go : une chaîne
vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la
configuration.
La joignabilité de Crawl4AI suit `crawlReachable` :
- `/health`, puis `/`, en moins de deux secondes en tout ;
- clé envoyée en `Authorization: Bearer` ;
- moins de 500 = joignable ;
- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent.
## Restent au Go
Le Go expose trois routes internes (`facade_system.go`) :
- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils
web, encore exécutés par le Go, est vidé à chaque changement ;
- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se
ferme quand l'interrupteur se coupe ;
- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne
s'applique qu'au redémarrage du moteur. La façade le relance elle-même si
elle le pilote (lot 16), sinon le Go, seulement s'il tourne.
`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une
optimisation.
Le relais du tunnel vers la façade garde désormais l'hôte et
`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle
que le client a tapée, pas celle de la façade.
## Correction en passant
`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`).
`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place.
Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté
Rust.
## Écarts assumés
- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en
conteneur Linux, où le Go répondait déjà « inconnu ».
- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé
devient U+FFFD, octet par octet, comme son encodeur JSON.
## Parité
`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec
après chacun les clés touchées en base et les en-têtes reçus par trois faux
serveurs Crawl4AI (200, 500, 404). Ils couvrent :
- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ;
- clés longues, courtes, d'un caractère, résiduelle en configuration ;
- erreurs de type qui laissent passer les autres champs, `null` ;
- vision par projecteur, preset externe ou Strata ;
- clé générée (masquée dans le journal), saisie, de treize octets, repli sur
`config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une
optimisation ;
- écoute ouverte, fermée, `localhost`, `::1`, port illisible.
Il tire aussi huit dispositions du dossier de Playwright pour le Chromium
retenu.
`tests/system.rs` rejoue le tout par une seule façade, cache de
joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache
vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur
quand la façade pilote le moteur. Une mutation de chaque règle fait échouer
les tests.
Côté Go :
- `TestFacadeSysteme` vérifie le relais et les trois routes internes ;
- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ;
- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes.
@@ -0,0 +1,78 @@
# Lot 18 — hub des projets tenu par la façade
## Contexte
Le hub des projets vivait dans le Go :
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
- les mutations de `projects.go` : `createProject`, `renameProject`,
`setProjectDesc`, `deleteProject` ;
- `projectSwitch` (`chat_sessions.go`).
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
| Action | Rôle |
|---|---|
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<slug>` et son index amorcé |
| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas |
| `desc` | 2 000 runes au plus, vide = effacée |
| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) |
| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible |
Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal`
avec le HTML échappé), la mémoire de chaque projet dans `memory/<slug>`.
## Restent au Go
Le projet actif passe toujours par le Go (`/internal/v1/project/activate`),
qui en garde une copie en RAM pour la mémoire, les outils et les tâches.
Les discussions sont à qui tient la conversation.
Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime
elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion
et en ouvre une du premier projet restant.
Sinon le Go garde deux routes internes (`facade_projects.go`) :
- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet
quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ;
- `POST /internal/v1/project/drop-convs` : les discussions du projet
supprimées (`convDelete`, fichiers et résultats compris).
Une génération en cours refuse la bascule et la suppression (409). La façade
lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16).
## Écarts assumés
- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ;
la façade lit le projet actif enregistré.
- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409,
comme quand le Go tient la conversation. Le Go, relayant à la façade,
répondait 400 (« projet introuvable ») dans ce cas.
## Parité
`internal/loki/parity_projects_test.go` part d'une base amorcée :
- pages visibles, cachées, hors `.md`, dans un sous-dossier ;
- discussions de Générale, d'avant les projets, d'un projet à venir et d'un
projet disparu ;
- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`.
Il tire du Go 40 appels, avec après chacun le registre, le projet actif,
l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent :
- créations (doublons, translittération, sans lettres, longues, vides) ;
- renommages et descriptions (longues, coupées sur une espace, effacées) ;
- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant
une génération) ;
- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au
dernier projet) ;
- actions inconnues, corps vide ou illisible, erreur de type, méthode
refusée.
`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui
bascule et supprime les discussions comme le vrai. La bascule et la
suppression quand la façade tient la conversation sont vérifiées de bout en
bout. Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes.
@@ -0,0 +1,65 @@
# Lot 19 — export, mode Code et critères de la discussion
## Contexte
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
routes de la discussion ouverte restaient au Go :
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
qui peut avoir un temps de retard sur la conversation vivante ;
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
ses critères ;
- `/api/chat/criteria` : critères édités à la main.
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<id>`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) |
| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:<id>`), sur le fil (`{criteria, ts}`) |
L'export suit `chat_export.go` :
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
les outils en liste (seul l'état final d'un outil compte, comme au
rejeu), la compaction signalée, un export partiel annoncé en tête ;
- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans
mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec
sa taille), journal filtré par les mêmes cases ;
- les derniers échanges comptent à partir des messages de l'utilisateur, pas
des rappels de Loki.
## Restent au Go
Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça
ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent.
## Écarts assumés
- L'export part de la conversation vivante, pas de sa copie enregistrée :
le dernier tour y est, même tout juste fini.
- Les images d'un tour en cours restent dans l'export JSON, élidées. Une
conversation relue par le Go les avait déjà perdues.
## Parité
`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation
riche sous 22 jeux d'options lus dans la requête. La conversation contient :
- pièces jointes, raisonnement en deltas ;
- outils finis, en cours, avec et sans sortie, labels piégés pour le
Markdown ;
- statistiques, compaction ;
- images en base64 et en URL, rappel de Loki ;
- mise à jour de contexte marquée, et une tapée par l'utilisateur.
Plus les exports d'une conversation vide.
Il tire aussi 23 appels aux routes du mode et des critères, avec la base et
les événements du fil après chacun : erreurs JSON, y compris celles d'un
critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères.
`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la
façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des
routes au Go sans la conversation. Une mutation de chaque règle fait échouer
les tests.
@@ -0,0 +1,93 @@
# Lot 20 — serveurs MCP tenus par la façade
## Contexte
Le Go tenait les serveurs MCP (Model Context Protocol) :
- `mcp_config.go` : la configuration, dans `state/mcp` ;
- `mcp_catalog.go` : le catalogue embarqué ;
- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ;
- les routes `/api/mcp*` de `web_api.go`.
Le pool sert deux usages : l'état affiché dans l'interface, et les outils
`mcp__<serveur>__<outil>` annoncés au modèle et exécutés pour l'agent.
Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) |
| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non |
| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport |
| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil |
| `POST /api/mcp/test` | reconnexion forcée |
## Client MCP en Rust
`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go :
- stdio : un processus local, un message par ligne ;
- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE,
session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés.
La session s'ouvre par `initialize` puis `notifications/initialized`. Elle
sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du
serveur reçoit sa réponse.
Le pool (`mcp/mod.rs`) suit `mcp_client.go` :
- une session gardée d'un tour à l'autre ;
- une entrée publiée avant la connexion, pour que deux appelants partagent
la même tentative ;
- une session invalidée pendant sa connexion n'est jamais republiée ;
- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ;
- un appel borné à 120 s, une reconnexion si la session est morte ;
- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur
d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ».
## Le Go
Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de
l'agent, lui demande les outils à annoncer, leurs appels et le
pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`,
`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre
pool reste vide.
L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation
Rust : un seul catalogue pour les deux binaires.
## Écarts assumés
- Les erreurs de transport (connexion refusée, flux coupé) ont les messages
du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du
Go.
- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en
JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans
l'ordre alphabétique.
## Parité
`internal/loki/parity_mcp_test.go` tire du Go :
- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur
d'origine d'un outil, ligne du préambule, délais et leurs messages,
aplatissement de neuf résultats ;
- la lecture de la configuration et seize mutations avec leurs erreurs et la
base après chacune ;
- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP :
onze outils, et le résultat de chacun ;
- ce que le client Go en tire : état de quatre serveurs (joignable,
binaire introuvable, désactivé, mal configuré), outils annoncés (un
masqué), appels.
`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur
resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les
outils et les appels tirés par la façade doivent être ceux du Go, sur une
seule session. Sont aussi vérifiés de bout en bout :
- le transport stdio : environnement, ping du serveur, serveur mort en plein
appel puis reconnexion ;
- les routes de configuration, en-têtes compris ;
- le catalogue ;
- une session invalidée pendant sa connexion, sans processus laissé
derrière.
Une mutation de chaque règle fait échouer les tests. Côté Go,
`TestFacadeMCP` vérifie le relais, les outils, les appels, le
pré-chauffage et une façade injoignable.
@@ -0,0 +1,26 @@
# Lot 21 — mise à jour de Loki tenue par la façade
## Contexte
`/api/update` (version disponible) et `/api/update/apply` (téléchargement et
remplacement du binaire) vivaient dans `sys_update.go`. En conteneur
(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker,
et les deux routes le disent (`containerUpdateNote`) :
- `/api/update` : `{current, available: false, note}` ;
- `/api/update/apply` : 409 `{ok: false, error: note}`.
## Le lot
La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand
`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`.
Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement
et le remplacement du binaire hors conteneur.
La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de
la façade suivent toujours `loki-rs self-update`.
## Parité
`internal/loki/parity_update_test.go` tire du Go les quatre réponses en
conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par
la façade, version du Go comprise.
@@ -0,0 +1,111 @@
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
## Contexte
Le panneau Moteur s'appuie sur une famille de routes du Go :
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
- `backend_prebuilt.go` : binaires précompilés officiels ;
- `web_api.go` : backends custom et leur désinstallation ;
- `web_devices.go` : GPU vus par un moteur.
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
du job est en base (`state/build_job`), ses lignes dans
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
interrompu (`lcRestore`).
## Le lot
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
| `/api/backends`, `/api/backends/custom` | les backends présents |
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
Ces jobs sont :
- la compilation depuis les sources (`install`, `update`, `install-custom`,
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
- la mise à jour du moteur de l'image ;
- Strata.
Côté Go (`facade_backends.go`), quand la façade tient le lot :
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
`lcJobSnapshot` passent par la façade ;
- le garde de l'optimiseur reste au Go, avant le démarrage ;
- le Go ne restaure plus de job au démarrage ;
- les routes reçues par le tunnel sont relayées à la façade.
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
injectables (`backends/probe.rs`) :
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
## Correction du Go
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
Hors CUDA Windows, la version CUDA est vide : le marqueur
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
reconnu, et chaque vérification retéléchargeait la release sous Linux et
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
champ, et la version CUDA ignore ce champ. Le port Rust suit.
## Parité
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
`/opt/rocm` existent sur la machine.
Le journal contient :
- **le statut** dans tous ses états :
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
- origine perdue, BIN dans un fork ;
- précompilé versionné, moteur téléchargé, moteur de l'image ;
- **les backends custom** et chaque refus de désinstallation ;
- **la bascule fast/opt** ;
- **la dernière release** :
- refus HTTP, tag vide, JSON illisible ;
- aucun asset adapté ;
- **le job précompilé** :
- installation, « déjà à jour », ancien format réextrait ;
- optimisation en cours, GitHub qui refuse ;
- archive absente, archive piégée (`../`), archive sans serveur ;
- **les GPU** :
- liaison PCIe, mémoire complétée par nvidia-smi ;
- cache, mémoire nulle non figée, repli sur le disque ;
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
- **le job pas à pas** :
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
- écritures en base limitées à une toutes les 2 s ;
- refus d'un second job ;
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
Autres tests :
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
l'API interne et le service arrêté puis relancé autour de l'installation.
## Écarts assumés
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
- Quand un asset de la release a un champ de mauvais type, le message n'est
pas celui du Go.
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
plus ses lignes dans ce job.
@@ -0,0 +1,85 @@
# Lot 23 — sonde de gabarit portée en Rust
## Contexte
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
modèle chargé fait de ce qu'on lui envoie :
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
préfixe exact du suivant ?
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
Le Go la lance après chaque complétion du fil principal, avec les outils et
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
- le résumé des performances (`/api/perf/summary`).
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
servent le panneau Moteur, autour d'une mise à jour.
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
ne tournait :
- l'écho décidait sans verdict ;
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
## Le lot
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
clé du moteur et les relances sur 503) ;
- les rendus synthétiques et leurs verdicts ;
- le cache par build, modèle, gabarit et forme ;
- le dernier verdict, global et par forme ;
- la ligne de journal ;
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
- le relevé du rendu et sa comparaison.
Avec `LOKI_CHAT=rust` :
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
- **la politique d'écho** lit le dernier verdict ;
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
## Parité
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
Les comportements de moteur couverts :
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
ignorée ;
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
- 503 relancés ou persistants, 429 ;
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
- rendus incohérents, preset externe.
Le journal contient aussi :
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
- le cache : même moteur, gabarit changé, build changé ;
- le relevé du rendu sur trois gabarits, et une erreur ;
- neuf comparaisons de relevés ;
- l'empreinte de six formes.
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
Il vérifie aussi le déclenchement borné.
Autres tests :
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
complétions signalées ;
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
relit le verdict de la façade.
@@ -0,0 +1,130 @@
# Lot 24 — panneau « moteur de l'image » tenu par la façade
## Contexte
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
l'image. Le moteur est pris dans l'image de conteneur officielle
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
servent :
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
suppression ;
- `web_engine_reco.go` : version recommandée, risque de rendu du
raisonnement (défaut de b10763), retour à la version précédente,
vérification du rendu du gabarit avant/après la bascule ;
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
couches, essai à blanc.
La mise à jour est un job, le job des moteurs que la façade tient depuis le
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
de gabarit, portée au lot 23.
## Le lot
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
La sonde de gabarit est celle de la conversation quand la façade la tient
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
le Go (`/internal/v1/service/action`).
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
tunnel.
## Parité
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
rejoué par `tests/engineimg.rs`. Il tourne contre :
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
couches .tar.gz ;
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
Le journal couvre :
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
précédente (absente quand c'est le moteur courant), le relevé du rendu,
sans moteur d'image, sans aucun moteur ;
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
illisible, manifeste refusé ou illisible, aucune image pour
l'architecture) ;
- **le plan** :
- dernière version (avec ou sans numéro) ou recommandée (build trop
ancien, tag figé absent, sans numéro), et déjà à jour ;
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
qui garde la réflexion, build antérieur au défaut, preset externe ;
- **la mise à jour** :
- couches du sommet vers la base ; une couche du dessus masque celle du
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
ignorée ; phase du téléchargement ;
- essai à blanc ;
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
(moteur local arrêté) ;
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
configuration, déjà posée, ou inconnue du moteur ;
- ménage : versions triées par build, dossier sans moteur épargné, version
précédente gardée ;
- rendu comparé avant/après : changé, identique, ou impossible sur le
nouveau moteur ;
- déjà à jour, corps illisible ;
- **les échecs** :
- optimisation en cours, version inconnue, image sans serveur ou sans
version annoncée, manifeste sans couche, runtime au sommet, couche
introuvable, tag illisible ;
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
voit aucune carte ;
- **les bascules** : vers une version installée, vers l'image, vers une
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
- **la suppression** : version (`../` neutralisé), version absente, moteur
utilisé ;
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
l'arborescence de `engine/`.
Autres tests :
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
« non vérifié à temps », le 503 sans backends, les variantes et les
vérifications périmées ;
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
façade, et elles seulement.
## Mutations
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
- la garde « moteur d'image absent » de la source : `same_path` ne
rapproche jamais un chemin vide ;
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
est le nouveau : même résultat ;
- une vérification du rendu périmée qui continue d'attendre son moteur : son
relevé est de toute façon écarté au rangement.
## Écarts assumés
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
Go : le nom Go du type anonyme n'est pas reproduit.
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
@@ -0,0 +1,121 @@
# Lot 25 — Strata tenu par la façade
## Contexte
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
`backend_strata.go` sert trois routes :
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
la recommandation par version et les modèles installés, avec leur
configuration de lancement en clair ;
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
service, la configuration est réappliquée et le moteur relancé ;
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
## Le lot
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
(`rust/loki-rs/src/strata`) sert les trois routes :
- **la décision pure** (`strata/mod.rs`) :
- placement des experts : drop, ram, arena ou disk, selon la RAM
disponible, la carte d'appoint et `experts.bin` ;
- configuration de lancement : réglages validés, carte d'aide, mode des
experts, réglages du preset ;
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
- **la machine** (`strata/api.rs`) :
- cartes via `nvidia-smi` (la sonde des backends) ;
- Python 3.10+ et son module venv ;
- RAM totale et disponible (`/proc/meminfo`) ;
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
- place libre ;
- **l'installation** : le job des moteurs. La sortie de l'installeur est
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
montrée que toutes les 10 s. La bascule finale passe par le Go
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
relance le service.
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
délègue pour un preset Strata).
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
tunnel.
## Correction du Go
`/api/strata/settings` décidait si le preset était en service **après** y
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
correspondait alors plus à la configuration active : changer un réglage du
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
« En service » se décide désormais avant l'écriture, comme dans
`SavePresetApplying`. Le port Rust suit.
## Parité
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
`serviceActionOSFn`. Le journal couvre :
- **la machine** :
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
petite, carte d'aide trop ancienne ;
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
arrondie au dixième) ;
- Python absent, en panne, illisible, trop ancien, sans venv ;
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
présentes ;
- **les réglages** :
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
llama.cpp ;
- hors limites, experts inconnus ;
- preset inactif ; preset actif (appliqué et relancé), et pendant une
optimisation ;
- **l'installation** :
- GET, corps illisible, quant inconnu, optimisation en cours ;
- machine sans carte, modèle qui ne tient pas ;
- release introuvable, moteur altéré (empreinte) ;
- installation complète, puis réinstallation (sources déjà là et
décompressées, moteur téléchargé périmé retéléchargé) ;
- installeur en échec, installeur sans configuration ;
- **les modèles installés** :
- détails de lancement du modèle actif et de modèles inactifs (experts
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
- mode du dernier lancement ;
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
coupé dans le preset ;
- configuration de lancement disparue ou illisible ;
- **après chaque appel** : les actions du service, les clés de la
configuration et l'arborescence de `presets/` et `strata/` (sans le
contenu des archives).
Autres tests :
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
configuration de lancement, placement des experts, recommandation,
réglages, noms ;
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
à la Go ;
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
elles seules.
## Mutations
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
## Écarts assumés
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
message du Go, qui ouvre la source puis la destination.
@@ -0,0 +1,193 @@
# Lot 26 — Tâches planifiées tenues par la façade
## Contexte
Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe
deux sortes :
- **une tâche IA** : une consigne, exécutée par l'agent dans un fil
éphémère ;
- **une tâche « script seul »** : un script du dossier protégé
(`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le
compte-rendu.
Le Go les tient dans plusieurs fichiers :
- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ;
- `tasks_cron.go` : les fréquences ;
- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due
par tic ;
- `tasks_run.go` et `tasks_script.go` : l'exécution ;
- `web_tasks.go` : sept routes.
Les fréquences prennent quatre formes :
- `@every 2h` : intervalle glissant d'au moins une minute ;
- `@every 3d@9:30` : tous les N jours, à une heure murale ;
- `@once 2026-10-01 16:54` : une seule fois ;
- une expression cron à cinq champs.
L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un
fuseau inconnu, c'est l'heure locale du serveur.
## Le lot
Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`)
sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne
démarre plus. Elle exécute elle-même les tâches script :
- le shell de l'agent (`run_shell`), dans le dossier de la discussion
ouverte ;
- une limite de 10 min ;
- un arrêt par `/api/tasks/stop`.
Une tâche IA passe par le verrou de génération et les outils de l'agent, qui
restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`,
que le Go traite comme son propre planificateur l'aurait fait :
- preset épinglé ;
- projet forcé ;
- `RunAutonomous` ;
- état persisté ;
- notification.
Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge
pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage.
Les notifications des tâches script partent par la façade si elle tient les
notifications, sinon par le Go (`/internal/v1/push/send`).
Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte
ce que le Go fait de la zoneinfo du système :
- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les
transitions ;
- `time.Local` (`$TZ`, `/etc/localtime`) ;
- `time.Date`, avec la normalisation des débordements ;
- les heures qui sautent ou qui se répètent, résolues comme le Go.
`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris.
Côté Go :
- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux
routes internes ;
- `web_server.go` : le planificateur n'est pas démarré ;
- `tasksNow` : l'heure des tâches, remplaçable en test.
## Correction de la façade
Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un
outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps
était coupé :
- une page web lente ;
- le navigateur ;
- un serveur MCP tenu par le Go.
Le résultat partait en erreur alors que l'outil continuait. Ces appels sont
désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans
autre limite que 24 h.
## Ce que les mutations ont corrigé dans le port
Le premier passage des mutations a laissé survivre des cas que le journal ne
voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts :
- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en
2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours
qui mène au-delà. Les instants des fréquences passent sur 128 bits ;
- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go
(`time.Duration`), qui rend une date passée. Le port déborde de même ;
- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à
l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis
ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ;
- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un
caractère s'il le faut, et chaque octet orphelin devient U+FFFD à
l'encodage JSON. Le port reculait à une frontière de caractère ;
- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement
un composant `..`.
## Parité
`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué
par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les
identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où
ils apparaissent. Le journal couvre :
- **l'écran** :
- scripts du dossier (sous-dossiers compris) ;
- mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ;
- projets, mémoire coupée, internet armé ;
- la tâche en cours pendant un script ;
- **l'enregistrement** :
- corps illisible, sans nom, sans consigne ;
- script absent, dossier, remontée, chemin absolu, séparateur Windows,
caractère interdit ;
- chaque erreur de fréquence : vide, intervalle trop court, illisible ou
sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en
lettres, plage inversée, pas nul, jamais satisfait, date illisible ou
déjà passée ;
- chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure
qui saute), New York, Calcutta, fuseau inconnu ;
- id inconnu gardé, projet inconnu remplacé par l'actif ;
- modification avec ou sans changement de fréquence ;
- **la bascule, l'interrupteur maître, la suppression** ;
- **les lancements** :
- tâche absente ;
- tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ;
- script en succès, en échec, arrêté à la main ;
- script qui échoue après un succès, puis se rétablit ;
- tâche script rangée sans script, au nom refusé, au script disparu ;
- **le planificateur** :
- tic suspendu, tic pendant une optimisation ;
- une tâche IA due qui attend le modèle ;
- une tâche due par tic, dans l'ordre des noms ;
- la tâche « une fois » qui s'éteint ;
- une tâche sans prochain passage (posé sans la lancer) ;
- une fréquence devenue invalide ;
- deux tâches de même nom (rangées par id).
Après chaque étape, le journal enregistre les tâches en base.
Autres tests :
- fuseaux et fréquences confrontés au Go :
- Paris aux deux changements d'heure, New York, Calcutta ;
- avant la première transition, pile à une transition, la table avant la
règle prolongée ;
- `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ;
- noms refusés, « UTC » sans base ;
- mois hors bornes, années bissextiles, mois de 30 jours ;
- `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et
364 jours) ;
- le compte-rendu tronqué, les noms de script ;
- l'amorçage au démarrage (tâches actives sans prochain passage) ;
- la durée d'un passage, à une horloge qui avance ;
- la tâche IA en cours vue par l'écran ;
- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ;
- le planificateur tourne à son pas sans demande ;
- sans `LOKI_TASKS`, les routes partent au Go ;
- le Go (`TestFacadeTaches`) :
- les sept routes partent à la façade, et elles seules ;
- `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien
d'enregistré) et sur une tâche script ;
- `/internal/v1/push/send`.
## Mutations
79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage
l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux
équivalentes :
- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom
qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour
Windows) ;
- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur
Crawl4AI sans adresse, seul cas où les deux diffèrent.
## Écarts assumés
- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche
enregistrée sans projet à ce moment-là prend, côté façade, le projet actif
rangé.
@@ -0,0 +1,151 @@
# Lot 27 — Banc d'essai tenu par la façade
## Contexte
Le banc d'essai mesure le moteur local tel que le chat s'en sert, sur
`/v1/chat/completions`, avec le gabarit, le raisonnement et
l'échantillonnage du preset (`llm_bench.go`, `llm_bench_job.go`). Il a deux
modes :
- **rapide** : un préchauffage jeté, puis la ligne courte (~2000 jetons de
prompt, ~300 générés) ;
- **complet** : la ligne courte, puis une discussion à profondeur réelle —
un prefill à froid de D jetons, et trois tours qui y ajoutent chacun ~2048
jetons neufs. La reprise du cache est lue dans la réponse, jamais
supposée.
Il tourne en tâche de fond derrière quatre routes :
- `POST /api/bench` lance (202 et un id) ;
- `GET /api/bench/status` dit la phase, puis le résultat ou l'erreur ;
- `POST /api/bench/cancel` annule ;
- `GET /api/bench/last` rend la dernière mesure enregistrée.
Pendant la mesure, le bench tient le verrou de génération de la
conversation. Un message, une tâche planifiée ou un client `/v1` reçoivent
le refus du bench. Seule une mesure complète est enregistrée : la
dernière, et celle du preset actif.
## Le lot
Avec `LOKI_BENCH=rust` (expérimental), la façade (`rust/loki-rs/src/bench`)
sert les quatre routes et déroule le protocole contre le moteur :
- `bench/mod.rs` : le protocole, porté du Go à l'octet près. On y trouve :
- les mêmes corpus : la prose du Go et l'interface web embarquée, de la
même source ;
- les mêmes requêtes ;
- le même calcul de profondeur, de reprise du cache et de débit ;
- les mêmes messages d'erreur ;
- `bench/job.rs` : le job, les routes et l'enregistrement (`last_bench`,
`bench_presets`, avec l'empreinte de configuration du Go).
Le verrou de génération reste celui de la conversation du Go. La façade le
prend par le **bail du moteur**, avec le motif `bench` :
- le Go note alors `benching` et donne aux autres le refus du benchmark ;
- changer de discussion ne rompt pas le bail ;
- `benchRunning()` le voit aussi : les clients `/v1`, le préchauffage et
l'optimiseur attendent ;
- un stop du chat côté Go (signalé au renouvellement), un Reset ou un bail
perdu annulent le bench.
Le Go prête aussi à la façade :
- **le travail annexe** (`/internal/v1/engine/sidejob`) : à la fin, le slot
du bench est effacé si c'est sans risque, comme pour un bench du Go ;
- **la télémétrie** (`/internal/v1/bench/perf`) : la trace du bench dans
`perf_log`.
Le port, la clé de l'API locale et le second slot (`SIDE_SLOT`) viennent de
`/internal/v1/oai/route`. Les niveaux de raisonnement appris sont ceux de la
conversation de la façade si elle la tient, sinon ceux du bench.
La ligne de commande (`loki bench`) reste au Go.
## Correction des tests du Go
Trois tests du préchauffage échouaient dix minutes par jour, avant minuit :
le préchauffage se tait alors (`prewarmNearMidnight`), et ils lisaient
l'heure réelle. L'heure de ce garde-fou devient remplaçable (`prewarmNow`),
et les tests la fixent à midi.
## Parité
`internal/loki/parity_bench_test.go` (Linux) tire du Go un journal rejoué
par `tests/bench.rs` contre le même faux moteur (`fakeBenchEngine`, porté).
Chaque requête reçue par le moteur est comparée ; les longs textes (corpus)
sont réduits à leur taille et à leur empreinte. Le journal couvre :
- **les routes** : statut et dernière mesure sans bench, GET refusés,
annulation sans bench ;
- **les refus au lancement** : preset externe, optimisation en cours, modèle
pas prêt, génération en cours, second lancement pendant un bench ;
- **les échecs dans le job** : moteur occupé par un autre (`/slots`), preset
Strata ;
- **les mesures** :
- mode rapide, puis mode complet (profondeur, trois tours, reprise du
cache, brouillon) ;
- contexte trop petit (profondeur sautée), contexte inconnu du moteur
(celui du preset) ;
- paramètres bornés, petits paramètres, corps illisible ;
- **les échecs d'une mesure** :
- préchauffage, ligne courte, prefill à froid ou tour en profondeur en
erreur ;
- réponse sans timings ;
- **le raisonnement** : niveau refusé par le gabarit puis rejoué, puis le
niveau appris envoyé d'emblée ;
- **budget et annulation** : budget d'une requête dépassé (profondeur
partielle, non enregistrée), annulation pendant la profondeur ;
- **après chaque étape** : la dernière mesure et les mesures par preset.
Autres tests :
- ceux du Go (`llm_bench_test.go`), portés (`bench/tests.rs`) : profondeur,
types de cache, requête, morceaux du corpus, reprise du cache, relectures
du disque, empreinte de l'enregistrement ;
- la réponse du moteur (timings, cache, brouillon, nombres illisibles),
`Duration.String` du Go, les flottants écrits à la Go ;
- la façade : le bail « bench », le travail annexe (slot servi ou non), la
trace, la clé de l'API locale, le second slot, le stop du chat et le bail
perdu, les routes sans la façade ;
- le Go (`TestFacadeBench`) :
- les quatre routes partent à la façade, et elles seules ;
- le bail « bench » : refus, bascule de discussion, libération,
expiration ;
- le travail annexe et la trace.
## Mutations
84 mutations du port Rust, dont 81 tuées. Les survivantes du premier passage
l'ont été par les tests ajoutés :
- `bench/tests.rs` : la tranche pile à la fin du corpus, un tour sans
attendu à côté d'un tour mesuré, les débits nuls rendus par le moteur
(recalculés), le niveau de raisonnement rejoué une seule fois, le
préchauffage et la ligne courte d'un `run` rapide (paramètres par défaut,
64 jetons de préchauffage au moins, code ajouté juste au-delà de la
prose), la profondeur arrêtée (contexte plein, corpus épuisé, réponse du
tour précédent reprise, note de reprise selon le modèle hybride) ;
- `tests/bench.rs` : un second lancement pendant la prise du bail, le preset
relu au départ du job (devenu externe, ou optimiseur lancé entre-temps),
la mesure enregistrée sous le preset ACTIF ;
- le journal : un prompt juste au-delà de la prose.
Restent trois équivalentes :
- l'annulation vérifiée avant d'envoyer : déjà annulé, le `select!` rend
l'annulation à son premier tour, avant que la requête ne parte ;
- la progression d'un job remplacé : un seul bench à la fois, un job fini ne
rapporte plus rien ;
- le PID nul du moteur : `/proc/0` n'existe pas, la lecture échoue de toute
façon.
## Écarts assumés
- Une erreur réseau vers le moteur garde le texte de `reqwest` après le
préfixe du Go (`Post "url": …`).
- Un niveau de raisonnement appris par le bench de la façade n'est pas
transmis au chat du Go, quand le Go tient la conversation (et
inversement) : la première requête paie le refus, puis la traduction est
retenue.
@@ -0,0 +1,93 @@
# Lot 7 — outils de l'agent exécutés par la façade
## Contexte
Lot 6 : quand la façade tient la conversation (`LOKI_CHAT=rust`), la boucle du
tour est en Rust mais chaque appel d'outil repart au Go
(`/internal/v1/tool/exec`). Lot 7 : porter les outils eux-mêmes, en retirant
les appels au Go un par un. Rien ne change sans `LOKI_CHAT=rust` : la boucle
Go exécute ses outils comme avant.
## Étape 1 — fichiers et shell (fait)
Exécutés par la façade (`rust/loki-rs/src/tools/`) : `read`, `grep`, `glob`,
`write`, `edit`, `bash`, `ask`, avec les mêmes garde-fous dans le même ordre
(commande catastrophique, `&` final en mode Code, bac à sable du mode Code,
dossier mémoire interdit, « lu avant d'écrire »), le même diff pour l'UI et le
même nettoyage de la sortie du shell (`| tail -N`, ANSI, fin bornée).
L'état partagé reste au Go, en un seul exemplaire, derrière trois routes
internes (`web_internal_toolstate.go`) :
| Route | Rôle |
|---|---|
| `POST /internal/v1/tool/context` | dossier de travail (`agentCwd`), dossier mémoire, cible distante |
| `POST /internal/v1/tool/tracker` | `read` : lecture notée ; `check` : refus éventuel avant écriture |
| `POST /internal/v1/tool/wrote` | après une écriture : note du suivi, bloc des diagnostics LSP |
Le suivi reste au Go parce que les sous-agents et les tâches planifiées, qui y
tournent, le lisent et l'écrivent aussi ; les serveurs LSP y vivent.
Repli sur le Go : outil non porté ; `write`/`edit`/`bash` quand l'agent vise
un poste distant (une commande catastrophique est refusée avant, ici) ;
contexte indisponible.
Le jeton de l'API interne est retiré de l'environnement du shell de l'agent.
### Parité
`internal/loki/parity_tools_test.go` passe 58 appels à `execTool` sur une
arborescence de test et range le journal (libellés, résultats, diffs,
questions, arborescence finale) dans `rust/loki-rs/tests/parity_tools/` ;
`rust/loki-rs/tests/tools.rs` rejoue les mêmes appels dans `exec_local` et
retrouve le même journal. Le test Go échoue si le comportement Go change
(`LOKI_PARITY_UPDATE=1` pour régénérer, puis le test Rust dit si le port suit).
### Écarts assumés
- Motif `grep` invalide : le message d'erreur est celui du moteur d'expressions
de Rust (crate `regex`), pas celui de Go.
- Classes `\w`, `\b`, `\s`, `\d` d'un motif `grep` : Unicode en Rust, ASCII en
Go (un mot accentué y est un mot entier).
- Octets hors UTF-8 dans un fichier lu ou cherché : remplacés par U+FFFD par
séquence invalide (Go : par octet, à l'encodage JSON). `edit` travaille sur
les octets et laisse intact ce qu'il ne remplace pas.
- `mkdir` en échec : le chemin cité est le dossier parent visé, pas le
composant qui a échoué.
## Étape 2 — boucle du contrat du mode Code (fait)
`codeVerifyLoop` porté (`rust/loki-rs/src/chat/verify.rs`) : à la fin d'un
tour de build en mode Code, relances du builder sur les critères encore
ouverts, passe de vérification sur un contexte isolé (`POST
/internal/v1/turn/verifier` : prompt du rôle et faits de la machine, outils
fermés, rôle `verifier` qui seul peut marquer `passed`), corrections, abandon
dit dans le fil après deux corrections. Mêmes deltas que le Go (`role`,
`verify_done`, mesures `ctx_isolated`). Les critères sont lus en base
(`chat/crit:<id>`) ; l'outil `criteria` reste au Go. Toute compaction en mode
Code rend leur état au résumé, comme `compactMessages`.
Écart assumé : l'isolation du slot du moteur après la vérification
(`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la
façade.
## Étape 3 — git (fait)
`git_status`, `git_diff`, `git_clone` (`rust/loki-rs/src/tools/git.rs`) :
dépôt deviné comme le Go (sous-dossier demandé, racine, unique sous-dossier
dépôt), sorties mêlées et bornées, jamais d'invite, clone limité à https:// et
git@hôte: et rangé dans le dossier de la discussion. Toujours sur cette
machine, cible distante ou non (comme le Go). Journal de référence
`tests/parity_tools/git.json` (`TestPariteOutilsGit`, deux dépôts, aucun appel
réseau), rejoué par `tests/tools_git.rs`.
## Étapes suivantes
1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : le
cache de pages et la conversion HTML → Markdown (bibliothèques Go) ne se
reproduisent pas à l'octet ; à porter avec un journal de référence
structurel plutôt qu'exact.
2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) : ses travaux sont lus par
l'optimiseur et la suppression d'une discussion, au Go.
3. Mémoire (`mem_*`, `recall*`, `tracker`) : avec le portage du chiffrement.
4. Tâches, navigateur (CDP), MCP, sous-agents, critères.
@@ -0,0 +1,101 @@
# Lot 8 — routes des modèles tenues par la façade
## Contexte
Les modèles `.gguf` vivaient dans le Go :
- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ;
- les dossiers de modèles (`backend_model_dirs.go`) ;
- la suppression (`deleteModelFile`) ;
- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ;
- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`).
Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ;
sans la variable rien ne change, tout part au Go).
## Ce que sert la façade
| Route | Module | Rôle |
|---|---|---|
| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) |
| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus |
| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait |
| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` |
| `POST /api/models/download` | `downloads.rs` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes |
| `GET /api/models/download/status` | `downloads.rs` | état de chaque téléchargement de ce lancement |
| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé |
| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint |
| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) |
| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair |
Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre
autres :
- téléchargement :
- lien de page Hugging Face (`/blob/`) converti en lien direct ;
- modèle en tranches rapatrié en entier ;
- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de
refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ;
- plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une
connexion coupée ;
- jeton envoyé aux seuls hôtes Hugging Face ;
- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un
dépôt), cache vidé à l'enregistrement d'un jeton ;
- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait
plus, il effacerait un téléchargement qu'elle mène.
## Côté Go (`facade_models.go`)
Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par
la façade : `withFacadeChat` lui remet ces routes, authentifiées par le
jeton interne, comme celles du chat. Façade injoignable : 502 « modèles
indisponibles : … ».
La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance.
## Écarts assumés
- Une source qui ignore les plages n'est plus lue en entier pendant la sonde.
- Erreurs de transport (réseau, TLS) au format de reqwest.
- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie
jamais).
- Hôte d'un lien collé en majuscules converti comme les autres.
- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est
livrée que pour Linux).
- `/api/models/download/status` trié par date de début.
- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même
valeur JSON.
## Parité
Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour
régénérer) :
- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` →
`tests/downloads.rs` : lien normalisé, famille de tranches,
`Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier
de destination ;
- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` →
`tests/models.rs` : quantification déduite du nom, découpage
d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du
projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels
(liste, dossiers, suppressions avec et sans `force`, modèle en service,
tranches, chemins refusés) rejoué à l'identique, fichiers restants et
dossiers enregistrés compris.
Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré)
font échouer les rejeux.
De bout en bout :
- téléchargements contre un faux Hugging Face : trois connexions sur des
plages disjointes, source sans plages, tranches dont une présente, dépôt
verrouillé, panne pendant la deuxième tranche, annulation et 409 ;
- catalogue contre un faux Hugging Face : recherche, cache, taille LFS,
dépôt verrouillé ou absent, jeton refusé ou accepté ;
- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go
écrite), et laissées au Go sans la variable.
## Reste
- Essai réel sur une installation avant d'en faire le défaut.
@@ -0,0 +1,96 @@
# Lot 9 — dictée vocale tenue par la façade
## Contexte
La dictée vocale vivait dans le Go :
- `dictate_config.go` : réglages ;
- `dictate_models.go` : catalogue Parakeet, téléchargement et extraction ;
- `dictate_server.go` : supervision de `sherpa-onnx-offline-websocket-server`
et protocole WebSocket ;
- `web_transcribe.go` : routes.
Lot 9 : la façade la tient quand `LOKI_DICTATE=rust` (expérimental ; sans la
variable rien ne change).
## Ce que sert la façade (`rust/loki-rs/src/dictate.rs`)
| Route | Rôle |
|---|---|
| `POST /api/transcribe` | un WAV (≥ 1000 octets, ≤ 32 Mio) entre, le texte sort ; modèle absent : 503 `{downloading, pct}` et téléchargement lancé (relancé après un échec, qui est dit) |
| `GET\|POST /api/dictate/config` | modèle (validé contre le catalogue) et réactivité ; enregistrer arrête le serveur, qui repart au prochain mot avec le nouveau modèle |
| `GET /api/dictate/models` | catalogue trié par taille, présence des quatre fichiers |
| `GET\|POST /api/dictate/models/download` | un téléchargement à la fois, progression sur les octets reçus |
| `GET /api/dictate/state` | serveur allumé (et depuis quand), modèle, présence, téléchargement |
| `POST /internal/facade/v1/dictate/stop` | (le Go, jeton interne) arrêt sans attendre derrière un démarrage |
Même mécanique que le Go :
**Archive `.tar.bz2`**
- Décompressée au fil du téléchargement, dans un dossier `.part` renommé à la fin.
- Entrées absolues ou qui remontent refusées, liens ignorés.
**Serveur de transcription**
- Lancé à la demande sur un port libre de la boucle locale, GPU masqués, sans
le jeton interne dans son environnement.
- Prêt dès que sa socket répond.
- Une mort au démarrage est rapportée avec sa cause et la dernière ligne du
journal ; un processus qui ne répond pas fait échouer le démarrage au bout
de 2 minutes.
- Éteint après dix minutes d'inactivité.
- `shutdown_fast` tue le processus qui démarre au lieu d'attendre le verrou.
**Protocole**
- En-tête de 8 octets (fréquence, nombre d'octets), échantillons float32 en
trames de 10 Kio, une trame texte en retour.
- Annotations (`[musique]`, `(rires)`) écartées.
## Côté Go (`facade_dictate.go`)
- `withFacadeChat` remet les routes du tunnel et de la redistribution E2E à la
façade. Si elle ne répond pas : 502 « dictée indisponible : … ».
- `asrShutdownVite` passe par la façade quand elle tient la dictée : son
serveur n'est pas un enfant de ce process. Cela couvre la libération de la
VRAM et l'arrêt du moteur principal par l'optimiseur.
## Écarts assumés
- Messages d'erreur de transport (WebSocket, HTTP) et de décompression au
format des crates Rust.
- Fin de connexion sans réponse : « lecture du résultat : connexion fermée ».
## Parité
`internal/loki/parity_dictate_test.go` tire du Go ce journal :
- treize WAV : mono, bloc `LIST` intercalé, stéréo moyenné, flottant 32 bits,
données tronquées, impaires, vides, et les refus ;
- les chemins d'archive ;
- les marqueurs ;
- les réglages par défaut ;
- les arguments du serveur ;
- les adresses des modèles ;
- la présence ;
- treize appels de route.
Le tout est rangé dans `tests/parity_dictate/dictate.json` et rejoué par
`tests/dictate.rs`, les routes par la façade. Une mutation (premier canal au
lieu de la moyenne) fait échouer le rejeu.
`tests/dictate.rs` couvre aussi :
- le protocole contre un faux serveur WebSocket (en-tête, découpage
10240 + 10240 + 3528, marqueurs, réponse illisible, serveur injoignable) ;
- l'extraction (lien ignoré, entrée qui remonte refusée sans rien laisser,
archive corrompue) ;
- le téléchargement depuis un faux serveur (100 %, HTTP 404, modèle inconnu,
un seul à la fois) ;
- la supervision d'un faux sherpa-onnx (script Python qui parle le
protocole) :
- transcription de bout en bout par la façade ;
- même serveur réutilisé ;
- arrêt demandé par le Go, et refusé sans le jeton ;
- réglage changé ;
- les démarrages ratés : sortie en code 3 avec sa dernière ligne, serveur
muet, arrêt « vite » qui tue le démarrage, binaire introuvable, modèle
absent.
@@ -0,0 +1,263 @@
# Lot 28 — Optimiseur tenu par la façade
## Contexte
L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur
plus rapides **sans rien changer à ce que le modèle calcule** : même
contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il
procède ainsi :
1. Il arrête le vrai moteur.
2. Il lance chaque essai sur une COPIE de la configuration :
- posée dans `LOKI_HOME/tune/run/<essai>/config.env`, jamais
`config.env` ;
- composée par « loki serve » à blanc ;
- puis lancée sur 127.0.0.1 et un port libre.
3. Il mesure chaque essai par le bench complet, à une profondeur fixe.
4. Il garde ce qui gagne au-delà du bruit.
5. Il relance le vrai moteur.
Rien n'est écrit dans un preset sans un geste de l'utilisateur : dans une
copie, ou dans le preset lui-même — moteur redémarré, sondé, et ancienne
version rétablie si la sonde échoue.
Cinq routes :
- `POST /api/tune` lance (202) ;
- `GET /api/tune/status` dit la phase, les essais, l'ETA, puis le résultat ;
- `POST /api/tune/cancel` annule ;
- `POST /api/tune/apply` écrit le résultat, sur un clic (`copy` ou
`preset`) ;
- `GET /api/tune/last` rend le dernier résultat d'un preset, et si le preset
ou le moteur ont changé depuis.
Le moteur, pendant une optimisation, appartient aux essais. Le verrou est un
**fichier** de `LOKI_HOME` (`tune.lock`), créé de façon exclusive, que tout
le monde lit :
- le Go, dans tous ses refus (`tuneGuard`) ;
- « loki tune » en ligne de commande ;
- désormais la façade.
## Le lot
Avec `LOKI_TUNE=rust` (expérimental, Unix seulement), la façade
(`rust/loki-rs/src/tune`) sert les cinq routes et déroule l'optimisation :
- `tune/core.rs` : la partie pure, portée à l'octet près :
- découpe et réécriture d'`EXTRA_ARGS` ;
- réglages par clé ou par drapeau ;
- essais de chaque étape selon la machine et les cases cochées ;
- garde-fous : cœur de la ligne, dénature, clés permises ;
- diff et réécriture du preset ;
- journal d'un essai ;
- score et départage ;
- `tune/lock.rs` : le verrou, **au format du Go** :
- contenu du fichier, clé de prise, binaires comparés par leur nom ;
- identité d'un processus comme `procIdentity` l'écrit : champ 22 de
`/proc/<pid>/stat`, préfixé de l'identifiant du démarrage ; `ps` ailleurs ;
- écriture d'un bloc ;
- rendu neutre avant retrait ;
- ménage par déplacement-relecture ;
- `tune/job.rs` : les routes, le job, les essais et l'application.
**La reprise reste au Go.** Sa reprise (`tuneRecoverWatch`, toutes les
30 s) lit le verrou de la façade et la reconnaît vivante, par la même
identité. Si la façade meurt en plein essai, le Go écarte son verrou comme
celui d'un « loki tune » tué :
- l'essai orphelin est arrêté (identité complète vérifiée) ;
- une application interrompue est défaite ;
- le moteur est relancé s'il tournait.
La façade fait le même ménage quand elle trouve un verrou périmé en prenant
le sien.
**Les essais restent des « loki serve » du Go**, lancés par la façade avec
`LOKI_TUNE_TRIAL` (et `LOKI_TUNE_DRYRUN` à blanc), dans leur propre groupe de
processus. Le groupe est arrêté en sortie, quoi qu'il arrive. L'essai est
noté dans le verrou avant de charger quoi que ce soit.
Le verrou de génération reste celui de la conversation du Go. La façade le
prend par le **bail du moteur**, avec le motif `tune` :
- le Go note alors `benching` et donne aux autres le refus de l'optimiseur ;
- changer de discussion ne rompt pas le bail ;
- un stop du chat, un Reset ou un bail perdu annulent l'optimisation.
Le Go prête à la façade ce qu'il est seul à voir
(`/internal/v1/tune/context`) :
- **le refus du processus web** : un benchmark (le sien ou celui de la
façade), une génération, des jobs d'arrière-plan du mode code ;
- **l'état du vrai moteur** (`engineNeedsStop`) ;
- **la télémétrie**, d'où viennent les poids d'un tour type.
Il prête aussi le démarrage et l'arrêt du moteur **sans le refus de
l'optimiseur** (`/internal/v1/service/action`, `os`), puisque c'est la
façade qui tient le verrou. Si la façade pilote elle-même le moteur
(`LOKI_ENGINE=rust`), elle passe par son superviseur.
Le preset est réécrit et appliqué par les fonctions des presets
(`save_applying`, mode `apply` : la configuration, sans redémarrage). Le
redémarrage, la sonde et le retour arrière sont ceux de l'optimiseur.
La ligne de commande (`loki tune`) reste au Go.
Hors Unix, la façade ne tient pas l'optimiseur. Elle passe alors
`LOKI_TUNE` vide au Go, qui le garde et ne relaie rien.
## Parité
`internal/loki/parity_tune_test.go` tire du Go un journal
(`tests/parity_tune/core.json`) rejoué par `tests/tune_core.rs`. Il couvre :
- toute la partie pure ;
- le **verrou** : contenu du fichier octet pour octet, clé de prise,
comparaison des binaires ;
- le **résultat** tel qu'enregistré et montré (listes vides écrites `null`
comme une tranche nil du Go) ;
- la vérification **après application** (la configuration est-elle l'essai
retenu ?).
`tests/tune.rs` déroule de vraies optimisations, essais compris : le binaire
de test joue « loki serve ».
- À blanc, il écrit la ligne de commande de l'essai.
- Pour de vrai, il sert un faux llama-server dont le prefill est d'autant
plus rapide que le micro-lot est petit.
Les tests couvrent :
- **une optimisation entière** :
- bail « tune » et verrou au nom de la façade ;
- vrai moteur arrêté puis relancé sans le refus de l'optimiseur, dictée
coupée ;
- quatre essais composés, lancés, mesurés, l'un retenu ;
- copies de configuration sur une adresse privée ;
- résultat enregistré par preset et rendu par `/api/tune/last` ;
- **l'application** :
- dans une copie (rien d'autre ne bouge) ;
- dans le preset (réécrit, appliqué, moteur redémarré et sondé) ;
- refusée sur un preset changé depuis la mesure ;
- **une sonde qui échoue** (contexte perdu) : preset et configuration
rétablis, moteur redémarré dessus, sauvegarde faite avant d'écrire ;
- **les essais refusés** : à la composition, au chargement (mémoire
insuffisante) ; la référence qui ne se compose pas ; la relance qui
échoue, dite dans l'erreur ;
- **l'annulation**, par la route et par le stop du chat ;
- **les refus d'entrée**, avec les messages du Go :
- preset externe, Strata, aucun preset actif ;
- jobs du mode code, moteur occupé, génération en cours ;
- optimisation d'un autre processus (et `elsewhere` dans le statut) ;
- **un verrou périmé** :
- l'essai orphelin arrêté seulement si son identité concorde ;
- une application interrompue défaite ;
- les routes sans la façade.
Les tests du verrou du Go sont portés (`tune/tests.rs`) :
- prise, refus, essai noté, rendu ;
- propriétaire mort ;
- verrou à notre PID qui n'est pas l'un des nôtres ;
- illisible neuf ou vieux ;
- ménage jamais sur un verrou vivant ;
- binaire remplacé ;
- identité d'un processus ;
- arrêt du groupe.
Côté Go, `TestFacadeTune` vérifie :
- les cinq routes partent à la façade, et elles seules ;
- le bail « tune » : refus, bascule de discussion, libération ;
- le contexte : au repos, pendant l'optimisation, pendant un bench ;
- le démarrage et l'arrêt sans le refus de l'optimiseur.
## Mutations
105 mutations du port Rust (verrou et job). Le premier passage en a tué 39.
Les survivantes l'ont été ensuite par les tests ajoutés :
- `tune/tests.rs` :
- un verrou sans PID ;
- l'identité d'un processus (démarrage noté, binaire admis) ;
- l'application planifiée : clé sur option refusée sans l'option, valeur
vide qui retire la clé ;
- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ;
- `tests/tune.rs` :
- **une optimisation entière** :
- l'essai noté dans le verrou pendant la mesure ;
- l'application refusée pendant la mesure ;
- la progression, les confirmations, le gain ;
- aucun changement de moteur signalé quand son build est inconnu ;
- la cible inconnue ;
- **l'application gardée** :
- preset qui n'est plus celui en service, API externe ;
- clé changée ailleurs pendant l'application (défaite) ;
- sauvegarde notée dans le verrou avant d'écrire ;
- **les essais** :
- écartés (ligne dénaturée, doublon, contexte perdu) ;
- muets (délai de chargement) ;
- **la mesure** :
- VRAM mesurée par un faux nvidia-smi ;
- budget atteint, profondeur commune ;
- notes et poids tirés de la télémétrie ;
- écarts de mesure (sous le seuil, nul, decode plus lent) ;
- **le moteur** :
- `/props` sans `default_generation_settings` ;
- SIDE_SLOT sur un seul slot ;
- threads comptés sur les cœurs de la sonde ;
- piloté par la façade (LOKI_ENGINE=rust) ;
- **les refus** :
- un second lancement pendant la prise du bail ;
- une application refusée sans journal ;
- hors phase d'application, un verrou périmé n'est pas défait.
Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))`
garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle,
et tuée.
Restent vingt équivalentes, ou hors de portée d'un test :
- **le verrou** :
- le fichier retiré quand son écriture échoue après sa création (disque
plein), que les tests ne provoquent pas ;
- deux re-vérifications du ménage, contre un propriétaire qui revient
entre deux lectures : courses seulement ;
- la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le
retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte
que si le retrait échoue ;
- **le suivi** :
- le résultat montré hors mesure : il est effacé au départ d'une mesure ;
- l'annulation n'est posée que pendant une mesure ;
- la borne de 6 h du budget, la durée d'un doublon, les étapes restantes :
l'estimation seulement ;
- l'annulation vérifiée entre deux essais et à la fin : l'essai en cours
l'a déjà dite (course) ;
- **la mesure** :
- VRAM nulle et nvidia-smi absent vont ensemble ;
- les candidats générés passent toujours `cfg_check` (garde contre une
génération fautive) ;
- la fin d'un essai constatée avant d'attendre : un réveil perdu, quand
l'essai meurt entre deux attentes (course) ;
- l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le
verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant
tout arrêt ;
- **l'application** :
- une valeur vide gardée dans la configuration gagnante : `cfg_check` et la
réécriture la traitent en clé absente ;
- la configuration active comparée à celle de la mesure : les empreintes
du preset et de la configuration sont prises ensemble, sur le preset dont
l'empreinte est celle de la configuration ; les deux contrôles qui
précèdent (preset inchangé, preset en service) l'impliquent ;
- le preset « non appliqué » : la configuration a changé entre le contrôle
et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre
message.
## Écarts assumés
- Une sortie à blanc qui échoue est lue dans un fichier où vont les deux
sorties (comme `CombinedOutput`), mais une erreur de lecture de
`launch.json` ou d'une requête garde le texte de Rust.
- Un niveau de raisonnement appris pendant les essais n'est pas transmis au
chat du Go quand le Go tient la conversation.
@@ -0,0 +1,82 @@
# Lot 29 — Télémétrie des complétions de la façade
## Contexte
Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`).
Pour chacune, il note :
- combien du prompt venait du cache, combien a été recalculé ;
- à quelle vitesse ;
- ce que le décodage spéculatif a gardé.
Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte
de cache** d'une complétion : ce que le cache aurait dû reprendre de la
précédente complétion de la même discussion, et n'a pas repris. Il nomme ce
qui s'est intercalé entre les deux, et signale la perte à l'interface
au-delà d'un seuil tiré du preset.
Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions
n'apparaissaient nulle part. Le résumé des performances ignorait les tours ;
l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne
recevait jamais la perte de cache.
## Le lot
Les complétions de la façade rejoignent l'anneau du Go, par un seul point
d'entrée, `/internal/v1/turn/perf`. Il est arrivé sur `main` en même temps que
ce lot (« Conversation tenue par Rust : télémétrie et requêtes en vol comme
le Go »), et la fusion n'en garde qu'un.
- `TurnHost::perf_record` range une complétion. La boucle du tour l'appelle :
- après chaque complétion lue, complète ou non, numérotée dans le tour
(`perfIter`) ;
- avec les empreintes seulement si elle est complète (`perf_prefix`,
FNV-1a cumulé des messages envoyés) ;
- pour une complétion coupée par un stop, rangée incomplète.
La dernière statistique d'une complétion complète porte ce que le Go en
dit : la nature, la perte de cache, ce qui s'est intercalé et l'alerte.
- La nature, posée par l'hôte :
- `main` pour un tour et pour les corrections du builder du mode Code ;
- `verify` pour la passe de vérification isolée ;
- `compact` pour un résumé de compaction, rangé dès que sa réponse se lit,
sans empreintes.
- `chat/perf.rs` porte ce que la façade relève elle-même : la réponse entière
d'un résumé (`perfRecFromWire`, sans échec possible, avec les mêmes
tolérances que `perfNum`), traduite en statistiques d'une complétion.
- Le Go range le relevé comme les siens (`perfRecFromStats`) : numéro,
instant, perte de cache, ce qui s'est intercalé. Il calcule l'alerte au
seuil du preset actif (`perfLostAlertAt`), jamais pour un preset externe.
- La sonde du gabarit n'en part pas : la façade la tient (lot 23) et la lance
elle-même après une complétion complète de forme normale. Le Go lui demande
son verdict (`facadeTplCaps`).
`/api/perf/summary` reste au Go, qui tient l'anneau.
## Tests
- `chat/perf.rs` :
- relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes,
octet parasite, cache d'OpenAI, timings d'un autre type ;
- empreintes cumulées, FNV-1a de référence.
- La boucle du tour :
- deux complétions numérotées, totaux, empreintes qui se prolongent ;
- dernière statistique avec la nature et la perte vues par le Go ;
- complétion coupée rangée incomplète, sans statistique finale.
- Le vrai hôte (`tests/lot6.rs`) :
- complétions d'un tour rangées par le Go, rattachées à la discussion ;
- résumés de compaction (rangés si la réponse se lit, pas sinon).
- Le Go (`web_internal_turnperf_test.go`) :
- complétion rangée dans l'anneau ;
- perte et ce qui s'est intercalé, alerte ;
- preset externe sans alerte, complétion incomplète sans empreintes ;
- nature d'un résumé gardée, un tour par défaut ;
- corps illisible, GET refusé.
## Écarts assumés
- Les empreintes sont calculées sur le JSON des messages tel que Rust
l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre
complétions de la même discussion et de la même nature, toutes produites
par la façade.
- Une complétion coupée par un stop est rangée sans ses compteurs partiels.
@@ -0,0 +1,54 @@
# Lot 30 — Motifs de relance personnalisés tenus par la façade
## Contexte
Quand le modèle écrit un appel d'outil en toutes lettres au lieu de
l'émettre par le protocole, Loki le reconnaît et relance le tour avec une
consigne corrective. Les motifs embarqués couvrent les formats connus. Un
expert peut les remplacer par la clé d'état `retry_patterns` :
- c'est un tableau JSON de regex du Go, chacune compilée en `(?mi)` ;
- une regex illisible est sautée ;
- une surcharge illisible ou vide laisse les motifs embarqués.
Jusqu'ici, une surcharge posée gardait la conversation au Go
(`facadeChatBlocker`), même avec `LOKI_CHAT=rust`.
## Le lot
La façade lit la surcharge au démarrage de sa conversation
(`chat::generate::retry_patterns`), comme le Go la compile une fois par
processus. Le Go ne garde plus la conversation pour cette clé.
Les regex sont celles du Go (paquet `regexp`), pas celles de `regex`.
`chat::stream::go_regex` les réécrit pour garder le sens du Go :
- les classes Perl et les limites de mot du Go sont ASCII ; celles de
`regex` sont Unicode :
- hors d'une classe : `\w`, `\W`, `\d`, `\D`, `\s`, `\S` deviennent des
classes ASCII ;
- dans une classe : leur équivalent dans la classe ;
- `\b`, `\B` deviennent `(?-u:\b)`, `(?-u:\B)` ;
- `\Q…\E` (texte littéral) devient le texte échappé ; le Go le refuse dans
une classe, la traduction aussi ;
- dans une classe, `[`, `&&`, `~~` sont littéraux pour le Go, et `--` est un
intervalle : ils sont échappés ;
- l'octal du Go (`\101`) est permis (`octal(true)`). Une référence arrière
(`\1`), que le Go refuse, est refusée.
## Parité
`internal/loki/parity_retry_test.go` compile côté Go une soixantaine de
regex (dont celles qu'il refuse). Il note leur première correspondance sur
onze textes : accents, chiffres non ASCII, espaces insécables, crochets,
majuscules accentuées. Le journal (`tests/parity_retry/patterns.json`) est
rejoué par `tests/retry.rs` : même compilation, même correspondance, même
position.
Autres tests :
- la lecture de la surcharge : absente, `null`, vide, mal typée, suivie de
texte ;
- les regex sautées, les correspondances ;
- la surcharge lue au démarrage de la conversation de la façade ;
- côté Go, la clé ne garde plus la conversation.
@@ -0,0 +1,88 @@
# Lot 31 — Slots du moteur pour le tour tenu par la façade
## Contexte
Trois clés opt-in du moteur local gardaient la conversation au Go
(`facadeChatBlocker`), faute d'être honorées par la façade :
- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes.
Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la
discussion (étape d'un tour, résumé en continuation), 1 pour le reste
(vérification, résumé sur transcription). Un cache KV à court de place en
plein calcul est rejoué tel quel, sans compaction : c'est la faute du
voisin, pas un prompt trop long.
- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est
gardé sur disque à la bascule de preset, et rechargé avant la première
requête de la discussion au retour.
Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après
la passe de vérification du mode Code, le Go efface le slot local s'il a
servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses
requêtes.
Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au
moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`).
La machinerie peut donc rester au Go ; il suffit que la façade la consulte.
## Le lot
### Go
- `/internal/v1/turn/slot` (`facade_slots.go`) :
- entrée : `{"conv":"…","main":true}` ;
- sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ;
- avec `main` et `conv`, recharge d'abord l'état gardé de la discussion
(`slotPersistRestore`), comme la boucle Go avant une requête d'un tour.
- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès
que le moteur accepte une étape de son tour : `/internal/v1/oai/served`
appelle `engineMarkMain`.
- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation.
### Rust
- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose
l'`id_slot` rendu dans le corps.
- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot
en service et un moteur local :
- deux fois au plus, après l'attente des reprises réseau ;
- puis l'erreur telle quelle ;
- ni compaction ni réduction.
- `GoTurnHost` :
- moteur externe : aucun slot ;
- étape d'un tour : `main` avec la discussion (donc le rechargement
`SLOT_PERSIST`) ;
- vérification : le second slot, et le ticket « en vol » ouvert sur lui
(`side`).
- `EngineSummarizer` : un résumé est désormais compté en vol, comme
`postSummary`. Il part sur le second slot s'il est fait sur la
transcription, sur le slot 0 s'il est fait en continuation.
- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`.
À la fin, le Go efface le slot si le moteur a servi.
## Tests
- Go (`facade_slots_test.go`) :
- sans la clé, aucun slot et `/props` jamais lu ;
- fil de la discussion sur le 0, le reste sur le 1 ;
- second slot hors service ;
- rechargement une seule fois, pour la bonne discussion et une étape de tour
seulement ;
- une étape de la façade acceptée par le moteur suffit à la sauvegarde ;
- corps illisible, GET refusé.
- La boucle du tour :
- `id_slot` dans chaque requête, slot demandé avant chaque envoi ;
- cache KV plein : rejoué deux fois sans compaction, puis l'erreur ;
- sans slot, ou moteur externe : un débordement comme un autre ;
- motif `sideSlotPoolError` repris des cas du Go.
- Le vrai hôte (`tests/lot6.rs`) :
- contrat du mode Code : le tour sur le 0, la vérification sur le 1 ;
- tickets ouverts sur le second slot, travail annexe ouvert puis rendu ;
- résumés : transcription sur le 1, continuation sur le 0, tickets rendus.
## Écarts assumés
- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go
relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent
le même cache de dix secondes.
- Le slot est demandé avant de composer la requête, et non juste avant de
l'envoyer : rien ne se passe entre les deux.
@@ -0,0 +1,97 @@
# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
## Contexte
Sans la clé, une image montrée par un outil (capture, `see_image`) est
éphémère. Le modèle la voit pendant le tour, puis elle disparaît de
l'historique. Le tour suivant renvoie donc un fil où elle manque : le
préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est
recalculée.
`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée,
sous conditions :
- vision réellement active ;
- image rangée par référence et relue à l'octet près ;
- coût **mesuré** par le moteur, jamais estimé ;
- toutes les images gardées tiennent dans 10 % de la fenêtre.
Les images gardées partent avant tout résumé. La clé gardait la conversation
au Go.
## Le lot
### Go (`facade_keepimg.go`)
- `/internal/v1/turn/keepimg` rend deux choses :
- si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ;
- le budget des images gardées (`keepImgBudget`).
- `/internal/v1/turn/keepimg/store` range une image relayée par référence
(`refRelayImage`) :
- fichier chiffré si la mémoire l'est ;
- date rafraîchie ;
- relu à l'octet près.
Il rend le message qui la porte ainsi et son poids en octets, ou un refus.
- `KEEP_TURN_IMAGES` ne garde plus la conversation.
### Rust
- `chat/keepimg.rs` porte l'état du tour (`keepImages`) :
- `relay` : image gardée à l'essai, dans `messages` et `extra` ;
- `sending` ;
- `observe` : mesure de la requête qui l'a montrée la première, partage au
prorata des octets, seuil de 16 jetons, budget ;
- `rewritten` ;
- `publishable` ;
- `finish` : une image à l'essai jamais mesurée redevient éphémère.
Une image refusée arrête le tour pour les suivantes.
- La boucle du tour :
- `TurnHost::keep_images` donne le budget, mais seulement pour le fil
principal d'une discussion, avec la clé et la vision active ;
- `TurnHost::keep_image` range l'image par le Go ;
- l'historique réécrit en cours de tour est publié sans les relais non
gardés.
- Les images gardées partent d'abord, avant tout résumé :
- au filet réactif (prompt trop long) : requête rejouée sans elles ;
- en cours de tour, au seuil : la compaction ne suit que si elle reste
nécessaire ;
- sur une complétion coupée par une fenêtre pleine : rejouée sans résumé
si leur retrait suffit.
- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont
retirées si le contexte le demande, ou si elles ne doivent plus partir
(clé retirée, vision absente).
La compaction et la réduction forcée retiraient déjà les images gardées
d'abord (`chat/compact.rs`).
## Tests
- `chat/keepimg` :
- clé ;
- gardée au coût mesuré ;
- budget ;
- sans mesure (pas d'usage, complétion coupée, pas de base) ;
- requête relancée ;
- rangement refusé ;
- partage entre deux images ;
- publication, réécriture, fin de tour, budget plein avant rangement.
- La boucle du tour :
- requête identique avec et sans la clé, image gardée par référence au
coût mesuré ;
- refus : budget, pas d'usage, rangement refusé ;
- image à l'essai en fin de tour ;
- retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à
chaque fois sans résumé.
- Le vrai hôte (`tests/lot6.rs`) :
- image rangée par le Go, vue en data-URL par le moteur, gardée avec son
coût ;
- clé retirée : l'image part au début du tour suivant, légende et marque
gardées.
- Le Go (`facade_keepimg_test.go`) :
- état sans projecteur puis vu par le moteur, budget ;
- rangement relu à l'octet près ;
- refus : texte seul, URL distante, format inconnu, base64 illisible, deux
images ;
- corps illisible, GET refusé.
@@ -0,0 +1,112 @@
# Lot 33 — Préchauffage (PREWARM) tenu par la façade
## Contexte
Certains recalculs sont inévitables :
- le prompt réécrit par une compaction ;
- le dernier message assistant rendu autrement au tour suivant ;
- la discussion reprise après une tâche qui a pris le slot.
Sans la clé, ils tombent sur le premier jeton du message suivant.
`PREWARM` (`chat_prewarm.go`) envoie au moteur local, dès qu'il est libre, la
requête du prochain tour telle qu'elle partira, suivie d'une sentinelle « . »
et avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne
calcule plus que son propre message.
Le préchauffage ne passe jamais devant un vrai travail :
- toute requête de Loki l'annule, sauf un tour qui le prolonge exactement ;
- un seul est en vol à la fois ;
- aucune erreur ne remonte.
La clé gardait la conversation au Go.
## Le lot
La façade tient l'historique : c'est elle qui assemble et envoie. Le Go
garde le compteur des requêtes en vol (`engineGate`) et ce qui annule.
### Go (`facade_prewarm.go`)
- `/internal/v1/prewarm/check` dit si le préchauffage peut partir :
- clé posée, moteur local ;
- ni travail annexe, ni bench, ni optimiseur en cours ;
- loin de minuit ;
- moteur en bonne santé ;
- slot visé : un seul slot, ou celui de la discussion avec SIDE_SLOT.
- `/internal/v1/prewarm/begin` :
- recharge l'état SLOT_PERSIST de la discussion ;
- inscrit le préchauffage comme LA requête en vol (`prewarmBegin`) ;
- son annulation (une autre requête part) est relayée à la façade
(`/internal/facade/v1/chat/prewarm/cancel`).
- `/internal/v1/prewarm/end` :
- rend la place ;
- note ce que le moteur a repris (`noteEnginePrompt`).
- `/internal/v1/oai/begin` accepte `prewarm_keep` : l'étape d'un tour de la
façade qui prolonge le préchauffage ne l'annule pas.
- Après une tâche planifiée, le déclencheur part à la façade
(`/internal/facade/v1/chat/prewarm`).
- PREWARM ne garde plus la conversation.
### Rust (`chat/prewarm.rs`)
- L'état partagé :
- le préchauffage en vol, son inscription, son annulation ;
- les capacités du dernier tour de chaque discussion (64 au plus).
- L'assemblage : les mêmes fonctions que le tour.
- Vue du Go, historique sans blocs `<context_update>`, préambule.
- Écho, messages d'envoi, corps.
- Sentinelle « . » (rôle `builder` en mode Code), non streamé, un seul jeton.
- On s'abstient dans ces cas :
- capacités inconnues ;
- tour en cours ;
- discussion vide ;
- compaction attendue ;
- assemblage inattendu.
- Déclencheurs :
- fin de tour quand rien n'attend dans la file ;
- fin de tâche (relayée par le Go) ;
- changement de discussion ou de projet : le préchauffage en cours est
annulé ; avec `full`, la nouvelle discussion est préparée après 3 s.
- Le tour :
- `TurnHost::prewarm_keeps` compare les empreintes des messages envoyés
et des champs qui façonnent le rendu (`prewarmOptsHash`) ;
- une requête qui ne prolonge pas le préchauffage le coupe tout de suite,
sans attendre l'aller-retour par le Go.
- Télémétrie : le préchauffage est rangé dans l'anneau (`kind=prewarm`).
## Tests
- `chat/prewarm` :
- mode (jamais pour un preset externe) ;
- empreinte des options (l'échantillonnage n'y entre pas, un champ absent
n'est pas un champ vide) ;
- rôle de la sentinelle ;
- prolongement ;
- état partagé (un seul en vol, garde, annulation, fin) ;
- capacités bornées.
- La boucle du tour : le drapeau transmis à l'envoi, jamais vers une API
externe.
- Le vrai hôte (`tests/lot6.rs`) :
- fin de tour : requête préchauffée (sentinelle, un seul jeton, même
préfixe et mêmes outils que le tour), inscrite au Go, pas de ticket de
la façade ;
- le tour suivant la prolonge et la garde (`prewarm_keep`) ;
- fin rendue avec total et cache, télémétrie ;
- refus du Go : rien ne part ;
- annulation relayée : connexion coupée, place rendue.
- Le Go (`facade_prewarm_test.go`) :
- contrôle : clé, slots, minuit, travail annexe, moteur muet ;
- inscription et deuxième préchauffage refusé ;
- gardé par le tour qui le prolonge, annulé et relayé sinon ;
- fin unique ;
- requête déjà en vol ;
- déclencheur après tâche relayé.
## Écarts assumés
- L'annulation d'un préchauffage pas encore inscrit au Go (aller-retour en
cours) annule celui qui part, quel que soit son numéro : il n'y en a
qu'un.
@@ -0,0 +1,92 @@
# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
## Contexte
Le contexte du projet part en tête du premier message utilisateur :
- description ;
- index mémoire ;
- trackers ;
- consignes du dépôt ;
- avec la clé, la date et le dossier de travail.
Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un
tracker reçoit un point. Tout ce qui le suit dans le prompt est alors
recalculé.
`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par
discussion. Les changements arrivent en `<context_update>` en tête du
nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc
n'est rafraîchi que quand le début du prompt change de toute façon :
compaction, système ou outils, clé du moteur, projet, mode, trop de mises à
jour accumulées.
C'était le dernier réglage qui gardait la conversation au Go.
## Le lot
L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`),
et la compaction le remet à zéro comme le Go. Il manquait la décision, qui
lit le contexte du projet : elle reste au Go.
### Go (`facade_projsnap.go`)
- `/internal/v1/turn/view`, avec la clé et l'agent :
- reçoit l'instantané de la discussion (`snap`) ;
- rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant
s'il faut rafraîchir) et le préambule sans date ni dossier
(`envInCtx`) ;
- rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser,
l'instantané à ranger, la clé et l'empreinte du système du tour.
- `dry` (préchauffage) : la même décision, rien à ranger.
- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il
devient (`projSnapTurn.refresh`).
- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la
décision prise une fois par processus disparaissent. Le Go la remet dès
que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là.
### Rust
- `ChatService::assemble_turn`, partagé par le tour et la relance du builder
du mode Code :
- rafraîchi : instantané neuf, blocs `<context_update>` retirés de
l'historique ;
- mise à jour : posée en tête du dernier message utilisateur
(`prependCtxUpdate`), dans la discussion et dans la requête ; l'état
annoncé ne bouge que si elle a trouvé où se poser ;
- sans la clé : la discussion qui porte encore des blocs en est nettoyée,
comme avant.
- `TurnHost::project_refresh` : après une compaction ou une réduction en
plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les
mises à jour sont retirées. L'instantané posé devient celui de la
discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne
touche pas au début du prompt : pas de rafraîchissement.
- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de
l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour.
## Tests
- Go (`facade_projsnap_test.go`) :
- la séquence assemblée par la façade égale celle de `turnView`, outils et
instantané compris ;
- une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`,
état annoncé suivi ;
- sans changement : rien à poser ;
- `dry` sans rien à ranger ;
- clé changée : rafraîchi, bloc vivant en préchauffage ;
- rafraîchissement en plein tour : bloc vivant, instantané à la clé du
tour.
- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade
tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et
pourquoi.
- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc
projet remplacé à sa place (cas du Go).
- Rust, boucle du tour : rafraîchi après une compaction et une réduction ;
pas après un retrait d'images gardées.
- Rust, vrai hôte (`tests/lot6.rs`) :
- instantané rangé ;
- mise à jour posée dans la requête et l'historique (marque retirée à
l'envoi) ;
- rafraîchissement qui retire les mises à jour ;
- compaction en plein tour : bloc vivant envoyé, instantané rangé ;
- préchauffage en `dry`.
@@ -0,0 +1,48 @@
# Lot 35 — Routes de base par le tunnel du relais
## Contexte
La façade sert toujours neuf routes, sans variable (`api.rs`) :
- `/api/ping` ;
- `/api/status` ;
- `/api/config` ;
- `/api/engine/cmdline` ;
- `/api/model/layers` ;
- `/api/paths` ;
- `/api/ram` ;
- `/api/vram` ;
- `/api/service/log`.
Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel.
`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur
distant tombait donc encore sur les handlers Go de ces neuf routes : deux
implémentations à garder d'accord.
## Le lot
- Go (`facade_core.go`) : une table des routes de base, remises à la façade
par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne
présents), sans variable. Façade muette : 502 « façade indisponible ».
Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même.
- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été
authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau,
comme pour les routes des lots ; la version du Go est lue quand même pour
`/api/ping`.
## Tests
- Go (`facade_core_test.go`) :
- les neuf routes relayées avec le jeton ;
- une route du Go gardée ;
- façade muette ;
- sans façade, servies ici.
- Rust (`tests/lot2.rs`) :
- relayée par le Go avec un hôte distant et une clé posée : servie par la
façade, pas renvoyée au Go ;
- sans jeton, ou avec un faux : refusée.
## Reste à faire
L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses
handlers sont gardés pour `LOKI_FACADE=0`.
@@ -0,0 +1,56 @@
# Lot 36 — Appels d'outils MCP sans détour par le Go
## Contexte
Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions
stdio et HTTP, registre des outils exposés. Le tour de la façade, lui,
faisait encore exécuter un appel `mcp__*` par le Go
(`/internal/v1/tool/exec`).
Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade
(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux
allers-retours locaux pour revenir au même pool.
Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) :
- ni capture ;
- ni aperçu du navigateur ;
- ni image relayée.
Seul le texte revient.
## Le lot
- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une
fois dans `main.rs`).
- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call`
directement. Le résultat est celui de la route interne :
- texte aplati et coupé ;
- « [l'outil a renvoyé une erreur] » ;
- « [ok] (aucune sortie) » ;
- une reconnexion si la session est morte, puis « [erreur MCP] … ».
- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route
interne. Elle est lue une fois (`/internal/v1/auth`) et retenue.
- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go,
qui l'exécute avec son propre pool.
Le Go garde la route interne pour les tours qu'il exécute encore :
- sous-agents ;
- tâches IA ;
- discussion rendue au Go.
## Tests
`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux
serveur MCP stdio. Un tour de la façade appelle :
- un outil qui répond ;
- un outil dont le serveur meurt.
Le test vérifie :
- aucun appel au Go ;
- le résultat de chaque outil tel que le modèle le reçoit ;
- l'erreur après une reconnexion ;
- la version du Go annoncée à cette reconnexion.
@@ -0,0 +1,82 @@
# Lot 37 — Outils task_* de l'agent en Rust
## Contexte
Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) :
- les routes ;
- le planificateur ;
- les tâches « script seul ».
Les quatre outils par lesquels le modèle gère lui-même ses tâches
(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` :
- `task_list` ;
- `task_create` ;
- `task_update` ;
- `task_delete`.
## Le lot
`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère
près :
- **`task_list`** :
- liste du projet actif, aperçu d'une ligne par tâche ;
- compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par
octet comme à l'encodage JSON du Go ;
- avec un id, la tâche seule et son compte-rendu complet ;
- une tâche d'un autre projet est « introuvable » ;
- **`task_create`** :
- tâche unique « dans N minutes » (durée tronquée comme
`time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée,
à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ;
- fréquence validée ;
- script vérifié dans le dossier protégé ;
- tâche rattachée au projet actif ;
- fuseau : celui du navigateur, sinon celui de la première tâche qui en a
un ;
- **`task_update`** : modification partielle ; la prochaine exécution est
recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui
n'en a pas ;
- **`task_delete`**.
Un projet disparu vaut le projet par défaut (`taskProjectOf`).
`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches
(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part
au Go comme avant.
Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de
`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la
fixer.
## Parité
`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44
appels (`tests/parity_tasks/tools.json`). Chaque étape porte :
- l'heure, le fuseau, le projet actif, les projets connus ;
- les scripts présents ;
- une tâche posée telle quelle en base ;
- les arguments tels que le modèle les envoie ;
- le résultat tel qu'il le reçoit ;
- les tâches en base après coup.
Le journal couvre :
- l'heure du trou du changement d'heure ;
- une date passée ou impossible ;
- des arguments d'un autre type ;
- un script absent, hostile ou dossier ;
- le compte-rendu coupé au milieu d'un caractère ;
- le fuseau d'une tâche existante.
`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust.
`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la
façade qui crée une tâche. Il vérifie :
- rien ne passe par le Go ;
- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ;
- le résultat est celui que le modèle reçoit.
@@ -0,0 +1,77 @@
# Lot 38 — L'outil criteria du mode Code dans la façade
## Contexte
En mode Code, le modèle tient les critères d'acceptation de sa tâche avec
l'outil `criteria` :
- les poser ;
- les marquer faits ;
- les lister ;
- les effacer.
La passe de vérification les marque ensuite `passed` ou `failed`.
Depuis le lot 19, la façade sert déjà les critères de l'écran
(`/api/chat/criteria`, `chat/crit:<id>` scellés). La passe de vérification
les relit depuis le lot 7. Mais l'outil lui-même partait au Go
(`/internal/v1/tool/exec`), qui le renvoyait à la façade comme delta
(`critNotify` → `/internal/facade/v1/delta`).
## Le lot
`chat/crittool.rs` porte `toolCriteria`, mêmes messages au caractère près :
- **list** : action vide ou d'un autre type comprise ;
- **add** :
- textes vides ou d'un autre type ignorés ;
- ids repris après le plus grand ;
- vingt critères au plus ;
- **set** :
- id numérique (tronqué) ou en chaîne (« "2" », « "#2" », « +1 ») ;
- statut libre, note rognée ;
- `passed` refusé hors de la passe de vérification ;
- **clear**.
Les règles de rangement et d'affichage :
- une liste vide supprime la clé ; une erreur d'écriture est ignorée, comme
dans le Go ;
- une liste rangée illisible vaut « aucun critère » ;
- la note vide n'est pas écrite (`omitempty`).
`GoTurnHost::exec_tool` l'exécute pour la discussion du tour
(`main_conv`). La passe de vérification (`Role` = verifier) est la seule à
pouvoir marquer `passed`. Chaque changement pousse la liste à l'écran (delta
`criteria` + `ts`) par un nouveau champ `deltas` de l'hôte :
- le journal de la discussion pour un tour ;
- celui de la conversation pour une passe de vérification.
Écart assumé : le Go rangeait les critères sous la discussion **active**
au moment de l'appel (`convEnsureActive`). La façade les range sous celle du
tour. Elles ne diffèrent que si l'utilisateur change de discussion pendant
la génération, et c'est alors la façade qui a raison.
`see_image` reste au Go. Il redresse et réduit l'image (EXIF, taille) avant
de l'encoder, ce qu'un port ne rendrait pas octet pour octet.
## Parité
`internal/loki/parity_criteria_test.go` tire du Go un journal de 22 appels
(`tests/parity_tools/criteria.json`). Chaque étape porte :
- les arguments, builder ou vérification ;
- le résultat ;
- les critères rangés ensuite.
`tests/tools.rs` (`criteria_comme_le_go`) le rejoue contre le port. Il
vérifie aussi :
- le rangement scellé ;
- la liste poussée à l'écran à chaque changement, et seulement alors.
`tests/lot6.rs` (`contrat_verifie_par_la_facade`) fait tourner un vrai
contrat. La passe de vérification marque le critère `passed` dans la
façade : rien ne passe par le Go, le critère est rangé scellé, l'écran
reçoit la liste.
@@ -0,0 +1,92 @@
# Lot 39 — Anneau de télémétrie tenu par la façade
## Contexte
Chaque complétion qui passe par le moteur est rangée dans un anneau
(`perf_log.go`). L'anneau :
- calcule la perte de cache d'une complétion contre la précédente de la même
discussion et de la même nature ;
- nomme ce qui s'est intercalé (sous-agent, client externe…) ;
- signale une perte au-delà du seuil du preset (stats finales) ;
- nourrit `/api/perf/summary` et les poids de l'optimiseur.
Depuis le lot 29, la façade envoyait chaque complétion de ses tours au Go
(`/internal/v1/turn/perf`). C'était un aller-retour par complétion pour un
anneau tenu ailleurs.
## Le lot
**Rust** (`src/perfring.rs`, port de `perf_log.go`) :
- l'anneau : 5000 entrées, 128 discussions suivies, 500 entrées remontées au
plus pour nommer ce qui s'est intercalé ;
- la perte : seulement si la complétion prolonge strictement la précédente
(empreintes cumulées des messages), cache connu, complétion allée au bout ;
- la ligne `[perf]` (LOKI_PERF_LOG) ;
- le seuil d'alerte, dans le même ordre de priorité qu'au lancement :
- `-cms` d'EXTRA_ARGS ;
- `LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` ;
- `CKPT_MIN_STEP` ;
- 2048 sur un modèle hybride ;
- plus le micro-batch ;
- le résumé de `/api/perf/summary` (par nature, par profondeur, brouillon,
dernier verdict de la sonde de gabarit).
`ChatShared` porte l'anneau. Les tours, les passes de vérification, les
résumés de compaction et le préchauffage de la façade y rangent leurs
complétions directement. Les marques (perte, ce qui s'est intercalé,
alerte) partent sur les stats finales comme avant.
La façade sert :
- `POST /internal/facade/v1/perf/record` : une complétion faite par le Go
(sous-agent, tâche IA, bench, compaction et préchauffage de ses tours,
client externe de `/v1`), rendue telle que rangée ;
- `POST /internal/facade/v1/perf/snapshot` : l'anneau, pour les poids de
l'optimiseur ;
- `GET /api/perf/summary`.
**Go** (`facade_perf.go`) : quand la façade tient la conversation,
`perfRecord` range chez elle, et `perfSnapshot` relit son anneau
(optimiseur, contexte de `/internal/v1/tune/context`). `/api/perf/summary`
lui est remis (`facadeChatRoutes`). Façade injoignable : l'anneau du Go
prend le relais. Sans façade, ou la conversation rendue au Go
(`LOKI_CHAT=go`), rien ne change.
La route `/internal/v1/turn/perf` est retirée : plus personne ne l'appelle.
## Parité
`internal/loki/parity_perf_test.go` tire du Go un journal
(`tests/parity_perf/perf.json`). Quatorze complétions y sont rangées, avec
les empreintes de leurs messages ; elles couvrent :
- prolongement ;
- perte après un sous-agent et un client externe ;
- cache inconnu ;
- divergence ;
- autre discussion ;
- complétion coupée ;
- compaction, bench, vérification.
Pour chaque complétion, le journal garde ce qui est rangé, la ligne
`[perf]` et l'alerte. Il porte aussi le résumé, le résumé vide, et onze
seuils d'alerte. `src/perfring/tests.rs` le rejoue, avec l'anneau borné et
l'heure posée.
`tests/lot6.rs` :
- `cache_perdu_mesure_par_la_facade` : deux tours de la même discussion ;
la perte est mesurée par la façade et signalée dans les stats finales ;
- `anneau_de_la_facade_pour_le_go` : ce que le Go confie (perte après un
client externe), ce qu'il relit, le jeton exigé, `/api/perf/summary`
servi par la façade ;
- les tests du lot 29 lisent désormais l'anneau de la façade.
`internal/loki/facade_perf_test.go` (`TestFacadePerfAnneau`) :
- complétion confiée à la façade ;
- anneau relu ;
- repli sur l'anneau du Go quand la façade est en panne ;
- route remise.
@@ -0,0 +1,87 @@
# Lot 40 — Outils mémoire de l'agent dans la façade
## Contexte
Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` :
- pages ;
- index `MEMORY.md` ;
- trackers ;
- mode mémoire.
Les outils par lesquels le modèle s'en sert partaient pourtant au Go
(`/internal/v1/tool/exec`) :
- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ;
- `tracker`.
Deux processus écrivaient donc les mêmes pages et le même index.
## Le lot
`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`),
mêmes résultats au caractère près. Il réutilise les lectures et écritures des
routes de la mémoire :
- pages scellées selon le chiffrement ;
- `.bak` de la version précédente ;
- écriture vérifiée ;
- index tenu par le code.
Chaque outil :
- **`mem_search`** : classement par couverture des termes puis par
pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ;
extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère
coupé) ; limite 8 par défaut, 30 au plus ;
- **`mem_read`** : lignes numérotées, plage, 500 au plus ;
- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au
nom du projet), diff d'ajout ;
- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ;
- **`tracker`** :
- vue d'ensemble ;
- squelette (années, année en cours par mois) ;
- une année par mois ;
- points (60 plus récents au plus) ;
- ajout, modification, suppression d'un point (jamais d'un tracker
entier) ;
- arguments d'un autre type rendus comme `str` du Go.
`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`).
`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool`
y exécute les outils quand la façade la tient ; sinon l'appel part au Go
comme avant.
Côté Go, l'heure des trackers (« maintenant », année en cours) passe par
`trackerNow`, que le journal fixe ; rien ne change en service.
## Parité
`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels
(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque
étape porte :
- les arguments ;
- le libellé du tour ;
- le résultat, le diff ;
- les fichiers de la mémoire du projet (`.bak` et index compris) ;
- les trackers en base.
Il couvre :
- recherche : couverture, rareté, requête vide, limites, extrait coupé ;
- lecture : plage, au-delà, page absente, nom invalide ;
- ajout : existant, nom invalide, majuscules ;
- modification : rejouée, déjà en place, absente, en double, vide ;
- trackers : chaque vue, dates illisibles, nom en nombre, identifiants
inconnus.
`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port.
`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la
façade qui crée une page. Il vérifie :
- rien ne passe par le Go ;
- la page est rangée dans le projet actif, l'index tenu ;
- le résultat est celui que le modèle reçoit.
@@ -0,0 +1,133 @@
# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade
## Contexte
Depuis le lot 22, la façade tient les backends llama.cpp quand
`LOKI_BACKENDS=rust` :
- statut, vérification git ;
- binaires précompilés ;
- bascule fast/opt ;
- backends custom ;
- le job des moteurs.
La compilation depuis les sources restait au Go. Il en rapportait chaque
étape au job de la façade par l'API interne
(`/internal/facade/v1/build-job/*`). Elle couvre trois routes :
- `POST /api/llamacpp/install` `{force}` ;
- `POST /api/llamacpp/update` `{clean}` ;
- `POST /api/llamacpp/install-custom` `{repo, name, ref}`.
## Le lot
`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go :
- `backend_build.go` : plan, CMake, étapes suivies ;
- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail`
(`web_llamacpp.go`) ;
- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL`
(`backend_llamacpp.go`) ;
- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`).
Le port couvre :
- **le plan complet** (`detectBuildPlan`) :
- drapeaux Release, natif, UI de llama-server coupée ;
- Metal sous macOS ;
- CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et
compilateur explicites hors `/usr`, architectures lues par
`nvidia-smi` ;
- HIP, puis Vulkan ;
- **CMake** :
- `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en
`build.old` ;
- nvcc dans le PATH et `CUDACXX` ;
- `configure.log` et `build.log` dans le dépôt ;
- erreurs « configuration CMake échouée » et « compilation échouée » ;
- **les étapes suivies** :
- sorties standard et d'erreur dans un même tube, ordre gardé ;
- git : découpage du `sinkWriter` (barre qui se redessine montrée toutes
les 10 s, reste sans fin de ligne perdu) ;
- CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de
plus de 1 Mio qui arrête la lecture) ;
- octets invalides en U+FFFD un à un ;
- erreurs au format d'`exec` (`exit status N`, `signal: killed`,
programme introuvable) ;
- **les outils** :
- git et cmake, puis cc, c++, make ;
- un manquant est installé au mieux par apt-get, dnf, pacman ou brew
(sudo hors root) ;
- sinon le message du Go ;
- **les jobs** :
- installation : clone, compilation propre, BIN ; dépôt présent : bascule
en mise à jour ; `force` : supprimé puis recloné ;
- mise à jour : fetch, pull au besoin, commits avant et après ;
« déjà à jour » sans recompiler ; service arrêté le temps du build et
relancé même en échec ;
- échec du build : fin des deux journaux (30 lignes) remontée dans le job ;
- backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à
un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans
toucher à BIN.
Les routes répondent comme le Go :
- moteur fourni par l'image : 409 et le message du Go ;
- custom sans URL : 400 « URL du dépôt requise » ;
- optimisation en cours : 409 ;
- job déjà lancé : 409 ;
- sinon 200 `{"ok":true}` et le job tourne.
Le service moteur est celui de la façade si elle le pilote, sinon celui du
Go (`/internal/v1/service/action`), comme pour les binaires précompilés.
Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous
Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la
façade :
- générateur Visual Studio ;
- intégration CUDA de MSBuild ;
- `CUDA_PATH`.
Les messages du terminal du Go (outils manquants, dépendance connue absente
après un configure en échec) vont à la sortie d'erreur de la façade.
## Parité
`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15
étapes (`tests/parity_backends/build.json`). De faux git, cmake et
compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape
porte :
- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ;
- BIN ;
- les dépôts qui ont un `build.old`.
Il couvre :
- mise à jour sans dépôt ;
- installation, puis installation sur un dépôt présent et installation
forcée ;
- déjà à jour, nouveau commit, recompilation propre ;
- build en échec (fin des journaux) ;
- cmake absent, compilateur absent ;
- clone en échec ;
- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier
existant sur un ref (URL `git@`).
`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la
façade : mêmes faux outils dans le PATH des sondes, POST de la route, job
suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes.
Les tests unitaires de `build.rs` couvrent :
- la barre de progression (`TestSinkWriterProgression`) ;
- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne
démesurée) ;
- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ;
- le cache CMake déplacé ;
- noms et URL des backends custom ;
- le plan par machine (CPU, nvcc du PATH, toolkit versionné,
architectures, HIP, Vulkan) ;
- les erreurs des commandes (code de sortie, signal, programme absent,
journal écrit).
@@ -0,0 +1,121 @@
# Lot 42 — Outils web de l'agent dans la façade
## Contexte
La façade tient la conversation par défaut. Les outils web de ses tours
partaient pourtant au Go (`/internal/v1/tool/exec`) :
- `web_search` ;
- `web_images` ;
- `web_open`, `web_read`, `web_grep`.
Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read`
faisaient chacun un aller-retour.
## Le lot
`src/webtools.rs` porte les outils. Sources Go :
- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ;
- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats
DuckDuckGo ;
- `chat_web_images.go` : recherche d'images.
Ce qui est porté :
- **le cache** :
- clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page
(actions, attente, bandeaux), à l'échappement HTML près du Go ;
- `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL,
toutes options confondues ;
- 10 minutes ;
- **Crawl4AI** :
- corps envoyé au Go près (clés triées, échappement HTML) ;
- clé d'accès en `Bearer` ;
- réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal`
(types faux, `null`, casse des clés) ;
- markdown en chaîne ou `{raw_markdown, fit_markdown}` ;
- erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur
injoignable (texte de l'erreur du client Go) ;
- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs
réduits ; plan des titres ;
- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois
comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et
liens DuckDuckGo écartés, défi anti-bot ;
- **les images** :
- jeton `vqd` de la page, puis `i.js` ;
- mêmes en-têtes ;
- cookies gardés par hôte, comme le jar du client Go ;
- réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au
texte du Go) ;
- **`web_grep`** :
- regex du Go (`(?i)`, classes ASCII) ;
- erreurs au texte de `regexp` pour les fautes courantes ;
- **le plafond** de 8000 caractères ;
- **les arguments** : nombres tronqués comme `int(float64)` du Go.
Le moteur intégré lit le HTML avec go-readability, html-to-markdown et le
parseur HTML5 de x/net : rien en Rust ne les reproduit au caractère près.
Ces deux étapes restent au Go, derrière deux appels sans état :
- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}`
(`goFetchMarkdown`) ;
- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}`
(`goSearch`).
`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute
avant tout le reste. `web_screenshot` reste au Go (navigateur piloté).
Le traducteur des regex du Go (`go_regex`) refuse désormais :
- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ;
- un compte au-delà de 1000 ou inversé.
Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les
motifs `retry_patterns` en profitent.
Côté Go, pour le journal :
- `webNow` : l'horloge du cache ;
- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo.
## Écart connu
reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go
n'en pose aucun. Seule la page de recherche d'images est concernée, sans
effet sur DuckDuckGo.
## Parité
`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74
appels (`tests/parity_web/outils.json`). Chaque appel porte :
- le résultat ;
- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ;
- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ;
- les pages et recherches demandées au moteur intégré.
Il couvre :
- recherche : limites, anti-bot, aucun résultat, panne ;
- lecture : plages, bornes, au-delà, page jamais ouverte ;
- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept
regex invalides ;
- cache : réouverture, forcée, options, la plus récente, péremption ;
- GitHub ;
- les réponses de Crawl4AI ;
- serveur injoignable, sans serveur, sans clé ;
- moteur intégré : ouverture, texte brut, page absente, recherche ;
- images : doublons, cookie gardé, jeton absent, pannes, réponses
illisibles ou mal typées, `null`.
`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le
moteur intégré a tiré des pages.
D'autres tests complètent le journal :
- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre
une page sans passer par `/tool/exec` ;
- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ;
- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de
regex, réponses de Crawl4AI.
@@ -0,0 +1,82 @@
# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade
## Contexte
En mode Code, le modèle lance un serveur de dev, un watcher ou un build long
avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi
l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui
gardait les processus. C'étaient les derniers outils shell du tour encore au
Go, `bash` étant porté depuis le lot 7.
## Le lot
`src/tools/jobs.rs` porte `code_jobs.go`.
**`bash_bg`** :
- refus avant tout lancement :
- commande vide ;
- commande dangereuse (même politique que `bash`) ;
- optimisation en cours (`/internal/v1/tune/guard`) ;
- journal `.loki/jobs/<id>.log` dans le dossier de la discussion (le
contexte des outils du Go) ;
- numéros `job1`, `job2`… ;
- shell de la plateforme, sans le jeton interne dans l'environnement ;
- le processus survit au tour ;
- code de sortie du Go : -1 pour un processus tué.
**`bash_tail`** :
- 40 lignes par défaut, 200 au plus ; un nombre est tronqué comme
`int(float64)` ;
- état « en cours » ou « terminé (exit N) » et durée arrondie à la
seconde ;
- sortie vide signalée ;
- fin gardée en caractères ;
- octets invalides en U+FFFD un à un ;
- `kill:true` arrête le job et relit aussitôt, comme le Go : l'état peut
encore dire « en cours ».
Les erreurs d'écriture ont le texte du Go. `paths::go_mkdir_all` reproduit
`os.MkdirAll` : l'erreur nomme l'élément du chemin qui a échoué.
`ChatShared.jobs` (les jobs du processus) sert les tours :
`GoTurnHost::exec_tool` exécute les deux outils. Sans contexte d'outils,
`bash_bg` passe au Go comme avant.
Les jobs d'une discussion supprimée s'arrêtent avec elle
(`GoSessionHooks::dropped`, `stopConvJobs`).
L'optimiseur refuse de mesurer tant qu'un job tourne. Côté Go,
`bgJobsRunning` ajoute ceux de la façade quand elle tient la conversation
(`POST /internal/facade/v1/bgjobs` → `{running}`) ; façade injoignable :
les siens seulement.
## Parité
`internal/loki/parity_jobs_test.go` (Linux) tire du Go un journal de 24
étapes (`tests/parity_jobs/jobs.json`). Chaque étape porte le résultat (pid
et durées masqués) et les journaux présents. Il couvre :
- commande vide, refusée ;
- sortie des deux flux, lignes demandées (nulles, en texte, tronquées) ;
- arrêt d'un job fini ;
- job inconnu, sans id ;
- sortie en erreur sans rien d'écrit ;
- job long, en cours, arrêté (état relu masqué), puis fini à -1 ;
- quarante lignes par défaut, deux cents au plus ;
- sortie démesurée en caractères multi-octets ;
- octets invalides.
`tests/jobs.rs` le rejoue contre le port.
D'autres tests complètent le journal :
- `tests/lot6.rs` (`job_arriere_plan_par_la_facade`) : un tour de la façade
lance un job dans le dossier de la discussion sans passer par le Go ;
supprimer une autre discussion n'y touche pas, la sienne l'arrête ;
- `anneau_de_la_facade_pour_le_go` : le compte des jobs par l'API interne ;
- `TestFacadeJobsArrierePlan` : le Go les ajoute aux siens, façade en panne
comprise ;
- tests unitaires : refus de l'optimiseur, discussion supprimée, dossier
impossible.
@@ -0,0 +1,138 @@
# Lot 44 — Navigateur piloté dans la façade
## Contexte
Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le
protocole DevTools (CDP). L'agent l'utilise ainsi :
- il ouvre une page et lit les éléments interactifs numérotés ;
- il clique, tape, défile par numéro ;
- avec la vision, il voit une capture quadrillée et clique en
coordonnées.
La façade tient la conversation par défaut, mais la session Chrome, les
outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient
au Go (`/internal/v1/tool/exec`).
## Le lot
`src/browser/` porte quatre fichiers Go :
- `computer_cdp.go` : client CDP, lancement, scripts ;
- `computer_use.go` : les neuf outils ;
- `browser_grid.go` : la grille de la capture ;
- `cu_autoshot.go` : les aperçus.
Ce qui est porté :
- **le lancement** :
- binaire imposé, celui de Playwright ou du système ;
- profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ;
- mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ;
- tué avec la façade (`PR_SET_PDEATHSIG`) ;
- port lu dans `DevToolsActivePort`, cible « page » par `/json` ;
- viewport fixe 1280×800 ;
- script qui garde les nouveaux onglets dans l'onglet courant ;
- **la session** :
- une seule, lancée à la demande ;
- vérifiée par un ping avant chaque outil et relancée si elle ne répond
plus ;
- commandes multiplexées par numéro, 30 s au plus ;
- requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus)
après un chargement ;
- **les scripts** injectés, copiés du Go à l'octet près (le journal
compare leurs empreintes) :
- éléments actionnables et visibles ;
- shadow DOM ;
- nom d'un champ par son libellé, sans révéler un mot de passe ;
- recherche non destructive ;
- liste déroulante native réglée par son option ;
- **les outils**, mêmes textes :
- snapshot dédoublonné ;
- indices de défilement ;
- erreurs de navigation (`errorText`, page `chrome-error://`,
`ERR_ABORTED` bénin) ;
- `back` et `forward` ;
- touches nommées ;
- plafond de 8000 caractères ;
- **la capture** (`browser_screenshot`, vision active) :
- PNG propre rangé dans le dossier de travail
(`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ;
- image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ;
à 1280×800 elle est sous la taille maximale et part telle quelle ;
- relayée au modèle comme `see_image` ;
- **les aperçus** :
- après une action qui change la page, jamais après une erreur ;
- JPEG en mémoire, anneau de huit ;
- servis par `/api/chat/cushot`, que le Go remet désormais à la façade.
`ChatShared.browser` porte le navigateur du processus ;
`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu.
Un seul navigateur : quand la façade tient la conversation, le Go lui passe
ses propres appels (`facade_browser.go`) :
- outils des sous-agents et des tâches IA ;
- aperçus ;
- fermeture (interrupteur coupé, ligne de commande).
Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations :
`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go
prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux.
## Écarts connus
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe
rend le cas impossible).
## Parité
`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42
étapes (`tests/parity_browser/browser.json`) contre un faux Chrome :
- un script annonce le port d'un faux serveur CDP ;
- le faux CDP répond selon le scénario de chaque étape et note chaque
commande.
Chaque étape porte :
- le résultat ;
- les commandes CDP (méthode, paramètres, scripts résumés par leur
empreinte) ;
- pour une capture, ses dimensions et dix pixels de la grille ;
- les captures rangées.
Les arguments de lancement sont relevés aussi. Le journal couvre :
- ouverture, snapshot inchangé, réouverture complète ;
- snapshots vide, `null`, sans valeur, illisible, exception JS ;
- défilements ;
- clics par numéro, introuvable, refusé, en coordonnées ;
- saisies libre, dans un champ, liste déroulante, option absente, champ
introuvable ;
- touches ;
- recherches ;
- navigations en échec, page d'erreur, interruption bénigne, `back`,
`forward`, commande refusée ;
- captures sans vision, quadrillée, refusée ;
- sortie plafonnée.
`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG
de l'anneau) et la fermeture (profil retiré).
`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un
vrai Chromium sur une page locale : ouverture, recherche, saisie, liste
déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation
en échec.
D'autres tests complètent :
- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre
une page sans passer par le Go, avec aperçu ; l'API du Go et
`/api/chat/cushot` par une façade complète ;
- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et
fermeture à la façade, et se replie sur son navigateur si elle est en
panne ;
- tests unitaires : outils, nombres au format du Go, arguments, grille,
profils, lancement impossible.
@@ -0,0 +1,116 @@
# Lot 45 — Outils d'image de l'agent dans la façade
## Contexte
Deux outils de l'agent montrent une image au modèle :
- `see_image` charge un fichier image du disque dans la vision du
modèle ;
- `web_screenshot` photographie une page rendue par Playwright, la range
dans le dossier de la discussion et donne à l'agent la ligne markdown
qui la montre à l'utilisateur.
La façade tient la conversation par défaut, mais ces deux outils passaient
encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à
relayer dans `vision_img`.
## Le lot
`src/imagetools.rs` porte trois fichiers Go :
- `chat_vision_tool.go` (`see_image`) ;
- `chat_screenshot.go` (`web_screenshot`) ;
- `prepareImageForModel` (`web_upload_orient.go`).
Ce qui est porté :
- **la préparation de l'image**, la même que pour les pièces jointes :
- orientation EXIF d'une JPEG redressée dans les pixels ;
- grand côté ramené sous 1568 px (mêmes pixels que le Go) ;
- PNG réencodée en PNG, le reste en JPEG qualité 88 ;
- rien à faire, ou image que la bibliothèque standard du Go ne décode
pas (WebP, BMP, PNG cassée) : les octets d'origine ;
- **la sonde du moteur** (`engineSeesImages`) :
- preset externe : sa case « vision » ;
- sinon `GET /props` du moteur local, authentifié par la clé de
complétion, gardé 10 s ;
- appelée en dernier, après toutes les vérifications du fichier ;
- **`see_image`**, mêmes textes :
- vision coupée, poste distant, chemin manquant ;
- format non reconnu, fichier introuvable, dossier ;
- trop lourde (12 Mo au plus) ;
- moteur aveugle, fichier vide ;
- chemin relatif au dossier de travail de l'agent, ou absolu ;
- **`web_screenshot`**, mêmes textes :
- Playwright absent, url manquante ou invalide ;
- internet coupé : seule une adresse locale passe (`localhost`,
`127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme
le Go) ;
- mêmes arguments de Playwright (viewport, page entière, attente,
90 s au plus) ;
- erreur résumée par ses trois dernières lignes non vides ;
- aucune image ou image vide refusée ;
- capture rangée dans `captures/` du dossier de la discussion, nommée
par l'hôte et l'heure locale ;
- ménage : 20 captures et 40 Mo au plus, la nouvelle reste ;
- relayée au modèle (« Voici la capture demandée. ») seulement si la
vision est active et que le moteur voit les images.
`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool`
exécute les deux outils :
- `see_image` prend le dossier de travail et le poste visé du contexte
des outils (lot 7) ; contexte indisponible : le Go prend le relais ;
- `web_screenshot` range ses captures dans le dossier de la discussion
principale, sous le dossier de travail de l'agent, et les montre par
`ToolOutcome.image`.
Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go
garde les siens pour ses sous-agents et ses tâches IA.
## Écarts connus
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
## Parité
`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal
(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux
moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et
un faux Playwright (script qui note ses arguments et produit l'image, rien,
un fichier vide ou une erreur).
Chaque étape porte :
- le résultat, avec le texte relayé au modèle ;
- l'image relayée :
- identique à un fichier : son nom ;
- réencodée : son type et ses dimensions ;
- en PNG : cinq pixels ;
- la capture montrée, les arguments de Playwright, le nombre de sondes ;
- le dossier des captures.
Le journal couvre :
- `see_image` :
- vision coupée ;
- chemin vide, pas une image, introuvable, dossier, trop lourde ;
- moteur aveugle, fichier vide ;
- onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée,
grandes images réduites en largeur et en hauteur, sous-dossier,
chemin absolu ;
- `web_screenshot` :
- url manquante ou invalide, internet coupé ;
- capture locale sans vision, capture vue par le modèle ;
- page entière réduite ;
- vision mais moteur aveugle ;
- Playwright en échec, image vide, aucune image ;
- ménage des captures.
`tests/imagetools.rs` le rejoue contre le port. D'autres tests
complètent :
- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade
appelle les deux outils sans passer par le Go ;
- tests unitaires : types d'image, noms de capture, adresses locales,
dernières lignes, préparation, ménage, refus avant Playwright.
@@ -0,0 +1,97 @@
# Lot 46 — Outils recall et recall_search dans la façade
## Contexte
La compaction résume les vieux tours d'une discussion. Avant de résumer un
gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id
court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver :
- `recall(id)` rend le bloc exact ;
- `recall_search(query)` retrouve un bloc et son id par mots-clés, même
sorti du résumé courant.
La façade compacte elle-même et archive par le Go
(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la
base, que seul le Go tient. Les deux outils passaient encore par
`/internal/v1/tool/exec`.
## Le lot
`src/recall.rs` porte `toolRecall` et `toolRecallSearch`
(`chat_recall.go`) :
- **l'id tolérant** (`recallIDToSeq`) :
- espaces, un préfixe `r` puis un préfixe `R`, espaces encore ;
- des chiffres seulement : ni signe, ni dépassement de 64 bits ;
- un id mal formé ne lit pas l'archive ;
- **les mots-clés** (`recallTokens`) :
- mise en minuscules rune par rune, comme `strings.ToLower` (`İ`
donne `i`, `Σ` donne `σ`) ;
- coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ;
- deux runes au moins ;
- **le score** :
- un point par terme présent dans le contenu, deux dans le libellé ;
- à égalité, le plus récent d'abord ;
- 5 résultats par défaut, 20 au plus, limite tronquée comme
`int(float64)` ;
- **les textes**, mêmes que le Go :
- id ou requête manquants ;
- bloc introuvable (id cité par `%q`) ;
- bloc rappelé verbatim ;
- liste des résultats avec un extrait d'une ligne de 160 runes ;
- aucun résultat.
L'archive reste au Go, qui la sert à la façade :
- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est
absent ou chiffré sous une mémoire verrouillée ;
- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence
croissante.
`recallGet` et `recallSearch` du Go passent par les mêmes lectures
(`recallGetSeq`, `recallAll`).
`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go.
Si le Go ne répond pas, l'appel passe par lui.
## Parité
`internal/loki/parity_recall_test.go` archive 35 blocs variés :
- accents et casse Unicode ;
- balises et entités ;
- bloc vide ;
- premières lignes longues ;
- vingt-cinq blocs semblables.
Il tire ensuite du Go un journal de 41 appels
(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les
sert :
- `recall` :
- id absent, non texte, blanc ;
- variantes de préfixe et d'espaces ;
- zéros de tête ;
- mal formé, négatif, signe plus, zéro, absent, démesuré ;
- guillemets dans l'id ;
- `recall_search` :
- requête absente, non texte, blanche ;
- mots trop courts ;
- casse, accents, `İ`, grec hors des mots ;
- souligné coupé ;
- extraits bornés ;
- limites : une, tronquée, négative, par défaut, au plus, en texte
ignorée ;
- aucun résultat ;
- ponctuation.
`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie
aussi le repli quand le Go ne répond pas.
D'autres tests complètent :
- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle
et cherche sans passer par `/tool/exec` ;
- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq
illisible, méthode refusée, ordre) ;
- tests unitaires : ids, mots-clés, score, extraits, textes.
@@ -0,0 +1,115 @@
# Lot 47 — Outil subagent du mode Code dans la façade
## Contexte
En mode Code, le builder peut déléguer une question bornée à un rôle en
lecture seule :
- `explorer` cartographie le code ;
- `code-reviewer` relit un changement ;
- `planner` découpe un travail et pose les critères d'acceptation.
Le rôle travaille dans son propre contexte : le prompt du rôle, le
briefing machine et la tâche, rien de l'historique. Sa trace n'est ni
rangée ni affichée. Seul son texte final revient au builder, comme
résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui
évite de payer dix lectures de fichiers dans le fil principal.
La façade tient le tour du builder et sa passe de vérification, mais
`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait
tourner un `runChat` à lui.
## Le lot
`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`).
Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le
même ordre, avec les mêmes textes :
- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ;
- tâche vide ;
- appel depuis un tour qui n'est ni le fil principal ni le builder
(garde-fou anti-récursion).
Le contexte du rôle vient du Go
(`POST /internal/v1/turn/subagent {role}`), qui rend :
- le prompt du rôle suivi du briefing machine ;
- les outils du rôle ;
- ses capacités (agent, Code, mémoire coupée).
`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`.
Le tour du rôle tourne ici avec `run_turn`, comme la passe de
vérification :
- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt
sont ceux du tour qui délègue (`SubTurn`) ;
- la température est 0,6 (le TEMP du preset l'emporte) ;
- la tâche porte le cadrage du rôle :
- planner : critères et étapes, sans demander confirmation ;
- autres rôles : seulement les trouvailles, `fichier:ligne` ;
- c'est un travail annexe (`engineSideJob`) : second slot, puis slot
effacé à la fin pour que le builder reparte du cache RAM ;
- la télémétrie le compte à part (`subagent`) ;
- les critères posés par le planner s'affichent comme ceux du fil.
Le rapport se construit ainsi :
- seul le texte écrit après le dernier outil compte ;
- il est rogné de ses blancs, puis préfixé du rôle ;
- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ;
- vide : « n'a rien répondu » ;
- erreur du tour : « sous-agent <rôle> : <erreur> ».
Le tour du rôle est porté par `SubHost`. Ce `TurnHost` délègue tout au
`GoTurnHost` du rôle, sauf un nouvel appel à `subagent`, toujours refusé.
Les outils de la façade sont dans `GoTurnHost::exec_tool_base` ; seul le
`exec_tool` du tour principal y ajoute `subagent`. Ainsi le type du tour
imbriqué ne se contient pas lui-même.
Le tour du builder et ses relances par la boucle du contrat prêtent leur
`SubTurn`. La vérification aussi, mais son rôle la fait refuser.
## Parité
`internal/loki/parity_subagent_test.go` tire du Go un journal
(`tests/parity_subagent/subagent.json`) contre un faux moteur scripté. Les
vues des trois rôles sont rangées avec lui.
Pour chaque tour délégué, le journal garde chaque requête du rôle :
- système identique à la vue ;
- message de la tâche cadrée ;
- température ;
- outils ;
- nombre de messages.
Il couvre :
- les refus :
- rôle inconnu, `verifier`, rôle absent ;
- tâche vide ou absente ;
- récursion depuis un rôle ou depuis la vérification ;
- les tours :
- réponse directe rognée ;
- rapport après deux outils, narration écartée ;
- texte vide relancé puis remplacé, comme le fait le tour ;
- rien après l'outil ;
- rapport tronqué ;
- cadrage du planner ;
- moteur qui refuse.
`subagent_comme_le_go` (`tests/lot6.rs`) le rejoue :
- les refus par `precheck` ;
- les tours par un tour complet de la façade qui délègue, contre le même
script.
Il vérifie les requêtes du rôle, le rapport rendu au builder et le travail
annexe ouvert puis fermé.
D'autres tests complètent :
- `TestSubagentVueInterne` : la vue servie par le Go, rôles refusés ;
- tests unitaires : refus et leur ordre, rapports, cadrages.
+6 -1
View File
@@ -73,11 +73,16 @@ func writeAPIKey(key string) error {
}
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
// chars, elide the middle. Empty in → empty out.
// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets
// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et
// /api/apikey tombait à chaque lecture tant que la clé restait en place.
func maskAPIKey(k string) string {
if k == "" {
return ""
}
if len(k) <= 2 {
return "…"
}
if len(k) <= 13 {
return "…" + k[len(k)-2:]
}
+142 -8
View File
@@ -282,10 +282,96 @@ type dlState struct {
Part int `json:"part"` // tranche en cours (1-based)
Finished bool `json:"finished"`
Canceled bool `json:"canceled"`
Paused bool `json:"paused"`
Err string `json:"error"`
StartedAt int64 `json:"started_at"`
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
gate *dlGate `json:"-"`
}
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
// s'était arrêté, par une requête de plage.
type dlGate struct {
mu sync.Mutex
paused bool
wake chan struct{} // fermé à la reprise
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
next int
}
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
func (g *dlGate) setPaused(p bool) {
if g == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
if p == g.paused {
return
}
g.paused = p
if p {
g.wake = make(chan struct{})
for _, c := range g.attempt {
c()
}
return
}
close(g.wake)
}
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
func (g *dlGate) wait(ctx context.Context) bool {
if g == nil {
return ctx.Err() == nil
}
g.mu.Lock()
paused, wake := g.paused, g.wake
g.mu.Unlock()
if !paused {
return ctx.Err() == nil
}
select {
case <-wake:
return ctx.Err() == nil
case <-ctx.Done():
return false
}
}
func (g *dlGate) isPaused() bool {
if g == nil {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
return g.paused
}
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
if g == nil {
return ctx, func() {}
}
actx, cancel := context.WithCancel(ctx)
g.mu.Lock()
id := g.next
g.next++
g.attempt[id] = cancel
if g.paused {
cancel() // pause tombée entre wait et begin
}
g.mu.Unlock()
return actx, func() {
g.mu.Lock()
delete(g.attempt, id)
g.mu.Unlock()
cancel()
}
}
var (
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
}
dlDownloads[name] = st
dlMu.Unlock()
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
dt := now.Sub(lastAt).Seconds()
dlMu.Lock()
st.Done = cur
if dt > 0 {
if st.Paused {
st.Speed = 0
} else if dt > 0 {
inst := int64(float64(cur-last) / dt)
if st.Speed == 0 {
st.Speed = inst
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
return err
}
}
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
@@ -845,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
// dlFetch writes the whole body into f, either as one stream or as `conns`
// parallel byte ranges. done is incremented atomically as bytes land on disk.
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
if conns <= 1 {
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
}
size := total / int64(conns)
var wg sync.WaitGroup
@@ -861,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
wg.Add(1)
go func(i int, start, end int64) {
defer wg.Done()
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
}(i, start, end)
}
wg.Wait()
@@ -876,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
// dlChunk downloads [start,end] into f at the right offset, retrying from where
// it stopped if the connection drops mid-chunk. With whole=true it streams the
// entire body sequentially (server without range support, unknown size).
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
const attempts = 4
pos := start
var lastErr error
for try := 0; try < attempts; try++ {
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
if !g.wait(ctx) {
return ctx.Err()
}
if err := ctx.Err(); err != nil {
return err // cancelled: never retry
}
@@ -900,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
} else if pos > start {
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
}
req, err := dlRequest(ctx, dlURL, rng)
actx, release := g.begin(ctx)
req, err := dlRequest(actx, dlURL, rng)
if err != nil {
release()
return err
}
resp, err := dlClient.Do(req)
if err != nil {
release()
if g.isPaused() && ctx.Err() == nil {
try--
continue
}
lastErr = err
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
err := dlSourceError(resp, dlURL)
resp.Body.Close()
release()
return err
}
if resp.StatusCode == 200 && pos > start {
@@ -921,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
}
n, cerr := dlCopy(f, resp.Body, pos, done)
resp.Body.Close()
release()
pos += n
if cerr == nil {
return nil
}
if g.isPaused() && ctx.Err() == nil {
try-- // coupé par la pause : la reprise repart de pos
continue
}
lastErr = cerr
}
return lastErr
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
Pause bool `json:"pause"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
name := filepath.Base(strings.TrimSpace(req.Filename))
dlMu.Lock()
st, ok := dlDownloads[name]
if !ok || st.Finished {
dlMu.Unlock()
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
return
}
st.Paused = req.Pause
if req.Pause {
st.Speed = 0
}
g := st.gate
dlMu.Unlock()
g.setPaused(req.Pause)
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
}
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
// startup. A download killed by a crash or a service restart can't be resumed
// (its state lived in memory), so the partial file would otherwise sit there
+93
View File
@@ -231,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
t.Errorf("message %q sans indication sur le jeton", err)
}
}
type slowWriter struct{ http.ResponseWriter }
func (s slowWriter) Write(b []byte) (int, error) {
time.Sleep(2 * time.Millisecond)
return s.ResponseWriter.Write(b)
}
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
// fichier identique à la source (repris d'AJEAN v0.18.1).
func TestRunDownloadPauseResume(t *testing.T) {
data := make([]byte, 48<<20)
rand.New(rand.NewSource(2)).Read(data)
t.Setenv("LOKI_DL_CONNS", "4")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "m.gguf")
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
done := make(chan struct{})
go func() {
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
close(done)
}()
time.Sleep(300 * time.Millisecond)
st.gate.setPaused(true)
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
dlMu.Lock()
a := st.Done
dlMu.Unlock()
time.Sleep(1500 * time.Millisecond)
dlMu.Lock()
b, fin := st.Done, st.Finished
dlMu.Unlock()
if fin || a != b || a == 0 {
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
}
st.gate.setPaused(false)
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("pas terminé après la reprise")
}
if st.Err != "" {
t.Fatalf("erreur après reprise : %s", st.Err)
}
got, err := os.ReadFile(dest)
if err != nil || !bytes.Equal(got, data) {
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
}
}
// La route : pause puis reprise d'un téléchargement connu, refus pour
// l'inconnu et pour un téléchargement terminé.
func TestHandleModelDownloadPause(t *testing.T) {
dlMu.Lock()
saved := dlDownloads
dlDownloads = map[string]*dlState{
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
"b.gguf": {Filename: "b.gguf", Finished: true},
}
dlMu.Unlock()
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
post := func(body string) (int, string) {
w := httptest.NewRecorder()
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
return w.Code, strings.TrimSpace(w.Body.String())
}
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
t.Fatalf("pause : %d %s", code, out)
}
dlMu.Lock()
st := dlDownloads["a.gguf"]
paused, speed := st.Paused, st.Speed
dlMu.Unlock()
if !paused || speed != 0 || !st.gate.isPaused() {
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
}
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
t.Fatalf("reprise : %d %s", code, out)
}
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
if code, _ := post(body); code != 404 {
t.Errorf("%s : %d, attendu 404", body, code)
}
}
if code, _ := post(`[`); code != 400 {
t.Errorf("corps illisible : %d", code)
}
}
+21 -15
View File
@@ -29,7 +29,8 @@ import (
"time"
)
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub.
var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
type ghAsset struct {
Name string `json:"name"`
@@ -41,18 +42,25 @@ func prebuiltDir() string {
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
}
// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer
// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a
// alors que DEUX champs, et le format est le second.
func prebuiltVersionFields() []string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return nil
}
return strings.Fields(strings.TrimSpace(string(b)))
}
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
// (cudaVer vide hors CUDA Windows).
func prebuiltVersion() (tag, cudaVer string) {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return "", ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
f := prebuiltVersionFields()
if len(f) > 0 {
tag = f[0]
}
if len(f) > 1 {
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
cudaVer = f[1]
}
return
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
// sera refaite au lieu d'être déclarée « déjà à jour ».
const prebuiltFormat = "fmt2"
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION (""
// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA
// était vide (Linux, macOS) : chaque vérification réinstallait la release.
func prebuiltVersionFormat() string {
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
if len(f) > 2 {
return f[2]
f := prebuiltVersionFields()
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
return f[len(f)-1]
}
return ""
}
+1
View File
@@ -527,6 +527,7 @@ type serveSysInfo struct {
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
+22
View File
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
return
}
si.Draft = p
if a, err := os.Stat(p); err == nil {
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
si.DraftIsModel = true
}
}
if g, err := ggufMeta(p); err == nil {
si.DraftGGUF = &g
}
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
// (common_speculative_init_result charge tout fichier -md comme un modèle
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
// lieu de 22. On l'ignore : même tête, sans les poids en double.
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
c := make(map[string]string, len(cfg))
for k, v := range cfg {
c[k] = v
}
delete(c, "MODEL_DRAFT")
args, notes, auto = specArgs(c, extra, si)
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
}
mode := specMode(cfg)
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
+31
View File
@@ -1,6 +1,7 @@
package loki
import (
"os"
"reflect"
"slices"
"strings"
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
si: func(s *serveSysInfo) {
s.Draft = s.Model
s.DraftGGUF = s.GGUF
s.DraftIsModel = true
}},
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
t.Errorf("variable : %q", n)
}
}
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
func TestProbeSpecDraftIsModel(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
dir := t.TempDir()
model := dir + "/Swift-27B.gguf"
other := dir + "/mtp-Swift.gguf"
for _, p := range []string{model, other} {
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
if !si.DraftIsModel {
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
}
si = serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
if si.DraftIsModel || si.Draft != other {
t.Errorf("tête à part prise pour le modèle : %+v", si)
}
}
+206 -32
View File
@@ -5,11 +5,13 @@ package loki
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
// une carte de joueur.
//
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.0 — sources de Strata et
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.1 ») : le moteur est le paquet
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.1 — sources de Strata et
// binaire Linux CUDA 12 compilé et validé par l'auteur d'AJEAN (avec ses
// modifications : experts de la carte d'appoint gardés hors RAM). Les deux
// fichiers sont vérifiés par SHA-256 avant usage.
// modifications : experts de la carte d'appoint gardés hors RAM et, depuis la
// 1.1, calculés par elle à la lecture des prompts). Les deux fichiers sont
// vérifiés par SHA-256 avant usage. Un modèle installé avec la 1.0 passe à la
// 1.1 à son premier lancement (strataMigrate).
//
// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
@@ -47,22 +49,34 @@ import (
"os/exec"
)
// Paquet figé (release moe-v1.0 d'AJEAN). Le moteur est compilé en CUDA 12.8,
// Paquet figé (release moe-v1.1 d'AJEAN). Le moteur est compilé en CUDA 12.8,
// mode portable AVX2, sm_75/80/86/89/120 : RTX 20 à 50. Un moteur installé d'un
// autre paquet est remplacé au lancement (strataEnsureEngine).
// autre paquet est remplacé au lancement (strataEnsureEngine), une installation
// d'une version précédente est reprise (strataMigrate).
const (
strataVersion = "1.0"
strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
strataVersion = "1.1"
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
strataEngineLocal = "strata-linux-x64-cuda12.zip"
)
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
// strataReleaseBase : la release figée. Une variable pour que les tests la
// pointent sur un faux serveur.
var strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
// meminfoPath, strataDiskFree : la RAM et la place libre lues sur la machine,
// remplaçables par les tests (journal de parité de Strata).
var (
meminfoPath = "/proc/meminfo"
strataDiskFree = diskFree
)
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage
// (celles qu'AJEAN publie dans son backend_moe.go pour moe-v1.1).
var strataAssetSHA = map[string]string{
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c",
strataSrcAsset: "5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115",
strataEngineAsset: "8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3",
}
// Valeurs de la clé ENGINE d'un preset.
@@ -122,8 +136,14 @@ const strataLowRAMHeadroomGB = 10
// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la
// carte d'aide garde les siens hors RAM (mesuré par AJEAN : 46 Go de RAM,
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres).
const strataDropHeadroomGB = 7
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres ; le moteur 1.1 prend
// ~1 Go de plus que la 1.0).
const strataDropHeadroomGB = 8
// strataDropMaxCtx : contexte maximal en mode hors RAM. Le cache KV au-delà de
// la part en VRAM est verrouillé en RAM : à 167 936 jetons, le serveur de test
// d'AJEAN tombait à 1,3 Go libres dès la première requête (moteur 1.1).
const strataDropMaxCtx = 131072
// strataHelperExpertsGB : ce que la carte d'aide garde d'experts (sa VRAM moins
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
@@ -154,7 +174,7 @@ const (
// memAvailableGB : la RAM réellement disponible (MemAvailable : libre + cache
// récupérable), en Go. Hors Linux ou illisible : la RAM totale.
func memAvailableGB() float64 {
b, err := os.ReadFile("/proc/meminfo")
b, err := os.ReadFile(meminfoPath)
if err != nil {
return totalRAMGB()
}
@@ -200,7 +220,9 @@ func strataPackHasExpertsBin(args []string) bool {
// Pure : la décision se teste en table.
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
// (experts de la carte d'aide hors RAM : propre au mode d'aide, jamais en
// partage des couches, où la seconde carte ne garde pas d'experts)
dropReady := binReady && strataHelperOn(cfg) && !strataLayerSplit(cfg) && cfg["STRATA_DROP"] != "0"
arena := strataArenaGB(cfg)
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
case strataExpertsDisk:
@@ -284,7 +306,9 @@ const strataMinArch = 75
func strataDetect() strataEnv {
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
if f := diskFree(LokiHome()); f > 0 {
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
if f := strataDiskFree(strataHome()); f > 0 {
env.DiskFreeGB = float64(f) / 1e9
}
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
@@ -482,6 +506,7 @@ type strataDetails struct {
// pour les réglages : ce que la machine permet et l'état actuel
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
HelperOn bool `json:"helper_on"`
Split bool `json:"split_layers"` // les deux cartes se partagent les couches
Vision bool `json:"vision"`
}
@@ -543,6 +568,7 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
if strataHelperOn(pc) {
d.HelperOn = true
d.HelperGPU = gpuName(h)
d.Split = strataLayerSplit(pc)
}
}
d.Vision = pc["STRATA_VISION"] != "0"
@@ -774,7 +800,10 @@ func strataRunInstall(req strataInstallReq) {
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
"--low-ram", "off",
}
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1"
// XDG_CACHE_HOME : les caches de pip et de Hugging Face (~/.cache, soit
// /root dans le conteneur, donc l'image Docker — 20 Go sur Unraid) suivent
// le reste de Strata dans /data/strata.
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1\x00XDG_CACHE_HOME=" + filepath.Join(strataHome(), "cache")
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
return
@@ -855,7 +884,8 @@ func strataFindConfig(quant string) (string, error) {
//
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides ;
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides
// (48 avec une carte d'aide, voir strataBuildConfig) ;
// - --prompt-cache-root 256 : point de reprise posé à la fin du prompt système
// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s.
var strataTuning = map[string]string{
@@ -942,6 +972,16 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
}
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
hasHelper := strataHelperOn(cfg)
if strataLayerSplit(cfg) {
// Partage des couches : le serveur de Strata le fait de lui-même quand sa
// clé « gpu » liste plusieurs cartes (--layer-split auto, réparti selon la
// VRAM libre), numérotées comme nvidia-smi. Pas de chemin « aide » : la
// réserve de VRAM de l'installeur et l'encodeur d'images restent en place.
main, _ := strconv.Atoi(strings.TrimSpace(cfg["STRATA_MAIN_GPU"]))
second, _ := strconv.Atoi(helper)
out["gpu"] = []any{main, second}
hasHelper = false
}
// Deuxième carte en aide : elle garde et CALCULE sa part des experts (le
// chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
@@ -987,6 +1027,10 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
env["STRATA_STAGER_THREADS"] = "8"
env["STRATA_STAGER_RING"] = "32"
// sa VRAM libre réservée : 640 Mio (défaut 1024) = ~230 experts de plus
// hors RAM, l'encodeur d'images y tourne encore ; le moteur refuse moins
// de 512
env["STRATA_REMOTE_DROP_RESERVE_MIB"] = "640"
case "arena":
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
// le mappage n'en donne pas l'adresse GPU.
@@ -1002,8 +1046,22 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
k, v, _ := strings.Cut(kv, "=")
env[k] = v
}
if hasHelper {
// Lecture d'un prompt (moteur 1.1) : les experts que la carte d'aide garde
// sont calculés par elle au lieu d'être recopiés vers la principale par son
// lien PCIe (le chemin passe par la source « helper ») ; le chemin par blocs
// coûte alors moins cher, les ajouts courts n'en sont exclus que sous 48
// jetons. Mesuré par AJEAN : 100 nouveaux jetons 1,19 -> 0,85 s, 200 :
// 1,96 -> 1,12 s.
env["STRATA_PREFILL_HELPER_SOURCE"] = "1"
args = strataSetArg(args, "--short-read", "48")
}
out["env"] = env
if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" {
// hors RAM, le cache KV verrouillé au-delà de strataDropMaxCtx ne tient plus
if n, err := strconv.Atoi(ctx); err == nil && n > strataDropMaxCtx && env["STRATA_REMOTE_DROP"] == "1" {
ctx = strconv.Itoa(strataDropMaxCtx)
}
args = strataSetArg(args, "--max-context", ctx)
}
// réglages imposés par le preset (sinon : ceux de l'installeur)
@@ -1147,6 +1205,16 @@ func strataHelperOn(cfg map[string]string) bool {
return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0"
}
// strataLayerSplit : les deux cartes se partagent les COUCHES du modèle (clé de
// preset STRATA_SPLIT=layers) au lieu que la seconde n'aide que pour une part
// des experts. Mesuré par un utilisateur d'AJEAN sur deux RTX 3060 identiques
// (#121 amont) : la carte d'aide ne travaillait qu'à ~15 %, le partage des
// couches donnait +50 % en génération. Sur deux cartes très différentes, le
// mode d'aide reste le défaut mesuré : le partage est un choix, jamais imposé.
func strataLayerSplit(cfg map[string]string) bool {
return strataHelperOn(cfg) && cfg["STRATA_SPLIT"] == "layers"
}
// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0.
func strataCudaDevices(cfg map[string]string) string {
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
@@ -1169,17 +1237,99 @@ func strataPreflight(cfg map[string]string) error {
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path)
}
py := filepath.Join(filepath.Dir(path), ".venv", "bin", "python")
py := strataVenvPython(filepath.Dir(path))
if _, err := os.Stat(py); err != nil {
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
}
return nil
}
// strataVenvPython : le Python de l'environnement de Strata, dans ses sources.
func strataVenvPython(srcDir string) string { return filepath.Join(srcDir, ".venv", "bin", "python") }
// strataMigrate : un preset installé par une version précédente du paquet
// (STRATA_CONFIG dans strataHome()/ajean-moe-<ancienne>) passe à la version
// courante au lancement, sans réinstaller : les modèles et packs (data/)
// servent tels quels. Les sources de la version courante sont posées (~340 Mo
// avec le moteur), l'environnement Python de l'ancienne est repris par un lien
// quand les dépendances n'ont pas changé, le moteur compilé est installé par
// strataEnsureEngine, la config de l'installeur est recopiée avec ses chemins
// réécrits, et les presets qui la citaient comme la config vivante pointent sur
// la nouvelle. En cas d'échec rien n'est réécrit : l'ancienne version continue
// de tourner. Repris d'AJEAN (moeMigrate, AJEAN MoE 1.1).
func strataMigrate(cfg map[string]string) error {
old := strings.TrimSpace(cfg["STRATA_CONFIG"])
oldDir := filepath.Dir(old)
newDir := strataSrcDir()
if old == "" || oldDir == newDir || filepath.Dir(oldDir) != strataHome() || !strings.HasPrefix(filepath.Base(oldDir), "ajean-moe-") {
return nil
}
newPath := filepath.Join(newDir, filepath.Base(old))
if !isFile(newPath) {
raw, err := os.ReadFile(old)
if err != nil {
return err
}
if !isFile(filepath.Join(newDir, "setup.py")) {
src, err := strataFetch(strataSrcAsset)
if err != nil {
return err
}
if err := extractArchive(src, strataHome()); err != nil {
return fmt.Errorf("décompression : %w", err)
}
}
venv := filepath.Join(newDir, ".venv")
if _, err := os.Lstat(venv); err != nil {
a, _ := os.ReadFile(filepath.Join(oldDir, "requirements.txt"))
b, _ := os.ReadFile(filepath.Join(newDir, "requirements.txt"))
if len(a) == 0 || string(a) != string(b) || !isFile(strataVenvPython(oldDir)) {
return fmt.Errorf("dépendances Python changées : réinstalle le modèle depuis Paramètres → Moteur → Strata")
}
if err := os.Symlink(filepath.Join(oldDir, ".venv"), venv); err != nil {
return err
}
}
if err := os.MkdirAll(filepath.Join(newDir, "engine-cuda12"), 0o755); err != nil {
return err
}
if err := strataEnsureEngine(); err != nil {
return err
}
body := strings.ReplaceAll(string(raw), oldDir+string(os.PathSeparator), newDir+string(os.PathSeparator))
if err := os.WriteFile(newPath+".new", []byte(body), 0o644); err != nil {
return err
}
if err := os.Rename(newPath+".new", newPath); err != nil {
return err
}
}
// les presets qui citent l'ancienne config, puis la config vivante (les deux
// changent ensemble : l'empreinte du preset actif reste celle de la config)
if list, _ := filepath.Glob(filepath.Join(presetsDir(), "*.env")); len(list) > 0 {
for _, p := range list {
b, err := os.ReadFile(p)
if err != nil || !strings.Contains(string(b), "STRATA_CONFIG="+old+"\n") {
continue
}
_ = os.WriteFile(p, []byte(strings.ReplaceAll(string(b), "STRATA_CONFIG="+old+"\n", "STRATA_CONFIG="+newPath+"\n")), 0o644)
}
}
if err := SetConfigKey("STRATA_CONFIG", newPath); err != nil {
return err
}
cfg["STRATA_CONFIG"] = newPath
fmt.Fprintf(os.Stderr, "[loki serve] paquet AJEAN MoE %s : installation reprise depuis %s\n", strataVersion, filepath.Base(oldDir))
return nil
}
// serveStrata : la branche Strata de `loki serve`. Écrit la config finale à
// côté de celle de l'installeur puis remplace le process par le serveur de
// Strata (comme pour llama-server : le superviseur suit directement le moteur).
func serveStrata(cfg map[string]string) error {
if err := strataMigrate(cfg); err != nil {
fmt.Fprintf(os.Stderr, "[loki serve] passage au paquet AJEAN MoE %s impossible (%v) : la version installée reste utilisée\n", strataVersion, err)
}
if err := strataPreflight(cfg); err != nil {
return err
}
@@ -1208,7 +1358,7 @@ func serveStrata(cfg map[string]string) error {
return err
}
srcDir := filepath.Dir(path)
py := filepath.Join(srcDir, ".venv", "bin", "python")
py := strataVenvPython(srcDir)
tag := strataConfigTag(path)
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
@@ -1311,6 +1461,9 @@ type strataSettingsReq struct {
Vision bool `json:"vision"`
Helper bool `json:"helper"`
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
// Split : rôle de la seconde carte, « helper » (aide aux experts) ou
// « layers » (partage des couches). Vide = inchangé (anciens clients).
Split string `json:"split"`
}
// strataApplySettings réécrit les clés du preset (pure, testable).
@@ -1325,6 +1478,8 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
case r.Split != "" && r.Split != "helper" && r.Split != "layers":
return "", fmt.Errorf("répartition inconnue : %s", r.Split)
}
set := map[string]string{
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
@@ -1335,6 +1490,14 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
set["STRATA_EXPERTS"] = r.Experts
keys = append(keys, "STRATA_EXPERTS")
}
if r.Split != "" {
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
set["STRATA_SPLIT"] = ""
if r.Split == "layers" {
set["STRATA_SPLIT"] = "layers"
}
keys = append(keys, "STRATA_SPLIT")
}
var lines []string
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
@@ -1344,6 +1507,9 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
lines = append(lines, l)
}
for _, k := range keys {
if set[k] == "" && k == "STRATA_SPLIT" {
continue
}
lines = append(lines, k+"="+set[k])
}
return strings.Join(lines, "\n") + "\n", nil
@@ -1375,25 +1541,33 @@ func handleStrataSettings(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
// « Actif » se décide AVANT l'écriture (comme SavePresetApplying) : c'est
// l'ancien contenu qui porte l'empreinte de la configuration courante. Lu
// après, un réglage changé faisait passer le preset en service pour
// inactif, et rien n'était appliqué.
active := false
if list, err := ListPresets(); err == nil {
for _, p := range list {
if p.ID == req.Preset && p.Active {
active = true
}
}
}
path := filepath.Join(presetsDir(), req.Preset+".env")
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted := false
if list, err := ListPresets(); err == nil {
for _, p := range list {
if p.ID == req.Preset && p.Active {
if tuneDenyHTTP(w) {
return
}
if err := SwitchToPreset(path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted = true
}
if active {
if tuneDenyHTTP(w) {
return
}
if err := SwitchToPreset(path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted = true
}
sendJSON(w, 200, map[string]any{"ok": true, "restarted": restarted})
}
@@ -0,0 +1,133 @@
package loki
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// Moteur 1.1 : avec une carte d'aide, la lecture d'un prompt passe par elle
// (source « helper ») et le chemin direct ne garde que les ajouts sous 48 jetons.
func TestStrataBuildConfigHelperPrompt(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p"}}
out, _ := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
if argAfter(strataArgs(t, out), "--short-read") != "48" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != "1" {
t.Errorf("carte d'aide : lecture courte et source d'aide attendues : %v %v", strataArgs(t, out), out["env"])
}
// carte d'aide coupée ou absente : réglages d'avant
for _, cfg := range []map[string]string{{}, {"STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}} {
out, _ = strataBuildConfig(base, cfg, "", "ram")
if argAfter(strataArgs(t, out), "--short-read") != "150" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != nil {
t.Errorf("une carte (%v) : %v %v", cfg, strataArgs(t, out), out["env"])
}
}
}
// Mode hors RAM : réserve de VRAM de la carte d'aide abaissée, contexte plafonné.
func TestStrataBuildConfigDropReserveAndCtx(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p"}, "env": map[string]any{}}
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "CTX": "167936"}
out, _ := strataBuildConfig(base, cfg, "", "drop")
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != "640" {
t.Errorf("réserve de la carte d'aide : %v", out["env"])
}
if got := argAfter(strataArgs(t, out), "--max-context"); got != "131072" {
t.Errorf("contexte hors RAM non plafonné : %s", got)
}
// hors du mode hors RAM : le contexte demandé
for _, mode := range []string{"ram", "arena", "disk"} {
out, _ = strataBuildConfig(base, cfg, "", mode)
if got := argAfter(strataArgs(t, out), "--max-context"); got != "167936" {
t.Errorf("%s : contexte plafonné sans mode hors RAM : %s", mode, got)
}
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != nil {
t.Errorf("%s : réserve du mode hors RAM posée : %v", mode, out["env"])
}
}
}
// Un modèle installé avec le paquet 1.0 passe au paquet courant à son premier
// lancement, sans réinstallation.
func TestStrataMigrate(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("lien symbolique : Strata ne tourne que sous Linux")
}
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
newDir := strataSrcDir()
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), filepath.Join(newDir, "engine-cuda12"), presetsDir()} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
for _, d := range []string{oldDir, newDir} {
_ = os.WriteFile(filepath.Join(d, "requirements.txt"), []byte("numpy\n"), 0o644)
}
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
old := filepath.Join(oldDir, "strata-swift-iq3_xxs.json")
_ = os.WriteFile(old, []byte(`{"exe": "`+filepath.Join(oldDir, "engine-cuda12", "strata")+`", "args": []}`), 0o644)
preset := filepath.Join(presetsDir(), "P.env")
_ = os.WriteFile(preset, []byte("ENGINE=strata\nSTRATA_CONFIG="+old+"\nCTX=1\n"), 0o644)
other := filepath.Join(presetsDir(), "L.env")
_ = os.WriteFile(other, []byte("MODEL=/m.gguf\n"), 0o644)
if err := SetConfigKey("STRATA_CONFIG", old); err != nil {
t.Fatal(err)
}
cfg := map[string]string{"ENGINE": "strata", "STRATA_CONFIG": old}
if err := strataMigrate(cfg); err != nil {
t.Fatal(err)
}
want := filepath.Join(newDir, "strata-swift-iq3_xxs.json")
if cfg["STRATA_CONFIG"] != want || ReadConfig()["STRATA_CONFIG"] != want {
t.Fatalf("config vivante : %q", cfg["STRATA_CONFIG"])
}
b, _ := os.ReadFile(want)
if strings.Contains(string(b), oldDir) || !strings.Contains(string(b), filepath.Join(newDir, "engine-cuda12")) {
t.Errorf("chemins non réécrits : %s", b)
}
if p, _ := os.ReadFile(preset); !strings.Contains(string(p), "STRATA_CONFIG="+want+"\n") {
t.Errorf("preset non réécrit : %s", p)
}
if p, _ := os.ReadFile(other); string(p) != "MODEL=/m.gguf\n" {
t.Errorf("preset d'un autre moteur touché : %s", p)
}
if !isFile(strataVenvPython(newDir)) {
t.Error("environnement Python non repris")
}
// déjà à jour : rien à faire
if err := strataMigrate(cfg); err != nil || cfg["STRATA_CONFIG"] != want {
t.Errorf("seconde passe : %v %q", err, cfg["STRATA_CONFIG"])
}
}
// Dépendances Python changées : rien n'est réécrit, l'ancienne version tourne.
func TestStrataMigrateRequirementsChanged(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
newDir := strataSrcDir()
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), newDir} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
_ = os.WriteFile(filepath.Join(oldDir, "requirements.txt"), []byte("numpy\n"), 0o644)
_ = os.WriteFile(filepath.Join(newDir, "requirements.txt"), []byte("numpy\ntorch\n"), 0o644)
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
old := filepath.Join(oldDir, "strata-iq3_xxs.json")
_ = os.WriteFile(old, []byte(`{"args": []}`), 0o644)
cfg := map[string]string{"STRATA_CONFIG": old}
if err := strataMigrate(cfg); err == nil || !strings.Contains(err.Error(), "dépendances") {
t.Fatalf("erreur de dépendances attendue : %v", err)
}
if cfg["STRATA_CONFIG"] != old || isFile(filepath.Join(newDir, "strata-iq3_xxs.json")) {
t.Errorf("config réécrite malgré l'échec : %q", cfg["STRATA_CONFIG"])
}
// un preset hors de strata/ (copié à la main) n'est pas touché
cfg = map[string]string{"STRATA_CONFIG": filepath.Join(home, "ailleurs", "ajean-moe-1.0", "strata-x.json")}
if err := strataMigrate(cfg); err != nil {
t.Errorf("config hors de strata/ : %v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Partage des couches (#121 amont) : la clé « gpu » liste les deux cartes (le
// serveur de Strata en déduit --layer-split), et rien du mode d'aide ne subsiste.
func TestStrataBuildConfigLayerSplit(t *testing.T) {
base := map[string]any{
"args": []any{"--pack", "/p", "--vram-reserve-mib", "900"},
"vision": map[string]any{"exe": "/v"},
}
cfg := map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_SPLIT": "layers"}
out, err := strataBuildConfig(base, cfg, "", "drop")
if err != nil {
t.Fatal(err)
}
g, ok := out["gpu"].([]any)
if !ok || len(g) != 2 || g[0] != 1 || g[1] != 0 {
t.Fatalf("gpu = %v, attendu [1 0] (principale d'abord, numéros nvidia-smi)", out["gpu"])
}
a := strataArgs(t, out)
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
t.Errorf("options du mode d'aide en partage des couches : %v", a)
}
if argAfter(a, "--vram-reserve-mib") != "900" {
t.Errorf("la réserve de VRAM de l'installeur doit rester : %v", a)
}
if argAfter(a, "--short-read") != "150" {
t.Errorf("lecture des prompts par la carte d'aide en partage des couches : %v", a)
}
env := out["env"].(map[string]any)
if env["STRATA_REMOTE_DROP"] != nil || env["STRATA_PREFILL_HELPER_SOURCE"] != nil {
t.Errorf("experts hors RAM ou source d'aide (mode d'aide) en partage des couches : %v", env)
}
if _, has := out["vision"].(map[string]any)["cuda_device"]; has {
t.Errorf("encodeur d'images déplacé sur la seconde carte en partage des couches")
}
// Sans la clé, rien ne change : le mode d'aide reste le défaut.
delete(cfg, "STRATA_SPLIT")
out, _ = strataBuildConfig(base, cfg, "", "drop")
if _, has := out["gpu"]; has {
t.Errorf("clé gpu posée en mode d'aide : %v", out["gpu"])
}
if !strataHasArg(strataArgs(t, out), "--remote-expert-opt") {
t.Errorf("mode d'aide perdu sans STRATA_SPLIT")
}
// Seconde carte coupée : pas de partage même si la clé traîne.
cfg["STRATA_SPLIT"], cfg["STRATA_HELPER"] = "layers", "0"
if strataLayerSplit(cfg) {
t.Errorf("partage des couches avec la seconde carte coupée")
}
}
// En partage des couches, la seconde carte ne garde pas d'experts : le mode
// hors RAM (qui compte sur elle pour en loger une part) n'est jamais choisi.
func TestStrataResolveExpertsLayerSplit(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
pack := filepath.Join(home, "pack")
_ = os.MkdirAll(pack, 0o755)
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
args := []string{"--pack", pack}
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
t.Fatalf("mode d'aide : %s, attendu drop", got)
}
cfg["STRATA_SPLIT"] = "layers"
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
t.Errorf("partage des couches : %s, attendu arena", got)
}
cfg["STRATA_EXPERTS"] = "ram"
if got := strataResolveExperts(cfg, args, 44, 12); got != "ram" {
t.Errorf("partage des couches, experts en RAM : %s, attendu ram", got)
}
}
func TestStrataApplySettingsSplit(t *testing.T) {
in := "ENGINE=strata\nSTRATA_HELPER_GPU=0\n"
req := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Vision: true, Helper: true, Split: "layers"}
out, err := strataApplySettings(in, req)
if err != nil || !strings.Contains(out, "STRATA_SPLIT=layers\n") {
t.Fatalf("STRATA_SPLIT=layers attendu : %q (%v)", out, err)
}
req.Split = "helper"
if out, _ = strataApplySettings(out, req); strings.Contains(out, "STRATA_SPLIT") {
t.Errorf("retour au mode d'aide : la clé doit disparaître : %q", out)
}
// un ancien client n'envoie pas « split » : la clé est conservée
req.Split = ""
out, _ = strataApplySettings("STRATA_SPLIT=layers\n", req)
if !strings.Contains(out, "STRATA_SPLIT=layers") {
t.Errorf("clé effacée par un client qui ne la connaît pas : %q", out)
}
req.Split = "nimporte"
if _, err := strataApplySettings(in, req); err == nil {
t.Errorf("répartition inconnue acceptée")
}
}
func TestStrataDetailsForLayerSplit(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
base := filepath.Join(home, "base.json")
_ = os.WriteFile(base, []byte(`{"args":["--pack","/p"],"vision":{"exe":"v"}}`), 0o644)
_ = os.MkdirAll(presetsDir(), 0o755)
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_SPLIT=layers\n"
_ = os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644)
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}, {Index: 1, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}}}
d := strataDetailsFor("X", env)
if d == nil {
t.Fatal("pas de détails")
}
if !d.Split || !d.HelperOn || d.HelperGPU == "" || d.VisionGPU != d.MainGPU || d.ShortRead != "150" {
t.Errorf("%+v", *d)
}
}
+16 -1
View File
@@ -184,7 +184,7 @@ func TestStrataDetailsFor(t *testing.T) {
}
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
d.ShortRead != "48" || d.CacheRoot != "256" || d.Prefill != "16384" {
t.Errorf("%+v", *d)
}
}
@@ -362,3 +362,18 @@ func TestStrataApplySettingsExperts(t *testing.T) {
t.Errorf("vide doit laisser le choix en place : %q", out)
}
}
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
}
}
+1 -1
View File
@@ -552,7 +552,7 @@ func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (out *tuneResu
res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg),
PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(),
Weights: tuneWeightsFrom(perfLog.snapshot())}
Weights: tuneWeightsFrom(perfSnapshot())}
// Référence : la ligne composée telle quelle, la sonde de la machine.
tr.setPhase("référence : composition de la ligne de commande")
+56 -6
View File
@@ -242,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
}
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
// chaque étape suivante relançait un résumé complet voué au même échec : plus
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
// encore vaut mieux que déborder.
func compactRetryDue(retryAt, used int, msgs []Message) bool {
if retryAt <= 0 {
return true
}
est := compactUsedOrEstimate(used, msgs)
return est >= retryAt || est*10 >= ctxWindow()*9
}
// compactNextRetry : seuil du prochain essai après une compaction en cours de
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
// de la fenêtre.
func compactNextRetry(changed bool, used int, msgs []Message) int {
if changed {
return 0
}
est := compactUsedOrEstimate(used, msgs)
next := est + ctxWindow()/20
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
return next
}
func compactUsedOrEstimate(used int, msgs []Message) int {
if used > 0 {
return used
}
return estimateTokens(msgs)
}
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
@@ -437,18 +473,24 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
var index []recallEntry
if caps.Agent && compactEnabled() {
names := torsoToolNames(torso)
var blocks []recallArchiveInput
var at []int // position dans le torse de chaque bloc
for i, m := range torso {
if !recallEligible(m) {
continue
}
content := msgText(m)
label := recallLabel(m.Role, names[m.ToolCallID], content)
id, aerr := archiveRecallBlock(label, m.Role, content)
if aerr != nil {
continue
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
at = append(at, i)
}
// Une seule transaction pour tous les blocs (AJEAN #130).
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
for k, e := range entries {
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
index = append(index, e)
}
archived[i] = &recallEntry{id: id, label: label}
index = append(index, recallEntry{id: id, label: label})
} else if len(blocks) > 0 {
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
}
}
@@ -528,6 +570,14 @@ func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt com
} else {
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
}
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
// 68659e73).
if err != nil {
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
} else if summaryLooksEmpty(summary) {
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
}
var mid []Message
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
+32
View File
@@ -0,0 +1,32 @@
package loki
import "testing"
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
// fenêtre — sauf à 90 % de la fenêtre ou plus.
func TestCompactRetryBackoff(t *testing.T) {
testHome(t)
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
t.Fatal(err)
}
if !compactRetryDue(0, 70000, nil) {
t.Fatal("sans échec précédent, l'essai doit être permis")
}
next := compactNextRetry(false, 70000, nil)
if next != 75000 {
t.Fatalf("prochain essai à %d, attendu 75000", next)
}
if compactRetryDue(next, 72000, nil) {
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
}
if !compactRetryDue(next, 75000, nil) {
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
}
if !compactRetryDue(200000, 90000, nil) {
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
}
if compactNextRetry(true, 70000, nil) != 0 {
t.Fatal("une compaction réussie doit lever le garde-fou")
}
}
+6 -1
View File
@@ -88,6 +88,8 @@ type Conversation struct {
// errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le
// même verrou (backend_tune_job.go).
leaseWhy error
// extLease : le verrou est tenu par la façade Rust (web_internal_lease.go).
extLease *extLeaseState
// 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
@@ -200,6 +202,7 @@ func LoadConversation() {
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
conv.Generating = false
conv.cancel = nil
conv.extLeaseClearLocked()
}
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
@@ -235,6 +238,7 @@ func (c *Conversation) loadFrom(id string, b []byte) {
if !c.benching {
c.Generating = false
c.cancel = nil
c.extLeaseClearLocked() // le tour de la façade est rompu, comme un tour Go
}
c.epoch++
c.cond.Broadcast()
@@ -1199,7 +1203,8 @@ func (c *Conversation) Reset() {
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
c.leaseWhy = nil
c.cancel = nil
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
c.extLeaseClearLocked() // Reset débloque aussi un tour de la façade
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
c.cond.Broadcast()
c.mu.Unlock()
c.persist()
+3 -2
View File
@@ -378,11 +378,12 @@ func exportFilename(ext string) string {
// exportBody rend la conversation selon les options. Renvoie le corps, son type
// MIME et l'extension de fichier.
func exportBody(o exportOpts) ([]byte, string, string, error) {
src := exportSource() // la façade peut tenir la conversation (facade_chat.go)
if o.Format == "json" {
b, err := conv.ExportJSON(o)
b, err := src.ExportJSON(o)
return b, "application/json; charset=utf-8", "json", err
}
return []byte(conv.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil
return []byte(src.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil
}
// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard).
+10 -3
View File
@@ -347,6 +347,9 @@ var (
const pageCacheTTL = 10 * time.Minute
// webNow : l'horloge du cache des pages (fixée par le journal de parité).
var webNow = time.Now
type fetchOptions struct {
force bool
actions []string
@@ -371,7 +374,7 @@ func findCached(rawURL string) *cacheEntry {
var best *cacheEntry
for k, v := range pageCache {
if k == u || strings.HasPrefix(k, u+"#") {
if time.Since(v.fetchedAt) > pageCacheTTL {
if webNow().Sub(v.fetchedAt) > pageCacheTTL {
continue
}
if best == nil || v.fetchedAt.After(best.fetchedAt) {
@@ -388,7 +391,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
pageCacheMu.Lock()
cached := pageCache[key]
pageCacheMu.Unlock()
if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
if !opts.force && cached != nil && webNow().Sub(cached.fetchedAt) < pageCacheTTL {
return cached, nil
}
@@ -418,7 +421,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
if strings.TrimSpace(md) == "" {
return nil, fmt.Errorf("page vide")
}
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: webNow()}
pageCacheMu.Lock()
pageCache[key] = entry
pageCacheMu.Unlock()
@@ -493,6 +496,10 @@ func decodeHTMLEntities(s string) string {
})
}
// ddgHTMLBase : le point d'entrée HTML de DuckDuckGo lu par le moteur intégré
// (goSearch ; un faux pour les tests).
var ddgHTMLBase = "https://html.duckduckgo.com"
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
func decodeUddg(raw string) string {
s := raw
+4
View File
@@ -15,6 +15,10 @@ import (
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
// mem_add, mem_edit.
//
// Port Rust des outils (lot 40) : rust/loki-rs/src/memory/tools.rs, employé
// par les tours de la façade quand elle tient la mémoire (LOKI_MEM=rust) ;
// journal de parité dans parity_mem_tools_test.go.
type MemPage struct {
Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
+9 -1
View File
@@ -245,6 +245,8 @@ func prewarmNearMidnight(now time.Time) bool {
// Points d'accroche remplaçables par les tests.
var (
// prewarmNow : l'heure du garde-fou de minuit.
prewarmNow = time.Now
// prewarmAsync : false = le préchauffage tourne dans l'appelant (tests).
prewarmAsync = true
// prewarmSlots : nombre de slots du moteur local (/props total_slots).
@@ -265,10 +267,16 @@ var (
)
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
// Quand la façade tient la conversation, le déclencheur lui part
// (facade_prewarm.go) : c'est elle qui a l'historique.
func (c *Conversation) prewarmKick(why string) {
if prewarmMode(ReadConfig()) == "" {
return
}
if facadeChat() {
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm", map[string]any{"why": why}) }()
return
}
if prewarmAsync {
go c.prewarm(why)
return
@@ -375,7 +383,7 @@ func (c *Conversation) prewarm(why string) {
return
}
}
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) {
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(prewarmNow()) {
return
}
if !healthCheck() {
+5
View File
@@ -79,6 +79,11 @@ func (m *moteurPrewarm) completions() []string {
// prewarmSetup : base neuve, discussion active, faux moteur à un slot,
// préchauffage synchrone. keys : réglages posés.
func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm {
// Le préchauffage se tait dans les dix minutes avant minuit : midi ici,
// pour que les tests ne dépendent pas de l'heure à laquelle ils tournent.
oldNow := prewarmNow
prewarmNow = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local) }
t.Cleanup(func() { prewarmNow = oldNow })
t.Helper()
withWorkspace(t)
activeProjMu.Lock()
+105 -60
View File
@@ -78,58 +78,88 @@ func recallIDToSeq(id string) (uint64, bool) {
return n, true
}
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
// l'appelant se rabat sur le compactage classique (troncature sans id).
func archiveRecallBlock(label, role, content string) (string, error) {
// recallArchiveInput : un bloc à archiver (voir archiveRecallBlocks).
type recallArchiveInput struct {
Label string
Role string
Content string
}
// archiveRecallBlocks enregistre tous les blocs d'une compaction dans UNE
// transaction et renvoie leurs entrées, dans l'ordre des blocs fournis. Les ids
// sont alloués par NextSequence dans la transaction : monotones, sans
// collision, même si deux compactages se croisent. Une transaction par bloc
// payait une ouverture de la base et une synchronisation disque chacune, sous
// le verrou global de la base, en plein tour (AJEAN #130). Tout ou rien : en
// cas d'échec (base indisponible, ex. tests sans LOKI_HOME, mémoire
// verrouillée) aucun id n'est rendu, et l'appelant se rabat sur le compactage
// classique (troncature sans id).
func archiveRecallBlocks(blocks []recallArchiveInput) ([]recallEntry, error) {
if len(blocks) == 0 {
return nil, nil
}
// 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
return nil, err
}
var id string
now := time.Now().Unix()
var entries []recallEntry
err = withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
entries = entries[:0]
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
if err != nil {
return err
}
seq, err := b.NextSequence()
if err != nil {
return err
for _, in := range blocks {
seq, err := b.NextSequence()
if err != nil {
return err
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: in.Label,
Role: in.Role,
Content: in.Content,
Created: now,
}
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré
// comme le reste quand la mémoire l'est (l'encodeur rend le clair
// inchangé si le chiffrement est inactif ; verrouillé,
// memEncoderNow a déjà refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
if err := b.Put(recallKey(seq), enc); err != nil {
return err
}
entries = append(entries, recallEntry{id: blk.ID, label: blk.Label})
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: label,
Role: role,
Content: content,
Created: time.Now().Unix(),
}
id = blk.ID
raw, err := json.Marshal(blk)
if err != nil {
return err
}
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
// refusé plus haut).
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put(recallKey(seq), enc)
return nil
})
})
if err != nil {
return "", err
return nil, err
}
cacheBust(bkRecall)
return id, nil
return entries, nil
}
// archiveRecallBlock enregistre un seul bloc et renvoie son id ("r<seq>").
func archiveRecallBlock(label, role, content string) (string, error) {
e, err := archiveRecallBlocks([]recallArchiveInput{{Label: label, Role: role, Content: content}})
if err != nil {
return "", err
}
return e[0].id, nil
}
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
@@ -138,6 +168,11 @@ func recallGet(id string) (recallBlock, bool) {
if !ok {
return recallBlock{}, false
}
return recallGetSeq(seq)
}
// recallGetSeq ramène un bloc par sa séquence (aussi servi à la façade).
func recallGetSeq(seq uint64) (recallBlock, bool) {
var blk recallBlock
found := false
_ = view(bkRecall, func(b *bolt.Bucket) error {
@@ -155,6 +190,27 @@ func recallGet(id string) (recallBlock, bool) {
return blk, found
}
// recallAll : tous les blocs lisibles, par séquence croissante (un bloc
// chiffré sous une mémoire verrouillée n'en fait pas partie).
func recallAll() []recallBlock {
var out []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
}
if json.Unmarshal(dec, &blk) != nil {
return nil
}
out = append(out, blk)
return nil
})
})
return out
}
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
// Score = nombre de termes distincts de la requête présents dans (libellé +
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
@@ -176,33 +232,22 @@ func recallSearch(query string, limit int) []recallBlock {
score int
}
var hits []scored
_ = view(bkRecall, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var blk recallBlock
dec, err := decodeMemContent(v)
if err != nil {
return nil // chiffré et verrouillé : ce bloc ne participe pas
for _, blk := range recallAll() {
label := strings.ToLower(blk.Label)
body := strings.ToLower(blk.Content)
score := 0
for t := range terms {
if strings.Contains(label, t) {
score += 2
}
if json.Unmarshal(dec, &blk) != nil {
return nil
if strings.Contains(body, t) {
score++
}
label := strings.ToLower(blk.Label)
body := strings.ToLower(blk.Content)
score := 0
for t := range terms {
if strings.Contains(label, t) {
score += 2
}
if strings.Contains(body, t) {
score++
}
}
if score > 0 {
hits = append(hits, scored{blk, score})
}
return nil
})
})
}
if score > 0 {
hits = append(hits, scored{blk, score})
}
}
sort.Slice(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
+44
View File
@@ -221,3 +221,47 @@ func TestCompactSummaryUserMsg(t *testing.T) {
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
}
}
// Les blocs d'une compaction s'écrivent en une seule transaction : ids dans
// l'ordre des blocs, croissants, chacun relisible mot pour mot.
func TestArchiveRecallBlocksBatch(t *testing.T) {
testHome(t)
if e, err := archiveRecallBlocks(nil); err != nil || e != nil {
t.Fatalf("lot vide : %v, %v", e, err)
}
in := []recallArchiveInput{
{Label: "un", Role: "tool", Content: bigBlock("PREMIER")},
{Label: "deux", Role: "user", Content: bigBlock("SECOND")},
{Label: "trois", Role: "assistant", Content: bigBlock("TROISIEME")},
}
entries, err := archiveRecallBlocks(in)
if err != nil {
t.Fatal(err)
}
if len(entries) != len(in) {
t.Fatalf("%d entrées pour %d blocs", len(entries), len(in))
}
var last uint64
for i, e := range entries {
seq, ok := recallIDToSeq(e.id)
if !ok || seq <= last {
t.Fatalf("id %q non croissant (précédent %d)", e.id, last)
}
last = seq
if e.label != in[i].Label {
t.Fatalf("entrée %d : libellé %q, attendu %q", i, e.label, in[i].Label)
}
blk, ok := recallGet(e.id)
if !ok || blk.Content != in[i].Content || blk.Role != in[i].Role {
t.Fatalf("bloc %s mal relu : %+v", e.id, blk)
}
}
// Un archivage isolé suit la même séquence.
id, err := archiveRecallBlock("quatre", "tool", bigBlock("QUATRE"))
if err != nil {
t.Fatal(err)
}
if seq, _ := recallIDToSeq(id); seq != last+1 {
t.Fatalf("id isolé %q, attendu r%d", id, last+1)
}
}
+7 -3
View File
@@ -41,11 +41,15 @@ func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
}
testHome(t)
start := time.Now()
out := runShell(context.Background(), "sleep 30 & echo lance", 2)
if d := time.Since(start); d > 10*time.Second {
// Délai de 10 s, bien au-delà de WaitDelay (2 s) : c'est le détachement
// qui rend la main, pas le délai de la commande.
out := runShell(context.Background(), "sleep 30 & echo lance", 10)
if d := time.Since(start); d > 8*time.Second {
t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
}
if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
// La commande a fini (exit 0) : sa sortie est rendue, pas effacée par
// « WaitDelay expired » (AJEAN 7403a8a0).
if !strings.Contains(out, "exit: 0") || !strings.Contains(out, "lance") || !strings.Contains(out, "arrière-plan") {
t.Errorf("sortie inattendue : %q", out)
}
}
+11
View File
@@ -297,6 +297,14 @@ 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()
// ErrWaitDelay : la commande s'est terminée normalement, mais un process
// laissé en arrière-plan tenait encore les tubes. Ce qu'elle a écrit est
// déjà capturé : le rendre, au lieu de « [erreur: exec: WaitDelay expired
// before I/O complete] » qui l'effaçait (repris d'AJEAN 7403a8a0).
detached := errors.Is(err, exec.ErrWaitDelay)
if detached {
err = nil
}
// 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)) }
@@ -337,6 +345,9 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
if errOut != "" {
parts = append(parts, "stderr:\n"+errOut)
}
if detached {
parts = append(parts, "(un process lancé en arrière-plan tourne toujours ; sa sortie n'est plus suivie — pour un serveur, utilise bash_bg)")
}
return strings.Join(parts, "\n\n")
}
+3
View File
@@ -238,6 +238,9 @@ func tplCapsCurrent() (tplProbeResult, bool) {
if externalActive() {
return tplProbeResult{}, false
}
if facadeChat() { // la façade sonde après SES complétions (facade_chat.go)
return facadeTplCaps()
}
tplProbeMu.Lock()
defer tplProbeMu.Unlock()
if tplProbeLast == nil {
+5 -2
View File
@@ -46,6 +46,9 @@ type imageResult struct {
var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
// ddgImagesBase : DuckDuckGo (un faux pour les tests).
var ddgImagesBase = "https://duckduckgo.com"
func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
@@ -75,7 +78,7 @@ func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
func duckduckgoImages(query string, limit int) ([]imageResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
defer cancel()
page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
page := ddgImagesBase + "/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
body, err := ddgGet(ctx, page, "")
if err != nil {
return nil, err
@@ -84,7 +87,7 @@ func duckduckgoImages(query string, limit int) ([]imageResult, error) {
if m == nil {
return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)")
}
api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
api := ddgImagesBase + "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
body, err = ddgGet(ctx, api, "https://duckduckgo.com/")
if err != nil {
return nil, err
+10 -5
View File
@@ -9,6 +9,10 @@ package loki
//
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
// la discussion, sa suppression les emporte.
//
// Port Rust de l'outil (lot 38) : rust/loki-rs/src/chat/crittool.rs, employé
// par les tours de la façade ; journal de parité dans
// parity_criteria_test.go.
import (
"encoding/json"
@@ -214,14 +218,15 @@ func toolCriteria(args map[string]any, allowPass bool) string {
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
// on publie sur l'epoch courant.
func critNotify(list []Criterion) {
if conv == nil {
if list == nil {
list = []Criterion{}
}
delta := map[string]any{"criteria": list, "ts": time.Now().UnixMilli()}
if facadeDelta(delta) || conv == nil {
return
}
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
if list == nil {
list = []Criterion{}
}
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
conv.appendDelta(epoch, delta)
}
+18 -3
View File
@@ -177,10 +177,25 @@ func toolBashTail(args map[string]any) string {
return out + "\n" + tailRunes(tail, toolMaxOutput)
}
// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de
// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le
// disque que mesurent ses essais.
// bgJobsRunning : jobs d'arrière-plan encore en vie, ceux de la façade
// compris quand elle tient la conversation (lot 43 : ses tours lancent les
// leurs). L'optimiseur refuse de mesurer à côté d'eux : un build ou un
// serveur de dev prend le CPU et le disque que mesurent ses essais.
func bgJobsRunning() int {
n := localBgJobsRunning()
if facadeChat() {
var out struct {
Running int `json:"running"`
}
if err := facadePerfJSON("/internal/facade/v1/bgjobs", map[string]any{}, &out); err == nil {
n += out.Running
}
}
return n
}
// localBgJobsRunning : les jobs lancés par ce processus.
func localBgJobsRunning() int {
jobsMu.Lock()
defer jobsMu.Unlock()
n := 0
+18 -8
View File
@@ -86,18 +86,26 @@ func convHasRepo() bool {
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
func maybeCodeHint(c *Conversation, epoch int, text string) {
if codeHintDue(text) {
c.appendDelta(epoch, map[string]any{"code_hint": true})
}
}
// codeHintDue dit si la puce doit apparaître pour ce message, et la marque
// alors comme montrée pour la discussion active (une fois par discussion).
func codeHintDue(text string) bool {
if convCodeMode() || !agentEnabled() {
return
return false
}
id := convEnsureActive()
if getBool(bkChat, hintKey(id)) {
return
return false
}
if !looksLikeCode(text) && !convHasRepo() {
return
return false
}
_ = putBool(bkChat, hintKey(id), true)
c.appendDelta(epoch, map[string]any{"code_hint": true})
return true
}
// --- API ---------------------------------------------------------------------
@@ -124,10 +132,12 @@ func handleChatMode(w http.ResponseWriter, r *http.Request) {
}
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
// suivre, comme le fil lui-même).
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
if !facadeDelta(map[string]any{"mode": body.Mode}) {
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
}
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
return
}
+75 -19
View File
@@ -16,6 +16,7 @@ import (
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
)
@@ -40,39 +41,94 @@ var defaultRetryPatterns = []string{
// Compilés à la première utilisation, pas à l'init du package : la surcharge
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
// choisi son rôle (CLI, serveur…).
//
// Relus quand la surcharge change (OpenFox c52c5bf7) : un sync.Once les
// figeait jusqu'au redémarrage du process. retryPatterns tourne pendant le flux,
// à chaque morceau : la base n'est rouverte que si une écriture de ce process a
// touché le bucket d'état (retryStateBust, via cacheBust) ou, au plus une fois
// par seconde, si date ou taille du fichier trahissent l'écriture d'un autre
// process (`loki config set` pendant que le service tourne).
var (
retryOnce sync.Once
retryRes []*regexp.Regexp
retryMu sync.Mutex
retryRes []*regexp.Regexp
// retryCustom : la surcharge `retry_patterns` est active. Ses motifs sont
// quelconques (\A, (?s).*? sur des Ko…) : le balayage fenêtré pendant le
// flux (textualToolCallFrom) ne vaut que pour les motifs embarqués.
retryCustom bool
retryCustom bool
retryLoaded bool
retryStale bool // écriture locale du bucket d'état depuis la lecture
retryRaw string
retryPath string // la base lue (LOKI_HOME change d'un test à l'autre)
retryChecked time.Time
retryMtime time.Time
retrySize int64
)
func retryPatterns() []*regexp.Regexp {
retryOnce.Do(func() { retryRes, retryCustom = compileRetryPatterns() })
return retryRes
// retryStateBust : appelé par cacheBust pour toute écriture du bucket d'état.
func retryStateBust() {
retryMu.Lock()
retryStale = true
retryMu.Unlock()
}
func compileRetryPatterns() ([]*regexp.Regexp, bool) {
pats := defaultRetryPatterns
custom := false
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
if raw := getStr(bkState, "retry_patterns"); raw != "" {
var over []string
if json.Unmarshal([]byte(raw), &over) == nil && len(over) > 0 {
pats, custom = over, true
func retryPatterns() []*regexp.Regexp {
res, _ := retryState()
return res
}
// retryState renvoie les motifs compilés et si ce sont ceux de la surcharge.
func retryState() ([]*regexp.Regexp, bool) {
retryMu.Lock()
defer retryMu.Unlock()
reload := !retryLoaded || retryStale || retryPath != dbPath()
if !reload && time.Since(retryChecked) >= time.Second {
retryChecked = time.Now()
if mtime, size := dbStamp(); size != retrySize || !mtime.Equal(retryMtime) {
reload = true
}
}
if reload {
raw := getStr(bkState, "retry_patterns")
retryMtime, retrySize = dbStamp()
retryChecked, retryStale, retryPath = time.Now(), false, dbPath()
if !retryLoaded || raw != retryRaw {
retryRes, retryCustom = compileRetryPatterns(raw)
retryRaw, retryLoaded = raw, true
}
}
return retryRes, retryCustom
}
// compileRetryPatterns compile la surcharge raw (tableau JSON de regex), ou
// les motifs embarqués quand elle est absente, illisible, ou sans un seul motif
// utilisable. Un motif vide est écarté : "(?mi)" correspond à TOUT, et chaque
// tour était relancé une fois (OpenFox 8e9b4869).
func compileRetryPatterns(raw string) ([]*regexp.Regexp, bool) {
// Surcharge optionnelle : clé d'état `retry_patterns`. Pas d'UI dédiée —
// c'est un réglage d'expert, posé via `loki config` ou l'API ; l'embarqué
// couvre les cas connus.
if raw != "" {
var over []string
if json.Unmarshal([]byte(raw), &over) == nil {
if out := compileRetryList(over); len(out) > 0 {
return out, true
}
}
}
return compileRetryList(defaultRetryPatterns), false
}
func compileRetryList(pats []string) []*regexp.Regexp {
var out []*regexp.Regexp
for _, p := range pats {
if strings.TrimSpace(p) == "" {
continue
}
if re, err := regexp.Compile("(?mi)" + p); err == nil {
out = append(out, re)
}
}
return out, custom
return out
}
// textualToolCall dit si le texte final du tour contient un appel d'outil
@@ -100,9 +156,9 @@ func textualToolCallFrom(content string, scanned int) bool {
if content == "" {
return false
}
res := retryPatterns()
res, custom := retryState()
start := 0
if !retryCustom {
if !custom {
start = retryScanStart(content, scanned)
}
w := content[start:]
+47
View File
@@ -0,0 +1,47 @@
package loki
import "testing"
// Un motif vide dans la surcharge correspondait à tout : chaque tour était
// relancé une fois. Il est écarté ; une surcharge sans motif utilisable retombe
// sur les motifs embarqués.
func TestCompileRetryPatternsSkipsEmpty(t *testing.T) {
res, custom := compileRetryPatterns(`["", " ", "<oups>"]`)
if !custom || len(res) != 1 {
t.Fatalf("custom=%v, %d motifs ; attendu la surcharge avec un seul motif", custom, len(res))
}
if res[0].MatchString("une réponse tout à fait normale") {
t.Fatal("un texte ordinaire déclenche une relance")
}
res, custom = compileRetryPatterns(`["", "([invalide"]`)
if custom || len(res) != len(defaultRetryPatterns) {
t.Fatalf("custom=%v, %d motifs ; attendu les motifs embarqués", custom, len(res))
}
if _, custom = compileRetryPatterns(`pas du json`); custom {
t.Fatal("surcharge illisible prise en compte")
}
}
// Un changement de retry_patterns agit sans redémarrer le process.
func TestRetryPatternsReloadOnChange(t *testing.T) {
testHome(t)
retryStateBust()
if _, custom := retryState(); custom {
t.Fatal("surcharge active sans clé")
}
if err := putStr(bkState, "retry_patterns", `["OUPS-OUTIL"]`); err != nil {
t.Fatal(err)
}
if !textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("nouveau motif ignoré après l'écriture")
}
if err := putStr(bkState, "retry_patterns", ""); err != nil {
t.Fatal(err)
}
if _, custom := retryState(); custom {
t.Fatal("surcharge retirée mais toujours active")
}
if textualToolCall("bon, OUPS-OUTIL, voilà") {
t.Fatal("ancien motif encore appliqué")
}
}
+21 -10
View File
@@ -90,17 +90,10 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
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."
}
caps, system := subagentView(role)
msgs := []Message{
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
{Role: "user", Content: task + framing},
{Role: "system", Content: system},
{Role: "user", Content: task + subagentFraming(role)},
}
var out strings.Builder
// Le sous-agent occupe le slot de la conversation du builder : une fois son
@@ -133,3 +126,21 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string
}
return "[" + role + "]\n" + res
}
// subagentView : les capacités et le prompt système d'un rôle délégué —
// servis aussi à la façade (/internal/v1/turn/subagent, lot 47).
func subagentView(role string) (Caps, string) {
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
return caps, rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)
}
// subagentFraming : le cadrage ajouté à la tâche. 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.
func subagentFraming(role string) string {
if role == "planner" {
return "\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."
}
return "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
}
+29
View File
@@ -2,6 +2,7 @@ package loki
import (
"context"
"encoding/json"
"strings"
"testing"
)
@@ -54,3 +55,31 @@ func TestSubagentRolesAreReadOnly(t *testing.T) {
}
}
}
// Lot 47 : la façade demande au Go le contexte isolé d'un rôle délégué —
// le même prompt système, les mêmes outils et capacités que toolSubagent.
func TestSubagentVueInterne(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", `{"role":"planner"}`)
if w.Code != 200 {
t.Fatalf("vue : %d %s", w.Code, w.Body.String())
}
var v struct {
System string `json:"system"`
Tools []Tool `json:"tools"`
Caps Caps `json:"caps"`
}
if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
caps, system := subagentView("planner")
if v.System != system || v.Caps != caps || len(v.Tools) != len(EnabledTools(caps)) {
t.Fatalf("vue : %+v", v.Caps)
}
for _, body := range []string{`{"role":"verifier"}`, `{"role":""}`, `{`} {
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", body); w.Code != 400 {
t.Fatalf("%s : %d", body, w.Code)
}
}
}
+1
View File
@@ -457,6 +457,7 @@ func sweepStaleCUDirs() {
}
func cdpShutdown() {
facadeBrowserShutdown() // lot 44 : celui de la façade, quand elle tient la conversation
cdpMu.Lock()
defer cdpMu.Unlock()
if cdpCur != nil {
+30
View File
@@ -169,6 +169,9 @@ func cuPromptLine() string {
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
func toolCUOpen(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_open", args); ok {
return r
}
url, _ := args["url"].(string)
if strings.TrimSpace(url) == "" {
return "[erreur] url manquante"
@@ -188,6 +191,9 @@ func toolCUOpen(args map[string]any) string {
}
func toolCUSnapshot() string {
if r, _, ok := facadeBrowserTool("browser_snapshot", map[string]any{}); ok {
return r
}
s, err := cdpGet()
if err != nil {
return "[erreur] " + err.Error()
@@ -200,6 +206,9 @@ func toolCUSnapshot() string {
}
func toolCUFind(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_find", args); ok {
return r
}
text, _ := args["text"].(string)
if strings.TrimSpace(text) == "" {
return "[erreur] texte de recherche manquant"
@@ -216,6 +225,9 @@ func toolCUFind(args map[string]any) string {
}
func toolCUClick(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_click", args); ok {
return r
}
ref, ok := intArg(args, "ref")
if !ok {
return "[erreur] ref manquant (numéro de l'élément)"
@@ -232,6 +244,9 @@ func toolCUClick(args map[string]any) string {
}
func toolCUType(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_type", args); ok {
return r
}
text, _ := args["text"].(string)
s, err := cdpGet()
if err != nil {
@@ -249,6 +264,9 @@ func toolCUType(args map[string]any) string {
}
func toolCUKey(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_key", args); ok {
return r
}
key, _ := args["key"].(string)
s, err := cdpGet()
if err != nil {
@@ -262,6 +280,9 @@ func toolCUKey(args map[string]any) string {
}
func toolCUClickXY(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_click_xy", args); ok {
return r
}
x, okx := floatArg(args, "x")
y, oky := floatArg(args, "y")
if !okx || !oky {
@@ -279,6 +300,9 @@ func toolCUClickXY(args map[string]any) string {
}
func toolCUScroll(args map[string]any) string {
if r, _, ok := facadeBrowserTool("browser_scroll", args); ok {
return r
}
dir, _ := args["direction"].(string)
s, err := cdpGet()
if err != nil {
@@ -299,6 +323,12 @@ func toolCUScroll(args map[string]any) string {
// 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 r, img, ok := facadeBrowserTool("browser_screenshot", map[string]any{}); ok {
if len(img) == 0 {
img = nil
}
return r, img
}
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
}
+3
View File
@@ -46,6 +46,9 @@ func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n"
// renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais
// bloquant pour l'outil).
func cuAutoShot() string {
if id, ok := facadeBrowserShot(); ok {
return id
}
s, err := cdpGet()
if err != nil {
cuLog("aperçu : navigateur indisponible : %v", err)
+4
View File
@@ -414,6 +414,10 @@ func asrShutdown() {
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
// l'utilisateur qui vient de demander la place, pas une panne.
func asrShutdownVite() {
// Dictée tenue par la façade (LOKI_DICTATE=rust) : son serveur est le sien.
if facadeDictateStop() {
return
}
for i := 0; i < 20; i++ {
if wsrvMu.TryLock() {
asrStopLocked()
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"time"
)
// facade_backends.go — backends llama.cpp tenus par la façade Rust
// (LOKI_BACKENDS=rust, lot 22 de la migration ; expérimental, le Go reste le
// défaut) : statut, vérification git, binaires précompilés, bascule fast/opt,
// suivi du job, backends custom et GPU vus par un moteur
// (rust/loki-rs/src/backends).
//
// La façade détient alors LE job des moteurs, et compile elle-même llama.cpp
// depuis les sources sous Linux et macOS (install, update, install-custom ;
// lot 41, backends/build.rs). Ceux que le Go fait encore tourner — compilation
// sous Windows (chaîne MSVC), moteur de l'image, Strata — lui rapportent
// chaque étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail,
// lcDone, startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure
// plus de job au démarrage (la façade s'en charge).
const facadeBackendsEnv = "LOKI_BACKENDS"
// facadeBackends : la façade tient-elle les backends et le job ?
func facadeBackends() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBackendsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeBackendsRoutes : les routes que la façade sert alors (backends/mod.rs).
var facadeBackendsRoutes = map[string]bool{
"/api/llamacpp": true,
"/api/llamacpp/check": true,
"/api/llamacpp/prebuilt": true,
"/api/llamacpp/prebuilt/check": true,
"/api/llamacpp/use": true,
"/api/llamacpp/job": true,
"/api/llamacpp/uninstall-custom": true,
"/api/backends": true,
"/api/backends/custom": true,
"/api/backends/devices": true,
}
func init() {
// La compilation depuis les sources part aussi à la façade (lot 41), sauf
// sous Windows : générateur Visual Studio et intégration CUDA de MSBuild
// restent au Go.
if runtime.GOOS != "windows" {
for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} {
facadeBackendsRoutes[p] = true
}
}
}
// facadeJobClient : appels courts (une ligne de journal, une phase).
var facadeJobClient = &http.Client{Timeout: 30 * time.Second}
// facadeJobCall envoie une étape du job à la façade. out peut être nil.
// Une réponse 409 rend l'erreur que la façade explique (job déjà en cours).
func facadeJobCall(op string, body map[string]any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/build-job/"+op, bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeJobClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
var e struct {
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&e)
if e.Error == "" {
e.Error = resp.Status
}
return errors.New(e.Error)
}
if resp.StatusCode >= 300 {
return fmt.Errorf("façade : %s", resp.Status)
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// facadeJobNote : une étape dont l'échec ne doit pas arrêter le job (le
// journal perd une ligne, rien de plus) — tracée sur la sortie d'erreur.
func facadeJobNote(op string, body map[string]any) {
if err := facadeJobCall(op, body, nil); err != nil {
fmt.Fprintf(os.Stderr, "[job] étape %s non transmise à la façade : %v\n", op, err)
}
}
// facadeJobStart démarre le job côté façade, puis son corps ici.
func facadeJobStart(action string, run func()) error {
if err := facadeJobCall("start", map[string]any{"action": action}, nil); err != nil {
return err
}
setBuildSink(lcAppend)
go func() {
defer func() {
setBuildSink(nil)
facadeJobNote("end", map[string]any{})
}()
run()
}()
return nil
}
// facadeJobSnapshot : la vue du job tenue par la façade.
func facadeJobSnapshot(from int, withLines bool) map[string]any {
lines := "0"
if withLines {
lines = "1"
}
req, err := http.NewRequest(http.MethodGet, "http://"+facadeAddr()+
"/internal/facade/v1/build-job/snapshot?from="+strconv.Itoa(from)+"&lines="+lines, nil)
if err != nil {
return map[string]any{"exists": false}
}
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeJobClient.Do(req)
if err != nil {
return map[string]any{"exists": false}
}
defer resp.Body.Close()
var out map[string]any
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil {
return map[string]any{"exists": false}
}
return out
}
// lcSetCommit note le commit d'avant ("old") ou d'après ("new") une mise à
// jour.
func lcSetCommit(which, commit string) {
if facadeBackends() {
facadeJobNote("commits", map[string]any{which: commit})
return
}
lcMu.Lock()
if lcCur != nil {
if which == "old" {
lcCur.OldCommit = commit
} else {
lcCur.NewCommit = commit
}
}
lcMu.Unlock()
}
+15
View File
@@ -0,0 +1,15 @@
package loki
import "time"
// resetBackendsState : pas de job ni de GPU en cache d'un test à l'autre.
func resetBackendsState() {
lcMu.Lock()
lcCur = nil
lcLastSave = time.Time{}
lcMu.Unlock()
setBuildSink(nil)
devCacheMu.Lock()
devCache = map[string]devCacheEntry{}
devCacheMu.Unlock()
}
+95
View File
@@ -0,0 +1,95 @@
package loki
import (
"net/http"
"os"
"strings"
"sync"
)
// facade_bench.go — le banc d'essai tenu par la façade Rust (LOKI_BENCH=rust,
// lot 27 de la migration ; expérimental, le Go reste le défaut) : les routes
// /api/bench*, le job et le protocole de mesure (rust/loki-rs/src/bench). Le
// verrou de génération reste celui de la conversation du Go : la façade le
// prend par le bail du moteur, avec le motif « bench » (benching, et le refus
// errBenchBusy pour les autres). Le Go lui prête aussi l'isolation des travaux
// annexes (effacement du slot à la fin) et la télémétrie (perf_log.go).
const facadeBenchEnv = "LOKI_BENCH"
// facadeBench : la façade tient-elle le banc d'essai ?
func facadeBench() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBenchEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeBenchRoutes : les routes que la façade sert alors.
var facadeBenchRoutes = map[string]bool{
"/api/bench": true,
"/api/bench/status": true,
"/api/bench/cancel": true,
"/api/bench/last": true,
}
// facadeSideJobs : les travaux annexes ouverts par la façade (id → fin).
var facadeSideJobs = struct {
mu sync.Mutex
seq int
m map[int]func()
}{m: map[int]func(){}}
// handleInternalSideJob : POST {} ouvre un travail annexe (engineSideJob) et
// rend son id ; POST {"end": id, "served": bool} le ferme — served : le
// moteur a servi au moins une requête du travail (engineServed), le slot
// porte donc son état et peut être effacé.
func handleInternalSideJob(w http.ResponseWriter, r *http.Request) {
var in struct {
End int `json:"end"`
Served bool `json:"served"`
}
if !readLeaseBody(w, r, &in) {
return
}
if in.End == 0 {
done := engineSideJob()
facadeSideJobs.mu.Lock()
facadeSideJobs.seq++
id := facadeSideJobs.seq
facadeSideJobs.m[id] = done
facadeSideJobs.mu.Unlock()
sendJSON(w, http.StatusOK, map[string]any{"id": id})
return
}
facadeSideJobs.mu.Lock()
done := facadeSideJobs.m[in.End]
delete(facadeSideJobs.m, in.End)
facadeSideJobs.mu.Unlock()
if done == nil {
sendJSON(w, http.StatusNotFound, map[string]any{"error": "travail annexe inconnu"})
return
}
if in.Served {
engineServed()
}
done()
w.WriteHeader(http.StatusNoContent)
}
// handleInternalBenchPerf : la trace d'un bench de la façade dans la
// télémétrie (le bench a pris le slot : la perte de cache du tour suivant lui
// revient).
func handleInternalBenchPerf(w http.ResponseWriter, r *http.Request) {
var in struct {
PromptN int `json:"prompt_n"`
PromptMs float64 `json:"prompt_ms"`
PromptPerSecond float64 `json:"prompt_per_second"`
PredictedN int `json:"predicted_n"`
PredictedPerSec float64 `json:"predicted_per_second"`
}
if !readLeaseBody(w, r, &in) {
return
}
perfRecord(perfRec{Kind: perfBench, Complete: true, Total: in.PromptN, New: in.PromptN,
PPms: in.PromptMs, PPtps: in.PromptPerSecond, Gen: in.PredictedN, TGtps: in.PredictedPerSec}, nil)
w.WriteHeader(http.StatusNoContent)
}
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
// Le banc d'essai part à la façade avec LOKI_BENCH=rust (lot 27) ; le reste,
// non. La façade tient le verrou de génération par le bail du moteur, motif
// « bench », et le Go lui prête le travail annexe et la télémétrie.
func TestFacadeBench(t *testing.T) {
testHome(t)
var mu sync.Mutex
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.Path)
mu.Unlock()
io.WriteString(w, `{"ok":true,"de":"facade"}`)
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
t.Setenv(facadeBenchEnv, "")
if facadeBench() {
t.Fatal("tenu sans LOKI_BENCH")
}
for p := range facadeBenchRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_BENCH, %s : %q", p, w.Body.String())
}
}
t.Setenv(facadeBenchEnv, " RUST")
if len(facadeBenchRoutes) != 4 {
t.Fatalf("routes : %v", facadeBenchRoutes)
}
for p := range facadeBenchRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if !strings.Contains(w.Body.String(), "facade") {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
mu.Lock()
n := len(calls)
mu.Unlock()
if n != len(facadeBenchRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/tune", local)(w, httptest.NewRequest("POST", "/api/tune", nil))
if w.Body.String() != "go" {
t.Fatalf("l'optimiseur reste au Go : %q", w.Body.String())
}
// Le bail « bench » : le verrou, le refus du bench pour les autres.
post := func(h http.HandlerFunc, path, body string) (int, map[string]any) {
rec := httptest.NewRecorder()
h(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{"ttl_ms":60000,"why":"bench"}`)
if code != 200 {
t.Fatalf("bail : %d %v", code, out)
}
id, _ := out["lease"].(string)
if !benchRunning() || conv.busyReason() != errBenchBusy {
t.Fatalf("bail du bench : running=%v refus=%v", benchRunning(), conv.busyReason())
}
if code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{}`); code != 409 || out["error"] != errBenchBusy.Error() {
t.Fatalf("second bail : %d %v", code, out)
}
// Changer de discussion ne rompt pas le bail d'un bench.
conv.loadFrom("autre", nil)
if !benchRunning() || !conv.isGenerating() {
t.Fatal("le bail du bench a sauté à la bascule de discussion")
}
if code, _ := post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); code != 204 {
t.Fatalf("libération : %d", code)
}
if benchRunning() || conv.isGenerating() || conv.busyReason() == errBenchBusy {
t.Fatal("bail rendu : le verrou devait être libre")
}
// Un bail ordinaire n'est pas un bench ; un bail de bench expiré rend
// le verrou et le refus.
code, out = post(handleInternalLease, "/internal/v1/engine/lease", `{}`)
if code != 200 || benchRunning() {
t.Fatalf("bail ordinaire : %d %v bench=%v", code, out, benchRunning())
}
post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+out["lease"].(string)+`"}`)
conv.mu.Lock()
conv.extLeaseClearLocked()
conv.Generating = false
conv.mu.Unlock()
if _, err := conv.extLeaseAcquire(5*time.Second, errBenchBusy); err != nil {
t.Fatal(err)
}
conv.mu.Lock()
st := conv.extLease
conv.mu.Unlock()
conv.extLeaseExpire(st)
if benchRunning() || conv.busyReason() == errBenchBusy {
t.Fatal("bail de bench expiré : refus du bench encore là")
}
// Le travail annexe ouvert puis fermé par la façade.
code, out = post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{}`)
job, _ := out["id"].(float64)
if code != 200 || job <= 0 || sideJobs.Load() != 1 {
t.Fatalf("ouverture : %d %v (en cours : %d)", code, out, sideJobs.Load())
}
served := engineServedCount()
if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":`+strconv.Itoa(int(job))+`,"served":true}`); code != 204 {
t.Fatalf("fermeture : %d", code)
}
if sideJobs.Load() != 0 || engineServedCount() != served+1 {
t.Fatalf("après la fermeture : en cours %d, servies %d", sideJobs.Load(), engineServedCount()-served)
}
if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":999}`); code != 404 {
t.Fatalf("travail inconnu : %d", code)
}
rec := httptest.NewRecorder()
handleInternalSideJob(rec, httptest.NewRequest("GET", "/internal/v1/engine/sidejob", nil))
if rec.Code != 405 {
t.Fatalf("GET : %d", rec.Code)
}
// La trace du bench dans la télémétrie.
var before uint64
if recs := perfLog.snapshot(); len(recs) > 0 {
before = recs[len(recs)-1].Seq
}
if code, _ := post(handleInternalBenchPerf, "/internal/v1/bench/perf",
`{"prompt_n":2036,"prompt_ms":1018,"prompt_per_second":2000,"predicted_n":300,"predicted_per_second":50}`); code != 204 {
t.Fatalf("trace : %d", code)
}
recs := perfLog.snapshot()
if len(recs) == 0 || recs[len(recs)-1].Seq != before+1 {
t.Fatalf("trace absente (dernière avant : %d)", before)
}
r := recs[len(recs)-1]
if r.Kind != perfBench || !r.Complete || r.Total != 2036 || r.New != 2036 || r.Gen != 300 || r.TGtps != 50 || r.PPtps != 2000 || r.PPms != 1018 {
t.Fatalf("trace : %+v", r)
}
t.Setenv(internalTokenEnv, "")
if facadeBench() {
t.Fatal("tenu sans jeton interne")
}
}
+35
View File
@@ -0,0 +1,35 @@
package loki
import (
"strings"
"testing"
)
// Lot 43 : les jobs d'arrière-plan des tours de la façade comptent pour
// l'optimiseur du Go ; façade en panne, seuls les siens.
func TestFacadeJobsArrierePlan(t *testing.T) {
testHome(t)
jobsMu.Lock()
saved := jobs
jobs = map[string]*bgJob{"job1": {ID: "job1"}}
jobsMu.Unlock()
t.Cleanup(func() {
jobsMu.Lock()
jobs = saved
jobsMu.Unlock()
})
f := demarrerFacade(t)
f.internalStatus = 200
f.internalBody = `{"running":2}`
if n := bgJobsRunning(); n != 3 {
t.Fatalf("jobs : %d, attendu 3 (1 ici, 2 à la façade)", n)
}
vu := f.recu()
if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/bgjobs jeton ") {
t.Fatalf("appel : %v", vu)
}
f.internalStatus = 500
if n := bgJobsRunning(); n != 1 {
t.Fatalf("façade en panne : %d", n)
}
}
+86
View File
@@ -0,0 +1,86 @@
package loki
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// facade_browser.go — le navigateur piloté tenu par la façade (lot 44 de la
// migration Rust). Quand elle tient la conversation, la session Chrome est
// la sienne : ses tours exécutent les outils browser_* chez elle, et ceux du
// Go (sous-agents, tâches IA) passent par /internal/facade/v1/browser — un
// seul navigateur. L'aperçu après une action et la fermeture aussi.
// Façade injoignable : le navigateur du Go prend le relais.
// facadeBrowserClient : une action du navigateur peut attendre le
// lancement de Chrome, puis le chargement et le calme réseau de la page.
var facadeBrowserClient = &http.Client{Timeout: 3 * time.Minute}
// facadeBrowserCall appelle l'API du navigateur de la façade.
func facadeBrowserCall(body map[string]any, out any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/browser", bytes.NewReader(b))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := facadeBrowserClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("façade : %s", resp.Status)
}
return json.NewDecoder(resp.Body).Decode(out)
}
// facadeBrowserTool exécute un outil browser_* à la façade ; ok faux : elle
// ne tient pas la conversation, ou ne répond pas (le Go le fait lui-même).
func facadeBrowserTool(name string, args map[string]any) (result string, image map[string]any, ok bool) {
if !facadeChat() {
return "", nil, false
}
var out struct {
Result string `json:"result"`
Image map[string]any `json:"image"`
}
if err := facadeBrowserCall(map[string]any{"op": "tool", "name": name, "args": args}, &out); err != nil {
fmt.Fprintf(os.Stderr, "[cu] navigateur de la façade injoignable, repli local : %v\n", err)
return "", nil, false
}
return out.Result, out.Image, true
}
// facadeBrowserShot : l'aperçu pris par la façade.
func facadeBrowserShot() (string, bool) {
if !facadeChat() {
return "", false
}
var out struct {
ID string `json:"id"`
}
if err := facadeBrowserCall(map[string]any{"op": "shot"}, &out); err != nil {
return "", false
}
return out.ID, true
}
// facadeBrowserShutdown ferme le navigateur de la façade.
func facadeBrowserShutdown() {
if !facadeChat() {
return
}
var out struct{}
if err := facadeBrowserCall(map[string]any{"op": "shutdown"}, &out); err != nil {
fmt.Fprintf(os.Stderr, "[cu] fermeture du navigateur de la façade : %v\n", err)
}
}
+53
View File
@@ -0,0 +1,53 @@
package loki
import (
"strings"
"testing"
)
// Lot 44 : quand la façade tient la conversation, le navigateur est le sien —
// les outils browser_* du Go, l'aperçu et la fermeture passent par elle ;
// façade en panne, le navigateur du Go prend le relais.
func TestFacadeNavigateur(t *testing.T) {
testHome(t)
t.Setenv("LOKI_CHROME", "/nulle/part/chrome")
f := demarrerFacade(t)
f.internalStatus = 200
f.internalBody = `{"result":"# Exemple\nhttps://exemple.test/","image":null}`
if got := toolCUOpen(map[string]any{"url": "exemple.test"}); got != "# Exemple\nhttps://exemple.test/" {
t.Fatalf("ouverture : %q", got)
}
vu := f.recu()
if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/browser jeton ") ||
!strings.Contains(vu[0], `"op":"tool"`) || !strings.Contains(vu[0], `"name":"browser_open"`) ||
!strings.Contains(vu[0], `"url":"exemple.test"`) {
t.Fatalf("appel : %v", vu)
}
f.internalBody = `{"result":"[ok] capture","image":{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}}`
res, img := toolCUScreenshot()
if res != "[ok] capture" || img["type"] != "image_url" {
t.Fatalf("capture : %q %v", res, img)
}
f.internalBody = `{"result":"[erreur] la vision n'est pas active","image":null}`
if res, img := toolCUScreenshot(); img != nil || !strings.HasPrefix(res, "[erreur]") {
t.Fatalf("capture refusée : %q %v", res, img)
}
f.internalBody = `{"id":"abc"}`
if id := cuAutoShot(); id != "abc" {
t.Fatalf("aperçu : %q", id)
}
f.internalBody = `{}`
cdpShutdown()
vu = f.recu()
if last := vu[len(vu)-1]; !strings.Contains(last, `"op":"shutdown"`) {
t.Fatalf("fermeture : %v", vu)
}
if !facadeChatRoutes["/api/chat/cushot"] {
t.Fatal("/api/chat/cushot n'est pas remis à la façade")
}
// Façade en panne : le Go pilote lui-même.
f.internalStatus = 500
if got := toolCUSnapshot(); got != "[erreur] fork/exec /nulle/part/chrome: no such file or directory" {
t.Fatalf("repli : %q", got)
}
}
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff. Show more