Jarvis
Une voix, une flotte d'agents, une mémoire qui dure.
Trois étages, et une seule phrase qui les traverse
Jarvis n'est pas un service mais un assemblage de trois briques indépendantes : un canal qui capte, un cerveau qui route, une base qui se souvient. Chacune peut tomber sans emporter les deux autres.
Capter
Deux portes, une seule sortie : le même orchestrateur, avec un canal déclaré.
L'invariant : tout ce qui arrive par la voix est une transcription. Une date qui contredit le jour même est presque toujours un mot mal entendu.
Router
Un orchestrateur ne fait rien lui-même : il choisit qui fait, puis vérifie.
L'invariant : aucun geste irréversible sans validation de Boris. Le garde-fou vit dans l'orchestrateur, jamais dans les sous-agents.
Retenir
Écriture à chaud pendant la conversation, distillation à froid une fois le silence installé.
L'invariant : rien n'est jamais supprimé. Un fait périmé est daté et sort de la circulation, il ne disparaît pas.
La pile vocale : trois modèles, un seul aller-retour
Le code vit dans jarvis-voice/ et se déploie au CLI par lk agent deploy. Les trois modèles passent par LiveKit Inference : aucune clé fournisseur séparée à gérer.
parler_a_jarvis
openai/gpt-4.1-mini
jarvis-livekit
sFSVByv2KNilurt3
Trois réglages décident si la voix marche ou non
Aucun des trois n'est visible depuis une interface. Chacun a produit une panne complète, et deux d'entre elles étaient parfaitement silencieuses.
client_session_timeout_seconds
Le défaut de 5 secondes coupait toute demande mobilisant deux sous-agents : 7 à 9 s pour agenda + mails. La météo passait, les mails non — d'où un système qui semblait marcher par intermittence.
Porté à 180 s dans le code. L'erreur n'apparaissait que dans lk agent logs.
language: multi
Boris parle anglais, mais son monde est français. En anglais strict, nova-3 a transcrit « Dijon » en « in June » : l'orchestrateur a daté sa réponse du 15 juin.
Même piège pour les noms de contacts, les rues et les adresses.
agent_name = "JARVIS"
Un agent nommé ne rejoint aucune salle de son plein gré : il faut un dispatch explicite. Un client qui publie son micro sans convoquer parle dans le vide, sans la moindre erreur nulle part.
Diagnostic en une commande : lk agent status.
1 000 par mois. C'est celui qu'affiche le tableau de bord, et le seul que Boris peut relever.
2,50 $, soit environ 5 h 40 de conversation. C'est lui qui tombe en premier.
5 000 par mois. Elles courent application ouverte, micro fermé : c'est la connexion, pas la parole.
Le comptable déduit les minutes et affiche l'écart avec le relevé. Le tableau de bord retarde jusqu'à deux heures.
L'orchestrateur route, il n'exécute pas
Un seul workflow reçoit tout : message, sessionId, canal. Il n'a aucun accès direct à Gmail, au Drive ou au web — il ne sait qu'appeler des sous-agents et lire ce qu'ils répondent.
- Un sous-workflow, pas un chat. Le chat trigger d'origine répondait 200 sans jamais créer d'exécution. Il a été supprimé, pas réparé : les vraies portes sont Telegram et le serveur MCP.
- Les sous-agents n'ont pas de mémoire, volontairement. Ils reçoivent tout leur contexte dans
input. Une mémoire sans clé de session ferait fuiter le contexte entre deux appels sans rapport. - Le nom du champ
$fromAIest un contrat. Treize outils annonçaientinputdans leur description et mappaientinstruction: aucun sous-agent n'était appelable. La flotte n'avait jamais tourné pour cette seule raison. - Le garde-fou est un nœud, pas une consigne. Le
Contrôle de sincéritérelit les étapes réellement exécutées et ampute toute promesse que rien n'honore. Une règle de prompt ne se vérifie pas. - Un canal déclaré change la sortie. En
livekit, la réponse sera lue à voix haute : le markdown est retiré mécaniquement, trois phrases maximum, aucune URL.
Seize sous-agents, un workflow chacun
Chaque sous-agent est un workflow n8n autonome, déclenché par executeWorkflowTrigger et branché sur l'orchestrateur en toolWorkflow. En typeVersion 2.2 le nom du nœud est le nom de l'outil.
Ce qui se passe entre la question et la réponse
Cinq étapes, dont deux que Boris ne voit jamais : le chargement de la mémoire avant le modèle, et la vérification après lui.
- 1 EntréeMCP LiveKit ou Telegram. Le canal est déclaré, il changera la forme de la réponse.
- 2 Charger la mémoireUn appel Postgres, un aller-retour : faits retenus + trois derniers résumés.
- 3 RouterLe modèle appelle zéro, un ou plusieurs sous-agents, et lit ce qu'ils renvoient.
- 4 VérifierUn nœud Code compare la réponse aux outils réellement exécutés.
- 5 ArchiverLes deux tours partent en base. Rien n'est résumé maintenant.
Trois mensonges, trois filets
Une action annoncée sans qu'aucun outil ait tourné ; une source en erreur passée sous silence ; un envoi Telegram promis sur le canal voix, où rien ne peut le tenir.
Le cas qui ampute
Préfixer un avertissement devant une promesse intacte obligerait Boris à arbitrer entre deux phrases contradictoires. Le nœud retire la phrase, puis en ajoute une honnête.
Une alerte est prononcée
Les trois textes étaient d'abord écrits comme des consignes à un modèle. Aucun modèle ne tourne après ce nœud : l'agent vocal lisait la consigne à voix haute.
Écrire à chaud, distiller à froid
Rien n'est résumé pendant la conversation : le chemin chaud n'écrit que du texte brut. La distillation attend le silence, parce qu'une conversation ne dit ce qu'elle valait qu'une fois finie.
Deux appels, aucun modèle
Le coût du chemin chaud doit rester négligeable, sinon se souvenir devient plus cher que répondre.
chargement_initial()
Une fonction SQL, un aller-retour. Elle rend les faits retenus et les trois derniers résumés de session, déjà mis en forme.noter_tour() × 2
La question et la réponse partent en base. La fonction rattache le tour à la session ouverte sur cette clé, ou en ouvre une.livekit:boris pour la voix, telegram:<chatId> pour l'écrit. Deux fils séparés, une seule base.Un seul modèle, une seule session
Une session traitée par passage : aucun appariement d'items à gérer, chaque exécution reste lisible.
cloturer_sessions()
Une session est close après 30 minutes sans tour. En vocal la salle se ferme ; une conversation Telegram, elle, ne finit jamais explicitement : c'est le silence qui fait la frontière.appliquer_fait() + embedding
Chaque fait est vectorisé par text-embedding-3-small puis appliqué. Le résumé est écrit avant les faits : si un fait échoue, la session reste marquée traitée et l'exécution passe au rouge, sans boucle infinie.Quatre tables, et une seule qui compte vraiment
Les trois premières servent à produire la quatrième. tours est la source de vérité du texte échangé ; faits est ce que Jarvis emporte dans la conversation suivante.
- code text · PK
- libelle text
- poids real · défaut 1.0
- description text
- id bigserial · PK
- cle_session text
- canal voix | telegram
- debut timestamptz
- dernier_tour timestamptz
- fin timestamptz · null = ouverte
- nb_tours integer
- resume text
- sujets text[]
- traitee_at null = à distiller
- cout_usd numeric(10,6)
- id bigserial · PK
- session_id → sessions.id
- externe_id text · UNIQUE
- role boris | jarvis
- texte text
- at timestamptz
- faits_sujet_actif_idx UNIQUE (categorie, lower(sujet)) WHERE retire_at IS NULL
- faits_embedding_idx USING hnsw (embedding vector_cosine_ops)
- sessions_a_traiter_idx WHERE fin IS NOT NULL AND traitee_at IS NULL
- id bigserial · PK
- categorie → categories.code
- sujet text · sert au dédoublonnage
- contenu text · une phrase autoportante
- confiance declare | observe | deduit
- statut observation → confirme → suggere
- permanent boolean
- occurrences integer
- embedding vector(1536)
- session_id → sessions.id
- cree_at timestamptz
- maj_at timestamptz
- retire_at timestamptz
- motif_retrait text
- nb_rappels integer
- dernier_rappel timestamptz
Un fait, treize catégories, quatre opérations
Le modèle de distillation ne peut pas écrire librement en base. Il choisit une catégorie, une confiance, et une opération parmi quatre : tout le reste est du SQL.
categoriesdeclare veut dire Boris l'a dit. observe veut dire Jarvis l'a constaté sur son propre fonctionnement — presque toujours un journal de bugs, souvent périmé (« YouTube inaccessible » suivi de « YouTube a marché »). Ces faits-là n'ont rien à faire dans le contexte d'une conversation.
Le score final multiplie la similarité cosinus par le poids de la catégorie et par ce coefficient. Un fait déduit est présenté avec la mention « hypothèse ».
appliquer_fait()ajouter
Insertion, avec repli sur mise à jour si le sujet existe déjà dans la catégorie.
confirmer
Incrémente occurrences. Un fait revu passe de deduit à observe.
remplacer
L'ancien sort de circulation, daté ; le nouveau est inséré en dessous.
retirer
Pose retire_at et un motif. Aucun DELETE n'existe dans le code.
faitsÉcrire un fait ne suffit pas : encore faut-il le relire
Une mémoire ne vaut que par son critère de chargement. Celui de Jarvis a été faux pendant cinq jours, et la panne était invisible : la base était pleine, indexée, et jamais lue.
chargement_initial(cle_session) — à chaque tour
Un seul appel SQL, deux blocs : les faits retenus, puis les trois derniers résumés de session, horodatés et étiquetés par canal.
Critère : permanent OU confiance = 'declare', trié par poids de catégorie puis par fraîcheur, plafonné à 30 faits pour protéger le prompt.
recherche_semantique(embedding, limite, seuil)
Similarité cosinus sur l'index HNSW, seuil à 0.35, huit résultats par défaut, pondérés par catégorie et par confiance.
Écrite, indexée, pas encore appelée. Tant qu'aucun outil ne l'invoque, la profondeur de l'historique reste inexploitée : c'est le premier chantier de la mémoire.
Ce que la mémoire a déjà produit
Elle a signalé d'elle-même deux échecs répétés de l'agent calendrier. L'enquête a trouvé quatre défauts réels, dont une heure de fin explicite silencieusement écrasée. Aucun autre mécanisme ne les aurait vus.
faits enregistrés, indexés, jamais chargés
tokens par appel, le prix du correctif
Boris signale que Jarvis a oublié sa couleur préférée. Le fait existait bien : catégorie preferences, confiance declare, enregistré cinq jours plus tôt. Mais permanent valait false, et le chargement ne lisait que les faits permanents.
Un fait invisible équivaut à un fait absent. Et un fait permanent faux est pire qu'un fait manquant : il est chargé dans chaque conversation et se donne l'autorité d'une certitude.
6,29 $ par mois, et la répartition renverse l'intuition
Chiffres relevés par l'agent comptable, qui lit l'API n8n hors bande et n'appelle aucun modèle sur son chemin normal. Un monitoring ne doit jamais coûter plus cher que ce qu'il surveille.
L'orchestrateur coûte six fois l'agent vocal — et il n'apparaissait dans aucun tableau de bord fournisseur. Son prompt système repart en entier à chaque itération d'outil.
Ce que l'architecture a appris
Un chiffre déduit s'affiche à côté du relevé du fournisseur, jamais à sa place. La première mesure LiveKit était fausse d'un facteur deux : elle prenait l'amplitude de la session mémoire, qui contenait un trou de 23,8 minutes. Une session mémoire n'est pas une session facturée.
Une règle de prompt ne se vérifie pas. Les garde-fous qui tiennent sont des nœuds Code : ils relisent ce qui a réellement tourné. Ceux qui étaient écrits dans le prompt ont tous été pris en défaut.
Ne jamais valider à l'oreille. La première réponse vocale « correcte » citait une ville jamais demandée et un mois faux. Seule la lecture de l'exécution n8n montre ce qui a été transcrit et ce qui a été produit.