Aller au contenu

Stockage de session et modèle d'entrée

Ce document est la source de vérité concernant la manière dont les sessions de l’agent de codage sont représentées, persistées, migrées et reconstruites à l’exécution.

Couvre :

  • Le format JSONL des sessions et le versionnage
  • La taxonomie des entrées et la sémantique arborescente (id/parentId + pointeur de feuille)
  • Le comportement de migration/compatibilité lors du chargement de fichiers anciens ou malformés
  • La reconstruction du contexte (buildSessionContext)
  • Les garanties de persistance, le comportement en cas d’échec, la troncature/externalisation des blobs
  • Les abstractions de stockage (FileSessionStorage, MemorySessionStorage) et les utilitaires associés

Ne couvre pas le comportement de rendu de l’interface /tree au-delà des sémantiques qui affectent les données de session.

Emplacement par défaut du fichier de session :

~/.xcsh/agent/sessions/--<cwd-encoded>--/<timestamp>_<sessionId>.jsonl

<cwd-encoded> est dérivé du répertoire de travail en supprimant le slash initial et en remplaçant /, \\ et : par -.

Emplacement du magasin de blobs :

~/.xcsh/agent/blobs/<sha256>

Les fichiers de fil d’Ariane du terminal sont écrits sous :

~/.xcsh/agent/terminal-sessions/<terminal-id>

Le contenu du fil d’Ariane se compose de deux lignes : le répertoire de travail original, puis le chemin du fichier de session. continueRecent() privilégie ce pointeur limité au terminal avant de scanner le mtime le plus récent.

Les fichiers de session sont en JSONL : un objet JSON par ligne.

  • La ligne 1 est toujours l’en-tête de session (type: "session").
  • Les lignes restantes sont des valeurs SessionEntry.
  • Les entrées sont en ajout uniquement à l’exécution ; la navigation entre branches déplace un pointeur (leafId) plutôt que de modifier les entrées existantes.
{
"type": "session",
"version": 3,
"id": "1f9d2a6b9c0d1234",
"timestamp": "2026-02-16T10:20:30.000Z",
"cwd": "/work/pi",
"title": "optional session title",
"parentSession": "optional lineage marker"
}

Notes :

  • version est optionnel dans les fichiers v1 ; son absence signifie v1.
  • parentSession est une chaîne de lignage opaque. Le code actuel écrit soit un identifiant de session, soit un chemin de session selon le flux (fork, forkFrom, createBranchedSession, ou newSession({ parentSession }) explicite). À traiter comme des métadonnées, pas comme une clé étrangère typée.

Toutes les entrées non-en-tête incluent :

{
"type": "...",
"id": "8-char-id",
"parentId": "previous-or-branch-parent",
"timestamp": "2026-02-16T10:20:30.000Z"
}

parentId peut être null pour une entrée racine (premier ajout, ou après resetLeaf()).

SessionEntry est l’union de :

  • message
  • thinking_level_change
  • model_change
  • compaction
  • branch_summary
  • custom
  • custom_message
  • label
  • ttsr_injection
  • session_init
  • mode_change

Stocke directement un AgentMessage.

{
"type": "message",
"id": "a1b2c3d4",
"parentId": null,
"timestamp": "2026-02-16T10:21:00.000Z",
"message": {
"role": "assistant",
"provider": "anthropic",
"model": "claude-sonnet-4-5",
"content": [{ "type": "text", "text": "Done." }],
"usage": { "input": 100, "output": 20, "cacheRead": 0, "cacheWrite": 0, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0 } },
"timestamp": 1760000000000
}
}
{
"type": "model_change",
"id": "b1c2d3e4",
"parentId": "a1b2c3d4",
"timestamp": "2026-02-16T10:21:30.000Z",
"model": "openai/gpt-4o",
"role": "default"
}

role est optionnel ; son absence est traitée comme default lors de la reconstruction du contexte.

{
"type": "thinking_level_change",
"id": "c1d2e3f4",
"parentId": "b1c2d3e4",
"timestamp": "2026-02-16T10:22:00.000Z",
"thinkingLevel": "high"
}
{
"type": "compaction",
"id": "d1e2f3a4",
"parentId": "c1d2e3f4",
"timestamp": "2026-02-16T10:23:00.000Z",
"summary": "Conversation summary",
"shortSummary": "Short recap",
"firstKeptEntryId": "a1b2c3d4",
"tokensBefore": 42000,
"details": { "readFiles": ["src/a.ts"] },
"preserveData": { "hookState": true },
"fromExtension": false
}
{
"type": "branch_summary",
"id": "e1f2a3b4",
"parentId": "a1b2c3d4",
"timestamp": "2026-02-16T10:24:00.000Z",
"fromId": "a1b2c3d4",
"summary": "Summary of abandoned path",
"details": { "note": "optional" },
"fromExtension": true
}

Si le branchement se fait depuis la racine (branchFromId === null), fromId est la chaîne littérale "root".

Persistance de l’état des extensions ; ignoré par buildSessionContext.

{
"type": "custom",
"id": "f1a2b3c4",
"parentId": "e1f2a3b4",
"timestamp": "2026-02-16T10:25:00.000Z",
"customType": "my-extension",
"data": { "state": 1 }
}

Message fourni par une extension qui participe au contexte LLM.

{
"type": "custom_message",
"id": "a2b3c4d5",
"parentId": "f1a2b3c4",
"timestamp": "2026-02-16T10:26:00.000Z",
"customType": "my-extension",
"content": "Injected context",
"display": true,
"details": { "debug": false }
}
{
"type": "label",
"id": "b2c3d4e5",
"parentId": "a2b3c4d5",
"timestamp": "2026-02-16T10:27:00.000Z",
"targetId": "a1b2c3d4",
"label": "checkpoint"
}

label: undefined efface un label pour targetId.

{
"type": "ttsr_injection",
"id": "c2d3e4f5",
"parentId": "b2c3d4e5",
"timestamp": "2026-02-16T10:28:00.000Z",
"injectedRules": ["ruleA", "ruleB"]
}
{
"type": "session_init",
"id": "d2e3f4a5",
"parentId": "c2d3e4f5",
"timestamp": "2026-02-16T10:29:00.000Z",
"systemPrompt": "...",
"task": "...",
"tools": ["read", "edit"],
"outputSchema": { "type": "object" }
}
{
"type": "mode_change",
"id": "e2f3a4b5",
"parentId": "d2e3f4a5",
"timestamp": "2026-02-16T10:30:00.000Z",
"mode": "plan",
"data": { "planFile": "/tmp/plan.md" }
}

Version actuelle de session : 3.

Appliquée lorsque le version de l’en-tête est absent ou < 2 :

  • Ajoute id et parentId à chaque entrée non-en-tête.
  • Reconstruit une chaîne parentale linéaire en utilisant l’ordre du fichier.
  • Migre le champ de compaction firstKeptEntryIndex -> firstKeptEntryId lorsqu’il est présent.
  • Définit version = 2 dans l’en-tête.

Appliquée lorsque le version de l’en-tête est < 3 :

  • Pour les entrées message : réécrit l’ancien message.role === "hookMessage" en "custom".
  • Définit version = 3 dans l’en-tête.
  • Les migrations s’exécutent lors du chargement de session (setSessionFile).
  • Si une migration a été exécutée, le fichier entier est réécrit sur le disque immédiatement.
  • La migration modifie d’abord les entrées en mémoire, puis persiste le JSONL réécrit.

Comportement de loadEntriesFromFile(path) :

  • Fichier manquant (ENOENT) -> retourne [].
  • Les lignes non analysables sont gérées par le parseur JSONL tolérant (parseJsonlLenient).
  • Si la première entrée analysée n’est pas un en-tête de session valide (type !== "session" ou id de type chaîne manquant) -> retourne [].

Comportement de SessionManager.setSessionFile() :

  • [] retourné par le chargeur est traité comme une session vide/inexistante et remplacé par un nouveau fichier de session initialisé à ce chemin.
  • Les fichiers valides sont chargés, migrés si nécessaire, les références de blobs sont résolues, puis indexés.

Le modèle sous-jacent est un arbre en ajout uniquement + un pointeur de feuille mutable :

  • Chaque méthode d’ajout crée exactement une nouvelle entrée dont le parentId est le leafId actuel.
  • La nouvelle entrée devient le nouveau leafId.
  • branch(entryId) déplace uniquement leafId ; les entrées existantes restent inchangées.
  • resetLeaf() définit leafId = null ; le prochain ajout crée une nouvelle entrée racine (parentId: null).
  • branchWithSummary() définit la feuille sur la cible de branchement et ajoute une entrée branch_summary.

getEntries() retourne toutes les entrées non-en-tête dans l’ordre d’insertion. Les entrées existantes ne sont pas supprimées en fonctionnement normal ; les réécritures préservent l’historique logique tout en mettant à jour la représentation (migrations, déplacements, utilitaires de réécriture ciblée).

buildSessionContext(entries, leafId, byId?) détermine ce qui est envoyé au modèle.

Algorithme :

  1. Déterminer la feuille :
    • leafId === null -> retourner un contexte vide.
    • leafId explicite -> utiliser cette entrée si trouvée.
    • sinon, repli sur la dernière entrée.
  2. Remonter la chaîne parentId de la feuille à la racine et inverser pour obtenir le chemin racine->feuille.
  3. Dériver l’état à l’exécution à travers le chemin :
    • thinkingLevel depuis le dernier thinking_level_change (par défaut "off")
    • carte des modèles depuis les entrées model_change (role ?? "default")
    • models.default de repli depuis le provider/modèle du message assistant s’il n’y a pas de changement de modèle explicite
    • injectedTtsrRules dédupliquées depuis toutes les entrées ttsr_injection
    • mode/modeData depuis le dernier mode_change (mode par défaut "none")
  4. Construire la liste des messages :
    • Les entrées message passent directement
    • Les entrées custom_message deviennent des AgentMessages custom via createCustomMessage
    • Les entrées branch_summary deviennent des AgentMessages branchSummary via createBranchSummaryMessage
    • Si une compaction existe sur le chemin :
      • Émettre d’abord le résumé de compaction (createCompactionSummaryMessage)
      • Émettre les entrées du chemin à partir de firstKeptEntryId jusqu’à la limite de compaction
      • Émettre les entrées après la limite de compaction

Les entrées custom et session_init n’injectent pas directement de contexte dans le modèle.

Garanties de persistance et modèle de défaillance

Section intitulée « Garanties de persistance et modèle de défaillance »
  • SessionManager.create/open/continueRecent/forkFrom -> mode persistant (persist = true).
  • SessionManager.inMemory -> mode non persistant (persist = false) avec MemorySessionStorage.

Les écritures sont sérialisées via une chaîne de promesses interne (#persistChain) et NdjsonFileWriter.

  • append* met à jour l’état en mémoire immédiatement.
  • La persistance est différée jusqu’à ce qu’au moins un message assistant existe.
    • Avant le premier assistant : les entrées sont conservées en mémoire ; aucun ajout au fichier ne se produit.
    • Lorsque le premier assistant existe : l’intégralité de la session en mémoire est vidée dans le fichier.
    • Par la suite : les nouvelles entrées sont ajoutées de manière incrémentale.

Justification dans le code : éviter de persister des sessions qui n’ont jamais produit de réponse assistant.

  • flush() vide l’écrivain et appelle fsync().
  • Les réécritures atomiques complètes (#rewriteFile) écrivent dans un fichier temporaire, effectuent flush+fsync, ferment, puis renomment par-dessus la cible.
  • Utilisées pour les migrations, setSessionName, rewriteEntries, les opérations de déplacement et les réécritures d’arguments d’appels d’outils.
  • Les erreurs de persistance sont verrouillées (#persistError) et relancées lors des opérations suivantes.
  • La première erreur est journalisée une seule fois avec le contexte du fichier de session.
  • La fermeture de l’écrivain est en meilleur effort mais propage la première erreur significative.

Contrôles de taille des données et externalisation des blobs

Section intitulée « Contrôles de taille des données et externalisation des blobs »

Avant de persister les entrées :

  • Les grandes chaînes sont tronquées à MAX_PERSIST_CHARS (500 000 caractères) avec notification :
    • "[Session persistence truncated large content]"
  • Les champs transitoires partialJson et jsonlEvents sont supprimés.
  • Si l’objet a à la fois content et lineCount, le nombre de lignes est recalculé après troncature.
  • Les blocs d’images dans les tableaux content avec une longueur base64 >= 1024 sont externalisés en références de blob :
    • stockés sous la forme blob:sha256:<hash>
    • les octets bruts sont écrits dans le magasin de blobs (BlobStore.put)

Au chargement, les références de blob sont résolues en base64 pour les blocs d’images des message/custom_message.

L’interface SessionStorage fournit toutes les opérations du système de fichiers utilisées par SessionManager :

  • synchrones : ensureDirSync, existsSync, writeTextSync, statSync, listFilesSync
  • asynchrones : exists, readText, readTextPrefix, writeText, rename, unlink, openWriter

Implémentations :

  • FileSessionStorage : système de fichiers réel (Bun + node fs)
  • MemorySessionStorage : implémentation en mémoire basée sur une map pour les tests/sessions non persistantes

SessionStorageWriter expose writeLine, flush, fsync, close, getError.

Définis dans session-manager.ts :

  • getRecentSessions(sessionDir, limit) -> métadonnées légères pour l’interface/sélecteur de session
  • findMostRecentSession(sessionDir) -> la plus récente par mtime
  • list(cwd, sessionDir?) -> sessions dans le périmètre d’un projet
  • listAll() -> sessions à travers tous les périmètres de projets sous ~/.xcsh/agent/sessions

L’extraction des métadonnées ne lit qu’un préfixe (readTextPrefix(..., 4096)) lorsque c’est possible.

Connexe mais distinct : stockage de l’historique des prompts

Section intitulée « Connexe mais distinct : stockage de l’historique des prompts »

HistoryStorage (history-storage.ts) est un sous-système SQLite séparé pour le rappel/la recherche de prompts, pas pour la relecture de sessions.

  • Base de données : ~/.xcsh/agent/history.db
  • Table : history(id, prompt, created_at, cwd)
  • Index FTS5 : history_fts avec synchronisation maintenue par déclencheurs
  • Déduplique les prompts identiques consécutifs en utilisant un cache en mémoire du dernier prompt
  • Insertion asynchrone (setImmediate) pour que la capture de prompt ne bloque pas l’exécution du tour

Utilisez les fichiers de session pour le graphe de conversation/la relecture d’état ; utilisez HistoryStorage pour l’expérience utilisateur de l’historique des prompts.