diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 23:33:28 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 23:33:28 -0300 |
| commit | cbf81b04e57e03b9e1b646e0d0b7f7c1fd79acc2 (patch) | |
| tree | e62f57de5831167bccdc7eb9007ad0bfc9bee4e7 /claude_logbook | |
| parent | 82ab6d5f70e5067a32ba7ad5db5786322c9fef5f (diff) | |
| download | claude-logbook-main.tar.gz claude-logbook-main.zip | |
The CLI output and help stay in Spanish, as documented in the README.
Diffstat (limited to 'claude_logbook')
| -rw-r--r-- | claude_logbook/__init__.py | 4 | ||||
| -rw-r--r-- | claude_logbook/cli.py | 48 | ||||
| -rw-r--r-- | claude_logbook/memory.py | 106 | ||||
| -rw-r--r-- | claude_logbook/sessions.py | 134 | ||||
| -rw-r--r-- | claude_logbook/terminal.py | 54 | ||||
| -rw-r--r-- | claude_logbook/webpage.py | 32 |
6 files changed, 189 insertions, 189 deletions
diff --git a/claude_logbook/__init__.py b/claude_logbook/__init__.py index e7f967d..0929294 100644 --- a/claude_logbook/__init__.py +++ b/claude_logbook/__init__.py @@ -1,6 +1,6 @@ -"""Explorador de las sesiones que Claude Code guarda en ~/.claude/projects/. +"""Browser for the sessions Claude Code stores in ~/.claude/projects/. -Sin dependencias: solo la biblioteca estándar. +No dependencies: standard library only. """ __version__ = "1.1.0" diff --git a/claude_logbook/cli.py b/claude_logbook/cli.py index 6a465dd..bf1a1af 100644 --- a/claude_logbook/cli.py +++ b/claude_logbook/cli.py @@ -1,4 +1,4 @@ -"""Interfaz de línea de comandos.""" +"""Command-line interface.""" import argparse import io @@ -22,7 +22,7 @@ from . import webpage DEFAULT_HTML = "sesiones.html" -# Una sesión escrita hace menos de esto puede estar abierta en otra terminal. +# A session written less than this long ago may be open in another terminal. RECENT_SECONDS = 300 EPILOG = """\ @@ -85,32 +85,32 @@ 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", + memories_list = ap.add_argument_group("memoria de los proyectos") + memories_list.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, + memories_list.add_argument("--type", metavar="TIPO", choices=mem.TYPES, help="filtra por tipo: " + " | ".join(mem.TYPES)) - recuerdos.add_argument("--check", action="store_true", + memories_list.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", + output_path = ap.add_argument_group("exportar") + output_path.add_argument("--json", action="store_true", help="vuelca todas las sesiones en JSON") - salida.add_argument("--html", nargs="?", const=DEFAULT_HTML, metavar="ARCHIVO", + output_path.add_argument("--html", nargs="?", const=DEFAULT_HTML, metavar="ARCHIVO", help=f"genera una página autocontenida (por defecto {DEFAULT_HTML})") - salida.add_argument("--template", metavar="ARCHIVO", + output_path.add_argument("--template", metavar="ARCHIVO", help="usa otro template para --html") - salida.add_argument("--open", action="store_true", + output_path.add_argument("--open", action="store_true", help="abre en el navegador lo que genere --html") - borrar = ap.add_argument_group("borrado") - borrar.add_argument("-D", "--delete", metavar="REF", nargs="+", + delete_items = ap.add_argument_group("borrado") + delete_items.add_argument("-D", "--delete", metavar="REF", nargs="+", help="borra esas sesiones (índice o prefijo de UUID)") - borrar.add_argument("--delete-empty", action="store_true", + delete_items.add_argument("--delete-empty", action="store_true", help="borra todas las sesiones sin mensajes") - borrar.add_argument("-y", "--yes", action="store_true", + delete_items.add_argument("-y", "--yes", action="store_true", help="no pregunta antes de borrar") - borrar.add_argument("--dry-run", action="store_true", + delete_items.add_argument("--dry-run", action="store_true", help="muestra qué se borraría y no toca nada") ap.add_argument("--no-cache", action="store_true", @@ -130,10 +130,10 @@ def filtered(sessions, args): ) -# ──────────────────────────────── borrado ──────────────────────────────── +# ──────────────────────────────── deletion ─────────────────────────────── def confirm(question): - """Pregunta s/N. Sin terminal no hay confirmación posible: devuelve False.""" + """Asks y/N. Without a terminal no confirmation is possible: returns False.""" try: tty = open("/dev/tty") except OSError: @@ -149,7 +149,7 @@ def confirm(question): def delete_sessions(targets, args, st): - """Borra las sesiones dadas. Devuelve el código de salida.""" + """Deletes the given sessions. Returns the exit code.""" if not targets: print("No hay sesiones que borrar con ese criterio.", file=sys.stderr) return 0 @@ -177,8 +177,8 @@ def delete_sessions(targets, args, st): print(f"\n{st.faint}{fmt_size(total_kb)} en total{st.reset}") if recent: - verbo = "se escribió" if len(recent) == 1 else "se escribieron" - print(f"\n{st.copper}Ojo: {len(recent)} de estas {verbo} hace menos de " + verb = "se escribió" if len(recent) == 1 else "se escribieron" + print(f"\n{st.copper}Ojo: {len(recent)} de estas {verb} hace menos de " f"5 minutos. Si es una sesión abierta ahora mismo, Claude Code la " f"sigue usando y va a volver a escribirla al cerrarse.{st.reset}") @@ -210,7 +210,7 @@ def delete_sessions(targets, args, st): def delete_targets(pool, args): - """Las sesiones que pidió borrar, sin repetidas y en el orden pedido.""" + """The sessions the user asked to delete, without duplicates and in the requested order.""" targets, seen = [], set() if args.delete_empty: @@ -228,7 +228,7 @@ def delete_targets(pool, args): return targets -# ──────────────────────────────── comandos ──────────────────────────────── +# ──────────────────────────────── commands ──────────────────────────────── def cmd_json(sessions): import json @@ -447,7 +447,7 @@ def main(argv=None): print(f"error: {e}", file=sys.stderr) return 2 except (BrokenPipeError, KeyboardInterrupt): - # El pipe ya está cerrado: silenciamos el flush de salida al terminar. + # The pipe is already closed: silence the output flush at exit. try: sys.stdout.close() except Exception: diff --git a/claude_logbook/memory.py b/claude_logbook/memory.py index ccb2e65..955628c 100644 --- a/claude_logbook/memory.py +++ b/claude_logbook/memory.py @@ -1,32 +1,32 @@ -"""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. +"""Project memories: the .md files Claude Code leaves in <project>/memory/. + +Each project can accumulate memories in + + ~/.claude/projects/<project>/memory/<name>.md + +One file per memory, with YAML frontmatter (`name`, `description`, +`metadata.type`, `metadata.originSessionId`) and a markdown body. Next to them +lives `MEMORY.md`, the index: one line per memory, and the only thing loaded +into context when a session starts. A memory missing from it is still on disk +but is no longer remembered, so the difference between both is worth checking. + +Schema of the record returned by `read_memory`, with the same short keys as +`sessions` because it also travels embedded in the HTML: + + name frontmatter name (or the file name if missing) + file file name, with extension + p project cwd + desc frontmatter description + ty declared type: project | user | feedback | reference + src uuid of the session that created it, if declared + body markdown body, without the frontmatter + ln [[...]] links that appear in the body + k size in KB + l file mtime (ISO 8601) + ix True if listed in MEMORY.md + hix True if the project has a MEMORY.md + +`project_dir` is internal and `public_records()` drops it before serializing. """ import glob @@ -44,7 +44,7 @@ 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". +# In the index each line is "- [Title](file.md) — hint". INDEX_LINK_RE = re.compile(r"\(([^)\n]+)\.md\)") @@ -60,17 +60,17 @@ def index_path(project_dir, root=None): return os.path.join(memory_dir(project_dir, root), INDEX_NAME) -# ──────────────────────────────── parseo ──────────────────────────────── +# ──────────────────────────────── parsing ─────────────────────────────── 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.""" + """Value of a frontmatter key. Flat: enough for what Claude Code writes, + which nests `type` and `originSessionId` but without repeating them.""" 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. + # One-line YAML: if it comes quoted, the inner quotes are + # escaped and have to be restored as they were. for quote in ('"', "'"): if len(value) >= 2 and value[0] == quote and value[-1] == quote: value = value[1:-1] @@ -104,7 +104,7 @@ def read_memory(path, project_dir): def read_index(project_dir, root=None): - """Nombres (sin .md) que el MEMORY.md del proyecto enlaza.""" + """Names (without .md) that the project's MEMORY.md links to.""" try: with open(index_path(project_dir, root), "r", encoding="utf-8", errors="ignore") as f: @@ -113,13 +113,13 @@ def read_index(project_dir, root=None): return set() -# ──────────────────────────────── carga ──────────────────────────────── +# ──────────────────────────────── loading ────────────────────────────── def load_memories(sessions, root=None): - """Lee las memorias de todos los proyectos. + """Reads the memories of every project. - La ruta real del proyecto sale de las sesiones: el nombre del directorio - codifica "/" y "." los dos como "-" y no se puede invertir. + The real project path comes from the sessions: the directory name encodes + "/" and "." both as "-" and cannot be reversed. """ root = root or default_root() cwd_by_dir = {} @@ -132,7 +132,7 @@ def load_memories(sessions, root=None): 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 + continue # an empty memory/ is not a project with memory has_index = os.path.exists(os.path.join(d, INDEX_NAME)) listed = read_index(project_dir, root) if has_index else set() @@ -152,7 +152,7 @@ def load_memories(sessions, root=None): def public_records(memories): - """Copia sin las claves internas, lista para serializar.""" + """Copy without the internal keys, ready to serialize.""" out = [] for m in memories: clean = dict(m) @@ -162,7 +162,7 @@ def public_records(memories): return out -# ──────────────────────────────── filtros ──────────────────────────────── +# ──────────────────────────────── filters ──────────────────────────────── def apply_filters(memories, project=None, query=None, kind=None): out = memories @@ -186,7 +186,7 @@ def apply_filters(memories, project=None, query=None, kind=None): def pick(memories, ref): - """Resuelve un índice de la tabla (1-based) o un prefijo del nombre.""" + """Resolves a table index (1-based) or a name prefix.""" if ref.isdigit(): i = int(ref) if 1 <= i <= len(memories): @@ -202,16 +202,16 @@ def pick(memories, ref): return hits[0] if not hits: raise SessionError(f"ninguna memoria coincide con '{ref}'") - nombres = ", ".join(m["name"] for m in hits[:4]) + name_list = ", ".join(m["name"] for m in hits[:4]) raise SessionError( - f"'{ref}' es ambiguo, coincide con {len(hits)}: {nombres}" + f"'{ref}' es ambiguo, coincide con {len(hits)}: {name_list}" + (", …" if len(hits) > 4 else "")) -# ──────────────────────────────── auditoría ──────────────────────────────── +# ──────────────────────────────── audit ──────────────────────────────────── def audit(memories, sessions, root=None): - """Inconsistencias entre archivos, índices, enlaces y sesiones de origen.""" + """Inconsistencies between files, indexes, links and origin sessions.""" known = {m["name"] for m in memories} | {m["file"][:-3] for m in memories} session_ids = {s["id"] for s in sessions} @@ -238,13 +238,13 @@ def audit_total(report): return sum(len(v) for v in report.values()) -# ──────────────────────────────── borrado ──────────────────────────────── +# ──────────────────────────────── deletion ─────────────────────────────── def unindex(m, root=None): - """Saca del MEMORY.md la línea que apunta a esta memoria. + """Removes the line pointing to this memory from MEMORY.md. - Devuelve True si el índice cambió. No es un error que no cambie: la memoria - podía no estar listada. + Returns True if the index changed. It is not an error if it does not: the + memory may not have been listed. """ path = index_path(m["project_dir"], root) try: @@ -267,6 +267,6 @@ def unindex(m, root=None): def delete(m, root=None): - """Borra el archivo y lo saca del índice. Devuelve si se desindexó.""" + """Deletes the file and removes it from the index. Returns whether it was unindexed.""" os.remove(memory_path(m, root)) return unindex(m, root) diff --git a/claude_logbook/sessions.py b/claude_logbook/sessions.py index 1ff48c0..e4fa9b2 100644 --- a/claude_logbook/sessions.py +++ b/claude_logbook/sessions.py @@ -1,28 +1,28 @@ -"""Parseo de los .jsonl que Claude Code deja en ~/.claude/projects/. - -Cada conversación es un archivo JSON Lines: una línea por evento. De ahí sale un -registro por sesión con claves de una letra, porque ese mismo registro viaja -embebido dentro del HTML y los nombres largos se pagan una vez por sesión. - -Esquema del registro que devuelve `read_session`: - - id uuid de la sesión (el nombre del archivo) - p cwd del proyecto - b rama de git - t título - ai True si el título lo generó Claude, False si es el primer mensaje - n True si parece un `claude -p` no interactivo - e True si la sesión no tiene ningún mensaje - i True si `p` se dedujo de otra sesión del mismo proyecto - f/l timestamp del primer y del último evento (ISO 8601) - d duración en minutos - u/a cantidad de mensajes tuyos / de Claude - k tamaño del .jsonl en KB - v versión de Claude Code - c transcripción: [{"r": "u" | "a" | "t", "x": texto}] - -`project_dir` y `mtime` son internos y no salen del módulo: `public_records()` -los saca antes de que el registro se serialice. +"""Parsing of the .jsonl files Claude Code leaves in ~/.claude/projects/. + +Each conversation is a JSON Lines file: one line per event. From it comes one +record per session with one-letter keys, because that same record travels +embedded inside the HTML and long names are paid once per session. + +Schema of the record returned by `read_session`: + + id session uuid (the file name) + p project cwd + b git branch + t title + ai True if Claude generated the title, False if it is the first message + n True if it looks like a non-interactive `claude -p` + e True if the session has no message at all + i True if `p` was inferred from another session of the same project + f/l timestamp of the first and last event (ISO 8601) + d duration in minutes + u/a number of messages from you / from Claude + k size of the .jsonl in KB + v Claude Code version + c transcript: [{"r": "u" | "a" | "t", "x": text}] + +`project_dir` and `mtime` are internal and never leave the module: +`public_records()` drops them before the record is serialized. """ import glob @@ -31,8 +31,8 @@ import os import re from datetime import datetime, timezone -# Sube si cambia el esquema del registro: invalida los cachés viejos en vez de -# leer registros con la forma anterior. +# Bump it when the record schema changes: it invalidates old caches instead of +# reading records with the previous shape. CACHE_VERSION = 2 EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) @@ -41,13 +41,13 @@ INTERNAL_KEYS = ("project_dir", "mtime") class SessionError(Exception): - """Error de uso que la CLI convierte en un mensaje y un código de salida.""" + """Usage error that the CLI turns into a message and an exit code.""" -# ──────────────────────────────── ubicaciones ──────────────────────────────── +# ──────────────────────────────── locations ────────────────────────────────── def default_root(): - """~/.claude/projects, o el equivalente si CLAUDE_CONFIG_DIR está seteada.""" + """~/.claude/projects, or its equivalent if CLAUDE_CONFIG_DIR is set.""" base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join( os.path.expanduser("~"), ".claude") return os.path.join(base, "projects") @@ -59,19 +59,19 @@ def default_cache_path(): def session_path(s, root=None): - """Ruta del .jsonl. El nombre del archivo es el UUID y el del directorio - padre es lo que guardamos en project_dir, así que es reconstruible.""" + """Path of the .jsonl. The file name is the UUID and the parent directory + name is what we keep in project_dir, so it can be rebuilt.""" return os.path.join(root or default_root(), s["project_dir"], s["id"] + ".jsonl") -# ─────────────────────────── parseo de los .jsonl ─────────────────────────── +# ─────────────────────────── parsing the .jsonl files ─────────────────────── TAG_RE = re.compile(r"<[^>]+>") REMINDER_RE = re.compile(r"<system-reminder>.*?</system-reminder>", re.S) -# Un mensaje que empieza con alguno de estos no es texto del usuario: es un -# bloque que genera la propia CLI al ejecutar un comando local. +# A message starting with any of these is not user text: it is a +# block the CLI itself generates when running a local command. SKIP_PREFIXES = ( "<local-command-caveat", "<command-name", "<command-message", "<command-args", "<local-command-stdout", "<system-reminder", @@ -80,11 +80,11 @@ SKIP_PREFIXES = ( TITLE_MAX = 160 TOOL_ARG_MAX = 140 -# Umbral del heurístico de `claude -p`: un único mensaje más largo que esto, sin -# ninguna ida y vuelta, es un pipe por stdin y no una conversación. +# Threshold of the `claude -p` heuristic: a single message longer than this, with +# no back and forth, is a pipe on stdin and not a conversation. NONINTERACTIVE_CHARS = 1500 -# Para cada herramienta, el parámetro que mejor resume qué hizo. +# For each tool, the parameter that best summarizes what it did. TOOL_KEY = { "Bash": "command", "Read": "file_path", "Edit": "file_path", "Write": "file_path", "NotebookEdit": "notebook_path", "Glob": "pattern", @@ -94,7 +94,7 @@ TOOL_KEY = { def clean_text(s): - """Devuelve texto de usuario legible, o None si es ruido del harness.""" + """Returns readable user text, or None if it is harness noise.""" if not isinstance(s, str): return None s = s.strip() @@ -107,7 +107,7 @@ def clean_text(s): def tool_summary(block): - """Una línea del estilo 'Bash: git status' para una llamada a herramienta.""" + """A line like 'Bash: git status' for a tool call.""" name = block.get("name") or "tool" args = block.get("input") or {} if not isinstance(args, dict): @@ -131,7 +131,7 @@ def blocks_of(message): def parse_ts(ts): - """ISO 8601 → datetime con zona, o None si no se puede leer.""" + """ISO 8601 → timezone-aware datetime, or None if it cannot be read.""" if not ts: return None try: @@ -141,7 +141,7 @@ def parse_ts(ts): def read_session(path): - """Parsea un .jsonl entero y devuelve el registro de esa sesión.""" + """Parses a whole .jsonl and returns that session's record.""" session_id = os.path.basename(path)[:-6] # sin .jsonl first_ts = last_ts = cwd = git_branch = version = None ai_title = fallback_title = None @@ -156,7 +156,7 @@ def read_session(path): try: obj = json.loads(line) except json.JSONDecodeError: - continue # línea truncada por una sesión que sigue escribiendo + continue # line truncated by a session that is still writing if not isinstance(obj, dict): continue @@ -164,7 +164,7 @@ def read_session(path): if kind == "ai-title": if obj.get("aiTitle"): - ai_title = obj["aiTitle"] # nos quedamos con el más reciente + ai_title = obj["aiTitle"] # keep the most recent one continue ts = obj.get("timestamp") @@ -219,9 +219,9 @@ def read_session(path): st = os.stat(path) ft, lt = parse_ts(first_ts), parse_ts(last_ts) - # Un único mensaje enorme y ninguna ida y vuelta es la firma de un - # `claude -p` con algo piped por stdin (p. ej. un git diff para redactar el - # mensaje de commit), no de una conversación. + # A single huge message and no back and forth is the signature of a + # `claude -p` with something piped on stdin (e.g. a git diff to write the + # commit message), not of a conversation. noninteractive = ( user_msgs == 1 and not ai_title and bool(convo) and len(convo[0]["x"]) > NONINTERACTIVE_CHARS @@ -248,10 +248,10 @@ def read_session(path): } -# ──────────────────────────────── caché ──────────────────────────────── +# ──────────────────────────────── cache ──────────────────────────────── def _load_cache(path): - """Entradas del caché, o {} si no existe, está roto o quedó viejo.""" + """Cache entries, or {} if it does not exist, is broken or is stale.""" try: with open(path, encoding="utf-8") as f: blob = json.load(f) @@ -264,21 +264,21 @@ def _load_cache(path): def _save_cache(path, entries): - """Escribe el caché de forma atómica. Si falla, no pasa nada.""" + """Writes the cache atomically. If it fails, nothing happens.""" try: os.makedirs(os.path.dirname(path), exist_ok=True) - # El pid en el temporal evita que dos corridas simultáneas se pisen. + # The pid in the temporary file keeps two simultaneous runs from clobbering each other. tmp = f"{path}.{os.getpid()}.tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump({"v": CACHE_VERSION, "entries": entries}, f, ensure_ascii=False, separators=(",", ":")) os.replace(tmp, path) except OSError: - pass # el caché es una optimización, no una condición de uso + pass # the cache is an optimization, not a requirement def drop_from_cache(paths, cache_path=None): - """Saca del caché las sesiones borradas para que no reaparezcan.""" + """Drops deleted sessions from the cache so they do not reappear.""" cache_path = cache_path or default_cache_path() entries = _load_cache(cache_path) if not entries: @@ -287,15 +287,15 @@ def drop_from_cache(paths, cache_path=None): _save_cache(cache_path, entries) -# ──────────────────────────────── carga ──────────────────────────────── +# ──────────────────────────────── loading ────────────────────────────── def _fill_gaps(sessions): - """Completa lo que falta después de parsear todos los archivos. + """Fills in what is missing after parsing every file. - Algunas sesiones (un /resume cancelado) nunca registran cwd. El nombre del - directorio no se puede invertir de forma fiable porque "/" y "." se - codifican los dos como "-", así que tomamos la ruta prestada de otra sesión - del mismo proyecto y lo dejamos marcado en `i`. + Some sessions (a cancelled /resume) never record a cwd. The directory name + cannot be reliably reversed because "/" and "." are both encoded as "-", so + the path is borrowed from another session of the same project and flagged in + `i`. """ known = {} for s in sessions: @@ -313,7 +313,7 @@ def _fill_gaps(sessions): def load_sessions(root=None, cache_path=None, use_cache=True): - """Parsea todas las sesiones, reusando del caché las que no cambiaron.""" + """Parses every session, reusing from the cache the ones that did not change.""" root = root or default_root() cache_path = cache_path or default_cache_path() paths = sorted(glob.glob(os.path.join(root, "*", "*.jsonl"))) @@ -340,8 +340,8 @@ def load_sessions(root=None, cache_path=None, use_cache=True): fresh[path] = {"stamp": stamp, "rec": rec} sessions.append(rec) - # Antes de `_fill_gaps`, a propósito: al caché va el registro tal como salió - # del archivo, sin los campos deducidos a partir de las otras sesiones. + # Before `_fill_gaps`, on purpose: the cache gets the record as it came out + # of the file, without the fields inferred from the other sessions. if use_cache and (reparsed or len(fresh) != len(cache)): _save_cache(cache_path, fresh) @@ -351,19 +351,19 @@ def load_sessions(root=None, cache_path=None, use_cache=True): def latest_activity(sessions): - """El instante más reciente de los datos: el "ahora" contra el que se - calculan las fechas relativas, para que no dependan del reloj de quien mira.""" + """The most recent instant in the data: the "now" relative dates are computed + against, so they do not depend on the viewer's clock.""" stamps = [parse_ts(s["l"]) for s in sessions] return max([t for t in stamps if t], default=EPOCH) def public_records(sessions): - """Copias sin las claves internas, listas para serializar.""" + """Copies without the internal keys, ready to serialize.""" return [{k: v for k, v in s.items() if k not in INTERNAL_KEYS} for s in sessions] -# ──────────────────────────────── filtros ──────────────────────────────── +# ──────────────────────────────── filters ──────────────────────────────── def apply_filters(sessions, project=None, grep=None, query=None, hide_empty=False): @@ -393,7 +393,7 @@ def apply_filters(sessions, project=None, grep=None, query=None, def pick(sessions, ref): - """Resuelve un índice de la tabla (1-based) o un prefijo de UUID.""" + """Resolves a table index (1-based) or a UUID prefix.""" if ref.isdigit(): i = int(ref) if 1 <= i <= len(sessions): diff --git a/claude_logbook/terminal.py b/claude_logbook/terminal.py index 1b56770..fa35eb4 100644 --- a/claude_logbook/terminal.py +++ b/claude_logbook/terminal.py @@ -1,4 +1,4 @@ -"""Salida para la terminal: colores, tabla y lectura de una conversación.""" +"""Terminal output: colors, table and reading a conversation.""" import os import re @@ -14,19 +14,19 @@ MES = ["ene", "feb", "mar", "abr", "may", "jun", ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") -# Anchos mínimos de terminal para mostrar cada columna opcional. +# Minimum terminal widths to show each optional column. MIN_COLS_PATH = 92 MIN_COLS_DUR = 74 class Style: - """Códigos ANSI, o cadenas vacías si la salida no es una terminal.""" + """ANSI codes, or empty strings if the output is not a terminal.""" CODES = { "reset": "0", "bold": "1", "dim": "2", "italic": "3", "amber": "38;5;179", "copper": "38;5;173", "grey": "38;5;245", "faint": "38;5;240", "ink": "38;5;252", "blue": "38;5;110", - # barra de antigüedad, del ámbar vivo al gris + # age bar, from bright amber to grey "age0": "38;5;214", "age1": "38;5;179", "age2": "38;5;137", "age3": "38;5;239", } @@ -43,20 +43,20 @@ class Style: @classmethod def from_stream(cls, stream, no_color=False): - """Color solo si hay terminal, no lo desactivaron y no hay NO_COLOR.""" + """Color only with a terminal, when not disabled and without NO_COLOR.""" return cls(bool(getattr(stream, "isatty", lambda: False)()) and not no_color and not os.environ.get("NO_COLOR")) -# ──────────────────────────────── formato ──────────────────────────────── +# ──────────────────────────────── formatting ───────────────────────────── def visible_len(s): return len(ANSI_RE.sub("", s)) def clip(s, width): - """Recorta a `width` columnas, con … si no entra.""" + """Truncates to `width` columns, with … if it does not fit.""" s = s.replace("\n", " ") if len(s) <= width: return s @@ -105,7 +105,7 @@ def fmt_size(kb): def stripe(iso, now, st): - """Barra de antigüedad a la izquierda de cada fila.""" + """Age bar to the left of each row.""" if not st.on: return "|" n = (now - parse_ts(iso)).total_seconds() / 86400 @@ -120,7 +120,7 @@ def print_table(sessions, st, now, out, width=None): show_path = width >= MIN_COLS_PATH show_dur = width >= MIN_COLS_DUR - # columnas fijas: idx(3) barra(1) fecha(6) rel(9) msgs(4) dur(6) id(8) + gaps + # fixed columns: idx(3) bar(1) date(6) rel(9) msgs(4) dur(6) id(8) + gaps fixed = 3 + 1 + 6 + 9 + 4 + (6 if show_dur else 0) + 8 gaps = 7 if show_dur else 6 flex = max(24, width - fixed - gaps) @@ -167,16 +167,16 @@ def print_table(sessions, st, now, out, width=None): print(" ".join(cells), file=out) -# ──────────────────────────── una conversación ──────────────────────────── +# ──────────────────────────── one conversation ──────────────────────────── def strip_md(text): - """Markdown mínimo para la terminal: saca ** y marcadores de título.""" + """Minimal markdown for the terminal: removes ** and heading markers.""" text = re.sub(r"^#{1,6}\s+", "", text, flags=re.M) return re.sub(r"\*\*([^*\n]+)\*\*", r"\1", text) def render_block(text, st, width, code_color): - """Formatea un mensaje respetando las cercas de código.""" + """Formats a message honouring code fences.""" lines = [] in_code = False for raw in text.split("\n"): @@ -219,8 +219,8 @@ def print_chat(s, st, out, show_tools=True, width=None): first = False continue - # La separación va antes de cada turno: así una tanda de herramientas - # queda pegada al mensaje que la lanzó y separada del siguiente. + # The separator goes before each turn: that way a batch of tool calls + # stays attached to the message that launched it and apart from the next one. if not first: print(file=out) first = False @@ -233,12 +233,12 @@ def print_chat(s, st, out, show_tools=True, width=None): def resume_cmd(s): - """El --resume solo encuentra la sesión desde su directorio original.""" + """--resume only finds the session from its original directory.""" return f"cd {s['p']} && claude --resume {s['id']}" def pager(text): - """Manda el texto a $PAGER si hay terminal; si no, a stdout.""" + """Sends the text to $PAGER with a terminal; otherwise to stdout.""" if not sys.stdout.isatty(): sys.stdout.write(text) return @@ -251,9 +251,9 @@ def pager(text): sys.stdout.write(text) -# ──────────────────────────────── memorias ──────────────────────────────── +# ──────────────────────────────── memories ──────────────────────────────── -# Anchos mínimos para las columnas opcionales de la tabla de memorias. +# Minimum widths for the optional columns of the memories table. MIN_COLS_MEM_PROJECT = 78 MIN_COLS_MEM_DESC = 104 @@ -266,7 +266,7 @@ def print_memories(memories, st, now, out, width=None): show_project = width >= MIN_COLS_MEM_PROJECT show_desc = width >= MIN_COLS_MEM_DESC - # columnas fijas: idx(3) tipo(9) fecha(6) cuándo(9) + separadores + # fixed columns: idx(3) type(9) date(6) when(9) + separators fixed = 3 + 9 + 6 + 9 gaps = 4 + (1 if show_project else 0) + (1 if show_desc else 0) flex = max(18, width - fixed - gaps) @@ -288,7 +288,7 @@ def print_memories(memories, st, now, out, width=None): print(" ".join(head), file=out) for i, m in enumerate(memories, 1): - # El asterisco marca lo que no está en MEMORY.md: existe pero no se carga. + # The asterisk marks what is not in MEMORY.md: it exists but is not loaded. tag = "" if m["ix"] else f" {st.copper}*{st.reset}" avail = w_name - visible_len(tag) cells = [ @@ -321,9 +321,9 @@ def print_memory(m, st, out, path=None, width=None |