Projets
Life OS
Un seul endroit pour les tâches, le journal, l'agenda et les chiffres derrière tout ça, au lieu de quatre applications qui s'ignorent.
Le problème
Les tâches sont dans une application, les notes dans une autre, le calendrier ailleurs. Rien ne communique, donc c'est vous qui faites le lien.
Ce que j'ai fait
Un seul backend NestJS, mais chaque domaine garde une frontière interne nette, donc une modification du journal ne peut pas casser l'agenda en douce. L'assistant IA n'est pas non plus ajouté après coup : il passe par le même command bus et query bus qu'une requête normale. C'est l'architecture sur laquelle je voudrais encore pouvoir travailler dans deux ans.
Construit avec
- Next.js
- NestJS
- Expo
- PostgreSQL
- Prisma
- Anthropic
- Gemini
- Groq
- Cloudinary
- Cloudflare R2
Pas de porte dérobée pour l'assistant
La plupart des « fonctionnalités IA » sont une fenêtre de chat greffée sur une application, qui parle au modèle par son propre chemin bricolé à part. L'assistant de LifeOS n'en a pas. Chaque outil qu'il appelle — créer une tâche, replanifier un engagement, supprimer une note — passe par le même command bus et le même query bus qu'une requête REST utiliserait. Il hérite des mêmes validations, des mêmes règles du domaine, des mêmes événements. Cette seule décision explique la majeure partie de l'architecture ci-dessous.
Un monolithe modulaire, pas une constellation de services
Dix modules métier — auth, assistant, brain, scheduler, project, analytics, finance, export, settings, health — vivent dans un seul processus NestJS, un seul déploiement, une seule base Postgres. Découper ça en microservices n'aurait rien apporté : les données d'un seul utilisateur, sur un seul VPS, n'ont aucun axe de montée en charge indépendant qui justifierait le saut réseau.
Ce que chaque module garde, en revanche, c'est le même découpage interne strict à chaque fois — domain, application, infrastructure, presentation. Un contrôleur ne touche jamais Prisma directement : il envoie une commande ou une requête et renvoie ce qui revient. Le repository lui-même est une interface, liée à son implémentation Prisma par un jeton, de sorte qu'un module métier peut être testé unitairement contre un faux repository sans toucher à une base de données.
Chaque route est protégée par un garde JWT global, sauf mention explicite du contraire. Les jetons d'accès et de rafraîchissement sont séparés, le jeton de rafraîchissement est persisté et tourné, et l'authentification Google passe par deux chemins de vérification indépendants — un échange de code côté serveur pour le client web, et un point d'entrée à part pour le mobile qui vérifie l'émetteur, l'audience et le drapeau email_verified du jeton d'identité avant de lui faire confiance.
Le modèle est une pièce interchangeable, pas une fondation
Chaque fonctionnalité IA — le chat, l'appel d'outils, l'enrichissement des notes — passe par une seule interface :
interface LlmPort {
modelTokenLimit: number;
supportsStreamingToolCalls: boolean;
complete(messages: ChatMessage[], options?: CompletionOptions): Promise<CompletionResult>;
stream(messages: ChatMessage[], options?: CompletionOptions): AsyncIterable<StreamChunk>;
}
Quatre adaptateurs l'implémentent — Anthropic, Gemini, Groq, et vLLM pour un point d'accès local compatible OpenAI — choisis au démarrage par une seule variable d'environnement. Rien dans le module assistant n'importe un SDK de fournisseur directement.
L'interface doit combler de vrais écarts entre fournisseurs, pas seulement des formats de requête différents. Les modèles de raisonnement de Gemini rejettent un aller-retour d'outil qui revient sans le thoughtSignature attaché à l'appel d'origine, donc les appels d'outils portent un champ de signature optionnel que tous les autres adaptateurs ignorent simplement. supportsStreamingToolCalls est un drapeau de capacité plutôt qu'une supposition, parce que tous les fournisseurs ne peuvent pas diffuser des jetons et un appel d'outil dans le même souffle. Les embeddings passent par leur propre interface, puisqu'aucun des quatre fournisseurs de chat ne les propose actuellement.
Un assistant qui peut agir, sous contrôle là où il le faut
L'assistant dispose de 38 outils répartis sur cinq domaines — tâches, projets, notes, engagements, mémoire à long terme — et chacun passe par le même command bus et query bus décrits plus haut. Il n'a aucun accès direct à un repository ; lui demander de supprimer une tâche emprunte exactement le même chemin qu'une requête DELETE.
Ce qu'il ne fait pas, c'est agir sans supervision sur quoi que ce soit de destructeur :
const deleteTaskTool: ToolDefinition = {
name: 'delete_task',
requiresConfirmation: true,
async describeAction(ctx, args) {
const task = await ctx.queryBus.execute(new GetTaskByIdQuery(ctx.userId, args.taskId));
return task ? `Supprimer définitivement la tâche « ${task.title} » ?` : 'Supprimer définitivement cette tâche ?';
},
async execute(ctx, args) {
await ctx.commandBus.execute(new DeleteTaskCommand(ctx.userId, args.taskId));
return { summary: 'Tâche supprimée.' };
},
};
Cinq des 38 outils déclarent requiresConfirmation ; tout outil sans déclaration du tout — un outil MCP, par exemple — est bloqué par défaut plutôt que présumé sûr. Un appel signalé est retenu en mémoire, à usage unique, pendant dix minutes en attendant l'approbation de l'utilisateur, plutôt qu'écrit en base — un redémarrage du serveur l'invalide au lieu de ressusciter une suppression restée en attente.
Une mémoire qui se dégrade au lieu d'échouer
Les notes sont vectorisées à l'écriture : un écouteur sur les événements de création et de mise à jour d'une note envoie le titre et le corps à travers l'interface d'embeddings et stocke le vecteur obtenu à côté de la note. La recherche vectorise la requête de la même façon et classe par distance cosinus — mais si cet appel échoue, le plus souvent parce qu'aucune clé d'embeddings n'est configurée, le gestionnaire enregistre un avertissement et bascule sur une recherche par mots-clés dans la même table plutôt que de renvoyer une erreur. La fonctionnalité perd en précision, pas en disponibilité.
Des analytics qui n'importent jamais scheduler ni brain
Le module analytics n'a aucune dépendance directe envers scheduler ou brain : il importe seulement leurs types d'événements et réagit via @nestjs/event-emitter. Un seul écouteur traite neuf noms d'événements distincts venant de trois autres modules pour garder les résumés quotidiens et les séries à jour. Supprimez entièrement le module analytics, et scheduler comme brain ne s'en apercevraient pas — ils continueraient d'émettre dans une pièce où plus personne n'écoute.
Deux clients, un seul contrat, des contraintes différentes
Le web (Next.js) et le mobile (Expo) parlent chacun au backend via des clients API par module, qui attachent le jeton JWT et déballent l'enveloppe de réponse que le backend ajoute à chaque réponse. Le panneau de chat web affiche un appel d'outil signalé comme un contrôle intégré d'approbation ou de refus, si bien qu'une action destructrice montre sa confirmation directement dans la conversation plutôt que dans une fenêtre à part.
Le mobile porte le problème le plus difficile : un mode vocal mains libres avec une vraie détection d'activité vocale — une base de bruit lissée décide quand l'utilisateur a fini de parler — plus une détection d'interruption pendant que l'assistant parle. Les API audio disponibles sur l'appareil n'ont pas d'annulation d'écho acoustique, donc l'interruption reste volontairement approximative, avec un bouton d'interruption manuel en secours plutôt qu'une promesse que le micro gagnera toujours la course.
Ce que ça donne, en chiffres
24 232 lignes de TypeScript côté backend, 15 719 côté web, 11 507 côté mobile. Dix modules métier. 38 outils pour l'assistant, dont cinq verrouillés derrière une confirmation. Quatre fournisseurs de modèles interchangeables derrière une seule variable d'environnement. Onze écouteurs d'événements inter-modules qui tiennent l'ensemble sans qu'aucun module n'en importe un autre.
Deux coutures honnêtes
Le verrou de confirmation appartient spécifiquement à la boucle de chat de l'assistant — le pipeline de traitement des notes dans brain (extraction des pièces jointes, enrichissement) appelle le modèle par un mécanisme séparé, sans registre d'outils partagé. Et la détection d'activité vocale vit dans l'écran dédié au mode vocal mains libres, pas dans l'enregistreur à pression de l'onglet de chat classique. Aucun des deux n'est un bug ; ce sont deux systèmes qui font deux métiers différents, et ça vaut la peine de le dire plutôt que de faire comme s'il n'y en avait qu'un.
Rien ici n'est une maquette de portfolio. LifeOS, c'est l'outil avec lequel je fais vraiment tourner ma semaine, sur un seul conteneur Docker sur un VPS personnel — et cette contrainte a façonné l'architecture plus qu'aucun framework ne l'aurait fait. Une application qu'on utilise tous les jours punit les raccourcis qu'une démo ne révélerait jamais, ce qui est le véritable argument pour faire passer l'assistant par le même bus que l'API plutôt que de lui en donner un à part, et pour faire dégrader la recherche vers une correspondance par mots-clés plutôt que de planter quand une clé manque.