diff --git a/.gitignore b/.gitignore index abfbf3d..2be012b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ dist *.log .env .maia -generated_page.md diff --git a/README.md b/README.md index 90182d8..4fffa52 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, ` ce qui rend la logique testable (`store.test.ts`). Les composants (saisie, liste, confirmation, spinner) sont écrits à la main ; seuls les badges viennent de [`@inkjs/ui`](https://github.com/vadimdemedes/ink-ui). Un terminal interactif (TTY) est requis. -Présentation : la bannière d'accueil reprend le style « MAIA » : masque pixel-art à gauche (demi-blocs ▀▄█, 20 × 20 pixels), titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 46 colonnes pour la version complète ; entre 25 et 45 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ; +Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ; une ligne vide sépare chaque message. Messages du journal, avec le composant `Badge` de `@inkjs/ui` : @@ -80,8 +80,14 @@ Messages du journal, avec le composant `Badge` de `@inkjs/ui` : | `FAIL` (rouge) | erreur | `#DC8ADD` (`ERROR_TEXT`) | | `ASK` (vert) | question posée à l'utilisateur (liste de choix, confirmation o/n), à gauche de la question | gras | +Les réponses de l'agent sont rendues en markdown (titres, listes, code, tableaux, gras…) avec [`markdansi`](https://github.com/steipete/markdansi). Tout est isolé dans `packages/ui/src/markdown.tsx` : pour le retirer, supprimer ce fichier, la dépendance `markdansi` et remplacer `` par `` dans `components.tsx`. Le texte en cours de streaming reste brut ; la mise en forme apparaît quand la réponse est terminée. `MAIA_NO_MARKDOWN=1` désactive le rendu sans toucher au code, et un échec de `markdansi` retombe sur le texte brut. + Les couleurs sont dans `packages/ui/src/colors.ts`. Une fois la réponse validée, la question est remplacée par la ligne `◆ Question : choix` dans le journal. +Barre de contexte : sous la zone de saisie, en bas à droite, une barre de carrés ■ (de gauche à droite) montre la taille du contexte de la conversation par rapport à la taille maximale du modèle, à l'échelle **1 carré = 10 000 tokens** (200 000 tokens → 20 carrés), avec le libellé « 45.7k / 200k · 23% ». Les carrés sont verts, jaunes à partir de 60 %, rouges à partir de 85 %. Si la barre ne tient pas dans le terminal (très grands contextes), l'échelle est agrandie par paliers de 10 000 et indiquée (`1■ = 20k`). +- **Taille maximale** : après le choix du modèle (au démarrage, avec `--model`/config, et avec `/model`), MAIA demande au LLM « Taille maximale du contexte pour ce modèle » (réponse en tokens, lue par `parseTokenCount` : `128000`, `128 000 tokens`, `200k`, `1,5 million`…). La valeur est annoncée (INFO) ; une réponse par modèle et par exécution. Si la réponse est illisible, ou si la question échoue ou dépasse 60 s (Ctrl+C l'abandonne), un avertissement s'affiche et la barre est masquée. Un LLM peut se tromper sur sa propre limite : la valeur affichée en INFO permet de la vérifier. +- **Contexte utilisé** : mis à jour après chaque réponse du LLM (donc après chaque appel d'outil intermédiaire) avec la consommation réelle renvoyée par LiteLLM (`stream_options.include_usage` : tokens de la requête + de la réponse). Sans consommation renvoyée, une estimation (≈ 4 caractères par token : prompt système, outils, messages) est utilisée. L'estimation sert aussi au démarrage, après `/clear`, `/new`, `/load` et après un tour interrompu. + La zone de saisie est encadrée par une bordure en pointillés (┈) **au-dessus et au-dessous** seulement, en `greenBright`, sans bords gauche ni droit. À sa gauche, le spinner `arrow3` de [cli-spinners](https://github.com/sindresorhus/cli-spinners) s'anime en continu. Pendant l'attente de la réponse après validation de la saisie, « En réflexion » s'affiche avec le spinner `dots10` à sa gauche, en `yellowBright` (même spinner pour les autres attentes : chargement des modèles, exécution d'une commande `!`). @@ -267,6 +273,7 @@ Exemple pour éviter les confirmations sur les outils en lecture seule : voir `s - Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé). - `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie. - Liste à cocher (choix multiples) : n'existe pas encore ; elle recevrait le badge `ASK`. +- Taille de contexte : lue en interrogeant le LLM (demande de l'utilisateur) ; la route `/model/info` de LiteLLM (`max_input_tokens`) serait plus fiable, et un réglage manuel dans `settings.json` pourrait corriger une réponse erronée. - Nerd Fonts (icônes) : proposé comme réglage optionnel dans `settings.json`, non réalisé. - Tests : les suites vitest et le typecheck sont à lancer après `pnpm install` (écrits sans avoir pu être exécutés dans l'environnement de développement). @@ -294,3 +301,6 @@ Journal des évolutions, dans l'ordre : 18. **`/clear`** : supprime le contexte et recharge `MAIA.md` / `CLAUDE.md` (et les skills) comme au démarrage ; `/new` fait de même. 19. **Permissions granulaires** : règles `allow` / `ask` / `deny` par outil (`Outil` ou `Outil(motif)`), évaluées dans l'ordre deny > ask > allow, `ask` par défaut ; définies dans `~/.maia/settings.json` et `./.maia/settings.json` (pas le dossier parent). 20. **`/permissions`** : affiche les permissions chargées, par fichier et par type (badges ALLOW / ASK / DENY) ; les fichiers sont listés l'un sous l'autre au démarrage. +21. **Rendu markdown** des réponses de l'agent via `markdansi`, dans un seul composant isolé (`packages/ui/src/markdown.tsx`), désactivable avec `MAIA_NO_MARKDOWN=1`. +22. **Image de la bannière** : nouvelle image (visage à couronne), fond retiré, réduite à 32 × 32 en couleurs réelles (sans pixelisation ni réduction de palette), centrée verticalement à côté du titre ; données générées par `packages/ui/scripts/make-mask.py`. +23. **Barre de progression du contexte** sous la zone de saisie (1 carré = 10 000 tokens) : taille maximale demandée au LLM après la sélection du modèle, contexte utilisé mis à jour après chaque réponse (consommation renvoyée par LiteLLM, sinon estimation) ; correction d'un import de type manquant dans `ink-ui.tsx`. diff --git a/generated_page.md b/generated_page.md new file mode 100644 index 0000000..1ade067 --- /dev/null +++ b/generated_page.md @@ -0,0 +1,33 @@ +# 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. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c019e58..98d0a08 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,11 +6,13 @@ import { SessionStore, createSession, ensureMaiaHome, + estimateSessionTokens, findSkill, normalizeSessionName, parseShellInvocation, resolveFileReferences, parseSkillInvocation, + queryContextWindow, runTurn, type AgentSession, type Message, @@ -24,6 +26,7 @@ import { runUserShell } from "./user-shell"; const REPLAY_RESULT_MAX = 600; const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints +const CONTEXT_QUERY_TIMEOUT_MS = 60_000; // question « taille maximale du contexte » posée au LLM /** Réaffiche le contenu d'une session rechargée (échanges, appels d'outils, résultats tronqués). */ function replayMessages(ui: InkUI, session: AgentSession, messages: Message[]): void { @@ -152,7 +155,51 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { log: (m) => ui.info(m), warn: (m) => ui.warn(m), }); - return createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd }); + const created = createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd }); + refreshContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens + return created; + }; + + /** Recalcule (estimation) la taille du contexte et met à jour la barre : nouvelle conversation, session rechargée, tour interrompu. */ + const refreshContext = (target: AgentSession): void => { + target.contextTokens = estimateSessionTokens(target); + ui.onContextUsage(target.contextTokens); + }; + + // Tailles maximales de contexte déjà demandées au LLM (une seule question par modèle et par exécution) + const contextWindows = new Map(); + + /** + * Après la sélection d'un modèle : demande au LLM la taille maximale de son contexte (en tokens) et la donne à la barre + * de progression. Si la réponse est introuvable ou illisible, la barre est masquée (l'application continue). + */ + const resolveContextWindow = async (spec: string): Promise => { + let max = contextWindows.get(spec) ?? null; + if (max === null) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CONTEXT_QUERY_TIMEOUT_MS); + ui.setInterruptHandler(() => controller.abort()); // Ctrl+C abandonne la question + ui.setBusy("Recherche de la taille maximale du contexte…"); + try { + const { tokens, answer } = await queryContextWindow(createProvider(spec, settings), controller.signal); + if (tokens === null) { + const shown = answer.length > 120 ? `${answer.slice(0, 120)}…` : answer; + ui.warn(`Taille maximale du contexte non déterminée (réponse du modèle : « ${shown} »). Barre de progression masquée.`); + } else { + max = tokens; + contextWindows.set(spec, tokens); + ui.info(`Taille maximale du contexte : ${tokens.toLocaleString("fr-FR")} tokens (réponse du modèle)`); + } + } catch (err) { + const why = controller.signal.aborted ? "question abandonnée (délai dépassé ou Ctrl+C)" : (err as Error).message; + ui.warn(`Taille maximale du contexte indisponible : ${why}. Barre de progression masquée.`); + } finally { + clearTimeout(timer); + ui.setBusy(null); + ui.setInterruptHandler(null); + } + } + ui.setContextMax(max); }; ui.intro("Maia"); @@ -170,8 +217,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { } // 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur - let modelSpec = config.model; - if (!modelSpec) { + let modelSpec: string; + if (config.model) { + modelSpec = config.model; + } else { const picked = await pickModel(); if (!picked) { ui.outro("Aucun modèle choisi"); @@ -190,6 +239,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { return; } ui.info(`Modèle : ${modelSpec}`); + await resolveContextWindow(modelSpec); // Sauvegarde de la session courante (silencieuse si elle est vide ou inchangée). Retourne son nom si écrite. const saveSession = (): string | null => { @@ -351,6 +401,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { session.pendingContext.length = 0; session.pendingContext.push(...saved.pendingContext); sessions.restore(saved); + refreshContext(session); ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`); if (session.pendingContext.length > 0) { ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`); @@ -378,6 +429,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { session.provider = createProvider(choice, settings); modelSpec = choice; ui.info(`Modèle actif : ${modelSpec}`); + await resolveContextWindow(modelSpec); } catch (err) { ui.error((err as Error).message); } @@ -418,6 +470,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise { if (controller.signal.aborted) { 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 + refreshContext(session); ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); } else { ui.error((err as Error).message); diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 412a7e4..b9662cc 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -122,3 +122,56 @@ describe("contexte en attente", () => { expect(session.pendingContext).toEqual([]); }); }); + +describe("taille du contexte", () => { + const echo: Tool = { + spec: { name: "echo", description: "echo", inputSchema: { type: "object" } }, + permission: "auto", + execute: async () => "ok", + }; + + it("utilise la consommation renvoyée par le serveur après chaque réponse", async () => { + const seen: number[] = []; + let call = 0; + const provider: LLMProvider = { + id: "fake", + model: "fake", + capabilities: { tools: true }, + async *chat(): AsyncIterable { + call++; + if (call === 1) { + yield { type: "tool_use", id: "t1", name: "echo", input: {} }; + yield { type: "usage", inputTokens: 1000, outputTokens: 50 }; + } else { + yield { type: "text_delta", text: "fin" }; + yield { type: "usage", inputTokens: 1200, outputTokens: 30 }; + } + yield { type: "done", stopReason: call === 1 ? "tool_use" : "end_turn" }; + }, + }; + const session = createSession({ + provider, + init: { systemPrompt: "t", tools: [echo] }, + ui: { ...silentUI, onContextUsage: (n) => seen.push(n) }, + cwd: process.cwd(), + }); + await runTurn(session, "go"); + expect(seen).toEqual([1050, 1230]); + expect(session.contextTokens).toBe(1230); + }); + + it("sans consommation renvoyée : estimation à partir de la conversation", async () => { + const seen: number[] = []; + const session = createSession({ + provider: fakeProvider("echo"), + init: { systemPrompt: "x".repeat(4000), tools: [echo] }, + ui: { ...silentUI, onContextUsage: (n) => seen.push(n) }, + cwd: process.cwd(), + }); + await runTurn(session, "go"); + expect(seen).toHaveLength(2); + expect(seen[0]).toBeGreaterThanOrEqual(1000); // prompt système de 4000 caractères + expect(seen[1]).toBeGreaterThan(seen[0]!); + expect(session.contextTokens).toBe(seen[1]); + }); +}); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7a448d2..678afc6 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,3 +1,4 @@ +import { estimateTokens } from "./context"; import type { AgentSkill, AgentUI, @@ -22,6 +23,8 @@ export interface AgentSession { permissionSources: PermissionSource[]; env?: Record; messages: Message[]; + /** Taille du contexte (tokens) après la dernière réponse du LLM : valeur renvoyée par le serveur, sinon estimation. */ + contextTokens: number; /** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */ pendingContext: string[]; ui: AgentUI; @@ -41,9 +44,10 @@ export function createSession(opts: { commands: opts.init.commands ?? [], skills: opts.init.skills ?? [], permissions: opts.init.permissions, - permissionSources: opts.init.permissionSources ?? [], + permissionSources: opts.init.permissionSources ?? [], env: opts.init.env, messages: [], + contextTokens: 0, pendingContext: [], ui: opts.ui, cwd: opts.cwd, @@ -69,6 +73,7 @@ export async function runTurn( for (let i = 0; i < MAX_ITERATIONS; i++) { const assistant: ContentBlock[] = []; let text = ""; + let usedTokens: number | null = null; ui.onThinking(true); let first = true; @@ -90,6 +95,9 @@ export async function runTurn( ui.onText(ev.text); } else if (ev.type === "tool_use") { assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input }); + } else if (ev.type === "usage") { + // Requête + réponse : c'est la taille du contexte une fois la réponse ajoutée à la conversation + usedTokens = ev.inputTokens + ev.outputTokens; } // "done" : la fin du flux suffit, les appels d'outils décident de la suite } @@ -102,6 +110,8 @@ export async function runTurn( assistant.unshift({ type: "text", text }); } session.messages.push({ role: "assistant", content: assistant }); + session.contextTokens = usedTokens ?? estimateSessionTokens(session); + ui.onContextUsage?.(session.contextTokens); const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use"); if (calls.length === 0) return; @@ -161,3 +171,8 @@ async function executeToolCall( return result(msg, true); } } + +/** Estimation de la taille du contexte d'une session (sans réponse du serveur) : prompt système, outils et messages. */ +export function estimateSessionTokens(session: Pick): number { + return estimateTokens(session.system, session.messages, [...session.tools.values()].map((t) => t.spec)); +} diff --git a/packages/core/src/context.test.ts b/packages/core/src/context.test.ts new file mode 100644 index 0000000..b78e610 --- /dev/null +++ b/packages/core/src/context.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { CONTEXT_WINDOW_QUESTION, estimateTokens, parseTokenCount, queryContextWindow } from "./context"; +import type { LLMProvider, StreamEvent } from "./types"; + +describe("parseTokenCount", () => { + it.each([ + ["128000", 128_000], + ["128 000", 128_000], + ["128 000 tokens", 128_000], + ["131,072", 131_072], + ["1.000.000", 1_000_000], + ["200k", 200_000], + ["200K tokens", 200_000], + ["1M", 1_000_000], + ["1,5 million de tokens", 1_500_000], + ["2 millions", 2_000_000], + ["1.5M", 1_500_000], + ["Environ 128 000 tokens.", 128_000], + ])("%s → %d", (text, expected) => { + expect(parseTokenCount(text)).toBe(expected); + }); + + it("ignore les nombres du nom du modèle", () => { + expect(parseTokenCount("GPT-4o a un contexte de 128 000 tokens")).toBe(128_000); + expect(parseTokenCount("Claude 3.5 Sonnet : 200k tokens")).toBe(200_000); + }); + + it("une valeur avec unité l'emporte sur une valeur plus grande sans unité", () => { + expect(parseTokenCount("sortie 16 384 ; contexte 128k tokens ; ref 5000000")).toBe(128_000); + }); + + it("sans valeur exploitable → null", () => { + expect(parseTokenCount("Je ne sais pas.")).toBeNull(); + expect(parseTokenCount("")).toBeNull(); + expect(parseTokenCount("42")).toBeNull(); + }); +}); + +describe("estimateTokens", () => { + it("≈ 4 caractères par token, outils compris", () => { + expect(estimateTokens("a".repeat(400), [])).toBeGreaterThanOrEqual(100); + const without = estimateTokens("s", []); + const withTools = estimateTokens("s", [], [{ name: "T", description: "d".repeat(400), inputSchema: {} }]); + expect(withTools).toBeGreaterThan(without + 90); + }); +}); + +describe("queryContextWindow", () => { + const provider = (reply: string, seen: { text?: string } = {}): LLMProvider => ({ + id: "fake", + model: "fake", + capabilities: { tools: false }, + async *chat(req): AsyncIterable { + seen.text = JSON.stringify(req.messages); + yield { type: "text_delta", text: reply.slice(0, 3) }; + yield { type: "text_delta", text: reply.slice(3) }; + yield { type: "done", stopReason: "end_turn" }; + }, + }); + + it("pose la question et lit le nombre de tokens", async () => { + const seen: { text?: string } = {}; + const r = await queryContextWindow(provider("200 000 tokens", seen)); + expect(r).toEqual({ tokens: 200_000, answer: "200 000 tokens" }); + expect(seen.text).toContain(CONTEXT_WINDOW_QUESTION); + expect(CONTEXT_WINDOW_QUESTION).toMatch(/Taille maximale du contexte pour ce modèle/); + }); + + it("réponse illisible : tokens null, réponse conservée", async () => { + expect(await queryContextWindow(provider("Aucune idée"))).toEqual({ tokens: null, answer: "Aucune idée" }); + }); +}); diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts new file mode 100644 index 0000000..a814670 --- /dev/null +++ b/packages/core/src/context.ts @@ -0,0 +1,77 @@ +// Taille du contexte : estimation locale, lecture d'une taille en tokens, question posée au LLM. +import type { LLMProvider, Message, ToolSpec } from "./types"; + +/** Estimation grossière (≈ 4 caractères par token) quand le serveur ne renvoie pas la consommation réelle. */ +export function estimateTokens(system: string, messages: Message[], tools: ToolSpec[] = []): number { + const chars = system.length + JSON.stringify(messages).length + (tools.length > 0 ? JSON.stringify(tools).length : 0); + return Math.ceil(chars / 4); +} + +/** Question posée au LLM après le choix du modèle (la consigne finale facilite la lecture de la réponse). */ +export const CONTEXT_WINDOW_QUESTION = + "Taille maximale du contexte pour ce modèle, en tokens ? Réponds uniquement par un nombre entier, sans autre texte."; + +const MIN_CONTEXT = 1_000; +const MAX_CONTEXT = 100_000_000; + +const MULTIPLIERS: Record = { + k: 1_000, + mille: 1_000, + m: 1_000_000, + million: 1_000_000, + millions: 1_000_000, + milliard: 1_000_000_000, + milliards: 1_000_000_000, +}; + +// 128 000 · 128,000 · 128.000 · 1.5 · 1,5 · 200000 — suivi éventuel d'un multiplicateur (k, M, mille, million…) ou de « token » +const NUMBER = /(\d{1,3}(?:[ .,  ]\d{3})+(?!\d)|\d+(?:[.,]\d+)?)\s*(milliards?|millions?|mille|[kKmM](?![A-Za-zÀ-ÿ])|(?=tokens?\b))?/gi; + +function toNumber(raw: string): number { + if (/^\d{1,3}([ .,  ]\d{3})+$/.test(raw)) return Number(raw.replace(/[ .,  ]/g, "")); + return Number(raw.replace(",", ".")); +} + +/** + * Extrait une taille de contexte (en tokens) de la réponse d'un LLM : « 128000 », « 128 000 tokens », « 200k », « 1M », + * « 1,5 million »… Une valeur avec unité l'emporte ; sinon la plus grande valeur plausible (1 000 à 100 000 000). + * Retourne null si rien d'exploitable. + */ +export function parseTokenCount(text: string): number | null { + let best: { value: number; unit: boolean } | null = null; + for (const m of text.matchAll(NUMBER)) { + const base = toNumber(m[1] ?? ""); + const suffix = (m[2] ?? "").toLowerCase(); + const value = Math.round(base * (MULTIPLIERS[suffix] ?? 1)); + if (!Number.isFinite(value) || value < MIN_CONTEXT || value > MAX_CONTEXT) continue; + const unit = suffix !== "" || /^\s*tokens?\b/i.test(text.slice((m.index ?? 0) + m[0].length)); + if (!best || (unit && !best.unit) || (unit === best.unit && value > best.value)) best = { value, unit }; + } + return best ? best.value : null; +} + +export interface ContextWindowAnswer { + /** Taille maximale en tokens, null si la réponse n'a pas pu être lue. */ + tokens: number | null; + /** Réponse brute du LLM (utile pour informer l'utilisateur en cas d'échec). */ + answer: string; +} + +/** Demande au LLM la taille maximale de son contexte. Lève une erreur si l'appel échoue (réseau, HTTP, annulation). */ +export async function queryContextWindow( + provider: LLMProvider, + signal?: AbortSignal, +): Promise { + let answer = ""; + for await (const ev of provider.chat( + { + system: "Tu réponds de façon brève et exacte.", + messages: [{ role: "user", content: [{ type: "text", text: CONTEXT_WINDOW_QUESTION }] }], + tools: [], + }, + signal, + )) { + if (ev.type === "text_delta") answer += ev.text; + } + return { tokens: parseTokenCount(answer), answer: answer.trim() }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3db59f4..c8a6880 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,3 +4,4 @@ export * from "./skills"; export * from "./shell"; export * from "./fileref"; export * from "./sessions"; +export * from "./context"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 483291c..c68c2b4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -36,6 +36,8 @@ export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "other"; export type StreamEvent = | { type: "text_delta"; text: string } | { type: "tool_use"; id: string; name: string; input: Record } + /** Consommation de la requête, quand le serveur la renvoie (avant "done"). */ + | { type: "usage"; inputTokens: number; outputTokens: number } | { type: "done"; stopReason: StopReason }; export interface LLMProvider { @@ -89,6 +91,8 @@ export interface AgentUI { onToolCall(name: string, summary: string): void; onToolResult(name: string, output: string, isError: boolean): void; confirm(message: string): Promise; + /** Taille courante du contexte (en tokens), après chaque réponse du LLM. */ + onContextUsage?(tokens: number): void; } // ---------- Initialisation de l'agent ---------- diff --git a/packages/providers/src/litellm/litellm.test.ts b/packages/providers/src/litellm/litellm.test.ts index ca33046..50dd768 100644 --- a/packages/providers/src/litellm/litellm.test.ts +++ b/packages/providers/src/litellm/litellm.test.ts @@ -62,7 +62,7 @@ describe("format OpenAI", () => { { role: "user", content: [{ type: "text", text: "suite" }, { type: "tool_result", toolUseId: "c1", content: "R" }] }, ], }); - expect(body).toMatchObject({ model: "m", stream: true, max_tokens: 100 }); + expect(body).toMatchObject({ model: "m", stream: true, max_tokens: 100, stream_options: { include_usage: true } }); const msgs = body.messages as { role: string }[]; expect(msgs.map((m) => m.role)).toEqual(["system", "user", "assistant", "tool", "user"]); expect(msgs[2]).toMatchObject({ tool_calls: [{ id: "c1", function: { name: "Read", arguments: '{"a":1}' } }] }); @@ -95,6 +95,18 @@ describe("format OpenAI", () => { ]); }); + it("streaming : consommation (tokens) lue dans le dernier morceau, avant « done »", async () => { + const raw = + sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }) + + sse({ choices: [], usage: { prompt_tokens: 1200, completion_tokens: 30, total_tokens: 1230 } }) + + "data: [DONE]\n\n"; + expect(await collect(parseSse(chunks(raw)))).toEqual([ + { type: "text_delta", text: "ok" }, + { type: "usage", inputTokens: 1200, outputTokens: 30 }, + { type: "done", stopReason: "end_turn" }, + ]); + }); + it("erreur renvoyée dans le flux", async () => { await expect(collect(parseSse(chunks(sse({ error: { message: "boom" } }))))).rejects.toThrow(/boom/); }); diff --git a/packages/providers/src/litellm/openai.ts b/packages/providers/src/litellm/openai.ts index 2800b7f..101212b 100644 --- a/packages/providers/src/litellm/openai.ts +++ b/packages/providers/src/litellm/openai.ts @@ -26,7 +26,8 @@ export function buildChatBody(model: string, req: ChatRequest): Record = { model, messages, stream: true }; + // include_usage : le serveur renvoie la consommation réelle (tokens) dans le dernier morceau du flux + const body: Record = { model, messages, stream: true, stream_options: { include_usage: true } }; if (req.maxTokens) body.max_tokens = req.maxTokens; if (req.tools.length > 0) { body.tools = req.tools.map((t) => ({ @@ -39,6 +40,7 @@ export function buildChatBody(model: string, req: ChatRequest): Record): AsyncGenerator const decoder = new TextDecoder(); const calls = new Map(); let finish: string | null | undefined; + // objet plutôt que variable : l'assignation se fait dans un générateur imbriqué (TypeScript ne la verrait pas) + const seen: { usage: { inputTokens: number; outputTokens: number } | null } = { usage: null }; let buffer = ""; function* handleLine(line: string): Generator { @@ -73,6 +77,12 @@ export async function* parseSse(body: AsyncIterable): AsyncGenerator return; } if (chunk.error) throw new Error(`Erreur du serveur LiteLLM : ${chunk.error.message ?? JSON.stringify(chunk.error)}`); + // Le morceau d'usage arrive en dernier, avec une liste `choices` vide + if (chunk.usage && typeof chunk.usage.prompt_tokens === "number") { + const input = chunk.usage.prompt_tokens; + const output = chunk.usage.completion_tokens ?? Math.max(0, (chunk.usage.total_tokens ?? input) - input); + seen.usage = { inputTokens: input, outputTokens: output }; + } const choice = chunk.choices?.[0]; if (!choice) return; const delta = choice.delta; @@ -111,5 +121,6 @@ export async function* parseSse(body: AsyncIterable): AsyncGenerator } yield { type: "tool_use", id: call.id || `call_${++n}`, name: call.name, input }; } + if (seen.usage) yield { type: "usage", ...seen.usage }; yield { type: "done", stopReason: stopReason(finish, calls.size > 0) }; } diff --git a/packages/ui/assets/mask.png b/packages/ui/assets/mask.png new file mode 100644 index 0000000..2fc32d8 Binary files /dev/null and b/packages/ui/assets/mask.png differ diff --git a/packages/ui/package.json b/packages/ui/package.json index 39e8b3a..df17b30 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,6 +12,7 @@ "@maia/core": "workspace:*", "cli-spinners": "^2.9.2", "ink": "^7.0.3", + "markdansi": "^0.2.0", "react": "^19.2.0" }, "devDependencies": { diff --git a/packages/ui/scripts/make-mask.py b/packages/ui/scripts/make-mask.py new file mode 100644 index 0000000..cc64966 --- /dev/null +++ b/packages/ui/scripts/make-mask.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Génère src/mask-data.ts à partir de assets/mask.png (nécessite Pillow : pip install pillow). + +Usage : python3 scripts/make-mask.py [taille] (32 par défaut ; l'image affichée fait taille × taille pixels, +soit taille colonnes × taille/2 lignes de terminal) + +Le fond uni (couleur des coins) est rendu transparent, l'image est recadrée au carré puis réduite +en moyennant les couleurs : aucune pixelisation par blocs, aucune réduction de palette. +""" +import sys +from collections import deque +from pathlib import Path +from PIL import Image + +N = int(sys.argv[1]) if len(sys.argv) > 1 else 32 +root = Path(__file__).resolve().parent.parent +im = Image.open(root / "assets" / "mask.png").convert("RGB") +W, H = im.size +px = im.load() +bg = px[0, 0] +near = lambda c: all(abs(c[i] - bg[i]) <= 12 for i in range(3)) + +# Fond = zone connectée aux coins et proche de la couleur du fond (le visage crème est isolé par son contour noir). +transparent = [[False] * W for _ in range(H)] +queue = deque([(0, 0), (W - 1, 0), (0, H - 1), (W - 1, H - 1)]) +while queue: + x, y = queue.popleft() + if x < 0 or y < 0 or x >= W or y >= H or transparent[y][x] or not near(px[x, y]): + continue + transparent[y][x] = True + queue.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]) + +xs = [x for y in range(H) for x in range(W) if not transparent[y][x]] +ys = [y for y in range(H) for x in range(W) if not transparent[y][x]] +x0, x1, y0, y1 = min(xs), max(xs) + 1, min(ys), max(ys) + 1 +side = max(x1 - x0, y1 - y0) +ox = x0 - (side - (x1 - x0)) // 2 +oy = y0 - (side - (y1 - y0)) // 2 + +rows = [] +for gy in range(N): + row = "" + for gx in range(N): + total = opaque = 0 + acc = [0, 0, 0] + for y in range(int(oy + gy * side / N), int(oy + (gy + 1) * side / N) + 1): + for x in range(int(ox + gx * side / N), int(ox + (gx + 1) * side / N) + 1): + total += 1 + if 0 <= x < W and 0 <= y < H and not transparent[y][x]: + opaque += 1 + for i in range(3): + acc[i] += px[x, y][i] + # moyenne des pixels opaques uniquement (évite le liseré clair du fond) ; transparent si moins de la moitié est opaque + row += "......" if opaque * 2 < total or opaque == 0 else "".join("%02x" % round(a / opaque) for a in acc) + rows.append(row) + +lines = "\n".join(f' "{r}",' for r in rows) +(root / "src" / "mask-data.ts").write_text( + "// Fichier généré par scripts/make-mask.py à partir de assets/mask.png : ne pas modifier à la main.\n" + "// Une chaîne par ligne de pixels ; chaque pixel = 6 chiffres hexadécimaux rrggbb, \"......\" = transparent.\n" + f"export const MASK_SIZE = {N};\n\n" + f"export const MASK_DATA: string[] = [\n{lines}\n];\n", + encoding="utf-8", +) +print(f"{N}x{N} écrit dans src/mask-data.ts") diff --git a/packages/ui/src/banner.tsx b/packages/ui/src/banner.tsx index 4b42895..4aafd35 100644 --- a/packages/ui/src/banner.tsx +++ b/packages/ui/src/banner.tsx @@ -90,7 +90,7 @@ export function Banner({ title }: { title: string }) { if (columns < MASK_WIDTH + GAP + titleWidth(title)) return right; return ( - + {right} diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 7294ac0..5ecfea8 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -5,6 +5,8 @@ import { Badge } from "@inkjs/ui"; import { Box, Static, Text, useInput } from "ink"; import { Banner } from "./banner"; import { ERROR_TEXT, LIGHT_BLUE } from "./colors"; +import { ContextBar } from "./context-bar"; +import { Markdown } from "./markdown"; import type { ConfirmPromptState, InputPromptState, @@ -12,6 +14,7 @@ import type { PermissionFile, PromptState, SelectPromptState, + UIState, UIStore, } from "./store"; @@ -108,7 +111,8 @@ function LogLine({ item }: { item: LogItem }) { ); case "assistant": - return {item.text}; + // Réponse finale rendue en markdown (voir markdown.tsx) ; le texte en cours de streaming reste brut. + return ; case "permissions": return ; case "shell": @@ -346,10 +350,15 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) { ); } -function PromptView({ prompt }: { prompt: PromptState }) { +function PromptView({ prompt, state }: { prompt: PromptState; state: UIState }) { switch (prompt.kind) { case "input": - return ; + return ( + + + {state.contextMax !== null ? : null} + + ); case "select": return ; case "confirm": @@ -390,7 +399,7 @@ export function App({ store }: { store: UIStore }) { ) : null} - {state.prompt ? : null} + {state.prompt ? : null} ); } diff --git a/packages/ui/src/context-bar.tsx b/packages/ui/src/context-bar.tsx new file mode 100644 index 0000000..bbd5d27 --- /dev/null +++ b/packages/ui/src/context-bar.tsx @@ -0,0 +1,24 @@ +// Barre de progression du contexte (sous la zone de saisie, alignée à droite, de gauche à droite). +import React from "react"; +import { Box, Text, useStdout } from "ink"; +import { contextBarModel, formatTokens } from "./context-model"; + +const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const; +const EMPTY_COLOR = "#555555"; + +export function ContextBar({ used, max }: { used: number; max: number }) { + const { stdout } = useStdout(); + const columns = stdout?.columns ?? 80; + const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100); + const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `; + // Marge d'1 colonne de chaque côté ; le libellé passe avant les carrés + const model = contextBarModel(used, max, Math.max(1, columns - label.length - 2)); + const scale = model.tokensPerSquare === 10_000 ? "" : ` (1■ = ${formatTokens(model.tokensPerSquare)})`; + return ( + + {label.trimEnd() + scale + " "} + {"■".repeat(model.filled)} + {"■".repeat(model.total - model.filled)} + + ); +} diff --git a/packages/ui/src/context-model.test.ts b/packages/ui/src/context-model.test.ts new file mode 100644 index 0000000..eb132aa --- /dev/null +++ b/packages/ui/src/context-model.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { TOKENS_PER_SQUARE, contextBarModel, formatTokens } from "./context-model"; + +describe("contextBarModel", () => { + it("1 carré = 10 000 tokens : 200 000 tokens → 20 carrés", () => { + expect(TOKENS_PER_SQUARE).toBe(10_000); + expect(contextBarModel(0, 200_000)).toMatchObject({ total: 20, filled: 0, tokensPerSquare: 10_000, level: "ok" }); + }); + + it("la barre se remplit de la gauche vers la droite selon l'usage", () => { + expect(contextBarModel(50_000, 200_000).filled).toBe(5); + expect(contextBarModel(100_000, 200_000)).toMatchObject({ filled: 10, ratio: 0.5, level: "ok" }); + expect(contextBarModel(130_000, 200_000)).toMatchObject({ filled: 13, level: "warn" }); + expect(contextBarModel(180_000, 200_000)).toMatchObject({ filled: 18, level: "danger" }); + }); + + it("un contexte non vide montre au moins un carré ; un contexte plein les remplit tous", () => { + expect(contextBarModel(300, 200_000).filled).toBe(1); + expect(contextBarModel(200_000, 200_000)).toMatchObject({ filled: 20, ratio: 1 }); + }); + + it("dépassement du maximum : plafonné", () => { + expect(contextBarModel(250_000, 200_000)).toMatchObject({ filled: 20, ratio: 1, level: "danger" }); + }); + + it("taille non multiple de 10 000 : carré partiel arrondi vers le haut au total", () => { + expect(contextBarModel(0, 128_000).total).toBe(13); + }); + + it("barre trop large pour le terminal : échelle agrandie par paliers de 10 000", () => { + const m = contextBarModel(500_000, 1_000_000, 60); // 100 carrés demandés, 60 disponibles + expect(m.tokensPerSquare).toBe(20_000); + expect(m.total).toBe(50); + expect(m.filled).toBe(25); + }); +}); + +describe("formatTokens", () => { + it.each([ + [950, "950"], + [12_345, "12.3k"], + [128_000, "128k"], + [200_000, "200k"], + [1_048_576, "1.05M"], + [1_000_000, "1M"], + ])("%d → %s", (n, expected) => expect(formatTokens(n)).toBe(expected)); +}); diff --git a/packages/ui/src/context-model.ts b/packages/ui/src/context-model.ts new file mode 100644 index 0000000..ca7adc0 --- /dev/null +++ b/packages/ui/src/context-model.ts @@ -0,0 +1,47 @@ +// Barre de progression du contexte : calculs purs (sans React), testables seuls. + +/** Échelle par défaut : 1 carré = 10 000 tokens. */ +export const TOKENS_PER_SQUARE = 10_000; + +export interface ContextBarModel { + /** Nombre total de carrés. */ + total: number; + /** Carrés remplis (au moins 1 dès que le contexte n'est pas vide). */ + filled: number; + /** Tokens représentés par un carré (10 000, sauf si la barre est trop large pour le terminal). */ + tokensPerSquare: number; + /** Part du contexte utilisée, de 0 à 1 (plafonnée). */ + ratio: number; + /** vert : confortable · jaune : à surveiller · rouge : presque plein. */ + level: "ok" | "warn" | "danger"; +} + +/** + * @param used tokens du contexte actuel + * @param max taille maximale du contexte du modèle + * @param maxSquares largeur disponible pour la barre ; au-delà, l'échelle est agrandie (1 carré = plus de 10 000 tokens) + */ +export function contextBarModel(used: number, max: number, maxSquares = Infinity): ContextBarModel { + const safeMax = Math.max(1, max); + const safeUsed = Math.max(0, used); + let tokensPerSquare = TOKENS_PER_SQUARE; + if (Math.ceil(safeMax / tokensPerSquare) > maxSquares) { + // Échelle agrandie par paliers de 10 000 pour que la barre tienne dans la largeur disponible + tokensPerSquare = Math.ceil(safeMax / Math.max(1, maxSquares) / TOKENS_PER_SQUARE) * TOKENS_PER_SQUARE; + } + const total = Math.max(1, Math.ceil(safeMax / tokensPerSquare)); + const ratio = Math.min(1, safeUsed / safeMax); + const filled = safeUsed === 0 ? 0 : Math.min(total, Math.max(1, Math.round(safeUsed / tokensPerSquare))); + const level = ratio >= 0.85 ? "danger" : ratio >= 0.6 ? "warn" : "ok"; + return { total, filled, tokensPerSquare, ratio, level }; +} + +/** 950 → "950" · 12 345 → "12.3k" · 200 000 → "200k" · 1 048 576 → "1.05M". */ +export function formatTokens(n: number): string { + if (n < 1_000) return String(Math.round(n)); + if (n < 1_000_000) { + const k = n / 1_000; + return `${k >= 100 ? Math.round(k) : Math.round(k * 10) / 10}k`; + } + return `${Math.round((n / 1_000_000) * 100) / 100}M`; +} diff --git a/packages/ui/src/ink-ui.tsx b/packages/ui/src/ink-ui.tsx index 046157f..8e860c9 100644 --- a/packages/ui/src/ink-ui.tsx +++ b/packages/ui/src/ink-ui.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render, type Instance } from "ink"; import type { AgentUI } from "@maia/core"; import { App } from "./components"; -import { UIStore, type Choice } from "./store"; +import { UIStore, type Choice, type PermissionFile } from "./store"; /** * Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes @@ -116,4 +116,13 @@ export class InkUI implements AgentUI { onToolResult(name: string, output: string, isError: boolean): void { this.start().toolResult(name, output, isError); } + /** Taille du contexte (tokens) après chaque réponse du LLM : met à jour la barre de progression. */ + onContextUsage(tokens: number): void { + this.start().setContextUsed(tokens); + } + + /** Taille maximale du contexte du modèle (null : inconnue, la barre est masquée). */ + setContextMax(tokens: number | null): void { + this.start().setContextMax(tokens); + } } diff --git a/packages/ui/src/markdown.test.ts b/packages/ui/src/markdown.test.ts new file mode 100644 index 0000000..eb35c6c --- /dev/null +++ b/packages/ui/src/markdown.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { renderMarkdown } from "./markdown"; + +describe("renderMarkdown", () => { + afterEach(() => { + delete process.env.MAIA_NO_MARKDOWN; + }); + + it("rend du markdown sans laisser les marqueurs de titre ni de gras", () => { + // eslint-disable-next-line no-control-regex + const plain = renderMarkdown("# Titre\n\nUn mot **important**.", 60).replace(/\u001b\[[0-9;]*m/g, ""); + expect(plain).toContain("Titre"); + expect(plain).toContain("important"); + expect(plain).not.toContain("**"); + expect(plain).not.toMatch(/^# /m); + }); + + it("ne termine pas par un saut de ligne", () => { + expect(renderMarkdown("Bonjour", 60).endsWith("\n")).toBe(false); + }); + + it("renvoie le texte brut si MAIA_NO_MARKDOWN=1", () => { + process.env.MAIA_NO_MARKDOWN = "1"; + expect(renderMarkdown("# Titre", 60)).toBe("# Titre"); + }); +}); diff --git a/packages/ui/src/markdown.tsx b/packages/ui/src/markdown.tsx new file mode 100644 index 0000000..4df4bda --- /dev/null +++ b/packages/ui/src/markdown.tsx @@ -0,0 +1,34 @@ +// Rendu markdown des réponses de l'agent. +// +// Composant volontairement isolé : c'est le SEUL fichier qui connaît `markdansi`. +// Pour le retirer : supprimer ce fichier, retirer `markdansi` de package.json et +// remplacer `` par `{…}` dans components.tsx (un seul endroit). +import React from "react"; +import { Text } from "ink"; +import { render } from "markdansi"; + +/** Largeur de repli quand le terminal ne la fournit pas (sortie redirigée, etc.). */ +const DEFAULT_WIDTH = 80; + +/** + * Convertit du markdown en texte ANSI. Ne lève jamais d'exception : + * en cas d'échec de markdansi (ou si MAIA_NO_MARKDOWN=1), renvoie le texte brut. + */ +export function renderMarkdown(text: string, width: number = terminalWidth()): string { + if (process.env.MAIA_NO_MARKDOWN === "1") return text; + try { + // trimEnd : markdansi termine par un saut de ligne, qui ajouterait une ligne vide sous la réponse. + return render(text, { width }).trimEnd(); + } catch { + return text; + } +} + +function terminalWidth(): number { + const columns = process.stdout.columns; + return typeof columns === "number" && columns > 0 ? columns : DEFAULT_WIDTH; +} + +export function Markdown({ text }: { text: string }) { + return {renderMarkdown(text)}; +} diff --git a/packages/ui/src/mask-data.ts b/packages/ui/src/mask-data.ts new file mode 100644 index 0000000..090de84 --- /dev/null +++ b/packages/ui/src/mask-data.ts @@ -0,0 +1,38 @@ +// Fichier généré par scripts/make-mask.py à partir de assets/mask.png : ne pas modifier à la main. +// Une chaîne par ligne de pixels ; chaque pixel = 6 chiffres hexadécimaux rrggbb, "......" = transparent. +export const MASK_SIZE = 32; + +export const MASK_DATA: string[] = [ + "..........................................................................................1514141d1c1c..........................................................................................", + "................................................1515151d1a0b1c1c1c..................1b1b1b7868205f531a212121..................1a1a1a1c19091a1a1a................................................", + "................................................222014ddbe3b5449181d1d1d............3e3613f7d442ebca3f2d2811............1818186a5c1dcbaf37161512................................................", + "..................1a1a19171615..................433a13f7d442e6c63e403813......191919ac942ec4a835d3b5398f7b261e1e1e......564b18ebca3ff9d643312c16..................171615191919..................", + "..................201c10baa132473e161b1b1b......594c18af972f72621fcbaf361d1c16282414f4d1417f6f239c872adebf3b1a1914242113e0c13c574b18cbaf373d3511......171716564b18ab942e0b0b0b..................", + "..................211e14f2d041f8d5427565201a1a19534816e7c63e362f0ff9d643544917483e13ffda447f6f239c872afeda442b260c70611eeac93f352e0ff8d54337300f1f1e1a8e7b26fdd843d5b839111111..................", + "..................151412e0c13ca48d2cf5d24267591c322b0dfeda44504516c2a6349b852a5b4e19ffda447f6f239c872affda443f3611b79d31a68e2d6c5d1df3d141161306837123ecca3faa922ec4a834141414..................", + "..................161615c4a93562551ba38c2cedcb3f2a250bd4b6399782297a6921d5b739887525ffda44837224a08a2bffda44776720e9c83e5e511ab39a30b89e313c3410f8d5428774247e6d22a8902d191919..................", + "..................1a1a1a9d872ad0b2383d3411f9d542b49b30d4b639e5c43da08a2bffda44ffda44ffda44eecc40f4d041ffda44ffda44ffda44968129f3d041c4a835c5a935eccb3f332c0ee5c43d806e22........................", + "........................66591cffda445e511aa78f2dffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda448a77257a6921ffda444b4115........................", + "........................282414eac93fcdaf37534717ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44fdd944443a13e3c23dd7b93a1f1d16........................", + "........................222222786720ffda44f0ce40ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44f1ce41feda445c5019..............................", + "..............................1c1a11d1b438ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44b99f32161512..............................", + "..............................181818322b0e64581b64581b64581b64581b64581b64581b64581b64581b64581b64581b695b1de1cd45b8a63764581ba1912ff0dd4b7d6e2364581b27220b1c1c1c..............................", + "........................1b15144a2c261f25108aa74489a74489a74489a74489a74489a74489a74489a74489a74489a7448daa46b9e15cabcf5589a744a2c551bde55e95b44a88a4431b180d4d2e281a1615........................", + "..................1b1b1b693e368c53482b3315b6dd5b53652a4c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c26607430bbe25d2622139b5c5058342d1c1c1c..................", + "..................18131294584c8c53482b3315abcf552a260cb79f32b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b49c312a2c11bae15d2622139b5c50834e430f0f0f..................", + "..................171110975a4e8c53482b3315abcf5527230ba9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da68f2d282b10bae15d2622139b5c50864f450a0a0a..................", + "..................171110975a4e8c53482b3315abcf551b1b19464444241512462a242415124543431a14146f2d2d6228281d1c1c3937372c1a17462a241c110f514f4f232717bae15d2622139b5c50864f450a0a0a..................", + "..................0e0b0b36201c351f1b2a3215abcf55393836a7a2a24127229b5c50422722a5a0a0392c2cf76363db5858413e3e89858553312b9a5c50301d19bfb9b9313524bae15d1b1c0e3a221e311d190a0a0a..................", + "............2d17178d3939bf4d4da743432b3215abcf55393836eee6e6484545241614484444eee5e5392c2cf76363db5858413e3ee0d8d83a35352316145c5959f5ecec313524bae15d271f12ba4b4bbc4c4c823434251616............", + "......391a1ae25b5ba643435e26265c25252b3215abcf55393836fff6f6fbf1f1e0d8d8fbf1f1e9e1e1352020fa6565e65d5d322b2bf6ededf8eeeee1d9d9fdf4f4fbf2f2313524bae15d221d10632828622828b74949d556562b1616......", + "151313c04d4d88373742091f9e164a86133f2a3115abcf55393836fff6f6fff5f5fff5f5fff5f5847f7f803434ff6666ff66666429299f9a9afff5f5fff5f5fff5f5fbf2f2313524bae15d21191097154798154838091aa54343a24141151515", + "301818f161612e0d14ca1c5fd81e66c31b5c2c3116abcf55393836fff6f6fff5f5fff5f5fff5f53e3c3cbb4b4bff6666ff66669f4040595757fff5f5fff5f5fff5f5fbf2f2313524bae15d2d1a16d71e66d81e66be1a5aa53645ec5f5f170e0e", + "371717e75d5d2e0815d81e66d81e66c81c5f2d2e16b0d558373833fff6f6fff5f5fff5f5cdc6c6413f3f260f0f5d25255d25251c0b0b525050d9d1d1fff5f5fff5f5f6eded303621b8de5b2f1617d81e66d81e66d71e66e74363f964641c0c0c", + "221717e85d5d481c1d931445d81e66d21d63281a13b7de5b2f3421f3eaeafff5f5c9c2c237311ba48e2cbfa433bfa433bfa433bea433968128322f22ddd5d5fff5f5d9d1d1333d1ba6c9532f0a16d81e66d81e667e113b7d3133ce5353151313", + "1918187c3232d7565640181b3a081b32091712080898b84c435121bab4b4fff5f5676464ae962fceb037ad942ead942ead942ead942ed7b83a887525837f7ffff5f59e9999586b2c839e4116080936081937081a4e1f20e45c5c5e2626......", + "......151111823535ec5f5fd45555df5a5a8034346378317f9a3f5f5c5cfff5f55b5958c0a533a28b2c6e5e1e6e5e1e6e5e1e6e5e1eb299309a8429787474fcf3f345444294b44a4e5e279a3f3fda5858d85757e85d5d6c2c2c161515......", + "............1b1b1b2817174a1e1e431c1c1f1616242a15afd457333c1dbdb7b7a7a1a14c4216e2c33dfdd843fdd843fdd843fcd843d4b639383114c1bbbba29d9d424f21a8cb53181b11221515461d1d471d1d2316161e1e1e............", + "..........................................1f1f1f62773197b74b2a2f1a303424181c0d21240e3132123132123132123132121d200d1b1f103135252e361ba4c7524d5d26262626..........................................", + "................................................181a138aa845bbe35dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe35d78923c171815................................................", + "......................................................14160f181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c151712......................................................", +]; diff --git a/packages/ui/src/pixels.test.ts b/packages/ui/src/pixels.test.ts index b8c0ee1..e178ab0 100644 --- a/packages/ui/src/pixels.test.ts +++ b/packages/ui/src/pixels.test.ts @@ -45,15 +45,24 @@ describe("pixelsToRuns", () => { }); describe("masque", () => { - it("fait 20 × 20 pixels, que des couleurs de la palette ou du transparent", () => { + it("fait 32 × 32 pixels, soit 16 lignes de terminal", () => { const grid = buildMaskGrid(); - expect(grid).toHaveLength(20); + expect(MASK_WIDTH).toBe(32); + expect(grid).toHaveLength(MASK_WIDTH); for (const row of grid) expect(row).toHaveLength(MASK_WIDTH); - expect(pixelsToRuns(grid)).toHaveLength(10); // 10 lignes de terminal + expect(pixelsToRuns(grid)).toHaveLength(16); }); - it("est symétrique gauche/droite", () => { - for (const row of buildMaskGrid()) expect([...row].reverse()).toEqual(row); + it("n'a que des couleurs #rrggbb ou du transparent, avec fond transparent et image au centre", () => { + const grid = buildMaskGrid(); + for (const row of grid) for (const p of row) expect(p === null || /^#[0-9a-f]{6}$/.test(p)).toBe(true); + expect(grid[0]![0]).toBeNull(); + expect(grid[16]![16]).not.toBeNull(); + }); + + it("n'est pas pixelisé en aplats : beaucoup de couleurs différentes (couleurs réelles)", () => { + const colors = new Set(buildMaskGrid().flat().filter((p) => p !== null)); + expect(colors.size).toBeGreaterThan(30); }); }); diff --git a/packages/ui/src/pixels.ts b/packages/ui/src/pixels.ts index e18ebe6..ef17388 100644 --- a/packages/ui/src/pixels.ts +++ b/packages/ui/src/pixels.ts @@ -1,6 +1,7 @@ // Pixel-art pour le terminal, sans dépendance à React : chaque caractère « demi-bloc » (▀ ▄ █) affiche // deux pixels superposés, ce qui donne des pixels carrés (une cellule de terminal est ~2 fois plus haute que large). import { hslToHex } from "./colors"; +import { MASK_DATA, MASK_SIZE } from "./mask-data"; /** Couleur "#rrggbb", ou null pour un pixel transparent. */ export type Pixel = string | null; @@ -49,45 +50,22 @@ export function pixelsToRuns(grid: Pixel[][]): Run[][] { return lines; } -// ───────────── Masque (20 × 20 pixels → 10 lignes de terminal) ───────────── +// ───────────── Masque : image affichée telle quelle (couleurs réelles, sans pixelisation) ───────────── +// Les pixels viennent de mask-data.ts, généré depuis assets/mask.png par scripts/make-mask.py. -const PALETTE: Record = { - Y: "#F7C61B", // jaune (couronne, mâchoire) - O: "#E8962A", // orange (bandeau) - G: "#3DDC4B", // vert (contour du visage) - K: "#111418", // noir (traits) - W: "#F6E7D2", // crème (visage) - R: "#E0364F", // rouge (nez, oreilles) - B: "#8A4B2D", // brun (yeux, côtés) -}; - -const MASK = [ - ".........YY.........", - "...Y.....YY.....Y...", - "...YY...YYYY...YY...", - "..YYY...YYYY...YYY..", - "..YYYY.YYYYYY.YYYY..", - "..YYYYYYYYYYYYYYYY..", - "...OOOOOOOOOOOOOO...", - "...GGGGGGGGGGGGGG...", - ".BBGKKKKKKKKKKKKGBB.", - ".BBGKYYYYYYYYYYKGBB.", - ".BBGKWWWWWWWWWWKGBB.", - "..RGKWBBWWWWBBWKGR..", - "RRRGKWBBWWWWBBWKGRRR", - "RRRGKWWWWRRWWWWKGRRR", - "RRRGKWWWRRRRWWWKGRRR", - "RRRGKWWWWRRWWWWKGRRR", - ".RRGKWWYYYYYYWWKGRR.", - "..RGKWWWYYYYWWWKGR..", - "...GKKKKKKKKKKKKG...", - "...GGGGGGGGGGGGGG...", -]; - -export const MASK_WIDTH = 20; +/** Largeur du masque en colonnes (= nombre de pixels par ligne ; 1 pixel = 1 colonne de terminal). */ +export const MASK_WIDTH = MASK_SIZE; +/** Décode les lignes de mask-data.ts : 6 chiffres hexadécimaux par pixel, "......" = transparent. */ export function buildMaskGrid(): Pixel[][] { - return MASK.map((row) => [...row].map((ch) => PALETTE[ch] ?? null)); + return MASK_DATA.map((row) => { + const pixels: Pixel[] = []; + for (let i = 0; i + 6 <= row.length; i += 6) { + const hex = row.slice(i, i + 6); + pixels.push(hex === "......" ? null : `#${hex}`); + } + return pixels; + }); } // ───────────── Titre en gros caractères (police 5 × 7) ───────────── diff --git a/packages/ui/src/store.test.ts b/packages/ui/src/store.test.ts index ebc6f13..68f1ff7 100644 --- a/packages/ui/src/store.test.ts +++ b/packages/ui/src/store.test.ts @@ -120,4 +120,16 @@ describe("UIStore", () => { expect(ko).toMatchObject({ detail: "(aucune sortie)\n[code de sortie : 1]", failed: true }); expect(big?.detail).toContain("caractères omis à l'affichage"); }); + + it("contexte : maximum inconnu au départ (barre masquée), puis usage et maximum renseignés", () => { + const s = new UIStore(); + expect(s.getSnapshot()).toMatchObject({ contextUsed: 0, contextMax: null }); + s.setContextMax(200_000); + s.setContextUsed(45_678.4); + expect(s.getSnapshot()).toMatchObject({ contextUsed: 45_678, contextMax: 200_000 }); + s.setContextMax(null); + expect(s.getSnapshot().contextMax).toBeNull(); + s.setContextMax(0); + expect(s.getSnapshot().contextMax).toBeNull(); + }); }); diff --git a/packages/ui/src/store.ts b/packages/ui/src/store.ts index a11ab86..d73e1c0 100644 --- a/packages/ui/src/store.ts +++ b/packages/ui/src/store.ts @@ -74,13 +74,17 @@ export interface UIState { /** Libellé du spinner, ou null. */ busy: string | null; prompt: PromptState | null; + /** Tokens du contexte de la conversation (mis à jour après chaque réponse du LLM). */ + contextUsed: number; + /** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */ + contextMax: number | null; } const RESULT_PREVIEW_MAX = 600; const SHELL_PREVIEW_MAX = 4000; export class UIStore { - private state: UIState = { items: [], streaming: "", busy: null, prompt: null }; + private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null }; private listeners = new Set<() => void>(); private nextId = 1; private history: string[] = []; @@ -136,6 +140,16 @@ export class UIStore { this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) }); } + /** Contexte actuel de la conversation, en tokens. */ + setContextUsed(tokens: number): void { + this.commit({ contextUsed: Math.max(0, Math.round(tokens)) }); + } + + /** Taille maximale du contexte du modèle (null : inconnue, barre masquée). */ + setContextMax(tokens: number | null): void { + this.commit({ contextMax: tokens !== null && tokens > 0 ? Math.round(tokens) : null }); + } + setBusy(label: string | null): void { this.commit({ busy: label }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb20674..4f227eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: ink: specifier: ^7.0.3 version: 7.1.1(@types/react@19.3.0)(react@19.3.1) + markdansi: + specifier: ^0.2.0 + version: 0.2.1 react: specifier: ^19.2.0 version: 19.3.1 @@ -728,9 +731,18 @@ packages: cpu: [x64] os: [win32] + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.20.5': resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} @@ -743,6 +755,9 @@ packages: '@types/turndown@5.0.6': resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -810,6 +825,9 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} @@ -818,6 +836,9 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -877,6 +898,9 @@ packages: supports-color: optional: true + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -885,6 +909,13 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -914,6 +945,10 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -946,6 +981,10 @@ packages: resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + ignore@6.0.2: resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==} engines: {node: '>= 4'} @@ -995,12 +1034,143 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdansi@0.2.1: + resolution: {integrity: sha512-ri9ce2cmpcbL0A0FJ5a3WtIxMBRE8fPNEnSAz1CZGCfZ7MazmBEGmreOpBhXiaQZ31FXT+GYUi+86bxYpCTo8w==} + engines: {node: '>=22'} + hasBin: true + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.3: + resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==} + + mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.1: + resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.2.0: + resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -1152,6 +1322,14 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-hyperlinks@4.6.0: + resolution: {integrity: sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==} + engines: {node: '>=20'} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -1239,6 +1417,18 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + vite-node@2.1.9: resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1328,6 +1518,9 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@alcalzone/ansi-tokenize@0.3.1': @@ -1662,8 +1855,18 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.64.6': optional: true + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/estree@1.0.9': {} + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@22.20.5': dependencies: undici-types: 6.21.0 @@ -1676,6 +1879,8 @@ snapshots: '@types/turndown@5.0.6': {} + '@types/unist@3.0.3': {} + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -1739,6 +1944,8 @@ snapshots: cac@6.7.14: {} + ccount@2.0.1: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1749,6 +1956,8 @@ snapshots: chalk@5.6.2: {} + character-entities@2.0.2: {} + check-error@2.1.3: {} chokidar@4.0.3: @@ -1788,10 +1997,20 @@ snapshots: dependencies: ms: 2.1.3 + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + deep-eql@5.0.2: {} deepmerge@4.3.1: {} + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + environment@1.1.0: {} es-module-lexer@1.7.0: {} @@ -1884,6 +2103,8 @@ snapshots: escape-string-regexp@2.0.0: {} + escape-string-regexp@5.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1909,6 +2130,8 @@ snapshots: get-east-asian-width@1.7.0: {} + has-flag@5.0.1: {} + ignore@6.0.2: {} indent-string@5.0.0: {} @@ -1963,12 +2186,331 @@ snapshots: load-tsconfig@0.2.5: {} + longest-streak@3.1.0: {} + loupe@3.2.1: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + markdansi@0.2.1: + dependencies: + chalk: 5.6.2 + mdast-util-from-markdown: 2.1.0 + mdast-util-gfm: 3.1.0 + micromark: 4.0.3 + micromark-extension-gfm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + string-width: 8.3.0 + strip-ansi: 7.2.0 + supports-hyperlinks: 4.6.0 + transitivePeerDependencies: + - supports-color + + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.3: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.3 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.1.0 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.1 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-markdown@2.2.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + mimic-fn@2.1.0: {} mlly@1.8.2: @@ -2119,6 +2661,13 @@ snapshots: tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + + supports-hyperlinks@4.6.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + tagged-tag@1.0.0: {} terminal-size@4.0.1: {} @@ -2198,6 +2747,25 @@ snapshots: undici-types@6.21.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + vite-node@2.1.9(@types/node@22.20.5): dependencies: cac: 6.7.14 @@ -2277,3 +2845,5 @@ snapshots: ws@8.22.0: {} yoga-layout@3.2.1: {} + + zwitch@2.0.4: {}