# -*- coding: utf-8 -*- """Filesystem-backed project context store.""" import json import os import re import secrets import threading from datetime import datetime from typing import Optional from . import gitea_link from ..knowledge import gitea SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") # Contesti condivisi: stessa struttura dei progetti personali, sotto un namespace comune. # Tutta la famiglia li legge e li aggiorna; archiviazione e ritorno a personale solo # per proprietario o admin. SHARED_NS = "_shared" SHARED_WITH_FAMILY = "family" _SHARED_META_KEYS = ("shared", "shared_with", "owner", "shared_at", "shared_by", "updated_by") class ConflictError(ValueError): """Il contesto è stato modificato da qualcun altro dopo l'ultima lettura.""" # I tool MCP girano in parallelo nel threadpool: serializza le scritture per progetto _project_locks: dict[tuple[str, str], threading.Lock] = {} _project_locks_guard = threading.Lock() def _project_lock(username: str, project_id: str) -> threading.Lock: with _project_locks_guard: return _project_locks.setdefault((username, project_id), threading.Lock()) def _context_root() -> str: return os.environ.get("MCP_CONTEXT_ROOT", "/data/context") def _user_root(username: str) -> str: path = os.path.join(_context_root(), username, "projects") os.makedirs(path, exist_ok=True) return path def _shared_root() -> str: path = os.path.join(_context_root(), SHARED_NS, "projects") os.makedirs(path, exist_ok=True) return path def _project_dir(username: str, project_id: str) -> str: if not SLUG_RE.match(project_id): raise ValueError("ID progetto non valido") return os.path.join(_user_root(username), project_id) def _id_taken(project_id: str, username: str, shared: bool) -> bool: """Un ID condiviso non deve coincidere con progetti personali di nessun utente (altrimenti per quell'utente il personale nasconderebbe il condiviso).""" root = _context_root() if os.path.exists(os.path.join(_shared_root(), project_id)): return True users = [u for u in os.listdir(root) if u != SHARED_NS] if shared else [username] return any(os.path.exists(os.path.join(root, u, "projects", project_id)) for u in users) def _read_meta(meta_path: str) -> dict: with open(meta_path, encoding="utf-8") as f: return json.load(f) def _is_member(meta: dict) -> bool: return meta.get("shared_with") == SHARED_WITH_FAMILY def _resolve_project(username: str, project_id: str) -> tuple[str, str]: """(namespace, cartella) del progetto: prima quello personale, poi quello condiviso.""" personal = _project_dir(username, project_id) if os.path.isfile(os.path.join(personal, "meta.json")): return username, personal shared = os.path.join(_shared_root(), project_id) meta_path = os.path.join(shared, "meta.json") if os.path.isfile(meta_path) and _is_member(_read_meta(meta_path)): return SHARED_NS, shared raise FileNotFoundError("Progetto non trovato") def _load_meta(username: str, project_id: str) -> tuple[str, dict]: _, proj_dir = _resolve_project(username, project_id) meta_path = os.path.join(proj_dir, "meta.json") return meta_path, _read_meta(meta_path) def _require_owner_or_admin(username: str, meta: dict, is_admin: bool, action: str) -> None: if meta.get("shared") and not is_admin and meta.get("owner") != username: raise PermissionError( f"Solo il proprietario ({meta.get('owner')}) o un admin può {action} un contesto condiviso" ) def _save_meta(meta_path: str, meta: dict) -> None: meta["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds") with open(meta_path, "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) def _apply_gitea_link( username: str, meta: dict, gitea_repo: Optional[str], *, verify: bool = True, seed_from_gitea: bool = False, ) -> dict: repo = gitea_link.normalize_gitea_repo(gitea_repo) if verify and repo: if not gitea.is_configured(username): raise RuntimeError( "Gitea non configurato per questo utente — impossibile collegare il repository" ) gitea_link.verify_repo_access(username, repo) if repo: meta["gitea_repo"] = repo else: meta.pop("gitea_repo", None) if seed_from_gitea and repo: _, proj_dir = _resolve_project(username, meta["id"]) ctx_path = os.path.join(proj_dir, "context.md") context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else "" new_md, changed = gitea_link.seed_context_from_readme(username, repo, context_md) if changed: with open(ctx_path, "w", encoding="utf-8") as f: f.write(new_md) meta["gitea_seeded_at"] = datetime.now().astimezone().isoformat(timespec="seconds") return meta def list_projects(username: str, include_archived: bool = False) -> list: """Progetti personali dell'utente seguiti dai contesti condivisi (campo shared=true).""" projects = [] for root, shared in ((_user_root(username), False), (_shared_root(), True)): for name in sorted(os.listdir(root)): meta_path = os.path.join(root, name, "meta.json") if not os.path.isfile(meta_path): continue meta = _read_meta(meta_path) if shared and not _is_member(meta): continue if meta.get("archived") and not include_archived: continue projects.append(meta) return projects def create_project( username: str, title: str, tags: Optional[list[str]] = None, gitea_repo: Optional[str] = None, seed_from_gitea: bool = False, shared: bool = False, ) -> dict: slug_base = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "progetto" project_id = slug_base[:40].strip("-") # ID univoco sia tra i progetti personali sia tra i condivisi (la risoluzione li unisce) while _id_taken(project_id, username, shared): project_id = f"{slug_base[:32].strip('-')}-{secrets.token_hex(2)}" proj_dir = os.path.join(_shared_root() if shared else _user_root(username), project_id) os.makedirs(os.path.join(proj_dir, "sessions"), exist_ok=True) os.makedirs(os.path.join(proj_dir, "artifacts"), exist_ok=True) now = datetime.now().astimezone().isoformat(timespec="seconds") meta = { "id": project_id, "title": title.strip(), "tags": tags or [], "status": "active", "archived": False, "created_at": now, "updated_at": now, } if shared: meta.update(_shared_fields(username, now)) repo = gitea_link.normalize_gitea_repo(gitea_repo) if repo: meta["gitea_repo"] = repo with open(os.path.join(proj_dir, "meta.json"), "w", encoding="utf-8") as f: json.dump(meta, f, ensure_ascii=False, indent=2) with open(os.path.join(proj_dir, "context.md"), "w", encoding="utf-8") as f: f.write(f"# {title.strip()}\n\n") if repo: meta = _apply_gitea_link( username, meta, repo, verify=True, seed_from_gitea=seed_from_gitea, ) _save_meta(os.path.join(proj_dir, "meta.json"), meta) return meta def _shared_fields(username: str, now: str) -> dict: return { "shared": True, "shared_with": SHARED_WITH_FAMILY, "owner": username, "shared_by": username, "shared_at": now, } def share_project(username: str, project_id: str, shared: bool = True, is_admin: bool = False) -> dict: """Sposta un progetto tra lo spazio personale del proprietario e quello condiviso. La storia (context.md, sessions/, artifacts/) si sposta con il progetto. """ ns, proj_dir = _resolve_project(username, project_id) meta = _read_meta(os.path.join(proj_dir, "meta.json")) if shared: if ns == SHARED_NS: return meta target = os.path.join(_shared_root(), project_id) others = [ u for u in os.listdir(_context_root()) if u not in (SHARED_NS, username) and os.path.exists(os.path.join(_context_root(), u, "projects", project_id)) ] if others: raise ValueError( f"L'ID '{project_id}' è usato anche da un progetto personale di {', '.join(others)}: " "crea un contesto condiviso nuovo (create_project shared=true) e copiaci il contenuto" ) else: if ns != SHARED_NS: return meta _require_owner_or_admin(username, meta, is_admin, "rendere personale") target = os.path.join(_user_root(meta["owner"]), project_id) if os.path.exists(target): raise ValueError(f"Esiste già un progetto '{project_id}' nella destinazione: rinomina prima") with _project_lock(ns, project_id): os.rename(proj_dir, target) meta_path = os.path.join(target, "meta.json") meta = _read_meta(meta_path) if shared: meta.update(_shared_fields(username, datetime.now().astimezone().isoformat(timespec="seconds"))) else: for key in _SHARED_META_KEYS: meta.pop(key, None) _save_meta(meta_path, meta) return meta def link_project_repo( username: str, project_id: str, gitea_repo: Optional[str] = None, seed_from_gitea: bool = False, ) -> dict: ns, _ = _resolve_project(username, project_id) with _project_lock(ns, project_id): meta_path, meta = _load_meta(username, project_id) meta = _apply_gitea_link( username, meta, gitea_repo, verify=bool(gitea_link.normalize_gitea_repo(gitea_repo)), seed_from_gitea=seed_from_gitea, ) _save_meta(meta_path, meta) return meta def get_project_context( username: str, project_id: str, session_limit: int = 5, include_gitea: bool = True, ) -> dict: _, proj_dir = _resolve_project(username, project_id) meta = _read_meta(os.path.join(proj_dir, "meta.json")) ctx_path = os.path.join(proj_dir, "context.md") context_md = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else "" sessions_dir = os.path.join(proj_dir, "sessions") sessions = [] if os.path.isdir(sessions_dir): files = sorted(os.listdir(sessions_dir), reverse=True)[:session_limit] for fname in files: path = os.path.join(sessions_dir, fname) if os.path.isfile(path): sessions.append({"name": fname, "content": open(path, encoding="utf-8").read()}) result = {"meta": meta, "context_md": context_md, "recent_sessions": sessions} gitea_repo = meta.get("gitea_repo") if include_gitea and gitea_repo: result["gitea"] = gitea_link.project_enrichment(username, gitea_repo) return result def save_context( username: str, project_id: str, content: str, mode: str = "append", if_revision: Optional[int] = None, ) -> dict: """if_revision: meta.revision letto prima di modificare; se nel frattempo qualcuno ha salvato, ConflictError invece di sovrascrivere (utile nei contesti condivisi).""" ns, proj_dir = _resolve_project(username, project_id) with _project_lock(ns, project_id): return _save_context_locked(username, proj_dir, content, mode, if_revision) def _save_context_locked( username: str, proj_dir: str, content: str, mode: str, if_revision: Optional[int] ) -> dict: meta_path = os.path.join(proj_dir, "meta.json") if not os.path.isfile(meta_path): raise FileNotFoundError("Progetto non trovato") meta = _read_meta(meta_path) if if_revision is not None and int(meta.get("revision", 0)) != int(if_revision): raise ConflictError( f"Contesto modificato (revisione {meta.get('revision', 0)}) il {meta.get('updated_at')}" + (f" da {meta['updated_by']}" if meta.get("updated_by") else "") + ": rileggilo con get_project_context e riapplica la modifica" ) ctx_path = os.path.join(proj_dir, "context.md") if mode == "replace": text = content else: existing = open(ctx_path, encoding="utf-8").read() if os.path.isfile(ctx_path) else "" text = existing.rstrip() + "\n\n" + content.strip() + "\n" with open(ctx_path, "w", encoding="utf-8") as f: f.write(text) meta["revision"] = int(meta.get("revision", 0)) + 1 if meta.get("shared"): meta["updated_by"] = username _save_meta(meta_path, meta) # Nei condivisi il nome della sessione dice chi ha scritto stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + (f"-{username}" if meta.get("shared") else "") snapshot = stamp + ".md" n = 1 while os.path.exists(os.path.join(proj_dir, "sessions", snapshot)): snapshot = f"{stamp}-{n}.md" n += 1 with open(os.path.join(proj_dir, "sessions", snapshot), "w", encoding="utf-8") as f: f.write(content) return meta def archive_project( username: str, project_id: str, archived: bool = True, is_admin: bool = False ) -> dict: ns, _ = _resolve_project(username, project_id) with _project_lock(ns, project_id): meta_path, meta = _load_meta(username, project_id) _require_owner_or_admin(username, meta, is_admin, "archiviare") meta["archived"] = archived meta["status"] = "archived" if archived else "active" _save_meta(meta_path, meta) return meta def list_resources(username: str) -> list: resources = [] for meta in list_projects(username, include_archived=False): ns = SHARED_NS if meta.get("shared") else username desc = f"Contesto {'condiviso con la famiglia' if meta.get('shared') else 'progetto'} {meta['id']}" if meta.get("gitea_repo"): desc += f" (Gitea: {meta['gitea_repo']})" resources.append( { "uri": f"loogle://context/{ns}/{meta['id']}", "name": meta["title"], "description": desc, "mimeType": "text/markdown", } ) return resources def read_resource(username: str, uri: str) -> dict: for ns in (username, SHARED_NS): prefix = f"loogle://context/{ns}/" if uri.startswith(prefix): project_id = uri[len(prefix):] break else: raise FileNotFoundError("Risorsa non trovata") data = get_project_context(username, project_id, session_limit=0, include_gitea=False) text = data["context_md"] gitea_repo = data["meta"].get("gitea_repo") if gitea_repo: text = f"\n\n" + text return { "uri": uri, "mimeType": "text/markdown", "text": text, }