Files
openbot/memory.py
2026-04-27 16:19:02 +02:00

504 lines
18 KiB
Python

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 list_clients(limit=100):
with connect() as db:
rows = db.execute(
"SELECT * FROM clients ORDER BY name COLLATE NOCASE 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
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")