Assistant personnel · Offline-first · Privé
Un assistant local construit sur CrewAI + Ollama. Zéro cloud, zéro API externe — tout tourne sur votre machine. Il apprend de vous, raisonne avec vous, et agit pour vous.
Mnemo n'est pas un wrapper autour d'un LLM cloud. C'est une architecture locale pensée pour la confidentialité, la durabilité et l'autonomie.
Aucune donnée ne quitte votre machine. Les modèles LLM tournent via Ollama en local. Le seul réseau optionnel : la recherche web, avec confirmation explicite à chaque requête.
Trois couches complémentaires : session JSON pour le court terme, Markdown lisible comme source de vérité, SQLite avec FTS5 + embeddings 768d pour la récupération sémantique.
Trois niveaux en cascade : keywords déterministes, classificateur ML (scikit-learn, seuils configurables), puis LLM en fallback. Le LLM n'est sollicité que quand c'est nécessaire.
Chaque type de tâche a son crew dédié : conversation, shell, agenda, planification, briefing, curiosité. Chaque crew est testable et remplaçable indépendamment.
La mémoire de Mnemo est un système à trois niveaux avec fusion hybride.
memory.md
est la source de vérité — la base de données en est le dérivé indexé.
Transcription complète de la session en cours.
Chaque échange est horodaté et associé à une session_id.
Conservé jusqu'à la consolidation en fin de session.
sessions/session_{id}.json
Source de vérité humainement lisible. ConsolidationCrew extrait les faits
importants et les écrit dans des sections structurées (identité, projets,
préférences, décisions…).
memory.md
Index bi-modal : Full-Text Search (FTS5) pour les mots-clés,
vecteurs 768d (nomic-embed-text via Ollama) pour la sémantique.
Dérivé de memory.md via sync_markdown_to_db().
memory.db
identité 1.5× · décision 1.3× · projet 1.2× · préférence 1.1×
connaissance 1.0× · historique_session 0.7×
Décroissance exponentielle — demi-vie 30 jours. Poids adaptatif : requête courte → keyword-heavy, requête longue → vector-heavy.
Chaque message traverse un pipeline en cascade. Le LLM (EvaluationCrew) n'est activé que si les niveaux rapides ne sont pas suffisamment confiants.
Keywords déterministes + classificateur ML (sklearn pipeline, joblib). Si confiance ≥ seuil, route directe sans LLM.
Analyse LLM complète : intent, entités, topics, besoin mémoire, besoin web, clarification, complexité, route cible.
Trois garde-fous avant l'exécution : clarification utilisateur, confirmation web (query figée), confirmation shell (whitelist).
Dispatch vers le crew cible selon la route. web_context injecté dans tous les crews si la recherche a eu lieu.
En fin de session : ConsolidationCrew extrait les faits, puis CuriosityCrew détecte les lacunes et pose des questions.
Chaque crew est un ensemble d'agents et de tâches isolés, testable indépendamment. Le routing hybride décide lequel activer.
Analyse l'intent de chaque message et produit un JSON de routing : route, needs_web, needs_clarification, memory_query, complexité…
Récupère la mémoire pertinente (RRF) et génère une réponse contextuelle. Reçoit web_context si une recherche a eu lieu.
Extrait les faits importants de la session. Les écrit dans memory.md par section, puis re-synchronise la base de données.
Détecte les lacunes dans la mémoire (Python structurel + LLM contextuel). Pose jusqu'à 5 questions pertinentes à l'utilisateur.
Traduit les demandes en commandes shell. Validation par whitelist + confirmation explicite obligatoire avant toute exécution.
Traduit les demandes en tâches planifiées (format JSON multi-tâches). CRUD dans scheduled_tasks SQLite + miroir humain tasks.md.
Génère briefing.md (quotidien) et weekly.md (hebdo) depuis le calendrier ICS, les sessions récentes et la mémoire.
Écrit et modifie des notes directement dans memory.md via FileWriterTool. Modifications structurées par section.
CRUD complet sur fichier ICS local : créer, modifier, supprimer. Indices numériques (#N) pour le ciblage LLM, résolution UID en Python. Confirmation obligatoire en CLI, bypass automatique en web.
Huit phases (0–7). Cinq complètes, une en cours, deux planifiées. L'objectif final : un assistant capable d'agir, d'apprendre et de s'interfacer avec n'importe quel support.
Implémentation complète d'un planificateur GOAP (Goal-Oriented Action Planning)
en backward chaining avec tri topologique. 9 actions dans l'ACTION_REGISTRY
(FetchCalendar, SyncMemory, AssessMemoryGaps, FillBlockingGaps, ReconModule, CreatePlan,
GenerateBriefing, GenerateWeekly, SendDeadlineAlert). Le scheduler est migré : les tâches
système (briefing, weekly, deadline_alert)
passent désormais par goap_dispatch(goal) — le planner calcule automatiquement
la séquence d'actions optimale. Comportement émergent : user_online=False
bloque FillBlockingGaps sans code spécifique.
goap/planner.py · scheduler.py — scheduler piloté par objectifs
Deux nouveaux crews. ReconnaissanceCrew : lit les fichiers source en Python
(sans LLM) à partir des hints extraits du message utilisateur, puis effectue une synthèse
LLM unique → recon_context persisté dans world_state.json.
Évite toute hallucination sur des symboles inexistants.
PlannerCrew : charge les lacunes mémoire du WorldState, génère un plan
structuré via LLM, et le persiste dans plan.md via PlanStore.
Déclenché automatiquement quand la complexité est élevée.
crew.py · routing/dispatch.py — route "plan" opérationnelle
PlanStore : I/O Markdown avec cases [ ]/[x],
sections Étapes / Bloquants / Journal, statut structuré. Opérations :
create(), get_next_step(), mark_done(),
add_blocker(), append_log().
PlanRunner : exécute les steps via un dictionnaire d'executors
(_STEP_EXECUTOR), s'arrête au premier bloquant (option B).
check_active_plans() appelé au démarrage de chaque session pour reprendre
les plans en cours.
tools/plan_tools.py · main.py — planification persistante inter-sessions
Phase 5.5 : poids appris par profil d'usage
(learned_weights_{profile}.json), régression vers la moyenne
(REGRESSION_RATE=0.05), audit trail (learned_weights_history.jsonl,
purge > 90 jours). Priorité : statique → learned_global → learned_profil → overrides.
Phase 5.6 : MemoryGapReport structure les lacunes en
bloquantes vs enrichissantes. to_world_state() génère
les flags GOAP (memory_gaps_known, memory_blocking_gaps,
memory_completeness). Persisté dans world_state.json.
memory_tools.py · main.py — mémoire auto-évaluée et GOAP-ready
Le test de régression vers la moyenne échouait : base["identité"]=2.8
produisait un poids brut de 3.08 — au-dessus de WEIGHT_MAX=3.0 avant
ET après régression, les deux clampés à 3.0, delta=0.
Fix : base["identité"]=2.5 → poids brut 2.75, régression mesurable.
test_active_learning.py — test régression valide
_actions_that_produce() ne gérait que les effets True.
FillBlockingGaps a effects={"memory_blocking_gaps": False}
→ jamais sélectionné par le planner.
Fix : signature étendue à _actions_that_produce(key, value, actions)
pour matcher toute valeur d'effet.
goap/planner.py — effets booléens correctement résolus
"implémenter" matchait le keyword strong "implémente" → route forcée vers
plan sur n'importe quel verbe contenant la sous-chaîne.
Fix : re.search(r"\b" + re.escape(kw) + r"\b") pour les
keywords mono-mot sans espace ni tiret.
routing/handlers/keyword.py — détection plan précise
Remplacement de Piper par Kokoro-82M (~327 MB pour la gestion des voix françaises et japonaises). Modèle HuggingFace GustavZ/kokoro-82m
(fork de,
hexgrad/Kokoro-82M). Téléchargement automatique au premier appel dans
/data/models/ (volume persistant). Détection de langue par plage Unicode :
hiragana / katakana / kanji → pipeline japonais, sinon → pipeline français.
Double-checked locking pour les deux singletons (_kokoro_pipeline_fr,
_kokoro_pipeline_ja) — thread-safe dès l'initialisation.
audio_tools.py — Piper supprimé, Kokoro-82M en production
Le frontend découpe la réponse en phrases avant d'appeler
POST /api/tts. Seules 2 requêtes coexistent à tout moment :
la phrase en lecture et la suivante en prefetch.
Supprime les chevauchements audio et les race conditions sur le singleton Kokoro.
Pattern : nextFetch lancé avant await fetchPromise,
puis lu en séquence.
ChatPage.tsx — latence perçue réduite, zéro superposition
Nouveau VoicePage.tsx dans le dashboard : choix de voix Kokoro (FR / JA),
vitesse, toggle RVC on/off, 6 sliders RVC (f0_method, f0_up_key,
index_rate, filter_radius, rms_mix_rate,
protect), upload de modèle .pth + .index custom,
bouton Tester (applique les réglages du formulaire au runtime sans sauvegarder)
et bouton Sauvegarder (persiste dans /data/voice_settings.json).
VoicePage.tsx · api.py — réglages voix sans redémarrage
phonemizer copie libespeak-ng.so dans /tmp
et la charge via ctypes. Le tmpfs Docker est noexec par défaut
→ OSError: failed to map segment from shared object.
Fix : tmpfs: - /tmp:size=256m,mode=1777,exec dans
docker-compose.yml.
docker-compose.yml — Kokoro japonais fonctionnel en container
L'image est read_only: true. HF Hub écrit des lock files dans
HF_HOME. Tentatives en /tmp et /app/models
→ échecs. Fix : HF_HOME=/data/models (volume en écriture),
suppression de HF_HUB_OFFLINE=1. Kokoro se télécharge une fois,
est mis en cache de façon persistante.
dockerfile · docker-compose.yml — cache HF sur volume /data
misaki[ja] installe le package unidic mais pas ses données
de dictionnaire. Premier appel TTS japonais → RuntimeError: Failed initializing
MeCab. Fix : ajout de python -m unidic download dans le
dockerfile après l'installation de requirements.audio.txt.
dockerfile — japonais opérationnel après rebuild
Kokoro retourne un torch.Tensor, le code supposait un
np.ndarray → AttributeError: 'Tensor' has no attribute 'astype'.
Fix : if hasattr(audio, "numpy"): audio = audio.numpy().
Voix ff_emma et fm_galvani retirées de
KOKORO_VOICES_FR (inexistantes dans Kokoro-82M) — seul
ff_siwis est disponible côté français.
audio_tools.py — TTS 500 résolu, liste voix exacte
Race condition dans le handler done du WebSocket : streamBufRef.current = ''
s'exécutait immédiatement, avant que React traite l'updater de setMessages
qui lisait le ref déjà vidé. Le message apparaissait pendant le streaming puis
disparaissait au moment de la finalisation.
Fix : capture de const finalContent = streamBufRef.current avant le reset,
passage de la valeur snapshot à l'updater.
ChatPage.tsx — streaming → messages persistants
Trois causes en cascade. (1) ingest_tools.py importait DB_PATH
depuis memory_tools — symbole supprimé lors de la migration multi-utilisateurs :
ImportError sur chaque message, update_session_memory jamais appelée.
(2) main.py a un appel module-level _set_data_dir("/data") ;
importé lazily dans user_context.run(), ce code écrasait le chemin utilisateur.
(3) Fix : import anticipé de main.py au démarrage API
(import Mnemo.main as _mnemo_main) + DB_PATH remplac�� par
_db_path_default() dans ingest_tools.py.
api.py · ingest_tools.py — sessions écrites dans users/{username}/sessions/
Le gutter des heures n'avait pas d'espaceur en haut alors que les colonnes de jours
ont un header de 44 px : toutes les étiquettes horaires apparaissaient une ligne trop haut.
Fix : ajout d'un <div className={styles.weekGutterSpacer} /> dans le
gutter + CSS height: 44px associé.
CalendarPage.tsx — alignement heure exact
duration inexistant
Migration ModalState.duration → endTime incomplète :
openEdit, submit (×2) et le JSX référençaient encore
modal.duration après la mise à jour du type. Les 4 occurrences corrigées.
La durée est désormais calculée automatiquement via
timeDiffMinutes(modal.time, modal.endTime) (minimum 15 min).
CalendarPage.tsx — build TypeScript propre
Bouton ↑ ICS dans le header du calendrier (visible si writable).
Endpoint POST /api/calendar/import : parse le fichier uploadé,
fusionne les VEVENTs dans le calendrier local, déduplique par UID existant.
Retourne {"imported": N, "skipped": M} avec toast de confirmation.
Import sans doublon · feedback immédiat
Le formulaire d'ajout / modification d'événement affiche désormais un champ
Heure de fin (<input type="time">) plutôt qu'une durée
en minutes. Plus naturel : "de 14h à 17h" au lieu de "180 minutes".
La durée est calculée automatiquement à la soumission.
CalendarPage.tsx — UX alignée sur les habitudes utilisateurs
À chaque connexion WebSocket authentifiée, consolidate_orphan_sessions()
s'exécute dans le contexte utilisateur via run_in_executor.
Scanne sessions/*.json sans marker .done,
lance ConsolidationCrew sur chacune → extraction des faits →
memory.md + sync SQLite + marker .done.
Parité avec le comportement CLI au démarrage (crash recovery).
api.py — mémoire consolidée même sans fermeture propre
Nouveau composant React réutilisable en remplacement de tous les
window.confirm() et alert().
Pattern useRef + Promise : await askConfirm()
retourne true/false comme une confirmation synchrone.
Props : message, confirmLabel, danger
(fond rouge via var(--a4)).
Utilisé dans MemoryPage, CalendarPage et
ChatPage.
UX cohérente · aucun dialogue natif du navigateur
MemoryPage entièrement réécrite : sidebar de sections cliquables,
éditeur titre + corps, guard dirty state (confirmation avant changement de section),
ajout d'une nouvelle section, suppression avec confirmation danger.
Sauvegarde → POST /api/memory → écriture memory.md
+ sync_markdown_to_db(). Préambule (contenu avant le premier ##)
préservé à chaque round-trip.
Mémoire éditable et synchronisée sans CLI
Fini le refus silencieux de needs_web en mode dashboard.
Premier appel POST /api/message : si le LLM retourne
needs_web=true, l'API répond {needs_web_confirm: true, web_query}
au lieu de procéder. Le frontend affiche une ConfirmModal
avec la requête figée.
Deuxième appel avec web_confirmed: true/false : le backend
lance (ou non) web_search() et injecte le contexte dans
_route_message().
Parité CLI / dashboard sur la confirmation web
Nouveau endpoint qui parse les blocs ## 🔔 Rappel de
briefing.md (généré par le scheduler). Chaque rappel reçoit
un identifiant MD5 pour la déduplication côté client.
App.tsx poll toutes les 60 secondes quand l'onglet est actif.
Chaque rappel nouveau déclenche un toast react-toastify.
Déduplication par date dans localStorage : un rappel vu
aujourd'hui ne réapparaît pas.
Rappels planifiés visibles dans le dashboard sans action manuelle
get_events_with_uid reçoit un nouveau paramètre
from_date : l'API passe le lundi de la semaine courante
pour inclure les jours déjà écoulés.
Côté frontend : classes CSS pastDay / pastHdr /
pastEv grisent les colonnes passées sans les masquer.
Semaine complète visible, passé discret
Toutes les décisions de routing sont désormais loguées avec leur source :
[EVAL] (bypass kw), (bypass ML) ou (LLM),
dans main.py et api.py.
Le JSON d'évaluation complet est affiché à chaque message.
Routing transparent et debuggable
Incohérence LLM corrigée à deux niveaux : règle absolue ajoutée au prompt
("si web_query est non-null, needs_web DOIT être true") +
coercion code dans main.py après _parse_eval_json.
Mots-clés "fouille", "fouille le net", "cherche sur internet" ajoutés.
Règle scheduler simplifiée : heure précisée sans date → aujourd'hui,
sinon → demain (exemples ISO concrets).
Routing web et scheduler fiables sur les petits modèles
Le dashboard vanilla JS (1 200+ lignes) migré vers React + TypeScript + Vite avec CSS Modules.
8 composants créés : App, NavBar, MessageBubble,
ChatPage, MemoryPage, SessionsPage,
CalendarPage + wrapper api/index.ts typé.
Proxy Vite→FastAPI en dev, build vers src/Mnemo/static/.
Dashboard scalable, état centralisé, zéro code spaghetti
@app.get("/{full_path:path}") était enregistré avant toutes les routes API
dans FastAPI. Après rebuild Docker, toutes les requêtes /api/* retournaient 404.
Corrigé en déplaçant le catch-all SPA en toute fin de api.py.
Routes API fonctionnelles après rebuild
Les UIDs Google Calendar (chj66opo6gsj...@google.com) étaient copiés
de façon inexacte par le LLM, causant des erreurs "UID inconnu".
Solution : format_events_with_uid passe des indices #N,
CalendarWriteCrew.run() résout #N → UID complet en Python avant
tout appel delete_event / update_event.
Suppression et modification fonctionnelles même avec des UIDs opaques
Le LLM confondait "samedi" avec aujourd'hui ou calculait une date erronée,
faute d'ancre explicite. Ajout de get_week_dates_for_prompt() :
table samedi = 2026-03-14, dimanche = 2026-03-15… sur 14 jours,
injectée en {week_dates} en tête du prompt CalendarWriteCrew.
Contexte injecté réduit de 14 → 7 jours pour limiter le bruit.
Dates résolues par lookup direct, sans arithmétique LLM
Type SessionMessage incorrect côté frontend
(user_message/response vs role/content).
Preview API vide (cherchait messages[0].user_message).
Les deux corrigés.
Bulles de sessions peuplées correctement
CalendarWriteCrew recevait _web_mode=False en contexte web,
déclenchant un input() bloquant la requête API.
Corrigé dans base_inputs de _route_message()
et _detect_calendar_write_intent ajouté au handler web.
Calendrier modifiable depuis le dashboard sans blocage
rebuild
Lundi prochain tagué "Cette semaine" (diff flottant < 7 j).
Corrigé avec les bornes calendaires réelles (lun–dim).
Ajout de ./mnemo.sh rebuild :
stop → build → up en une commande.
Labels d'urgence corrects · workflow rebuild simplifié
toISOString().slice(0,10) retourne la date UTC — en GMT+1,
les événements du lundi apparaissaient le mardi. Corrigé par un helper
_fmtDate(d) basé sur getFullYear /
getMonth / getDate (heure locale).
Vue semaine alignée avec le fuseau horaire local
Le LLM résolvait les dates relatives sans ancre ISO explicite, causant
des décalages. Injection de today_iso (YYYY-MM-DD) dans
crew.py et dans le prompt YAML. Règle ajoutée :
si le jour nommé correspond à aujourd'hui, utiliser today_iso
directement sans dériver.
Dates résolues depuis une référence ISO absolue
"Ajoute un événement…" était routé vers ConversationCrew.
Ajout de _CALENDAR_WRITE_KEYWORDS et
_detect_calendar_write_intent() dans main.py,
bypass déterministe identique au pattern shell/scheduler.
Demandes calendrier (impératif) routées sans LLM
"Tu peux me le supprimer du calendrier ?" n'était pas détecté
(forme infinitive + lieu, sans impératif). Ajout d'une détection par
co-occurrence : verbe d'action (supprimer, retire,
annule…) + mot contexte (calendrier,
agenda, événement…). 21 nouveaux exemples
injectés dans training_data.jsonl + modèle ML ré-entraîné.
Toutes les formulations naturelles de suppression/création détectées
Cache Python par processus (CLI ≠ FastAPI) : l'API retournait
des données stale jusqu'à 5 min après une écriture CLI.
Fix : vérification mtime ICS à chaque GET +
polling frontend 30s si l'onglet Calendrier est actif.
Événements créés par l'IA visibles en < 30s
CRUD complet en vue semaine : bouton ✕ rouge au hover à côté du ✎,
avec confirm() avant suppression.
display:flex via onmouseenter pour les deux
boutons côte à côte.
Delete accessible directement depuis la vue semaine
services
./mnemo.sh services démarre mnemo-scheduler
et mnemo-api en une seule commande.
stop arrête les deux. Ajout de logs-api
et de la commande api seule.
Démarrage complet de l'environnement en 1 commande
Implémentation complète du stub. Trois fonctions write ICS locales :
add_event, update_event, delete_event.
Confirmation interactive obligatoire avant toute modification ou suppression
(commande figée après kickoff). Sources URL distantes refusées en écriture.
api.py : 10 routes REST — health, message, memory, sessions,
et calendrier CRUD (GET / POST / PUT / DELETE). Pipeline web sans stdin :
shell bloqué, web auto-refusé, clarification ignorée.
Service Docker mnemo-api exposé sur
DASHBOARD_HOST:DASHBOARD_PORT.
Dashboard vanilla JS, 4 tabs : Chat, Mémoire, Sessions, Calendrier. Sessions affichées en bulles de conversation. Calendrier avec CRUD intégré (modal formulaire, boutons ✎ / ✕ au hover). Vue Liste et Vue Semaine (grille 7 j × 7h–22h) avec navigation ← Aujourd'hui →.
La SPA vanilla approche 1 200 lignes de code imbriqué. Migration prévue vers React.js pour un scaling propre : composants réutilisables, état centralisé, fin du code spaghetti. Déclencheur : prochaine feature majeure frontend.
_make_event_dict calculait DTEND − ev_datetime
(date de l'occurrence reconstruite), produisant des durées négatives
pour les événements récurrents. Corrigé en DTEND − DTSTART
depuis le composant original.
Vue semaine : hauteurs des blocs désormais correctes
Le rendu utilisait m.user_message / m.agent_response
alors que le JSON de session stocke des objets {role, content}
individuels. Corrigé par un map basé sur m.role.
Bulles de conversation correctement peuplées
38 tests couvrant les fonctions write ICS et CalendarWriteCrew.run().
N1 : fonctions pures. N2 : écriture réelle sur fichier ICS temporaire.
N3 : crew avec kickoff() mocké (create, delete confirmé/annulé, update, cas dégradés).
38 / 38 · 55 s
Outil d'écriture directe dans memory.md et dans les notes.
Opérations supportées : append_section, replace_section,
create_file. Validation de chemin intégrée pour éviter
les écritures hors du répertoire /data.
Crew dédié à la prise de notes structurée. Utilise FileWriterTool
pour écrire dans memory.md par section. Route note
ajoutée au pipeline. Intégré dans le routing ML à 5 classes.
Le classificateur ML passe de 4 à 5 routes :
conversation, shell, scheduler,
calendar, note.
Ré-entraînement du pipeline sklearn, mise à jour des seuils
et du fichier router_model.joblib.
787 tests verts sur l'ensemble du projet.
31 régressions identifiées et corrigées (FileWriterTool, NoteWriterCrew,
routes ML, mocks shell, calendar_tools).
Nouveaux fichiers : test_note_writer.py, test_file_writer.py.
Correction de l'expansion RRULE pour les règles WEEKLY avec BYDAY. Gestion des EXDATE et COUNT dans l'expansion manuelle.
Tests calendar : 100% pass
Mise à jour des mocks ShellCrew.run() dans
test_router.py et test_session_cycle.py pour le nouveau
format d'inputs avec web_context.
Tests shell : 100% pass
Refactorisation CSS : de 349 lignes à ~270 lignes.
Système de cards générique avec custom properties CSS
(--cc, --cb) remplaçant 6 blocs dupliqués.
HTML réécrit comme présentation projet