mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Le système portait deux valeurs qui bougent : la date du jour — à minuit, toute discussion en cours recalculait son prompt entier — et le dossier de travail, propre à chaque discussion, si bien que deux discussions ne partageaient même pas leur système. Avec PROJ_SNAPSHOT, ces valeurs rejoignent le bloc projet figé, en tête du premier message ; le système ne garde que les consignes (« ton dossier de travail est dans ton contexte »). - Bloc « Environment (from Loki) » : date (avec l'année, et l'année périmée que la consigne web nommait), dossier de la discussion, ou poste distant ciblé (nom, système, dossier, hors ligne). - Contenu toujours vivant : relu à chaque tour ; un changement de jour part en une ligne de <context_update>, tout autre écart (poste hors ligne, autre cible) renvoie le message entier. L'avertissement « hors ligne » ne fige donc plus le système. - Hôte, compte et dossier des scripts, les mêmes pour toutes les discussions, restent dans le système. - Seul l'assemblage d'un tour de discussion avec la clé pose caps.envInCtx : tâches planifiées, sous-agents, vérification et terminal gardent leur système, date et dossier compris. - Sans la clé : système identique à l'octet (gabarits relevés sur le code d'avant, test). Format d'instantané passé à 2 : les instantanés persistés sont repris neufs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
474 lines
22 KiB
Go
474 lines
22 KiB
Go
package loki
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
toolDefaultTimeout = 30
|
|
toolMaxTimeout = 300
|
|
toolMaxOutput = 8000 // characters of stdout/stderr returned to the model
|
|
)
|
|
|
|
// baseSystemPrompt is the always-on system preamble. Structured like pi's
|
|
// proven prompt (identity → method → guidelines → concision → date): a concrete,
|
|
// procedural prompt gives the model rails so it stops deliberating forever
|
|
// ("Wait, let me check… Wait, I'll just run it…") and commits to an action.
|
|
// The per-tool "Outil disponible" sections live in machine/skills prompts so
|
|
// they only appear when the matching feature is on.
|
|
//
|
|
// tools = les outils annoncés sur ce même tour (EnabledTools) : ce qui en est
|
|
// dit ici (la ligne MCP) en est déduit, jamais relu ailleurs.
|
|
func baseSystemPrompt(caps Caps, tools []Tool) string {
|
|
hasMem := caps.Mem != MemOff
|
|
// No tool access at all → no agentic preamble. A plain chat model told to
|
|
// "call tools immediately" hallucinates textual tool calls (e.g.
|
|
// default_api:bash) that leak into the answer. Let the user's own system
|
|
// prompt stand alone. (Internet requiert l'agent, donc pas testé ici.)
|
|
if !caps.Agent && !hasMem {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
// Prompt VOLONTAIREMENT court. Un préambule verbeux (longue liste de
|
|
// « guidelines », surtout des méta-instructions sur la réflexion) fait
|
|
// 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.
|
|
// « 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.")
|
|
// Mode code : le prompt du rôle (builder/planner/verifier…) prend la suite
|
|
// de l'identité. Court par construction (voir code_roles.go) — la règle
|
|
// « ne pas regonfler » vaut aussi pour lui.
|
|
if caps.Code {
|
|
role := caps.Role
|
|
if role == "" {
|
|
role = "builder"
|
|
}
|
|
if rp := rolePrompt(role); rp != "" {
|
|
b.WriteString("\n\n" + rp)
|
|
}
|
|
}
|
|
if memProactive(caps.Mem) {
|
|
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
|
}
|
|
// PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
|
|
// les décrivent déjà un par un. Les réénumérer coûtait ~120 tokens à chaque
|
|
// tour pour répéter ce que le modèle a juste à côté. Ne restent que les
|
|
// consignes que les schémas ne portent pas — le shell utilisé, et les
|
|
// politiques d'usage.
|
|
if caps.Agent {
|
|
// Le shell de la machine CIBLE (cmd.exe pour un poste Windows), pas celui du
|
|
// serveur : sinon le modèle écrit du bash là où tourne cmd.exe (et inversement).
|
|
b.WriteString("\n\nThe shell is " + agentTargetShellName() + ": use its syntax.\n")
|
|
} else {
|
|
b.WriteString("\n\n")
|
|
}
|
|
// Politique d'usage de la mémoire selon le mode.
|
|
switch caps.Mem {
|
|
case MemAlways:
|
|
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
|
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
|
|
// Mode INJECTÉ : l'index est en tête de contexte (mem_index.go), la
|
|
// recherche devient facultative — l'IA lit directement la bonne page.
|
|
b.WriteString("- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n")
|
|
case MemSearchFirst:
|
|
// Mode RECHERCHE : rien d'injecté, prompt léger — mais l'IA DOIT chercher
|
|
// elle-même, sinon elle ignore ce que sa mémoire contient.
|
|
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
|
|
b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
|
|
b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes. Nothing is preloaded: a search is the only way to know what you already know.\n")
|
|
case MemOnDemand:
|
|
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
|
|
}
|
|
if caps.Agent {
|
|
// La règle « write, jamais echo/cat » est INDISPENSABLE (cmd.exe massacre
|
|
// les guillemets imbriqués) mais elle vit maintenant dans les schémas de
|
|
// write et bash, là où elle s'applique. Elle était écrite trois fois.
|
|
b.WriteString("For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
|
|
// Un lien Markdown ordinaire, comme dans n'importe quel chat : l'UI en fait
|
|
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
|
|
// à connaître pour le modèle — juste [texte](chemin).
|
|
b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
|
|
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
|
|
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
|
|
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
|
|
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
|
|
// disent pas. Une ligne, pas trois : le budget du préambule est compté
|
|
// (TestSystemPromptStaysLean).
|
|
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n")
|
|
if caps.Mem == MemSearchFirst {
|
|
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
|
|
}
|
|
}
|
|
if caps.Internet {
|
|
// Le catalogue des outils web est parti dans leurs schémas ; ne reste ici
|
|
// que l'ordre d'appel, que les schémas pris isolément ne disent pas.
|
|
year := time.Now().Format("2006")
|
|
b.WriteString("\nWeb: web_open first, then web_read/web_grep on it.\n")
|
|
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
|
|
if caps.envInCtx {
|
|
// Même consigne, sans la valeur : l'année est dans la ligne de date du
|
|
// contexte (envContextMessage), qui nomme aussi l'année périmée.
|
|
b.WriteString("Today's date is in your context. If a query needs a year use ONLY the current year, never a remembered past year — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
|
} else {
|
|
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
|
}
|
|
}
|
|
if caps.Agent && caps.ComputerUse {
|
|
b.WriteString(cuPromptLine())
|
|
}
|
|
// Ligne MCP tirée des outils de CE tour (voir mcpPromptLine) : un rôle sans
|
|
// outil MCP n'en reçoit pas, et le premier tour après un démarrage l'a déjà.
|
|
if caps.Agent {
|
|
b.WriteString(mcpPromptLine(tools))
|
|
}
|
|
// PROJ_SNAPSHOT (chat_projsnap.go) : le bloc projet est figé, ses changements
|
|
// arrivent en tête des messages suivants. Une ligne fixe, seulement avec la
|
|
// clé, pour qu'ils ne passent ni pour la parole de l'utilisateur ni pour
|
|
// moins récents que le bloc.
|
|
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
|
b.WriteString(projSnapSystemLine)
|
|
}
|
|
// PROJ_SNAPSHOT : la date part dans le bloc projet figé (chat_envctx.go). Ici,
|
|
// elle changeait le système à minuit — tout le prompt recalculé.
|
|
if caps.envInCtx {
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
|
return b.String()
|
|
}
|
|
|
|
// prevYear returns the year before the given "2006"-formatted year string, used
|
|
// to name explicitly the stale year the model must NOT put in search queries.
|
|
func prevYear(year string) string {
|
|
n, err := strconv.Atoi(year)
|
|
if err != nil {
|
|
return year
|
|
}
|
|
return strconv.Itoa(n - 1)
|
|
}
|
|
|
|
// machineSystemPrompt returns a short briefing about the host the model is
|
|
// running on, so that when machine access is enabled it knows *which* machine
|
|
// run_shell acts upon (and doesn't claim it has no access to "your PC").
|
|
// Returns "" when machine access is off.
|
|
func machineSystemPrompt(caps Caps) string {
|
|
if !caps.Agent {
|
|
return ""
|
|
}
|
|
// Cible = un poste distant : bash/write/edit s'exécutent LÀ-BAS, pas sur ce
|
|
// serveur. Le modèle doit le savoir explicitement, sinon il croit agir sur
|
|
// l'hôte du serveur et se trompe de machine.
|
|
if tgt, ok := nodeTargetMetaGet(); ok {
|
|
var b strings.Builder
|
|
// PROJ_SNAPSHOT (caps.envInCtx) : nom, système, dossier et état du poste
|
|
// sont dans le bloc projet (envMachineFacts) ; ici, les seules consignes.
|
|
if caps.envInCtx {
|
|
b.WriteString("Machine: you are operating on a REMOTE node (name, OS, working folder and status are in your context)")
|
|
} else {
|
|
b.WriteString("Machine: you are operating on a REMOTE node named " + tgt.name)
|
|
if tgt.os != "" {
|
|
b.WriteString(" (" + tgt.os + ")")
|
|
}
|
|
}
|
|
b.WriteString(". Your bash, write and edit tools run on THAT machine — a different computer than this server.")
|
|
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
|
|
b.WriteString(" Its shell is cmd.exe: use cmd syntax (never bash idioms like ls, 2>nul, single quotes, or 'cmd //c'). To create a file, use the write tool, never echo/type into it.")
|
|
}
|
|
if tgt.root != "" {
|
|
if caps.envInCtx {
|
|
b.WriteString(" File paths in write/edit resolve inside its working folder; read/write are confined there.")
|
|
} else {
|
|
b.WriteString(" File paths in write/edit resolve inside its working folder " + tgt.root + "; read/write are confined there.")
|
|
}
|
|
}
|
|
// Le poste peut tourner en compte de service (Windows: LocalSystem) : les
|
|
// variables d'environnement personnelles (%USERPROFILE%, $HOME) ne désignent
|
|
// PAS forcément l'utilisateur interactif. Utiliser des chemins absolus.
|
|
b.WriteString(" It may run as a background service account, so %USERPROFILE%/$HOME may not point to the interactive user — prefer absolute paths (e.g. C:\\Users\\<name>\\...).")
|
|
if !tgt.connected && !caps.envInCtx {
|
|
b.WriteString(" " + nodeOfflineLine)
|
|
}
|
|
return b.String()
|
|
}
|
|
host, _ := os.Hostname()
|
|
if host == "" {
|
|
host = "unknown"
|
|
}
|
|
who := ""
|
|
if u, err := user.Current(); err == nil {
|
|
who = u.Username
|
|
}
|
|
cwd := agentCwd()
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
|
|
if who != "" {
|
|
b.WriteString(", user=" + who)
|
|
}
|
|
// PROJ_SNAPSHOT (caps.envInCtx) : le chemin, propre à chaque discussion, est
|
|
// dans le bloc projet (envMachineFacts) ; hôte et compte, les mêmes pour
|
|
// toutes, restent ici avec les consignes.
|
|
if cwd != "" && !caps.envInCtx {
|
|
b.WriteString(", cwd=" + cwd)
|
|
}
|
|
b.WriteString(".")
|
|
if cwd != "" {
|
|
// Formulation reprise de l'amont AJEAN (v0.10.2) : dire ce que le dossier
|
|
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
|
|
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
|
|
// aux « installations » en /usr/local/bin qui échouent faute de root.
|
|
if caps.envInCtx {
|
|
b.WriteString(" Your working folder (cwd) is given in your context: relative paths in write/edit/bash resolve there")
|
|
} else {
|
|
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here")
|
|
}
|
|
b.WriteString(", and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// runShell executes a command via the platform shell (bash -c on Unix, cmd /C
|
|
// on Windows — see newShellCmd in sys_platform_*.go) with a clamped timeout,
|
|
// returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..."
|
|
// truncated to keep tool output bounded.
|
|
//
|
|
// ⚠️ parent est le contexte DU TOUR : c'est lui qui rend le bouton stop utile.
|
|
// La commande naissait auparavant d'un context.Background(), donc arrêter la
|
|
// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
|
|
// délai (5 minutes au maximum), bouton stop sans effet.
|
|
func runShell(parent context.Context, command string, timeoutSec int) string {
|
|
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
|
|
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
|
|
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
|
|
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
|
|
// n'avait pas servie.
|
|
if msg := guardToolOnlyCommand(command); msg != "" {
|
|
return msg
|
|
}
|
|
if timeoutSec <= 0 {
|
|
timeoutSec = toolDefaultTimeout
|
|
}
|
|
if timeoutSec > toolMaxTimeout {
|
|
timeoutSec = toolMaxTimeout
|
|
}
|
|
// « … | tail -N » final : retiré, on garde nous-mêmes les N dernières lignes
|
|
// (chat_shell_hygiene.go) — sinon le code de sortie est celui de tail.
|
|
command, keepLines := splitTailPipe(command)
|
|
start := time.Now()
|
|
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
|
|
defer cancel()
|
|
cmd := newShellCmd(ctx, command)
|
|
// Le shell démarre dans le dossier de la discussion ouverte, pas dans le
|
|
// dossier d'où loki a été lancé : un `> notes.txt` du modèle ne doit pas
|
|
// atterrir sur le Bureau, ni dans une AUTRE discussion.
|
|
//
|
|
// La racine est résolue UNE fois par process (agentWorkspace), donc si elle
|
|
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même la
|
|
// supprime — toutes les commandes suivantes échouaient sur un « chdir : no
|
|
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
|
|
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
|
|
// tout refuser.
|
|
if ws := agentCwd(); ws != "" {
|
|
if err := os.MkdirAll(ws, 0o755); err == nil {
|
|
cmd.Dir = ws
|
|
}
|
|
}
|
|
var stdout, stderr strings.Builder
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
// ⚠️ WaitDelay borne l'attente APRÈS la fin (ou la mise à mort) du process.
|
|
// Sans elle, Wait attend que les tubes de sortie soient fermés — donc que
|
|
// TOUS ceux qui les tiennent aient disparu, petits-enfants compris. Une
|
|
// 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 loki-ui.
|
|
cmd.WaitDelay = 2 * time.Second
|
|
err := cmd.Run()
|
|
// Sortie nettoyée (couleurs ANSI retirées), réduite à la fin si la commande
|
|
// demandait un tail.
|
|
clean := func(s string) string { return tailOutput(stripANSI(s)) }
|
|
out := stdout.String()
|
|
if keepLines > 0 {
|
|
out = tailLines(out, keepLines)
|
|
}
|
|
out = clean(out)
|
|
errOut := clean(stderr.String())
|
|
switch {
|
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
|
// La sortie déjà produite reste utile (où le build en était, quel test
|
|
// bloquait) : on la rend au lieu d'un « timeout » sec (OpenFox).
|
|
msg := fmt.Sprintf("[timeout après %ds — commande arrêtée ; pour un serveur ou une tâche longue, utilise bash_bg]", timeoutSec)
|
|
if out != "" {
|
|
msg += "\n\nstdout (partiel):\n" + out
|
|
}
|
|
if errOut != "" {
|
|
msg += "\n\nstderr (partiel):\n" + errOut
|
|
}
|
|
return msg
|
|
case errors.Is(parent.Err(), context.Canceled):
|
|
return "[commande interrompue]"
|
|
}
|
|
exit := 0
|
|
if err != nil {
|
|
if ee, ok := err.(*exec.ExitError); ok {
|
|
exit = ee.ExitCode()
|
|
} else {
|
|
return fmt.Sprintf("[erreur: %v]", err)
|
|
}
|
|
}
|
|
// « exit: N » en tête : tasks_script.go s'y fie. La durée suit.
|
|
parts := []string{fmt.Sprintf("exit: %d · %s", exit, time.Since(start).Round(100*time.Millisecond))}
|
|
if out != "" {
|
|
parts = append(parts, "stdout:\n"+out)
|
|
}
|
|
if errOut != "" {
|
|
parts = append(parts, "stderr:\n"+errOut)
|
|
}
|
|
return strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
// shellName is the shell runShell actually spawns on this platform. The model is
|
|
// told this explicitly: advertising the tool as "bash" on Windows made it emit
|
|
// bash quoting into cmd.exe, which mangles it (unterminated string literals, and
|
|
// stray "Commande ECHO activée." landing inside generated files).
|
|
func shellName() string {
|
|
if runtime.GOOS == "windows" {
|
|
return "cmd.exe"
|
|
}
|
|
return "bash"
|
|
}
|
|
|
|
// fileWrite writes content to path verbatim, creating parent directories and
|
|
// replacing any existing file. This is the escape hatch from shell quoting: a
|
|
// model with only a shell has to build files with echo/python -c, which is
|
|
// unreliable everywhere and outright broken on cmd.exe.
|
|
func fileWrite(path, content string) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
return "[erreur] chemin vide"
|
|
}
|
|
path = resolveAgentPath(path)
|
|
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
|
if msg := guardToolOnlyPath(path); msg != "" {
|
|
return msg
|
|
}
|
|
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
|
mu := fileMu(path)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
}
|
|
// Préserve les permissions d'origine quand le fichier existe déjà (un script
|
|
// 0755 réécrit doit rester exécutable).
|
|
mode := os.FileMode(0o644)
|
|
existed := false
|
|
if fi, err := os.Stat(path); err == nil {
|
|
mode = fi.Mode()
|
|
existed = true
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
verb := "créé"
|
|
if existed {
|
|
verb = "réécrit"
|
|
}
|
|
return fmt.Sprintf("[ok] %s %s (%d octets)", path, verb, len(content))
|
|
}
|
|
|
|
// fileEdit applies a single exact-text replacement to a file on disk: oldText
|
|
// must appear EXACTLY once (otherwise it errors), so the model can patch a file
|
|
// without rewriting it whole. Returns a short status string for the tool result.
|
|
func fileEdit(path, oldText, newText string) string {
|
|
if strings.TrimSpace(path) == "" {
|
|
return "[erreur] chemin vide"
|
|
}
|
|
if oldText == "" {
|
|
return "[erreur] old vide"
|
|
}
|
|
path = resolveAgentPath(path)
|
|
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
|
if msg := guardToolOnlyPath(path); msg != "" {
|
|
return msg
|
|
}
|
|
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
|
mu := fileMu(path)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
content := string(b)
|
|
// Fichier en CRLF, modèle en LF : la recherche exacte échouait toujours.
|
|
// On compare alors en LF normalisé, et on réécrit dans le style du fichier
|
|
// (préservation des fins de ligne — repris des tests line-ending d'OpenFox).
|
|
crlf := strings.Contains(content, "\r\n")
|
|
search, oldN, newN := content, oldText, newText
|
|
if crlf && !strings.Contains(oldText, "\r\n") {
|
|
search = strings.ReplaceAll(content, "\r\n", "\n")
|
|
}
|
|
n := strings.Count(search, oldN)
|
|
if n == 0 {
|
|
// Modification déjà en place : on le dit clairement plutôt que de renvoyer
|
|
// une erreur, sinon le modèle croit avoir échoué et recommence.
|
|
if newN != "" && strings.Contains(search, newN) {
|
|
return "[ok] déjà à jour — le fichier contient déjà cette modification"
|
|
}
|
|
return "[erreur] old introuvable dans le fichier"
|
|
}
|
|
if n > 1 {
|
|
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
|
}
|
|
updated := strings.Replace(search, oldN, newN, 1)
|
|
if search != content {
|
|
// La comparaison s'est faite en LF : on remet le fichier en CRLF.
|
|
updated = strings.ReplaceAll(updated, "\n", "\r\n")
|
|
}
|
|
// Préserve les permissions d'origine (un script 0755 doit rester exécutable).
|
|
mode := os.FileMode(0o644)
|
|
if fi, err := os.Stat(path); err == nil {
|
|
mode = fi.Mode()
|
|
}
|
|
if err := os.WriteFile(path, []byte(updated), mode); err != nil {
|
|
return "[erreur] " + err.Error()
|
|
}
|
|
return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
|
|
}
|
|
|
|
// tailOutput garde la FIN d'une sortie de commande (toolMaxOutput runes) et dit
|
|
// explicitement ce qui a été coupé (AJEAN 0.15.5). La coupe était silencieuse :
|
|
// le modèle croyait voir toute la sortie, et la bulle affichait toujours
|
|
// ~2004 tok sans qu'on sache pourquoi.
|
|
func tailOutput(s string) string {
|
|
n := len([]rune(s))
|
|
if n <= toolMaxOutput {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("[sortie tronquée : %d caractères au total, seuls les %d derniers sont gardés]\n", n, toolMaxOutput) + tailRunes(s, toolMaxOutput)
|
|
}
|
|
|
|
// tailRunes returns the last n runes of s (used to cap tool output).
|
|
func tailRunes(s string, n int) string {
|
|
r := []rune(s)
|
|
if len(r) <= n {
|
|
return s
|
|
}
|
|
return string(r[len(r)-n:])
|
|
}
|