From 0d1fe286ac6ea30b91c15b44c0872b57a864ea7f Mon Sep 17 00:00:00 2001
From: Elvis Claros Castro
Date: Mon, 17 Aug 2026 15:12:12 -0300
Subject: =?UTF-8?q?Explorar=20tambi=C3=A9n=20las=20memorias=20de=20los=20p?=
=?UTF-8?q?royectos?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Claude Code deja, además de las sesiones, un directorio memory/ por proyecto:
un .md por recuerdo con frontmatter, y un MEMORY.md que hace de índice. Lo
que no figura en ese índice sigue en disco pero deja de cargarse en contexto,
así que la diferencia entre ambos es justamente lo que vale la pena mirar.
-m cambia de sesiones a memorias y reusa los mismos verbos:
claude-sesiones -m tabla de memorias
claude-sesiones -m docker busca en nombre, descripción y cuerpo
claude-sesiones -m -s 3 lee la nº 3 (o -s )
claude-sesiones -m --check audita índices, enlaces y orígenes
claude-sesiones -m -D 3 la borra y la saca de MEMORY.md
claude-sesiones -m --type feedback filtra por tipo declarado
--check es la parte con criterio propio: reporta memorias que no están en
MEMORY.md, entradas del índice que apuntan a archivos que ya no existen,
enlaces [[...]] rotos y sesiones de origen que ya no están.
El registro de una memoria usa claves cortas como el de sesiones, por el
mismo motivo: viaja embebido en el HTML, que ahora tiene su propia pestaña
de memorias.
Claude-Session: https://claude.ai/code/session_01RmtZ9qBemrc9TncwVTG6ED
---
README.es.md | 91 ++++++++-
README.md | 90 ++++++++-
claude_sesiones/cli.py | 159 +++++++++++++++-
claude_sesiones/memory.py | 272 ++++++++++++++++++++++++++
claude_sesiones/template.html | 434 +++++++++++++++++++++++++++++++++++++++---
claude_sesiones/terminal.py | 121 ++++++++++++
claude_sesiones/webpage.py | 28 ++-
tests/fixtures.py | 63 ++++++
tests/test_cli.py | 131 ++++++++++++-
tests/test_memory.py | 231 ++++++++++++++++++++++
tests/test_terminal.py | 76 ++++++++
tests/test_webpage.py | 20 +-
12 files changed, 1651 insertions(+), 65 deletions(-)
create mode 100644 claude_sesiones/memory.py
create mode 100644 tests/test_memory.py
diff --git a/README.es.md b/README.es.md
index 09003b6..be117a0 100644
--- a/README.es.md
+++ b/README.es.md
@@ -19,7 +19,8 @@ Español · **[English](README.md)** · Sin dependencias, solo biblioteca están
## ⚠️ Tus transcripciones son privadas
-`--json` y `--html` escriben **el texto completo de tus conversaciones**:
+`--json` y `--html` escriben **el texto completo de tus conversaciones y de las
+memorias de tus proyectos**:
prompts, respuestas, rutas de archivos, nombres de ramas. El `sesiones.html` que
sale es una copia legible de todo lo que escribiste alguna vez en Claude Code.
@@ -54,6 +55,7 @@ claude-sesiones -g "port already" # busca dentro de las conversaciones
claude-sesiones -r 3 # imprime el comando para reanudarla
eval "$(claude-sesiones -r 3)" # …o la reanuda directamente
claude-sesiones --html --open # genera sesiones.html y lo abre
+claude-sesiones -m # las memorias de tus proyectos
```
El número es la posición de la fila **en la tabla que estás viendo**, así que si
@@ -82,6 +84,9 @@ claude-sesiones docker -s 2 # lee el 2º de esos tres
| `--template ARCHIVO` | Usa tu propio template para `--html`. |
| `--open` | Abre en el navegador lo que haya generado `--html`. |
| `--no-cache` | Ignora el caché y re-parsea todo. |
+| `-m`, `--memory` | Trabaja sobre las memorias en vez de las sesiones. |
+| `--type TIPO` | Con `-m`: filtra por `project`, `user`, `feedback` o `reference`. |
+| `--check` | Con `-m`: audita índices, enlaces y sesiones de origen. |
### Borrar sesiones
@@ -98,6 +103,58 @@ Avisa si alguno de los archivos se escribió en los últimos cinco minutos: es m
probable que sea una sesión que Claude Code todavía tiene abierta, y que la
vuelva a escribir al cerrarse.
+## Memoria de los proyectos
+
+Claude Code guarda recuerdos por proyecto en
+`~/.claude/projects//memory/`: un `.md` por memoria, con frontmatter
+YAML y cuerpo markdown, más un `MEMORY.md` que los indexa.
+
+**El índice es lo único que se carga en contexto al arrancar una sesión.** Una
+memoria que está en disco pero no figura en `MEMORY.md` deja de recordarse
+aunque el archivo siga ahí, así que la diferencia entre ambos conviene mirarla.
+
+`-m` cambia el sustantivo y reusa los mismos verbos que ya conocés:
+
+```bash
+claude-sesiones -m # tabla de memorias
+claude-sesiones -m docker # busca en nombre, descripción y cuerpo
+claude-sesiones -m --type user # solo las de un tipo
+claude-sesiones -m -s 3 # lee la memoria nº 3
+claude-sesiones -m -s deadlock # lo mismo, por nombre
+claude-sesiones -m -p /home/u/proj # las de un proyecto
+```
+
+Los tipos los define Claude al escribirlas: **project** es trabajo en curso,
+**user** quién sos y cómo trabajás, **feedback** correcciones tuyas, y
+**reference** punteros a recursos externos.
+
+### Auditar
+
+```bash
+claude-sesiones -m --check
+```
+
+Sale con código 1 si encuentra algo, y reporta:
+
+- proyectos con memorias pero sin `MEMORY.md`;
+- memorias que no figuran en el índice de su proyecto;
+- entradas del índice que apuntan a un archivo que ya no existe;
+- enlaces `[[...]]` sin destino — el formato los permite, marcan algo que
+ todavía no se escribió;
+- memorias cuya sesión de origen ya no está en disco: la memoria sobrevivió a
+ la conversación que la creó.
+
+### Borrar memorias
+
+Igual que con las sesiones: irreversible, pregunta antes salvo con `-y`. Además
+de borrar el archivo, saca su línea de `MEMORY.md` para no dejar el índice
+apuntando a la nada.
+
+```bash
+claude-sesiones -m -D 3 --dry-run # qué borraría
+claude-sesiones -m -D deploy-docker # borra esa memoria
+```
+
## La página HTML
`claude-sesiones --html` genera un único archivo con los datos adentro. Sin
@@ -155,9 +212,18 @@ Algunos detalles que conviene saber:
### Esquema del JSON
-`--json` imprime un arreglo, de la sesión más recientemente activa a la más
-vieja. Las claves son de una letra porque esos mismos registros van embebidos en
-el HTML, donde el costo se paga una vez por sesión:
+`--json` imprime un objeto con dos arreglos: `s` son las sesiones, de la más
+recientemente activa a la más vieja, y `m` las memorias, de la más recién
+modificada a la más vieja.
+
+```json
+{"s": [ … ], "m": [ … ]}
+```
+
+Las claves son de una letra porque esos mismos registros van embebidos en el
+HTML, donde el costo se paga una vez por registro.
+
+Cada sesión de `s`:
| Clave | Qué es |
| --- | --- |
@@ -176,6 +242,23 @@ el HTML, donde el costo se paga una vez por sesión:
| `v` | Versión de Claude Code. |
| `c` | Transcripción: `[{"r": "u"|"a"|"t", "x": texto}]`. |
+Cada memoria de `m`:
+
+| Clave | Qué es |
+| --- | --- |
+| `name` | Nombre del frontmatter (o el del archivo, si falta). |
+| `file` | Nombre del archivo, con extensión. |
+| `p` | Ruta del proyecto. |
+| `desc` | Descripción del frontmatter. |
+| `ty` | Tipo: `project`, `user`, `feedback` o `reference`. |
+| `src` | UUID de la sesión que la escribió, si lo declara. |
+| `body` | Cuerpo markdown, sin el frontmatter. |
+| `ln` | Enlaces `[[...]]` que aparecen en el cuerpo. |
+| `k` | Tamaño en KB. |
+| `l` | Última modificación (ISO 8601). |
+| `ix` | `true` si figura en `MEMORY.md`. |
+| `hix` | `true` si el proyecto tiene `MEMORY.md`. |
+
## Desarrollo
```bash
diff --git a/README.md b/README.md
index 4252071..31756a2 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,8 @@ page you open with a double click.
## ⚠️ Your transcripts are private
-`--json` and `--html` write out **the full text of your conversations**: prompts,
+`--json` and `--html` write out **the full text of your conversations and your
+projects' memories**: prompts,
answers, file paths, branch names. The generated `sesiones.html` is a complete,
readable copy of everything you ever typed into Claude Code.
@@ -58,6 +59,7 @@ claude-sesiones -g "port already" # search inside the conversations
claude-sesiones -r 3 # print the command that resumes it
eval "$(claude-sesiones -r 3)" # …or resume it right away
claude-sesiones --html --open # build sesiones.html and open it
+claude-sesiones -m # your projects' memories
```
The number is the row's position **in the table you are looking at**, so if you
@@ -86,6 +88,9 @@ claude-sesiones docker -s 2 # reads the 2nd of those three
| `--template FILE` | Use your own template for `--html`. |
| `--open` | Open whatever `--html` produced in your browser. |
| `--no-cache` | Ignore the cache and re-parse everything. |
+| `-m`, `--memory` | Work on memories instead of sessions. |
+| `--type KIND` | With `-m`: filter by `project`, `user`, `feedback` or `reference`. |
+| `--check` | With `-m`: audit indexes, links and origin sessions. |
### Deleting sessions
@@ -101,6 +106,58 @@ claude-sesiones -p /tmp --delete-empty # only the empty ones of that project
It warns you about any file written in the last five minutes: that is very
likely a session Claude Code still has open, and it will write it back on exit.
+## Project memory
+
+Claude Code stores per-project memories in
+`~/.claude/projects//memory/`: one `.md` per memory, with YAML
+frontmatter and a markdown body, plus a `MEMORY.md` that indexes them.
+
+**The index is the only part loaded into context when a session starts.** A
+memory that is on disk but missing from `MEMORY.md` stops being remembered even
+though the file is still there, so the gap between the two is worth watching.
+
+`-m` swaps the noun and reuses the verbs you already know:
+
+```bash
+claude-sesiones -m # table of memories
+claude-sesiones -m docker # search name, description and body
+claude-sesiones -m --type user # only one kind
+claude-sesiones -m -s 3 # read memory #3
+claude-sesiones -m -s deadlock # same, by name
+claude-sesiones -m -p /home/u/proj # only one project's
+```
+
+Claude picks the kind when it writes them: **project** is work in progress,
+**user** is who you are and how you work, **feedback** is corrections you gave,
+and **reference** points at external resources.
+
+### Auditing
+
+```bash
+claude-sesiones -m --check
+```
+
+Exits 1 if it finds anything, and reports:
+
+- projects with memories but no `MEMORY.md`;
+- memories missing from their project's index;
+- index entries pointing at a file that no longer exists;
+- `[[...]]` links with no target — the format allows them, they mark something
+ not written yet;
+- memories whose origin session is gone from disk: the memory outlived the
+ conversation that created it.
+
+### Deleting memories
+
+Same as sessions: irreversible, asks first unless you pass `-y`. Besides
+removing the file it drops its line from `MEMORY.md`, so the index is not left
+pointing at nothing.
+
+```bash
+claude-sesiones -m -D 3 --dry-run # what it would delete
+claude-sesiones -m -D deploy-docker # delete that memory
+```
+
## The HTML page
`claude-sesiones --html` produces one file with the data embedded inside it. No
@@ -159,9 +216,17 @@ A few details worth knowing:
### JSON schema
-`--json` prints an array, most recently active first. Keys are one letter
-because the same records are embedded in the HTML, where the cost is paid once
-per session:
+`--json` prints an object with two arrays: `s` holds the sessions, most
+recently active first, and `m` the memories, most recently modified first.
+
+```json
+{"s": [ … ], "m": [ … ]}
+```
+
+Keys are one letter because the same records are embedded in the HTML, where
+the cost is paid once per record.
+
+Each session in `s`:
| Key | Meaning |
| --- | --- |
@@ -180,6 +245,23 @@ per session:
| `v` | Claude Code version. |
| `c` | Transcript: `[{"r": "u"|"a"|"t", "x": text}]`. |
+Each memory in `m`:
+
+| Key | Meaning |
+| --- | --- |
+| `name` | Name from the frontmatter (or the filename, if missing). |
+| `file` | File name, with extension. |
+| `p` | Project path. |
+| `desc` | Description from the frontmatter. |
+| `ty` | Kind: `project`, `user`, `feedback` or `reference`. |
+| `src` | UUID of the session that wrote it, when declared. |
+| `body` | Markdown body, without the frontmatter. |
+| `ln` | `[[...]]` links found in the body. |
+| `k` | Size in KB. |
+| `l` | Last modified (ISO 8601). |
+| `ix` | `true` if listed in `MEMORY.md`. |
+| `hix` | `true` if the project has a `MEMORY.md`. |
+
## Development
```bash
diff --git a/claude_sesiones/cli.py b/claude_sesiones/cli.py
index 90fb51b..4dc5ab2 100644
--- a/claude_sesiones/cli.py
+++ b/claude_sesiones/cli.py
@@ -14,9 +14,10 @@ from .sessions import (
load_sessions, pick, public_records, session_path,
)
from .terminal import (
- Style, clip, fmt_date, fmt_size, plural, print_chat, print_table,
- resume_cmd,
+ Style, clip, fmt_date, fmt_size, plural, print_audit, print_chat,
+ print_memories, print_memory, print_table, resume_cmd,
)
+from . import memory as mem
from . import webpage
DEFAULT_HTML = "sesiones.html"
@@ -46,6 +47,15 @@ borrado (irreversible; pregunta antes, salvo con -y):
claude-sesiones --delete-empty borra las vacías
claude-sesiones -D 101 -D e0a4300e borra sesiones puntuales
claude-sesiones -p /tmp --delete-empty solo las vacías de ese proyecto
+
+memoria de los proyectos (-m cambia de sesiones a memorias y reusa los mismos
+verbos: filtro, -s para leer, -D para borrar):
+ claude-sesiones -m tabla de memorias
+ claude-sesiones -m docker busca en nombre, descripción y cuerpo
+ claude-sesiones -m -s 3 lee la memoria nº 3
+ claude-sesiones -m -s deadlock lo mismo, por nombre
+ claude-sesiones -m --check audita índices, enlaces y orígenes
+ claude-sesiones -m -D 3 la borra y la saca de MEMORY.md
"""
@@ -75,6 +85,14 @@ def build_parser():
help="no usa $PAGER para el chat")
ap.add_argument("--no-color", action="store_true", help="salida sin color")
+ recuerdos = ap.add_argument_group("memoria de los proyectos")
+ recuerdos.add_argument("-m", "--memory", action="store_true",
+ help="trabaja sobre las memorias en vez de las sesiones")
+ recuerdos.add_argument("--type", metavar="TIPO", choices=mem.TYPES,
+ help="filtra por tipo: " + " | ".join(mem.TYPES))
+ recuerdos.add_argument("--check", action="store_true",
+ help="audita índices, enlaces y sesiones de origen")
+
salida = ap.add_argument_group("exportar")
salida.add_argument("--json", action="store_true",
help="vuelca todas las sesiones en JSON")
@@ -214,19 +232,23 @@ def delete_targets(pool, args):
def cmd_json(sessions):
import json
- json.dump(public_records(sessions), sys.stdout,
- ensure_ascii=False, separators=(",", ":"))
+ payload = webpage.build_payload(
+ public_records(sessions),
+ mem.public_records(mem.load_memories(sessions)))
+ json.dump(payload, sys.stdout, ensure_ascii=False, separators=(",", ":"))
sys.stdout.write("\n")
return 0
def cmd_html(sessions, args):
out = args.html
- stats = webpage.write(public_records(sessions), out,
+ memories = mem.public_records(mem.load_memories(sessions))
+ stats = webpage.write(public_records(sessions), out, memories=memories,
template=webpage.template_text(args.template))
print(f"{stats['sesiones']} sesiones · {stats['proyectos']} proyectos · "
f"{stats['mensajes']} mensajes · {stats['bloques']} bloques "
- f"de transcripción → {out}", file=sys.stderr)
+ f"de transcripción · {stats['memorias']} memorias → {out}",
+ file=sys.stderr)
if args.open:
webbrowser.open("file://" + os.path.abspath(out))
return 0
@@ -263,6 +285,124 @@ def cmd_table(sessions, args, st):
return 0
+def mem_filtered(memories, args):
+ return mem.apply_filters(memories, project=args.project,
+ query=" ".join(args.query) or None,
+ kind=args.type)
+
+
+def cmd_mem_show(memories, args, st):
+ m = mem.pick(mem_filtered(memories, args), args.show)
+ buf = io.StringIO()
+ print_memory(m, st, buf, path=mem.memory_path(m))
+ text = buf.getvalue()
+ if args.no_pager:
+ sys.stdout.write(text)
+ else:
+ from .terminal import pager
+ pager(text)
+ return 0
+
+
+def cmd_mem_check(memories, sessions, st):
+ report = mem.audit(memories, sessions)
+ total = print_audit(report, st, sys.stdout)
+ if total:
+ print(f"{st.faint}{plural(total, 'cosa para mirar', 'cosas para mirar')} "
+ f"en {plural(len(memories), 'memoria', 'memorias')}.{st.reset}")
+ return 1
+ print(f"{st.amber}Todo en orden: "
+ f"{plural(len(memories), 'memoria', 'memorias')}, "
+ f"índices y enlaces consistentes.{st.reset}")
+ return 0
+
+
+def delete_memories(targets, args, st):
+ if not targets:
+ print("No hay memorias que borrar con ese criterio.", file=sys.stderr)
+ return 0
+
+ print(f"{st.bold}Se van a borrar "
+ f"{plural(len(targets), 'memoria', 'memorias')}:{st.reset}\n")
+ total_kb = 0
+ for m in targets:
+ total_kb += m["k"]
+ print(f" {st.ink}{clip(m['name'], 38):<38}{st.reset} "
+ f"{clip(m['ty'], 9):<9} "
+ f"{st.grey}{clip(m['p'], 34):<34}{st.reset} "
+ f"{st.faint}{fmt_size(m['k']):>9}{st.reset}")
+ if m["desc"]:
+ print(f" {st.faint}{clip(m['desc'], 86)}{st.reset}")
+
+ print(f"\n{st.faint}{fmt_size(total_kb)} en total · "
+ f"también se quita su línea de MEMORY.md{st.reset}")
+
+ if args.dry_run:
+ print(f"\n{st.faint}--dry-run: no se tocó nada.{st.reset}")
+ return 0
+
+ if not args.yes:
+ print(f"\n{st.copper}Esto no se puede deshacer.{st.reset}")
+ if not confirm("¿Confirmás? [s/N] "):
+ print("Cancelado.", file=sys.stderr)
+ return 1
+
+ done = failed = unlisted = 0
+ for m in targets:
+ try:
+ if mem.delete(m):
+ unlisted += 1
+ done += 1
+ except OSError as e:
+ print(f"error: {m['name']}: {e}", file=sys.stderr)
+ failed += 1
+
+ extra = f", {unlisted} sacadas del índice" if unlisted else ""
+ print(f"\n{plural(done, 'memoria borrada', 'memorias borradas')}{extra}.")
+ return 1 if failed else 0
+
+
+def cmd_mem_table(memories, args, st, total):
+ if not memories:
+ print("Ninguna memoria coincide con ese filtro.", file=sys.stderr)
+ return 1
+ shown = memories[: args.limit] if args.limit else memories
+
+ print_memories(shown, st, latest_activity(shown), sys.stdout)
+
+ projects = len({m["p"] for m in memories})
+ tail = (f"{len(shown)} de {total} memorias" if len(shown) != total
+ else f"{plural(total, 'memoria', 'memorias')} · "
+ f"{plural(projects, 'proyecto', 'proyectos')}")
+ print(f"\n{st.faint}{tail} · -m -s para leer una{st.reset}")
+ return 0
+
+
+def run_memory(sessions, args, st):
+ memories = mem.load_memories(sessions)
+ if not memories:
+ print("Ningún proyecto tiene memorias todavía.", file=sys.stderr)
+ return 1
+
+ if args.check:
+ return cmd_mem_check(memories, sessions, st)
+
+ if args.delete:
+ pool = mem_filtered(memories, args)
+ targets, seen = [], set()
+ for ref in args.delete:
+ m = mem.pick(pool, ref)
+ if m["name"] not in seen:
+ seen.add(m["name"])
+ targets.append(m)
+ return delete_memories(targets, args, st)
+
+ if args.show:
+ return cmd_mem_show(memories, args, st)
+
+ return cmd_mem_table(mem_filtered(memories, args), args, st, len(memories))
+
+
def run(args):
root = default_root()
if not os.path.isdir(root):
@@ -275,13 +415,16 @@ def run(args):
print("No hay ninguna sesión registrada todavía.", file=sys.stderr)
return 1
+ st = Style.from_stream(sys.stdout, args.no_color)
+
+ if args.memory:
+ return run_memory(sessions, args, st)
+
if args.json:
return cmd_json(sessions)
if args.html:
return cmd_html(sessions, args)
- st = Style.from_stream(sys.stdout, args.no_color)
-
if args.delete or args.delete_empty:
targets = delete_targets(filtered(sessions, args), args)
return delete_sessions(targets, args, st)
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 /memory/.
+
+Cada proyecto puede acumular recuerdos en
+
+ ~/.claude/projects//memory/.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)
diff --git a/claude_sesiones/template.html b/claude_sesiones/template.html
index 279749c..e56cc30 100644
--- a/claude_sesiones/template.html
+++ b/claude_sesiones/template.html
@@ -570,6 +570,153 @@
.reader { width: 100%; border-left: 0; }
}
+ /* ---------- pestañas y memorias ---------- */
+
+ .tabs {
+ display: flex;
+ gap: 2px;
+ margin-bottom: 18px;
+ border-bottom: 1px solid var(--line);
+ }
+ .tab {
+ font: inherit;
+ font-size: 13.5px;
+ color: var(--muted);
+ background: none;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ padding: 8px 14px;
+ margin-bottom: -1px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ }
+ .tab:hover { color: var(--ink); }
+ .tab[aria-selected="true"] { color: var(--amber); border-bottom-color: var(--amber); }
+ .tab .n {
+ font-family: var(--mono);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ color: var(--faint);
+ background: var(--raised);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 0 6px;
+ }
+ .tab[aria-selected="true"] .n { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+
+ body[data-mode="s"] .only-m,
+ body[data-mode="m"] .only-s { display: none; }
+
+ .row.mem { grid-template-columns: 3px 1fr 104px 210px 108px; }
+
+ .mem-name { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
+ .mem-name .nm { font-size: 14px; color: var(--ink); font-family: var(--mono); }
+ .mem-desc {
+ margin: 3px 0 0;
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .ty {
+ font-family: var(--mono);
+ font-size: 10.5px;
+ letter-spacing: .05em;
+ padding: 1px 6px;
+ border-radius: 3px;
+ border: 1px solid var(--line);
+ color: var(--muted);
+ white-space: nowrap;
+ }
+ .ty-project { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+ .ty-user { color: var(--focus); border-color: var(--focus); background: transparent; }
+ .ty-feedback { color: var(--copper, var(--amber)); border-color: var(--amber-lo); }
+ .ty-reference { color: var(--muted); }
+
+ /* Una memoria fuera de MEMORY.md sigue en disco pero no se carga en contexto. */
+ .chip-unlisted { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+
+ .mem-body {
+ font-size: 14.5px;
+ line-height: 1.68;
+ color: var(--ink-soft);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ }
+ .mem-body pre {
+ margin: 10px 0;
+ padding: 10px 12px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ overflow-x: auto;
+ font-family: var(--mono);
+ font-size: 12.5px;
+ white-space: pre;
+ color: var(--ink);
+ }
+ .mem-body code {
+ font-family: var(--mono);
+ font-size: .87em;
+ background: var(--surface);
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ padding: 1px 4px;
+ }
+ .mem-body pre code { background: none; border: 0; padding: 0; font-size: inherit; }
+ .mem-body strong { color: var(--ink); font-weight: 600; }
+ .mem-body .h { display: block; margin: 10px 0 -2px; font-weight: 620; color: var(--ink); }
+
+ .wl {
+ font-family: var(--mono);
+ font-size: .9em;
+ color: var(--amber);
+ background: var(--amber-bg);
+ border: 1px solid var(--amber-lo);
+ border-radius: 4px;
+ padding: 0 4px;
+ cursor: pointer;
+ }
+ .wl.dead { color: var(--faint); border-color: var(--line); background: none; cursor: default; }
+
+ .mem-foot {
+ margin-top: 22px;
+ padding-top: 14px;
+ border-top: 1px solid var(--line);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--muted);
+ }
+ .mem-foot .lbl { color: var(--faint); margin-right: 6px; }
+
+ .jump {
+ align-self: flex-start;
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--muted);
+ background: var(--raised);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 6px 10px;
+ cursor: pointer;
+ }
+ .jump:hover { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+
+ @media (max-width: 860px) {
+ .row.mem { grid-template-columns: 3px 1fr auto; }
+ .row.mem .cell-ty { grid-column: 2; padding-bottom: 10px; }
+ .row.mem .cell-proj { display: none; }
+ }
+
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
@@ -590,6 +737,15 @@
<script id="payload">.