personal memory agent
0
fork

Configure Feed

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

Normalize muse frontmatter and consolidate shared utilities

Audit and fix muse prompt metadata: add missing titles/descriptions
(importer, joke_bot, default), assign unique colors to all 34 agents
with group-cohesive palettes (teal for entities, orange for todos),
deduplicate 3 color collisions, fix hardcoded name in messages.md,
remove SPDX headers from prompt bodies, add priority to decisionalizer
and todo review so they run after dependent agents.

Extract calc_agent_cost into think/models.py as shared utility used by
both routes.py and cortex.py. Consolidate DATE_RE definitions (remove
duplicates in formatters.py and indexer/journal.py, simplify
re.fullmatch(DATE_RE.pattern) to DATE_RE.fullmatch() across all apps).
Cache _build_agents_meta with lru_cache. Return 202 for active agent
runs instead of misleading 404. Fix speakers app cookie name bug
(selected_facet -> selectedFacet).

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

+105 -94
+13 -29
apps/agents/routes.py
··· 8 8 import json 9 9 import re 10 10 from datetime import date, datetime 11 + from functools import lru_cache 11 12 from pathlib import Path 12 13 from typing import Any 13 14 ··· 16 17 from convey import state 17 18 from convey.utils import DATE_RE, format_date 18 19 from think.facets import get_facets 20 + from think.models import calc_agent_cost 19 21 from think.utils import get_muse_configs 20 22 21 23 agents_bp = Blueprint( ··· 92 94 return result 93 95 94 96 95 - def _calc_agent_cost(model: str | None, usage: dict | None) -> float | None: 96 - """Calculate cost from model and usage data. 97 - 98 - Args: 99 - model: Model name string 100 - usage: Token usage dict 101 - 102 - Returns: 103 - Total cost in USD, or None if calculation fails 104 - """ 105 - if not model or not usage: 106 - return None 107 - 108 - try: 109 - from think.models import calc_token_cost 110 - 111 - cost_data = calc_token_cost({"model": model, "usage": usage}) 112 - if cost_data: 113 - return cost_data["total_cost"] 114 - except Exception: 115 - pass 116 - return None 117 - 118 - 119 97 def _parse_agent_file(agent_file: Path) -> dict[str, Any] | None: 120 98 """Parse agent JSONL file and extract metadata. 121 99 ··· 174 152 event_data["finish_ts"] - agent_info["start"] 175 153 ) / 1000.0 176 154 177 - # Calculate cost using shared helper 178 - agent_info["cost"] = _calc_agent_cost( 155 + # Calculate cost 156 + agent_info["cost"] = calc_agent_cost( 179 157 event_data["model"], event_data["usage"] 180 158 ) 181 159 ··· 225 203 return agents 226 204 227 205 206 + @lru_cache(maxsize=1) 228 207 def _build_agents_meta() -> dict[str, dict[str, Any]]: 229 208 """Build agent metadata dict from all muse configs. 230 209 231 210 Returns dict mapping agent name to metadata with capability fields 232 - for frontend display. 211 + for frontend display. Cached for process lifetime since muse configs 212 + are static. 233 213 """ 234 214 configs = get_muse_configs(include_disabled=True) 235 215 agents: dict[str, dict[str, Any]] = {} ··· 265 245 @agents_bp.route("/<day>") 266 246 def agents_day(day: str) -> str: 267 247 """Render agent history viewer for a specific day.""" 268 - if not re.fullmatch(DATE_RE.pattern, day): 248 + if not DATE_RE.fullmatch(day): 269 249 return "", 404 270 250 271 251 title = format_date(day) ··· 294 274 "facets": {name: {title, color}...} 295 275 } 296 276 """ 297 - if not re.fullmatch(DATE_RE.pattern, day): 277 + if not DATE_RE.fullmatch(day): 298 278 return jsonify({"error": "Invalid day format"}), 400 299 279 300 280 facet_filter = _get_facet_filter() ··· 336 316 agent_file = journal_path / "agents" / f"{agent_id}.jsonl" 337 317 338 318 if not agent_file.exists(): 319 + # Check if the agent is still running 320 + active_file = journal_path / "agents" / f"{agent_id}_active.jsonl" 321 + if active_file.exists(): 322 + return jsonify({"error": "Agent run is still in progress"}), 202 339 323 return jsonify({"error": f"Agent run {agent_id} not found"}), 404 340 324 341 325 try:
+7 -7
apps/calendar/routes.py
··· 35 35 @calendar_bp.route("/<day>") 36 36 def calendar_day(day: str) -> str: 37 37 """Render events timeline for a specific day.""" 38 - if not re.fullmatch(DATE_RE.pattern, day): 38 + if not DATE_RE.fullmatch(day): 39 39 return "", 404 40 40 day_dir = str(day_path(day)) 41 41 if not os.path.isdir(day_dir): ··· 53 53 @calendar_bp.route("/api/day/<day>/events") 54 54 def calendar_day_events(day: str) -> Any: 55 55 """Return events for a specific day from facet event logs.""" 56 - if not re.fullmatch(DATE_RE.pattern, day): 56 + if not DATE_RE.fullmatch(day): 57 57 return "", 404 58 58 59 59 from think.indexer.journal import get_events ··· 131 131 @calendar_bp.route("/<day>/screens") 132 132 def _dev_calendar_screens_list(day: str) -> str: 133 133 """Render list of screen.jsonl files for a specific day.""" 134 - if not re.fullmatch(DATE_RE.pattern, day): 134 + if not DATE_RE.fullmatch(day): 135 135 return "", 404 136 136 137 137 day_dir = str(day_path(day)) ··· 153 153 day: str, timestamp: str, filename: str = "screen.jsonl" 154 154 ) -> str: 155 155 """Render detail view for a specific screen.jsonl file.""" 156 - if not re.fullmatch(DATE_RE.pattern, day): 156 + if not DATE_RE.fullmatch(day): 157 157 return "", 404 158 158 from think.utils import segment_key 159 159 ··· 188 188 @calendar_bp.route("/api/screen_files/<day>") 189 189 def _dev_screen_files(day: str) -> Any: 190 190 """Return list of *screen.jsonl files for a day.""" 191 - if not re.fullmatch(DATE_RE.pattern, day): 191 + if not DATE_RE.fullmatch(day): 192 192 return "", 404 193 193 194 194 day_dir = str(day_path(day)) ··· 248 248 @calendar_bp.route("/api/screen_frames/<day>/<timestamp>/<filename>") 249 249 def _dev_screen_frames(day: str, timestamp: str, filename: str = "screen.jsonl") -> Any: 250 250 """Return all frame records and pre-cache decoded frames from video.""" 251 - if not re.fullmatch(DATE_RE.pattern, day): 251 + if not DATE_RE.fullmatch(day): 252 252 return "", 404 253 253 from think.utils import segment_key 254 254 ··· 350 350 @calendar_bp.route("/api/screen_frame_image/<day>/<timestamp>/<int:frame_id>") 351 351 def _dev_screen_frame_image(day: str, timestamp: str, frame_id: int) -> Any: 352 352 """Serve a cached frame image as JPEG.""" 353 - if not re.fullmatch(DATE_RE.pattern, day): 353 + if not DATE_RE.fullmatch(day): 354 354 return "", 404 355 355 from think.utils import segment_key 356 356
+1
apps/entities/muse/entities.md
··· 2 2 3 3 "title": "Entity Detector", 4 4 "description": "Mines journal for entity mentions and records facet-scoped detections with day-specific context", 5 + "color": "#00897b", 5 6 "schedule": "daily", 6 7 "priority": 25, 7 8 "tools": "journal, entities",
+1
apps/entities/muse/entities_review.md
··· 2 2 3 3 "title": "Entity Reviewer", 4 4 "description": "Reviews detected entities and promotes recurring ones to attached status", 5 + "color": "#00796b", 5 6 "schedule": "daily", 6 7 "priority": 26, 7 8 "tools": "journal, entities",
+1
apps/entities/muse/entity_assist.md
··· 2 2 3 3 "title": "Entity Assistant", 4 4 "description": "Quick entity addition with intelligent type detection and automatic description generation", 5 + "color": "#00695c", 5 6 "tools": "journal, entities", 6 7 "group": "Entities" 7 8
+1 -3
apps/entities/muse/entity_describe.md
··· 2 2 3 3 "title": "Entity Description", 4 4 "description": "Research and generate single-sentence descriptions for attached entities", 5 + "color": "#26a69a", 5 6 "tools": "journal", 6 7 "group": "Entities" 7 8 8 9 } 9 - 10 - # SPDX-License-Identifier: AGPL-3.0-only 11 - # Copyright (c) 2026 sol pbc 12 10 13 11 ## Core Mission 14 12
+3 -4
apps/entities/muse/entity_observer.md
··· 2 2 3 3 "title": "Entity Observer", 4 4 "description": "Extracts durable factoids about attached entities from journal content", 5 + "color": "#004d40", 5 6 "schedule": "daily", 6 7 "priority": 27, 7 8 "tools": "journal, entities", 8 - "multi_facet": true 9 + "multi_facet": true, 10 + "group": "Entities" 9 11 10 12 } 11 - 12 - # SPDX-License-Identifier: AGPL-3.0-only 13 - # Copyright (c) 2026 sol pbc 14 13 15 14 ## Core Mission 16 15
+1 -1
apps/insights/routes.py
··· 60 60 @insights_bp.route("/<day>") 61 61 def insights_day(day: str) -> str: 62 62 """Render insights viewer for a specific day.""" 63 - if not re.fullmatch(DATE_RE.pattern, day): 63 + if not DATE_RE.fullmatch(day): 64 64 return "", 404 65 65 66 66 topic_map = _build_topic_map()
+9 -9
apps/speakers/routes.py
··· 432 432 @speakers_bp.route("/<day>") 433 433 def speakers_day(day: str) -> str: 434 434 """Render speaker management view for a specific day.""" 435 - if not re.fullmatch(DATE_RE.pattern, day): 435 + if not DATE_RE.fullmatch(day): 436 436 return "", 404 437 437 438 438 title = format_date(day) ··· 464 464 @speakers_bp.route("/api/segments/<day>") 465 465 def api_segments(day: str) -> Any: 466 466 """Return segments with embeddings and 2+ speakers for a day.""" 467 - if not re.fullmatch(DATE_RE.pattern, day): 467 + if not DATE_RE.fullmatch(day): 468 468 return error_response("Invalid day format", 400) 469 469 470 470 segments = _scan_segment_embeddings(day) ··· 478 478 Requires a facet (via query param or cookie) for entity matching. 479 479 Returns matched and unmatched speakers. 480 480 """ 481 - if not re.fullmatch(DATE_RE.pattern, day): 481 + if not DATE_RE.fullmatch(day): 482 482 return error_response("Invalid day format", 400) 483 483 484 484 if not validate_segment_key(segment_key): 485 485 return error_response("Invalid segment key", 400) 486 486 487 487 # Require facet for entity matching (query param takes precedence over cookie) 488 - selected_facet = request.args.get("facet") or request.cookies.get("selected_facet") 488 + selected_facet = request.args.get("facet") or request.cookies.get("selectedFacet") 489 489 if not selected_facet: 490 490 return error_response("Select a facet to view speaker details", 400) 491 491 ··· 530 530 @speakers_bp.route("/api/sentences/<day>/<segment_key>/<source>") 531 531 def api_sentences(day: str, segment_key: str, source: str) -> Any: 532 532 """Return sentences with embeddings and matches for an audio source.""" 533 - if not re.fullmatch(DATE_RE.pattern, day): 533 + if not DATE_RE.fullmatch(day): 534 534 return error_response("Invalid day format", 400) 535 535 536 536 if not validate_segment_key(segment_key): 537 537 return error_response("Invalid segment key", 400) 538 538 539 539 # Get selected facet from cookie (optional - sentences work without it) 540 - selected_facet = request.cookies.get("selected_facet") 540 + selected_facet = request.cookies.get("selectedFacet") 541 541 542 542 # Load sentences and embeddings 543 543 sentences, emb_data = _load_sentences(day, segment_key, source) ··· 615 615 @speakers_bp.route("/api/serve_audio/<day>/<path:encoded_path>") 616 616 def serve_audio(day: str, encoded_path: str) -> Any: 617 617 """Serve audio files for playback.""" 618 - if not re.fullmatch(DATE_RE.pattern, day): 618 + if not DATE_RE.fullmatch(day): 619 619 return "", 404 620 620 621 621 try: ··· 654 654 return error_response("Missing required fields", 400) 655 655 656 656 # Validate formats 657 - if not re.fullmatch(DATE_RE.pattern, day): 657 + if not DATE_RE.fullmatch(day): 658 658 return error_response("Invalid day format", 400) 659 659 if not validate_segment_key(segment_key): 660 660 return error_response("Invalid segment key", 400) ··· 717 717 return error_response("Missing required fields", 400) 718 718 719 719 # Validate formats 720 - if not re.fullmatch(DATE_RE.pattern, day): 720 + if not DATE_RE.fullmatch(day): 721 721 return error_response("Invalid day format", 400) 722 722 if not validate_segment_key(segment_key): 723 723 return error_response("Invalid segment key", 400)
+3 -1
apps/todos/muse/review.md
··· 2 2 3 3 "title": "TODO Review", 4 4 "description": "Validates checklist entries against journal evidence and marks items complete via MCP todo tools.", 5 + "color": "#e65100", 6 + "schedule": "daily", 7 + "priority": 30, 5 8 "tools": "journal, todo", 6 9 "multi_facet": true, 7 - "schedule": "daily", 8 10 "group": "Todos" 9 11 10 12 }
+1
apps/todos/muse/todo.md
··· 2 2 3 3 "title": "TODO Generator", 4 4 "description": "Maintains the daily todos checklist by mining the journal, prioritising tasks, and applying updates via the todo_* tools.", 5 + "color": "#ef6c00", 5 6 "schedule": "daily", 6 7 "priority": 20, 7 8 "tools": "journal, todo",
+1
apps/todos/muse/weekly.md
··· 2 2 3 3 "title": "TODO Weekly Scout", 4 4 "description": "Audits the past week's journal follow-ups to confirm completions and surface the next five high-impact todos for today.", 5 + "color": "#f4511e", 5 6 "tools": "journal, todo", 6 7 "group": "Todos" 7 8
+6 -6
apps/transcripts/routes.py
··· 52 52 @transcripts_bp.route("/<day>") 53 53 def transcripts_day(day: str) -> str: 54 54 """Render transcript viewer for a specific day.""" 55 - if not re.fullmatch(DATE_RE.pattern, day): 55 + if not DATE_RE.fullmatch(day): 56 56 return "", 404 57 57 58 58 title = format_date(day) ··· 63 63 @transcripts_bp.route("/api/ranges/<day>") 64 64 def transcript_ranges(day: str) -> Any: 65 65 """Return available transcript ranges for a day.""" 66 - if not re.fullmatch(DATE_RE.pattern, day): 66 + if not DATE_RE.fullmatch(day): 67 67 return "", 404 68 68 69 69 audio_ranges, screen_ranges = cluster_scan(day) ··· 76 76 77 77 Returns list of segments with their content types for the segment selector UI. 78 78 """ 79 - if not re.fullmatch(DATE_RE.pattern, day): 79 + if not DATE_RE.fullmatch(day): 80 80 return "", 404 81 81 82 82 segments = cluster_segments(day) ··· 86 86 @transcripts_bp.route("/api/serve_file/<day>/<path:encoded_path>") 87 87 def serve_file(day: str, encoded_path: str) -> Any: 88 88 """Serve actual media files for embedding.""" 89 - if not re.fullmatch(DATE_RE.pattern, day): 89 + if not DATE_RE.fullmatch(day): 90 90 return "", 404 91 91 92 92 try: ··· 183 183 - segment_key: segment directory name 184 184 - cost: processing cost in USD (float, 0.0 if no data) 185 185 """ 186 - if not re.fullmatch(DATE_RE.pattern, day): 186 + if not DATE_RE.fullmatch(day): 187 187 return "", 404 188 188 189 189 if not validate_segment_key(segment_key): ··· 355 355 Returns: 356 356 JSON success response or error response 357 357 """ 358 - if not re.fullmatch(DATE_RE.pattern, day): 358 + if not DATE_RE.fullmatch(day): 359 359 return error_response("Invalid day format", 400) 360 360 361 361 if not validate_segment_key(segment_key):
+2 -1
muse/anticipation.md
··· 1 1 { 2 2 3 3 "title": "Anticipation Extraction", 4 - "description": "Extracts structured anticipation events (future scheduled items) from insight summaries." 4 + "description": "Extracts structured anticipation events (future scheduled items) from insight summaries.", 5 + "color": "#4527a0" 5 6 6 7 } 7 8
+2 -1
muse/daily_news.md
··· 2 2 3 3 "title": "Daily News Briefing", 4 4 "description": "Creates a crisp TL;DR briefing highlighting yesterday's top activities across all facets, delivered to inbox", 5 + "color": "#1565c0", 5 6 "schedule": "daily", 6 7 "priority": 15, 7 - "tools": "journal,facets" 8 + "tools": "journal, facets" 8 9 9 10 } 10 11
+2
muse/decisionalizer.md
··· 2 2 3 3 "title": "Decision Dossier Generator", 4 4 "description": "Analyzes yesterday's top decision-actions to create detailed dossiers identifying gaps and stakeholder impacts", 5 + "color": "#c62828", 5 6 "schedule": "daily", 7 + "priority": 30, 6 8 "output": "md", 7 9 "tools": "default" 8 10
+2
muse/default.md
··· 1 1 { 2 2 3 3 "title": "Journal Chat", 4 + "description": "Interactive assistant for searching, exploring, and understanding journal entries across all facets", 5 + "color": "#455a64", 4 6 "label": "Chat Messages", 5 7 "group": "Apps", 6 8 "tools": "journal, todo, entities"
+1
muse/entities.md
··· 2 2 3 3 "title": "Entity Extraction", 4 4 "description": "Extracts people, companies, projects, and tools from segment content", 5 + "color": "#2e7d32", 5 6 "schedule": "segment", 6 7 "hook": "entities", 7 8 "thinking_budget": 4096,
+2 -1
muse/facet_newsletter.md
··· 2 2 3 3 "title": "Facet Newsletter Generator", 4 4 "description": "Creates comprehensive daily newsletters for each facet, capturing activities, progress, and insights", 5 + "color": "#0d47a1", 5 6 "schedule": "daily", 6 7 "priority": 10, 7 8 "multi_facet": true, 8 - "tools": "journal,facets" 9 + "tools": "journal, facets" 9 10 10 11 } 11 12
+3 -1
muse/importer.md
··· 1 1 { 2 2 3 - "color": "blue", 3 + "title": "Audio Importer", 4 + "description": "Analyzes imported audio transcripts to extract knowledge, entities, and action items into a comprehensive summary", 5 + "color": "#1976d2", 4 6 "extract": false, 5 7 "output": "md" 6 8
+2
muse/joke_bot.md
··· 1 1 { 2 2 3 3 "title": "Joke Bot", 4 + "description": "Mines yesterday's journal for poignant moments and crafts a personalized joke delivered via message", 5 + "color": "#f9a825", 4 6 "schedule": "daily", 5 7 "priority": 99, 6 8 "tools": "default"
+2 -2
muse/messages.md
··· 2 2 3 3 "title": "Messaging Activity", 4 4 "description": "Tracks use of email and chat applications across the day. Each interaction is summarized with participants, app used and visible message content.", 5 - "occurrences": "Create an occurrence for every message read or sent. Include the time block, app name, contacts involved and whether Jeremie was reading or replying. Summaries should capture any visible text.", 5 + "occurrences": "Create an occurrence for every message read or sent. Include the time block, app name, contacts involved and whether $preferred was reading or replying. Summaries should capture any visible text.", 6 6 "hook": "occurrence", 7 - "color": "#6c757d", 7 + "color": "#78909c", 8 8 "schedule": "daily", 9 9 "output": "md" 10 10
+2 -1
muse/occurrence.md
··· 1 1 { 2 2 3 3 "title": "Occurrence Extraction", 4 - "description": "Extracts structured occurrence events from insight summaries." 4 + "description": "Extracts structured occurrence events from insight summaries.", 5 + "color": "#37474f" 5 6 6 7 } 7 8
+1 -1
muse/schedule.md
··· 3 3 "title": "Upcoming Schedule", 4 4 "description": "Identifies all future calendar events and scheduled activities noted in transcripts. Extracts dates, times, participants, and event details for anything scheduled beyond today.", 5 5 "hook": "anticipation", 6 - "color": "#6f42c1", 6 + "color": "#5e35b1", 7 7 "schedule": "daily", 8 8 "output": "md" 9 9
+1 -1
muse/speakers.md
··· 4 4 "description": "Detects meetings in the segment and extracts participant names from screen and conversation.", 5 5 "schedule": "segment", 6 6 "output": "json", 7 - "color": "#ff5722" 7 + "color": "#e64a19" 8 8 9 9 } 10 10
+1 -1
muse/timeline.md
··· 4 4 "description": "Constructs a detailed chronological timeline documenting every activity, task shift, and event throughout the workday. Creates a comprehensive historical record with rich descriptions of what happened when.", 5 5 "occurrences": "Create an occurrence for each hour segment, don't break down hours into any smaller segments the goal for timeline occurrences is for them to capture whatever happened within each hour of the day where there was activity.", 6 6 "hook": "occurrence", 7 - "color": "#9c27b0", 7 + "color": "#7b1fa2", 8 8 "schedule": "daily", 9 9 "output": "md" 10 10
+7 -12
think/cortex.py
··· 609 609 610 610 # Extract segment from env if set (flat merge puts env at top level) 611 611 env_config = original_request.get("env", {}) 612 - segment = env_config.get("SEGMENT_KEY") if env_config else None 612 + segment = ( 613 + env_config.get("SEGMENT_KEY") 614 + if env_config 615 + else None 616 + ) 613 617 614 618 log_token_usage( 615 619 model=model, ··· 943 947 """ 944 948 from datetime import datetime 945 949 946 - from think.models import calc_token_cost 950 + from think.models import calc_agent_cost 947 951 948 952 _ = context # Reserved for future context support 949 953 meta: dict[str, Any] = {} ··· 1254 1258 meta["model"] = model 1255 1259 meta["usage"] = usage 1256 1260 1257 - # Calculate cost if we have model and usage 1258 - cost = None 1259 - if model and usage: 1260 - try: 1261 - cost_data = calc_token_cost({"model": model, "usage": usage}) 1262 - if cost_data: 1263 - cost = cost_data["total_cost"] 1264 - except Exception: 1265 - pass 1266 - meta["cost"] = cost 1261 + meta["cost"] = calc_agent_cost(model, usage) 1267 1262 1268 1263 # Indexer metadata - agents aren't indexed but include for consistency 1269 1264 meta["indexer"] = {"topic": "agent"}
+4 -7
think/formatters.py
··· 43 43 from pathlib import Path 44 44 from typing import Any, Callable 45 45 46 - from think.utils import day_dirs, get_journal, segment_key 47 - 48 - # Date pattern for path parsing 49 - _DATE_RE = re.compile(r"^\d{8}$") 46 + from think.utils import DATE_RE, day_dirs, get_journal, segment_key 50 47 51 48 52 49 def extract_path_metadata(rel_path: str) -> dict[str, str]: ··· 75 72 topic = "" 76 73 77 74 # Extract day from YYYYMMDD directory prefix 78 - if parts[0] and _DATE_RE.match(parts[0]): 75 + if parts[0] and DATE_RE.fullmatch(parts[0]): 79 76 day = parts[0] 80 77 81 78 # Extract facet from facets/{facet}/... paths 82 79 if parts[0] == "facets" and len(parts) >= 3: 83 80 facet = parts[1] 84 81 # Day from YYYYMMDD filename (events/entities/todos/news) 85 - if len(parts) >= 4 and _DATE_RE.match(basename): 82 + if len(parts) >= 4 and DATE_RE.fullmatch(basename): 86 83 day = basename 87 84 88 85 # Extract day from imports/YYYYMMDD_HHMMSS/... ··· 92 89 93 90 # Extract day from config/actions/YYYYMMDD.jsonl (journal-level logs) 94 91 if parts[0] == "config" and len(parts) >= 3 and parts[1] == "actions": 95 - if _DATE_RE.match(basename): 92 + if DATE_RE.fullmatch(basename): 96 93 day = basename 97 94 98 95 # Derive topic for markdown files only
+2 -5
think/indexer/journal.py
··· 28 28 format_file, 29 29 load_jsonl, 30 30 ) 31 - from think.utils import get_journal 31 + from think.utils import DATE_RE, get_journal 32 32 33 33 logger = logging.getLogger(__name__) 34 34 35 35 # Database constants 36 36 INDEX_DIR = "indexer" 37 37 DB_NAME = "journal.sqlite" 38 - 39 - # Date pattern for historical day check 40 - DATE_RE = re.compile(r"^\d{8}$") 41 38 42 39 # Schema for the unified journal index 43 40 SCHEMA = [ ··· 158 155 return False 159 156 160 157 first_part = rel_path.split("/")[0] 161 - if not DATE_RE.match(first_part): 158 + if not DATE_RE.fullmatch(first_part): 162 159 return False # Not a day directory 163 160 164 161 today = datetime.now().strftime("%Y%m%d")
+21
think/models.py
··· 820 820 return None 821 821 822 822 823 + def calc_agent_cost( 824 + model: Optional[str], usage: Optional[Dict[str, Any]] 825 + ) -> Optional[float]: 826 + """Calculate total cost for an agent run from model and usage data. 827 + 828 + Convenience wrapper around calc_token_cost for agent cost lookups. 829 + 830 + Returns total cost in USD, or None if data is missing or pricing unavailable. 831 + """ 832 + if not model or not usage: 833 + return None 834 + try: 835 + cost_data = calc_token_cost({"model": model, "usage": usage}) 836 + if cost_data: 837 + return cost_data["total_cost"] 838 + except Exception: 839 + return None 840 + return None 841 + 842 + 823 843 def iter_token_log(day: str) -> Any: 824 844 """Iterate over token log entries for a given day. 825 845 ··· 1174 1194 # Utilities 1175 1195 "log_token_usage", 1176 1196 "calc_token_cost", 1197 + "calc_agent_cost", 1177 1198 "get_usage_cost", 1178 1199 "iter_token_log", 1179 1200 "get_model_provider",