Files
Loki/internal/loki/chat_cmd.go
T
MichaelandClaude Opus 5.5 7dd319a058 Cache : la ligne MCP du préambule ne manque plus au premier tour après un démarrage
Le préambule système était bâti (InjectSkills → baseSystemPrompt) AVANT que
runChat n'appelle EnabledTools, donc avant que mcpTools n'ouvre les connexions.
mcpPromptLine lisait le pool encore vide : le premier tour après un démarrage
partait sans la ligne MCP, le second avec — tout le prompt à recalculer pour
une ligne. Elle comptait en plus les outils masqués par l'utilisateur, et
s'affichait pour le planner, qui ne reçoit aucun outil MCP.

- prepareTurn calcule les outils UNE fois par tour, puis le préambule à partir
  d'eux ; runChatTools les reprend tels quels (pas de second passage par
  mcpEnsureAll, qui retentait deux fois un serveur en panne).
- mcpPromptLine se déduit de cette tranche : seuls les outils réellement
  envoyés sont comptés, et rien n'est annoncé sans outil MCP.
- trackerList départage les égalités d'horodatage par nom puis slug : la liste
  part dans le contexte, un ordre tiré au sort changeait le prompt.
- Le commentaire de tasks_run.go ne prétend plus que le préfixe d'une tâche
  égale celui du chat (dossier de travail et taskCaps diffèrent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 01:31:00 +02:00

162 lines
4.7 KiB
Go

package loki
import (
"bufio"
"context"
"fmt"
"os"
"strings"
)
// cmdChat is the interactive terminal chat loop.
// First positional arg (if any) becomes the system prompt.
func cmdChat(args []string) error {
if !healthCheck() {
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("loki chat"))
if sysPrompt != "" {
fmt.Println(dim("system: " + sysPrompt))
}
fmt.Println()
sc := bufio.NewScanner(os.Stdin)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for {
fmt.Print(bold("you") + " > ")
if !sc.Scan() {
fmt.Println()
return nil
}
user := sc.Text()
u := strings.TrimSpace(user)
if u == "" {
continue
}
if u == "/quit" || u == "/exit" {
return nil
}
if u == "/reset" {
kept := []Message{}
for _, m := range msgs {
if m.Role == "system" {
kept = append(kept, m)
}
}
msgs = kept
fmt.Println(dim("[contexte vidé]"))
continue
}
if strings.HasPrefix(u, "/sys ") {
newSys := strings.TrimSpace(u[5:])
msgs = msgs[:0]
if newSys != "" {
msgs = append(msgs, Message{Role: "system", Content: newSys})
}
fmt.Println(dim("[system mis à jour]"))
continue
}
msgs = append(msgs, Message{Role: "user", Content: user})
full := strings.Builder{}
inReason := false
var stats *StatsEvent
// Print the assistant prefix once; reasoning is shown inline with a tag.
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.
if compacted, changed := MaybeCompact(context.Background(), msgs, caps, 0); changed {
msgs = compacted
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
}
sent, tools := prepareTurn(msgs, caps)
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
case ev.NewHistory != nil:
// Compaction survenue pendant le tour : elle REMPLACE l'historique (elle
// contient déjà le tour en cours), préfixe système injecté retiré. Sans
// ça le terminal repartirait du fil complet au tour suivant.
base := ev.NewHistory
for len(base) > 0 && base[0].Role == "system" {
base = base[1:]
}
msgs = append([]Message(nil), base...)
fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
case ev.Stats != nil:
stats = ev.Stats
case ev.DropReasoning:
// Le tour a « pensé sans agir » : on relance. Impossible d'effacer le
// texte déjà imprimé en terminal — on referme juste la ligne reasoning.
if inReason {
fmt.Print("\n")
inReason = false
}
case ev.ToolUsed != nil:
if ev.ToolUsed.Done || ev.ToolUsed.Typing {
break // résultat / frappe live affichés côté web ; en terminal on garde l'annonce seule
}
icon := "🧠"
verb := "mémoire"
switch ev.ToolUsed.Name {
case "bash":
icon = "⚙️"
verb = "exécution"
case "write":
icon = "📄"
verb = "écriture"
case "edit":
icon = "✏️"
verb = "édition"
case "web_search", "web_open", "web_read", "web_grep":
icon = "🌐"
verb = "web"
case "web_screenshot":
icon = "📸"
verb = "capture"
}
if inReason {
fmt.Print("\n")
inReason = false
}
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(""))
inReason = true
}
fmt.Print(dim(ev.Reasoning))
case ev.Content != "":
if inReason {
fmt.Print("\n" + cyan("loki") + " > ")
inReason = false
}
full.WriteString(ev.Content)
fmt.Print(ev.Content)
}
return true
})
if inReason {
fmt.Println()
}
fmt.Println()
if stats != nil {
fmt.Printf("%s prefill %d tok · %.0f tok/s decode %d tok · %.1f tok/s\n",
dim("→"), stats.PromptTokens, stats.PromptPerSecond, stats.GenTokens, stats.GenPerSecond)
}
fmt.Println()
if err == nil {
// Persist the tool turns (skill reads, shell runs) BEFORE the final
// answer, so next turn the model remembers it already did them instead
// of re-invoking the same skill/command from scratch.
msgs = append(msgs, extra...)
msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
}
}
}