diff --git a/.env.example b/.env.example
index e90608e..3419ae3 100644
--- a/.env.example
+++ b/.env.example
@@ -4,3 +4,4 @@ GMAIL_PASSWORD=your_gmail_app_password
APP_SECRET=change_me_to_a_long_random_string
TTS_PROVIDER=browser
CONTACTS_FILE=/root/openbot/data/contacts.json
+MEMORY_DB=/root/openbot/data/memory.sqlite
diff --git a/DEPLOY_NOTES.md b/DEPLOY_NOTES.md
index c2987ea..62f2fb3 100644
--- a/DEPLOY_NOTES.md
+++ b/DEPLOY_NOTES.md
@@ -28,7 +28,8 @@ Derniers changements locaux a deployer:
- Voix navigateur adoucie avec preference pour voix feminine francaise.
- Ajout de contacts email par conversation.
- Carnet de contacts prod dans `/root/openbot/data/contacts.json`.
-- Cache PWA: opensquared-assistant-v6.
+- Memoire professionnelle SQLite dans `/root/openbot/data/memory.sqlite`.
+- Cache PWA: opensquared-assistant-v7.
```
## VPS
@@ -182,6 +183,8 @@ Correctif applique:
- La version v6 privilegie une voix feminine francaise si disponible sur l'appareil.
- Les contacts peuvent etre ajoutes en disant par exemple: `Ajoute Sophie avec l'adresse sophie@example.com`.
- Le service systemd definit `CONTACTS_FILE=/root/openbot/data/contacts.json`.
+- La version v7 ajoute une memoire professionnelle SQLite pour clients, projets, taches, notes et depots Git.
+- Le service systemd definit `MEMORY_DB=/root/openbot/data/memory.sqlite`.
Si le bouton ne reagit pas apres un deploiement:
@@ -191,3 +194,71 @@ Android: fermer l'app, rouvrir Chrome, ou desinstaller/reinstaller la PWA
```
Si ca persiste, ouvrir F12 -> Console et lire l'erreur JS.
+
+## Fin de session - 2026-04-27
+
+Etat fonctionnel vise:
+
+- L'app ne depend plus d'une session Putty ouverte.
+- `systemd` gere le processus `opensquared-assistant`.
+- Gunicorn lance `app:app` depuis `/root/openbot`.
+- Le bouton `Demarrer` active une ecoute mains libres.
+- Une phrase est transmise automatiquement apres environ 2 secondes de silence.
+- La reponse est lue avec une voix navigateur francaise, adoucie et preferee feminine si disponible.
+- Les contacts email peuvent etre ajoutes par conversation.
+- Les clients, projets, taches, notes et depots Git associes peuvent etre memorises en SQLite.
+
+Commits a avoir sur le VPS:
+
+```text
+f876b3a Add hands-free PWA mode and systemd service
+94e14a3 Add soft French voice and contact saving
+```
+
+Checklist VPS:
+
+```bash
+cd /root/openbot
+git pull
+cp deploy/opensquared-assistant.service /etc/systemd/system/opensquared-assistant.service
+systemctl daemon-reload
+systemctl enable --now opensquared-assistant
+install -m 755 deploy/openbot /usr/local/bin/openbot
+openbot restart
+openbot status
+```
+
+Commandes utiles:
+
+```bash
+openbot start
+openbot stop
+openbot restart
+openbot logs
+systemctl status opensquared-assistant
+journalctl -u opensquared-assistant -f
+```
+
+Variables importantes:
+
+```text
+TTS_PROVIDER=browser
+CONTACTS_FILE=/root/openbot/data/contacts.json
+MEMORY_DB=/root/openbot/data/memory.sqlite
+```
+
+Exemples vocaux:
+
+```text
+Ajoute Sophie avec l'adresse sophie@example.com
+Envoie un email a Sophie
+SAFTCO est un nouveau client, note quelque part que je dois rappeler le directeur
+Associe le repo /root/openbot/repos/saftco au projet Portail SAFTCO
+Qu'est-ce que tu sais sur SAFTCO ?
+```
+
+Attention cache mobile:
+
+- `index.html` charge `/app.js?v=7`.
+- Service worker: `opensquared-assistant-v7`.
+- Si Android garde l'ancien JS, fermer/rouvrir Chrome ou reinstaller la PWA.
diff --git a/README.md b/README.md
index d6a6d64..2b0159b 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,7 @@ POC de PWA mobile pour parler a l'assistant depuis un telephone.
- Lecture audio automatique de la reponse avec une voix francaise douce cote navigateur.
- Envoi d'emails Gmail avec carnet de contacts `contacts.json`.
- Ajout de contacts par conversation, par exemple : `Ajoute Sophie avec l'adresse sophie@example.com`.
+- Memoire professionnelle SQLite pour clients, projets, taches, notes et depots Git associes.
## Configuration locale
@@ -27,6 +28,7 @@ GMAIL_PASSWORD=...
APP_SECRET=...
TTS_PROVIDER=browser
CONTACTS_FILE=/root/openbot/data/contacts.json
+MEMORY_DB=/root/openbot/data/memory.sqlite
```
Installer :
@@ -141,6 +143,25 @@ Envoie un email a Sophie
L'assistant remplacera `Sophie` par son adresse email.
+## Memoire professionnelle
+
+Les informations professionnelles sont stockees dans une base SQLite. Sur le VPS, le service utilise :
+
+```text
+/root/openbot/data/memory.sqlite
+```
+
+Exemples de phrases :
+
+```text
+SAFTCO est un nouveau client, note quelque part que je dois rappeler le directeur.
+Note que le projet Portail SAFTCO est associe au client SAFTCO.
+Associe le repo /root/openbot/repos/saftco au projet Portail SAFTCO.
+Qu'est-ce que tu sais sur SAFTCO ?
+```
+
+La memoire peut contenir des clients, projets, taches, notes et depots Git associes a un projet.
+
## Notes
- Groq Orpheus TTS doit etre accepte dans la console Groq pour fonctionner :
diff --git a/app.py b/app.py
index 3606295..f406d05 100644
--- a/app.py
+++ b/app.py
@@ -1,6 +1,7 @@
import base64
import json
import os
+import re
import smtplib
import tempfile
from email.mime.multipart import MIMEMultipart
@@ -10,6 +11,15 @@ from pathlib import Path
from dotenv import load_dotenv
from flask import Flask, jsonify, request, send_from_directory
from groq import Groq
+from memory import (
+ add_memory,
+ add_repository,
+ add_task,
+ init_db,
+ search_memory,
+ upsert_client,
+ upsert_project,
+)
load_dotenv()
@@ -29,6 +39,7 @@ TTS_PROVIDER = os.getenv("TTS_PROVIDER", "browser").lower()
app = Flask(__name__, static_folder=str(STATIC_DIR), static_url_path="")
app.secret_key = os.getenv("APP_SECRET", "dev-secret-change-me")
+init_db()
client = Groq(api_key=GROQ_API_KEY)
histories = {}
@@ -46,6 +57,21 @@ Quand l'utilisateur veut ajouter, memoriser ou modifier un contact email, collec
Quand tu as les deux informations, reponds uniquement avec ce JSON :
{"action": "ajouter_contact", "nom": "...", "email": "..."}
+Tu disposes aussi d'une memoire professionnelle persistante pour les clients, projets, taches, notes et depots Git.
+Quand l'utilisateur te demande de noter, memoriser, ajouter ou associer une information professionnelle, reponds uniquement avec un JSON.
+Actions disponibles :
+{"action": "ajouter_client", "nom": "...", "notes": "...", "statut": "actif"}
+{"action": "ajouter_projet", "nom": "...", "client": "...", "statut": "...", "resume": "...", "prochaine_action": "..."}
+{"action": "ajouter_tache", "titre": "...", "client": "...", "projet": "...", "details": "...", "echeance": "..."}
+{"action": "ajouter_repo", "nom": "...", "projet": "...", "chemin": "...", "url": "...", "branche": "..."}
+{"action": "ajouter_note", "contenu": "...", "sujet": "...", "client": "...", "projet": "...", "type": "note"}
+{"action": "chercher_memoire", "requete": "..."}
+
+Si une phrase contient plusieurs choses a enregistrer, utilise :
+{"actions": [{...}, {...}]}
+
+Quand l'utilisateur demande ce que tu sais d'un client, d'un projet, d'une tache ou d'un depot, utilise chercher_memoire.
+
Pour tout le reste, reponds en francais naturel, avec des phrases courtes.
N'utilise pas d'anglais sauf si l'utilisateur le demande explicitement.
En mode voiture, sois bref : une ou deux phrases maximum."""
@@ -87,11 +113,23 @@ def extraire_action(reponse):
data, _ = decodeur.raw_decode(reponse[index:])
except json.JSONDecodeError:
continue
- if isinstance(data, dict) and data.get("action") in {"envoyer_email", "ajouter_contact"}:
+ if isinstance(data, dict) and (data.get("action") in ACTIONS or isinstance(data.get("actions"), list)):
return data
return None
+ACTIONS = {
+ "envoyer_email",
+ "ajouter_contact",
+ "ajouter_client",
+ "ajouter_projet",
+ "ajouter_tache",
+ "ajouter_repo",
+ "ajouter_note",
+ "chercher_memoire",
+}
+
+
def ajouter_contact(nom, email):
contacts = charger_contacts()
nom_normalise = " ".join(nom.strip().lower().split())
@@ -137,6 +175,78 @@ def demander_au_llm(session_id, texte):
return contenu
+def detecter_action_directe(texte):
+ texte_nettoye = texte.strip()
+ texte_min = texte_nettoye.lower()
+
+ recherche_match = re.search(
+ r"(?:qu(?:'| )?est[- ]?ce que tu sais sur|que sais[- ]?tu sur|cherche dans la memoire|memoire sur)\s+(.+)",
+ texte_nettoye,
+ flags=re.IGNORECASE,
+ )
+ if recherche_match:
+ return {
+ "action": "chercher_memoire",
+ "requete": recherche_match.group(1).strip(" ?.,;:"),
+ }
+
+ if re.search(r"\b(liste|affiche|montre)\b.*\bclients?\b", texte_min):
+ return {"action": "chercher_memoire", "requete": ""}
+
+ if re.search(r"\b(liste|affiche|montre)\b.*\b(projets?|taches?|repos?|depots?)\b", texte_min):
+ return {"action": "chercher_memoire", "requete": ""}
+
+ repo_match = re.search(
+ r"(?:associe|ajoute|note)\s+(?:le\s+)?repo\s+(\S+)(?:\s+au\s+projet\s+(.+))?",
+ texte_nettoye,
+ flags=re.IGNORECASE,
+ )
+ if repo_match:
+ chemin_ou_url = repo_match.group(1).strip(" .,;:")
+ projet = (repo_match.group(2) or "").strip(" .,;:")
+ return {
+ "action": "ajouter_repo",
+ "chemin": chemin_ou_url if not chemin_ou_url.startswith(("http://", "https://", "git@")) else "",
+ "url": chemin_ou_url if chemin_ou_url.startswith(("http://", "https://", "git@")) else "",
+ "projet": projet,
+ }
+
+ client_match = re.search(
+ r"\b([A-Z0-9][A-Z0-9&.\- ]{2,})\s+est\s+(?:un|une)\s+nouveau\s+client\b",
+ texte_nettoye,
+ flags=re.IGNORECASE,
+ )
+ if not client_match:
+ return None
+
+ nom_client = client_match.group(1).strip(" .,;:")
+ actions = [
+ {
+ "action": "ajouter_client",
+ "nom": nom_client,
+ "notes": texte_nettoye,
+ "statut": "actif",
+ }
+ ]
+
+ rappel_match = re.search(
+ r"(?:je\s+dois\s+|il\s+faut\s+|note\s+(?:quelque\s+part\s+)?que\s+je\s+dois\s+)(rappeler\s+.+)",
+ texte_nettoye,
+ flags=re.IGNORECASE,
+ )
+ if rappel_match:
+ actions.append(
+ {
+ "action": "ajouter_tache",
+ "titre": rappel_match.group(1).strip(" .,;:"),
+ "client": nom_client,
+ "details": texte_nettoye,
+ }
+ )
+
+ return {"actions": actions}
+
+
def transcrire_audio(fichier):
suffix = Path(fichier.filename or "voice.webm").suffix or ".webm"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
@@ -186,20 +296,145 @@ def generer_audio(texte):
pass
-def traiter_message(session_id, texte, avec_audio=False):
- reponse = demander_au_llm(session_id, texte)
- action = extraire_action(reponse)
- email_sent = False
- contact_saved = False
+def executer_action(action):
+ nom_action = action.get("action")
- if action and action.get("action") == "envoyer_email":
+ if nom_action == "envoyer_email":
destinataire = envoyer_email(action["destinataire"], action["sujet"], action["corps"])
- reponse = f"Email envoye a {destinataire}."
- email_sent = True
- elif action and action.get("action") == "ajouter_contact":
+ return f"Email envoye a {destinataire}.", {"emailSent": True}
+
+ if nom_action == "ajouter_contact":
nom, email = ajouter_contact(action["nom"], action["email"])
- reponse = f"Contact ajoute : {nom.title()}."
- contact_saved = True
+ return f"Contact ajoute : {nom.title()}.", {"contactSaved": True}
+
+ if nom_action == "ajouter_client":
+ client_mem = upsert_client(
+ action.get("nom", ""),
+ notes=action.get("notes", ""),
+ status=action.get("statut", "actif"),
+ )
+ return f"C'est note : {client_mem['name']} est dans tes clients.", {"memorySaved": True}
+
+ if nom_action == "ajouter_projet":
+ projet = upsert_project(
+ action.get("nom", ""),
+ client_name=action.get("client", ""),
+ status=action.get("statut", "actif"),
+ summary=action.get("resume", ""),
+ next_action=action.get("prochaine_action", ""),
+ )
+ return f"C'est note pour le projet {projet['name']}.", {"memorySaved": True}
+
+ if nom_action == "ajouter_tache":
+ tache = add_task(
+ action.get("titre", ""),
+ client_name=action.get("client", ""),
+ project_name=action.get("projet", ""),
+ details=action.get("details", ""),
+ due_at=action.get("echeance", ""),
+ )
+ return f"Tache ajoutee : {tache['title']}.", {"memorySaved": True}
+
+ if nom_action == "ajouter_repo":
+ repo = add_repository(
+ action.get("nom", ""),
+ project_name=action.get("projet", ""),
+ local_path=action.get("chemin", ""),
+ remote_url=action.get("url", ""),
+ main_branch=action.get("branche", ""),
+ )
+ return f"Depot associe : {repo['name']}.", {"memorySaved": True}
+
+ if nom_action == "ajouter_note":
+ note = add_memory(
+ action.get("contenu", ""),
+ kind=action.get("type", "note"),
+ subject=action.get("sujet", ""),
+ client_name=action.get("client", ""),
+ project_name=action.get("projet", ""),
+ )
+ sujet = note["subject"] or "memoire professionnelle"
+ return f"C'est note dans {sujet}.", {"memorySaved": True}
+
+ if nom_action == "chercher_memoire":
+ resultat = search_memory(action.get("requete", ""))
+ return formater_memoire(resultat), {"memoryRead": True}
+
+ return "", {}
+
+
+def executer_actions(action_data):
+ actions = action_data.get("actions")
+ if not isinstance(actions, list):
+ actions = [action_data]
+
+ reponses = []
+ flags = {
+ "emailSent": False,
+ "contactSaved": False,
+ "memorySaved": False,
+ "memoryRead": False,
+ }
+ for action in actions:
+ if not isinstance(action, dict):
+ continue
+ reponse, action_flags = executer_action(action)
+ if reponse:
+ reponses.append(reponse)
+ for cle in flags:
+ flags[cle] = flags[cle] or bool(action_flags.get(cle))
+
+ return " ".join(reponses), flags
+
+
+def formater_memoire(resultat):
+ lignes = []
+ for client_mem in resultat["clients"][:5]:
+ detail = f"Client : {client_mem['name']}"
+ if client_mem.get("notes"):
+ detail += f" - {client_mem['notes']}"
+ lignes.append(detail)
+
+ for projet in resultat["projects"][:5]:
+ detail = f"Projet : {projet['name']}"
+ if projet.get("client_name"):
+ detail += f" ({projet['client_name']})"
+ if projet.get("status"):
+ detail += f" - statut : {projet['status']}"
+ if projet.get("next_action"):
+ detail += f" - prochaine action : {projet['next_action']}"
+ lignes.append(detail)
+
+ for tache in resultat["tasks"][:5]:
+ contexte = tache.get("client_name") or tache.get("project_name") or "general"
+ lignes.append(f"Tache : {tache['title']} ({contexte})")
+
+ for repo in resultat["repositories"][:5]:
+ chemin = repo.get("local_path") or repo.get("remote_url") or "chemin non renseigne"
+ lignes.append(f"Depot : {repo['name']} - {chemin}")
+
+ for note in resultat["memories"][:5]:
+ sujet = note.get("subject") or note.get("client_name") or note.get("project_name") or "note"
+ lignes.append(f"Note : {sujet} - {note['content']}")
+
+ if not lignes:
+ return "Je n'ai rien trouve dans la memoire professionnelle."
+ return "Voici ce que j'ai trouve :\n" + "\n".join(f"- {ligne}" for ligne in lignes[:10])
+
+
+def traiter_message(session_id, texte, avec_audio=False):
+ action_directe = detecter_action_directe(texte)
+ reponse = json.dumps(action_directe, ensure_ascii=False) if action_directe else demander_au_llm(session_id, texte)
+ action = extraire_action(reponse)
+ flags = {
+ "emailSent": False,
+ "contactSaved": False,
+ "memorySaved": False,
+ "memoryRead": False,
+ }
+
+ if action:
+ reponse, flags = executer_actions(action)
audio_url = None
if avec_audio and TTS_PROVIDER == "groq":
@@ -208,8 +443,10 @@ def traiter_message(session_id, texte, avec_audio=False):
return {
"reply": reponse,
"audioUrl": audio_url,
- "emailSent": email_sent,
- "contactSaved": contact_saved,
+ "emailSent": flags["emailSent"],
+ "contactSaved": flags["contactSaved"],
+ "memorySaved": flags["memorySaved"],
+ "memoryRead": flags["memoryRead"],
}
diff --git a/deploy/opensquared-assistant.service b/deploy/opensquared-assistant.service
index 30342c2..86a3a3d 100644
--- a/deploy/opensquared-assistant.service
+++ b/deploy/opensquared-assistant.service
@@ -6,6 +6,7 @@ After=network.target
WorkingDirectory=/root/openbot
EnvironmentFile=/root/openbot/.env
Environment=CONTACTS_FILE=/root/openbot/data/contacts.json
+Environment=MEMORY_DB=/root/openbot/data/memory.sqlite
ExecStart=/root/openbot/.venv/bin/gunicorn -w 2 -b 0.0.0.0:8080 app:app
Restart=always
RestartSec=3
diff --git a/memory.py b/memory.py
new file mode 100644
index 0000000..3d284eb
--- /dev/null
+++ b/memory.py
@@ -0,0 +1,398 @@
+import os
+import sqlite3
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent
+
+
+def get_memory_db():
+ return Path(os.getenv("MEMORY_DB", ROOT / "data" / "memory.sqlite"))
+
+
+def connect():
+ memory_db = get_memory_db()
+ memory_db.parent.mkdir(parents=True, exist_ok=True)
+ connection = sqlite3.connect(memory_db)
+ connection.row_factory = sqlite3.Row
+ connection.execute("PRAGMA foreign_keys = ON")
+ return connection
+
+
+def init_db():
+ with connect() as db:
+ db.executescript(
+ """
+ CREATE TABLE IF NOT EXISTS clients (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ status TEXT DEFAULT 'actif',
+ notes TEXT DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS projects (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ status TEXT DEFAULT 'actif',
+ summary TEXT DEFAULT '',
+ next_action TEXT DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS tasks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
+ project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
+ title TEXT NOT NULL,
+ details TEXT DEFAULT '',
+ due_at TEXT DEFAULT '',
+ done INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE TABLE IF NOT EXISTS repositories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
+ name TEXT NOT NULL,
+ local_path TEXT DEFAULT '',
+ remote_url TEXT DEFAULT '',
+ main_branch TEXT DEFAULT '',
+ last_indexed_at TEXT DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(name, local_path, remote_url)
+ );
+
+ CREATE TABLE IF NOT EXISTS memories (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ client_id INTEGER REFERENCES clients(id) ON DELETE SET NULL,
+ project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
+ kind TEXT NOT NULL DEFAULT 'note',
+ subject TEXT DEFAULT '',
+ content TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ """
+ )
+
+
+def row_to_dict(row):
+ return dict(row) if row else None
+
+
+def find_client(db, name):
+ if not name:
+ return None
+ return db.execute(
+ "SELECT * FROM clients WHERE name = ? COLLATE NOCASE",
+ (clean_text(name),),
+ ).fetchone()
+
+
+def find_project(db, name):
+ if not name:
+ return None
+ return db.execute(
+ "SELECT * FROM projects WHERE name = ? COLLATE NOCASE",
+ (clean_text(name),),
+ ).fetchone()
+
+
+def upsert_client(name, notes="", status="actif"):
+ name = clean_text(name)
+ notes = clean_text(notes)
+ status = clean_text(status) or "actif"
+ if not name:
+ raise ValueError("Nom de client manquant")
+
+ with connect() as db:
+ existing = find_client(db, name)
+ if existing:
+ merged_notes = merge_notes(existing["notes"], notes)
+ db.execute(
+ """
+ UPDATE clients
+ SET status = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (status, merged_notes, existing["id"]),
+ )
+ client_id = existing["id"]
+ else:
+ cursor = db.execute(
+ "INSERT INTO clients (name, status, notes) VALUES (?, ?, ?)",
+ (name, status, notes),
+ )
+ client_id = cursor.lastrowid
+ return row_to_dict(db.execute("SELECT * FROM clients WHERE id = ?", (client_id,)).fetchone())
+
+
+def upsert_project(name, client_name="", status="actif", summary="", next_action=""):
+ name = clean_text(name)
+ if not name:
+ raise ValueError("Nom de projet manquant")
+
+ client_id = None
+ with connect() as db:
+ if client_name:
+ client = find_client(db, client_name)
+ if client is None:
+ cursor = db.execute(
+ "INSERT INTO clients (name, notes) VALUES (?, ?)",
+ (clean_text(client_name), "Ajoute automatiquement depuis un projet."),
+ )
+ client_id = cursor.lastrowid
+ else:
+ client_id = client["id"]
+
+ existing = find_project(db, name)
+ if existing:
+ db.execute(
+ """
+ UPDATE projects
+ SET client_id = COALESCE(?, client_id),
+ status = COALESCE(NULLIF(?, ''), status),
+ summary = COALESCE(NULLIF(?, ''), summary),
+ next_action = COALESCE(NULLIF(?, ''), next_action),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ """,
+ (client_id, clean_text(status), clean_text(summary), clean_text(next_action), existing["id"]),
+ )
+ project_id = existing["id"]
+ else:
+ cursor = db.execute(
+ """
+ INSERT INTO projects (client_id, name, status, summary, next_action)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (client_id, name, clean_text(status) or "actif", clean_text(summary), clean_text(next_action)),
+ )
+ project_id = cursor.lastrowid
+ return row_to_dict(db.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone())
+
+
+def add_task(title, client_name="", project_name="", details="", due_at=""):
+ title = clean_text(title)
+ if not title:
+ raise ValueError("Titre de tache manquant")
+
+ with connect() as db:
+ client_id = ensure_client_id(db, client_name)
+ project_id = ensure_project_id(db, project_name, client_id)
+ cursor = db.execute(
+ """
+ INSERT INTO tasks (client_id, project_id, title, details, due_at)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (client_id, project_id, title, clean_text(details), clean_text(due_at)),
+ )
+ return row_to_dict(db.execute("SELECT * FROM tasks WHERE id = ?", (cursor.lastrowid,)).fetchone())
+
+
+def add_repository(name, project_name="", local_path="", remote_url="", main_branch=""):
+ name = clean_text(name) or derive_repo_name(local_path, remote_url)
+ if not name:
+ raise ValueError("Nom de depot manquant")
+
+ with connect() as db:
+ project_id = ensure_project_id(db, project_name, None)
+ db.execute(
+ """
+ INSERT INTO repositories (project_id, name, local_path, remote_url, main_branch)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(name, local_path, remote_url) DO UPDATE SET
+ project_id = COALESCE(excluded.project_id, repositories.project_id),
+ main_branch = COALESCE(NULLIF(excluded.main_branch, ''), repositories.main_branch),
+ updated_at = CURRENT_TIMESTAMP
+ """,
+ (project_id, name, clean_text(local_path), clean_text(remote_url), clean_text(main_branch)),
+ )
+ return row_to_dict(
+ db.execute(
+ """
+ SELECT * FROM repositories
+ WHERE name = ? AND local_path = ? AND remote_url = ?
+ """,
+ (name, clean_text(local_path), clean_text(remote_url)),
+ ).fetchone()
+ )
+
+
+def add_memory(content, kind="note", subject="", client_name="", project_name=""):
+ content = clean_text(content)
+ if not content:
+ raise ValueError("Memoire vide")
+
+ with connect() as db:
+ client_id = ensure_client_id(db, client_name)
+ project_id = ensure_project_id(db, project_name, client_id)
+ cursor = db.execute(
+ """
+ INSERT INTO memories (client_id, project_id, kind, subject, content)
+ VALUES (?, ?, ?, ?, ?)
+ """,
+ (client_id, project_id, clean_text(kind) or "note", clean_text(subject), content),
+ )
+ return row_to_dict(db.execute("SELECT * FROM memories WHERE id = ?", (cursor.lastrowid,)).fetchone())
+
+
+def search_memory(query="", limit=12):
+ query = clean_text(query)
+ like = f"%{query}%"
+ with connect() as db:
+ if query:
+ clients = db.execute(
+ "SELECT * FROM clients WHERE name LIKE ? OR notes LIKE ? ORDER BY updated_at DESC LIMIT ?",
+ (like, like, limit),
+ ).fetchall()
+ projects = db.execute(
+ """
+ SELECT p.*, c.name AS client_name
+ FROM projects p
+ LEFT JOIN clients c ON c.id = p.client_id
+ WHERE p.name LIKE ? OR p.summary LIKE ? OR p.next_action LIKE ? OR c.name LIKE ?
+ ORDER BY p.updated_at DESC LIMIT ?
+ """,
+ (like, like, like, like, limit),
+ ).fetchall()
+ tasks = db.execute(
+ """
+ SELECT t.*, c.name AS client_name, p.name AS project_name
+ FROM tasks t
+ LEFT JOIN clients c ON c.id = t.client_id
+ LEFT JOIN projects p ON p.id = t.project_id
+ WHERE t.done = 0 AND (t.title LIKE ? OR t.details LIKE ? OR c.name LIKE ? OR p.name LIKE ?)
+ ORDER BY t.created_at DESC LIMIT ?
+ """,
+ (like, like, like, like, limit),
+ ).fetchall()
+ repos = db.execute(
+ """
+ SELECT r.*, p.name AS project_name
+ FROM repositories r
+ LEFT JOIN projects p ON p.id = r.project_id
+ WHERE r.name LIKE ? OR r.local_path LIKE ? OR r.remote_url LIKE ? OR p.name LIKE ?
+ ORDER BY r.updated_at DESC LIMIT ?
+ """,
+ (like, like, like, like, limit),
+ ).fetchall()
+ notes = db.execute(
+ """
+ SELECT m.*, c.name AS client_name, p.name AS project_name
+ FROM memories m
+ LEFT JOIN clients c ON c.id = m.client_id
+ LEFT JOIN projects p ON p.id = m.project_id
+ WHERE m.subject LIKE ? OR m.content LIKE ? OR c.name LIKE ? OR p.name LIKE ?
+ ORDER BY m.created_at DESC LIMIT ?
+ """,
+ (like, like, like, like, limit),
+ ).fetchall()
+ else:
+ clients = db.execute("SELECT * FROM clients ORDER BY updated_at DESC LIMIT ?", (limit,)).fetchall()
+ projects = db.execute(
+ """
+ SELECT p.*, c.name AS client_name
+ FROM projects p
+ LEFT JOIN clients c ON c.id = p.client_id
+ ORDER BY p.updated_at DESC LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ tasks = db.execute(
+ """
+ SELECT t.*, c.name AS client_name, p.name AS project_name
+ FROM tasks t
+ LEFT JOIN clients c ON c.id = t.client_id
+ LEFT JOIN projects p ON p.id = t.project_id
+ WHERE t.done = 0
+ ORDER BY t.created_at DESC LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ repos = db.execute(
+ """
+ SELECT r.*, p.name AS project_name
+ FROM repositories r
+ LEFT JOIN projects p ON p.id = r.project_id
+ ORDER BY r.updated_at DESC LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+ notes = db.execute(
+ """
+ SELECT m.*, c.name AS client_name, p.name AS project_name
+ FROM memories m
+ LEFT JOIN clients c ON c.id = m.client_id
+ LEFT JOIN projects p ON p.id = m.project_id
+ ORDER BY m.created_at DESC LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+
+ return {
+ "clients": [row_to_dict(row) for row in clients],
+ "projects": [row_to_dict(row) for row in projects],
+ "tasks": [row_to_dict(row) for row in tasks],
+ "repositories": [row_to_dict(row) for row in repos],
+ "memories": [row_to_dict(row) for row in notes],
+ }
+
+
+def ensure_client_id(db, client_name):
+ client_name = clean_text(client_name)
+ if not client_name:
+ return None
+ client = find_client(db, client_name)
+ if client:
+ return client["id"]
+ cursor = db.execute("INSERT INTO clients (name) VALUES (?)", (client_name,))
+ return cursor.lastrowid
+
+
+def ensure_project_id(db, project_name, client_id):
+ project_name = clean_text(project_name)
+ if not project_name:
+ return None
+ project = find_project(db, project_name)
+ if project:
+ if client_id and not project["client_id"]:
+ db.execute(
+ "UPDATE projects SET client_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
+ (client_id, project["id"]),
+ )
+ return project["id"]
+ cursor = db.execute(
+ "INSERT INTO projects (client_id, name) VALUES (?, ?)",
+ (client_id, project_name),
+ )
+ return cursor.lastrowid
+
+
+def merge_notes(existing, new):
+ existing = clean_text(existing)
+ new = clean_text(new)
+ if not new:
+ return existing
+ if not existing:
+ return new
+ if new.lower() in existing.lower():
+ return existing
+ return f"{existing}\n{new}"
+
+
+def clean_text(value):
+ return " ".join(str(value or "").strip().split())
+
+
+def derive_repo_name(local_path, remote_url):
+ source = clean_text(local_path) or clean_text(remote_url)
+ if not source:
+ return ""
+ name = source.rstrip("/\\").split("/")[-1].split("\\")[-1]
+ return name.removesuffix(".git")
diff --git a/static/app.js b/static/app.js
index 9cbbdf7..b993a96 100644
--- a/static/app.js
+++ b/static/app.js
@@ -323,8 +323,14 @@ async function sendVoiceBlob(blob) {
async function handleAssistantResponse(data) {
const reply = data.reply || "";
addBubble(reply, "assistant");
- const status = data.emailSent ? "Email envoye" : data.contactSaved ? "Contact ajoute" : "Pret";
- setStatus(status, data.emailSent || data.contactSaved ? "live" : "idle");
+ const status = data.emailSent
+ ? "Email envoye"
+ : data.contactSaved
+ ? "Contact ajoute"
+ : data.memorySaved
+ ? "Memoire mise a jour"
+ : "Pret";
+ setStatus(status, data.emailSent || data.contactSaved || data.memorySaved ? "live" : "idle");
if (!audioToggle.checked || !reply) return;
diff --git a/static/index.html b/static/index.html
index 1075acc..bbfc04f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -50,6 +50,6 @@
-
+