import os import sqlite3 from pathlib import Path ROOT = Path(__file__).resolve().parent ALLOWED_READ_TABLES = {"clients", "projects", "tasks", "repositories", "memories"} FORBIDDEN_SQL_WORDS = { "insert", "update", "delete", "drop", "alter", "create", "replace", "truncate", "attach", "detach", "pragma", "vacuum", "reindex", } 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 execute_read_query(sql, limit=100): sql = normalize_read_sql(sql) limited_sql = f"SELECT * FROM ({sql}) AS readonly_query LIMIT ?" with connect() as db: db.set_authorizer(readonly_authorizer) rows = db.execute(limited_sql, (limit,)).fetchall() return [row_to_dict(row) for row in rows] def normalize_read_sql(sql): sql = clean_text(sql).rstrip(";") sql_lower = sql.lower() if not sql_lower.startswith("select "): raise ValueError("Seules les requetes SELECT sont autorisees") if ";" in sql: raise ValueError("Une seule requete SELECT est autorisee") if "--" in sql or "/*" in sql or "*/" in sql: raise ValueError("Les commentaires SQL ne sont pas autorises") for word in FORBIDDEN_SQL_WORDS: if re_word_match(sql_lower, word): raise ValueError(f"Mot SQL interdit: {word}") return sql def re_word_match(text, word): import re return re.search(rf"\b{re.escape(word)}\b", text) is not None def readonly_authorizer(action, arg1, arg2, db_name, trigger_name): if action == sqlite3.SQLITE_SELECT: return sqlite3.SQLITE_OK if action == sqlite3.SQLITE_READ: table_name = arg1 or "" if table_name in ALLOWED_READ_TABLES: return sqlite3.SQLITE_OK return sqlite3.SQLITE_DENY if action == sqlite3.SQLITE_FUNCTION: return sqlite3.SQLITE_OK return sqlite3.SQLITE_DENY 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 deactivate_client(name): name = clean_text(name) if not name: raise ValueError("Nom de client manquant") with connect() as db: row = find_client(db, name) if not row: return None db.execute( """ UPDATE clients SET status = 'inactif', updated_at = CURRENT_TIMESTAMP WHERE id = ? """, (row["id"],), ) return row_to_dict(row) 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 list_clients(limit=100): with connect() as db: rows = db.execute( """ SELECT * FROM clients WHERE status != 'inactif' ORDER BY name COLLATE NOCASE LIMIT ? """, (limit,), ).fetchall() return [row_to_dict(row) for row in rows] def list_open_tasks(limit=100): with connect() as db: rows = 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 (c.id IS NULL OR c.status != 'inactif') ORDER BY COALESCE(c.name, p.name, 'zzzz') COLLATE NOCASE, t.created_at DESC LIMIT ? """, (limit,), ).fetchall() return [row_to_dict(row) for row in rows] def get_memory_snapshot(limit=25): with connect() as db: clients = db.execute( """ SELECT c.*, COUNT(t.id) AS open_task_count FROM clients c LEFT JOIN tasks t ON t.client_id = c.id AND t.done = 0 WHERE c.status != 'inactif' GROUP BY c.id ORDER BY c.name COLLATE NOCASE 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() 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() 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() return { "clients": [row_to_dict(row) for row in clients], "tasks": [row_to_dict(row) for row in tasks], "projects": [row_to_dict(row) for row in projects], "repositories": [row_to_dict(row) for row in repos], } def close_task(task_id): with connect() as db: row = 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.id = ? AND t.done = 0 """, (task_id,), ).fetchone() if not row: return None db.execute( "UPDATE tasks SET done = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (task_id,), ) return row_to_dict(row) 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}%" search_long_text = int(len(query) > 4) with connect() as db: if query: clients = db.execute( """ SELECT * FROM clients WHERE name LIKE ? OR (? = 1 AND notes LIKE ?) ORDER BY CASE WHEN name = ? COLLATE NOCASE THEN 0 ELSE 1 END, updated_at DESC LIMIT ? """, (like, search_long_text, like, query, 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 c.name LIKE ? OR (? = 1 AND (p.summary LIKE ? OR p.next_action LIKE ?)) ORDER BY CASE WHEN p.name = ? COLLATE NOCASE OR c.name = ? COLLATE NOCASE THEN 0 ELSE 1 END, p.updated_at DESC LIMIT ? """, (like, like, search_long_text, like, like, query, query, 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 CASE WHEN c.name = ? COLLATE NOCASE OR p.name = ? COLLATE NOCASE THEN 0 ELSE 1 END, t.created_at DESC LIMIT ? """, (like, like, like, like, query, query, 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 CASE WHEN r.name = ? COLLATE NOCASE OR p.name = ? COLLATE NOCASE THEN 0 ELSE 1 END, r.updated_at DESC LIMIT ? """, (like, like, like, like, query, query, 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 c.name LIKE ? OR p.name LIKE ? OR (? = 1 AND (m.subject LIKE ? OR m.content LIKE ?)) ORDER BY m.created_at DESC LIMIT ? """, (like, like, search_long_text, 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")