diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go
index 03e04c8..96c33ab 100644
--- a/internal/ajean/chat_export.go
+++ b/internal/ajean/chat_export.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"os"
+ "strconv"
"strings"
"time"
)
@@ -19,8 +20,62 @@ import (
// personne ne peut les récupérer. C'est une régression pour qui relisait ses
// échanges, et ce fichier la répare.
//
-// Deux formats, un même contenu : le JSON est fidèle (vue modèle + journal
-// d'affichage complet, rejouable), le Markdown est fait pour être lu.
+// Deux formats : le Markdown est fait pour être LU (et se taille avec des
+// options — un fil relu pour retrouver une réponse n'a que faire des mille
+// lignes de raisonnement qui l'ont produite), le JSON est fait pour être
+// RETRAITÉ et reste fidèle par défaut.
+
+// exportOpts décrit ce qu'on veut sortir. Le zéro de la structure n'est PAS un
+// défaut utilisable (tout serait à false) : passer par defaultExportOpts.
+type exportOpts struct {
+ Format string // "md" | "json"
+ Reasoning bool // Markdown : inclure les blocs de raisonnement
+ Tools bool // Markdown : inclure les appels d'outils
+ Results bool // Markdown : inclure la sortie des outils (implique Tools)
+ Turns int // 0 = tout le fil ; N = les N derniers échanges
+ JSONFull bool // JSON : joindre le journal d'affichage en plus des messages
+}
+
+func defaultExportOpts() exportOpts {
+ return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true, JSONFull: true}
+}
+
+// exportOptsFromQuery lit les options d'une requête. Toute clé absente garde sa
+// valeur par défaut : `/api/chat/export` tout court reste l'export complet.
+func exportOptsFromQuery(q map[string][]string) exportOpts {
+ o := defaultExportOpts()
+ get := func(k string) (string, bool) {
+ v, ok := q[k]
+ if !ok || len(v) == 0 {
+ return "", false
+ }
+ return v[0], true
+ }
+ flag := func(k string, cur bool) bool {
+ v, ok := get(k)
+ if !ok {
+ return cur
+ }
+ return v == "1" || v == "true" || v == "on"
+ }
+ if v, ok := get("format"); ok && v == "json" {
+ o.Format = "json"
+ }
+ o.Reasoning = flag("reasoning", o.Reasoning)
+ o.Tools = flag("tools", o.Tools)
+ o.Results = flag("results", o.Results)
+ o.JSONFull = flag("full", o.JSONFull)
+ if v, ok := get("turns"); ok {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ o.Turns = n
+ }
+ }
+ // Sans les bulles d'outils, leur sortie n'a nulle part où aller.
+ if !o.Tools {
+ o.Results = false
+ }
+ return o
+}
// exportPayload est la forme du fichier JSON exporté. `messages` est la vue
// envoyée au modèle, `log` le journal d'affichage (celui qui porte les
@@ -30,23 +85,65 @@ type exportPayload struct {
ExportedAt string `json:"exported_at"`
CtxUsed int `json:"ctx_used"`
Messages []Message `json:"messages"`
- Log []LogEvent `json:"log"`
+ Log []LogEvent `json:"log,omitempty"`
}
-// ExportJSON rend la conversation entière en JSON indenté.
-func (c *Conversation) ExportJSON() ([]byte, error) {
+// ExportJSON rend la conversation en JSON indenté.
+func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
c.mu.Lock()
+ msgs := append([]Message(nil), c.Messages...)
+ log := append([]LogEvent(nil), c.Log...)
+ ctxUsed := c.CtxUsed
+ c.mu.Unlock()
+
+ if o.Turns > 0 {
+ msgs = lastTurnsMessages(msgs, o.Turns)
+ log = lastTurnsLog(log, o.Turns)
+ }
p := exportPayload{
Version: Version,
ExportedAt: time.Now().Format(time.RFC3339),
- CtxUsed: c.CtxUsed,
- Messages: append([]Message(nil), c.Messages...),
- Log: append([]LogEvent(nil), c.Log...),
+ CtxUsed: ctxUsed,
+ Messages: msgs,
+ }
+ if o.JSONFull {
+ p.Log = log
}
- c.mu.Unlock()
return json.MarshalIndent(p, "", " ")
}
+// lastTurnsMessages garde les n derniers échanges de la vue modèle, un échange
+// commençant à un message `user`.
+func lastTurnsMessages(msgs []Message, n int) []Message {
+ seen := 0
+ for i := len(msgs) - 1; i >= 0; i-- {
+ if msgs[i].Role != "user" {
+ continue
+ }
+ seen++
+ if seen == n {
+ return msgs[i:]
+ }
+ }
+ return msgs
+}
+
+// lastTurnsLog fait de même sur le journal d'affichage, où un échange commence à
+// un événement `user`.
+func lastTurnsLog(log []LogEvent, n int) []LogEvent {
+ seen := 0
+ for i := len(log) - 1; i >= 0; i-- {
+ if _, ok := log[i].Delta["user"]; !ok {
+ continue
+ }
+ seen++
+ if seen == n {
+ return log[i:]
+ }
+ }
+ return log
+}
+
// ExportMarkdown rend la conversation en Markdown lisible : un bloc par tour,
// le raisonnement replié dans un (il est souvent plus long que la
// réponse), et les outils appelés en liste.
@@ -55,53 +152,64 @@ func (c *Conversation) ExportJSON() ([]byte, error) {
// les raisonnements et la trace des outils, précisément ce qu'on vient chercher
// dans un export. coalesceReplay est réutilisé tel quel pour recoller les
// milliers de deltas d'un tour en un bloc de texte par bulle.
-func (c *Conversation) ExportMarkdown() string {
+func (c *Conversation) ExportMarkdown(o exportOpts) string {
c.mu.Lock()
snapshot := append([]LogEvent(nil), c.Log...)
c.mu.Unlock()
+ if o.Turns > 0 {
+ snapshot = lastTurnsLog(snapshot, o.Turns)
+ }
var b strings.Builder
fmt.Fprintf(&b, "# Conversation AJEAN\n\nExportée le %s par AJEAN %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.
+ if note := exportNote(o); note != "" {
+ fmt.Fprintf(&b, "\n%s\n", note)
+ }
// 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
// quand la réponse est entrecoupée d'appels d'outils.
openBubble := false
+ head := func() {
+ if !openBubble {
+ b.WriteString("\n## AJEAN\n")
+ openBubble = true
+ }
+ }
for _, ev := range coalesceReplay(snapshot, 0) {
switch {
case ev["user"] != nil:
openBubble = false
fmt.Fprintf(&b, "\n---\n\n## Vous\n\n%s\n", mdText(ev["user"]))
case ev["reasoning_content"] != nil:
- if !openBubble {
- b.WriteString("\n## AJEAN\n")
- openBubble = true
+ if !o.Reasoning {
+ break
}
+ head()
fmt.Fprintf(&b, "\n\nRaisonnement\n\n%s\n\n\n",
mdText(ev["reasoning_content"]))
case ev["content"] != nil:
- if !openBubble {
- b.WriteString("\n## AJEAN\n")
- openBubble = true
- }
+ head()
fmt.Fprintf(&b, "\n%s\n", mdText(ev["content"]))
case ev["tool_used"] != nil:
+ if !o.Tools {
+ break
+ }
tu, _ := ev["tool_used"].(map[string]any)
if tu == nil {
break
}
- if !openBubble {
- b.WriteString("\n## AJEAN\n")
- openBubble = true
- }
+ head()
label, _ := tu["label"].(string)
name, _ := tu["name"].(string)
if label == "" {
label = name
}
fmt.Fprintf(&b, "\n> 🔧 **%s** — %s\n", mdInline(name), mdInline(label))
- if res, _ := tu["result"].(string); strings.TrimSpace(res) != "" {
+ if res, _ := tu["result"].(string); o.Results && strings.TrimSpace(res) != "" {
fmt.Fprintf(&b, "\n```\n%s\n```\n", strings.TrimRight(res, "\n"))
}
case ev["compacted"] != nil:
@@ -114,6 +222,26 @@ func (c *Conversation) ExportMarkdown() string {
return b.String()
}
+// exportNote résume en une ligne ce que l'export ne contient PAS.
+func exportNote(o exportOpts) string {
+ var parts []string
+ if o.Turns > 0 {
+ parts = append(parts, fmt.Sprintf("%d derniers échanges seulement", o.Turns))
+ }
+ if !o.Reasoning {
+ parts = append(parts, "raisonnements retirés")
+ }
+ if !o.Tools {
+ parts = append(parts, "appels d'outils retirés")
+ } else if !o.Results {
+ parts = append(parts, "sorties d'outils retirées")
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "_Export partiel : " + strings.Join(parts, ", ") + "._"
+}
+
// mdText rend une valeur d'événement en texte de bloc Markdown.
func mdText(v any) string {
s, _ := v.(string)
@@ -135,6 +263,16 @@ func exportFilename(ext string) string {
return "ajean-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext
}
+// exportBody rend la conversation selon les options. Renvoie le corps, son type
+// MIME et l'extension de fichier.
+func exportBody(o exportOpts) ([]byte, string, string, error) {
+ if o.Format == "json" {
+ b, err := conv.ExportJSON(o)
+ return b, "application/json; charset=utf-8", "json", err
+ }
+ return []byte(conv.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil
+}
+
// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard).
//
// ajean export → ajean-conversation-.md dans le dossier courant
@@ -142,19 +280,43 @@ func exportFilename(ext string) string {
// 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
//
+// Options de contenu, les mêmes que la fenêtre d'export de l'interface :
+//
+// --no-reasoning --no-tools --no-results --last N --messages-only
+//
// La conversation est relue depuis la base à chaque appel : la commande marche
// pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go).
func cmdExport(args []string) error {
- format, out := "md", ""
- for _, a := range args {
+ o := defaultExportOpts()
+ out := ""
+ for i := 0; i < len(args); i++ {
+ a := args[i]
switch a {
case "--json", "-j":
- format = "json"
+ o.Format = "json"
case "--md", "--markdown", "-m":
- format = "md"
+ o.Format = "md"
+ case "--no-reasoning":
+ o.Reasoning = false
+ case "--no-tools":
+ o.Tools, o.Results = false, false
+ case "--no-results":
+ o.Results = false
+ case "--messages-only":
+ o.JSONFull = false
+ case "--last":
+ if i+1 >= len(args) {
+ return fmt.Errorf("--last attend un nombre d'échanges")
+ }
+ i++
+ n, err := strconv.Atoi(args[i])
+ if err != nil || n <= 0 {
+ return fmt.Errorf("--last : nombre d'échanges invalide (%s)", args[i])
+ }
+ o.Turns = n
default:
if strings.HasPrefix(a, "-") && a != "-" {
- return fmt.Errorf("option inconnue : %s (attendu --json ou --md)", a)
+ return fmt.Errorf("option inconnue : %s (voir « ajean help »)", a)
}
out = a
}
@@ -162,21 +324,15 @@ func cmdExport(args []string) error {
// Une extension explicite l'emporte sur le drapeau : `ajean export fil.json`
// qui produirait du Markdown serait un piège.
if strings.HasSuffix(strings.ToLower(out), ".json") {
- format = "json"
+ o.Format = "json"
} else if strings.HasSuffix(strings.ToLower(out), ".md") {
- format = "md"
+ o.Format = "md"
}
LoadConversation()
- var body []byte
- if format == "json" {
- b, err := conv.ExportJSON()
- if err != nil {
- return err
- }
- body = b
- } else {
- body = []byte(conv.ExportMarkdown())
+ body, _, ext, err := exportBody(o)
+ if err != nil {
+ return err
}
if out == "-" {
@@ -184,7 +340,7 @@ func cmdExport(args []string) error {
return err
}
if out == "" {
- out = exportFilename(format)
+ out = exportFilename(ext)
}
if err := os.WriteFile(out, body, 0o644); err != nil {
return err
@@ -196,20 +352,14 @@ func cmdExport(args []string) error {
return nil
}
-// handleChatExport sert la conversation en téléchargement. `format=md` (défaut)
-// ou `format=json`.
+// handleChatExport sert la conversation en téléchargement. Options en paramètres
+// d'URL (voir exportOptsFromQuery) ; sans aucun paramètre, c'est l'export complet.
func handleChatExport(w http.ResponseWriter, r *http.Request) {
- var body []byte
- var ctype, ext string
- if r.URL.Query().Get("format") == "json" {
- b, err := conv.ExportJSON()
- if err != nil {
- sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
- return
- }
- body, ctype, ext = b, "application/json; charset=utf-8", "json"
- } else {
- body, ctype, ext = []byte(conv.ExportMarkdown()), "text/markdown; charset=utf-8", "md"
+ o := exportOptsFromQuery(r.URL.Query())
+ body, ctype, ext, err := exportBody(o)
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition", `attachment; filename="`+exportFilename(ext)+`"`)
diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go
index 2160f3c..1d0c0ad 100644
--- a/internal/ajean/chat_export_test.go
+++ b/internal/ajean/chat_export_test.go
@@ -34,7 +34,7 @@ func convDeTest(t *testing.T) {
func TestExportMarkdownPorteToutLeFil(t *testing.T) {
convDeTest(t)
- md := conv.ExportMarkdown()
+ md := conv.ExportMarkdown(defaultExportOpts())
for _, want := range []string{
"## Vous", "combien font 2+2 ?",
"## AJEAN",
@@ -54,7 +54,7 @@ func TestExportMarkdownPorteToutLeFil(t *testing.T) {
func TestExportJSONRelisible(t *testing.T) {
convDeTest(t)
- b, err := conv.ExportJSON()
+ b, err := conv.ExportJSON(defaultExportOpts())
if err != nil {
t.Fatal(err)
}
@@ -96,6 +96,114 @@ func TestHandleChatExportEnPieceJointe(t *testing.T) {
}
}
+// Les options taillent l'export, et un export tronqué le DIT (sans quoi, relu
+// plus tard, il passerait pour le fil complet).
+func TestExportOptionsTaillentLeMarkdown(t *testing.T) {
+ convDeTest(t)
+ o := defaultExportOpts()
+ o.Reasoning = false
+ md := conv.ExportMarkdown(o)
+ if strings.Contains(md, "addition simple") {
+ t.Error("raisonnement présent malgré --no-reasoning")
+ }
+ if !strings.Contains(md, "Export partiel") || !strings.Contains(md, "raisonnements retirés") {
+ t.Errorf("export allégé non signalé :\n%s", md)
+ }
+ if !strings.Contains(md, "echo 4") {
+ t.Error("les outils ont disparu alors que seule la réflexion était exclue")
+ }
+
+ o = defaultExportOpts()
+ o.Results = false
+ if md := conv.ExportMarkdown(o); strings.Contains(md, "```") {
+ t.Error("sortie d'outil présente malgré --no-results")
+ }
+
+ // Couper les outils coupe forcément leurs sorties : la sortie n'a plus de
+ // bulle où s'accrocher.
+ o = exportOptsFromQuery(map[string][]string{"tools": {"0"}, "results": {"1"}})
+ if o.Results {
+ t.Error("results resté actif alors que tools est coupé")
+ }
+ md = conv.ExportMarkdown(o)
+ if strings.Contains(md, "echo 4") {
+ t.Error("outil présent malgré tools=0")
+ }
+}
+
+// Sans paramètre, l'endpoint reste l'export COMPLET : les options ne doivent pas
+// silencieusement appauvrir ceux qui ne les connaissent pas.
+func TestExportOptsFromQueryDefautComplet(t *testing.T) {
+ o := exportOptsFromQuery(map[string][]string{})
+ if o != defaultExportOpts() {
+ t.Fatalf("défauts modifiés : %+v", o)
+ }
+ o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}, "full": {"0"}})
+ if o.Format != "json" || o.Turns != 2 || o.JSONFull {
+ t.Fatalf("options mal lues : %+v", o)
+ }
+ // Une valeur de tours absurde ne doit pas vider l'export.
+ if o := exportOptsFromQuery(map[string][]string{"turns": {"-3"}}); o.Turns != 0 {
+ t.Fatalf("turns=-3 accepté : %d", o.Turns)
+ }
+}
+
+func TestExportDerniersEchanges(t *testing.T) {
+ convDeTest(t)
+ // Un deuxième échange s'ajoute au fil de test.
+ conv.mu.Lock()
+ conv.Messages = append(conv.Messages,
+ Message{Role: "user", Content: "et 3+3 ?"}, Message{Role: "assistant", Content: "6"})
+ conv.Log = append(conv.Log,
+ LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}},
+ LogEvent{Seq: 7, TS: 7, Delta: map[string]any{"content": "6"}})
+ conv.mu.Unlock()
+
+ o := defaultExportOpts()
+ o.Turns = 1
+ md := conv.ExportMarkdown(o)
+ if strings.Contains(md, "2+2") {
+ t.Errorf("le premier échange est encore là avec turns=1 :\n%s", md)
+ }
+ if !strings.Contains(md, "3+3") || !strings.Contains(md, "1 derniers échanges") {
+ t.Errorf("dernier échange absent ou non signalé :\n%s", md)
+ }
+ // Demander plus d'échanges qu'il n'en existe rend tout le fil, sans erreur.
+ o.Turns = 99
+ if md := conv.ExportMarkdown(o); !strings.Contains(md, "2+2") {
+ t.Error("turns supérieur au nombre d'échanges a tronqué le fil")
+ }
+ // Même découpe côté JSON.
+ o = defaultExportOpts()
+ o.Turns = 1
+ b, err := conv.ExportJSON(o)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var p exportPayload
+ if err := json.Unmarshal(b, &p); err != nil {
+ t.Fatal(err)
+ }
+ if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" {
+ t.Fatalf("messages mal découpés : %+v", p.Messages)
+ }
+ // --messages-only laisse tomber le journal. ⚠️ Structure NEUVE : Unmarshal ne
+ // remet pas à zéro les champs absents du JSON, un `p` réutilisé garderait le
+ // journal de l'appel précédent et le test passerait pour de mauvaises raisons.
+ o.JSONFull = false
+ b, _ = conv.ExportJSON(o)
+ var sansLog exportPayload
+ if err := json.Unmarshal(b, &sansLog); err != nil {
+ t.Fatal(err)
+ }
+ if len(sansLog.Log) != 0 {
+ t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log))
+ }
+ if len(sansLog.Messages) == 0 {
+ t.Fatal("messages perdus avec --messages-only")
+ }
+}
+
func TestCmdExportEcritLeFichier(t *testing.T) {
testHome(t)
convDeTest(t)
diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go
index 57f8b21..c3e4cfe 100644
--- a/internal/ajean/chat_memory.go
+++ b/internal/ajean/chat_memory.go
@@ -10,7 +10,7 @@ import (
"strings"
)
-// La mémoire de ajean = des fichiers Markdown plats sous MEMORY/.md.
+// La mémoire de ajean = des fichiers Markdown plats sous memory/.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.
diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go
index ae89c85..76a0743 100644
--- a/internal/ajean/chat_tools.go
+++ b/internal/ajean/chat_tools.go
@@ -54,7 +54,7 @@ func baseSystemPrompt(caps Caps) string {
b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n")
}
if hasMem {
- b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.\n")
+ b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under memory/.\n")
}
// Politique d'usage de la mémoire selon le mode.
switch caps.Mem {
diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go
index 15de10e..e987bf7 100644
--- a/internal/ajean/llm_client.go
+++ b/internal/ajean/llm_client.go
@@ -56,7 +56,7 @@ func memSearchTool() Tool {
Type: "function",
Function: ToolFunction{
Name: "mem_search",
- Description: "Search your memory (Markdown pages under MEMORY/). Returns a ranked list of {file, title, snippet}. Use it FIRST when the user mentions something you might already know (preferences, ongoing projects, past decisions). Follow up with mem_read on the most relevant page.",
+ Description: "Search your memory (Markdown pages under memory/). Returns a ranked list of {file, title, snippet}. Use it FIRST when the user mentions something you might already know (preferences, ongoing projects, past decisions). Follow up with mem_read on the most relevant page.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
diff --git a/internal/ajean/run.go b/internal/ajean/run.go
index 623dbe4..15fd425 100644
--- a/internal/ajean/run.go
+++ b/internal/ajean/run.go
@@ -144,8 +144,10 @@ Interface (ajean-ui) :
Interaction:
chat [system-prompt] chat terminal streamé
- export [--json] [fichier] exporte la conversation de l'interface web
+ export [options] [fichier] exporte la conversation de l'interface web
(Markdown par défaut, « - » = sortie standard)
+ --json --last N --no-reasoning --no-tools
+ --no-results --messages-only
agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire)
memory [off|ondemand|always|status] mode mémoire de l'IA
internet [on|off|status|engine |url |key ]
diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html
index f7a1efc..5fafaf7 100644
--- a/internal/ajean/ui/index.html
+++ b/internal/ajean/ui/index.html
@@ -1341,18 +1341,20 @@ button:hover{border-color:var(--dim);color:var(--text)}
-