ccakashic 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  An Akashic Record of your Claude Code sessions — browse Claude Code session logs (`~/.claude/projects/`) as beautiful HTML in your browser.
4
4
 
5
+ ![Session detail with stats, cost badges, and chat layout](docs/screenshot1.png)
6
+
7
+ ![Collapsible tool calls, code blocks, and per-message cost](docs/screenshot2.png)
8
+
5
9
  ## Usage
6
10
 
7
11
  ### npx
@@ -23,13 +27,16 @@ A local HTTP server starts and your browser opens automatically.
23
27
  ## Features
24
28
 
25
29
  - **Fully browser-based** — Project list → Session list → Conversation detail
26
- - **Chat-style layout** — User / assistant messages in bubbles
27
- - **Collapsible tool calls** — Bash, Read, Edit, and other tool invocations are collapsed into `<details>` blocks
30
+ - **Chat-style layout** — User / assistant messages in chat bubbles
31
+ - **Collapsible tool calls** — Bash, Read, Edit, and other tool invocations collapsed by default
28
32
  - **Diff view** — File edits shown with red/green line highlights
29
33
  - **Date navigation** — Side nav and sticky headers to jump between dates
30
- - **Token stats** — Per-session input/output tokens, cache hit rate, and duration
34
+ - **Cost estimation** — Per-turn and per-message USD cost based on Claude Opus 4 pricing (input / output / cache read / cache write breakdown)
35
+ - **Elapsed time** — Per-turn duration and per-tool execution time derived from timestamps
36
+ - **Local command display** — `!` shell commands rendered with prompt and output
31
37
  - **Inline subagent conversations** — Subagent dialogues nested inside the Agent tool_use that spawned them
32
- - **Permalinks** — Click any message timestamp to get a shareable URL
38
+ - **Permalinks** — Click any message timestamp to get a shareable URL (`#t20260416103045`)
39
+ - **Session-level stats** — Estimated cost, turns, token breakdown, cache hit rate, and duration in the header
33
40
  - **Dark mode** — Follows `prefers-color-scheme` automatically
34
41
  - **Filter search** — Incremental filtering on list pages
35
42
  - **Keyboard navigation** — `j` / `k` to move between messages
@@ -132,12 +132,40 @@ function renderMessage(msg) {
132
132
  const id = msgId(msg.timestamp);
133
133
  const time = `<a class="timestamp" href="#${id}">${formatTime(msg.timestamp)}</a>`;
134
134
 
135
+ const turnBadge = msg.turnUsage
136
+ ? (() => {
137
+ const c = calcCost(msg.turnUsage.input, msg.turnUsage.output, msg.turnUsage.cacheRead, msg.turnUsage.cacheCreate);
138
+ const dur = msg.turnUsage.durationMs ? ` | ${formatDuration(msg.turnUsage.durationMs)}` : '';
139
+ return `<span class="turn-usage">&#9889; Turn: ${c.totalStr}${dur} <span class="usage-detail">(in:${c.inStr} out:${c.outStr} cache-r:${c.crStr} cache-w:${c.cwStr})</span></span>`;
140
+ })()
141
+ : '';
142
+
143
+ function makeItemBadge(m) {
144
+ const parts = [];
145
+ const c = m.usage
146
+ ? calcCost(m.usage.input_tokens || 0, m.usage.output_tokens || 0, m.usage.cache_read_input_tokens || 0, m.usage.cache_creation_input_tokens || 0)
147
+ : null;
148
+ if (c) {
149
+ parts.push(`${c.totalStr}`);
150
+ }
151
+ if (m.execMs) {
152
+ parts.push(`${formatDuration(m.execMs)}`);
153
+ } else if (m.elapsedMs && m.elapsedMs >= 1000) {
154
+ parts.push(`${formatDuration(m.elapsedMs)}`);
155
+ }
156
+ if (!parts.length) return '';
157
+ const detail = c ? ` <span class="usage-detail">(in:${c.inStr} out:${c.outStr} cache-r:${c.crStr} cache-w:${c.cwStr})</span>` : '';
158
+ return `<span class="item-usage">${parts.join(' | ')}${detail}</span>`;
159
+ }
160
+
161
+ const itemBadge = makeItemBadge(msg);
162
+
135
163
  switch (msg.type) {
136
164
  case 'user':
137
- return `<div class="msg msg-user" id="${id}">${time}<div class="msg-content" data-markdown>${escapeHtml(msg.text)}</div></div>`;
165
+ return `<div class="msg msg-user" id="${id}">${time}<div class="msg-content" data-markdown>${escapeHtml(msg.text)}</div>${turnBadge}</div>`;
138
166
 
139
167
  case 'assistant':
140
- return `<div class="msg msg-assistant" id="${id}">${time}<div class="msg-content" data-markdown>${escapeHtml(msg.text)}</div></div>`;
168
+ return `<div class="msg msg-assistant" id="${id}">${time}<div class="msg-content" data-markdown>${escapeHtml(msg.text)}</div>${itemBadge ? `<div>${itemBadge}</div>` : ''}</div>`;
141
169
 
142
170
  case 'thinking':
143
171
  return `<div class="msg msg-thinking" id="${id}">${time}<span class="thinking-indicator">Thinking...</span></div>`;
@@ -148,16 +176,23 @@ function renderMessage(msg) {
148
176
  const subagentHtml = msg.subagentMessages
149
177
  ? `<div class="subagent-inline"><details><summary><span class="tool-summary">Subagent conversation</span></summary><div class="subagent-content">${msg.subagentMessages.map(renderMessage).join('\n')}</div></details></div>`
150
178
  : '';
151
- return `<div class="msg msg-tool" id="${id}"><details><summary>${time}<span class="tool-summary">${summary}</span></summary><div class="tool-details">${renderToolInput(msg)}${resultHtml}${subagentHtml}</div></details></div>`;
179
+ return `<div class="msg msg-tool" id="${id}"><details><summary>${time}<span class="tool-summary">${summary}</span></summary><div class="tool-details">${renderToolInput(msg)}${resultHtml}${subagentHtml}</div></details>${itemBadge ? `<div class="tool-usage-row">${itemBadge}</div>` : ''}</div>`;
152
180
  }
153
181
 
154
182
  case 'tool_result':
155
183
  // Unpaired tool result (shouldn't happen often)
156
184
  return `<div class="msg msg-tool" id="${id}"><div class="tool-output"><pre><code>${escapeHtml((msg.content || '').slice(0, 2000))}</code></pre></div></div>`;
157
185
 
186
+ case 'local_command': {
187
+ const cmd = msg.command ? `<div class="local-cmd-input"><span class="local-cmd-prompt">$</span> ${escapeHtml(msg.command)}</div>` : '';
188
+ const stdout = msg.stdout && msg.stdout.trim() ? `<pre class="local-cmd-output"><code>${escapeHtml(msg.stdout)}</code></pre>` : '';
189
+ const stderr = msg.stderr && msg.stderr.trim() ? `<pre class="local-cmd-output local-cmd-stderr"><code>${escapeHtml(msg.stderr)}</code></pre>` : '';
190
+ return `<div class="msg msg-local-cmd" id="${id}">${time}${cmd}${stdout}${stderr}${turnBadge}</div>`;
191
+ }
192
+
158
193
  case 'system':
159
194
  if (msg.subtype === 'turn_duration') {
160
- return `<div class="msg msg-system" id="${id}"><span class="duration">Turn: ${formatDuration(msg.durationMs)}</span></div>`;
195
+ return ''; // Now shown in turn usage badge
161
196
  }
162
197
  return `<div class="msg msg-system" id="${id}">${escapeHtml(msg.content || '')}</div>`;
163
198
 
@@ -214,6 +249,40 @@ function groupMessagesByDate(messages) {
214
249
  return groups;
215
250
  }
216
251
 
252
+ // Cost per 1M tokens (USD) - Claude Opus 4 pricing
253
+ const COST_PER_M = {
254
+ input: 15,
255
+ output: 75,
256
+ cacheRead: 1.5,
257
+ cacheWrite: 18.75,
258
+ };
259
+
260
+ function tokenCostUsd(tokens, rate) {
261
+ return (tokens / 1_000_000) * rate;
262
+ }
263
+
264
+ function formatCost(usd) {
265
+ if (usd < 0.001) return '<$0.01';
266
+ if (usd < 0.01) return `$${usd.toFixed(3)}`;
267
+ if (usd < 1) return `$${usd.toFixed(2)}`;
268
+ return `$${usd.toFixed(2)}`;
269
+ }
270
+
271
+ function calcCost(input, output, cacheRead, cacheWrite) {
272
+ const inCost = tokenCostUsd(input, COST_PER_M.input);
273
+ const outCost = tokenCostUsd(output, COST_PER_M.output);
274
+ const crCost = tokenCostUsd(cacheRead, COST_PER_M.cacheRead);
275
+ const cwCost = tokenCostUsd(cacheWrite, COST_PER_M.cacheWrite);
276
+ const total = inCost + outCost + crCost + cwCost;
277
+ return {
278
+ totalStr: formatCost(total),
279
+ inStr: formatTokens(input),
280
+ outStr: formatTokens(output),
281
+ crStr: formatTokens(cacheRead),
282
+ cwStr: formatTokens(cacheWrite),
283
+ };
284
+ }
285
+
217
286
  function formatTokens(n) {
218
287
  if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
219
288
  if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
@@ -233,20 +302,18 @@ function formatDurationLong(ms) {
233
302
  function renderStats(stats) {
234
303
  if (!stats || !stats.turns) return '';
235
304
 
305
+ const totalCost = calcCost(stats.inputTokens, stats.outputTokens, stats.cacheRead, stats.cacheCreation);
306
+
236
307
  const items = [];
308
+ items.push(`<span class="stat-item"><span class="stat-label">Est. Cost</span><span class="stat-value">${totalCost.totalStr}</span></span>`);
237
309
  items.push(`<span class="stat-item"><span class="stat-label">Turns</span><span class="stat-value">${stats.turns}</span></span>`);
238
310
  items.push(`<span class="stat-item"><span class="stat-label">Input</span><span class="stat-value">${formatTokens(stats.inputTokens)}</span></span>`);
239
311
  items.push(`<span class="stat-item"><span class="stat-label">Output</span><span class="stat-value">${formatTokens(stats.outputTokens)}</span></span>`);
240
- items.push(`<span class="stat-item"><span class="stat-label">Cache Create</span><span class="stat-value">${formatTokens(stats.cacheCreation)}</span></span>`);
241
312
  items.push(`<span class="stat-item"><span class="stat-label">Cache Read</span><span class="stat-value">${formatTokens(stats.cacheRead)}</span></span>`);
242
- items.push(`<span class="stat-item"><span class="stat-label">Total</span><span class="stat-value">${formatTokens(stats.totalTokens)}</span></span>`);
313
+ items.push(`<span class="stat-item"><span class="stat-label">Cache Write</span><span class="stat-value">${formatTokens(stats.cacheCreation)}</span></span>`);
243
314
  items.push(`<span class="stat-item"><span class="stat-label">Cache Hit</span><span class="stat-value">${(stats.cacheHitRate * 100).toFixed(0)}%</span></span>`);
244
315
  if (stats.durationMs) {
245
316
  items.push(`<span class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDurationLong(stats.durationMs)}</span></span>`);
246
- if (stats.outputTokens && stats.durationMs > 0) {
247
- const tokPerMin = Math.round(stats.outputTokens / (stats.durationMs / 60000));
248
- items.push(`<span class="stat-item"><span class="stat-label">Output/min</span><span class="stat-value">${formatTokens(tokPerMin)}</span></span>`);
249
- }
250
317
  }
251
318
 
252
319
  return `<div class="stats-bar">${items.join('')}</div>`;
package/lib/parser.js CHANGED
@@ -142,21 +142,97 @@ function buildConversation(lines) {
142
142
  // Pair tool_use with tool_result
143
143
  pairToolMessages(messages);
144
144
 
145
+ // Calculate per-turn token usage (tokens between each user input)
146
+ calculateTurnUsage(messages);
147
+
148
+ // Calculate elapsed time for each message (time since previous message)
149
+ calculateElapsed(messages);
150
+
145
151
  return messages;
146
152
  }
147
153
 
154
+ function parseLocalCommand(text) {
155
+ // Parse <bash-input>, <bash-stdout>, <bash-stderr> tags
156
+ const inputMatch = text.match(/<bash-input>([\s\S]*?)<\/bash-input>/);
157
+ const stdoutMatch = text.match(/<bash-stdout>([\s\S]*?)<\/bash-stdout>/);
158
+ const stderrMatch = text.match(/<bash-stderr>([\s\S]*?)<\/bash-stderr>/);
159
+
160
+ if (inputMatch) {
161
+ return { type: 'local_command', subtype: 'input', command: inputMatch[1] };
162
+ }
163
+ if (stdoutMatch || stderrMatch) {
164
+ return {
165
+ type: 'local_command',
166
+ subtype: 'output',
167
+ stdout: stdoutMatch ? stdoutMatch[1] : '',
168
+ stderr: stderrMatch ? stderrMatch[1] : '',
169
+ };
170
+ }
171
+ return null;
172
+ }
173
+
174
+ function processUserText(text, line, messages) {
175
+ // Skip local-command-caveat (system instruction, not user input)
176
+ if (text.match(/^<local-command-caveat>/)) {
177
+ return;
178
+ }
179
+
180
+ // Parse local command input/output
181
+ const localCmd = parseLocalCommand(text);
182
+ if (localCmd) {
183
+ if (localCmd.subtype === 'input') {
184
+ messages.push({
185
+ type: 'local_command',
186
+ command: localCmd.command,
187
+ timestamp: line.timestamp,
188
+ uuid: line.uuid,
189
+ });
190
+ } else if (localCmd.subtype === 'output') {
191
+ // Attach to previous local_command if exists
192
+ const prev = messages.length > 0 ? messages[messages.length - 1] : null;
193
+ if (prev && prev.type === 'local_command' && !prev.stdout) {
194
+ prev.stdout = localCmd.stdout;
195
+ prev.stderr = localCmd.stderr;
196
+ } else {
197
+ messages.push({
198
+ type: 'local_command',
199
+ command: '',
200
+ stdout: localCmd.stdout,
201
+ stderr: localCmd.stderr,
202
+ timestamp: line.timestamp,
203
+ uuid: line.uuid,
204
+ });
205
+ }
206
+ }
207
+ return;
208
+ }
209
+
210
+ // Strip system-reminder tags but keep surrounding user text
211
+ const stripped = text
212
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
213
+ .replace(/<command-name>[\s\S]*?<\/command-name>/g, '')
214
+ .replace(/<command-message>[\s\S]*?<\/command-message>/g, '')
215
+ .replace(/<command-args>[\s\S]*?<\/command-args>/g, '')
216
+ .replace(/<local-command-stdout>[\s\S]*?<\/local-command-stdout>/g, '')
217
+ .trim();
218
+
219
+ if (stripped) {
220
+ messages.push({
221
+ type: 'user',
222
+ text: stripped,
223
+ timestamp: line.timestamp,
224
+ uuid: line.uuid,
225
+ });
226
+ }
227
+ }
228
+
148
229
  function processUserMessage(line, messages) {
149
230
  const content = line.message?.content;
150
231
  if (!content) return;
151
232
 
152
233
  if (typeof content === 'string') {
153
234
  if (content.trim()) {
154
- messages.push({
155
- type: 'user',
156
- text: content,
157
- timestamp: line.timestamp,
158
- uuid: line.uuid,
159
- });
235
+ processUserText(content, line, messages);
160
236
  }
161
237
  return;
162
238
  }
@@ -164,12 +240,7 @@ function processUserMessage(line, messages) {
164
240
  if (Array.isArray(content)) {
165
241
  for (const block of content) {
166
242
  if (block.type === 'text' && block.text?.trim()) {
167
- messages.push({
168
- type: 'user',
169
- text: block.text,
170
- timestamp: line.timestamp,
171
- uuid: line.uuid,
172
- });
243
+ processUserText(block.text, line, messages);
173
244
  } else if (block.type === 'tool_result') {
174
245
  const toolResult = {
175
246
  type: 'tool_result',
@@ -218,6 +289,9 @@ function processAssistantMessage(line, messages) {
218
289
  const content = line.message?.content;
219
290
  if (!content || !Array.isArray(content)) return;
220
291
 
292
+ const usage = line.message?.usage || null;
293
+ let usageAssigned = false;
294
+
221
295
  for (const block of content) {
222
296
  if (block.type === 'thinking') {
223
297
  messages.push({
@@ -232,7 +306,9 @@ function processAssistantMessage(line, messages) {
232
306
  model: line.message.model,
233
307
  timestamp: line.timestamp,
234
308
  uuid: line.uuid,
309
+ usage: !usageAssigned ? usage : null,
235
310
  });
311
+ usageAssigned = true;
236
312
  } else if (block.type === 'tool_use') {
237
313
  messages.push({
238
314
  type: 'tool_use',
@@ -241,7 +317,79 @@ function processAssistantMessage(line, messages) {
241
317
  input: block.input,
242
318
  timestamp: line.timestamp,
243
319
  uuid: line.uuid,
320
+ usage: !usageAssigned ? usage : null,
244
321
  });
322
+ usageAssigned = true;
323
+ }
324
+ }
325
+ }
326
+
327
+ function calculateTurnUsage(messages) {
328
+ // Walk backwards from each user message, summing usage of preceding assistant messages
329
+ // A "turn" = user input → all assistant responses until next user input
330
+ for (let i = 0; i < messages.length; i++) {
331
+ if (messages[i].type !== 'user' && messages[i].type !== 'local_command') continue;
332
+
333
+ // Sum usage of all following assistant/tool messages until next user message
334
+ let totalInput = 0;
335
+ let totalOutput = 0;
336
+ let totalCacheRead = 0;
337
+ let totalCacheCreate = 0;
338
+ const seenUsageIds = new Set(); // Dedupe (multiple blocks from same API response)
339
+
340
+ for (let j = i + 1; j < messages.length; j++) {
341
+ const m = messages[j];
342
+ if (m.type === 'user' || m.type === 'local_command') break;
343
+ if (m.usage && !seenUsageIds.has(m.uuid + (m.usage.input_tokens || 0))) {
344
+ seenUsageIds.add(m.uuid + (m.usage.input_tokens || 0));
345
+ totalInput += m.usage.input_tokens || 0;
346
+ totalOutput += m.usage.output_tokens || 0;
347
+ totalCacheRead += m.usage.cache_read_input_tokens || 0;
348
+ totalCacheCreate += m.usage.cache_creation_input_tokens || 0;
349
+ }
350
+ }
351
+
352
+ // Find turn_duration in the following messages
353
+ let turnDurationMs = 0;
354
+ for (let j = i + 1; j < messages.length; j++) {
355
+ const m = messages[j];
356
+ if (m.type === 'user' || m.type === 'local_command') break;
357
+ if (m.type === 'system' && m.subtype === 'turn_duration') {
358
+ turnDurationMs = m.durationMs || 0;
359
+ break;
360
+ }
361
+ }
362
+
363
+ const total = totalInput + totalOutput + totalCacheRead + totalCacheCreate;
364
+ if (total > 0 || turnDurationMs > 0) {
365
+ messages[i].turnUsage = {
366
+ input: totalInput,
367
+ output: totalOutput,
368
+ cacheRead: totalCacheRead,
369
+ cacheCreate: totalCacheCreate,
370
+ total,
371
+ durationMs: turnDurationMs,
372
+ };
373
+ }
374
+ }
375
+ }
376
+
377
+ function calculateElapsed(messages) {
378
+ for (let i = 1; i < messages.length; i++) {
379
+ const prev = messages[i - 1];
380
+ const curr = messages[i];
381
+ if (prev.timestamp && curr.timestamp) {
382
+ const elapsed = new Date(curr.timestamp) - new Date(prev.timestamp);
383
+ if (elapsed > 0) {
384
+ curr.elapsedMs = elapsed;
385
+ }
386
+ }
387
+ // For tool_use with paired result, calculate execution time
388
+ if (curr.type === 'tool_use' && curr.result?.timestamp && curr.timestamp) {
389
+ const execTime = new Date(curr.result.timestamp) - new Date(curr.timestamp);
390
+ if (execTime > 0) {
391
+ curr.execMs = execTime;
392
+ }
245
393
  }
246
394
  }
247
395
  }
@@ -131,6 +131,71 @@ body {
131
131
  position: relative;
132
132
  }
133
133
 
134
+ /* Local command (user ! commands) */
135
+ .msg-local-cmd {
136
+ align-self: flex-end;
137
+ margin-left: 20%;
138
+ background: var(--bg-secondary);
139
+ border: 1px solid var(--border);
140
+ border-radius: 8px;
141
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
142
+ font-size: 0.82rem;
143
+ }
144
+ .local-cmd-input {
145
+ padding: 2px 0;
146
+ font-weight: 600;
147
+ }
148
+ .local-cmd-prompt {
149
+ color: var(--text-muted);
150
+ margin-right: 4px;
151
+ }
152
+ .local-cmd-output {
153
+ margin-top: 4px;
154
+ padding: 6px 8px;
155
+ background: var(--code-block-bg);
156
+ color: var(--code-block-text);
157
+ border-radius: 4px;
158
+ overflow-x: auto;
159
+ font-size: 0.78rem;
160
+ max-height: 200px;
161
+ overflow-y: auto;
162
+ }
163
+ .local-cmd-output code {
164
+ background: none;
165
+ padding: 0;
166
+ color: inherit;
167
+ }
168
+ .local-cmd-stderr {
169
+ border-left: 3px solid #ef4444;
170
+ }
171
+
172
+ /* Token usage badges */
173
+ .turn-usage, .item-usage {
174
+ display: inline-block;
175
+ margin-top: 6px;
176
+ padding: 2px 8px;
177
+ font-size: 0.68rem;
178
+ font-variant-numeric: tabular-nums;
179
+ color: var(--text-muted);
180
+ background: var(--bg-secondary);
181
+ border: 1px solid var(--border);
182
+ border-radius: 4px;
183
+ }
184
+ .turn-usage {
185
+ font-weight: 600;
186
+ }
187
+ .item-usage {
188
+ font-weight: 400;
189
+ opacity: 0.8;
190
+ }
191
+ .tool-usage-row {
192
+ padding: 4px 14px 6px;
193
+ }
194
+ .usage-detail {
195
+ font-size: 0.6rem;
196
+ opacity: 0.7;
197
+ }
198
+
134
199
  .msg .timestamp {
135
200
  display: block;
136
201
  font-size: 0.7rem;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "ccakashic",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Browse Claude Code session logs (~/.claude/projects/) as beautiful HTML in your browser — an Akashic Record of your Claude Code sessions",
5
5
  "bin": {
6
- "ccakashic": "./bin/ccakashic.js"
6
+ "ccakashic": "bin/ccakashic.js"
7
7
  },
8
8
  "files": [
9
9
  "bin",