aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.es.md91
-rw-r--r--README.md90
-rw-r--r--claude_sesiones/cli.py159
-rw-r--r--claude_sesiones/memory.py272
-rw-r--r--claude_sesiones/template.html434
-rw-r--r--claude_sesiones/terminal.py121
-rw-r--r--claude_sesiones/webpage.py28
-rw-r--r--tests/fixtures.py63
-rw-r--r--tests/test_cli.py131
-rw-r--r--tests/test_memory.py231
-rw-r--r--tests/test_terminal.py76
-rw-r--r--tests/test_webpage.py20
12 files changed, 1651 insertions, 65 deletions
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/<proyecto>/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/<project>/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 <nº> 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 <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)
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.