aboutsummaryrefslogtreecommitdiffstats
path: root/claude_logbook
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 23:33:28 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 23:33:28 -0300
commitcbf81b04e57e03b9e1b646e0d0b7f7c1fd79acc2 (patch)
treee62f57de5831167bccdc7eb9007ad0bfc9bee4e7 /claude_logbook
parent82ab6d5f70e5067a32ba7ad5db5786322c9fef5f (diff)
downloadclaude-logbook-main.tar.gz
claude-logbook-main.zip
Translate identifiers, comments and docstrings to EnglishHEADmain
The CLI output and help stay in Spanish, as documented in the README.
Diffstat (limited to 'claude_logbook')
-rw-r--r--claude_logbook/__init__.py4
-rw-r--r--claude_logbook/cli.py48
-rw-r--r--claude_logbook/memory.py106
-rw-r--r--claude_logbook/sessions.py134
-rw-r--r--claude_logbook/terminal.py54
-rw-r--r--claude_logbook/webpage.py32
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