Last active
June 22, 2026 20:28
-
-
Save georgestephanis/aed824ea91e6f6c20968b59fccb95752 to your computer and use it in GitHub Desktop.
Ever want to keep a log of conversations you have with claude in your projects, to serve as instructional tools or something to look at and review later? This'll do it, just drop the python file in `~/.claude/hooks/` and then drop the html file in your projects so it can render the generated jsonl file for you easily!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Conversation Log</title> | |
| <script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| background-color: #e5ddd5; | |
| color: #333; | |
| line-height: 1.5; | |
| padding: 20px; | |
| padding-top: 64px; | |
| max-width: 900px; | |
| margin: 0 auto; | |
| } | |
| #toolbar { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| z-index: 100; | |
| background: #2c3e50; | |
| color: #fff; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 0 16px; | |
| height: 44px; | |
| box-shadow: 0 2px 6px rgba(0,0,0,0.3); | |
| font-size: 0.85em; | |
| } | |
| #toolbar h1 { | |
| font-size: 1em; | |
| font-weight: 600; | |
| margin: 0; | |
| margin-right: 12px; | |
| color: #fff; | |
| white-space: nowrap; | |
| } | |
| .tb-sep { | |
| width: 1px; | |
| height: 20px; | |
| background: rgba(255,255,255,0.2); | |
| margin: 0 4px; | |
| } | |
| .tb-toggle { | |
| display: flex; | |
| align-items: center; | |
| gap: 5px; | |
| cursor: pointer; | |
| user-select: none; | |
| padding: 4px 8px; | |
| border-radius: 6px; | |
| transition: background 0.15s; | |
| white-space: nowrap; | |
| } | |
| .tb-toggle:hover { background: rgba(255,255,255,0.12); } | |
| .tb-toggle input[type=checkbox] { accent-color: #4fc3f7; cursor: pointer; } | |
| #toolbar .spacer { flex: 1; } | |
| .tb-btn { | |
| background: rgba(255,255,255,0.15); | |
| border: none; | |
| color: #fff; | |
| padding: 4px 10px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| font-size: 0.9em; | |
| transition: background 0.15s; | |
| } | |
| .tb-btn:hover { background: rgba(255,255,255,0.28); } | |
| /* Hide-tool-calls mode */ | |
| body.hide-tools .tool-call { display: none; } | |
| body.hide-tools .tools-only { display: none; } | |
| /* Hide-notifications mode */ | |
| body.hide-notifications .notification-row { display: none; } | |
| /* Hide-timestamps mode */ | |
| body.hide-timestamps .meta { display: none; } | |
| /* Compact mode — tighten gap between rows */ | |
| body.compact .conversation-container { gap: 2px; } | |
| h1 { | |
| text-align: center; | |
| color: #2c3e50; | |
| margin-bottom: 16px; | |
| } | |
| .conversation-container { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| padding: 16px 0; | |
| } | |
| .message-row { | |
| display: flex; | |
| align-items: flex-end; | |
| gap: 8px; | |
| } | |
| .message-row.user-row { | |
| flex-direction: row-reverse; | |
| } | |
| .avatar { | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 14px; | |
| font-weight: bold; | |
| color: white; | |
| } | |
| .user-row .avatar { background: #2196f3; } | |
| .assistant-row .avatar { background: #757575; } | |
| .bubble-wrap { | |
| display: flex; | |
| flex-direction: column; | |
| max-width: 72%; | |
| min-width: 0; | |
| overflow: hidden; | |
| } | |
| .user-row .bubble-wrap { align-items: flex-end; } | |
| .assistant-row .bubble-wrap { align-items: flex-start; } | |
| .bubble { | |
| padding: 10px 14px; | |
| border-radius: 18px; | |
| font-size: 0.95em; | |
| word-break: break-word; | |
| } | |
| .user-row .bubble { | |
| background: #dcf8c6; | |
| border-bottom-right-radius: 4px; | |
| white-space: pre-wrap; | |
| } | |
| .assistant-row .bubble { | |
| background: #ffffff; | |
| border-bottom-left-radius: 4px; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.1); | |
| } | |
| /* Markdown rendered inside assistant bubbles */ | |
| .assistant-row .bubble > :first-child { margin-top: 0; } | |
| .assistant-row .bubble > :last-child { margin-bottom: 0; } | |
| .assistant-row .bubble p { margin: 0.4em 0; } | |
| .assistant-row .bubble h1, | |
| .assistant-row .bubble h2, | |
| .assistant-row .bubble h3 { margin: 0.6em 0 0.3em; font-size: 1em; font-weight: bold; } | |
| .assistant-row .bubble ul, | |
| .assistant-row .bubble ol { margin: 0.3em 0; padding-left: 1.4em; } | |
| .assistant-row .bubble li { margin: 0.15em 0; } | |
| .assistant-row .bubble code { | |
| background: #f0f0f0; | |
| padding: 1px 4px; | |
| border-radius: 3px; | |
| font-family: monospace; | |
| font-size: 0.9em; | |
| } | |
| .assistant-row .bubble pre { | |
| background: #f5f5f5; | |
| border: 1px solid #ddd; | |
| border-radius: 6px; | |
| padding: 8px 10px; | |
| overflow-x: auto; | |
| margin: 0.4em 0; | |
| white-space: pre; | |
| } | |
| .assistant-row .bubble pre code { | |
| background: none; | |
| padding: 0; | |
| } | |
| .assistant-row .bubble blockquote { | |
| border-left: 3px solid #ccc; | |
| margin: 0.4em 0; | |
| padding-left: 0.8em; | |
| color: #666; | |
| } | |
| .assistant-row .bubble a { color: #1a73e8; } | |
| .meta { | |
| font-size: 0.72em; | |
| color: #999; | |
| margin-top: 3px; | |
| padding: 0 4px; | |
| } | |
| .tool-call { | |
| background-color: #fff8e1; | |
| border: 1px solid #ffc107; | |
| padding: 10px; | |
| margin-top: 8px; | |
| border-radius: 10px; | |
| font-family: monospace; | |
| font-size: 0.85em; | |
| width: 100%; | |
| min-width: 0; | |
| overflow-x: auto; | |
| } | |
| .tool-call-inner { | |
| overflow: hidden; | |
| transition: max-height 0.25s ease; | |
| } | |
| .tool-call-inner.collapsed { | |
| max-height: 250px; | |
| -webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%); | |
| mask-image: linear-gradient(to bottom, black 60%, transparent 100%); | |
| } | |
| .tool-expand-btn { | |
| display: block; | |
| margin-top: 6px; | |
| background: none; | |
| border: none; | |
| color: #e65100; | |
| font-size: 0.85em; | |
| cursor: pointer; | |
| padding: 0; | |
| font-family: inherit; | |
| } | |
| .tool-expand-btn:hover { text-decoration: underline; } | |
| .tool-header { | |
| font-weight: bold; | |
| color: #e65100; | |
| margin-bottom: 5px; | |
| } | |
| pre { | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| margin: 4px 0 0; | |
| } | |
| .notification-row { | |
| display: flex; | |
| justify-content: center; | |
| margin: 2px 0; | |
| } | |
| .notification-pill { | |
| background: rgba(0,0,0,0.08); | |
| color: #666; | |
| font-size: 0.78em; | |
| padding: 3px 12px; | |
| border-radius: 12px; | |
| font-family: monospace; | |
| max-width: 80%; | |
| text-align: center; | |
| } | |
| .notification-pill strong { color: #444; font-family: sans-serif; } | |
| .message-row { position: relative; } | |
| .jump-btn { | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| right: -42px; | |
| background: #2196f3; | |
| color: white; | |
| border: none; | |
| border-radius: 50%; | |
| width: 30px; | |
| height: 30px; | |
| font-size: 16px; | |
| line-height: 30px; | |
| text-align: center; | |
| cursor: pointer; | |
| opacity: 0; | |
| transition: opacity 0.15s; | |
| box-shadow: 0 1px 4px rgba(0,0,0,0.25); | |
| padding: 0; | |
| } | |
| .assistant-row:hover .jump-btn { opacity: 1; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="toolbar"> | |
| <h1>Conversation Log</h1> | |
| <div class="tb-sep"></div> | |
| <label class="tb-toggle"><input type="checkbox" id="tog-tools"> Tool calls</label> | |
| <label class="tb-toggle"><input type="checkbox" id="tog-notifs"> System messages</label> | |
| <label class="tb-toggle"><input type="checkbox" id="tog-timestamps" checked> Timestamps</label> | |
| <label class="tb-toggle"><input type="checkbox" id="tog-compact"> Compact</label> | |
| <div class="spacer"></div> | |
| <button class="tb-btn" id="btn-bottom">↓ Bottom</button> | |
| </div> | |
| <div id="log-container" class="conversation-container"> | |
| <p>Loading conversation...</p> | |
| </div> | |
| <script> | |
| async function loadLog() { | |
| const container = document.getElementById('log-container'); | |
| try { | |
| // Use a fallback for file:// protocol by checking if the window location starts with file:// | |
| let url = 'conversation-log.jsonl'; | |
| if (window.location.protocol === 'file:') { | |
| // In file:// mode, we can't use fetch() due to CORS. | |
| // We provide a message explaining this and a button to try a local server. | |
| const port = Math.floor(Math.random() * (9999 - 8100 + 1)) + 8100; | |
| const deepLink = `http://localhost:${port}/conversation-log.html`; | |
| container.innerHTML = ` | |
| <div style="padding: 20px; text-align: center; color: #e67e22;"> | |
| <p><strong>Note:</strong> Browsers block <code>fetch()</code> requests from <code>file://</code> URLs for security reasons.</p> | |
| <p>To view this log, please run a local web server in this directory:</p> | |
| <div style="background: #eee; padding: 10px; border-radius: 4px; margin-bottom: 10px;"> | |
| <p><strong>Python:</strong> <code>python3 -m http.server ${port}</code></p> | |
| <p><strong>Node.js:</strong> <code>npx http-server . -p ${port}</code></p> | |
| <p><strong>PHP:</strong> <code>php -S localhost:${port}</code></p> | |
| </div> | |
| <p>Then open <a href="${deepLink}" target="_blank">${deepLink}</a></p> | |
| </div>`; | |
| return; | |
| } | |
| const response = await fetch(url); | |
| const text = await response.text(); | |
| const lines = text.split('\n').filter(line => line.trim() !== ''); | |
| container.innerHTML = ''; | |
| const allRows = []; | |
| lines.forEach((line, index) => { | |
| try { | |
| const entry = JSON.parse(line); | |
| const isUser = entry.role === 'user'; | |
| const rawContent = entry.content || ''; | |
| // Strip system tags out of user messages, collect them as annotations | |
| const SYSTEM_TAGS = ['task-notification', 'ide_opened_file', 'system-reminder']; | |
| const foundTags = []; | |
| let stripped = rawContent; | |
| if (isUser) { | |
| stripped = rawContent.replace(/<([a-z_-]+)>([\s\S]*?)<\/\1>/g, (match, tag, inner) => { | |
| if (!SYSTEM_TAGS.includes(tag)) return match; | |
| let detail = ''; | |
| if (tag === 'task-notification') { | |
| const s = inner.match(/<status>(.*?)<\/status>/s); | |
| const t = inner.match(/<task-id>(.*?)<\/task-id>/s); | |
| detail = (s ? s[1].trim() : '?') + (t ? ` · …${t[1].trim().slice(-8)}` : ''); | |
| } else if (tag === 'ide_opened_file') { | |
| detail = inner.trim().split('/').pop() || ''; | |
| } | |
| foundTags.push({ tag, detail }); | |
| return ''; | |
| }).trim(); | |
| } | |
| // If nothing but system tags, render as centred pill row and stop | |
| if (isUser && !stripped && foundTags.length) { | |
| foundTags.forEach(({ tag, detail }) => { | |
| const row = document.createElement('div'); | |
| row.className = 'notification-row'; | |
| row.dataset.role = 'notification'; | |
| allRows.push(row); | |
| const pill = document.createElement('div'); | |
| pill.className = 'notification-pill'; | |
| pill.innerHTML = `<strong>${tag}</strong>${detail ? ' · ' + detail : ''}`; | |
| row.appendChild(pill); | |
| container.appendChild(row); | |
| }); | |
| return; | |
| } | |
| // Outer row (positions bubble left or right) | |
| const row = document.createElement('div'); | |
| row.className = `message-row ${isUser ? 'user-row' : 'assistant-row'}`; | |
| row.dataset.role = entry.role; | |
| allRows.push(row); | |
| // Avatar | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'avatar'; | |
| avatar.textContent = isUser ? 'G' : 'AI'; | |
| row.appendChild(avatar); | |
| // Wrap holds bubble + meta + tool calls | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'bubble-wrap'; | |
| // Bubble — skip if no visible content | |
| const displayContent = isUser ? stripped : rawContent; | |
| const isCcr = /^<<ccr:[a-f0-9]+,/.test(displayContent); | |
| if (displayContent.trim() || isCcr) { | |
| const bubble = document.createElement('div'); | |
| bubble.className = 'bubble'; | |
| if (isCcr) { | |
| const em = document.createElement('em'); | |
| em.style.color = '#aaa'; | |
| em.textContent = '[compressed content reference]'; | |
| bubble.appendChild(em); | |
| } else if (!isUser && typeof marked !== 'undefined') { | |
| bubble.innerHTML = marked.parse(displayContent); | |
| } else { | |
| bubble.textContent = displayContent; | |
| } | |
| wrap.appendChild(bubble); | |
| } | |
| // Subtle system-tag annotations — appended to container after row to avoid overflow clipping | |
| if (foundTags.length) { | |
| foundTags.forEach(({ tag, detail }) => { | |
| const ann = document.createElement('div'); | |
| ann.className = 'notification-row'; | |
| ann.dataset.role = 'notification'; | |
| const pill = document.createElement('div'); | |
| pill.className = 'notification-pill'; | |
| pill.innerHTML = `<strong>${tag}</strong>${detail ? ' · ' + detail : ''}`; | |
| ann.appendChild(pill); | |
| // defer so it lands after the row is appended | |
| row._annotations = row._annotations || []; | |
| row._annotations.push(ann); | |
| }); | |
| } | |
| // Tool calls (appended inside wrap, below bubble) | |
| if (Array.isArray(entry.tool_calls) && entry.tool_calls.length > 0) { | |
| entry.tool_calls.forEach(tc => { | |
| const toolDiv = document.createElement('div'); | |
| toolDiv.className = 'tool-call'; | |
| const inner = document.createElement('div'); | |
| inner.className = 'tool-call-inner collapsed'; | |
| const header = document.createElement('div'); | |
| header.className = 'tool-header'; | |
| header.textContent = `Tool: ${tc.name}`; | |
| inner.appendChild(header); | |
| const inputPre = document.createElement('pre'); | |
| inputPre.textContent = JSON.stringify(tc.input, null, 2); | |
| inner.appendChild(inputPre); | |
| if (tc.result !== undefined && tc.result !== null) { | |
| const resultLabel = document.createElement('div'); | |
| resultLabel.style.cssText = 'font-weight:bold;margin-top:6px;color:#555;'; | |
| resultLabel.textContent = 'Result:'; | |
| inner.appendChild(resultLabel); | |
| const resultPre = document.createElement('pre'); | |
| resultPre.textContent = typeof tc.result === 'string' ? tc.result : JSON.stringify(tc.result, null, 2); | |
| inner.appendChild(resultPre); | |
| } | |
| toolDiv.appendChild(inner); | |
| // Only add expand toggle after render (need scrollHeight) | |
| requestAnimationFrame(() => { | |
| if (inner.scrollHeight > 250) { | |
| const btn = document.createElement('button'); | |
| btn.className = 'tool-expand-btn'; | |
| btn.textContent = '▼ Show more'; | |
| btn.addEventListener('click', () => { | |
| const expanded = !inner.classList.contains('collapsed'); | |
| if (expanded) { | |
| inner.classList.add('collapsed'); | |
| inner.style.maxHeight = ''; | |
| btn.textContent = '▼ Show more'; | |
| } else { | |
| inner.classList.remove('collapsed'); | |
| inner.style.maxHeight = inner.scrollHeight + 'px'; | |
| btn.textContent = '▲ Show less'; | |
| } | |
| }); | |
| toolDiv.appendChild(btn); | |
| } else { | |
| inner.classList.remove('collapsed'); | |
| } | |
| }); | |
| wrap.appendChild(toolDiv); | |
| }); | |
| } | |
| // Timestamp below bubble | |
| const meta = document.createElement('div'); | |
| meta.className = 'meta'; | |
| meta.textContent = new Date(entry.timestamp).toLocaleString(); | |
| wrap.appendChild(meta); | |
| // Mark rows whose only content is tool calls | |
| if (!wrap.querySelector('.bubble') && wrap.querySelector('.tool-call')) { | |
| row.classList.add('tools-only'); | |
| } | |
| row.appendChild(wrap); | |
| // Jump-to-next-user-message button on assistant rows | |
| if (!isUser) { | |
| const jumpBtn = document.createElement('button'); | |
| jumpBtn.className = 'jump-btn'; | |
| jumpBtn.title = 'Jump to next message'; | |
| jumpBtn.textContent = '↓'; | |
| jumpBtn.addEventListener('click', () => { | |
| const myIdx = allRows.indexOf(row); | |
| const next = allRows.slice(myIdx + 1).find(r => r.dataset.role === 'user'); | |
| if (next) next.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| }); | |
| row.appendChild(jumpBtn); | |
| } | |
| container.appendChild(row); | |
| (row._annotations || []).forEach(ann => container.appendChild(ann)); | |
| } catch (e) { | |
| console.error("Error parsing line", index, e); | |
| } | |
| }); | |
| } catch (err) { | |
| container.innerHTML = `<p style="color:red;">Error loading log: ${err.message}</p>`; | |
| } | |
| } | |
| loadLog(); | |
| // Toolbar toggles | |
| function tbToggle(id, cls) { | |
| document.getElementById(id).addEventListener('change', e => { | |
| document.body.classList.toggle(cls, !e.target.checked); | |
| }); | |
| } | |
| document.body.classList.add('hide-tools'); | |
| tbToggle('tog-tools', 'hide-tools'); | |
| tbToggle('tog-timestamps', 'hide-timestamps'); | |
| // Notifications hidden by default — toggle is unchecked, class pre-applied | |
| document.body.classList.add('hide-notifications'); | |
| document.getElementById('tog-notifs').addEventListener('change', e => { | |
| document.body.classList.toggle('hide-notifications', !e.target.checked); | |
| }); | |
| document.getElementById('tog-compact').addEventListener('change', e => { | |
| document.body.classList.toggle('compact', e.target.checked); | |
| }); | |
| document.getElementById('btn-bottom').addEventListener('click', () => { | |
| window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Appends every user prompt and assistant reply (with tool calls) to conversation-log.jsonl.""" | |
| import json | |
| import os | |
| import sys | |
| from datetime import datetime, timezone | |
| PROJECT_DIR = sys.argv[1] if len(sys.argv) > 1 else None | |
| PROJECT_LOG = os.path.join(PROJECT_DIR, "conversation-log.jsonl") if PROJECT_DIR else None | |
| def append(entry): | |
| if not PROJECT_LOG or not os.path.isdir(PROJECT_DIR): | |
| return | |
| with open(PROJECT_LOG, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(entry, ensure_ascii=False) + "\n") | |
| def extract_result_text(content): | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| return "\n".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text") | |
| return "" | |
| def main(): | |
| try: | |
| data = json.load(sys.stdin) | |
| except Exception: | |
| sys.exit(0) | |
| event = data.get("hook_event_name", "") | |
| session_id = data.get("session_id", "") | |
| ts = datetime.now(timezone.utc).isoformat() | |
| if event == "UserPromptSubmit": | |
| append({ | |
| "timestamp": ts, | |
| "session_id": session_id, | |
| "role": "user", | |
| "content": data.get("prompt", ""), | |
| }) | |
| elif event == "Stop": | |
| transcript_path = data.get("transcript_path", "") | |
| if not transcript_path or not os.path.exists(transcript_path): | |
| sys.exit(0) | |
| # Load all transcript entries | |
| entries = [] | |
| with open(transcript_path, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| entries.append(json.loads(line)) | |
| except Exception: | |
| continue | |
| # Find the last real user message (not a tool_result turnaround) | |
| last_user_idx = -1 | |
| for i, entry in enumerate(entries): | |
| if entry.get("type") == "user": | |
| content = entry.get("message", {}).get("content", []) | |
| if isinstance(content, str): | |
| last_user_idx = i | |
| elif isinstance(content, list): | |
| if not all( | |
| isinstance(b, dict) and b.get("type") == "tool_result" | |
| for b in content if isinstance(b, dict) | |
| ): | |
| last_user_idx = i | |
| # Build a tool_use_id -> result map from tool_result entries after last_user_idx | |
| tool_results = {} | |
| for entry in entries[last_user_idx + 1:]: | |
| if entry.get("type") == "user": | |
| content = entry.get("message", {}).get("content", []) | |
| if isinstance(content, list): | |
| for block in content: | |
| if isinstance(block, dict) and block.get("type") == "tool_result": | |
| tool_results[block.get("tool_use_id")] = extract_result_text(block.get("content", "")) | |
| # Collect all assistant sub-turns since last_user_idx, in order | |
| for entry in entries[last_user_idx + 1:]: | |
| if entry.get("type") != "assistant": | |
| continue | |
| content = entry.get("message", {}).get("content", []) | |
| text_parts = [ | |
| b.get("text", "") | |
| for b in content | |
| if isinstance(b, dict) and b.get("type") == "text" | |
| ] | |
| tool_calls = [ | |
| { | |
| "id": b.get("id"), | |
| "name": b.get("name"), | |
| "input": b.get("input", {}), | |
| "result": tool_results.get(b.get("id")), | |
| } | |
| for b in content | |
| if isinstance(b, dict) and b.get("type") == "tool_use" | |
| ] | |
| if text_parts or tool_calls: | |
| log_entry = { | |
| "timestamp": ts, | |
| "session_id": session_id, | |
| "role": "assistant", | |
| "content": "\n".join(text_parts), | |
| } | |
| if tool_calls: | |
| log_entry["tool_calls"] = tool_calls | |
| append(log_entry) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment