diff options
Diffstat (limited to 'claude_logbook')
| -rw-r--r-- | claude_logbook/__init__.py | 2 | ||||
| -rw-r--r-- | claude_logbook/cli.py | 30 | ||||
| -rw-r--r-- | claude_logbook/sessions.py | 57 | ||||
| -rw-r--r-- | claude_logbook/template.html | 34 | ||||
| -rw-r--r-- | claude_logbook/terminal.py | 7 |
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 |