personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add segment breakdown to tokens app dashboard

- Add by_segment aggregation in routes.py to group token usage by segment key
- Add Avg/Segment metric to summary card (shows average cost per attributed segment)
- Add By Segment table with filter, showing segment, requests, tokens, cost, and models
- Format segment keys as "HH:MM:SS (Xm)" for readability with raw key in tooltip
- Group entries without segment field under "[unattributed]" with gray styling
- Clean up unused imports (timedelta, List)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+143 -5
+47 -3
apps/tokens/routes.py
··· 4 4 5 5 import json 6 6 from collections import defaultdict 7 - from datetime import date, timedelta 7 + from datetime import date 8 8 from pathlib import Path 9 - from typing import Any, Dict, List 9 + from typing import Any, Dict 10 10 11 11 from flask import Blueprint, jsonify, render_template, request 12 12 ··· 40 40 """ 41 41 Read and aggregate token usage data for a given day. 42 42 43 - Returns dict with daily summary and breakdowns by provider, model, token type, and context. 43 + Returns dict with daily summary and breakdowns by provider, model, token type, context, and segment. 44 44 """ 45 45 log_path = _get_token_log_path(day) 46 46 ··· 51 51 "requests": 0, 52 52 "tokens": 0, 53 53 "cost": 0.0, 54 + "segment_avg_cost": 0.0, 54 55 }, 55 56 "by_provider": [], 56 57 "by_model": [], 57 58 "by_token_type": {}, 58 59 "by_context": [], 60 + "by_segment": [], 59 61 } 60 62 61 63 # Accumulators ··· 92 94 } 93 95 ) 94 96 97 + by_segment: Dict[str, Dict[str, Any]] = defaultdict( 98 + lambda: { 99 + "requests": 0, 100 + "tokens": 0, 101 + "cost": 0.0, 102 + "models": set(), 103 + } 104 + ) 105 + 95 106 # Token type totals 96 107 token_types = { 97 108 "input": {"tokens": 0, "cost": 0.0}, ··· 178 189 by_context[context_prefix]["cost"] += entry_cost 179 190 by_context[context_prefix]["models"].add(model) 180 191 192 + # Update segment breakdown 193 + segment = entry.get("segment") or "[unattributed]" 194 + by_segment[segment]["requests"] += 1 195 + by_segment[segment]["tokens"] += total_entry_tokens 196 + by_segment[segment]["cost"] += entry_cost 197 + by_segment[segment]["models"].add(model) 198 + 181 199 # Update token type breakdown 182 200 token_types["input"]["tokens"] += input_tokens 183 201 token_types["input"]["cost"] += entry_input_cost ··· 243 261 ] 244 262 context_list.sort(key=lambda x: x["cost"], reverse=True) 245 263 264 + # Build segment list (exclude [unattributed] from avg calculation) 265 + segment_list = [ 266 + { 267 + "segment": seg, 268 + "requests": data["requests"], 269 + "tokens": data["tokens"], 270 + "cost": round(data["cost"], 6), 271 + "models_used": sorted(list(data["models"])), 272 + "percent": round( 273 + (data["cost"] / total_cost * 100) if total_cost > 0 else 0, 1 274 + ), 275 + } 276 + for seg, data in by_segment.items() 277 + ] 278 + segment_list.sort(key=lambda x: x["cost"], reverse=True) 279 + 280 + # Calculate segment average (excluding unattributed) 281 + attributed_segments = [s for s in segment_list if s["segment"] != "[unattributed]"] 282 + segment_count = len(attributed_segments) 283 + segment_total_cost = sum(s["cost"] for s in attributed_segments) 284 + segment_avg_cost = ( 285 + round(segment_total_cost / segment_count, 6) if segment_count > 0 else 0.0 286 + ) 287 + 246 288 # Calculate cached/reasoning percentages for display annotations 247 289 # - cached_tokens are a subset of input_tokens (reduce cost) 248 290 # - reasoning_tokens are part of output_tokens (billed as output) ··· 285 327 "requests": total_requests, 286 328 "tokens": total_tokens, 287 329 "cost": round(total_cost, 6), 330 + "segment_avg_cost": segment_avg_cost, 288 331 }, 289 332 "by_provider": provider_list, 290 333 "by_model": model_list, 291 334 "by_token_type": token_types, 292 335 "by_context": context_list, 336 + "by_segment": segment_list, 293 337 } 294 338 295 339
+96 -2
apps/tokens/workspace.html
··· 18 18 <div class="summary-label">Requests</div> 19 19 <div class="summary-value" id="total-requests">0</div> 20 20 </div> 21 + <div class="summary-item"> 22 + <div class="summary-label">Avg/Segment</div> 23 + <div class="summary-value" id="segment-avg-cost">-</div> 24 + </div> 21 25 </div> 22 26 23 27 <!-- By Provider --> ··· 107 111 <tbody id="context-body"> 108 112 <tr class="empty-row"> 109 113 <td colspan="6">No data for this day</td> 114 + </tr> 115 + </tbody> 116 + </table> 117 + </div> 118 + 119 + <!-- By Segment --> 120 + <div class="breakdown-section"> 121 + <h2>By Segment</h2> 122 + <div class="search-box"> 123 + <input type="text" id="segment-search" placeholder="Filter segments..." /> 124 + </div> 125 + <table class="breakdown-table" id="segment-table"> 126 + <thead> 127 + <tr> 128 + <th class="sortable" data-sort="segment">Segment</th> 129 + <th class="sortable num" data-sort="requests">Requests</th> 130 + <th class="sortable num" data-sort="tokens">Tokens</th> 131 + <th class="sortable num" data-sort="cost">Total Cost</th> 132 + <th class="num">% of Day</th> 133 + <th>Models Used</th> 134 + </tr> 135 + </thead> 136 + <tbody id="segment-body"> 137 + <tr class="empty-row"> 138 + <td colspan="6">No segment data for this day</td> 110 139 </tr> 111 140 </tbody> 112 141 </table> ··· 305 334 return '$' + value.toFixed(4) + '/1K'; 306 335 } 307 336 337 + // Format segment key as "HH:MM:SS (Xm)" for readability 338 + function formatSegmentKey(segment) { 339 + if (segment === '[unattributed]') { 340 + return segment; 341 + } 342 + // Parse HHMMSS_LEN format 343 + const match = segment.match(/^(\d{2})(\d{2})(\d{2})_(\d+)$/); 344 + if (!match) { 345 + return segment; 346 + } 347 + const [, hh, mm, ss, len] = match; 348 + const minutes = Math.round(parseInt(len, 10) / 60); 349 + return `${hh}:${mm}:${ss} (${minutes}m)`; 350 + } 351 + 308 352 // Create percent bar 309 353 function createPercentBar(percent, color) { 310 354 const width = Math.min(100, Math.max(0, percent * 2)); // Scale for visual effect ··· 343 387 document.getElementById('total-tokens').textContent = formatNumber(data.total.tokens); 344 388 document.getElementById('total-requests').textContent = formatNumber(data.total.requests); 345 389 390 + // Update segment avg cost (show "-" if no segment data) 391 + const segmentAvgEl = document.getElementById('segment-avg-cost'); 392 + if (data.total.segment_avg_cost > 0) { 393 + segmentAvgEl.textContent = formatCost(data.total.segment_avg_cost); 394 + } else { 395 + segmentAvgEl.textContent = '-'; 396 + } 397 + 346 398 // Render provider table 347 399 renderProviderTable(data.by_provider); 348 400 ··· 354 406 355 407 // Render context table 356 408 renderContextTable(data.by_context); 409 + 410 + // Render segment table 411 + renderSegmentTable(data.by_segment); 357 412 } 358 413 359 414 // Render provider table ··· 462 517 `).join(''); 463 518 } 464 519 520 + // Render segment table 521 + function renderSegmentTable(segments, filter = '') { 522 + const tbody = document.getElementById('segment-body'); 523 + 524 + let filteredSegments = segments || []; 525 + if (filter) { 526 + const lowerFilter = filter.toLowerCase(); 527 + filteredSegments = filteredSegments.filter(s => s.segment.toLowerCase().includes(lowerFilter)); 528 + } 529 + 530 + if (filteredSegments.length === 0) { 531 + tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No segment data for this day</td></tr>'; 532 + return; 533 + } 534 + 535 + tbody.innerHTML = filteredSegments.map(s => { 536 + const displayName = formatSegmentKey(s.segment); 537 + const isUnattributed = s.segment === '[unattributed]'; 538 + return ` 539 + <tr> 540 + <td><code title="${s.segment}">${displayName}</code></td> 541 + <td class="num">${formatNumber(s.requests)}</td> 542 + <td class="num">${formatNumber(s.tokens)}</td> 543 + <td class="num cost-value">${formatCost(s.cost)}</td> 544 + <td class="num">${createPercentBar(s.percent, isUnattributed ? '#999' : '#667eea')} ${formatPercent(s.percent)}</td> 545 + <td class="models-list">${s.models_used.join(', ')}</td> 546 + </tr> 547 + `; 548 + }).join(''); 549 + } 550 + 465 551 // Context search filter 466 552 document.addEventListener('DOMContentLoaded', () => { 467 - const searchInput = document.getElementById('context-search'); 468 - searchInput.addEventListener('input', (e) => { 553 + const contextSearchInput = document.getElementById('context-search'); 554 + contextSearchInput.addEventListener('input', (e) => { 469 555 if (currentData) { 470 556 renderContextTable(currentData.by_context, e.target.value); 557 + } 558 + }); 559 + 560 + // Segment search filter 561 + const segmentSearchInput = document.getElementById('segment-search'); 562 + segmentSearchInput.addEventListener('input', (e) => { 563 + if (currentData) { 564 + renderSegmentTable(currentData.by_segment, e.target.value); 471 565 } 472 566 }); 473 567