Skip to content

Instantly share code, notes, and snippets.

@georgestephanis
Last active June 22, 2026 20:28
Show Gist options
  • Select an option

  • Save georgestephanis/aed824ea91e6f6c20968b59fccb95752 to your computer and use it in GitHub Desktop.

Select an option

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!
<!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>
#!/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