Modifications du projet

This commit is contained in:
Arnaud 2026-10-16 00:54:08 +02:00
parent 3bfd5cc629
commit ad2cf661c3
29 changed files with 1279 additions and 56 deletions

1
.gitignore vendored
View file

@ -3,4 +3,3 @@ dist
*.log *.log
.env .env
.maia .maia
generated_page.md

View file

@ -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). 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. 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. une ligne vide sépare chaque message.
Messages du journal, avec le composant `Badge` de `@inkjs/ui` : 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`) | | `FAIL` (rouge) | erreur | `#DC8ADD` (`ERROR_TEXT`) |
| `ASK` (vert) | question posée à l'utilisateur (liste de choix, confirmation o/n), à gauche de la question | gras | | `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 `<Markdown />` par `<Text>` 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. 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. 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 `!`). À 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é). - É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. - `@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`. - 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é. - 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). - 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. 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). 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. 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`.

33
generated_page.md Normal file
View file

@ -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.

View file

@ -6,11 +6,13 @@ import {
SessionStore, SessionStore,
createSession, createSession,
ensureMaiaHome, ensureMaiaHome,
estimateSessionTokens,
findSkill, findSkill,
normalizeSessionName, normalizeSessionName,
parseShellInvocation, parseShellInvocation,
resolveFileReferences, resolveFileReferences,
parseSkillInvocation, parseSkillInvocation,
queryContextWindow,
runTurn, runTurn,
type AgentSession, type AgentSession,
type Message, type Message,
@ -24,6 +26,7 @@ import { runUserShell } from "./user-shell";
const REPLAY_RESULT_MAX = 600; const REPLAY_RESULT_MAX = 600;
const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints 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). */ /** 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 { function replayMessages(ui: InkUI, session: AgentSession, messages: Message[]): void {
@ -152,7 +155,51 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
log: (m) => ui.info(m), log: (m) => ui.info(m),
warn: (m) => ui.warn(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<string, number>();
/**
* 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<void> => {
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"); ui.intro("Maia");
@ -170,8 +217,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
} }
// 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur // 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur
let modelSpec = config.model; let modelSpec: string;
if (!modelSpec) { if (config.model) {
modelSpec = config.model;
} else {
const picked = await pickModel(); const picked = await pickModel();
if (!picked) { if (!picked) {
ui.outro("Aucun modèle choisi"); ui.outro("Aucun modèle choisi");
@ -190,6 +239,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
return; return;
} }
ui.info(`Modèle : ${modelSpec}`); 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. // Sauvegarde de la session courante (silencieuse si elle est vide ou inchangée). Retourne son nom si écrite.
const saveSession = (): string | null => { const saveSession = (): string | null => {
@ -351,6 +401,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
session.pendingContext.length = 0; session.pendingContext.length = 0;
session.pendingContext.push(...saved.pendingContext); session.pendingContext.push(...saved.pendingContext);
sessions.restore(saved); sessions.restore(saved);
refreshContext(session);
ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`); ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`);
if (session.pendingContext.length > 0) { if (session.pendingContext.length > 0) {
ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`); 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<void> {
session.provider = createProvider(choice, settings); session.provider = createProvider(choice, settings);
modelSpec = choice; modelSpec = choice;
ui.info(`Modèle actif : ${modelSpec}`); ui.info(`Modèle actif : ${modelSpec}`);
await resolveContextWindow(modelSpec);
} catch (err) { } catch (err) {
ui.error((err as Error).message); ui.error((err as Error).message);
} }
@ -418,6 +470,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
if (controller.signal.aborted) { if (controller.signal.aborted) {
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat) session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
refreshContext(session);
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande."); ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
} else { } else {
ui.error((err as Error).message); ui.error((err as Error).message);

View file

@ -122,3 +122,56 @@ describe("contexte en attente", () => {
expect(session.pendingContext).toEqual([]); 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<StreamEvent> {
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]);
});
});

View file

@ -1,3 +1,4 @@
import { estimateTokens } from "./context";
import type { import type {
AgentSkill, AgentSkill,
AgentUI, AgentUI,
@ -22,6 +23,8 @@ export interface AgentSession {
permissionSources: PermissionSource[]; permissionSources: PermissionSource[];
env?: Record<string, string>; env?: Record<string, string>;
messages: Message[]; 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. */ /** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */
pendingContext: string[]; pendingContext: string[];
ui: AgentUI; ui: AgentUI;
@ -44,6 +47,7 @@ export function createSession(opts: {
permissionSources: opts.init.permissionSources ?? [], permissionSources: opts.init.permissionSources ?? [],
env: opts.init.env, env: opts.init.env,
messages: [], messages: [],
contextTokens: 0,
pendingContext: [], pendingContext: [],
ui: opts.ui, ui: opts.ui,
cwd: opts.cwd, cwd: opts.cwd,
@ -69,6 +73,7 @@ export async function runTurn(
for (let i = 0; i < MAX_ITERATIONS; i++) { for (let i = 0; i < MAX_ITERATIONS; i++) {
const assistant: ContentBlock[] = []; const assistant: ContentBlock[] = [];
let text = ""; let text = "";
let usedTokens: number | null = null;
ui.onThinking(true); ui.onThinking(true);
let first = true; let first = true;
@ -90,6 +95,9 @@ export async function runTurn(
ui.onText(ev.text); ui.onText(ev.text);
} else if (ev.type === "tool_use") { } else if (ev.type === "tool_use") {
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input }); 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 // "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 }); assistant.unshift({ type: "text", text });
} }
session.messages.push({ role: "assistant", content: assistant }); 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"); const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
if (calls.length === 0) return; if (calls.length === 0) return;
@ -161,3 +171,8 @@ async function executeToolCall(
return result(msg, true); 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<AgentSession, "system" | "messages" | "tools">): number {
return estimateTokens(session.system, session.messages, [...session.tools.values()].map((t) => t.spec));
}

View file

@ -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<StreamEvent> {
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" });
});
});

View file

@ -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<string, number> = {
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<ContextWindowAnswer> {
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() };
}

View file

@ -4,3 +4,4 @@ export * from "./skills";
export * from "./shell"; export * from "./shell";
export * from "./fileref"; export * from "./fileref";
export * from "./sessions"; export * from "./sessions";
export * from "./context";

View file

@ -36,6 +36,8 @@ export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "other";
export type StreamEvent = export type StreamEvent =
| { type: "text_delta"; text: string } | { type: "text_delta"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> } | { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
/** Consommation de la requête, quand le serveur la renvoie (avant "done"). */
| { type: "usage"; inputTokens: number; outputTokens: number }
| { type: "done"; stopReason: StopReason }; | { type: "done"; stopReason: StopReason };
export interface LLMProvider { export interface LLMProvider {
@ -89,6 +91,8 @@ export interface AgentUI {
onToolCall(name: string, summary: string): void; onToolCall(name: string, summary: string): void;
onToolResult(name: string, output: string, isError: boolean): void; onToolResult(name: string, output: string, isError: boolean): void;
confirm(message: string): Promise<boolean>; confirm(message: string): Promise<boolean>;
/** Taille courante du contexte (en tokens), après chaque réponse du LLM. */
onContextUsage?(tokens: number): void;
} }
// ---------- Initialisation de l'agent ---------- // ---------- Initialisation de l'agent ----------

View file

@ -62,7 +62,7 @@ describe("format OpenAI", () => {
{ role: "user", content: [{ type: "text", text: "suite" }, { type: "tool_result", toolUseId: "c1", content: "R" }] }, { 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 }[]; const msgs = body.messages as { role: string }[];
expect(msgs.map((m) => m.role)).toEqual(["system", "user", "assistant", "tool", "user"]); 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}' } }] }); 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 () => { it("erreur renvoyée dans le flux", async () => {
await expect(collect(parseSse(chunks(sse({ error: { message: "boom" } }))))).rejects.toThrow(/boom/); await expect(collect(parseSse(chunks(sse({ error: { message: "boom" } }))))).rejects.toThrow(/boom/);
}); });

View file

@ -26,7 +26,8 @@ export function buildChatBody(model: string, req: ChatRequest): Record<string, u
} }
} }
const body: Record<string, unknown> = { model, messages, stream: true }; // include_usage : le serveur renvoie la consommation réelle (tokens) dans le dernier morceau du flux
const body: Record<string, unknown> = { model, messages, stream: true, stream_options: { include_usage: true } };
if (req.maxTokens) body.max_tokens = req.maxTokens; if (req.maxTokens) body.max_tokens = req.maxTokens;
if (req.tools.length > 0) { if (req.tools.length > 0) {
body.tools = req.tools.map((t) => ({ body.tools = req.tools.map((t) => ({
@ -39,6 +40,7 @@ export function buildChatBody(model: string, req: ChatRequest): Record<string, u
interface Chunk { interface Chunk {
error?: { message?: string }; error?: { message?: string };
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | null;
choices?: { choices?: {
finish_reason?: string | null; finish_reason?: string | null;
delta?: { delta?: {
@ -60,6 +62,8 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const calls = new Map<number, { id: string; name: string; args: string }>(); const calls = new Map<number, { id: string; name: string; args: string }>();
let finish: string | null | undefined; 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 = ""; let buffer = "";
function* handleLine(line: string): Generator<StreamEvent> { function* handleLine(line: string): Generator<StreamEvent> {
@ -73,6 +77,12 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
return; return;
} }
if (chunk.error) throw new Error(`Erreur du serveur LiteLLM : ${chunk.error.message ?? JSON.stringify(chunk.error)}`); 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]; const choice = chunk.choices?.[0];
if (!choice) return; if (!choice) return;
const delta = choice.delta; const delta = choice.delta;
@ -111,5 +121,6 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
} }
yield { type: "tool_use", id: call.id || `call_${++n}`, name: call.name, input }; 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) }; yield { type: "done", stopReason: stopReason(finish, calls.size > 0) };
} }

BIN
packages/ui/assets/mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -12,6 +12,7 @@
"@maia/core": "workspace:*", "@maia/core": "workspace:*",
"cli-spinners": "^2.9.2", "cli-spinners": "^2.9.2",
"ink": "^7.0.3", "ink": "^7.0.3",
"markdansi": "^0.2.0",
"react": "^19.2.0" "react": "^19.2.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -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")

View file

@ -90,7 +90,7 @@ export function Banner({ title }: { title: string }) {
if (columns < MASK_WIDTH + GAP + titleWidth(title)) return right; if (columns < MASK_WIDTH + GAP + titleWidth(title)) return right;
return ( return (
<Box> <Box alignItems="center">
<PixelBlock lines={MASK_LINES} /> <PixelBlock lines={MASK_LINES} />
<Box marginLeft={GAP}>{right}</Box> <Box marginLeft={GAP}>{right}</Box>
</Box> </Box>

View file

@ -5,6 +5,8 @@ import { Badge } from "@inkjs/ui";
import { Box, Static, Text, useInput } from "ink"; import { Box, Static, Text, useInput } from "ink";
import { Banner } from "./banner"; import { Banner } from "./banner";
import { ERROR_TEXT, LIGHT_BLUE } from "./colors"; import { ERROR_TEXT, LIGHT_BLUE } from "./colors";
import { ContextBar } from "./context-bar";
import { Markdown } from "./markdown";
import type { import type {
ConfirmPromptState, ConfirmPromptState,
InputPromptState, InputPromptState,
@ -12,6 +14,7 @@ import type {
PermissionFile, PermissionFile,
PromptState, PromptState,
SelectPromptState, SelectPromptState,
UIState,
UIStore, UIStore,
} from "./store"; } from "./store";
@ -108,7 +111,8 @@ function LogLine({ item }: { item: LogItem }) {
</Box> </Box>
); );
case "assistant": case "assistant":
return <Text>{item.text}</Text>; // Réponse finale rendue en markdown (voir markdown.tsx) ; le texte en cours de streaming reste brut.
return <Markdown text={item.text} />;
case "permissions": case "permissions":
return <PermissionsView files={item.permissions ?? []} />; return <PermissionsView files={item.permissions ?? []} />;
case "shell": 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) { switch (prompt.kind) {
case "input": case "input":
return <InputPrompt prompt={prompt} />; return (
<Box flexDirection="column">
<InputPrompt prompt={prompt} />
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} /> : null}
</Box>
);
case "select": case "select":
return <SelectPrompt prompt={prompt} />; return <SelectPrompt prompt={prompt} />;
case "confirm": case "confirm":
@ -390,7 +399,7 @@ export function App({ store }: { store: UIStore }) {
<Spinner label={state.busy} /> <Spinner label={state.busy} />
</Box> </Box>
) : null} ) : null}
{state.prompt ? <PromptView prompt={state.prompt} /> : null} {state.prompt ? <PromptView prompt={state.prompt} state={state} /> : null}
</> </>
); );
} }

View file

@ -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 (
<Box justifyContent="flex-end" paddingRight={1}>
<Text dimColor>{label.trimEnd() + scale + " "}</Text>
<Text color={COLORS[model.level]}>{"■".repeat(model.filled)}</Text>
<Text color={EMPTY_COLOR}>{"■".repeat(model.total - model.filled)}</Text>
</Box>
);
}

View file

@ -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));
});

View file

@ -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`;
}

View file

@ -2,7 +2,7 @@ import React from "react";
import { render, type Instance } from "ink"; import { render, type Instance } from "ink";
import type { AgentUI } from "@maia/core"; import type { AgentUI } from "@maia/core";
import { App } from "./components"; 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 * 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 { onToolResult(name: string, output: string, isError: boolean): void {
this.start().toolResult(name, output, isError); 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);
}
} }

View file

@ -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");
});
});

View file

@ -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 `<Markdown text=… />` par `<Text>{…}</Text>` 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 <Text>{renderMarkdown(text)}</Text>;
}

View file

@ -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......................................................",
];

View file

@ -45,15 +45,24 @@ describe("pixelsToRuns", () => {
}); });
describe("masque", () => { 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(); 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); 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", () => { it("n'a que des couleurs #rrggbb ou du transparent, avec fond transparent et image au centre", () => {
for (const row of buildMaskGrid()) expect([...row].reverse()).toEqual(row); 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);
}); });
}); });

View file

@ -1,6 +1,7 @@
// Pixel-art pour le terminal, sans dépendance à React : chaque caractère « demi-bloc » (▀ ▄ █) affiche // 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). // 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 { hslToHex } from "./colors";
import { MASK_DATA, MASK_SIZE } from "./mask-data";
/** Couleur "#rrggbb", ou null pour un pixel transparent. */ /** Couleur "#rrggbb", ou null pour un pixel transparent. */
export type Pixel = string | null; export type Pixel = string | null;
@ -49,45 +50,22 @@ export function pixelsToRuns(grid: Pixel[][]): Run[][] {
return lines; 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<string, string> = { /** Largeur du masque en colonnes (= nombre de pixels par ligne ; 1 pixel = 1 colonne de terminal). */
Y: "#F7C61B", // jaune (couronne, mâchoire) export const MASK_WIDTH = MASK_SIZE;
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;
/** Décode les lignes de mask-data.ts : 6 chiffres hexadécimaux par pixel, "......" = transparent. */
export function buildMaskGrid(): Pixel[][] { 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) ───────────── // ───────────── Titre en gros caractères (police 5 × 7) ─────────────

View file

@ -120,4 +120,16 @@ describe("UIStore", () => {
expect(ko).toMatchObject({ detail: "(aucune sortie)\n[code de sortie : 1]", failed: true }); expect(ko).toMatchObject({ detail: "(aucune sortie)\n[code de sortie : 1]", failed: true });
expect(big?.detail).toContain("caractères omis à l'affichage"); 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();
});
}); });

View file

@ -74,13 +74,17 @@ export interface UIState {
/** Libellé du spinner, ou null. */ /** Libellé du spinner, ou null. */
busy: string | null; busy: string | null;
prompt: PromptState | 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 RESULT_PREVIEW_MAX = 600;
const SHELL_PREVIEW_MAX = 4000; const SHELL_PREVIEW_MAX = 4000;
export class UIStore { export class UIStore {
private state: UIState = { items: [], streaming: "", busy: null, prompt: null }; private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null };
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private nextId = 1; private nextId = 1;
private history: string[] = []; private history: string[] = [];
@ -136,6 +140,16 @@ export class UIStore {
this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) }); 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 { setBusy(label: string | null): void {
this.commit({ busy: label }); this.commit({ busy: label });
} }

View file

@ -111,6 +111,9 @@ importers:
ink: ink:
specifier: ^7.0.3 specifier: ^7.0.3
version: 7.1.1(@types/react@19.3.0)(react@19.3.1) version: 7.1.1(@types/react@19.3.0)(react@19.3.1)
markdansi:
specifier: ^0.2.0
version: 0.2.1
react: react:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.3.1 version: 19.3.1
@ -728,9 +731,18 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/estree@1.0.9': '@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} 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': '@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
@ -743,6 +755,9 @@ packages:
'@types/turndown@5.0.6': '@types/turndown@5.0.6':
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} 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': '@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@ -810,6 +825,9 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@5.3.3: chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -818,6 +836,9 @@ packages:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} 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: check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@ -877,6 +898,9 @@ packages:
supports-color: supports-color:
optional: true optional: true
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
deep-eql@5.0.2: deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -885,6 +909,13 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} 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: environment@1.1.0:
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -914,6 +945,10 @@ packages:
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
engines: {node: '>=8'} 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: estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
@ -946,6 +981,10 @@ packages:
resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
has-flag@5.0.1:
resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==}
engines: {node: '>=12'}
ignore@6.0.2: ignore@6.0.2:
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==} resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -995,12 +1034,143 @@ packages:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 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: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 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: mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1152,6 +1322,14 @@ packages:
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
hasBin: true 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: tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -1239,6 +1417,18 @@ packages:
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 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: vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@ -1328,6 +1518,9 @@ packages:
yoga-layout@3.2.1: yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
snapshots: snapshots:
'@alcalzone/ansi-tokenize@0.3.1': '@alcalzone/ansi-tokenize@0.3.1':
@ -1662,8 +1855,18 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.64.6': '@rollup/rollup-win32-x64-msvc@4.64.6':
optional: true optional: true
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/estree@1.0.9': {} '@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': '@types/node@22.20.5':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@ -1676,6 +1879,8 @@ snapshots:
'@types/turndown@5.0.6': {} '@types/turndown@5.0.6': {}
'@types/unist@3.0.3': {}
'@vitest/expect@2.1.9': '@vitest/expect@2.1.9':
dependencies: dependencies:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9
@ -1739,6 +1944,8 @@ snapshots:
cac@6.7.14: {} cac@6.7.14: {}
ccount@2.0.1: {}
chai@5.3.3: chai@5.3.3:
dependencies: dependencies:
assertion-error: 2.0.1 assertion-error: 2.0.1
@ -1749,6 +1956,8 @@ snapshots:
chalk@5.6.2: {} chalk@5.6.2: {}
character-entities@2.0.2: {}
check-error@2.1.3: {} check-error@2.1.3: {}
chokidar@4.0.3: chokidar@4.0.3:
@ -1788,10 +1997,20 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
deep-eql@5.0.2: {} deep-eql@5.0.2: {}
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
dequal@2.0.3: {}
devlop@1.1.0:
dependencies:
dequal: 2.0.3
environment@1.1.0: {} environment@1.1.0: {}
es-module-lexer@1.7.0: {} es-module-lexer@1.7.0: {}
@ -1884,6 +2103,8 @@ snapshots:
escape-string-regexp@2.0.0: {} escape-string-regexp@2.0.0: {}
escape-string-regexp@5.0.0: {}
estree-walker@3.0.3: estree-walker@3.0.3:
dependencies: dependencies:
'@types/estree': 1.0.9 '@types/estree': 1.0.9
@ -1909,6 +2130,8 @@ snapshots:
get-east-asian-width@1.7.0: {} get-east-asian-width@1.7.0: {}
has-flag@5.0.1: {}
ignore@6.0.2: {} ignore@6.0.2: {}
indent-string@5.0.0: {} indent-string@5.0.0: {}
@ -1963,12 +2186,331 @@ snapshots:
load-tsconfig@0.2.5: {} load-tsconfig@0.2.5: {}
longest-streak@3.1.0: {}
loupe@3.2.1: {} loupe@3.2.1: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.6.0 '@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: {} mimic-fn@2.1.0: {}
mlly@1.8.2: mlly@1.8.2:
@ -2119,6 +2661,13 @@ snapshots:
tinyglobby: 0.2.17 tinyglobby: 0.2.17
ts-interface-checker: 0.1.13 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: {} tagged-tag@1.0.0: {}
terminal-size@4.0.1: {} terminal-size@4.0.1: {}
@ -2198,6 +2747,25 @@ snapshots:
undici-types@6.21.0: {} 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): vite-node@2.1.9(@types/node@22.20.5):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
@ -2277,3 +2845,5 @@ snapshots:
ws@8.22.0: {} ws@8.22.0: {}
yoga-layout@3.2.1: {} yoga-layout@3.2.1: {}
zwitch@2.0.4: {}