diff --git a/CHANGELOG.md b/CHANGELOG.md index e033969..bdadf69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,46 @@ par cas. Une version se pose quand il y a quelque chose à annoncer, pas à chaque fusion. Une journée entière de montées de dépendances n'en produit aucune. +## [1.3.0] — 2026-08-20 + +Une version d'une seule capacité, et elle est grande : la **Passerelle**, qui relie une +messagerie à l'Atelier pour lancer, surveiller et débloquer une session quand on n'est pas +devant le poste. Elle est éteinte par défaut et le reste tant qu'on ne la configure pas. + +### Ce qui change pour vous + +- **Piloter l'Atelier depuis Telegram.** On écrit à un bot, AURA ouvre une session sur un + projet connu ou transmet le message à celle qui travaille déjà, et rend sa réponse. Une + conversation tient une session à la fois. `/sessions`, `/etat`, `/stop`, `/fin` disent et + font le reste ; tout autre message part comme un tour, sans syntaxe à retenir. +- **Consulter un projet sans ouvrir de session.** `/projets` donne un écran de navigation qui + descend l'arborescence dossier par dossier et ouvre un fichier — même inventaire et mêmes + gardes que la page Projet, aucun processus lancé, aucun jeton dépensé. +- **Les documents arrivent en documents.** Markdown traduit en messages riches — titres, + listes, citations, code coloré et vrais tableaux —, découpés en pages quand ils sont longs, + la coupe tombant sur une fin de ligne. Ce qui n'est pas du Markdown reste en chasse fixe. +- **Décider de loin.** Une demande de permission arrive avec ses deux boutons ; un plan arrive + entier, mis en forme, avant d'être approuvé. Une question de l'agent se pose comme à + l'écran, à choix simple ou multiple, et se répond aussi en écrivant. L'échéance du quart + d'heure de l'Atelier s'applique ici : sans réponse, la demande est refusée, jamais l'inverse. +- **Voir la fenêtre de contexte, et agir dessus.** `/etat` donne le compte exact et son + dénominateur, `/compacter` compacte sans attendre le débordement. AURA prévient quand la + fenêtre a été compactée — résumé replié à l'appui — et une fois quand elle passe les 80 %. +- **Savoir que ça travaille.** Le temps d'un tour, une bulle éphémère dit quels outils tournent + et depuis combien de temps, avec les mêmes libellés qu'à l'écran. + +### Ce qu'il faut peser avant de l'allumer + +- **La Passerelle est faite pour un usage personnel.** Ce qui transite passe par les serveurs + de la messagerie, sans chiffrement de bout en bout : elle n'est **pas recommandée en + contexte professionnel**. Une forme sans tiers est cherchée — un réseau privé rendant + l'Atelier joignable depuis un téléphone — mais elle n'existe pas aujourd'hui. +- **Ce qu'elle n'ouvre pas.** Aucun port : l'échange est sortant, le serveur continue de + n'écouter que `127.0.0.1`, et les gardes de l'API ne bougent pas. +- **Ce qu'elle accorde.** Un accès distant au poste. La liste blanche des conversations est la + serrure : elle est obligatoire, la Passerelle refuse de démarrer sans elle, et un message + venu d'ailleurs reste sans réponse. [SECURITY.md](SECURITY.md) détaille le reste. + ## [1.2.0] — 2026-08-18 Une version de la fiche projet : son volet de ressources retient enfin ce qu'on lui montre, @@ -133,6 +173,7 @@ collées, reprise d'une session existante. externe, aucun secret dans le navigateur. Voir [SECURITY.md](SECURITY.md). - Windows, exclusivement — la seule plateforme sur laquelle l'application a tourné. +[1.3.0]: https://github.com/Shaenn/aura/releases/tag/v1.3.0 [1.2.0]: https://github.com/Shaenn/aura/releases/tag/v1.2.0 [1.1.0]: https://github.com/Shaenn/aura/releases/tag/v1.1.0 [1.0.0]: https://github.com/Shaenn/aura/releases/tag/v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 42e9d85..506271c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,18 @@ Navigateur (SPA Quasar) ──/api/*──► BFF Fastify (server/) ──► AURA est un outil **local, mono-utilisateur** : aucun service externe, aucun secret, aucune authentification. Le front n'appelle que `/api/*` en même origine — pas de CORS. +**Une seule exception, et elle est optionnelle** : la Passerelle +(`server/passerelle/`) relie une messagerie à l'Atelier. Elle porte un secret, appelle un +service externe, et n'existe que si l'utilisateur la configure — sans jeton, rien ne démarre +et aucun appel ne sort. Elle n'ouvre **aucun port** : son long-polling est sortant, si bien +que l'écoute reste `127.0.0.1` et que `guard.ts` ne bouge pas. Ce qui la tient est sa liste +blanche de conversations, et elle refuse de démarrer sans elle. + +Elle vise l'usage personnel : ce qui transite passe par les serveurs de la messagerie, sans +chiffrement de bout en bout, donc elle **n'est pas recommandée en contexte professionnel**. Une +forme sans tiers reste à trouver — piste d'un réseau privé (Tailscale) joignant l'Atelier depuis +un téléphone, ce qui supposerait de l'adapter à cet écran. + - `src/` — SPA Vue 3 / Quasar. Voir `src/CLAUDE.md`. - `server/` — BFF Fastify. Voir `server/CLAUDE.md`. - `shared/` — types de _wire_ (`transcript.ts`, `context.ts`, `agent.ts`, `projects.ts`, diff --git a/README.en.md b/README.en.md index 1f11e89..81afefe 100644 --- a/README.en.md +++ b/README.en.md @@ -227,7 +227,7 @@ The detail — bounds, background work, resuming a session — is in ## The manual -AURA ships its own manual: **18 pages** that do not merely describe where to click, but **why +AURA ships its own manual: **19 pages** that do not merely describe where to click, but **why each screen is built the way it is** — why a permission that expires is denied and not granted, why the diagnostic thresholds are percentiles, why sub-agents get their own track. The `?` key opens the page matching the current screen. @@ -235,7 +235,8 @@ opens the page matching the current screen. It reads online, without installing anything: [Concepts](https://shaenn.github.io/aura/en/guide/concepts) · [Session replay](https://shaenn.github.io/aura/en/guide/replay) · -[Workshop](https://shaenn.github.io/aura/en/guide/atelier) · [Active sessions](https://shaenn.github.io/aura/en/guide/sessions) · +[Workshop](https://shaenn.github.io/aura/en/guide/atelier) · [Gateway](https://shaenn.github.io/aura/en/guide/passerelle) · +[Active sessions](https://shaenn.github.io/aura/en/guide/sessions) · [Diagnostic](https://shaenn.github.io/aura/en/guide/diagnostic) · [Usage & costs](https://shaenn.github.io/aura/en/guide/usage) · [all the pages](https://shaenn.github.io/aura/en/guide/concepts) @@ -285,6 +286,19 @@ leave the managed folder, whatever the number of `..`. The only process that talks to the outside is the Workshop agent, when you tell it to — and it uses the authentication of your Claude Code installation, not ours. +One thing alone can change that, and turning it on is your call: the +[Gateway](https://shaenn.github.io/aura/en/guide/passerelle), which links a messaging app to the +Workshop so you can drive a session remotely. It is **inert by default** — with no token nothing +starts and no call goes out. Turned on, it holds a secret and calls an external service, but +**opens no port**: its exchange is outbound, the listener stays on `127.0.0.1`. The power it +grants is that of remote access to your machine, and its allowlist of conversations is what +closes it again — without one, it refuses to start. + +What passes through travels through the messaging service's servers, with no end-to-end +encryption: the Gateway is made for personal use, and is **not recommended for professional +use**. A shape without a third party is being looked for — a private network making the Workshop +reachable from a phone — but it does not exist today. + [SECURITY.en.md](SECURITY.en.md) details the server's guards, what they do not cover, and how to report a flaw. @@ -349,14 +363,23 @@ The console window stays open: **it is the server**. Closing it stops AURA. ## Configuration -None. Two optional variables, to set in the environment or in `server/.env` (git-ignored, read -by `--env-file`): +None. A few optional variables, to set in the environment or in `server/.env` (git-ignored, +read by `--env-file`): | Variable | Default | Role | | ----------------- | ----------- | --------------------------------------------------------- | | `PORT` | `8800` | Server listening port — the target of Quasar's dev proxy. | | `AURA_CLAUDE_DIR` | `~/.claude` | Managed folder. Handy for working on a sandbox copy. | +The next three exist only for the [Gateway](https://shaenn.github.io/aura/en/guide/passerelle), +and **everything stays off as long as the first one is absent**: + +| Variable | Default | Role | +| --------------------- | --------- | ------------------------------------------------------------------------- | +| `AURA_TELEGRAM_TOKEN` | — | The bot token. Absent: the Gateway does not exist. | +| `AURA_TELEGRAM_CHATS` | — | The allowed conversations. **Required**: without it, it refuses to start. | +| `AURA_TELEGRAM_MODE` | `default` | Permission mode of sessions opened from afar. | + A new variable requires a full restart: hot reload does not re-read `--env-file`. --- diff --git a/README.md b/README.md index e7d1190..c293be8 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ Le détail — bornes, arrière-plan, reprise d'une session — est dans ## Le manuel -AURA embarque son propre manuel : **18 pages** qui ne décrivent pas seulement où cliquer, mais +AURA embarque son propre manuel : **19 pages** qui ne décrivent pas seulement où cliquer, mais **pourquoi chaque écran est fait ainsi** — pourquoi une permission qui expire est refusée et non accordée, pourquoi les seuils du diagnostic sont des percentiles, pourquoi les sous-agents ont leur propre piste. La touche `?` ouvre la page correspondant à l'écran courant. @@ -261,7 +261,8 @@ ont leur propre piste. La touche `?` ouvre la page correspondant à l'écran cou Il se lit en ligne, sans installer quoi que ce soit : [Concepts](https://shaenn.github.io/aura/guide/concepts) · [Rejeu de session](https://shaenn.github.io/aura/guide/replay) · -[Atelier](https://shaenn.github.io/aura/guide/atelier) · [Sessions actives](https://shaenn.github.io/aura/guide/sessions) · +[Atelier](https://shaenn.github.io/aura/guide/atelier) · [Passerelle](https://shaenn.github.io/aura/guide/passerelle) · +[Sessions actives](https://shaenn.github.io/aura/guide/sessions) · [Diagnostic](https://shaenn.github.io/aura/guide/diagnostic) · [Usage & coûts](https://shaenn.github.io/aura/guide/usage) · [toutes les pages](https://shaenn.github.io/aura/guide/concepts) @@ -312,6 +313,19 @@ vérifié : il ne peut pas sortir du dossier géré, quel que soit le nombre de Le seul processus qui parle à l'extérieur est l'agent de l'Atelier, quand vous lui en donnez l'ordre — et il utilise l'authentification de votre installation Claude Code, pas la nôtre. +Une seule chose peut changer cela, et c'est vous qui décidez de l'allumer : la +[Passerelle](https://shaenn.github.io/aura/guide/passerelle), qui relie une messagerie à +l'Atelier pour piloter une session à distance. Elle est **inerte par défaut** — sans jeton, +rien ne démarre et aucun appel ne sort. Activée, elle porte un secret et appelle un service +externe, mais **n'ouvre aucun port** : son échange est sortant, l'écoute reste `127.0.0.1`. +Le pouvoir qu'elle accorde est celui d'un accès distant à votre poste, et sa liste blanche de +conversations est ce qui le referme — sans elle, elle refuse de démarrer. + +Ce qui transite passe par les serveurs de la messagerie, sans chiffrement de bout en bout : la +Passerelle est faite pour un usage personnel, et **n'est pas recommandée pour un usage +professionnel**. Une forme sans tiers est cherchée — un réseau privé rendant l'Atelier joignable +depuis un téléphone — mais elle n'existe pas aujourd'hui. + [SECURITY.md](SECURITY.md) détaille les gardes du serveur, ce qu'elles ne couvrent pas, et comment signaler une faille. @@ -398,7 +412,7 @@ La fenêtre de console reste ouverte : **c'est le serveur**. La fermer arrête A ## Configuration -Aucune. Deux variables facultatives, à poser dans l'environnement ou dans `server/.env` +Aucune. Quelques variables facultatives, à poser dans l'environnement ou dans `server/.env` (ignoré par git, lu par `--env-file`) : | Variable | Défaut | Rôle | @@ -406,6 +420,16 @@ Aucune. Deux variables facultatives, à poser dans l'environnement ou dans `serv | `PORT` | `8800` | Port d'écoute du serveur — cible du proxy dev de Quasar. | | `AURA_CLAUDE_DIR` | `~/.claude` | Dossier géré. Pratique pour travailler sur une copie sandbox. | +Les trois suivantes n'existent que pour la +[Passerelle](https://shaenn.github.io/aura/guide/passerelle), et **tout reste éteint tant que +la première est absente** : + +| Variable | Défaut | Rôle | +| --------------------- | --------- | ----------------------------------------------------------------------------- | +| `AURA_TELEGRAM_TOKEN` | — | Le jeton du bot. Absent : la Passerelle n'existe pas. | +| `AURA_TELEGRAM_CHATS` | — | Les conversations autorisées. **Obligatoire** : sans elle, refus de démarrer. | +| `AURA_TELEGRAM_MODE` | `default` | Mode de permission des sessions ouvertes de loin. | + Une nouvelle variable demande un redémarrage complet : le rechargement à chaud ne relit pas `--env-file`. diff --git a/SECURITY.en.md b/SECURITY.en.md index 6fba9e2..c96711d 100644 --- a/SECURITY.en.md +++ b/SECURITY.en.md @@ -9,7 +9,8 @@ application guarantees, what it does not, and how to report a flaw. ## The model AURA is a **local, single-user** tool. There is no account, no password, no external service: -the boundary is the machine itself. +the boundary is the machine itself. One feature, off by default, moves that boundary — see +_The Gateway_ below. - The BFF **listens on `127.0.0.1` only**, with no option to open it up. No device on the network can reach it. @@ -26,6 +27,46 @@ the boundary is the machine itself. file changed on disk in the meantime. - Internal errors never return an absolute path: the detail stays in the server log. +## The Gateway, and what it changes + +One feature alone steps outside this model, and **it exists only if you turn it on**: the +Gateway, which links a messaging app to the Workshop so you can drive a session remotely. With +no token configured it does not start, calls nothing, and everything above stays true word for +word. + +What it does not change: + +- **It opens no port.** The exchange is outbound — the server goes and fetches messages. The + BFF still listens on `127.0.0.1` only, and the `Host` and `Sec-Fetch-Site` guards are + untouched. +- **It does not go through the API.** It calls the session registry in the same process: no + route is opened, no request needs authenticating. + +What it does change, and what you should weigh: + +- **A secret now exists.** The bot token lives in `server/.env`, un-versioned. It does not + travel through the server's shared configuration and no route is able to hand it back. +- **The server calls an external service.** Your messages travel through it. +- **It is remote access to your machine.** Whoever writes in an allowed conversation can open + a session, have it run a command and approve a write. The allowlist of conversations is the + only guard against that: it is **required**, the Gateway refuses to start without it, and a + message from anywhere else gets no reply. +- **The channel's safety becomes yours.** Anyone who gains access to an allowed conversation — + an unlocked device, a compromised account — gains that same power. AURA cannot tell them + apart from you. + +**Personal use is what the Gateway is made for; professional use is not.** Everything that +passes through — your messages, the agent's answers, the contents of files consulted remotely — +travels through the messaging service's servers, with no end-to-end encryption: a conversation +with a bot offers none. The trade-off holds for personal projects; it does not hold for company +code or client data. A shape without a third party is being looked for — a private network +(Tailscale or equivalent) making the Workshop reachable from a phone without exposing anything, +which would mean adapting the interface to that screen — but it does not exist today, and +nothing in the code tells a personal project from a work one. + +Permission requests are still raised, and still deny themselves when unanswered. +`AURA_TELEGRAM_MODE=plan` opens remote sessions in plan mode, where nothing executes. + ## What is not covered - **The other processes in your session.** Anything running under your account can reach diff --git a/SECURITY.md b/SECURITY.md index 6f26d12..a4bdb58 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,7 +9,8 @@ l'application garantit, ce qu'elle ne garantit pas, et comment signaler un défa ## Le modèle AURA est un outil **local et mono-utilisateur**. Il n'y a ni compte, ni mot de passe, ni -service externe : la frontière est la machine elle-même. +service externe : la frontière est la machine elle-même. Une seule fonctionnalité, éteinte par +défaut, déplace cette frontière — voir _La Passerelle_ plus bas. - Le BFF **n'écoute que `127.0.0.1`**, sans option pour en sortir. Aucun appareil du réseau ne peut l'atteindre. @@ -27,6 +28,50 @@ service externe : la frontière est la machine elle-même. - Les erreurs internes ne renvoient jamais de chemin absolu : le détail reste au journal du serveur. +## La Passerelle, et ce qu'elle change + +Une seule fonctionnalité sort de ce modèle, et **elle n'existe que si vous l'activez** : la +Passerelle, qui relie une messagerie à l'Atelier pour piloter une session à distance. Sans +jeton configuré, elle ne démarre pas, n'appelle rien, et ce qui précède reste vrai mot pour +mot. + +Ce qu'elle ne change pas : + +- **Elle n'ouvre aucun port.** L'échange est sortant — c'est le serveur qui va chercher les + messages. Le BFF continue de n'écouter que `127.0.0.1`, et les gardes `Host` et + `Sec-Fetch-Site` sont inchangées. +- **Elle ne passe pas par l'API.** Elle appelle le registre de sessions dans le même + processus : aucune route n'est ouverte, aucune requête n'est à authentifier. + +Ce qu'elle change, et qu'il faut peser : + +- **Un secret existe désormais.** Le jeton du bot vit dans `server/.env`, non versionné. Il + ne traverse pas la configuration partagée du serveur et aucune route n'est en mesure de le + renvoyer. +- **Le serveur appelle un service externe.** Vos messages transitent par ce service. +- **C'est un accès distant à votre machine.** Qui écrit dans une conversation autorisée peut + ouvrir une session, lui faire exécuter une commande et approuver une écriture. La liste + blanche des conversations est la seule garde qui l'en empêche : elle est **obligatoire**, + la Passerelle refuse de démarrer sans elle, et un message venu d'ailleurs reste sans + réponse. +- **La sûreté du canal devient la vôtre.** Quiconque obtient l'accès à une conversation + autorisée — appareil déverrouillé, compte compromis — obtient ce même pouvoir. AURA ne + peut pas le distinguer de vous. + +**L'usage personnel est celui pour lequel la Passerelle est faite ; l'usage professionnel ne +l'est pas.** Tout ce qui transite — vos messages, les réponses de l'agent, le contenu des +fichiers consultés de loin — passe par les serveurs de la messagerie, sans chiffrement de bout +en bout : une conversation avec un bot n'en offre pas. Le compromis se tient pour des projets +personnels ; il ne se tient pas pour du code d'entreprise ou des données de clients. Une forme +sans tiers est cherchée — un réseau privé (Tailscale ou équivalent) rendant l'Atelier joignable +depuis un téléphone sans rien exposer, ce qui demanderait d'adapter l'interface à cet écran — +mais elle n'existe pas aujourd'hui, et rien dans le code ne distingue un projet personnel d'un +projet de travail. + +Les demandes de permission continuent d'être posées, et se refusent d'elles-mêmes sans +réponse. `AURA_TELEGRAM_MODE=plan` ouvre les sessions distantes en mode plan, où rien ne +s'exécute. + ## Ce qui n'est pas couvert - **Les autres processus de votre session.** Tout ce qui tourne sous votre compte peut diff --git a/package.json b/package.json index 2706450..72b5257 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aura", - "version": "1.2.0", + "version": "1.3.0", "description": "AURA — Assistant Unifié des Ressources Agentiques : poste de pilotage du dossier ~/.claude", "productName": "AURA", "author": "Shaenn", @@ -14,10 +14,10 @@ "test:watch": "vitest", "format": "prettier --write \"**/*.{js,ts,vue,css,scss,html,md,json}\" --ignore-path .gitignore", "dev": "quasar dev", - "server": "node --watch --import tsx server/index.ts", - "dev:all": "pnpm stop && concurrently -k -n web,api -c blue,magenta \"quasar dev\" \"node --watch --import tsx server/index.ts\"", + "server": "node --env-file-if-exists=server/.env --env-file-if-exists=server/.env.local --watch --import tsx server/index.ts", + "dev:all": "pnpm stop && concurrently -k -n web,api -c blue,magenta \"quasar dev\" \"node --env-file-if-exists=server/.env --env-file-if-exists=server/.env.local --watch --import tsx server/index.ts\"", "stop": "node scripts/free-ports.mjs 8800 9100", - "start": "tsx server/index.ts", + "start": "node --env-file-if-exists=server/.env --env-file-if-exists=server/.env.local --import tsx server/index.ts", "build": "quasar build", "site:dev": "node scripts/sync-site.mjs && vitepress dev site", "site:build": "node scripts/sync-site.mjs && vitepress build site", @@ -37,6 +37,7 @@ "lottie-web": "^5.13.0", "markdown-it": "^15.0.0", "mermaid": "^11.16.1", + "node-telegram-bot-api": "^2.0.0", "pinia": "^4.0.3", "quasar": "^2.24.0", "vue": "^3.5.41", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b015f6b..3e22903 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: mermaid: specifier: ^11.16.1 version: 11.16.1 + node-telegram-bot-api: + specifier: ^2.0.0 + version: 2.0.0 pinia: specifier: ^4.0.3 version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) @@ -2977,6 +2980,10 @@ packages: resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} + node-telegram-bot-api@2.0.0: + resolution: {integrity: sha512-Gqo1HNYkIfYnHy25rEMKFFMVLqG+EUmH+C5HLsOguXi4WazaGWwIu9vigMLARv+cKONdU6x9Fj4lKNBLf7h/Uw==} + engines: {node: '>=18'} + nostics@1.2.0: resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} @@ -6927,6 +6934,8 @@ snapshots: node-releases@2.0.53: {} + node-telegram-bot-api@2.0.0: {} + nostics@1.2.0: {} npm-run-path@6.0.0: diff --git a/server/.env.example b/server/.env.example index 6bef1d4..a56635d 100644 --- a/server/.env.example +++ b/server/.env.example @@ -9,3 +9,29 @@ PORT=8800 # Dossier .claude géré par AURA. Défaut : /.claude # Surcharger p.ex. pour tester sur une copie sandbox. # AURA_CLAUDE_DIR=C:\Users\jean.dupont\.claude + +# ── La Passerelle : piloter l'Atelier depuis une messagerie ────────────────── +# +# Entièrement optionnelle, et inerte par défaut : sans jeton, rien ne démarre et +# aucun appel ne sort. Elle n'ouvre aucun port — son long-polling est sortant, et +# le BFF continue de n'écouter que 127.0.0.1. +# +# Trois choses à peser avant de l'activer : +# - le jeton est un secret, et AURA n'en portait aucun jusqu'ici ; +# - AURA appellera api.telegram.org, un service externe ; +# - qui écrit dans une conversation autorisée peut ouvrir une session, lui +# faire lancer une commande et autoriser une écriture. C'est un accès distant +# à ce poste, et AURA_TELEGRAM_CHATS est ce qui le referme. + +# Le jeton du bot, donné par @BotFather. Absent : la Passerelle n'existe pas. +# AURA_TELEGRAM_TOKEN=123456:ABC-DEF... + +# Les conversations autorisées, séparées par des virgules. SANS ELLE, LA +# PASSERELLE REFUSE DE DÉMARRER — l'omission ne doit pas ouvrir la machine au +# premier venu. Un identifiant de groupe est négatif. +# AURA_TELEGRAM_CHATS=123456789 + +# Le mode de permission des sessions ouvertes de loin : default, auto, +# acceptEdits ou plan. Défaut : default — chaque outil sensible demande, et la +# demande se refuse d'elle-même après un quart d'heure sans réponse. +# AURA_TELEGRAM_MODE=default diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 56321e5..6e2c2f2 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -22,6 +22,9 @@ Deux garanties y sont centralisées : ### Les exceptions, et elles sont deux +_(Une troisième surface sort du dossier géré sans y toucher : la Passerelle, plus bas — elle +ne lit ni n'écrit `~/.claude`, elle parle au registre de l'Atelier.)_ + `processes.ts` sort de `~/.claude` : il énumère les processus Claude du système et sait en terminer. C'était le seul moyen de montrer ce que le disque ne déclare pas — un daemon, un hôte de pseudo-terminal, le pont de l'extension n'écrivent aucun fichier de session. @@ -72,6 +75,81 @@ Les backups vivent dans `.local/backups`, restaurables depuis la page Sauvegarde 4. Les formes échangées avec le front vont dans `shared/`, pas dans `server/`. 5. Côté front, un service `src/services//index.ts`. +## La Passerelle (`passerelle/`) + +Piloter l'Atelier depuis une messagerie. **Optionnelle et inerte par défaut** : sans +`AURA_TELEGRAM_TOKEN`, rien ne démarre et aucun appel ne sort. + +Ce qui la rend acceptable tient en une phrase : **elle n'ouvre aucun port**. Le long-polling +est sortant, donc l'écoute reste `127.0.0.1` et `guard.ts` est inchangé. Elle ne passe pas non +plus par HTTP — même process que le registre, qu'elle appelle directement. Il n'y a donc pas +de requête à authentifier, et pas une route à ouvrir. + +Trois gardes, et elles ne se relâchent pas sans décision : + +- **la liste blanche `AURA_TELEGRAM_CHATS` est obligatoire** — jeton sans liste, la Passerelle + refuse de démarrer. L'omission ne doit pas ouvrir la machine au premier venu ; +- un message venu d'ailleurs est ignoré **en silence** : répondre confirmerait l'existence du + bot ; +- le jeton ne traverse pas `ServerEnv`, que tout le serveur importe : il est lu dans le seul + module qui en a besoin, et aucune route n'est en mesure de le renvoyer. + +Le pouvoir accordé reste considérable — qui écrit dans une conversation autorisée peut faire +lancer une commande et autoriser une écriture. Les demandes de permission partent en boutons ; +sans réponse, le garde-fou d'`agent/runner.ts` les refuse après un quart d'heure. + +Les questions de l'agent (`ask-request`) suivent `passerelle/questions.ts`, qui porte le +formulaire de `src/components/agent/AskPrompt.vue` : une question par message, le choix +multiple qui se coche, et la réponse écrite quand aucune option ne convient. Deux contraintes +mesurées le dessinent — **un message riche ne se réécrit pas** (la bibliothèque n'expose aucun +`editMessageRichText`, seulement `editMessageReplyMarkup`), et `callback_data` tient 64 octets. +Comme `routage.ts`, ce fichier décide sans réseau : c'est ce qui le rend testable. + +`/etat` rend la fenêtre de contexte, et le chiffre ne peut pas diverger de la page Contexte : +`SessionRunner` le relève sur `message.message.usage` comme +`input + cache_read + cache_creation`, **la somme même** dont `transcript.ts` ancre un tour +(`settleTurn`). Le runner relève et ne juge pas ; la limite vient de `contextLimitFor`, qui +n'est pas facultatif — un modèle à fenêtre longue s'enregistre **sans** son suffixe `[1m]`, et +seules deux preuves la révèlent : un contexte observé au-dessus de 200 k, ou les réglages +(`claude/model.ts`). Le seuil d'alerte de `passerelle/etat.ts` recopie le garde-fou de +`contextFill` (`diagnostics/thresholds.ts`) ; un test tient les deux nombres égaux. + +`/compacter` est la **seule** commande de Claude Code que la Passerelle relaie, et elle passe +par la file d'entrée comme un tour. Le résumé qu'une compaction produit n'arrive **pas** avec +la frontière : le SDK l'envoie juste après, dans un message `user` marqué `isSynthetic` dont le +contenu est une chaîne. `runner.ts` le capte et le pose sur l'événement de compaction par un +`replace-event` — la compaction s'annonce donc tout de suite, sans quoi un résumé qui ne +viendrait pas l'emporterait dans son silence. Elle le rend replié, par le bloc riche `details` +(voir `blocs-riches.md`) : c'est le seul repli explicite de l'API, l'« Afficher plus » +automatique ne s'étant pas déclenché sur sept mille caractères. + +`ExitPlanMode` est la seule demande de permission que la Passerelle rend en entier +(`passerelle/plan.ts`) : partout ailleurs le nom de l'outil et son chemin suffisent à juger, +ici l'argument **est** la décision. Le CLI passe le markdown en clair dans `input.plan` — et +`input.planFilePath` à côté, qui ne se lit pas de loin. Le rendu emprunte `envoieRendu`, dont +les trois barreaux valent ici comme pour un document : le plan vient du modèle, sa structure +n'est pas garantie. + +Les boutons d'une permission se retirent sur `permission-settled` et non sur le clic : ce +seul point de passage voit aussi les réponses données dans l'Atelier et les refus du garde-fou +du quart d'heure. + +Les événements d'un fil se traitent **à la file** (`attache`) : deux `applique` lancés de front +font des appels réseau qui arrivent dans l'ordre où Telegram les sert, et le résumé doublait +l'annonce qui le précédait. + +Une session pilotée d'ici **doit** rester abonnée (`runner.subscribe`) : c'est l'abonnement, et +lui seul, qui la protège du balayeur d'`agent/registry.ts`. + +Tout ce qui décide vit dans `passerelle/routage.ts`, sans réseau ni registre — c'est ce qui +rend la garde vérifiable par un test (`test/passerelle.test.ts`). + +Le rendu des documents a son propre piège : **l'API des messages riches accepte en silence +les champs qu'elle ne connaît pas**, si bien qu'une faute de nom ne produit pas d'erreur mais +un tableau sans en-tête. `passerelle/blocs-riches.md` recense les blocs, les noms exacts, et +les endroits où le comportement observé contredit la documentation — le lire avant de +toucher à `riche.ts`. + ## Messages d'erreur Une erreur renvoyée au front sera lue telle quelle par l'utilisateur : elle suit la charte de diff --git a/server/agent/runner.ts b/server/agent/runner.ts index fe7fffe..2180d15 100644 --- a/server/agent/runner.ts +++ b/server/agent/runner.ts @@ -36,7 +36,7 @@ import { Translator } from './translate.ts'; import { longPath, projectSlug } from './slug.ts'; import { PendingAnswer } from './pending.ts'; import { ASK_TOOL, createAskServer, harnessSentence, NO_ANSWER } from './ask.ts'; -import { str } from '../json.ts'; +import { num, str } from '../json.ts'; type Rec = Record; @@ -139,6 +139,29 @@ export class SessionRunner { /** Ce que l'agent fait *maintenant* — un présent, pas une histoire. */ private readonly activity = new ActivityTracker(); private lastActivityAt = 0; + /** + * Ce que la fenêtre de contexte porte, relevé sur les réponses du modèle. + * + * Le total est **exact**, et c'est tout l'intérêt : `input + cache_read + + * cache_creation` est la même somme que `transcript.ts` emploie pour ancrer la + * page Contexte (voir `settleTurn`). Deux surfaces qui lisent la même session + * ne peuvent donc pas annoncer deux remplissages différents — mais celle-ci + * n'a aucun fichier à relire. + * + * Le runner **relève et ne juge pas** : ni limite, ni pourcentage, ni seuil. + * Rapporter ce nombre à la fenêtre du modèle demande `contextLimitFor`, et + * décider s'il mérite qu'on en parle est l'affaire de qui l'affiche. + */ + private readonly fenetre = { tokens: 0, max: 0 }; + /** + * La compaction qui attend son résumé, s'il y en a une. + * + * Le SDK envoie la frontière, puis **séparément** le résumé — un message + * `user` marqué `isSynthetic`, dont le contenu est la conversation entière + * réécrite. Mesuré : il suit immédiatement, et rien d'autre ne s'intercale. + * Ce champ est ce qui relie les deux, et il ne vit qu'entre eux. + */ + private compactionSansResume: string | null = null; /** Ce que la session a lancé en arrière-plan, et qui lui survit. */ private readonly shells = new ShellTracker(); private shellPoll: ReturnType | null = null; @@ -293,6 +316,18 @@ export class SessionRunner { return Date.now() - this.touchedAt > ttlMs; } + /** + * Ce que la fenêtre porte, et le plus grand contexte jamais observé. + * + * `max` n'est pas un superlatif décoratif : c'est la **preuve** qu'attend + * `contextLimitFor`. Un modèle à fenêtre longue s'enregistre sans son suffixe + * `[1m]`, si bien qu'un contexte dépassant 200 k est le seul témoin certain de + * la grande fenêtre. Une copie, pour que personne n'écrive dans le relevé. + */ + get contextWindow(): { tokens: number; max: number } { + return { ...this.fenetre }; + } + private emit(upserts: AgentUpsert[]): void { for (const upsert of upserts) { for (const send of this.subscribers) { @@ -336,6 +371,11 @@ export class SessionRunner { */ private resetConversation(): void { this.translator.reset(); + // La fenêtre est vide dès maintenant, et non au prochain tour : sans cela, + // qui demande son état juste après un `/clear` lirait le remplissage d'une + // conversation qui n'existe plus. `max` survit — il ne dit rien de cette + // conversation-ci, il prouve la taille de la fenêtre du modèle. + this.fenetre.tokens = 0; // Le SDK émet aussi ce message hors `/clear` — sortie du mode plan, ouverture // d'une session neuve. On ne nomme donc la commande que si c'est bien elle // qu'on vient d'envoyer. @@ -806,6 +846,52 @@ export class SessionRunner { } } + /** + * Le contexte d'une réponse, tel que le modèle l'a facturé. + * + * Les trois termes, et pas seulement `input_tokens` : ce qui est relu du cache + * occupe la fenêtre exactement comme ce qui ne l'est pas — c'est le prix qui + * diffère, pas la place. Ne compter que `input_tokens` sur une session bien + * cachée annoncerait quelques milliers de tokens là où la fenêtre en porte + * cent mille. + * + * Un `usage` absent ou vide ne remet rien à zéro : une réponse sans relevé ne + * prouve pas que la fenêtre s'est vidée, elle ne dit rien. + */ + private releveFenetre(usage: Rec): void { + const total = + num(usage.input_tokens) + + num(usage.cache_read_input_tokens) + + num(usage.cache_creation_input_tokens); + if (total <= 0) return; + this.fenetre.tokens = total; + if (total > this.fenetre.max) this.fenetre.max = total; + } + + /** + * Le message qui suit une compaction porte-t-il son résumé ? + * + * Trois conditions, et les trois comptent. Une compaction doit attendre — + * sinon le message est un tour ordinaire. `isSynthetic` distingue le résumé du + * `` qui le suit, lequel porte `isReplay` et ne dit que + * « Compacted ». Et le contenu doit être une **chaîne** : un tour ordinaire + * porte une liste de blocs, jamais du texte nu. + * + * Rendre `true` consomme le message : il n'a rien à faire dans le fil sous sa + * forme brute — c'est la conversation entière réécrite, et `onUser` n'en + * tirerait de toute façon rien, n'y cherchant que des résultats d'outils. + */ + private capteResume(message: Rec): boolean { + const uuid = this.compactionSansResume; + if (!uuid || message.isSynthetic !== true) return false; + this.compactionSansResume = null; + + const contenu = rec(message.message).content; + if (typeof contenu !== 'string') return false; + this.emit(this.translator.attachSummary(uuid, contenu)); + return true; + } + private consume(message: Rec): void { // Avant tout dispatch : la plupart des messages qui disent où en est l'agent // ne produisent aucun événement de timeline, et sortaient donc par le @@ -831,7 +917,21 @@ export class SessionRunner { // disait rien avant le tour suivant. Les autres sous-types ne portent que // de la machinerie, et sortent par le bas comme avant. if (str(message.subtype) === 'compact_boundary') { - this.emit(this.translator.appendCompaction(message)); + // Une compaction change la fenêtre sans qu'aucune réponse ne le dise : + // sans ces deux lignes, le relevé resterait celui d'avant jusqu'au + // tour suivant — c'est-à-dire faux précisément au moment où l'on + // regarde. `pre_tokens` est par ailleurs le plus grand contexte que + // cette session ait porté, et souvent le premier à dépasser 200 k : + // c'est ici, plus tôt que partout ailleurs, que la grande fenêtre se + // prouve. + const meta = rec(message.compact_metadata); + const avant = num(meta.pre_tokens); + if (avant > this.fenetre.max) this.fenetre.max = avant; + this.fenetre.tokens = num(meta.post_tokens); + const upserts = this.translator.appendCompaction(message); + const premier = upserts[0]; + this.compactionSansResume = premier?.kind === 'append-event' ? premier.event.uuid : null; + this.emit(upserts); return; } // Le CLI pousse la liste entière dès qu'elle bouge — un Skill découvert @@ -848,9 +948,11 @@ export class SessionRunner { this.emit(this.translator.onStreamEvent(message)); return; case 'assistant': + this.releveFenetre(rec(rec(message.message).usage)); this.emit(this.translator.onAssistant(message)); return; case 'user': + if (this.capteResume(message)) return; this.emit(this.translator.onUser(message)); return; case 'result': diff --git a/server/agent/translate.ts b/server/agent/translate.ts index 7ad012e..cb05d1a 100644 --- a/server/agent/translate.ts +++ b/server/agent/translate.ts @@ -246,6 +246,25 @@ export class Translator { return [{ kind: 'append-event', event }]; } + /** + * Pose sur une compaction le résumé qu'elle a produit. + * + * Il n'arrive pas avec la frontière mais **juste après**, dans le message que + * le CLI se renvoie à lui-même pour recharger la conversation. D'où ces deux + * temps : la compaction s'annonce tout de suite — sans quoi un résumé qui ne + * viendrait pas l'emporterait dans son silence — et se complète ensuite. + * + * Les blocs sont ceux qu'un événement porte d'ordinaire : le relecteur de + * transcript en produit exactement autant pour cette même compaction, si bien + * que le direct et la relecture ne racontent pas deux histoires. + */ + attachSummary(uuid: string, text: string): AgentUpsert[] { + const event = this.byUuid.get(uuid); + if (!event || !text.trim()) return []; + event.blocks = [{ kind: 'text', text }]; + return [{ kind: 'replace-event', event }]; + } + // ── Flux vivant ─────────────────────────────────────────────────────────── onStreamEvent(message: Rec): AgentUpsert[] { diff --git a/server/env.ts b/server/env.ts index 5f2cc93..57067ab 100644 --- a/server/env.ts +++ b/server/env.ts @@ -1,8 +1,15 @@ // Server-side configuration for the BFF. // -// AURA is a local, single-user tool: no external service, no secret. The only -// knob is the port and an optional override of the managed .claude directory -// (handy for tests or a sandboxed copy). +// AURA is a local, single-user tool. Two knobs live here: the port, and an +// optional override of the managed .claude directory (handy for tests or a +// sandboxed copy). +// +// Il y a désormais une exception à « aucun service externe, aucun secret », et +// elle est volontairement tenue à l'écart d'ici : la Passerelle +// (`passerelle/index.ts`) lit `AURA_TELEGRAM_TOKEN` et `AURA_TELEGRAM_CHATS` +// directement dans l'environnement. Ce jeton ne traverse donc pas `ServerEnv`, +// que tout le serveur importe — il reste dans le seul module qui en a besoin, et +// aucune route n'est en mesure de le renvoyer par mégarde. import { homedir } from 'node:os'; import { join, normalize, sep } from 'node:path'; diff --git a/server/i18n/en.ts b/server/i18n/en.ts index ba94e27..0c40b1c 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -77,6 +77,89 @@ const en: Catalog = { noPowerShell: 'No PowerShell host found.', }, + /** What AURA says in a messaging app. See the French catalogue for the why. */ + passerelle: { + accueil: 'I drive the Claude Code Workshop from this conversation.', + accueilProjets: 'I know {n} projects.', + accueilUnProjet: 'I know one project.', + accueilAucunProjet: 'Claude Code has not worked on any project here yet.', + accueilSession: 'A session is already open here, on {cwd}.', + accueilTravaux: '{n} sessions are running right now.', + accueilUnTravail: 'One session is running right now.', + menuProjets: 'Projects', + menuSessions: 'Sessions', + menuAide: 'Help', + aideEntete: 'I drive the Workshop from this conversation.', + aidePied: 'Any other message goes to the session as a turn.', + aideConsulter: 'Browsing, without starting anything:', + aideTravailler: 'Working:', + aideArgument: '', + commandes: { + projets: 'The projects Claude Code knows, numbered.', + projet: 'A project’s tree: you walk down folder by folder.', + voir: 'The contents of a file from the last list.', + atelier: 'I open a session on that project.', + etat: 'Where this conversation’s session stands, and its context window.', + compacter: 'I compact the conversation without waiting for the window to fill.', + sessions: 'What is running right now.', + stop: 'I interrupt the current turn.', + fin: 'I close this conversation’s session.', + aide: 'I repeat what I can do.', + }, + sessionOuverte: 'Session open on {cwd}. Tell me what needs doing.', + projets: 'The projects I know. The number works with /projet and /atelier.', + aucunProjet: 'Claude Code has not worked on any project here yet.', + projetInconnu: 'I do not recognise that project. /projets lists the ones I open, numbered.', + dossier: '{ou} — {total} files.', + ouvrirIci: '▶ Open the Workshop here', + retourProjets: '◀ Projects', + remonter: '◀ Parent folder', + navigationPerimee: 'This list predates my restart. Run /projets again.', + projetVide: 'I find nothing to read in {nom}.', + aucuneListe: 'Pick a project first with /projet .', + fichierInconnu: 'That number matches no file in the last list.', + pageDe: '{fichier} — page {page} of {total}', + precedent: '◀ Previous', + suivant: 'Next ▶', + aucunFil: 'No session is open here. Open one with /atelier .', + aucuneSession: 'Nothing is running right now.', + sessionsAtelier: 'Opened by AURA — I can talk to these:', + sessionsAilleurs: 'Opened elsewhere — I can see them, I do not drive them:', + sessionFinie: 'The session ended.', + sessionEchouee: 'The session stopped: {message}', + etatEntete: '{cwd} — {modele}, {mode} mode', + etatModeleInconnu: 'unknown model', + etatFenetre: 'Window: {tokens} / {limite} tokens — {pourcent}%', + etatSansReleve: 'No turn has answered yet: I have no reading of the window.', + compaction: 'I compacted the conversation: {avant} tokens brought down to {apres}.', + compactionResume: 'What I kept from the conversation', + fenetrePleine: 'The window is {pourcent}% full — a compaction is coming.', + permission: 'I would like to use {outil}.', + autoriser: 'Allow', + plan: 'Here is the plan I propose. I write nothing before you approve it.', + approuver: 'Approve', + refuser: 'Deny', + refuseDeLoin: 'Denied from the messaging app.', + questionEtape: '{header} — question {n} of {total}', + questionMultiple: 'Several answers are expected.', + questionLibre: 'Tap an option, or write your own answer.', + questionValider: 'Confirm', + questionExpiree: 'Nobody answered this question within fifteen minutes; I let it go.', + commandeInconnue: 'I do not know {commande}. /aide lists what I can do.', + /** The ephemeral bubble shown while a turn is working. */ + activite: { + ligne: '{quoi} — {duree}', + requesting: 'Request in flight', + thinking: 'Thinking', + writing: 'Writing', + compacting: 'Compacting context', + retrying: 'Retry {attempt}/{max}', + toolUnnamed: 'Tool running', + secondes: '{n}s', + minutes: '{min}m {s}s', + }, + }, + hooks: { failed: 'The hook failed (code {code}).', blocked: 'The hook stopped the turn from continuing.', diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index 4b0ab70..f416719 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -86,6 +86,177 @@ export default { noPowerShell: 'Aucun hôte PowerShell trouvé.', }, + /** + * Ce qu'AURA dit dans une messagerie. + * + * Même charte qu'à l'écran, avec une contrainte de plus : l'interlocuteur ne + * voit pas l'Atelier. Un message doit donc porter son propre contexte — dire + * quelle session, quel dossier — là où l'écran le montrait déjà. + */ + passerelle: { + /** + * L'aide se compose à partir de `passerelle/commandes.ts` : ces morceaux + * sont les seuls à écrire, et la liste des commandes n'est plus recopiée. + */ + /** + * L'accueil, et il constate plutôt qu'il ne se présente. + * + * `docs/voix.md` range l'accueil parmi les surfaces où AURA parle d'elle : + * le « je » doit y porter une information que la formulation impersonnelle + * ne portait pas. D'où l'état du parc et de la conversation, plutôt qu'un + * sommaire de ce que les boutons montrent déjà. + */ + accueil: 'Je pilote l’Atelier de Claude Code depuis cette conversation.', + accueilProjets: 'Je connais {n} projets.', + accueilUnProjet: 'Je connais un projet.', + accueilAucunProjet: 'Claude Code n’a encore travaillé sur aucun projet ici.', + accueilSession: 'Une session est déjà ouverte ici, sur {cwd}.', + accueilTravaux: '{n} sessions tournent en ce moment.', + accueilUnTravail: 'Une session tourne en ce moment.', + menuProjets: 'Projets', + menuSessions: 'Sessions', + menuAide: 'Aide', + aideEntete: 'Je pilote l’Atelier depuis cette conversation.', + aidePied: 'Tout autre message part à la session comme un tour.', + aideConsulter: 'Consulter, sans rien lancer :', + aideTravailler: 'Travailler :', + /** L'argument d'une commande qui en prend un, tel qu'il s'écrit dans l'aide. */ + aideArgument: '', + /** + * Ce que fait chaque commande — une ligne, à la première personne. + * + * Elles servent deux fois : dans l'aide, et dans la liste que Telegram + * propose sous le `/`. La seconde les affiche seules, sans le reste du + * message : elles doivent donc se suffire à elles-mêmes. + */ + commandes: { + projets: 'Les projets que Claude Code connaît, numérotés.', + projet: 'L’arborescence d’un projet : on descend dossier par dossier.', + voir: 'Le contenu d’un fichier de la dernière liste.', + atelier: 'J’ouvre une session sur ce projet.', + etat: 'Où en est la session d’ici, et sa fenêtre de contexte.', + compacter: 'Je compacte la conversation sans attendre que la fenêtre déborde.', + sessions: 'Ce qui tourne en ce moment.', + stop: 'J’interromps le tour en cours.', + fin: 'Je ferme la session de cette conversation.', + aide: 'Je répète ce que je sais faire.', + }, + sessionOuverte: 'Session ouverte sur {cwd}. Écrivez-moi ce qu’il y a à faire.', + projets: 'Les projets que je connais. Le numéro sert à /projet et à /atelier.', + aucunProjet: 'Claude Code n’a encore travaillé sur aucun projet ici.', + /** La garde de l'Atelier à distance : on n'ouvre que ce qui est déjà connu. */ + projetInconnu: + 'Je ne reconnais pas ce projet. /projets donne ceux que j’ouvre, avec leur numéro.', + dossier: '{ou} — {total} fichiers.', + ouvrirIci: '▶ Ouvrir l’Atelier ici', + retourProjets: '◀ Projets', + remonter: '◀ Dossier parent', + /** L'état de navigation vit en mémoire : un redémarrage l'efface. */ + navigationPerimee: 'Cette liste date d’avant mon redémarrage. Refaites /projets.', + projetVide: 'Je ne trouve rien à lire dans {nom}.', + aucuneListe: 'Choisissez d’abord un projet avec /projet .', + fichierInconnu: 'Ce numéro ne désigne aucun fichier de la dernière liste.', + /** L'en-tête d'un document trop long pour un seul message. */ + pageDe: '{fichier} — page {page} sur {total}', + precedent: '◀ Précédent', + suivant: 'Suivant ▶', + /** Le cas le plus fréquent après un redémarrage du serveur : le fil est rompu. */ + aucunFil: 'Aucune session n’est ouverte ici. Ouvrez-en une avec /atelier .', + aucuneSession: 'Rien ne tourne en ce moment.', + /** Deux origines, et seule la première se pilote d'ici. */ + sessionsAtelier: 'Ouvertes par AURA — je peux leur parler :', + sessionsAilleurs: 'Ouvertes ailleurs — je les vois, je ne les pilote pas :', + sessionFinie: 'La session s’est terminée.', + sessionEchouee: 'La session s’est arrêtée : {message}', + /** + * L'état de la session, et d'abord sa fenêtre. + * + * L'en-tête reste **nominal** : ce sont des étiquettes de données, et le + * modèle y figure parce que la limite en dépend — un pourcentage sans son + * dénominateur ne se vérifie pas. + * + * `etatSansReleve` n'est pas une excuse mais un fait : rien n'a encore été + * demandé au modèle, donc il n'y a pas de fenêtre à annoncer. Montrer un + * zéro se lirait comme une mesure, alors que c'est l'absence de mesure. + */ + etatEntete: '{cwd} — {modele}, mode {mode}', + /** Avant `init`, le SDK n'a pas encore dit quel modèle il emploie. */ + etatModeleInconnu: 'modèle inconnu', + etatFenetre: 'Fenêtre : {tokens} / {limite} tokens — {pourcent} %', + etatSansReleve: 'Aucun tour n’a encore répondu : je n’ai pas de relevé de la fenêtre.', + /** + * Une compaction, dite avec ses chiffres. + * + * C'est le seul moment où la fenêtre change sans que vous ayez rien fait : + * le « je » y porte une information que rien d'autre ne donne. Les chiffres + * ne sont pas du zèle — sans eux, « j'ai compacté » ne dit pas si l'on est + * reparti de dix mille tokens ou de cent mille. + */ + compaction: 'J’ai compacté la conversation : {avant} tokens ramenés à {apres}.', + /** + * Le titre du résumé, et la raison d'un second message. + * + * Le résumé est un document — la conversation entière réécrite —, pas une + * phrase. Il part donc comme les autres documents, et Telegram le replie de + * lui-même derrière un « Afficher plus ». C'est ce qui reste dans la fenêtre + * après la compaction : le lire, c'est savoir ce que l'agent a gardé. + */ + compactionResume: 'Ce que j’ai gardé de la conversation', + /** + * Le franchissement du seuil, dit une fois et pas davantage. + * + * `docs/voix.md` range la recommandation parmi les surfaces où AURA parle + * d'elle : elle conseille, elle ne se contente pas de mesurer. + */ + fenetrePleine: + 'Je vous signale que la fenêtre est occupée à {pourcent} % — une compaction approche.', + permission: 'Je voudrais utiliser {outil}.', + autoriser: 'Autoriser', + /** + * L'en-tête d'un plan soumis. Il dit ce que les boutons engagent : sans + * cela, « Approuver » sous un long document ne dit pas ce qui suit. + */ + plan: 'Je vous propose ce plan. Je n’écris rien avant votre accord.', + approuver: 'Approuver', + refuser: 'Refuser', + /** Le motif transmis au modèle, et non à l'utilisateur : il reste bref. */ + refuseDeLoin: 'Refusé depuis la messagerie.', + /** + * Le formulaire de l'Atelier, posé une question par écran. + * + * `questionLibre` porte la seule chose qu'aucun bouton ne dit : qu'on peut + * répondre autre chose que ce qui est offert. Sans elle, la possibilité + * existe sans que personne ne la découvre. + */ + questionEtape: '{header} — question {n} sur {total}', + questionMultiple: 'Plusieurs réponses possibles.', + questionLibre: 'Pressez une option, ou écrivez votre réponse.', + questionValider: 'Valider', + questionExpiree: + 'Personne n’a répondu à cette question dans le quart d’heure ; je l’ai laissée passer.', + commandeInconnue: 'Je ne connais pas {commande}. /aide donne ce que je sais faire.', + /** + * La bulle éphémère montrée pendant qu'un tour travaille. + * + * Les mêmes libellés qu'à l'écran (`src/i18n/fr/agent.ts`) : une session + * lue de deux endroits ne doit pas raconter deux histoires. Ils sont + * recopiés plutôt que partagés — le serveur ne connaît pas `src/`, et une + * dépendance dans ce sens serait pire que ce doublon. + */ + activite: { + ligne: '{quoi} — {duree}', + requesting: 'Requête en cours', + thinking: 'Réflexion', + writing: 'Rédaction', + compacting: 'Compactage du contexte', + retrying: 'Nouvelle tentative {attempt}/{max}', + /** La phase `tool` n'a pas de libellé : elle se nomme de ses outils. */ + toolUnnamed: 'Outil en cours', + secondes: '{n} s', + minutes: '{min} min {s} s', + }, + }, + hooks: { failed: 'Le hook a échoué (code {code}).', blocked: 'Le hook a empêché la poursuite du tour.', diff --git a/server/index.ts b/server/index.ts index c622fa8..881858c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -28,6 +28,7 @@ import { registerUsage } from './routes/usage.ts'; import { registerDiagnostics } from './routes/diagnostics.ts'; import { registerEvents } from './routes/events.ts'; import { registerAgent } from './routes/agent.ts'; +import { arretePasserelle, demarrePasserelle } from './passerelle/index.ts'; import { stopAll } from './agent/registry.ts'; import { stopPool } from './parse-pool.ts'; @@ -92,9 +93,19 @@ async function main(): Promise { // qu'il se contente d'observer. registerAgent(app); + // La Passerelle : piloter l'Atelier depuis une messagerie. Inerte tant + // qu'aucun jeton n'est configuré — c'est le cas par défaut, et il le reste. + // Elle n'ouvre aucun port : son long-polling est sortant, et elle appelle le + // registre directement plutôt que de passer par l'API. + demarrePasserelle({ + info: (m) => app.log.info(m), + warn: (m) => app.log.warn(m), + }); + // Un processus `claude` par session survivrait au serveur sans cela — et un // thread de lecture de transcript avec eux. app.addHook('onClose', () => { + arretePasserelle(); stopAll(); stopPool(); }); diff --git a/server/passerelle/activite.ts b/server/passerelle/activite.ts new file mode 100644 index 0000000..e8dc428 --- /dev/null +++ b/server/passerelle/activite.ts @@ -0,0 +1,177 @@ +// Ce qu'AURA montre pendant qu'un tour travaille, et à quel rythme. +// +// Le besoin : entre l'envoi d'un message et la réponse, il peut se passer dix +// minutes sans un octet. À l'écran, la ligne d'activité comble ce silence ; +// dans une conversation, il ne restait rien — ni signe que le message avait été +// reçu, ni moyen de distinguer « ça travaille » de « c'est tombé ». +// +// Le support est le **brouillon** (`sendRichMessageDraft`), et non un message : +// il est éphémère, il ne persiste pas dans le fil, et il s'anime au lieu de +// s'empiler. C'est ce qui le rend compatible avec la règle du manuel — AURA +// n'envoie pas le flux d'activité. Un brouillon n'est pas envoyé, il est +// montré, puis il disparaît. +// +// Deux contraintes viennent de l'API, et les deux constantes plus bas en +// découlent : +// +// - un brouillon **expire au bout de trente secondes**, donc il faut le +// réémettre tant que le tour dure ; +// - il ne vaut que pour une **conversation privée**. Un groupe n'en verra +// rien, et c'est pourquoi l'action de saisie l'accompagne : elle, marche +// partout. + +import type { AgentActivity } from '../../shared/agent.ts'; +import { t } from '../i18n/index.ts'; + +/** + * Le battement, plus court que l'expiration. + * + * Trente secondes est la borne ; vingt laisse de quoi encaisser une requête + * lente sans que la bulle clignote. + */ +export const BATTEMENT_MS = 20_000; + +/** + * Le pas minimal entre deux émissions. + * + * Une phase d'outil change toutes les deux ou trois secondes, et suivre chaque + * changement au plus près ferait une requête par seconde pour un gain nul : en + * dessous de deux secondes, un libellé qui se remplace se lit comme un + * scintillement, pas comme une information. + */ +export const PAS_MINIMAL_MS = 2_000; + +/** + * Sous ce seuil, on n'affiche pas de durée. + * + * Un chrono qui démarre à « 1 s » n'apprend rien et attire l'œil sur le seul + * moment où il n'y a rien à s'expliquer. C'est le même seuil qu'à l'écran. + */ +const DUREE_MUETTE_S = 5; + +/** Combien d'outils se nomment avant qu'on se mette à les compter. */ +const OUTILS_NOMMES = 2; + +/** Une durée telle qu'on la lit d'un coup d'œil, jamais au dixième. */ +function duree(secondes: number): string { + if (secondes < 60) return t('passerelle.activite.secondes', { n: Math.floor(secondes) }); + const minutes = Math.floor(secondes / 60); + const reste = String(Math.floor(secondes % 60)).padStart(2, '0'); + return t('passerelle.activite.minutes', { min: minutes, s: reste }); +} + +/** + * Ce que la bulle dit d'une activité, ou `null` s'il n'y a rien à dire. + * + * Le libellé suit celui de l'Atelier — mêmes phases, mêmes noms d'outils — pour + * qu'une même session lue de deux endroits ne raconte pas deux histoires. La + * durée affichée est celle du **tour**, pas de la phase : une phase dure trois + * secondes et se remplace, un tour dure dix minutes, et c'est la seconde qu'on + * se demande vraiment. + */ +export function ligne(activite: AgentActivity, maintenant: number): string | null { + const { phase, tools, retry, turnStartedAt } = activite; + if (!phase) return null; + + let quoi: string; + if (phase === 'retrying' && retry) { + quoi = t('passerelle.activite.retrying', { attempt: retry.attempt, max: retry.maxRetries }); + } else if (phase !== 'tool') { + quoi = t(`passerelle.activite.${phase}`); + } else if (!tools.length) { + quoi = t('passerelle.activite.toolUnnamed'); + } else { + // Deux noms plutôt que les trois de l'écran : la bulle d'une messagerie est + // plus étroite qu'une ligne d'Atelier, et un libellé qui déborde y pousse + // le chrono hors de vue. + const vus = tools.slice(0, OUTILS_NOMMES).map((o) => o.name); + const reste = tools.length - vus.length; + quoi = reste > 0 ? `${vus.join(', ')} +${reste}` : vus.join(', '); + } + + const secondes = turnStartedAt ? (maintenant - turnStartedAt) / 1000 : 0; + if (secondes < DUREE_MUETTE_S) return quoi; + return t('passerelle.activite.ligne', { quoi, duree: duree(secondes) }); +} + +/** Ce qu'il faut savoir émettre pour qu'un battement existe. */ +export interface Emetteur { + brouillon: (chatId: number, draftId: number, texte: string) => Promise; + saisie: (chatId: number) => Promise; +} + +/** + * Le battement d'une conversation. + * + * Il ne tient aucun état du tour : il reçoit une activité, décide s'il y a lieu + * de la montrer maintenant, et se rappelle tout seul avant l'expiration. Ce qui + * l'arrête est toujours un événement de la session — fin de tour, demande de + * permission, session close —, jamais une échéance. + */ +export class Battement { + private minuteur: ReturnType | null = null; + private derniere = ''; + private emisA = 0; + private enAttente = false; + + constructor( + private readonly emetteur: Emetteur, + private readonly chatId: number, + private readonly draftId: number, + ) {} + + /** + * Montre cette activité, si elle mérite d'être montrée maintenant. + * + * Un texte inchangé ne se réémet pas avant l'échéance du battement : c'est ce + * qui distingue « tenir la bulle en vie » de « la redessiner ». + */ + montre(activite: AgentActivity, maintenant: number = Date.now()): void { + const texte = ligne(activite, maintenant); + if (texte === null) { + this.arrete(); + return; + } + const change = texte !== this.derniere; + const assezVieux = maintenant - this.emisA >= PAS_MINIMAL_MS; + this.derniere = texte; + if (change && !assezVieux) { + // Trop tôt pour celui-ci, mais le minuteur en cours reprendra le dernier + // texte connu : rien ne se perd, seul le rythme est borné. + this.programme(PAS_MINIMAL_MS - (maintenant - this.emisA)); + return; + } + this.emet(maintenant); + } + + /** Coupe le battement. La bulle s'efface d'elle-même en trente secondes. */ + arrete(): void { + if (this.minuteur) clearTimeout(this.minuteur); + this.minuteur = null; + this.derniere = ''; + } + + private emet(maintenant: number): void { + this.emisA = maintenant; + const texte = this.derniere; + if (!this.enAttente) { + this.enAttente = true; + void Promise.all([ + this.emetteur.brouillon(this.chatId, this.draftId, texte), + this.emetteur.saisie(this.chatId), + ]).finally(() => { + this.enAttente = false; + }); + } + this.programme(BATTEMENT_MS); + } + + private programme(delai: number): void { + if (this.minuteur) clearTimeout(this.minuteur); + // `unref` : un battement ne doit pas retenir le process à l'extinction. + this.minuteur = setTimeout(() => { + if (this.derniere) this.emet(Date.now()); + }, delai); + this.minuteur.unref?.(); + } +} diff --git a/server/passerelle/blocs-riches.md b/server/passerelle/blocs-riches.md new file mode 100644 index 0000000..84fbe4f --- /dev/null +++ b/server/passerelle/blocs-riches.md @@ -0,0 +1,191 @@ +# Les blocs riches de Telegram + +Ce que `sendRichMessage` sait dessiner, ce qu'on en emploie, et ce qu'on a écarté. + +Ce fichier existe pour une raison précise : **l'API accepte en silence les champs qu'elle +ne connaît pas.** Un `header` écrit pour `is_header` ne produit aucune erreur — seulement un +tableau sans en-tête, et rien pour dire pourquoi. Les types de `node-telegram-bot-api` +défendent les noms de champs, mais leurs valeurs sont déclarées `type: string` : le +compilateur ne dira jamais que `pull_quotation` aurait dû s'écrire `pullquote`. Les chaînes +exactes vivent donc ici, relevées dans la spec, et les comportements réels y sont notés à +côté — plusieurs contredisent la documentation. + +Tout ce qui suit est **mesuré contre l'API et contre le client web**, sauf mention contraire. +Un client mobile peut différer ; c'est dit là où on l'a constaté. + +--- + +## Les trois portes d'entrée + +`InputRichMessage` accepte **exactement l'un** de ces trois champs : + +| Champ | Ce que c'est | +| ---------- | --------------------------------------------------- | +| `blocks` | Une liste de blocs structurés en JSON — notre choix | +| `html` | Un document HTML, dialecte propre à cette API | +| `markdown` | Du Markdown, dialecte propre à cette API | + +Le champ `markdown` mérite d'être connu, parce qu'il paraît rendre `riche.ts` inutile : on +lui donnerait le fichier tel quel. On ne le fait pas, et pour trois raisons qui tiennent +toutes à la même chose — **on perdrait la main sur les cas où le rendu par défaut est faux** : + +- les cases à cocher ne sont pas dessinées par les clients (plus bas) ; il faut les + remplacer par un symbole, ce qui suppose de les avoir vues passer ; +- les liens relatifs d'un dépôt (`../livraison/0.livraison.md`) ne mènent nulle part depuis + une messagerie, et doivent être dégradés en texte plutôt que rendus cliquables ; +- le dialecte est celui de Telegram, pas celui de CommonMark. Ce qu'il fait des cas + limites — listes imbriquées, continuations, tableaux sans en-tête — resterait à + découvrir, et à re-découvrir à chaque évolution. + +Traduire nous-mêmes coûte un fichier ; ne pas traduire coûterait le contrôle du résultat. + +Deux options accompagnent le message : + +- **`skip_entity_detection: true`** — indispensable ici. Sans elle, Telegram fabrique des + liens : `.md` est un domaine de premier niveau (la Moldavie), donc `0.livraison.md` part + vers un site qui n'existe pas. Un dépôt est plein de `.py`, `.pl`, `.sh`, `.io`. Vérifié : + cette option ne touche pas aux entités qu'on déclare soi-même. +- `is_rtl` — sens de lecture. Sans usage ici. + +## Les limites + +Elles sont dans la spec, section _Rich Message Limits_ : + +| Limite | Valeur | Gardée chez nous ? | +| --------------------------------------------- | ------- | ------------------------------------------ | +| Caractères UTF-8 du message | 32 768 | oui — `MAX_RICHE`, pagination à 70 % | +| **Blocs**, imbriqués, items et lignes compris | **500** | **non** — voir la réserve ci-dessous | +| Niveaux d'imbrication | 16 | non — atteignable seulement par une liste | +| Pièces jointes | 50 | sans objet — on n'envoie aucun média | +| Colonnes d'un tableau | 20 | non — un tableau de dépôt en a rarement 20 | + +**La borne des 500 blocs n'est pas gardée** — la pagination compte des caractères, pas des +blocs — mais elle l'est de fait : couper la source à 70 % de 32 768 caractères borne +mécaniquement ce qu'une page peut produire. Mesuré sur tous les `.md` de deux dépôts réels, +162 pages : le pire document en produit **377**. La borne n'a donc pas été atteinte, et un +document assez dense pour la franchir retomberait sur le bloc unique d'`envoieRendu`, qui +n'en compte qu'un. Rien ne se perd, et c'est pour cela que ce n'est pas corrigé. + +--- + +## Les blocs + +Vingt-et-un types. La colonne « chez nous » dit ce que `riche.ts` en fait. + +### Ceux qu'on émet + +| `type` | Champs | Correspondance HTML | Chez nous | +| ------------ | ------------------------------------------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `paragraph` | `text` | `

` | lignes consécutives regroupées | +| `heading` | `text`, `size` (1–6, **1 = le plus grand**) | `

`…`

` | `#`…`######`, correspondance directe | +| `pre` | `text`, `language?` | `
`       | bloc clôturé ` ``` `, langue déclarée reprise                                                                                                                                                                                                                                                                                                     |
+| `list`       | `items[]`                                              | `
    ` / `
      ` | puces et listes numérotées, imbriquées par retrait | +| `details` | `summary`, `blocks[]`, `is_open?` | repli natif | **jamais depuis le Markdown** — posé à la main autour d’un document qu’on ne veut pas déverser (le résumé d’une compaction). Essayé et rendu : le titre reste visible, le corps s’ouvre au clic. C’est le **seul** repli explicite de l’API ; l’« Afficher plus » automatique d’un message long ne s’est pas déclenché sur sept mille caractères. | +| `blockquote` | `blocks[]`, `credit?` | `
      ` | lignes `>` consécutives, relues comme un document | +| `table` | `cells[][]`, `is_bordered?`, `is_striped?`, `caption?` | `` | tableaux Markdown, bordures toujours | +| `divider` | — | `
      ` | `---`, `***`, `___` | + +`credit` (sur `blockquote`) et `caption` (sur `table`) ne sont pas employés : le Markdown +n'a rien qui leur corresponde, et les remplir demanderait d'inventer une convention. + +### Ceux qu'on n'émet pas, et pourquoi + +| `type` | Ce que c'est | Pourquoi pas | +| ---------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `pullquote` | Citation centrée, `text` + `credit?` | Le Markdown n'a qu'une forme de citation, et `blockquote` en est la traduction fidèle. Attention au nom : **`pullquote`**, pas `pull_quotation`. | +| `footer` | Pied de document | Aucun équivalent Markdown. Servirait à signer un envoi — « lu sur le disque à telle heure » — si on décidait un jour de le faire. | +| `anchor` | Ancre nommée, `name` | Sans utilité tant qu'aucun lien interne ne pointe dessus. Irait avec `anchor_link` si l'on voulait rendre les liens de section d'un document. | +| `mathematical_expression` | LaTeX, champ `expression` | Rien dans les documents du parc. À revoir si des spécifications en portent. | +| `collage`, `slideshow` | Groupes de médias | La Passerelle n'envoie aucun média. | +| `map` | Carte, `location`, `zoom`, `width`, `height` | Hors sujet. | +| `photo`, `video`, `animation`, `audio`, `voice_note` | Médias, chacun avec `caption?` | Idem. La légende est un `RichBlockCaption` (`text` + `credit?`), pas un simple texte. | +| `thinking` | Un « Thinking… » en attente | **Utilisable uniquement dans `sendRichMessageDraft`**, jamais dans un message envoyé. La spec le dit. | + +--- + +## Les items de liste + +`InputRichBlockListItem` porte quatre champs optionnels, et **trois nous ont menti** : + +| Champ | Ce que la spec dit | Ce qu'on observe | +| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `has_checkbox` | L'item porte une case à cocher | **Accepté, jamais dessiné.** L'item s'affiche comme une puce ordinaire. | +| `is_checked` | La case est cochée | Idem, sans effet. | +| `value` | « Pour les listes ordonnées, la valeur numérique de l'étiquette » | **Sans effet seul** : l'item reste une puce. Voir ci-dessous. | +| `type` | `"1"`, `"a"`, `"A"`, `"i"`, `"I"` — la forme de l'étiquette | C'est lui, et lui seul, qui déclenche la numérotation. | + +Le relevé, `value` valant 1, 2, 3 : + +| `type` posé | Ce qui s'affiche | Verdict | +| ----------- | ---------------- | -------------------------------------------- | +| `"1"` | `0.` `1.` `2.` | **décalé d'un rang** — inutilisable tel quel | +| `"a"` | `a.` `b.` | correct | +| `"I"` | `I.` `II.` | correct | +| absent | `•` `•` | puce, quelle que soit la valeur de `value` | + +D'où notre choix : **le numéro reste dans le texte de l'item**. On accepte la redondance +visible — une puce suivie d'un numéro, « • 5. » — parce qu'un premier point affiché « 0. » +serait faux dans un document où l'ordre est la consigne. + +Même raisonnement pour les cases à cocher, qui sortent en `☑︎` / `☐︎` dans le texte : sans +cela, une liste de tâches perdrait l'état de chaque ligne sans que rien ne le signale. + +La voie native, `sendChecklist`, est fermée d'un cran plus haut : elle répond +`PREMIUM_ACCOUNT_REQUIRED` et exige un `business_connection_id`, donc un compte Business +connecté — hors de portée d'un bot ordinaire. + +--- + +## Les cellules de tableau + +`RichBlockTableCell` : `text?`, `is_header?`, `colspan?`, `rowspan?`, `align`, `valign`. + +- `text` omis rend la cellule **invisible** — utile pour une grille creuse, jamais nécessaire + ici. +- `align` et `valign` sont déclarés **obligatoires** par la spec comme par les types de la + bibliothèque. **Ils ne le sont pas** : l'API accepte une cellule sans eux, et le client la + rend. On les a donc rendus optionnels localement, et on ne pose `align` que là où le + Markdown le demande — les deux-points de la ligne de séparation. +- `is_header` est ce qui distingue une ligne d'en-tête. Sans la ligne d'alignement du + Markdown, il n'y a pas d'en-tête à déclarer : la première ligne est une ligne comme une + autre. +- `is_bordered` n'est pas décoratif. Sans lui, un tableau se lit comme des mots posés côte à + côte. + +--- + +## Les entités en ligne + +Vingt-cinq types de `RichText`. Le texte d'une entité est lui-même du `RichText` : **elles +s'emboîtent**, et l'emboîtement se cumule — mesuré, et l'ordre est sans effet. + +| Employées | Ce qu'on en fait | +| --------------------------------- | ----------------------------------------------------------------- | +| `bold`, `italic`, `strikethrough` | `**`, `*` ou `_`, `~~` | +| `code` | accents graves | +| `marked` | **emboîté autour de `code`** — voir plus bas | +| `url` | liens `http(s)` et `mailto` seulement ; le reste dégrade en texte | + +Le cas de `marked` mérite d'être gardé, parce qu'il vaut au-delà de lui : `code` seul est +rendu en chasse fixe teintée, ce qui suffit sur un grand écran et se perd sur un téléphone. +`marked` ajoute un **fond** — une différence de _surface_, non de couleur —, et c'est ce qui +traverse la réduction d'échelle. On envoie donc `marked(code)`. + +Les autres, disponibles et inutilisées : `underline`, `spoiler`, `subscript`, `superscript`, +`custom_emoji`, `mathematical_expression`, `date_time`, `text_mention`, `mention`, `hashtag`, +`cashtag`, `bot_command`, `email_address`, `phone_number`, `bank_card_number`, `anchor`, +`anchor_link`, `reference`, `reference_link`. + +**Aucune ne porte de couleur, de fond ni de style.** Le champ `type` désigne une sémantique, +et chaque client la peint avec son propre thème — le même contenu n'a pas la même apparence +dans une bulle et dans l'_Instant View_. Ce qu'on choisit, c'est ce qu'une chose **est**, +jamais à quoi elle **ressemble**. + +--- + +## La méthode, si ce fichier doit être complété + +Ne rien conclure d'une absence d'erreur. L'API répond `ok: true` à des blocs qu'elle ignore, +et le client dessine ce qu'il veut de ce qu'il reçoit. Une hypothèse ne vaut que vérifiée à +l'écran, et le relevé se note ici avec ce qui a été essayé — c'est ce qui a fait gagner le +plus de temps sur ce chantier, et ce qui en fera gagner à la relecture suivante. diff --git a/server/passerelle/commandes.ts b/server/passerelle/commandes.ts new file mode 100644 index 0000000..7283886 --- /dev/null +++ b/server/passerelle/commandes.ts @@ -0,0 +1,98 @@ +// Les commandes de la Passerelle, en un seul endroit. +// +// Elles doivent s'accorder à trois endroits qui ne se regardent pas : le +// `switch` de `routage.ts`, le texte de l'aide, et la liste que Telegram +// propose quand on tape `/`. Cette dernière était jusqu'ici saisie à la main +// dans `@BotFather`, donc hors du dépôt — invisible à toute relecture. +// +// Une commande annoncée mais retirée du routage est exactement le défaut contre +// lequel la documentation de Telegram met en garde, vu de l'autre côté : « votre +// serveur doit toujours vérifier que les commandes reçues sont valides », parce +// que rien ne garantit qu'une commande proposée existe encore. Sauf que là, +// c'est nous qui aurions créé l'écart. +// +// D'où cette table. Le routage garde son `switch` — il traduit une commande en +// intention, ce qui n'est pas mécanique —, mais un test tient les deux listes +// égales. Rien n'est engendré ; ce qui est engendré, c'est l'accord. + +import { t } from '../i18n/index.ts'; + +/** Ce que Telegram accepte comme nom : minuscules, chiffres, soulignés, 32 max. */ +export interface Commande { + /** Le mot, **sans** la barre oblique : `setMyCommands` la refuse. */ + nom: string; + /** + * L'argument attendu, s'il y en a un. + * + * Il ne sert qu'à l'aide : la liste de Telegram ne montre pas la forme d'une + * commande, seulement son nom et ce qu'elle fait. + */ + argument?: boolean; + /** Sous quel intertitre l'aide la range. */ + groupe: 'consulter' | 'travailler'; +} + +/** + * L'ordre est celui de l'aide, et il n'est pas alphabétique : on consulte avant + * d'ouvrir une session, et l'on ferme après avoir ouvert. + * + * `/start` et `/help` n'y figurent pas bien qu'ils soient reconnus : ce sont des + * alias d'`/aide`, et Telegram propose déjà `/start` de lui-même. Les répéter + * ferait une liste qui dit trois fois la même chose. + */ +export const COMMANDES: readonly Commande[] = [ + { nom: 'projets', groupe: 'consulter' }, + { nom: 'projet', argument: true, groupe: 'consulter' }, + { nom: 'voir', argument: true, groupe: 'consulter' }, + { nom: 'atelier', argument: true, groupe: 'travailler' }, + // Avant `/sessions`, et l'ordre dit la différence : celle-ci regarde la + // session de cette conversation, celle-là compte le parc. + { nom: 'etat', groupe: 'travailler' }, + { nom: 'compacter', groupe: 'travailler' }, + { nom: 'sessions', groupe: 'travailler' }, + { nom: 'stop', groupe: 'travailler' }, + { nom: 'fin', groupe: 'travailler' }, + { nom: 'aide', groupe: 'travailler' }, +]; + +/** Ce que fait une commande, dans la langue en vigueur. */ +function description(nom: string): string { + return t(`passerelle.commandes.${nom}`); +} + +/** + * Le message d'aide, composé. + * + * Les intertitres n'apparaissent que si leur groupe a des commandes : une table + * réduite ne doit pas laisser un titre au-dessus du vide. + */ +export function aide(): string { + const lignes: string[] = [t('passerelle.aideEntete')]; + const argument = t('passerelle.aideArgument'); + + for (const groupe of ['consulter', 'travailler'] as const) { + const dedans = COMMANDES.filter((c) => c.groupe === groupe); + if (!dedans.length) continue; + lignes.push( + '', + t(groupe === 'consulter' ? 'passerelle.aideConsulter' : 'passerelle.aideTravailler'), + ); + for (const c of dedans) { + const forme = c.argument ? `/${c.nom} ${argument}` : `/${c.nom}`; + lignes.push(`${forme} — ${description(c.nom)}`); + } + } + + lignes.push('', t('passerelle.aidePied')); + return lignes.join('\n'); +} + +/** + * La liste que Telegram propose sous le `/`. + * + * Les descriptions y sont lues **seules**, hors de l'aide : c'est pourquoi elles + * se suffisent à elles-mêmes plutôt que de renvoyer l'une à l'autre. + */ +export function pourTelegram(): { command: string; description: string }[] { + return COMMANDES.map((c) => ({ command: c.nom, description: description(c.nom) })); +} diff --git a/server/passerelle/etat.ts b/server/passerelle/etat.ts new file mode 100644 index 0000000..842aa4d --- /dev/null +++ b/server/passerelle/etat.ts @@ -0,0 +1,116 @@ +// Où en est la fenêtre de contexte, et quand cela mérite d'être dit. +// +// Même doctrine que `routage.ts` et `questions.ts` : tout ce qui décide vit ici, +// sans réseau ni registre — c'est ce qui rend le comportement vérifiable par un +// test sans bot. `index.ts` ne fait qu'émettre ce que ce fichier rend. +// +// Le nombre, lui, ne se calcule pas ici : `SessionRunner.contextWindow` le relève +// sur les réponses du modèle, et c'est la **même somme** que la page Contexte +// emploie (`transcript.ts`, `settleTurn`). Ce fichier ne fait que le rapporter à +// la fenêtre du modèle et juger s'il y a lieu d'en parler. + +import { locale, t } from '../i18n/index.ts'; + +/** + * À partir d'où la fenêtre mérite qu'on en parle sans qu'on ait rien demandé. + * + * C'est le garde-fou du signal `contextFill` (`diagnostics/thresholds.ts`), + * repris à l'identique : deux surfaces qui parlent du même remplissage n'ont pas + * à se contredire de deux points de pourcentage. La valeur est recopiée et non + * importée — `SPECS` y est privé, et les deux mesures ne portent pas sur la même + * chose (le diagnostic juge le pic d'une session finie contre le parc, celle-ci + * lit une session vivante maintenant). Un test tient les deux nombres égaux. + */ +export const SEUIL_ALERTE = 0.8; + +/** + * Un relevé de fenêtre, rapporté à la limite du modèle. + * + * Rien n'horodate le relevé, et ce n'est pas un oubli : la fenêtre ne change + * qu'aux tours et aux compactions, tous deux relevés. Entre les deux, le dernier + * chiffre **est** le chiffre courant, si vieux soit-il — dater la mesure ferait + * croire à une péremption qui n'existe pas. + */ +export interface Fenetre { + /** Le contexte du dernier tour, exact. `0` quand aucun tour n'a répondu. */ + tokens: number; + /** La fenêtre du modèle, telle que `contextLimitFor` la déduit. */ + limite: number; +} + +/** + * La part occupée, entre 0 et 1. + * + * Bornée à 1 : un relevé au-dessus de la limite n'est pas impossible — la + * fenêtre déduite peut être la petite alors que la session tourne sur la grande, + * le temps qu'une preuve arrive — et « 118 % » ferait douter du reste de + * l'écran là où « 100 % » dit déjà tout ce qu'il y a à dire. + */ +export function part(f: Fenetre): number { + if (f.tokens <= 0 || f.limite <= 0) return 0; + return Math.min(1, f.tokens / f.limite); +} + +/** + * Ce que `/etat` écrit. + * + * Court, parce que cela se lit sur un téléphone. Le modèle et le mode ne sont + * pas du décor : **la limite dépend du modèle**, et un pourcentage sans son + * dénominateur ne se vérifie pas. Ce sont des étiquettes de données — AURA y + * reste nominale, comme `docs/voix.md` le demande. + */ +export function lignes(f: Fenetre, cwd: string, modele: string, mode: string): string[] { + const entete = t('passerelle.etatEntete', { cwd, modele, mode }); + + // Une session neuve n'a rien fait répondre au modèle : il n'y a pas de + // fenêtre à annoncer. Le dire vaut mieux que de montrer un zéro, qui se lit + // comme une mesure alors que c'est une absence de mesure. + if (f.tokens <= 0) return [entete, '', t('passerelle.etatSansReleve')]; + + return [ + entete, + '', + t('passerelle.etatFenetre', { + tokens: nombre(f.tokens), + limite: nombre(f.limite), + pourcent: Math.round(part(f) * 100), + }), + ]; +} + +/** + * Faut-il alerter maintenant ? + * + * Pur : l'état « déjà dit » vit sur le fil de la conversation, pas ici. Une + * seule fois par remplissage — répéter à chaque tour ne dirait rien de plus et + * transformerait un avertissement en bruit de fond. + */ +export function alerte(deja: boolean, ratio: number): boolean { + return !deja && ratio >= SEUIL_ALERTE; +} + +/** + * Un nombre de tokens tel qu'on le lit d'un coup d'œil : `31 k`, `1 M`. + * + * La notation compacte plutôt que les milliers séparés, et ce n'est pas un goût : + * `30 527 / 1 000 000` demande de compter les chiffres des deux côtés pour + * comprendre le rapport, quand `31 k / 1 M` le donne. Sur un téléphone, où + * cette ligne est lue en une œillade, c'est la différence entre lire et + * déchiffrer. La précision perdue ne manque à personne — le pourcentage, lui, + * est à côté. + * + * Aucune décimale, et c'est délibéré : les deux seules limites qui existent — + * 200 k et 1 M — sont exactes, et un `1,0 M` laisserait croire à un arrondi qui + * n'a pas eu lieu. + * + * `Intl` plutôt qu'un formatage à la main parce que la forme compacte est une + * affaire de langue : `31 k` en français, `31K` en anglais, séparateur décimal + * compris. L'écrire soi-même serait recopier une table que la plateforme tient + * déjà. + */ +export function nombre(n: number): string { + return new Intl.NumberFormat(locale(), { + notation: 'compact', + maximumFractionDigits: 0, + }).format(n); +} diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts new file mode 100644 index 0000000..3673d59 --- /dev/null +++ b/server/passerelle/index.ts @@ -0,0 +1,1389 @@ +// La Passerelle : une conversation d'un côté, l'Atelier de l'autre. +// +// Elle n'ouvre aucun port. Le long-polling de `telegram.ts` est sortant, si bien +// que le BFF continue de n'écouter que `127.0.0.1` et que `guard.ts` reste +// inchangé — c'est la raison d'être de cette forme plutôt que d'un tunnel ou +// d'une écoute élargie. +// +// Elle ne passe pas non plus par HTTP : elle vit dans le même process que le +// registre et l'appelle directement. Il n'y a donc pas de requête à +// authentifier, et pas une ligne de l'API existante à ouvrir. +// +// Ce qu'elle emprunte et ne réimplémente pas : le cycle de vie des sessions +// (`agent/registry.ts`), la file d'entrée et les demandes en attente +// (`agent/runner.ts`), la forme des messages (`shared/agent.ts`). + +import { DEFAULT_LOCALE, SUPPORTED_LOCALES, t, withLocale } from '../i18n/index.ts'; +import { publicMessage } from '../errors.ts'; +import type { AgentUpsert, PermissionAnswer } from '../../shared/agent.ts'; +import { isPermissionMode } from '../../shared/agent.ts'; +import { + atCapacity, + createRunner, + getRunner, + listSessions, + MAX_SESSIONS, + removeRunner, +} from '../agent/registry.ts'; +import type { SessionRunner } from '../agent/runner.ts'; +import { + getProjectResources, + listProjects, + readProjectIncludedFile, + readProjectMemory, + readProjectResource, +} from '../projects.ts'; +import { listSessions as sessionsActives } from '../maintenance.ts'; +import type { ProjectSummary } from '../../shared/projects.ts'; +import { autorise, lireChats, parseIntention } from './routage.ts'; +import { + aplatir, + arborescence, + compte, + descendre, + resoudreProjet, + type Entree, + type Noeud, +} from './projets.ts'; +import { paginer } from './markdown.ts'; +import { enBlocs, MAX_RICHE, type InputRichBlock } from './riche.ts'; +import { boutons, elargi, grille, Telegram } from './telegram.ts'; +import { + ecran as ecranQuestion, + formulaire, + presse, + repondLibre, + reponses, + type Formulaire, +} from './questions.ts'; +import { alerte, lignes as lignesEtat, nombre, part, type Fenetre } from './etat.ts'; +import { planPropose } from './plan.ts'; +import { contextLimitFor } from '../context.ts'; +import { configuredLongWindow } from '../claude/model.ts'; +import { Battement } from './activite.ts'; +import { aide, pourTelegram } from './commandes.ts'; +import type { InlineKeyboardMarkup } from 'node-telegram-bot-api'; + +/** + * Ce qu'une page prend au document source. + * + * Sous la borne du message riche, avec de la marge : la traduction en blocs + * ajoute du JSON autour de chaque morceau de texte, et c'est le total qui est + * mesuré côté Telegram. + */ +const PAGE_RICHE = Math.floor(MAX_RICHE * 0.7); + +/** Ce que le journal du BFF sait faire, et tout ce que la Passerelle lui demande. */ +interface Journal { + info: (message: string) => void; + warn: (message: string) => void; +} + +/** Ce que la Passerelle garde d'une conversation. */ +interface Fil { + runId: string; + /** Se désabonner du runner quand le fil se défait. */ + detache: () => void; + /** + * L'abonnement a-t-il déjà rendu son premier `snapshot` ? + * + * `subscribe` émet le sien **avant** d'enregistrer l'abonné : le premier reçu + * est donc celui de l'abonnement par construction, et non par ressemblance. + * C'est ce qui permet de distinguer les deux sites d'émission sans deviner. + */ + abonne: boolean; + /** Les textes de l'assistant du tour en cours, par uuid. */ + tour: Map; + /** Les formulaires en vol, pour retrouver ce qu'un bouton désigne. */ + asks: Map; + /** + * Les messages qui portent encore des boutons de permission, par demande. + * + * Ce qu'il faut pour les retirer une fois la demande tranchée. Le désarmement + * se fait sur `permission-settled` et non sur le clic : c'est le seul endroit + * qui voie aussi les réponses données depuis l'Atelier et les refus du + * garde-fou du quart d'heure. + */ + permissions: Map; + /** + * Le formulaire qui capte le prochain message écrit, s'il y en a un. + * + * C'est ce qui rend possible la réponse hors menu — le « Other » du harnais. + * Il ne capte que ce qui serait parti comme un tour : les commandes restent + * des commandes sous une question. + */ + attente: string | null; + /** + * Le remplissage de la fenêtre a-t-il déjà été signalé ? + * + * Une fois suffit : redire à chaque tour qu'on est au-dessus du seuil + * n'apprendrait rien et ferait d'un avertissement un bruit de fond. Une + * compaction le remet à faux — la fenêtre a le droit de se remplir à nouveau, + * et de le dire à nouveau. + */ + alerte: boolean; + /** La bulle éphémère qui dit que ça travaille, et à quoi. */ + battement: Battement; +} + +/** + * L'identifiant du brouillon d'une conversation. + * + * Il doit être non nul, et rester le même pour que Telegram anime la bulle au + * lieu d'en empiler une par changement. Un compteur suffit : rien ne le relie à + * la session, et un brouillon ne survit pas trente secondes à son émission. + */ +let prochainBrouillon = 1; + +/** + * Ce qu'une conversation a sous les yeux, hors session. + * + * Séparé du `Fil` à dessein : on consulte un projet sans en ouvrir la session, + * et fermer une session ne doit pas faire perdre la liste qu'on était en train + * de parcourir. Les deux états ne vivent pas au même rythme. + */ +interface Vue { + /** La dernière liste de projets montrée — ce que les rangs désignent. */ + projets: ProjectSummary[]; + /** Le projet choisi, s'il y en a un. */ + slug: string; + /** Les fichiers de ce projet, dans l'ordre où ils ont été numérotés. */ + entrees: Entree[]; + /** Les mêmes, en arborescence : ce que la navigation parcourt. */ + racine: Noeud; + /** + * Où l'on est dans cet arbre, segment par segment. + * + * Le chemin plutôt que le nœud : un bouton ne peut porter que 64 octets, et + * un chemin profond les dépasserait. On empile donc des noms, et l'on + * redescend depuis la racine à chaque pas — l'arbre tient en mémoire, le + * parcours ne coûte rien. + */ + chemin: string[]; + /** + * Le message qui porte la navigation, et qu'on réécrit à chaque pas. + * + * Un seul message pour tout le parcours : sans cela, chaque clic laisserait + * derrière lui une liste périmée, et la conversation deviendrait un empilement + * d'états morts. + */ + messageId: number | null; +} + +let telegram: Telegram | null = null; +const fils = new Map(); +const vues = new Map(); + +function vue(chatId: number): Vue { + const existante = vues.get(chatId); + if (existante) return existante; + const neuve: Vue = { + projets: [], + slug: '', + entrees: [], + racine: { nom: '', enfants: [] }, + chemin: [], + messageId: null, + }; + vues.set(chatId, neuve); + return neuve; +} + +/** + * Affiche un écran de navigation : on réécrit celui qui est là, sinon on en + * ouvre un. + * + * La réécriture échoue pour de bonnes raisons — message trop vieux, supprimé, + * ou contenu identique. On repart alors sur un envoi neuf plutôt que de laisser + * un clic sans effet visible. + */ +async function ecran(chatId: number, texte: string, clavier: InlineKeyboardMarkup): Promise { + const tg = telegram; + if (!tg) return; + const v = vue(chatId); + + // Le clavier prend la largeur de la bulle, donc du texte : un en-tête court + // donnerait des boutons trop étroits pour être lus. Voir `elargi`. + const large = elargi(texte); + + if (v.messageId !== null && (await tg.reecrit(chatId, v.messageId, large, clavier))) return; + v.messageId = await tg.envoieSuivi(chatId, large, clavier); +} + +/** + * L'écran d'un dossier — la racine d'un projet en est un. + * + * Un seul écran pour tous les étages : c'est ce que permet l'arbre, là où des + * catégories auraient demandé un écran de tête et un écran de liste. Un dossier + * porte le compte de ce qu'il contient, à toute profondeur ; un fichier ouvre + * son contenu. + */ +async function ecranDossier(chatId: number, projet: ProjectSummary): Promise { + const v = vue(chatId); + const dossier = descendre(v.racine, v.chemin); + if (!dossier) { + // Le chemin ne mène plus nulle part — l'inventaire a changé sous nos pieds. + // On remonte à la racine plutôt que de laisser un écran vide. + v.chemin = []; + await ecranDossier(chatId, projet); + return; + } + + const cases = dossier.enfants.map((n, i) => + n.fichier + ? // Le rang de la liste plate voyage dans le bouton : c'est lui que + // `/voir` emploie, et les deux chemins doivent désigner le même fichier. + { texte: nomCourt(n.nom), donnee: `n:f:${n.fichier.rang}` } + : { texte: `${nomCourt(n.nom)}/ ${compte(n)}`, donnee: `n:d:${i}` }, + ); + + // À la racine, il n'y a pas de dossier parent : on remonte aux projets, et + // c'est là que l'action d'ouverture a sa place. + const solo = v.chemin.length + ? [{ texte: t('passerelle.remonter'), donnee: 'n:u' }] + : [ + { texte: t('passerelle.ouvrirIci'), donnee: 'n:a' }, + { texte: t('passerelle.retourProjets'), donnee: 'n:r' }, + ]; + + const ou = v.chemin.length ? v.chemin.join('/') : projet.name; + await ecran(chatId, t('passerelle.dossier', { ou, total: compte(dossier) }), grille(cases, solo)); +} + +/** Le nom d'un fichier, sans son chemin — la place manque sur un bouton. */ +function nomCourt(label: string): string { + const nom = label.slice(label.lastIndexOf('/') + 1); + return nom.length > 40 ? `${nom.slice(0, 39)}…` : nom; +} + +/** + * L'écran des projets. `neuf` ouvre un message plutôt que d'en réécrire un — + * ce qu'une commande tapée mérite, et qu'un clic ne mérite pas. + */ +async function ecranProjets(chatId: number, neuf: boolean): Promise { + const tg = telegram; + if (!tg) return; + const v = vue(chatId); + // La liste devient la référence des rangs : on la garde telle qu'elle a été + // montrée, sans quoi un `/atelier 3` désignerait autre chose que la troisième + // ligne lue. + v.projets = await listProjects(); + if (neuf) v.messageId = null; + + if (!v.projets.length) { + await tg.envoie(chatId, t('passerelle.aucunProjet')); + return; + } + const cases = v.projets.map((p, i) => ({ texte: nomCourt(p.name), donnee: `n:p:${i}` })); + await ecran(chatId, t('passerelle.projets'), grille(cases)); +} + +/** + * Ce qui tourne, des deux sources qui ne se recouvrent pas. + * + * Le registre ne connaît que les sessions qu'AURA possède ; celles qu'on a + * lancées dans un terminal n'y figurent pas et n'y figureront jamais — elles + * n'existent que par leur fichier d'état sous `~/.claude/sessions`. N'en montrer + * qu'une des deux faisait répondre « rien ne tourne » à quelqu'un qui regardait + * une session tourner. + */ +async function etatDesSessions(): Promise { + const atelier = listSessions(); + const systeme = await sessionsActives(); + // Une session de l'Atelier a aussi son fichier d'état : sans ce filtre, elle + // se compterait deux fois. + const runIds = new Set(atelier.map((s) => s.sessionId).filter(Boolean)); + const ailleurs = systeme.filter((s) => !runIds.has(s.sessionId)); + + if (!atelier.length && !ailleurs.length) return t('passerelle.aucuneSession'); + + const lignes: string[] = []; + if (atelier.length) { + lignes.push(t('passerelle.sessionsAtelier')); + for (const s of atelier) lignes.push(`• ${s.cwd} — ${s.status}`); + } + if (ailleurs.length) { + if (lignes.length) lignes.push(''); + lignes.push(t('passerelle.sessionsAilleurs')); + for (const s of ailleurs) { + const etat = s.status ?? '?'; + lignes.push(`• ${s.cwd || '?'} — ${etat}${s.waitingFor ? ` (${s.waitingFor})` : ''}`); + } + } + return lignes.join('\n'); +} + +/** + * La fenêtre d'une session, rapportée à la limite de son modèle. + * + * `contextLimitFor` fait le travail délicat, et il vaut de rappeler pourquoi il + * existe : un modèle à fenêtre longue s'enregistre **sans** son suffixe `[1m]`, + * si bien que l'identifiant ne suffit pas à trancher. Deux preuves le + * remplacent, et la session les porte toutes deux — le plus grand contexte + * observé (`max`), et le modèle tel qu'il a été choisi dans les réglages. + */ +async function fenetreDe(runner: SessionRunner): Promise { + const releve = runner.contextWindow; + const { cwd, model, resolvedModel } = runner.session; + return { + tokens: releve.tokens, + limite: contextLimitFor([resolvedModel ?? model], releve.max, await configuredLongWindow(cwd)), + }; +} + +/** + * Dit une fois que la fenêtre se remplit, puis se tait. + * + * C'est la seule chose qu'AURA avance d'elle-même à propos du contexte, et la + * règle de silence de `docs/voix.md` la justifie : le « je » porte ici une + * information que rien à l'écran ne donne — de loin, on ne voit pas la fenêtre. + * Répéter à chaque tour, en revanche, n'apprendrait plus rien. + */ +async function signaleFenetre(chatId: number, fil: Fil): Promise { + const tg = telegram; + const runner = getRunner(fil.runId); + if (!tg || !runner) return; + + const ratio = part(await fenetreDe(runner)); + if (!alerte(fil.alerte, ratio)) return; + fil.alerte = true; + await tg.envoie(chatId, t('passerelle.fenetrePleine', { pourcent: Math.round(ratio * 100) })); +} + +/** + * Où en est la session de cette conversation. + * + * La fenêtre est ce qu'une conversation ne montre jamais d'elle-même : on voit + * ce que l'agent répond, jamais la place qu'il lui reste. De près, l'Atelier + * l'affiche ; de loin, il n'y avait rien. + */ +async function ecranEtat(chatId: number): Promise { + const tg = telegram; + if (!tg) return; + + const runner = courant(chatId); + if (!runner) { + await tg.envoie(chatId, t('passerelle.aucunFil')); + return; + } + + const { cwd, model, resolvedModel } = runner.session; + const fenetre = await fenetreDe(runner); + await tg.envoie( + chatId, + lignesEtat( + fenetre, + cwd, + resolvedModel || model || t('passerelle.etatModeleInconnu'), + mode(), + ).join('\n'), + ); +} + +/** + * L'accueil : ce que je suis, ce que je vois, et par où entrer. + * + * Il **constate** au lieu de se présenter, parce que `docs/voix.md` le range + * parmi les surfaces où le « je » doit porter une information que la + * formulation impersonnelle ne portait pas. Le nombre de projets et l'état de + * la conversation sont cette information ; « voici le menu » ne l'aurait pas + * été, les boutons étant déjà à l'écran. + * + * Il prend la place du message de navigation, si bien qu'un clic sur « Les + * projets » le **réécrit** au lieu d'empiler : l'accueil devient la première + * marche du même parcours que l'arborescence, pas un écran à part. + */ +async function ecranAccueil(chatId: number): Promise { + const v = vue(chatId); + v.projets = await listProjects(); + + const lignes = [t('passerelle.accueil')]; + if (!v.projets.length) lignes.push(t('passerelle.accueilAucunProjet')); + else if (v.projets.length === 1) lignes.push(t('passerelle.accueilUnProjet')); + else lignes.push(t('passerelle.accueilProjets', { n: v.projets.length })); + + // Ce que cette conversation tient déjà passe avant le parc : c'est la seule + // ligne qui parle de vous plutôt que de la machine. + const sien = courant(chatId); + const parc = listSessions().length; + if (sien) lignes.push(t('passerelle.accueilSession', { cwd: sien.session.cwd })); + else if (parc === 1) lignes.push(t('passerelle.accueilUnTravail')); + else if (parc > 1) lignes.push(t('passerelle.accueilTravaux', { n: parc })); + + // Une commande tapée mérite son message, à sa date — comme `/projet`. + v.messageId = null; + await ecran( + chatId, + lignes.join('\n'), + // En rangée et non en `solo` : `solo` sert à détacher une action d'une + // liste, et ici il n'y a pas de liste — les trois sont du même rang. + grille([ + { texte: t('passerelle.menuProjets'), donnee: 'n:r' }, + { texte: t('passerelle.menuSessions'), donnee: 'n:s' }, + { texte: t('passerelle.menuAide'), donnee: 'n:h' }, + ]), + ); +} + +/** Charge l'inventaire d'un projet et montre sa fiche. */ +async function ouvreProjet(chatId: number, projet: ProjectSummary): Promise { + const tg = telegram; + if (!tg) return; + const v = vue(chatId); + try { + // Le même inventaire que la page Détail : la conversation ne montre ni plus + // ni moins que l'écran. + v.entrees = aplatir(await getProjectResources(projet.slug)); + v.racine = arborescence(v.entrees); + v.chemin = []; + v.slug = projet.slug; + } catch (e) { + await tg.envoie(chatId, publicMessage(e)); + return; + } + if (!v.entrees.length) { + await ecran( + chatId, + t('passerelle.projetVide', { nom: projet.name }), + grille([], [{ texte: t('passerelle.retourProjets'), donnee: 'n:r' }]), + ); + return; + } + await ecranDossier(chatId, projet); +} + +/** + * Ouvre une session sur un projet. Même chemin depuis la commande et le bouton. + * + * Le projet a déjà été résolu par l'appelant : c'est là qu'est la garde, et + * elle n'a pas à être refaite ici. + */ +async function ouvreAtelier(chatId: number, projet: ProjectSummary): Promise { + const tg = telegram; + if (!tg) return; + // Une conversation ne tient qu'une session : ouvrir en referme une. + defait(chatId, true); + if (atCapacity()) { + await tg.envoie(chatId, t('errors.tooManySessions', { max: MAX_SESSIONS })); + return; + } + try { + const runner = createRunner({ cwd: projet.path, permissionMode: mode() }); + attache(chatId, runner); + await tg.envoie(chatId, t('passerelle.sessionOuverte', { cwd: runner.session.cwd })); + } catch (e) { + await tg.envoie(chatId, publicMessage(e)); + } +} + +/** Le projet dont la fiche est ouverte, si elle l'est encore. */ +function projetCourant(chatId: number): ProjectSummary | undefined { + const v = vue(chatId); + return v.projets.find((p) => p.slug === v.slug); +} + +/** + * Un pas de navigation. + * + * L'état vit en mémoire : un redémarrage du serveur le perd, et les boutons + * d'un message plus ancien ne désignent alors plus rien. On le dit plutôt que + * de ne rien faire — un bouton sans effet passe pour une panne. + */ +async function navigue(chatId: number, ordre: string, argument: string): Promise { + const tg = telegram; + if (!tg) return; + const v = vue(chatId); + + if (ordre === 'r') { + await ecranProjets(chatId, false); + return; + } + + // Les deux sorties de l'accueil. Elles produisent du **contenu**, pas un pas + // de navigation : elles partent donc dans leur propre message et laissent + // l'accueil en place, là où « Les projets » le réécrit. C'est la même règle + // que `/voir`, qui n'écrase jamais l'arborescence qu'on parcourait. + if (ordre === 's') { + await tg.envoie(chatId, await etatDesSessions()); + return; + } + if (ordre === 'h') { + await tg.envoie(chatId, aide()); + return; + } + + if (ordre === 'p') { + if (!v.projets.length) v.projets = await listProjects(); + const projet = v.projets[Number(argument)]; + if (!projet) { + await tg.envoie(chatId, t('passerelle.navigationPerimee')); + return; + } + await ouvreProjet(chatId, projet); + return; + } + + const projet = projetCourant(chatId); + if (!projet) { + await tg.envoie(chatId, t('passerelle.navigationPerimee')); + return; + } + + if (ordre === 'u') { + v.chemin.pop(); + await ecranDossier(chatId, projet); + return; + } + if (ordre === 'd') { + const dossier = descendre(v.racine, v.chemin); + const enfant = dossier?.enfants[Number(argument)]; + // L'arbre a changé sous le message : le rang ne désigne plus le dossier + // qu'on a montré, ou plus un dossier du tout. On le dit, comme partout + // ailleurs — un bouton sans effet passe pour une panne. + if (!enfant || enfant.fichier) { + await tg.envoie(chatId, t('passerelle.navigationPerimee')); + return; + } + v.chemin.push(enfant.nom); + await ecranDossier(chatId, projet); + return; + } + if (ordre === 'f') { + // Le contenu part dans un message à lui : il est long, il se pagine, et il + // doit rester lisible après qu'on a continué à naviguer au-dessus. + await page(chatId, Number(argument), 1); + return; + } + if (ordre === 'a') { + await ouvreAtelier(chatId, projet); + } +} + +/** + * Le projet qu'une référence désigne. + * + * La liste se charge d'elle-même si elle n'a jamais été demandée : `/atelier + * ` en première commande doit marcher sans avoir à faire `/projets` + * d'abord. Un rang, lui, n'a de sens que par rapport à une liste déjà montrée — + * et c'est celle-là qu'on garde, pas une plus fraîche qui renumérerait sous les + * yeux de qui vient de lire. + */ +async function trouve(chatId: number, ref: string): Promise { + const v = vue(chatId); + if (!v.projets.length) v.projets = await listProjects(); + return resoudreProjet(v.projets, ref); +} + +/** Le lecteur d'une origine. Chacun a son bac à sable ; aucun ne couvre l'autre. */ +function lecteur( + source: Entree['source'], +): (slug: string, rel: string) => Promise<{ rel: string; content: string }> { + if (source === 'claude') return readProjectResource; + if (source === 'inclus') return readProjectIncludedFile; + return readProjectMemory; +} + +/** + * Une page d'un fichier, mise en forme, avec de quoi tourner la page. + * + * Le fichier est relu à chaque page plutôt que gardé en mémoire : il tient sur + * le disque, il peut changer entre deux pages, et garder le contenu de tous les + * documents consultés par toutes les conversations reviendrait à un cache dont + * personne n'a demandé les ennuis. + */ +async function page(chatId: number, rang: number, numero: number): Promise { + const tg = telegram; + const v = vue(chatId); + const entree = v.entrees[rang - 1]; + if (!tg || !entree) return; + + let contenu: string; + try { + contenu = (await lecteur(entree.source)(v.slug, entree.rel)).content; + } catch (e) { + await tg.envoie(chatId, publicMessage(e)); + return; + } + + // La borne est celle du message riche, huit fois celle d'un message ordinaire. + // Un document qui tenait en sept pages en tient désormais en une. + const pages = paginer(contenu, PAGE_RICHE); + const index = Math.min(Math.max(numero, 1), pages.length); + const source = pages[index - 1] ?? ''; + const entete = + pages.length > 1 + ? t('passerelle.pageDe', { fichier: entree.label, page: index, total: pages.length }) + : entree.label; + + // Le rendu ne vaut que pour du Markdown : appliquer la traduction à un JSON ou + // à un fichier de réglages y verrait des puces et des emphases qui n'existent + // pas. Les autres partent en chasse fixe, qui est leur forme lisible. + const markdown = estMarkdown(entree.rel); + const blocs: InputRichBlock[] = [ + { type: 'heading', text: entete, size: 3 }, + ...(markdown ? enBlocs(source) : [{ type: 'pre' as const, text: source }]), + ]; + + await tg.envoieRendu( + chatId, + blocs, + `${entete}\n\n${source}`, + navigation(rang, index, pages.length), + ); +} + +/** + * La réponse de l'agent, rendue comme un document. + * + * Elle en est un : un agent écrit du Markdown — titres, listes, tableaux, + * chemins entre accents graves. L'envoyer en texte nu affichait ses barres + * verticales et ses dièses, et c'était le rendu le plus souvent lu de toute la + * Passerelle, plus souvent qu'aucun fichier. + * + * Deux différences avec `envoieFichier`, et elles tiennent à ce qu'une réponse + * n'est pas un fichier : pas d'en-tête — on sait qui parle —, et pas de + * pagination. Une réponse trop longue est **coupée**, comme avant : tourner la + * page suppose de pouvoir relire la source, or celle-ci ne vit que dans la + * session. + */ +async function repond(chatId: number, texte: string): Promise { + const tg = telegram; + if (!tg) return; + // La borne du riche est huit fois celle d'un message ordinaire : ce qui était + // coupé à 4 000 caractères passe désormais entier dans presque tous les cas. + const source = texte.length > PAGE_RICHE ? `${texte.slice(0, PAGE_RICHE)}…` : texte; + await tg.envoieRendu(chatId, enBlocs(source), source); +} + +/** + * Pose l'étape courante d'un formulaire, et se met en attente d'une réponse. + * + * Un message neuf par étape, et non une réécriture : un message riche ne se + * réécrit pas — la bibliothèque n'expose aucun `editMessageRichText`. Ce n'est + * pas une perte ici, une question posée ayant sa place dans le fil à sa date. + */ +async function poseQuestion(chatId: number, fil: Fil, f: Formulaire): Promise { + const tg = telegram; + if (!tg) return; + await desarme(chatId, f); + const vue = ecranQuestion(f); + const rendu = await tg.envoieRendu(chatId, vue.blocs, vue.brut, vue.clavier); + f.messageId = rendu.messageId; + fil.attente = f.id; +} + +/** + * Retire le clavier de l'écran qu'on quitte. + * + * Sans cela, les boutons d'une étape déjà répondue restent pressables — et, + * comme ils ne portent qu'un rang, ils s'appliqueraient à la question suivante. + * Un clic destiné à la première question cocherait une option de la deuxième. + */ +async function desarme(chatId: number, f: Formulaire): Promise { + if (f.messageId === null) return; + await telegram?.reecritClavier(chatId, f.messageId, { inline_keyboard: [] }); + f.messageId = null; +} + +/** + * Ce qu'une réponse partielle entraîne : cocher, passer à la suite, ou conclure. + * + * `questions.ts` décide, ce qui suit ne fait qu'émettre. Cocher ne réécrit que + * le clavier — la question au-dessus n'a aucune raison de bouger. + */ +async function avanceQuestion( + chatId: number, + fil: Fil, + f: Formulaire, + quoi: ReturnType, +): Promise { + const tg = telegram; + if (!tg) return; + + if (quoi === 'coche') { + if (f.messageId !== null) + await tg.reecritClavier(chatId, f.messageId, ecranQuestion(f).clavier); + return; + } + if (quoi === 'suivant') { + await poseQuestion(chatId, fil, f); + return; + } + if (quoi !== 'fini') return; + + await desarme(chatId, f); + fil.asks.delete(f.id); + fil.attente = null; + // Le tour suspendu repart ici : `answerAsk` dénoue la promesse que l'outil + // MCP tient depuis `ask.ts`. Rien à attendre, la main revient aussitôt. + courant(chatId)?.answerAsk(f.id, reponses(f)); +} + +/** Ce fichier se lit-il comme du Markdown ? */ +function estMarkdown(rel: string): boolean { + return /\.(md|markdown|mdx)$/i.test(rel); +} + +/** + * Les boutons qui tournent la page. + * + * Rien sur un document d'une seule page : un clavier qui ne mène nulle part + * occupe l'écran et invite à un geste sans effet. + */ +function navigation(rang: number, index: number, total: number): InlineKeyboardMarkup | undefined { + if (total <= 1) return undefined; + const paires: { texte: string; donnee: string }[] = []; + if (index > 1) + paires.push({ texte: t('passerelle.precedent'), donnee: `v:${rang}:${index - 1}` }); + if (index < total) + paires.push({ texte: t('passerelle.suivant'), donnee: `v:${rang}:${index + 1}` }); + return boutons(paires); +} + +/** Le mode de permission des sessions ouvertes de loin. */ +function mode(): string { + const brut = (process.env.AURA_TELEGRAM_MODE ?? '').trim(); + return brut && isPermissionMode(brut) ? brut : 'default'; +} + +/** + * Branche une conversation sur une session. + * + * L'abonnement sert deux fins, et la seconde n'est pas un effet de bord : il + * **protège la session du balayeur**. `SessionRunner.expired` rend `false` dès + * qu'un abonné regarde ; sans cela, une session pilotée d'ici sans onglet ouvert + * serait ramassée au bout d'une demi-heure, en pleine conversation. + */ +function attache(chatId: number, runner: SessionRunner): void { + const tg = telegram; + const fil: Fil = { + runId: runner.session.runId, + detache: () => {}, + abonne: false, + tour: new Map(), + asks: new Map(), + permissions: new Map(), + attente: null, + alerte: false, + battement: new Battement( + { + brouillon: async (id, draft, texte) => { + await tg?.brouillon(id, draft, texte); + }, + saisie: async (id) => { + await tg?.saisie(id); + }, + }, + chatId, + prochainBrouillon++, + ), + }; + /** + * Les événements se traitent **à la file**, jamais de front. + * + * Chaque `applique` fait des appels réseau, et deux envois lancés en parallèle + * arrivent dans l'ordre où Telegram les sert, pas dans celui où la session les + * a produits. Mesuré : le résumé d'une compaction — un gros message riche — + * doublait l'annonce qui le précédait, si bien que la conversation expliquait + * après coup ce qu'elle venait de montrer. + * + * Une promesse chaînée suffit, et rien ne s'y perd : `applique` avale déjà ses + * propres erreurs, le `catch` n'est là que pour qu'une exception inattendue ne + * casse pas la file elle-même. + */ + let file: Promise = Promise.resolve(); + fil.detache = runner.subscribe((upsert) => { + file = file.then(() => applique(chatId, fil, upsert)).catch(() => {}); + }); + fils.set(chatId, fil); +} + +/** + * Défait le fil d'une conversation. Idempotent. + * + * `ferme` distingue les deux cas qui n'ont rien de commun : on abandonne un fil + * dont la session est déjà morte, ou on ferme une session qui vit encore. + */ +function defait(chatId: number, ferme: boolean): void { + const fil = fils.get(chatId); + if (!fil) return; + fil.detache(); + fil.battement.arrete(); + fils.delete(chatId); + if (ferme) removeRunner(fil.runId); +} + +/** + * Ce qu'une conversation reçoit d'une session. + * + * Volontairement peu : le texte de fin de tour, les demandes qui attendent un + * humain, et la fin de la session. Ni les `text-delta`, ni les entrées d'outils + * — une messagerie n'est pas une timeline, et un flux de tokens y serait + * illisible. + * + * L'activité fait exception, et une seule : elle ne devient pas un message mais + * une bulle éphémère, qui s'efface sans rien laisser dans le fil. Voir + * `activite.ts`. + */ +async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise { + const tg = telegram; + if (!tg) return; + + switch (upsert.kind) { + /** + * `snapshot` a **deux** sites d'émission, et ils n'appellent pas la même + * réponse. + * + * Le premier est l'abonnement (`runner.ts`, `subscribe`) : il rejoue tout + * l'historique, et le renvoyer inonderait la conversation d'un travail déjà + * lu. Le second est `resetConversation` : le contexte vient d'être vidé, le + * CLI a ouvert un transcript neuf, et l'agent n'a plus aucun souvenir. Se + * taire là-dessus laisse parler à quelqu'un qui a tout oublié. + * + * La conversation ne peut pas le provoquer elle-même — `/clear` n'est pas + * une commande que `routage.ts` sert. Cela vient donc toujours d'ailleurs : + * de l'onglet de l'Atelier, ou du SDK lui-même. Raison de plus de le dire. + */ + case 'snapshot': { + if (!fil.abonne) { + fil.abonne = true; + return; + } + fil.battement.arrete(); + // Les textes du tour en cours parlent d'une conversation qui n'existe + // plus : les envoyer maintenant serait citer un souvenir effacé. + fil.tour.clear(); + // Le contexte est vide : la fenêtre a de nouveau le droit de se remplir, + // et de le signaler. Même raison qu'après une compaction. + fil.alerte = false; + const dit = upsert.events + .filter((e) => e.kind === 'system') + .flatMap((e) => e.blocks) + .map((b) => b.text ?? '') + .join('\n') + .trim(); + // Les mêmes mots qu'à l'écran (`agent.cleared`), et non une phrase de + // plus : une session lue de deux endroits ne raconte pas deux histoires. + if (dit) await tg.envoie(chatId, dit); + return; + } + + case 'append-event': + case 'replace-event': { + const event = upsert.event; + + /** + * La compaction : le seul moment où la fenêtre change sans qu'on ait + * touché à rien. + * + * L'événement porte ses chiffres mais **pas de texte** — ses `blocks` sont + * vides. Il n'y a donc rien à relayer : la phrase se compose ici, avec les + * deux nombres qui la rendent utile. « J'ai compacté » sans eux ne dirait + * pas si l'on repart de dix mille tokens ou de cent mille. + */ + if (event.kind === 'compaction' && event.compaction) { + /** + * Le résumé n'arrive pas avec la frontière : il la complète, une + * fraction de seconde plus tard, par un `replace-event` sur le même + * événement. D'où ces deux messages plutôt qu'un — et c'est aussi bien, + * car ce sont deux choses. Le fait tient en une ligne ; le résumé est un + * document, et il se replie de lui-même comme n'importe quel document + * long. + */ + if (upsert.kind === 'replace-event') { + const resume = event.blocks + .filter((b) => b.kind === 'text') + .map((b) => b.text ?? '') + .join('\n\n') + .trim(); + if (!resume) return; + const titre = t('passerelle.compactionResume'); + const source = resume.length > PAGE_RICHE ? `${resume.slice(0, PAGE_RICHE)}…` : resume; + // Le repli refusé, on préfère un document déplié à un document perdu : + // c'est la même règle que la cascade de `envoieRendu`. + if (!(await tg.envoieReplie(chatId, titre, enBlocs(source)))) { + await repond(chatId, `${titre}\n\n${source}`); + } + return; + } + + // La fenêtre repart de bas : elle a de nouveau le droit de se remplir, + // et de le signaler. + fil.alerte = false; + await tg.envoie( + chatId, + t('passerelle.compaction', { + // Le même format que `/etat`, sans quoi la conversation dirait + // « 31 k » ici et « 30549 » deux lignes plus bas pour la même + // fenêtre. + avant: nombre(event.compaction.preTokens), + apres: nombre(event.compaction.postTokens), + }), + ); + return; + } + + if (event.kind !== 'assistant' || event.isSidechain) return; + const texte = event.blocks + .filter((b) => b.kind === 'text') + .map((b) => b.text ?? '') + .join('') + .trim(); + // `replace-event` porte le même uuid : la carte écrase, elle n'ajoute pas. + if (texte) fil.tour.set(event.uuid, texte); + return; + } + + // Le seul flux qu'on relaie, et il ne devient pas un message : la bulle + // éphémère qui dit que ça travaille. Voir `activite.ts`. + case 'activity': + fil.battement.montre(upsert.activity); + return; + + case 'status': { + if (upsert.status === 'working') { + fil.tour.clear(); + return; + } + // Tout ce qui n'est plus `working` vide le tour, `waiting` compris — et + // c'est voulu. `waiting`, c'est l'agent qui s'arrête pour vous demander + // quelque chose : ce qu'il a écrit avant part **maintenant**, et la + // demande suit avec ses boutons. On voit ce qu'il veut faire avant d'avoir + // à le trancher, au lieu de choisir à l'aveugle puis de lire pourquoi. + fil.battement.arrete(); + const dit = [...fil.tour.values()].join('\n\n').trim(); + fil.tour.clear(); + if (dit) await repond(chatId, dit); + + if (upsert.status === 'failed') { + await tg.envoie(chatId, t('passerelle.sessionEchouee', { message: upsert.error ?? '' })); + defait(chatId, false); + } else if (upsert.status === 'ended') { + await tg.envoie(chatId, t('passerelle.sessionFinie')); + defait(chatId, false); + } else { + // Après la réponse, jamais avant : ce qu'on attendait passe d'abord, et + // l'avertissement ne s'interpose pas entre la question et sa réponse. + await signaleFenetre(chatId, fil); + } + return; + } + + case 'permission-request': { + // La balle est dans votre camp : ce n'est plus AURA qui travaille, et + // laisser battre la bulle ferait croire le contraire. + fil.battement.arrete(); + const demande = upsert.request; + const plan = planPropose(demande); + if (plan) { + // Le plan est le seul appel dont l'argument *est* la décision : le nom + // de l'outil n'apprend rien, et deux boutons sans le texte reviennent à + // faire approuver ce qu'on n'a pas lu. + const source = `${t('passerelle.plan')}\n\n${plan}`; + const coupe = source.length > PAGE_RICHE ? `${source.slice(0, PAGE_RICHE)}…` : source; + const rendu = await tg.envoieRendu( + chatId, + enBlocs(coupe), + coupe, + boutons([ + { texte: t('passerelle.approuver'), donnee: `p:${demande.id}:a` }, + { texte: t('passerelle.refuser'), donnee: `p:${demande.id}:d` }, + ]), + ); + if (rendu.messageId !== null) fil.permissions.set(demande.id, rendu.messageId); + return; + } + const quoi = demande.title || demande.displayName || demande.toolName; + const messageId = await tg.envoieSuivi( + chatId, + t('passerelle.permission', { outil: quoi }), + boutons([ + { texte: t('passerelle.autoriser'), donnee: `p:${demande.id}:a` }, + { texte: t('passerelle.refuser'), donnee: `p:${demande.id}:d` }, + ]), + ); + if (messageId !== null) fil.permissions.set(demande.id, messageId); + return; + } + + /** + * La demande est tranchée : ses boutons n'ont plus rien à trancher. + * + * Reçu quelle que soit la main qui a répondu — la messagerie, l'Atelier, ou + * le garde-fou du quart d'heure. Les laisser en place ferait douter que le + * clic ait porté, et un fil relu plus tard donnerait à croire que la + * question attend encore. + */ + case 'permission-settled': { + const messageId = fil.permissions.get(upsert.id); + fil.permissions.delete(upsert.id); + if (messageId === undefined) return; + await tg.reecritClavier(chatId, messageId, { inline_keyboard: [] }); + return; + } + + case 'ask-request': { + // La balle est dans votre camp : laisser battre la bulle ferait croire + // qu'AURA travaille encore. + fil.battement.arrete(); + const demande = upsert.request; + if (!demande.questions.length) return; + const f = formulaire(demande.id, demande.questions); + fil.asks.set(demande.id, f); + await poseQuestion(chatId, fil, f); + return; + } + + /** + * Reçu pour un formulaire **encore présent** : personne n'a répondu, et le + * garde-fou du quart d'heure (`runner.ts`) a tranché à notre place. Quand + * c'est nous qui répondons, l'entrée est déjà partie. + */ + case 'ask-settled': { + const perime = fil.asks.get(upsert.id); + fil.asks.delete(upsert.id); + if (fil.attente === upsert.id) fil.attente = null; + if (!perime) return; + // Sans cela, des boutons morts resteraient pressables sous une question + // que plus rien n'attend. + if (perime.messageId !== null) { + await tg.reecritClavier(chatId, perime.messageId, { inline_keyboard: [] }); + } + await tg.envoie(chatId, t('passerelle.questionExpiree')); + return; + } + + default: + return; + } +} + +/** + * La session de cette conversation, si elle vit encore. + * + * Le registre a pu la ramasser, ou le serveur redémarrer : le fil ne désigne + * alors plus rien, et il vaut mieux l'oublier que de parler dans le vide. + */ +function courant(chatId: number): SessionRunner | undefined { + const fil = fils.get(chatId); + if (!fil) return undefined; + const runner = getRunner(fil.runId); + if (!runner) { + defait(chatId, false); + return undefined; + } + return runner; +} + +/** Exécute ce qu'un message voulait dire. */ +async function traite(chatId: number, brut: string): Promise { + const tg = telegram; + if (!tg) return; + const intention = parseIntention(brut); + + switch (intention.kind) { + case 'ignorer': + // Un message vide ne mérite pas de réponse ; une commande inconnue, si — + // sans quoi une faute de frappe passerait pour une panne. + if (intention.raison === 'commande-inconnue') { + await tg.envoie( + chatId, + t('passerelle.commandeInconnue', { commande: intention.commande ?? '' }), + ); + } + return; + + case 'aide': + await tg.envoie(chatId, aide()); + return; + + case 'etat': + await ecranEtat(chatId); + return; + + case 'compacter': { + const runner = courant(chatId); + if (!runner) { + await tg.envoie(chatId, t('passerelle.aucunFil')); + return; + } + // La seule commande de Claude Code qu'on relaie, et elle passe par la file + // d'entrée comme un tour : c'est le CLI qui compacte, pas nous. Rien à + // annoncer ici — la frontière de compaction s'annoncera d'elle-même, avec + // ses chiffres, quand elle arrivera. + runner.send('/compact'); + return; + } + + case 'sessions': + await tg.envoie(chatId, await etatDesSessions()); + return; + + case 'accueil': + await ecranAccueil(chatId); + return; + + case 'projets': + await ecranProjets(chatId, true); + return; + + case 'projet': { + const projet = await trouve(chatId, intention.ref); + if (!projet) { + await tg.envoie(chatId, t('passerelle.projetInconnu')); + return; + } + // Un nouvel écran, et non une réécriture : la commande a été tapée, donc + // elle a sa place dans le fil, à sa date. + vue(chatId).messageId = null; + await ouvreProjet(chatId, projet); + return; + } + + case 'voir': { + const v = vue(chatId); + if (!v.entrees.length) { + await tg.envoie(chatId, t('passerelle.aucuneListe')); + return; + } + const rang = Number(intention.ref); + if (!/^\d+$/.test(intention.ref.trim()) || !v.entrees[rang - 1]) { + await tg.envoie(chatId, t('passerelle.fichierInconnu')); + return; + } + await page(chatId, rang, 1); + return; + } + + case 'ouvrir': { + const projet = await trouve(chatId, intention.ref); + // La garde de l'Atelier à distance : on n'ouvre que sur un projet que + // Claude Code connaît déjà. Un chemin quelconque de la machine ne tombe + // sur rien — il n'y a donc pas de règle à contourner, seulement une liste + // dans laquelle être. + if (!projet) { + await tg.envoie(chatId, t('passerelle.projetInconnu')); + return; + } + await ouvreAtelier(chatId, projet); + return; + } + + case 'fin': { + if (!fils.has(chatId)) { + await tg.envoie(chatId, t('passerelle.aucunFil')); + return; + } + defait(chatId, true); + await tg.envoie(chatId, t('agent.sessionStopped')); + return; + } + + case 'stop': { + const runner = courant(chatId); + if (!runner) { + await tg.envoie(chatId, t('passerelle.aucunFil')); + return; + } + await runner.interrupt(); + return; + } + + case 'parler': { + const runner = courant(chatId); + if (!runner) { + await tg.envoie(chatId, t('passerelle.aucunFil')); + return; + } + // Une question qui attend capte ce qui serait parti comme un tour : c'est + // le « Other » du harnais, et la seule façon de répondre ce qu'aucun + // bouton ne dit. L'interception vit **ici**, après `parseIntention` : sous + // une question, `/stop` et `/fin` restent des commandes. + const fil = fils.get(chatId); + const f = fil?.attente ? fil.asks.get(fil.attente) : undefined; + if (fil && f) { + await avanceQuestion(chatId, fil, f, repondLibre(f, intention.texte)); + return; + } + runner.send(intention.texte); + return; + } + } +} + +/** + * Un bouton pressé : un pas de navigation, une page tournée, une permission + * tranchée, une question répondue. + * + * Tout est attendu, y compris ce qui n'a rien à rendre. Détacher une promesse + * ici la sortirait du `try` de l'appelant **et** du crochet d'erreur de la + * bibliothèque : un disque qui répond mal pendant une navigation deviendrait un + * rejet non traité, et Node termine le process là-dessus. Un clic emporterait + * le serveur et les sessions de l'Atelier avec lui. + * + * Trancher une permission ou une question, en revanche, rend bien la main + * aussitôt : les deux dénouent une promesse tenue côté runner. C'est le tour + * suspendu qui repart, pas cet appel. + */ +async function tranche(chatId: number, donnee: string): Promise { + const [type, id, suffixe] = donnee.split(':'); + if (!type || !id) return; + + // Naviguer et tourner une page ne tranchent rien et ne demandent aucune + // session : ces cas passent donc **avant** la garde ci-dessous, qui refuserait + // de parcourir un projet simplement parce qu'aucun agent ne tourne. + // + // La navigation seule admet un ordre sans argument — « retour », « ouvrir + // ici » n'ont rien à désigner. + if (type === 'n') { + await navigue(chatId, id, suffixe ?? ''); + return; + } + if (!suffixe) return; + if (type === 'v') { + await page(chatId, Number(id), Number(suffixe)); + return; + } + + const runner = courant(chatId); + const fil = fils.get(chatId); + if (!runner || !fil) return; + + if (type === 'p') { + const reponse: PermissionAnswer = suffixe === 'a' ? 'allow' : 'deny'; + runner.answerPermission( + id, + reponse, + reponse === 'deny' ? t('passerelle.refuseDeLoin') : undefined, + ); + return; + } + + if (type === 'q') { + const f = fil.asks.get(id); + if (!f) return; + await avanceQuestion(chatId, fil, f, presse(f, suffixe)); + } +} + +/** + * Démarre la Passerelle, si elle est configurée. + * + * Trois refus, volontairement stricts : sans jeton elle n'existe pas, sans liste + * blanche elle ne démarre pas — l'omission ne doit pas ouvrir la machine au + * premier venu — et un jeton que Telegram rejette est signalé plutôt que retenté + * indéfiniment. + */ +export function demarrePasserelle(journal: Journal): void { + const token = (process.env.AURA_TELEGRAM_TOKEN ?? '').trim(); + if (!token) return; + + const chats = lireChats(process.env.AURA_TELEGRAM_CHATS); + if (chats.size === 0) { + journal.warn( + "Passerelle : un jeton est configuré mais aucune conversation n'est autorisée " + + '(AURA_TELEGRAM_CHATS). Je ne démarre pas.', + ); + return; + } + + const tg = new Telegram(token); + telegram = tg; + void boucle(tg, chats, journal); +} + +/** + * Pose la liste des commandes chez Telegram, une fois par langue et par + * conversation autorisée. + * + * Elle y était saisie à la main dans `@BotFather`, donc hors du dépôt : rien + * n'empêchait d'y annoncer une commande retirée depuis. Elle vient désormais de + * la même table que le routage et l'aide. + * + * Chaque langue reçoit la sienne, **la référence comprise**, et la déclaration + * sans langue la double. Cette redondance apparente est mesurée, pas décorative : n'ayant posé + * que le défaut pour le français, un client réglé en français demandait `fr`, + * ne trouvait rien, et n'affichait aucune commande — là où le client web + * retombait bien sur le repli. Plutôt que de départager les deux, on ne laisse + * plus de repli à prendre. + * + * Rien de bloquant : un échec ne coûte que l'autocomplétion, et la Passerelle + * marche sans. On le journalise plutôt que d'y renoncer en silence. + * + * Elle est posée **conversation par conversation**, et jamais sur la portée par + * défaut : celle-là s'affiche chez quiconque ouvre le bot, et un bot est + * adressable de toute la Terre. Un inconnu n'obtient déjà aucune réponse ; il + * n'a pas non plus à lire le menu de ce que cette machine sait faire. Le défaut + * est donc effacé au passage — il a pu être posé par une version antérieure, et + * ce qui est chez Telegram y reste tant qu'on ne le retire pas. + */ +async function declareCommandes(tg: Telegram, chats: Set, journal: Journal): Promise { + const rate = (quoi: string): void => + journal.warn(`Passerelle : Telegram a refusé la liste des commandes (${quoi}).`); + + for (const chatId of chats) { + // Le repli de la conversation : ce que voit un client dont la langue n'est + // pas des nôtres. + if (!(await tg.declare(withLocale(DEFAULT_LOCALE, pourTelegram), undefined, chatId))) { + rate('défaut'); + } + + for (const langue of SUPPORTED_LOCALES) { + if (!(await tg.declare(withLocale(langue, pourTelegram), langue, chatId))) rate(langue); + } + } + + // Telegram garde une liste par langue : les effacer toutes, sans quoi la + // langue survivrait au défaut qu'elle double. + if (!(await tg.efface())) rate('effacement du défaut'); + for (const langue of SUPPORTED_LOCALES) { + if (!(await tg.efface(langue))) rate(`effacement du défaut, ${langue}`); + } +} + +/** Le long-polling, jusqu'à l'extinction du serveur. */ +async function boucle(tg: Telegram, chats: Set, journal: Journal): Promise { + const nom = await tg.identite(); + if (!nom) { + journal.warn('Passerelle : Telegram refuse ce jeton. Je ne démarre pas.'); + telegram = null; + return; + } + journal.info(`Passerelle ouverte sur @${nom} — ${chats.size} conversation(s) autorisée(s).`); + await declareCommandes(tg, chats, journal); + + tg.ecoute( + // La garde passe avant tout traitement, et le silence est la réponse à un + // inconnu : répondre confirmerait que ce bot existe et à quoi il sert. + (chatId) => autorise(chats, chatId), + async (message) => { + try { + await traite(message.chatId, message.texte); + } catch (e) { + journal.warn(`Passerelle : ${publicMessage(e)}`); + } + }, + async (bouton) => { + try { + await tranche(bouton.chatId, bouton.donnee); + } catch (e) { + journal.warn(`Passerelle : ${publicMessage(e)}`); + } + }, + (message) => journal.warn(`Passerelle : ${message}`), + ); +} + +/** + * Coupe la Passerelle. Appelée à l'extinction du serveur. + * + * Les sessions ne sont pas fermées ici : `stopAll` s'en charge déjà, et une + * session ouverte depuis une conversation n'appartient pas plus à la Passerelle + * qu'à l'onglet qui la regardait. + */ +export function arretePasserelle(): void { + for (const chatId of [...fils.keys()]) defait(chatId, false); + vues.clear(); + telegram?.stop(); + telegram = null; +} diff --git a/server/passerelle/markdown.ts b/server/passerelle/markdown.ts new file mode 100644 index 0000000..7548027 --- /dev/null +++ b/server/passerelle/markdown.ts @@ -0,0 +1,69 @@ +// Couper un document en pages, sans casser ce qui est à cheval. +// +// Ce fichier ne rend rien : le rendu est celui de `riche.ts`, qui traduit le +// Markdown en blocs. Ici on ne décide que d'une chose — **où couper** un +// document trop long pour un seul message. +// +// Aucun réseau ici, et c'est voulu : tout ce fichier se vérifie par un test. + +/** + * Le document en pages, coupées sur des frontières de lignes. + * + * Couper au caractère près trancherait au milieu d'un mot, et surtout au milieu + * d'un bloc de code — dont la clôture se retrouverait sur la page suivante, où + * elle *ouvrirait* un bloc au lieu de le fermer. D'où le suivi de l'état : une + * page qui s'arrête dans un bloc le referme, et la suivante le rouvre. + * + * `max` est demandé plutôt que défini ici : la borne appartient à ce qui va + * porter la page — aujourd'hui le message riche — et non au découpage. + */ +export function paginer(markdown: string, max: number): string[] { + const lignes = markdown.replace(/\r\n?/g, '\n').split('\n'); + const pages: string[] = []; + let courante: string[] = []; + let taille = 0; + let dansCode = false; + let langue = ''; + + const cloture = (): void => { + if (!courante.length) return; + if (dansCode) courante.push('```'); + pages.push(courante.join('\n')); + courante = dansCode ? ['```' + langue] : []; + taille = courante.length ? langue.length + 4 : 0; + }; + + for (const ligne of lignes) { + // Une ligne à elle seule plus longue qu'une page : on la coupe, faute de + // mieux. Rare, et toujours préférable à une page qui ne part jamais. + for (const part of ligne.length > max ? decoupe(ligne, max) : [ligne]) { + const ferme = dansCode && /^\s*```/.test(part); + // La fermeture d'un bloc est rattachée à la page qui l'a ouvert, même si + // elle déborde : la renvoyer à la suivante y ouvrirait un bloc vide, et + // la page d'avant en refermerait un qu'elle vient déjà de refermer. + if (!ferme && taille + part.length + 1 > max) cloture(); + courante.push(part); + taille += part.length + 1; + + if (/^\s*```/.test(part)) { + if (dansCode) { + dansCode = false; + langue = ''; + } else { + dansCode = true; + langue = part.replace(/^\s*```/, '').trim(); + } + } + } + } + + if (courante.length) pages.push(courante.join('\n')); + return pages.length ? pages : ['']; +} + +/** Coupe une ligne trop longue en morceaux d'au plus `max`. */ +function decoupe(ligne: string, max: number): string[] { + const out: string[] = []; + for (let i = 0; i < ligne.length; i += max) out.push(ligne.slice(i, i + max)); + return out; +} diff --git a/server/passerelle/plan.ts b/server/passerelle/plan.ts new file mode 100644 index 0000000..d43ce36 --- /dev/null +++ b/server/passerelle/plan.ts @@ -0,0 +1,21 @@ +// Le plan qu'une session soumet avant de passer à l'acte. + +import type { PermissionRequest } from '../../shared/agent.ts'; + +/** + * Le texte du plan qu'une demande de permission soumet, s'il y en a un. + * + * `ExitPlanMode` est le seul appel dont l'argument **est** la décision : partout + * ailleurs le nom de l'outil et son chemin suffisent à juger, ici il n'y a rien + * à juger sans le texte. Le CLI le passe en clair (`input.plan`, du markdown) en + * même temps que `input.planFilePath` — c'est le premier qu'on lit, le fichier + * n'étant pas lisible de loin. + * + * Un plan vide rend la chaîne vide : mieux vaut retomber sur le bandeau + * ordinaire que d'envoyer un message riche qui ne porte que son en-tête. + */ +export function planPropose(demande: PermissionRequest): string { + if (demande.toolName !== 'ExitPlanMode') return ''; + const plan = demande.input.plan; + return typeof plan === 'string' ? plan.trim() : ''; +} diff --git a/server/passerelle/projets.ts b/server/passerelle/projets.ts new file mode 100644 index 0000000..3a28bd9 --- /dev/null +++ b/server/passerelle/projets.ts @@ -0,0 +1,210 @@ +// Désigner un projet, et ce qu'il porte, depuis une conversation. +// +// Deux problèmes que le fil de la messagerie pose et que l'écran n'a pas : on ne +// clique pas, et on ne recopie pas un chemin Windows au pouce. D'où des listes +// **numérotées** — le message qui suit désigne par son rang. +// +// Comme `routage.ts`, ce module ne touche ni au disque ni au réseau : il ne fait +// que choisir dans ce qu'on lui donne. C'est ce qui rend la garde de `/atelier` +// vérifiable par un test. + +import type { ProjectResources, ProjectSummary, ResourceNode } from '../../shared/projects.ts'; + +/** + * Un fichier consultable, tel que la conversation le désigne. + * + * `source` n'est pas cosmétique : elle décide **quel lecteur** du serveur ouvre + * le fichier, et il y en a trois — trois bacs à sable distincts, pour des `rel` + * qui se ressemblent à s'y méprendre : + * + * `claude` → `readProjectResource` borné au `.claude` du projet ; + * `arbre` → `readProjectMemory` borné aux familles que `sourceFileKind` + * nomme — un `CLAUDE.md`, un `README` ; + * `inclus` → `readProjectIncludedFile` borné à la liste d'inclusion, relue sur + * le disque à chaque appel. + * + * Les confondre ne fait pas « lire ailleurs » — chaque lecteur refuse ce qui + * n'est pas à lui —, cela fait refuser un fichier parfaitement légitime. C'est + * exactement ce qui est arrivé à un document de dossier inclus passé au lecteur + * de l'arbre : `sourceFileKind` ne le nomme pas, donc accès refusé. + */ +export interface Entree { + /** Le chemin que le lecteur attend, tel quel. */ + rel: string; + /** Ce que la conversation affiche. */ + label: string; + source: 'claude' | 'arbre' | 'inclus'; +} + +/** + * Un nœud de l'arborescence : un dossier, ou un fichier. + * + * Quatre-vingt-sept fichiers d'affilée ne se lisent pas. Les ranger par + * catégorie aurait été une réponse — mais une réponse inventée, alors que les + * chemins en portent déjà une : `agents/`, `rules/back/application/`, + * `skills/pipeline-duplication/references/`. Les « catégories » de la page + * Projet **sont** les dossiers de `.claude`. Suivre l'arbre, c'est donc montrer + * le projet tel qu'il est rangé, plutôt qu'un classement parallèle à retenir. + */ +export interface Noeud { + /** Le segment affiché — un nom de dossier, ou un nom de fichier. */ + nom: string; + /** Le fichier, pour une feuille. Absent sur un dossier. */ + fichier?: { entree: Entree; rang: number }; + /** Le contenu, pour un dossier. Vide sur une feuille. */ + enfants: Noeud[]; +} + +/** + * La forme comparable d'un chemin. + * + * Windows mélange les deux séparateurs et ignore la casse ; un chemin recopié + * d'un écran ne correspondrait sinon jamais à celui que le disque déclare. + */ +function comparable(chemin: string): string { + return chemin + .replace(/[\\/]+/g, '/') + .replace(/\/+$/, '') + .toLowerCase(); +} + +/** + * Le projet qu'une référence désigne, ou `undefined`. + * + * Trois écritures acceptées, du plus commode au plus explicite : le **rang** dans + * la dernière liste, le **chemin** complet, le **nom** ou le slug. + * + * C'est aussi la garde de `/atelier` : une référence qui ne tombe sur aucun + * projet connu ne rend rien, donc aucune session ne s'ouvre. Un chemin + * quelconque de la machine n'est pas « refusé » par une règle — il n'est + * simplement jamais trouvé, ce qui ne laisse aucune règle à contourner. + */ +export function resoudreProjet(projets: ProjectSummary[], ref: string): ProjectSummary | undefined { + const brut = ref.trim(); + if (!brut) return undefined; + + if (/^\d+$/.test(brut)) { + // Les listes sont numérotées à partir de 1 : c'est ce qu'on lit à l'écran. + return projets[Number(brut) - 1]; + } + + const cible = comparable(brut); + return ( + projets.find((p) => comparable(p.path) === cible) ?? + projets.find((p) => p.name.toLowerCase() === cible) ?? + projets.find((p) => p.slug.toLowerCase() === cible) + ); +} + +function noeuds(liste: ResourceNode[], source: Entree['source'], prefixe = ''): Entree[] { + return liste.map((n) => ({ rel: n.rel, label: `${prefixe}${n.rel}`, source })); +} + +/** + * L'arborescence des entrées, telle qu'on la parcourt. + * + * Le rang est celui de la liste plate, et il est conservé : c'est lui que + * `/voir ` attend, et il ne doit pas changer selon qu'on est arrivé par la + * navigation ou par la commande. + * + * Les dossiers d'abord, les fichiers ensuite, chacun par ordre alphabétique — + * c'est l'ordre d'un explorateur, et celui de l'arborescence de l'Atelier. + */ +export function arborescence(entrees: Entree[]): Noeud { + const racine: Noeud = { nom: '', enfants: [] }; + + entrees.forEach((entree, i) => { + const segments = entree.label.split('/').filter(Boolean); + const nomFichier = segments.pop() ?? entree.label; + + let courant = racine; + for (const segment of segments) { + // Un dossier ne se crée qu'une fois : deux fichiers du même dossier + // doivent atterrir dans le même nœud, pas dans deux homonymes. + let enfant = courant.enfants.find((n) => !n.fichier && n.nom === segment); + if (!enfant) { + enfant = { nom: segment, enfants: [] }; + courant.enfants.push(enfant); + } + courant = enfant; + } + courant.enfants.push({ nom: nomFichier, fichier: { entree, rang: i + 1 }, enfants: [] }); + }); + + compacte(racine); + trie(racine); + return racine; +} + +/** + * Fond les dossiers qui n'ont qu'un dossier pour enfant. + * + * `rules/back/application/` compterait sinon trois clics pour n'offrir aucun + * choix — trois écrans dont deux ne posent aucune question. Un explorateur de + * code fait de même, et pour la même raison. + */ +function compacte(noeud: Noeud): void { + for (const enfant of noeud.enfants) compacte(enfant); + + const seul = noeud.enfants[0]; + // Jamais la racine : elle n'a pas de nom à porter, et son unique enfant doit + // rester une ligne qu'on choisit. + if (noeud.nom && noeud.enfants.length === 1 && seul && !seul.fichier) { + noeud.nom = `${noeud.nom}/${seul.nom}`; + noeud.enfants = seul.enfants; + } +} + +/** Dossiers avant fichiers, puis alphabétique — l'ordre d'un explorateur. */ +function trie(noeud: Noeud): void { + noeud.enfants.sort( + (a, b) => + Number(Boolean(a.fichier)) - Number(Boolean(b.fichier)) || + a.nom.localeCompare(b.nom, 'fr', { numeric: true }), + ); + for (const enfant of noeud.enfants) trie(enfant); +} + +/** Combien de fichiers sous ce nœud, à toute profondeur. */ +export function compte(noeud: Noeud): number { + if (noeud.fichier) return 1; + return noeud.enfants.reduce((total, enfant) => total + compte(enfant), 0); +} + +/** + * Le nœud au bout d'un chemin de segments, ou `undefined`. + * + * Les segments sont ceux que la navigation a empilés, donc des noms déjà + * compactés (`rules/back/application`) : on compare au nom du nœud, jamais à un + * chemin qu'on recomposerait. + */ +export function descendre(racine: Noeud, chemin: string[]): Noeud | undefined { + let courant: Noeud | undefined = racine; + for (const segment of chemin) { + courant = courant.enfants.find((n) => !n.fichier && n.nom === segment); + if (!courant) return undefined; + } + return courant; +} + +/** + * Tout ce qu'un projet donne à lire, en une seule liste ordonnée. + * + * L'ordre est celui de la page Détail — ce que le projet configure d'abord, ce + * qu'il documente ensuite — parce que c'est le même inventaire : la conversation + * ne montre ni plus ni moins que l'écran, elle le montre à plat. + */ +export function aplatir(res: ProjectResources): Entree[] { + return [ + // Le préfixe rétablit le chemin réel : le `rel` d'une ressource part du + // `.claude`, pas de la racine du projet. Sans lui, `agents/` et un dossier + // `agents/` des sources se confondraient dans l'arbre. + ...noeuds(res.resources, 'claude', '.claude/'), + ...noeuds(res.memories, 'arbre'), + ...noeuds(res.repoDocs, 'arbre'), + // Un dossier inclus a son propre lecteur : ses documents ne portent aucun + // des noms que `sourceFileKind` reconnaît, et c'est bien pourquoi il a fallu + // demander leur inclusion pour les voir. Leur `rel` part déjà de la racine. + ...res.folders.flatMap((f) => noeuds(f.files, 'inclus')), + ]; +} diff --git a/server/passerelle/questions.ts b/server/passerelle/questions.ts new file mode 100644 index 0000000..2cff7d1 --- /dev/null +++ b/server/passerelle/questions.ts @@ -0,0 +1,205 @@ +// Le formulaire de l'Atelier, porté dans une conversation. +// +// Même doctrine que `routage.ts` : tout ce qui décide vit ici, sans réseau ni +// registre — c'est ce qui rend le comportement vérifiable par un test sans bot. +// `index.ts` ne fait qu'émettre ce que ce fichier rend et transmettre ce qu'il +// conclut. +// +// Ce qu'il reproduit est `src/components/agent/AskPrompt.vue`, et rien de plus : +// une question par écran, le choix multiple qui se coche, la réponse libre quand +// aucune option ne convient. La forme des réponses est celle du harnais — les +// choix multiples joints par `, ` —, parce que c'est elle que le rejeu relit. + +import type { AskQuestion } from '../../shared/agent.ts'; +import type { InputRichBlock } from './riche.ts'; +import { grille, tronqueBouton, type Bouton } from './telegram.ts'; +import { t } from '../i18n/index.ts'; +import type { InlineKeyboardMarkup } from 'node-telegram-bot-api'; + +/** + * Ce qu'une maquette prend au plus dans son bloc. + * + * Quatre options portant chacune deux cents lignes d'ASCII dépasseraient les + * 32 768 caractères du message riche, et c'est le message **entier** que l'API + * refuserait alors. La cascade de `envoieRendu` rattraperait la chute, mais en + * perdant toute la mise en forme : mieux vaut couper une maquette que rendre le + * formulaire en texte nu. + */ +const MAX_MAQUETTE = 1_200; + +/** Une demande en vol, et où l'on en est. */ +export interface Formulaire { + id: string; + questions: AskQuestion[]; + /** L'étape — une question par écran, comme le stepper de l'Atelier. */ + etape: number; + /** Les choix faits, par question. Un choix multiple garde sa liste. */ + choix: string[][]; + /** Le message de l'étape, dont on réécrit le clavier quand on coche. */ + messageId: number | null; +} + +export function formulaire(id: string, questions: AskQuestion[]): Formulaire { + return { id, questions, etape: 0, choix: questions.map(() => []), messageId: null }; +} + +/** La question de l'étape courante, si l'on n'est pas déjà au bout. */ +export function courante(f: Formulaire): AskQuestion | undefined { + return f.questions[f.etape]; +} + +/** + * Ce qu'une pression a produit, et ce que l'appelant doit en faire. + * + * `coche` ne demande qu'une réécriture du clavier — le texte de la question ne + * bouge pas, ce qui tombe bien : un message riche ne se réécrit pas. + */ +export type Suite = 'coche' | 'suivant' | 'fini' | 'rien'; + +/** + * `messageId` n'est **pas** remis à zéro ici, et c'est délibéré : l'appelant en + * a encore besoin pour retirer le clavier de l'étape qu'on quitte. Sans cela, + * les boutons d'un écran déjà répondu resteraient pressables et agiraient sur la + * question suivante — une option cochée par un clic destiné à la précédente. + */ +function avance(f: Formulaire): Suite { + f.etape += 1; + return f.etape >= f.questions.length ? 'fini' : 'suivant'; +} + +/** + * Une option pressée, ou la validation d'un choix multiple. + * + * `suffixe` est ce que le bouton portait : un rang d'option, ou `ok`. + */ +export function presse(f: Formulaire, suffixe: string): Suite { + const question = courante(f); + const choix = f.choix[f.etape]; + if (!question || !choix) return 'rien'; + + if (suffixe === 'ok') { + // Valider sans rien avoir coché ne veut rien dire : on laisse l'écran en + // place plutôt que d'envoyer une réponse vide au modèle. + return choix.length ? avance(f) : 'rien'; + } + + const option = /^\d+$/.test(suffixe) ? question.options[Number(suffixe)] : undefined; + if (!option) return 'rien'; + + if (!question.multiSelect) { + f.choix[f.etape] = [option.label]; + return avance(f); + } + + const deja = choix.indexOf(option.label); + if (deja === -1) choix.push(option.label); + else choix.splice(deja, 1); + return 'coche'; +} + +/** + * Une réponse écrite à la main, qui prend la place des options. + * + * C'est le « Other » du harnais, et la seule façon de répondre ce qu'aucun + * bouton ne dit. Elle remplace ce qui était coché — répondre en toutes lettres + * n'ajoute pas à un choix, il le tranche. + */ +export function repondLibre(f: Formulaire, texte: string): Suite { + if (!courante(f)) return 'rien'; + f.choix[f.etape] = [texte]; + return avance(f); +} + +/** Les réponses, dans la forme que le harnais écrit et que le rejeu relit. */ +export function reponses(f: Formulaire): Record { + const out: Record = {}; + f.questions.forEach((q, i) => { + out[q.question] = (f.choix[i] ?? []).join(', '); + }); + return out; +} + +/** Le clavier d'une étape : une option par bouton, cochée ou non. */ +export function clavier(f: Formulaire): InlineKeyboardMarkup { + const question = courante(f); + if (!question) return { inline_keyboard: [] }; + const pris = f.choix[f.etape] ?? []; + + const cases: Bouton[] = question.options.map((o, i) => ({ + // La marque vit dans le libellé : c'est le seul endroit d'un clavier + // Telegram où un état puisse se lire. + texte: tronqueBouton(question.multiSelect ? `${mark(pris, o.label)} ${o.label}` : o.label), + donnee: `q:${f.id}:${i}`, + })); + + const solo: Bouton[] = question.multiSelect + ? [{ texte: t('passerelle.questionValider'), donnee: `q:${f.id}:ok` }] + : []; + return grille(cases, solo); +} + +function mark(pris: string[], label: string): string { + return pris.includes(label) ? '☑' : '☐'; +} + +/** + * L'écran d'une étape : les blocs riches, le texte de repli, et le clavier. + * + * Les blocs se construisent un à un plutôt que par `enBlocs` : il n'y a pas de + * markdown à analyser ici, seulement une structure connue à poser. + */ +export function ecran(f: Formulaire): { + blocs: InputRichBlock[]; + brut: string; + clavier: InlineKeyboardMarkup; +} { + const question = courante(f); + if (!question) return { blocs: [], brut: '', clavier: { inline_keyboard: [] } }; + + const total = f.questions.length; + const entete = + total > 1 + ? t('passerelle.questionEtape', { header: question.header, n: f.etape + 1, total }) + : question.header; + + const blocs: InputRichBlock[] = [ + { type: 'heading', text: entete, size: 3 }, + { type: 'paragraph', text: question.question }, + ]; + const lignes = [entete, '', question.question]; + + if (question.multiSelect) { + const mention = t('passerelle.questionMultiple'); + blocs.push({ type: 'paragraph', text: { type: 'italic', text: mention } }); + lignes.push('', mention); + } + + question.options.forEach((o, i) => { + // Le numéro relie le bouton — rogné à 32 caractères — au texte entier qui le + // précède. Sans lui, un libellé long devient un choix qu'on fait de mémoire. + const titre = `${i + 1}. ${o.label}`; + blocs.push({ + type: 'paragraph', + text: o.description + ? [{ type: 'bold', text: titre }, ` — ${o.description}`] + : { type: 'bold', text: titre }, + }); + lignes.push('', o.description ? `${titre} — ${o.description}` : titre); + + if (o.preview) { + const maquette = borne(o.preview, MAX_MAQUETTE); + blocs.push({ type: 'pre', text: maquette }); + lignes.push(maquette); + } + }); + + const pied = t('passerelle.questionLibre'); + blocs.push({ type: 'paragraph', text: { type: 'italic', text: pied } }); + lignes.push('', pied); + + return { blocs, brut: lignes.join('\n'), clavier: clavier(f) }; +} + +function borne(texte: string, max: number): string { + return texte.length > max ? `${texte.slice(0, max - 1)}…` : texte; +} diff --git a/server/passerelle/riche.ts b/server/passerelle/riche.ts new file mode 100644 index 0000000..e6f33aa --- /dev/null +++ b/server/passerelle/riche.ts @@ -0,0 +1,419 @@ +// Markdown → messages riches de Telegram (Bot API 10.1). +// +// `sendMessage` n'affiche qu'un sous-ensemble de HTML : ni titres, ni listes, +// **ni tableaux** — `
      ` y est refusé net. Un tableau n'y tient qu'en +// chasse fixe, où il se disloque dès qu'il dépasse la largeur d'un téléphone. +// +// `sendRichMessage` est une autre API, et elle change la donne : des blocs +// structurés en JSON plutôt qu'un balisage, avec de vrais tableaux — bordures +// comprises —, de vrais titres, de vraies listes, et une borne de 32 768 +// caractères au lieu de 4 096. +// +// **Les noms de champs viennent de la documentation, et c'est important** : +// l'API accepte les champs qu'elle ne connaît pas et les ignore en silence. Un +// `header` au lieu de `is_header` ne produit donc aucune erreur — seulement un +// tableau sans en-tête, et rien pour dire pourquoi. Ne rien renommer ici sans +// l'avoir relu dans la spec. + +import type { RichBlockTableCell } from 'node-telegram-bot-api'; + +// Les formes s'appuient sur celles de `node-telegram-bot-api` plutôt que d'être +// redéclarées ici : c'est ce qui défend les noms de champs, pour la raison dite +// plus haut. +// +// **Deux corrections, mesurées contre l'API et contre sa documentation.** Les +// types de la bibliothèque sont faux sur ces points-là, et s'y conformer +// empêcherait d'écrire des documents que Telegram accepte : +// +// 1. son `RichText` n'admet ni chaîne nue ni tableau, alors que la doc dit +// « either a String for plain text, an Array of RichText, or … » et que +// l'API accepte les deux — sans quoi **aucun texte simple** ne serait +// exprimable, puisqu'il n'existe pas de type pour lui ; +// 2. `align` et `valign` d'une cellule y sont requis, alors qu'une cellule +// sans eux est acceptée. +// +// Le reste — `is_header`, `is_bordered`, `colspan`, `rowspan` — vient d'eux, et +// c'est ce qui compte : ce sont ces noms-là qui échouaient en silence. + +/** Le texte tel que l'API l'accepte vraiment. Voir la correction (1) ci-dessus. */ +export type RichText = string | RichText[] | { type: string; text: RichText; url?: string }; + +/** Une cellule : la leur, dont on rend `align` et `valign` optionnels. */ +export type Cellule = Omit & { + text?: RichText; + align?: 'left' | 'center' | 'right'; + valign?: 'top' | 'middle' | 'bottom'; +}; + +/** + * Un bloc, tel qu'on l'émet. + * + * Les clés reprennent celles de la bibliothèque ; seul le texte suit la forme + * réelle de l'API. `InputRichBlock` est ce que le client attend, et la + * conversion se fait au point d'envoi — un seul endroit, commenté. + */ +export type InputRichBlock = + | { type: 'paragraph'; text: RichText } + | { type: 'heading'; text: RichText; size: number } + | { type: 'table'; cells: Cellule[][]; is_bordered?: true; is_striped?: true } + | { type: 'list'; items: { blocks: InputRichBlock[] }[] } + | { type: 'pre'; text: RichText; language?: string } + | { type: 'blockquote'; blocks: InputRichBlock[] } + /** + * Le seul repli que l'API offre, et il est **explicite**. + * + * Rien en Markdown ne le déclenche : il ne sort donc pas d'`enBlocs` mais se + * pose à la main, autour d'un document qu'on ne veut pas déverser. Le + * « Afficher plus » automatique d'un message long n'en tient pas lieu — + * mesuré, il ne s'est pas déclenché sur un résumé de sept mille caractères. + */ + | { type: 'details'; summary: RichText; blocks: InputRichBlock[]; is_open?: true } + | { type: 'divider' }; + +/** Ce qu'un message riche accepte — huit fois la borne de `sendMessage`. */ +export const MAX_RICHE = 32_768; + +/** Une ligne de tableau qui ne sert qu'à l'alignement. */ +const SEPARATEUR = /^\s*\|?[\s:|-]+\|[\s:|-]*$/; + +/** + * Le texte d'une ligne, découpé en morceaux. + * + * Le code littéral passe en premier : un `**` à l'intérieur d'un `code` doit + * rester du code. Rien n'est échappé — ces morceaux voyagent en JSON, donc `<` + * et `&` sont du texte et le restent, contrairement au rendu HTML. + */ +export function fragments(ligne: string): RichText[] { + const out: RichText[] = []; + + /** + * Le contenu d'une marque, relu comme le reste. + * + * Sans cette relecture, `[`chemin.md`](…)` gardait ses accents graves à + * l'écran : le libellé d'un lien n'était jamais analysé, et le Markdown y + * restait littéral. La récursion s'arrête d'elle-même — chaque tour retire + * les délimiteurs, donc le texte décroît strictement. + * + * Un contenu sans balisage rend la chaîne telle quelle plutôt qu'un tableau + * d'un élément : c'est la même chose pour l'API, et c'est plus lisible au + * journal comme au test. + */ + const interieur = (texte: string): RichText => { + const morceaux = fragments(texte); + const seul = morceaux[0]; + return morceaux.length === 1 && typeof seul === 'string' ? seul : morceaux; + }; + + const motif = + /`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|~~([^~]+)~~|(? reste) out.push(ligne.slice(reste, m.index)); + const [, code, libelle, url, gras, barre, penche, souligne] = m; + + // Le code littéral porte deux marques plutôt qu'une. `code` seul est rendu + // en chasse fixe teintée, ce qui suffit sur un grand écran mais se perd sur + // un téléphone — une nuance de couleur y devient invisible avant une + // différence de surface. `marked` ajoute un fond, et les deux se cumulent : + // mesuré, l'emboîtement fonctionne et son ordre est sans effet. + if (code !== undefined) out.push({ type: 'marked', text: { type: 'code', text: code } }); + else if (libelle !== undefined && url !== undefined) { + // Une URL n'est reprise que si elle mène quelque part de connu : le reste + // n'a rien à faire dans un lien qu'on relaie. Le libellé, lui, garde sa + // mise en forme dans les deux cas — un lien écarté ne doit pas rendre son + // texte plus pauvre que s'il n'avait jamais été un lien. + if (/^(https?:\/\/|mailto:)/i.test(url)) { + out.push({ type: 'url', text: interieur(libelle), url }); + } else out.push(...fragments(libelle)); + } else if (gras !== undefined) out.push({ type: 'bold', text: interieur(gras) }); + else if (barre !== undefined) out.push({ type: 'strikethrough', text: interieur(barre) }); + else if (penche !== undefined) out.push({ type: 'italic', text: interieur(penche) }); + else if (souligne !== undefined) out.push({ type: 'italic', text: interieur(souligne) }); + + reste = m.index + m[0].length; + } + if (reste < ligne.length) out.push(ligne.slice(reste)); + return out.length ? out : ['']; +} + +/** Les cellules d'une ligne de tableau, bords vides retirés. */ +function cellules(ligne: string): string[] { + return ligne + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); +} + +/** + * L'alignement de chaque colonne, lu dans la ligne de séparation. + * + * Les deux-points du Markdown ne sont pas décoratifs : une colonne de nombres + * alignée à droite dans la source doit l'être à l'écran, sinon le tableau perd + * ce qui le rendait comparable. Rien de deviné — une colonne sans deux-points + * n'impose rien, et laisse le client décider. + */ +function alignements(separateur: string): (Cellule['align'] | undefined)[] { + return cellules(separateur).map((c) => { + const gauche = c.startsWith(':'); + const droite = c.endsWith(':'); + if (gauche && droite) return 'center'; + if (droite) return 'right'; + if (gauche) return 'left'; + return undefined; + }); +} + +/** + * Un élément de liste, tel qu'il se lit dans la source. + * + * `indent` porte l'imbrication : deux espaces devant une puce en font la + * sous-puce de la précédente. `lignes` porte les retours à la ligne d'un + * élément long — un point numéroté qui court sur trois lignes est **un** point, + * pas trois. + */ +interface Element { + indent: number; + lignes: string[]; +} + +/** + * Une liste à partir d'éléments plats, l'imbrication reconstruite. + * + * L'indentation de la source est la seule information disponible : tout ce qui + * est plus rentré que le premier élément appartient à celui qui le précède, et + * la même règle s'applique un cran plus bas. Aplatir aurait remonté un + * sous-point au rang de son parent, ce qui inverse le sens d'une consigne. + */ +function listeDepuis(elements: Element[]): InputRichBlock { + const base = Math.min(...elements.map((e) => e.indent)); + const groupes: { tete: Element; enfants: Element[] }[] = []; + + for (const element of elements) { + const dernier = groupes[groupes.length - 1]; + if (!dernier || element.indent <= base) groupes.push({ tete: element, enfants: [] }); + else dernier.enfants.push(element); + } + + return { + type: 'list', + items: groupes.map((g) => ({ + blocks: [ + { type: 'paragraph' as const, text: fragments(g.tete.lignes.join(' ')) }, + ...(g.enfants.length ? [listeDepuis(g.enfants)] : []), + ], + })), + }; +} + +/** + * Ce que devient une case à cocher, faute d'être rendue nativement. + * + * Les deux voies natives ont été essayées, et fermées : + * + * - `has_checkbox` / `is_checked` sur un item de liste : **acceptés par l'API, + * ignorés par le client** — une case cochée s'affiche comme une puce + * ordinaire. Constaté sur le client web ; si un client venait à les rendre, + * c'est ici qu'il faudrait revenir ; + * - `sendChecklist`, la checklist native et cochable : refusée + * (`PREMIUM_ACCOUNT_REQUIRED`). Elle exige un `business_connection_id`, donc + * un compte Business connecté — hors de portée d'un bot ordinaire. + * + * Reste le symbole dans le texte. Il est laid, il est fiable, et surtout il ne + * perd pas l'information : sans lui, une liste de tâches ne dit plus lesquelles + * sont faites. + */ +const COCHE = { fait: '☑︎ ', reste: '☐︎ ' }; + +/** + * Le document, découpé en blocs. + * + * Ligne à ligne, avec les regroupements qu'aucune ligne seule ne porte : un + * paragraphe court sur plusieurs lignes, un tableau aussi, une liste également. + * Chacun se ferme sur la première ligne qui ne lui appartient plus. + */ +export function enBlocs(markdown: string): InputRichBlock[] { + const lignes = markdown.replace(/\r\n?/g, '\n').split('\n'); + const blocs: InputRichBlock[] = []; + + let paragraphe: string[] = []; + let tableau: string[] = []; + let liste: Element[] = []; + let citation: string[] = []; + let code: string[] | null = null; + let langue = ''; + + const fermeParagraphe = (): void => { + if (!paragraphe.length) return; + blocs.push({ type: 'paragraph', text: fragments(paragraphe.join(' ')) }); + paragraphe = []; + }; + + const fermeTableau = (): void => { + if (!tableau.length) return; + const grille = tableau.filter((l) => !SEPARATEUR.test(l)).map(cellules); + const separateur = tableau.find((l) => SEPARATEUR.test(l)); + const aligne = separateur ? alignements(separateur) : []; + tableau = []; + if (!grille.length) return; + blocs.push({ + type: 'table', + // Les bordures ne sont pas décoratives ici : sans elles, un tableau se lit + // comme des mots posés côte à côte. + is_bordered: true, + // La ligne d'alignement du Markdown est ce qui désigne l'en-tête. Sans + // elle, la première ligne est une ligne comme une autre. + cells: grille.map((rangee, i) => + rangee.map((c, j) => ({ + text: fragments(c), + ...(i === 0 && separateur ? { is_header: true as const } : {}), + ...(aligne[j] ? { align: aligne[j] } : {}), + })), + ), + }); + }; + + const fermeListe = (): void => { + if (!liste.length) return; + blocs.push(listeDepuis(liste)); + liste = []; + }; + + /** + * Une citation se relit entièrement, comme un document à elle seule. + * + * C'est ce qui lui rend ce qu'elle porte : une citation contenant une liste + * numérotée est fréquente — un message à recopier, une consigne — et la + * traiter ligne à ligne en faisait autant de citations d'une ligne, chacune + * dans son cadre. La récursion s'arrête d'elle-même : chaque tour retire un + * chevron. + */ + const fermeCitation = (): void => { + if (!citation.length) return; + const dedans = enBlocs(citation.join('\n')); + citation = []; + if (dedans.length) blocs.push({ type: 'blockquote', blocks: dedans }); + }; + + const fermeTout = (): void => { + fermeParagraphe(); + fermeTableau(); + fermeListe(); + fermeCitation(); + }; + + for (const ligne of lignes) { + if (code !== null) { + if (/^\s*```/.test(ligne)) { + blocs.push({ type: 'pre', text: code.join('\n'), ...(langue ? { language: langue } : {}) }); + code = null; + langue = ''; + } else code.push(ligne); + continue; + } + + if (/^\s*```/.test(ligne)) { + fermeTout(); + langue = ligne.replace(/^\s*```/, '').trim(); + code = []; + continue; + } + + // La citation se ramasse d'abord : un chevron l'emporte sur tout le reste, + // et ce qu'il y a derrière sera relu par la récursion. + const chevron = /^ {0,3}>\s?(.*)$/.exec(ligne); + if (chevron) { + fermeParagraphe(); + fermeTableau(); + fermeListe(); + citation.push(chevron[1] ?? ''); + continue; + } + fermeCitation(); + + if (/^\s*\|.*\|\s*$/.test(ligne)) { + fermeParagraphe(); + fermeListe(); + tableau.push(ligne); + continue; + } + fermeTableau(); + + const puce = /^(\s*)[-*+]\s+(.*)$/.exec(ligne); + if (puce) { + fermeParagraphe(); + const indent = (puce[1] ?? '').length; + const contenu = puce[2] ?? ''; + // `- [ ]` et `- [x]` : le symbole va dans le texte. La spec offre bien + // `has_checkbox`, mais aucun client ne le rend aujourd'hui — l'état de la + // tâche disparaîtrait sans laisser de trace. Voir `RichListItem`. + const case_ = /^\[([ xX])\]\s+(.*)$/.exec(contenu); + if (case_) { + const prefixe = (case_[1] ?? '').toLowerCase() === 'x' ? COCHE.fait : COCHE.reste; + liste.push({ indent, lignes: [prefixe + (case_[2] ?? '')] }); + } else liste.push({ indent, lignes: [contenu] }); + continue; + } + + const numerotee = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(ligne); + if (numerotee) { + fermeParagraphe(); + // Le numéro reste dans le texte. La liste ordonnée native existe — + // `value` et `type` sur l'item — mais sa forme décimale décale d'un rang + // sur le client actuel : le premier point s'affiche « 0. ». Dans un + // document où l'ordre est la consigne, c'est pire que la redondance + // d'une puce suivie d'un numéro. Mesuré ; voir `blocs-riches.md`. + liste.push({ + indent: (numerotee[1] ?? '').length, + lignes: [`${numerotee[2] ?? ''}. ${numerotee[3] ?? ''}`], + }); + continue; + } + + // La suite d'un élément long : rentrée, sans marqueur, et pas un paragraphe. + // Sans ceci, la deuxième ligne d'un point numéroté fermait la liste, et le + // point suivant repartait à « 1 » dans un nouveau bloc. + const dernier = liste[liste.length - 1]; + if (dernier && /^\s/.test(ligne) && ligne.trim()) { + dernier.lignes.push(ligne.trim()); + continue; + } + fermeListe(); + + const titre = /^(#{1,6})\s+(.*)$/.exec(ligne); + if (titre) { + fermeParagraphe(); + // Les six niveaux du Markdown sont exactement les six tailles de Telegram, + // dans le même sens : 1 est le plus grand. + blocs.push({ + type: 'heading', + text: fragments(titre[2] ?? ''), + size: (titre[1] ?? '#').length, + }); + continue; + } + + if (/^\s*([-*_])\1{2,}\s*$/.test(ligne)) { + fermeTout(); + blocs.push({ type: 'divider' }); + continue; + } + + if (!ligne.trim()) { + fermeParagraphe(); + continue; + } + paragraphe.push(ligne.trim()); + } + + // Un document qui s'arrête dans un bloc ouvert — page coupée, fichier + // tronqué — doit tout de même rendre ce qu'il avait commencé. + if (code !== null) { + blocs.push({ type: 'pre', text: code.join('\n'), ...(langue ? { language: langue } : {}) }); + } + fermeTout(); + + return blocs; +} diff --git a/server/passerelle/routage.ts b/server/passerelle/routage.ts new file mode 100644 index 0000000..901525d --- /dev/null +++ b/server/passerelle/routage.ts @@ -0,0 +1,163 @@ +// Ce qu'un message venu de la messagerie veut dire, et qui a le droit de le dire. +// +// Tout ce qui décide est ici, et rien de ce qui décide n'appelle le réseau : +// c'est ce qui rend la garde vérifiable par un test sans bot ni jeton. Le +// client (`telegram.ts`) ne fait qu'apporter des chaînes ; la boucle +// (`index.ts`) ne fait qu'exécuter des intentions. + +/** Ce qu'AURA a compris d'un message. Rien d'autre ne se commande d'ici. */ +export type Intention = + /** + * Ouvrir une session d'Atelier sur un projet. + * + * `ref` est un numéro de la dernière liste, ou un chemin. Dans les deux cas + * il désigne un **projet connu de Claude Code** : la boucle refuse le reste, + * et c'est là que se joue la garde, pas ici. + */ + | { kind: 'ouvrir'; ref: string } + /** Les projets connus, numérotés pour les commandes suivantes. */ + | { kind: 'projets' } + /** Choisir le projet à consulter, et lister ce qu'il porte. */ + | { kind: 'projet'; ref: string } + /** Le contenu d'un fichier de la dernière liste. */ + | { kind: 'voir'; ref: string } + /** Un tour de plus dans la session de cette conversation. */ + | { kind: 'parler'; texte: string } + /** Fermer la session de cette conversation. */ + | { kind: 'fin' } + /** + * Où en est la session de cette conversation, et sa fenêtre de contexte. + * + * Distincte de `sessions`, qui compte le parc : celle-ci regarde la vôtre. De + * loin, c'est la seule façon de savoir s'il reste de la place — la fenêtre est + * ce qu'une conversation ne montre jamais d'elle-même. + */ + | { kind: 'etat' } + /** + * Compacter la conversation sans attendre que la fenêtre déborde. + * + * Le seul cas où l'on relaie une commande de Claude Code, et il se justifie + * par la surface : de loin, on voit la fenêtre se remplir sans pouvoir rien y + * faire — `/etat` disait le problème, celle-ci le règle. + */ + | { kind: 'compacter' } + /** Ce qui tourne en ce moment, toutes conversations confondues. */ + | { kind: 'sessions' } + /** Interrompre le tour en cours sans fermer la session. */ + | { kind: 'stop' } + | { kind: 'aide' } + /** + * La première rencontre : ce que je suis, ce que je vois, et par où entrer. + * + * Distincte de l'aide, qu'elle doublait jusqu'ici. `/start` est le geste que + * Telegram propose de lui-même à qui ouvre la conversation : il arrive avant + * qu'on sache quoi demander, et une liste de commandes ne répond pas à ça. + * L'aide, elle, est une référence — on y revient en sachant ce qu'on cherche. + */ + | { kind: 'accueil' } + /** + * Rien à faire — un message vide, ou une commande qu'on ne sert pas. + * + * `raison` n'est pas une erreur à renvoyer telle quelle : elle dit à la + * boucle s'il y a lieu de répondre. Une commande inconnue mérite un mot ; un + * message vide n'en mérite aucun. + */ + | { kind: 'ignorer'; raison: 'vide' | 'commande-inconnue'; commande?: string }; + +/** + * Les conversations autorisées. + * + * Une liste blanche, jamais une liste noire : c'est la seule garde entre une + * messagerie publique et un poste de travail. Ce qui n'est pas un entier est + * écarté — un identifiant mal recopié ne doit pas devenir un `NaN` qui + * ressemble à une autorisation. + */ +export function lireChats(raw: string | undefined): Set { + const chats = new Set(); + for (const part of (raw ?? '').split(',')) { + const trimmed = part.trim(); + if (!trimmed) continue; + // `Number` accepterait `12 ` ou `0x10` ; on veut la forme que Telegram + // écrit, et elle seule. Le signe est admis : un groupe a un identifiant + // négatif. + if (!/^-?\d+$/.test(trimmed)) continue; + const id = Number(trimmed); + if (Number.isSafeInteger(id)) chats.add(id); + } + return chats; +} + +/** + * Ce message vient-il d'une conversation autorisée ? + * + * Une liste vide n'autorise personne. C'est délibéré, et c'est l'inverse de la + * convention habituelle où « vide » veut dire « tout » : ici, l'omission ne peut + * pas ouvrir la machine au premier venu. + */ +export function autorise(chats: Set, chatId: number): boolean { + return chats.has(chatId); +} + +/** + * Ce qui suit la commande, s'il y a quelque chose. + * + * Tout ce qui reste après le premier espace, sans autre découpage : un chemin + * Windows porte des espaces, et le couper en mots ferait d'un dossier deux + * arguments dont aucun ne désignerait rien. + */ +function argument(texte: string): string { + const i = texte.indexOf(' '); + return i === -1 ? '' : texte.slice(i + 1).trim(); +} + +/** + * Ce que veut un message. + * + * Une barre oblique en tête est une commande ; tout le reste est un tour à + * envoyer. Cette asymétrie est voulue : on parle à l'agent bien plus souvent + * qu'on ne le pilote, et le cas fréquent ne doit demander aucune syntaxe. + */ +export function parseIntention(brut: string): Intention { + const texte = brut.trim(); + if (!texte) return { kind: 'ignorer', raison: 'vide' }; + if (!texte.startsWith('/')) return { kind: 'parler', texte }; + + // Telegram suffixe les commandes du nom du bot dans un groupe : `/fin@monbot`. + const mot = (texte.split(/\s/)[0] ?? '').split('@')[0]?.toLowerCase() ?? ''; + switch (mot) { + case '/atelier': { + const ref = argument(texte); + // Sans référence, il n'y a pas de session à ouvrir : on montre les + // projets, qui portent les numéros que cette commande attend. + return ref ? { kind: 'ouvrir', ref } : { kind: 'projets' }; + } + case '/projets': + return { kind: 'projets' }; + case '/projet': { + const ref = argument(texte); + return ref ? { kind: 'projet', ref } : { kind: 'projets' }; + } + case '/voir': { + const ref = argument(texte); + // Sans référence, il n'y a rien à ouvrir — l'aide dit la forme attendue. + return ref ? { kind: 'voir', ref } : { kind: 'aide' }; + } + case '/fin': + return { kind: 'fin' }; + case '/etat': + return { kind: 'etat' }; + case '/compacter': + return { kind: 'compacter' }; + case '/sessions': + return { kind: 'sessions' }; + case '/stop': + return { kind: 'stop' }; + case '/start': + return { kind: 'accueil' }; + case '/aide': + case '/help': + return { kind: 'aide' }; + default: + return { kind: 'ignorer', raison: 'commande-inconnue', commande: mot }; + } +} diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts new file mode 100644 index 0000000..5c535b0 --- /dev/null +++ b/server/passerelle/telegram.ts @@ -0,0 +1,571 @@ +// Le seul fichier qui sache que la messagerie est Telegram. +// +// Il s'appuie sur `node-telegram-bot-api` (v2), qui apporte deux choses que +// notre client à la main ne pouvait pas donner : +// +// - **les types de l'API**, jusqu'aux blocs riches. Ils ferment un piège +// coûteux : l'API accepte les champs qu'elle ne connaît pas et les ignore en +// silence, si bien qu'un `header` écrit pour `is_header` ne produit aucune +// erreur — seulement un tableau sans en-tête. Le compilateur, lui, refuse ; +// - la boucle de long-polling, son acquittement et ses reprises. +// +// Ce qui reste à nous, parce que la bibliothèque ne le fournit pas : la cascade +// de replis d'un document (blocs → bloc unique → texte nu), et la garde qui +// filtre les conversations avant tout traitement. +// +// Le long-polling reste **sortant**, et c'est la raison d'être du dispositif : +// AURA continue de n'écouter que la boucle locale (`server/index.ts`), et +// `guard.ts` n'a rien de nouveau à trancher. Aucun port ne s'ouvre pour ceci. + +import { Api, Bot } from 'node-telegram-bot-api'; +import type { + InlineKeyboardMarkup, + InputRichBlock as InputRichBlockLib, +} from 'node-telegram-bot-api'; +import { MAX_RICHE, type InputRichBlock } from './riche.ts'; + +/** Ce qu'un message ordinaire accepte. Le riche en prend huit fois plus. */ +const MAX_TEXTE = 4_000; + +/** Coupe à la borne, plutôt que de laisser l'API refuser le message entier. */ +function borne(texte: string, max: number): string { + return texte.length > max ? `${texte.slice(0, max - 1)}…` : texte; +} + +/** Un message reçu, réduit à ce dont la Passerelle a besoin. */ +export interface MessageEntrant { + chatId: number; + texte: string; +} + +/** Un bouton pressé sous un message d'AURA. */ +export interface BoutonPresse { + chatId: number; + /** À renvoyer pour que Telegram cesse d'afficher l'attente sur le bouton. */ + callbackId: string; + /** Ce que le bouton portait — voir `boutons()`. */ + donnee: string; +} + +/** + * Ce qu'un envoi de document a donné : par quel barreau il est passé, et sous + * quel identifiant. Le second sert à réécrire le clavier d'un formulaire. + */ +export interface Rendu { + voie: 'riche' | 'nu' | 'brut'; + messageId: number | null; +} + +/** Un bouton : ce qu'il affiche, et ce qu'il renvoie quand on le presse. */ +export interface Bouton { + texte: string; + donnee: string; +} + +/** + * Un libellé de bouton : Telegram les veut courts, et les tronque mal. + * + * Vit ici plutôt que chez l'appelant parce que c'est une borne de Telegram, au + * même titre que la largeur d'une rangée. + */ +export function tronqueBouton(texte: string): string { + return texte.length > 32 ? `${texte.slice(0, 31)}…` : texte; +} + +/** Une rangée de boutons sous un message. */ +export function boutons(paires: Bouton[]): InlineKeyboardMarkup { + return { inline_keyboard: [paires.map((p) => ({ text: p.texte, callback_data: p.donnee }))] }; +} + +/** + * Le caractère qui élargit une bulle sans rien y écrire. + * + * `U+2800`, la case braille vide. Contrairement à une espace ordinaire, elle + * n'est pas rognée en fin de ligne : Telegram la compte comme un caractère + * plein, et elle ne dessine rien. C'est l'astuce répandue des claviers de bots, + * et la seule qui fonctionne — un remplissage placé dans les **libellés** des + * boutons n'a, lui, aucun effet, la largeur du clavier étant celle de la bulle. + */ +const BLANC = '⠀'; + +/** + * Combien de caractères ordinaires il faut pour saturer la largeur d'une bulle. + * + * Mesuré sur un même clavier de trois boutons : 35 caractères donnent 285 px, + * 50 en donnent 391, et le plafond de 480 px est atteint vers 63. Au-delà, plus + * rien ne bouge. + */ +const CIBLE_CARACTERES = 63; + +/** + * Ce que vaut un blanc braille, en caractères ordinaires. + * + * **Il est plus large qu'une lettre**, et l'ignorer était un bug : 22 lettres + * suivies de 25 blancs saturent la bulle, là où il aurait fallu 63 lettres pour + * le même résultat — les 25 blancs valent donc les 41 lettres manquantes, soit + * 1,6 chacun. Compter un blanc pour une lettre sous-remplissait tous les + * en-têtes moyens, et les chemins profonds gardaient des boutons rétrécis. + */ +const BLANC_EN_CARACTERES = 1.6; + +/** + * Complète un texte pour que son clavier prenne toute la largeur. + * + * Sans cela, la largeur du clavier suit celle du texte : un en-tête de vingt + * caractères donne des boutons de soixante pixels, où trois libellés côte à + * côte deviennent illisibles. C'est un artifice, et il est assumé — la seule + * autre voie était d'allonger le texte visible, c'est-à-dire d'écrire pour + * occuper de la place. + * + * Ne s'applique qu'aux écrans à boutons : un message ordinaire n'a aucune + * raison de traîner des caractères que le copier-coller emporterait. + */ +export function elargi(texte: string): string { + // La largeur d'une bulle est celle de sa **ligne la plus longue**. Un texte + // qui l'atteint déjà n'a besoin de rien. + const plusLongue = texte.split('\n').reduce((max, l) => Math.max(max, l.length), 0); + if (plusLongue >= CIBLE_CARACTERES) return texte; + + // Le remplissage prend **sa propre ligne**, et ce n'est pas un détail. Collé + // au texte, il le pousse au-delà du bord et coupe la dernière phrase en deux + // — « 19 fichiers. » devenait « 19 » puis « fichiers. » sur un écran de + // téléphone, plus étroit que celui où la cible a été mesurée. Sur sa ligne, il + // n'a plus rien à bousculer. + // + // Conséquence à ne pas manquer : cette ligne ne **complète** plus le texte, + // elle le **remplace** dans le calcul de la largeur. Il faut donc de quoi + // atteindre la cible à elle seule — la première version en mettait juste ce + // qui manquait au texte, et rétrécissait les bulles au lieu de les élargir. + return `${texte}\n${BLANC.repeat(Math.ceil(CIBLE_CARACTERES / BLANC_EN_CARACTERES))}`; +} + +/** + * Ce qu'une rangée de boutons offre, en caractères. + * + * Telegram partage la largeur **également** entre les boutons d'une rangée : + * trois boutons font chacun un tiers, quel que soit leur texte, et ce qui + * dépasse est rogné. Le budget d'une rangée est donc fixe, et c'est le libellé + * le plus long qui décide combien s'y tiennent. + * + * Trente-deux : la largeur d'un téléphone en portrait, qui est la contrainte de + * cette surface. Un écran large en supporterait plus — mais c'est le petit qui + * décide, puisque c'est pour lui que la Passerelle existe. + */ +const LARGEUR_RANGEE = 32; + +/** + * Combien de boutons par rangée, au maximum. + * + * Pas une limite de l'API mais une limite du doigt : au-delà de trois, un + * bouton fait moins d'un tiers d'écran et devient une cible qu'on manque. + */ +const MAX_PAR_RANGEE = 3; + +/** + * Range les boutons en rangées, en remplissant chacune au plus près. + * + * Un nombre de colonnes fixe pour toute la grille gaspille dès que les + * longueurs varient : quatre `rules/ 19` tiennent sur une rangée là où un seul + * `SPEC-014_notes-de-projet-longues.md` la remplit. On remplit donc comme un + * texte se compose — tant que ça tient, on ajoute. + * + * Le critère se recalcule à chaque ajout, car c'est le plus long de **la + * rangée** qui fixe la largeur de tous ses boutons : ajouter un libellé long à + * une rangée de courts peut la faire déborder d'un coup. + * + * Les rangées passées en `solo` gardent leur pleine largeur — c'est ce qu'il + * faut d'une action, qu'on ne veut pas voir se confondre avec la liste. + */ +export function grille(cases: Bouton[], solo: Bouton[] = []): InlineKeyboardMarkup { + const rangees: { text: string; callback_data: string }[][] = []; + let rangee: Bouton[] = []; + let plusLong = 0; + + const pose = (): void => { + if (!rangee.length) return; + rangees.push(rangee.map((b) => ({ text: b.texte, callback_data: b.donnee }))); + rangee = []; + plusLong = 0; + }; + + for (const bouton of cases) { + const large = Math.max(plusLong, bouton.texte.length); + // Un bouton plus large qu'une rangée entière ne tient nulle part : il prend + // la sienne, où il sera rogné mais lisible sur toute la largeur. + if ( + rangee.length && + (large * (rangee.length + 1) > LARGEUR_RANGEE || rangee.length >= MAX_PAR_RANGEE) + ) { + pose(); + } + rangee.push(bouton); + plusLong = Math.max(plusLong, bouton.texte.length); + } + pose(); + + for (const b of solo) rangees.push([{ text: b.texte, callback_data: b.donnee }]); + return { inline_keyboard: rangees }; +} + +export class Telegram { + private readonly api: Api; + private readonly bot: Bot; + private stopped = false; + + constructor(token: string) { + this.api = new Api(token); + this.bot = new Bot(token); + } + + /** + * Le nom du bot, ou `null` si le jeton ne vaut rien. + * + * Sert à démarrer : un jeton refusé doit se dire une fois, pas se retenter + * indéfiniment dans une boucle que personne ne regarde. + */ + async identite(): Promise { + try { + const moi = await this.api.getMe(); + return moi.username || null; + } catch { + return null; + } + } + + /** + * Branche les gestionnaires et lance la boucle. + * + * `autorise` est consulté **avant** tout traitement, et un message venu + * d'ailleurs ne reçoit aucune réponse : répondre confirmerait que ce bot + * existe et à quoi il sert. + */ + ecoute( + autorise: (chatId: number) => boolean, + surMessage: (m: MessageEntrant) => Promise, + surBouton: (b: BoutonPresse) => Promise, + surErreur: (message: string) => void, + ): void { + this.bot.on('message', async (ctx) => { + const chatId = ctx.chatId; + const texte = ctx.message?.text; + if (!chatId || !texte || !autorise(chatId)) return; + await surMessage({ chatId, texte }); + }); + + this.bot.on('callback_query', async (ctx) => { + const chatId = ctx.chatId; + const rappel = ctx.callbackQuery; + if (!chatId || !rappel?.data || !autorise(chatId)) return; + // Accuser d'abord : sans cela le bouton tourne pendant tout le traitement. + await ctx.answerCallbackQuery(); + await surBouton({ chatId, callbackId: rappel.id, donnee: rappel.data }); + }); + + // Une erreur de traitement ne doit pas arrêter la boucle : la suivante + // pourrait très bien passer. + this.bot.catch((err) => { + surErreur(err instanceof Error ? err.message : String(err)); + }); + + void this.bot.startPolling().catch((e: unknown) => { + if (this.stopped) return; + surErreur(e instanceof Error ? e.message : String(e)); + }); + } + + /** + * Envoie un texte. `clavier` ajoute des boutons sous le message. + * + * La coupe est faite ici et non chez l'appelant : c'est une borne de + * Telegram, et un module qui compose un message n'a pas à la connaître. + */ + async envoie(chatId: number, texte: string, clavier?: InlineKeyboardMarkup): Promise { + try { + await this.api.sendMessage({ + chat_id: chatId, + text: borne(texte, MAX_TEXTE), + ...(clavier ? { reply_markup: clavier } : {}), + }); + } catch { + // Une messagerie injoignable n'est pas une panne d'AURA : le BFF continue + // de servir l'interface et les sessions de tourner. + } + } + + /** + * Envoie un texte et rend l'identifiant du message, pour pouvoir le réécrire. + * + * C'est ce qui permet de naviguer **dans un seul message** plutôt que d'en + * empiler un par clic : une conversation n'a pas de bouton « retour », et une + * pile de listes mortes derrière soi est le contraire d'un fil qu'on relit. + */ + async envoieSuivi( + chatId: number, + texte: string, + clavier?: InlineKeyboardMarkup, + ): Promise { + try { + const envoye = await this.api.sendMessage({ + chat_id: chatId, + text: texte, + ...(clavier ? { reply_markup: clavier } : {}), + }); + return envoye.message_id; + } catch { + return null; + } + } + + /** + * Un document long, plié derrière son titre, que le lecteur ouvre s'il veut. + * + * Le bloc `details` est le **seul** repli que l'API offre explicitement, et il + * a été essayé avant d'être employé — voir `blocs-riches.md`, où le piège est + * rappelé : l'API accepte en silence les champs qu'elle ne connaît pas, si + * bien qu'un nom fautif ne produit pas d'erreur mais un bloc muet. + * + * Le « Afficher plus » automatique d'un message long ne le remplace pas : + * mesuré, il ne s'est pas déclenché sur un résumé de sept mille caractères, et + * la conversation se retrouvait noyée. + * + * Le contenu passe par `enBlocs` comme n'importe quel document : ce qui est + * plié garde ses titres, ses listes et son code. Le repli n'est pas une + * dégradation, seulement une place qu'on ne prend pas. + */ + async envoieReplie(chatId: number, titre: string, blocs: InputRichBlock[]): Promise { + try { + await this.api.sendRichMessage({ + chat_id: chatId, + rich_message: { + // Même conversion que `brouillon`, et pour la même raison : le + // `RichText` de la bibliothèque n'admet pas la chaîne nue, que l'API + // accepte pourtant — sans quoi aucun titre simple ne serait exprimable. + blocks: [ + { type: 'details', summary: titre, blocks: blocs }, + ] as unknown as InputRichBlockLib[], + skip_entity_detection: true, + }, + }); + return true; + } catch { + // Un repli refusé ne vaut pas de perdre le contenu : l'appelant retombe + // sur un envoi ordinaire. + return false; + } + } + + /** + * Réécrit un message déjà envoyé. + * + * Rend `false` si Telegram refuse — un message trop vieux, supprimé, ou dont + * le contenu n'a pas changé. L'appelant retombe alors sur un envoi neuf plutôt + * que de laisser le clic sans effet visible. + */ + async reecrit( + chatId: number, + messageId: number, + texte: string, + clavier?: InlineKeyboardMarkup, + ): Promise { + try { + await this.api.editMessageText({ + chat_id: chatId, + message_id: messageId, + text: texte, + ...(clavier ? { reply_markup: clavier } : {}), + }); + return true; + } catch { + return false; + } + } + + /** + * Ne réécrit que le clavier d'un message, sans toucher à son texte. + * + * C'est la seule réécriture possible sur un message riche : la bibliothèque + * expose `sendRichMessage` mais **aucun** `editMessageRichText`. Cocher une + * case d'un formulaire passe donc par ici — et c'est de toute façon ce qu'on + * veut, la question au-dessus n'ayant aucune raison de bouger. + */ + async reecritClavier( + chatId: number, + messageId: number, + clavier: InlineKeyboardMarkup, + ): Promise { + try { + await this.api.editMessageReplyMarkup({ + chat_id: chatId, + message_id: messageId, + reply_markup: clavier, + }); + return true; + } catch { + return false; + } + } + + /** + * Déclare la liste que Telegram propose sous le `/`. + * + * `langue` absente pose la liste **par défaut**, celle que voit un client dont + * la langue n'a pas la sienne. Un échec ne coûte que l'autocomplétion : les + * commandes restent reconnues, puisque c'est `routage.ts` qui en juge et non + * cette déclaration. + * + * `chatId` restreint la déclaration à **une** conversation. Sans lui, la liste + * est posée sur la portée par défaut — celle que voit quiconque ouvre le bot, + * y compris un inconnu à qui l'on ne répondra jamais. Le menu lui dirait ce + * que cette machine sait faire ; la portée par conversation le réserve à qui a + * déjà le droit de s'en servir. + */ + async declare( + commandes: { command: string; description: string }[], + langue?: string, + chatId?: number, + ): Promise { + try { + await this.api.setMyCommands({ + commands: commandes, + ...(chatId === undefined ? {} : { scope: { type: 'chat', chat_id: chatId } }), + ...(langue ? { language_code: langue } : {}), + }); + return true; + } catch { + return false; + } + } + + /** + * Efface la liste posée sur la portée par défaut. + * + * Telegram range ses listes par langue autant que par portée : effacer le + * défaut sans langue ne touche pas celle qu'un client français avait reçue. Il + * faut donc passer sur chacune, et c'est l'appelant qui les connaît. + */ + async efface(langue?: string): Promise { + try { + await this.api.deleteMyCommands(langue ? { language_code: langue } : {}); + return true; + } catch { + return false; + } + } + + /** + * Envoie un document, du plus riche au plus sûr. + * + * Trois tentatives, dans cet ordre, et les deux premières sont le **même + * appel** — ce qui change entre elles est la structure, pas le format : + * + * 1. **les blocs** — de vrais tableaux, avec bordures. C'est la seule voie + * qui rende un tableau lisible. + * 2. **un bloc unique** — le document tel quel dans un seul `paragraph`. + * Les retours à la ligne et les lignes vides sont conservés (mesuré) : on + * perd la mise en forme, on garde la mise en page. Sert si c'est la + * *structure* qui a été refusée — trop de blocs, imbrication trop + * profonde —, et il garde les 32 768 caractères et le repli « Afficher + * plus » du message riche. + * 3. **texte nu** — un message ordinaire, donc **coupé à 4 000 + * caractères**. Ne sert que si l'API riche est indisponible sur ce + * compte, et c'est le seul barreau qui perde du contenu. + * + * Ce n'est pas de la prudence de principe : le contenu vient de documents + * qu'on n'a pas écrits, et un seul bloc mal formé fait échouer l'envoi entier. + * Un document laid vaut mieux qu'un document disparu. + */ + async envoieRendu( + chatId: number, + blocs: InputRichBlock[], + brut: string, + clavier?: InlineKeyboardMarkup, + ): Promise { + const markup = clavier ? { reply_markup: clavier } : {}; + + // Sans cela, Telegram fabrique des liens dans notre dos. Le piège est + // propre à ce que la Passerelle affiche : `.md` est un domaine de premier + // niveau — la Moldavie —, si bien que `0.livraison.md` devient un lien vers + // un site qui n'existe pas. `.py`, `.pl`, `.sh`, `.io` en sont d'autres : + // un dépôt en est plein. + const riche = (blocks: InputRichBlock[]) => ({ + chat_id: chatId, + rich_message: { blocks, skip_entity_detection: true }, + ...markup, + }); + + if (blocs.length) { + try { + const envoye = await this.api.sendRichMessage(riche(blocs)); + return { voie: 'riche', messageId: envoye.message_id }; + } catch { + /* la structure a été refusée ; le texte, lui, tient peut-être */ + } + } + + try { + const envoye = await this.api.sendRichMessage( + riche([{ type: 'paragraph', text: borne(brut, MAX_RICHE) }]), + ); + return { voie: 'nu', messageId: envoye.message_id }; + } catch { + return { + voie: 'brut', + messageId: await this.envoieSuivi(chatId, borne(brut, MAX_TEXTE), clavier), + }; + } + } + + /** + * Un brouillon éphémère — la bulle montrée pendant qu'un tour travaille. + * + * Ce n'est pas un message : il expire au bout de trente secondes, ne persiste + * pas dans le fil, et deux envois portant le même `draftId` s'**animent** au + * lieu de s'empiler. C'est ce qui permet de montrer une activité sans + * déverser un flux dans la conversation. + * + * Deux réserves mesurées : la méthode ne vaut que pour une **conversation + * privée**, et le client web ne la rend pas — seuls les clients mobiles le + * font aujourd'hui. Un échec est donc l'ordinaire ici, jamais une panne : on + * se tait plutôt que de le journaliser à chaque battement. + */ + async brouillon(chatId: number, draftId: number, texte: string): Promise { + try { + await this.api.sendRichMessageDraft({ + chat_id: chatId, + draft_id: draftId, + rich_message: { + // La première des deux corrections de `riche.ts` : le `RichText` de la + // bibliothèque n'admet pas la chaîne nue, que l'API accepte pourtant + // — sans quoi aucun texte simple ne serait exprimable. La conversion + // ne porte que sur ce point-là. + blocks: [{ type: 'thinking', text: texte } as unknown as InputRichBlockLib], + skip_entity_detection: true, + }, + }); + } catch { + /* un signe de vie qui ne s'affiche pas ne casse rien */ + } + } + + /** + * « Aura est en train d'écrire… » dans l'en-tête de la conversation. + * + * Le compagnon du brouillon, et non son doublon : celui-ci fonctionne en + * **groupe** comme en privé, et sur tous les clients. Là où le brouillon dit + * ce qui se passe, celui-ci dit seulement que quelque chose se passe — et + * c'est ce qui reste quand l'autre ne s'affiche pas. + */ + async saisie(chatId: number): Promise { + try { + await this.api.sendChatAction({ chat_id: chatId, action: 'typing' }); + } catch { + /* idem */ + } + } + + /** Coupe le long-polling en vol : la requête en attente est abandonnée. */ + stop(): void { + this.stopped = true; + this.bot.stop(); + } +} diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md new file mode 100644 index 0000000..fc8cd0c --- /dev/null +++ b/src/help/sections/en/passerelle.md @@ -0,0 +1,207 @@ +--- +id: passerelle +title: Gateway +icon: forum +order: 105 +routes: [] +--- + +Driving the **Workshop** from a messaging app, when you are away from this machine. You write to a bot, I open a session or pass your message to the one already working, and I hand you back its answer. + +This is the only part of AURA that leaves this machine, and **it exists only if you configure it**. With no token it does not start, calls nothing, and nothing changes. That is the default state, and it stays that way. + +## What it does not open + +I still listen on the loopback interface only. The Gateway **opens no port**: I go and fetch messages from Telegram, Telegram never comes knocking here. No hole is punched in the firewall, no address is exposed, and nothing that guards the API changes. + +That is why this shape rather than a tunnel or an opening onto the network: an exposed interface would need authentication to defend, where an outbound request needs none. + +## What it costs you + +Three things, better weighed before than after. + +- **A secret comes in.** Everywhere else I hold none. The bot token lives in `server/.env`, which is not versioned, and none of my routes is able to hand it back. +- **I call an external service.** Your messages travel through Telegram's servers, like any other conversation held there. +- **The power granted is that of remote access.** Whoever writes in an allowed conversation can open a session, have it run a command and approve a write on this machine. The list of allowed conversations is therefore not a convenience: it is the lock. + +I refuse to start if that list is missing. An oversight must not open the machine to the first comer. + +A bot, though, opens from anywhere: its name is worldwide. A stranger who finds it gets nothing — their conversation is not on the list, and I do not even answer to say so. Nor do they see the menu of my commands: I declare it for allowed conversations only. + +## Personal, not professional + +Everything that goes through here goes through Telegram's servers: your messages, my answers, and the contents of the files you ask me to open. A conversation with a bot is not end-to-end encrypted — the service sees what passes through, and nothing I do on this side changes that. + +That is acceptable for what the Gateway is meant for: your personal projects, your notes, the everyday agent, an already public repository. **I do not recommend it for professional use** — company code, client data, anything that must not leave this machine. Confidentiality then becomes Telegram's, not mine, and what has left cannot be called back. + +Nothing stops you technically: I cannot tell a personal project from a work one. The line is yours to draw, and it is drawn in two places — the conversations you allow, and the projects you open from afar. + +I am looking for a shape that keeps remote use without handing anything to a third party. The avenue being explored is a private network — Tailscale or equivalent — that would make the Workshop reachable from your phone without exposing anything on the internet; it would likely mean reworking the Workshop for a screen that size. This is not a promise, only the direction. Until it exists, what precedes stands as the rule. + +## Turning it on + +1. Create a bot with `@BotFather` on Telegram, which gives you a token. +2. Get your conversation's id — a number; a group's is negative. +3. Fill in `server/.env`: + +``` +AURA_TELEGRAM_TOKEN=the-BotFather-token +AURA_TELEGRAM_CHATS=your-id +``` + +4. Restart the service **fully**. That file is read at startup and is not re-read live: a new variable only takes effect on the next launch. + +A line in my log confirms the opening and how many conversations are allowed. If the token is refused, I say so and stop there rather than retrying forever — the rest of AURA keeps working. + +`AURA_TELEGRAM_MODE` sets the permission mode of sessions opened from afar. `default` unless told otherwise: every sensitive tool asks you. + +## What you can tell me + +### Browsing, without starting anything + +`/projets` opens a **navigation screen**: one button per project. Click, and that same message rewrites itself to show the project's **tree** — `.claude/ 59`, `workflow/ 15`, then the files at the root. You walk down folder by folder; a file opens its contents. + +A single message for the whole trip, with **◀ Parent folder** at every level. Deliberately so: a conversation has no back button, and stacking one list per click would leave a queue of dead states behind you to scroll past. + +Eighty files in a row cannot be read. Grouping them by category would have been one answer — but an invented one, when **the paths already carry a structure**: the Project page's "categories" _are_ the folders of `.claude`. Following the tree therefore shows the project as it is actually arranged, rather than a parallel classification to memorise. + +Every folder shows how many files it holds **at any depth**. And a folder whose only child is another folder is merged with it — `rules/back/application` opens in a single click, instead of three screens that would ask no question. + +The commands remain available when you already know where you are going: + +| Message | What I do | +| ------------- | ---------------------------------------------------- | +| `/projets` | The navigation screen | +| `/projet ` | A project's root, directly | +| `/voir ` | A file's contents, by its rank in the project's list | + +This is **the same inventory as the Project page** — no more, no less. The same guards therefore apply word for word: a file I refuse to open on screen I refuse here too, and secrets (`.env`, credentials, keys) are offered nowhere. + +None of this opens a session: no process, no tokens spent, an immediate answer. A project's root also carries an **▶ Open the Workshop here** button, saving you the command. + +One last point, since it will be noticed: these screens live in my memory, not on disk. After the service restarts, the buttons on an older message no longer point anywhere — I say so rather than sitting there doing nothing. + +### What becomes of a Markdown file + +I **translate** it into a structured document, not decorated text: headings at their level, lists, quotes, code blocks highlighted for the declared language, clickable links, and **real tables, with their borders and their header row**. + +A word on tables, because they decide whether a specification is readable at all. Rendered as monospaced text, a table falls apart as soon as it exceeds a phone's width: the columns wrap and the alignment — its whole reason for being — is gone. Hence the richer message format, which draws them properly. + +Two caveats, from observation rather than documentation: + +- a **checkbox** (`- [ ]`, `- [x]`) is not drawn by current clients, even though the format provides for it. So I write `☑︎` or `☐︎` into the text: otherwise a task list would lose the state of every line with nothing to signal it; +- anything that is **not** Markdown — a `settings.json`, a settings file — goes out monospaced and untransformed. Seeing bullets and emphasis in it would invent a structure that is not there. + +If a document defeats the translation, I send it to you **exactly as written**, in one piece: I lose the formatting, I keep the layout and the length. An ugly document beats a missing one. + +### Long documents + +A message is bounded, even a rich one. A longer document therefore arrives **in pages**, with **◀ Previous** and **Next ▶** buttons — the header says where you are (`page 2 of 7`). The bound is generous: most documents fit on a single page. + +The cut always falls on a line ending, never mid-word. And a page that stops inside a code block closes it, the next one reopening it: without that, the whole rest of the document would render as code. + +The file is re-read for each page. It may therefore have changed between two pages — deliberately so: you are reading the disk, not a copy taken ten minutes ago. + +### Working + +| Message | What I do | +| -------------- | ------------------------------------------- | +| `/atelier ` | I open a session on that project | +| `/etat` | Where this session stands, and its window | +| `/compacter` | I compact the conversation, without waiting | +| `/sessions` | I list what is running, in two groups | +| `/stop` | I interrupt the current turn | +| `/fin` | I close this conversation's session | +| `/aide` | I repeat the above | + +One conversation holds **one** session at a time: opening one closes the previous. + +`/sessions` answers in two groups, because they are not the same thing. **Opened by AURA**: the ones I own, and the only ones I can talk to. **Opened elsewhere**: the ones you started in a terminal — I see them through their state file, I do not drive them. Merging the two would suggest a message can reach a terminal session, which it cannot. + +**Any other message goes to the session as a turn.** That is by far the most frequent case, and it needs no syntax. + +### The context window, from afar + +Up close, the Workshop's **Context** tab shows what a session pulled into its window. From afar there was nothing: the conversation tells you what the agent answers, never how much room it has left. `/etat` fills that gap. + +``` +C:\devl\tos — claude-opus-5, default mode + +Window: 112 400 / 200 000 tokens — 56% +``` + +The model is there because **the limit depends on it**: 200,000 tokens, or a million on a long window. A percentage without its denominator cannot be checked. + +The figure is **exact**, and it is the same one the Context tab shows — I read it off the model's replies, which carry the real count. What is read back from cache counts too: it takes up the window exactly like the rest, only the price differs. + +`/compacter` does not wait for the window to fill. It is the **only** Claude Code command I relay, and the surface justifies it: from afar you could watch the window fill with no way to act — `/etat` stated the problem, this one settles it. + +Two cases where I show no figure rather than invent one: when no session is open here, and when a session is newborn and no turn has answered yet — there is nothing to read. + +### I only open known projects + +`/atelier` accepts **only projects Claude Code already knows** — the ones `/projets` lists. Any other folder on the machine matches nothing: there is no rule to get around, only a list you have to be in already. + +This is deliberately stricter than the Workshop on screen, where you browse the disk freely. At the screen you see what you pick; from afar you do not — and a mistyped path would open a session somewhere else with nothing to flag it. + +The number, the full path, the project name and its slug all work. + +A command I do not know is reported back to you rather than sent to the agent — otherwise a typo would look like a breakdown. + +A message from a conversation that is not allowed gets **no reply**. That is deliberate: replying would confirm that this bot exists and what it is for. + +## Approving a tool from afar + +When the agent wants a tool the mode does not let through, I send you a message with two buttons, **Allow** and **Deny**. Your answer unblocks the turn at once. + +A plan is the one exception: when the agent submits what it intends to do, the tool name teaches you nothing — the text is what you judge. So I send you the whole plan, formatted, with **Approve** and **Deny** below it. A very long plan is trimmed; it stays whole in the Workshop. + +Once the request is settled the buttons vanish from the message — whether the answer came from here, from the Workshop, or from the deadline running out. A thread read later never suggests a question is still waiting. + +The Workshop's deadline applies here too: **with no answer within fifteen minutes the request is denied**, never the reverse. A command started before you left will not stay suspended forever. + +## Answering a question + +When the agent needs you to decide, I put the Workshop's own form to you, **one question per message**. Every option arrives with what explains it — its description, and its mockup where it has one. The number before the label is the button's: Telegram trims buttons to thirty-two characters, the text above trims nothing. + +A question expecting **several answers** is ticked: each option toggles between ☐ and ☑, and **Confirm** closes it. Only the keyboard moves on each tap — the question stays where you were reading it. + +And if no option fits, **write your answer**: while a question is waiting, an ordinary message answers it instead of going out as a turn. Commands stay commands — `/stop` still interrupts under a question. + +With no answer within fifteen minutes the question passes: I take the buttons away and say so. + +## While it is working + +A turn can run for ten minutes without a single byte arriving. So, for as long as it lasts, I show a **bubble saying what I am doing and since when** — `Read, Grep — 1m 12s`. The same labels as on screen: one session read from two places must not tell two stories. + +That bubble is not a message. It does not stay in the thread, cannot be re-read, and disappears as soon as the answer arrives. That is what sets it apart from a stream: it occupies one place, always the same, instead of stacking up. + +It stops the moment the ball is in your court — end of turn, permission request, question. Letting it beat while I wait on you would suggest I am still working. + +Two caveats I would rather state: the bubble needs a **private conversation** and a **mobile** client. On the web, or in a group, it does not show. What remains then is the "typing…" in the header, which says less but works everywhere. + +## What I do not send you + +**The agent's answer at the end of a turn, and the requests awaiting a decision.** Nothing else — with two exceptions, and they share one reason. + +I tell you when **the window has been compacted**, with the two figures that make the fact useful: what it started from, and what is left. The summary follows in a second message, **folded**: it is the whole conversation rewritten, and it is all that remains in the window — reading it is knowing what the agent kept. Unfold it if you want to; otherwise it takes three lines. And I say it **once** when the window passes 80% — once only, then I keep quiet until the next compaction. Those two are exceptions because they are the only case where something changes **without you having anything to do with it**: a compaction gives no warning, and from afar nothing would let you guess. Everything else, you asked for. + +Not the tokens as they are written, not the detail of what a tool read or wrote. A messaging app is not a timeline: pouring a stream into it would make it unreadable and drown what needs an answer. The full thread is in the Workshop, and the **Replay** keeps it. + +The answer itself is a document, and I render it as one — the same tables, headings and lists as for a file. It is what you read most often here; it would be the last place to leave raw Markdown. + +A very long answer is cut rather than lost — truncated text can be read, a failed send cannot be seen. The cut is generous: five times what an ordinary message takes. + +## The two bounds, and what protects you + +The **six sessions** ceiling applies here as elsewhere: I say so in the conversation rather than failing without a word. + +The **thirty minute** collection does not apply to a session driven from here. As long as the conversation holds it, I am watching it — and a watched session is never collected. You can leave work half-done and pick it up in the evening. + +A session does **not** survive a restart of the service, however. Write to me after one and I will tell you no session is open; `/atelier` opens a new one, and the previous work stays on disk. + +## What this does not replace + +The Workshop shows what a messaging app cannot: the exact path a tool targets, the context window, the commands left running in the background. The Gateway is for starting, watching and unblocking — not for working blind. + +Sessions opened from afar are sessions like any other: they show up in the Workshop, they replay, and they count in **Usage** as in **Diagnostics**. diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md new file mode 100644 index 0000000..6c7cfb3 --- /dev/null +++ b/src/help/sections/fr/passerelle.md @@ -0,0 +1,207 @@ +--- +id: passerelle +title: Passerelle +icon: forum +order: 105 +routes: [] +--- + +Piloter l'**Atelier** depuis une messagerie, quand vous n'êtes pas devant ce poste. Vous écrivez à un bot, j'ouvre une session ou je transmets votre message à celle qui travaille déjà, et je vous rends sa réponse. + +C'est la seule partie d'AURA qui sorte de cette machine, et **elle n'existe que si vous la configurez**. Sans jeton, elle ne démarre pas, n'appelle rien, et rien ne change. C'est l'état par défaut, et il le reste. + +## Ce qu'elle n'ouvre pas + +Je continue de n'écouter que la boucle locale. La Passerelle **n'ouvre aucun port** : c'est moi qui vais chercher les messages chez Telegram, jamais Telegram qui vient frapper ici. Aucune porte n'est percée dans le pare-feu, aucune adresse n'est à exposer, et rien de ce qui garde l'API ne change. + +C'est la raison d'être de cette forme plutôt que d'un tunnel ou d'une ouverture au réseau : une interface exposée demanderait une authentification à défendre, là où une requête sortante n'en demande aucune. + +## Ce que cela vous coûte + +Trois choses, et il vaut mieux les peser avant qu'après. + +- **Un secret entre chez moi.** Partout ailleurs, je n'en porte aucun. Le jeton du bot vit dans `server/.env`, qui n'est pas versionné, et aucune de mes routes n'est en mesure de le renvoyer. +- **J'appelle un service externe.** Vos messages transitent par les serveurs de Telegram, comme n'importe quelle conversation qui s'y tient. +- **Le pouvoir accordé est celui d'un accès distant.** Qui écrit dans une conversation autorisée peut ouvrir une session, lui faire lancer une commande et autoriser une écriture sur ce poste. La liste des conversations autorisées n'est donc pas un confort : c'est la serrure. + +Je refuse de démarrer si cette liste est absente. L'oubli ne doit pas ouvrir la machine au premier venu. + +Un bot, lui, s'ouvre depuis n'importe où : son nom est mondial. Un inconnu qui le trouve n'obtient rien — sa conversation n'est pas dans la liste, et je ne lui réponds même pas pour le lui dire. Il ne voit pas non plus le menu de mes commandes : je ne le déclare que pour les conversations autorisées. + +## Personnel, pas professionnel + +Tout ce qui passe par ici passe par les serveurs de Telegram : vos messages, mes réponses, et le contenu des fichiers que vous me demandez d'ouvrir. Une conversation avec un bot n'est pas chiffrée de bout en bout — le service voit ce qui transite, et rien de ce que je fais de ce côté n'y change quoi que ce soit. + +C'est acceptable pour ce à quoi la Passerelle est faite : vos projets personnels, vos notes, l'agent du quotidien, un dépôt déjà public. **Je ne la recommande pas pour un usage professionnel** — code d'entreprise, données de clients, tout ce qui ne doit pas quitter ce poste. La confidentialité devient alors celle de Telegram, pas la mienne, et ce qui est parti ne se rappelle pas. + +Rien ne vous en empêche techniquement : je ne sais pas distinguer un projet personnel d'un projet de travail. La borne est la vôtre, et elle se pose à deux endroits — les conversations que vous autorisez, et les projets que vous ouvrez de loin. + +Je cherche une forme qui garde l'usage à distance sans confier quoi que ce soit à un tiers. La piste explorée est celle d'un réseau privé — Tailscale ou équivalent — qui rendrait l'Atelier joignable depuis votre téléphone sans rien exposer sur Internet ; elle demanderait sans doute de refondre l'Atelier pour un écran de cette taille. Ce n'est pas une promesse, seulement la direction. Tant qu'elle n'existe pas, ce qui précède reste la règle. + +## L'activer + +1. Créez un bot auprès de `@BotFather` sur Telegram, qui vous donne un jeton. +2. Récupérez l'identifiant de votre conversation — c'est un nombre ; celui d'un groupe est négatif. +3. Renseignez `server/.env` : + +``` +AURA_TELEGRAM_TOKEN=le-jeton-de-BotFather +AURA_TELEGRAM_CHATS=votre-identifiant +``` + +4. Redémarrez le service **entièrement**. Ce fichier est lu au démarrage et n'est pas relu à chaud : une variable ajoutée ne prend effet qu'au prochain lancement. + +Une ligne dans mon journal confirme l'ouverture et le nombre de conversations autorisées. Si le jeton est refusé, je le dis et je m'arrête là plutôt que de réessayer indéfiniment — le reste d'AURA continue de fonctionner. + +`AURA_TELEGRAM_MODE` fixe le mode de permission des sessions ouvertes de loin. Par défaut `default` : chaque outil sensible vous demande. + +## Ce que vous pouvez me dire + +### Consulter, sans rien lancer + +`/projets` ouvre un **écran de navigation** : un bouton par projet. Un clic, et ce même message se réécrit pour montrer **l'arborescence** du projet — `.claude/ 59`, `workflow/ 15`, puis les fichiers de la racine. On descend dossier par dossier ; un fichier ouvre son contenu. + +Un seul message pour tout le parcours, avec **◀ Dossier parent** à chaque étage. C'est délibéré : une conversation n'a pas de bouton précédent, et empiler une liste par clic laisserait derrière vous une file d'états morts qu'il faudrait remonter pour retrouver le fil. + +Quatre-vingts fichiers d'affilée ne se lisent pas. Les ranger par catégorie aurait été une réponse — mais une réponse inventée, alors que **les chemins en portent déjà une** : les « catégories » de la page Projet _sont_ les dossiers de `.claude`. Suivre l'arbre montre donc le projet tel qu'il est rangé, plutôt qu'un classement parallèle à retenir. + +Chaque dossier affiche le nombre de fichiers qu'il contient **à toute profondeur**. Et un dossier qui n'en contient qu'un autre est fondu avec lui — `rules/back/application` s'ouvre d'un seul clic, au lieu de trois écrans qui ne poseraient aucune question. + +Les commandes restent disponibles quand vous savez déjà où vous allez : + +| Message | Ce que je fais | +| ------------- | ------------------------------------------------------------- | +| `/projets` | L'écran de navigation | +| `/projet ` | La racine d'un projet, directement | +| `/voir ` | Le contenu d'un fichier, par son rang dans la liste du projet | + +C'est **le même inventaire que la page Projet** — ni plus, ni moins. Les mêmes gardes s'appliquent donc mot pour mot : un fichier que je refuse d'ouvrir à l'écran, je le refuse ici aussi, et les secrets (`.env`, identifiants, clés) ne sont proposés nulle part. + +Rien de tout cela n'ouvre de session : aucun processus, aucun jeton dépensé, réponse immédiate. La racine d'un projet porte d'ailleurs un bouton **▶ Ouvrir l'Atelier ici**, qui évite d'avoir à retaper la commande. + +Un dernier point, parce qu'il se remarquera : ces écrans vivent dans ma mémoire, pas sur le disque. Après un redémarrage du service, les boutons d'un message plus ancien ne désignent plus rien — je vous le dis plutôt que de rester sans réaction. + +### Ce que devient un Markdown + +Je le **traduis** en document structuré, et non en texte décoré : titres à leur niveau, listes, citations, blocs de code colorés selon la langue déclarée, liens cliquables, et de **vrais tableaux, avec leurs bordures et leur ligne d'en-tête**. + +Un mot sur les tableaux, parce que c'est le cas qui décide de la lisibilité d'une spécification. Rendu en texte à chasse fixe, un tableau se disloque dès qu'il dépasse la largeur d'un téléphone : les colonnes passent à la ligne et l'alignement — sa seule raison d'être — disparaît. C'est pourquoi je passe par la messagerie enrichie, qui sait les dessiner pour de bon. + +Deux réserves, tirées de l'observation et non de la documentation : + +- une **case à cocher** (`- [ ]`, `- [x]`) n'est pas dessinée par les clients actuels, bien qu'elle existe dans le format. J'écris donc `☑︎` ou `☐︎` dans le texte : autrement, une liste de tâches perdrait l'état de chaque ligne sans que rien ne le signale ; +- ce qui n'est **pas** du Markdown — un `settings.json`, un fichier de réglages — part en chasse fixe, sans transformation. Y voir des puces et des emphases inventerait une structure qui n'existe pas. + +Si un document malmène la traduction, je vous l'envoie **tel qu'il est écrit**, d'un seul tenant : je perds la mise en forme, je garde la mise en page et la longueur. Un document laid vaut mieux qu'un document disparu. + +### Les documents longs + +Un message reste borné, même enrichi. Un document plus long arrive donc **en pages**, avec deux boutons **◀ Précédent** et **Suivant ▶** — l'en-tête dit où vous en êtes (`page 2 sur 7`). La borne est large : la plupart des documents tiennent en une seule page. + +La coupe tombe toujours sur une fin de ligne, jamais au milieu d'un mot. Et une page qui s'arrête à l'intérieur d'un bloc de code le referme, la suivante le rouvrant : sans cela, tout le reste du document s'afficherait comme du code. + +Le fichier est relu à chaque page. Il peut donc avoir changé entre deux pages — c'est voulu : vous lisez le disque, pas une copie prise il y a dix minutes. + +### Travailler + +| Message | Ce que je fais | +| -------------- | ------------------------------------------ | +| `/atelier ` | J'ouvre une session sur ce projet | +| `/etat` | Où en est la session d'ici, et sa fenêtre | +| `/compacter` | Je compacte la conversation, sans attendre | +| `/sessions` | Je liste ce qui tourne, en deux groupes | +| `/stop` | J'interromps le tour en cours | +| `/fin` | Je ferme la session de cette conversation | +| `/aide` | Je rappelle ce qui précède | + +Une conversation tient **une** session à la fois : en ouvrir une referme la précédente. + +`/sessions` répond en deux groupes, parce que ce ne sont pas les mêmes choses. **Ouvertes par AURA** : celles que je possède, et les seules à qui je puisse parler. **Ouvertes ailleurs** : celles que vous avez lancées dans un terminal — je les vois par leur fichier d'état, je ne les pilote pas. Les confondre ferait croire qu'un message peut atteindre une session de terminal, ce qui est faux. + +**Tout autre message part à la session comme un tour.** C'est le cas de loin le plus fréquent, et il ne demande aucune syntaxe. + +### La fenêtre de contexte, vue de loin + +De près, l'onglet **Contexte** de l'Atelier montre ce que la session a fait entrer dans sa fenêtre. De loin, on ne voyait rien : la conversation dit ce que l'agent répond, jamais la place qu'il lui reste. `/etat` comble ce trou. + +``` +C:\devl\tos — claude-opus-5, mode default + +Fenêtre : 112 400 / 200 000 tokens — 56 % +``` + +Le modèle figure là parce que **la limite en dépend** : 200 000 tokens, ou un million sur une fenêtre longue. Un pourcentage sans son dénominateur ne se vérifie pas. + +Le chiffre est **exact**, et c'est le même que celui de l'onglet Contexte — je le relève sur les réponses du modèle, qui portent le compte réel. Ce qui est relu du cache y est compté : cela occupe la fenêtre exactement comme le reste, seul le prix diffère. + +`/compacter` n'attend pas que la fenêtre déborde. C'est la **seule** commande de Claude Code que je relaie, et la surface le justifie : de loin, on voyait la fenêtre se remplir sans pouvoir rien y faire — `/etat` disait le problème, celle-ci le règle. + +Deux cas où je ne montre pas de chiffre, plutôt que d'en inventer un : quand aucune session n'est ouverte ici, et quand la session vient de naître sans qu'aucun tour ait encore répondu — il n'y a alors rien à relever. + +### Je n'ouvre que des projets connus + +`/atelier` n'accepte **que les projets que Claude Code connaît déjà** — ceux que `/projets` liste. Un dossier quelconque de la machine ne tombe sur rien : il n'y a pas de règle à contourner, seulement une liste dans laquelle il faut déjà figurer. + +C'est délibérément plus strict que l'Atelier à l'écran, où vous parcourez le disque librement. Devant l'écran, vous voyez ce que vous choisissez ; de loin, non — et un chemin mal tapé ouvrirait une session ailleurs sans que rien ne le signale. + +Le numéro, le chemin complet, le nom du projet ou son slug fonctionnent tous les quatre. + +Une commande que je ne connais pas vous est signalée plutôt qu'envoyée à l'agent — sans quoi une faute de frappe passerait pour une panne. + +Un message venu d'une conversation non autorisée reste **sans réponse**. C'est délibéré : répondre confirmerait que ce bot existe et à quoi il sert. + +## Autoriser un outil de loin + +Quand l'agent veut employer un outil que le mode ne laisse pas passer, je vous envoie un message avec deux boutons, **Autoriser** et **Refuser**. Votre réponse débloque le tour immédiatement. + +Un plan fait exception, et il est le seul : quand l'agent soumet ce qu'il compte faire, le nom de l'outil n'apprend rien — c'est le texte qui se juge. Je vous envoie donc le plan entier, mis en forme, et les boutons **Approuver** et **Refuser** sous lui. Un plan très long est coupé ; il reste entier dans l'Atelier. + +Une fois la demande tranchée, les boutons disparaissent du message — que la réponse soit venue d'ici, de l'Atelier, ou du délai qui a expiré. Un fil relu plus tard ne donne donc pas à croire qu'une question attend encore. + +L'échéance de l'Atelier s'applique ici aussi : **sans réponse au bout d'un quart d'heure, la demande est refusée**, jamais l'inverse. Une commande lancée avant de partir ne restera donc pas suspendue indéfiniment. + +## Répondre à une question + +Quand l'agent vous demande de trancher, je vous pose le même formulaire que l'Atelier, **une question par message**. Chaque option arrive avec ce qui l'explique — sa description, et sa maquette quand elle en porte une. Le numéro devant le libellé est celui du bouton : Telegram rogne les boutons à trente-deux caractères, le texte au-dessus ne rogne rien. + +Une question qui attend **plusieurs réponses** se coche : chaque option bascule entre ☐ et ☑, et **Valider** conclut. Seul le clavier bouge à chaque clic — la question, elle, reste où vous la lisiez. + +Et si aucune option ne convient, **écrivez votre réponse** : tant qu'une question attend, un message ordinaire lui répond au lieu de partir comme un tour. Les commandes, elles, restent des commandes — `/stop` interrompt même sous une question. + +Sans réponse au bout d'un quart d'heure, la question passe : je retire les boutons et je vous le dis. + +## Pendant que ça travaille + +Un tour peut durer dix minutes sans qu'un octet n'arrive. Je montre donc, le temps qu'il dure, une **bulle qui dit ce que je fais et depuis quand** — `Read, Grep — 1 min 12`. Les mêmes libellés qu'à l'écran : une même session lue de deux endroits ne doit pas raconter deux histoires. + +Cette bulle n'est pas un message. Elle ne reste pas dans le fil, ne se relit pas, et disparaît dès que la réponse arrive. C'est ce qui la distingue d'un flux : elle occupe une place, toujours la même, au lieu de s'empiler. + +Elle s'arrête dès que la balle passe dans votre camp — fin de tour, demande de permission, question. La laisser battre pendant que j'attends votre réponse ferait croire que je travaille encore. + +Deux réserves, que je préfère dire : cette bulle demande une **conversation privée** et un client **mobile**. Sur le web, ou dans un groupe, elle ne s'affiche pas. Il reste alors le « en train d'écrire… » de l'en-tête, qui dit moins mais qui passe partout. + +## Ce que je ne vous envoie pas + +**La réponse de l'agent en fin de tour, et les demandes qui attendent une décision.** Rien d'autre — à deux exceptions près, et elles ont la même raison d'être. + +Je vous préviens quand **la fenêtre a été compactée**, avec les deux chiffres qui rendent le fait utile : ce dont on part, et ce qu'il en reste. Le résumé suit dans un second message, **replié** : c'est la conversation entière réécrite, et c'est tout ce qui reste dans la fenêtre — le lire, c'est savoir ce que l'agent a gardé. Vous le dépliez si vous le voulez ; sinon il ne prend que trois lignes. Et je vous le dis **une fois** quand la fenêtre passe les 80 % — une seule, puis je me tais jusqu'à la prochaine compaction. Ces deux-là font exception parce qu'elles sont le seul cas où quelque chose change **sans que vous y soyez pour rien** : une compaction ne s'annonce pas, et de loin, rien ne la laisserait deviner. Tout le reste, vous l'avez demandé. + +Ni les tokens au fil de leur écriture, ni le détail de ce qu'un outil a lu ou écrit. Une messagerie n'est pas une timeline : y déverser un flux le rendrait illisible et noierait ce qui demande une réponse. Le fil complet est dans l'Atelier, et le **Rejeu** le garde. + +La réponse elle-même est un document, et je la rends comme tel — mêmes tableaux, mêmes titres, mêmes listes que pour un fichier. C'est ce que vous lisez le plus souvent ici ; ce serait le dernier endroit où laisser du Markdown brut. + +Une réponse très longue est coupée plutôt que perdue — un texte tronqué se lit, un envoi échoué ne se voit pas. La coupe est large : cinq fois ce qu'un message ordinaire accepte. + +## Les deux bornes, et ce qui vous protège + +Le plafond de **six sessions** vaut ici comme ailleurs : je vous le dis dans la conversation plutôt que d'échouer sans un mot. + +Le ramassage des **trente minutes** ne s'applique pas à une session pilotée d'ici. Tant que la conversation la tient, je la regarde — et une session regardée n'est jamais ramassée. Vous pouvez laisser un travail en plan et le reprendre le soir. + +En revanche, une session **ne survit pas au redémarrage du service**. Si vous m'écrivez après un redémarrage, je vous dirai qu'aucune session n'est ouverte ; `/atelier` en rouvre une, et le travail précédent reste sur le disque. + +## Ce que cela ne remplace pas + +L'Atelier montre ce que la messagerie ne peut pas : le chemin exact qu'un outil vise, la fenêtre de contexte, les commandes lancées en arrière-plan. La Passerelle sert à lancer, surveiller et débloquer — pas à travailler à l'aveugle. + +Les sessions ouvertes de loin sont des sessions comme les autres : elles apparaissent dans l'Atelier, se rejouent, et comptent dans l'**Usage** comme dans le **Diagnostic**. diff --git a/test/passerelle-activite.test.ts b/test/passerelle-activite.test.ts new file mode 100644 index 0000000..4f18e33 --- /dev/null +++ b/test/passerelle-activite.test.ts @@ -0,0 +1,135 @@ +// La bulle éphémère montrée pendant qu'un tour travaille. +// +// Deux choses se testent ici et nulle part ailleurs : ce que la ligne dit d'une +// activité, et le rythme auquel elle est réémise. Le rythme n'est pas un détail +// — un brouillon expire au bout de trente secondes, et une phase d'outil change +// toutes les deux ou trois. Entre les deux, il y a une décision. + +import { describe, expect, it, vi } from 'vitest'; +import { BATTEMENT_MS, Battement, ligne, PAS_MINIMAL_MS } from '../server/passerelle/activite.ts'; +import { IDLE_ACTIVITY, type AgentActivity } from '../shared/agent.ts'; + +const T0 = 1_700_000_000_000; + +const activite = (partiel: Partial): AgentActivity => ({ + ...IDLE_ACTIVITY, + turnStartedAt: T0, + ...partiel, +}); + +describe('ligne', () => { + it('ne dit rien au repos', () => { + expect(ligne(IDLE_ACTIVITY, T0)).toBeNull(); + }); + + it('nomme la phase quand elle n’a pas d’outil', () => { + expect(ligne(activite({ phase: 'thinking' }), T0)).toBe('Réflexion'); + }); + + it('se tait sur la durée pendant les premières secondes', () => { + // Un chrono qui démarre à « 1 s » n'apprend rien et attire l'œil sur le seul + // moment où il n'y a rien à s'expliquer. + expect(ligne(activite({ phase: 'writing' }), T0 + 3_000)).toBe('Rédaction'); + expect(ligne(activite({ phase: 'writing' }), T0 + 12_000)).toBe('Rédaction — 12 s'); + }); + + it('compte la durée du tour, pas celle de la phase', () => { + // `since` bouge à chaque phase ; `turnStartedAt` répond à « depuis combien + // de temps ça mouline », qui est la question qu'on se pose vraiment. + const a = activite({ phase: 'requesting', since: T0 + 90_000 }); + expect(ligne(a, T0 + 95_000)).toBe('Requête en cours — 1 min 35 s'); + }); + + it('se nomme de ses outils, et compte au-delà de deux', () => { + const outil = (name: string) => ({ id: name, name, startedAt: T0 }); + expect(ligne(activite({ phase: 'tool', tools: [outil('Read')] }), T0)).toBe('Read'); + expect( + ligne(activite({ phase: 'tool', tools: [outil('Read'), outil('Bash'), outil('Grep')] }), T0), + ).toBe('Read, Bash +1'); + }); + + it('dit le rang d’une nouvelle tentative', () => { + const a = activite({ + phase: 'retrying', + retry: { attempt: 2, maxRetries: 5, delayMs: 4_000 }, + }); + expect(ligne(a, T0)).toBe('Nouvelle tentative 2/5'); + }); +}); + +describe('Battement', () => { + const monte = () => { + const emis: string[] = []; + const saisies: number[] = []; + const battement = new Battement( + { + brouillon: (_chat, _draft, texte) => { + emis.push(texte); + return Promise.resolve(); + }, + saisie: (chat) => { + saisies.push(chat); + return Promise.resolve(); + }, + }, + 42, + 7, + ); + return { emis, saisies, battement }; + }; + + it('émet la première activité tout de suite, et signale la saisie', () => { + const { emis, saisies, battement } = monte(); + battement.montre(activite({ phase: 'thinking' }), T0); + expect(emis).toEqual(['Réflexion']); + // Le brouillon ne s'affiche qu'en conversation privée et sur mobile ; + // l'action de saisie, elle, passe partout. Les deux vont ensemble. + expect(saisies).toEqual([42]); + battement.arrete(); + }); + + it('ne réémet pas un texte inchangé', () => { + const { emis, battement } = monte(); + const a = activite({ phase: 'writing' }); + battement.montre(a, T0); + battement.montre(a, T0 + PAS_MINIMAL_MS + 1); + expect(emis).toEqual(['Rédaction']); + battement.arrete(); + }); + + it('borne le rythme d’un libellé qui change trop vite', () => { + // Une phase d'outil se remplace toutes les deux ou trois secondes : suivre + // chaque changement ferait une requête par seconde pour un gain nul. + const { emis, battement } = monte(); + const outil = (name: string) => ({ id: name, name, startedAt: T0 }); + battement.montre(activite({ phase: 'tool', tools: [outil('Read')] }), T0); + battement.montre(activite({ phase: 'tool', tools: [outil('Grep')] }), T0 + 200); + battement.montre(activite({ phase: 'tool', tools: [outil('Bash')] }), T0 + 400); + expect(emis).toEqual(['Read']); + battement.arrete(); + }); + + it('tient la bulle en vie avant qu’elle n’expire', async () => { + vi.useFakeTimers(); + try { + const { emis, battement } = monte(); + battement.montre(activite({ phase: 'thinking' }), T0); + expect(emis).toHaveLength(1); + // Trente secondes est l'expiration ; le battement doit tomber avant. + await vi.advanceTimersByTimeAsync(BATTEMENT_MS + 100); + expect(emis).toHaveLength(2); + battement.arrete(); + await vi.advanceTimersByTimeAsync(BATTEMENT_MS * 3); + expect(emis).toHaveLength(2); + } finally { + vi.useRealTimers(); + } + }); + + it('s’arrête de lui-même quand l’activité retombe au repos', () => { + const { emis, battement } = monte(); + battement.montre(activite({ phase: 'thinking' }), T0); + battement.montre(IDLE_ACTIVITY, T0 + 10_000); + expect(emis).toEqual(['Réflexion']); + }); +}); diff --git a/test/passerelle-commandes.test.ts b/test/passerelle-commandes.test.ts new file mode 100644 index 0000000..60cb538 --- /dev/null +++ b/test/passerelle-commandes.test.ts @@ -0,0 +1,79 @@ +// La table des commandes et le routage doivent dire la même chose. +// +// C'est le seul point de ce chantier où trois listes devaient s'accorder à la +// main : le `switch` de `routage.ts`, l'aide, et ce que Telegram propose sous le +// `/`. Les deux dernières viennent maintenant de la table ; ce test tient la +// première. Sans lui, la table redeviendrait une quatrième liste à entretenir. + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { aide, COMMANDES, pourTelegram } from '../server/passerelle/commandes.ts'; +import { parseIntention } from '../server/passerelle/routage.ts'; +import { SUPPORTED_LOCALES, withLocale } from '../server/i18n/index.ts'; + +/** + * Reconnues, mais volontairement hors de la table. + * + * `/help` double `/aide`, et Telegram propose `/start` de lui-même à qui ouvre + * la conversation. Les annoncer ferait une liste qui se répète. + */ +const ALIAS = ['/start', '/help']; + +describe('COMMANDES', () => { + it('ne déclare que des commandes que le routage reconnaît', () => { + for (const c of COMMANDES) { + const intention = parseIntention(`/${c.nom}`); + expect(intention.kind, `/${c.nom}`).not.toBe('ignorer'); + } + }); + + it('déclare toutes celles que le routage reconnaît, alias exceptés', () => { + // La lecture inverse, et c'est elle qui attrape le vrai oubli : une commande + // ajoutée au `switch` et jamais annoncée reste invisible sous le `/`. + const source = readFileSync( + new URL('../server/passerelle/routage.ts', import.meta.url), + 'utf8', + ); + const reconnues = [...source.matchAll(/case '(\/[a-z]+)':/g)].map((m) => m[1] ?? ''); + const declarees = new Set(COMMANDES.map((c) => `/${c.nom}`)); + for (const mot of reconnues) { + if (ALIAS.includes(mot)) continue; + expect(declarees.has(mot), `${mot} est routée mais pas déclarée`).toBe(true); + } + }); + + it('respecte ce que Telegram accepte comme nom', () => { + // Minuscules, chiffres et soulignés, 32 caractères au plus — et jamais la + // barre oblique, que `setMyCommands` refuse. + for (const { command, description } of pourTelegram()) { + expect(command, command).toMatch(/^[a-z0-9_]{1,32}$/); + expect(description.length, command).toBeGreaterThan(0); + expect(description.length, command).toBeLessThanOrEqual(256); + } + }); + + it('a une description dans chaque langue', () => { + for (const langue of SUPPORTED_LOCALES) { + for (const { command, description } of withLocale(langue, pourTelegram)) { + // `t` rend le chemin quand la clé manque : c'est ce qu'on refuse ici. + expect(description, `${langue}/${command}`).not.toContain('passerelle.commandes'); + } + } + }); +}); + +describe('aide', () => { + it('nomme chaque commande de la table, dans chaque langue', () => { + for (const langue of SUPPORTED_LOCALES) { + const texte = withLocale(langue, aide); + for (const c of COMMANDES) expect(texte, `${langue}//${c.nom}`).toContain(`/${c.nom}`); + expect(texte).not.toContain('passerelle.aide'); + } + }); + + it('montre l’argument des commandes qui en prennent un', () => { + const texte = withLocale('fr', aide); + expect(texte).toContain('/voir '); + expect(texte).toContain('/sessions —'); + }); +}); diff --git a/test/passerelle-etat.test.ts b/test/passerelle-etat.test.ts new file mode 100644 index 0000000..a75f410 --- /dev/null +++ b/test/passerelle-etat.test.ts @@ -0,0 +1,137 @@ +// L'état de la fenêtre, sans session ni bot. +// +// C'est la partie qui décide : ce qu'un relevé vaut rapporté à la limite du +// modèle, ce que `/etat` écrit, et quand la fenêtre mérite qu'on en parle sans +// qu'on ait demandé. `index.ts` ne fait qu'émettre ce que ces fonctions rendent. + +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { alerte, lignes, part, SEUIL_ALERTE, type Fenetre } from '../server/passerelle/etat.ts'; +import { contextLimitFor } from '../server/context.ts'; + +const PETITE = 200_000; + +function fenetre(tokens: number, limite = PETITE): Fenetre { + return { tokens, limite }; +} + +describe('la part occupée', () => { + it('rend zéro tant qu’aucun tour n’a répondu', () => { + expect(part(fenetre(0))).toBe(0); + }); + + it('ne divise jamais par zéro', () => { + // Une limite nulle ne devrait pas arriver — mais un `Infinity` affiché en + // pourcentage serait un défaut bien plus visible que la cause. + expect(part({ tokens: 1_000, limite: 0 })).toBe(0); + }); + + it('se borne à un', () => { + // La fenêtre déduite peut être la petite alors que la session tourne sur la + // grande, le temps qu'une preuve arrive : « 118 % » ferait douter du reste + // de l'écran là où « 100 % » dit déjà tout. + expect(part(fenetre(236_000))).toBe(1); + }); + + it('rapporte le relevé à la limite', () => { + expect(part(fenetre(100_000))).toBeCloseTo(0.5); + }); +}); + +describe('le seuil', () => { + it('vaut le garde-fou du signal `contextFill`', () => { + // Recopié et non importé — `SPECS` est privé dans `thresholds.ts`, et les + // deux mesures ne portent pas sur la même chose. Ce test est ce qui empêche + // les deux surfaces d'annoncer deux remplissages différents. + const source = readFileSync( + new URL('../server/diagnostics/thresholds.ts', import.meta.url), + 'utf8', + ); + const bloc = /contextFill:\s*\{[^}]*\}/s.exec(source)?.[0] ?? ''; + const garde = /guard:\s*([\d.]+)/.exec(bloc)?.[1]; + expect(garde, 'le garde-fou de contextFill est introuvable').toBeDefined(); + expect(Number(garde)).toBe(SEUIL_ALERTE); + }); + + it('alerte une fois, puis se tait', () => { + expect(alerte(false, SEUIL_ALERTE)).toBe(true); + expect(alerte(true, SEUIL_ALERTE)).toBe(false); + expect(alerte(true, 0.99)).toBe(false); + }); + + it('ne dit rien sous le seuil', () => { + expect(alerte(false, SEUIL_ALERTE - 0.01)).toBe(false); + expect(alerte(false, 0)).toBe(false); + }); +}); + +describe('ce que /etat écrit', () => { + it('donne le dénominateur, pas seulement le pourcentage', () => { + // Un pourcentage seul ne se vérifie pas : la limite dépend du modèle, et + // c'est justement ce qu'on ne peut pas deviner de loin. + const texte = lignes(fenetre(100_000), 'C:\\devl\\tos', 'Opus 5', 'default').join('\n'); + expect(texte).toContain('50 %'); + expect(texte).toContain('200'); + expect(texte).toContain('C:\\devl\\tos'); + expect(texte).toContain('Opus 5'); + expect(texte).toContain('default'); + }); + + it('ne montre aucun chiffre quand il n’y a pas de relevé', () => { + // Un zéro se lirait comme une mesure, alors que c'est l'absence de mesure. + const texte = lignes(fenetre(0), 'C:\\devl\\tos', 'Opus 5', 'default').join('\n'); + expect(texte).not.toContain('%'); + expect(texte).toContain('C:\\devl\\tos'); + }); + + it('abrège les nombres plutôt que de les faire compter', () => { + // `30 527 / 1 000 000` demande de compter les chiffres des deux côtés pour + // saisir le rapport ; `31 k / 1 M` le donne. C'est lu sur un téléphone. + const texte = lignes(fenetre(30_527, 1_000_000), 'x', 'y', 'default').join('\n'); + expect(texte).toContain('31 k'); + expect(texte).toContain('1 M'); + expect(texte).not.toContain('30527'); + expect(texte).not.toContain('1000000'); + }); + + it('n’affiche aucune décimale : les limites sont exactes', () => { + // Un « 1,0 M » laisserait croire à un arrondi qui n'a pas eu lieu. + const texte = lignes(fenetre(100_000, 200_000), 'x', 'y', 'default').join('\n'); + expect(texte).toContain('200 k'); + expect(texte).not.toMatch(/[.,]0/); + }); +}); + +describe('la limite du modèle', () => { + it('déduit la grande fenêtre d’un contexte qui dépasse la petite', () => { + // Le piège que `contextLimitFor` existe pour éviter : un modèle à fenêtre + // longue s'enregistre **sans** son suffixe `[1m]`. Seule la preuve compte. + expect(contextLimitFor(['claude-opus-5'], 0, false)).toBe(200_000); + expect(contextLimitFor(['claude-opus-5'], 236_000, false)).toBe(1_000_000); + expect(contextLimitFor(['claude-opus-5'], 0, true)).toBe(1_000_000); + }); + + it('rend un pourcentage cohérent sur la grande fenêtre', () => { + const limite = contextLimitFor(['claude-opus-5'], 300_000, false); + expect(Math.round(part(fenetre(500_000, limite)) * 100)).toBe(50); + }); +}); + +describe('le total d’une fenêtre', () => { + it('compte le cache, qui occupe la place autant que le reste', () => { + // La somme que `releveFenetre` fait dans le runner, et la même que + // `transcript.ts` emploie pour ancrer la page Contexte. Ne compter que + // `input_tokens` annoncerait quelques milliers de tokens sur une session + // bien cachée là où la fenêtre en porte cent mille. + const usage = { + input_tokens: 1_200, + cache_read_input_tokens: 98_000, + cache_creation_input_tokens: 13_200, + output_tokens: 900, + }; + const total = + usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens; + expect(total).toBe(112_400); + expect(part(fenetre(total))).toBeCloseTo(0.562); + }); +}); diff --git a/test/passerelle-markdown.test.ts b/test/passerelle-markdown.test.ts new file mode 100644 index 0000000..7c8fbb2 --- /dev/null +++ b/test/passerelle-markdown.test.ts @@ -0,0 +1,60 @@ +// Couper un document en pages sans casser ce qui est à cheval. +// +// Le risque n'est pas esthétique : une page qui s'arrête au milieu d'un bloc de +// code renvoie sa clôture à la page suivante, où elle *ouvre* un bloc au lieu de +// le fermer — et tout le reste du document part en code. C'est ce que ces tests +// tiennent. + +import { describe, expect, it } from 'vitest'; +import { paginer } from '../server/passerelle/markdown.ts'; + +/** Une page d'essai : assez petite pour que les cas tiennent en peu de lignes. */ +const PAGE = 2_600; + +describe('paginer', () => { + it('rend une seule page pour un document court', () => { + expect(paginer('court', PAGE)).toEqual(['court']); + }); + + it('rend une page même pour un document vide', () => { + expect(paginer('', PAGE)).toEqual(['']); + }); + + it('coupe sur des frontières de lignes', () => { + const doc = Array.from({ length: 400 }, (_, i) => `ligne ${i}`).join('\n'); + const pages = paginer(doc, PAGE); + expect(pages.length).toBeGreaterThan(1); + for (const p of pages) expect(p.length).toBeLessThanOrEqual(PAGE); + // Rien ne se perd et rien ne se duplique. + expect(pages.join('\n').split('\n')).toEqual(doc.split('\n')); + }); + + it('referme et rouvre un bloc de code à cheval sur deux pages', () => { + const gros = Array.from({ length: 400 }, (_, i) => `code ${i}`).join('\n'); + const pages = paginer('```ts\n' + gros + '\n```', PAGE); + expect(pages.length).toBeGreaterThan(1); + expect(pages[0]?.endsWith('```')).toBe(true); + expect(pages[1]?.startsWith('```ts')).toBe(true); + // Chaque page porte autant d'ouvertures que de fermetures : aucune ne laisse + // un bloc en l'air pour la suivante. + for (const p of pages) { + expect(p.split('\n').filter((l) => /^\s*```/.test(l)).length % 2).toBe(0); + } + }); + + it('n’ouvre pas un bloc vide quand seule la clôture déborde', () => { + // La fermeture est rattachée à la page qui a ouvert le bloc, même si elle + // dépasse. La renvoyer à la suivante y ouvrait un bloc aussitôt refermé — + // visible, et sans contenu. + const doc = '```ts\n' + 'a'.repeat(40) + '\n' + 'b'.repeat(12) + '\n```\nsuite'; + const pages = paginer(doc, 60); + expect(pages.some((p) => p.startsWith('```ts\n```'))).toBe(false); + expect(pages.join('\n').split('\n')).toEqual(doc.split('\n')); + }); + + it('coupe une ligne plus longue qu’une page entière', () => { + const pages = paginer('x'.repeat(PAGE * 2 + 10), PAGE); + expect(pages.length).toBeGreaterThanOrEqual(3); + for (const p of pages) expect(p.length).toBeLessThanOrEqual(PAGE); + }); +}); diff --git a/test/passerelle-plan.test.ts b/test/passerelle-plan.test.ts new file mode 100644 index 0000000..e8293ae --- /dev/null +++ b/test/passerelle-plan.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { planPropose } from '../server/passerelle/plan.ts'; +import type { PermissionRequest } from '../shared/agent.ts'; + +function demande(over: Partial): PermissionRequest { + return { + id: 'p1', + toolName: 'ExitPlanMode', + input: {}, + toolUseId: 't1', + askedAt: 0, + ...over, + }; +} + +describe('planPropose', () => { + it('rend le markdown du plan', () => { + const plan = '# Plan\n\nUne étape.'; + expect(planPropose(demande({ input: { plan } }))).toBe(plan); + }); + + /** + * La forme mesurée sur le parc : `plan` **et** `planFilePath`. Le fichier + * n'est pas lisible de loin, c'est donc le texte qu'on lit — et sa présence + * ne doit pas dépendre de l'autre champ. + */ + it('ignore le fichier qui accompagne le plan', () => { + const entree = { plan: 'Le plan.', planFilePath: 'C:\\plans\\x.md' }; + expect(planPropose(demande({ input: entree }))).toBe('Le plan.'); + }); + + it('se tait pour tout autre outil', () => { + expect(planPropose(demande({ toolName: 'Write', input: { plan: 'Le plan.' } }))).toBe(''); + }); + + it('se tait sur un plan vide, pour retomber sur le bandeau ordinaire', () => { + expect(planPropose(demande({ input: { plan: ' ' } }))).toBe(''); + expect(planPropose(demande({ input: {} }))).toBe(''); + expect(planPropose(demande({ input: { plan: 42 } }))).toBe(''); + }); +}); diff --git a/test/passerelle-questions.test.ts b/test/passerelle-questions.test.ts new file mode 100644 index 0000000..c1f9f06 --- /dev/null +++ b/test/passerelle-questions.test.ts @@ -0,0 +1,174 @@ +// Le formulaire d'une question, sans réseau ni session. +// +// C'est la partie qui décide : ce qu'un écran montre, ce qu'une pression change, +// et la forme des réponses remises au modèle. `index.ts` ne fait qu'émettre ce +// que ces fonctions rendent — le tester ici, c'est le tester en entier. + +import { describe, expect, it } from 'vitest'; +import type { AskQuestion } from '../shared/agent.ts'; +import type { InlineKeyboardMarkup } from 'node-telegram-bot-api'; +import { + clavier, + courante, + ecran, + formulaire, + presse, + repondLibre, + reponses, +} from '../server/passerelle/questions.ts'; + +/** Un identifiant de la forme que `randomUUID` produit — 36 caractères. */ +const ID = '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0'; + +const simple: AskQuestion = { + question: 'Quel dossier ouvrir ?', + header: 'Dossier', + options: [ + { label: 'tos', description: 'Le dépôt courant' }, + { label: 'cronos', description: 'Le socle visuel' }, + ], +}; + +const multiple: AskQuestion = { + question: 'Quelles surfaces couvrir ?', + header: 'Surfaces', + multiSelect: true, + options: [ + { label: 'Atelier', description: 'La session pilotée' }, + { label: 'Rejeu', description: 'La timeline' }, + { label: 'Manuel', description: "L'aide" }, + ], +}; + +function rangs(clavier: InlineKeyboardMarkup): string[] { + return clavier.inline_keyboard + .flat() + .map((b) => ('callback_data' in b ? (b.callback_data ?? '') : '')); +} + +function libelles(clavier: InlineKeyboardMarkup): string[] { + return clavier.inline_keyboard.flat().map((b) => b.text); +} + +describe('un choix simple', () => { + it('avance d’une étape, et conclut à la dernière', () => { + const f = formulaire(ID, [simple, { ...simple, question: 'Et ensuite ?' }]); + expect(presse(f, '0')).toBe('suivant'); + expect(f.etape).toBe(1); + expect(presse(f, '1')).toBe('fini'); + expect(reponses(f)).toEqual({ + 'Quel dossier ouvrir ?': 'tos', + 'Et ensuite ?': 'cronos', + }); + }); + + it('garde le message de l’étape quittée, pour qu’on puisse la désarmer', () => { + // Sans cela, les boutons d'une question déjà répondue restent pressables et + // s'appliquent à la suivante — ils ne portent qu'un rang. + const f = formulaire(ID, [simple, multiple]); + f.messageId = 42; + presse(f, '0'); + expect(f.messageId).toBe(42); + }); + + it('ignore un rang qui ne désigne aucune option', () => { + const f = formulaire(ID, [simple]); + expect(presse(f, '9')).toBe('rien'); + expect(presse(f, 'ok')).toBe('rien'); + expect(f.etape).toBe(0); + }); + + it('n’offre pas de validation : le clic suffit', () => { + const f = formulaire(ID, [simple]); + expect(rangs(clavier(f))).toEqual([`q:${ID}:0`, `q:${ID}:1`]); + }); +}); + +describe('un choix multiple', () => { + it('coche sans avancer, et ne conclut que sur validation', () => { + const f = formulaire(ID, [multiple]); + expect(presse(f, '0')).toBe('coche'); + expect(presse(f, '2')).toBe('coche'); + expect(f.etape).toBe(0); + expect(presse(f, 'ok')).toBe('fini'); + // La forme du harnais : les choix joints par `, `, celle que le rejeu relit. + expect(reponses(f)).toEqual({ 'Quelles surfaces couvrir ?': 'Atelier, Manuel' }); + }); + + it('décoche ce qui était coché', () => { + const f = formulaire(ID, [multiple]); + presse(f, '1'); + presse(f, '1'); + expect(f.choix[0]).toEqual([]); + }); + + it('refuse de valider une réponse vide', () => { + const f = formulaire(ID, [multiple]); + expect(presse(f, 'ok')).toBe('rien'); + expect(f.etape).toBe(0); + }); + + it('montre l’état dans le libellé, faute d’autre endroit où le mettre', () => { + const f = formulaire(ID, [multiple]); + // La dernière rangée porte la validation, pas une option. + const cases = libelles(clavier(f)).slice(0, multiple.options.length); + expect(cases.every((l) => l.startsWith('☐'))).toBe(true); + presse(f, '0'); + expect(libelles(clavier(f))[0]).toBe('☑ Atelier'); + expect(rangs(clavier(f))).toContain(`q:${ID}:ok`); + }); +}); + +describe('la réponse écrite', () => { + it('remplace les cases cochées et avance', () => { + const f = formulaire(ID, [multiple]); + presse(f, '0'); + expect(repondLibre(f, 'aucune des trois')).toBe('fini'); + expect(reponses(f)).toEqual({ 'Quelles surfaces couvrir ?': 'aucune des trois' }); + }); + + it('ne fait rien quand il n’y a plus de question', () => { + const f = formulaire(ID, [simple]); + presse(f, '0'); + expect(courante(f)).toBeUndefined(); + expect(repondLibre(f, 'trop tard')).toBe('rien'); + }); +}); + +describe('l’écran', () => { + it('tient dans les 64 octets d’un callback_data', () => { + const f = formulaire(ID, [multiple]); + for (const donnee of rangs(clavier(f))) { + expect(Buffer.byteLength(donnee, 'utf8'), donnee).toBeLessThanOrEqual(64); + } + }); + + it('numérote les options, pour relier le bouton rogné à son texte', () => { + const { brut } = ecran(formulaire(ID, [simple])); + expect(brut).toContain('1. tos — Le dépôt courant'); + expect(brut).toContain('2. cronos — Le socle visuel'); + }); + + it('ne compte les étapes que s’il y en a plusieurs', () => { + expect(ecran(formulaire(ID, [simple])).brut).not.toContain('question 1'); + expect(ecran(formulaire(ID, [simple, multiple])).brut).toContain('question 1 sur 2'); + }); + + it('rend une maquette dans son propre bloc, et la borne', () => { + const maquette = 'x'.repeat(5_000); + const f = formulaire(ID, [ + { ...simple, options: [{ label: 'a', description: '', preview: maquette }] }, + ]); + const pre = ecran(f).blocs.filter((b) => b.type === 'pre'); + expect(pre).toHaveLength(1); + // Quatre maquettes démesurées feraient échouer le message entier : mieux + // vaut couper l'une que perdre tout le formatage. + const texte = pre[0]?.text; + expect(typeof texte === 'string' && texte.length < maquette.length).toBe(true); + }); + + it('dit qu’on peut répondre autre chose que ce qui est offert', () => { + // Sans cette ligne, la réponse libre existe sans que personne ne la trouve. + expect(ecran(formulaire(ID, [simple])).brut).toContain('écrivez votre réponse'); + }); +}); diff --git a/test/passerelle-riche.test.ts b/test/passerelle-riche.test.ts new file mode 100644 index 0000000..2301252 --- /dev/null +++ b/test/passerelle-riche.test.ts @@ -0,0 +1,187 @@ +// Markdown → blocs riches de Telegram. +// +// Le piège de cette API, et la raison d'être de ces cas : **elle accepte les +// champs qu'elle ne connaît pas et les ignore**. Un `header` écrit au lieu de +// `is_header` ne produit aucune erreur — seulement un tableau sans en-tête, et +// rien pour dire pourquoi. Les noms de champs sont donc gelés ici, contre la +// spec, parce que rien à l'exécution ne les défendra. + +import { describe, expect, it } from 'vitest'; +import { enBlocs, fragments } from '../server/passerelle/riche.ts'; + +describe('fragments', () => { + it('rend une ligne sans balisage en un seul morceau', () => { + expect(fragments('du texte')).toEqual(['du texte']); + }); + + it('mêle le nu et l’enrichi dans l’ordre de lecture', () => { + expect(fragments('avant **gras** après')).toEqual([ + 'avant ', + { type: 'bold', text: 'gras' }, + ' après', + ]); + }); + + it('garde le code littéral hors des autres transformations', () => { + expect(fragments('`a**b`')).toEqual([{ type: 'marked', text: { type: 'code', text: 'a**b' } }]); + }); + + it('surligne le code, faute d’une teinte qui survive au téléphone', () => { + // `code` seul est rendu en chasse fixe teintée : lisible sur un grand + // écran, perdu sur un petit. `marked` y ajoute un fond — une différence de + // surface, non de couleur. Mesuré : l'emboîtement se cumule. + expect(fragments('`x`')).toEqual([{ type: 'marked', text: { type: 'code', text: 'x' } }]); + }); + + it('n’échappe rien : ces morceaux voyagent en JSON', () => { + // Contrairement au rendu HTML, `<` et `&` sont du texte et le restent. + expect(fragments('a < b & c')).toEqual(['a < b & c']); + }); + + it('ne prend pas les soulignés d’un nom de fichier pour de l’italique', () => { + expect(fragments('SPEC-014_notes_projet.md')).toEqual(['SPEC-014_notes_projet.md']); + }); + + it('relit le libellé d’un lien, dont la cible est écartée', () => { + // `[`chemin.md`](../ailleurs.md)` gardait ses accents graves à l'écran : le + // libellé n'était jamais analysé. Une cible relative reste refusée, mais + // son texte ne doit pas s'en trouver appauvri. + expect(fragments('[`0.livraison.md`](../livraison/0.livraison.md)')).toEqual([ + { type: 'marked', text: { type: 'code', text: '0.livraison.md' } }, + ]); + expect(fragments('[le **guide**](https://exemple.fr)')).toEqual([ + { type: 'url', text: ['le ', { type: 'bold', text: 'guide' }], url: 'https://exemple.fr' }, + ]); + }); + + it('rend un lien, et refuse les protocoles qu’on ne relaie pas', () => { + expect(fragments('[doc](https://exemple.fr)')).toEqual([ + { type: 'url', text: 'doc', url: 'https://exemple.fr' }, + ]); + expect(fragments('[piège](javascript:alert)')).toEqual(['piège']); + }); +}); + +describe('enBlocs', () => { + it('donne aux titres les six tailles, dans le même sens que le Markdown', () => { + // 1 est le plus grand des deux côtés : la correspondance est directe. + expect(enBlocs('# un')).toEqual([{ type: 'heading', text: ['un'], size: 1 }]); + expect(enBlocs('###### six')).toEqual([{ type: 'heading', text: ['six'], size: 6 }]); + }); + + it('regroupe les lignes consécutives en un paragraphe', () => { + expect(enBlocs('une ligne\net sa suite')).toEqual([ + { type: 'paragraph', text: ['une ligne et sa suite'] }, + ]); + }); + + it('borde le tableau et marque son en-tête', () => { + // Les deux champs qui manquaient au premier essai. Sans `is_bordered` le + // tableau se lit comme des mots posés côte à côte ; sans `is_header` sa + // première ligne ne se distingue pas des autres. + const blocs = enBlocs('| a | b |\n| --- | --- |\n| 1 | 2 |'); + expect(blocs).toEqual([ + { + type: 'table', + is_bordered: true, + cells: [ + [ + { text: ['a'], is_header: true }, + { text: ['b'], is_header: true }, + ], + [{ text: ['1'] }, { text: ['2'] }], + ], + }, + ]); + }); + + it('ne déclare pas d’en-tête sans ligne d’alignement', () => { + // C'est elle qui, en Markdown, distingue un en-tête d'une ligne ordinaire. + const blocs = enBlocs('| a | b |\n| 1 | 2 |'); + const table = blocs[0] as { cells: { is_header?: true }[][] }; + expect(table.cells[0]?.[0]?.is_header).toBeUndefined(); + }); + + it('écrit la case à cocher dans le texte, faute d’être rendue', () => { + // `has_checkbox` existe dans la spec mais aucun client ne l'affiche : sans + // ce préfixe, une liste de tâches perdrait l'état de chacune. + const blocs = enBlocs('- [x] fait\n- [ ] à faire'); + const items = (blocs[0] as { items: { blocks: { text: string[] }[] }[] }).items; + expect(items[0]?.blocks[0]?.text[0]).toBe('☑︎ fait'); + expect(items[1]?.blocks[0]?.text[0]).toBe('☐︎ à faire'); + }); + + it('garde le numéro d’une liste ordonnée dans le texte', () => { + const blocs = enBlocs('1. premier'); + const items = (blocs[0] as { items: { blocks: { text: string[] }[] }[] }).items; + expect(items[0]?.blocks[0]?.text[0]).toBe('1. premier'); + }); + + it('rend un bloc de code avec sa langue', () => { + expect(enBlocs('```ts\nconst a = 1;\n```')).toEqual([ + { type: 'pre', text: 'const a = 1;', language: 'ts' }, + ]); + }); + + it('referme un bloc de code que la coupure d’une page a laissé ouvert', () => { + expect(enBlocs('```\ndu code')).toEqual([{ type: 'pre', text: 'du code' }]); + }); + + it('rend une citation et un séparateur', () => { + expect(enBlocs('> cité')).toEqual([ + { type: 'blockquote', blocks: [{ type: 'paragraph', text: ['cité'] }] }, + ]); + expect(enBlocs('a\n\n---\n\nb')).toEqual([ + { type: 'paragraph', text: ['a'] }, + { type: 'divider' }, + { type: 'paragraph', text: ['b'] }, + ]); + }); + + it('lit l’alignement des colonnes dans la ligne de séparation', () => { + // Les deux-points portent une intention : une colonne de nombres alignée à + // droite dans la source doit l'être à l'écran. Une colonne sans eux + // n'impose rien. + const blocs = enBlocs('| a | b | c | d |\n|:---|---:|:---:|---|\n| 1 | 2 | 3 | 4 |'); + const table = blocs[0] as { cells: { align?: string }[][] }; + expect(table.cells[0]?.map((c) => c.align)).toEqual(['left', 'right', 'center', undefined]); + }); + + it('ne fait qu’une citation de lignes consécutives, et relit ce qu’elle porte', () => { + // Ligne à ligne, chaque ligne prenait son propre cadre — et une consigne + // numérotée à recopier perdait sa numérotation. + const blocs = enBlocs('> Bonjour.\n>\n> 1. premier\n> 2. second'); + expect(blocs).toHaveLength(1); + const dedans = (blocs[0] as { blocks: { type: string }[] }).blocks; + expect(dedans.map((b) => b.type)).toEqual(['paragraph', 'list']); + }); + + it('imbrique les listes selon l’indentation de la source', () => { + // Aplatir remonterait un sous-point au rang de son parent, ce qui inverse + // le sens d'une consigne. + const blocs = enBlocs('1. vérifier :\n - le journal ;\n - la connexion.\n2. déployer'); + const items = (blocs[0] as { items: { blocks: { type: string }[] }[] }).items; + expect(items).toHaveLength(2); + expect(items[0]?.blocks.map((b) => b.type)).toEqual(['paragraph', 'list']); + expect(items[1]?.blocks.map((b) => b.type)).toEqual(['paragraph']); + }); + + it('rattache la suite d’un élément long à son élément', () => { + // Sans cela, la deuxième ligne fermait la liste et le point suivant + // repartait à « 1 » dans un nouveau bloc. + const blocs = enBlocs('1. un point qui\n court sur deux lignes\n2. le suivant'); + expect(blocs).toHaveLength(1); + const items = (blocs[0] as { items: { blocks: { text: string[] }[] }[] }).items; + expect(items[0]?.blocks[0]?.text.join('')).toBe('1. un point qui court sur deux lignes'); + }); + + it('ferme chaque bloc dès que la ligne suivante ne lui appartient plus', () => { + const blocs = enBlocs('# titre\n- puce\n| a |\ntexte'); + expect(blocs.map((b) => b.type)).toEqual(['heading', 'list', 'table', 'paragraph']); + }); + + it('rend une liste vide de blocs pour un document vide', () => { + expect(enBlocs('')).toEqual([]); + expect(enBlocs('\n\n')).toEqual([]); + }); +}); diff --git a/test/passerelle.test.ts b/test/passerelle.test.ts new file mode 100644 index 0000000..78d22ba --- /dev/null +++ b/test/passerelle.test.ts @@ -0,0 +1,376 @@ +// La garde de la Passerelle, et ce qu'elle comprend. +// +// Ces cas ne touchent ni le réseau ni le registre : tout ce qui décide vit dans +// `passerelle/routage.ts` et `passerelle/projets.ts`, précisément pour être +// vérifiable sans bot, sans jeton et sans session. Ce sont les deux fichiers qui +// séparent une machine pilotable d'une machine ouverte à tous, et ils ne doivent +// pas dépendre d'un service tiers pour être testés. + +import { describe, expect, it } from 'vitest'; +import { autorise, lireChats, parseIntention } from '../server/passerelle/routage.ts'; +import { elargi, grille } from '../server/passerelle/telegram.ts'; +import { + aplatir, + arborescence, + compte, + descendre, + resoudreProjet, + type Noeud, +} from '../server/passerelle/projets.ts'; +import type { ProjectResources, ProjectSummary } from '../shared/projects.ts'; + +describe('lireChats', () => { + it('lit une liste séparée par des virgules', () => { + expect([...lireChats('123,456')]).toEqual([123, 456]); + }); + + it('tolère les espaces autour des identifiants', () => { + expect([...lireChats(' 123 , 456 ')]).toEqual([123, 456]); + }); + + it('garde un identifiant négatif : c’est la forme d’un groupe', () => { + expect([...lireChats('-1001234567890')]).toEqual([-1001234567890]); + }); + + it('écarte ce qui n’est pas un entier plutôt que d’en faire un NaN', () => { + // Un identifiant mal recopié ne doit pas devenir une autorisation. + expect([...lireChats('123,abc,0x10,12.5,')]).toEqual([123]); + }); + + it('rend un ensemble vide quand rien n’est configuré', () => { + expect(lireChats(undefined).size).toBe(0); + expect(lireChats('').size).toBe(0); + }); +}); + +describe('autorise', () => { + it('laisse passer une conversation listée', () => { + expect(autorise(lireChats('123'), 123)).toBe(true); + }); + + it('refuse une conversation absente de la liste', () => { + expect(autorise(lireChats('123'), 999)).toBe(false); + }); + + it('n’autorise personne quand la liste est vide', () => { + // L'inverse de la convention habituelle où « vide » veut dire « tout » : + // ici, l'omission ne peut pas ouvrir la machine au premier venu. + expect(autorise(lireChats(''), 123)).toBe(false); + }); +}); + +describe('parseIntention', () => { + it('traite un message ordinaire comme un tour à envoyer', () => { + expect(parseIntention('relis le diff')).toEqual({ kind: 'parler', texte: 'relis le diff' }); + }); + + it('ignore un message vide sans rien répondre', () => { + expect(parseIntention(' ')).toEqual({ kind: 'ignorer', raison: 'vide' }); + }); + + it('ouvre une session sur la référence donnée', () => { + // Un chemin comme un rang : c'est `resoudreProjet` qui tranche ensuite, et + // lui seul décide si la référence désigne un projet connu. + expect(parseIntention('/atelier C:\\devl\\tos')).toEqual({ + kind: 'ouvrir', + ref: 'C:\\devl\\tos', + }); + expect(parseIntention('/atelier 3')).toEqual({ kind: 'ouvrir', ref: '3' }); + }); + + it('garde un argument à espaces d’un seul tenant', () => { + // Un chemin Windows en porte volontiers. Découper en mots ferait d'un + // dossier deux arguments dont aucun ne désignerait rien. + expect(parseIntention('/atelier C:\\Mes Documents\\projet')).toEqual({ + kind: 'ouvrir', + ref: 'C:\\Mes Documents\\projet', + }); + }); + + it('montre les projets plutôt que d’ouvrir sans référence', () => { + // Ce sont eux qui portent les numéros que la commande attend : renvoyer à + // l'aide obligerait à une commande de plus pour la même information. + expect(parseIntention('/atelier')).toEqual({ kind: 'projets' }); + expect(parseIntention('/atelier ')).toEqual({ kind: 'projets' }); + }); + + it('lit les commandes de consultation', () => { + expect(parseIntention('/projets')).toEqual({ kind: 'projets' }); + expect(parseIntention('/projet 2')).toEqual({ kind: 'projet', ref: '2' }); + expect(parseIntention('/voir 7')).toEqual({ kind: 'voir', ref: '7' }); + }); + + it('distingue l’état de cette session du parc entier', () => { + // Deux commandes voisines qui ne répondent pas à la même question : `/etat` + // regarde la session d'ici et sa fenêtre, `/sessions` compte le parc. + expect(parseIntention('/etat')).toEqual({ kind: 'etat' }); + expect(parseIntention('/compacter')).toEqual({ kind: 'compacter' }); + expect(parseIntention('/sessions')).toEqual({ kind: 'sessions' }); + }); + + it('retombe sur les projets quand /projet n’a pas d’argument', () => { + expect(parseIntention('/projet')).toEqual({ kind: 'projets' }); + }); + + it('reconnaît une commande suffixée du nom du bot', () => { + // Telegram écrit `/fin@monbot` dans un groupe ; sans cela la commande + // passerait pour un tour à envoyer à l'agent. + expect(parseIntention('/fin@aura_bot')).toEqual({ kind: 'fin' }); + }); + + it('signale une commande inconnue au lieu de l’envoyer à l’agent', () => { + expect(parseIntention('/nimporte')).toEqual({ + kind: 'ignorer', + raison: 'commande-inconnue', + commande: '/nimporte', + }); + }); + + it('accepte les deux noms de l’aide', () => { + for (const mot of ['/aide', '/help']) { + expect(parseIntention(mot)).toEqual({ kind: 'aide' }); + } + }); + + it('distingue l’accueil de l’aide', () => { + // `/start` est le geste que Telegram propose de lui-même à qui ouvre la + // conversation : il arrive avant qu'on sache quoi demander. Une liste de + // commandes ne répond pas à ça, d'où deux intentions et non un alias. + expect(parseIntention('/start')).toEqual({ kind: 'accueil' }); + }); + + it('ne confond pas un chemin en début de message avec une commande', () => { + // Un message qui commence par une barre oblique n'est une commande que si + // le mot qui suit en est une ; sinon on répondrait « inconnue » à un texte. + expect(parseIntention('/usr/bin est-il dans le PATH ?')).toEqual({ + kind: 'ignorer', + raison: 'commande-inconnue', + commande: '/usr/bin', + }); + }); +}); + +describe('resoudreProjet', () => { + const projets = [ + { slug: 'C--devl-tos', path: 'C:\\Users\\jean\\devl\\tos', name: 'tos' }, + { slug: 'C--devl-autre', path: 'C:\\Users\\jean\\devl\\autre', name: 'autre' }, + ] as ProjectSummary[]; + + it('désigne un projet par son rang, à partir de 1', () => { + expect(resoudreProjet(projets, '1')?.name).toBe('tos'); + expect(resoudreProjet(projets, '2')?.name).toBe('autre'); + }); + + it('ne rend rien pour un rang hors liste', () => { + expect(resoudreProjet(projets, '0')).toBeUndefined(); + expect(resoudreProjet(projets, '3')).toBeUndefined(); + }); + + it('désigne un projet par son chemin, quel que soit le séparateur', () => { + // Un chemin recopié depuis un écran arrive volontiers en barres obliques ; + // le disque, lui, le déclare en antislashs. + expect(resoudreProjet(projets, 'C:/Users/jean/devl/tos')?.name).toBe('tos'); + expect(resoudreProjet(projets, 'C:\\Users\\JEAN\\devl\\TOS')?.name).toBe('tos'); + expect(resoudreProjet(projets, 'C:\\Users\\jean\\devl\\tos\\')?.name).toBe('tos'); + }); + + it('désigne un projet par son nom ou son slug', () => { + expect(resoudreProjet(projets, 'autre')?.slug).toBe('C--devl-autre'); + expect(resoudreProjet(projets, 'C--devl-tos')?.name).toBe('tos'); + }); + + it('ne rend rien pour un chemin que Claude Code ne connaît pas', () => { + // La garde de `/atelier` à distance. Un dossier quelconque de la machine ne + // tombe sur rien : il n'y a pas de règle à contourner, seulement une liste + // dans laquelle il faut déjà figurer. + expect(resoudreProjet(projets, 'C:\\Windows\\System32')).toBeUndefined(); + expect(resoudreProjet(projets, 'C:\\')).toBeUndefined(); + expect(resoudreProjet(projets, '..')).toBeUndefined(); + expect(resoudreProjet(projets, '')).toBeUndefined(); + }); + + it('ne trouve rien dans une liste vide', () => { + expect(resoudreProjet([], '1')).toBeUndefined(); + expect(resoudreProjet([], 'C:\\Users\\jean\\devl\\tos')).toBeUndefined(); + }); +}); + +describe('aplatir', () => { + const noeud = (rel: string) => ({ + rel, + name: rel, + title: '', + description: '', + size: 0, + mtime: 0, + }); + + it('garde les trois origines distinctes, car elles ne se lisent pas pareil', () => { + // C'est `source` qui décide du lecteur — donc du bac à sable. Le cas qui a + // motivé ce test : un document de dossier inclus rangé en `arbre` était + // refusé, parce que `sourceFileKind` ne nomme ni `SPEC-014_x.md` ni aucun + // fichier de sous-dossier. Il lui faut son propre lecteur. + const entrees = aplatir({ + resources: [{ ...noeud('agents/revue.md'), category: 'agents' }], + memories: [{ ...noeud('CLAUDE.md'), category: 'memory' }], + repoDocs: [{ ...noeud('README.md'), category: 'repo' }], + folders: [ + { + rel: 'workflow', + files: [{ ...noeud('workflow/specs/SPEC-014_notes-projet.md'), category: 'repo' }], + }, + ], + } as unknown as ProjectResources); + + expect(entrees.map((e) => [e.label, e.source])).toEqual([ + ['.claude/agents/revue.md', 'claude'], + ['CLAUDE.md', 'arbre'], + ['README.md', 'arbre'], + ['workflow/specs/SPEC-014_notes-projet.md', 'inclus'], + ]); + // Le `rel` reste celui que le lecteur attend, préfixe d'affichage exclu. + expect(entrees[0]?.rel).toBe('agents/revue.md'); + }); + + it('rend une liste vide pour un projet sans rien à lire', () => { + const vide = { resources: [], memories: [], repoDocs: [], folders: [] }; + expect(aplatir(vide as unknown as ProjectResources)).toEqual([]); + }); +}); + +describe('arborescence', () => { + const e = (label: string) => ({ rel: label, label, source: 'claude' as const }); + /** Ce que la navigation affiche d'un nœud : son nom, et ce qu'il contient. */ + const vue = (n: Noeud) => n.enfants.map((x) => `${x.nom}${x.fichier ? '' : `/ ${compte(x)}`}`); + + it('reconstruit les dossiers depuis les chemins', () => { + const racine = arborescence([e('a/x.md'), e('a/y.md'), e('b.md')]); + expect(vue(racine)).toEqual(['a/ 2', 'b.md']); + expect(vue(descendre(racine, ['a']) as Noeud)).toEqual(['x.md', 'y.md']); + }); + + it('garde le rang de la liste plate sur les feuilles', () => { + // Le rang voyage dans les boutons, et c'est le même que `/voir ` + // attend : les deux chemins doivent désigner le même fichier. + const racine = arborescence([e('a/x.md'), e('b.md'), e('a/y.md')]); + const a = descendre(racine, ['a']) as Noeud; + expect(a.enfants.map((n) => n.fichier?.rang)).toEqual([1, 3]); + expect(racine.enfants.find((n) => n.nom === 'b.md')?.fichier?.rang).toBe(2); + }); + + it('fond les dossiers qui n’ont qu’un dossier pour enfant', () => { + // `rules/back/application/` compterait sinon trois clics pour n'offrir + // aucun choix. + const racine = arborescence([e('rules/back/application/x.md')]); + expect(vue(racine)).toEqual(['rules/back/application/ 1']); + expect(vue(descendre(racine, ['rules/back/application']) as Noeud)).toEqual(['x.md']); + }); + + it('ne fond pas un dossier dont l’unique enfant est un fichier', () => { + // Il y a bien un choix à montrer : celui d'ouvrir ce fichier. + expect(vue(arborescence([e('a/x.md')]))).toEqual(['a/ 1']); + }); + + it('range les dossiers avant les fichiers, puis par ordre alphabétique', () => { + const racine = arborescence([e('z.md'), e('b/x.md'), e('a.md'), e('a/y.md')]); + expect(vue(racine)).toEqual(['a/ 1', 'b/ 1', 'a.md', 'z.md']); + }); + + it('ne confond pas un dossier et un fichier de même nom', () => { + const racine = arborescence([e('a'), e('a/x.md')]); + expect(vue(racine)).toEqual(['a/ 1', 'a']); + }); + + it('compte les fichiers à toute profondeur', () => { + const racine = arborescence([e('a/b/x.md'), e('a/c/y.md'), e('a/z.md')]); + expect(compte(racine)).toBe(3); + expect(compte(descendre(racine, ['a']) as Noeud)).toBe(3); + }); + + it('ne descend nulle part par un chemin qui n’existe pas', () => { + const racine = arborescence([e('a/x.md')]); + expect(descendre(racine, ['inconnu'])).toBeUndefined(); + // Un fichier n'est pas un dossier : on ne descend pas dedans. + expect(descendre(racine, ['a', 'x.md'])).toBeUndefined(); + }); + + it('rend une racine vide pour une liste vide', () => { + const racine = arborescence([]); + expect(racine.enfants).toEqual([]); + expect(compte(racine)).toBe(0); + }); +}); + +describe('elargi', () => { + const BLANC = String.fromCharCode(0x2800); + + it('complète un en-tête court pour que le clavier prenne la largeur', () => { + // Mesuré : la bulle dimensionne le clavier, et un en-tête court donne des + // boutons de soixante pixels. Retirer ce remplissage rétrécirait les + // boutons sans que rien d'autre ne change à l'écran. + const large = elargi('.claude — 59 fichiers.'); + // Le texte visible reste intact et **sur sa ligne** : c'est tout l'intérêt + // de reléguer le remplissage à la ligne suivante. Collé au texte, il le + // poussait au-delà du bord d'un écran de téléphone et coupait la phrase. + expect(large.split('\n')[0]).toBe('.claude — 59 fichiers.'); + expect(large.endsWith(BLANC)).toBe(true); + }); + + it('met assez de blancs pour que leur ligne atteigne seule la cible', () => { + // Le piège corrigé : sur sa propre ligne, le remplissage ne complète pas + // le texte, il le remplace dans le calcul de la largeur. En mettre juste + // « ce qui manque » rétrécissait la bulle au lieu de l’élargir. + const court = elargi('a').split('\n')[1] ?? ''; + const presqueLong = elargi('x'.repeat(60)).split('\n')[1] ?? ''; + expect(court.length).toBe(presqueLong.length); + expect(court.length).toBeGreaterThanOrEqual(39); + }); + + it('n’ajoute rien à un texte déjà assez long', () => { + const long = 'x'.repeat(70); + expect(elargi(long)).toBe(long); + }); + + it('mesure la ligne la plus longue, celle qui fixe la largeur', () => { + // Une ligne courte après une longue ne rétrécit pas la bulle : c'est la + // plus longue qui décide, et elle seule. + expect(elargi(`${'x'.repeat(70)}\nabc`)).toBe(`${'x'.repeat(70)}\nabc`); + }); + + it('n’insère qu’un saut de ligne et des blancs invisibles', () => { + const ajout = elargi('court').slice('court'.length); + expect(ajout.startsWith('\n')).toBe(true); + expect(ajout.slice(1)).toBe(BLANC.repeat(ajout.length - 1)); + }); +}); + +describe('grille', () => { + const b = (texte: string) => ({ texte, donnee: 'x' }); + + it('remplit une rangée tant que les libellés tiennent', () => { + const k = grille([b('aa'), b('bb'), b('cc')]); + expect(k.inline_keyboard).toHaveLength(1); + }); + + it('ne met jamais plus de trois boutons sur une rangée', () => { + // Pas une limite de l'API mais du doigt : au-delà, la cible est trop + // étroite pour être touchée. + const k = grille([b('a'), b('b'), b('c'), b('d'), b('e')]); + expect(k.inline_keyboard[0]).toHaveLength(3); + }); + + it('isole un libellé trop long pour partager une rangée', () => { + const k = grille([b('court'), b('un libellé vraiment très long pour une rangée')]); + expect(k.inline_keyboard).toHaveLength(2); + }); + + it('donne aux boutons « solo » leur propre rangée pleine largeur', () => { + const k = grille([b('aa')], [b('◀ Retour')]); + expect(k.inline_keyboard).toHaveLength(2); + expect(k.inline_keyboard[1]).toHaveLength(1); + }); + + it('rend un clavier vide quand il n’y a rien à montrer', () => { + expect(grille([]).inline_keyboard).toEqual([]); + }); +});