aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-10-03 19:47:45 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-10-03 19:47:45 -0300
commita5896fe2e28eb1e9ff9affe5d47ccd443bee5fc2 (patch)
treed76155b2c6aed9b905fe1b04094b879420964177
parent60c586001cf4d21ef1351be8c8ad7a269a6f005a (diff)
downloadclaude-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.md19
-rw-r--r--README.md17
-rw-r--r--claude_logbook/__init__.py2
-rw-r--r--claude_logbook/sessions.py50
-rw-r--r--claude_logbook/template.html19
-rw-r--r--claude_logbook/terminal.py10
-rw-r--r--server/render.go4
-rw-r--r--server/server_test.go10
-rw-r--r--tests/test_sessions.py45
-rw-r--r--tests/test_terminal.py8
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`:
diff --git a/README.md b/README.md
index 4913fcf..d077269 100644
--- a/README.md
+++ b/README.md
@@ -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)