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")