From c9fb1f8c3c1de1e80828e0b67068033b6427494e Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:44:12 +0200 Subject: [PATCH 01/28] =?UTF-8?q?La=20Passerelle=20relie=20une=20messageri?= =?UTF-8?q?e=20=C3=A0=20l'Atelier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le streaming input du SDK était déjà en place — `agent/queue.ts` est exactement ce mécanisme, et `POST /send` le nourrit. Ce qui manquait n'était pas le tuyau mais la porte. Les channels MCP, qui semblaient la réponse, n'ont pas pu la fournir : `Options` du SDK n'expose aucun champ `channels`, `createSdkMcpServer` n'accepte pas de capability `experimental` — donc le serveur in-process d'`agent/ask.ts` ne peut pas en être un — et un channel ne livre que dans une session déjà ouverte, jamais pour en ouvrir une. D'où cette forme : la Passerelle appelle le registre directement, dans le même process. Son long-polling est sortant, si bien qu'aucun port ne s'ouvre — l'écoute reste `127.0.0.1` et `guard.ts` ne bouge pas d'une ligne. Ce que cela coûte, et qui est assumé : un secret entre dans AURA, elle appelle un service externe, et qui écrit dans une conversation autorisée obtient un accès distant au poste. La liste blanche est la garde, pas une commodité — sans elle, la Passerelle refuse de démarrer. Une session pilotée d'ici reste abonnée au runner : c'est l'abonnement, et lui seul, qui la protège du balayeur des trente minutes. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 7 + server/.env.example | 26 ++ server/CLAUDE.md | 32 +++ server/env.ts | 13 +- server/i18n/en.ts | 26 ++ server/i18n/fr.ts | 34 +++ server/index.ts | 11 + server/passerelle/index.ts | 434 ++++++++++++++++++++++++++++++++++ server/passerelle/routage.ts | 104 ++++++++ server/passerelle/telegram.ts | 190 +++++++++++++++ test/passerelle.test.ts | 102 ++++++++ 11 files changed, 976 insertions(+), 3 deletions(-) create mode 100644 server/passerelle/index.ts create mode 100644 server/passerelle/routage.ts create mode 100644 server/passerelle/telegram.ts create mode 100644 test/passerelle.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 42e9d85..c2c7f41 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,13 @@ 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. + - `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/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..b7e2852 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,35 @@ 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. + +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`). + ## 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/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..1b7de40 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -77,6 +77,32 @@ const en: Catalog = { noPowerShell: 'No PowerShell host found.', }, + /** What AURA says in a messaging app. See the French catalogue for the why. */ + passerelle: { + aide: [ + 'I drive the Workshop from this conversation.', + '', + '/atelier — I open a session on that folder.', + '/sessions — what is running right now.', + '/stop — I interrupt the current turn.', + '/fin — I close this conversation’s session.', + '', + 'Any other message goes to the session as a turn.', + ].join('\n'), + sessionOuverte: 'Session open on {cwd}. Tell me what needs doing.', + aucunFil: 'No session is open here. Open one with /atelier .', + aucuneSession: 'Nothing is running right now.', + sessionFinie: 'The session ended.', + sessionEchouee: 'The session stopped: {message}', + permission: 'I would like to use {outil}.', + autoriser: 'Allow', + refuser: 'Deny', + refuseDeLoin: 'Denied from the messaging app.', + questionTropRiche: + 'This question needs a form I cannot render here. It is waiting for you in the Workshop.', + commandeInconnue: 'I do not know {commande}. /aide lists what I can do.', + }, + 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..a0540da 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -86,6 +86,40 @@ 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: { + aide: [ + 'Je pilote l’Atelier depuis cette conversation.', + '', + '/atelier — j’ouvre une session sur ce dossier.', + '/sessions — ce qui tourne en ce moment.', + '/stop — j’interromps le tour en cours.', + '/fin — je ferme la session de cette conversation.', + '', + 'Tout autre message part à la session comme un tour.', + ].join('\n'), + sessionOuverte: 'Session ouverte sur {cwd}. Écrivez-moi ce qu’il y a à faire.', + /** 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.', + sessionFinie: 'La session s’est terminée.', + sessionEchouee: 'La session s’est arrêtée : {message}', + permission: 'Je voudrais utiliser {outil}.', + autoriser: 'Autoriser', + refuser: 'Refuser', + /** Le motif transmis au modèle, et non à l'utilisateur : il reste bref. */ + refuseDeLoin: 'Refusé depuis la messagerie.', + questionTropRiche: + 'Cette question demande un formulaire que je ne sais pas poser ici. Elle vous attend dans l’Atelier.', + commandeInconnue: 'Je ne connais pas {commande}. /aide donne ce que je sais faire.', + }, + 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/index.ts b/server/passerelle/index.ts new file mode 100644 index 0000000..c0a38bb --- /dev/null +++ b/server/passerelle/index.ts @@ -0,0 +1,434 @@ +// 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 { t } from '../i18n/index.ts'; +import { publicMessage } from '../errors.ts'; +import type { AgentUpsert, AskQuestion, 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 { autorise, lireChats, parseIntention } from './routage.ts'; +import { boutons, Telegram } from './telegram.ts'; + +/** 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 qu'un message peut peser chez Telegram. + * + * Une réponse d'agent dépasse volontiers cette taille. On tronque plutôt que de + * laisser l'envoi échouer en silence : un texte coupé se lit, un texte perdu ne + * se voit pas. + */ +const MAX_TEXTE = 4_000; + +/** 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; + /** Les textes de l'assistant du tour en cours, par uuid. */ + tour: Map; + /** Les questions en vol, pour retrouver l'option qu'un bouton désigne. */ + asks: Map; +} + +let telegram: Telegram | null = null; +const fils = new Map(); + +function tronque(texte: string): string { + return texte.length > MAX_TEXTE ? `${texte.slice(0, MAX_TEXTE)}…` : texte; +} + +/** Un libellé de bouton : Telegram les veut courts, et les tronque mal. */ +function tronqueBouton(texte: string): string { + return texte.length > 32 ? `${texte.slice(0, 31)}…` : texte; +} + +/** 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 fil: Fil = { + runId: runner.session.runId, + detache: () => {}, + tour: new Map(), + asks: new Map(), + }; + fil.detache = runner.subscribe((upsert) => { + void applique(chatId, fil, upsert); + }); + 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(); + 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 l'activité, ni les + * entrées d'outils — une messagerie n'est pas une timeline, et un flux de tokens + * y serait illisible. + */ +async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise { + const tg = telegram; + if (!tg) return; + + switch (upsert.kind) { + // `snapshot` rejoue tout l'historique à l'abonnement : le renvoyer + // inonderait la conversation d'un travail déjà lu. + case 'snapshot': + return; + + case 'append-event': + case 'replace-event': { + const event = upsert.event; + 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; + } + + case 'status': { + if (upsert.status === 'working') { + fil.tour.clear(); + return; + } + // Fin de tour : le moment où il y a enfin quelque chose à dire. + const dit = [...fil.tour.values()].join('\n\n').trim(); + fil.tour.clear(); + if (dit) await tg.envoie(chatId, tronque(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); + } + return; + } + + case 'permission-request': { + const demande = upsert.request; + const quoi = demande.title || demande.displayName || demande.toolName; + await tg.envoie( + 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` }, + ]), + ); + return; + } + + case 'ask-request': { + const demande = upsert.request; + const premiere = demande.questions[0]; + // Un formulaire à plusieurs questions ne se rend pas en boutons sans + // inventer un dialogue à étapes. On le dit plutôt que d'y répondre à + // moitié : l'écran de l'Atelier, lui, sait le poser en entier. + if (demande.questions.length !== 1 || !premiere) { + await tg.envoie(chatId, t('passerelle.questionTropRiche')); + return; + } + fil.asks.set(demande.id, demande.questions); + await tg.envoie( + chatId, + tronque(`${premiere.header}\n\n${premiere.question}`), + boutons( + premiere.options + .slice(0, 4) + .map((o, i) => ({ texte: tronqueBouton(o.label), donnee: `q:${demande.id}:${i}` })), + ), + ); + return; + } + + case 'ask-settled': + fil.asks.delete(upsert.id); + 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, t('passerelle.aide')); + return; + + case 'sessions': { + const sessions = listSessions(); + if (!sessions.length) { + await tg.envoie(chatId, t('passerelle.aucuneSession')); + return; + } + await tg.envoie(chatId, tronque(sessions.map((s) => `• ${s.cwd} — ${s.status}`).join('\n'))); + return; + } + + case 'ouvrir': { + // 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: intention.cwd, permissionMode: mode() }); + attache(chatId, runner); + await tg.envoie(chatId, t('passerelle.sessionOuverte', { cwd: runner.session.cwd })); + } catch (e) { + await tg.envoie(chatId, publicMessage(e)); + } + 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; + } + runner.send(intention.texte); + return; + } + } +} + +/** + * Un bouton pressé : une permission tranchée, ou une question répondue. + * + * Rien à attendre ici — les deux réponses dénouent une promesse tenue côté + * runner et rendent la main aussitôt. C'est le tour suspendu qui repart, pas + * cet appel. + */ +function tranche(chatId: number, donnee: string): void { + const runner = courant(chatId); + const fil = fils.get(chatId); + if (!runner || !fil) return; + + const [type, id, suffixe] = donnee.split(':'); + if (!id || !suffixe) 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 question = fil.asks.get(id)?.[0]; + const option = question?.options[Number(suffixe)]; + if (!question || !option) return; + fil.asks.delete(id); + runner.answerAsk(id, { [question.question]: option.label }); + } +} + +/** + * 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); +} + +/** 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).`); + + // Les échecs consécutifs, et rien d'autre, décident du délai d'attente : une + // messagerie injoignable ne doit pas faire tourner une boucle serrée sur le + // réseau pendant des heures. + let echecs = 0; + while (!tg.arrete) { + const mises = await tg.mises(); + if (tg.arrete) break; + + if (mises === null) { + echecs += 1; + await pause(tg.attente(echecs)); + continue; + } + echecs = 0; + + for (const message of mises.messages) { + // Le silence est la réponse à un inconnu : répondre confirmerait que ce + // bot existe et à quoi il sert. + if (!autorise(chats, message.chatId)) continue; + try { + await traite(message.chatId, message.texte); + } catch (e) { + journal.warn(`Passerelle : ${publicMessage(e)}`); + } + } + + for (const bouton of mises.boutons) { + if (!autorise(chats, bouton.chatId)) continue; + // Accuser d'abord : sans cela le bouton tourne pendant tout le traitement. + await tg.accuse(bouton.callbackId); + try { + tranche(bouton.chatId, bouton.donnee); + } catch (e) { + journal.warn(`Passerelle : ${publicMessage(e)}`); + } + } + } +} + +/** Une attente qui ne retient pas le process à elle seule. */ +function pause(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms).unref?.(); + }); +} + +/** + * 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); + telegram?.stop(); + telegram = null; +} diff --git a/server/passerelle/routage.ts b/server/passerelle/routage.ts new file mode 100644 index 0000000..815038a --- /dev/null +++ b/server/passerelle/routage.ts @@ -0,0 +1,104 @@ +// 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 dossier. */ + | { kind: 'ouvrir'; cwd: string } + /** Un tour de plus dans la session de cette conversation. */ + | { kind: 'parler'; texte: string } + /** Fermer la session de cette conversation. */ + | { kind: 'fin' } + /** Ce qui tourne en ce moment, toutes conversations confondues. */ + | { kind: 'sessions' } + /** Interrompre le tour en cours sans fermer la session. */ + | { kind: 'stop' } + | { kind: 'aide' } + /** + * 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); +} + +/** Le nom du dossier de travail d'une commande `/atelier`, s'il y en a un. */ +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 cwd = argument(texte); + // Sans dossier, il n'y a pas de session à ouvrir : c'est l'aide qui + // répond, elle porte la forme attendue. + return cwd ? { kind: 'ouvrir', cwd } : { kind: 'aide' }; + } + case '/fin': + return { kind: 'fin' }; + case '/sessions': + return { kind: 'sessions' }; + case '/stop': + return { kind: 'stop' }; + case '/aide': + case '/start': + 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..627b465 --- /dev/null +++ b/server/passerelle/telegram.ts @@ -0,0 +1,190 @@ +// Le seul fichier qui sache que la messagerie est Telegram. +// +// Rien ici ne décide : on lit des mises à jour, on envoie du texte, on accuse +// réception d'un bouton. Le jour où un second fournisseur se présente, c'est ce +// fichier qu'on double — et lui seul. +// +// Le long-polling est **sortant**, et c'est la raison d'être de tout le +// 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 que ceci fonctionne. + +import { num, str } from '../json.ts'; + +const API = 'https://api.telegram.org'; + +/** + * Combien de temps Telegram garde la requête ouverte quand rien n'arrive. + * + * Vingt-cinq secondes : sous la minute au-delà de laquelle les intermédiaires + * coupent, et assez long pour qu'une journée sans message ne coûte que quelques + * milliers de requêtes vides. + */ +const POLL_SECONDS = 25; + +/** Le délai après un échec réseau, et le plafond qu'il ne dépasse pas. */ +const RETRY_MS = 2_000; +const RETRY_MAX_MS = 60_000; + +/** 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; +} + +export interface Mises { + messages: MessageEntrant[]; + boutons: BoutonPresse[]; +} + +/** Un couple de boutons sous un message, tel que Telegram l'attend. */ +export function boutons(paires: { texte: string; donnee: string }[]): unknown { + return { + inline_keyboard: [paires.map((p) => ({ text: p.texte, callback_data: p.donnee }))], + }; +} + +export class Telegram { + private offset = 0; + private readonly aborter = new AbortController(); + private stopped = false; + + constructor(private readonly token: string) {} + + private url(methode: string): string { + return `${API}/bot${this.token}/${methode}`; + } + + /** + * Un appel à l'API. Rend `null` sur échec plutôt que de lever. + * + * Une messagerie injoignable n'est pas une panne d'AURA : le BFF continue de + * servir l'interface et les sessions de tourner. L'appelant retentera. + */ + private async appel(methode: string, corps: unknown, timeoutMs: number): Promise { + // Une horloge propre à l'appel, en plus de l'arrêt global : sans elle, un + // `getUpdates` dont la socket reste ouverte sans jamais répondre tiendrait + // la boucle indéfiniment. + const horloge = AbortSignal.timeout(timeoutMs); + try { + const res = await fetch(this.url(methode), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(corps), + signal: AbortSignal.any([this.aborter.signal, horloge]), + }); + if (!res.ok) return null; + const json: unknown = await res.json(); + const rec = (json ?? {}) as Record; + return rec.ok === true ? rec.result : null; + } catch { + // Abandon volontaire compris : `stopped` dira à la boucle quoi en faire. + return null; + } + } + + /** Le nom du bot, ou `null` si le jeton ne vaut rien. Sert à démarrer. */ + async identite(): Promise { + const res = await this.appel('getMe', {}, 10_000); + const nom = str((res as Record | null)?.username); + return nom || null; + } + + /** + * Les mises à jour depuis la dernière lue. `null` si l'appel a échoué. + * + * La distinction compte : une attente vide est le régime normal du + * long-polling et ne doit rien ralentir, là où un échec doit faire monter le + * délai. Les confondre ferait tourner une boucle serrée sur un réseau coupé. + * + * `offset` vaut acquittement : demander la suite dit à Telegram d'oublier ce + * qui précède. On l'avance donc dès la lecture, avant tout traitement — sinon + * un message qui fait échouer le traitement reviendrait à chaque tour de + * boucle, indéfiniment. + */ + async mises(): Promise { + const res = await this.appel( + 'getUpdates', + { + offset: this.offset, + timeout: POLL_SECONDS, + allowed_updates: ['message', 'callback_query'], + }, + // Au-delà du temps que Telegram tient lui-même la requête : sans marge, + // on couperait chaque attente vide au moment où elle allait aboutir. + (POLL_SECONDS + 10) * 1_000, + ); + + if (!Array.isArray(res)) return null; + const out: Mises = { messages: [], boutons: [] }; + + for (const brut of res) { + const maj = (brut ?? {}) as Record; + const id = num(maj.update_id); + if (id >= this.offset) this.offset = id + 1; + + const message = (maj.message ?? {}) as Record; + const chat = (message.chat ?? {}) as Record; + const chatId = num(chat.id); + const texte = str(message.text); + if (chatId && texte) out.messages.push({ chatId, texte }); + + const rappel = (maj.callback_query ?? {}) as Record; + const rappelMessage = (rappel.message ?? {}) as Record; + const rappelChat = (rappelMessage.chat ?? {}) as Record; + const rappelChatId = num(rappelChat.id); + const callbackId = str(rappel.id); + const donnee = str(rappel.data); + if (rappelChatId && callbackId && donnee) { + out.boutons.push({ chatId: rappelChatId, callbackId, donnee }); + } + } + return out; + } + + /** Envoie un texte. `clavier` ajoute des boutons sous le message. */ + async envoie(chatId: number, texte: string, clavier?: unknown): Promise { + await this.appel( + 'sendMessage', + { + chat_id: chatId, + text: texte, + ...(clavier ? { reply_markup: clavier } : {}), + }, + 15_000, + ); + } + + /** Accuse réception d'un bouton : sans cela, il tourne côté client. */ + async accuse(callbackId: string, texte?: string): Promise { + await this.appel( + 'answerCallbackQuery', + { callback_query_id: callbackId, ...(texte ? { text: texte } : {}) }, + 10_000, + ); + } + + get arrete(): boolean { + return this.stopped; + } + + /** Le délai à observer après un tour de boucle infructueux. */ + attente(echecs: number): number { + return Math.min(RETRY_MS * 2 ** Math.max(0, echecs - 1), RETRY_MAX_MS); + } + + /** Coupe le long-polling en vol : la requête en attente est abandonnée. */ + stop(): void { + this.stopped = true; + this.aborter.abort(); + } +} diff --git a/test/passerelle.test.ts b/test/passerelle.test.ts new file mode 100644 index 0000000..bda08be --- /dev/null +++ b/test/passerelle.test.ts @@ -0,0 +1,102 @@ +// 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`, précisément pour être vérifiable sans bot, sans jeton +// et sans session. C'est le fichier qui sépare une machine pilotable d'une +// machine ouverte à tous, et il ne doit pas dépendre d'un service tiers pour +// être testé. + +import { describe, expect, it } from 'vitest'; +import { autorise, lireChats, parseIntention } from '../server/passerelle/routage.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 le dossier donné', () => { + expect(parseIntention('/atelier C:\\devl\\tos')).toEqual({ + kind: 'ouvrir', + cwd: 'C:\\devl\\tos', + }); + }); + + it('renvoie à l’aide plutôt que d’ouvrir sans dossier', () => { + expect(parseIntention('/atelier')).toEqual({ kind: 'aide' }); + expect(parseIntention('/atelier ')).toEqual({ kind: 'aide' }); + }); + + 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 trois portes d’entrée de l’aide', () => { + for (const mot of ['/aide', '/start', '/help']) { + expect(parseIntention(mot)).toEqual({ kind: 'aide' }); + } + }); + + 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', + }); + }); +}); From 9bb16bd39ed50ebf058fd96b01230b10bc9e9a0f Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:40:42 +0200 Subject: [PATCH 02/28] Le manuel explique la Passerelle, et les documents cessent de la taire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La fonctionnalité était livrée sans sa page. Le manuel est une surface à part entière — il alimente le tiroir contextuel, la page /aide et le site vitrine —, et une capacité qui ouvre un accès distant est précisément celle qu'on ne peut pas laisser sans mode d'emploi. Une page transversale (`routes: []`) plutôt qu'une section de l'Atelier : la Passerelle n'a pas d'écran, et ce qu'elle demande de comprendre — ce qu'elle coûte, ce qu'elle n'ouvre pas — ne se lit pas en marge d'autre chose. La barre latérale du site et l'ordre du manuel s'en déduisent seuls, il n'y avait rien à inscrire ailleurs. Trois documents affirmaient par ailleurs quelque chose qui vient de devenir faux, et un document de sécurité qui ment est pire que muet : - README — « aucun appel sortant », « aucun secret à fournir », et deux variables de configuration là où il y en a désormais cinq ; - SECURITY — « ni compte, ni mot de passe, ni service externe », qui décrivait la seule frontière du modèle. Chacun dit maintenant ce que la Passerelle change, ce qu'elle ne change pas, et que tout reste éteint tant qu'aucun jeton n'est posé. Co-Authored-By: Claude Opus 5 (1M context) --- README.en.md | 26 +++++++-- README.md | 25 +++++++- SECURITY.en.md | 34 ++++++++++- SECURITY.md | 37 +++++++++++- src/help/sections/en/passerelle.md | 92 ++++++++++++++++++++++++++++++ src/help/sections/fr/passerelle.md | 92 ++++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 src/help/sections/en/passerelle.md create mode 100644 src/help/sections/fr/passerelle.md diff --git a/README.en.md b/README.en.md index 1f11e89..c0fee53 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,14 @@ 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. + [SECURITY.en.md](SECURITY.en.md) details the server's guards, what they do not cover, and how to report a flaw. @@ -349,14 +358,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..115fcf8 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,14 @@ 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. + [SECURITY.md](SECURITY.md) détaille les gardes du serveur, ce qu'elles ne couvrent pas, et comment signaler une faille. @@ -398,7 +407,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 +415,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..9af1497 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,37 @@ 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. + +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..ca7815f 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,40 @@ 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. + +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/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md new file mode 100644 index 0000000..d4ea1be --- /dev/null +++ b/src/help/sections/en/passerelle.md @@ -0,0 +1,92 @@ +--- +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. + +## 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 + +One conversation holds **one** session at a time. + +| Message | What I do | +| ------------------- | ------------------------------------------- | +| `/atelier ` | I open a session on that folder | +| `/sessions` | I list what is running, whatever started it | +| `/stop` | I interrupt the current turn | +| `/fin` | I close this conversation's session | +| `/aide` | I repeat the above | + +**Any other message goes to the session as a turn.** That is by far the most frequent case, and it needs no syntax. + +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. + +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. + +A multiple-choice question reaches you the same way, as buttons. If it holds several questions, I tell you rather than answering it halfway: that form needs the screen, and it is waiting for you in the Workshop. + +## What I do not send you + +**The agent's answer at the end of a turn, and the requests awaiting a decision.** Nothing else. + +Not the tokens as they are written, not the activity line, not the detail of the tools used. A messaging app is not a timeline: pouring a token 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. + +A very long answer is cut rather than lost — truncated text can be read, a failed send cannot be seen. + +## 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, a question's mockups, 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..526adc6 --- /dev/null +++ b/src/help/sections/fr/passerelle.md @@ -0,0 +1,92 @@ +--- +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. + +## 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 + +Une conversation tient **une** session à la fois. + +| Message | Ce que je fais | +| -------------------- | -------------------------------------------------- | +| `/atelier ` | J'ouvre une session sur ce dossier | +| `/sessions` | Je liste ce qui tourne, toutes origines confondues | +| `/stop` | J'interromps le tour en cours | +| `/fin` | Je ferme la session de cette conversation | +| `/aide` | Je rappelle ce qui précède | + +**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. + +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. + +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. + +Une question à choix vous parvient de la même façon, en boutons. Si elle en compte plusieurs, je vous le dis sans y répondre à moitié : ce formulaire-là demande l'écran, et il vous attend dans l'Atelier. + +## 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. + +Ni les tokens au fil de leur écriture, ni la ligne d'activité, ni le détail des outils employés. Une messagerie n'est pas une timeline : y déverser un flux de tokens le rendrait illisible et noierait ce qui demande une réponse. Le fil complet est dans l'Atelier, et le **Rejeu** le garde. + +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. + +## 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, les maquettes d'une question, 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**. From 99e424d2584655bbe1579ffc4507ee03e37d8f10 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:20:49 +0200 Subject: [PATCH 03/28] La Passerelle s'appuie sur node-telegram-bot-api MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elle parlait à Telegram par `fetch` : deux cents lignes de long-polling, d'acquittement et de reprises, qui marchaient. Ce n'est pas pour elles qu'on prend la dépendance, mais pour ses **types**. L'API Bot accepte les champs qu'elle ne connaît pas et les ignore en silence. Un `header` écrit pour `is_header` ne produit donc aucune erreur — seulement un tableau sans en-tête, sans rien pour dire pourquoi. C'est exactement l'erreur qui a été commise, et que rien à l'exécution n'aurait signalée. Le compilateur, lui, la refuse. Version 2.0.0, réécriture complète, **zéro dépendance transitive**. Ses types sont pourtant faux sur deux points de l'API riche, et il a fallu les corriger localement (voir `passerelle/riche.ts`) : son `RichText` n'admet ni chaîne nue ni tableau — ce qui rend inexprimable un paragraphe de texte simple —, et `align`/`valign` y sont requis alors qu'une cellule sans eux est acceptée. Le reste est juste, et c'est le reste qui comptait. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 7 ++++--- pnpm-lock.yaml | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 2706450..5775aa7 100644 --- a/package.json +++ b/package.json @@ -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: From b667aebbab168f46a4468a59f79c173ac5649cf2 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:21:04 +0200 Subject: [PATCH 04/28] Un document part en message riche, tableaux compris MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram n'affiche pas le Markdown, et son HTML n'a ni titre, ni liste, **ni tableau** — `` y est refusé net. Un tableau rendu en chasse fixe 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. `sendRichMessage` est une autre API, arrivée en Bot API 10.1 : des blocs structurés en JSON plutôt qu'un balisage. De vrais tableaux, avec bordures et ligne d'en-tête, de vrais titres, de vraies listes — et une borne de 32 768 caractères au lieu de 4 096, soit huit fois moins de pages à tourner. `riche.ts` traduit vers ces blocs ; `markdown.ts` reste le repli, pour le jour où l'API riche refuserait un document. Les deux sont mesurés contre l'API réelle, et les pièges relevés au passage y sont écrits — un tableau porte `cells` à la racine et non `rows`, un titre exige un `size` numérique, une liste porte `items[].blocks`. Une case à cocher, elle, est écrite dans le texte (`☑︎`, `☐︎`). Le format prévoit `has_checkbox`, mais aucun client ne le dessine, et `sendChecklist` — la checklist native — exige un compte Business (`PREMIUM_ACCOUNT_REQUIRED`). Sans ce préfixe, une liste de tâches perdrait l'état de chaque ligne sans laisser de trace. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/markdown.ts | 295 +++++++++++++++++++++++++++++++ server/passerelle/riche.ts | 292 ++++++++++++++++++++++++++++++ test/passerelle-markdown.test.ts | 149 ++++++++++++++++ test/passerelle-riche.test.ts | 131 ++++++++++++++ 4 files changed, 867 insertions(+) create mode 100644 server/passerelle/markdown.ts create mode 100644 server/passerelle/riche.ts create mode 100644 test/passerelle-markdown.test.ts create mode 100644 test/passerelle-riche.test.ts diff --git a/server/passerelle/markdown.ts b/server/passerelle/markdown.ts new file mode 100644 index 0000000..35d358a --- /dev/null +++ b/server/passerelle/markdown.ts @@ -0,0 +1,295 @@ +// Rendre un document Markdown lisible dans une conversation. +// +// Telegram n'affiche pas le Markdown. Il affiche un **sous-ensemble de HTML** — +// `b`, `i`, `s`, `u`, `code`, `pre`, `a`, `blockquote`, et rien d'autre. Ni +// titres, ni listes, ni tableaux : un `

` ou un `
    ` fait échouer l'envoi +// en 400, et le `.md` brut affiché tel quel noie le texte sous sa ponctuation. +// +// D'où cette traduction, qui ne cherche pas la fidélité mais la **lisibilité au +// pouce** : un titre devient du gras, une puce devient une puce, un tableau +// reste en chasse fixe parce que c'est son alignement qui le rend lisible, et +// tout le reste tombe en texte simple. +// +// Aucun réseau ici, et c'est voulu : tout ce fichier se vérifie par un test. + +/** Ce que Telegram accepte dans un message, balises comprises. */ +export const MAX_MESSAGE = 4096; + +/** + * Ce qu'une page prend au document source. + * + * Nettement sous la limite : la traduction *ajoute* des balises, et un document + * dense en `code` peut gagner un tiers de sa taille. La marge évite d'avoir à + * re-découper après coup, ce qui couperait au mauvais endroit. + */ +export const PAGE_SOURCE = 2_600; + +/** Les trois caractères qui feraient lire une balise là où il n'y en a pas. */ +export function echappe(texte: string): string { + return texte.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * Les transformations en ligne, sur du texte **déjà échappé**. + * + * L'ordre n'est pas indifférent : le code littéral part en premier et revient en + * dernier, sous forme de jetons, pour qu'un `**` à l'intérieur d'un `code` reste + * du code et ne devienne pas du gras. + */ +function enLigne(texte: string): string { + const litteraux: string[] = []; + // `\uE000` est le premier point de la zone à usage privé : aucun document + // ne le porte, et il est retiré de l’entrée au nettoyage — un jeton ne peut donc + // jamais entrer en collision avec le texte. Un caractère de contrôle ferait le + // même office, mais `no-control-regex` le refuse à raison : en regex, il ne se + // relit pas. + const jeton = (i: number): string => `\uE000${i}\uE000`; + + let out = texte.replace(/`([^`]+)`/g, (_all, code: string) => { + litteraux.push(`${code}`); + return jeton(litteraux.length - 1); + }); + + // Les liens avant l'emphase : un libellé en gras dedans doit rester dans le + // libellé, pas couper la balise en deux. + out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_all, libelle: string, url: string) => { + // Une URL n'est reprise que si elle mène quelque part de connu : un + // `javascript:` dans un href est refusé par Telegram, et n'a rien à faire + // dans un document qu'on relaie. + if (!/^(https?:\/\/|mailto:)/i.test(url)) return libelle; + return `${libelle}`; + }); + + out = out + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s.,;:!?)]|$)/g, '$1$2') + .replace(/(^|[\s(])_([^_\n]+)_(?=[\s.,;:!?)]|$)/g, '$1$2') + .replace(/~~([^~]+)~~/g, '$1'); + + return out.replace(/\uE000(\d+)\uE000/g, (_all, i: string) => litteraux[Number(i)] ?? ''); +} + +/** Une ligne de tableau Markdown — celles qui ne servent qu'à l'alignement. */ +const SEPARATEUR_TABLEAU = /^\s*\|?[\s:|-]+\|[\s:|-]*$/; + +/** + * Au-delà de quelle largeur un tableau cesse d'être lisible en chasse fixe. + * + * Mesuré contre l'API, pas déduit : **Telegram n'a aucune balise de tableau** + * pour les bots — `

` est refusé net (« Unsupported start tag »), tout + * comme `
    ` et `

    `. Un tableau ne peut donc être qu'un `
    `, et un
    + * `
    ` trop large ne défile pas sur un téléphone : il **passe à la ligne**,
    + * ce qui détruit exactement l'alignement qui le rendait lisible.
    + *
    + * Quarante colonnes est ce qu'un écran de téléphone tient sans replier. Au-delà,
    + * mieux vaut renoncer à la forme tabulaire que la voir se disloquer.
    + */
    +const LARGEUR_TABLEAU = 40;
    +
    +/** 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());
    +}
    +
    +/**
    + * Un tableau, rendu de la façon qui reste lisible.
    + *
    + * Deux formes, et le choix se fait sur la largeur :
    + *
    + *  - **étroit** — un `
    ` aux colonnes recalculées au plus juste. Le padding
    + *    du document est refait plutôt que repris : un tableau écrit large dans le
    + *    fichier tient souvent une fois ses colonnes serrées.
    + *  - **large** — une fiche par ligne : la première cellule en titre, puis
    + *    `en-tête : valeur`. On perd la comparaison colonne à colonne, on garde ce
    + *    que chaque ligne dit — et c'est tout ce qui survivait au repli automatique.
    + */
    +function rendTableau(lignes: string[]): string[] {
    +  const grille = lignes.filter((l) => !SEPARATEUR_TABLEAU.test(l)).map(cellules);
    +  if (!grille.length) return [];
    +
    +  const colonnes = Math.max(...grille.map((r) => r.length));
    +  const largeurs = Array.from({ length: colonnes }, (_, c) =>
    +    Math.max(...grille.map((r) => (r[c] ?? '').length)),
    +  );
    +  // `| ` + ` | ` entre colonnes + ` |` : la largeur qu'aurait la forme serrée.
    +  const largeur = largeurs.reduce((a, b) => a + b, 0) + 3 * colonnes + 1;
    +
    +  if (largeur <= LARGEUR_TABLEAU) {
    +    const lignesRendues = grille.map(
    +      (r) => `| ${largeurs.map((w, c) => (r[c] ?? '').padEnd(w)).join(' | ')} |`,
    +    );
    +    return ['
    ', ...lignesRendues.map(echappe), '
    ']; + } + + const [entetes, ...corps] = grille; + if (!entetes || !corps.length) { + return ['
    ', ...grille.map((r) => echappe(r.join(' | '))), '
    ']; + } + + const out: string[] = []; + for (const rangee of corps) { + const titre = rangee[0] ?? ''; + out.push('', `${enLigne(echappe(titre))}`); + for (let c = 1; c < colonnes; c++) { + const valeur = rangee[c] ?? ''; + if (!valeur) continue; + const entete = entetes[c] ?? ''; + out.push(` ${enLigne(echappe(entete))} : ${enLigne(echappe(valeur))}`); + } + } + return out; +} + +/** + * Le document, traduit pour Telegram. + * + * Travaille ligne à ligne, avec deux états qui ne se devinent pas d'une ligne + * seule : on est dans un bloc de code, ou dans un tableau. Les deux se rendent + * en chasse fixe et échappent à toute transformation — le premier parce que + * c'est du code, le second parce que seul l'alignement le rend lisible. + */ +export function enHtml(markdown: string): string { + const lignes = markdown + .replace(/\uE000/g, '') + .replace(/\r\n?/g, '\n') + .split('\n'); + const out: string[] = []; + let dansCode = false; + let tableau: string[] = []; + + const fermeTableau = (): void => { + if (!tableau.length) return; + out.push(...rendTableau(tableau)); + tableau = []; + }; + + for (const ligne of lignes) { + const cloture = /^\s*```/.test(ligne); + + if (dansCode) { + if (cloture) { + out.push('
    '); + dansCode = false; + } else { + out.push(echappe(ligne)); + } + continue; + } + + if (cloture) { + fermeTableau(); + const langue = ligne.replace(/^\s*```/, '').trim(); + out.push(langue ? `
    ` : '
    ');
    +      dansCode = true;
    +      continue;
    +    }
    +
    +    // Un tableau est mis de côté jusqu'à sa dernière ligne : sa forme ne se
    +    // décide qu'une fois sa largeur connue — voir `rendTableau`.
    +    if (/^\s*\|.*\|\s*$/.test(ligne)) {
    +      tableau.push(ligne);
    +      continue;
    +    }
    +    fermeTableau();
    +
    +    const titre = /^(#{1,6})\s+(.*)$/.exec(ligne);
    +    if (titre) {
    +      // Pas de niveaux : Telegram n'a qu'un gras. Une ligne vide avant fait le
    +      // découpage visuel que la taille de police ferait ailleurs.
    +      out.push('', `${enLigne(echappe(titre[2] ?? ''))}`);
    +      continue;
    +    }
    +
    +    const puce = /^(\s*)[-*+]\s+(.*)$/.exec(ligne);
    +    if (puce) {
    +      // L'indentation devient un retrait visible : deux espaces par niveau,
    +      // sinon une sous-liste se confond avec sa mère.
    +      const retrait = ' '.repeat(Math.floor((puce[1] ?? '').length / 2) * 2);
    +      out.push(`${retrait}• ${enLigne(echappe(puce[2] ?? ''))}`);
    +      continue;
    +    }
    +
    +    const citation = /^\s*>\s?(.*)$/.exec(ligne);
    +    if (citation) {
    +      out.push(`
    ${enLigne(echappe(citation[1] ?? ''))}
    `); + continue; + } + + if (/^\s*([-*_])\1{2,}\s*$/.test(ligne)) { + out.push('──────────'); + continue; + } + + out.push(enLigne(echappe(ligne))); + } + + // Un document qui se termine dans un bloc ouvert — page coupée, fichier + // tronqué — laisserait une balise en l'air, et Telegram refuserait tout. + if (dansCode) out.push('
    '); + fermeTableau(); + + return out + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * 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. + */ +export function paginer(markdown: string, max = PAGE_SOURCE): 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]) { + if (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/riche.ts b/server/passerelle/riche.ts new file mode 100644 index 0000000..1613dcf --- /dev/null +++ b/server/passerelle/riche.ts @@ -0,0 +1,292 @@ +// 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. C'est ce qui obligeait à rendre +// un tableau 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 tout l'intérêt de la dépendance : elle **défend les +// noms de champs**, là où l'API ne les défend pas. Un `header` écrit pour +// `is_header` ne produit aucune erreur à l'exécution — l'API ignore ce qu'elle +// ne connaît pas — et donnait un tableau sans en-tête, sans rien pour le dire. +// +// **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[] } + | { 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[] = []; + const motif = + /`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*|~~([^~]+)~~|(? reste) out.push(ligne.slice(reste, m.index)); + const [, code, libelle, url, gras, barre, penche, souligne] = m; + + if (code !== undefined) out.push({ 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. + if (/^(https?:\/\/|mailto:)/i.test(url)) out.push({ type: 'url', text: libelle, url }); + else out.push(libelle); + } else if (gras !== undefined) out.push({ type: 'bold', text: gras }); + else if (barre !== undefined) out.push({ type: 'strikethrough', text: barre }); + else if (penche !== undefined) out.push({ type: 'italic', text: penche }); + else if (souligne !== undefined) out.push({ type: 'italic', text: 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()); +} + +/** + * 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: 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 avaitSeparateur = tableau.some((l) => SEPARATEUR.test(l)); + 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) => ({ + text: fragments(c), + ...(i === 0 && avaitSeparateur ? { is_header: true as const } : {}), + })), + ), + }); + }; + + const fermeListe = (): void => { + if (!liste.length) return; + blocs.push({ + type: 'list', + items: liste.map((texte) => ({ + blocks: [{ type: 'paragraph', text: fragments(texte) }], + })), + }); + liste = []; + }; + + const fermeTout = (): void => { + fermeParagraphe(); + fermeTableau(); + fermeListe(); + }; + + 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; + } + + if (/^\s*\|.*\|\s*$/.test(ligne)) { + fermeParagraphe(); + fermeListe(); + tableau.push(ligne); + continue; + } + fermeTableau(); + + const puce = /^\s*[-*+]\s+(.*)$/.exec(ligne); + if (puce) { + fermeParagraphe(); + const contenu = puce[1] ?? ''; + // `- [ ]` 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(prefixe + (case_[2] ?? '')); + } else liste.push(contenu); + continue; + } + + const numerotee = /^\s*(\d+)[.)]\s+(.*)$/.exec(ligne); + if (numerotee) { + fermeParagraphe(); + // Le numéro reste dans le texte : le client dessine ses propres puces et + // ignore le `label` qui aurait dû le porter. + liste.push(`${numerotee[1] ?? ''}. ${numerotee[2] ?? ''}`); + 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; + } + + const citation = /^\s*>\s?(.*)$/.exec(ligne); + if (citation) { + fermeParagraphe(); + blocs.push({ + type: 'blockquote', + blocks: [{ type: 'paragraph', text: fragments(citation[1] ?? '') }], + }); + 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/test/passerelle-markdown.test.ts b/test/passerelle-markdown.test.ts new file mode 100644 index 0000000..6042772 --- /dev/null +++ b/test/passerelle-markdown.test.ts @@ -0,0 +1,149 @@ +// Rendre un Markdown lisible dans une conversation, sans casser l'envoi. +// +// Deux risques, et un seul se voit à l'œil : un rendu laid, et un message que +// Telegram **refuse** parce qu'une balise traîne ouverte ou qu'un `<` du +// document a été pris pour du balisage. Le second est le vrai danger — l'envoi +// échoue en bloc, et le document disparaît. + +import { describe, expect, it } from 'vitest'; +import { echappe, enHtml, paginer, PAGE_SOURCE } from '../server/passerelle/markdown.ts'; + +describe('echappe', () => { + it('neutralise ce qui se lirait comme du balisage', () => { + expect(echappe('a < b & c > d')).toBe('a < b & c > d'); + }); + + it('échappe l’esperluette avant tout, sinon elle mange les autres', () => { + // `<` produit par la première passe ne doit pas devenir `&lt;`. + expect(echappe('<')).toBe('<'); + }); +}); + +describe('enHtml', () => { + it('rend les titres en gras, faute de niveaux chez Telegram', () => { + expect(enHtml('# Titre')).toBe('Titre'); + expect(enHtml('### Sous-titre')).toBe('Sous-titre'); + }); + + it('rend les puces avec un vrai caractère de puce', () => { + expect(enHtml('- un\n- deux')).toBe('• un\n• deux'); + }); + + it('retrait les sous-listes, sinon elles se confondent avec leur mère', () => { + expect(enHtml('- mère\n - fille')).toBe('• mère\n • fille'); + }); + + it('garde le gras, l’italique et le barré', () => { + expect(enHtml('**gras** et *penché* et ~~barré~~')).toBe( + 'gras et penché et barré', + ); + }); + + it('ne prend pas un souligné de nom de fichier pour de l’italique', () => { + // Le cas réel qui a motivé la règle : `SPEC-014_notes-projet.md`. + expect(enHtml('SPEC-014_notes-de-projet-longues.md')).toBe( + 'SPEC-014_notes-de-projet-longues.md', + ); + }); + + it('ne touche pas à ce qui est entre accents graves', () => { + // `**` dans du code reste du code : c'est tout l'intérêt des jetons. + expect(enHtml('voir `a**b` ici')).toBe('voir a**b ici'); + }); + + it('rend un bloc de code et le referme', () => { + expect(enHtml('```ts\nconst a = 1;\n```')).toBe( + '
\nconst a = 1;\n
', + ); + }); + + it('referme un bloc de code laissé ouvert par une coupure', () => { + // Une page coupée en plein bloc laisserait sinon une balise en l'air, et + // Telegram refuserait le message entier. + expect(enHtml('```\ndu code')).toContain('
'); + }); + + it('échappe le contenu d’un bloc de code', () => { + expect(enHtml('```\nif (a < b) {}\n```')).toContain('if (a < b) {}'); + }); + + it('garde un tableau en chasse fixe et jette sa ligne d’alignement', () => { + const rendu = enHtml('| a | b |\n| --- | --- |\n| 1 | 2 |'); + expect(rendu).toBe('
\n| a | b |\n| 1 | 2 |\n
'); + }); + + it('rend un lien, et laisse tomber les protocoles qu’on ne relaie pas', () => { + expect(enHtml('[doc](https://exemple.fr)')).toBe('doc'); + expect(enHtml('[courriel](mailto:a@b.fr)')).toBe('courriel'); + // Un `javascript:` n'a rien à faire dans un href qu'on relaie : seul le + // libellé survit. Une URL à parenthèses imbriquées laisse en plus une + // parenthèse orpheline — la capture s'arrête à la première fermante. C'est + // le défaut classique du Markdown à une passe, et il est sans conséquence + // ici : le lien est refusé dans les deux cas. + expect(enHtml('[piège](javascript:alert)')).toBe('piège'); + expect(enHtml('[piège](javascript:alert(1))')).toBe('piège)'); + }); + + it('rend une citation et une ligne de séparation', () => { + expect(enHtml('> cité')).toBe('
cité
'); + expect(enHtml('---')).toBe('──────────'); + }); + + it('ne laisse jamais un jeton interne dans le rendu', () => { + // Les jetons qui mettent le code littéral de côté doivent tous être + // ressortis : un jeton qui survit s'afficherait tel quel, et le `` + // qu'il portait aurait disparu. + const JETON = String.fromCharCode(0xe000); + expect(enHtml('`a` et `b` et du texte')).not.toContain(JETON); + expect(enHtml('`a` et `b`')).toBe('a et b'); + }); + + it('désamorce un jeton que le document porterait lui-même', () => { + // Un document qui contiendrait ce caractère pourrait sinon faire ressortir + // un littéral qui n'est pas le sien. Le nettoyage retire le caractère et + // **garde son voisinage** : le texte n'est pas amputé, la contrefaçon ne + // ressemble plus à un jeton. + const JETON = String.fromCharCode(0xe000); + expect(enHtml(`avant${JETON}0${JETON}après`)).toBe('avant0après'); + }); +}); + +describe('paginer', () => { + it('rend une seule page pour un document court', () => { + expect(paginer('court')).toEqual(['court']); + }); + + it('rend une page même pour un document vide', () => { + expect(paginer('')).toEqual(['']); + }); + + it('coupe sur des frontières de lignes', () => { + const doc = Array.from({ length: 400 }, (_, i) => `ligne ${i}`).join('\n'); + const pages = paginer(doc); + expect(pages.length).toBeGreaterThan(1); + for (const p of pages) expect(p.length).toBeLessThanOrEqual(PAGE_SOURCE); + // 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', () => { + // Sans cela, la clôture ``` se retrouve en tête de la page suivante où elle + // *ouvre* un bloc au lieu de le fermer, et tout le reste part en code. + const gros = Array.from({ length: 400 }, (_, i) => `code ${i}`).join('\n'); + const pages = paginer('```ts\n' + gros + '\n```'); + expect(pages.length).toBeGreaterThan(1); + expect(pages[0]?.endsWith('```')).toBe(true); + expect(pages[1]?.startsWith('```ts')).toBe(true); + // Et chaque page se rend seule, sans balise en l'air. + for (const p of pages) { + const html = enHtml(p); + expect(html.split('
').length).toBe(html.split('
').length); + } + }); + + it('coupe une ligne plus longue qu’une page entière', () => { + const pages = paginer('x'.repeat(PAGE_SOURCE * 2 + 10)); + expect(pages.length).toBeGreaterThanOrEqual(3); + for (const p of pages) expect(p.length).toBeLessThanOrEqual(PAGE_SOURCE); + }); +}); diff --git a/test/passerelle-riche.test.ts b/test/passerelle-riche.test.ts new file mode 100644 index 0000000..7ffc084 --- /dev/null +++ b/test/passerelle-riche.test.ts @@ -0,0 +1,131 @@ +// 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: 'code', text: 'a**b' }]); + }); + + 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('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('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([]); + }); +}); From a3c87e8944c4745cd49df57e90b72b9567f9204f Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:21:19 +0200 Subject: [PATCH 05/28] =?UTF-8?q?On=20consulte=20un=20projet,=20et=20on=20?= =?UTF-8?q?n'ouvre=20que=20ceux=20qu'on=20conna=C3=AEt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois commandes de consultation — `/projets`, `/projet`, `/voir` — et une navigation par boutons dans un message qui **se réécrit** : une conversation n'a pas de bouton précédent, et empiler une liste par clic laisserait derrière soi une file d'états morts. Ce qui est montré est l'**arborescence du projet**, pas un classement inventé : les « catégories » de la page Projet *sont* les dossiers de `.claude`. Un dossier qui n'en contient qu'un autre est fondu avec lui — `rules/back/application` s'ouvre d'un clic au lieu de trois écrans qui ne posent aucune question. Aucune garde n'est élargie : les trois lecteurs de `projects.ts` sont appelés tels quels, et chacun garde son bac à sable. C'est l'origine de l'entrée qui décide lequel, jamais la forme du chemin — les confondre faisait refuser un document de dossier inclus parfaitement légitime. **`/atelier` n'ouvre plus que sur un projet connu.** Un chemin quelconque de la machine ne tombe sur rien : il n'y a pas de règle à contourner, seulement une liste dans laquelle figurer. C'est délibérément plus strict que l'Atelier à l'écran, où l'on voit ce qu'on choisit. Deux détails de mise en page qui ne se devinent pas, et qui sont mesurés plutôt que supposés : - une rangée se remplit tant que les libellés tiennent, trois au plus — au-delà, un bouton fait moins d'un tiers d'écran et devient une cible qu'on manque ; - **le clavier prend la largeur de la bulle, donc du texte.** Un en-tête de vingt caractères donnait des boutons de soixante pixels. Le remplissage invisible qui corrige cela prend sa propre ligne : collé au texte, il le poussait au-delà du bord d'un téléphone et coupait la dernière phrase en deux. Co-Authored-By: Claude Opus 5 (1M context) --- server/i18n/en.ts | 24 +- server/i18n/fr.ts | 29 +- server/passerelle/index.ts | 534 ++++++++++++++++++++++++++++++---- server/passerelle/projets.ts | 210 +++++++++++++ server/passerelle/routage.ts | 43 ++- server/passerelle/telegram.ts | 437 ++++++++++++++++++++-------- test/passerelle.test.ts | 277 +++++++++++++++++- 7 files changed, 1357 insertions(+), 197 deletions(-) create mode 100644 server/passerelle/projets.ts diff --git a/server/i18n/en.ts b/server/i18n/en.ts index 1b7de40..0333292 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -82,7 +82,13 @@ const en: Catalog = { aide: [ 'I drive the Workshop from this conversation.', '', - '/atelier — I open a session on that folder.', + 'Browsing, without starting anything:', + '/projets — the projects Claude Code knows, numbered.', + '/projet — its tree: you walk down folder by folder.', + '/voir — the contents of a file from the last list.', + '', + 'Working:', + '/atelier — I open a session on that project.', '/sessions — what is running right now.', '/stop — I interrupt the current turn.', '/fin — I close this conversation’s session.', @@ -90,8 +96,24 @@ const en: Catalog = { 'Any other message goes to the session as a turn.', ].join('\n'), 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}', permission: 'I would like to use {outil}.', diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index a0540da..5bbbfac 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -97,7 +97,13 @@ export default { aide: [ 'Je pilote l’Atelier depuis cette conversation.', '', - '/atelier — j’ouvre une session sur ce dossier.', + 'Consulter, sans rien lancer :', + '/projets — les projets que Claude Code connaît, numérotés.', + '/projet — son arborescence : on descend dossier par dossier.', + '/voir — le contenu d’un fichier de la dernière liste.', + '', + 'Travailler :', + '/atelier — j’ouvre une session sur ce projet.', '/sessions — ce qui tourne en ce moment.', '/stop — j’interromps le tour en cours.', '/fin — je ferme la session de cette conversation.', @@ -105,9 +111,30 @@ export default { 'Tout autre message part à la session comme un tour.', ].join('\n'), 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}', permission: 'Je voudrais utiliser {outil}.', diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index c0a38bb..aab5296 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -26,8 +26,38 @@ import { 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 { boutons, Telegram } from './telegram.ts'; +import { + aplatir, + arborescence, + compte, + descendre, + resoudreProjet, + type Entree, + type Noeud, +} from './projets.ts'; +import { echappe, enHtml, paginer } from './markdown.ts'; +import { enBlocs, MAX_RICHE, type InputRichBlock } from './riche.ts'; +import { boutons, elargi, grille, Telegram } from './telegram.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 { @@ -55,8 +85,359 @@ interface Fil { asks: Map; } +/** + * 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)); +} + +/** 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; + } + + 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)]; + if (!enfant || enfant.fichier) 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 }]), + ]; + const corps = markdown ? enHtml(source) : `
${echappe(source)}
`; + + await tg.envoieRendu( + chatId, + blocs, + `${echappe(entete)}\n\n${corps}`, + `${entete}\n\n${source}`, + navigation(rang, index, pages.length), + ); +} + +/** 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); +} function tronque(texte: string): string { return texte.length > MAX_TEXTE ? `${texte.slice(0, MAX_TEXTE)}…` : texte; @@ -246,29 +627,84 @@ async function traite(chatId: number, brut: string): Promise { return; case 'sessions': { - const sessions = listSessions(); - if (!sessions.length) { + // Deux sources, et elles 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. + 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) { await tg.envoie(chatId, t('passerelle.aucuneSession')); return; } - await tg.envoie(chatId, tronque(sessions.map((s) => `• ${s.cwd} — ${s.status}`).join('\n'))); + + 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})` : ''}`); + } + } + await tg.envoie(chatId, tronque(lignes.join('\n'))); + 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 'ouvrir': { - // 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 })); + case 'voir': { + const v = vue(chatId); + if (!v.entrees.length) { + await tg.envoie(chatId, t('passerelle.aucuneListe')); return; } - try { - const runner = createRunner({ cwd: intention.cwd, permissionMode: mode() }); - attache(chatId, runner); - await tg.envoie(chatId, t('passerelle.sessionOuverte', { cwd: runner.session.cwd })); - } catch (e) { - await tg.envoie(chatId, publicMessage(e)); + 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; } @@ -312,13 +748,29 @@ async function traite(chatId: number, brut: string): Promise { * cet appel. */ function tranche(chatId: number, donnee: string): void { + 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') { + void navigue(chatId, id, suffixe ?? ''); + return; + } + if (!suffixe) return; + if (type === 'v') { + void page(chatId, Number(id), Number(suffixe)); + return; + } + const runner = courant(chatId); const fil = fils.get(chatId); if (!runner || !fil) return; - const [type, id, suffixe] = donnee.split(':'); - if (!id || !suffixe) return; - if (type === 'p') { const reponse: PermissionAnswer = suffixe === 'a' ? 'allow' : 'deny'; runner.answerPermission( @@ -374,50 +826,27 @@ async function boucle(tg: Telegram, chats: Set, journal: Journal): Promi } journal.info(`Passerelle ouverte sur @${nom} — ${chats.size} conversation(s) autorisée(s).`); - // Les échecs consécutifs, et rien d'autre, décident du délai d'attente : une - // messagerie injoignable ne doit pas faire tourner une boucle serrée sur le - // réseau pendant des heures. - let echecs = 0; - while (!tg.arrete) { - const mises = await tg.mises(); - if (tg.arrete) break; - - if (mises === null) { - echecs += 1; - await pause(tg.attente(echecs)); - continue; - } - echecs = 0; - - for (const message of mises.messages) { - // Le silence est la réponse à un inconnu : répondre confirmerait que ce - // bot existe et à quoi il sert. - if (!autorise(chats, message.chatId)) continue; + 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)}`); } - } - - for (const bouton of mises.boutons) { - if (!autorise(chats, bouton.chatId)) continue; - // Accuser d'abord : sans cela le bouton tourne pendant tout le traitement. - await tg.accuse(bouton.callbackId); + }, + async (bouton) => { try { tranche(bouton.chatId, bouton.donnee); } catch (e) { journal.warn(`Passerelle : ${publicMessage(e)}`); } - } - } -} - -/** Une attente qui ne retient pas le process à elle seule. */ -function pause(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms).unref?.(); - }); + return Promise.resolve(); + }, + (message) => journal.warn(`Passerelle : ${message}`), + ); } /** @@ -429,6 +858,7 @@ function pause(ms: number): Promise { */ export function arretePasserelle(): void { for (const chatId of [...fils.keys()]) defait(chatId, false); + vues.clear(); telegram?.stop(); telegram = null; } 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/routage.ts b/server/passerelle/routage.ts index 815038a..cce3033 100644 --- a/server/passerelle/routage.ts +++ b/server/passerelle/routage.ts @@ -7,8 +7,20 @@ /** 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 dossier. */ - | { kind: 'ouvrir'; cwd: string } + /** + * 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. */ @@ -61,7 +73,13 @@ export function autorise(chats: Set, chatId: number): boolean { return chats.has(chatId); } -/** Le nom du dossier de travail d'une commande `/atelier`, s'il y en a un. */ +/** + * 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(); @@ -83,10 +101,21 @@ export function parseIntention(brut: string): Intention { const mot = (texte.split(/\s/)[0] ?? '').split('@')[0]?.toLowerCase() ?? ''; switch (mot) { case '/atelier': { - const cwd = argument(texte); - // Sans dossier, il n'y a pas de session à ouvrir : c'est l'aide qui - // répond, elle porte la forme attendue. - return cwd ? { kind: 'ouvrir', cwd } : { kind: 'aide' }; + 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' }; diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts index 627b465..e90997f 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -1,30 +1,25 @@ // Le seul fichier qui sache que la messagerie est Telegram. // -// Rien ici ne décide : on lit des mises à jour, on envoie du texte, on accuse -// réception d'un bouton. Le jour où un second fournisseur se présente, c'est ce -// fichier qu'on double — et lui seul. +// Il s'appuie sur `node-telegram-bot-api` (v2), qui apporte deux choses que +// notre client à la main ne pouvait pas donner : // -// Le long-polling est **sortant**, et c'est la raison d'être de tout le -// 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 que ceci fonctionne. - -import { num, str } from '../json.ts'; - -const API = 'https://api.telegram.org'; - -/** - * Combien de temps Telegram garde la requête ouverte quand rien n'arrive. - * - * Vingt-cinq secondes : sous la minute au-delà de laquelle les intermédiaires - * coupent, et assez long pour qu'une journée sans message ne coûte que quelques - * milliers de requêtes vides. - */ -const POLL_SECONDS = 25; +// - **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 (riche → HTML → 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. -/** Le délai après un échec réseau, et le plafond qu'il ne dépasse pas. */ -const RETRY_MS = 2_000; -const RETRY_MAX_MS = 60_000; +import { Api, Bot } from 'node-telegram-bot-api'; +import type { InlineKeyboardMarkup } from 'node-telegram-bot-api'; +import type { InputRichBlock } from './riche.ts'; /** Un message reçu, réduit à ce dont la Passerelle a besoin. */ export interface MessageEntrant { @@ -41,150 +36,338 @@ export interface BoutonPresse { donnee: string; } -export interface Mises { - messages: MessageEntrant[]; - boutons: BoutonPresse[]; +/** Un bouton : ce qu'il affiche, et ce qu'il renvoie quand on le presse. */ +export interface Bouton { + texte: string; + donnee: string; +} + +/** 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))}`; } -/** Un couple de boutons sous un message, tel que Telegram l'attend. */ -export function boutons(paires: { texte: string; donnee: string }[]): unknown { - return { - inline_keyboard: [paires.map((p) => ({ text: p.texte, callback_data: p.donnee }))], +/** + * 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 offset = 0; - private readonly aborter = new AbortController(); + private readonly api: Api; + private readonly bot: Bot; private stopped = false; - constructor(private readonly token: string) {} - - private url(methode: string): string { - return `${API}/bot${this.token}/${methode}`; + constructor(token: string) { + this.api = new Api(token); + this.bot = new Bot(token); } /** - * Un appel à l'API. Rend `null` sur échec plutôt que de lever. + * Le nom du bot, ou `null` si le jeton ne vaut rien. * - * Une messagerie injoignable n'est pas une panne d'AURA : le BFF continue de - * servir l'interface et les sessions de tourner. L'appelant retentera. + * Sert à démarrer : un jeton refusé doit se dire une fois, pas se retenter + * indéfiniment dans une boucle que personne ne regarde. */ - private async appel(methode: string, corps: unknown, timeoutMs: number): Promise { - // Une horloge propre à l'appel, en plus de l'arrêt global : sans elle, un - // `getUpdates` dont la socket reste ouverte sans jamais répondre tiendrait - // la boucle indéfiniment. - const horloge = AbortSignal.timeout(timeoutMs); + async identite(): Promise { try { - const res = await fetch(this.url(methode), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(corps), - signal: AbortSignal.any([this.aborter.signal, horloge]), - }); - if (!res.ok) return null; - const json: unknown = await res.json(); - const rec = (json ?? {}) as Record; - return rec.ok === true ? rec.result : null; + const moi = await this.api.getMe(); + return moi.username || null; } catch { - // Abandon volontaire compris : `stopped` dira à la boucle quoi en faire. return null; } } - /** Le nom du bot, ou `null` si le jeton ne vaut rien. Sert à démarrer. */ - async identite(): Promise { - const res = await this.appel('getMe', {}, 10_000); - const nom = str((res as Record | null)?.username); - return nom || 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. */ + async envoie(chatId: number, texte: string, clavier?: InlineKeyboardMarkup): Promise { + try { + await this.api.sendMessage({ + chat_id: chatId, + text: 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. + } } /** - * Les mises à jour depuis la dernière lue. `null` si l'appel a échoué. - * - * La distinction compte : une attente vide est le régime normal du - * long-polling et ne doit rien ralentir, là où un échec doit faire monter le - * délai. Les confondre ferait tourner une boucle serrée sur un réseau coupé. + * Envoie un texte et rend l'identifiant du message, pour pouvoir le réécrire. * - * `offset` vaut acquittement : demander la suite dit à Telegram d'oublier ce - * qui précède. On l'avance donc dès la lecture, avant tout traitement — sinon - * un message qui fait échouer le traitement reviendrait à chaque tour de - * boucle, indéfiniment. + * 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 mises(): Promise { - const res = await this.appel( - 'getUpdates', - { - offset: this.offset, - timeout: POLL_SECONDS, - allowed_updates: ['message', 'callback_query'], - }, - // Au-delà du temps que Telegram tient lui-même la requête : sans marge, - // on couperait chaque attente vide au moment où elle allait aboutir. - (POLL_SECONDS + 10) * 1_000, - ); - - if (!Array.isArray(res)) return null; - const out: Mises = { messages: [], boutons: [] }; - - for (const brut of res) { - const maj = (brut ?? {}) as Record; - const id = num(maj.update_id); - if (id >= this.offset) this.offset = id + 1; - - const message = (maj.message ?? {}) as Record; - const chat = (message.chat ?? {}) as Record; - const chatId = num(chat.id); - const texte = str(message.text); - if (chatId && texte) out.messages.push({ chatId, texte }); - - const rappel = (maj.callback_query ?? {}) as Record; - const rappelMessage = (rappel.message ?? {}) as Record; - const rappelChat = (rappelMessage.chat ?? {}) as Record; - const rappelChatId = num(rappelChat.id); - const callbackId = str(rappel.id); - const donnee = str(rappel.data); - if (rappelChatId && callbackId && donnee) { - out.boutons.push({ chatId: rappelChatId, callbackId, donnee }); - } + 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; } - return out; } - /** Envoie un texte. `clavier` ajoute des boutons sous le message. */ - async envoie(chatId: number, texte: string, clavier?: unknown): Promise { - await this.appel( - 'sendMessage', - { + /** + * 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 } : {}), - }, - 15_000, - ); + }); + return true; + } catch { + return false; + } } - /** Accuse réception d'un bouton : sans cela, il tourne côté client. */ - async accuse(callbackId: string, texte?: string): Promise { - await this.appel( - 'answerCallbackQuery', - { callback_query_id: callbackId, ...(texte ? { text: texte } : {}) }, - 10_000, - ); + /** + * Envoie un document, du plus riche au plus sûr. + * + * Trois tentatives, dans cet ordre, et chacune sait faire ce que la suivante + * ne fait pas : + * + * 1. **`sendRichMessage`** — de vrais tableaux, avec bordures, et 32 768 + * caractères. C'est la seule voie qui rende un tableau lisible. + * 2. **HTML** — pas de tableaux, mais du gras, du code et des citations. + * Sert si l'API riche est indisponible sur ce compte ou refuse le + * document. + * 3. **texte nu** — ne peut échouer que si le réseau est coupé. + * + * 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[], + html: string, + brut: string, + clavier?: InlineKeyboardMarkup, + ): Promise<'riche' | 'html' | 'brut'> { + const markup = clavier ? { reply_markup: clavier } : {}; + + if (blocs.length) { + try { + await this.api.sendRichMessage({ + chat_id: chatId, + rich_message: { blocks: blocs }, + ...markup, + }); + return 'riche'; + } catch { + /* on tente la mise en forme simple */ + } + } + + try { + await this.api.sendMessage({ + chat_id: chatId, + text: html, + parse_mode: 'HTML', + link_preview_options: { is_disabled: true }, + ...markup, + }); + return 'html'; + } catch { + await this.envoie(chatId, brut, clavier); + return 'brut'; + } } get arrete(): boolean { return this.stopped; } - /** Le délai à observer après un tour de boucle infructueux. */ - attente(echecs: number): number { - return Math.min(RETRY_MS * 2 ** Math.max(0, echecs - 1), RETRY_MAX_MS); - } - /** Coupe le long-polling en vol : la requête en attente est abandonnée. */ stop(): void { this.stopped = true; - this.aborter.abort(); + this.bot.stop(); } } diff --git a/test/passerelle.test.ts b/test/passerelle.test.ts index bda08be..44a451c 100644 --- a/test/passerelle.test.ts +++ b/test/passerelle.test.ts @@ -1,13 +1,23 @@ // 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`, précisément pour être vérifiable sans bot, sans jeton -// et sans session. C'est le fichier qui sépare une machine pilotable d'une -// machine ouverte à tous, et il ne doit pas dépendre d'un service tiers pour -// être testé. +// `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', () => { @@ -58,16 +68,40 @@ describe('parseIntention', () => { expect(parseIntention(' ')).toEqual({ kind: 'ignorer', raison: 'vide' }); }); - it('ouvre une session sur le dossier donné', () => { + 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', - cwd: 'C:\\devl\\tos', + ref: 'C:\\devl\\tos', }); + expect(parseIntention('/atelier 3')).toEqual({ kind: 'ouvrir', ref: '3' }); }); - it('renvoie à l’aide plutôt que d’ouvrir sans dossier', () => { - expect(parseIntention('/atelier')).toEqual({ kind: 'aide' }); - expect(parseIntention('/atelier ')).toEqual({ kind: 'aide' }); + 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('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', () => { @@ -100,3 +134,228 @@ describe('parseIntention', () => { }); }); }); + +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([]); + }); +}); From 16efe82c25aec8726ea8850753ed1c3355f19624 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:21:29 +0200 Subject: [PATCH 06/28] Le manuel dit ce que la Passerelle est devenue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il décrivait une liste numérotée à plat et un rendu HTML : deux choses qui n'existent plus. Il décrit maintenant la navigation en arborescence, le rendu structuré, la pagination, et `/sessions` en deux groupes. Trois passages tiennent une observation plutôt qu'une intention, et c'est volontaire — ce sont ceux qu'on aurait envie de « corriger » plus tard : une case à cocher n'est pas dessinée par les clients actuels, un tableau en chasse fixe se disloque sur un téléphone, et les écrans de navigation vivent en mémoire, donc un redémarrage périme les boutons d'un vieux message. Co-Authored-By: Claude Opus 5 (1M context) --- src/help/sections/en/passerelle.md | 73 ++++++++++++++++++++++++++---- src/help/sections/fr/passerelle.md | 73 ++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 16 deletions(-) diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index d4ea1be..7fb9cc7 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -45,18 +45,75 @@ A line in my log confirms the opening and how many conversations are allowed. If ## What you can tell me -One conversation holds **one** session at a time. +### Browsing, without starting anything -| Message | What I do | -| ------------------- | ------------------------------------------- | -| `/atelier ` | I open a session on that folder | -| `/sessions` | I list what is running, whatever started it | -| `/stop` | I interrupt the current turn | -| `/fin` | I close this conversation's session | -| `/aide` | I repeat the above | +`/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 fall back to simple formatting, then to plain text. 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 | +| `/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. +### 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. diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index 526adc6..23da590 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -45,18 +45,75 @@ Une ligne dans mon journal confirme l'ouverture et le nombre de conversations au ## Ce que vous pouvez me dire -Une conversation tient **une** session à la fois. +### Consulter, sans rien lancer -| Message | Ce que je fais | -| -------------------- | -------------------------------------------------- | -| `/atelier ` | J'ouvre une session sur ce dossier | -| `/sessions` | Je liste ce qui tourne, toutes origines confondues | -| `/stop` | J'interromps le tour en cours | -| `/fin` | Je ferme la session de cette conversation | -| `/aide` | Je rappelle ce qui précède | +`/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 retombe sur une mise en forme simple, puis sur le texte nu. 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 | +| `/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. +### 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. From 6cd97329a9626d6d08511497e49b54f7549a5a94 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:32:28 +0200 Subject: [PATCH 07/28] =?UTF-8?q?La=20Passerelle=20ne=20dit=20plus=20si=20?= =?UTF-8?q?elle=20est=20arr=C3=AAt=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'accesseur `arrete` datait du client à la main, où la boucle de long-polling était la nôtre et où `index.ts` avait besoin d'en interroger l'état. Depuis que `Bot` la tient, plus personne ne le lit — `stopped` ne sert qu'en interne, à taire l'erreur d'une requête abandonnée par `stop()`. Un accesseur public que rien n'appelle laisse croire qu'il répond à une question que quelqu'un se pose. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/telegram.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts index e90997f..4d9b51b 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -361,10 +361,6 @@ export class Telegram { } } - get arrete(): boolean { - return this.stopped; - } - /** Coupe le long-polling en vol : la requête en attente est abandonnée. */ stop(): void { this.stopped = true; From e9c508ced8df30f1cb5f52d8e86ff51241c3b4cc Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:45:13 +0200 Subject: [PATCH 08/28] =?UTF-8?q?Une=20citation,=20une=20liste=20imbriqu?= =?UTF-8?q?=C3=A9e=20et=20une=20colonne=20align=C3=A9e=20arrivent=20enti?= =?UTF-8?q?=C3=A8res?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre pertes de sens à la traduction, toutes constatées sur un document réel — `docs/projet/mise-en-recette.md`, qui les porte toutes les quatre. **Une citation se relit entièrement**, comme un document à elle seule. Traitée ligne à ligne, une citation de six lignes donnait six cadres empilés, et la consigne numérotée qu'elle contenait perdait sa numérotation. La récursion s'arrête d'elle-même : chaque tour retire un chevron. **Les listes suivent l'indentation de la source.** Aplatir remontait un sous-point au rang de son parent, ce qui inverse le sens d'une consigne : « à vérifier, dans cet ordre » suivi de quatre sous-points devenait cinq points de même rang. **La suite d'un élément long lui reste attachée.** Une deuxième ligne rentrée fermait la liste, et le point suivant repartait à « 1 » dans un nouveau bloc. **L'alignement des colonnes est lu dans la ligne de séparation.** Les deux-points du Markdown portent une intention — une colonne de nombres alignée à droite dans la source doit l'être à l'écran. C'est précisément ce que `align` exprime, et le champ qu'on avait pris la peine de rendre optionnel contre les types de la bibliothèque. Le commentaire d'en-tête disait deux fois la même chose sur `is_header` ; il ne le dit plus qu'une. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/riche.ts | 149 +++++++++++++++++++++++++++------- test/passerelle-riche.test.ts | 37 +++++++++ 2 files changed, 156 insertions(+), 30 deletions(-) diff --git a/server/passerelle/riche.ts b/server/passerelle/riche.ts index 1613dcf..8e02afd 100644 --- a/server/passerelle/riche.ts +++ b/server/passerelle/riche.ts @@ -19,10 +19,8 @@ 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 tout l'intérêt de la dépendance : elle **défend les -// noms de champs**, là où l'API ne les défend pas. Un `header` écrit pour -// `is_header` ne produit aucune erreur à l'exécution — l'API ignore ce qu'elle -// ne connaît pas — et donnait un tableau sans en-tête, sans rien pour le dire. +// 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 @@ -114,6 +112,67 @@ function cellules(ligne: string): string[] { .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. * @@ -146,7 +205,8 @@ export function enBlocs(markdown: string): InputRichBlock[] { let paragraphe: string[] = []; let tableau: string[] = []; - let liste: string[] = []; + let liste: Element[] = []; + let citation: string[] = []; let code: string[] | null = null; let langue = ''; @@ -159,7 +219,8 @@ export function enBlocs(markdown: string): InputRichBlock[] { const fermeTableau = (): void => { if (!tableau.length) return; const grille = tableau.filter((l) => !SEPARATEUR.test(l)).map(cellules); - const avaitSeparateur = tableau.some((l) => SEPARATEUR.test(l)); + const separateur = tableau.find((l) => SEPARATEUR.test(l)); + const aligne = separateur ? alignements(separateur) : []; tableau = []; if (!grille.length) return; blocs.push({ @@ -170,9 +231,10 @@ export function enBlocs(markdown: string): InputRichBlock[] { // 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) => ({ + rangee.map((c, j) => ({ text: fragments(c), - ...(i === 0 && avaitSeparateur ? { is_header: true as const } : {}), + ...(i === 0 && separateur ? { is_header: true as const } : {}), + ...(aligne[j] ? { align: aligne[j] } : {}), })), ), }); @@ -180,19 +242,31 @@ export function enBlocs(markdown: string): InputRichBlock[] { const fermeListe = (): void => { if (!liste.length) return; - blocs.push({ - type: 'list', - items: liste.map((texte) => ({ - blocks: [{ type: 'paragraph', text: fragments(texte) }], - })), - }); + 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) { @@ -212,6 +286,18 @@ export function enBlocs(markdown: string): InputRichBlock[] { 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(); @@ -220,27 +306,40 @@ export function enBlocs(markdown: string): InputRichBlock[] { } fermeTableau(); - const puce = /^\s*[-*+]\s+(.*)$/.exec(ligne); + const puce = /^(\s*)[-*+]\s+(.*)$/.exec(ligne); if (puce) { fermeParagraphe(); - const contenu = puce[1] ?? ''; + 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(prefixe + (case_[2] ?? '')); - } else liste.push(contenu); + liste.push({ indent, lignes: [prefixe + (case_[2] ?? '')] }); + } else liste.push({ indent, lignes: [contenu] }); continue; } - const numerotee = /^\s*(\d+)[.)]\s+(.*)$/.exec(ligne); + const numerotee = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(ligne); if (numerotee) { fermeParagraphe(); // Le numéro reste dans le texte : le client dessine ses propres puces et // ignore le `label` qui aurait dû le porter. - liste.push(`${numerotee[1] ?? ''}. ${numerotee[2] ?? ''}`); + 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(); @@ -258,16 +357,6 @@ export function enBlocs(markdown: string): InputRichBlock[] { continue; } - const citation = /^\s*>\s?(.*)$/.exec(ligne); - if (citation) { - fermeParagraphe(); - blocs.push({ - type: 'blockquote', - blocks: [{ type: 'paragraph', text: fragments(citation[1] ?? '') }], - }); - continue; - } - if (/^\s*([-*_])\1{2,}\s*$/.test(ligne)) { fermeTout(); blocs.push({ type: 'divider' }); diff --git a/test/passerelle-riche.test.ts b/test/passerelle-riche.test.ts index 7ffc084..82bda89 100644 --- a/test/passerelle-riche.test.ts +++ b/test/passerelle-riche.test.ts @@ -119,6 +119,43 @@ describe('enBlocs', () => { ]); }); + 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']); From 98c73847a552da78866ba0d78341f4043abc70f9 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:55:50 +0200 Subject: [PATCH 09/28] =?UTF-8?q?Le=20code=20litt=C3=A9ral=20se=20surligne?= =?UTF-8?q?,=20pour=20survivre=20=C3=A0=20un=20petit=20=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `code` seul est rendu par le client en chasse fixe teintée. Sur un grand écran la teinte suffit à distinguer un chemin du texte qui l'entoure ; sur un téléphone elle se perd, et un document dense en chemins redevient un mur. `marked` ajoute un fond — une différence de **surface**, non de couleur, et c'est ce qui traverse la réduction d'échelle. Les deux entités se cumulent : mesuré contre l'API, `marked(code)` porte bien la chasse fixe, la teinte et le fond, et l'ordre de l'emboîtement est sans effet. Cela valait d'être mesuré plutôt que déduit : le client ignore en silence ce qu'il ne sait pas rendre — `has_checkbox` l'avait déjà montré — et la spec seule n'aurait rien prouvé. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/riche.ts | 7 ++++++- test/passerelle-riche.test.ts | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/passerelle/riche.ts b/server/passerelle/riche.ts index 8e02afd..f3f2799 100644 --- a/server/passerelle/riche.ts +++ b/server/passerelle/riche.ts @@ -85,7 +85,12 @@ export function fragments(ligne: string): RichText[] { if (m.index > reste) out.push(ligne.slice(reste, m.index)); const [, code, libelle, url, gras, barre, penche, souligne] = m; - if (code !== undefined) out.push({ type: 'code', text: code }); + // 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. diff --git a/test/passerelle-riche.test.ts b/test/passerelle-riche.test.ts index 82bda89..71ef2e1 100644 --- a/test/passerelle-riche.test.ts +++ b/test/passerelle-riche.test.ts @@ -23,7 +23,14 @@ describe('fragments', () => { }); it('garde le code littéral hors des autres transformations', () => { - expect(fragments('`a**b`')).toEqual([{ type: 'code', text: 'a**b' }]); + 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', () => { From d9d471bcd7e498f1b4b325761a5a0e9010a5b8e7 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:00:42 +0200 Subject: [PATCH 10/28] =?UTF-8?q?Un=20libell=C3=A9=20de=20lien=20se=20reli?= =?UTF-8?q?t,=20et=20Telegram=20cesse=20d'en=20inventer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts qui se voyaient sur la même ligne d'un document réel : « la marche à suivre est dans [`docs/livraison/0.livraison.md`](…) ». **Le libellé d'un lien n'était jamais analysé**, donc ses accents graves restaient à l'écran. Il se relit désormais comme le reste, y compris quand la cible est écartée : un lien relatif reste refusé — il ne mène nulle part depuis une messagerie — mais son texte ne doit pas s'en trouver appauvri. La même relecture vaut pour le gras, l'italique et le barré, qui avaient le même trou. **Telegram fabriquait des liens dans notre dos.** `.md` est un domaine de premier niveau — la Moldavie —, si bien que `0.livraison.md` partait vers un site qui n'existe pas. Le piège est propre à ce que la Passerelle affiche : un dépôt est plein de `.py`, `.pl`, `.sh`, `.io`. `skip_entity_detection` le ferme, et laisse intactes les entités qu'on déclare nous-mêmes. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/riche.ts | 36 ++++++++++++++++++++++++++++------- server/passerelle/telegram.ts | 10 +++++++++- test/passerelle-riche.test.ts | 12 ++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/server/passerelle/riche.ts b/server/passerelle/riche.ts index f3f2799..cd00365 100644 --- a/server/passerelle/riche.ts +++ b/server/passerelle/riche.ts @@ -77,6 +77,25 @@ const SEPARATEUR = /^\s*\|?[\s:|-]+\|[\s:|-]*$/; */ 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]+)\)|\*\*([^*]+)\*\*|~~([^~]+)~~|(? { 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' }, From bdc50938490fe0f4a5e43599afa978cb0b1228e0 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:07:07 +0200 Subject: [PATCH 11/28] Un fichier dit ce que les blocs riches savent faire, et ce qu'ils font vraiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le piège de cette API n'a pas de garde possible : elle accepte en silence les champs qu'elle ne connaît pas, et les types de la bibliothèque déclarent leurs valeurs `string`. Le compilateur ne dira jamais que `pull_quotation` aurait dû s'écrire `pullquote`. Les chaînes exactes ont donc besoin d'un endroit où vivre. `blocs-riches.md` recense les vingt-et-un blocs et les vingt-cinq entités en ligne, dit lesquels on émet et pourquoi les autres sont écartés — et surtout note, à côté de chaque cas, ce que l'observation contredit dans la spec. Une correction en découle. Le commentaire des listes numérotées accusait le champ `label` : c'est faux, `label` n'existe que sur les blocs *reçus*. Le champ d'envoi est `value`, secondé par `type`. Mesuré : `type` seul déclenche la numérotation, `value` seul ne fait rien, et la forme décimale décale d'un rang — le premier point s'affiche « 0. », là où les lettres et les romains sont justes. Garder le numéro dans le texte reste donc le bon choix, mais pour la vraie raison. Le fichier note aussi ce que la spec offre et qu'on n'exploite pas : `markdown` en entrée directe — écarté, on y perdrait la main sur les cas où le rendu par défaut est faux —, le bloc repliable `details`, et la borne des 500 blocs, la seule limite qu'on ne garde pas. Co-Authored-By: Claude Opus 5 (1M context) --- server/CLAUDE.md | 6 + server/passerelle/blocs-riches.md | 190 ++++++++++++++++++++++++++++++ server/passerelle/riche.ts | 7 +- 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 server/passerelle/blocs-riches.md diff --git a/server/CLAUDE.md b/server/CLAUDE.md index b7e2852..57ec87e 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -104,6 +104,12 @@ 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/passerelle/blocs-riches.md b/server/passerelle/blocs-riches.md new file mode 100644 index 0000000..2987289 --- /dev/null +++ b/server/passerelle/blocs-riches.md @@ -0,0 +1,190 @@ +# 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**, et c'est la seule qui puisse mordre : la +pagination compte des caractères, pas des blocs. Un document fait d'une longue liste — six +cents lignes courtes — tiendrait sous 32 768 caractères tout en dépassant les 500 items. +L'envoi échouerait alors et retomberait sur le HTML, donc rien ne se perd : c'est pour cela +que ce n'est pas corrigé, et non parce que le cas n'existe pas. + +--- + +## 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 | +| `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 | +| ---------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `details` | Bloc repliable — `summary` toujours visible, `is_open?` | **La piste la plus sérieuse.** Rien en Markdown ne le déclenche, mais il replierait un long tableau ou un bloc de code derrière son titre. | +| `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/riche.ts b/server/passerelle/riche.ts index cd00365..4775b10 100644 --- a/server/passerelle/riche.ts +++ b/server/passerelle/riche.ts @@ -352,8 +352,11 @@ export function enBlocs(markdown: string): InputRichBlock[] { const numerotee = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(ligne); if (numerotee) { fermeParagraphe(); - // Le numéro reste dans le texte : le client dessine ses propres puces et - // ignore le `label` qui aurait dû le porter. + // 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] ?? ''}`], From db8a12ca4a094c719eea725f646de289e5d1b0fa Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:27:30 +0200 Subject: [PATCH 12/28] =?UTF-8?q?Une=20bulle=20=C3=A9ph=C3=A9m=C3=A8re=20d?= =?UTF-8?q?it=20que=20=C3=A7a=20travaille,=20et=20=C3=A0=20quoi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entre l'envoi d'un message et la réponse, il pouvait 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`), pas un message. Il est éphémère, ne persiste pas dans le fil, et 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é, et un brouillon n'est pas envoyé — il est montré, puis il disparaît. Deux contraintes de l'API dictent le reste. Un brouillon expire au bout de trente secondes : d'où un battement de vingt. Une phase d'outil se remplace toutes les deux ou trois secondes : d'où un pas minimal de deux, en dessous duquel un libellé qui change se lit comme un scintillement. Deux réserves mesurées, et l'action de saisie qui en découle : le brouillon ne vaut qu'en conversation privée, et le client web ne le rend pas — seuls les clients mobiles le font, vérifié. `sendChatAction` l'accompagne donc : elle ne dit que « quelque chose se passe », mais elle passe partout. Le libellé reprend celui de l'Atelier — mêmes phases, mêmes noms d'outils — parce qu'une session lue de deux endroits ne doit pas raconter deux histoires. La durée affichée est celle du tour, jamais de la phase : c'est la seule qu'on se demande vraiment. Le battement s'arrête sur trois événements, et jamais sur une échéance : fin de tour, demande de permission, question. Sur les deux derniers, la balle est dans le camp de l'utilisateur — laisser battre la bulle ferait croire le contraire. Co-Authored-By: Claude Opus 5 (1M context) --- server/i18n/en.ts | 12 +++ server/i18n/fr.ts | 20 ++++ server/passerelle/activite.ts | 177 +++++++++++++++++++++++++++++++ server/passerelle/index.ts | 37 +++++++ server/passerelle/telegram.ts | 53 ++++++++- test/passerelle-activite.test.ts | 135 +++++++++++++++++++++++ 6 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 server/passerelle/activite.ts create mode 100644 test/passerelle-activite.test.ts diff --git a/server/i18n/en.ts b/server/i18n/en.ts index 0333292..45ad9fa 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -123,6 +123,18 @@ const en: Catalog = { questionTropRiche: 'This question needs a form I cannot render here. It is waiting for you in the Workshop.', 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: { diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index 5bbbfac..1dc1c84 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -145,6 +145,26 @@ export default { questionTropRiche: 'Cette question demande un formulaire que je ne sais pas poser ici. Elle vous attend dans l’Atelier.', 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: { 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/index.ts b/server/passerelle/index.ts index aab5296..a82120a 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -48,6 +48,7 @@ import { import { echappe, enHtml, paginer } from './markdown.ts'; import { enBlocs, MAX_RICHE, type InputRichBlock } from './riche.ts'; import { boutons, elargi, grille, Telegram } from './telegram.ts'; +import { Battement } from './activite.ts'; import type { InlineKeyboardMarkup } from 'node-telegram-bot-api'; /** @@ -83,8 +84,19 @@ interface Fil { tour: Map; /** Les questions en vol, pour retrouver l'option qu'un bouton désigne. */ asks: Map; + /** 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. * @@ -463,11 +475,24 @@ function mode(): string { * 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: () => {}, tour: new Map(), asks: new Map(), + battement: new Battement( + { + brouillon: async (id, draft, texte) => { + await tg?.brouillon(id, draft, texte); + }, + saisie: async (id) => { + await tg?.saisie(id); + }, + }, + chatId, + prochainBrouillon++, + ), }; fil.detache = runner.subscribe((upsert) => { void applique(chatId, fil, upsert); @@ -485,6 +510,7 @@ 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); } @@ -521,12 +547,19 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< 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; } // Fin de tour : le moment où il y a enfin quelque chose à dire. + fil.battement.arrete(); const dit = [...fil.tour.values()].join('\n\n').trim(); fil.tour.clear(); if (dit) await tg.envoie(chatId, tronque(dit)); @@ -542,6 +575,9 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< } 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 quoi = demande.title || demande.displayName || demande.toolName; await tg.envoie( @@ -556,6 +592,7 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< } case 'ask-request': { + fil.battement.arrete(); const demande = upsert.request; const premiere = demande.questions[0]; // Un formulaire à plusieurs questions ne se rend pas en boutons sans diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts index 8e5770c..12f342b 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -18,7 +18,10 @@ // `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 } from 'node-telegram-bot-api'; +import type { + InlineKeyboardMarkup, + InputRichBlock as InputRichBlockLib, +} from 'node-telegram-bot-api'; import type { InputRichBlock } from './riche.ts'; /** Un message reçu, réduit à ce dont la Passerelle a besoin. */ @@ -369,6 +372,54 @@ export class Telegram { } } + /** + * 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; 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']); + }); +}); From 7c07e1361f92dc69155551fb890581893d7b32e1 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:31:29 +0200 Subject: [PATCH 13/28] =?UTF-8?q?La=20r=C3=A9ponse=20de=20l'agent=20est=20?= =?UTF-8?q?un=20document,=20et=20se=20rend=20comme=20tel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un agent écrit du Markdown — titres, listes, tableaux, chemins entre accents graves. On l'envoyait en texte nu, si bien qu'un tableau arrivait avec ses barres verticales et ses tirets, à côté d'une traduction en messages riches qu'on ne servait qu'aux fichiers. C'était le rendu le plus souvent lu de toute la Passerelle : on lit une réponse à chaque tour, un fichier de temps en temps. Deux différences avec l'envoi d'un fichier, et elles tiennent à ce qu'une réponse n'en est pas un. Pas d'en-tête — on sait qui parle. Pas de pagination : tourner la page suppose de pouvoir relire la source, or celle-ci ne vit que dans la session. Une réponse trop longue reste donc coupée, mais la coupe passe de 4 000 à près de 23 000 caractères, la borne du riche étant huit fois celle d'un message ordinaire. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/index.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index a82120a..9acd258 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -430,6 +430,29 @@ async function page(chatId: number, rang: number, numero: number): Promise ); } +/** + * 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), enHtml(source), tronque(source)); +} + /** Ce fichier se lit-il comme du Markdown ? */ function estMarkdown(rel: string): boolean { return /\.(md|markdown|mdx)$/i.test(rel); @@ -562,7 +585,7 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< fil.battement.arrete(); const dit = [...fil.tour.values()].join('\n\n').trim(); fil.tour.clear(); - if (dit) await tg.envoie(chatId, tronque(dit)); + if (dit) await repond(chatId, dit); if (upsert.status === 'failed') { await tg.envoie(chatId, t('passerelle.sessionEchouee', { message: upsert.error ?? '' })); From 690a3f53db329841d35a5c3b3f61f38371702d9d Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:33:11 +0200 Subject: [PATCH 14/28] Le manuel dit ce qui se passe pendant qu'un tour travaille MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux choses visibles ont changé, et le manuel les taisait. La bulle d'activité y gagne sa propre section, avec les deux réserves qu'il vaut mieux lire avant de s'étonner : elle demande une conversation privée et un client mobile. Sur le web ou dans un groupe, il ne reste que le « en train d'écrire… » de l'en-tête. Et la section « ce que je ne vous envoie pas » disait le contraire de ce que la Passerelle fait maintenant : elle promettait de ne pas envoyer la ligne d'activité. La promesse tenait à ce qu'une messagerie n'est pas une timeline — elle tient toujours, mais l'exception doit se dire : une bulle qui s'efface n'empile rien. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/index.ts | 10 +++++++--- src/help/sections/en/passerelle.md | 16 ++++++++++++++-- src/help/sections/fr/passerelle.md | 16 ++++++++++++++-- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 9acd258..9554142 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -542,9 +542,13 @@ function defait(chatId: number, ferme: boolean): void { * 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 l'activité, ni les - * entrées d'outils — une messagerie n'est pas une timeline, et un flux de tokens - * y serait illisible. + * 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; diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index 7fb9cc7..51f8767 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -126,13 +126,25 @@ The Workshop's deadline applies here too: **with no answer within fifteen minute A multiple-choice question reaches you the same way, as buttons. If it holds several questions, I tell you rather than answering it halfway: that form needs the screen, and it is waiting for you in the Workshop. +## 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. -Not the tokens as they are written, not the activity line, not the detail of the tools used. A messaging app is not a timeline: pouring a token 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. +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. +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 diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index 23da590..e00b402 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -126,13 +126,25 @@ L'échéance de l'Atelier s'applique ici aussi : **sans réponse au bout d'un qu Une question à choix vous parvient de la même façon, en boutons. Si elle en compte plusieurs, je vous le dis sans y répondre à moitié : ce formulaire-là demande l'écran, et il vous attend dans l'Atelier. +## 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. -Ni les tokens au fil de leur écriture, ni la ligne d'activité, ni le détail des outils employés. Une messagerie n'est pas une timeline : y déverser un flux de tokens le rendrait illisible et noierait ce qui demande une réponse. Le fil complet est dans l'Atelier, et le **Rejeu** le garde. +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. +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 From baa9832a2094c0984c543eb88173b35fa44a9134 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:02:50 +0200 Subject: [PATCH 15/28] Le repli d'un document reste un message riche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le barreau intermédiaire de `envoieRendu` rendait le document en HTML. Mesuré sur 162 pages de deux dépôts réels, il ne pouvait partir que sur 20 % d'entre elles : le HTML pèse 10 à 16 % de plus que sa source pour une limite huit fois plus basse, si bien qu'il échouait précisément sur les documents qui en avaient besoin — et retombait alors sur du texte nu coupé à 4 000 caractères. Il ne protégeait pas non plus de ce qu'on croyait : un bloc mal formé par `riche.ts` produit une structure légale qui s'affiche mal, l'API répond `ok: true`, et le repli ne se déclenche jamais. Il ne couvrait que le refus de charge, où le HTML est le plus faible des deux. Le repli est désormais le même appel avec un bloc unique : le document tel quel dans un `paragraph`. Mesuré contre l'API — les retours à la ligne et les lignes vides sont conservés, en texte proportionnel. On perd la mise en forme, on garde la mise en page, les 32 768 caractères et le repli « Afficher plus ». `pre` conservait autant mais enfermait la prose dans un encadré de code. Ce qui disparaît avec : un second analyseur Markdown qui dérivait du premier — la fusion des citations corrigée dans `riche.ts` n'y avait jamais été portée. `markdown.ts` se réduit à la pagination, et son défaut de bloc vide est corrigé au passage : la clôture d'un bloc de code reste sur la page qui l'a ouvert, au lieu d'ouvrir un bloc aussitôt refermé sur la suivante. Les bornes de Telegram passent dans `telegram.ts`, qui est le seul à avoir à les connaître. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/blocs-riches.md | 11 +- server/passerelle/index.ts | 23 +-- server/passerelle/markdown.ts | 252 ++--------------------------- server/passerelle/riche.ts | 5 +- server/passerelle/telegram.ts | 84 +++++----- src/help/sections/en/passerelle.md | 2 +- src/help/sections/fr/passerelle.md | 2 +- test/passerelle-markdown.test.ts | 145 ++++------------- 8 files changed, 102 insertions(+), 422 deletions(-) diff --git a/server/passerelle/blocs-riches.md b/server/passerelle/blocs-riches.md index 2987289..a6e67d2 100644 --- a/server/passerelle/blocs-riches.md +++ b/server/passerelle/blocs-riches.md @@ -59,11 +59,12 @@ Elles sont dans la spec, section _Rich Message Limits_ : | 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**, et c'est la seule qui puisse mordre : la -pagination compte des caractères, pas des blocs. Un document fait d'une longue liste — six -cents lignes courtes — tiendrait sous 32 768 caractères tout en dépassant les 500 items. -L'envoi échouerait alors et retomberait sur le HTML, donc rien ne se perd : c'est pour cela -que ce n'est pas corrigé, et non parce que le cas n'existe pas. +**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é. --- diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 9554142..60dcfde 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -45,7 +45,7 @@ import { type Entree, type Noeud, } from './projets.ts'; -import { echappe, enHtml, paginer } from './markdown.ts'; +import { paginer } from './markdown.ts'; import { enBlocs, MAX_RICHE, type InputRichBlock } from './riche.ts'; import { boutons, elargi, grille, Telegram } from './telegram.ts'; import { Battement } from './activite.ts'; @@ -66,15 +66,6 @@ interface Journal { warn: (message: string) => void; } -/** - * Ce qu'un message peut peser chez Telegram. - * - * Une réponse d'agent dépasse volontiers cette taille. On tronque plutôt que de - * laisser l'envoi échouer en silence : un texte coupé se lit, un texte perdu ne - * se voit pas. - */ -const MAX_TEXTE = 4_000; - /** Ce que la Passerelle garde d'une conversation. */ interface Fil { runId: string; @@ -419,12 +410,10 @@ async function page(chatId: number, rang: number, numero: number): Promise { type: 'heading', text: entete, size: 3 }, ...(markdown ? enBlocs(source) : [{ type: 'pre' as const, text: source }]), ]; - const corps = markdown ? enHtml(source) : `
${echappe(source)}
`; await tg.envoieRendu( chatId, blocs, - `${echappe(entete)}\n\n${corps}`, `${entete}\n\n${source}`, navigation(rang, index, pages.length), ); @@ -450,7 +439,7 @@ async function repond(chatId: number, texte: string): Promise { // 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), enHtml(source), tronque(source)); + await tg.envoieRendu(chatId, enBlocs(source), source); } /** Ce fichier se lit-il comme du Markdown ? */ @@ -474,10 +463,6 @@ function navigation(rang: number, index: number, total: number): InlineKeyboardM return boutons(paires); } -function tronque(texte: string): string { - return texte.length > MAX_TEXTE ? `${texte.slice(0, MAX_TEXTE)}…` : texte; -} - /** Un libellé de bouton : Telegram les veut courts, et les tronque mal. */ function tronqueBouton(texte: string): string { return texte.length > 32 ? `${texte.slice(0, 31)}…` : texte; @@ -632,7 +617,7 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< fil.asks.set(demande.id, demande.questions); await tg.envoie( chatId, - tronque(`${premiere.header}\n\n${premiere.question}`), + `${premiere.header}\n\n${premiere.question}`, boutons( premiere.options .slice(0, 4) @@ -722,7 +707,7 @@ async function traite(chatId: number, brut: string): Promise { lignes.push(`• ${s.cwd || '?'} — ${etat}${s.waitingFor ? ` (${s.waitingFor})` : ''}`); } } - await tg.envoie(chatId, tronque(lignes.join('\n'))); + await tg.envoie(chatId, lignes.join('\n')); return; } diff --git a/server/passerelle/markdown.ts b/server/passerelle/markdown.ts index 35d358a..7548027 100644 --- a/server/passerelle/markdown.ts +++ b/server/passerelle/markdown.ts @@ -1,244 +1,11 @@ -// Rendre un document Markdown lisible dans une conversation. +// Couper un document en pages, sans casser ce qui est à cheval. // -// Telegram n'affiche pas le Markdown. Il affiche un **sous-ensemble de HTML** — -// `b`, `i`, `s`, `u`, `code`, `pre`, `a`, `blockquote`, et rien d'autre. Ni -// titres, ni listes, ni tableaux : un `

` ou un `
    ` fait échouer l'envoi -// en 400, et le `.md` brut affiché tel quel noie le texte sous sa ponctuation. -// -// D'où cette traduction, qui ne cherche pas la fidélité mais la **lisibilité au -// pouce** : un titre devient du gras, une puce devient une puce, un tableau -// reste en chasse fixe parce que c'est son alignement qui le rend lisible, et -// tout le reste tombe en texte simple. +// 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. -/** Ce que Telegram accepte dans un message, balises comprises. */ -export const MAX_MESSAGE = 4096; - -/** - * Ce qu'une page prend au document source. - * - * Nettement sous la limite : la traduction *ajoute* des balises, et un document - * dense en `code` peut gagner un tiers de sa taille. La marge évite d'avoir à - * re-découper après coup, ce qui couperait au mauvais endroit. - */ -export const PAGE_SOURCE = 2_600; - -/** Les trois caractères qui feraient lire une balise là où il n'y en a pas. */ -export function echappe(texte: string): string { - return texte.replace(/&/g, '&').replace(//g, '>'); -} - -/** - * Les transformations en ligne, sur du texte **déjà échappé**. - * - * L'ordre n'est pas indifférent : le code littéral part en premier et revient en - * dernier, sous forme de jetons, pour qu'un `**` à l'intérieur d'un `code` reste - * du code et ne devienne pas du gras. - */ -function enLigne(texte: string): string { - const litteraux: string[] = []; - // `\uE000` est le premier point de la zone à usage privé : aucun document - // ne le porte, et il est retiré de l’entrée au nettoyage — un jeton ne peut donc - // jamais entrer en collision avec le texte. Un caractère de contrôle ferait le - // même office, mais `no-control-regex` le refuse à raison : en regex, il ne se - // relit pas. - const jeton = (i: number): string => `\uE000${i}\uE000`; - - let out = texte.replace(/`([^`]+)`/g, (_all, code: string) => { - litteraux.push(`${code}`); - return jeton(litteraux.length - 1); - }); - - // Les liens avant l'emphase : un libellé en gras dedans doit rester dans le - // libellé, pas couper la balise en deux. - out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_all, libelle: string, url: string) => { - // Une URL n'est reprise que si elle mène quelque part de connu : un - // `javascript:` dans un href est refusé par Telegram, et n'a rien à faire - // dans un document qu'on relaie. - if (!/^(https?:\/\/|mailto:)/i.test(url)) return libelle; - return `${libelle}`; - }); - - out = out - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s.,;:!?)]|$)/g, '$1$2') - .replace(/(^|[\s(])_([^_\n]+)_(?=[\s.,;:!?)]|$)/g, '$1$2') - .replace(/~~([^~]+)~~/g, '$1'); - - return out.replace(/\uE000(\d+)\uE000/g, (_all, i: string) => litteraux[Number(i)] ?? ''); -} - -/** Une ligne de tableau Markdown — celles qui ne servent qu'à l'alignement. */ -const SEPARATEUR_TABLEAU = /^\s*\|?[\s:|-]+\|[\s:|-]*$/; - -/** - * Au-delà de quelle largeur un tableau cesse d'être lisible en chasse fixe. - * - * Mesuré contre l'API, pas déduit : **Telegram n'a aucune balise de tableau** - * pour les bots — `

` est refusé net (« Unsupported start tag »), tout - * comme `
    ` et `

    `. Un tableau ne peut donc être qu'un `
    `, et un
    - * `
    ` trop large ne défile pas sur un téléphone : il **passe à la ligne**,
    - * ce qui détruit exactement l'alignement qui le rendait lisible.
    - *
    - * Quarante colonnes est ce qu'un écran de téléphone tient sans replier. Au-delà,
    - * mieux vaut renoncer à la forme tabulaire que la voir se disloquer.
    - */
    -const LARGEUR_TABLEAU = 40;
    -
    -/** 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());
    -}
    -
    -/**
    - * Un tableau, rendu de la façon qui reste lisible.
    - *
    - * Deux formes, et le choix se fait sur la largeur :
    - *
    - *  - **étroit** — un `
    ` aux colonnes recalculées au plus juste. Le padding
    - *    du document est refait plutôt que repris : un tableau écrit large dans le
    - *    fichier tient souvent une fois ses colonnes serrées.
    - *  - **large** — une fiche par ligne : la première cellule en titre, puis
    - *    `en-tête : valeur`. On perd la comparaison colonne à colonne, on garde ce
    - *    que chaque ligne dit — et c'est tout ce qui survivait au repli automatique.
    - */
    -function rendTableau(lignes: string[]): string[] {
    -  const grille = lignes.filter((l) => !SEPARATEUR_TABLEAU.test(l)).map(cellules);
    -  if (!grille.length) return [];
    -
    -  const colonnes = Math.max(...grille.map((r) => r.length));
    -  const largeurs = Array.from({ length: colonnes }, (_, c) =>
    -    Math.max(...grille.map((r) => (r[c] ?? '').length)),
    -  );
    -  // `| ` + ` | ` entre colonnes + ` |` : la largeur qu'aurait la forme serrée.
    -  const largeur = largeurs.reduce((a, b) => a + b, 0) + 3 * colonnes + 1;
    -
    -  if (largeur <= LARGEUR_TABLEAU) {
    -    const lignesRendues = grille.map(
    -      (r) => `| ${largeurs.map((w, c) => (r[c] ?? '').padEnd(w)).join(' | ')} |`,
    -    );
    -    return ['
    ', ...lignesRendues.map(echappe), '
    ']; - } - - const [entetes, ...corps] = grille; - if (!entetes || !corps.length) { - return ['
    ', ...grille.map((r) => echappe(r.join(' | '))), '
    ']; - } - - const out: string[] = []; - for (const rangee of corps) { - const titre = rangee[0] ?? ''; - out.push('', `${enLigne(echappe(titre))}`); - for (let c = 1; c < colonnes; c++) { - const valeur = rangee[c] ?? ''; - if (!valeur) continue; - const entete = entetes[c] ?? ''; - out.push(` ${enLigne(echappe(entete))} : ${enLigne(echappe(valeur))}`); - } - } - return out; -} - -/** - * Le document, traduit pour Telegram. - * - * Travaille ligne à ligne, avec deux états qui ne se devinent pas d'une ligne - * seule : on est dans un bloc de code, ou dans un tableau. Les deux se rendent - * en chasse fixe et échappent à toute transformation — le premier parce que - * c'est du code, le second parce que seul l'alignement le rend lisible. - */ -export function enHtml(markdown: string): string { - const lignes = markdown - .replace(/\uE000/g, '') - .replace(/\r\n?/g, '\n') - .split('\n'); - const out: string[] = []; - let dansCode = false; - let tableau: string[] = []; - - const fermeTableau = (): void => { - if (!tableau.length) return; - out.push(...rendTableau(tableau)); - tableau = []; - }; - - for (const ligne of lignes) { - const cloture = /^\s*```/.test(ligne); - - if (dansCode) { - if (cloture) { - out.push('
    '); - dansCode = false; - } else { - out.push(echappe(ligne)); - } - continue; - } - - if (cloture) { - fermeTableau(); - const langue = ligne.replace(/^\s*```/, '').trim(); - out.push(langue ? `
    ` : '
    ');
    -      dansCode = true;
    -      continue;
    -    }
    -
    -    // Un tableau est mis de côté jusqu'à sa dernière ligne : sa forme ne se
    -    // décide qu'une fois sa largeur connue — voir `rendTableau`.
    -    if (/^\s*\|.*\|\s*$/.test(ligne)) {
    -      tableau.push(ligne);
    -      continue;
    -    }
    -    fermeTableau();
    -
    -    const titre = /^(#{1,6})\s+(.*)$/.exec(ligne);
    -    if (titre) {
    -      // Pas de niveaux : Telegram n'a qu'un gras. Une ligne vide avant fait le
    -      // découpage visuel que la taille de police ferait ailleurs.
    -      out.push('', `${enLigne(echappe(titre[2] ?? ''))}`);
    -      continue;
    -    }
    -
    -    const puce = /^(\s*)[-*+]\s+(.*)$/.exec(ligne);
    -    if (puce) {
    -      // L'indentation devient un retrait visible : deux espaces par niveau,
    -      // sinon une sous-liste se confond avec sa mère.
    -      const retrait = ' '.repeat(Math.floor((puce[1] ?? '').length / 2) * 2);
    -      out.push(`${retrait}• ${enLigne(echappe(puce[2] ?? ''))}`);
    -      continue;
    -    }
    -
    -    const citation = /^\s*>\s?(.*)$/.exec(ligne);
    -    if (citation) {
    -      out.push(`
    ${enLigne(echappe(citation[1] ?? ''))}
    `); - continue; - } - - if (/^\s*([-*_])\1{2,}\s*$/.test(ligne)) { - out.push('──────────'); - continue; - } - - out.push(enLigne(echappe(ligne))); - } - - // Un document qui se termine dans un bloc ouvert — page coupée, fichier - // tronqué — laisserait une balise en l'air, et Telegram refuserait tout. - if (dansCode) out.push('
    '); - fermeTableau(); - - return out - .join('\n') - .replace(/\n{3,}/g, '\n\n') - .trim(); -} - /** * Le document en pages, coupées sur des frontières de lignes. * @@ -246,8 +13,11 @@ export function enHtml(markdown: string): string { * 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 = PAGE_SOURCE): string[] { +export function paginer(markdown: string, max: number): string[] { const lignes = markdown.replace(/\r\n?/g, '\n').split('\n'); const pages: string[] = []; let courante: string[] = []; @@ -267,7 +37,11 @@ export function paginer(markdown: string, max = PAGE_SOURCE): string[] { // 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]) { - if (taille + part.length + 1 > max) cloture(); + 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; diff --git a/server/passerelle/riche.ts b/server/passerelle/riche.ts index 4775b10..476660c 100644 --- a/server/passerelle/riche.ts +++ b/server/passerelle/riche.ts @@ -1,9 +1,8 @@ // 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. C'est ce qui obligeait à rendre -// un tableau en chasse fixe, où il se disloque dès qu'il dépasse la largeur d'un -// téléphone. +// **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 diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts index 12f342b..7b86d1e 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -10,8 +10,8 @@ // - 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 (riche → HTML → texte nu), et la garde qui filtre les -// conversations avant tout traitement. +// 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 @@ -22,7 +22,15 @@ import type { InlineKeyboardMarkup, InputRichBlock as InputRichBlockLib, } from 'node-telegram-bot-api'; -import type { InputRichBlock } from './riche.ts'; +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 { @@ -246,12 +254,17 @@ export class Telegram { }); } - /** Envoie un texte. `clavier` ajoute des boutons sous le message. */ + /** + * 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: texte, + text: borne(texte, MAX_TEXTE), ...(clavier ? { reply_markup: clavier } : {}), }); } catch { @@ -313,15 +326,20 @@ export class Telegram { /** * Envoie un document, du plus riche au plus sûr. * - * Trois tentatives, dans cet ordre, et chacune sait faire ce que la suivante - * ne fait pas : + * 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. **`sendRichMessage`** — de vrais tableaux, avec bordures, et 32 768 - * caractères. C'est la seule voie qui rende un tableau lisible. - * 2. **HTML** — pas de tableaux, mais du gras, du code et des citations. - * Sert si l'API riche est indisponible sur ce compte ou refuse le - * document. - * 3. **texte nu** — ne peut échouer que si le réseau est coupé. + * 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. @@ -330,42 +348,34 @@ export class Telegram { async envoieRendu( chatId: number, blocs: InputRichBlock[], - html: string, brut: string, clavier?: InlineKeyboardMarkup, - ): Promise<'riche' | 'html' | 'brut'> { + ): Promise<'riche' | 'nu' | 'brut'> { 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 { - await this.api.sendRichMessage({ - chat_id: chatId, - rich_message: { - blocks: blocs, - // 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. - skip_entity_detection: true, - }, - ...markup, - }); + await this.api.sendRichMessage(riche(blocs)); return 'riche'; } catch { - /* on tente la mise en forme simple */ + /* la structure a été refusée ; le texte, lui, tient peut-être */ } } try { - await this.api.sendMessage({ - chat_id: chatId, - text: html, - parse_mode: 'HTML', - link_preview_options: { is_disabled: true }, - ...markup, - }); - return 'html'; + await this.api.sendRichMessage(riche([{ type: 'paragraph', text: borne(brut, MAX_RICHE) }])); + return 'nu'; } catch { await this.envoie(chatId, brut, clavier); return 'brut'; diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index 51f8767..d128d18 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -80,7 +80,7 @@ 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 fall back to simple formatting, then to plain text. An ugly document beats a missing one. +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 diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index e00b402..5062df5 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -80,7 +80,7 @@ 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 retombe sur une mise en forme simple, puis sur le texte nu. Un document laid vaut mieux qu'un document disparu. +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 diff --git a/test/passerelle-markdown.test.ts b/test/passerelle-markdown.test.ts index 6042772..7c8fbb2 100644 --- a/test/passerelle-markdown.test.ts +++ b/test/passerelle-markdown.test.ts @@ -1,149 +1,60 @@ -// Rendre un Markdown lisible dans une conversation, sans casser l'envoi. +// Couper un document en pages sans casser ce qui est à cheval. // -// Deux risques, et un seul se voit à l'œil : un rendu laid, et un message que -// Telegram **refuse** parce qu'une balise traîne ouverte ou qu'un `<` du -// document a été pris pour du balisage. Le second est le vrai danger — l'envoi -// échoue en bloc, et le document disparaît. +// 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 { echappe, enHtml, paginer, PAGE_SOURCE } from '../server/passerelle/markdown.ts'; +import { paginer } from '../server/passerelle/markdown.ts'; -describe('echappe', () => { - it('neutralise ce qui se lirait comme du balisage', () => { - expect(echappe('a < b & c > d')).toBe('a < b & c > d'); - }); - - it('échappe l’esperluette avant tout, sinon elle mange les autres', () => { - // `<` produit par la première passe ne doit pas devenir `&lt;`. - expect(echappe('<')).toBe('<'); - }); -}); - -describe('enHtml', () => { - it('rend les titres en gras, faute de niveaux chez Telegram', () => { - expect(enHtml('# Titre')).toBe('Titre'); - expect(enHtml('### Sous-titre')).toBe('Sous-titre'); - }); - - it('rend les puces avec un vrai caractère de puce', () => { - expect(enHtml('- un\n- deux')).toBe('• un\n• deux'); - }); - - it('retrait les sous-listes, sinon elles se confondent avec leur mère', () => { - expect(enHtml('- mère\n - fille')).toBe('• mère\n • fille'); - }); - - it('garde le gras, l’italique et le barré', () => { - expect(enHtml('**gras** et *penché* et ~~barré~~')).toBe( - 'gras et penché et barré', - ); - }); - - it('ne prend pas un souligné de nom de fichier pour de l’italique', () => { - // Le cas réel qui a motivé la règle : `SPEC-014_notes-projet.md`. - expect(enHtml('SPEC-014_notes-de-projet-longues.md')).toBe( - 'SPEC-014_notes-de-projet-longues.md', - ); - }); - - it('ne touche pas à ce qui est entre accents graves', () => { - // `**` dans du code reste du code : c'est tout l'intérêt des jetons. - expect(enHtml('voir `a**b` ici')).toBe('voir a**b ici'); - }); - - it('rend un bloc de code et le referme', () => { - expect(enHtml('```ts\nconst a = 1;\n```')).toBe( - '
\nconst a = 1;\n
', - ); - }); - - it('referme un bloc de code laissé ouvert par une coupure', () => { - // Une page coupée en plein bloc laisserait sinon une balise en l'air, et - // Telegram refuserait le message entier. - expect(enHtml('```\ndu code')).toContain('
'); - }); - - it('échappe le contenu d’un bloc de code', () => { - expect(enHtml('```\nif (a < b) {}\n```')).toContain('if (a < b) {}'); - }); - - it('garde un tableau en chasse fixe et jette sa ligne d’alignement', () => { - const rendu = enHtml('| a | b |\n| --- | --- |\n| 1 | 2 |'); - expect(rendu).toBe('
\n| a | b |\n| 1 | 2 |\n
'); - }); - - it('rend un lien, et laisse tomber les protocoles qu’on ne relaie pas', () => { - expect(enHtml('[doc](https://exemple.fr)')).toBe('doc'); - expect(enHtml('[courriel](mailto:a@b.fr)')).toBe('courriel'); - // Un `javascript:` n'a rien à faire dans un href qu'on relaie : seul le - // libellé survit. Une URL à parenthèses imbriquées laisse en plus une - // parenthèse orpheline — la capture s'arrête à la première fermante. C'est - // le défaut classique du Markdown à une passe, et il est sans conséquence - // ici : le lien est refusé dans les deux cas. - expect(enHtml('[piège](javascript:alert)')).toBe('piège'); - expect(enHtml('[piège](javascript:alert(1))')).toBe('piège)'); - }); - - it('rend une citation et une ligne de séparation', () => { - expect(enHtml('> cité')).toBe('
cité
'); - expect(enHtml('---')).toBe('──────────'); - }); - - it('ne laisse jamais un jeton interne dans le rendu', () => { - // Les jetons qui mettent le code littéral de côté doivent tous être - // ressortis : un jeton qui survit s'afficherait tel quel, et le `` - // qu'il portait aurait disparu. - const JETON = String.fromCharCode(0xe000); - expect(enHtml('`a` et `b` et du texte')).not.toContain(JETON); - expect(enHtml('`a` et `b`')).toBe('a et b'); - }); - - it('désamorce un jeton que le document porterait lui-même', () => { - // Un document qui contiendrait ce caractère pourrait sinon faire ressortir - // un littéral qui n'est pas le sien. Le nettoyage retire le caractère et - // **garde son voisinage** : le texte n'est pas amputé, la contrefaçon ne - // ressemble plus à un jeton. - const JETON = String.fromCharCode(0xe000); - expect(enHtml(`avant${JETON}0${JETON}après`)).toBe('avant0après'); - }); -}); +/** 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')).toEqual(['court']); + expect(paginer('court', PAGE)).toEqual(['court']); }); it('rend une page même pour un document vide', () => { - expect(paginer('')).toEqual(['']); + 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); + const pages = paginer(doc, PAGE); expect(pages.length).toBeGreaterThan(1); - for (const p of pages) expect(p.length).toBeLessThanOrEqual(PAGE_SOURCE); + 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', () => { - // Sans cela, la clôture ``` se retrouve en tête de la page suivante où elle - // *ouvre* un bloc au lieu de le fermer, et tout le reste part en code. const gros = Array.from({ length: 400 }, (_, i) => `code ${i}`).join('\n'); - const pages = paginer('```ts\n' + gros + '\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); - // Et chaque page se rend seule, sans balise en l'air. + // 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) { - const html = enHtml(p); - expect(html.split('
').length).toBe(html.split('
').length); + 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_SOURCE * 2 + 10)); + const pages = paginer('x'.repeat(PAGE * 2 + 10), PAGE); expect(pages.length).toBeGreaterThanOrEqual(3); - for (const p of pages) expect(p.length).toBeLessThanOrEqual(PAGE_SOURCE); + for (const p of pages) expect(p.length).toBeLessThanOrEqual(PAGE); }); }); From 1d793d80dd7433db296402217c4bedad0311ccf9 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:13:35 +0200 Subject: [PATCH 16/28] Un clic ne peut plus emporter le serveur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tranche` était synchrone et lançait ses branches en `void`. Le `void` détachait la promesse : elle sortait du `try` de l'appelant, et du crochet d'erreur de la bibliothèque avec. Or `navigue` appelle `listProjects()` hors de tout `try` — un `~/.claude` déplacé, un disque qui répond mal, et c'était un rejet non traité. Il n'y a pas de `unhandledRejection` dans `server/`, donc c'est le défaut de Node : le process se termine. Un clic sur un bouton emportait le BFF et les sessions de l'Atelier avec lui. Le chemin des messages n'avait pas le défaut — `await traite(...)` est bien dans son `try`. C'est l'asymétrie entre les deux portes qui faisait le trou. Au passage, deux points relevés à la relecture : Un bouton de descente dont le rang ne désigne plus un dossier ne disait rien. Partout ailleurs le fichier répond `navigationPerimee`, avec pour raison écrite qu'un bouton sans effet passe pour une panne — ce cas-là contredisait sa propre règle. Et `applique` traite `waiting` comme une fin de tour, ce qui n'en est pas une : le commentaire le disait mal. C'est voulu, et c'est maintenant écrit — ce que l'agent a rédigé avant de demander part tout de suite, la demande suit avec ses boutons, et l'on voit ce qu'il veut faire avant d'avoir à le trancher. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/index.ts | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 60dcfde..a9051f3 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -330,7 +330,13 @@ async function navigue(chatId: number, ordre: string, argument: string): Promise if (ordre === 'd') { const dossier = descendre(v.racine, v.chemin); const enfant = dossier?.enfants[Number(argument)]; - if (!enfant || enfant.fichier) return; + // 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; @@ -570,7 +576,11 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< fil.tour.clear(); return; } - // Fin de tour : le moment où il y a enfin quelque chose à dire. + // 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(); @@ -790,13 +800,20 @@ async function traite(chatId: number, brut: string): Promise { } /** - * Un bouton pressé : une permission tranchée, ou une question répondue. + * 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. * - * Rien à attendre ici — les deux réponses dénouent une promesse tenue côté - * runner et rendent la main aussitôt. C'est le tour suspendu qui repart, pas - * cet appel. + * 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. */ -function tranche(chatId: number, donnee: string): void { +async function tranche(chatId: number, donnee: string): Promise { const [type, id, suffixe] = donnee.split(':'); if (!type || !id) return; @@ -807,12 +824,12 @@ function tranche(chatId: number, donnee: string): void { // La navigation seule admet un ordre sans argument — « retour », « ouvrir // ici » n'ont rien à désigner. if (type === 'n') { - void navigue(chatId, id, suffixe ?? ''); + await navigue(chatId, id, suffixe ?? ''); return; } if (!suffixe) return; if (type === 'v') { - void page(chatId, Number(id), Number(suffixe)); + await page(chatId, Number(id), Number(suffixe)); return; } @@ -888,11 +905,10 @@ async function boucle(tg: Telegram, chats: Set, journal: Journal): Promi }, async (bouton) => { try { - tranche(bouton.chatId, bouton.donnee); + await tranche(bouton.chatId, bouton.donnee); } catch (e) { journal.warn(`Passerelle : ${publicMessage(e)}`); } - return Promise.resolve(); }, (message) => journal.warn(`Passerelle : ${message}`), ); From 28b7ef92a6a6c1003819d65aa43a6a665da7625c Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:35:54 +0200 Subject: [PATCH 17/28] Les commandes n'ont plus qu'une source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois listes devaient s'accorder à la main : le `switch` de `routage.ts`, le texte de l'aide dans les deux catalogues, et ce que Telegram propose sous le `/`. Cette dernière était saisie dans `@BotFather`, donc hors du dépôt — invisible à toute relecture, et libre d'annoncer une commande retirée depuis. `commandes.ts` porte désormais la table. L'aide s'en compose, et `setMyCommands` en dérive au démarrage, une fois par langue : la langue de référence tient la liste par défaut, celle que voit un client dont la langue n'a pas la sienne. Le routage garde son `switch` — il traduit une commande en intention, ce qui n'est pas mécanique. C'est un test qui tient les deux listes égales, dans les deux sens : la lecture inverse est celle qui attrape le vrai oubli, une commande ajoutée au routage et jamais annoncée. Vérifié en le mettant en défaut. Les descriptions changent de forme parce qu'elles changent d'usage : la liste de Telegram les affiche seules, hors de l'aide. « son arborescence » ne veut plus rien dire quand il n'y a pas de phrase avant. La déclaration ne bloque rien : un refus ne coûte que l'autocomplétion, et les commandes restent reconnues puisque c'est `routage.ts` qui en juge. Co-Authored-By: Claude Opus 5 (1M context) --- server/i18n/en.ts | 31 +++++----- server/i18n/fr.ts | 43 ++++++++------ server/passerelle/commandes.ts | 94 +++++++++++++++++++++++++++++++ server/passerelle/index.ts | 27 ++++++++- server/passerelle/telegram.ts | 23 ++++++++ test/passerelle-commandes.test.ts | 74 ++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 34 deletions(-) create mode 100644 server/passerelle/commandes.ts create mode 100644 test/passerelle-commandes.test.ts diff --git a/server/i18n/en.ts b/server/i18n/en.ts index 45ad9fa..64fcb61 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -79,22 +79,21 @@ const en: Catalog = { /** What AURA says in a messaging app. See the French catalogue for the why. */ passerelle: { - aide: [ - 'I drive the Workshop from this conversation.', - '', - 'Browsing, without starting anything:', - '/projets — the projects Claude Code knows, numbered.', - '/projet — its tree: you walk down folder by folder.', - '/voir — the contents of a file from the last list.', - '', - 'Working:', - '/atelier — I open a session on that project.', - '/sessions — what is running right now.', - '/stop — I interrupt the current turn.', - '/fin — I close this conversation’s session.', - '', - 'Any other message goes to the session as a turn.', - ].join('\n'), + 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.', + 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.', diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index 1dc1c84..17b3610 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -94,22 +94,33 @@ export default { * quelle session, quel dossier — là où l'écran le montrait déjà. */ passerelle: { - aide: [ - 'Je pilote l’Atelier depuis cette conversation.', - '', - 'Consulter, sans rien lancer :', - '/projets — les projets que Claude Code connaît, numérotés.', - '/projet — son arborescence : on descend dossier par dossier.', - '/voir — le contenu d’un fichier de la dernière liste.', - '', - 'Travailler :', - '/atelier — j’ouvre une session sur ce projet.', - '/sessions — ce qui tourne en ce moment.', - '/stop — j’interromps le tour en cours.', - '/fin — je ferme la session de cette conversation.', - '', - 'Tout autre message part à la session comme un tour.', - ].join('\n'), + /** + * 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. + */ + 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.', + 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.', diff --git a/server/passerelle/commandes.ts b/server/passerelle/commandes.ts new file mode 100644 index 0000000..c4dc878 --- /dev/null +++ b/server/passerelle/commandes.ts @@ -0,0 +1,94 @@ +// 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' }, + { 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/index.ts b/server/passerelle/index.ts index a9051f3..5e33c66 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -13,7 +13,7 @@ // (`agent/registry.ts`), la file d'entrée et les demandes en attente // (`agent/runner.ts`), la forme des messages (`shared/agent.ts`). -import { t } from '../i18n/index.ts'; +import { DEFAULT_LOCALE, SUPPORTED_LOCALES, t, withLocale } from '../i18n/index.ts'; import { publicMessage } from '../errors.ts'; import type { AgentUpsert, AskQuestion, PermissionAnswer } from '../../shared/agent.ts'; import { isPermissionMode } from '../../shared/agent.ts'; @@ -49,6 +49,7 @@ import { paginer } from './markdown.ts'; import { enBlocs, MAX_RICHE, type InputRichBlock } from './riche.ts'; import { boutons, elargi, grille, Telegram } from './telegram.ts'; import { Battement } from './activite.ts'; +import { aide, pourTelegram } from './commandes.ts'; import type { InlineKeyboardMarkup } from 'node-telegram-bot-api'; /** @@ -682,7 +683,7 @@ async function traite(chatId: number, brut: string): Promise { return; case 'aide': - await tg.envoie(chatId, t('passerelle.aide')); + await tg.envoie(chatId, aide()); return; case 'sessions': { @@ -882,6 +883,27 @@ export function demarrePasserelle(journal: Journal): void { void boucle(tg, chats, journal); } +/** + * Pose la liste des commandes chez Telegram, une fois par langue. + * + * 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. + * + * La langue de référence tient la liste **par défaut** — celle que voit un + * client dont la langue n'a pas la sienne. Les autres ont la leur, nommée. + * + * 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. + */ +async function declareCommandes(tg: Telegram, journal: Journal): Promise { + for (const langue of SUPPORTED_LOCALES) { + const liste = withLocale(langue, pourTelegram); + const ok = await tg.declare(liste, langue === DEFAULT_LOCALE ? undefined : langue); + if (!ok) journal.warn(`Passerelle : Telegram a refusé la liste des commandes (${langue}).`); + } +} + /** Le long-polling, jusqu'à l'extinction du serveur. */ async function boucle(tg: Telegram, chats: Set, journal: Journal): Promise { const nom = await tg.identite(); @@ -891,6 +913,7 @@ async function boucle(tg: Telegram, chats: Set, journal: Journal): Promi return; } journal.info(`Passerelle ouverte sur @${nom} — ${chats.size} conversation(s) autorisée(s).`); + await declareCommandes(tg, journal); tg.ecoute( // La garde passe avant tout traitement, et le silence est la réponse à un diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts index 7b86d1e..622f536 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -323,6 +323,29 @@ export class Telegram { } } + /** + * 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. + */ + async declare( + commandes: { command: string; description: string }[], + langue?: string, + ): Promise { + try { + await this.api.setMyCommands({ + commands: commandes, + ...(langue ? { language_code: langue } : {}), + }); + return true; + } catch { + return false; + } + } + /** * Envoie un document, du plus riche au plus sûr. * diff --git a/test/passerelle-commandes.test.ts b/test/passerelle-commandes.test.ts new file mode 100644 index 0000000..29dda9a --- /dev/null +++ b/test/passerelle-commandes.test.ts @@ -0,0 +1,74 @@ +// 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'; + +/** Les alias reconnus mais volontairement absents de la table. */ +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 —'); + }); +}); From 31cb6845eaf3084c7c3d2f142e2c8d3a5171cecc Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:40:33 +0200 Subject: [PATCH 18/28] =?UTF-8?q?Chaque=20langue=20a=20sa=20liste,=20la=20?= =?UTF-8?q?r=C3=A9f=C3=A9rence=20comprise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le mobile n'affichait aucune commande là où le web les affichait toutes. La cause se lit dans `getMyCommands` : la liste par défaut répondait, la même interrogée en `fr` était vide. N'ayant posé que le défaut pour le français — en jugeant qu'une liste `fr` explicite serait redondante —, un client réglé en français demandait `fr`, ne trouvait rien à aucun scope, et n'affichait rien. Le web retombait sur le défaut, le mobile non. Plutôt que de départager les deux clients, on ne laisse plus de repli à prendre : le défaut est posé, puis chaque langue reçoit la sienne, la référence comprise. La redondance est mesurée, pas décorative. C'était un raisonnement là où il fallait une mesure — le même défaut que `riche.ts` documente pour les blocs : l'API accepte sans erreur ce qui ne s'affichera pas. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/index.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 5e33c66..768e383 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -890,17 +890,25 @@ export function demarrePasserelle(journal: Journal): void { * 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. * - * La langue de référence tient la liste **par défaut** — celle que voit un - * client dont la langue n'a pas la sienne. Les autres ont la leur, nommée. + * Chaque langue reçoit la sienne, **la référence comprise**, et le défaut 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 défaut. 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. */ async function declareCommandes(tg: Telegram, journal: Journal): Promise { + const rate = (quoi: string): void => + journal.warn(`Passerelle : Telegram a refusé la liste des commandes (${quoi}).`); + + // Le défaut : ce que voit un client dont la langue n'est pas des nôtres. + if (!(await tg.declare(withLocale(DEFAULT_LOCALE, pourTelegram)))) rate('défaut'); + for (const langue of SUPPORTED_LOCALES) { - const liste = withLocale(langue, pourTelegram); - const ok = await tg.declare(liste, langue === DEFAULT_LOCALE ? undefined : langue); - if (!ok) journal.warn(`Passerelle : Telegram a refusé la liste des commandes (${langue}).`); + if (!(await tg.declare(withLocale(langue, pourTelegram), langue))) rate(langue); } } From abb9b514f65c3a725a447e8ae36cf71b38fcfd77 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:40:26 +0200 Subject: [PATCH 19/28] =?UTF-8?q?L'accueil=20constate=20au=20lieu=20de=20r?= =?UTF-8?q?=C3=A9citer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/start` était un alias d'`/aide` : le geste que Telegram propose de lui-même à qui ouvre la conversation répondait par la liste des huit commandes. C'est une référence, pas une porte d'entrée — elle arrive avant qu'on sache quoi demander. Il a donc son intention à lui. Deux lignes et trois boutons : ce que je suis, ce que je vois — le nombre de projets, la session déjà ouverte ici, ce qui tourne ailleurs — et par où entrer. `docs/voix.md` range l'accueil parmi les surfaces où le « je » doit porter ce que la formulation impersonnelle ne portait pas ; l'état du parc est cette information, un sommaire des boutons ne l'aurait pas été. L'accueil prend la place du message de navigation : « Projets » le réécrit au lieu d'empiler, si bien qu'il devient la première marche du même parcours que l'arborescence. « Sessions » et « Aide » produisent du contenu, pas un pas : elles partent dans leur propre message et laissent l'accueil en place — la même règle que `/voir`. Les boutons sont en rangée et non en `solo` : `solo` détache une action d'une liste, et ici il n'y a pas de liste. Leurs libellés perdent leur article pour tenir sur une rangée, et parce que `docs/voix.md` veut les boutons nominaux — `Projets`, comme `Autoriser`. `/aide` est inchangée et reste la référence. Le listage des sessions devient une fonction, les deux entrées qui y mènent devant aboutir au même endroit. Co-Authored-By: Claude Opus 5 (1M context) --- server/i18n/en.ts | 10 +++ server/i18n/fr.ts | 18 ++++ server/passerelle/index.ts | 131 ++++++++++++++++++++++-------- server/passerelle/routage.ts | 12 ++- test/passerelle-commandes.test.ts | 7 +- test/passerelle.test.ts | 11 ++- 6 files changed, 152 insertions(+), 37 deletions(-) diff --git a/server/i18n/en.ts b/server/i18n/en.ts index 64fcb61..e79abc8 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -79,6 +79,16 @@ const en: Catalog = { /** 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:', diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index 17b3610..b52a7fb 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -98,6 +98,24 @@ export default { * 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 :', diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 768e383..52c0279 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -232,6 +232,86 @@ async function ecranProjets(chatId: number, neuf: boolean): Promise { 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'); +} + +/** + * 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; @@ -306,6 +386,19 @@ async function navigue(chatId: number, ordre: string, argument: string): Promise 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)]; @@ -686,41 +779,13 @@ async function traite(chatId: number, brut: string): Promise { await tg.envoie(chatId, aide()); return; - case 'sessions': { - // Deux sources, et elles 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. - 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) { - await tg.envoie(chatId, t('passerelle.aucuneSession')); - return; - } + case 'sessions': + await tg.envoie(chatId, await etatDesSessions()); + return; - 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})` : ''}`); - } - } - await tg.envoie(chatId, lignes.join('\n')); + case 'accueil': + await ecranAccueil(chatId); return; - } case 'projets': await ecranProjets(chatId, true); diff --git a/server/passerelle/routage.ts b/server/passerelle/routage.ts index cce3033..d395524 100644 --- a/server/passerelle/routage.ts +++ b/server/passerelle/routage.ts @@ -30,6 +30,15 @@ export type Intention = /** 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. * @@ -123,8 +132,9 @@ export function parseIntention(brut: string): Intention { return { kind: 'sessions' }; case '/stop': return { kind: 'stop' }; - case '/aide': case '/start': + return { kind: 'accueil' }; + case '/aide': case '/help': return { kind: 'aide' }; default: diff --git a/test/passerelle-commandes.test.ts b/test/passerelle-commandes.test.ts index 29dda9a..60cb538 100644 --- a/test/passerelle-commandes.test.ts +++ b/test/passerelle-commandes.test.ts @@ -11,7 +11,12 @@ 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'; -/** Les alias reconnus mais volontairement absents de la table. */ +/** + * 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', () => { diff --git a/test/passerelle.test.ts b/test/passerelle.test.ts index 44a451c..f452822 100644 --- a/test/passerelle.test.ts +++ b/test/passerelle.test.ts @@ -118,12 +118,19 @@ describe('parseIntention', () => { }); }); - it('accepte les trois portes d’entrée de l’aide', () => { - for (const mot of ['/aide', '/start', '/help']) { + 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. From cb4fba43feff2a5dcd2d4caaf30f4ba2912bfb75 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:29:04 +0200 Subject: [PATCH 20/28] =?UTF-8?q?Une=20question=20se=20pose=20en=20entier,?= =?UTF-8?q?=20un=20contexte=20vid=C3=A9=20se=20dit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux silences de la Passerelle, relevés en revue et laissés en suspens. `AskUserQuestion` n'était traitée qu'au quart : la première option de la première question, le reste jeté, et un refus dès qu'il y en avait plusieurs. Deux pertes étaient muettes — `multiSelect` était ignoré, si bien qu'une question à choix multiples se répondait d'un seul clic ; et `description` et `preview` disparaissaient, alors qu'`ask.ts` s'était donné du mal pour que la seconde traverse son schéma zod. `passerelle/questions.ts` porte désormais le formulaire de `AskPrompt.vue`, sans rien y ajouter : une question par message, le choix multiple qui se coche, la réponse écrite quand aucune option ne convient. Même doctrine que `routage.ts` — il décide sans réseau, donc il se teste. Deux mesures le dessinent. Un message riche ne se réécrit pas : la bibliothèque expose `sendRichMessage` et aucun `editMessageRichText`. Cocher ne touche donc qu'au clavier, par `editMessageReplyMarkup` — ce qui est de toute façon ce qu'on veut, la question n'ayant aucune raison de bouger sous les doigts. Et l'écran quitté est désarmé avant le suivant : ses boutons ne portent qu'un rang, un clic tardif cocherait une option de la question d'après. Vu à l'écran, corrigé. La réponse écrite est interceptée dans le seul cas `parler`, après `parseIntention` : sous une question, `/stop` reste `/stop`. `snapshot` avait deux sites d'émission et un commentaire qui n'en nommait qu'un. L'abonnement rejoue l'historique — on le passe, et le premier reçu est le sien par construction, `subscribe` émettant avant d'enregistrer. L'autre est `resetConversation` : le contexte vient d'être vidé et la conversation n'en apprenait rien, continuant de parler à quelqu'un qui a tout oublié. Elle relaie maintenant les mots mêmes de l'écran. Rien ne peut le provoquer d'ici — `/clear` n'est pas une commande servie —, ce qui est la raison de le dire plutôt que l'inverse. Vérifié de bout en bout sur Telegram : trois écrans enchaînés, un `multiSelect` coché puis validé, deux maquettes ASCII rendues en blocs préformatés, une réponse tapée à la main acceptée, et un `/clear` lancé du dehors annoncé dans la conversation. Co-Authored-By: Claude Opus 5 (1M context) --- server/CLAUDE.md | 7 + server/i18n/en.ts | 7 +- server/i18n/fr.ts | 15 ++- server/passerelle/index.ts | 196 ++++++++++++++++++++++----- server/passerelle/questions.ts | 205 +++++++++++++++++++++++++++++ server/passerelle/telegram.ts | 62 ++++++++- src/help/sections/en/passerelle.md | 12 +- src/help/sections/fr/passerelle.md | 12 +- test/passerelle-questions.test.ts | 174 ++++++++++++++++++++++++ 9 files changed, 640 insertions(+), 50 deletions(-) create mode 100644 server/passerelle/questions.ts create mode 100644 test/passerelle-questions.test.ts diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 57ec87e..1f3c1d4 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -98,6 +98,13 @@ Le pouvoir accordé reste considérable — qui écrit dans une conversation aut 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. + 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`. diff --git a/server/i18n/en.ts b/server/i18n/en.ts index e79abc8..a232539 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -129,8 +129,11 @@ const en: Catalog = { autoriser: 'Allow', refuser: 'Deny', refuseDeLoin: 'Denied from the messaging app.', - questionTropRiche: - 'This question needs a form I cannot render here. It is waiting for you in the Workshop.', + 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: { diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index b52a7fb..137c5a1 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -171,8 +171,19 @@ export default { refuser: 'Refuser', /** Le motif transmis au modèle, et non à l'utilisateur : il reste bref. */ refuseDeLoin: 'Refusé depuis la messagerie.', - questionTropRiche: - 'Cette question demande un formulaire que je ne sais pas poser ici. Elle vous attend dans l’Atelier.', + /** + * 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. diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 52c0279..90629e9 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -15,7 +15,7 @@ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, t, withLocale } from '../i18n/index.ts'; import { publicMessage } from '../errors.ts'; -import type { AgentUpsert, AskQuestion, PermissionAnswer } from '../../shared/agent.ts'; +import type { AgentUpsert, PermissionAnswer } from '../../shared/agent.ts'; import { isPermissionMode } from '../../shared/agent.ts'; import { atCapacity, @@ -48,6 +48,14 @@ import { 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 { Battement } from './activite.ts'; import { aide, pourTelegram } from './commandes.ts'; import type { InlineKeyboardMarkup } from 'node-telegram-bot-api'; @@ -72,10 +80,26 @@ 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 questions en vol, pour retrouver l'option qu'un bouton désigne. */ - asks: Map; + /** Les formulaires en vol, pour retrouver ce qu'un bouton désigne. */ + asks: 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; /** La bulle éphémère qui dit que ça travaille, et à quoi. */ battement: Battement; } @@ -542,6 +566,70 @@ async function repond(chatId: number, texte: string): Promise { 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); @@ -563,11 +651,6 @@ function navigation(rang: number, index: number, total: number): InlineKeyboardM return boutons(paires); } -/** Un libellé de bouton : Telegram les veut courts, et les tronque mal. */ -function tronqueBouton(texte: string): string { - return texte.length > 32 ? `${texte.slice(0, 31)}…` : texte; -} - /** Le mode de permission des sessions ouvertes de loin. */ function mode(): string { const brut = (process.env.AURA_TELEGRAM_MODE ?? '').trim(); @@ -587,8 +670,10 @@ function attache(chatId: number, runner: SessionRunner): void { const fil: Fil = { runId: runner.session.runId, detache: () => {}, + abonne: false, tour: new Map(), asks: new Map(), + attente: null, battement: new Battement( { brouillon: async (id, draft, texte) => { @@ -640,10 +725,40 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< if (!tg) return; switch (upsert.kind) { - // `snapshot` rejoue tout l'historique à l'abonnement : le renvoyer - // inonderait la conversation d'un travail déjà lu. - case 'snapshot': + /** + * `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(); + 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': { @@ -708,32 +823,35 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< } 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; - const premiere = demande.questions[0]; - // Un formulaire à plusieurs questions ne se rend pas en boutons sans - // inventer un dialogue à étapes. On le dit plutôt que d'y répondre à - // moitié : l'écran de l'Atelier, lui, sait le poser en entier. - if (demande.questions.length !== 1 || !premiere) { - await tg.envoie(chatId, t('passerelle.questionTropRiche')); - return; - } - fil.asks.set(demande.id, demande.questions); - await tg.envoie( - chatId, - `${premiere.header}\n\n${premiere.question}`, - boutons( - premiere.options - .slice(0, 4) - .map((o, i) => ({ texte: tronqueBouton(o.label), donnee: `q:${demande.id}:${i}` })), - ), - ); + if (!demande.questions.length) return; + const f = formulaire(demande.id, demande.questions); + fil.asks.set(demande.id, f); + await poseQuestion(chatId, fil, f); return; } - case 'ask-settled': + /** + * 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; @@ -859,6 +977,16 @@ async function traite(chatId: number, brut: string): Promise { 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; } @@ -914,11 +1042,9 @@ async function tranche(chatId: number, donnee: string): Promise { } if (type === 'q') { - const question = fil.asks.get(id)?.[0]; - const option = question?.options[Number(suffixe)]; - if (!question || !option) return; - fil.asks.delete(id); - runner.answerAsk(id, { [question.question]: option.label }); + const f = fil.asks.get(id); + if (!f) return; + await avanceQuestion(chatId, fil, f, presse(f, suffixe)); } } 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/telegram.ts b/server/passerelle/telegram.ts index 622f536..95a4149 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -47,12 +47,31 @@ export interface BoutonPresse { 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 }))] }; @@ -323,6 +342,31 @@ export class Telegram { } } + /** + * 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 `/`. * @@ -373,7 +417,7 @@ export class Telegram { blocs: InputRichBlock[], brut: string, clavier?: InlineKeyboardMarkup, - ): Promise<'riche' | 'nu' | 'brut'> { + ): Promise { const markup = clavier ? { reply_markup: clavier } : {}; // Sans cela, Telegram fabrique des liens dans notre dos. Le piège est @@ -389,19 +433,23 @@ export class Telegram { if (blocs.length) { try { - await this.api.sendRichMessage(riche(blocs)); - return 'riche'; + 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 { - await this.api.sendRichMessage(riche([{ type: 'paragraph', text: borne(brut, MAX_RICHE) }])); - return 'nu'; + const envoye = await this.api.sendRichMessage( + riche([{ type: 'paragraph', text: borne(brut, MAX_RICHE) }]), + ); + return { voie: 'nu', messageId: envoye.message_id }; } catch { - await this.envoie(chatId, brut, clavier); - return 'brut'; + return { + voie: 'brut', + messageId: await this.envoieSuivi(chatId, borne(brut, MAX_TEXTE), clavier), + }; } } diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index d128d18..b558271 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -124,7 +124,15 @@ When the agent wants a tool the mode does not let through, I send you a message 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. -A multiple-choice question reaches you the same way, as buttons. If it holds several questions, I tell you rather than answering it halfway: that form needs the screen, and it is waiting for you in the Workshop. +## 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 @@ -156,6 +164,6 @@ A session does **not** survive a restart of the service, however. Write to me af ## What this does not replace -The Workshop shows what a messaging app cannot: the exact path a tool targets, a question's mockups, the context window, the commands left running in the background. The Gateway is for starting, watching and unblocking — not for working blind. +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 index 5062df5..af88def 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -124,7 +124,15 @@ Quand l'agent veut employer un outil que le mode ne laisse pas passer, je vous e 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. -Une question à choix vous parvient de la même façon, en boutons. Si elle en compte plusieurs, je vous le dis sans y répondre à moitié : ce formulaire-là demande l'écran, et il vous attend dans l'Atelier. +## 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 @@ -156,6 +164,6 @@ En revanche, une session **ne survit pas au redémarrage du service**. Si vous m ## Ce que cela ne remplace pas -L'Atelier montre ce que la messagerie ne peut pas : le chemin exact qu'un outil vise, les maquettes d'une question, 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. +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-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'); + }); +}); From 69fea4a1724c531c29405f1d9ea82bb215075295 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:46:00 +0200 Subject: [PATCH 21/28] =?UTF-8?q?La=20fen=C3=AAtre=20de=20contexte=20se=20?= =?UTF-8?q?dit,=20de=20loin=20comme=20de=20pr=C3=A8s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De loin, on ne savait pas où l'on en était. La conversation dit ce que l'agent répond, jamais la place qu'il lui reste — et une compaction, qui vide la fenêtre en plein travail, ne laissait aucune trace : `applique` ne relaie que les événements `assistant`. `/etat` rend la fenêtre, et le chiffre ne peut pas diverger de la page Contexte. Le runner le relève sur `message.message.usage` comme `input + cache_read + cache_creation`, la somme même dont `transcript.ts` ancre un tour. Pas de transcript à relire : c'est en mémoire, et c'est exact. Le cache y est compté — ce qui est relu occupe la fenêtre comme le reste, seul le prix diffère. Le runner relève et ne juge pas. La limite vient de `contextLimitFor`, qui existe pour une raison : 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. `pre_tokens` d'une compaction est la plus précoce des deux. Deux messages qu'AURA avance d'elle-même, et le manuel les nomme désormais : la compaction avec ses chiffres, et le franchissement des 80 % dit une seule fois. Ce seuil est le garde-fou de `contextFill`, recopié faute de pouvoir l'importer, et un test tient les deux nombres égaux. Co-Authored-By: Claude Opus 5 (1M context) --- server/CLAUDE.md | 9 ++ server/agent/runner.ts | 67 ++++++++++++++- server/i18n/en.ts | 7 ++ server/i18n/fr.ts | 34 ++++++++ server/passerelle/commandes.ts | 3 + server/passerelle/etat.ts | 100 ++++++++++++++++++++++ server/passerelle/index.ts | 115 ++++++++++++++++++++++++++ server/passerelle/routage.ts | 10 +++ src/help/sections/en/passerelle.md | 35 ++++++-- src/help/sections/fr/passerelle.md | 21 ++++- test/passerelle-etat.test.ts | 128 +++++++++++++++++++++++++++++ test/passerelle.test.ts | 7 ++ 12 files changed, 526 insertions(+), 10 deletions(-) create mode 100644 server/passerelle/etat.ts create mode 100644 test/passerelle-etat.test.ts diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1f3c1d4..3403228 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -105,6 +105,15 @@ mesurées le dessinent — **un message riche ne se réécrit pas** (la biblioth `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. + 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`. diff --git a/server/agent/runner.ts b/server/agent/runner.ts index fe7fffe..1f56157 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,20 @@ 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 }; /** 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 +307,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 +362,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 +837,28 @@ 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; + } + 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,6 +884,17 @@ 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') { + // 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); this.emit(this.translator.appendCompaction(message)); return; } @@ -848,6 +912,7 @@ 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': diff --git a/server/i18n/en.ts b/server/i18n/en.ts index a232539..1fdf325 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -99,6 +99,7 @@ const en: Catalog = { 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.', sessions: 'What is running right now.', stop: 'I interrupt the current turn.', fin: 'I close this conversation’s session.', @@ -125,6 +126,12 @@ const en: Catalog = { 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}.', + fenetrePleine: 'The window is {pourcent}% full — a compaction is coming.', permission: 'I would like to use {outil}.', autoriser: 'Allow', refuser: 'Deny', diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index 137c5a1..79aef5e 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -134,6 +134,7 @@ export default { 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.', sessions: 'Ce qui tourne en ce moment.', stop: 'J’interromps le tour en cours.', fin: 'Je ferme la session de cette conversation.', @@ -166,6 +167,39 @@ export default { 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 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', refuser: 'Refuser', diff --git a/server/passerelle/commandes.ts b/server/passerelle/commandes.ts index c4dc878..4ee28cb 100644 --- a/server/passerelle/commandes.ts +++ b/server/passerelle/commandes.ts @@ -45,6 +45,9 @@ export const COMMANDES: readonly Commande[] = [ { 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: 'sessions', groupe: 'travailler' }, { nom: 'stop', groupe: 'travailler' }, { nom: 'fin', groupe: 'travailler' }, diff --git a/server/passerelle/etat.ts b/server/passerelle/etat.ts new file mode 100644 index 0000000..a0fe95f --- /dev/null +++ b/server/passerelle/etat.ts @@ -0,0 +1,100 @@ +// 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 { 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. + * + * L'espace fine insécable est celle du français typographique, et elle tient + * dans un message : sans elle, `112400` se compte à la main. + */ +function nombre(n: number): string { + return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); +} diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 90629e9..4e959eb 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -56,6 +56,9 @@ import { reponses, type Formulaire, } from './questions.ts'; +import { alerte, lignes as lignesEtat, part, type Fenetre } from './etat.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'; @@ -100,6 +103,15 @@ interface Fil { * 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; } @@ -291,6 +303,73 @@ async function etatDesSessions(): Promise { 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. * @@ -674,6 +753,7 @@ function attache(chatId: number, runner: SessionRunner): void { tour: new Map(), asks: new Map(), attente: null, + alerte: false, battement: new Battement( { brouillon: async (id, draft, texte) => { @@ -748,6 +828,9 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< // 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) @@ -763,6 +846,30 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< 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) { + // 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', { + avant: event.compaction.preTokens, + apres: event.compaction.postTokens, + }), + ); + return; + } + if (event.kind !== 'assistant' || event.isSidechain) return; const texte = event.blocks .filter((b) => b.kind === 'text') @@ -801,6 +908,10 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< } 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; } @@ -897,6 +1008,10 @@ async function traite(chatId: number, brut: string): Promise { await tg.envoie(chatId, aide()); return; + case 'etat': + await ecranEtat(chatId); + return; + case 'sessions': await tg.envoie(chatId, await etatDesSessions()); return; diff --git a/server/passerelle/routage.ts b/server/passerelle/routage.ts index d395524..21ee210 100644 --- a/server/passerelle/routage.ts +++ b/server/passerelle/routage.ts @@ -25,6 +25,14 @@ export type Intention = | { 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' } /** Ce qui tourne en ce moment, toutes conversations confondues. */ | { kind: 'sessions' } /** Interrompre le tour en cours sans fermer la session. */ @@ -128,6 +136,8 @@ export function parseIntention(brut: string): Intention { } case '/fin': return { kind: 'fin' }; + case '/etat': + return { kind: 'etat' }; case '/sessions': return { kind: 'sessions' }; case '/stop': diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index b558271..223946f 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -92,13 +92,14 @@ The file is re-read for each page. It may therefore have changed between two pag ### Working -| Message | What I do | -| -------------- | ------------------------------------- | -| `/atelier ` | I open a session on that project | -| `/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 | +| Message | What I do | +| -------------- | ----------------------------------------- | +| `/atelier ` | I open a session on that project | +| `/etat` | Where this session stands, and its window | +| `/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. @@ -106,6 +107,22 @@ One conversation holds **one** session at a time: opening one closes the previou **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. + +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. @@ -146,7 +163,9 @@ Two caveats I would rather state: the bubble needs a **private conversation** an ## What I do not send you -**The agent's answer at the end of a turn, and the requests awaiting a decision.** Nothing else. +**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. 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. diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index af88def..b9b64d0 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -95,6 +95,7 @@ Le fichier est relu à chaque page. Il peut donc avoir changé entre deux pages | 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 | | `/sessions` | Je liste ce qui tourne, en deux groupes | | `/stop` | J'interromps le tour en cours | | `/fin` | Je ferme la session de cette conversation | @@ -106,6 +107,22 @@ Une conversation tient **une** session à la fois : en ouvrir une referme la pr **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. + +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. @@ -146,7 +163,9 @@ Deux réserves, que je préfère dire : cette bulle demande une **conversation p ## 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. +**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. 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. diff --git a/test/passerelle-etat.test.ts b/test/passerelle-etat.test.ts new file mode 100644 index 0000000..d24dadc --- /dev/null +++ b/test/passerelle-etat.test.ts @@ -0,0 +1,128 @@ +// 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('sépare les milliers d’une espace insécable', () => { + // Sans elle, Telegram est libre de couper le nombre en fin de ligne — et un + // nombre coupé en deux se relit deux fois. + const texte = lignes(fenetre(112_400), 'x', 'y', 'default').join('\n'); + expect(texte).toContain('112\u202f400'); + expect(texte).not.toContain('112400'); + }); +}); + +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.test.ts b/test/passerelle.test.ts index f452822..384f44b 100644 --- a/test/passerelle.test.ts +++ b/test/passerelle.test.ts @@ -100,6 +100,13 @@ describe('parseIntention', () => { 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('/sessions')).toEqual({ kind: 'sessions' }); + }); + it('retombe sur les projets quand /projet n’a pas d’argument', () => { expect(parseIntention('/projet')).toEqual({ kind: 'projets' }); }); From e76258da11b7b3e4996ceea5040855d9a6e015d6 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:59:53 +0200 Subject: [PATCH 22/28] =?UTF-8?q?Les=20nombres=20de=20la=20fen=C3=AAtre=20?= =?UTF-8?q?s'abr=C3=A8gent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. Sur un téléphone, où cette ligne est lue en une œillade, c'est la différence entre lire et déchiffrer. `Intl` plutôt qu'un formatage à la main : la forme compacte est une affaire de langue — `31 k` en français, `31K` en anglais, séparateur décimal compris. Aucune décimale, parce que les deux seules limites qui existent — 200 k et 1 M — sont exactes, et qu'un `1,0 M` laisserait croire à un arrondi qui n'a pas eu lieu. La compaction passait ses chiffres bruts, relevé à l'essai : la conversation annonçait « 31 k » sur une ligne et « 30549 » sur la suivante, pour la même fenêtre. Elle emprunte désormais le même formateur. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/etat.ts | 28 ++++++++++++++++++++++------ server/passerelle/index.ts | 9 ++++++--- test/passerelle-etat.test.ts | 21 +++++++++++++++------ 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/server/passerelle/etat.ts b/server/passerelle/etat.ts index a0fe95f..842aa4d 100644 --- a/server/passerelle/etat.ts +++ b/server/passerelle/etat.ts @@ -9,7 +9,7 @@ // 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 { t } from '../i18n/index.ts'; +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é. @@ -90,11 +90,27 @@ export function alerte(deja: boolean, ratio: number): boolean { } /** - * Un nombre de tokens tel qu'on le lit d'un coup d'œil. + * Un nombre de tokens tel qu'on le lit d'un coup d'œil : `31 k`, `1 M`. * - * L'espace fine insécable est celle du français typographique, et elle tient - * dans un message : sans elle, `112400` se compte à la main. + * 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à. */ -function nombre(n: number): string { - return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); +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 index 4e959eb..2c089e4 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -56,7 +56,7 @@ import { reponses, type Formulaire, } from './questions.ts'; -import { alerte, lignes as lignesEtat, part, type Fenetre } from './etat.ts'; +import { alerte, lignes as lignesEtat, nombre, part, type Fenetre } from './etat.ts'; import { contextLimitFor } from '../context.ts'; import { configuredLongWindow } from '../claude/model.ts'; import { Battement } from './activite.ts'; @@ -863,8 +863,11 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< await tg.envoie( chatId, t('passerelle.compaction', { - avant: event.compaction.preTokens, - apres: event.compaction.postTokens, + // 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; diff --git a/test/passerelle-etat.test.ts b/test/passerelle-etat.test.ts index d24dadc..a75f410 100644 --- a/test/passerelle-etat.test.ts +++ b/test/passerelle-etat.test.ts @@ -84,12 +84,21 @@ describe('ce que /etat écrit', () => { expect(texte).toContain('C:\\devl\\tos'); }); - it('sépare les milliers d’une espace insécable', () => { - // Sans elle, Telegram est libre de couper le nombre en fin de ligne — et un - // nombre coupé en deux se relit deux fois. - const texte = lignes(fenetre(112_400), 'x', 'y', 'default').join('\n'); - expect(texte).toContain('112\u202f400'); - expect(texte).not.toContain('112400'); + 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/); }); }); From 2efab4ecb8f23572ef01618043f81e4f26b4d56c Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:26:05 +0200 Subject: [PATCH 23/28] =?UTF-8?q?La=20compaction=20se=20d=C3=A9clenche=20d?= =?UTF-8?q?'ici,=20et=20se=20replie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux manques que `/etat` avait rendus visibles : on voyait la fenêtre se remplir sans pouvoir rien y faire, et la compaction s'annonçait sans dire ce qu'elle avait gardé. `/compacter` est la seule commande de Claude Code que la Passerelle relaie, et la surface le justifie — de loin, il n'y avait aucun autre geste. Elle passe par la file d'entrée comme un tour : c'est le CLI qui compacte. Le résumé, lui, n'arrive pas avec la frontière. Mesuré en instrumentant le flux SDK : il suit immédiatement, dans un message `user` marqué `isSynthetic` dont le contenu est une chaîne — et le `` qui vient après porte `isReplay`, ce qui les sépare. Le runner le capte et le pose sur l'événement de compaction par un `replace-event`, si bien que la compaction s'annonce tout de suite : un résumé qui ne viendrait pas ne l'emporterait pas dans son silence. Il se rend replié. Le bloc riche `details` existe — `blocs-riches.md` le donnait pour « la piste la plus sérieuse » —, il a été essayé et il rend : titre visible, corps qui s'ouvre au clic, formatage conservé et les 32 768 caractères du riche. L'« Afficher plus » automatique ne le remplace pas, ne s'étant pas déclenché sur sept mille caractères. Un défaut relevé à l'essai, et il dépasse ce chantier : les événements d'un fil partaient de front, si bien que le résumé — un gros message — doublait l'annonce qui le précédait. Ils se traitent désormais à la file. Co-Authored-By: Claude Opus 5 (1M context) --- server/CLAUDE.md | 13 +++++++ server/agent/runner.ts | 39 ++++++++++++++++++++- server/agent/translate.ts | 19 +++++++++++ server/i18n/en.ts | 2 ++ server/i18n/fr.ts | 10 ++++++ server/passerelle/blocs-riches.md | 40 +++++++++++----------- server/passerelle/commandes.ts | 1 + server/passerelle/index.ts | 55 +++++++++++++++++++++++++++++- server/passerelle/riche.ts | 9 +++++ server/passerelle/routage.ts | 10 ++++++ server/passerelle/telegram.ts | 38 +++++++++++++++++++++ src/help/sections/en/passerelle.md | 21 +++++++----- src/help/sections/fr/passerelle.md | 21 +++++++----- test/passerelle.test.ts | 1 + 14 files changed, 239 insertions(+), 40 deletions(-) diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 3403228..cce0ec8 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -114,6 +114,19 @@ seules deux preuves la révèlent : un contexte observé au-dessus de 200 k, ou (`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. + +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`. diff --git a/server/agent/runner.ts b/server/agent/runner.ts index 1f56157..2180d15 100644 --- a/server/agent/runner.ts +++ b/server/agent/runner.ts @@ -153,6 +153,15 @@ export class SessionRunner { * 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; @@ -859,6 +868,30 @@ export class SessionRunner { 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 @@ -895,7 +928,10 @@ export class SessionRunner { const avant = num(meta.pre_tokens); if (avant > this.fenetre.max) this.fenetre.max = avant; this.fenetre.tokens = num(meta.post_tokens); - this.emit(this.translator.appendCompaction(message)); + 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 @@ -916,6 +952,7 @@ export class SessionRunner { 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/i18n/en.ts b/server/i18n/en.ts index 1fdf325..ebe1142 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -100,6 +100,7 @@ const en: Catalog = { 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.', @@ -131,6 +132,7 @@ const en: Catalog = { 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', diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index 79aef5e..8a0162b 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -135,6 +135,7 @@ export default { 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.', @@ -192,6 +193,15 @@ export default { * 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. * diff --git a/server/passerelle/blocs-riches.md b/server/passerelle/blocs-riches.md index a6e67d2..84fbe4f 100644 --- a/server/passerelle/blocs-riches.md +++ b/server/passerelle/blocs-riches.md @@ -74,32 +74,32 @@ 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 | -| `blockquote` | `blocks[]`, `credit?` | `
      ` | lignes `>` consécutives, relues comme un document | -| `table` | `cells[][]`, `is_bordered?`, `is_striped?`, `caption?` | `
` | tableaux Markdown, bordures toujours | -| `divider` | — | `
` | `---`, `***`, `___` | +| `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 | -| ---------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `details` | Bloc repliable — `summary` toujours visible, `is_open?` | **La piste la plus sérieuse.** Rien en Markdown ne le déclenche, mais il replierait un long tableau ou un bloc de code derrière son titre. | -| `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. | +| `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. | --- diff --git a/server/passerelle/commandes.ts b/server/passerelle/commandes.ts index 4ee28cb..7283886 100644 --- a/server/passerelle/commandes.ts +++ b/server/passerelle/commandes.ts @@ -48,6 +48,7 @@ export const COMMANDES: readonly Commande[] = [ // 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' }, diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 2c089e4..885e1b2 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -767,8 +767,22 @@ function attache(chatId: number, runner: SessionRunner): void { 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) => { - void applique(chatId, fil, upsert); + file = file.then(() => applique(chatId, fil, upsert)).catch(() => {}); }); fils.set(chatId, fil); } @@ -857,6 +871,31 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< * 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; @@ -1015,6 +1054,20 @@ async function traite(chatId: number, brut: string): Promise { 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; diff --git a/server/passerelle/riche.ts b/server/passerelle/riche.ts index 476660c..e6f33aa 100644 --- a/server/passerelle/riche.ts +++ b/server/passerelle/riche.ts @@ -59,6 +59,15 @@ export type InputRichBlock = | { 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`. */ diff --git a/server/passerelle/routage.ts b/server/passerelle/routage.ts index 21ee210..901525d 100644 --- a/server/passerelle/routage.ts +++ b/server/passerelle/routage.ts @@ -33,6 +33,14 @@ export type Intention = * 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. */ @@ -138,6 +146,8 @@ export function parseIntention(brut: string): Intention { return { kind: 'fin' }; case '/etat': return { kind: 'etat' }; + case '/compacter': + return { kind: 'compacter' }; case '/sessions': return { kind: 'sessions' }; case '/stop': diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts index 95a4149..261cb46 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -316,6 +316,44 @@ export class Telegram { } } + /** + * 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é. * diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index 223946f..35a3d94 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -92,14 +92,15 @@ The file is re-read for each page. It may therefore have changed between two pag ### Working -| Message | What I do | -| -------------- | ----------------------------------------- | -| `/atelier ` | I open a session on that project | -| `/etat` | Where this session stands, and its window | -| `/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 | +| 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. @@ -121,6 +122,8 @@ The model is there because **the limit depends on it**: 200,000 tokens, or a mil 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 @@ -165,7 +168,7 @@ Two caveats I would rather state: the bubble needs a **private conversation** an **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. 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. +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. diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index b9b64d0..f2d7f76 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -92,14 +92,15 @@ Le fichier est relu à chaque page. Il peut donc avoir changé entre deux pages ### 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 | -| `/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 | +| 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. @@ -121,6 +122,8 @@ Le modèle figure là parce que **la limite en dépend** : 200 000 tokens, ou un 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 @@ -165,7 +168,7 @@ Deux réserves, que je préfère dire : cette bulle demande une **conversation p **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. 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é. +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. diff --git a/test/passerelle.test.ts b/test/passerelle.test.ts index 384f44b..78d22ba 100644 --- a/test/passerelle.test.ts +++ b/test/passerelle.test.ts @@ -104,6 +104,7 @@ describe('parseIntention', () => { // 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' }); }); From ddc2c6a550203d50ef4f545aec460a9112c58570 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:41:49 +0200 Subject: [PATCH 24/28] Un plan se lit avant de s'approuver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExitPlanMode arrivait comme une permission ordinaire : deux boutons et le nom de l'outil. Le plan lui-même vit dans `input.plan` et n'était pas montré — on approuvait donc un texte qu'on n'avait pas lu. C'est le seul appel dont l'argument *est* la décision. Il passe désormais par `envoieRendu`, comme un document : le markdown entier, mis en forme, et les deux boutons sous lui. Co-Authored-By: Claude Opus 5 (1M context) --- server/CLAUDE.md | 7 +++++ server/i18n/en.ts | 2 ++ server/i18n/fr.ts | 6 +++++ server/passerelle/index.ts | 19 ++++++++++++++ server/passerelle/plan.ts | 21 +++++++++++++++ src/help/sections/en/passerelle.md | 2 ++ src/help/sections/fr/passerelle.md | 2 ++ test/passerelle-plan.test.ts | 41 ++++++++++++++++++++++++++++++ 8 files changed, 100 insertions(+) create mode 100644 server/passerelle/plan.ts create mode 100644 test/passerelle-plan.test.ts diff --git a/server/CLAUDE.md b/server/CLAUDE.md index cce0ec8..67ec353 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -123,6 +123,13 @@ viendrait pas l'emporterait dans son silence. Elle le rend replié, par le bloc (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 é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. diff --git a/server/i18n/en.ts b/server/i18n/en.ts index ebe1142..0c40b1c 100644 --- a/server/i18n/en.ts +++ b/server/i18n/en.ts @@ -136,6 +136,8 @@ const en: Catalog = { 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}', diff --git a/server/i18n/fr.ts b/server/i18n/fr.ts index 8a0162b..f416719 100644 --- a/server/i18n/fr.ts +++ b/server/i18n/fr.ts @@ -212,6 +212,12 @@ export default { '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.', diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index 885e1b2..a3525b8 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -57,6 +57,7 @@ import { 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'; @@ -963,6 +964,24 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< // 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; + 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` }, + ]), + ); + return; + } const quoi = demande.title || demande.displayName || demande.toolName; await tg.envoie( chatId, 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/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index 35a3d94..402dcda 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -142,6 +142,8 @@ A message from a conversation that is not allowed gets **no reply**. That is del 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. + 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 diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index f2d7f76..68f96a2 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -142,6 +142,8 @@ Un message venu d'une conversation non autorisée reste **sans réponse**. C'est 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. + 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 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(''); + }); +}); From b0c889aacc17572ce57a2862f08e00cb8b477070 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:49:10 +0200 Subject: [PATCH 25/28] =?UTF-8?q?Une=20demande=20tranch=C3=A9e=20retire=20?= =?UTF-8?q?ses=20boutons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les boutons d'une permission restaient pressables après la réponse : on ne savait pas si le clic avait porté, et un fil relu donnait à croire qu'une question attendait encore. Le désarmement se fait sur `permission-settled`, jamais sur le clic — c'est le seul point de passage qui voie aussi les réponses données dans l'Atelier et les refus du garde-fou du quart d'heure. Co-Authored-By: Claude Opus 5 (1M context) --- server/CLAUDE.md | 4 ++++ server/passerelle/index.ts | 32 ++++++++++++++++++++++++++++-- src/help/sections/en/passerelle.md | 2 ++ src/help/sections/fr/passerelle.md | 2 ++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 67ec353..6e2c2f2 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -130,6 +130,10 @@ ici l'argument **est** la décision. Le CLI passe le markdown en clair dans `inp 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. diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index a3525b8..effc2f5 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -96,6 +96,15 @@ interface Fil { 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. * @@ -753,6 +762,7 @@ function attache(chatId: number, runner: SessionRunner): void { abonne: false, tour: new Map(), asks: new Map(), + permissions: new Map(), attente: null, alerte: false, battement: new Battement( @@ -971,7 +981,7 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< // 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; - await tg.envoieRendu( + const rendu = await tg.envoieRendu( chatId, enBlocs(coupe), coupe, @@ -980,10 +990,11 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< { 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; - await tg.envoie( + const messageId = await tg.envoieSuivi( chatId, t('passerelle.permission', { outil: quoi }), boutons([ @@ -991,6 +1002,23 @@ async function applique(chatId: number, fil: Fil, upsert: AgentUpsert): Promise< { 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; } diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index 402dcda..bbcc95a 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -144,6 +144,8 @@ When the agent wants a tool the mode does not let through, I send you a message 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 diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index 68f96a2..66c3bb3 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -144,6 +144,8 @@ Quand l'agent veut employer un outil que le mode ne laisse pas passer, je vous e 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 From 1b3e6e3cb76bfa5c929f70b52d78c659e9a27912 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:36:14 +0200 Subject: [PATCH 26/28] =?UTF-8?q?Le=20menu=20des=20commandes=20ne=20s'affi?= =?UTF-8?q?che=20plus=20que=20l=C3=A0=20o=C3=B9=20il=20sert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La liste était déclarée sur la portée par défaut, donc visible de quiconque ouvre le bot — et un bot est adressable de toute la Terre. Un inconnu n'obtenait déjà aucune réponse, mais il lisait le menu : ouvrir une session, lancer une commande, lire un fichier. C'est de l'obscurité et non de la sécurité, mais elle ne coûte rien. `declare` accepte désormais une portée de conversation, et la liste est posée pour chacune des conversations autorisées, langue par langue comme avant. Le défaut est effacé au même moment : ce qui a été posé chez Telegram y reste tant qu'on ne le retire pas, et une version antérieure l'avait posé. L'effacement passe sur chaque langue. Telegram range ses listes par langue autant que par portée, si bien qu'effacer le défaut sans langue laisserait debout celle qu'un client français avait reçue. Co-Authored-By: Claude Opus 5 (1M context) --- server/passerelle/index.ts | 38 +++++++++++++++++++++++------- server/passerelle/telegram.ts | 24 +++++++++++++++++++ src/help/sections/en/passerelle.md | 2 ++ src/help/sections/fr/passerelle.md | 2 ++ 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/server/passerelle/index.ts b/server/passerelle/index.ts index effc2f5..3673d59 100644 --- a/server/passerelle/index.ts +++ b/server/passerelle/index.ts @@ -1293,31 +1293,51 @@ export function demarrePasserelle(journal: Journal): void { } /** - * Pose la liste des commandes chez Telegram, une fois par langue. + * 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 le défaut la - * double. Cette redondance apparente est mesurée, pas décorative : n'ayant posé + * 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 défaut. Plutôt que de départager les deux, on ne laisse + * 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, journal: Journal): Promise { +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}).`); - // Le défaut : ce que voit un client dont la langue n'est pas des nôtres. - if (!(await tg.declare(withLocale(DEFAULT_LOCALE, pourTelegram)))) rate('défaut'); + 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.declare(withLocale(langue, pourTelegram), langue))) rate(langue); + if (!(await tg.efface(langue))) rate(`effacement du défaut, ${langue}`); } } @@ -1330,7 +1350,7 @@ async function boucle(tg: Telegram, chats: Set, journal: Journal): Promi return; } journal.info(`Passerelle ouverte sur @${nom} — ${chats.size} conversation(s) autorisée(s).`); - await declareCommandes(tg, journal); + await declareCommandes(tg, chats, journal); tg.ecoute( // La garde passe avant tout traitement, et le silence est la réponse à un diff --git a/server/passerelle/telegram.ts b/server/passerelle/telegram.ts index 261cb46..5c535b0 100644 --- a/server/passerelle/telegram.ts +++ b/server/passerelle/telegram.ts @@ -412,14 +412,22 @@ export class Telegram { * 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; @@ -428,6 +436,22 @@ export class Telegram { } } + /** + * 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. * diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index bbcc95a..24d3301 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -26,6 +26,8 @@ Three things, better weighed before than after. 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. + ## Turning it on 1. Create a bot with `@BotFather` on Telegram, which gives you a token. diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index 66c3bb3..547a2ad 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -26,6 +26,8 @@ Trois choses, et il vaut mieux les peser avant qu'après. 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. + ## L'activer 1. Créez un bot auprès de `@BotFather` sur Telegram, qui vous donne un jeton. From 2567e14dda834e9374eb4b36fca63aa57cd399c8 Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:30:39 +0200 Subject: [PATCH 27/28] La Passerelle dit pour quel usage elle est faite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ce qui transite passe par les serveurs de la messagerie, sans chiffrement de bout en bout. Le manuel, SECURITY et le README le disent maintenant avant qu'on configure le jeton, et nomment la piste cherchée pour s'en passer. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 5 +++++ README.en.md | 5 +++++ README.md | 5 +++++ SECURITY.en.md | 9 +++++++++ SECURITY.md | 10 ++++++++++ src/help/sections/en/passerelle.md | 10 ++++++++++ src/help/sections/fr/passerelle.md | 10 ++++++++++ 7 files changed, 54 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index c2c7f41..506271c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,11 @@ et aucun appel ne sort. Elle n'ouvre **aucun port** : son long-polling est sorta 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 c0fee53..81afefe 100644 --- a/README.en.md +++ b/README.en.md @@ -294,6 +294,11 @@ starts and no call goes out. Turned on, it holds a secret and calls an external 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. diff --git a/README.md b/README.md index 115fcf8..c293be8 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,11 @@ externe, mais **n'ouvre aucun port** : son échange est sortant, l'écoute reste 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. diff --git a/SECURITY.en.md b/SECURITY.en.md index 9af1497..c96711d 100644 --- a/SECURITY.en.md +++ b/SECURITY.en.md @@ -55,6 +55,15 @@ What it does change, and what you should weigh: 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. diff --git a/SECURITY.md b/SECURITY.md index ca7815f..a4bdb58 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -58,6 +58,16 @@ Ce qu'elle change, et qu'il faut peser : 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. diff --git a/src/help/sections/en/passerelle.md b/src/help/sections/en/passerelle.md index 24d3301..fc8cd0c 100644 --- a/src/help/sections/en/passerelle.md +++ b/src/help/sections/en/passerelle.md @@ -28,6 +28,16 @@ I refuse to start if that list is missing. An oversight must not open the machin 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. diff --git a/src/help/sections/fr/passerelle.md b/src/help/sections/fr/passerelle.md index 547a2ad..6c7cfb3 100644 --- a/src/help/sections/fr/passerelle.md +++ b/src/help/sections/fr/passerelle.md @@ -28,6 +28,16 @@ Je refuse de démarrer si cette liste est absente. L'oubli ne doit pas ouvrir la 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. From 3699ef82adbe18afbffe70e2866f69ad2f2c7c0a Mon Sep 17 00:00:00 2001 From: Shaenn <22753401+Shaenn@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:35:26 +0200 Subject: [PATCH 28/28] AURA passe en 1.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une capacité nouvelle, éteinte par défaut : la Passerelle. Mineure, donc, au sens que le journal donne aux trois nombres. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 41 +++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) 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/package.json b/package.json index 5775aa7..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",