Mémoire longue (recall), projets et trackers

Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork.

MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go)
Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais
ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être
résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le
retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive
grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la
session n'est plus perdu au compactage suivant.
Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la
fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et
fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne
demande au lieu de continuer la tâche en cours.

PROJETS (projects.go)
Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture
est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en
hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md
→ memory/generale/, discussions et tâches orphelines rattachées). Une tâche
planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive
pas dans la mémoire du chantier affiché à l'écran.

TRACKERS (tracker.go)
3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais
en entier — consultation par niveaux (vue d'ensemble → année → mois →
événements), et la dernière valeur de chaque tracker est donnée d'emblée au
modèle, qui répond sans appeler l'outil.

Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation
avec la description du projet (le modèle ne peut plus le désynchroniser) ;
prompt système rattaché au PRESET et non plus global — stocké en base, pas
dans le .env, qui ne saurait pas porter un texte multiligne.

Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les
fichiers : msgText signale la présence d'une image au compactage, et le
garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges).

Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-09-07 22:34:33 +02:00
1 parent c45ba615fa
commit 17f8fa8f34
31 files changed
+3859 -62

No files matched your search

+42 -13
View File
@@ -329,6 +329,10 @@ func EnabledTools(caps Caps) []Tool {
}
if caps.Agent {
tools = append(tools, bashTool(), writeTool(), editTool())
// Mémoire longue de la conversation (chat_recall.go) : le compactage
// n'archive des blocs QUE quand le mode agent est actif — annoncer recall
// sans lui donnerait un outil qui ne peut rien trouver.
tools = append(tools, recallTool(), recallSearchTool())
}
if caps.Code {
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
@@ -338,6 +342,9 @@ func EnabledTools(caps Caps) []Tool {
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
if caps.Mem != MemOff {
tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool())
// Trackers : 3e type de mémoire (données datées). Même axe que les pages —
// un seul outil pour les quatre actions, cf. trackerTool.
tools = append(tools, trackerTool())
}
// Outils web : seulement si le mode agent ET l'accès internet sont actifs.
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
@@ -697,9 +704,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
repeatCount := map[string]int{}
// Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un
// appel d'outil dans leur <think> puis émettent le token de fin SANS l'émettre
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
// explicite au lieu d'afficher « pas de réponse ».
nudged := false
// (ni réponse, ni tool_call). On relance alors le tour avec un nudge explicite
// au lieu d'afficher « pas de réponse ». Le premier suffit la plupart du temps ;
// un second, plus direct, rattrape le cas où le modèle re-décrit le même plan
// en boucle sans jamais l'exécuter — une simple reformulation de « agis » ne
// suffit alors plus. Borné à maxNudges : un modèle vraiment bloqué doit finir
// par rendre la main plutôt que faire attendre.
const maxNudges = 2
nudgeCount := 0
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
// relance par tour, comme le nudge.
patternRetried := false
@@ -919,10 +931,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if cur := toolCalls[0]; cur != nil {
key := "command"
switch cur.Function.Name {
case "mem_search", "web_search":
case "mem_search", "web_search", "recall_search":
key = "query"
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
key = "file"
case "recall":
key = "id"
case "web_open", "web_read", "web_grep":
key = "url"
case "grep", "glob":
@@ -1103,10 +1117,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// nothing while a slow shell command runs and looks frozen.
label := ""
switch tc.Function.Name {
case "mem_search", "web_search":
case "mem_search", "web_search", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
label, _ = args["file"].(string)
case "recall":
label, _ = args["id"].(string)
case "tracker":
// Libellé lisible : « nom » ou « action nom », pas un dump d'arguments.
label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"]))
case "bash", "bash_bg":
label, _ = args["command"].(string)
case "grep", "glob":
@@ -1152,6 +1171,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
continue
}
switch tc.Function.Name {
case "recall":
result = toolRecall(args)
case "recall_search":
result = toolRecallSearch(args)
case "tracker":
result = toolTracker(args)
case "mem_search":
lim := 0
if v, ok := args["limit"].(float64); ok {
@@ -1368,18 +1393,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// instead of leaving the user staring at a silent, finished chat.
if strings.TrimSpace(assistantContent.String()) == "" {
// Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) :
// si un modèle « pense sans agir » malgré tout, on le relance UNE fois avec
// une consigne impérative au lieu d'afficher « pas de réponse ».
if len(tools) > 0 && !disableTools && !nudged {
nudged = true
// si un modèle « pense sans agir » malgré tout, on le relance avec une
// consigne impérative au lieu d'afficher « pas de réponse ».
if len(tools) > 0 && !disableTools && nudgeCount < maxNudges {
nudgeCount++
// Le raisonnement de ce tour avorté ne mène à rien : on demande à
// l'UI de l'effacer avant de relancer, pour ne pas afficher deux
// blocs de réflexion successifs.
cb(StreamEvent{DropReasoning: true})
messages = append(messages, Message{
Role: "user",
Content: "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.",
})
nudge := "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."
if nudgeCount > 1 {
// Le premier nudge n'a pas suffi : le modèle re-décrit le même
// plan sans l'exécuter. Second nudge plus impératif, où on lui
// interdit explicitement de re-raisonner.
nudge = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."
}
messages = append(messages, Message{Role: "user", Content: nudge})
continue
}
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})