aboutsummaryrefslogtreecommitdiffstats
path: root/claude_logbook
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-10-03 23:29:44 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-10-03 23:29:44 -0300
commit80ea7b675e918b930af01b82b60583d474bc0105 (patch)
tree0d73b3a65344e810be61263dfa86490eef68510b /claude_logbook
parentf546a8a1578bef9fbc144580b84f51e3e90480b1 (diff)
downloadclaude-logbook-main.tar.gz
claude-logbook-main.zip
Share and export a session's images with --imagesHEADmain
Diffstat (limited to 'claude_logbook')
-rw-r--r--claude_logbook/__init__.py2
-rw-r--r--claude_logbook/cli.py30
-rw-r--r--claude_logbook/sessions.py57
-rw-r--r--claude_logbook/template.html34
-rw-r--r--claude_logbook/terminal.py7
5 files changed, 119 insertions, 11 deletions
diff --git a/claude_logbook/__init__.py b/claude_logbook/__init__.py
index 334c56b..faa8544 100644
--- a/claude_logbook/__init__.py
+++ b/claude_logbook/__init__.py
@@ -3,6 +3,6 @@
No dependencies: standard library only.
"""
-__version__ = "1.6.0"
+__version__ = "1.7.0"
__all__ = ["__version__"]
diff --git a/claude_logbook/cli.py b/claude_logbook/cli.py
index 64c6c01..0802507 100644
--- a/claude_logbook/cli.py
+++ b/claude_logbook/cli.py
@@ -10,8 +10,8 @@ import webbrowser
from . import __version__
from .sessions import (
- SessionError, apply_filters, default_root, drop_from_cache, latest_activity,
- load_sessions, pick, public_records, session_path,
+ SessionError, apply_filters, count_images, default_root, drop_from_cache,
+ latest_activity, load_sessions, pick, public_records, session_path, with_images,
)
from .terminal import (
Style, clip, fmt_date, fmt_size, plural, print_audit, print_chat,
@@ -77,6 +77,7 @@ user settings unless -p names a project, then to its settings.local.json;
sharing (one session, readable by anyone with the link; asks first unless -y):
claude-logbook -s 3 --share upload #3, prints its link
claude-logbook -s 3 --share --expire 7d gone in a week (default 30d)
+ claude-logbook -s 3 --share --images with the screenshots and pictures
claude-logbook --shares what this machine has shared
claude-logbook --unshare 5d10f1ee delete it (share id, URL or session)
"""
@@ -149,6 +150,9 @@ def build_parser():
help="use another template for --html")
output_path.add_argument("--open", action="store_true",
help="open what --html writes in the browser")
+ output_path.add_argument("--images", action="store_true",
+ help="with -s: include the session's images in --html, "
+ "--json or --share")
sharing = ap.add_argument_group("share")
sharing.add_argument("--share", action="store_true",
@@ -296,7 +300,8 @@ def export_selection(sessions, args):
only the memories of the projects that made it. A single session (-s) goes
alone, as it would be shared: no memories."""
if args.show:
- return [pick(filtered(sessions, args), args.show)], []
+ s = pick(filtered(sessions, args), args.show)
+ return [with_images(s) if args.images else s], []
else:
chosen = filtered(sessions, args)
if args.limit:
@@ -598,6 +603,9 @@ def cmd_share(sessions, args, st):
if s["e"]:
raise SessionError("that session has no messages, there is nothing to share")
server = share.server_url(args.server)
+ if args.images:
+ s = with_images(s)
+ images, with_src = count_images(s)
payload = webpage.build_payload(public_records([s]), [])
size = len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode())
old = share.entry_for_session(share.load_registry(), s["id"], server)
@@ -606,6 +614,12 @@ def cmd_share(sessions, args, st):
print(f"{st.bold}{s['t'] or 'Untitled session'}{st.reset}", file=err)
print(f" {short_home(s['p'])} · {s['u']} yours / {s['a']} from Claude · "
f"{len(s['c'])} blocks · {fmt_size(size / 1024)}", file=err)
+ if with_src:
+ print(f" {plural(with_src, 'image', 'images')} included; they are not "
+ f"checked for secrets, look at them first", file=err)
+ elif images:
+ print(f" {plural(images, 'image', 'images')} left out (add --images "
+ f"to include them)", file=err)
where = f"updates {old['url']}" if old else f"new link on {server}"
print(f" {where}, expires in {args.expire}", file=err)
for kind, blocks in share.scan_secrets(payload):
@@ -623,7 +637,12 @@ def cmd_share(sessions, args, st):
print("Nothing was uploaded.", file=err)
return 1
- entry, created = share.publish(server, payload, args.expire, s["id"], s["t"])
+ try:
+ entry, created = share.publish(server, payload, args.expire, s["id"], s["t"])
+ except SessionError as e:
+ if with_src and "too large" in str(e):
+ raise SessionError(f"{e}; try it without --images") from None
+ raise
print(f"{'Shared' if created else 'Updated'} · expires {fmt_expiry(entry)}", file=err)
print(entry["url"])
print(f"{st.faint} {entry['url']}.txt · {entry['url']}.json{st.reset}", file=err)
@@ -684,6 +703,9 @@ def run(args):
if args.memory:
return run_memory(sessions, args, st)
+ if args.images and not args.show:
+ raise SessionError("--images needs a session (-s REF)")
+
if args.share:
return cmd_share(sessions, args, st)
diff --git a/claude_logbook/sessions.py b/claude_logbook/sessions.py
index 75db116..362b398 100644
--- a/claude_logbook/sessions.py
+++ b/claude_logbook/sessions.py
@@ -19,9 +19,11 @@ 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" | "c" | "d", "x": text}]
+ c transcript: [{"r": "u" | "a" | "t" | "c" | "d" | "i" | "v", "x": text}]
(t: tool call summary, c: compaction summary, d: the working
- directory changed to x)
+ directory changed to x, i: an image you attached, v: an image a
+ tool gave Claude). Image blocks carry the picture itself in "src",
+ as a data: URL, only when read with `images=True`
h how many blocks at the start of `c` were copied from the session
this one branched from (/branch); 0 if it is not a branch
o id of that session, or None
@@ -42,7 +44,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 = 4
+CACHE_VERSION = 5
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
@@ -136,6 +138,30 @@ def tool_summary(block):
return f"{name}: {val}" if val else name
+# The formats a browser shows that cannot carry a script (so no SVG).
+IMAGE_TYPES = ("image/png", "image/jpeg", "image/gif", "image/webp")
+
+
+def image_src(block):
+ """data: URL of an image block, or None if it is not one we can show."""
+ src = block.get("source")
+ if not isinstance(src, dict) or src.get("type") != "base64":
+ return None
+ kind, data = src.get("media_type"), src.get("data")
+ if kind not in IMAGE_TYPES or not isinstance(data, str) or not data:
+ return None
+ return f"data:{kind};base64,{data}"
+
+
+def image_block(role, block, images):
+ out = {"r": role, "x": "[image]"}
+ if images:
+ src = image_src(block)
+ if src:
+ out["src"] = src
+ return out
+
+
def blocks_of(message):
content = message.get("content")
if isinstance(content, str):
@@ -167,8 +193,10 @@ def active_minutes(stamps):
return round(total)
-def read_session(path):
- """Parses a whole .jsonl and returns that session's record."""
+def read_session(path, images=False):
+ """Parses a whole .jsonl and returns that session's record. With images
+ the image blocks bring the pictures, which can weigh megabytes: that
+ record is for a single export, never for the cache."""
session_id = os.path.basename(path)[:-6] # without .jsonl
first_ts = last_ts = cwd = here = git_branch = version = None
origin = None # the session /branch copied the history from
@@ -263,7 +291,12 @@ def read_session(path):
fallback_title = text[:TITLE_MAX]
convo.append({"r": "u", "x": text})
elif b.get("type") == "image":
- convo.append({"r": "u", "x": "[imagen adjunta]"})
+ convo.append(image_block("i", b, images))
+ elif b.get("type") == "tool_result" and isinstance(b.get("content"), list):
+ # What a tool handed back to Claude: a screenshot, an image it read.
+ for part in b["content"]:
+ if isinstance(part, dict) and part.get("type") == "image":
+ convo.append(image_block("v", part, images))
else:
counted = False
for b in blocks_of(message):
@@ -417,6 +450,18 @@ def load_sessions(root=None, cache_path=None, use_cache=True):
return sessions
+def with_images(s, root=None):
+ """The same record with its pictures, read again from its .jsonl."""
+ full = read_session(session_path(s, root), images=True)
+ return {**s, "c": full["c"]}
+
+
+def count_images(s):
+ """(images, of which with the picture) in a record."""
+ imgs = [m for m in s["c"] if m["r"] in ("i", "v")]
+ return len(imgs), sum(1 for m in imgs if m.get("src"))
+
+
def latest_activity(sessions):
"""The most recent instant in the data: the "now" relative dates are computed
against, so they do not depend on the viewer's clock."""
diff --git a/claude_logbook/template.html b/claude_logbook/template.html
index 39740ac..85edcb4 100644
--- a/claude_logbook/template.html
+++ b/claude_logbook/template.html
@@ -561,6 +561,25 @@
border-bottom: 1px dashed var(--line);
}
+ .shot { margin: 8px 0 8px 12px; }
+ .shot.mine { margin-left: 0; }
+ .shot.mine img { border-color: var(--amber-lo); }
+ .shot img {
+ display: block;
+ max-width: min(100%, 360px);
+ max-height: 260px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ cursor: zoom-in;
+ }
+ .shot.zoom img { max-width: 100%; max-height: none; cursor: zoom-out; }
+ .shot-missing {
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--muted);
+ padding: 3px 0 3px 12px;
+ }
+
.cwd {
font-family: var(--mono);
font-size: 11.5px;
@@ -1216,11 +1235,24 @@ function showReader() {
if (!SINGLE) readerEl.focus();
}
+// Only a data: URL of a raster image ever reaches an <img>: the server checks
+// it, and this checks it again for pages written by hand.
+const IMG_RE = /^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+/=\s]+$/;
+
+function imageHtml(m) {
+ const what = m.r === 'i' ? 'image attached' : 'image from the tool';
+ if (!m.src || !IMG_RE.test(m.src))
+ return `<div class="shot-missing">▣ ${what}${m.src ? '' : ' (not included)'}</div>`;
+ return `<div class="shot${m.r === 'i' ? ' mine' : ''}"><img src="${m.src}" alt="${what}" title="${what}" loading="lazy"></div>`;
+}
+
function blockHtml(m) {
return m.r === 't'
? `<div class="tool"><b>⚒</b><span>${esc(m.x)}</span></div>`
: m.r === 'd'
? `<div class="cwd">→ ${esc(m.x)}</div>`
+ : m.r === 'i' || m.r === 'v'
+ ? imageHtml(m)
: 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>`;
@@ -1392,6 +1424,8 @@ rowsEl.addEventListener('keydown', e => {
// Inside the reader: [[...]] links between memories and jump to the source session.
bodyEl.addEventListener('click', e => {
+ const shot = e.target.closest('.shot');
+ if (shot) { shot.classList.toggle('zoom'); return; }
const wl = e.target.closest('[data-wl]');
if (wl) { openMemory(wl.dataset.wl); return; }
const jump = e.target.closest('[data-chat]');
diff --git a/claude_logbook/terminal.py b/claude_logbook/terminal.py
index 351c9bf..1731f14 100644
--- a/claude_logbook/terminal.py
+++ b/claude_logbook/terminal.py
@@ -236,6 +236,13 @@ def print_chat(s, st, out, show_tools=True, width=None):
first = False
divider(" context compacted here ")
continue
+ if m["r"] in ("i", "v"):
+ # An image cannot be printed here: only that it was there.
+ if m["r"] == "i" or show_tools:
+ what = "image attached" if m["r"] == "i" else "image from the tool"
+ print(f" {st.faint}▣ {what}{st.reset}", file=out)
+ first = False
+ continue
if m["r"] == "d":
print(f" {st.faint}→ {clip(m['x'], body - 4)}{st.reset}", file=out)
first = False