diff options
Diffstat (limited to 'claude_sesiones/memory.py')
| -rw-r--r-- | claude_sesiones/memory.py | 272 |
1 files changed, 272 insertions, 0 deletions
diff --git a/claude_sesiones/memory.py b/claude_sesiones/memory.py new file mode 100644 index 0000000..ccb2e65 --- /dev/null +++ b/claude_sesiones/memory.py @@ -0,0 +1,272 @@ +"""Memorias de proyecto: los .md que Claude Code deja en <proyecto>/memory/. + +Cada proyecto puede acumular recuerdos en + + ~/.claude/projects/<proyecto>/memory/<nombre>.md + +Un archivo por recuerdo, con frontmatter YAML (`name`, `description`, +`metadata.type`, `metadata.originSessionId`) y cuerpo markdown. Al lado vive +`MEMORY.md`, el índice: una línea por memoria, y es lo único que se carga en +contexto al arrancar una sesión. Una memoria que no figura ahí sigue en disco +pero deja de recordarse, así que la diferencia entre ambos vale la pena mirarla. + +Esquema del registro que devuelve `read_memory`, con las mismas claves cortas +que `sessions` porque también viaja embebido en el HTML: + + name nombre del frontmatter (o el del archivo si falta) + file nombre del archivo, con extensión + p cwd del proyecto + desc descripción del frontmatter + ty tipo declarado: project | user | feedback | reference + src uuid de la sesión que la creó, si lo declara + body cuerpo markdown, sin el frontmatter + ln enlaces [[...]] que aparecen en el cuerpo + k tamaño en KB + l mtime del archivo (ISO 8601) + ix True si figura en MEMORY.md + hix True si el proyecto tiene MEMORY.md + +`project_dir` es interno y `public_records()` lo saca antes de serializar. +""" + +import glob +import os +import re +from datetime import datetime, timezone + +from .sessions import SessionError, default_root + +INDEX_NAME = "MEMORY.md" + +INTERNAL_KEYS = ("project_dir",) + +TYPES = ("project", "user", "feedback", "reference") + +FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?", re.S) +LINK_RE = re.compile(r"\[\[([^\]\n]+)\]\]") +# En el índice cada línea es "- [Título](archivo.md) — pista". +INDEX_LINK_RE = re.compile(r"\(([^)\n]+)\.md\)") + + +def memory_dir(project_dir, root=None): + return os.path.join(root or default_root(), project_dir, "memory") + + +def memory_path(m, root=None): + return os.path.join(memory_dir(m["project_dir"], root), m["file"]) + + +def index_path(project_dir, root=None): + return os.path.join(memory_dir(project_dir, root), INDEX_NAME) + + +# ──────────────────────────────── parseo ──────────────────────────────── + +def _field(front, key): + """Valor de una clave del frontmatter. Plano: alcanza para lo que escribe + Claude Code, que anida `type` y `originSessionId` pero sin repetirlas.""" + hit = re.search(r"^\s*%s:\s*(.+?)\s*$" % re.escape(key), front, re.M) + if not hit: + return None + value = hit.group(1).strip() + # YAML de una línea: si viene entrecomillado, las comillas internas están + # escapadas y hay que devolverlas como estaban. + for quote in ('"', "'"): + if len(value) >= 2 and value[0] == quote and value[-1] == quote: + value = value[1:-1] + if quote == '"': + value = value.replace('\\"', '"').replace("\\\\", "\\") + break + return value or None + + +def read_memory(path, project_dir): + with open(path, "r", encoding="utf-8", errors="ignore") as f: + raw = f.read() + + match = FRONTMATTER_RE.match(raw) + front, body = (match.group(1), raw[match.end():]) if match else ("", raw) + stat = os.stat(path) + filename = os.path.basename(path) + + return { + "name": _field(front, "name") or filename[:-3], + "file": filename, + "project_dir": project_dir, + "desc": _field(front, "description") or "", + "ty": _field(front, "type") or "—", + "src": _field(front, "originSessionId"), + "body": body.strip(), + "ln": sorted(set(LINK_RE.findall(body))), + "k": round(stat.st_size / 1024, 1), + "l": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + + +def read_index(project_dir, root=None): + """Nombres (sin .md) que el MEMORY.md del proyecto enlaza.""" + try: + with open(index_path(project_dir, root), "r", + encoding="utf-8", errors="ignore") as f: + return set(INDEX_LINK_RE.findall(f.read())) + except OSError: + return set() + + +# ──────────────────────────────── carga ──────────────────────────────── + +def load_memories(sessions, root=None): + """Lee las memorias de todos los proyectos. + + La ruta real del proyecto sale de las sesiones: el nombre del directorio + codifica "/" y "." los dos como "-" y no se puede invertir. + """ + root = root or default_root() + cwd_by_dir = {} + for s in sessions: + cwd_by_dir.setdefault(s.get("project_dir"), s.get("p")) + + memories = [] + for d in sorted(glob.glob(os.path.join(root, "*", "memory"))): + project_dir = os.path.basename(os.path.dirname(d)) + files = sorted(f for f in glob.glob(os.path.join(d, "*.md")) + if os.path.basename(f) != INDEX_NAME) + if not files: + continue # un memory/ vacío no es un proyecto con memoria + + has_index = os.path.exists(os.path.join(d, INDEX_NAME)) + listed = read_index(project_dir, root) if has_index else set() + + for path in files: + try: + m = read_memory(path, project_dir) + except OSError: + continue + m["p"] = cwd_by_dir.get(project_dir) or project_dir + m["hix"] = has_index + m["ix"] = m["file"][:-3] in listed + memories.append(m) + + memories.sort(key=lambda m: m["l"], reverse=True) + return memories + + +def public_records(memories): + """Copia sin las claves internas, lista para serializar.""" + out = [] + for m in memories: + clean = dict(m) + for key in INTERNAL_KEYS: + clean.pop(key, None) + out.append(clean) + return out + + +# ──────────────────────────────── filtros ──────────────────────────────── + +def apply_filters(memories, project=None, query=None, kind=None): + out = memories + + if project: + needle = os.path.expanduser(project).rstrip("/").lower() + out = [m for m in out if needle in m["p"].lower()] + + if kind: + out = [m for m in out if m["ty"].lower() == kind.lower()] + + if query: + needle = query.lower() + out = [m for m in out + if needle in m["name"].lower() + or needle in m["desc"].lower() + or needle in m["p"].lower() + or needle in m["body"].lower()] + + return out + + +def pick(memories, ref): + """Resuelve un índice de la tabla (1-based) o un prefijo del nombre.""" + if ref.isdigit(): + i = int(ref) + if 1 <= i <= len(memories): + return memories[i - 1] + raise SessionError( + f"el índice {i} está fuera de rango (hay {len(memories)} memorias)") + + needle = ref.lower() + hits = [m for m in memories if m["name"].lower().startswith(needle)] + if not hits: + hits = [m for m in memories if needle in m["name"].lower()] + if len(hits) == 1: + return hits[0] + if not hits: + raise SessionError(f"ninguna memoria coincide con '{ref}'") + nombres = ", ".join(m["name"] for m in hits[:4]) + raise SessionError( + f"'{ref}' es ambiguo, coincide con {len(hits)}: {nombres}" + + (", …" if len(hits) > 4 else "")) + + +# ──────────────────────────────── auditoría ──────────────────────────────── + +def audit(memories, sessions, root=None): + """Inconsistencias entre archivos, índices, enlaces y sesiones de origen.""" + known = {m["name"] for m in memories} | {m["file"][:-3] for m in memories} + session_ids = {s["id"] for s in sessions} + + report = { + "sin_indice": [m for m in memories if not m["hix"]], + "sin_listar": [m for m in memories if m["hix"] and not m["ix"]], + "enlaces_rotos": [(m, link) for m in memories + for link in m["ln"] if link not in known], + "origen_perdido": [m for m in memories + if m["src"] and m["src"] not in session_ids], + "indice_fantasma": [], + } + + for project_dir in sorted({m["project_dir"] for m in memories if m["hix"]}): + real = {m["file"][:-3] for m in memories + if m["project_dir"] == project_dir} + for missing in sorted(read_index(project_dir, root) - real): + report["indice_fantasma"].append((project_dir, missing)) + + return report + + +def audit_total(report): + return sum(len(v) for v in report.values()) + + +# ──────────────────────────────── borrado ──────────────────────────────── + +def unindex(m, root=None): + """Saca del MEMORY.md la línea que apunta a esta memoria. + + Devuelve True si el índice cambió. No es un error que no cambie: la memoria + podía no estar listada. + """ + path = index_path(m["project_dir"], root) + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + except OSError: + return False + + needle = "(%s)" % m["file"] + kept = [ln for ln in lines if needle not in ln] + if len(kept) == len(lines): + return False + + try: + with open(path, "w", encoding="utf-8") as f: + f.writelines(kept) + except OSError: + return False + return True + + +def delete(m, root=None): + """Borra el archivo y lo saca del índice. Devuelve si se desindexó.""" + os.remove(memory_path(m, root)) + return unindex(m, root) |