From ad2cf661c308eaa3392bf1e1209c3a462bb908c9 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 16 Oct 2026 00:54:08 +0200 Subject: [PATCH] Modifications du projet --- .gitignore | 1 - README.md | 12 +- generated_page.md | 33 + packages/cli/src/index.ts | 59 +- packages/core/src/agent.test.ts | 53 ++ packages/core/src/agent.ts | 17 +- packages/core/src/context.test.ts | 72 +++ packages/core/src/context.ts | 77 +++ packages/core/src/index.ts | 1 + packages/core/src/types.ts | 4 + .../providers/src/litellm/litellm.test.ts | 14 +- packages/providers/src/litellm/openai.ts | 13 +- packages/ui/assets/mask.png | Bin 0 -> 45848 bytes packages/ui/package.json | 1 + packages/ui/scripts/make-mask.py | 65 ++ packages/ui/src/banner.tsx | 2 +- packages/ui/src/components.tsx | 17 +- packages/ui/src/context-bar.tsx | 24 + packages/ui/src/context-model.test.ts | 47 ++ packages/ui/src/context-model.ts | 47 ++ packages/ui/src/ink-ui.tsx | 11 +- packages/ui/src/markdown.test.ts | 26 + packages/ui/src/markdown.tsx | 34 ++ packages/ui/src/mask-data.ts | 38 ++ packages/ui/src/pixels.test.ts | 19 +- packages/ui/src/pixels.ts | 50 +- packages/ui/src/store.test.ts | 12 + packages/ui/src/store.ts | 16 +- pnpm-lock.yaml | 570 ++++++++++++++++++ 29 files changed, 1279 insertions(+), 56 deletions(-) create mode 100644 generated_page.md create mode 100644 packages/core/src/context.test.ts create mode 100644 packages/core/src/context.ts create mode 100644 packages/ui/assets/mask.png create mode 100644 packages/ui/scripts/make-mask.py create mode 100644 packages/ui/src/context-bar.tsx create mode 100644 packages/ui/src/context-model.test.ts create mode 100644 packages/ui/src/context-model.ts create mode 100644 packages/ui/src/markdown.test.ts create mode 100644 packages/ui/src/markdown.tsx create mode 100644 packages/ui/src/mask-data.ts 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 0000000000000000000000000000000000000000..2fc32d81694df3bb0f56a57b438cd23e817da98c GIT binary patch literal 45848 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 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: {}