Rebaptise AJEAN en Loki (fork, lignée conservée)

- module github.com/R0m1k3/Loki, cmd/loki, internal/loki (package loki)
- LOKI_HOME, LOKI_MODEL_DIRS, LOKI_SERVICE, LOKI_DL_CONNS ; /etc/loki ;
  units loki-engine / loki-ui ; binaire et aide CLI
- updateRepo pointe sur R0m1k3/Loki (l'auto-update ne tirera plus les
  binaires AJEAN amont)

Conservé à l'identique : le domaine ajean.link (service de tunnel amont),
les littéraux de migration 0.7.x (migrate_07.go), RELEASE_NOTES.md et
LICENSE (historique et licence de l'amont).

go build/vet/test : verts.
This commit is contained in:
Loki committed 2026-08-14 21:33:15 +00:00
1 parent 2164b46319
commit de6551a153
157 files changed
+928 -928

No files matched your search

@@ -1,6 +1,6 @@
// backend_build.go — machinerie de compilation de llama.cpp : détection du
// plan de build (CUDA/ROCm/Metal/Vulkan/CPU), cmake, suivi de progression, logs.
package ajean
package loki
import (
"bufio"
@@ -73,7 +73,7 @@ func detectBuildPlan() buildPlan {
// L'UI web embarquée de llama-server exige npm (ou un téléchargement
// d'assets pré-compilés depuis HuggingFace) pour générer un service-worker
// PWA — une dépendance lourde qui casse le build sur une machine sans node.
// ajean fournit sa propre UI, donc on la désactive : build plus rapide et
// loki fournit sa propre UI, donc on la désactive : build plus rapide et
// sans dépendance réseau/npm. BUILD_UI=OFF coupe npm ; USE_PREBUILT_UI=OFF
// coupe le téléchargement d'assets pré-compilés depuis HuggingFace (qui
// échoue sur un réseau restreint et fait planter l'embed). Sur un checkout
@@ -237,7 +237,7 @@ func hintMissingBuildDep(p buildPlan, cfgLog string) {
fmt.Printf(" Relance l'installeur du CUDA Toolkit (installation personnalisée) en cochant « CUDA → Visual Studio Integration »\n")
fmt.Printf(" (Visual Studio avec le workload C++ doit déjà être installé), ou copie les fichiers de\n")
fmt.Printf(" <toolkit>\\extras\\visual_studio_integration\\MSBuildExtensions vers\n")
fmt.Printf(" <VS>\\MSBuild\\Microsoft\\VC\\<version>\\BuildCustomizations, puis relance %s.\n", bold("ajean llamacpp install"))
fmt.Printf(" <VS>\\MSBuild\\Microsoft\\VC\\<version>\\BuildCustomizations, puis relance %s.\n", bold("loki llamacpp install"))
}
// Linux/CUDA : nvcc trouvé mais en-têtes/cudart introuvables = le toolkit
// complet n'est pas installé (seul le paquet nvcc l'est). On passe déjà
@@ -245,15 +245,15 @@ func hintMissingBuildDep(p buildPlan, cfgLog string) {
// c'est qu'il manque pour de bon.
if p.backend == "cuda" && strings.Contains(log, "CUDA Toolkit not found") {
fmt.Printf("\n%s nvcc est présent mais le CUDA Toolkit complet (en-têtes + cudart) est introuvable.\n", yellow("[dépendance]"))
fmt.Printf(" Installe le toolkit NVIDIA officiel (il se pose dans /usr/local/cuda), puis relance %s.\n", bold("ajean llamacpp install"))
fmt.Printf(" Installe le toolkit NVIDIA officiel (il se pose dans /usr/local/cuda), puis relance %s.\n", bold("loki llamacpp install"))
}
if p.backend == "vulkan" && strings.Contains(log, "SPIRV-Headers") {
fmt.Printf("\n%s dépendance manquante pour le backend %s : les en-têtes SPIR-V (paquet « SPIRV-Headers ») sont introuvables.\n",
yellow("[dépendance]"), green("Vulkan"))
if cmd := pkgInstallHint("spirv-headers"); cmd != "" {
fmt.Printf(" installe-les puis relance %s : %s\n", bold("ajean llamacpp install"), bold(cmd))
fmt.Printf(" installe-les puis relance %s : %s\n", bold("loki llamacpp install"), bold(cmd))
} else {
fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("ajean llamacpp install"))
fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("loki llamacpp install"))
}
}
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"errors"
@@ -1,4 +1,4 @@
package ajean
package loki
import "testing"
@@ -12,7 +12,7 @@ func TestCompiledFile(t *testing.T) {
`[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu",
// La ligne de commande nvcc géante ne doit PAS être prise pour un fichier.
` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "",
`Building Custom Rule C:/ProgramData/ajean/...`: "",
`Building Custom Rule C:/ProgramData/loki/...`: "",
`-- UI: running npm install`: "",
}
for in, want := range cases {
@@ -1,11 +1,11 @@
package ajean
package loki
// backend_catalog.go — récupère la liste de modèles curatée servie par ajean.link et la
// combine avec les infos matérielles locales, pour que l'écran d'accueil (à
// venir) propose « en un clic » un modèle adapté à la machine.
//
// La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir
// ajean-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
// loki-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
// catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer.
import (
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bufio"
@@ -18,7 +18,7 @@ import (
func readAPIKey() string { return getStr(bkState, "api_key") }
// authHeader sets the Authorization: Bearer header on req when an API key is
// configured, so AJEAN's own internal calls (chat/web/bench/test) authenticate
// configured, so Loki's own internal calls (chat/web/bench/test) authenticate
// against a protected llama-server. No-op when no key is set.
func authHeader(req *http.Request) {
if k := readAPIKey(); k != "" {
@@ -30,7 +30,7 @@ func authHeader(req *http.Request) {
func genAPIKey() string {
buf := make([]byte, 24)
_, _ = rand.Read(buf)
return "sk-ajean-" + hex.EncodeToString(buf)
return "sk-loki-" + hex.EncodeToString(buf)
}
// writeAPIKey enregistre (key != "") ou supprime (key == "") la clé Bearer.
@@ -41,7 +41,7 @@ func writeAPIKey(key string) error {
return putStr(bkState, "api_key", key)
}
// maskAPIKey renders a key for display: keep the "sk-ajean-" prefix and last 4
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
// chars, elide the middle. Empty in → empty out.
func maskAPIKey(k string) string {
if k == "" {
@@ -57,9 +57,9 @@ func maskAPIKey(k string) string {
// internet, le serveur exige alors « Authorization: Bearer <clé> » à chaque
// appel.
//
// ajean set-api-key <clé> définit la clé
// ajean set-api-key génère une clé aléatoire
// ajean set-api-key "" supprime la protection
// loki set-api-key <clé> définit la clé
// loki set-api-key génère une clé aléatoire
// loki set-api-key "" supprime la protection
func cmdSetAPIKey(args []string) error {
var key string
switch {
@@ -83,7 +83,7 @@ func cmdSetAPIKey(args []string) error {
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
sc := bufio.NewScanner(os.Stdin)
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
fmt.Println(dim("[info] pense à lancer 'ajean restart'"))
fmt.Println(dim("[info] pense à lancer 'loki restart'"))
return nil
}
return serviceAction("restart")
@@ -105,7 +105,7 @@ func WriteConfig(m map[string]string) error { return replaceKV(bkConfig, m) }
// eux : une paire ouvrante/fermante du même caractère.
//
// Un simple Trim(v, `"`) mangeait le guillemet d'un argument interne — par
// exemple EXTRA_ARGS=--chat-template-file "/etc/ajean/tpl.jinja" perdait son
// exemple EXTRA_ARGS=--chat-template-file "/etc/loki/tpl.jinja" perdait son
// guillemet final et repartait déséquilibré dans le preset (issue #17 : contenu
// tronqué / guillemets non appariés à la création d'un preset).
func unquoteValue(v string) string {
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"reflect"
@@ -10,7 +10,7 @@ import (
// dans le preset (issue #17).
func TestParseEnvKeepsInnerQuotes(t *testing.T) {
cases := map[string]string{
`EXTRA_ARGS=--jinja --chat-template-file "/etc/ajean/tpl.jinja"`: `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`,
`EXTRA_ARGS=--jinja --chat-template-file "/etc/loki/tpl.jinja"`: `--jinja --chat-template-file "/etc/loki/tpl.jinja"`,
`EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`,
`MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`,
`MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`,
@@ -31,7 +31,7 @@ func TestParseEnvKeepsInnerQuotes(t *testing.T) {
func TestFormatEnvRoundTrip(t *testing.T) {
in := map[string]string{
"MODEL": "/mnt/mes modèles/x.gguf",
"EXTRA_ARGS": `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`,
"EXTRA_ARGS": `--jinja --chat-template-file "/etc/loki/tpl.jinja"`,
"CTX": "32768",
}
out := parseEnv(formatEnv(in))
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bufio"
@@ -11,10 +11,10 @@ import (
// backend_gpu.go — sélection du/des GPU utilisés par llama-server.
//
// ajean gpu liste les GPU et montre la sélection courante
// ajean gpu 1 n'utilise que le GPU d'index 1
// ajean gpu 0 1 utilise les GPU 0 et 1
// ajean gpu all réinitialise (tous les GPU visibles)
// loki gpu liste les GPU et montre la sélection courante
// loki gpu 1 n'utilise que le GPU d'index 1
// loki gpu 0 1 utilise les GPU 0 et 1
// loki gpu all réinitialise (tous les GPU visibles)
//
// La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; backend_serve.go
// l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent
@@ -53,7 +53,7 @@ func cmdGPU(args []string) error {
return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok)
}
if n < 0 || n >= len(gpus) {
return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « ajean gpu »", n, len(gpus)-1)
return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « loki gpu »", n, len(gpus)-1)
}
idx = append(idx, strconv.Itoa(n))
}
@@ -102,7 +102,7 @@ func gpuList() error {
} else {
fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel)
}
fmt.Printf(" %s ajean gpu <index…> pour choisir, ajean gpu all pour réinitialiser\n", dim("→"))
fmt.Printf(" %s loki gpu <index…> pour choisir, loki gpu all pour réinitialiser\n", dim("→"))
return nil
}
@@ -120,7 +120,7 @@ func gpuSet(value string) error {
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
sc := bufio.NewScanner(os.Stdin)
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
fmt.Println(dim("[info] pense à lancer 'ajean restart'"))
fmt.Println(dim("[info] pense à lancer 'loki restart'"))
return nil
}
return serviceAction("restart")
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"fmt"
@@ -9,12 +9,12 @@ import (
// backend_llamacpp.go — gestion du backend llama.cpp (clone, build, mise à jour).
//
// `ajean llamacpp install` installe un build neuf, détecte automatiquement
// `loki llamacpp install` installe un build neuf, détecte automatiquement
// l'accélérateur (CUDA / ROCm / Metal / CPU) et la
// compute capability du GPU, puis pointe BIN dessus.
// `ajean llamacpp update` met à jour le dépôt existant (git pull) et recompile
// `loki llamacpp update` met à jour le dépôt existant (git pull) et recompile
// avec la bonne config, sans intervention.
// `ajean llamacpp status` montre le commit courant, le backend détecté et le
// `loki llamacpp status` montre le commit courant, le backend détecté et le
// retard éventuel sur origin.
const llamacppRepoURL = "https://github.com/ggml-org/llama.cpp.git"
@@ -55,7 +55,7 @@ func cmdLlamacpp(args []string) error {
if err := SetConfigKey("BIN", bin); err != nil {
return fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", err)
}
fmt.Printf("%s BIN mis à jour — %s pour appliquer\n", green("✓"), bold("ajean restart"))
fmt.Printf("%s BIN mis à jour — %s pour appliquer\n", green("✓"), bold("loki restart"))
return nil
default:
return fmt.Errorf("sous-commande inconnue: %s (install | update | prebuilt | status)", sub)
@@ -68,7 +68,7 @@ func cmdLlamacpp(args []string) error {
// llamacppRepoDir resolves the llama.cpp checkout: derived from config BIN when
// possible (so `update` targets whatever build the service actually runs),
// otherwise the default under $AJEAN_HOME/backends/llama.cpp.
// otherwise the default under $LOKI_HOME/backends/llama.cpp.
func llamacppRepoDir() string {
if bin := ReadConfig()["BIN"]; bin != "" {
if real, err := filepath.EvalSymlinks(bin); err == nil {
@@ -161,7 +161,7 @@ func llamacppInstall(args []string) error {
if isDir(filepath.Join(repo, ".git")) {
if !force {
fmt.Printf("%s dépôt déjà présent dans %s\n", yellow("[info]"), repo)
fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("ajean llamacpp update"))
fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("loki llamacpp update"))
return nil
}
fmt.Printf("%s --force : suppression de %s\n", yellow("[info]"), repo)
@@ -208,7 +208,7 @@ func llamacppInstall(args []string) error {
}
fmt.Printf("%s BIN mis à jour\n", green("✓"))
fmt.Printf("\nProchaines étapes :\n 1. renseigne MODEL : %s\n 2. démarre : %s\n",
bold("ajean edit"), bold("ajean restart"))
bold("loki edit"), bold("loki restart"))
return nil
}
@@ -246,7 +246,7 @@ func llamacppUpdate(args []string) error {
repo := llamacppRepoDir()
if !isDir(filepath.Join(repo, ".git")) {
return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("ajean llamacpp install"))
return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("loki llamacpp install"))
}
fmt.Printf("%s dépôt : %s\n", cyan("▶"), repo)
@@ -317,14 +317,14 @@ func llamacppUpdate(args []string) error {
fmt.Printf("\n%s mis à jour : %s → %s\n", green("✓"), oldCommit, newCommit)
if noRestart {
fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("ajean restart"))
fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("loki restart"))
return nil
}
if svcWasUp {
fmt.Printf("%s redémarrage du service…\n", cyan("▶"))
return serviceAction("start")
}
fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("ajean start"))
fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("loki start"))
return nil
}
@@ -337,7 +337,7 @@ func llamacppStatus(args []string) error {
fmt.Printf("%s\n", bold("llama.cpp"))
fmt.Printf(" dépôt : %s\n", repo)
if !isDir(filepath.Join(repo, ".git")) {
fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("ajean llamacpp install"))
fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("loki llamacpp install"))
return nil
}
commit := gitOutput(repo, "log", "-1", "--format=%h %ci %s")
@@ -354,7 +354,7 @@ func llamacppStatus(args []string) error {
// Retard sur origin (best-effort, sans fetch réseau).
if branch != "" && branch != "HEAD" {
if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); behind != "" && behind != "0" {
fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("ajean llamacpp update"))
fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("loki llamacpp update"))
}
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -10,13 +10,13 @@ import (
"strings"
)
// Les .gguf vivent dans $AJEAN_HOME/models, mais un modèle de 40 Go tient
// Les .gguf vivent dans $LOKI_HOME/models, mais un modèle de 40 Go tient
// rarement sur le disque système : on autorise des dossiers supplémentaires
// (disque dur externe, second SSD, montage réseau…). La liste est persistée en
// base — PAS dans la configuration, que le changement de preset remplace — et
// peut aussi venir de $AJEAN_MODEL_DIRS.
// peut aussi venir de $LOKI_MODEL_DIRS.
// modelDirsListSep sépare les dossiers dans $AJEAN_MODEL_DIRS (':' sous Unix,
// modelDirsListSep sépare les dossiers dans $LOKI_MODEL_DIRS (':' sous Unix,
// ';' sous Windows, comme le PATH).
func modelDirsListSep() string {
if runtime.GOOS == "windows" {
@@ -30,7 +30,7 @@ func modelDirsListSep() string {
func extraModelDirs() []string {
var raw []string
getJSON(bkState, "model_dirs", &raw)
if v := os.Getenv("AJEAN_MODEL_DIRS"); v != "" {
if v := os.Getenv("LOKI_MODEL_DIRS"); v != "" {
raw = append(raw, strings.Split(v, modelDirsListSep())...)
}
return dedupDirs(raw, modelsDir())
@@ -92,7 +92,7 @@ func pathWithin(dir, p string) bool {
// baseName renvoie le nom de fichier d'un chemin en acceptant les DEUX
// séparateurs : un config.env écrit sous Windows (C:\models\x.gguf) peut être
// relu par un ajean Linux, où filepath.Base ne coupe pas sur '\'.
// relu par un loki Linux, où filepath.Base ne coupe pas sur '\'.
func baseName(p string) string {
p = strings.TrimRight(strings.TrimSpace(p), `/\`)
if i := strings.LastIndexAny(p, `/\`); i >= 0 {
@@ -128,7 +128,7 @@ func resolveServeModelPath(name string) (string, error) {
// absolu) en chemin absolu vers un .gguf. Un chemin absolu n'est accepté que
// s'il est dans un des dossiers déclarés — sinon l'UI web deviendrait un moyen
// de lire/supprimer n'importe quel fichier de la machine. Un simple nom de
// fichier est cherché dans chaque dossier, AJEAN_HOME en premier.
// fichier est cherché dans chaque dossier, LOKI_HOME en premier.
func resolveModelPath(name string) (string, error) {
s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`))
if s == "" {
@@ -218,7 +218,7 @@ func resolveDownloadDir(dir string) (string, error) {
return "", fmt.Errorf("dossier non autorisé : %s — ajoute-le dans « Dossiers de modèles »", abs)
}
// handleModelDirs : GET liste les dossiers de modèles (AJEAN_HOME + ajoutés),
// handleModelDirs : GET liste les dossiers de modèles (LOKI_HOME + ajoutés),
// POST {path, action:"add"|"remove"} en ajoute ou en retire un.
func handleModelDirs(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
@@ -269,5 +269,5 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
n := modelDirCount(d)
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0, "free": diskFree(d)})
}
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("AJEAN_MODEL_DIRS") != ""})
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("LOKI_MODEL_DIRS") != ""})
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os"
@@ -11,7 +11,7 @@ import (
func TestResolveModelPathExtraDir(t *testing.T) {
testHome(t)
ext := t.TempDir()
t.Setenv("AJEAN_MODEL_DIRS", "")
t.Setenv("LOKI_MODEL_DIRS", "")
extModel := filepath.Join(ext, "gros.gguf")
if err := os.WriteFile(extModel, []byte("x"), 0o644); err != nil {
@@ -42,7 +42,7 @@ func TestResolveModelPathExtraDir(t *testing.T) {
func TestResolveModelPathHomeFirst(t *testing.T) {
testHome(t)
ext := t.TempDir()
t.Setenv("AJEAN_MODEL_DIRS", ext)
t.Setenv("LOKI_MODEL_DIRS", ext)
if err := os.MkdirAll(modelsDir(), 0o755); err != nil {
t.Fatal(err)
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -87,7 +87,7 @@ func detectQuant(content string) string {
}
// downloadDestPath resolves the destination of a downloaded model : le dossier
// demandé (vide = AJEAN_HOME) parmi les dossiers de modèles déclarés, en
// demandé (vide = LOKI_HOME) parmi les dossiers de modèles déclarés, en
// refusant tout ce qui en sortirait (path traversal).
func downloadDestPath(name, dir string) (string, error) {
base := filepath.Base(strings.TrimSpace(name))
@@ -146,7 +146,7 @@ func deleteModelFile(name string) error {
return nil
}
// handleModelDelete deletes a single .gguf from AJEAN_HOME.
// handleModelDelete deletes a single .gguf from LOKI_HOME.
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
@@ -208,10 +208,10 @@ var dlClient = &http.Client{
}
// dlConns is the number of parallel range requests used per download.
// Overridable with AJEAN_DL_CONNS (1 disables parallelism).
// Overridable with LOKI_DL_CONNS (1 disables parallelism).
func dlConns() int {
n := 8
if v := os.Getenv("AJEAN_DL_CONNS"); v != "" {
if v := os.Getenv("LOKI_DL_CONNS"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
n = p
}
@@ -256,7 +256,7 @@ func normalizeHFURL(raw string) (string, string, error) {
}
// handleModelDownload kicks off a background download of a .gguf from a URL
// (typically Hugging Face) into AJEAN_HOME. Progress is polled via
// (typically Hugging Face) into LOKI_HOME. Progress is polled via
// /api/models/download/status.
func handleModelDownload(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -412,7 +412,7 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
if k := os.Getenv("HF_TOKEN"); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "ajean/"+Version)
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept-Encoding", "identity") // never gzip a .gguf: it breaks ranges
if rng != "" {
req.Header.Set("Range", rng)
@@ -1,4 +1,4 @@
package ajean
package loki
// Destination des téléchargements : le dossier doit être choisi parmi les
// dossiers de modèles déclarés (sinon l'UI web écrirait n'importe où), et
@@ -14,7 +14,7 @@ import (
func TestDownloadDestPathDir(t *testing.T) {
testHome(t)
extra := t.TempDir()
t.Setenv("AJEAN_MODEL_DIRS", extra)
t.Setenv("LOKI_MODEL_DIRS", extra)
// Dossier vide → models/.
got, err := downloadDestPath("m.gguf", "")
@@ -77,7 +77,7 @@ func TestDiskFreeAndCheck(t *testing.T) {
func TestCleanStalePartFilesExtraDirs(t *testing.T) {
testHome(t)
extra := t.TempDir()
t.Setenv("AJEAN_MODEL_DIRS", extra)
t.Setenv("LOKI_MODEL_DIRS", extra)
stale := filepath.Join(extra, "coupe.gguf.part")
if err := os.WriteFile(stale, []byte("x"), 0o644); err != nil {
t.Fatal(err)
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bytes"
@@ -27,7 +27,7 @@ func serveBlob(data []byte, ranges bool) *httptest.Server {
// Un téléchargement annulé ne doit laisser NI le .gguf final NI le .part.
func TestRunDownloadCancelLeavesNothing(t *testing.T) {
t.Setenv("AJEAN_DL_CONNS", "4")
t.Setenv("LOKI_DL_CONNS", "4")
release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") == "bytes=0-0" { // sonde : réponse immédiate
@@ -99,7 +99,7 @@ func TestCleanStalePartFiles(t *testing.T) {
func TestRunDownloadParallelAndFallback(t *testing.T) {
data := make([]byte, 48<<20) // > 3×dlMinChunk so the split actually kicks in
rand.New(rand.NewSource(1)).Read(data)
t.Setenv("AJEAN_DL_CONNS", "4")
t.Setenv("LOKI_DL_CONNS", "4")
for _, ranges := range []bool{true, false} {
srv := serveBlob(data, ranges)
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"fmt"
@@ -15,7 +15,7 @@ import (
// llama-server n'a besoin QUE de la première : il ouvre les suivantes tout seul,
// à condition qu'elles soient dans le même dossier.
//
// Tout le reste d'AJEAN traitait chaque tranche comme un modèle indépendant :
// Tout le reste d'Loki traitait chaque tranche comme un modèle indépendant :
// le lien collé ne rapatriait qu'un fichier sur trois (le moteur mourait sur
// « tensor not found »), la liste du sélecteur montrait trois entrées dont deux
// ne démarrent pas, et supprimer « le modèle » laissait les deux autres tranches
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os"
@@ -8,7 +8,7 @@
// CUDA officiel pour Linux (on retombe sur Vulkan — la compilation locale
// reste la voie CUDA sous Linux). La compilation locale reste indispensable
// pour les forks (ex. PrismML).
package ajean
package loki
import (
"archive/tar"
@@ -375,7 +375,7 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
logf(fmt.Sprintf("release %s — variant retenu : %s", tag, label))
// Réinstaller à l'identique est inutile SAUF si l'extraction date d'une
// version de AJEAN qui ignorait les liens des archives (backend installé mais
// version de Loki qui ignorait les liens des archives (backend installé mais
// bibliothèques introuvables au lancement) : le marqueur de format force alors
// une ré-extraction propre au lieu d'un « déjà à jour » trompeur.
if cur := prebuiltServerBin(); curTag == tag && prebuiltVersionFormat() == prebuiltFormat && cur != "" {
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"runtime"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"archive/tar"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bufio"
@@ -193,7 +193,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
// choix de machine (`ajean gpu`) qui doit survivre aux bascules, MAIS un preset
// choix de machine (`loki gpu`) qui doit survivre aux bascules, MAIS un preset
// a le droit de l'imposer — « QWEN 3.6 27B FABLE 2 GPU » réclame `1,0` pour que
// son `--tensor-split 0.965,0.035` ait bien deux cartes en face. Le préserver
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os"
@@ -36,9 +36,9 @@ func TestPresetFingerprintIgnoresCosmetics(t *testing.T) {
// TestSwitchToPresetGardeLesReglagesMachine : basculer de preset ne doit pas
// effacer les réglages qui décrivent la MACHINE et non le modèle. Le cas vécu :
// `ajean gpu 1` écrit CUDA_VISIBLE_DEVICES dans config.env, la bascule de preset
// `loki gpu 1` écrit CUDA_VISIBLE_DEVICES dans config.env, la bascule de preset
// suivante l'écrasait, llama.cpp revoyait les deux cartes et réétalait le modèle
// sur la petite — pendant que `ajean gpu` réaffichait « auto ».
// sur la petite — pendant que `loki gpu` réaffichait « auto ».
func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
home := testHome(t)
write := func(p, body string) {
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bytes"
@@ -41,15 +41,15 @@ func cmdServe(args []string) error {
cfg := ReadConfig()
bin := cfg["BIN"]
if bin == "" {
return fmt.Errorf("BIN non défini — lance « ajean edit »")
return fmt.Errorf("BIN non défini — lance « loki edit »")
}
model := cfg["MODEL"]
if model == "" {
return fmt.Errorf("MODEL non défini — lance « ajean edit »")
return fmt.Errorf("MODEL non défini — lance « loki edit »")
}
// MODEL vaut soit un simple nom de fichier (le .gguf vit dans AJEAN_HOME ou
// MODEL vaut soit un simple nom de fichier (le .gguf vit dans LOKI_HOME ou
// dans un dossier déclaré — disque externe…), soit un chemin absolu. Sous
// systemd/launchd le WorkingDirectory vaut AJEAN_HOME, donc le relatif tombait
// systemd/launchd le WorkingDirectory vaut LOKI_HOME, donc le relatif tombait
// juste ; lancé depuis une app de bureau, le répertoire courant est « / » et
// llama-server ne trouvait rien. On résout donc explicitement, quel que soit
// le contexte de lancement.
@@ -69,7 +69,7 @@ func cmdServe(args []string) error {
strings.Join(missing, ", "), filepath.Dir(model), len(shardFamily(filepath.Base(model))))
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(AjeanHome(), bin)
bin = filepath.Join(LokiHome(), bin)
}
// Le moteur précompilé s'installe dans un dossier versionné : un preset écrit
// avant une mise à jour pointe sur une release qui n'existe plus. On le fait
@@ -81,7 +81,7 @@ func cmdServe(args []string) error {
// Linux, PATH on Windows — handled inside execServer.
setLibraryPath(filepath.Dir(bin))
// Sélection GPU (ajean gpu) : on filtre les devices visibles par llama-server.
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
@@ -160,12 +160,12 @@ func cmdServe(args []string) error {
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
fmt.Fprintf(os.Stderr, "[ajean serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
}
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $AJEAN_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k := readAPIKey(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
@@ -176,11 +176,11 @@ func cmdServe(args []string) error {
// together so a path with spaces stays one argument.
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
// Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(AjeanHome())
_ = os.Chdir(LokiHome())
fmt.Fprintf(os.Stderr, "[ajean serve] %s model=%s port=%s\n",
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
// Hand off to the llama-server process. On Unix this replaces the current
@@ -1,4 +1,4 @@
package ajean
package loki
import "fmt"
@@ -43,7 +43,7 @@ func cmdAgent(args []string) error {
fmt.Printf(" %s %s\n", bold(p.Name), p.Title)
}
default:
return fmt.Errorf("usage: ajean agent [on|off|status]")
return fmt.Errorf("usage: loki agent [on|off|status]")
}
return nil
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bufio"
@@ -12,14 +12,14 @@ import (
// First positional arg (if any) becomes the system prompt.
func cmdChat(args []string) error {
if !healthCheck() {
return fmt.Errorf("serveur injoignable sur :%d — ajean start d'abord", LLMPort())
return fmt.Errorf("serveur injoignable sur :%d — loki start d'abord", LLMPort())
}
sysPrompt := strings.Join(args, " ")
msgs := []Message{}
if sysPrompt != "" {
msgs = append(msgs, Message{Role: "system", Content: sysPrompt})
}
fmt.Printf("\n%s — /reset pour vider, /sys <prompt> pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("ajean chat"))
fmt.Printf("\n%s — /reset pour vider, /sys <prompt> pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("loki chat"))
if sysPrompt != "" {
fmt.Println(dim("system: " + sysPrompt))
}
@@ -65,7 +65,7 @@ func cmdChat(args []string) error {
inReason := false
var stats *StatsEvent
// Print the assistant prefix once; reasoning is shown inline with a tag.
fmt.Print(cyan("ajean") + " > ")
fmt.Print(cyan("loki") + " > ")
caps := globalCaps()
// Compaction proactive (façon Hermes) : on résume les vieux tours quand
// l'historique dépasse le seuil, au lieu d'imposer un /reset.
@@ -120,7 +120,7 @@ func cmdChat(args []string) error {
fmt.Print("\n")
inReason = false
}
fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("ajean")+" >")
fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("loki")+" >")
case ev.Reasoning != "":
if !inReason {
fmt.Print(magenta("[reasoning] ") + dim(""))
@@ -129,7 +129,7 @@ func cmdChat(args []string) error {
fmt.Print(dim(ev.Reasoning))
case ev.Content != "":
if inReason {
fmt.Print("\n" + cyan("ajean") + " > ")
fmt.Print("\n" + cyan("loki") + " > ")
inReason = false
}
full.WriteString(ev.Content)
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bytes"
@@ -159,7 +159,7 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool {
}
// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
// (donc dans `journalctl -u ajean-ui`). Sans ça, une compaction qui ne se
// (donc dans `journalctl -u loki-ui`). Sans ça, une compaction qui ne se
// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté
// UI on ne voit qu'une jauge qui reste haute, sans savoir si le seuil n'a pas
// été atteint ou si la réduction a été refusée.
@@ -318,7 +318,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// Garantie de réduction : on n'accepte la compaction que si elle enlève au
// moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable)
// on la refuse — sans ça, ajean « compactait » à presque chaque message sans
// on la refuse — sans ça, loki « compactait » à presque chaque message sans
// vraiment réduire, puis re-déclenchait aussitôt.
before, after := estimateTokens(msgs), estimateTokens(out)
if after > before*4/5 {
@@ -387,7 +387,7 @@ Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwawa
Write the summary in the SAME language as the conversation.`
payload := map[string]any{
"model": "ajean",
"model": "loki",
"messages": []Message{
{Role: "system", Content: sys},
{Role: "user", Content: transcript},
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"strings"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -1,4 +1,4 @@
package ajean
package loki
// chat_diff.go — petit diff ligne à ligne pour l'interface : quand l'IA modifie
// un fichier (outil `edit`) ou une page de mémoire (`mem_edit`, `mem_add`), on
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -13,10 +13,10 @@ import (
// chat_export.go — sortir la conversation de la base.
//
// Jusqu'à la 0.7, l'historique vivait dans un `conversation.json` qu'on pouvait
// ouvrir, lire et copier pour archive. La 0.8 l'a rangé dans ajean.db (bbolt) :
// ouvrir, lire et copier pour archive. La 0.8 l'a rangé dans loki.db (bbolt) :
// un fichier binaire, et qui porte de surcroît un verrou EXCLUSIF tant que le
// service tourne — donc impossible ne serait-ce que de le copier sans arrêter
// AJEAN. Le fil de discussion et les raisonnements sont toujours là, mais plus
// Loki. Le fil de discussion et les raisonnements sont toujours là, mais plus
// personne ne peut les récupérer. C'est une régression pour qui relisait ses
// échanges, et ce fichier la répare.
//
@@ -80,7 +80,7 @@ func exportOptsFromQuery(q map[string][]string) exportOpts {
// envoyée au modèle, `log` le journal d'affichage (celui qui porte les
// raisonnements, les outils et les vitesses).
type exportPayload struct {
Version string `json:"ajean_version"`
Version string `json:"loki_version"`
ExportedAt string `json:"exported_at"`
CtxUsed int `json:"ctx_used"`
Messages []Message `json:"messages"`
@@ -199,7 +199,7 @@ func (c *Conversation) ExportMarkdown(o exportOpts) string {
}
var b strings.Builder
fmt.Fprintf(&b, "# Conversation AJEAN\n\nExportée le %s par AJEAN %s.\n",
fmt.Fprintf(&b, "# Conversation Loki\n\nExportée le %s par Loki %s.\n",
time.Now().Format("02/01/2006 à 15:04"), Version)
// Un export tronqué ou allégé doit le DIRE : relu six mois plus tard, un fil
// sans ses raisonnements ne doit pas passer pour le fil complet.
@@ -208,12 +208,12 @@ func (c *Conversation) ExportMarkdown(o exportOpts) string {
}
// openBubble : une bulle assistant est ouverte (au moins un bout de réponse a
// été écrit). Sert à ne poser l'en-tête « AJEAN » qu'une fois par tour, même
// été écrit). Sert à ne poser l'en-tête « Loki » qu'une fois par tour, même
// quand la réponse est entrecoupée d'appels d'outils.
openBubble := false
head := func() {
if !openBubble {
b.WriteString("\n## AJEAN\n")
b.WriteString("\n## Loki\n")
openBubble = true
}
}
@@ -303,7 +303,7 @@ func mdInline(v any) string {
// exportFilename : nom proposé au téléchargement, horodaté pour que deux exports
// ne s'écrasent pas dans le dossier de téléchargements.
func exportFilename(ext string) string {
return "ajean-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext
return "loki-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext
}
// exportBody rend la conversation selon les options. Renvoie le corps, son type
@@ -318,10 +318,10 @@ func exportBody(o exportOpts) ([]byte, string, string, error) {
// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard).
//
// ajean export → ajean-conversation-<date>.md dans le dossier courant
// ajean export --json → idem en JSON
// ajean export mon-fil.md → nom de fichier imposé (l'extension choisit le format)
// ajean export - → sur la sortie standard, pour enchaîner un tube
// loki export → loki-conversation-<date>.md dans le dossier courant
// loki export --json → idem en JSON
// loki export mon-fil.md → nom de fichier imposé (l'extension choisit le format)
// loki export - → sur la sortie standard, pour enchaîner un tube
//
// Options de contenu, les mêmes que la fenêtre d'export de l'interface :
//
@@ -357,12 +357,12 @@ func cmdExport(args []string) error {
o.Turns = n
default:
if strings.HasPrefix(a, "-") && a != "-" {
return fmt.Errorf("option inconnue : %s (voir « ajean help »)", a)
return fmt.Errorf("option inconnue : %s (voir « loki help »)", a)
}
out = a
}
}
// Une extension explicite l'emporte sur le drapeau : `ajean export fil.json`
// Une extension explicite l'emporte sur le drapeau : `loki export fil.json`
// qui produirait du Markdown serait un piège.
if strings.HasSuffix(strings.ToLower(out), ".json") {
o.Format = "json"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -37,7 +37,7 @@ func TestExportMarkdownPorteToutLeFil(t *testing.T) {
md := conv.ExportMarkdown(defaultExportOpts())
for _, want := range []string{
"## Vous", "combien font 2+2 ?",
"## AJEAN",
"## Loki",
"<summary>Raisonnement</summary>", "addition simple",
"bash", "echo 4",
} {
@@ -45,9 +45,9 @@ func TestExportMarkdownPorteToutLeFil(t *testing.T) {
t.Errorf("Markdown exporté sans %q :\n%s", want, md)
}
}
// L'en-tête AJEAN ne doit apparaître qu'UNE fois pour un tour, même entrecoupé
// L'en-tête Loki ne doit apparaître qu'UNE fois pour un tour, même entrecoupé
// d'un appel d'outil.
if n := strings.Count(md, "\n## AJEAN\n"); n != 1 {
if n := strings.Count(md, "\n## Loki\n"); n != 1 {
t.Errorf("en-tête assistant répété %d fois", n)
}
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bytes"
@@ -18,7 +18,7 @@ import (
// Accès internet de l'IA — port Go de l'extension pi ~/.pi/agent/extensions/web.ts.
//
// ajean parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL
// loki parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL
// est configurée dans config.env (CRAWL4AI_URL). Quand le mode agent ET l'accès
// internet sont actifs ET que le serveur répond, l'IA dispose de 4 outils :
//
@@ -1,6 +1,6 @@
// chat_internet_tools.go — les 4 outils web exposés au modèle (web_search,
// web_open, web_read, web_grep) + la sous-commande `ajean internet`.
package ajean
// web_open, web_read, web_grep) + la sous-commande `loki internet`.
package loki
import (
"fmt"
@@ -281,7 +281,7 @@ func toolWebGrep(args map[string]any) string {
entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
}
// ─── CLI : ajean internet [on|off|status|engine|url|key] ────────────────────
// ─── CLI : loki internet [on|off|status|engine|url|key] ────────────────────
func cmdInternet(args []string) error {
sub := ""
@@ -292,7 +292,7 @@ func cmdInternet(args []string) error {
case "on":
// Le moteur intégré ne demande aucun réglage ; Crawl4AI exige un serveur.
if webEngine() == engineCrawl && crawl4aiURL() == "" {
return fmt.Errorf("configure d'abord l'URL : ajean internet url <url> (ou bascule sur le moteur intégré : ajean internet engine go)")
return fmt.Errorf("configure d'abord l'URL : loki internet url <url> (ou bascule sur le moteur intégré : loki internet engine go)")
}
if err := setInternetEnabled(true); err != nil {
return err
@@ -305,7 +305,7 @@ func cmdInternet(args []string) error {
fmt.Println(green("[ok]") + " accès internet désactivé")
case "url":
if len(args) < 2 {
return fmt.Errorf("usage: ajean internet url <url> (ex: http://localhost:11235)")
return fmt.Errorf("usage: loki internet url <url> (ex: http://localhost:11235)")
}
u := strings.TrimRight(strings.TrimSpace(args[1]), "/")
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
@@ -317,7 +317,7 @@ func cmdInternet(args []string) error {
fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
case "key":
if len(args) < 2 {
return fmt.Errorf("usage: ajean internet key <clé> (vide pour l'enlever : ajean internet key \"\")")
return fmt.Errorf("usage: loki internet key <clé> (vide pour l'enlever : loki internet key \"\")")
}
k := strings.TrimSpace(args[1])
if err := writeCrawlKey(k); err != nil {
@@ -333,7 +333,7 @@ func cmdInternet(args []string) error {
}
case "engine":
if len(args) < 2 {
return fmt.Errorf("usage: ajean internet engine <go|crawl4ai>")
return fmt.Errorf("usage: loki internet engine <go|crawl4ai>")
}
e := strings.ToLower(strings.TrimSpace(args[1]))
if err := setWebEngine(e); err != nil {
@@ -342,7 +342,7 @@ func cmdInternet(args []string) error {
if e == engineGo {
fmt.Println(green("[ok]") + " moteur intégré — aucune installation requise (pas de rendu JavaScript)")
} else {
fmt.Println(green("[ok]") + " moteur Crawl4AI — configure le serveur : ajean internet url <url>")
fmt.Println(green("[ok]") + " moteur Crawl4AI — configure le serveur : loki internet url <url>")
}
case "", "status", "list":
state := dim("off")
@@ -358,7 +358,7 @@ func cmdInternet(args []string) error {
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
u := crawl4aiURL()
if u == "" {
fmt.Printf(" serveur : %s — configure : ajean internet url <url>\n", dim("(non configuré)"))
fmt.Printf(" serveur : %s — configure : loki internet url <url>\n", dim("(non configuré)"))
return nil
}
reach := red("injoignable")
@@ -368,7 +368,7 @@ func cmdInternet(args []string) error {
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
default:
return fmt.Errorf("usage: ajean internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
}
return nil
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"errors"
@@ -10,7 +10,7 @@ import (
"strings"
)
// La mémoire de ajean = des fichiers Markdown plats sous memory/<nom>.md.
// La mémoire de loki = des fichiers Markdown plats sous memory/<nom>.md.
// 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.
@@ -331,7 +331,7 @@ func setMemMode(m MemMode) error {
return SetConfigKey("MEM_MODE", string(m))
}
// cmdMemory : ajean memory [off|ondemand|always|status]
// cmdMemory : loki memory [off|ondemand|always|status]
func cmdMemory(args []string) error {
sub := ""
if len(args) > 0 {
@@ -362,7 +362,7 @@ func cmdMemory(args []string) error {
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
return nil
default:
return fmt.Errorf("usage: ajean memory [off|ondemand|always|status]")
return fmt.Errorf("usage: loki memory [off|ondemand|always|status]")
}
m := memMode()
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -41,9 +41,9 @@ func baseSystemPrompt(caps Caps) string {
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
// puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
// mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
// « AJEAN » avec une majuscule : c'est un nom propre, et le modèle recopie
// littéralement la casse d'ici quand il se présente (« je suis ajean »).
b.WriteString("You are AJEAN, an expert assistant operating directly on this machine with real tools.")
// « Loki » avec une majuscule : c'est un nom propre, et le modèle recopie
// littéralement la casse d'ici quand il se présente (« je suis loki »).
b.WriteString("You are Loki, an expert assistant operating directly on this machine with real tools.")
if caps.Mem == MemAlways {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
@@ -161,7 +161,7 @@ func machineSystemPrompt(caps Caps) string {
}
b.WriteString(".")
if cwd != "" {
b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder ajean was launched from.")
b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder loki was launched from.")
}
return b.String()
}
@@ -185,7 +185,7 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
// Le shell démarre dans le workspace, pas dans le dossier d'où ajean a été
// Le shell démarre dans le workspace, pas dans le dossier d'où loki a été
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
//
// Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il
@@ -208,7 +208,7 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// commande du genre « ./serveur & » rend la main tout de suite mais laisse
// un process en arrière-plan accroché aux tubes : runShell ne revenait alors
// JAMAIS, ni au délai, ni au stop. Le tour restait bloqué à vie, et la seule
// issue connue était de redémarrer ajean-ui.
// issue connue était de redémarrer loki-ui.
cmd.WaitDelay = 2 * time.Second
err := cmd.Run()
switch {
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os"
@@ -11,14 +11,14 @@ import (
//
// Sans ça, les outils write/edit/bash héritent du répertoire courant du PROCESSUS :
// le modèle écrit "meteo.json", et le fichier atterrit là d'où l'utilisateur a
// lancé ajean — le Bureau quand on double-clique l'installateur, C:\ProgramData\ajean\bin
// lancé loki — le Bureau quand on double-clique l'installateur, C:\ProgramData\loki\bin
// quand on lance le binaire installé. Personne ne s'attend à voir un chat déposer
// des fichiers sur son Bureau. On donne donc à l'agent un dossier à lui : les
// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
// dossier précis, ça doit marcher.
const workspaceEnv = "AJEAN_WORKSPACE"
const workspaceEnv = "LOKI_WORKSPACE"
var (
workspaceOnce sync.Once
@@ -26,7 +26,7 @@ var (
)
// agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il
// essaie plusieurs emplacements car AjeanHome() vaut %ProgramData%\ajean sous
// essaie plusieurs emplacements car LokiHome() vaut %ProgramData%\loki sous
// Windows, qui n'est pas inscriptible par un utilisateur non administrateur.
func agentWorkspace() string {
workspaceOnce.Do(func() {
@@ -62,9 +62,9 @@ func workspaceCandidates() []string {
}
c = append(c, workspaceDir())
if home, err := os.UserHomeDir(); err == nil && home != "" {
c = append(c, filepath.Join(home, "ajean", "workspace"))
c = append(c, filepath.Join(home, "loki", "workspace"))
}
c = append(c, filepath.Join(os.TempDir(), "ajean-workspace"))
c = append(c, filepath.Join(os.TempDir(), "loki-workspace"))
return c
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"path/filepath"
@@ -7,7 +7,7 @@ import (
)
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
// répertoire courant du processus (Bureau, C:\ProgramData\ajean\bin…).
// répertoire courant du processus (Bureau, C:\ProgramData\loki\bin…).
func TestResolveAgentPathRelative(t *testing.T) {
got := resolveAgentPath("meteo.json")
want := filepath.Join(agentWorkspace(), "meteo.json")
@@ -1,12 +1,12 @@
package ajean
package loki
// cli_app.go — expérience « application » de AJEAN.
// cli_app.go — expérience « application » de Loki.
//
// Quand on double-clique sur le binaire (aucun argument, console fraîche), au
// lieu d'afficher l'aide dans une console qui se ferme, AJEAN démarre son UI web,
// lieu d'afficher l'aide dans une console qui se ferme, Loki démarre son UI web,
// l'ouvre dans le navigateur ET pose une icône dans la zone de notification
// Windows (voir sys_tray_windows.go) : on voit que AJEAN tourne et on le pilote
// (« Ouvrir AJEAN » / « Quitter »).
// Windows (voir sys_tray_windows.go) : on voit que Loki tourne et on le pilote
// (« Ouvrir Loki » / « Quitter »).
import (
"fmt"
@@ -34,13 +34,13 @@ func cmdApp(args []string) error {
addr := fmt.Sprintf("0.0.0.0:%d", appPort)
ln, err := net.Listen("tcp", addr)
if err != nil {
// AJEAN tourne déjà sur ce port : on ouvre juste l'UI sur l'instance
// Loki tourne déjà sur ce port : on ouvre juste l'UI sur l'instance
// existante plutôt que d'échouer.
fmt.Printf("AJEAN est déjà lancé — ouverture de %s\n", url)
fmt.Printf("Loki est déjà lancé — ouverture de %s\n", url)
return openBrowser(url)
}
// UN SEUL process propriétaire de la conversation, comme le service ajean-ui
// UN SEUL process propriétaire de la conversation, comme le service loki-ui
// sous Linux : l'app sert l'UI locale ET le tunnel avec le MÊME mux. Deux
// process qui servent la conversation (objet en mémoire persisté dans
// conversation.json) donnent deux fils divergents entre le local et
@@ -60,7 +60,7 @@ func cmdApp(args []string) error {
startAppLink(appWebMux)
}()
sp := showSplash("Lancement d'AJEAN en cours…")
sp := showSplash("Lancement d'Loki en cours…")
waitServerReady(url)
_ = openBrowser(url)
time.Sleep(900 * time.Millisecond) // laisse le navigateur s'afficher par-dessus le splash
@@ -1,8 +1,8 @@
// AJEAN — cœur du binaire (package ajean, appelé par cmd/ajean). Go n'autorise
// Loki — cœur du binaire (package loki, appelé par cmd/loki). Go n'autorise
// pas de sous-dossiers dans un même package : les fichiers sont donc préfixés
// par domaine.
//
// run.go point d'entrée : dispatch des sous-commandes, AjeanHome() et
// run.go point d'entrée : dispatch des sous-commandes, LokiHome() et
// l'arborescence de données
// store.go LA base (bbolt) : configuration, préférences, conversation,
// clés, jetons, interrupteurs — tout l'état non éditable à la main
@@ -28,11 +28,11 @@
// supprimer un symbole jugé inutilisé, vérifier `grep` sur les fichiers _darwin.
//
// DEUX SERVICES à l'exécution, un seul binaire :
// - ajean-engine (« ajean serve ») exec llama-server ;
// - ajean-ui (« ajean web ») sert l'UI locale, le tunnel du relais et
// - loki-engine (« loki serve ») exec llama-server ;
// - loki-ui (« loki web ») sert l'UI locale, le tunnel du relais et
// l'endpoint OpenAI depuis un SEUL process — condition d'une conversation
// unique, celle-ci vivant en mémoire (voir chat_conversation.go).
//
// ui/index.html est GÉNÉRÉ depuis ui/src/ : éditer les sources puis
// `go generate ./internal/ajean` (voir tools/assemble-ui).
package ajean
// `go generate ./internal/loki` (voir tools/assemble-ui).
package loki
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bytes"
@@ -70,7 +70,7 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) {
// Use the same endpoint your real chat hits, so the comparison is honest
// (chat template, reasoning, OpenAI-compat layer all included).
payload := map[string]any{
"model": "ajean",
"model": "loki",
"messages": []Message{{Role: "user", Content: prompt + "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics."}},
"max_tokens": nPredict,
"stream": false,
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bufio"
@@ -575,7 +575,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// le contexte — c'est un choix assumé.
for iter := 0; ; iter++ {
payload := map[string]any{
"model": "ajean",
"model": "loki",
"messages": messages,
"stream": true,
"temperature": temperature,
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"bufio"
@@ -83,12 +83,12 @@ func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) }
// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
// répond elle-même aux challenges ACME. La clé privée est stockée dans
// $AJEAN_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le
// $LOKI_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le
// démux (oaiPublicEnabled, lu en direct) qui décide de router ou non le trafic.
func oaiTLSConfig() *tls.Config {
certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")}
certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(LokiHome(), "certs")}
certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Email = os.Getenv("AJEAN_ACME_EMAIL")
certmagic.DefaultACME.Email = os.Getenv("LOKI_ACME_EMAIL")
certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement
magic := certmagic.NewDefault()
magic.OnDemand = &certmagic.OnDemandConfig{
@@ -206,7 +206,7 @@ func selfSignedTLSConfig(host string) (*tls.Config, error) {
// cmdOAI pilote l'accès OpenAI public côté agent.
//
// ajean oai serve [port] [host] (test local) termine le TLS sur :port avec un
// loki oai serve [port] [host] (test local) termine le TLS sur :port avec un
// cert auto-signé et proxifie vers llama /v1.
func cmdOAI(args []string) error {
sub := ""
@@ -236,12 +236,12 @@ func cmdOAI(args []string) error {
if err != nil {
return err
}
fmt.Printf("[ajean oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort())
fmt.Printf("[loki oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort())
return runOAIFront(ln, tlsCfg)
default:
fmt.Println("usage: ajean oai serve [port] [host] (front TLS de test → llama /v1)")
fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (ajean link)")
fmt.Println(" quand AJEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.")
fmt.Println("usage: loki oai serve [port] [host] (front TLS de test → llama /v1)")
fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (loki link)")
fmt.Println(" quand LOKI_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.")
return nil
}
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"strings"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -14,7 +14,7 @@ func cmdTest(args []string) error {
fmt.Printf("→ GET http://localhost:%d/health … ", port)
if !healthCheck() {
fmt.Println(red("ko"))
return fmt.Errorf("/health ne répond pas — ajean start d'abord")
return fmt.Errorf("/health ne répond pas — loki start d'abord")
}
fmt.Println(green("ok"))
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -127,7 +127,7 @@ func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
// connect établit une session pour la config donnée.
func mcpConnect(ctx context.Context, name string, cfg MCPServerConfig) (*mcpsdk.ClientSession, error) {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "ajean", Version: Version}, nil)
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "loki", Version: Version}, nil)
var transport mcpsdk.Transport
switch cfg.Transport() {
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os/exec"
@@ -17,7 +17,7 @@ func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) {
// Commande inexistante : la connexion échoue, mais l'entrée du pool est bien
// partagée — c'est ce qu'on teste.
cfg := MCPServerConfig{Command: "ajean-binaire-inexistant-pour-test", Enabled: true}
cfg := MCPServerConfig{Command: "loki-binaire-inexistant-pour-test", Enabled: true}
const callers = 8
var wg sync.WaitGroup
@@ -83,8 +83,8 @@ func TestMCPEndToEnd(t *testing.T) {
t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
}
out := mcpCall(echoName, map[string]any{"message": "bonjour-ajean"})
if !strings.Contains(out, "bonjour-ajean") {
out := mcpCall(echoName, map[string]any{"message": "bonjour-loki"})
if !strings.Contains(out, "bonjour-loki") {
t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
}
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -10,14 +10,14 @@ import (
// Configuration des serveurs MCP (Model Context Protocol).
//
// ajean peut se connecter à des serveurs MCP tiers pour enrichir la palette
// loki peut se connecter à des serveurs MCP tiers pour enrichir la palette
// d'outils de l'IA (accès fichiers rapide type desktop-commander, bases de
// données, APIs métier…). Deux transports :
//
// - stdio : ajean lance un process local (command + args) et parle en
// - stdio : loki lance un process local (command + args) et parle en
// JSON-RPC sur stdin/stdout. C'est le cas de desktop-commander, du serveur
// filesystem officiel, etc. — lancés via npx/uvx/un binaire.
// - http : ajean parle à un serveur MCP distant en Streamable HTTP (url +
// - http : loki parle à un serveur MCP distant en Streamable HTTP (url +
// éventuels en-têtes d'auth).
//
// Le format du fichier mcp.json reprend celui de Claude Desktop (clé
@@ -25,7 +25,7 @@ import (
// leurs configs existantes. On ajoute un champ "enabled" par serveur.
//
// IMPORTANT (sécurité) : un serveur MCP stdio exécute un process arbitraire sur
// la machine où tourne ajean — même niveau de confiance que l'outil `bash` du
// la machine où tourne loki — même niveau de confiance que l'outil `bash` du
// mode agent. La configuration MCP est donc réservée au propriétaire local de la
// machine et ne doit JAMAIS être pilotable depuis le relais/accès distant.
@@ -1,8 +1,8 @@
package ajean
package loki
// migrate_07.go — CODE TEMPORAIRE, à supprimer d'ici une version ou deux.
//
// Reprend une installation 0.7.x au moment de `ajean install`. Tout est ici, et
// Reprend une installation 0.7.x au moment de `loki install`. Tout est ici, et
// rien d'autre n'en dépend : le jour où le parc aura basculé, supprimer ce
// fichier et les deux appels à migrateFrom07() (sys_install_unix.go et
// sys_install_windows.go) suffit. C'est la seule raison d'être de ce fichier —
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os"
@@ -38,7 +38,7 @@ func fake07Home(t *testing.T) string {
write(".agent_enabled", "")
write("webprefs.json", `{"theme":"dark"}`)
write("mcp.json", `{"mcpServers":{"fs":{"command":"npx","enabled":true}}}`)
write("sysprompt.txt", "Tu es AJEAN.\n")
write("sysprompt.txt", "Tu es Loki.\n")
write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`)
write("SKILLS/vieux-skill/SKILL.md", "# un skill d'avant\n")
write(".authorized_users", "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA\n")
@@ -90,7 +90,7 @@ func TestMigration07(t *testing.T) {
if !agentEnabled() {
t.Error("mode agent perdu")
}
if readSysPrompt() != "Tu es AJEAN." {
if readSysPrompt() != "Tu es Loki." {
t.Errorf("prompt système = %q", readSysPrompt())
}
if loadWebPrefs()["theme"] != "dark" {
@@ -6,7 +6,7 @@
// - ENRÔLEMENT (public) : le poste échange un code d'appairage à usage unique
// contre sa clé d'appareil. Authentifié par le code, pas par la clé de
// pilotage — que le poste ne possède pas.
package ajean
package loki
import (
"encoding/base64"
@@ -1,16 +1,16 @@
// node_proto.go — côté serveur, alias vers le protocole partagé (internal/nodewire)
// + les helpers de nommage d'outils propres à ajean.
// + les helpers de nommage d'outils propres à loki.
//
// Le format du fil (Msg, capacités, confinement de chemin) vit dans nodewire,
// paquet SANS dépendance, pour que le client léger ajean-node le partage sans
// tirer tout ajean. Ici on ne fait qu'exposer ces symboles sous les noms
// paquet SANS dépendance, pour que le client léger loki-node le partage sans
// tirer tout loki. Ici on ne fait qu'exposer ces symboles sous les noms
// historiques du paquet, pour ne pas toucher le reste du code serveur.
package ajean
package loki
import (
"strings"
"github.com/nathaninline/ajean/internal/nodewire"
"github.com/R0m1k3/Loki/internal/nodewire"
)
// Capacités (alias des constantes partagées).
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"path/filepath"
@@ -2,7 +2,7 @@
// WebSocket, registre des postes connectés, et exposition de leurs outils à
// l'agent. Le serveur n'exécute JAMAIS rien lui-même ici : il route une demande
// d'outil vers le poste concerné et attend sa réponse.
package ajean
package loki
import (
"context"
@@ -18,7 +18,7 @@ import (
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/nathaninline/ajean/internal/nodewire"
"github.com/R0m1k3/Loki/internal/nodewire"
)
const (
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"context"
@@ -12,7 +12,7 @@ import (
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
"github.com/nathaninline/ajean/internal/nodewire"
"github.com/R0m1k3/Loki/internal/nodewire"
)
// TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
@@ -1,11 +1,11 @@
package ajean
package loki
// relay_e2e.go — chiffrement bout-en-bout du chat entre le NAVIGATEUR et CET agent, de
// sorte que le relais (ajean.link) ne voie que de l'opaque : « boîte noire ».
//
// Modèle : cet agent a une paire X25519 long-terme (clé privée locale, jamais
// transmise). Sa clé publique est publiée au relais et son EMPREINTE est affichée
// au `ajean link` — l'utilisateur la confirme dans le portail, ce qui défait tout
// au `loki link` — l'utilisateur la confirme dans le portail, ce qui défait tout
// MITM du relais. Le navigateur dérive une racine R de son mot de passe (exportKey
// OPAQUE), la SCELLE vers la clé publique de l'agent (le relais ne peut pas
// l'ouvrir), puis chiffre/déchiffre le chat avec une clé dérivée de R.
@@ -32,7 +32,7 @@ import (
"sync"
)
func e2eKeyPath() string { return filepath.Join(AjeanHome(), ".e2e_key") }
func e2eKeyPath() string { return filepath.Join(LokiHome(), ".e2e_key") }
var (
e2eOnce sync.Once
@@ -55,7 +55,7 @@ func e2ePrivateKey() (*ecdh.PrivateKey, error) {
e2eErr = err
return
}
_ = os.MkdirAll(AjeanHome(), 0o755)
_ = os.MkdirAll(LokiHome(), 0o755)
if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
e2eErr = err
return
@@ -1,4 +1,4 @@
package ajean
package loki
// relay_e2eauth.go — authentification MUTUELLE + anti-rejeu du canal chiffré.
//
@@ -9,7 +9,7 @@ package ajean
// Modèle : l'utilisateur a une identité X25519 dérivée de façon déterministe de son
// mot de passe (exportKey OPAQUE → racine R → uPriv) — le relais ne connaît pas R,
// donc ne peut PAS reproduire cette identité. La clé publique uPub est APPAIRÉE une
// fois à l'agent via un code affiché par « ajean link » (canal hors-bande : le log du
// fois à l'agent via un code affiché par « loki link » (canal hors-bande : le log du
// serveur, que le relais ne voit pas ; le code voyage scellé vers l'agent). Ensuite,
// chaque requête est chiffrée avec K = SHA256(ECDH(uPriv, agentPriv) || "authchan"),
// liée à uPub+horodatage (AAD) et protégée contre le rejeu. Seul le vrai utilisateur
@@ -92,7 +92,7 @@ func authorizeUser(uPubHex string) error {
// ---- Codes d'appairage : à la demande, usage unique, TTL 10 min --------------
//
// Générés par « ajean link code » (ou affichés par « ajean link »), ils sont
// Générés par « loki link code » (ou affichés par « loki link »), ils sont
// partagés avec le service d'interface (autre process) via la base — sinon les
// deux ne s'accorderaient pas. Stockés HACHÉS (SHA-256) : la base ne révèle
// aucun code. Chaque code expire au bout de 10 min et est consommé (retiré) au
@@ -232,7 +232,7 @@ func replayCheck(uPub string, ts int64, iv string) bool {
// ---- Dérivation de clé + ouverture authentifiée ----------------------------
// e2eAuthKey dérive la clé du canal authentifié pour l'utilisateur uPub :
// SHA256( ECDH(agentPriv, uPub) || "ajean-authchan-v1" ). Symétrique de la version
// SHA256( ECDH(agentPriv, uPub) || "loki-authchan-v1" ). Symétrique de la version
// navigateur (WASM) qui calcule ECDH(uPriv, agentPub) = même secret partagé.
func e2eAuthKey(uPubHex string) ([]byte, error) {
priv, err := e2ePrivateKey()
@@ -253,7 +253,7 @@ func e2eAuthKey(uPubHex string) ([]byte, error) {
}
h := sha256.New()
h.Write(ss)
h.Write([]byte("ajean-authchan-v1"))
h.Write([]byte("loki-authchan-v1"))
return h.Sum(nil), nil
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"strings"
@@ -1,16 +1,16 @@
package ajean
package loki
// relay_link.go — `ajean link <token>` : connecte ce serveur AJEAN au relais public
// relay_link.go — `loki link <token>` : connecte ce serveur Loki au relais public
// (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur
// y accède depuis n'importe où sans ouvrir de port (CGNAT, box, etc.).
//
// Principe : l'agent ouvre un WebSocket vers le relais, l'authentifie avec le
// token d'abonnement, puis multiplexe (yamux) ce lien unique en un stream par
// requête navigateur. Chaque stream est reverse-proxyfié vers le `ajean web`
// requête navigateur. Chaque stream est reverse-proxyfié vers le `loki web`
// local. Keepalive + reconnexion automatique avec backoff.
//
// Le token est fourni par la boutique à l'achat. Il est mémorisé dans
// $AJEAN_HOME/.link_token pour que `ajean link` (sans argument) reprenne la
// $LOKI_HOME/.link_token pour que `loki link` (sans argument) reprenne la
// connexion.
import (
@@ -39,7 +39,7 @@ import (
)
// defaultRelayURL est l'endpoint WebSocket du relais. Surchageable via
// $AJEAN_LINK_URL (utile pour tester contre un relais local).
// $LOKI_LINK_URL (utile pour tester contre un relais local).
const defaultRelayURL = "wss://ajean.link/agent"
// machineID renvoie un identifiant stable de la machine, créé au premier appel.
@@ -65,20 +65,20 @@ func removeLinkToken() error { return putStr(bkState, "link_token", "") }
// relayURL resolves the relay WebSocket endpoint (env override → default).
func relayURL() string {
if u := os.Getenv("AJEAN_LINK_URL"); u != "" {
if u := os.Getenv("LOKI_LINK_URL"); u != "" {
return u
}
return defaultRelayURL
}
// uiUnitName est l'unité qui exécute « ajean web » : l'UI locale, le tunnel du
// uiUnitName est l'unité qui exécute « loki web » : l'UI locale, le tunnel du
// relais et l'endpoint OpenAI, dans un seul et même process.
const uiUnitName = "ajean-ui"
const uiUnitName = "loki-ui"
func uiServiceName() string { return uiUnitName }
// cmdLink ne gère QUE le compte d'accès distant : le jeton, l'appairage, l'état.
// Le tunnel n'est plus un service à part — il est ouvert par « ajean web » dès
// Le tunnel n'est plus un service à part — il est ouvert par « loki web » dès
// qu'un jeton est enregistré. Piloter le tunnel, c'est donc piloter le service
// d'interface (voir cmdUI).
func cmdLink(args []string) error {
@@ -88,33 +88,33 @@ func cmdLink(args []string) error {
}
switch sub {
case "":
// `ajean link` seul : afficher l'aide (NE démarre rien — éviter de prendre
// `loki link` seul : afficher l'aide (NE démarre rien — éviter de prendre
// un mot pour un jeton et d'écraser le vrai).
printLinkHelp()
return nil
case "status":
tok := readLinkToken()
if tok == "" {
fmt.Println(yellow("[info]") + " aucun jeton enregistré — lance: ajean link <token>")
fmt.Println(yellow("[info]") + " aucun jeton enregistré — lance: loki link <token>")
return nil
}
fmt.Printf("%s jeton enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
if uiServiceActive() {
fmt.Printf("%s service %s actif — le tunnel est ouvert\n", green("[ok]"), uiServiceName())
} else {
fmt.Printf("%s service %s arrêté — pas de tunnel (ajean ui start)\n", yellow("[info]"), uiServiceName())
fmt.Printf("%s service %s arrêté — pas de tunnel (loki ui start)\n", yellow("[info]"), uiServiceName())
}
return nil
case "logout":
if err := removeLinkToken(); err != nil {
return err
}
fmt.Println(green("[ok]") + " jeton supprimé — « ajean ui restart » pour fermer le tunnel")
fmt.Println(green("[ok]") + " jeton supprimé — « loki ui restart » pour fermer le tunnel")
return nil
case "code":
code, err := newPairCode()
if err != nil {
return fmt.Errorf("génération du code (droits sur %s ?): %w", AjeanHome(), err)
return fmt.Errorf("génération du code (droits sur %s ?): %w", LokiHome(), err)
}
fmt.Printf("%s code d'appairage (valable 10 min, à usage unique) :\n %s\n", green("[link]"), bold(code))
return nil
@@ -138,7 +138,7 @@ func cmdLink(args []string) error {
return linkPrintIdentity()
}
// cmdUI pilote le service d'interface — « ajean web » en arrière-plan : l'UI
// cmdUI pilote le service d'interface — « loki web » en arrière-plan : l'UI
// locale, le tunnel du relais et l'endpoint OpenAI, servis par le MÊME process.
// C'est ce qui garantit une conversation unique, identique en local et à distance.
func cmdUI(args []string) error {
@@ -160,22 +160,22 @@ func cmdUI(args []string) error {
}
return nil
default:
return fmt.Errorf("usage: ajean ui [start|stop|restart|status]")
return fmt.Errorf("usage: loki ui [start|stop|restart|status]")
}
}
// printLinkHelp liste les sous-commandes de `ajean link`.
// printLinkHelp liste les sous-commandes de `loki link`.
func printLinkHelp() {
fmt.Print(`ajean link — accès distant via le relais ajean.link
fmt.Print(`loki link — accès distant via le relais ajean.link
Usage :
ajean link <token> enregistre le jeton (1re fois / pour le changer) et ouvre le tunnel
ajean link status état du jeton et du tunnel
ajean link code génère un code d'appairage (valable 10 min, à usage unique)
ajean link logout oublie le jeton enregistré
loki link <token> enregistre le jeton (1re fois / pour le changer) et ouvre le tunnel
loki link status état du jeton et du tunnel
loki link code génère un code d'appairage (valable 10 min, à usage unique)
loki link logout oublie le jeton enregistré
Le jeton est fourni sur ajean.link. Le tunnel est ouvert par le service
d'interface (« ajean ui ») dès qu'un jeton est enregistré : il n'y a pas de
d'interface (« loki ui ») dès qu'un jeton est enregistré : il n'y a pas de
service séparé pour l'accès distant.
`)
}
@@ -189,8 +189,8 @@ func linkPrintIdentity() error {
}
code, err := newPairCode()
if err != nil {
fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), AjeanHome(), err)
fmt.Printf(" Réessaie : sudo ajean link code\n")
fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), LokiHome(), err)
fmt.Printf(" Réessaie : sudo loki link code\n")
return nil
}
fmt.Printf(" Code d'appairage (valable 10 min, usage unique) : %s\n\n", bold(code))
@@ -208,7 +208,7 @@ func linkPrintIdentity() error {
//
// Sur un poste de bureau, l'app EST ce process propriétaire : elle sert déjà
// :8090, donc elle fait aussi tourner le tunnel elle-même au lieu de déléguer à
// un worker détaché. L'accès distant vit donc aussi longtemps que AJEAN.app est
// un worker détaché. L'accès distant vit donc aussi longtemps que Loki.app est
// ouverte — sur un portable qui s'endort, c'est de toute façon la réalité.
var appLink struct {
@@ -310,14 +310,14 @@ func killForeignUIWorker() {
}
// uiPIDPath / uiLogPath : suivi du worker de lien hors systemd (macOS,
// Windows), où AJEAN est une app de bureau lancée sans droits root.
func uiPIDPath() string { return filepath.Join(AjeanHome(), uiUnitName+".pid") }
func uiLogPath() string { return filepath.Join(AjeanHome(), uiUnitName+".log") }
// Windows), où Loki est une app de bureau lancée sans droits root.
func uiPIDPath() string { return filepath.Join(LokiHome(), uiUnitName+".pid") }
func uiLogPath() string { return filepath.Join(LokiHome(), uiUnitName+".log") }
// uiServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est
// l'unité systemd ajean-ui (avec sudo non interactif si on n'est pas root) ;
// l'unité systemd loki-ui (avec sudo non interactif si on n'est pas root) ;
// ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance
// « ajean link serve » en processus détaché suivi par un fichier PID, comme le
// « loki link serve » en processus détaché suivi par un fichier PID, comme le
// fait déjà le service principal. Sans ça, l'accès distant restait définitivement
// « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne
// composait jamais le tunnel.
@@ -422,7 +422,7 @@ func uiUserSvcCtl(action string) error {
if p, err := filepath.EvalSymlinks(self); err == nil {
self = p
}
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
if err := os.MkdirAll(LokiHome(), 0o755); err != nil {
return err
}
logf, err := os.OpenFile(uiLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
@@ -434,7 +434,7 @@ func uiUserSvcCtl(action string) error {
cmd := spawnDetached(self, "web")
cmd.Stdout, cmd.Stderr = logf, logf
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de « ajean web »: %w", err)
return fmt.Errorf("démarrage de « loki web »: %w", err)
}
pid := cmd.Process.Pid
// PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce
@@ -460,7 +460,7 @@ func uiUserSvcCtl(action string) error {
// - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint
// compatible OpenAI, avec injection de la clé API locale) → permet de
// brancher OpenCode, Hermes, etc. sur ajean.link/oai/<machine>/v1
// - tout le reste → l'UI web de AJEAN (avec injection de la clé de pilotage)
// - tout le reste → l'UI web de Loki (avec injection de la clé de pilotage)
func newLinkHandler(mux *http.ServeMux) http.Handler {
web := withLocalAuth(mux)
llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
@@ -484,7 +484,7 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
// - /api/e2e/chat : chat chiffré (streaming SSE chiffré).
// - /api/e2e/req : proxy de contrôle chiffré (presets, VRAM, skills, service…).
// Tout autre /api/* en clair est REFUSÉ via le tunnel.
oaiAllowed := os.Getenv("AJEAN_LINK_ALLOW_OAI") == "1"
oaiAllowed := os.Getenv("LOKI_LINK_ALLOW_OAI") == "1"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
if strings.HasPrefix(p, "/api/e2e/") {
@@ -514,7 +514,7 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
// L'endpoint OpenAI (OpenCode/Hermes) ne peut pas être chiffré navigateur :
// il transiterait en clair par le relais. Désactivé par défaut.
if !oaiAllowed {
http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; AJEAN_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden)
http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; LOKI_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden)
return
}
lp.ServeHTTP(w, r)
@@ -531,7 +531,7 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
//
// ⚠️ La clé est relue À CHAQUE REQUÊTE, et surtout pas capturée une fois pour
// toutes à l'ouverture du tunnel. Avec une capture, changer la clé (depuis
// l'interface ou `ajean set-web-key`) faisait injecter l'ANCIENNE jusqu'au
// l'interface ou `loki set-web-key`) faisait injecter l'ANCIENNE jusqu'au
// prochain redémarrage du service : tout l'accès distant tombait en 401, et
// comme le chat n'affiche pas le code HTTP d'un flux qui n'arrive jamais, le
// symptôme était un « chargement de la conversation » infini, sans la moindre
@@ -599,7 +599,7 @@ func runLinkSession(ctx context.Context, token string, handler http.Handler, oai
fmt.Printf("%s lien établi ✓\n", green("[link]"))
// Chaque Accept() = un stream. Deux natures possibles :
// - requête HTTP normale (UI AJEAN / E2E) → servie par `handler` ;
// - requête HTTP normale (UI Loki / E2E) → servie par `handler` ;
// - session TLS brute (accès OpenAI public) → terminée par le front TLS.
// On les distingue au 1er octet (0x16 = handshake TLS). Sans OAI activé, tout
// va au HTTP (comportement historique inchangé).
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"net/http"
@@ -8,7 +8,7 @@ import (
// Changer la clé de pilotage ne doit PAS couper l'accès distant jusqu'au
// prochain redémarrage. La clé était capturée à l'ouverture du tunnel : après un
// `ajean set-web-key`, le tunnel injectait l'ancienne, tout passait en 401, et
// `loki set-web-key`, le tunnel injectait l'ancienne, tout passait en 401, et
// le portail restait bloqué sur « chargement de la conversation » sans jamais
// dire pourquoi.
func TestWithLocalAuthSuitLaCleCourante(t *testing.T) {
@@ -1,17 +1,17 @@
// Sous-commande « ajean remote » — client « poste distant » intégré au binaire
// principal. On l'installe sur un PC pour que l'IA d'un serveur AJEAN puisse agir
// Sous-commande « loki remote » — client « poste distant » intégré au binaire
// principal. On l'installe sur un PC pour que l'IA d'un serveur Loki puisse agir
// dessus (shell/fichiers), chiffré de bout en bout via ajean.link (relais aveugle)
// ou en direct sur le réseau local.
//
// ajean remote install <url-serveur> --machine ID --key CLÉ --code CODE [--allow shell,read,write,list] [--root DIR]
// loki remote install <url-serveur> --machine ID --key CLÉ --code CODE [--allow shell,read,write,list] [--root DIR]
// appaire ce PC et l'installe en service (démarrage auto)
// ajean remote connect <url> ... idem mais en avant-plan (test)
// ajean remote run boucle client (utilisé par le service)
// ajean remote status | uninstall | logout
// loki remote connect <url> ... idem mais en avant-plan (test)
// loki remote run boucle client (utilisé par le service)
// loki remote status | uninstall | logout
//
// Historiquement un binaire séparé « ajean-remote » ; fusionné dans ce binaire
// Historiquement un binaire séparé « loki-remote » ; fusionné dans ce binaire
// pour n'avoir qu'une seule application.
package ajean
package loki
import (
"context"
@@ -20,7 +20,7 @@ import (
"runtime"
"strings"
"github.com/nathaninline/ajean/internal/nodeclient"
"github.com/R0m1k3/Loki/internal/nodeclient"
)
func cmdRemote(args []string) error {
@@ -59,18 +59,18 @@ func cmdRemote(args []string) error {
}
func remoteUsage() {
fmt.Print(`ajean remote — piloter CE PC depuis l'IA d'un serveur AJEAN
fmt.Print(`loki remote — piloter CE PC depuis l'IA d'un serveur Loki
ajean remote install <url-serveur> --code CODE [options] appaire + installe en service (démarrage auto, invisible)
ajean remote connect <url-serveur> --code CODE [options] idem en avant-plan (pour tester)
ajean remote status état de la configuration
ajean remote uninstall retire le service (garde la config)
ajean remote logout retire le service ET oublie la clé
loki remote install <url-serveur> --code CODE [options] appaire + installe en service (démarrage auto, invisible)
loki remote connect <url-serveur> --code CODE [options] idem en avant-plan (pour tester)
loki remote status état de la configuration
loki remote uninstall retire le service (garde la config)
loki remote logout retire le service ET oublie la clé
Options :
--machine ID machine à joindre via ajean.link (fournie par l'UI → Postes distants)
--key CLÉ clé publique de l'agent (le poste scelle vers elle)
--code CODE code d'appairage (10 min, généré dans l'UI AJEAN → Postes distants)
--code CODE code d'appairage (10 min, généré dans l'UI Loki → Postes distants)
--allow LISTE capacités acceptées : shell,read,write,list (défaut : ce que le serveur autorise)
--root DIR dossier autorisé (confine lecture/écriture)
--yes auto-approuve les actions (obligatoire pour le service, sans terminal)
@@ -84,8 +84,8 @@ func remoteConnect(args []string, install bool) error {
cfg, lerr := nodeclient.LoadConfig()
// Un --code est un jeton d'appairage à usage unique : le fournir veut TOUJOURS
// dire « (ré)appaire-moi maintenant ». Sans ce « || f.code != "" », une config
// résiduelle (test précédent, ancien binaire ajean-remote au même emplacement
// %ProgramData%\ajean-remote\config.json) faisait sauter l'appairage en silence :
// résiduelle (test précédent, ancien binaire loki-remote au même emplacement
// %ProgramData%\loki-remote\config.json) faisait sauter l'appairage en silence :
// le service démarrait avec de vieilles clés que le serveur ignore et le poste
// ne remontait jamais, alors que tout affichait « [ok] ».
needEnroll := lerr != nil || cfg.Priv == "" || f.code != ""
@@ -148,7 +148,7 @@ func remoteConnect(args []string, install bool) error {
func remoteRun() error {
cfg, err := nodeclient.LoadConfig()
if err != nil {
return fmt.Errorf("aucune configuration — lance d'abord « ajean remote install … »")
return fmt.Errorf("aucune configuration — lance d'abord « loki remote install … »")
}
return nodeclient.RunService(cfg)
}
+33 -33
View File
@@ -1,7 +1,7 @@
// ajean — single-binary LLM server manager + web UI for llama.cpp deployments.
// Point d'entrée réel : Main(), appelé par cmd/ajean (les métadonnées Windows
// loki — single-binary LLM server manager + web UI for llama.cpp deployments.
// Point d'entrée réel : Main(), appelé par cmd/loki (les métadonnées Windows
// .syso/go:generate vivent là-bas, dans le dossier du package main).
package ajean
package loki
import (
"fmt"
@@ -12,7 +12,7 @@ import (
const Version = "0.9.4"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
func Main() {
// Rattache la console du terminal parent si on est lancé depuis un shell
// (Windows : binaire GUI). Retourne false au double-clic (aucune console) →
@@ -34,7 +34,7 @@ func Main() {
// lance l'expérience « application » (UI web + navigateur + icône tray) plutôt
// que d'afficher l'aide. Le binaire étant compilé en sous-système GUI, il n'y a
// AUCUNE console à ce stade (donc plus de fenêtre noire). Lancé depuis un shell,
// `ajean` sans argument garde son comportement d'aide.
// `loki` sans argument garde son comportement d'aide.
if noArgs && !haveConsole {
mustExit(cmdApp(args))
return
@@ -107,7 +107,7 @@ func Main() {
case "uninstall":
mustExit(cmdUninstall(args))
case "version", "-v", "--version":
fmt.Println("ajean", Version)
fmt.Println("loki", Version)
case "help", "-h", "--help", "":
printHelp()
default:
@@ -118,14 +118,14 @@ func Main() {
}
func printHelp() {
fmt.Printf(`ajean %s — manager llama.cpp + UI web (single binary)
fmt.Printf(`loki %s — manager llama.cpp + UI web (single binary)
Usage: ajean <commande> [args]
Usage: loki <commande> [args]
AJEAN tourne en deux services : ajean-engine (le modèle) et ajean-ui
Loki tourne en deux services : loki-engine (le modèle) et loki-ui
(l'interface web, le tunnel d'accès distant et l'endpoint OpenAI).
Moteur (ajean-engine) :
Moteur (loki-engine) :
start | stop | restart gérer le service
status | logs état / logs en direct
enable | disable auto-démarrage au boot
@@ -138,7 +138,7 @@ Moteur (ajean-engine) :
network [on|off|status] rendre l'endpoint OpenAI joignable depuis le réseau local
(HOST + règle de pare-feu sous Windows)
Interface (ajean-ui) :
Interface (loki-ui) :
ui [start|stop|restart|status] pilote le service d'interface
web [PORT] sert l'interface au premier plan (défaut :8090) —
ouvre aussi le tunnel si un jeton est enregistré
@@ -160,7 +160,7 @@ Accès distant (ajean.link) :
link code code d'appairage (10 min, usage unique) pour le portail
link status | logout état du jeton / l'oublier
Poste distant (piloter CE PC depuis l'IA d'un serveur AJEAN) :
Poste distant (piloter CE PC depuis l'IA d'un serveur Loki) :
remote install <url> --machine ID --key CLÉ --code CODE [--allow shell,read,write,list]
appaire ce PC + installe le service (démarrage auto)
remote connect <url> … idem en avant-plan (pour tester)
@@ -179,8 +179,8 @@ Installation:
serve entrypoint du service : exec llama-server (usage interne)
Env:
AJEAN_HOME racine des données (défaut : /etc/ajean, %%ProgramData%%\ajean sous Windows)
EDITOR éditeur pour 'ajean edit' (défaut : nano, notepad sous Windows)
LOKI_HOME racine des données (défaut : /etc/loki, %%ProgramData%%\loki sous Windows)
EDITOR éditeur pour 'loki edit' (défaut : nano, notepad sous Windows)
`, Version)
}
@@ -191,21 +191,21 @@ func mustExit(err error) {
}
}
// AjeanHome resolves the AJEAN data directory.
// Précédence : $AJEAN_HOME → /etc/default/ajean (unix) → défaut plateforme.
func AjeanHome() string {
if h := os.Getenv("AJEAN_HOME"); h != "" {
// LokiHome resolves the Loki data directory.
// Précédence : $LOKI_HOME → /etc/default/loki (unix) → défaut plateforme.
func LokiHome() string {
if h := os.Getenv("LOKI_HOME"); h != "" {
return h
}
if h := readEtcDefault(); h != "" {
return h
}
return defaultAjeanHome()
return defaultLokiHome()
}
// readEtcDefault parses /etc/default/ajean for AJEAN_HOME=<path>. Quiet on errors.
// readEtcDefault parses /etc/default/loki for LOKI_HOME=<path>. Quiet on errors.
func readEtcDefault() string {
b, err := os.ReadFile("/etc/default/ajean")
b, err := os.ReadFile("/etc/default/loki")
if err != nil {
return ""
}
@@ -215,30 +215,30 @@ func readEtcDefault() string {
continue
}
s = strings.TrimPrefix(s, "export ")
if k, v, ok := strings.Cut(s, "="); ok && strings.TrimSpace(k) == "AJEAN_HOME" {
if k, v, ok := strings.Cut(s, "="); ok && strings.TrimSpace(k) == "LOKI_HOME" {
return strings.Trim(strings.TrimSpace(v), "\"'")
}
}
return ""
}
// Arborescence de $AJEAN_HOME. Elle tient en six dossiers, et rien d'autre :
// Arborescence de $LOKI_HOME. Elle tient en six dossiers, et rien d'autre :
// tout le reste (config, préférences, conversation, clés, drapeaux) vit dans la
// base ajean.db — voir store.go.
func backendsDir() string { return filepath.Join(AjeanHome(), "backends") }
func binDir() string { return filepath.Join(AjeanHome(), "bin") }
func presetsDir() string { return filepath.Join(AjeanHome(), "presets") }
func memoryDir() string { return filepath.Join(AjeanHome(), "memory") }
func modelsDir() string { return filepath.Join(AjeanHome(), "models") }
func workspaceDir() string { return filepath.Join(AjeanHome(), "workspace") }
// base loki.db — voir store.go.
func backendsDir() string { return filepath.Join(LokiHome(), "backends") }
func binDir() string { return filepath.Join(LokiHome(), "bin") }
func presetsDir() string { return filepath.Join(LokiHome(), "presets") }
func memoryDir() string { return filepath.Join(LokiHome(), "memory") }
func modelsDir() string { return filepath.Join(LokiHome(), "models") }
func workspaceDir() string { return filepath.Join(LokiHome(), "workspace") }
// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est
// uiUnitName (« ajean-ui »), qui sert l'interface et le tunnel.
// uiUnitName (« loki-ui »), qui sert l'interface et le tunnel.
func serviceName() string {
if n := os.Getenv("AJEAN_SERVICE"); n != "" {
if n := os.Getenv("LOKI_SERVICE"); n != "" {
return n
}
return "ajean-engine"
return "loki-engine"
}
// Color helpers (ANSI). Disabled when stdout is not a TTY.
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -13,7 +13,7 @@ import (
bolterrors "go.etcd.io/bbolt/errors"
)
// store.go — l'unique endroit où AJEAN écrit son état.
// store.go — l'unique endroit où Loki écrit son état.
//
// Avant, chaque réglage avait son fichier : config.env, webprefs.json,
// conversation.json, .api_key, .link_token, .agent_enabled, model_dirs.json…
@@ -40,7 +40,7 @@ const (
// bbolt pose un verrou EXCLUSIF sur son fichier tant qu'il est ouvert. Or une
// machine installée fait tourner en permanence le service de lien, qui sert le
// tunnel et l'UI : s'il gardait la base ouverte, plus une seule commande ne
// fonctionnerait à côté — « ajean status », « ajean switch », « ajean edit »
// fonctionnerait à côté — « loki status », « loki switch », « loki edit »
// échoueraient toutes sur un délai d'attente, sur la machine même où tout est
// censé marcher. On ouvre donc pour la durée d'une opération, puis on referme.
//
@@ -49,7 +49,7 @@ const (
// contention entre process ; dbMu la sérialise à l'intérieur du process.
var dbMu sync.Mutex
func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
func dbPath() string { return filepath.Join(LokiHome(), "loki.db") }
// withDB ouvre la base, exécute fn, puis referme — toujours, même en erreur.
func withDB(fn func(*bolt.DB) error) error {
@@ -1,9 +1,9 @@
package ajean
package loki
import "testing"
// Le cache de lecture ne doit jamais servir une valeur périmée après une
// écriture de ce process : un « ajean edit » suivi d'un « ajean restart » doit
// écriture de ce process : un « loki edit » suivi d'un « loki restart » doit
// démarrer sur la NOUVELLE configuration.
func TestCacheConfigInvalideParEcriture(t *testing.T) {
testHome(t)
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"sync"
@@ -1,14 +1,14 @@
package ajean
package loki
import "testing"
// testHome donne au test un $AJEAN_HOME à lui, et neutralise le pare-feu : un
// testHome donne au test un $LOKI_HOME à lui, et neutralise le pare-feu : un
// test ne doit jamais poser (ni retirer) une règle entrante sur la machine qui
// le fait tourner.
func testHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("AJEAN_HOME", home)
t.Setenv("LOKI_HOME", home)
firewallInert = true
t.Cleanup(func() { firewallInert = false })
return home
@@ -1,6 +1,6 @@
package ajean
package loki
// sys_brand_icon.go — l'icône de la marque AJEAN, rendue à la volée.
// sys_brand_icon.go — l'icône de la marque Loki, rendue à la volée.
//
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j »
// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour
@@ -9,7 +9,7 @@ package ajean
// au noir en laissant les icônes système en bleu.
//
// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est
// produite par `go generate ./cmd/ajean` (voir tools/gen-icon).
// produite par `go generate ./cmd/loki` (voir tools/gen-icon).
import (
"bytes"
@@ -1,16 +1,16 @@
//go:build darwin
package ajean
package loki
// sys_console_darwin.go — détection du lancement « par clic » sous macOS.
//
// Sur macOS il n'y a pas d'équivalent d'AttachConsole : un binaire lancé depuis
// le Finder hérite quand même de descripteurs stdout/stderr (vers les logs
// système). Le signal fiable, c'est l'emplacement de l'exécutable : la release
// publie un bundle AJEAN.app dont le binaire vit dans
// AJEAN.app/Contents/MacOS/ajean. Si on tourne depuis là, c'est un double-clic
// publie un bundle Loki.app dont le binaire vit dans
// Loki.app/Contents/MacOS/loki. Si on tourne depuis là, c'est un double-clic
// dans le Finder → expérience « application » (UI web + navigateur), exactement
// comme le double-clic sur ajean.exe sous Windows.
// comme le double-clic sur loki.exe sous Windows.
//
// Le même binaire copié dans /usr/local/bin garde évidemment le comportement CLI.
@@ -28,7 +28,7 @@ func setupConsole() bool {
if p, err := filepath.EvalSymlinks(exe); err == nil {
exe = p
}
// .../AJEAN.app/Contents/MacOS/ajean
// .../Loki.app/Contents/MacOS/loki
if strings.HasSuffix(filepath.Dir(exe), "/Contents/MacOS") {
fixFinderPath()
return false
@@ -36,7 +36,7 @@ func setupConsole() bool {
return true
}
// appWarning signale une situation de lancement qui casse AJEAN de façon peu
// appWarning signale une situation de lancement qui casse Loki de façon peu
// évidente. Aujourd'hui : l'App Translocation de Gatekeeper. Une app non signée
// ouverte depuis Téléchargements est exécutée depuis une copie en LECTURE SEULE
// sous /private/var/folders/…/AppTranslocation/<uuid>/, à un chemin différent à
@@ -44,7 +44,7 @@ func setupConsole() bool {
// dessus (la seconde trouve le port 8090 occupé et se contente d'ouvrir l'UI de
// la première, souvent une version périmée), des process fantômes issus de
// copies précédentes, et une mise à jour en place impossible.
// Le remède est côté utilisateur : déplacer AJEAN.app dans /Applications.
// Le remède est côté utilisateur : déplacer Loki.app dans /Applications.
func appWarning() string {
exe, err := os.Executable()
if err != nil {
@@ -53,14 +53,14 @@ func appWarning() string {
if !strings.Contains(exe, "/AppTranslocation/") {
return ""
}
return "AJEAN tourne depuis une copie temporaire en lecture seule (App Translocation de macOS). " +
"Fermez l'app, déplacez AJEAN.app dans le dossier Applications, puis rouvrez-la — " +
return "Loki tourne depuis une copie temporaire en lecture seule (App Translocation de macOS). " +
"Fermez l'app, déplacez Loki.app dans le dossier Applications, puis rouvrez-la — " +
"sinon chaque lancement crée une instance séparée et les mises à jour du moteur échouent."
}
// fixFinderPath complète le PATH famélique hérité du Finder. Une app lancée par
// LaunchServices reçoit /usr/bin:/bin:/usr/sbin:/sbin — pas les dossiers de
// Homebrew : sans ça, AJEAN ne voit ni `brew`, ni `cmake`, ni `git`, et la
// Homebrew : sans ça, Loki ne voit ni `brew`, ni `cmake`, ni `git`, et la
// compilation d'un backend échoue alors que les outils sont bien installés.
func fixFinderPath() {
extra := []string{
@@ -1,12 +1,12 @@
//go:build !windows && !darwin
package ajean
package loki
// setupConsole : sous Linux, le process a toujours une vraie console/tty
// standard (stdout hérité). Rien à faire → on signale simplement « on a une
// console ». Les cas « lancé par un clic » sont traités par
// sys_console_windows.go (double-clic sur ajean.exe) et sys_console_darwin.go
// (ouverture de AJEAN.app depuis le Finder).
// sys_console_windows.go (double-clic sur loki.exe) et sys_console_darwin.go
// (ouverture de Loki.app depuis le Finder).
func setupConsole() bool { return true }
// appWarning : rien à signaler hors macOS (l'App Translocation est spécifique à
@@ -1,19 +1,19 @@
//go:build windows
package ajean
package loki
// sys_console_windows.go — gestion de la console sous Windows.
//
// Le binaire est compilé en sous-système CONSOLE. C'est ce qui fait que `cmd`
// ATTEND la fin du programme : avec un binaire graphique, il rendait la main
// aussitôt et réaffichait son invite AVANT la sortie d'AJEAN, ce qui donnait
// aussitôt et réaffichait son invite AVANT la sortie d'Loki, ce qui donnait
//
// C:\Users\Nathan>ajean
// C:\Users\Nathan>loki
//
// C:\Users\Nathan>ajean 0.8.0 — manager llama.cpp + UI web
// C:\Users\Nathan>loki 0.8.0 — manager llama.cpp + UI web
//
// La même cause cassait la redirection et les tubes, et rendait `ajean chat`
// inutilisable depuis un terminal (le shell et AJEAN se disputaient l'entrée).
// La même cause cassait la redirection et les tubes, et rendait `loki chat`
// inutilisable depuis un terminal (le shell et Loki se disputaient l'entrée).
//
// EN CONTREPARTIE, Windows alloue une console à tout lancement sans terminal —
// un double-clic. On la referme alors immédiatement : voir setupConsole. Les
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"os"
@@ -11,15 +11,15 @@ import (
// modèle de config.env : quatre copies qui divergeaient à la première
// modification. Il n'y en a plus qu'une.
// dataDirs est l'arborescence complète de $AJEAN_HOME. Rien d'autre n'y est
// créé : tout le reste vit dans ajean.db.
// dataDirs est l'arborescence complète de $LOKI_HOME. Rien d'autre n'y est
// créé : tout le reste vit dans loki.db.
func dataDirs() []string {
return []string{AjeanHome(), backendsDir(), binDir(), presetsDir(), memoryDir(), modelsDir(), workspaceDir()}
return []string{LokiHome(), backendsDir(), binDir(), presetsDir(), memoryDir(), modelsDir(), workspaceDir()}
}
// defaultConfig est la configuration de départ d'une installation neuve. Les
// valeurs sont volontairement incomplètes (BIN et MODEL sont vides) : c'est
// l'écran d'accueil, ou « ajean llamacpp install », qui les renseigne.
// l'écran d'accueil, ou « loki llamacpp install », qui les renseigne.
func defaultConfig() map[string]string {
host := "0.0.0.0"
if runtime.GOOS == "windows" {
@@ -1,6 +1,6 @@
//go:build !windows
package ajean
package loki
// sys_disk_other.go — espace libre d'un système de fichiers via statfs(2).
// Bavail (et pas Bfree) : les blocs réservés à root ne sont pas utilisables pour
@@ -1,4 +1,4 @@
package ajean
package loki
// sys_disk_windows.go — espace libre d'un volume via GetDiskFreeSpaceExW. On
// veut l'espace réellement disponible pour l'utilisateur courant (quotas
@@ -1,6 +1,6 @@
//go:build !windows
package ajean
package loki
// Linux et macOS n'ont pas de pare-feu entrant activé par défaut, et quand il y
// en a un (ufw, firewalld, pf) c'est une affaire d'administration système qu'un
@@ -1,6 +1,6 @@
//go:build windows
package ajean
package loki
import (
"fmt"
@@ -14,9 +14,9 @@ import (
// Sur une installation Windows fraîche, même avec HOST=0.0.0.0, le pare-feu
// bloque tout ce qui arrive du réseau : l'utilisateur voyait « ça écoute
// partout » et se prenait quand même un délai d'attente depuis son autre
// machine. AJEAN pose donc la règle lui-même, quand il en a le droit.
// machine. Loki pose donc la règle lui-même, quand il en a le droit.
//
// `ajean install` ne réclame PAS les droits administrateur (voir
// `loki install` ne réclame PAS les droits administrateur (voir
// sys_install_windows.go) : netsh échouera donc souvent. On ne fait pas semblant
// que ça a marché — firewallState relit la règle, et firewallManualHint donne la
// commande exacte à coller dans un terminal administrateur.
@@ -24,7 +24,7 @@ import (
// firewallRuleName identifie nos règles. Une par port : changer PORT dans la
// configuration ne doit pas laisser une règle ouverte sur l'ancien.
func firewallRuleName(port int) string {
return "AJEAN moteur (port " + strconv.Itoa(port) + ")"
return "Loki moteur (port " + strconv.Itoa(port) + ")"
}
// netsh exécute netsh sans faire clignoter de console (hideCmd) et renvoie sa
@@ -71,7 +71,7 @@ func firewallState(port int) string {
}
// firewallManualHint : la commande à coller dans un terminal ADMINISTRATEUR
// quand AJEAN n'a pas pu poser la règle lui-même.
// quand Loki n'a pas pu poser la règle lui-même.
func firewallManualHint(port int) string {
return fmt.Sprintf("le pare-feu Windows bloque encore le port %d. Ouvre un terminal ADMINISTRATEUR et lance :\n"+
` netsh advfirewall firewall add rule name="%s" dir=in action=allow protocol=TCP localport=%d profile=private,domain`,
@@ -1,8 +1,8 @@
//go:build !windows
package ajean
package loki
// appFirstRun : hors Windows, `ajean app` est lancé depuis un terminal par
// appFirstRun : hors Windows, `loki app` est lancé depuis un terminal par
// quelqu'un qui a déjà suivi l'installation — pas de double-clic à désambiguïser,
// donc pas de boîte de dialogue. On se contente de provisionner les données au
// premier lancement. Ne relance jamais (renvoie toujours false).
@@ -1,6 +1,6 @@
//go:build windows
package ajean
package loki
import (
"crypto/sha256"
@@ -22,8 +22,8 @@ import (
// Premier lancement sous Windows.
//
// Avant : double-cliquer ajean-windows-amd64.exe déclenchait une installation
// SILENCIEUSE (copie du binaire dans %ProgramData%\ajean\bin, ajout au PATH) dont
// Avant : double-cliquer loki-windows-amd64.exe déclenchait une installation
// SILENCIEUSE (copie du binaire dans %ProgramData%\loki\bin, ajout au PATH) dont
// l'utilisateur ne voyait rien — cmdApp appelait cmdInstall sans console pour en
// afficher la sortie. D'où la confusion légitime : est-ce que ça installe ou est-ce
// que ça lance ? Et surtout, deux copies du binaire coexistaient (celle du Bureau,
@@ -32,13 +32,13 @@ import (
//
// Maintenant, deux cas seulement :
//
// 1. AJEAN n'est pas installé. On installe, sans demander : rester à
// 1. Loki n'est pas installé. On installe, sans demander : rester à
// l'emplacement du fichier téléchargé ne donne pas une installation
// utilisable, la question n'avait donc qu'une réponse. Copie + PATH +
// raccourcis, un message qui dit ce qui a été fait, et on démarre depuis la
// copie installée.
//
// 2. AJEAN est déjà installé. Le fichier téléchargé se comporte en installeur de
// 2. Loki est déjà installé. Le fichier téléchargé se comporte en installeur de
// mise à jour, avec trois situations bien distinctes (voir runAsInstaller) :
// plus récent et application arrêtée, on remplace et on démarre, sans un mot ;
// plus récent mais application EN COURS, on demande s'il faut la fermer pour
@@ -93,7 +93,7 @@ func appFirstRun() bool {
if canonPath(exe) == canonPath(target) {
// On EST l'application installée. On en profite pour garantir que les
// raccourcis existent : sans ça, quelqu'un qui perd son raccourci ne
// retrouve plus AJEAN, et n'a aucune raison de relancer le fichier
// retrouve plus Loki, et n'a aucune raison de relancer le fichier
// téléchargé (qu'il a souvent supprimé) pour le récupérer. En tâche de
// fond, l'appel PowerShell ne doit pas retarder le démarrage.
go ensureShortcuts(target)
@@ -114,18 +114,18 @@ func appFirstRun() bool {
// un fichier, autant dire où le programme s'est installé et qu'il peut
// supprimer ce qu'il a téléchargé.
if _, err := installSelf(filepath.Dir(target)); err != nil {
// Échec le plus courant : pas les droits sur %ProgramData%. AJEAN reste
// Échec le plus courant : pas les droits sur %ProgramData%. Loki reste
// parfaitement utilisable depuis son emplacement actuel, le dossier de
// données étant ailleurs — on démarre donc au lieu d'abandonner.
messageBox("Installation impossible :\n\n"+err.Error()+"\n\nAJEAN va démarrer depuis l'emplacement actuel.", "AJEAN", mbIconInfo)
messageBox("Installation impossible :\n\n"+err.Error()+"\n\nLoki va démarrer depuis l'emplacement actuel.", "Loki", mbIconInfo)
return false
}
_, _ = addToUserPath(filepath.Dir(target))
shortcuts := ensureShortcuts(target)
messageBox("AJEAN est installé.\n\n"+target+"\n\n"+shortcuts+
messageBox("Loki est installé.\n\n"+target+"\n\n"+shortcuts+
"\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.",
"AJEAN", mbIconInfo)
"Loki", mbIconInfo)
return launch(target)
}
@@ -193,7 +193,7 @@ func launch(target string) bool {
return cmd.Start() == nil
}
// runAsInstaller : AJEAN est déjà installé et on exécute une AUTRE copie (le
// runAsInstaller : Loki est déjà installé et on exécute une AUTRE copie (le
// fichier fraîchement téléchargé). Le fichier joue alors le rôle d'installeur de
// mise à jour. Trois situations, trois comportements distincts, parce qu'elles
// n'appellent pas la même décision de l'utilisateur.
@@ -224,37 +224,37 @@ func runAsInstaller(target string) bool {
// serait une régression silencieuse, et démarrer sans rien dire laisserait
// croire qu'on utilise la version qu'on vient de télécharger.
if messageBox(
"Une version plus récente d'AJEAN est déjà installée sur cet ordinateur.\n\n"+
"Une version plus récente d'Loki est déjà installée sur cet ordinateur.\n\n"+
" installée : "+installed+"\n"+
" ce fichier : "+Version+"\n\n"+
"Rien ne sera remplacé.\n\n"+
"Voulez-vous démarrer AJEAN (version "+installed+") ?\n"+
"Voulez-vous démarrer Loki (version "+installed+") ?\n"+
"Répondre Non ferme simplement cette fenêtre.",
"AJEAN", mbYesNo|mbIconQuestion) != idYes {
"Loki", mbYesNo|mbIconQuestion) != idYes {
return true // rien à faire, on quitte sans démarrer
}
ensureShortcuts(target)
return launch(target)
case cmp > 0 && len(running) > 0:
// Mise à jour disponible, mais AJEAN tourne. Écrire le nouveau binaire
// Mise à jour disponible, mais Loki tourne. Écrire le nouveau binaire
// sans redémarrer ne changerait RIEN à ce que l'utilisateur a sous les
// yeux : l'onglet qui s'ouvrirait serait servi par l'ancienne version. On
// pose donc la seule question qui compte.
if messageBox(
"AJEAN est déjà en cours d'exécution.\n\n"+
"Loki est déjà en cours d'exécution.\n\n"+
" version en cours : "+verLabel(installed)+"\n"+
" ce fichier : "+Version+" (plus récente)\n\n"+
"Fermer AJEAN et le redémarrer pour appliquer la mise à jour ?\n\n"+
"Répondre Non ouvre AJEAN dans sa version actuelle, sans rien mettre à jour.",
"Mise à jour d'AJEAN", mbYesNo|mbIconQuestion) != idYes {
"Fermer Loki et le redémarrer pour appliquer la mise à jour ?\n\n"+
"Répondre Non ouvre Loki dans sa version actuelle, sans rien mettre à jour.",
"Mise à jour d'Loki", mbYesNo|mbIconQuestion) != idYes {
ensureShortcuts(target)
return launch(target) // l'instance en cours reprend la main (port occupé)
}
stopProcesses(running)
if err := replaceInstalled(target); err != nil {
messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.",
"AJEAN", mbIconInfo)
messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nLoki va redémarrer dans sa version actuelle.",
"Loki", mbIconInfo)
}
ensureShortcuts(target)
return launch(target)
@@ -282,7 +282,7 @@ func verLabel(v string) string {
// replaceInstalled écrase le binaire installé par celui qu'on exécute. Le
// renommage préalable en .old permet de remplacer un exécutable encore ouvert
// (même mécanique que replaceBinary pour `ajean update`).
// (même mécanique que replaceBinary pour `loki update`).
func replaceInstalled(target string) error {
removeOldBinaries(target)
old, err := renameAside(target) // nom unique, cf. renameAside
@@ -298,7 +298,7 @@ func replaceInstalled(target string) error {
}
// runningPIDs liste les processus qui exécutent exactement ce fichier. On compare
// le CHEMIN, pas le nom : tuer par nom d'image (« ajean.exe ») emporterait aussi
// le CHEMIN, pas le nom : tuer par nom d'image (« loki.exe ») emporterait aussi
// le processus courant et toute autre copie sans rapport.
//
// La comparaison se fait ICI, sur des chemins canonisés, et non dans le script
@@ -375,10 +375,10 @@ func portBusy(port int) bool {
}
// binaryVersion lit la version dans les ressources du fichier (VS_VERSIONINFO,
// posées par goversioninfo, cf cmd/ajean/versioninfo.json), comme le fait
// posées par goversioninfo, cf cmd/loki/versioninfo.json), comme le fait
// l'onglet « Détails » des propriétés Windows.
//
// ⚠️ NE PAS remplacer par un `ajean version` exécuté : le binaire est compilé en
// ⚠️ NE PAS remplacer par un `loki version` exécuté : le binaire est compilé en
// sous-système GUI, il s'attache à la console du parent et n'écrit RIEN dans un
// tuyau. La sortie capturée est vide, donc la comparaison de versions échouerait
// toujours en silence et la mise à jour ne se ferait jamais (vérifié).
@@ -429,7 +429,7 @@ type vsFixedFileInfo struct {
FileDateLS uint32
}
// ensureShortcuts garantit qu'un raccourci « AJEAN » existe dans le menu
// ensureShortcuts garantit qu'un raccourci « Loki » existe dans le menu
// Démarrer et sur le Bureau, et qu'il pointe sur le bon exécutable. Appelée à
// CHAQUE lancement du fichier téléchargé, pas seulement à l'installation :
// un raccourci perdu (nettoyage, migration de profil, dossier Démarrer
@@ -448,9 +448,9 @@ $progs=Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs'
foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) {
if (-not $d) { continue }
if (-not (Test-Path $d)) { continue }
$lnk=Join-Path $d 'AJEAN.lnk'
$lnk=Join-Path $d 'Loki.lnk'
# Un raccourci existant est REPOINTÉ s'il vise autre chose que le binaire
# canonique — typiquement l'ancien « ajean.exe », qui n'est plus qu'un alias.
# canonique — typiquement l'ancien « loki.exe », qui n'est plus qu'un alias.
# Le laisser en l'état condamnait l'utilisateur à relancer indéfiniment une
# version périmée par son propre raccourci.
if (Test-Path $lnk) {
@@ -476,9 +476,9 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) {
$s=$w.CreateShortcut($lnk)
$s.TargetPath=$t
$s.WorkingDirectory=(Split-Path $t)
$s.Description='AJEAN, votre IA locale'
$s.Description='Loki, votre IA locale'
# Minimisé (7) : le binaire étant en sous-système console, Windows lui alloue
# une console au lancement. AJEAN la referme aussitôt, mais demander un
# une console au lancement. Loki la referme aussitôt, mais demander un
# démarrage minimisé garantit qu'elle n'est jamais peinte à l'écran.
$s.WindowStyle=7
$s.IconLocation="$t,0"
@@ -490,9 +490,9 @@ Write-Output ($done -join ';')`, psQuote(target))
cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
out, err := cmd.CombinedOutput()
if err != nil || strings.TrimSpace(string(out)) == "" {
return "(raccourcis non créés — lancez AJEAN depuis " + target + ")"
return "(raccourcis non créés — lancez Loki depuis " + target + ")"
}
return "Raccourci « AJEAN » ajouté au menu Démarrer et au Bureau."
return "Raccourci « Loki » ajouté au menu Démarrer et au Bureau."
}
// removeShortcuts efface les raccourcis posés par ensureShortcuts. Renvoie true
@@ -503,7 +503,7 @@ func removeShortcuts() bool {
$sm=[Environment]::GetFolderPath('StartMenu')
foreach ($d in @($sm, (Join-Path $sm 'Programs'), [Environment]::GetFolderPath('Desktop'))) {
if (-not $d) { continue }
$p=Join-Path $d 'AJEAN.lnk'
$p=Join-Path $d 'Loki.lnk'
if (Test-Path $p) { try { Remove-Item $p -Force; $n++ } catch {} }
}
Write-Output $n`
@@ -1,6 +1,6 @@
//go:build windows
package ajean
package loki
import (
"os"
@@ -12,7 +12,7 @@ import (
// binaryVersion pilote la mise à jour automatique du binaire installé quand on
// lance le fichier téléchargé : si elle renvoie du vide, plus aucune mise à jour
// n'a lieu, et en silence. C'est exactement ce qui arrivait avec la première
// implémentation (exécuter `ajean version`, dont la sortie est vide pour un
// implémentation (exécuter `loki version`, dont la sortie est vide pour un
// binaire en sous-système GUI). D'où ces tests sur la plomberie syscall.
func TestBinaryVersionReadsResource(t *testing.T) {
// kernel32.dll porte toujours une ressource de version.
@@ -28,10 +28,10 @@ func TestBinaryVersionReadsResource(t *testing.T) {
// Sur une machine vierge, le dossier bin n'existe pas encore : installSelf doit
// le créer. Sans ça, le premier lancement échouait sur « open
// C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified »
// et AJEAN ne s'installait jamais.
// C:\ProgramData\loki\bin\loki.exe: The system cannot find the path specified »
// et Loki ne s'installait jamais.
func TestInstallSelfCreatesBinDir(t *testing.T) {
binDir := filepath.Join(t.TempDir(), "ajean", "bin") // deux niveaux absents
binDir := filepath.Join(t.TempDir(), "loki", "bin") // deux niveaux absents
dst, err := installSelf(binDir)
if err != nil {
t.Fatalf("installSelf sur un dossier absent : %v", err)
@@ -45,7 +45,7 @@ func TestInstallSelfCreatesBinDir(t *testing.T) {
// cible existe déjà, et laisser l'ancien de côté sans le confondre avec la cible.
func TestReplaceInstalled(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "ajean.exe")
target := filepath.Join(dir, "loki.exe")
if err := os.WriteFile(target, []byte("ancienne version"), 0o755); err != nil {
t.Fatal(err)
}
@@ -81,7 +81,7 @@ func TestBinaryVersionWithoutResource(t *testing.T) {
}
}
// Reproduit le cas signale en usage : l'alias « ajean.exe » est EN COURS
// Reproduit le cas signale en usage : l'alias « loki.exe » est EN COURS
// d'execution au moment de la mise a jour. copyExe seul echouait, l'alias
// restait fige sur une version perimee, et tout raccourci le visant relancait
// indefiniment l'ancienne version — qui affichait « une version plus recente
@@ -89,7 +89,7 @@ func TestBinaryVersionWithoutResource(t *testing.T) {
func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "neuf.exe")
dst := filepath.Join(dir, "ajean.exe")
dst := filepath.Join(dir, "loki.exe")
if err := os.WriteFile(src, []byte("VERSION-NEUVE"), 0o755); err != nil {
t.Fatal(err)
}
@@ -120,7 +120,7 @@ func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) {
// echoue apres le renommage, l'ancien fichier revient a sa place.
func TestReplaceExeRestaureSiLaCopieEchoue(t *testing.T) {
dir := t.TempDir()
dst := filepath.Join(dir, "ajean.exe")
dst := filepath.Join(dir, "loki.exe")
if err := os.WriteFile(dst, []byte("a-preserver"), 0o755); err != nil {
t.Fatal(err)
}
@@ -1,6 +1,6 @@
//go:build darwin
package ajean
package loki
import (
"fmt"
@@ -17,7 +17,7 @@ import (
// Implémentation prudente, à valider sur une machine Apple.
// launchdPlistTemplate — champs : Label, binaire, argument, UserName,
// WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath.
// WorkingDirectory, LOKI_HOME, StandardOutPath, StandardErrorPath.
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au
// boot une fois chargé avec `-w`.
const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
@@ -33,7 +33,7 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<key>UserName</key><string>%s</string>
<key>WorkingDirectory</key><string>%s</string>
<key>EnvironmentVariables</key>
<dict><key>AJEAN_HOME</key><string>%s</string></dict>
<dict><key>LOKI_HOME</key><string>%s</string></dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
@@ -48,12 +48,12 @@ func unitArgs() map[string]string {
return map[string]string{serviceName(): "serve", uiUnitName: "web"}
}
func installServices(targetUser, ajeanHome string) error {
func installServices(targetUser, lokiHome string) error {
exe := installedExePath()
for name, arg := range unitArgs() {
logPath := filepath.Join(ajeanHome, name+".log")
logPath := filepath.Join(lokiHome, name+".log")
plist := fmt.Sprintf(launchdPlistTemplate,
launchdLabel(name), exe, arg, targetUser, ajeanHome, ajeanHome, logPath, logPath)
launchdLabel(name), exe, arg, targetUser, lokiHome, lokiHome, logPath, logPath)
path := launchdPlistPath(name)
if err := os.WriteFile(path, []byte(plist), 0o644); err != nil {
return err
@@ -1,6 +1,6 @@
//go:build linux
package ajean
package loki
import (
"fmt"
@@ -14,14 +14,14 @@ import (
// sudoersTemplate autorise l'utilisateur à piloter UNE unité sans mot de passe.
// Posé pour les deux : l'interface, qui tourne sans privilèges, doit pouvoir
// redémarrer le moteur (bascule de preset) comme elle-même (nouveau jeton).
const sudoersTemplate = `# Permet à %[1]s de piloter l'unité %[2]s sans mot de passe (posé par ajean install).
const sudoersTemplate = `# Permet à %[1]s de piloter l'unité %[2]s sans mot de passe (posé par loki install).
%[1]s ALL=(root) NOPASSWD: /bin/systemctl start %[2]s, /bin/systemctl stop %[2]s, /bin/systemctl restart %[2]s, /bin/systemctl enable %[2]s, /bin/systemctl disable %[2]s
`
// engineUnitTemplate — le moteur : exec llama-server, supervisé directement par
// systemd. Champs : User, WorkingDirectory, ExecStart.
const engineUnitTemplate = `[Unit]
Description=AJEAN — moteur llama.cpp
Description=Loki — moteur llama.cpp
After=network.target
[Service]
@@ -47,7 +47,7 @@ WantedBy=multi-user.target
// OpenAI, servis par un SEUL process (donc une seule conversation).
// Champs : unité du moteur (dépendance), User, WorkingDirectory, ExecStart.
const uiUnitTemplate = `[Unit]
Description=AJEAN — interface web + accès distant
Description=Loki — interface web + accès distant
After=network-online.target %s.service
Wants=network-online.target
@@ -63,11 +63,11 @@ RestartSec=5
WantedBy=multi-user.target
`
func installServices(targetUser, ajeanHome string) error {
func installServices(targetUser, lokiHome string) error {
svc, exe := serviceName(), installedExePath()
units := map[string]string{
svc: fmt.Sprintf(engineUnitTemplate, targetUser, ajeanHome, exe+" serve"),
uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, exe+" web"),
svc: fmt.Sprintf(engineUnitTemplate, targetUser, lokiHome, exe+" serve"),
uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, lokiHome, exe+" web"),
}
for name, body := range units {
path := "/etc/systemd/system/" + name + ".service"
@@ -1,6 +1,6 @@
//go:build !windows
package ajean
package loki
import (
"fmt"
@@ -23,7 +23,7 @@ import (
func cmdInstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)")
return fmt.Errorf("loki install doit être exécuté en root (sudo loki install)")
}
targetUser := os.Getenv("SUDO_USER")
if targetUser == "" {
@@ -38,19 +38,19 @@ func cmdInstall(args []string) error {
if err != nil {
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
}
ajeanHome := defaultAjeanHome()
if v := os.Getenv("AJEAN_HOME"); v != "" {
ajeanHome = v
lokiHome := defaultLokiHome()
if v := os.Getenv("LOKI_HOME"); v != "" {
lokiHome = v
}
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser))
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" LOKI_HOME = %s\n", lokiHome)
fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName)
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
// elle déplace des dossiers entiers, donc la destination ne doit pas
// encore exister. Code temporaire, voir migrate_07.go.
if err := migrateFrom07(ajeanHome); err != nil {
if err := migrateFrom07(lokiHome); err != nil {
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
}
@@ -60,45 +60,45 @@ func cmdInstall(args []string) error {
}
fmt.Printf(" %s arborescence prête\n", green("✓"))
// 2. Lien vers /usr/local/bin/ajean.
// 2. Lien vers /usr/local/bin/loki.
if err := linkInstalledExe(); err != nil {
return err
}
// 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME.
// 3. /etc/default/loki, pour que les invocations root résolvent LOKI_HOME.
if err := os.MkdirAll("/etc/default", 0o755); err != nil {
return err
}
defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome)
if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil {
defaults := fmt.Sprintf("# Généré par loki install — racine des données\nLOKI_HOME=%s\n", lokiHome)
if err := os.WriteFile("/etc/default/loki", []byte(defaults), 0o644); err != nil {
return err
}
fmt.Printf(" %s /etc/default/ajean\n", green("✓"))
fmt.Printf(" %s /etc/default/loki\n", green("✓"))
// 4. Déclaration des services (systemd ou launchd selon la plateforme).
if err := installServices(targetUser, ajeanHome); err != nil {
if err := installServices(targetUser, lokiHome); err != nil {
return err
}
// 5. AJEAN_HOME appartient à l'utilisateur cible : les deux services et les
// 5. LOKI_HOME appartient à l'utilisateur cible : les deux services et les
// commandes qu'il tape doivent pouvoir écrire dans la base.
chown(ajeanHome, u)
chown(lokiHome, u)
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. installe le moteur : %s (compile llama.cpp, renseigne BIN)\n", bold("sudo -u "+targetUser+" ajean llamacpp install"))
fmt.Printf(" 2. choisis le modèle : %s (renseigne MODEL, un fichier .gguf)\n", bold("sudo -u "+targetUser+" ajean edit"))
fmt.Printf(" 3. démarre le moteur : %s\n", bold("sudo ajean start"))
fmt.Printf(" 4. vérifie : %s\n", bold("ajean test"))
fmt.Printf(" 5. démarre l'interface : %s (http://localhost:8090)\n", bold("sudo ajean ui start"))
fmt.Printf(" 1. installe le moteur : %s (compile llama.cpp, renseigne BIN)\n", bold("sudo -u "+targetUser+" loki llamacpp install"))
fmt.Printf(" 2. choisis le modèle : %s (renseigne MODEL, un fichier .gguf)\n", bold("sudo -u "+targetUser+" loki edit"))
fmt.Printf(" 3. démarre le moteur : %s\n", bold("sudo loki start"))
fmt.Printf(" 4. vérifie : %s\n", bold("loki test"))
fmt.Printf(" 5. démarre l'interface : %s (http://localhost:8090)\n", bold("sudo loki ui start"))
return nil
}
// linkInstalledExe pose /usr/local/bin/ajean sur le binaire courant.
// linkInstalledExe pose /usr/local/bin/loki sur le binaire courant.
//
// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible — l'utilisateur a
// posé le binaire là puis lancé `sudo ajean install` — il ne faut surtout pas
// posé le binaire là puis lancé `sudo loki install` — il ne faut surtout pas
// Remove+Symlink sur soi-même : ça effacerait le vrai binaire et créerait un
// lien vers lui-même (« Too many levels of symbolic links »). On compare les
// chemins réels, résolus.
@@ -135,15 +135,15 @@ func linkInstalledExe() error {
func cmdUninstall(args []string) error {
if os.Geteuid() != 0 {
return fmt.Errorf("ajean uninstall doit être exécuté en root")
return fmt.Errorf("loki uninstall doit être exécuté en root")
}
uninstallServices()
for _, p := range []string{"/etc/default/ajean", installedExePath()} {
for _, p := range []string{"/etc/default/loki", installedExePath()} {
if err := os.Remove(p); err == nil {
fmt.Printf(" %s %s\n", green("✓"), p)
}
}
fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)"))
fmt.Println(dim("(données utilisateur conservées — supprime $LOKI_HOME manuellement si tu veux purger)"))
fmt.Println(green("[ok]") + " désinstallé")
return nil
}
@@ -1,6 +1,6 @@
//go:build windows
package ajean
package loki
import (
"fmt"
@@ -12,21 +12,21 @@ import (
)
// On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate.
// `ajean install` simply provisions the data directory and a starter config; the
// `loki install` simply provisions the data directory and a starter config; the
// service itself is managed by the PID-file supervisor in sys_service_windows.go
// (ajean start / stop / status), which needs no admin rights.
// (loki start / stop / status), which needs no admin rights.
func cmdInstall(args []string) error {
ajeanHome := AjeanHome()
lokiHome := LokiHome()
fmt.Printf("Installation (Windows)\n")
fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" LOKI_HOME = %s\n", lokiHome)
fmt.Printf(" service = %s\n", serviceName())
// 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
// elle déplace des dossiers entiers, donc la destination ne doit pas
// encore exister. Code temporaire, voir migrate_07.go.
if err := migrateFrom07(ajeanHome); err != nil {
if err := migrateFrom07(lokiHome); err != nil {
return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
}
@@ -37,8 +37,8 @@ func cmdInstall(args []string) error {
}
fmt.Printf(" %s arborescence prête\n", green("✓"))
// 2. Le binaire va dans AJEAN_HOME\bin, ajouté au PATH utilisateur, pour que
// `ajean` soit appelable depuis n'importe quel shell (pendant Windows du
// 2. Le binaire va dans LOKI_HOME\bin, ajouté au PATH utilisateur, pour que
// `loki` soit appelable depuis n'importe quel shell (pendant Windows du
// lien /usr/local/bin créé par l'installateur Unix).
dir := binDir()
onPath := false
@@ -62,40 +62,40 @@ func cmdInstall(args []string) error {
fmt.Println()
fmt.Printf("%s installation terminée.\n", green("[ok]"))
fmt.Printf("\nProchaines étapes :\n")
fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("ajean edit"))
fmt.Printf(" 2. démarre le service: %s\n", bold("ajean start"))
fmt.Printf(" 3. UI web : %s\n", bold("ajean web"))
fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("loki edit"))
fmt.Printf(" 2. démarre le service: %s\n", bold("loki start"))
fmt.Printf(" 3. UI web : %s\n", bold("loki web"))
if onPath {
fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'ajean' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]"))
fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'loki' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]"))
} else {
fmt.Printf("\n%s pour exécuter 'ajean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]"))
fmt.Printf("\n%s pour exécuter 'loki' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]"))
}
return nil
}
// installSelf copie l'exécutable en cours dans binDir sous le nom ajean.exe et
// installSelf copie l'exécutable en cours dans binDir sous le nom loki.exe et
// renvoie le chemin de destination. Sans effet si l'exe tourne déjà depuis là
// (ré-installation).
//
// binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge,
// le premier lancement passait par appFirstRun, qui ne créait que le dossier de
// données et pas son sous-dossier bin. L'utilisateur recevait alors « open
// C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified »
// et AJEAN démarrait depuis le fichier téléchargé, sans jamais s'installer.
// C:\ProgramData\loki\bin\loki.exe: The system cannot find the path specified »
// et Loki démarrait depuis le fichier téléchargé, sans jamais s'installer.
func installSelf(binDir string) (string, error) {
src, err := os.Executable()
if err != nil {
return "", err
}
src, _ = filepath.EvalSymlinks(src)
dst := filepath.Join(binDir, "ajean.exe")
dst := filepath.Join(binDir, "loki.exe")
if strings.EqualFold(src, dst) {
return dst, nil
}
if err := os.MkdirAll(binDir, 0o755); err != nil {
return "", err
}
// replaceExe et non copyExe : ajean.exe peut etre en cours d'execution
// replaceExe et non copyExe : loki.exe peut etre en cours d'execution
// (service en tache de fond), auquel cas Windows refuse de l'ecraser.
if err := replaceExe(src, dst); err != nil {
return "", err
@@ -107,7 +107,7 @@ func installSelf(binDir string) (string, error) {
//
// Windows refuse d'écraser un .exe en cours d'exécution, mais accepte de le
// RENOMMER : on décale l'ancien puis on écrit le nouveau à sa place. Sans ça,
// l'alias « ajean.exe » restait figé sur une version périmée dès qu'il tournait
// l'alias « loki.exe » restait figé sur une version périmée dès qu'il tournait
// au moment de la mise à jour — et comme les raccourcis existants le visent
// encore, chaque lancement relançait l'ancienne version, qui constatait qu'une
// plus récente était installée et le disait. À chaque fois. Constaté en usage.
@@ -209,7 +209,7 @@ func cmdUninstall(args []string) error {
// Stop the background server if it's running.
_ = svcStop(false)
// Pull AJEAN_HOME\bin off the user PATH (best-effort).
// Pull LOKI_HOME\bin off the user PATH (best-effort).
dir := binDir()
if removed, err := removeFromUserPath(dir); err == nil && removed {
fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), dir)
@@ -217,15 +217,15 @@ func cmdUninstall(args []string) error {
// Raccourcis posés à l'installation (menu Démarrer + Bureau).
if removeShortcuts() {
fmt.Printf(" %s raccourcis « AJEAN » supprimés\n", green("✓"))
fmt.Printf(" %s raccourcis « Loki » supprimés\n", green("✓"))
}
if !keepData {
ajeanHome := AjeanHome()
if err := os.RemoveAll(ajeanHome); err != nil {
return fmt.Errorf("suppression de %s: %w", ajeanHome, err)
lokiHome := LokiHome()
if err := os.RemoveAll(lokiHome); err != nil {
return fmt.Errorf("suppression de %s: %w", lokiHome, err)
}
fmt.Printf(" %s %s supprimé\n", green("✓"), ajeanHome)
fmt.Printf(" %s %s supprimé\n", green("✓"), lokiHome)
} else {
fmt.Println(dim("(données utilisateur conservées — relance avec --purge pour tout supprimer)"))
}
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -11,19 +11,19 @@ import (
//
// Le moteur écoute à l'adresse HOST. Sous Windows, l'installation pose
// HOST=127.0.0.1 : le chat du navigateur marche (il passe par le serveur web
// d'AJEAN, sur la même machine), mais l'endpoint OpenAI :8080/v1 est INVISIBLE
// d'Loki, sur la même machine), mais l'endpoint OpenAI :8080/v1 est INVISIBLE
// depuis le reste du réseau. Or c'est très exactement ce que les gens viennent
// chercher : brancher un logiciel tiers sur le modèle qui tourne dans le salon.
//
// Deux choses manquaient, et pas une seule :
// 1. HOST n'était réglable NULLE PART dans l'interface — il fallait connaître
// « ajean edit » et savoir quoi y écrire.
// « loki edit » et savoir quoi y écrire.
// 2. Même à 0.0.0.0, le pare-feu Windows bloque les connexions entrantes tant
// qu'aucune règle n'autorise le port. AJEAN n'en posait aucune.
// qu'aucune règle n'autorise le port. Loki n'en posait aucune.
//
// D'où un seul interrupteur qui fait les deux, et qui DIT ce qu'il n'a pas pu
// faire (poser une règle de pare-feu exige les droits administrateur, que
// l'installation d'AJEAN ne réclame pas).
// l'installation d'Loki ne réclame pas).
// firewallInert coupe tout pilotage du pare-feu. Positionné par les tests : une
// suite qui laisse une règle entrante derrière elle est un effet de bord
@@ -55,7 +55,7 @@ func lanExposed() bool {
return h != hostLocalOnly && h != "::1" && !strings.EqualFold(h, "localhost")
}
// netStatus est ce que l'interface affiche, et ce que renvoie `ajean network`.
// netStatus est ce que l'interface affiche, et ce que renvoie `loki network`.
type netStatus struct {
Exposed bool `json:"exposed"` // le moteur écoute-t-il sur le réseau
Host string `json:"host"` // adresse d'écoute effective
@@ -103,7 +103,7 @@ func setLANExposure(on bool) (netStatus, error) {
return networkStatus(), nil
}
// cmdNetwork : `ajean network [on|off|status]`.
// cmdNetwork : `loki network [on|off|status]`.
func cmdNetwork(args []string) error {
sub := "status"
if len(args) > 0 {
@@ -121,7 +121,7 @@ func cmdNetwork(args []string) error {
fmt.Printf("%s le moteur n'écoutera plus que sur cette machine\n", green("[ok]"))
}
printNetHint(st)
fmt.Printf("%s redémarre le moteur pour appliquer : %s\n", dim("[info]"), bold("ajean restart"))
fmt.Printf("%s redémarre le moteur pour appliquer : %s\n", dim("[info]"), bold("loki restart"))
return nil
case "status", "":
st := networkStatus()
@@ -136,7 +136,7 @@ func cmdNetwork(args []string) error {
case "ferme":
fmt.Printf(" pare-feu : %s\n", yellow("aucune règle pour ce port"))
default:
fmt.Printf(" pare-feu : %s\n", dim("non géré par AJEAN sur cette plateforme"))
fmt.Printf(" pare-feu : %s\n", dim("non géré par Loki sur cette plateforme"))
}
if st.Exposed {
fmt.Printf(" endpoint OpenAI : %s\n", bold(st.URL))
@@ -144,7 +144,7 @@ func cmdNetwork(args []string) error {
printNetHint(st)
return nil
}
return fmt.Errorf("usage: ajean network [on|off|status]")
return fmt.Errorf("usage: loki network [on|off|status]")
}
func printNetHint(st netStatus) {
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"encoding/json"
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"fmt"
@@ -13,11 +13,11 @@ import (
// emplacements invisibles : on ne sait plus si le .exe qu'on a double-cliqué EST
// l'application ou seulement un installateur, ni où atterrissent la config, les
// modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici,
// et on l'expose à la fois en ligne de commande (`ajean where`) et dans l'UI.
// et on l'expose à la fois en ligne de commande (`loki where`) et dans l'UI.
type ajeanPaths struct {
Home string `json:"home"` // AJEAN_HOME : racine des données
Database string `json:"database"` // ajean.db : config, préférences, conversation, clés
type lokiPaths struct {
Home string `json:"home"` // LOKI_HOME : racine des données
Database string `json:"database"` // loki.db : config, préférences, conversation, clés
Exe string `json:"exe"` // binaire en cours d'exécution
Installed string `json:"installed"` // binaire installé (peut différer de Exe)
Workspace string `json:"workspace"` // dossier de travail du mode agent
@@ -28,23 +28,23 @@ type ajeanPaths struct {
}
// installedExePath est l'emplacement canonique du binaire après installation.
// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/ajean
// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/loki
// (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows,
// faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur.
// faute d'équivalent, on utilise LOKI_HOME\bin ajouté au PATH utilisateur.
func installedExePath() string {
if runtime.GOOS == "windows" {
return filepath.Join(binDir(), "ajean.exe")
return filepath.Join(binDir(), "loki.exe")
}
return "/usr/local/bin/ajean"
return "/usr/local/bin/loki"
}
func currentPaths() ajeanPaths {
func currentPaths() lokiPaths {
exe, _ := os.Executable()
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
return ajeanPaths{
Home: AjeanHome(),
return lokiPaths{
Home: LokiHome(),
Database: dbPath(),
Exe: exe,
Installed: installedExePath(),
@@ -58,9 +58,9 @@ func currentPaths() ajeanPaths {
func cmdWhere(args []string) error {
p := currentPaths()
fmt.Printf("Emplacements AJEAN\n\n")
fmt.Printf("Emplacements Loki\n\n")
for _, row := range [][2]string{
{"données (AJEAN_HOME)", p.Home},
{"données (LOKI_HOME)", p.Home},
{"base", p.Database},
{"binaire en cours", p.Exe},
{"binaire installé", p.Installed},
@@ -1,6 +1,6 @@
//go:build unix
package ajean
package loki
import (
"context"
@@ -16,22 +16,22 @@ import (
"time"
)
// defaultAjeanHome est la racine des données quand ni $AJEAN_HOME ni
// /etc/default/ajean n'en imposent une.
// defaultLokiHome est la racine des données quand ni $LOKI_HOME ni
// /etc/default/loki n'en imposent une.
//
// Linux : /etc/ajean (machine de prod, service systemd lancé par root).
// macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder
// (« mkdir /etc/ajean: permission denied »), et un Mac est une machine de bureau,
// Linux : /etc/loki (machine de prod, service systemd lancé par root).
// macOS : /etc/loki n'est PAS écrivable par une app lancée depuis le Finder
// (« mkdir /etc/loki: permission denied »), et un Mac est une machine de bureau,
// pas un serveur — on retombe donc sur le dossier utilisateur standard, sauf si
// un `sudo ajean install` a déjà créé /etc/ajean à notre nom.
func defaultAjeanHome() string { return unixHome("ajean") }
// un `sudo loki install` a déjà créé /etc/loki à notre nom.
func defaultLokiHome() string { return unixHome("loki") }
func unixHome(name string) string {
if runtime.GOOS != "darwin" || os.Geteuid() == 0 {
return "/etc/" + name
}
if isWritableDir("/etc/" + name) {
return "/etc/" + name // installé par `sudo ajean install` puis chown à l'utilisateur
return "/etc/" + name // installé par `sudo loki install` puis chown à l'utilisateur
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, "Library", "Application Support", name)
@@ -46,7 +46,7 @@ func isWritableDir(dir string) bool {
if err != nil || !fi.IsDir() {
return false
}
probe := filepath.Join(dir, ".ajean-write-test")
probe := filepath.Join(dir, ".loki-write-test")
f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return false
@@ -56,7 +56,7 @@ func isWritableDir(dir string) bool {
return true
}
// defaultEditor is used by `ajean edit` when $EDITOR is unset.
// defaultEditor is used by `loki edit` when $EDITOR is unset.
func defaultEditor() string { return "nano" }
// hideCmd : no-op sur Unix (pas de fenêtre de console à masquer).
@@ -332,7 +332,7 @@ func ramUsageMB() (used, total int) {
// --- Supervision de processus détachés (worker de lien, service en mode
// utilisateur). Pendant Unix de sys_platform_windows.go.
// spawnDetached prépare une commande qui survivra à la mort de AJEAN : Setsid la
// spawnDetached prépare une commande qui survivra à la mort de Loki : Setsid la
// place dans une nouvelle session, donc elle n'est pas tuée avec notre groupe.
func spawnDetached(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...)
@@ -1,6 +1,6 @@
//go:build windows
package ajean
package loki
import (
"context"
@@ -15,7 +15,7 @@ import (
)
// init makes the Windows console behave like a modern terminal: UTF-8 so the
// Unicode glyphs ajean prints (✓ ▶ …) and child-process output don't turn into
// Unicode glyphs loki prints (✓ ▶ …) and child-process output don't turn into
// mojibake (the default OEM codepage, e.g. cp850, renders "✓" as "├ö"), and VT
// processing so the ANSI colour/cursor escapes (including the build progress
// line) are interpreted instead of printed literally. Best-effort: a redirected
@@ -43,7 +43,7 @@ func init() {
// homeRoot is the parent directory of the data root: %ProgramData% (machine-wide,
// the closest analogue to /etc), falling back to %LOCALAPPDATA% for unprivileged
// setups. Ancien et nouveau dossier partagent ce parent, ce qui garantit que la
// migration ajean → ajean est un rename intra-volume (voir sys_migrate.go).
// migration loki → loki est un rename intra-volume (voir sys_migrate.go).
func homeRoot() string {
if pd := os.Getenv("ProgramData"); pd != "" {
return pd
@@ -54,10 +54,10 @@ func homeRoot() string {
return os.TempDir()
}
// defaultAjeanHome est la racine des données quand $AJEAN_HOME est absent.
func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") }
// defaultLokiHome est la racine des données quand $LOKI_HOME est absent.
func defaultLokiHome() string { return filepath.Join(homeRoot(), "loki") }
// defaultEditor is used by `ajean edit` when $EDITOR is unset.
// defaultEditor is used by `loki edit` when $EDITOR is unset.
func defaultEditor() string { return "notepad" }
// openBrowser ouvre l'URL dans le navigateur par défaut. rundll32 évite les
@@ -67,7 +67,7 @@ func openBrowser(url string) error {
}
// hideCmd empêche une commande externe d'ouvrir une fenêtre de console
// (CREATE_NO_WINDOW). Indispensable quand AJEAN tourne sans console (mode app) :
// (CREATE_NO_WINDOW). Indispensable quand Loki tourne sans console (mode app) :
// sinon chaque `nvidia-smi`/`git`/… ferait clignoter une fenêtre noire. Fusionne
// avec les flags déjà présents pour ne pas écraser un éventuel détachement.
func hideCmd(cmd *exec.Cmd) *exec.Cmd {
@@ -144,7 +144,7 @@ func libraryPathEnv(dir string) []string {
}
// execServer runs llama-server as a child process and waits for it. Windows has
// no exec() that replaces the current image, so `ajean serve` stays alive as the
// no exec() that replaces the current image, so `loki serve` stays alive as the
// parent (this is the detached process the service supervisor tracks).
// args[0] is the binary path; the rest are its arguments.
func execServer(bin string, args []string) error {
@@ -246,7 +246,7 @@ func msvcGenerator() string {
// ensureCompiler makes sure an MSVC C++ toolchain is available, installing the
// Visual Studio 2022 Build Tools (VCTools workload) via winget if not. This is a
// large download but keeps `ajean llamacpp install` fully unattended on a bare
// large download but keeps `loki llamacpp install` fully unattended on a bare
// Windows box.
func ensureCompiler() error {
if msvcInstallVersion() != "" {
@@ -368,7 +368,7 @@ Sans elle, CMake échoue sur « No CUDA toolset found ». Pour corriger, au choi
1. relance l'installeur du CUDA Toolkit (installation personnalisée) et coche « CUDA → Visual Studio Integration » — Visual Studio doit déjà être installé à ce moment-là ;
2. ou copie (en admin) les fichiers de
%s
vers le dossier BuildCustomizations ci-dessus, puis relance ajean llamacpp install`, installPath, src)
vers le dossier BuildCustomizations ci-dessus, puis relance loki llamacpp install`, installPath, src)
}
// cudaPathEnv returns the CUDA toolkit env vars the MSBuild CUDA integration
@@ -386,7 +386,7 @@ func cudaPathEnv(toolkitDir string) []string {
}
// newShellCmd builds the command used by the run_shell tool. hideCmd évite un
// flash de console à chaque commande d'agent quand AJEAN tourne en mode app.
// flash de console à chaque commande d'agent quand Loki tourne en mode app.
func newShellCmd(ctx context.Context, command string) *exec.Cmd {
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
}
@@ -1,6 +1,6 @@
//go:build !windows
package ajean
package loki
// Pendant non-Windows de sys_restart_windows.go : sous Linux le redemarrage
// passe par systemd (restartAfterUpdate), qui sait deja relancer proprement un
@@ -1,6 +1,6 @@
//go:build windows
package ajean
package loki
import (
"fmt"
@@ -17,7 +17,7 @@ import (
// lancée avec une sous-commande interne — qui attend notre disparition, profite
// de cette fenêtre pour terminer la migration, puis relance l'application.
//
// C'est cette fenêtre qui manquait. Une mise à jour en place laisse AJEAN
// C'est cette fenêtre qui manquait. Une mise à jour en place laisse Loki
// tourner, et donc le dossier tenu : le renommage échouait avec « Accès refusé »,
// qui sous Windows ne distingue pas un verrou d'un manque de droits. D'où des
// postes qui restaient indéfiniment sur l'ancien nom.
@@ -44,7 +44,7 @@ func scheduleAppRestart() (bool, string) {
time.Sleep(1500 * time.Millisecond) // laisser la réponse atteindre le navigateur
os.Exit(0)
}()
return true, "AJEAN redémarre — la page se reconnectera toute seule dans quelques secondes."
return true, "Loki redémarre — la page se reconnectera toute seule dans quelques secondes."
}
// cmdRestartAfterUpdate est exécuté par l'accompagnateur détaché.
@@ -1,4 +1,4 @@
package ajean
package loki
import (
"fmt"
@@ -19,33 +19,33 @@ import (
// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
// toutes les 3 s, et `ajean start` affichait un « activating » rassurant pendant
// que `ajean test` répondait « /health ne répond pas ». Le diagnostic n'était
// toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant
// que `loki test` répondait « /health ne répond pas ». Le diagnostic n'était
// visible que dans le journal.
func preflightEngine() error {
cfg := ReadConfig()
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
bold("ajean llamacpp install"), bold("ajean edit"))
bold("loki llamacpp install"), bold("loki edit"))
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(AjeanHome(), bin)
bin = filepath.Join(LokiHome(), bin)
}
if _, err := os.Stat(prebuiltResolveBin(bin)); err != nil {
return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("ajean llamacpp install"))
return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("loki llamacpp install"))
}
model := strings.TrimSpace(cfg["MODEL"])
if model == "" {
return fmt.Errorf("MODEL non défini — indique un .gguf avec %s (dossier des modèles : %s)",
bold("ajean edit"), modelsDir())
bold("loki edit"), modelsDir())
}
p, err := resolveServeModelPath(model)
if err != nil {
return fmt.Errorf("MODEL=%s : %w", model, err)
}
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("ajean edit"))
return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("loki edit"))
}
// Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au moment
// où llama-server réclame un tenseur absent, dans le journal du service.
@@ -56,13 +56,13 @@ func preflightEngine() error {
return nil
}
// configTemplate est le squelette commenté proposé par `ajean edit` : la
// configTemplate est le squelette commenté proposé par `loki edit` : la
// configuration vit en base, donc sur une installation neuve le fichier
// temporaire était QUASI VIDE — impossible de deviner quoi écrire. On y déroule
// donc les clés utiles avec leur rôle, les valeurs déjà définies telles quelles,
// les autres commentées.
var configTemplate = []struct{ key, help string }{
{"BIN", "chemin de llama-server (posé par « ajean llamacpp install »)"},
{"BIN", "chemin de llama-server (posé par « loki llamacpp install »)"},
{"MODEL", "nom de fichier .gguf ou chemin complet"},
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
{"PORT", "port du moteur (défaut 8080)"},
@@ -83,9 +83,9 @@ var configTemplate = []struct{ key, help string }{
// configEditorText rend la configuration au format présenté dans $EDITOR.
func configEditorText(cfg map[string]string) string {
var b strings.Builder
b.WriteString("# Configuration du moteur AJEAN (ajean-engine).\n")
b.WriteString("# Configuration du moteur Loki (loki-engine).\n")
b.WriteString("# Une clé par ligne : CLE=valeur. Les lignes commentées (#) sont ignorées :\n")
b.WriteString("# décommente celles dont tu as besoin. « ajean restart » applique.\n\n")
b.WriteString("# décommente celles dont tu as besoin. « loki restart » applique.\n\n")
seen := map[string]bool{}
for _, f := range configTemplate {
seen[f.key] = true
@@ -120,7 +120,7 @@ func editConfig() error {
if editor == "" {
editor = defaultEditor()
}
tmp, err := os.CreateTemp("", "ajean-config-*.env")
tmp, err := os.CreateTemp("", "loki-config-*.env")
if err != nil {
return err
}
@@ -144,7 +144,7 @@ func editConfig() error {
if err := WriteConfig(parseEnv(string(b))); err != nil {
return err
}
fmt.Println(dim("[info] ajean restart pour appliquer"))
fmt.Println(dim("[info] loki restart pour appliquer"))
return nil
}
@@ -1,6 +1,6 @@
//go:build darwin
package ajean
package loki
import (
"fmt"
@@ -18,16 +18,16 @@ import (
// le support macOS était absent (le code systemd/Linux était utilisé par erreur,
// cf. issue #4). Implémentation prudente basée sur launchctl load/unload/list.
// launchdLabel dérive le label launchd du service (ex. "com.ajean.ajean"), en
// conservant le préfixe hérité « com.ajean. » tant que c'est LUI qui est
// launchdLabel dérive le label launchd du service (ex. "com.loki.loki"), en
// conservant le préfixe hérité « com.loki. » tant que c'est LUI qui est
// réellement installé : le plist d'un daemon déjà chargé ne se renomme pas tout
// seul, et viser le mauvais label reviendrait à ne plus voir le service.
func launchdLabel(svc string) string {
modern := "com.ajean." + svc
modern := "com.loki." + svc
if _, err := os.Stat("/Library/LaunchDaemons/" + modern + ".plist"); err == nil {
return modern
}
if legacy := "com.ajean." + svc; fileExists("/Library/LaunchDaemons/" + legacy + ".plist") {
if legacy := "com.loki." + svc; fileExists("/Library/LaunchDaemons/" + legacy + ".plist") {
return legacy
}
return modern
@@ -44,18 +44,18 @@ func launchdPlistPath(svc string) string {
return "/Library/LaunchDaemons/" + launchdLabel(svc) + ".plist"
}
// launchdLogPath : sortie standard/erreur du service, sous AJEAN_HOME (accessible
// launchdLogPath : sortie standard/erreur du service, sous LOKI_HOME (accessible
// en écriture par l'utilisateur du service après le chown de l'installation).
func launchdLogPath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
func launchdLogPath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
// serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w`
// (re)active le service ET le rend persistant au boot ; `unload -w` le désactive.
func serviceAction(action string) error {
svc := serviceName()
plist := launchdPlistPath(svc)
// Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où AJEAN tourne
// via AJEAN.app sans jamais passer par `sudo ajean install` : on gère alors le
// service comme sous Windows, avec un `ajean serve` détaché suivi par un fichier
// Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où Loki tourne
// via Loki.app sans jamais passer par `sudo loki install` : on gère alors le
// service comme sous Windows, avec un `loki serve` détaché suivi par un fichier
// PID. Aucun droit root requis. Sans ça, `start` échouait sur un plist absent
// et l'UI affichait éternellement « service arrêté ».
if _, err := os.Stat(plist); err != nil {
@@ -116,7 +116,7 @@ func checkStarted(svc string) error {
fmt.Println(strings.Join(lines, "\n"))
}
fmt.Println("------------------------------------------------")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
fmt.Printf("→ loki logs pour plus de détails\n→ loki edit pour corriger config.env\n")
return fmt.Errorf("service %s non démarré", svc)
}
@@ -143,14 +143,14 @@ func serviceIsActive() bool {
}
// ---------------------------------------------------------------------------
// Mode utilisateur (sans launchd) — même approche que Windows : `ajean serve` est
// lancé détaché, son PID va dans AJEAN_HOME/<svc>.pid et sa sortie dans
// AJEAN_HOME/<svc>.log. Setsid le détache de notre session pour qu'il survive à
// la fermeture de AJEAN.app, et permet de tuer tout le groupe (llama-server
// Mode utilisateur (sans launchd) — même approche que Windows : `loki serve` est
// lancé détaché, son PID va dans LOKI_HOME/<svc>.pid et sa sortie dans
// LOKI_HOME/<svc>.log. Setsid le détache de notre session pour qu'il survive à
// la fermeture de Loki.app, et permet de tuer tout le groupe (llama-server
// enfant compris) d'un seul signal.
func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
func userSvcAction(action string) error {
switch action {
@@ -173,7 +173,7 @@ func userSvcAction(action string) error {
return nil
case "enable", "disable":
fmt.Printf("%s '%s' réclame un service système : lance %s pour installer le LaunchDaemon.\n",
yellow("[info]"), action, bold("sudo ajean install"))
yellow("[info]"), action, bold("sudo loki install"))
return nil
}
return fmt.Errorf("action inconnue: %s", action)
@@ -188,7 +188,7 @@ func userSvcStart() error {
if err != nil {
return err
}
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
if err := os.MkdirAll(LokiHome(), 0o755); err != nil {
return err
}
logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
@@ -200,12 +200,12 @@ func userSvcStart() error {
cmd := exec.Command(self, "serve")
cmd.Stdout, cmd.Stderr = logf, logf
// Même répertoire de travail que sous systemd/launchd : les chemins relatifs
// de config.env (MODEL=…gguf) se résolvent depuis AJEAN_HOME, pas depuis « / »
// de config.env (MODEL=…gguf) se résolvent depuis LOKI_HOME, pas depuis « / »
// que nous hérite le Finder.
cmd.Dir = AjeanHome()
cmd.Dir = LokiHome()
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de 'ajean serve': %w", err)
return fmt.Errorf("démarrage de 'loki serve': %w", err)
}
pid := cmd.Process.Pid
if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
@@ -219,14 +219,14 @@ func userCheckStarted(pid int) error {
time.Sleep(2 * time.Second)
if processAlive(pid) {
fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid)
fmt.Printf(" logs: %s (ajean logs pour suivre)\n", dim(logFilePath()))
fmt.Printf(" logs: %s (loki logs pour suivre)\n", dim(logFilePath()))
return nil
}
fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName())
fmt.Println("------------------------------------------------")
fmt.Print(tailFile(logFilePath(), 20))
fmt.Println("------------------------------------------------")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
fmt.Printf("→ loki logs pour plus de détails\n→ loki edit pour corriger config.env\n")
_ = os.Remove(pidFilePath())
return fmt.Errorf("service %s non démarré", serviceName())
}
@@ -1,6 +1,6 @@
//go:build linux
package ajean
package loki
import (
"fmt"
@@ -65,9 +65,9 @@ func unitState(svc string) (active, sub string) {
//
// ⚠️ « activating » n'est PAS un succès : avec Restart=on-failure, un moteur qui
// meurt au démarrage repasse en boucle par activating (SubState auto-restart).
// L'ancien test l'acceptait, et `ajean start` répondait « [ok] activating » sur
// un service qui ne démarrerait jamais — d'où des rapports « ajean start dit ok
// mais ajean test ne répond pas ». On distingue donc le SubState, et on laisse
// L'ancien test l'acceptait, et `loki start` répondait « [ok] activating » sur
// un service qui ne démarrerait jamais — d'où des rapports « loki start dit ok
// mais loki test ne répond pas ». On distingue donc le SubState, et on laisse
// au moteur le temps de charger un gros modèle (il reste en activating, mais
// PAS en auto-restart).
func checkStarted(svc string) error {
@@ -86,7 +86,7 @@ func checkStarted(svc string) error {
if state == "activating" && sub != "auto-restart" {
// Chargement en cours (un gros .gguf prend des minutes) : légitime.
fmt.Printf("%s %s: démarrage en cours (chargement du modèle) — %s pour suivre\n",
green("[ok]"), svc, bold("ajean logs"))
green("[ok]"), svc, bold("loki logs"))
return nil
}
what := state
@@ -98,7 +98,7 @@ func checkStarted(svc string) error {
logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output()
fmt.Print(string(logs))
fmt.Println("------------------------------------------------")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger la configuration (BIN, MODEL…)\n")
fmt.Printf("→ loki logs pour plus de détails\n→ loki edit pour corriger la configuration (BIN, MODEL…)\n")
return fmt.Errorf("service %s non démarré", svc)
}
@@ -1,6 +1,6 @@
//go:build windows
package ajean
package loki
import (
"fmt"
@@ -16,8 +16,8 @@ import (
)
// On Windows there is no systemd, so "the service" is a background copy of
// `ajean serve` that this process launches detached. We track it with a PID file
// and stream its output to a log file under AJEAN_HOME. This needs no admin
// `loki serve` that this process launches detached. We track it with a PID file
// and stream its output to a log file under LOKI_HOME. This needs no admin
// rights and no external tools beyond the always-present tasklist/taskkill.
const (
@@ -26,8 +26,8 @@ const (
createNoWindow = 0x08000000 // CREATE_NO_WINDOW (aucune console pour l'enfant)
)
func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
func pidFilePath() string { return filepath.Join(LokiHome(), serviceName()+".pid") }
func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log") }
func serviceAction(action string) error {
switch action {
@@ -59,7 +59,7 @@ func svcStart() error {
if err != nil {
return err
}
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
if err := os.MkdirAll(LokiHome(), 0o755); err != nil {
return err
}
logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
@@ -71,15 +71,15 @@ func svcStart() error {
cmd := exec.Command(self, "serve")
cmd.Stdout = logf
cmd.Stderr = logf
cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis AJEAN_HOME
// createNoWindow + HideWindow : le service enfant (`ajean serve`) ne doit JAMAIS
// faire clignoter de console noire quand AJEAN est lancé en mode app (double-clic).
cmd.Dir = LokiHome() // les chemins relatifs de config.env se résolvent depuis LOKI_HOME
// createNoWindow + HideWindow : le service enfant (`loki serve`) ne doit JAMAIS
// faire clignoter de console noire quand Loki est lancé en mode app (double-clic).
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow,
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("démarrage de 'ajean serve': %w", err)
return fmt.Errorf("démarrage de 'loki serve': %w", err)
}
pid := cmd.Process.Pid
if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil {
@@ -94,14 +94,14 @@ func checkStarted(pid int) error {
time.Sleep(2 * time.Second)
if processAlive(pid) {
fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid)
fmt.Printf(" logs: %s (ajean logs pour suivre)\n", dim(logFilePath()))
fmt.Printf(" logs: %s (loki logs pour suivre)\n", dim(logFilePath()))
return nil
}
fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName())
fmt.Println("------------------------------------------------")
fmt.Print(tailFile(logFilePath(), 20))
fmt.Println("------------------------------------------------")
fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
fmt.Printf("→ loki logs pour plus de détails\n→ loki edit pour corriger config.env\n")
_ = os.Remove(pidFilePath())
return fmt.Errorf("service %s non démarré", serviceName())
}
@@ -1,6 +1,6 @@
//go:build !windows
package ajean
package loki
// sys_splash_other.go — pas de fenêtre de démarrage hors Windows (no-op).
@@ -1,8 +1,8 @@
//go:build windows
package ajean
package loki
// sys_splash_windows.go — écran de démarrage « Lancement de AJEAN… ».
// sys_splash_windows.go — écran de démarrage « Lancement de Loki… ».
//
// Petite fenêtre sans bordure, à coins arrondis, aux couleurs de la marque
// (fond bleu #1f6feb, logo « j » blanc, texte blanc), affichée le temps que le
@@ -127,7 +127,7 @@ func (s *splash) close() {
}
func createSplashWindow() uintptr {
className, _ := syscall.UTF16PtrFromString("AJEANSplash")
className, _ := syscall.UTF16PtrFromString("LokiSplash")
hInst, _, _ := pGetModuleHandleW.Call(0)
splashClassOnce.Do(func() {
@@ -154,7 +154,7 @@ func createSplashWindow() uintptr {
cy, _, _ := pGetSystemMetrics.Call(smCY)
x := (int(cx) - splashW) / 2
y := (int(cy) - splashH) / 2
title, _ := syscall.UTF16PtrFromString("AJEAN")
title, _ := syscall.UTF16PtrFromString("Loki")
hwnd, _, _ := pCreateWindowExW.Call(
uintptr(exTopmost|exToolwindow),
@@ -243,7 +243,7 @@ func paintSplash(hdc uintptr) {
pSetTextColor.Call(hdc, colWhite)
textX := ox + logo + 26
drawText(hdc, "AJEAN", -30, 700, rect{textX, 40, splashW - 20, 82})
drawText(hdc, "Loki", -30, 700, rect{textX, 40, splashW - 20, 82})
drawText(hdc, "Lancement en cours…", -17, 400, rect{textX, 84, splashW - 20, 118})
}
@@ -1,11 +1,11 @@
//go:build darwin
package ajean
package loki
// sys_tray_darwin.go — icône de AJEAN dans la barre de menus macOS (en haut à
// sys_tray_darwin.go — icône de Loki dans la barre de menus macOS (en haut à
// droite, à côté de l'horloge), pendant exact du system tray Windows : quand on
// ouvre AJEAN.app, l'UI web s'ouvre dans le navigateur ET l'icône apparaît, avec
// le menu « Ouvrir AJEAN » / « Quitter ».
// ouvre Loki.app, l'UI web s'ouvre dans le navigateur ET l'icône apparaît, avec
// le menu « Ouvrir Loki » / « Quitter ».
//
// Contrairement à Windows, systray passe ici par Cocoa → CGO obligatoire : les
// binaires macOS sont donc compilés sur un runner macOS (voir release.yml), et
@@ -26,10 +26,10 @@ func runTray(url string) {
// colore selon le thème. Indispensable depuis que la marque est noire —
// une icône noire « en dur » est illisible sur une barre de menus sombre.
systray.SetTemplateIcon(brandTemplatePNG(trayIconSize), BrandIconPNG(trayIconSize))
systray.SetTooltip("AJEAN — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface")
systray.SetTooltip("Loki — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir Loki", "Ouvrir l'interface")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN")
mQuit := systray.AddMenuItem("Quitter", "Arrêter Loki")
go func() {
for {
@@ -1,6 +1,6 @@
//go:build !windows && !darwin
package ajean
package loki
// sys_tray_other.go — pas d'icône de zone de notification sous Linux (serveur
// sans session graphique la plupart du temps). On se contente d'ouvrir l'UI dans
@@ -1,11 +1,11 @@
//go:build windows
package ajean
package loki
// sys_tray_windows.go — icône de AJEAN dans la zone de notification Windows (system
// tray). Quand on lance l'app (double-clic ou `ajean app`), l'UI web s'ouvre dans
// sys_tray_windows.go — icône de Loki dans la zone de notification Windows (system
// tray). Quand on lance l'app (double-clic ou `loki app`), l'UI web s'ouvre dans
// le navigateur ET une petite icône apparaît près de l'horloge : elle montre que
// AJEAN tourne et offre un menu « Ouvrir AJEAN » / « Quitter ».
// Loki tourne et offre un menu « Ouvrir Loki » / « Quitter ».
//
// getlantern/systray est pur Go sur Windows (Win32 via x/sys, aucun CGO) — le
// binaire reste statique. L'équivalent macOS vit dans sys_tray_darwin.go (Cocoa,
@@ -22,11 +22,11 @@ import (
func runTray(url string) {
systray.Run(func() {
systray.SetIcon(trayIcon())
systray.SetTitle("AJEAN")
systray.SetTooltip("AJEAN — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir AJEAN", "Ouvrir l'interface")
systray.SetTitle("Loki")
systray.SetTooltip("Loki — votre IA locale")
mOpen := systray.AddMenuItem("Ouvrir Loki", "Ouvrir l'interface")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quitter", "Arrêter AJEAN et décharger le modèle")
mQuit := systray.AddMenuItem("Quitter", "Arrêter Loki et décharger le modèle")
go func() {
for {
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more