diff options
| -rw-r--r-- | README.es.md | 91 | ||||
| -rw-r--r-- | README.md | 90 | ||||
| -rw-r--r-- | claude_sesiones/cli.py | 159 | ||||
| -rw-r--r-- | claude_sesiones/memory.py | 272 | ||||
| -rw-r--r-- | claude_sesiones/template.html | 434 | ||||
| -rw-r--r-- | claude_sesiones/terminal.py | 121 | ||||
| -rw-r--r-- | claude_sesiones/webpage.py | 28 | ||||
| -rw-r--r-- | tests/fixtures.py | 63 | ||||
| -rw-r--r-- | tests/test_cli.py | 131 | ||||
| -rw-r--r-- | tests/test_memory.py | 231 | ||||
| -rw-r--r-- | tests/test_terminal.py | 76 | ||||
| -rw-r--r-- | tests/test_webpage.py | 20 |
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 @@ -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. |