Mise à jour locale des fichiers de travail

This commit is contained in:
Arnaud 2026-10-21 00:00:52 +02:00
parent 7a279a6065
commit 6ecf78a04c
14 changed files with 173 additions and 79 deletions

2
.gitignore vendored
View file

@ -3,5 +3,3 @@ dist
*.log *.log
.env .env
.maia .maia
HOW_To_Agents.md
resume.md

View file

@ -334,6 +334,8 @@ L'appel est **bloquant** (pas de tâche de fond). Ctrl+C l'annule sans rien modi
Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`. Code : `packages/core/src/compact.ts`, `sessions.ts` (`nextCompactedName`, `startCompacted`), `packages/agent/src/init.ts` (`startContext`), orchestration dans `packages/cli/src/index.ts`.
**Contexte refusé par le serveur** : si LiteLLM répond « Input is too long » (ou équivalent), MAIA compacte automatiquement la conversation (résultats d'outils tronqués à 1 500 caractères dans la demande de résumé, pour qu'elle tienne), puis reprend la demande une seule fois. Si le compactage échoue aussi, l'erreur d'origine est affichée (utilisez alors `/new`). Les sous-agents n'ont pas ce mécanisme.
## Sous-agents (`./.maia/agents`) ## Sous-agents (`./.maia/agents`)
MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle. MAIA peut confier une tâche à un **sous-agent** : un processus Node autonome, avec sa propre conversation (vide) et sa propre définition. L'agent principal le lance avec l'outil `Agent` (`agent` = nom, `task` = tâche complète : le sous-agent ne voit rien de la conversation principale). Seul le **résumé final** du sous-agent revient dans la conversation principale. Plusieurs appels `Agent` dans une même réponse s'exécutent en parallèle.
@ -423,3 +425,8 @@ Journal des évolutions, dans l'ordre :
31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux. 31. **Coupure à 4096 tokens diagnostiquée** (log `MAIA_DEBUG_LLM`) : détection d'une sortie tombant sur une limite usuelle avec un appel d'outil, message d'erreur de `Write` qui conseille d'écrire en plusieurs morceaux.
32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu). 32. **Outil Bash via `runShell`** : timeout et Ctrl+C arrêtent toute l'arborescence de processus (groupe de processus, SIGTERM puis SIGKILL) ; stdout et stderr fusionnés dans l'ordre d'arrivée, dernière ligne `[code de sortie : N]` ou état (délai dépassé, interrompu).
33. **Sous-agents** : définitions `./.maia/agents/<nom>/AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`. 33. **Sous-agents** : définitions `./.maia/agents/<nom>/AGENT.md` et `~/.maia/agents` (en-tête YAML : name, description, model, tools, permissions, effort, skills, omitMd, trace ; sections Instructions system / Missions / Output), outil `Agent`, processus autonome (`fork` + IPC), conversation vide, résumé final seul renvoyé au parent, confirmations relayées à l'interface principale, notification INFO de fin, trace optionnelle `session_…-agent_1.json`, commande `/agents`.
34. **Compactage sur « Input is too long »** : réponse du serveur reconnue (`isContextTooLongError`), compactage d'urgence (résultats d'outils tronqués dans la demande de résumé), reprise de la demande une fois.
35. **Barre de contexte pendant le travail de l'agent** : taille recomptée à chaque itération (après la demande, chaque réponse du LLM et chaque série de résultats d'outils) et barre affichée pendant les itérations, plus seulement à la saisie.
36. **Barre de contexte toujours affichée** : en bas de l'écran en permanence (saisie, itérations, outils, confirmations, listes) ; si la taille maximale du contexte est inconnue, seul le nombre de tokens utilisés est montré.
37. **Zone de saisie toujours visible** : grisée pendant le travail de l'agent (« Traitement en cours — Ctrl+C : interrompre »), et c'est elle qui porte les demandes d'autorisation (`ASK … (o/n)`), dans le même cadre en pointillés.
38. **Ligne d'état réservée** au-dessus de la zone de saisie (« En réflexion… », outils en cours) : toujours présente, vide hors traitement, pour que la zone de saisie ne remonte ni ne descende.

View file

@ -426,6 +426,8 @@ Après le choix d'un modèle (démarrage, `/model`), MAIA pose au LLM la questio
Sous la zone de saisie, en bas à droite : carrés ■ de gauche à droite, **1 carré = 10 000 tokens** (200 000 → 20 carrés), libellé `45.7k / 200k · 23%`. Au moins 1 carré rempli dès que le contexte n'est pas vide. Couleurs : vert, **jaune dès 60 %**, **rouge dès 85 %** ; carrés vides gris. Si la barre dépasse la largeur du terminal, l'échelle grandit par paliers de 10 000 et est indiquée (`1■ = 20k`). Format des nombres : `950`, `12.3k`, `200k`, `1.05M`. Sous la zone de saisie, en bas à droite : carrés ■ de gauche à droite, **1 carré = 10 000 tokens** (200 000 → 20 carrés), libellé `45.7k / 200k · 23%`. Au moins 1 carré rempli dès que le contexte n'est pas vide. Couleurs : vert, **jaune dès 60 %**, **rouge dès 85 %** ; carrés vides gris. Si la barre dépasse la largeur du terminal, l'échelle grandit par paliers de 10 000 et est indiquée (`1■ = 20k`). Format des nombres : `950`, `12.3k`, `200k`, `1.05M`.
Pendant un tour, `runTurn` appelle `onContextUsage` après l'ajout de la demande, après chaque réponse du LLM et après chaque série de résultats d'outils ; la barre est affichée en permanence en bas de l'écran (saisie, itérations, outils, confirmations, listes) ; taille maximale inconnue : « N tokens · taille maximale inconnue », sans carrés.
## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`) ## 14. Compactage (`core/src/compact.ts` + `cli/src/index.ts`)
### 14.1 Principe ### 14.1 Principe
@ -469,6 +471,10 @@ En cas d'erreur ou de Ctrl+C **avant** le remplacement, la conversation est inch
Le fichier d'avant compactage reste intact. Le fichier `xxx__n` contient `startContext` (le résumé) et la suite de la conversation ; il est écrit **immédiatement** (une session avec `startContext` n'est pas « vide »). `/load xxx__n` reconstruit l'agent avec ce contexte de départ et affiche le résumé. Le fichier d'avant compactage reste intact. Le fichier `xxx__n` contient `startContext` (le résumé) et la suite de la conversation ; il est écrit **immédiatement** (une session avec `startContext` n'est pas « vide »). `/load xxx__n` reconstruit l'agent avec ce contexte de départ et affiche le résumé.
### 14.5 Contexte refusé par le serveur
`isContextTooLongError(err)` reconnaît les erreurs « input is too long », « prompt is too long », « context_length_exceeded », « maximum context length »… Dans la boucle de `cli/src/index.ts`, hors interruption et une seule fois par demande : avertissement, `compactNow({ emergency: true })` (la demande de résumé utilise `shrinkToolResults(messages, EMERGENCY_TOOL_RESULT_CHARS = 1500)` pour tenir dans la fenêtre ; la session d'origine est sauvegardée avant), puis la demande est relancée avec une consigne de reprise sur la conversation compactée. Si le compactage échoue, l'erreur d'origine est affichée. Non couvert : sous-agents.
## 15. Interface (`packages/ui`) ## 15. Interface (`packages/ui`)
### 15.1 Architecture ### 15.1 Architecture
@ -506,6 +512,8 @@ Image 32 × 32 en **couleurs réelles** (sans pixelisation ni réduction de pale
| Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter | | Saisie | Entrée : envoyer · ←/→, Ctrl+A/E : déplacement · ↑/↓ : historique · Ctrl+U/W : effacer · Ctrl+C ou Ctrl+D (saisie vide) : quitter |
| Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande | | Pendant un tour | Ctrl+C : interrompt et restaure la conversation d'avant la demande |
La zone de saisie (cadre en pointillés) est toujours à l'écran : saisie active, grisée « Traitement en cours » pendant un tour (masquée à l'arrêt du programme), ou demande d'autorisation `ASK … (o/n)` (réponse `o`/`y` ou `n`/Échap/Ctrl+C). Les listes de choix (`/model`, `/load`) remplacent la zone le temps du choix. Au-dessus, une ligne d'état est toujours réservée (spinner « En réflexion », outils en cours ; vide sinon) : la zone de saisie garde la même position. La barre de contexte est toujours en dessous.
## 16. Fichiers et variables ## 16. Fichiers et variables
| Chemin / variable | Rôle | | Chemin / variable | Rôle |

View file

@ -1,33 +0,0 @@
# Les Mystères de l'Univers Numérique
## Architecture Décentralisée et Résilience des Systèmes
Dans le vaste paysage technologique moderne, les systèmes décentralisés représentent une avancée majeure. Ils permettent une meilleure distribution des charges et une résilience accrue face aux défaillances.
## Innovation Quantique et Calcul de Demain
La révolution quantique approche. Les ordinateurs quantiques promettent de résoudre des problèmes complexes en temps polynomial, transformant complètement nos capacités de calcul actuelles.
## Sécurité Cryptographique et Protection des Données
La cryptographie post-quantique devient essentielle. Avec l'émergence des ordinateurs quantiques, les algorithmes classiques pourraient être compromis, d'où la nécessité de nouvelles approches.
## Intelligence Artificielle et Apprentissage Profond
Les réseaux de neurones profonds continuent d'évoluer. L'IA générative ouvre des perspectives inédites dans la création, l'automatisation et l'assistance intelligente.
## Durabilité Écologique du Développement Logiciel
La conscience écologique grandit dans l'industrie tech. Optimiser les algorithmes et réduire la consommation énergétique devient une priorité stratégique.
## Collaboration Distribuée et Télétravail Technologique
Les outils collaboratifs modernes permettent des équipes vraiment distribuées. Le télétravail en tech s'accompagne d'une infrastructure logicielle sophistiquée et intuitive.
## Automatisation DevOps et Intégration Continue
Le DevOps transforme la façon dont nous livrons les applications. L'intégration continue et le déploiement continu deviennent la norme industrielle incontournable.
## Microservices et Architecture Modulaire
L'approche microservices rompt avec les monolithes. Elle offre une flexibilité accrue, une évolutivité meilleure et une maintenance simplifiée pour les grandes applications.

View file

@ -8,7 +8,9 @@ import {
createSession, createSession,
ensureMaiaHome, ensureMaiaHome,
findSkill, findSkill,
EMERGENCY_TOOL_RESULT_CHARS,
isCompactable, isCompactable,
isContextTooLongError,
normalizeSessionName, normalizeSessionName,
parseShellInvocation, parseShellInvocation,
resolveFileReferences, resolveFileReferences,
@ -327,7 +329,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
* 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ; * 3. la conversation est vidée comme avec /clear, mais le résumé remplace MAIA.md / CLAUDE.md comme contexte de départ ;
* 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée. * 4. la session continue sous le nom « {nom d'origine}__{n} » et la barre de progression est recalculée.
*/ */
const compactNow = async (): Promise<boolean> => { const compactNow = async (opts: { emergency?: boolean } = {}): Promise<boolean> => {
if (!isCompactable(session)) { if (!isCompactable(session)) {
ui.info("Rien à compacter : la conversation est vide."); ui.info("Rien à compacter : la conversation est vide.");
return false; return false;
@ -337,7 +339,12 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
ui.setBusy("Compactage de la conversation…"); ui.setBusy("Compactage de la conversation…");
try { try {
saveSession(); // la session d'origine reste complète dans son fichier saveSession(); // la session d'origine reste complète dans son fichier
const { summary, truncated } = await requestCompactSummary(session, controller.signal); const { summary, truncated } = await requestCompactSummary(
session,
controller.signal,
// Urgence (le serveur a refusé le contexte) : la demande de résumé doit elle-même tenir dans la fenêtre
opts.emergency ? { shrinkToolResults: EMERGENCY_TOOL_RESULT_CHARS } : {},
);
const fresh = await startSession(modelSpec, { startContext: summary, quiet: true }); const fresh = await startSession(modelSpec, { startContext: summary, quiet: true });
session = fresh; session = fresh;
let target = ""; let target = "";
@ -583,7 +590,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
await autoCompactIfNeeded(); await autoCompactIfNeeded();
// Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation // Ctrl+C pendant le tour : annule la requête en cours et restaure la conversation
const mark = session.messages.length; let mark = session.messages.length;
const contextBackup = [...session.pendingContext]; const contextBackup = [...session.pendingContext];
// Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que // Références @fichier (communes à tous les agents) : le contenu des fichiers cités dans ce que
@ -601,20 +608,39 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
} }
const controller = new AbortController(); const controller = new AbortController();
ui.setInterruptHandler(() => controller.abort()); ui.setInterruptHandler(() => controller.abort());
let attempt = prompt;
let retried = false;
for (;;) {
try { try {
await runTurn(session, prompt, controller.signal); await runTurn(session, attempt, controller.signal);
break;
} catch (err) { } catch (err) {
if (controller.signal.aborted) { if (controller.signal.aborted) {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat) session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
showContext(session); showContext(session);
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
} else { break;
}
if (!retried && isContextTooLongError(err)) {
// Le serveur refuse le contexte (notre comptage local sous-estime) : compactage, puis reprise de la demande une seule fois
retried = true;
ui.warn("Le serveur indique que le contexte est trop long : compactage automatique, puis reprise de la demande.");
const compacted = await compactNow({ emergency: true });
ui.setInterruptHandler(() => controller.abort()); // compactNow a retiré le gestionnaire Ctrl+C
if (compacted) {
mark = 0; // nouvelle conversation (résumé comme contexte de départ)
attempt =
"Le contexte a été compacté parce qu'il dépassait la fenêtre du modèle. Le résumé de départ indique ce qui est déjà fait. " +
`Reprends là où tu t'es arrêté et termine la demande suivante :\n\n${prompt}`;
continue;
}
}
ui.error((err as Error).message); ui.error((err as Error).message);
break;
}
} }
} finally {
ui.setInterruptHandler(null); ui.setInterruptHandler(null);
}
// Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande // Le tour a pu faire franchir le seuil : on compacte tout de suite, sans attendre la prochaine demande
await autoCompactIfNeeded(); await autoCompactIfNeeded();
} }

View file

@ -182,11 +182,12 @@ describe("taille du contexte", () => {
cwd: process.cwd(), cwd: process.cwd(),
}); });
await runTurn(session, "go"); await runTurn(session, "go");
expect(seen).toHaveLength(2); // une mise à jour par réponse du LLM (appel d'outil, puis réponse finale) // mise à jour à chaque étape : demande, réponse avec appel d'outil, résultat de l'outil, réponse finale
expect(seen).toHaveLength(4);
expect(seen[0]).toBeGreaterThan(0); expect(seen[0]).toBeGreaterThan(0);
expect(seen[1]).toBeGreaterThan(seen[0]!); for (let i = 1; i < seen.length; i++) expect(seen[i]!).toBeGreaterThan(seen[i - 1]!);
expect(seen[1]).toBe(sessionContextTokens(session)); expect(seen.at(-1)).toBe(sessionContextTokens(session));
expect(seen[1]).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée expect(seen.at(-1)!).toBeLessThan(100_000); // la consommation du serveur (≈ 990 000) est ignorée
}); });
it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => { it("le contexte en attente (commandes « ! ») est compté dans la taille de la session", () => {

View file

@ -75,6 +75,7 @@ export async function runTurn(
const context = session.pendingContext.splice(0); const context = session.pendingContext.splice(0);
const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText; const text = context.length > 0 ? `${context.join("\n\n")}\n\n${userText}` : userText;
session.messages.push({ role: "user", content: [{ type: "text", text }] }); session.messages.push({ role: "user", content: [{ type: "text", text }] });
ui.onContextUsage?.(sessionContextTokens(session)); // la demande (et le contexte joint) occupe déjà des tokens
for (let i = 0; i < maxIterations; i++) { for (let i = 0; i < maxIterations; i++) {
const assistant: ContentBlock[] = []; const assistant: ContentBlock[] = [];
@ -138,6 +139,7 @@ export async function runTurn(
// 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins // 1 appel : premier plan ; 2 appels ou plus : une tâche de fond par outil, on attend toutes les fins
const results = await runToolCalls(session, calls, signal); const results = await runToolCalls(session, calls, signal);
session.messages.push({ role: "user", content: results }); session.messages.push({ role: "user", content: results });
ui.onContextUsage?.(sessionContextTokens(session)); // les résultats d'outils pèsent avant l'itération suivante
} }
session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true); session.ui.onToolResult("agent", `Arrêt : plus de ${maxIterations} itérations`, true);
return "max_iterations"; return "max_iterations";

View file

@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createSession } from "./agent"; import { createSession } from "./agent";
import { COMPACT_PROMPT, compactThreshold, isCompactable, requestCompactSummary, shouldCompact } from "./compact"; import { COMPACT_PROMPT, compactThreshold, isCompactable, isContextTooLongError, requestCompactSummary, shouldCompact, shrinkToolResults } from "./compact";
import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions"; import { SessionManager, SessionStore, ensureMaiaHome, nextCompactedName } from "./sessions";
import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types"; import type { AgentUI, ChatRequest, LLMProvider, Message, StreamEvent } from "./types";
@ -190,3 +190,37 @@ describe("persistance d'une session compactée", () => {
expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false); expect(mgr.isEmpty({ messages: [], pendingContext: [], startContext: "r" })).toBe(false);
}); });
}); });
describe("contexte refusé par le serveur", () => {
it("reconnaît les messages « entrée trop longue » des principaux serveurs", () => {
const msg = 'Échec (HTTP 400). {"error":{"code":"UPSTREAM_LLM_ERROR","message":"{\\"message\\":\\"Input is too long for requested model.\\"}"}}';
expect(isContextTooLongError(new Error(msg))).toBe(true);
expect(isContextTooLongError(new Error("prompt is too long: 250000 tokens > 200000 maximum"))).toBe(true);
expect(isContextTooLongError(new Error("This model's maximum context length is 128000 tokens"))).toBe(true);
expect(isContextTooLongError(new Error("context_length_exceeded"))).toBe(true);
expect(isContextTooLongError(new Error("HTTP 401 Unauthorized"))).toBe(false);
});
it("shrinkToolResults : tronque les gros résultats d'outils sans toucher aux originaux", () => {
const big = "x".repeat(5000);
const original: Message[] = [
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: big }, { type: "text", text: "garde" }] },
];
const out = shrinkToolResults(original, 100);
const block = out[0]!.content[0] as { content: string };
expect(block.content.length).toBeLessThan(200);
expect(block.content).toContain("tronqué");
expect(out[0]!.content[1]).toEqual({ type: "text", text: "garde" });
expect((original[0]!.content[0] as { content: string }).content).toBe(big);
});
it("requestCompactSummary en urgence : la requête de résumé contient des résultats tronqués", async () => {
const seen: ChatRequest[] = [];
const s = sessionWith(providerReplying([{ type: "text_delta", text: "## R" }, { type: "done", stopReason: "end_turn" }], seen), [
user("q"),
{ role: "assistant", content: [{ type: "tool_use", id: "1", name: "Read", input: {} }] },
{ role: "user", content: [{ type: "tool_result", toolUseId: "1", content: "y".repeat(9000) }] },
]);
await requestCompactSummary(s, undefined, { shrinkToolResults: 500 });
expect(JSON.stringify(seen[0]!.messages).length).toBeLessThan(3000);
expect((s.messages[2]!.content[0] as { content: string }).content.length).toBe(9000);
});
});

View file

@ -1,5 +1,5 @@
import type { AgentSession } from "./agent"; import type { AgentSession } from "./agent";
import type { Message } from "./types"; import type { ContentBlock, Message } from "./types";
// Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ // Compactage : résumer toute la conversation de la session, puis repartir d'un contexte vide dont le point de départ
// est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ; // est ce résumé (au lieu de MAIA.md / CLAUDE.md). Le cœur ne fait que demander et valider le résumé ;
@ -51,15 +51,41 @@ export function isCompactable(session: Pick<AgentSession, "messages" | "pendingC
return session.messages.length > 0 || session.pendingContext.length > 0; return session.messages.length > 0 || session.pendingContext.length > 0;
} }
/** Réponse du serveur « entrée trop longue » (HTTP 400 : Anthropic/Bedrock, OpenAI, LiteLLM…) : le contexte dépasse la fenêtre du modèle. */
export function isContextTooLongError(err: unknown): boolean {
const text = err instanceof Error ? err.message : String(err);
return /input is too long|prompt is too long|too long for requested model|context[_ ]length[_ ]exceeded|maximum context length|exceeds? the (maximum )?context|context window/i.test(text);
}
/** Nombre de caractères conservés par résultat d'outil quand le compactage est lancé en urgence (contexte déjà saturé). */
export const EMERGENCY_TOOL_RESULT_CHARS = 1500;
/** Copie des messages dont les résultats d'outils sont tronqués à `max` caractères (les originaux ne sont pas modifiés). */
export function shrinkToolResults(messages: Message[], max: number): Message[] {
return messages.map((m) => ({
...m,
content: m.content.map((b): ContentBlock =>
b.type === "tool_result" && b.content.length > max
? { ...b, content: `${b.content.slice(0, max)}… (tronqué pour le compactage)` }
: b,
),
}));
}
/** /**
* Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée : * Demande au LLM le résumé compacté de la conversation (appel bloquant). La session n'est PAS modifiée :
* c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu). * c'est à l'appelant de bâtir la nouvelle session une fois le résumé obtenu (en cas d'erreur ou d'annulation, rien n'est perdu).
* Lève une erreur si le résumé est vide. * Lève une erreur si le résumé est vide.
*/ */
export async function requestCompactSummary(session: AgentSession, signal?: AbortSignal): Promise<CompactSummary> { export async function requestCompactSummary(
session: AgentSession,
signal?: AbortSignal,
opts: { shrinkToolResults?: number } = {},
): Promise<CompactSummary> {
// Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer // Le contexte en attente (commandes « ! », fichiers cités) fait partie de la conversation à résumer
const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n"); const request = [...session.pendingContext, COMPACT_PROMPT].join("\n\n");
const messages: Message[] = [...session.messages]; const messages: Message[] =
opts.shrinkToolResults !== undefined ? shrinkToolResults(session.messages, opts.shrinkToolResults) : [...session.messages];
const last = messages.at(-1); const last = messages.at(-1);
if (last?.role === "user") { if (last?.role === "user") {
// L'alternance user/assistant doit être respectée : on complète le dernier message // L'alternance user/assistant doit être respectée : on complète le dernier message

View file

@ -184,6 +184,15 @@ const DOTTED_BORDER = {
right: "┊", right: "┊",
} as const; } as const;
/** Zone de saisie (cadre en pointillés) : toujours visible ; sert aussi aux demandes d'autorisation et à l'attente. */
function InputFrame({ color, children }: { color: string; children: React.ReactNode }) {
return (
<Box marginTop={1} borderStyle={DOTTED_BORDER} borderColor={color} borderLeft={false} borderRight={false} paddingX={1}>
{children}
</Box>
);
}
const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter"; const PLACEHOLDER = "Votre demande — /help : aide · § : skills · Ctrl+C : quitter";
function InputPrompt({ prompt }: { prompt: InputPromptState }) { function InputPrompt({ prompt }: { prompt: InputPromptState }) {
@ -254,14 +263,7 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
const { value, cursor } = view; const { value, cursor } = view;
const at = value[cursor] ?? " "; const at = value[cursor] ?? " ";
return ( return (
<Box <InputFrame color="greenBright">
marginTop={1}
borderStyle={DOTTED_BORDER}
borderColor="greenBright"
borderLeft={false}
borderRight={false}
paddingX={1}
>
<Text color={INPUT_COLOR} bold> <Text color={INPUT_COLOR} bold>
{`${frame} `} {`${frame} `}
</Text> </Text>
@ -278,7 +280,16 @@ function InputPrompt({ prompt }: { prompt: InputPromptState }) {
{value.slice(cursor + 1)} {value.slice(cursor + 1)}
</Text> </Text>
)} )}
</Box> </InputFrame>
);
}
/** Pendant le travail de l'agent : la zone de saisie reste là, grisée (la saisie reprend à la fin du tour). */
function WaitingInput() {
return (
<InputFrame color="gray">
<Text dimColor>{"… Traitement en cours — Ctrl+C : interrompre"}</Text>
</InputFrame>
); );
} }
@ -338,15 +349,16 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false); else if (input === "n" || input === "N" || key.escape || (key.ctrl && input === "c")) prompt.resolve(false);
// Entrée seule est ignorée : une action sensible exige une réponse explicite. // Entrée seule est ignorée : une action sensible exige une réponse explicite.
}); });
// La demande d'autorisation prend la place de la zone de saisie
return ( return (
<Box marginTop={1}> <InputFrame color="yellow">
<Box marginRight={1} flexShrink={0}> <Box marginRight={1} flexShrink={0}>
<Badge color="green">ASK</Badge> <Badge color="green">ASK</Badge>
</Box> </Box>
<Text> <Text>
{prompt.message} <Text dimColor>(o/n)</Text> {prompt.message} <Text dimColor>(o/n)</Text>
</Text> </Text>
</Box> </InputFrame>
); );
} }
@ -356,7 +368,6 @@ function PromptView({ prompt, state }: { prompt: PromptState; state: UIState })
return ( return (
<Box flexDirection="column"> <Box flexDirection="column">
<InputPrompt prompt={prompt} /> <InputPrompt prompt={prompt} />
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} /> : null}
</Box> </Box>
); );
case "select": case "select":
@ -394,12 +405,13 @@ export function App({ store }: { store: UIStore }) {
<Text>{state.streaming}</Text> <Text>{state.streaming}</Text>
</Box> </Box>
) : null} ) : null}
{state.busy ? ( {/* Ligne d'état réservée : toujours présente (vide hors traitement) pour que la zone de saisie ne bouge jamais */}
<Box marginTop={1}> <Box marginTop={1} height={1}>
<Spinner label={state.busy} /> {state.busy ? <Spinner label={state.busy} /> : <Text> </Text>}
</Box> </Box>
) : null} {state.prompt ? <PromptView prompt={state.prompt} state={state} /> : state.closing ? null : <WaitingInput />}
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : null} {/* Toujours affichée, tout en bas : saisie, itérations, outils, confirmations, listes (taille maximale inconnue : tokens seuls) */}
<ContextBar used={state.contextUsed} max={state.contextMax} />
</> </>
); );
} }

View file

@ -6,8 +6,16 @@ import { contextBarModel, formatTokens } from "./context-model";
const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const; const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
const EMPTY_COLOR = "#555555"; const EMPTY_COLOR = "#555555";
export function ContextBar({ used, max }: { used: number; max: number }) { export function ContextBar({ used, max }: { used: number; max: number | null }) {
const { stdout } = useStdout(); const { stdout } = useStdout();
if (max === null) {
// Taille maximale inconnue : pas de carrés, seulement le contexte utilisé
return (
<Box justifyContent="flex-end" paddingRight={1}>
<Text dimColor>{`${formatTokens(used)} tokens · taille maximale inconnue`}</Text>
</Box>
);
}
const columns = stdout?.columns ?? 80; const columns = stdout?.columns ?? 80;
const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100); const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `; const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;

View file

@ -24,6 +24,7 @@ export class InkUI implements AgentUI {
const app = this.app; const app = this.app;
if (!app) return; if (!app) return;
this.app = null; this.app = null;
this.store.setClosing();
await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages await new Promise((resolve) => setTimeout(resolve, 100)); // laisse Ink afficher les derniers messages
const exited = app.waitUntilExit(); // à créer AVANT unmount() const exited = app.waitUntilExit(); // à créer AVANT unmount()
app.unmount(); app.unmount();

View file

@ -78,13 +78,15 @@ export interface UIState {
contextUsed: number; contextUsed: number;
/** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */ /** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */
contextMax: number | null; contextMax: number | null;
/** Arrêt du programme en cours : la zone de saisie grisée n'est plus affichée. */
closing: boolean;
} }
const RESULT_PREVIEW_MAX = 600; const RESULT_PREVIEW_MAX = 600;
const SHELL_PREVIEW_MAX = 4000; const SHELL_PREVIEW_MAX = 4000;
export class UIStore { export class UIStore {
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null }; private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null, closing: false };
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private nextId = 1; private nextId = 1;
private history: string[] = []; private history: string[] = [];
@ -99,6 +101,10 @@ export class UIStore {
}; };
}; };
setClosing(): void {
this.commit({ closing: true });
}
private commit(patch: Partial<UIState>): void { private commit(patch: Partial<UIState>): void {
this.state = { ...this.state, ...patch }; this.state = { ...this.state, ...patch };
for (const l of this.listeners) l(); for (const l of this.listeners) l();

View file

@ -1,4 +1,2 @@
packages: packages:
- "packages/*" - "packages/*"
allowBuilds:
esbuild: true