diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-10-03 19:47:45 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-10-03 19:47:45 -0300 |
| commit | a5896fe2e28eb1e9ff9affe5d47ccd443bee5fc2 (patch) | |
| tree | d76155b2c6aed9b905fe1b04094b879420964177 | |
| parent | 60c586001cf4d21ef1351be8c8ad7a269a6f005a (diff) | |
| download | claude-logbook-a5896fe2e28eb1e9ff9affe5d47ccd443bee5fc2.tar.gz claude-logbook-a5896fe2e28eb1e9ff9affe5d47ccd443bee5fc2.zip | |
Use the /rename title, mark compactions, count active time
A title set with /rename (custom-title) now wins over Claude's. The
summary Claude Code leaves when the context is compacted is a block of its
own ("c"), shown as a "context compacted here" mark instead of a message of
yours, and not counted. Duration is active time: pauses over 30 minutes
between messages are left out.
| -rw-r--r-- | README.es.md | 19 | ||||
| -rw-r--r-- | README.md | 17 | ||||
| -rw-r--r-- | claude_logbook/__init__.py | 2 | ||||
| -rw-r--r-- | claude_logbook/sessions.py | 50 | ||||
| -rw-r--r-- | claude_logbook/template.html | 19 | ||||
| -rw-r--r-- | claude_logbook/terminal.py | 10 | ||||
| -rw-r--r-- | server/render.go | 4 | ||||
| -rw-r--r-- | server/server_test.go | 10 | ||||
| -rw-r--r-- | tests/test_sessions.py | 45 | ||||
| -rw-r--r-- | tests/test_terminal.py | 8 |
10 files changed, 162 insertions, 22 deletions
diff --git a/README.es.md b/README.es.md index 17c372d..d62583d 100644 --- a/README.es.md +++ b/README.es.md @@ -293,9 +293,16 @@ del sentido. Algunos detalles que conviene saber: -- **Títulos.** Claude genera uno durante la sesión (eventos `ai-title`); gana el - más reciente. Cuando falta, se usa lo primero que escribiste vos — y ahí se - nota, porque arranca en minúscula o suena a pregunta suelta. +- **Títulos.** Gana el que pusiste con `/rename` (eventos `custom-title`); si + no, el que genera Claude durante la sesión (`ai-title`), el más reciente de + cada uno. Cuando faltan los dos, se usa lo primero que escribiste vos — y ahí + se nota, porque arranca en minúscula o suena a pregunta suelta. +- **Compactación.** Cuando se acaba el contexto (o con `/compact`) Claude Code + reemplaza la conversación anterior por un resumen. Ese resumen se muestra como + una marca "context compacted here", no como un mensaje tuyo, y no se cuenta. +- **Duración** es tiempo activo: los intervalos entre mensajes, sin las pausas + de más de 30 minutos. Una sesión retomada a lo largo de varios días no cuenta + los días del medio. - **Sesiones vacías** son las que se abrieron pero nunca recibieron un mensaje: un `/resume` cancelado, un `/login`. - **No interactivas** son `claude -p` con algo piped por stdin — típicamente un @@ -332,16 +339,16 @@ Cada sesión de `s`: | `p` | Ruta del proyecto (`cwd`). | | `b` | Rama de git. | | `t` | Título. | -| `ai` | `true` si el título lo generó Claude. | +| `ai` | `true` si el título lo pusiste con `/rename` o lo generó Claude. | | `n` | `true` si parece un `claude -p` no interactivo. | | `e` | `true` si la sesión no tiene mensajes. | | `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. | +| `d` | Minutos activos (sin las pausas de más de 30 minutos). | | `u` / `a` | Cantidad de mensajes tuyos / de Claude. | | `k` | Tamaño del archivo en KB. | | `v` | Versión de Claude Code. | -| `c` | Transcripción: `[{"r": "u"|"a"|"t", "x": texto}]`. | +| `c` | Transcripción: `[{"r": "u"|"a"|"t"|"c", "x": texto}]`; `t` es una llamada a herramienta, `c` un resumen de compactación. | Cada memoria de `m`: @@ -292,10 +292,17 @@ about 95% of the bytes on disk and almost none of the meaning. A few details worth knowing: -- **Titles.** Claude generates one during the session (`ai-title` events); the - most recent wins. Without one, the first thing you typed is used instead — +- **Titles.** The one you set with `/rename` wins (`custom-title` events); + otherwise the one Claude generates during the session (`ai-title`), the most + recent of each. Without either, the first thing you typed is used instead — which is visible, because it starts in lowercase or reads like a loose question. +- **Compaction.** When the context runs out (or on `/compact`) Claude Code + replaces the earlier conversation with a summary. That summary is shown as a + "context compacted here" mark, not as a message of yours, and is not counted. +- **Duration** is active time: the gaps between messages, leaving out pauses + longer than 30 minutes. A session resumed over several days does not count + the days in between. - **Empty sessions** were opened but never received a message: a cancelled `/resume`, a `/login`. - **Non-interactive** sessions are `claude -p` with something piped into stdin — @@ -330,16 +337,16 @@ Each session in `s`: | `p` | Project path (`cwd`). | | `b` | Git branch. | | `t` | Title. | -| `ai` | `true` if Claude generated the title. | +| `ai` | `true` if the title was set with `/rename` or generated by Claude. | | `n` | `true` if it looks like a non-interactive `claude -p`. | | `e` | `true` if the session has no messages. | | `i` | `true` if `p` was inferred from a sibling session. | | `f` / `l` | First and last event timestamps (ISO 8601). | -| `d` | Duration in minutes. | +| `d` | Active minutes (pauses over 30 minutes left out). | | `u` / `a` | Message counts, yours / Claude's. | | `k` | File size in KB. | | `v` | Claude Code version. | -| `c` | Transcript: `[{"r": "u"|"a"|"t", "x": text}]`. | +| `c` | Transcript: `[{"r": "u"|"a"|"t"|"c", "x": text}]`; `t` is a tool call, `c` a compaction summary. | Each memory in `m`: diff --git a/claude_logbook/__init__.py b/claude_logbook/__init__.py index b05f474..bcc14b2 100644 --- a/claude_logbook/__init__.py +++ b/claude_logbook/__init__.py @@ -3,6 +3,6 @@ No dependencies: standard library only. """ -__version__ = "1.5.0" +__version__ = "1.5.1" __all__ = ["__version__"] diff --git a/claude_logbook/sessions.py b/claude_logbook/sessions.py index 63b4886..5f3035d 100644 --- a/claude_logbook/sessions.py +++ b/claude_logbook/sessions.py @@ -19,7 +19,8 @@ Schema of the record returned by `read_session`: 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}] + c transcript: [{"r": "u" | "a" | "t" | "c", "x": text}] + (t: tool call summary, c: compaction summary) `project_dir` and `mtime` are internal and never leave the module: `public_records()` drops them before the record is serialized. @@ -33,7 +34,7 @@ from datetime import datetime, timezone # Bump it when the record schema changes: it invalidates old caches instead of # reading records with the previous shape. -CACHE_VERSION = 2 +CACHE_VERSION = 3 EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) @@ -84,6 +85,10 @@ TOOL_ARG_MAX = 140 # no back and forth, is a pipe on stdin and not a conversation. NONINTERACTIVE_CHARS = 1500 +# Active time: a pause between two messages longer than this is the session +# left open, not work, and does not count towards its duration. +IDLE_GAP_MINUTES = 30 + # For each tool, the parameter that best summarizes what it did. TOOL_KEY = { "Bash": "command", "Read": "file_path", "Edit": "file_path", @@ -140,11 +145,26 @@ def parse_ts(ts): return None +def active_minutes(stamps): + """Minutes of work: the gaps between consecutive messages, leaving out + the pauses longer than IDLE_GAP_MINUTES. None without messages.""" + times = sorted(t for t in map(parse_ts, stamps) if t) + if not times: + return None + total = 0.0 + for a, b in zip(times, times[1:]): + gap = (b - a).total_seconds() / 60 + if gap <= IDLE_GAP_MINUTES: + total += gap + return round(total) + + def read_session(path): """Parses a whole .jsonl and returns that session's record.""" session_id = os.path.basename(path)[:-6] # without .jsonl first_ts = last_ts = cwd = git_branch = version = None - ai_title = fallback_title = None + ai_title = custom_title = fallback_title = None + stamps = [] # of the messages, for the active time user_msgs = assistant_msgs = 0 convo = [] @@ -166,6 +186,10 @@ def read_session(path): if obj.get("aiTitle"): ai_title = obj["aiTitle"] # keep the most recent one continue + if kind == "custom-title": + if obj.get("customTitle"): + custom_title = obj["customTitle"] # /rename, wins over the AI one + continue ts = obj.get("timestamp") if ts: @@ -185,10 +209,20 @@ def read_session(path): message = obj.get("message") if not isinstance(message, dict): continue + if ts: + stamps.append(ts) if kind == "user": if obj.get("isMeta"): continue + # What /compact (or running out of context) left in place of + # the earlier conversation: written by Claude, not by you. + if obj.get("isCompactSummary"): + text = "\n".join( + (b.get("text") or "").strip() for b in blocks_of(message) + if isinstance(b, dict) and b.get("type") == "text").strip() + convo.append({"r": "c", "x": text}) + continue for b in blocks_of(message): if not isinstance(b, dict): continue @@ -217,13 +251,11 @@ def read_session(path): assistant_msgs += 1 st = os.stat(path) - ft, lt = parse_ts(first_ts), parse_ts(last_ts) - # 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) + user_msgs == 1 and not (ai_title or custom_title) and bool(convo) and len(convo[0]["x"]) > NONINTERACTIVE_CHARS ) @@ -232,13 +264,13 @@ def read_session(path): "project_dir": os.path.basename(os.path.dirname(path)), "p": cwd, "b": git_branch, - "t": ai_title or fallback_title, - "ai": bool(ai_title), + "t": custom_title or ai_title or fallback_title, + "ai": bool(custom_title or ai_title), "n": noninteractive, "e": not convo, "f": first_ts, "l": last_ts, - "d": round((lt - ft).total_seconds() / 60) if ft and lt else None, + "d": active_minutes(stamps), "u": user_msgs, "a": assistant_msgs, "k": round(st.st_size / 1024, 1), diff --git a/claude_logbook/template.html b/claude_logbook/template.html index c018320..20dfbee 100644 --- a/claude_logbook/template.html +++ b/claude_logbook/template.html @@ -509,6 +509,23 @@ } .tool b { color: var(--amber); font-weight: 500; flex-shrink: 0; } + .compact { margin: 6px 0; } + .compact summary { + font-family: var(--mono); + font-size: 11.5px; + color: var(--muted); + cursor: pointer; + text-align: center; + padding: 6px 0; + border-top: 1px dashed var(--line); + border-bottom: 1px dashed var(--line); + list-style: none; + } + .compact summary::-webkit-details-marker { display: none; } + .compact summary::before { content: '▸ '; } + .compact[open] summary::before { content: '▾ '; } + .compact .bubble { margin-top: 8px; font-size: 13.5px; } + .reader-empty { color: var(--muted); font-family: var(--mono); font-size: 13px; text-align: center; padding: 60px 20px; } /* ---------- toast ---------- */ @@ -1170,6 +1187,8 @@ function openChat(id) { bodyEl.innerHTML = d.c.length ? d.c.map(m => m.r === 't' ? `<div class="tool"><b>⚒</b><span>${esc(m.x)}</span></div>` + : m.r === 'c' + ? `<details class="compact"><summary>Context compacted here · the summary Claude kept</summary><div class="bubble">${renderBody(m.x)}</div></details>` : `<div class="turn turn-${m.r}"><span class="who">${WHO[m.r]}</span><div class="bubble">${renderBody(m.x)}</div></div>` ).join('') : `<p class="reader-empty">This session has no messages.</p>`; diff --git a/claude_logbook/terminal.py b/claude_logbook/terminal.py index 65af046..61d05d8 100644 --- a/claude_logbook/terminal.py +++ b/claude_logbook/terminal.py @@ -214,6 +214,16 @@ def print_chat(s, st, out, show_tools=True, width=None): first = True for m in s["c"]: + if m["r"] == "c": + # The summary is long and Claude's, not part of the dialogue: + # only the mark of where the earlier context was folded. + if not first: + print(file=out) + first = False + label = " context compacted here " + side = max(3, (min(width, 80) - len(label)) // 2) + print(f"{st.faint}{'─' * side}{label}{'─' * side}{st.reset}", file=out) + continue if m["r"] == "t": if show_tools: print(f" {st.faint}⚒ {clip(m['x'], body - 4)}{st.reset}", file=out) diff --git a/server/render.go b/server/render.go index c23df78..be3ba01 100644 --- a/server/render.go +++ b/server/render.go @@ -81,7 +81,7 @@ func ParsePayload(raw []byte, maxSessions int) (*Payload, []byte, error) { return nil, nil, fmt.Errorf("payload: session %d: no c", i) } for j, b := range s.C { - if b.R != "u" && b.R != "a" && b.R != "t" { + if b.R != "u" && b.R != "a" && b.R != "t" && b.R != "c" { return nil, nil, fmt.Errorf("payload: session %d block %d: bad r", i, j) } } @@ -187,6 +187,8 @@ func RenderText(p *Payload) []byte { prev := "" for _, c := range s.C { switch c.R { + case "c": + b.WriteString("\n--- context compacted here ---\n") case "t": if prev != "t" { b.WriteString("\n") diff --git a/server/server_test.go b/server/server_test.go index 72f5b9c..d19c5e8 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -117,6 +117,16 @@ func TestCreateAndRead(t *testing.T) { } } +func TestCompactionBlock(t *testing.T) { + h := newHarness(t, nil) + withC := strings.Replace(session, `"c":[`, `"c":[{"r":"c","x":"earlier summary"},`, 1) + r := h.create(`{"payload":` + payload(withC) + `}`) + txt := h.do("GET", "/share/"+r.ID+".txt", "", "").Body.String() + if !strings.Contains(txt, "--- context compacted here ---") || strings.Contains(txt, "earlier summary") { + t.Fatalf("txt: %s", txt) + } +} + func TestUpdateAndDeleteNeedTheSecret(t *testing.T) { h := newHarness(t, nil) r := h.create(`{"payload":` + payload() + `}`) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index 1e10a48..7342058 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -97,6 +97,51 @@ class TestReadSession(TempRoot): self.assertFalse(S.read_session(path)["n"]) +class TestTitlesCompactionDuration(TempRoot): + def test_rename_beats_the_claude_title(self): + path = write_session(self.root, "-p", "22222222-0000-0000-0000-000000000001", [ + ai_title("What Claude thinks"), + user("hi there", at=ts(0)), + {"type": "custom-title", "customTitle": "Old name"}, + {"type": "custom-title", "customTitle": "Lo grave"}, + ai_title("A later Claude title"), + ]) + rec = S.read_session(path) + self.assertEqual(rec["t"], "Lo grave") + self.assertTrue(rec["ai"]) + + def test_compact_summary_is_not_a_message_of_yours(self): + path = write_session(self.root, "-p", "22222222-0000-0000-0000-000000000002", [ + {"type": "system", "subtype": "compact_boundary", "timestamp": ts(0)}, + user("This session is being continued from a previous conversation. " + "Summary: we fixed the build.", at=ts(0), isCompactSummary=True, + isVisibleInTranscriptOnly=True), + user("now the tests", at=ts(2)), + assistant("On it.", at=ts(3)), + ]) + rec = S.read_session(path) + self.assertEqual([m["r"] for m in rec["c"]], ["c", "u", "a"]) + self.assertIn("we fixed the build", rec["c"][0]["x"]) + self.assertEqual(rec["u"], 1) + self.assertEqual(rec["t"], "now the tests") + self.assertFalse(rec["n"]) + + def test_duration_leaves_out_long_pauses(self): + path = write_session(self.root, "-p", "22222222-0000-0000-0000-000000000003", [ + user("start", at=ts(0)), + assistant("ok", at=ts(10)), + user("next day", at=ts(0, day=15)), + assistant("ok", at=ts(20, day=15)), + {"type": "system", "timestamp": ts(0, day=20)}, # not a message + ]) + self.assertEqual(S.read_session(path)["d"], 30) + + def test_active_minutes(self): + self.assertIsNone(S.active_minutes([])) + self.assertEqual(S.active_minutes([ts(0)]), 0) + self.assertEqual(S.active_minutes([ts(0), ts(30), ts(31, hour=11)]), 30) + + class TestToolSummary(unittest.TestCase): def test_uses_the_representative_parameter(self): self.assertEqual( diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 0446dda..e7e6d69 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -127,6 +127,14 @@ class TestOutput(unittest.TestCase): T.print_chat(self.session_item(), T.Style(False), buf, show_tools=False) self.assertNotIn("Bash: ls", buf.getvalue()) + def test_compaction_is_a_mark_without_the_summary(self): + buf = io.StringIO() + c = [{"r": "c", "x": "SUMMARY TEXT"}, {"r": "u", "x": "hello"}] + T.print_chat(self.session_item(c=c), T.Style(False), buf) + out = buf.getvalue() + self.assertIn("context compacted here", out) + self.assertNotIn("SUMMARY TEXT", out) + def test_empty_session_says_so(self): buf = io.StringIO() T.print_chat(self.session_item(c=[], t=None), T.Style(False), buf) |